Files
aetherbound-guild/runtime/p9_3/core_loop_service.gd
T

536 lines
17 KiB
GDScript

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()}