405 lines
13 KiB
Rust
405 lines
13 KiB
Rust
|
|
//! The playable loop: render one seat's projection, offer it the legal
|
||
|
|
//! commands, read one, apply it (CB-WP-0008 T02).
|
||
|
|
//!
|
||
|
|
//! Everything a human sees comes from `GroundState::project` — K13's
|
||
|
|
//! projection, whose first consumer this is. The process itself holds
|
||
|
|
//! full state, because it is the referee: legality is decided by
|
||
|
|
//! `validate`, and hidden information is withheld at the *rendering*
|
||
|
|
//! boundary, which is where it leaks.
|
||
|
|
//!
|
||
|
|
//! **Stated non-goal:** no TUI, no colour, no readline. Stage 1 is the
|
||
|
|
//! inspectable 2D table; this is the smallest thing that makes the rules
|
||
|
|
//! playable. Dressing it up now would be inventing a UI before a player
|
||
|
|
//! has used one.
|
||
|
|
|
||
|
|
use cb_game_runtime::{Project, Setup, Viewer};
|
||
|
|
use cb_kernel::{Actor, PlayerId};
|
||
|
|
use games_ground::bot::{BotError, Choice, GreedyPolicy, Policy, RandomPolicy};
|
||
|
|
use games_ground::record::to_step;
|
||
|
|
use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView};
|
||
|
|
use games_ground::{GroundCommand, GroundState};
|
||
|
|
use std::io::{BufRead, Write};
|
||
|
|
|
||
|
|
pub struct Config {
|
||
|
|
pub seed: u64,
|
||
|
|
pub players: u8,
|
||
|
|
/// Seats a human plays, 0-based (`PlayerId(0)` is P1).
|
||
|
|
pub human_seats: Vec<u8>,
|
||
|
|
pub bot: String,
|
||
|
|
/// Where to write a `.cbreplay` bundle of the finished game.
|
||
|
|
pub replay_dir: Option<std::path::PathBuf>,
|
||
|
|
/// Where to write the finished game as a scenario file. A session
|
||
|
|
/// somebody played becomes a regression test.
|
||
|
|
pub record: Option<std::path::PathBuf>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Default for Config {
|
||
|
|
fn default() -> Self {
|
||
|
|
Self {
|
||
|
|
seed: 1,
|
||
|
|
players: 3,
|
||
|
|
human_seats: vec![0],
|
||
|
|
bot: "greedy".into(),
|
||
|
|
replay_dir: None,
|
||
|
|
record: None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// What a finished game reports back.
|
||
|
|
/// The end-state hash is what makes a session comparable to its replay.
|
||
|
|
#[derive(Debug)]
|
||
|
|
pub struct Summary {
|
||
|
|
pub rounds: u8,
|
||
|
|
pub end_state_hash: String,
|
||
|
|
pub scenario: cb_game_runtime::ScenarioFile,
|
||
|
|
pub bundle: Option<std::path::PathBuf>,
|
||
|
|
pub recorded: Option<std::path::PathBuf>,
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------- rendering
|
||
|
|
|
||
|
|
fn suit_name(s: games_ground::Suit) -> &'static str {
|
||
|
|
match s {
|
||
|
|
games_ground::Suit::Clarify => "Clarify",
|
||
|
|
games_ground::Suit::Repair => "Repair",
|
||
|
|
games_ground::Suit::Boundary => "Boundary",
|
||
|
|
games_ground::Suit::Change => "Change",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn seat_name(p: PlayerId) -> String {
|
||
|
|
format!("P{}", p.0 + 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn describe(command: &GroundCommand) -> String {
|
||
|
|
// Reuse the recorder's vocabulary rather than inventing a third one:
|
||
|
|
// what the player reads is what the scenario file will say.
|
||
|
|
let step = to_step(Actor::System, command);
|
||
|
|
let mut out = step.cmd.clone();
|
||
|
|
for (key, value) in &step.args {
|
||
|
|
let rendered = match value {
|
||
|
|
serde_yaml::Value::String(s) => s.clone(),
|
||
|
|
other => serde_yaml::to_string(other)
|
||
|
|
.unwrap_or_default()
|
||
|
|
.trim()
|
||
|
|
.to_string(),
|
||
|
|
};
|
||
|
|
out.push_str(&format!(" {key}={rendered}"));
|
||
|
|
}
|
||
|
|
out
|
||
|
|
}
|
||
|
|
|
||
|
|
fn render_player(id: PlayerId, p: &PlayerView, is_viewer: bool) -> String {
|
||
|
|
let hand = match &p.hand {
|
||
|
|
Some(cards) => {
|
||
|
|
let names: Vec<&str> = cards.iter().map(|c| suit_name(c.suit)).collect();
|
||
|
|
format!("hand [{}]", names.join(", "))
|
||
|
|
}
|
||
|
|
None => format!("hand {} card(s)", p.hand_size),
|
||
|
|
};
|
||
|
|
format!(
|
||
|
|
" {}{} stress {} freedom {} darvo {:?} blame {} {hand}",
|
||
|
|
seat_name(id),
|
||
|
|
if is_viewer { " (you)" } else { " " },
|
||
|
|
p.stress,
|
||
|
|
if p.freedom_ready { "READY" } else { "SPENT" },
|
||
|
|
p.darvo,
|
||
|
|
p.blame_from.len(),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// One seat's whole picture, from the projection and nothing else.
|
||
|
|
pub fn render(view: &GroundView) -> String {
|
||
|
|
let mut out = String::new();
|
||
|
|
out.push_str(&format!(
|
||
|
|
"\nround {} step {:?} lead {} deck {}\n",
|
||
|
|
view.round,
|
||
|
|
view.step,
|
||
|
|
seat_name(view.lead),
|
||
|
|
view.solution_deck_len,
|
||
|
|
));
|
||
|
|
for (id, p) in &view.players {
|
||
|
|
out.push_str(&render_player(*id, p, Some(*id) == view.viewer));
|
||
|
|
out.push('\n');
|
||
|
|
}
|
||
|
|
out.push_str(" problems:");
|
||
|
|
for (priority, problem) in &view.problems {
|
||
|
|
match problem {
|
||
|
|
ProblemView::FaceDown => out.push_str(&format!(" [{priority}] face-down")),
|
||
|
|
ProblemView::FaceUp {
|
||
|
|
suit,
|
||
|
|
value,
|
||
|
|
denied,
|
||
|
|
claimed_by,
|
||
|
|
..
|
||
|
|
} => {
|
||
|
|
out.push_str(&format!(
|
||
|
|
" [{priority}] {} {}{}{}",
|
||
|
|
suit_name(*suit),
|
||
|
|
value,
|
||
|
|
if *denied { " DENIED" } else { "" },
|
||
|
|
match claimed_by {
|
||
|
|
Some(p) => format!(" claimed by {}", seat_name(*p)),
|
||
|
|
None => String::new(),
|
||
|
|
}
|
||
|
|
));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out.push('\n');
|
||
|
|
if !view.relations.is_empty() {
|
||
|
|
out.push_str(" relations:");
|
||
|
|
for (pair, relation) in &view.relations {
|
||
|
|
out.push_str(&format!(" {pair} {relation:?}"));
|
||
|
|
}
|
||
|
|
out.push('\n');
|
||
|
|
}
|
||
|
|
if !view.selections.is_empty() {
|
||
|
|
out.push_str(" selections:");
|
||
|
|
for (id, sel) in &view.selections {
|
||
|
|
match sel {
|
||
|
|
SelectionView::Hidden => out.push_str(&format!(" {} face-down", seat_name(*id))),
|
||
|
|
SelectionView::Shown(s) => out.push_str(&format!(
|
||
|
|
" {} {:?}{}{}",
|
||
|
|
seat_name(*id),
|
||
|
|
s.action,
|
||
|
|
match s.target {
|
||
|
|
Some(t) => format!("→{}", seat_name(t)),
|
||
|
|
None => String::new(),
|
||
|
|
},
|
||
|
|
match s.problem {
|
||
|
|
Some(p) => format!("→[{p}]"),
|
||
|
|
None => String::new(),
|
||
|
|
}
|
||
|
|
)),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out.push('\n');
|
||
|
|
}
|
||
|
|
if let Some(o) = &view.outcome {
|
||
|
|
out.push_str(&format!(
|
||
|
|
" OUTCOME total {} / threshold {} group {} winners {:?}\n",
|
||
|
|
o.total,
|
||
|
|
o.threshold,
|
||
|
|
if o.group_success {
|
||
|
|
"SUCCESS"
|
||
|
|
} else {
|
||
|
|
"failure"
|
||
|
|
},
|
||
|
|
o.winners.iter().map(|w| seat_name(*w)).collect::<Vec<_>>(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
out
|
||
|
|
}
|
||
|
|
|
||
|
|
// --------------------------------------------------------------- policies
|
||
|
|
|
||
|
|
/// A seat driven from stdin. Implements the same `Policy` the bots do, so
|
||
|
|
/// a human and a bot are interchangeable and the driver stays one loop.
|
||
|
|
pub struct HumanPolicy<'a, R: BufRead, W: Write> {
|
||
|
|
input: &'a std::cell::RefCell<R>,
|
||
|
|
out: &'a std::cell::RefCell<W>,
|
||
|
|
/// `Policy::choose` cannot fail, so an unreadable input is parked
|
||
|
|
/// here and turned into a loud error by [`play`]. Returning some
|
||
|
|
/// default move instead would let a broken session play itself.
|
||
|
|
///
|
||
|
|
/// A shared slot rather than a trait method: widening `Policy` so one
|
||
|
|
/// implementor can fail would push a CLI concern into every bot.
|
||
|
|
failure: Failure,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Where a human seat parks the reason it could not answer.
|
||
|
|
pub type Failure = std::rc::Rc<std::cell::RefCell<Option<String>>>;
|
||
|
|
|
||
|
|
impl<'a, R: BufRead, W: Write> HumanPolicy<'a, R, W> {
|
||
|
|
pub fn new(
|
||
|
|
input: &'a std::cell::RefCell<R>,
|
||
|
|
out: &'a std::cell::RefCell<W>,
|
||
|
|
failure: Failure,
|
||
|
|
) -> Self {
|
||
|
|
Self {
|
||
|
|
input,
|
||
|
|
out,
|
||
|
|
failure,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl<R: BufRead, W: Write> Policy for HumanPolicy<'_, R, W> {
|
||
|
|
fn name(&self) -> &'static str {
|
||
|
|
"human"
|
||
|
|
}
|
||
|
|
|
||
|
|
fn choose(
|
||
|
|
&mut self,
|
||
|
|
state: &GroundState,
|
||
|
|
seat: PlayerId,
|
||
|
|
legal: &[GroundCommand],
|
||
|
|
may_pass: bool,
|
||
|
|
) -> Choice {
|
||
|
|
let mut w = self.out.borrow_mut();
|
||
|
|
let _ = write!(w, "{}", render(&state.project(Viewer::Player(seat))));
|
||
|
|
let _ = writeln!(w, " you are {}", seat_name(seat));
|
||
|
|
for (i, cmd) in legal.iter().enumerate() {
|
||
|
|
let _ = writeln!(w, " [{i}] {}", describe(cmd));
|
||
|
|
}
|
||
|
|
let _ = writeln!(
|
||
|
|
w,
|
||
|
|
" choose a number{}:",
|
||
|
|
if may_pass { " or `pass`" } else { "" }
|
||
|
|
);
|
||
|
|
let _ = w.flush();
|
||
|
|
drop(w);
|
||
|
|
|
||
|
|
let mut line = String::new();
|
||
|
|
loop {
|
||
|
|
line.clear();
|
||
|
|
match self.input.borrow_mut().read_line(&mut line) {
|
||
|
|
Ok(0) => {
|
||
|
|
*self.failure.borrow_mut() =
|
||
|
|
Some(format!("input ended while {} had to act", seat_name(seat)));
|
||
|
|
// Out of range on purpose: the driver reports it.
|
||
|
|
return Choice::Command(usize::MAX);
|
||
|
|
}
|
||
|
|
Err(e) => {
|
||
|
|
*self.failure.borrow_mut() = Some(format!("read error: {e}"));
|
||
|
|
return Choice::Command(usize::MAX);
|
||
|
|
}
|
||
|
|
Ok(_) => {}
|
||
|
|
}
|
||
|
|
let word = line.trim();
|
||
|
|
if word.is_empty() {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if word.eq_ignore_ascii_case("pass") {
|
||
|
|
return Choice::Pass;
|
||
|
|
}
|
||
|
|
match word.parse::<usize>() {
|
||
|
|
Ok(i) => return Choice::Command(i),
|
||
|
|
Err(_) => {
|
||
|
|
let mut w = self.out.borrow_mut();
|
||
|
|
let _ = writeln!(w, " not a number: {word:?}");
|
||
|
|
let _ = w.flush();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn bot_policy<'a>(kind: &str, seed: u64) -> Result<Box<dyn Policy + 'a>, String> {
|
||
|
|
match kind {
|
||
|
|
"greedy" => Ok(Box::new(GreedyPolicy)),
|
||
|
|
"random" => Ok(Box::new(RandomPolicy::new(seed))),
|
||
|
|
other => Err(format!("unknown bot policy {other:?} (greedy, random)")),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ----------------------------------------------------------------- driver
|
||
|
|
|
||
|
|
/// Play one game. The human seats read from `input`; the rest are bots.
|
||
|
|
pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<Summary, String> {
|
||
|
|
// The shared reader and writer must outlive the policy objects that
|
||
|
|
// borrow them, so ownership stays here and the game runs one frame in.
|
||
|
|
let input = std::cell::RefCell::new(input);
|
||
|
|
let out = std::cell::RefCell::new(out);
|
||
|
|
run_game(config, &input, &out)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
||
|
|
config: &Config,
|
||
|
|
input: &'a std::cell::RefCell<R>,
|
||
|
|
out: &'a std::cell::RefCell<W>,
|
||
|
|
) -> Result<Summary, String> {
|
||
|
|
let setup = Setup {
|
||
|
|
players: config.players,
|
||
|
|
preset: format!("standard-{}p", config.players),
|
||
|
|
patch: Default::default(),
|
||
|
|
};
|
||
|
|
let initial = <GroundState as cb_game_runtime::ScenarioGame>::setup(&setup, config.seed)?;
|
||
|
|
let initial_json = serde_json::to_value(&initial).map_err(|e| e.to_string())?;
|
||
|
|
let initial_hash = cb_events::state_hash_hex(&initial);
|
||
|
|
|
||
|
|
// Human seats and bot seats fill one policy vector; the driver does
|
||
|
|
// not know which is which, which is the point.
|
||
|
|
let failure: Failure = Default::default();
|
||
|
|
let mut policies: Vec<Box<dyn Policy + 'a>> = Vec::new();
|
||
|
|
for seat in 0..config.players {
|
||
|
|
if config.human_seats.contains(&seat) {
|
||
|
|
policies.push(Box::new(HumanPolicy::new(input, out, failure.clone())));
|
||
|
|
} else {
|
||
|
|
policies.push(bot_policy(&config.bot, config.seed + u64::from(seat))?);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let result = games_ground::bot::play(initial, &mut policies);
|
||
|
|
|
||
|
|
// A human seat that ran out of input reports *that*, not the
|
||
|
|
// out-of-range index it had to return to get here.
|
||
|
|
let human_failure = failure.borrow().clone();
|
||
|
|
let game = match (result, human_failure) {
|
||
|
|
(_, Some(msg)) => return Err(msg),
|
||
|
|
(Err(BotError::IllegalChoice { seat, offered, .. }), None) => {
|
||
|
|
return Err(format!(
|
||
|
|
"{} chose a command outside the {offered} offered",
|
||
|
|
seat_name(seat)
|
||
|
|
))
|
||
|
|
}
|
||
|
|
(Err(e), None) => return Err(e.to_string()),
|
||
|
|
(Ok(game), None) => game,
|
||
|
|
};
|
||
|
|
|
||
|
|
let end_hash = cb_events::state_hash_hex(&game.state);
|
||
|
|
let mut w = out.borrow_mut();
|
||
|
|
let _ = write!(w, "{}", render(&game.state.project(Viewer::Spectator)));
|
||
|
|
let _ = writeln!(
|
||
|
|
w,
|
||
|
|
" game over — {} commands, hash {}",
|
||
|
|
game.commands,
|
||
|
|
&end_hash[..12]
|
||
|
|
);
|
||
|
|
let _ = w.flush();
|
||
|
|
drop(w);
|
||
|
|
|
||
|
|
let scenario = games_ground::record::to_scenario(
|
||
|
|
"ground/cb-play-session",
|
||
|
|
config.seed,
|
||
|
|
config.players,
|
||
|
|
&game.steps,
|
||
|
|
Some(end_hash.clone()),
|
||
|
|
);
|
||
|
|
|
||
|
|
let bundle = match &config.replay_dir {
|
||
|
|
None => None,
|
||
|
|
Some(dir) => {
|
||
|
|
let end_json = serde_json::to_value(&game.state).map_err(|e| e.to_string())?;
|
||
|
|
Some(cb_game_runtime::replay::write_bundle(
|
||
|
|
dir,
|
||
|
|
&scenario,
|
||
|
|
&initial_json,
|
||
|
|
&initial_hash,
|
||
|
|
&end_json,
|
||
|
|
&end_hash,
|
||
|
|
"recorded by cb-play",
|
||
|
|
)?)
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
let recorded = match &config.record {
|
||
|
|
None => None,
|
||
|
|
Some(path) => {
|
||
|
|
let yaml = serde_yaml::to_string(&scenario).map_err(|e| e.to_string())?;
|
||
|
|
std::fs::write(path, yaml).map_err(|e| format!("write {}: {e}", path.display()))?;
|
||
|
|
Some(path.clone())
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
Ok(Summary {
|
||
|
|
rounds: game.rounds,
|
||
|
|
end_state_hash: end_hash,
|
||
|
|
scenario,
|
||
|
|
bundle,
|
||
|
|
recorded,
|
||
|
|
})
|
||
|
|
}
|