feat(m61-i03-i04): measure campaign pacing and the game-owned decision interval, plus a reduced-motion smoke
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
| 2026-09-15 | M79-F 区域 6 内容(数据/本地化/立绘/音乐/战场)+ 推进门到第十一份合同 + 注册表测试 R6 计修正 | `feat(m79-f)` `c728a49a`/`a4de1565` | `m79-region6` `20260915T050042Z-16542` | runtime_verified | 240 装备/45 遗物/74 敌人/12 Boss/12 合同/461 本地化;`contracts=11 region=RG-06`;15 marker 各 1 次 |
|
||||
| 2026-09-15 | M79-E 区域 5 内容(数据/本地化/立绘/音乐/战场)+ 推进门到第九份合同 + 注册表验证器修 13 敌人 | `feat(m79-e)` `aff0358b`/`6a9518e3` | `m79-region5` `20260915T023248Z-65213` | runtime_verified | 200 装备/37 遗物/61 敌人/10 Boss/10 合同/395 本地化;`contracts=9 region=RG-05`;15 marker 各 1 次 |
|
||||
| 2026-09-15 | M79-D 区域 4 美术+音乐+接线(含 R2/R3 姿态回填;risk 选项轮询去 flake) | `feat(m79-d)` `5f9723d2` + `test(m79-d)` `c7abcb97` | `m79-region4` `20260915T022134Z-55448` | runtime_verified | 28 张 R4 立绘+卷根战场+`battle_r4`/`boss_r4`;R2/R3 42 条姿态接线回填;15 marker 各 1 次 |
|
||||
| 2026-09-16 | M61-I03/I04 全战役节奏与游戏侧决策间隔实测 | `feat(m61-density)` | runtime_verified | `ABG_M61_CAMPAIGN_DENSITY_OK regions=8 battles=16 timeouts=0 defeats=8 per_decision_mean=35.9s`;`ABG_REDUCED_MOTION_OK poses=shrunk lunges=suppressed`;新缺陷 M61-I05 |
|
||||
| 2026-09-16 | M80-I34 EQ-276 跨战先手(ready_carry 全链路) | `feat(m80-i34)` | runtime_verified | contract_run 门 `ready_carry=carries`;implemented 276→277;17 门+探针 0 ERROR |
|
||||
| 2026-09-16 | M80-I33 资源入账站点 + 三处自检缺陷修复(重复计数/错键断言/挂死真相) | `fix(m80-i33)` | runtime_verified | implemented 274→276;五门 0 ERROR 绿 |
|
||||
| 2026-09-16 | M80-I32 跨单位标签机制(EQ-148 镜像 / EQ-154 共享清洁) | `feat(m80-i32)` | runtime_verified | 状态门 `mirror=once cleanse=shared`;implemented 272→274 |
|
||||
|
||||
@@ -58,6 +58,17 @@ content=12base,24advanced,36traits,40equipment,12enemies,2bosses
|
||||
- `M61-I03` OPEN:全战役 16 合同 / 16 Boss / 8 区域的节奏与反支配无法在
|
||||
内容构建完成前验证;建议在 `CONTENT-1`(首个新区域)完成后重跑同一模拟
|
||||
并扩展轮次。
|
||||
- `M61-I04` OPEN:墙钟决策密度(105–165s 间隔、战斗后 15s 决策)需要在
|
||||
真实运行中埋点或人工计时验证;本轮不做埋点改动(产品冻结)。
|
||||
- `M61-I04` **RESOLVED(2026-09-16,游戏侧部分)**:新探针
|
||||
`tools/m61_campaign_density_probe.gd`(复用 `domain/m80_ladder.gd` 与真实战斗桥)
|
||||
在 8 区域采样回合实测:**游戏侧每决策平均 35.9s、最高 62.6s**(战斗 + 结算拍 / 2 决策)。
|
||||
105–165s 目标中的余下部分由玩家的阅读与选择时间补足——**没有任何强制等待超过下限**,
|
||||
这是可测的下界证据;真人墙钟仍是 owner 门的一部分。
|
||||
- `M61-I03` **RESOLVED(2026-09-16,附带新缺陷)**:全战役节奏曲线已按区域采样测出
|
||||
(第 1 回合 1.00 通关、25–84s;第 12 回合 **8 区域全部 0.00、单场 90–120s**)。
|
||||
单次尝试、无携带的前提下,**后期回合打不过**——`timeouts=0 defeats=8`,是输而非僵局。
|
||||
这说明当前数值的后期难度**高于** `docs/02` 目标(0.4–0.7 通关)。登记为 `M61-I05`,
|
||||
与 `M80-I10`(数值手感待 owner 试玩定调)合并处理,**不由代理自行调参**。
|
||||
- `M61-I05` **OPEN(2026-09-16)**:后期回合单次尝试通关率 0.00(8/8 区域),
|
||||
与 `docs/02` 的 0.4–0.7 目标偏离;重试语义(携带 + 市场重试)下的通关率另计。
|
||||
处理顺序:owner 试玩定调 → 与 `M80-I10` 一起调参。
|
||||
- `M61-I01`、`M61-I02` 见计划文件,状态不变。
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
class_name ABGM80Ladder
|
||||
extends RefCounted
|
||||
|
||||
## Shared ladder, capacity and company authoring for the M80 probes and the
|
||||
## balance sweep. One source of truth so a probe can never measure a different
|
||||
## Company than the sweep reports.
|
||||
|
||||
const PROFESSIONS := ["PF-B01", "PF-B02", "PF-B05", "PF-B04", "PF-B03", "PF-B09",
|
||||
"PF-B08", "PF-B07", "PF-B06", "PF-B11", "PF-B12", "PF-B10"]
|
||||
|
||||
|
||||
static func level(region_index: int, round_index: int) -> int:
|
||||
return 3 + 2 * (region_index - 1) + int(floor(float(round_index - 1) / 2.0))
|
||||
|
||||
|
||||
static func capacity(round_index: int) -> int:
|
||||
# Section 17 capacity vector: cleared Rounds 0,2,4..12 -> 4,5,6..10, so the
|
||||
# Company grows one member every two cleared rounds.
|
||||
return mini(10, 4 + int(floor(float(round_index - 1) / 2.0)))
|
||||
|
||||
|
||||
static func meta_at(base: Dictionary, level: int) -> Dictionary:
|
||||
# Enemies share the run's growth ladder; the region multiplier keeps the
|
||||
# per-region pressure on top of the level the encounter is fought at.
|
||||
var meta := base.duplicate(true)
|
||||
for enemy_id in meta:
|
||||
var entry: Dictionary = Dictionary(meta[enemy_id])
|
||||
entry["level"] = level
|
||||
meta[enemy_id] = entry
|
||||
return meta
|
||||
|
||||
|
||||
static func survivors(result: Dictionary) -> int:
|
||||
var count := 0
|
||||
for ally in Array(result.get("allies", [])):
|
||||
if not bool(Dictionary(ally).get("fallen", false)):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
static func company(size: int, level_value: int, item_pool: Dictionary) -> Array:
|
||||
var rows: Array = []
|
||||
for index in range(mini(size, PROFESSIONS.size())):
|
||||
var profession_id: String = PROFESSIONS[index]
|
||||
var fit := "front" if index < (size + 1) / 2 else "rear"
|
||||
rows.append({
|
||||
"recruit_id": "R-%d" % (index + 1),
|
||||
"profession_id": profession_id,
|
||||
"level": level_value,
|
||||
"trait_id": "",
|
||||
"might": 8 + level_value, "finesse": 7 + level_value, "insight": 6 + level_value,
|
||||
"vigor": 9 + level_value, "resolve": 7 + level_value,
|
||||
"equipped_item_id": str(Array(item_pool.get(fit, [])).slice(0, 1)[0]) \
|
||||
if not Array(item_pool.get(fit, [])).is_empty() else "",
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
static func item_pool(catalog: Dictionary) -> Dictionary:
|
||||
var pool := {"front": [], "rear": [], "center": [], "any": []}
|
||||
for row in Array(catalog.get("equipment", [])):
|
||||
var entry: Dictionary = Dictionary(row)
|
||||
var fit := str(entry.get("fit", "any")).to_lower()
|
||||
var key := "any"
|
||||
if fit.contains("front/center"):
|
||||
key = "center"
|
||||
elif fit.contains("front"):
|
||||
key = "front"
|
||||
elif fit.contains("rear"):
|
||||
key = "rear"
|
||||
elif fit.contains("center"):
|
||||
key = "center"
|
||||
var bucket: Array = Array(pool[key])
|
||||
bucket.append(str(entry.get("id", "")))
|
||||
bucket.sort()
|
||||
pool[key] = bucket
|
||||
return pool
|
||||
|
||||
|
||||
static func enemy_meta(catalog: Dictionary) -> Dictionary:
|
||||
var meta := {}
|
||||
for row in Array(catalog.get("enemies", [])):
|
||||
var entry: Dictionary = Dictionary(row)
|
||||
meta[str(entry.get("id", ""))] = {"class": str(entry.get("class", "ordinary"))}
|
||||
for row in Array(catalog.get("bosses", [])):
|
||||
var entry: Dictionary = Dictionary(row)
|
||||
meta[str(entry.get("id", ""))] = {"class": "boss"}
|
||||
return meta
|
||||
|
||||
|
||||
static func contract(catalog: Dictionary, region_id: String, ordinal := 1) -> Dictionary:
|
||||
for row in Array(catalog.get("contracts", [])):
|
||||
var entry: Dictionary = Dictionary(row)
|
||||
if str(entry.get("region_id", "")) == region_id and int(entry.get("ordinal", 0)) == ordinal:
|
||||
return entry
|
||||
return {}
|
||||
|
||||
|
||||
static func enemy_ids(catalog: Dictionary, region_index: int) -> Array:
|
||||
var ids: Array = []
|
||||
for row in Array(catalog.get("enemies", [])):
|
||||
var entry: Dictionary = Dictionary(row)
|
||||
if int(entry.get("region", 0)) == region_index:
|
||||
ids.append(str(entry.get("id", "")))
|
||||
return ids
|
||||
@@ -0,0 +1 @@
|
||||
uid://cma88tpp5ttor
|
||||
@@ -0,0 +1,86 @@
|
||||
extends SceneTree
|
||||
|
||||
## M80 accessibility smoke: the shipped reduced-motion switch must (a) actually
|
||||
## shrink the battle motion and (b) leave a page readable. The pose functions are
|
||||
## driven directly so the assertion is a measured difference, not a flag read.
|
||||
|
||||
const EntryShellScript = preload("res://entry/entry_shell.gd")
|
||||
const InkCheck = preload("res://tools/ui_ink_check.gd")
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var shell = EntryShellScript.new()
|
||||
root.add_child(shell)
|
||||
for _frame in range(4):
|
||||
await process_frame
|
||||
|
||||
var normal_pose := _knockout_rotation(shell, false)
|
||||
var reduced_pose := _knockout_rotation(shell, true)
|
||||
_expect(reduced_pose < normal_pose and normal_pose > 0.0,
|
||||
"a knocked-out pose is smaller under reduced motion (normal=%.1f reduced=%.1f)"
|
||||
% [rad_to_deg(normal_pose), rad_to_deg(reduced_pose)])
|
||||
|
||||
var normal_lunge := _ally_lunge(shell, false)
|
||||
var reduced_lunge := _ally_lunge(shell, true)
|
||||
_expect(normal_lunge > 0.0 and reduced_lunge == 0.0,
|
||||
"an ally lunge is suppressed under reduced motion (normal=%.1f reduced=%.1f)"
|
||||
% [normal_lunge, reduced_lunge])
|
||||
|
||||
var normal_enemy := _enemy_lunge(shell, false)
|
||||
var reduced_enemy := _enemy_lunge(shell, true)
|
||||
_expect(normal_enemy < 0.0 and reduced_enemy == 0.0,
|
||||
"an enemy lunge is suppressed under reduced motion (normal=%.1f reduced=%.1f)"
|
||||
% [normal_enemy, reduced_enemy])
|
||||
|
||||
var normal_violations := InkCheck.scan(shell)
|
||||
shell.settings["reduced_motion"] = true
|
||||
var reduced_violations := InkCheck.scan(shell)
|
||||
_expect(normal_violations.is_empty() and reduced_violations.is_empty(),
|
||||
"the page stays readable in both modes (normal=%d reduced=%d violations)"
|
||||
% [normal_violations.size(), reduced_violations.size()])
|
||||
|
||||
if failures.is_empty():
|
||||
print("ABG_REDUCED_MOTION_OK poses=shrunk lunges=suppressed scans=2 violations=0")
|
||||
shell.queue_free()
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("M80 reduced motion: %s" % failure)
|
||||
shell.queue_free()
|
||||
quit(1)
|
||||
|
||||
|
||||
func _knockout_rotation(shell, reduced: bool) -> float:
|
||||
shell.settings["reduced_motion"] = reduced
|
||||
var portrait := TextureRect.new()
|
||||
shell._apply_battle_actor_pose(portrait, {"recruit_id": "r0"}, {"knocked_out_ids": ["r0"]}, true)
|
||||
return absf(portrait.rotation)
|
||||
|
||||
|
||||
func _ally_lunge(shell, reduced: bool) -> float:
|
||||
shell.settings["reduced_motion"] = reduced
|
||||
shell.battle_live_event = {"kind": "damage", "actor_id": "r0", "target_id": "e0"}
|
||||
var portrait := TextureRect.new()
|
||||
portrait.position.x = 0.0
|
||||
shell._apply_battle_actor_pose(portrait, {"recruit_id": "r0"}, {}, true)
|
||||
return portrait.position.x
|
||||
|
||||
|
||||
func _enemy_lunge(shell, reduced: bool) -> float:
|
||||
shell.settings["reduced_motion"] = reduced
|
||||
shell.battle_live_event = {"kind": "damage", "actor_id": "EN-R1-01", "target_id": "r0"}
|
||||
var portrait := TextureRect.new()
|
||||
portrait.position.x = 0.0
|
||||
shell._apply_battle_enemy_pose(portrait, "EN-R1-01", {})
|
||||
return portrait.position.x
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://mj32xkok8dj1
|
||||
@@ -0,0 +1,102 @@
|
||||
extends SceneTree
|
||||
|
||||
## M61-I03 / M61-I04 evidence: the campaign-wide pacing and the game-owned part
|
||||
## of the decision interval. For sampled rounds of every region it runs the real
|
||||
## battle (same ladder and Company as the balance sweep) and reports the seconds
|
||||
## the game itself owns between decisions: the battle plus the settlement beat.
|
||||
## Human reading and choice time sits on top, so this is a lower bound, and the
|
||||
## report says so instead of claiming the full 105-165s band is met.
|
||||
|
||||
const ContentRegistry = preload("res://p10_0/content_registry.gd")
|
||||
const ContractRun = preload("res://domain/contract_run.gd")
|
||||
const Bridge = preload("res://entry/battle_sim_bridge.gd")
|
||||
const Ladder = preload("res://domain/m80_ladder.gd")
|
||||
|
||||
const POST_BATTLE_SECONDS := 6.0
|
||||
const DECISIONS_PER_BATTLE := 2
|
||||
const INTERVAL_LOW := 105.0
|
||||
const INTERVAL_HIGH := 165.0
|
||||
|
||||
var battles := 0
|
||||
var timeouts := 0
|
||||
var defeats := 0
|
||||
var seconds_total := 0.0
|
||||
var seconds_max := 0.0
|
||||
var trivial_late := 0
|
||||
var wall_late := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var attempts := int(args[0]) if args.size() > 0 else 2
|
||||
var stride := int(args[1]) if args.size() > 1 else 3
|
||||
var catalog: Dictionary = ContentRegistry.snapshot()
|
||||
var enemy_meta := Ladder.enemy_meta(catalog)
|
||||
var item_pool := Ladder.item_pool(catalog)
|
||||
print("region round party_size level clear_rate seconds per_decision")
|
||||
for region_index in range(1, 9):
|
||||
var enemy_ids := Ladder.enemy_ids(catalog, region_index)
|
||||
if enemy_ids.is_empty():
|
||||
continue
|
||||
var late_clear := -1.0
|
||||
var rounds: Array = []
|
||||
var round_index := 1
|
||||
while round_index <= 12:
|
||||
rounds.append(round_index)
|
||||
round_index += stride
|
||||
if not rounds.has(12):
|
||||
rounds.append(12)
|
||||
for sampled in rounds:
|
||||
var level := Ladder.level(region_index, sampled)
|
||||
var party_size := Ladder.capacity(sampled)
|
||||
var recruits := Ladder.company(party_size, level, item_pool)
|
||||
var formation: Array = []
|
||||
for recruit in recruits:
|
||||
formation.append(str(Dictionary(recruit).get("recruit_id", "")))
|
||||
var clears := 0
|
||||
var seconds_sum := 0.0
|
||||
var carry := {}
|
||||
for attempt in range(attempts):
|
||||
var encounter := ContractRun.ordinary_encounter(region_index, enemy_ids, sampled, attempt, party_size)
|
||||
var ids: Array = []
|
||||
for entry in encounter:
|
||||
ids.append(str(Dictionary(entry).get("enemy_id", "")))
|
||||
var before := carry.duplicate(true)
|
||||
var result := Bridge.run("density|r%d|rnd%d|a%d" % [region_index, sampled, attempt],
|
||||
formation, recruits, ids, region_index, Ladder.meta_at(enemy_meta, level + 2))
|
||||
var seconds := float(result.get("ticks", 0)) / 10.0
|
||||
seconds_sum += seconds
|
||||
seconds_total += seconds
|
||||
seconds_max = maxf(seconds_max, seconds)
|
||||
if str(result.get("outcome", "")) == "timeout":
|
||||
timeouts += 1
|
||||
elif str(result.get("outcome", "")) != "victory":
|
||||
defeats += 1
|
||||
battles += 1
|
||||
if str(result.get("outcome", "")) == "victory":
|
||||
clears += 1
|
||||
carry = ContractRun.carry_from(result)
|
||||
else:
|
||||
carry = before
|
||||
var clear_rate := float(clears) / float(attempts)
|
||||
var mean_seconds := seconds_sum / float(attempts)
|
||||
var per_decision := (mean_seconds + POST_BATTLE_SECONDS) / float(DECISIONS_PER_BATTLE)
|
||||
if int(sampled) == 12:
|
||||
late_clear = clear_rate
|
||||
print("%d %d %d %d %.2f %.1f %.1f" % [region_index, sampled, party_size, level,
|
||||
clear_rate, mean_seconds, per_decision])
|
||||
if late_clear >= 0.95:
|
||||
trivial_late += 1
|
||||
if late_clear >= 0.0 and late_clear <= 0.05:
|
||||
wall_late += 1
|
||||
var mean_all := seconds_total / float(maxi(1, battles))
|
||||
var mean_decision := (mean_all + POST_BATTLE_SECONDS) / float(DECISIONS_PER_BATTLE)
|
||||
print("ABG_M61_CAMPAIGN_DENSITY_OK regions=8 battles=%d timeouts=%d defeats=%d battle_mean=%.1fs battle_max=%.1fs per_decision_mean=%.1fs per_decision_max=%.1fs trivial_late=%d wall_late=%d band=%.0f-%.0f human_share=on_top"
|
||||
% [battles, timeouts, defeats, mean_all, seconds_max, mean_decision,
|
||||
(seconds_max + POST_BATTLE_SECONDS) / float(DECISIONS_PER_BATTLE),
|
||||
trivial_late, wall_late, INTERVAL_LOW, INTERVAL_HIGH])
|
||||
quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dyvqqcn7bbbrd
|
||||
@@ -11,6 +11,7 @@ extends SceneTree
|
||||
## run can field, not the enemy side.
|
||||
|
||||
const ContentRegistry = preload("res://p10_0/content_registry.gd")
|
||||
const Ladder = preload("res://domain/m80_ladder.gd")
|
||||
const ContractRun = preload("res://domain/contract_run.gd")
|
||||
const Bridge = preload("res://entry/battle_sim_bridge.gd")
|
||||
|
||||
@@ -123,69 +124,27 @@ func _sweep_boss(catalog: Dictionary, region_index: int, enemy_meta: Dictionary,
|
||||
|
||||
|
||||
func _meta_at(base: Dictionary, level: int) -> Dictionary:
|
||||
# Enemies share the run's growth ladder; the region multiplier keeps the
|
||||
# per-region pressure on top of the level the encounter is fought at.
|
||||
var meta := base.duplicate(true)
|
||||
for enemy_id in meta:
|
||||
var entry: Dictionary = Dictionary(meta[enemy_id])
|
||||
entry["level"] = level
|
||||
meta[enemy_id] = entry
|
||||
return meta
|
||||
return Ladder.meta_at(base, level)
|
||||
|
||||
|
||||
func _survivors(result: Dictionary) -> int:
|
||||
var count := 0
|
||||
for ally in Array(result.get("allies", [])):
|
||||
if not bool(Dictionary(ally).get("fallen", false)):
|
||||
count += 1
|
||||
return count
|
||||
return Ladder.survivors(result)
|
||||
|
||||
|
||||
func _ladder_level(region_index: int, round_index: int) -> int:
|
||||
return 3 + 2 * (region_index - 1) + int(floor(float(round_index - 1) / 2.0))
|
||||
return Ladder.level(region_index, round_index)
|
||||
|
||||
|
||||
func _capacity(round_index: int) -> int:
|
||||
# Section 17 capacity vector: cleared Rounds 0,2,4..12 -> 4,5,6..10, so the
|
||||
# Company grows one member every two cleared rounds.
|
||||
return mini(10, 4 + int(floor(float(round_index - 1) / 2.0)))
|
||||
return Ladder.capacity(round_index)
|
||||
|
||||
|
||||
func _company(size: int, level: int, item_pool: Dictionary) -> Array:
|
||||
var rows: Array = []
|
||||
for index in range(mini(size, PROFESSIONS.size())):
|
||||
var profession_id: String = PROFESSIONS[index]
|
||||
var fit := "front" if index < (size + 1) / 2 else "rear"
|
||||
rows.append({
|
||||
"recruit_id": "R-%d" % (index + 1),
|
||||
"profession_id": profession_id,
|
||||
"level": level,
|
||||
"trait_id": "",
|
||||
"might": 8 + level, "finesse": 7 + level, "insight": 6 + level, "vigor": 9 + level, "resolve": 7 + level,
|
||||
"equipped_item_id": str(Array(item_pool.get(fit, [])).slice(0, 1)[0]) if not Array(item_pool.get(fit, [])).is_empty() else "",
|
||||
})
|
||||
return rows
|
||||
return Ladder.company(size, level, item_pool)
|
||||
|
||||
|
||||
func _item_pool(catalog: Dictionary) -> Dictionary:
|
||||
var pool := {"front": [], "rear": [], "center": [], "any": []}
|
||||
for row in Array(catalog.get("equipment", [])):
|
||||
var entry: Dictionary = Dictionary(row)
|
||||
var fit := str(entry.get("fit", "any")).to_lower()
|
||||
var key := "any"
|
||||
if fit.contains("front/center"):
|
||||
key = "center"
|
||||
elif fit.contains("front"):
|
||||
key = "front"
|
||||
elif fit.contains("rear"):
|
||||
key = "rear"
|
||||
elif fit.contains("center"):
|
||||
key = "center"
|
||||
var bucket: Array = Array(pool[key])
|
||||
bucket.append(str(entry.get("id", "")))
|
||||
bucket.sort()
|
||||
pool[key] = bucket
|
||||
return pool
|
||||
return Ladder.item_pool(catalog)
|
||||
|
||||
|
||||
func _enemy_meta(catalog: Dictionary) -> Dictionary:
|
||||
|
||||
Reference in New Issue
Block a user