fix(m103): defer battle feedback replay

This commit is contained in:
2026-09-19 10:12:44 +08:00
parent 196ee2ffab
commit 32e89f3f80
3 changed files with 79 additions and 0 deletions
+47
View File
@@ -11,6 +11,7 @@ const OBSERVATION_VERSION := 1
const REWARD_DRAFT_VERSION := 1
var company_path := DEFAULT_COMPANY_PATH
var presentation_journal_cache: Dictionary = {}
func load_company() -> Dictionary:
@@ -25,6 +26,22 @@ func replay_presentation_journal(company: Dictionary, battle: Dictionary) -> Dic
return {"ok": false, "code": "PRESENTATION_COMPANY_REQUIRED"}
if battle.is_empty() or String(battle.get("operation_id", "")).is_empty():
return {"ok": false, "code": "PRESENTATION_BATTLE_REQUIRED"}
var cache_key := _presentation_cache_key(battle)
if presentation_journal_cache.has(cache_key):
var cached_entry: Dictionary = Dictionary(presentation_journal_cache.get(cache_key, {}))
var cached: Array = Array(cached_entry.get("journal", [])).duplicate(true)
if not String(battle.get("sim_outcome", "")).is_empty() and String(cached_entry.get("outcome", "")) != String(battle.get("sim_outcome", "")):
return {"ok": false, "code": "PRESENTATION_REPLAY_OUTCOME_MISMATCH"}
if int(battle.get("sim_ticks", -1)) >= 0 and int(cached_entry.get("ticks", -1)) != int(battle.get("sim_ticks", -1)):
return {"ok": false, "code": "PRESENTATION_REPLAY_TICK_MISMATCH"}
return {
"ok": true,
"code": "presentation_journal_cached",
"journal": cached,
"outcome": String(cached_entry.get("outcome", "")),
"ticks": int(cached_entry.get("ticks", 0)),
"journal_hash": _hash(cached),
}
var contract: Dictionary = Dictionary(company.get("active_contract", {}))
var report: Dictionary = _run_simulation(company, contract, battle)
var expected_outcome := String(battle.get("sim_outcome", ""))
@@ -34,6 +51,11 @@ func replay_presentation_journal(company: Dictionary, battle: Dictionary) -> Dic
if expected_ticks >= 0 and int(report.get("ticks", -1)) != expected_ticks:
return {"ok": false, "code": "PRESENTATION_REPLAY_TICK_MISMATCH"}
var journal: Array = Array(report.get("journal", [])).duplicate(true)
presentation_journal_cache[cache_key] = {
"journal": journal.duplicate(true),
"outcome": String(report.get("outcome", "")),
"ticks": int(report.get("ticks", 0)),
}
return {
"ok": true,
"code": "presentation_journal_replayed",
@@ -44,6 +66,18 @@ func replay_presentation_journal(company: Dictionary, battle: Dictionary) -> Dic
}
func presentation_journal_cache_for(battle: Dictionary) -> Dictionary:
## Fast path used by BAT-002 rendering. A cache hit is presentation-only and
## never changes the receipt-bound battle state; a miss is deliberately left
## to the deferred replay path so page entry cannot block on simulation.
var cache_key := _presentation_cache_key(battle)
if cache_key.is_empty() or not presentation_journal_cache.has(cache_key):
return {"ok": false, "code": "PRESENTATION_CACHE_MISS"}
var entry: Dictionary = Dictionary(presentation_journal_cache.get(cache_key, {}))
var journal: Array = Array(entry.get("journal", [])).duplicate(true)
return {"ok": true, "code": "presentation_journal_cached", "journal": journal, "journal_hash": _hash(journal)}
func begin_battle(operation_id: String, risk_option_id: String) -> Dictionary:
if not _is_hex(operation_id, 32):
return {"ok": false, "code": "BATTLE_OPERATION_ID_INVALID"}
@@ -103,6 +137,11 @@ func begin_battle(operation_id: String, risk_option_id: String) -> Dictionary:
# appends exactly one event per advance, so the observation invariants and
# every receipt boundary stay intact.
var sim := _run_simulation(company, contract, battle)
presentation_journal_cache[_presentation_cache_key(battle)] = {
"journal": Array(sim.get("journal", [])).duplicate(true),
"outcome": String(sim.get("outcome", "")),
"ticks": int(sim.get("ticks", 0)),
}
battle["sim_outcome"] = String(sim.get("outcome", "victory"))
battle["sim_ticks"] = int(sim.get("ticks", 0))
battle["sim_events"] = Array(sim.get("events", []))
@@ -1117,6 +1156,14 @@ func _run_simulation(company: Dictionary, contract: Dictionary, battle: Dictiona
return BattleSimBridge.run(seed, Array(battle.get("front_to_rear_ids", [])), recruits, Array(battle.get("enemy_ids", [])), region_index, enemy_meta)
func _presentation_cache_key(battle: Dictionary) -> String:
var operation_id := String(battle.get("operation_id", ""))
var snapshot_hash := String(battle.get("snapshot_hash", ""))
if operation_id.is_empty() or snapshot_hash.is_empty():
return ""
return "%s|%s" % [operation_id, snapshot_hash]
func _enemy_meta(enemy_id: String) -> Dictionary:
var content: Dictionary = ContentRegistry.snapshot()
for row in Array(content.get("enemies", [])):
+31
View File
@@ -702,6 +702,8 @@ var battle_pause_log_open := false
var battle_live_view_data: Dictionary = {}
var battle_live_presentation_journal: Array = []
var battle_live_presentation_key := ""
var battle_live_presentation_loading := false
var battle_live_presentation_generation := 0
var battle_result_view_data: Dictionary = {}
var battle_knockout_view_data: Dictionary = {}
var battle_reward_view_data: Dictionary = {}
@@ -4291,9 +4293,38 @@ func _ensure_battle_live_presentation_journal(company: Dictionary, battle: Dicti
if key == "|":
return
battle_service.company_path = initial_company_service.company_path
var cached: Dictionary = battle_service.presentation_journal_cache_for(battle)
if bool(cached.get("ok", false)):
battle_live_presentation_journal = Array(cached.get("journal", [])).duplicate(true)
return
battle_live_presentation_generation += 1
battle_live_presentation_loading = true
call_deferred(
"_replay_battle_live_presentation_deferred",
company.duplicate(true),
battle.duplicate(true),
key,
battle_live_presentation_generation
)
func _replay_battle_live_presentation_deferred(company: Dictionary, battle: Dictionary, key: String, generation: int) -> void:
# Let the BAT-001 handoff and first-frame input settle before the read-only
# replay. The journal is explanatory UI data, never a prerequisite for
# entering BAT-002 or advancing its authoritative observation.
await get_tree().process_frame
await get_tree().process_frame
if generation != battle_live_presentation_generation or key != battle_live_presentation_key:
return
battle_service.company_path = initial_company_service.company_path
var replay: Dictionary = battle_service.replay_presentation_journal(company, battle)
if generation != battle_live_presentation_generation or key != battle_live_presentation_key:
return
if bool(replay.get("ok", false)):
battle_live_presentation_journal = Array(replay.get("journal", [])).duplicate(true)
battle_live_presentation_loading = false
if current_screen == "battle" and not battle_opening_mode:
_render_battle_live()
func _build_battle_live_canvas() -> void:
@@ -30,6 +30,7 @@ func run_test() -> void:
_cleanup()
root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS
root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_EXPAND
root.content_scale_size = Vector2i(844, 390)
root.size = Vector2i(844, 390)
var prepared := _create_prepared_company()
if not prepared.is_empty():