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 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, }, } 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.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") 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 result: Dictionary = battle.start_fixture(state.company, int(encounter.seed)) if not result.ok: return _reject(String(result.code)) _apply_encounter_configuration(state.selected_encounter) bell_equipment_boundary_resolved = false 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: 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": result.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()) state.phase = State.Phase.HALL state.selected_encounter = "" state.revision += 1 return _success("returned_to_hall") 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 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 _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()}