diff --git a/runtime/entry/battle_service.gd b/runtime/entry/battle_service.gd index 60935f04..2e0697e0 100644 --- a/runtime/entry/battle_service.gd +++ b/runtime/entry/battle_service.gd @@ -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", [])): diff --git a/runtime/entry/entry_shell.gd b/runtime/entry/entry_shell.gd index 290406c4..364b017c 100644 --- a/runtime/entry/entry_shell.gd +++ b/runtime/entry/entry_shell.gd @@ -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: diff --git a/runtime/tests/dev5/battle_live_field_test.gd b/runtime/tests/dev5/battle_live_field_test.gd index 48fc50d6..cc3e6035 100644 --- a/runtime/tests/dev5/battle_live_field_test.gd +++ b/runtime/tests/dev5/battle_live_field_test.gd @@ -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():