feat(m61-i03-i04): measure campaign pacing and the game-owned decision interval, plus a reduced-motion smoke

This commit is contained in:
2026-09-17 01:11:41 +08:00
parent bd14a657a5
commit 2477ca8bd6
9 changed files with 317 additions and 50 deletions
+1
View File
@@ -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 |
+13 -2
View File
@@ -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` 见计划文件,状态不变。
+105
View File
@@ -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
+1
View File
@@ -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
+102
View File
@@ -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
+7 -48
View File
@@ -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: