diff --git a/docs/EVIDENCE_LEDGER.md b/docs/EVIDENCE_LEDGER.md index 62e277f5..63d07514 100644 --- a/docs/EVIDENCE_LEDGER.md +++ b/docs/EVIDENCE_LEDGER.md @@ -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 | diff --git a/docs/runtime/M61_DESIGN_AND_DENSITY_REVIEW.md b/docs/runtime/M61_DESIGN_AND_DENSITY_REVIEW.md index e73b3e55..ae348fdf 100644 --- a/docs/runtime/M61_DESIGN_AND_DENSITY_REVIEW.md +++ b/docs/runtime/M61_DESIGN_AND_DENSITY_REVIEW.md @@ -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` 见计划文件,状态不变。 diff --git a/runtime/domain/m80_ladder.gd b/runtime/domain/m80_ladder.gd new file mode 100644 index 00000000..50643416 --- /dev/null +++ b/runtime/domain/m80_ladder.gd @@ -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 diff --git a/runtime/domain/m80_ladder.gd.uid b/runtime/domain/m80_ladder.gd.uid new file mode 100644 index 00000000..4b02a81b --- /dev/null +++ b/runtime/domain/m80_ladder.gd.uid @@ -0,0 +1 @@ +uid://cma88tpp5ttor diff --git a/runtime/tests/accessibility/reduced_motion_smoke_test.gd b/runtime/tests/accessibility/reduced_motion_smoke_test.gd new file mode 100644 index 00000000..9eac434c --- /dev/null +++ b/runtime/tests/accessibility/reduced_motion_smoke_test.gd @@ -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) diff --git a/runtime/tests/accessibility/reduced_motion_smoke_test.gd.uid b/runtime/tests/accessibility/reduced_motion_smoke_test.gd.uid new file mode 100644 index 00000000..e42d5cce --- /dev/null +++ b/runtime/tests/accessibility/reduced_motion_smoke_test.gd.uid @@ -0,0 +1 @@ +uid://mj32xkok8dj1 diff --git a/runtime/tools/m61_campaign_density_probe.gd b/runtime/tools/m61_campaign_density_probe.gd new file mode 100644 index 00000000..e10623e4 --- /dev/null +++ b/runtime/tools/m61_campaign_density_probe.gd @@ -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) diff --git a/runtime/tools/m61_campaign_density_probe.gd.uid b/runtime/tools/m61_campaign_density_probe.gd.uid new file mode 100644 index 00000000..24123808 --- /dev/null +++ b/runtime/tools/m61_campaign_density_probe.gd.uid @@ -0,0 +1 @@ +uid://dyvqqcn7bbbrd diff --git a/runtime/tools/m80_balance_sweep.gd b/runtime/tools/m80_balance_sweep.gd index 2a0936a4..4371ca2f 100644 --- a/runtime/tools/m80_balance_sweep.gd +++ b/runtime/tools/m80_balance_sweep.gd @@ -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: