diff --git a/runtime/domain/p9_2/battle_rules.gd b/runtime/domain/p9_2/battle_rules.gd new file mode 100644 index 00000000..ca8e8186 --- /dev/null +++ b/runtime/domain/p9_2/battle_rules.gd @@ -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))) diff --git a/runtime/domain/p9_2/battle_service.gd b/runtime/domain/p9_2/battle_service.gd new file mode 100644 index 00000000..f9b3b10d --- /dev/null +++ b/runtime/domain/p9_2/battle_service.gd @@ -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()} diff --git a/runtime/domain/p9_2/battle_state.gd b/runtime/domain/p9_2/battle_state.gd new file mode 100644 index 00000000..0f4bb635 --- /dev/null +++ b/runtime/domain/p9_2/battle_state.gd @@ -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 diff --git a/runtime/tests/p9_2/authoritative_battle_domain_test.gd b/runtime/tests/p9_2/authoritative_battle_domain_test.gd new file mode 100644 index 00000000..d8529931 --- /dev/null +++ b/runtime/tests/p9_2/authoritative_battle_domain_test.gd @@ -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)