class_name P93CoreLoopService extends RefCounted const State = preload("res://p9_3/core_loop_state.gd") const BattleService = preload("res://domain/p9_2/battle_service.gd") const Rules = preload("res://domain/p9_2/battle_rules.gd") const OFFERS := { "Wayrunner": { "cost": 10, "name_zh": "托马斯 · 游侠", "name_en": "Tomas · Wayrunner", "strength_zh": "闪避后反击", "strength_en": "Counters after evading", "limit_zh": "正面承伤较低", "limit_en": "Lower direct durability", }, "Farstring": { "cost": 12, "name_zh": "妮娅 · 弓手", "name_en": "Nia · Farstring", "strength_zh": "接触前远程射击", "strength_en": "Shoots before contact", "limit_zh": "被近身时脆弱", "limit_en": "Fragile at close range", }, "Kindhand": { "cost": 11, "name_zh": "赛布尔 · 治疗", "name_en": "Sable · Kindhand", "strength_zh": "治疗受伤队员", "strength_en": "Heals wounded allies", "limit_zh": "直接伤害较低", "limit_en": "Low direct damage", }, } const ENCOUNTERS := { "open_road": { "name_zh": "开阔旧道", "name_en": "Open Road", "reward": 8, "pressure_zh": "1 名近战", "pressure_en": "1 contact enemy", "seed": 9301, }, "crosswind": { "name_zh": "侧风隘口", "name_en": "Crosswind Pass", "reward": 12, "pressure_zh": "近战 + 远程", "pressure_en": "Contact + ranged", "seed": 9302, }, "bell_breach": { "name_zh": "钟门裂口", "name_en": "Bell Breach", "reward": 16, "pressure_zh": "近距离夹击", "pressure_en": "Close two-enemy pressure", "seed": 9303, }, "rift_tide": { "name_zh": "裂潮追击", "name_en": "Rift Tide Pursuit", "reward": 20, "pressure_zh": "连续两波 · 不自动恢复", "pressure_en": "Two waves · no automatic healing", "seed": 9601, }, } const PRE_BATTLE_RECOVERY_COST := 6 const FAILURE_RECOVERY_COST := 8 const EQUIPMENT := { "EQ-002": { "name_zh": "钉阵胫甲", "name_en": "Frontpin Greaves", "wearer": "Hearthguard", "benefit_zh": "抵住钟门的第一次推位", "benefit_en": "Resists Bell Breach's first push", "cost_zh": "第一次攻击晚 1 tick", "cost_en": "First basic waits 1 tick", }, "EQ-012": { "name_zh": "开线绳结", "name_en": "Open-Line Cord", "wearer": "Farstring", "benefit_zh": "被推到前方时抢射后排", "benefit_en": "Early rear shot after being pushed ahead", "cost_zh": "弓手暴露在最前方", "cost_en": "Farstring becomes the exposed lead", }, } var state := State.new() var battle := BattleService.new() var bell_equipment_boundary_resolved := false func enter_hall() -> Dictionary: if state.phase != State.Phase.TITLE: return _reject("title_required") state.phase = State.Phase.HALL state.revision += 1 return _success("hall_entered") func focus_offer(member_id: String) -> Dictionary: if state.phase != State.Phase.HALL: return _reject("hall_required") if member_id not in state.available_offers or not OFFERS.has(member_id): return _reject("offer_unavailable") state.focused_offer = member_id state.revision += 1 return _success("offer_focused") func hire(member_id: String) -> Dictionary: if state.phase != State.Phase.HALL: return _reject("hall_required") if member_id != state.focused_offer: return _reject("focus_required") if member_id not in state.available_offers or member_id in state.company: return _reject("offer_unavailable") if state.company.size() >= 10: return _reject("company_full") var cost := int(OFFERS[member_id].cost) if state.coin < cost: return _reject("insufficient_coin") var before := state.coin state.coin -= cost state.company.append(member_id) state.available_offers.erase(member_id) state.equipped_items[member_id] = "" state.member_levels[member_id] = 1 state.member_xp[member_id] = 0 state.member_condition[member_id] = int(Rules.member_definition(member_id).get("max_hp", 0)) state.focused_offer = "" state.hired_member = member_id state.phase = State.Phase.ENCOUNTER state.hire_receipts.append({ "index": state.hire_receipts.size(), "member_id": member_id, "cost": cost, "coin_before": before, "coin_after": state.coin, "company_size": state.company.size(), }) state.revision += 1 return _success("hire_committed") func select_encounter(encounter_id: String) -> Dictionary: if state.phase != State.Phase.ENCOUNTER: return _reject("encounter_phase_required") if not ENCOUNTERS.has(encounter_id): return _reject("unknown_encounter") var rift_tide_open := state.reward_receipts.size() == 3 and not state.rift_tide_plan.is_empty() if encounter_id == "rift_tide" and not rift_tide_open: return _reject("rift_tide_plan_required") if rift_tide_open and encounter_id != "rift_tide": return _reject("rift_tide_only") state.selected_encounter = encounter_id state.revision += 1 return _success("encounter_selected") func start_battle() -> Dictionary: if state.phase != State.Phase.ENCOUNTER: return _reject("encounter_phase_required") if not ENCOUNTERS.has(state.selected_encounter): return _reject("encounter_required") var encounter: Dictionary = ENCOUNTERS[state.selected_encounter] var starting_hp: Dictionary = state.member_condition if state.selected_encounter == "rift_tide" else {} var result: Dictionary = battle.start_fixture(state.company, int(encounter.seed), starting_hp) if not result.ok: return _reject(String(result.code)) _apply_encounter_configuration(state.selected_encounter) bell_equipment_boundary_resolved = false state.rift_tide_wave = 1 if state.selected_encounter == "rift_tide" else 0 state.phase = State.Phase.BATTLE state.reward_committed = false state.last_reward = 0 state.last_outcome = "" state.revision += 1 return { "ok": true, "code": "battle_started", "state": state.snapshot(), "battle": battle.state.current_snapshot(), } func advance_battle_ticks(ticks := 1) -> Dictionary: if state.phase != State.Phase.BATTLE: return _reject("battle_required") if ticks < 0: var rejected: Dictionary = battle.advance_ticks(ticks) return { "ok": rejected.ok, "code": rejected.code, "events": rejected.events, "state": state.snapshot(), "battle": rejected.snapshot, } var events: Array[Dictionary] = [] var result: Dictionary = battle.advance_ticks(0) for _tick in ticks: if state.phase != State.Phase.BATTLE: break for event in _resolve_bell_equipment_boundary(): events.append(event) result = battle.advance_ticks(1) for event in result.events: events.append(event) if battle.state.terminal and state.selected_encounter == "rift_tide": _record_rift_tide_wave() if battle.state.outcome == "victory" and state.rift_tide_wave == 1: var carried_hp: Dictionary = battle.state.member_hp.duplicate(true) var second_wave: Dictionary = battle.start_fixture(state.company, int(ENCOUNTERS.rift_tide.seed) + 1, carried_hp) assert(second_wave.ok) state.rift_tide_wave = 2 _apply_encounter_configuration("rift_tide") events.append(_append_battle_event("wave_started", { "wave": 2, "qualifier_en": "second wave", "cue": "danger", })) result = second_wave continue if battle.state.terminal: state.phase = State.Phase.RESULT state.last_outcome = battle.state.outcome state.revision += 1 return { "ok": result.ok, "code": result.code, "events": events, "state": state.snapshot(), "battle": battle.state.current_snapshot(), } func claim_and_return() -> Dictionary: if state.phase != State.Phase.RESULT: return _reject("result_required") if state.last_outcome == "victory" and not state.reward_committed: var reward := int(ENCOUNTERS[state.selected_encounter].reward) var before := state.coin state.coin += reward state.last_reward = reward state.reward_committed = true state.reward_receipts.append({ "index": state.reward_receipts.size(), "encounter_id": state.selected_encounter, "reward": reward, "coin_before": before, "coin_after": state.coin, }) _grant_battle_xp(state.reward_receipts.size()) if state.selected_encounter == "bell_breach" and state.last_outcome == "victory": state.member_condition = battle.state.member_hp.duplicate(true) if state.selected_encounter == "rift_tide": state.member_condition = battle.state.member_hp.duplicate(true) state.injured_members.clear() for member_id in state.company: if int(state.member_condition.get(member_id, 0)) <= 0: state.injured_members.append(member_id) state.recovery_required = not state.injured_members.is_empty() state.rift_tide_result_receipts.append({ "index": state.rift_tide_result_receipts.size(), "outcome": state.last_outcome, "plan": state.rift_tide_plan, "coin": state.coin, "member_hp": state.member_condition.duplicate(true), "waves": state.rift_tide_wave_receipts.slice(maxi(0, state.rift_tide_wave_receipts.size() - 2)).duplicate(true), }) state.phase = State.Phase.HALL state.selected_encounter = "" state.revision += 1 return _success("returned_to_hall") func rift_tide_plan_available() -> bool: return state.phase == State.Phase.HALL and state.reward_receipts.size() == 3 and state.rift_tide_result_receipts.is_empty() and not state.recovery_required and state.rift_tide_plan.is_empty() func choose_rift_tide_plan(plan_id: String) -> Dictionary: if not rift_tide_plan_available(): return _reject("rift_tide_plan_unavailable") if plan_id not in ["press_on", "recover"]: return _reject("unknown_rift_tide_plan") var cost := PRE_BATTLE_RECOVERY_COST if plan_id == "recover" else 0 if state.coin < cost: return _reject("insufficient_coin") var before := state.coin state.coin -= cost if plan_id == "recover": _restore_company_condition() state.recovery_receipts.append({ "index": state.recovery_receipts.size(), "reason": "pre_battle", "cost": cost, "coin_before": before, "coin_after": state.coin, }) state.rift_tide_plan = plan_id state.phase = State.Phase.ENCOUNTER state.revision += 1 return _success("rift_tide_plan_committed") func injury_recovery_cost() -> int: if not state.recovery_required: return 0 return FAILURE_RECOVERY_COST if state.last_outcome == "defeat" else state.injured_members.size() * 2 func recover_injured_company() -> Dictionary: if state.phase != State.Phase.HALL or not state.recovery_required: return _reject("injury_recovery_unavailable") var cost := injury_recovery_cost() if state.coin < cost: return _reject("insufficient_coin") var before := state.coin state.coin -= cost _restore_company_condition() state.recovery_required = false state.injured_members.clear() state.recovery_receipts.append({ "index": state.recovery_receipts.size(), "reason": "after_defeat" if state.last_outcome == "defeat" else "after_victory", "cost": cost, "coin_before": before, "coin_after": state.coin, }) if state.last_outcome == "defeat": state.rift_tide_plan = "recovered_after_defeat" state.rift_tide_wave = 0 state.phase = State.Phase.ENCOUNTER state.revision += 1 return _success("injury_recovery_committed") func offer(member_id: String) -> Dictionary: return Dictionary(OFFERS.get(member_id, {})).duplicate(true) func encounter(encounter_id: String) -> Dictionary: return Dictionary(ENCOUNTERS.get(encounter_id, {})).duplicate(true) func equipment(item_id: String) -> Dictionary: return Dictionary(EQUIPMENT.get(item_id, {})).duplicate(true) func equipment_choice_available() -> bool: return state.phase == State.Phase.HALL and state.reward_receipts.size() == 2 and state.equipment_receipts.is_empty() func focus_equipment(item_id: String) -> Dictionary: if not equipment_choice_available(): return _reject("equipment_choice_unavailable") if not EQUIPMENT.has(item_id): return _reject("unknown_equipment") state.focused_equipment = item_id state.revision += 1 return _success("equipment_focused") func equip_focused(member_id: String) -> Dictionary: if not equipment_choice_available(): return _reject("equipment_choice_unavailable") if not EQUIPMENT.has(state.focused_equipment): return _reject("equipment_focus_required") var item_id := state.focused_equipment if member_id != String(EQUIPMENT[item_id].wearer) or member_id not in state.company: return _reject("incompatible_wearer") if not String(state.equipped_items.get(member_id, "")).is_empty(): return _reject("equipment_slot_occupied") state.equipped_items[member_id] = item_id state.equipment_receipts.append({ "index": state.equipment_receipts.size(), "item_id": item_id, "member_id": member_id, "returned_item_id": "", }) state.focused_equipment = "" state.phase = State.Phase.ENCOUNTER state.revision += 1 return _success("equipment_committed") func _apply_encounter_configuration(encounter_id: String) -> void: match encounter_id: "open_road": battle.state.enemy_hp["RiftSpitter"] = 0 "bell_breach": battle.state.enemy_positions["RiftBulwark"] = 72 battle.state.enemy_positions["RiftSpitter"] = 84 "rift_tide": battle.state.enemy_positions["RiftBulwark"] = 60 battle.state.enemy_positions["RiftSpitter"] = 72 battle.state.event_log.append({ "index": battle.state.event_log.size(), "tick": battle.state.tick, "kind": "encounter_configured", "encounter_id": encounter_id, "reward": int(ENCOUNTERS[encounter_id].reward), }) battle.state.revision += 1 assert(battle.state.invariants_hold()) func _resolve_bell_equipment_boundary() -> Array[Dictionary]: var events: Array[Dictionary] = [] if bell_equipment_boundary_resolved or state.selected_encounter != "bell_breach" or battle.state.tick != 15: return events var item_id := String(state.equipped_items.get("Hearthguard", "")) if item_id.is_empty(): item_id = String(state.equipped_items.get("Farstring", "")) if item_id not in ["EQ-002", "EQ-012"]: return events bell_equipment_boundary_resolved = true if item_id == "EQ-002": battle.state.member_next_action_tick["Hearthguard"] = 17 events.append(_append_battle_event("equipment_trigger", { "source_member_id": "Hearthguard", "item_id": item_id, "qualifier_en": "push resisted", "cue": "guard", })) else: if battle.state.member_ids.size() < 2 or battle.state.member_ids[0] != "Hearthguard" or battle.state.member_ids[1] != "Farstring": return events battle.state.member_ids[0] = "Farstring" battle.state.member_ids[1] = "Hearthguard" battle.state.member_positions["Farstring"] = 32 battle.state.member_positions["Hearthguard"] = 20 if battle.state.member_ids.size() > 2: battle.state.member_positions[battle.state.member_ids[2]] = 8 battle.state.enemy_positions["RiftBulwark"] = 40 events.append(_append_battle_event("line_push", { "source_enemy_id": "RiftBulwark", "moved_member_id": "Hearthguard", "exposed_member_id": "Farstring", "qualifier_en": "line pushed", "cue": "push", })) var before := int(battle.state.enemy_hp.RiftSpitter) battle.state.enemy_hp.RiftSpitter = maxi(0, before - 14) battle.state.member_next_action_tick["Farstring"] = 28 events.append(_append_battle_event("member_action", { "source_member_id": "Farstring", "target_enemy_id": "RiftSpitter", "delta": -mini(14, before), "item_id": item_id, "qualifier_en": "extended shot", "cue": "hit", })) battle.state.revision += 1 assert(battle.state.invariants_hold()) return events func _append_battle_event(kind: String, payload: Dictionary) -> Dictionary: var event: Dictionary = { "index": battle.state.event_log.size(), "tick": battle.state.tick, "kind": kind, } for key in payload: event[key] = payload[key] battle.state.event_log.append(event) return event func _record_rift_tide_wave() -> void: state.rift_tide_wave_receipts.append({ "index": state.rift_tide_wave_receipts.size(), "wave": state.rift_tide_wave, "outcome": battle.state.outcome, "tick": battle.state.tick, "member_hp": battle.state.member_hp.duplicate(true), }) func _restore_company_condition() -> void: for member_id in state.company: state.member_condition[member_id] = int(Rules.member_definition(member_id).get("max_hp", 0)) func _grant_battle_xp(round_index: int) -> void: var grant := 10 + 2 * round_index var rows: Array[Dictionary] = [] for member_id in battle.state.living_member_ids(): var level := int(state.member_levels.get(member_id, 1)) var xp := int(state.member_xp.get(member_id, 0)) + grant var levels_gained := 0 while level < 10: var threshold := 20 + 10 * level if xp < threshold: break xp -= threshold level += 1 levels_gained += 1 if level >= 10: xp = 0 state.member_levels[member_id] = level state.member_xp[member_id] = xp rows.append({ "member_id": member_id, "grant": grant, "level": level, "xp": xp, "levels_gained": levels_gained, }) state.xp_receipts.append({ "index": state.xp_receipts.size(), "round": round_index, "grant": grant, "members": rows, }) func _success(code: String) -> Dictionary: assert(state.invariants_hold()) return {"ok": true, "code": code, "state": state.snapshot()} func _reject(code: String) -> Dictionary: assert(state.invariants_hold()) return {"ok": false, "code": code, "state": state.snapshot()}