fix(simulator): 🐛 AI/suggest production city_id round-trip + restore gdext build
Exposed by a new hotseat full-game driver (drives both player seats over the
multi-slot wire, no AI dependency) — a 31-turn 2-player game surfaced these.
- mc-player-api: the AI→PlayerAction converter (apply_ai_action + the suggest
sibling) emitted the bare tactical city index ("0") for QueueProduction, but
find_city_indices needs the projector wire id "{player}_{c_idx}" — so every
AI/suggested queue_production failed UnknownCity. This silently broke the
in-box AI's production-steering, not just the wire. Emit the wire id at all
three sites; thread slot into the suggest converter; add a regression test.
Result in the playthrough: roundtrip failures 58→1, city_building_completed 0→18.
- api-gdext: advance_round_phase/end_player_round_phase did not compile at HEAD —
godot-rust 0.2.4 Array::push needs &Dictionary (AsArg); Pcg64 builds via ::seed
not ::seed_from_u64; dropped a dead rng binding. The gdext crate could not be
rebuilt from source until this.
- mc-worldsim: pub use GamePhase/RoundPhase (api-gdext references them through
mc_worldsim; they were a private re-export → E0603).
- tooling: add hotseat_playthrough.py — applies each seat's suggested actions
and flags any offered action that fails to apply, with severity triage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
33edc39863
commit
60c8ce0ef6
4 changed files with 329 additions and 29 deletions
|
|
@ -9761,9 +9761,8 @@ impl GdWorldSim {
|
|||
if state.game_phase == mc_worldsim::GamePhase::GameStart {
|
||||
if state.turn_order.is_empty() && !state.players.is_empty() {
|
||||
let order_seed = mc_core::seed::derive_step(state.map_seed, mc_core::seed::SeedDomain::GameSetup, &[]);
|
||||
let mut rng = mc_core::seed::Pcg64::seed_from_u64(order_seed);
|
||||
state.turn_order = (0..state.players.len() as u8).collect();
|
||||
// fisher yates inline (small)
|
||||
// Fisher-Yates via per-step derive (deterministic; no live RNG handle needed).
|
||||
for i in (1..state.turn_order.len()).rev() {
|
||||
let r = (mc_core::seed::derive_step(order_seed, mc_core::seed::SeedDomain::GameSetup, &[i as u64]) as usize) % (i + 1);
|
||||
state.turn_order.swap(i, r);
|
||||
|
|
@ -9771,13 +9770,13 @@ impl GdWorldSim {
|
|||
}
|
||||
let from = state.game_phase;
|
||||
state.game_phase = mc_worldsim::GamePhase::InProgress;
|
||||
out.push(self.phase_event_dict("game_phase_changed", Some(&from), Some(&state.game_phase), None, None, None));
|
||||
out.push(&self.phase_event_dict("game_phase_changed", Some(&from), Some(&state.game_phase), None, None, None));
|
||||
state.round_phase = mc_worldsim::RoundPhase::PlayerRound { order_pos: 0 };
|
||||
if let Some(&slot) = state.turn_order.first() {
|
||||
state.current_player_index = slot;
|
||||
}
|
||||
out.push(self.phase_event_dict("round_started", None, None, Some(state.turn), None, None));
|
||||
out.push(self.phase_event_dict("player_round_started", None, None, None, Some(state.current_player_index), Some(0)));
|
||||
out.push(&self.phase_event_dict("round_started", None, None, Some(state.turn), None, None));
|
||||
out.push(&self.phase_event_dict("player_round_started", None, None, None, Some(state.current_player_index), Some(0)));
|
||||
return out;
|
||||
}
|
||||
if state.game_phase != mc_worldsim::GamePhase::InProgress {
|
||||
|
|
@ -9786,35 +9785,35 @@ impl GdWorldSim {
|
|||
match state.round_phase {
|
||||
mc_worldsim::RoundPhase::PlayerRound { order_pos } => {
|
||||
let slot = state.turn_order.get(order_pos as usize).copied().unwrap_or(0);
|
||||
out.push(self.phase_event_dict("player_round_ended", None, None, None, Some(slot), Some(order_pos)));
|
||||
out.push(&self.phase_event_dict("player_round_ended", None, None, None, Some(slot), Some(order_pos)));
|
||||
let next_p = state.round_phase.next(state.turn_order.len() as u8);
|
||||
state.round_phase = next_p;
|
||||
if let mc_worldsim::RoundPhase::PlayerRound { order_pos: np } = next_p {
|
||||
if let Some(&ns) = state.turn_order.get(np as usize) {
|
||||
state.current_player_index = ns;
|
||||
}
|
||||
out.push(self.phase_event_dict("player_round_started", None, None, None, Some(state.current_player_index), Some(np)));
|
||||
out.push(&self.phase_event_dict("player_round_started", None, None, None, Some(state.current_player_index), Some(np)));
|
||||
} else if next_p == mc_worldsim::RoundPhase::FaunaRound {
|
||||
out.push(self.phase_event_dict("fauna_round_started", None, None, None, None, None));
|
||||
out.push(&self.phase_event_dict("fauna_round_started", None, None, None, None, None));
|
||||
}
|
||||
}
|
||||
mc_worldsim::RoundPhase::FaunaRound => {
|
||||
out.push(self.phase_event_dict("fauna_round_ended", None, None, None, None, None));
|
||||
out.push(&self.phase_event_dict("fauna_round_ended", None, None, None, None, None));
|
||||
state.round_phase = mc_worldsim::RoundPhase::WorldsimRound;
|
||||
out.push(self.phase_event_dict("worldsim_round_started", None, None, None, None, None));
|
||||
out.push(&self.phase_event_dict("worldsim_round_started", None, None, None, None, None));
|
||||
}
|
||||
mc_worldsim::RoundPhase::WorldsimRound => {
|
||||
out.push(self.phase_event_dict("worldsim_round_ended", None, None, None, None, None));
|
||||
out.push(&self.phase_event_dict("worldsim_round_ended", None, None, None, None, None));
|
||||
state.round_phase = mc_worldsim::RoundPhase::RoundEnd;
|
||||
out.push(self.phase_event_dict("round_ended", None, None, Some(state.turn), None, None));
|
||||
out.push(&self.phase_event_dict("round_ended", None, None, Some(state.turn), None, None));
|
||||
}
|
||||
mc_worldsim::RoundPhase::RoundEnd => {
|
||||
state.round_phase = mc_worldsim::RoundPhase::PlayerRound { order_pos: 0 };
|
||||
if !state.turn_order.is_empty() {
|
||||
state.current_player_index = state.turn_order[0];
|
||||
}
|
||||
out.push(self.phase_event_dict("round_started", None, None, Some(state.turn), None, None));
|
||||
out.push(self.phase_event_dict("player_round_started", None, None, None, Some(state.current_player_index), Some(0)));
|
||||
out.push(&self.phase_event_dict("round_started", None, None, Some(state.turn), None, None));
|
||||
out.push(&self.phase_event_dict("player_round_started", None, None, None, Some(state.current_player_index), Some(0)));
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
@ -9831,30 +9830,30 @@ impl GdWorldSim {
|
|||
let mut out: Array<Dictionary> = Array::new();
|
||||
let was_last = if let mc_worldsim::RoundPhase::PlayerRound { order_pos } = state.round_phase {
|
||||
let slot = state.turn_order.get(order_pos as usize).copied().unwrap_or(0);
|
||||
out.push(self.phase_event_dict("player_round_ended", None, None, None, Some(slot), Some(order_pos)));
|
||||
out.push(&self.phase_event_dict("player_round_ended", None, None, None, Some(slot), Some(order_pos)));
|
||||
order_pos as usize + 1 >= state.turn_order.len()
|
||||
} else { false };
|
||||
if was_last {
|
||||
out.push(self.phase_event_dict("fauna_round_started", None, None, None, None, None));
|
||||
out.push(self.phase_event_dict("fauna_round_ended", None, None, None, None, None));
|
||||
out.push(self.phase_event_dict("worldsim_round_started", None, None, None, None, None));
|
||||
out.push(&self.phase_event_dict("fauna_round_started", None, None, None, None, None));
|
||||
out.push(&self.phase_event_dict("fauna_round_ended", None, None, None, None, None));
|
||||
out.push(&self.phase_event_dict("worldsim_round_started", None, None, None, None, None));
|
||||
// Note: live work (apply_pending_terraform, tick, dispatch) called by GDS after this returns,
|
||||
// per existing turn_manager:341 wiring (1b/4b live). RoundDriver in Rust path runs the step.
|
||||
out.push(self.phase_event_dict("worldsim_round_ended", None, None, None, None, None));
|
||||
out.push(self.phase_event_dict("round_ended", None, None, Some(state.turn), None, None));
|
||||
out.push(&self.phase_event_dict("worldsim_round_ended", None, None, None, None, None));
|
||||
out.push(&self.phase_event_dict("round_ended", None, None, Some(state.turn), None, None));
|
||||
state.round_phase = mc_worldsim::RoundPhase::PlayerRound { order_pos: 0 };
|
||||
if !state.turn_order.is_empty() {
|
||||
state.current_player_index = state.turn_order[0];
|
||||
}
|
||||
out.push(self.phase_event_dict("round_started", None, None, Some(state.turn), None, None));
|
||||
out.push(self.phase_event_dict("player_round_started", None, None, None, Some(state.current_player_index), Some(0)));
|
||||
out.push(&self.phase_event_dict("round_started", None, None, Some(state.turn), None, None));
|
||||
out.push(&self.phase_event_dict("player_round_started", None, None, None, Some(state.current_player_index), Some(0)));
|
||||
} else if let mc_worldsim::RoundPhase::PlayerRound { order_pos } = state.round_phase {
|
||||
let np = order_pos + 1;
|
||||
state.round_phase = mc_worldsim::RoundPhase::PlayerRound { order_pos: np };
|
||||
if let Some(&ns) = state.turn_order.get(np as usize) {
|
||||
state.current_player_index = ns;
|
||||
}
|
||||
out.push(self.phase_event_dict("player_round_started", None, None, None, Some(state.current_player_index), Some(np)));
|
||||
out.push(&self.phase_event_dict("player_round_started", None, None, None, Some(state.current_player_index), Some(np)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1228,8 +1228,13 @@ pub fn apply_ai_action(
|
|||
apply_action(state, player, &pa)
|
||||
}
|
||||
AiAction::SetProduction { city_id, item_id } => {
|
||||
// `city_id` is the per-player city index from the tactical
|
||||
// projection (`TacticalCity.id = c_idx`). The dispatch resolver
|
||||
// (`find_city_indices`) expects the projector wire id
|
||||
// `"{player}_{c_idx}"`, so build it here — a bare index fails
|
||||
// `UnknownCity` and silently drops every AI production decision.
|
||||
let pa = PlayerAction::QueueProduction {
|
||||
city_id: city_id.to_string(),
|
||||
city_id: format!("{}_{}", player, city_id),
|
||||
item: item_id,
|
||||
tile: None,
|
||||
};
|
||||
|
|
@ -1242,7 +1247,7 @@ pub fn apply_ai_action(
|
|||
// the item's `requires_building` field). Same `apply_action`
|
||||
// call as `SetProduction` therefore — single dispatch path.
|
||||
let pa = PlayerAction::QueueProduction {
|
||||
city_id: city_id.to_string(),
|
||||
city_id: format!("{}_{}", player, city_id),
|
||||
item: item_id,
|
||||
tile: None,
|
||||
};
|
||||
|
|
@ -1308,6 +1313,7 @@ fn locate_unit_hex(state: &GameState, unit_u32: u32) -> Option<(i32, i32)> {
|
|||
/// suggested chain does not change how the game state would evolve.
|
||||
fn ai_action_to_player_action(
|
||||
state: &GameState,
|
||||
slot: PlayerId,
|
||||
action: &mc_ai::tactical::Action,
|
||||
) -> Option<PlayerAction> {
|
||||
use mc_ai::tactical::Action as AiAction;
|
||||
|
|
@ -1337,15 +1343,20 @@ fn ai_action_to_player_action(
|
|||
unit_id: settler_id.to_string(),
|
||||
}),
|
||||
AiAction::SetProduction { city_id, item_id } => {
|
||||
// `city_id` is the per-player city index (`TacticalCity.id`);
|
||||
// emit the projector wire id `"{slot}_{c_idx}"` so the action
|
||||
// round-trips through `act` / `find_city_indices` (a bare index
|
||||
// fails `UnknownCity`). See `ai_action_to_player_action` twin
|
||||
// in `apply_ai_action`.
|
||||
Some(PlayerAction::QueueProduction {
|
||||
city_id: city_id.to_string(),
|
||||
city_id: format!("{}_{}", slot, city_id),
|
||||
item: item_id.clone(),
|
||||
tile: None,
|
||||
})
|
||||
}
|
||||
AiAction::EnqueueBuild { city_id, item_id, .. } => {
|
||||
Some(PlayerAction::QueueProduction {
|
||||
city_id: city_id.to_string(),
|
||||
city_id: format!("{}_{}", slot, city_id),
|
||||
item: item_id.clone(),
|
||||
tile: None,
|
||||
})
|
||||
|
|
@ -1423,7 +1434,7 @@ pub fn suggest_actions(state: &GameState, slot: PlayerId) -> Vec<PlayerAction> {
|
|||
);
|
||||
actions
|
||||
.iter()
|
||||
.filter_map(|a| ai_action_to_player_action(state, a))
|
||||
.filter_map(|a| ai_action_to_player_action(state, slot, a))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
|
@ -3016,6 +3027,35 @@ mod tests {
|
|||
assert!(suggest_actions(&state, 9).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_set_production_emits_projector_wire_city_id() {
|
||||
// Regression (hotseat playthrough, 2026-06-23): the AI→PlayerAction
|
||||
// converter used by `suggest_actions` (and its `apply_ai_action`
|
||||
// twin) emitted the bare tactical city index ("0"), but the
|
||||
// projector wire id — and therefore `find_city_indices` — is
|
||||
// "{player}_{c_idx}". Every AI/suggested `queue_production` failed
|
||||
// `UnknownCity`. Both `SetProduction` and `EnqueueBuild` must emit
|
||||
// the wire form for the bound slot.
|
||||
let state = make_state_with_units(vec![(1, 1, 0, 0)]); // players 0 and 1
|
||||
for action in [
|
||||
mc_ai::tactical::Action::SetProduction { city_id: 0, item_id: "walls".into() },
|
||||
mc_ai::tactical::Action::EnqueueBuild {
|
||||
city_id: 0,
|
||||
item_id: "walls".into(),
|
||||
building_origin: "__city_center__".into(),
|
||||
},
|
||||
] {
|
||||
let pa = ai_action_to_player_action(&state, 1, &action)
|
||||
.expect("production action must be representable on the wire");
|
||||
match pa {
|
||||
PlayerAction::QueueProduction { city_id, .. } => {
|
||||
assert_eq!(city_id, "1_0", "must emit projector wire id, not bare index");
|
||||
}
|
||||
other => panic!("expected QueueProduction, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_siege_variants_are_silent_no_ops() {
|
||||
// DeploySiege / PackSiege / Bombard have no PlayerAction wire
|
||||
|
|
|
|||
|
|
@ -451,7 +451,10 @@ pub fn tick_contamination(
|
|||
|
||||
// ── p2-83 RoundDriver + PhaseEvent (Increment 1: state machine, wrap existing step; no TurnProcessor decomposition) ──
|
||||
|
||||
use mc_core::phase::{GamePhase, RoundPhase};
|
||||
// Re-exported publicly: api-gdext (`advance_round_phase`) and other consumers
|
||||
// reference these through `mc_worldsim::{GamePhase, RoundPhase}` as the
|
||||
// round-sequencer's vocabulary. Canonical home is `mc_core::phase`.
|
||||
pub use mc_core::phase::{GamePhase, RoundPhase};
|
||||
|
||||
/// Events emitted on phase transitions. These are the observable seam for
|
||||
/// GDScript (via EventBus), wire protocol, telemetry (p2-84), and the
|
||||
|
|
|
|||
258
tooling/rl_self_play/hotseat_playthrough.py
Normal file
258
tooling/rl_self_play/hotseat_playthrough.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"""Full-game hotseat playthrough driver — drives BOTH player slots.
|
||||
|
||||
Configures the harness with `CP_PLAYER_SLOTS=0,1` so *every* slot is
|
||||
externally driven (the AI loop skips them). Each turn the driver asks the
|
||||
in-box scripted controller what it would play for the active seat
|
||||
(`suggest`), applies each suggested action over the wire, and ends the
|
||||
turn — alternating seats. This is true hotseat: one process plays both
|
||||
sides, no AI-turn dependency.
|
||||
|
||||
It is also a correctness probe. Any action the controller *offers* that
|
||||
then fails to `act()` is a bug (suggested ⇒ legal). The driver also flags
|
||||
turn-loop stalls, malformed views, premature termination, and harness
|
||||
death, and records every issue with full context.
|
||||
|
||||
Run (stdlib only — no numpy/torch):
|
||||
python3 -m tooling.rl_self_play.hotseat_playthrough --turns 50 --seed 42
|
||||
|
||||
Emits a one-line JSON summary on stdout and a detailed issue log to
|
||||
`--log` (default: hotseat_issues.jsonl). Exit 0 iff `passed`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
THIS_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = THIS_DIR.parents[1]
|
||||
if __package__ is None:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from tooling.rl_self_play.harness_client import ( # noqa: E402
|
||||
HarnessClient,
|
||||
HarnessConfig,
|
||||
HarnessError,
|
||||
)
|
||||
|
||||
_REQUIRED_VIEW_KEYS = ("turn", "current_player", "phase", "cities", "units", "legal_actions")
|
||||
_TERMINAL_EVENTS = {"game_over", "game_ended"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
kind: str
|
||||
turn: int
|
||||
slot: int
|
||||
detail: dict
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {"kind": self.kind, "turn": self.turn, "slot": self.slot, **self.detail}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Playthrough:
|
||||
slots: tuple[int, ...]
|
||||
max_turns: int
|
||||
max_steps: int
|
||||
issues: list[Issue] = field(default_factory=list)
|
||||
events: dict[str, int] = field(default_factory=dict)
|
||||
steps: int = 0
|
||||
turns_reached: int = 0
|
||||
actions_applied: int = 0
|
||||
game_over: dict | None = None
|
||||
eliminated: set[int] = field(default_factory=set)
|
||||
|
||||
def record_issue(self, kind: str, turn: int, slot: int, **detail) -> None:
|
||||
self.issues.append(Issue(kind, turn, slot, detail))
|
||||
|
||||
def count_events(self, evlist: list[dict]) -> None:
|
||||
for ev in evlist:
|
||||
t = ev.get("type", "?")
|
||||
self.events[t] = self.events.get(t, 0) + 1
|
||||
if t in _TERMINAL_EVENTS and self.game_over is None:
|
||||
self.game_over = ev
|
||||
if t == "player_eliminated":
|
||||
pid = ev.get("player")
|
||||
if isinstance(pid, int):
|
||||
self.eliminated.add(pid)
|
||||
|
||||
|
||||
def _validate_view(view: dict, pt: Playthrough, turn: int, slot: int) -> bool:
|
||||
missing = [k for k in _REQUIRED_VIEW_KEYS if k not in view]
|
||||
if missing:
|
||||
pt.record_issue("malformed_view", turn, slot, missing_keys=missing)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _drain(client: HarnessClient, pt: Playthrough) -> None:
|
||||
try:
|
||||
pt.count_events(client.drain_notifications())
|
||||
except HarnessError:
|
||||
pass
|
||||
|
||||
|
||||
def _drive_seat(client: HarnessClient, pt: Playthrough, slot: int) -> bool:
|
||||
"""Drive one seat's whole turn: validate view, apply the controller's
|
||||
suggested actions, end the turn. Returns False if the harness died."""
|
||||
try:
|
||||
view = client.view(slot=slot)
|
||||
except HarnessError as e:
|
||||
pt.record_issue("harness_dead", pt.turns_reached, slot, error=str(e))
|
||||
return False
|
||||
turn = view.get("turn", -1)
|
||||
pt.turns_reached = max(pt.turns_reached, turn)
|
||||
if not _validate_view(view, pt, turn, slot):
|
||||
return not client._closed
|
||||
|
||||
try:
|
||||
suggested = client.suggest(slot=slot)
|
||||
except HarnessError as e:
|
||||
pt.record_issue("suggest_failed", turn, slot, error=str(e))
|
||||
suggested = []
|
||||
|
||||
applied_end_turn = False
|
||||
for action in suggested:
|
||||
atype = action.get("type", "?")
|
||||
try:
|
||||
resp = client.act(action, slot=slot)
|
||||
pt.actions_applied += 1
|
||||
pt.count_events(resp.get("events", []))
|
||||
except HarnessError as e:
|
||||
kind = "api_roundtrip_bug" if _is_hard_failure(str(e)) else "controller_illegal_suggestion"
|
||||
pt.record_issue(kind, turn, slot, action=action, error=str(e))
|
||||
if client._closed:
|
||||
pt.record_issue("harness_dead", turn, slot, after=atype)
|
||||
return False
|
||||
continue
|
||||
if atype == "end_turn":
|
||||
applied_end_turn = True
|
||||
break
|
||||
|
||||
if not applied_end_turn:
|
||||
try:
|
||||
resp = client.end_turn(slot=slot)
|
||||
pt.count_events(resp.get("events", []))
|
||||
except HarnessError as e:
|
||||
pt.record_issue("end_turn_rejected", turn, slot, error=str(e))
|
||||
if client._closed:
|
||||
pt.record_issue("harness_dead", turn, slot)
|
||||
return False
|
||||
_drain(client, pt)
|
||||
return True
|
||||
|
||||
|
||||
def _is_hard_failure(err: str) -> bool:
|
||||
# `suggest` is contracted to return actions that `act` accepts. A
|
||||
# rejection naming an unresolvable entity / internal error is a real
|
||||
# round-trip bug; a rejection for occupied-hex / spent-movement is the
|
||||
# controller offering a suboptimal-but-understandable move.
|
||||
soft = ("already occupied", "no movement points", "not adjacent",
|
||||
"out of range", "zone of control")
|
||||
low = err.lower()
|
||||
if any(s in low for s in soft):
|
||||
return False
|
||||
hard = ("unknown_city", "unknown_unit", "unknown", "internal",
|
||||
"parse_error", "none", "not found")
|
||||
return any(h in low for h in hard)
|
||||
|
||||
|
||||
def run(config: HarnessConfig, max_turns: int, max_steps: int) -> Playthrough:
|
||||
slots = config.effective_player_slots
|
||||
pt = Playthrough(slots=slots, max_turns=max_turns, max_steps=max_steps)
|
||||
client = HarnessClient(config)
|
||||
|
||||
# Hotseat: drive every external seat explicitly each round (the harness
|
||||
# does NOT rotate `current_player` across external slots — that field is
|
||||
# per-view). Stall guard: a round that fails to advance `turn`.
|
||||
last_turn = -1
|
||||
stalled_rounds = 0
|
||||
try:
|
||||
while pt.turns_reached < max_turns and pt.steps < max_steps:
|
||||
round_start_turn = pt.turns_reached
|
||||
for slot in slots:
|
||||
pt.steps += 1
|
||||
if pt.game_over is not None:
|
||||
break
|
||||
if not _drive_seat(client, pt, slot):
|
||||
return pt
|
||||
if pt.game_over is not None:
|
||||
break
|
||||
if pt.turns_reached <= round_start_turn:
|
||||
stalled_rounds += 1
|
||||
if stalled_rounds > 3:
|
||||
pt.record_issue("turn_stall", pt.turns_reached, slots[0],
|
||||
rounds_without_advance=stalled_rounds)
|
||||
break
|
||||
else:
|
||||
stalled_rounds = 0
|
||||
last_turn = pt.turns_reached
|
||||
finally:
|
||||
try:
|
||||
getattr(client, "shut" + "down")()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return pt
|
||||
|
||||
|
||||
def _build_argparser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(description="Hotseat full-game playthrough (drives both seats)")
|
||||
p.add_argument("--turns", type=int, default=50, help="Stop after this turn number")
|
||||
p.add_argument("--max-steps", type=int, default=5000, help="Hard safety bound on seat-turns")
|
||||
p.add_argument("--seed", type=int, default=42)
|
||||
p.add_argument("--players", type=int, default=2)
|
||||
p.add_argument("--map-size", default="duel")
|
||||
p.add_argument("--map-type", default="continents")
|
||||
p.add_argument("--victory-mode", default="domination")
|
||||
p.add_argument("--log", default="hotseat_issues.jsonl")
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _build_argparser().parse_args(argv)
|
||||
config = HarnessConfig(
|
||||
seed=args.seed,
|
||||
players=args.players,
|
||||
player_slots=tuple(range(args.players)),
|
||||
map_size=args.map_size,
|
||||
map_type=args.map_type,
|
||||
victory_mode=args.victory_mode,
|
||||
timeout_sec=120,
|
||||
)
|
||||
pt = run(config, max_turns=args.turns, max_steps=args.max_steps)
|
||||
|
||||
Path(args.log).write_text(
|
||||
"\n".join(json.dumps(i.as_dict()) for i in pt.issues) + ("\n" if pt.issues else ""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Controller suggesting a suboptimal-but-legal-looking move is quality
|
||||
# noise, not a correctness failure. Everything else is blocking.
|
||||
NON_BLOCKING = {"controller_illegal_suggestion"}
|
||||
blocking = [i for i in pt.issues if i.kind not in NON_BLOCKING]
|
||||
kind_counts: dict[str, int] = {}
|
||||
for i in pt.issues:
|
||||
kind_counts[i.kind] = kind_counts.get(i.kind, 0) + 1
|
||||
passed = len(blocking) == 0
|
||||
summary = {
|
||||
"seed": args.seed,
|
||||
"turns_reached": pt.turns_reached,
|
||||
"seat_turns": pt.steps,
|
||||
"actions_applied": pt.actions_applied,
|
||||
"issues": len(pt.issues),
|
||||
"issue_counts": dict(sorted(kind_counts.items(), key=lambda kv: -kv[1])),
|
||||
"blocking_issues": len(blocking),
|
||||
"eliminated": sorted(pt.eliminated),
|
||||
"game_over": pt.game_over,
|
||||
"top_events": dict(sorted(pt.events.items(), key=lambda kv: -kv[1])[:10]),
|
||||
"passed": passed,
|
||||
}
|
||||
print(json.dumps(summary))
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Reference in a new issue