Files
aetherbound-guild/runtime/campaign/fg4_adapter.gd
T

843 lines
34 KiB
GDScript

class_name ABGFG4CampaignAdapter
extends RefCounted
const Contract1Battle = preload("res://fg4/contract1_battle_service.gd")
const Contract1Service = preload("res://fg4/contract1_service.gd")
const Contract2Activation = preload("res://fg4/contract2_activation_service.gd")
const Contract2Battle = preload("res://fg4/contract2_battle_service.gd")
const Contract2Execution = preload("res://fg4/contract2_service.gd")
const FG4Content = preload("res://fg4/content_registry.gd")
const P100Content = preload("res://p10_0/content_registry.gd")
const RG04Activation = preload("res://fg4/rg04_activation_service.gd")
const RG04Contract1Battle = preload("res://fg4/rg04_contract1_battle_service.gd")
const RG04Contract1Service = preload("res://fg4/rg04_contract1_service.gd")
const RG04Contract2Activation = preload("res://fg4/rg04_contract2_activation_service.gd")
const RG04Contract2Battle = preload("res://fg4/rg04_contract2_battle_service.gd")
const RG04Contract2Service = preload("res://fg4/rg04_contract2_service.gd")
const State = preload("res://fg4/run_state.gd")
const CONTRACT1_PHASES := [
"market", "prepared", "recovery", "boss_muster", "boss_prepared",
"boss_recovery", "contract_result",
]
const CONTRACT2_PHASES := [
"contract2_market", "contract2_prepared", "contract2_recovery",
"contract2_boss_muster", "contract2_boss_prepared",
"contract2_boss_recovery", "region_result", "region_closed",
]
const RG04_CONTRACT1_PHASES := [
"rg04_market", "rg04_prepared", "rg04_recovery", "rg04_boss_muster",
"rg04_boss_prepared", "rg04_boss_recovery", "rg04_contract_result",
]
const RG04_CONTRACT2_PHASES := [
"rg04_contract2_market", "rg04_contract2_prepared",
"rg04_contract2_recovery", "rg04_contract2_boss_muster",
"rg04_contract2_boss_prepared", "rg04_contract2_boss_recovery",
"rg04_region_result", "rg04_region_closed",
]
const PLAN_COPY := {
"vent_and_brace": ["排压并稳固", "VENT & BRACE"],
"strong_side_burst": ["爆发强侧", "BURST STRONG SIDE"],
"device_removal": ["拆除构装体", "REMOVE CONSTRUCT"],
"cooldown_bait": ["诱导冷却", "BAIT COOLDOWN"],
"vary_tags": ["轮换行动标签", "VARY ACTION TAGS"],
"safe_promotion": ["允许安全晋升", "ALLOW SAFE PROMOTION"],
"remove_sparks": ["提前清除火花", "REMOVE SPARKS"],
"focus_foreman": ["集火监工", "FOCUS FOREMAN"],
"feed_and_obey": ["喂入安全来源并遵守", "FEED SAFE SOURCE & OBEY"],
"deny_and_break": ["拒绝复制并主动违背", "DENY COPY & BREAK"],
"steer_and_obey": ["引导指针并遵守", "STEER POINTER & OBEY"],
"guard_and_break": ["守住索引并违背", "GUARD INDEX & BREAK"],
"bait_and_obey": ["诱导净化并遵守", "BAIT CLEANSE & OBEY"],
"raw_and_break": ["承受原始伤害并违背", "TAKE RAW & BREAK"],
"hold_and_obey": ["保持条款并遵守", "HOLD CLAUSE & OBEY"],
"race_and_break": ["抢先结算并违背", "RACE RESOLUTION & BREAK"],
"steer_action_count": ["操纵行动计数", "STEER ACTION COUNT"],
"interrupt_pointer": ["打断索引指针", "INTERRUPT INDEX POINTER"],
"seed_and_obey": ["预置安全规则并遵守", "SEED SAFE RULE & OBEY"],
"reorder_and_break": ["重排重放并违背", "REORDER REPLAY & BREAK"],
"seed_and_redirect": ["预置规则并重定向", "SEED RULE & REDIRECT"],
"interrupt_and_break": ["打断重放并违背", "INTERRUPT REPLAY & BREAK"],
"seed_and_cleanse": ["预置规则并净化", "SEED RULE & CLEANSE"],
"interrupt_and_cleanse": ["打断重放并净化", "INTERRUPT & CLEANSE"],
"seed_and_preserve": ["预置规则并保持", "SEED RULE & PRESERVE"],
"reorder_and_preserve": ["重排重放并保持", "REORDER & PRESERVE"],
"safe_rule_seed": ["预置无害规则", "SEED HARMLESS RULES"],
"reorder_and_interrupt": ["重排并打断危险重放", "REORDER & INTERRUPT"],
}
var service
var market
var valid := false
var error_code := "adapter_not_configured"
var current_battle: Dictionary = {}
var event_cursor := 0
var operation_serial := 0
var fg4_catalog: Dictionary = FG4Content.snapshot()
var p100_catalog: Dictionary = P100Content.snapshot()
var profession_rows: Dictionary = P100Content.rows_by_id(p100_catalog, "professions")
var enemy_rows: Dictionary = FG4Content.rows_by_id(fg4_catalog, "enemies")
var boss_rows: Dictionary = FG4Content.rows_by_id(fg4_catalog, "bosses")
func configure(snapshot_value: Dictionary) -> Dictionary:
current_battle = {}
event_cursor = 0
operation_serial = 0
if not _bind(snapshot_value):
return _reject(error_code)
return {
"ok": true,
"code": "fg4_adapter_configured",
"snapshot_hash": snapshot_hash(),
"view": view_state(),
}
func snapshot() -> Dictionary:
return service.state.snapshot() if valid else {}
func snapshot_hash() -> String:
return hash_value(snapshot())
func view_state(locale := "zh_CN") -> Dictionary:
if not valid:
return {}
var run: Dictionary = service.state.current_run
var phase := String(run.phase)
return {
"region_id": String(run.region_id),
"contract_id": String(run.contract_id),
"phase": phase,
"mode": _mode(phase),
"market_index": int(run.market_index),
"round_index": int(run.round_index),
"cleared_rounds": int(run.cleared_rounds),
"coin": int(run.coin),
"replacement_credit": int(run.replacement_credit),
"line_capacity": int(run.line_capacity),
"company": _company_view(run, locale),
"routes": _route_view(run, locale),
"plans": _plan_view(run, locale),
"battle": _battle_view(run, locale),
"reward": _result_view(run, locale),
"recovery_required": bool(run.recovery_required),
"recovery_cost": int(run.recovery_cost),
"boss_id": String(run.boss_muster.get("boss_id", "")),
"records_count": service.state.guild_profile.company_records.size(),
"region_records_count": _region_record_count(),
"persistent_renown": int(service.state.guild_profile.persistent_renown),
"unlocked_region_ids": Array(service.state.guild_profile.unlocked_region_ids).duplicate(),
"source_revision": int(service.state.revision),
"source_snapshot_hash": snapshot_hash(),
}
func prepare_route(offer_id: String, locale := "zh_CN") -> Dictionary:
if not valid:
return _reject(error_code)
var phase := String(service.state.current_run.phase)
if phase not in ["market", "contract2_market", "rg04_market", "rg04_contract2_market"]:
return _reject("market_required")
var result: Dictionary
if phase.begins_with("rg04_"):
result = service.commit_route(
_operation("prepare", offer_id), service.state.revision, offer_id,
String(service.state.current_run.market_board.board_hash)
)
elif phase == "contract2_market":
result = service.commit_contract2_route(
_operation("prepare", offer_id), service.state.revision, offer_id,
String(service.state.current_run.market_board.board_hash)
)
else:
result = service.commit_route(
_operation("prepare", offer_id), service.state.revision, offer_id,
String(service.state.current_run.market_board.board_hash)
)
return _replace_from_result(result, locale)
func start_plan(plan_id: String, locale := "zh_CN") -> Dictionary:
if not valid:
return _reject(error_code)
var phase := String(service.state.current_run.phase)
if phase in ["boss_muster", "contract2_boss_muster", "rg04_boss_muster", "rg04_contract2_boss_muster"]:
var prepared: Dictionary
if phase == "contract2_boss_muster":
prepared = service.commit_contract2_boss_plan(
_operation("boss-plan", plan_id), service.state.revision,
String(service.state.current_run.boss_muster.muster_hash), plan_id
)
else:
prepared = service.commit_boss_plan(
_operation("boss-plan", plan_id), service.state.revision,
String(service.state.current_run.boss_muster.muster_hash), plan_id
)
if not prepared.ok:
return prepared
if not _bind(prepared.snapshot):
return _reject(error_code)
phase = String(service.state.current_run.phase)
if phase not in [
"prepared", "contract2_prepared", "boss_prepared", "contract2_boss_prepared",
"rg04_prepared", "rg04_boss_prepared", "rg04_contract2_prepared",
"rg04_contract2_boss_prepared",
]:
return _reject("prepared_state_required")
var battle_service
if phase in ["rg04_contract2_prepared", "rg04_contract2_boss_prepared"]:
battle_service = RG04Contract2Battle.new(snapshot())
elif phase in ["rg04_prepared", "rg04_boss_prepared"]:
battle_service = RG04Contract1Battle.new(snapshot())
elif phase.begins_with("contract2_"):
battle_service = Contract2Battle.new(snapshot())
else:
battle_service = Contract1Battle.new(snapshot())
var battle: Dictionary = battle_service.run_to_terminal(plan_id)
if not battle.ok:
return battle
current_battle = Dictionary(battle.snapshot).duplicate(true)
event_cursor = 0
return {
"ok": true,
"code": "fg4_battle_started",
"plan_id": plan_id,
"event_log_hash": String(current_battle.event_log_hash),
"view": view_state(locale),
}
func advance_battle_ticks(count: int, locale := "zh_CN") -> Dictionary:
if current_battle.is_empty():
return _reject("battle_required")
var events: Array = current_battle.event_log
var next_cursor := mini(events.size(), event_cursor + maxi(1, count))
var fresh: Array = events.slice(event_cursor, next_cursor)
event_cursor = next_cursor
return {
"ok": true,
"code": "fg4_battle_terminal" if event_cursor == events.size() else "fg4_battle_advanced",
"events": normalize_events(fresh),
"event_log_hash": String(current_battle.event_log_hash),
"view": view_state(locale),
}
func run_battle_to_terminal(locale := "zh_CN") -> Dictionary:
if current_battle.is_empty():
return _reject("battle_required")
return advance_battle_ticks(current_battle.event_log.size() - event_cursor + 1, locale)
func settle_terminal(locale := "zh_CN") -> Dictionary:
if current_battle.is_empty() or event_cursor < current_battle.event_log.size():
return _reject("visible_terminal_required")
var operation_id := _operation("settle", String(current_battle.snapshot_hash))
var result: Dictionary
match String(current_battle.kind):
"fg4_rg04_contract1_boss", "fg4_rg04_contract2_boss":
result = service.settle_boss(operation_id, service.state.revision, current_battle)
"fg4_rg04_contract1_route", "fg4_rg04_contract2_route":
result = service.settle_route(operation_id, service.state.revision, current_battle)
"fg4_contract1_boss":
result = service.settle_boss(operation_id, service.state.revision, current_battle)
"fg4_contract2_boss":
result = service.settle_contract2_boss(operation_id, service.state.revision, current_battle)
"fg4_contract2_route":
result = service.settle_contract2_route(operation_id, service.state.revision, current_battle)
_:
result = service.settle_route(operation_id, service.state.revision, current_battle)
if not result.ok:
return result
current_battle = {}
event_cursor = 0
return _replace_from_result(result, locale)
func continue_result(locale := "zh_CN") -> Dictionary:
if not valid:
return _reject(error_code)
var phase := String(service.state.current_run.phase)
var result: Dictionary
if phase == "contract_result":
result = service.close_contract(
_operation("close-contract1", "CLEAR-RG03-C01"),
service.state.revision,
String(service.state.current_run.pending_boss_result.close_id)
)
elif phase == "contract2_handoff":
result = service.activate_contract(
_operation("activate-contract2", "CTR-R3-02"),
service.state.revision,
String(service.state.current_run.market_board.handoff_hash)
)
elif phase == "region_result":
result = service.close_region(
_operation("close-region", "RG-03"),
service.state.revision,
String(service.state.current_run.pending_boss_result.close_id)
)
elif phase == "region_closed":
var activation := RG04Activation.new(snapshot())
result = activation.activate_region(
_operation("activate-region", "RG-04"), activation.state.revision,
String(activation.state.current_run.market_board.result_hash)
)
elif phase == "rg04_contract_result":
result = service.close_contract(
_operation("close-contract3", "CLEAR-RG04-C01"),
service.state.revision,
String(service.state.current_run.pending_boss_result.close_id)
)
elif phase == "rg04_contract2_handoff":
result = service.activate_contract(
_operation("activate-contract4", "CTR-R4-02"),
service.state.revision,
String(service.state.current_run.market_board.handoff_hash)
)
elif phase == "rg04_region_result":
result = service.close_region(
_operation("close-region", "RG-04"),
service.state.revision,
String(service.state.current_run.pending_boss_result.close_id)
)
else:
return _reject("result_state_required")
return _replace_from_result(result, locale)
func recover(locale := "zh_CN") -> Dictionary:
if not valid:
return _reject(error_code)
var run: Dictionary = service.state.current_run
var phase := String(run.phase)
var result: Dictionary
if phase == "recovery":
result = service.recover_route(
_operation("recover-route", String(run.last_battle_report.battle_hash)),
service.state.revision,
String(run.last_battle_report.battle_hash),
int(run.recovery_cost)
)
elif phase == "contract2_recovery":
result = service.recover_contract2_route(
_operation("recover-route", String(run.last_battle_report.battle_hash)),
service.state.revision, String(run.last_battle_report.battle_hash),
int(run.recovery_cost)
)
elif phase == "boss_recovery":
result = service.recover_boss(
_operation("recover-boss", String(run.pending_boss_result.battle_hash)),
service.state.revision,
String(run.pending_boss_result.battle_hash),
int(run.recovery_cost)
)
elif phase == "contract2_boss_recovery":
result = service.recover_contract2_boss(
_operation("recover-boss", String(run.pending_boss_result.battle_hash)),
service.state.revision, String(run.pending_boss_result.battle_hash),
int(run.recovery_cost)
)
elif phase in ["rg04_recovery", "rg04_contract2_recovery"]:
result = service.recover_route(
_operation("recover-route", String(run.last_battle_report.battle_hash)),
service.state.revision, String(run.last_battle_report.battle_hash),
int(run.recovery_cost)
)
elif phase in ["rg04_boss_recovery", "rg04_contract2_boss_recovery"]:
result = service.recover_boss(
_operation("recover-boss", String(run.pending_boss_result.battle_hash)),
service.state.revision, String(run.pending_boss_result.battle_hash),
int(run.recovery_cost)
)
else:
return _reject("recovery_state_required")
return _replace_from_result(result, locale)
func commit_order(_ordered_recruit_ids: Array) -> Dictionary:
return _reject("formation_authority_frozen")
func normalize_events(events: Array) -> Array[Dictionary]:
var normalized: Array[Dictionary] = []
for raw_event in events:
if not raw_event is Dictionary:
continue
var event: Dictionary = raw_event
var authority_kind := String(event.get("kind", ""))
var kind: String = String({
"wave_entered": "wave_arrived",
"authoritative_damage": "damage",
"battle_terminal": "outcome",
"boss_terminal": "outcome",
}.get(authority_kind, authority_kind))
var detail: Dictionary = Dictionary(event.get("payload", {})).duplicate(true)
detail["authority_kind"] = authority_kind
if kind == "damage":
detail["raw"] = int(detail.get("amount", 0))
detail["effective"] = int(detail.get("amount", 0))
if kind == "outcome":
detail["outcome"] = String(detail.get("outcome", current_battle.get("outcome", "")))
normalized.append({
"event_id": String(event.get("event_id", "")),
"tick": int(event.get("tick", 0)),
"kind": String(kind),
"source_id": String(event.get("source_id", "")),
"target_id": String(event.get("target_id", "")),
"detail": detail,
})
return normalized
static func hash_value(value) -> String:
return JSON.stringify(_canonical(value)).sha256_text()
func _bind(snapshot_value: Dictionary) -> bool:
var probe := State.new()
if not probe.restore_snapshot(snapshot_value):
valid = false
error_code = "invalid_fg4_snapshot"
return false
var phase := String(probe.current_run.phase)
if phase == "contract2_handoff":
service = Contract2Activation.new(snapshot_value)
elif phase == "rg04_contract2_handoff":
service = RG04Contract2Activation.new(snapshot_value)
elif phase in RG04_CONTRACT2_PHASES:
service = RG04Contract2Service.new(snapshot_value)
elif phase in RG04_CONTRACT1_PHASES:
service = RG04Contract1Service.new(snapshot_value)
elif phase in CONTRACT2_PHASES:
service = Contract2Execution.new(snapshot_value)
elif phase in CONTRACT1_PHASES:
service = Contract1Service.new(snapshot_value)
else:
valid = false
error_code = "unsupported_fg4_phase:%s" % phase
return false
market = service
valid = bool(service.valid)
error_code = "" if valid else String(service.error_code)
return valid
func _replace_from_result(result: Dictionary, locale: String) -> Dictionary:
if not bool(result.get("ok", false)):
return result
if not _bind(Dictionary(result.snapshot)):
return _reject(error_code)
var forwarded := result.duplicate(true)
forwarded["view"] = view_state(locale)
return forwarded
func _mode(phase: String) -> String:
if phase in ["market", "contract2_market", "rg04_market", "rg04_contract2_market"]:
return "market"
if phase in ["prepared", "contract2_prepared", "rg04_prepared", "rg04_contract2_prepared"]:
return "plan"
if phase in ["boss_muster", "contract2_boss_muster", "rg04_boss_muster", "rg04_contract2_boss_muster"]:
return "boss_muster"
if phase in ["boss_prepared", "contract2_boss_prepared", "rg04_boss_prepared", "rg04_contract2_boss_prepared"]:
return "boss_plan"
if phase in ["recovery", "contract2_recovery", "boss_recovery", "contract2_boss_recovery", "rg04_recovery", "rg04_boss_recovery", "rg04_contract2_recovery", "rg04_contract2_boss_recovery"]:
return "recovery"
if phase == "contract_result":
return "contract_record"
if phase == "contract2_handoff":
return "contract_handoff"
if phase == "region_result":
return "region_record"
if phase == "region_closed":
return "region_closed"
if phase == "rg04_contract_result":
return "contract_record"
if phase == "rg04_contract2_handoff":
return "contract_handoff"
if phase == "rg04_region_result":
return "region_record"
if phase == "rg04_region_closed":
return "region_closed"
return "battle" if not current_battle.is_empty() else "unknown"
func _company_view(run: Dictionary, locale: String) -> Array[Dictionary]:
var rows: Array[Dictionary] = []
for recruit_id_value in run.company:
var recruit_id := String(recruit_id_value)
var record: Dictionary = run.recruit_records[recruit_id]
var profession_id := String(record.profession_id)
var profession: Dictionary = profession_rows.get(profession_id, {})
rows.append({
"recruit_id": recruit_id,
"actor_id": recruit_id,
"identity_id": profession_id,
"profession_id": profession_id,
"profession_name": String(profession.get("name", profession_id)),
"trait_id": String(record.trait_id),
"level": int(record.level),
"hp": int(record.current_hp),
"max_hp": int(record.max_hp),
"guard": 0,
"deployed": recruit_id in run.deployed_recruit_ids,
"locale": locale,
})
return rows
func _route_view(run: Dictionary, locale: String) -> Array[Dictionary]:
if String(run.phase) not in ["market", "contract2_market", "rg04_market", "rg04_contract2_market"]:
return []
var rows: Array[Dictionary] = []
for offer_value in run.market_board.battle_offers:
var offer: Dictionary = offer_value
var enemy_names: Array[String] = []
for enemy_id in offer.enemy_ids:
var enemy: Dictionary = enemy_rows.get(str(enemy_id), {})
enemy_names.append(_localized(enemy, locale, str(enemy_id)))
rows.append({
"offer_id": str(offer.offer_id),
"route_id": str(offer.route_id),
"name": _localized(offer, locale, str(offer.route_id)),
"risk": int(offer.risk),
"wave_count": offer.enemy_ids.size(),
"enemy_ids": Array(offer.enemy_ids).duplicate(),
"enemy_names": enemy_names,
"preview": _localized_field(offer.get("preview", ""), locale),
"failure_cause": _localized_field(offer.get("failure", offer.get("failure_cause", "")), locale),
"reward_coin": int(offer.reward_coin),
"recovery_coin": int(offer.recovery_coin),
})
return rows
func _plan_view(run: Dictionary, locale: String) -> Array[Dictionary]:
var phase := String(run.phase)
var ids: Array = []
var primary := ""
var secondary := ""
if phase in ["prepared", "contract2_prepared", "rg04_prepared", "rg04_contract2_prepared"]:
if phase == "prepared":
ids = Array(Contract1Battle.PLAN_IDS).duplicate()
var market_index := int(run.pending_encounter.round_index)
primary = String(Contract1Battle.PRIMARY_PLAN[market_index])
secondary = String(Contract1Battle.SECONDARY_PLAN[market_index])
elif phase == "contract2_prepared":
ids = Array(Contract2Battle.PLAN_IDS).duplicate()
var market_index := int(run.pending_encounter.round_index)
primary = String(Contract2Battle.PRIMARY_PLAN[market_index])
secondary = String(Contract2Battle.SECONDARY_PLAN[market_index])
elif phase == "rg04_prepared":
ids = Array(RG04Contract1Battle.PLAN_IDS).duplicate()
var market_index := int(run.pending_encounter.round_index)
primary = String(RG04Contract1Battle.PRIMARY_PLAN[market_index])
secondary = String(RG04Contract1Battle.SECONDARY_PLAN[market_index])
else:
ids = Array(RG04Contract2Battle.PLAN_IDS).duplicate()
var market_index := int(run.pending_encounter.round_index)
primary = String(RG04Contract2Battle.PRIMARY_PLAN[market_index])
secondary = String(RG04Contract2Battle.SECONDARY_PLAN[market_index])
elif phase in ["boss_muster", "contract2_boss_muster", "rg04_boss_muster", "rg04_contract2_boss_muster"]:
ids = Array(run.boss_muster.plan_ids).duplicate()
else:
return []
var rows: Array[Dictionary] = []
for plan_id_value in ids:
var plan_id := String(plan_id_value)
var copy: Array = PLAN_COPY.get(plan_id, [plan_id, plan_id])
rows.append({
"plan_id": plan_id,
"offer_id": plan_id,
"name": String(copy[1] if locale == "en" else copy[0]),
"primary": plan_id == primary,
"secondary": plan_id == secondary,
})
return rows
func _battle_view(run: Dictionary, locale: String) -> Dictionary:
if current_battle.is_empty():
return {}
var allies: Array[Dictionary] = []
var hp_by_actor := {}
for recruit_id_value in current_battle.final_order:
var recruit_id := String(recruit_id_value)
var actor: Dictionary = current_battle.actors[recruit_id]
hp_by_actor[recruit_id] = int(actor.hp_before)
for event in Array(current_battle.event_log).slice(0, event_cursor):
if String(event.get("kind", "")) == "authoritative_damage":
hp_by_actor[String(event.target_id)] = int(event.payload.hp_after)
for index in range(current_battle.final_order.size()):
var recruit_id := String(current_battle.final_order[index])
var actor: Dictionary = current_battle.actors[recruit_id]
var profession_id := String(actor.profession_id)
var profession: Dictionary = profession_rows.get(profession_id, {})
allies.append({
"actor_id": recruit_id,
"identity_id": profession_id,
"profession_id": profession_id,
"profession_name": String(profession.get("name", profession_id)),
"hp": int(hp_by_actor[recruit_id]),
"max_hp": int(actor.max_hp),
"guard": 0,
"slot_index": index,
})
var enemy_ids: Array = []
if String(current_battle.get("boss_id", "")).is_empty():
enemy_ids = Array(run.pending_encounter.enemy_ids).duplicate()
else:
enemy_ids = [String(current_battle.boss_id)]
var enemies: Array[Dictionary] = []
for index in range(enemy_ids.size()):
var enemy_id := String(enemy_ids[index])
var row: Dictionary = boss_rows.get(enemy_id, enemy_rows.get(enemy_id, {}))
enemies.append({
"actor_id": enemy_id,
"identity_id": enemy_id,
"name": _localized(row, locale, enemy_id),
"hp": 1,
"max_hp": 1,
"guard": 0,
"slot_index": index,
})
var revealed: Array = Array(current_battle.event_log).slice(0, event_cursor)
var terminal: bool = event_cursor >= current_battle.event_log.size()
var decisive: Dictionary = {}
for event in normalize_events(revealed):
if bool(event.detail.get("decisive", false)) or String(event.kind) in ["outcome", "plan_response"]:
decisive = event
break
return {
"allies": allies,
"enemies": enemies,
"terminal": terminal,
"outcome": String(current_battle.outcome) if terminal else "",
"cause": String(current_battle.cause) if terminal else "",
"next_wave": mini(enemy_ids.size(), _event_count(revealed, "wave_entered") + 1),
"wave_count": enemy_ids.size(),
"first_decisive_event": decisive,
"fallen_recruit_ids": Array(current_battle.fallen_recruit_ids).duplicate() if terminal else [],
"event_count": event_cursor,
"event_log_hash": String(current_battle.event_log_hash),
"plan_id": String(current_battle.plan_id),
"boss_id": String(current_battle.get("boss_id", "")),
"mechanics": _mechanics_view(revealed),
}
func _mechanics_view(revealed: Array) -> Dictionary:
var normalized := normalize_events(revealed)
var kinds: Dictionary = {}
var details: Dictionary = {}
for event in normalized:
var event_kind := String(event.kind)
kinds[event_kind] = int(kinds.get(event_kind, 0)) + 1
details[event_kind] = Dictionary(event.detail).duplicate(true)
var battle_kind := String(current_battle.get("kind", ""))
if battle_kind == "fg4_contract1_route":
return {
"kind": "valve_heat",
"valve": Dictionary(current_battle.get("pressure_valve", {})).duplicate(true),
"valve_disclosed": kinds.has("pressure_valve_disclosed"),
"construct_visible": kinds.has("construct_state_changed"),
"construct_removed": bool(details.get("construct_state_changed", {}).get("removed", false)),
"delayed_heat_visible": kinds.has("delayed_heat_forecast"),
"heat_resolve_tick": int(details.get("delayed_heat_forecast", {}).get("resolve_tick", 0)),
"cooldown_visible": kinds.has("item_cooldown_forecast"),
}
if battle_kind == "fg4_contract1_boss":
return {
"kind": "furnace_pressure",
"valve_disclosed": kinds.has("pressure_valve_disclosed"),
"weak_side": String(details.get("pressure_valve_disclosed", {}).get("weak_side", "left")),
"rupture_tick": int(details.get("pressure_valve_disclosed", {}).get("rupture_tick", 72)),
"side_swap_visible": kinds.has("side_swap_forecast"),
"vented": kinds.has("weak_side_vented"),
"burst": kinds.has("strong_side_burst"),
}
if battle_kind in ["fg4_contract2_route", "fg4_contract2_boss"]:
var disclosure: Dictionary = Dictionary(current_battle.get("spark_disclosure", {})).duplicate(true)
if disclosure.is_empty():
disclosure = {
"repeat_threshold": int(details.get("repeat_counter_disclosed", {}).get("threshold", 3)),
"next_spark_index": 4,
"copied_target_rule": String(details.get("next_spark_disclosed", {}).get("target_rule", "declared_action_target")),
}
return {
"kind": "spark_repeat",
"spark_count": 8,
"repeat_disclosed": kinds.has("repeat_counter_disclosed"),
"repeat_threshold": int(disclosure.get("repeat_threshold", 3)),
"next_spark_index": int(disclosure.get("next_spark_index", 4)),
"copied_target_rule": String(disclosure.get("copied_target_rule", "declared_action_target")),
"promoted": kinds.has("spark_promoted"),
"removed": kinds.has("spark_removed"),
"reset": kinds.has("repeat_counter_reset"),
}
if battle_kind in [
"fg4_rg04_contract1_route", "fg4_rg04_contract1_boss",
"fg4_rg04_contract2_route", "fg4_rg04_contract2_boss",
]:
var clause: Dictionary = Dictionary(current_battle.get("archive_clause", {})).duplicate(true)
var copy: Dictionary = Dictionary(current_battle.get("copy_disclosure", {})).duplicate(true)
var redaction: Dictionary = Dictionary(current_battle.get("redaction_disclosure", {})).duplicate(true)
return {
"kind": "quillroot_archive",
"clause_kind": String(clause.get("kind", "")),
"affected_field": String(clause.get("affected_field", "")),
"clause_disclosed": kinds.has("archive_clause_disclosed"),
"clause_resolved": kinds.has("archive_clause_obeyed") or kinds.has("archive_clause_broken"),
"pressure_family": String(copy.get("pressure_family", "")),
"copy_source": String(copy.get("source_rule", "")),
"copy_target": String(copy.get("target_rule", "")),
"copy_disclosed": kinds.has("copy_source_disclosed"),
"redaction_source": String(redaction.get("source_rule", "")),
"redaction_source_visible": kinds.has("redaction_source_disclosed"),
"omitted_target_clause": String(current_battle.get("omitted_target_clause", redaction.get("omitted_target_clause", ""))),
"omitted_target_visible": kinds.has("target_clause_omitted"),
"replay_order": Array(current_battle.get("replay_order", redaction.get("replay_order", []))).duplicate(),
"replay_order_visible": kinds.has("replay_order_disclosed"),
"pointer_visible": kinds.has("index_pointer_disclosed"),
"pointer_answered": kinds.has("action_count_steered") or kinds.has("index_pointer_interrupted"),
}
return {}
func _result_view(run: Dictionary, locale: String) -> Dictionary:
var phase := String(run.phase)
if phase == "contract_result":
return {
"kind": "contract_record",
"title": _text(locale, "第一份余烬契约已记录", "FIRST EMBERLOOM CONTRACT RECORDED"),
"body": _text(locale, "幸存队伍保留伤势、装备与神器,继续追查九火监工。", "THE SURVIVING COMPANY KEEPS HP, EQUIPMENT AND ARTIFACT WHILE PURSUING THE FOREMAN."),
"renown": 18,
"action": _text(locale, "写入第一份记录", "WRITE FIRST RECORD"),
}
if phase == "contract2_handoff":
return {
"kind": "contract_handoff",
"title": _text(locale, "第二份契约已开放", "CONTRACT TWO IS OPEN"),
"body": _text(locale, "保留当前成员、生命、装备与神器,进入十二轮火花账战线。", "KEEP COMPANY, HP, EQUIPMENT AND ARTIFACT FOR TWELVE SPARK-LEDGER ROUNDS."),
"action": _text(locale, "进入第二份契约", "ENTER CONTRACT TWO"),
}
if phase == "region_result":
return {
"kind": "region_record",
"title": _text(locale, "九火监工已停工", "FOREMAN NINE-SPARKS IS SILENT"),
"body": _text(locale, "第二份记录与 22 声望等待写入。", "THE SECOND RECORD AND 22 RENOWN ARE READY."),
"renown": 22,
"action": _text(locale, "修复余烬织境", "RESTORE EMBERLOOM"),
}
if phase == "region_closed":
return {
"kind": "region_closed",
"title": _text(locale, "余烬织境已修复", "EMBERLOOM RESTORED"),
"body": _text(locale, "两份队伍记录已保存,契文根庭 RG-04 已开放。", "TWO COMPANY RECORDS SAVED; QUILLROOT RG-04 UNLOCKED."),
"action": _text(locale, "进入根羽档案馆", "ENTER QUILLROOT ARCHIVE"),
}
if phase == "rg04_contract_result":
return {
"kind": "contract_record",
"title": _text(locale, "行走索引已归档", "THE WALKING INDEX IS FILED"),
"body": _text(locale, "第一份根羽记录与 20 声望等待写入。", "THE FIRST QUILLROOT RECORD AND 20 RENOWN ARE READY."),
"renown": 20,
"action": _text(locale, "写入第一份根羽记录", "WRITE FIRST QUILLROOT RECORD"),
}
if phase == "rg04_contract2_handoff":
return {
"kind": "contract_handoff",
"title": _text(locale, "最终删节契约已开放", "THE FINAL REDACTION CONTRACT IS OPEN"),
"body": _text(locale, "保留当前队伍与全部后果,进入十二轮删节重放。", "KEEP THE COMPANY AND EVERY CONSEQUENCE FOR TWELVE REDACTION REPLAYS."),
"action": _text(locale, "进入最终契约", "ENTER FINAL CONTRACT"),
}
if phase == "rg04_region_result":
return {
"kind": "region_record",
"title": _text(locale, "删节雄鹿已停止重写", "REDACTION HART HAS STOPPED REWRITING"),
"body": _text(locale, "第二份根羽记录与 24 声望等待写入。", "THE SECOND QUILLROOT RECORD AND 24 RENOWN ARE READY."),
"renown": 24,
"action": _text(locale, "修复根羽档案馆", "RESTORE QUILLROOT ARCHIVE"),
}
if phase == "rg04_region_closed":
return {
"kind": "region_closed",
"title": _text(locale, "根羽档案馆已修复", "QUILLROOT ARCHIVE RESTORED"),
"body": _text(locale, "四份中期记录已保存,RG-05 已开放。", "FOUR MIDGAME RECORDS SAVED; RG-05 UNLOCKED."),
"action": "",
}
return {}
func _region_record_count() -> int:
var count := 0
for record in service.state.guild_profile.company_records:
var contract_id := String(record.get("contract_id", ""))
var region_id := String(service.state.current_run.region_id)
if (
region_id == "RG-03" and contract_id in ["CTR-R3-01", "CTR-R3-02"]
or region_id == "RG-04" and contract_id in ["CTR-R4-01", "CTR-R4-02"]
):
count += 1
return count
func _operation(kind: String, target: String) -> String:
operation_serial += 1
return "fg4-runtime-%s-%d-%s" % [kind, operation_serial, target.sha256_text().substr(0, 8)]
func _localized(row: Dictionary, locale: String, fallback: String) -> String:
var names = row.get("name", {})
if names is Dictionary:
return str(names.get("en" if locale == "en" else "zh_CN", fallback))
return str(row.get("name_en", fallback)) if locale == "en" else str(row.get("name_zh", fallback))
func _localized_field(value, locale: String) -> String:
if value is Dictionary:
return str(value.get("en" if locale == "en" else "zh_CN", ""))
return str(value)
func _text(locale: String, zh: String, en: String) -> String:
return en if locale == "en" else zh
func _event_count(events: Array, kind: String) -> int:
var count := 0
for event in events:
if String(event.get("kind", "")) == kind:
count += 1
return count
func _reject(code: String) -> Dictionary:
return {
"ok": false,
"code": code,
"view": view_state() if valid else {},
}
static func _canonical(value):
if value is float and is_equal_approx(value, round(value)):
return int(round(value))
if value is Dictionary:
var result := {}
var keys: Array = value.keys()
keys.sort_custom(func(left, right): return String(left) < String(right))
for key in keys:
result[key] = _canonical(value[key])
return result
if value is Array:
var result: Array = []
for item in value:
result.append(_canonical(item))
return result
return value