feat(runtime): add P9.2 authoritative battle domain
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
class_name P92BattleRules
|
||||
extends RefCounted
|
||||
|
||||
const TICK_SECONDS := 0.1
|
||||
const TIMEOUT_TICK := 900
|
||||
const RETREAT_MIN_TICK := 50
|
||||
const RETREAT_DELAY_TICKS := 20
|
||||
const CONTACT_RANGE := 10
|
||||
const MEMBER_IDS: Array[String] = ["Hearthguard", "Wayrunner", "Farstring", "Kindhand"]
|
||||
const ENEMY_IDS: Array[String] = ["RiftBulwark", "RiftSpitter"]
|
||||
|
||||
static func member_definition(member_id: String) -> Dictionary:
|
||||
return {
|
||||
"Hearthguard": {"max_hp": 130, "damage": 10, "cooldown": 10, "role": "contact_guard"},
|
||||
"Wayrunner": {"max_hp": 100, "damage": 12, "cooldown": 8, "role": "flank"},
|
||||
"Farstring": {"max_hp": 85, "damage": 14, "cooldown": 12, "role": "ranged"},
|
||||
"Kindhand": {"max_hp": 90, "damage": 5, "cooldown": 10, "heal": 10, "role": "healer"},
|
||||
}.get(member_id, {})
|
||||
|
||||
static func enemy_definition(enemy_id: String) -> Dictionary:
|
||||
return {
|
||||
"RiftBulwark": {"max_hp": 180, "damage": 12, "cooldown": 8, "role": "contact"},
|
||||
"RiftSpitter": {"max_hp": 110, "damage": 8, "cooldown": 11, "role": "ranged"},
|
||||
}.get(enemy_id, {})
|
||||
|
||||
static func known_member(member_id: String) -> bool:
|
||||
return member_id in MEMBER_IDS
|
||||
|
||||
static func known_enemy(enemy_id: String) -> bool:
|
||||
return enemy_id in ENEMY_IDS
|
||||
|
||||
static func clamp_member_hp(member_id: String, value: int) -> int:
|
||||
var definition: Dictionary = member_definition(member_id)
|
||||
return clampi(value, 0, int(definition.get("max_hp", 0)))
|
||||
@@ -0,0 +1,221 @@
|
||||
class_name P92BattleService
|
||||
extends RefCounted
|
||||
|
||||
const Rules = preload("res://domain/p9_2/battle_rules.gd")
|
||||
const State = preload("res://domain/p9_2/battle_state.gd")
|
||||
|
||||
var state
|
||||
|
||||
func _init(initial_state = null) -> void:
|
||||
state = initial_state if initial_state != null else State.new()
|
||||
|
||||
func start_fixture(selected_members: Array[String], fixture_seed := 1) -> Dictionary:
|
||||
if selected_members.is_empty() or selected_members.size() > Rules.MEMBER_IDS.size():
|
||||
return _reject("selection_required")
|
||||
var seen := {}
|
||||
for member_id in selected_members:
|
||||
if not Rules.known_member(member_id) or seen.has(member_id):
|
||||
return _reject("invalid_member")
|
||||
seen[member_id] = true
|
||||
state.reset()
|
||||
state.seed = fixture_seed
|
||||
state.member_ids = selected_members.duplicate()
|
||||
for index in state.member_ids.size():
|
||||
var member_id: String = state.member_ids[index]
|
||||
state.member_hp[member_id] = int(Rules.member_definition(member_id).get("max_hp", 0))
|
||||
state.member_positions[member_id] = -index * 12
|
||||
state.member_next_action_tick[member_id] = 0
|
||||
state.enemy_ids = Rules.ENEMY_IDS.duplicate()
|
||||
for index in state.enemy_ids.size():
|
||||
var enemy_id: String = state.enemy_ids[index]
|
||||
state.enemy_hp[enemy_id] = int(Rules.enemy_definition(enemy_id).get("max_hp", 0))
|
||||
state.enemy_positions[enemy_id] = 100 + index * 12
|
||||
state.enemy_next_action_tick[enemy_id] = 0
|
||||
state.revision = 1
|
||||
_event("battle_started", {"seed": fixture_seed, "members": state.member_ids.duplicate()})
|
||||
return _success([])
|
||||
|
||||
func set_paused(value: bool) -> Dictionary:
|
||||
if state.terminal:
|
||||
return _reject("terminal")
|
||||
state.paused = value
|
||||
state.revision += 1
|
||||
return _success([_event("paused_changed", {"paused": value})], false)
|
||||
|
||||
func inspect() -> Dictionary:
|
||||
return {"ok": true, "snapshot": state.current_snapshot(), "events": state.event_log.slice(maxi(0, state.event_log.size() - 8)), "code": ""}
|
||||
|
||||
func request_retreat() -> Dictionary:
|
||||
if state.terminal:
|
||||
return _reject("terminal")
|
||||
if state.retreat_requested_tick >= 0:
|
||||
return {"ok": true, "snapshot": state.current_snapshot(), "events": [_original_retreat_event()], "code": "retreat_already_requested"}
|
||||
if state.tick < Rules.RETREAT_MIN_TICK:
|
||||
return _reject("retreat_too_early")
|
||||
state.retreat_requested_tick = state.tick
|
||||
state.retreat_resolution_tick = state.tick + Rules.RETREAT_DELAY_TICKS
|
||||
state.revision += 1
|
||||
return _success([_event("retreat_started", _retreat_receipt())], false)
|
||||
|
||||
func advance_ticks(requested_ticks: int = 1) -> Dictionary:
|
||||
if requested_ticks < 0:
|
||||
return _reject("invalid_tick_count")
|
||||
if state.paused:
|
||||
return {"ok": true, "snapshot": state.current_snapshot(), "events": [], "code": "paused"}
|
||||
var events: Array[Dictionary] = []
|
||||
for _index in requested_ticks:
|
||||
if state.terminal:
|
||||
break
|
||||
var tick_events := _advance_one_tick()
|
||||
for event in tick_events:
|
||||
events.append(event)
|
||||
return _success(events, false)
|
||||
|
||||
func _advance_one_tick() -> Array[Dictionary]:
|
||||
state.tick += 1
|
||||
var events: Array[Dictionary] = []
|
||||
_move_lines(events)
|
||||
_resolve_member_actions(events)
|
||||
_resolve_enemy_actions(events)
|
||||
_check_terminal(events)
|
||||
if not state.terminal and state.retreat_requested_tick >= 0 and state.tick >= state.retreat_resolution_tick:
|
||||
_lock_outcome("retreat", "withdrawal_complete", events)
|
||||
if not state.terminal and state.tick >= Rules.TIMEOUT_TICK:
|
||||
_lock_outcome("timeout", "encounter_timeout", events)
|
||||
state.revision += 1
|
||||
return events
|
||||
|
||||
func _move_lines(events: Array[Dictionary]) -> void:
|
||||
var living_members: Array[String] = state.living_member_ids()
|
||||
var living_enemies: Array[String] = state.living_enemy_ids()
|
||||
if living_members.is_empty() or living_enemies.is_empty():
|
||||
return
|
||||
var lead_id: String = living_members[0]
|
||||
var bulwark_id: String = living_enemies[0]
|
||||
var lead_position: int = int(state.member_positions.get(lead_id, 0))
|
||||
var enemy_position: int = int(state.enemy_positions.get(bulwark_id, 100))
|
||||
if enemy_position - lead_position > Rules.CONTACT_RANGE:
|
||||
lead_position += 2
|
||||
enemy_position -= 2
|
||||
state.member_positions[lead_id] = lead_position
|
||||
state.enemy_positions[bulwark_id] = enemy_position
|
||||
if enemy_position - lead_position <= Rules.CONTACT_RANGE:
|
||||
events.append(_event("contact", {"member_id": lead_id, "enemy_id": bulwark_id, "position": lead_position}))
|
||||
for index in range(1, living_members.size()):
|
||||
var member_id: String = living_members[index]
|
||||
var desired: int = lead_position - index * 12
|
||||
var current: int = int(state.member_positions.get(member_id, desired))
|
||||
state.member_positions[member_id] = mini(current + 2, desired) if current < desired else maxi(current - 2, desired)
|
||||
|
||||
func _resolve_member_actions(events: Array[Dictionary]) -> void:
|
||||
var living_enemies: Array[String] = state.living_enemy_ids()
|
||||
if living_enemies.is_empty():
|
||||
return
|
||||
var target_enemy: String = living_enemies[0]
|
||||
for member_id in state.living_member_ids():
|
||||
var ready_tick: int = int(state.member_next_action_tick.get(member_id, 0))
|
||||
if state.tick < ready_tick:
|
||||
continue
|
||||
var definition: Dictionary = Rules.member_definition(member_id)
|
||||
var role: String = String(definition.get("role", ""))
|
||||
if role in ["contact_guard", "flank"] and not _contact_reached():
|
||||
continue
|
||||
var target_member: String = _lowest_ratio_member()
|
||||
if role == "healer" and not target_member.is_empty() and _member_ratio(target_member) < 0.8:
|
||||
var before: int = int(state.member_hp[target_member])
|
||||
var after: int = Rules.clamp_member_hp(target_member, before + int(definition.get("heal", 0)))
|
||||
state.member_hp[target_member] = after
|
||||
events.append(_event("member_action", {"source_member_id": member_id, "target_member_id": target_member, "delta": after - before, "qualifier_en": "healing", "cue": "heal"}))
|
||||
else:
|
||||
var before_enemy: int = int(state.enemy_hp[target_enemy])
|
||||
var damage: int = int(definition.get("damage", 0))
|
||||
state.enemy_hp[target_enemy] = maxi(0, before_enemy - damage)
|
||||
if role == "contact_guard":
|
||||
state.guard_bonus = 6
|
||||
events.append(_event("member_action", {"source_member_id": member_id, "target_enemy_id": target_enemy, "delta": -mini(damage, before_enemy), "qualifier_en": "flank counter" if role == "flank" else ("ranged shot" if role == "ranged" else ("guard strike" if role == "contact_guard" else "support hit")), "cue": "hit"}))
|
||||
state.member_next_action_tick[member_id] = state.tick + int(definition.get("cooldown", 10))
|
||||
|
||||
func _resolve_enemy_actions(events: Array[Dictionary]) -> void:
|
||||
var living_members: Array[String] = state.living_member_ids()
|
||||
if living_members.is_empty():
|
||||
return
|
||||
for enemy_id in state.living_enemy_ids():
|
||||
var ready_tick: int = int(state.enemy_next_action_tick.get(enemy_id, 0))
|
||||
if state.tick < ready_tick:
|
||||
continue
|
||||
var definition: Dictionary = Rules.enemy_definition(enemy_id)
|
||||
if String(definition.get("role", "")) == "contact" and not _contact_reached():
|
||||
continue
|
||||
var target: String = living_members[0] if enemy_id == "RiftBulwark" else _lowest_ratio_member()
|
||||
if target.is_empty():
|
||||
continue
|
||||
var before: int = int(state.member_hp[target])
|
||||
var damage: int = int(definition.get("damage", 0))
|
||||
if enemy_id == "RiftBulwark" and state.guard_bonus > 0:
|
||||
damage = maxi(0, damage - state.guard_bonus)
|
||||
state.guard_bonus = 0
|
||||
var after: int = maxi(0, before - damage)
|
||||
state.member_hp[target] = after
|
||||
events.append(_event("enemy_action", {"source_enemy_id": enemy_id, "target_member_id": target, "delta": after - before, "qualifier_en": "contact pressure" if enemy_id == "RiftBulwark" else "spitter pressure", "cue": "hit"}))
|
||||
state.enemy_next_action_tick[enemy_id] = state.tick + int(definition.get("cooldown", 10))
|
||||
|
||||
func _check_terminal(events: Array[Dictionary]) -> void:
|
||||
if state.living_enemy_ids().is_empty():
|
||||
_lock_outcome("victory", "all_enemies_defeated", events)
|
||||
elif state.living_member_ids().is_empty():
|
||||
_lock_outcome("defeat", "all_members_fallen", events)
|
||||
|
||||
func _lock_outcome(kind: String, cause: String, events: Array[Dictionary]) -> void:
|
||||
if state.terminal:
|
||||
return
|
||||
state.terminal = true
|
||||
state.outcome = kind
|
||||
state.outcome_cause = cause
|
||||
var terminal_event := _event("outcome_locked", {"outcome": kind, "cause": cause, "tick": state.tick})
|
||||
events.append(terminal_event)
|
||||
|
||||
func _lowest_ratio_member() -> String:
|
||||
var best: String = ""
|
||||
var best_ratio: float = 2.0
|
||||
for member_id in state.living_member_ids():
|
||||
var ratio: float = _member_ratio(member_id)
|
||||
if ratio < best_ratio:
|
||||
best_ratio = ratio
|
||||
best = member_id
|
||||
return best
|
||||
|
||||
func _member_ratio(member_id: String) -> float:
|
||||
var maximum: float = float(Rules.member_definition(member_id).get("max_hp", 1))
|
||||
return float(state.member_hp.get(member_id, 0)) / maximum
|
||||
|
||||
func _contact_reached() -> bool:
|
||||
var living_members: Array[String] = state.living_member_ids()
|
||||
var living_enemies: Array[String] = state.living_enemy_ids()
|
||||
if living_members.is_empty() or living_enemies.is_empty():
|
||||
return false
|
||||
return int(state.enemy_positions.get(living_enemies[0], 100)) - int(state.member_positions.get(living_members[0], 0)) <= Rules.CONTACT_RANGE
|
||||
|
||||
func _retreat_receipt() -> Dictionary:
|
||||
return {"tick": state.retreat_requested_tick, "resolves_at": state.retreat_resolution_tick}
|
||||
|
||||
func _original_retreat_event() -> Dictionary:
|
||||
for event in state.event_log:
|
||||
if String(event.get("kind", "")) == "retreat_started":
|
||||
return event.duplicate(true)
|
||||
return {}
|
||||
|
||||
func _event(kind: String, payload: Dictionary) -> Dictionary:
|
||||
var event: Dictionary = {"index": state.event_log.size(), "tick": state.tick, "kind": kind}
|
||||
for key in payload:
|
||||
event[key] = payload[key]
|
||||
state.event_log.append(event)
|
||||
return event
|
||||
|
||||
func _success(events: Array, increment_revision := true) -> Dictionary:
|
||||
if increment_revision:
|
||||
state.revision += 1
|
||||
assert(state.invariants_hold())
|
||||
return {"ok": true, "code": "", "events": events, "snapshot": state.current_snapshot(), "state_hash": state.state_hash(), "event_hash": state.event_hash()}
|
||||
|
||||
func _reject(code: String) -> Dictionary:
|
||||
return {"ok": false, "code": code, "events": [], "snapshot": state.current_snapshot(), "state_hash": state.state_hash(), "event_hash": state.event_hash()}
|
||||
@@ -0,0 +1,115 @@
|
||||
class_name P92BattleState
|
||||
extends RefCounted
|
||||
|
||||
const Rules = preload("res://domain/p9_2/battle_rules.gd")
|
||||
|
||||
var revision := 0
|
||||
var seed := 0
|
||||
var tick := 0
|
||||
var paused := false
|
||||
var terminal := false
|
||||
var outcome := ""
|
||||
var outcome_cause := ""
|
||||
var retreat_requested_tick := -1
|
||||
var retreat_resolution_tick := -1
|
||||
var member_ids: Array[String] = []
|
||||
var member_hp: Dictionary = {}
|
||||
var member_positions: Dictionary = {}
|
||||
var member_next_action_tick: Dictionary = {}
|
||||
var guard_bonus := 0
|
||||
var enemy_ids: Array[String] = []
|
||||
var enemy_hp: Dictionary = {}
|
||||
var enemy_positions: Dictionary = {}
|
||||
var enemy_next_action_tick: Dictionary = {}
|
||||
var event_log: Array[Dictionary] = []
|
||||
|
||||
func reset() -> void:
|
||||
revision = 0
|
||||
seed = 0
|
||||
tick = 0
|
||||
paused = false
|
||||
terminal = false
|
||||
outcome = ""
|
||||
outcome_cause = ""
|
||||
retreat_requested_tick = -1
|
||||
retreat_resolution_tick = -1
|
||||
member_ids.clear()
|
||||
member_hp.clear()
|
||||
member_positions.clear()
|
||||
member_next_action_tick.clear()
|
||||
guard_bonus = 0
|
||||
enemy_ids.clear()
|
||||
enemy_hp.clear()
|
||||
enemy_positions.clear()
|
||||
enemy_next_action_tick.clear()
|
||||
event_log.clear()
|
||||
|
||||
func living_member_ids() -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for member_id in member_ids:
|
||||
if int(member_hp.get(member_id, 0)) > 0:
|
||||
result.append(member_id)
|
||||
return result
|
||||
|
||||
func living_enemy_ids() -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for enemy_id in enemy_ids:
|
||||
if int(enemy_hp.get(enemy_id, 0)) > 0:
|
||||
result.append(enemy_id)
|
||||
return result
|
||||
|
||||
func current_snapshot() -> Dictionary:
|
||||
return {
|
||||
"revision": revision,
|
||||
"seed": seed,
|
||||
"tick": tick,
|
||||
"paused": paused,
|
||||
"terminal": terminal,
|
||||
"outcome": outcome,
|
||||
"outcome_cause": outcome_cause,
|
||||
"retreat_requested_tick": retreat_requested_tick,
|
||||
"retreat_resolution_tick": retreat_resolution_tick,
|
||||
"members": {
|
||||
"ids": member_ids.duplicate(),
|
||||
"hp": member_hp.duplicate(true),
|
||||
"positions": member_positions.duplicate(true),
|
||||
"next_action": member_next_action_tick.duplicate(true),
|
||||
},
|
||||
"enemies": {
|
||||
"ids": enemy_ids.duplicate(),
|
||||
"hp": enemy_hp.duplicate(true),
|
||||
"positions": enemy_positions.duplicate(true),
|
||||
"next_action": enemy_next_action_tick.duplicate(true),
|
||||
},
|
||||
"guard_bonus": guard_bonus,
|
||||
"events": event_log.duplicate(true),
|
||||
}.duplicate(true)
|
||||
|
||||
func state_hash() -> int:
|
||||
return JSON.stringify(current_snapshot()).hash()
|
||||
|
||||
func event_hash() -> int:
|
||||
return JSON.stringify(event_log).hash()
|
||||
|
||||
func invariants_hold() -> bool:
|
||||
if tick < 0 or revision < 0:
|
||||
return false
|
||||
if outcome not in ["", "victory", "defeat", "timeout", "retreat"]:
|
||||
return false
|
||||
if terminal != (not outcome.is_empty()):
|
||||
return false
|
||||
if retreat_requested_tick >= 0 and retreat_requested_tick < Rules.RETREAT_MIN_TICK:
|
||||
return false
|
||||
for member_id in member_ids:
|
||||
if not Rules.known_member(member_id):
|
||||
return false
|
||||
var max_hp: int = int(Rules.member_definition(member_id).get("max_hp", 0))
|
||||
if int(member_hp.get(member_id, -1)) < 0 or int(member_hp.get(member_id, -1)) > max_hp:
|
||||
return false
|
||||
for enemy_id in enemy_ids:
|
||||
if not Rules.known_enemy(enemy_id):
|
||||
return false
|
||||
var max_hp: int = int(Rules.enemy_definition(enemy_id).get("max_hp", 0))
|
||||
if int(enemy_hp.get(enemy_id, -1)) < 0 or int(enemy_hp.get(enemy_id, -1)) > max_hp:
|
||||
return false
|
||||
return true
|
||||
@@ -0,0 +1,143 @@
|
||||
extends SceneTree
|
||||
|
||||
const Rules = preload("res://domain/p9_2/battle_rules.gd")
|
||||
const State = preload("res://domain/p9_2/battle_state.gd")
|
||||
const Service = preload("res://domain/p9_2/battle_service.gd")
|
||||
|
||||
var failures: Array[String] = []
|
||||
var deterministic_cases := 0
|
||||
var action_events := 0
|
||||
|
||||
func party(ids: Array) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for member_id in ids:
|
||||
result.append(String(member_id))
|
||||
return result
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run_test")
|
||||
|
||||
func run_test() -> void:
|
||||
_test_start_and_rejections()
|
||||
_test_movement_contact_and_roles()
|
||||
_test_pause_and_inspect()
|
||||
_test_retreat_boundaries()
|
||||
_test_terminal_outcomes()
|
||||
_test_determinism()
|
||||
if failures.is_empty():
|
||||
print("ABG_P9_2_AUTHORITATIVE_BATTLE_DOMAIN_OK cases=%d actions=%d tick_seconds=0.1 outcomes=victory,defeat,timeout,retreat pause=true inspect=true retreat_min_tick=50" % [deterministic_cases, action_events])
|
||||
quit(0)
|
||||
else:
|
||||
for failure in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
|
||||
func _test_start_and_rejections() -> void:
|
||||
var service: Variant = Service.new()
|
||||
var before: Dictionary = service.state.current_snapshot()
|
||||
var empty_members: Array[String] = []
|
||||
var unknown_members: Array[String] = ["Unknown"]
|
||||
_expect(not service.start_fixture(empty_members, 1).ok, "empty party rejected")
|
||||
_expect(service.state.current_snapshot() == before, "empty party preserves state")
|
||||
_expect(not service.start_fixture(unknown_members, 1).ok, "unknown member rejected")
|
||||
_expect(service.start_fixture(party(["Hearthguard", "Farstring", "Kindhand"]), 17).ok, "three member fixture starts")
|
||||
_expect(service.state.member_hp["Hearthguard"] == 130, "Hearthguard max hp")
|
||||
_expect(service.state.enemy_hp["RiftBulwark"] == 180, "Bulwark max hp")
|
||||
|
||||
func _test_movement_contact_and_roles() -> void:
|
||||
var service: Variant = _service(party(["Hearthguard", "Farstring", "Kindhand"]), 4)
|
||||
var saw_contact := false
|
||||
var saw_farstring := false
|
||||
var saw_kindhand := false
|
||||
for _index in 180:
|
||||
var result: Dictionary = service.advance_ticks(1)
|
||||
_expect(result.ok, "tick advances")
|
||||
for event in result.events:
|
||||
if event.kind == "contact": saw_contact = true
|
||||
if event.kind == "member_action":
|
||||
action_events += 1
|
||||
if event.get("source_member_id", "") == "Farstring": saw_farstring = true
|
||||
if event.get("source_member_id", "") == "Kindhand": saw_kindhand = true
|
||||
if service.state.terminal:
|
||||
break
|
||||
_expect(saw_contact, "lines reach contact")
|
||||
_expect(saw_farstring, "Farstring resolves by stable ID")
|
||||
_expect(saw_kindhand, "Kindhand resolves by stable ID")
|
||||
_expect(service.state.outcome == "victory", "three-member fixture wins")
|
||||
_expect(service.state.tick > 0, "accepted ticks advance")
|
||||
|
||||
func _test_pause_and_inspect() -> void:
|
||||
var service: Variant = _service(party(["Hearthguard", "Wayrunner"]), 9)
|
||||
service.advance_ticks(3)
|
||||
var before: Dictionary = service.state.current_snapshot()
|
||||
var inspect: Dictionary = service.inspect()
|
||||
_expect(inspect.ok, "inspect succeeds")
|
||||
_expect(service.state.current_snapshot() == before, "inspect is mutation-free")
|
||||
_expect(service.set_paused(true).ok, "pause succeeds")
|
||||
var paused_tick: int = service.state.tick
|
||||
service.advance_ticks(10)
|
||||
_expect(service.state.tick == paused_tick, "pause freezes simulation ticks")
|
||||
service.set_paused(false)
|
||||
service.advance_ticks(1)
|
||||
_expect(service.state.tick == paused_tick + 1, "resume accepts ticks")
|
||||
|
||||
func _test_retreat_boundaries() -> void:
|
||||
var service: Variant = _service(party(["Hearthguard", "Wayrunner"]), 11)
|
||||
var before: Dictionary = service.state.current_snapshot()
|
||||
var negative_ticks: Dictionary = service.advance_ticks(-1)
|
||||
_expect(not negative_ticks.ok and negative_ticks.code == "invalid_tick_count", "negative tick count rejected")
|
||||
_expect(service.state.current_snapshot() == before, "negative tick rejection preserves state")
|
||||
var early: Dictionary = service.request_retreat()
|
||||
_expect(not early.ok and early.code == "retreat_too_early", "retreat rejects before tick 50")
|
||||
_expect(service.state.current_snapshot() == before, "early retreat preserves state")
|
||||
service.advance_ticks(50)
|
||||
var requested: Dictionary = service.request_retreat()
|
||||
_expect(requested.ok, "retreat accepted at tick 50")
|
||||
var duplicate: Dictionary = service.request_retreat()
|
||||
_expect(duplicate.ok and duplicate.code == "retreat_already_requested", "duplicate retreat returns original receipt")
|
||||
_expect(duplicate.events.size() == 1 and requested.events.size() == 1, "both retreat responses contain one receipt")
|
||||
_expect(duplicate.events[0] == requested.events[0], "duplicate retreat returns the exact original receipt")
|
||||
var requested_snapshot: Dictionary = service.state.current_snapshot()
|
||||
service.advance_ticks(Rules.RETREAT_DELAY_TICKS - 1)
|
||||
_expect(not service.state.terminal, "retreat remains pending before withdrawal completes")
|
||||
service.advance_ticks(1)
|
||||
_expect(service.state.outcome == "retreat", "retreat locks after bounded withdrawal")
|
||||
_expect(service.state.current_snapshot().retreat_requested_tick == requested_snapshot.retreat_requested_tick, "retreat identity is preserved")
|
||||
|
||||
func _test_terminal_outcomes() -> void:
|
||||
var defeat: Variant = _service(party(["Hearthguard"]), 2)
|
||||
defeat.state.enemy_hp["RiftBulwark"] = 1
|
||||
defeat.state.enemy_hp["RiftSpitter"] = 1
|
||||
defeat.state.member_hp["Hearthguard"] = 1
|
||||
defeat.advance_ticks(1)
|
||||
_expect(defeat.state.outcome == "defeat", "defeat fixture reaches explicit defeat")
|
||||
var timeout: Variant = _service(party(["Kindhand"]), 3)
|
||||
timeout.state.member_next_action_tick["Kindhand"] = 9999
|
||||
timeout.state.enemy_next_action_tick["RiftBulwark"] = 9999
|
||||
timeout.state.enemy_next_action_tick["RiftSpitter"] = 9999
|
||||
timeout.advance_ticks(Rules.TIMEOUT_TICK)
|
||||
_expect(timeout.state.outcome == "timeout", "stalled fixture reaches timeout")
|
||||
|
||||
func _test_determinism() -> void:
|
||||
var first: Variant = _service(party(["Hearthguard", "Wayrunner", "Farstring", "Kindhand"]), 42)
|
||||
var second: Variant = _service(party(["Hearthguard", "Wayrunner", "Farstring", "Kindhand"]), 42)
|
||||
for _index in 240:
|
||||
first.advance_ticks(1)
|
||||
second.advance_ticks(1)
|
||||
_expect(first.state.state_hash() == second.state.state_hash(), "same seed has identical state hash")
|
||||
_expect(first.state.event_hash() == second.state.event_hash(), "same seed has identical event hash")
|
||||
deterministic_cases += 1
|
||||
if first.state.terminal:
|
||||
break
|
||||
_expect(first.state.outcome == second.state.outcome, "same seed has identical outcome")
|
||||
_expect(first.state.tick == second.state.tick, "same seed has identical terminal tick")
|
||||
|
||||
func _service(members: Array[String], fixture_seed: int):
|
||||
var service: Variant = Service.new(State.new())
|
||||
var started: Dictionary = service.start_fixture(members, fixture_seed)
|
||||
_expect(started.ok, "fixture starts")
|
||||
return service
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
if not condition and failures.size() < 80:
|
||||
failures.append(message)
|
||||
Reference in New Issue
Block a user