536 lines
17 KiB
GDScript
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()}
|