diff --git a/runtime/p9_3/core_loop_service.gd b/runtime/p9_3/core_loop_service.gd index 3405fdf0..5f01e5af 100644 --- a/runtime/p9_3/core_loop_service.gd +++ b/runtime/p9_3/core_loop_service.gd @@ -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()} diff --git a/runtime/p9_3/core_loop_state.gd b/runtime/p9_3/core_loop_state.gd index b18e4486..6a4a9898 100644 --- a/runtime/p9_3/core_loop_state.gd +++ b/runtime/p9_3/core_loop_state.gd @@ -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 diff --git a/runtime/tests/p9_5/equipment_growth_domain_test.gd b/runtime/tests/p9_5/equipment_growth_domain_test.gd new file mode 100644 index 00000000..61b8b6c1 --- /dev/null +++ b/runtime/tests/p9_5/equipment_growth_domain_test.gd @@ -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) diff --git a/runtime/tests/p9_5/equipment_growth_domain_test.gd.uid b/runtime/tests/p9_5/equipment_growth_domain_test.gd.uid new file mode 100644 index 00000000..b38581e8 --- /dev/null +++ b/runtime/tests/p9_5/equipment_growth_domain_test.gd.uid @@ -0,0 +1 @@ +uid://selkijo3q6s5