feat(runtime): add p9.5 equipment growth domain

This commit is contained in:
2026-08-14 18:13:15 +08:00
parent cca269ee47
commit 6b426c7172
4 changed files with 386 additions and 6 deletions
+188 -6
View File
@@ -61,8 +61,30 @@ const ENCOUNTERS := {
},
}
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:
@@ -99,6 +121,9 @@ func hire(member_id: String) -> Dictionary:
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
@@ -134,6 +159,7 @@ func start_battle() -> Dictionary:
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
@@ -150,15 +176,33 @@ func start_battle() -> Dictionary:
func advance_battle_ticks(ticks := 1) -> Dictionary:
if state.phase != State.Phase.BATTLE:
return _reject("battle_required")
var result: Dictionary = battle.advance_ticks(ticks)
if battle.state.terminal:
state.phase = State.Phase.RESULT
state.last_outcome = battle.state.outcome
state.revision += 1
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": result.events,
"events": events,
"state": state.snapshot(),
"battle": result.snapshot,
}
@@ -180,6 +224,7 @@ func claim_and_return() -> Dictionary:
"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
@@ -194,6 +239,47 @@ 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":
@@ -212,6 +298,102 @@ func _apply_encounter_configuration(encounter_id: String) -> void:
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()}
+45
View File
@@ -16,6 +16,12 @@ var last_reward := 0
var last_outcome := ""
var hire_receipts: Array[Dictionary] = []
var reward_receipts: Array[Dictionary] = []
var focused_equipment := ""
var equipped_items: Dictionary = {"Hearthguard": ""}
var equipment_receipts: Array[Dictionary] = []
var member_levels: Dictionary = {"Hearthguard": 1}
var member_xp: Dictionary = {"Hearthguard": 0}
var xp_receipts: Array[Dictionary] = []
func reset() -> void:
@@ -32,6 +38,12 @@ func reset() -> void:
last_outcome = ""
hire_receipts.clear()
reward_receipts.clear()
focused_equipment = ""
equipped_items = {"Hearthguard": ""}
equipment_receipts.clear()
member_levels = {"Hearthguard": 1}
member_xp = {"Hearthguard": 0}
xp_receipts.clear()
func snapshot() -> Dictionary:
@@ -50,6 +62,12 @@ func snapshot() -> Dictionary:
"last_outcome": last_outcome,
"hire_receipts": hire_receipts.duplicate(true),
"reward_receipts": reward_receipts.duplicate(true),
"focused_equipment": focused_equipment,
"equipped_items": equipped_items.duplicate(true),
"equipment_receipts": equipment_receipts.duplicate(true),
"member_levels": member_levels.duplicate(true),
"member_xp": member_xp.duplicate(true),
"xp_receipts": xp_receipts.duplicate(true),
}.duplicate(true)
@@ -65,6 +83,33 @@ func invariants_hold() -> bool:
if member_id not in ["Hearthguard", "Wayrunner", "Farstring", "Kindhand"] or seen.has(member_id):
return false
seen[member_id] = true
if not equipped_items.has(member_id) or String(equipped_items[member_id]) not in ["", "EQ-002", "EQ-012"]:
return false
if not member_levels.has(member_id) or int(member_levels[member_id]) < 1 or int(member_levels[member_id]) > 10:
return false
if not member_xp.has(member_id) or int(member_xp[member_id]) < 0:
return false
var level := int(member_levels[member_id])
if level < 10 and int(member_xp[member_id]) >= 20 + 10 * level:
return false
for member_id in equipped_items:
if member_id not in company:
return false
for member_id in member_levels:
if member_id not in company:
return false
for member_id in member_xp:
if member_id not in company:
return false
if not focused_equipment.is_empty() and focused_equipment not in ["EQ-002", "EQ-012"]:
return false
var owned_items := {}
for item_id in equipped_items.values():
if String(item_id).is_empty():
continue
if owned_items.has(item_id):
return false
owned_items[item_id] = true
for offer_id in available_offers:
if seen.has(offer_id):
return false
@@ -0,0 +1,152 @@
extends SceneTree
const Service = preload("res://p9_3/core_loop_service.gd")
const State = preload("res://p9_3/core_loop_state.gd")
const EXPECTED := {
"Wayrunner:EQ-002": {
"tick": 91,
"order": ["Hearthguard", "Farstring", "Wayrunner"],
"hp": {"Hearthguard": 22, "Farstring": 85, "Wayrunner": 100},
"coin": 38,
},
"Wayrunner:EQ-012": {
"tick": 88,
"order": ["Farstring", "Hearthguard", "Wayrunner"],
"hp": {"Hearthguard": 106, "Farstring": 9, "Wayrunner": 100},
"coin": 38,
},
"Kindhand:EQ-002": {
"tick": 145,
"order": ["Hearthguard", "Farstring", "Kindhand"],
"hp": {"Hearthguard": 82, "Farstring": 85, "Kindhand": 90},
"coin": 37,
},
"Kindhand:EQ-012": {
"tick": 124,
"order": ["Farstring", "Hearthguard", "Kindhand"],
"hp": {"Hearthguard": 108, "Farstring": 51, "Kindhand": 90},
"coin": 37,
},
}
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
for third_member in ["Wayrunner", "Kindhand"]:
for item_id in ["EQ-002", "EQ-012"]:
verify_path(third_member, item_id)
if failures.is_empty():
print("ABG_P9_5_EQUIPMENT_GROWTH_DOMAIN_OK paths=4 items=2 slots=1 ticks=91,88,145,124 coin=38,37 levels=2,2,2 xp=12,12,0 equip_once=true xp_once=true invalid_mutations=0")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func verify_path(third_member: String, item_id: String) -> void:
var path_id := "%s:%s" % [third_member, item_id]
var expected: Dictionary = EXPECTED[path_id]
var service = complete_two_cycles(third_member)
expect(service.state.phase == State.Phase.HALL, "%s begins at the second returned Hall" % path_id)
expect(service.equipment_choice_available(), "%s exposes the equipment choice" % path_id)
expect(service.state.coin == (22 if third_member == "Wayrunner" else 21), "%s preserves P9.4 Coin" % path_id)
expect(service.state.member_levels == {"Hearthguard": 1, "Farstring": 1, third_member: 1}, "%s keeps individual level one before Bell" % path_id)
expect(service.state.member_xp == {"Hearthguard": 26, "Farstring": 26, third_member: 14}, "%s preserves 12/14 XP history" % path_id)
expect(service.state.xp_receipts.size() == 2, "%s has two prior XP receipts" % path_id)
var before_unknown: Dictionary = service.state.snapshot()
var unknown: Dictionary = service.focus_equipment("EQ-999")
expect(not unknown.ok and service.state.snapshot() == before_unknown, "%s unknown item is inert" % path_id)
var before_unfocused: Dictionary = service.state.snapshot()
var unfocused: Dictionary = service.equip_focused("Hearthguard")
expect(not unfocused.ok and service.state.snapshot() == before_unfocused, "%s equip without focus is inert" % path_id)
expect(service.focus_equipment(item_id).ok, "%s focuses the item" % path_id)
var wearer := "Hearthguard" if item_id == "EQ-002" else "Farstring"
var incompatible := "Farstring" if wearer == "Hearthguard" else "Hearthguard"
var before_incompatible: Dictionary = service.state.snapshot()
var incompatible_result: Dictionary = service.equip_focused(incompatible)
expect(not incompatible_result.ok and service.state.snapshot() == before_incompatible, "%s incompatible wearer is inert" % path_id)
var equipped: Dictionary = service.equip_focused(wearer)
expect(equipped.ok and equipped.code == "equipment_committed", "%s equipment commits" % path_id)
expect(service.state.phase == State.Phase.ENCOUNTER, "%s equip opens encounter selection" % path_id)
expect(service.state.equipped_items[wearer] == item_id, "%s binds the item to its wearer" % path_id)
expect(count_equipped(service.state.equipped_items) == 1, "%s leaves exactly one equipped item" % path_id)
expect(service.state.equipment_receipts.size() == 1, "%s writes one equipment receipt" % path_id)
var committed: Dictionary = service.state.snapshot()
var duplicate: Dictionary = service.equip_focused(wearer)
expect(not duplicate.ok and service.state.snapshot() == committed, "%s duplicate equip is inert" % path_id)
expect(service.select_encounter("bell_breach").ok, "%s selects Bell Breach" % path_id)
expect(service.start_battle().ok, "%s starts Bell Breach" % path_id)
while service.state.phase == State.Phase.BATTLE:
service.advance_battle_ticks(1)
expect(service.state.phase == State.Phase.RESULT and service.state.last_outcome == "victory", "%s reaches victory" % path_id)
expect(service.battle.state.tick == int(expected.tick), "%s preserves terminal tick" % path_id)
expect(service.battle.state.member_ids == expected.order, "%s preserves terminal order" % path_id)
expect(service.battle.state.member_hp == expected.hp, "%s preserves terminal HP" % path_id)
if item_id == "EQ-002":
expect(count_event(service, "equipment_trigger", "push resisted") == 1, "%s resists one push" % path_id)
expect(count_event(service, "line_push", "line pushed") == 0, "%s has no committed line push" % path_id)
else:
expect(count_event(service, "line_push", "line pushed") == 1, "%s commits one line push" % path_id)
expect(count_event(service, "member_action", "extended shot") == 1, "%s commits one Open-Line shot" % path_id)
var returned: Dictionary = service.claim_and_return()
expect(returned.ok and service.state.phase == State.Phase.HALL, "%s returns to the Hall" % path_id)
expect(service.state.coin == int(expected.coin), "%s commits the +16 Bell reward" % path_id)
expect(service.state.member_levels == {"Hearthguard": 2, "Farstring": 2, third_member: 2}, "%s keeps levels individual" % path_id)
expect(service.state.member_xp == {"Hearthguard": 12, "Farstring": 12, third_member: 0}, "%s preserves exact personal XP remainder" % path_id)
expect(service.state.reward_receipts.size() == 3 and service.state.xp_receipts.size() == 3, "%s commits reward and XP once" % path_id)
expect(service.state.equipped_items[wearer] == item_id and count_equipped(service.state.equipped_items) == 1, "%s preserves one equipped item on return" % path_id)
var settled: Dictionary = service.state.snapshot()
var duplicate_return: Dictionary = service.claim_and_return()
expect(not duplicate_return.ok and service.state.snapshot() == settled, "%s duplicate reward/XP is inert" % path_id)
func complete_two_cycles(third_member: String):
var service = Service.new()
service.enter_hall()
service.focus_offer("Farstring")
service.hire("Farstring")
service.select_encounter("open_road")
service.start_battle()
while service.state.phase == State.Phase.BATTLE:
service.advance_battle_ticks(1)
service.claim_and_return()
service.focus_offer(third_member)
service.hire(third_member)
service.select_encounter("crosswind")
service.start_battle()
while service.state.phase == State.Phase.BATTLE:
service.advance_battle_ticks(1)
service.claim_and_return()
return service
func count_event(service, kind: String, qualifier: String) -> int:
var result := 0
for event in service.battle.state.event_log:
if String(event.get("kind", "")) == kind and String(event.get("qualifier_en", "")) == qualifier:
result += 1
return result
func count_equipped(items: Dictionary) -> int:
var result := 0
for item_id in items.values():
if not String(item_id).is_empty():
result += 1
return result
func expect(condition: bool, message: String) -> void:
if not condition:
failures.append("P9.5 equipment/growth domain: %s" % message)
@@ -0,0 +1 @@
uid://selkijo3q6s5