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

575 lines
20 KiB
GDScript

class_name ABGFG3CampaignAdapter
extends RefCounted
const Contract1Battle = preload("res://fg3/contract1_battle_service.gd")
const Contract1Service = preload("res://fg3/contract1_service.gd")
const Contract2Activation = preload("res://fg3/contract2_service.gd")
const Contract2Battle = preload("res://fg3/contract2_battle_service.gd")
const Contract2Execution = preload("res://fg3/contract2_execution_service.gd")
const FG3Content = preload("res://fg3/content_registry.gd")
const P100Content = preload("res://p10_0/content_registry.gd")
const P103Content = preload("res://p10_3/content_registry.gd")
const State = preload("res://fg3/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 PLAN_COPY := {
"hold_line": ["稳住战线", "HOLD LINE"],
"follow_tide": ["随潮换位", "FOLLOW TIDE"],
"focus_source": ["集火源头", "FOCUS SOURCE"],
"guard_center": ["护住中段", "GUARD CENTER"],
"follow_index_clear_eggs": ["跟随安全位并清卵", "FOLLOW INDEX & CLEAR EGGS"],
"shift_hatch_area": ["引导孵化进入预备区", "SHIFT HATCH INTO AREA"],
"interrupt_pulse": ["打断脉冲", "INTERRUPT PULSE"],
"fortify_links": ["加固链接位", "FORTIFY LINKS"],
"stagger_actions": ["错开行动", "STAGGER ACTIONS"],
"reorder_line": ["重排战线", "REORDER LINE"],
"interrupt_pulse_family": ["打断一组脉冲", "INTERRUPT PULSE FAMILY"],
"durable_links_stagger": ["耐受链接并错峰行动", "DURABLE LINKS & STAGGER"],
}
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 fg3_catalog: Dictionary = FG3Content.snapshot()
var p100_catalog: Dictionary = P100Content.snapshot()
var p103_catalog: Dictionary = P103Content.snapshot()
var profession_rows: Dictionary = P100Content.rows_by_id(p100_catalog, "professions")
var enemy_rows: Dictionary = P103Content.rows_by_id(p103_catalog, "enemies")
var boss_rows: Dictionary = FG3Content.rows_by_id(fg3_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": "fg3_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(),
"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"]:
return _reject("market_required")
var result: Dictionary = 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"]:
var prepared: Dictionary = 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"]:
return _reject("prepared_state_required")
var battle_service = (
Contract2Battle.new(snapshot())
if phase.begins_with("contract2_")
else 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": "fg3_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": "fg3_battle_terminal" if event_cursor == events.size() else "fg3_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
if String(current_battle.kind) in ["fg3_contract1_boss", "fg3_contract2_boss"]:
result = service.settle_boss(operation_id, service.state.revision, current_battle)
else:
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-RG02-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-R2-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-02"),
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 in ["recovery", "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 ["boss_recovery", "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_fg3_snapshot"
return false
var phase := String(probe.current_run.phase)
if phase == "contract2_handoff":
service = Contract2Activation.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_fg3_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"]:
return "market"
if phase in ["prepared", "contract2_prepared"]:
return "plan"
if phase in ["boss_muster", "contract2_boss_muster"]:
return "boss_muster"
if phase in ["boss_prepared", "contract2_boss_prepared"]:
return "boss_plan"
if phase in ["recovery", "contract2_recovery", "boss_recovery", "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"
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"]:
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(String(enemy_id), {})
enemy_names.append(_localized(enemy, locale, String(enemy_id)))
rows.append({
"offer_id": String(offer.offer_id),
"route_id": String(offer.route_id),
"name": _localized(offer, locale, String(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": String(offer.get("preview", "")),
"failure_cause": String(offer.get("failure_cause", "")),
"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"]:
if phase == "prepared":
ids = Array(Contract1Battle.PLAN_IDS).duplicate()
primary = String(Contract1Battle.PREFERRED_PLAN.get(String(run.pending_encounter.route_id), ""))
else:
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 in ["boss_muster", "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", "")),
}
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 SILTWAKE CONTRACT RECORDED"),
"body": _text(locale, "幸存队伍继续进入第二份契约。", "THE SURVIVING COMPANY CONTINUES INTO CONTRACT TWO."),
"renown": 16,
"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 BELL-TIDE ROUNDS."),
"action": _text(locale, "进入第二份契约", "ENTER CONTRACT TWO"),
}
if phase == "region_result":
return {
"kind": "region_record",
"title": _text(locale, "溺钟已沉默", "THE DROWNED BELL IS SILENT"),
"body": _text(locale, "第二份记录与 20 声望等待写入。", "THE SECOND RECORD AND 20 RENOWN ARE READY."),
"renown": 20,
"action": _text(locale, "完成淤潮沼泽", "COMPLETE SILTWAKE"),
}
if phase == "region_closed":
return {
"kind": "region_closed",
"title": _text(locale, "淤潮沼泽已修复", "SILTWAKE RESTORED"),
"body": _text(locale, "两份队伍记录已保存,余烬织境 RG-03 已开放。", "TWO COMPANY RECORDS SAVED; EMBERLOOM RG-03 UNLOCKED."),
"action": "",
}
return {}
func _operation(kind: String, target: String) -> String:
operation_serial += 1
return "fg3-runtime-%s-%d-%s" % [kind, operation_serial, target.sha256_text().substr(0, 8)]
func _localized(row: Dictionary, locale: String, fallback: String) -> String:
return String(row.get("name_en", fallback)) if locale == "en" else String(row.get("name_zh", fallback))
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