clay-borg/tools/cb-play/src/table.rs

405 lines
13 KiB
Rust
Raw Normal View History

//! 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,
})
}