feat(runtime): add p9.5 equipment growth domain
This commit is contained in:
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user