#![allow(non_snake_case)]
#![cfg(feature = "history")]
use gfcore::bot::BotProfile;
use gfcore::history::{GameCollection, GameRecord};
use gfcore::prelude::{Game, GamePhase, GameVariant, Player, PlayerAction};
const NUM_GAMES: usize = 100;
const PROGRESS_INTERVAL: usize = 10;
const MAX_STEPS_PER_GAME: usize = 13_000;
const MAX_BOOKS_STANDARD: usize = 13;
fn dump_and_panic(game_num: usize, context: &str, msg: String, collection: &GameCollection) -> ! {
let yaml = collection
.to_yaml()
.unwrap_or_else(|e| format!("(YAML serialization also failed: {e})"));
let path =
std::env::var("MARATHON_DUMP_PATH").unwrap_or_else(|_| "marathon_failure.yaml".to_string());
let _ = std::fs::write(&path, &yaml);
panic!(
"bot_marathon FAILED at game {game_num} [{context}]: {msg}\n\
(YAML written to {path} — download the CI artifact if the log is truncated)"
);
}
fn play_one_game(
game_num: usize,
profiles: &[BotProfile],
collection: &GameCollection,
) -> GameRecord {
let players: Vec<Player> = profiles
.iter()
.map(|p| Player::new(p.name.clone()))
.collect();
let mut game = match Game::new(GameVariant::Standard, players) {
Ok(g) => g,
Err(e) => dump_and_panic(game_num, "Game::new", e.to_string(), collection),
};
for _ in 0..MAX_STEPS_PER_GAME {
if game.is_over() {
break;
}
let state = match game.state() {
Ok(s) => s,
Err(e) => dump_and_panic(game_num, "state", e.to_string(), collection),
};
let action = match state.phase {
GamePhase::WaitingForAsk | GamePhase::BookCompleted => {
let cp = state.current_player;
let hand = state
.players
.iter()
.find(|v| v.index == cp)
.and_then(|v| v.hand.as_ref())
.cloned()
.unwrap_or_default();
profiles[cp % profiles.len()].decide(&hand, &state.players, &state.ask_log)
}
GamePhase::WaitingForDraw => PlayerAction::Draw,
GamePhase::GameOver => break,
};
if let Err(e) = game.act(action) {
dump_and_panic(game_num, "act", e.to_string(), collection);
}
}
if !game.is_over() {
dump_and_panic(
game_num,
"termination",
format!("game did not terminate within {MAX_STEPS_PER_GAME} steps"),
collection,
);
}
game.record()
}
fn validate_last_game(game_num: usize, collection: &GameCollection) {
let record = match collection.iter().last() {
Some(r) => r,
None => dump_and_panic(
game_num,
"validate",
"collection is empty".to_string(),
collection,
),
};
let total_books: usize = record
.turns
.last()
.map(|t| t.books_after_turn.iter().sum())
.unwrap_or(0);
if total_books > MAX_BOOKS_STANDARD {
dump_and_panic(
game_num,
"book_conservation",
format!("total books {total_books} exceeds maximum of {MAX_BOOKS_STANDARD}"),
collection,
);
}
if let Some(winner) = record.winner {
if winner >= record.players.len() {
dump_and_panic(
game_num,
"winner_range",
format!(
"winner index {winner} out of range (players: {})",
record.players.len()
),
collection,
);
}
}
let audit = record.audit();
if !audit.is_consistent {
dump_and_panic(
game_num,
"audit",
format!("audit violations: {:?}", audit.violations),
collection,
);
}
let yaml = record
.to_yaml()
.unwrap_or_else(|e| dump_and_panic(game_num, "to_yaml", e.to_string(), collection));
let parsed = GameRecord::from_yaml(&yaml)
.unwrap_or_else(|e| dump_and_panic(game_num, "from_yaml", e.to_string(), collection));
if record != &parsed {
dump_and_panic(
game_num,
"round_trip",
"YAML round-trip produced unequal record".to_string(),
collection,
);
}
}
#[test]
#[ignore = "marathon: 100 games with 4 bot profiles; use --include-ignored"]
fn bot_marathon__100_games_without_error() {
let profiles = BotProfile::default_profiles();
let mut collection = GameCollection::new();
for game_num in 1..=NUM_GAMES {
let record = play_one_game(game_num, &profiles, &collection);
collection.push(record);
validate_last_game(game_num, &collection);
if game_num % PROGRESS_INTERVAL == 0 {
println!("bot_marathon: {game_num}/{NUM_GAMES} games complete");
}
}
println!("bot_marathon: complete — {NUM_GAMES} games played without error");
}