fix(m103): defer battle feedback replay
This commit is contained in:
@@ -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", [])):
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user