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

405 lines
12 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 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()}