Some checks failed
ci / check (push) Failing after 3s
make trials reported "positions unreachable: 4, target 0 — the recording exists but the position moved". All four were false. A recording holds one hash, the final state, and reachability asked whether the note's hash was in that file — so a mid-game note could never match, and a post-game note from any but the last game could not either. Instance 8 of the ADR-0018 family: vary only WHEN a note was written and the answer flips, with nothing having moved. The root cause was not the metric. play again reused state belonging to a game: it overwrote the previous game's recording (data loss), never cleared the journal (game 2's log opened with game 1's commands), and so a note's command index pointed into a recording without those commands. Fixing reachability alone would have gone green while a session still destroyed its own evidence. A note now binds by (game, after) — an index into the recording's own commands list, which a reader can replay to. The hash keeps a job as the integrity check at the end of a game, where it can actually fail. Game 1 keeps the path it was given, so GameDesign §5's documented invocation is unchanged; later games get -2, -3 and nothing is overwritten. Legacy 5-column logs stay readable and are reported as legacy, never as orphans — an unsubstantiated orphan claim is the defect being fixed. All three fixes mutation-proven, including at the call site via a real two-game session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1788 lines
74 KiB
Rust
1788 lines
74 KiB
Rust
//! Hot-seat play in a browser (ADR-0007, INTENT stage 1).
|
|
//!
|
|
//! One loopback listener, one tab, seats taking turns. The server blocks
|
|
//! inside [`Policy::choose`] until the page reports a pointer fact that
|
|
//! resolves to a legal command — so the game loop is the same
|
|
//! `games_ground::bot::play` loop the CLI and the bots run through, and a
|
|
//! browser seat is indistinguishable from a CLI seat to the driver.
|
|
//!
|
|
//! Every control in ADR-0007 §Decision 5 lives in `cb-render-html`; this
|
|
//! module is the socket and the turn-taking. It deliberately holds no
|
|
//! game logic beyond "which seat is being asked" — [`cb_render_html`]
|
|
//! decides what a drag means, and the aggregate decides whether the
|
|
//! result is legal.
|
|
|
|
use std::cell::RefCell;
|
|
use std::fmt::Write as _;
|
|
use std::io::{Read, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::rc::Rc;
|
|
|
|
use cb_game_runtime::{Project, Viewer};
|
|
use cb_kernel::PlayerId;
|
|
use cb_render_html::{resolve, Guard, Note, PointerFact, Request};
|
|
use games_ground::bot::{Choice, Policy};
|
|
use games_ground::{GroundCommand, GroundState};
|
|
|
|
/// The shared listener. One per process; every human seat borrows it.
|
|
pub struct Server {
|
|
listener: TcpListener,
|
|
guard: Guard,
|
|
log: RefCell<Vec<String>>,
|
|
/// The live account of the game, shared with the driver (CB-WP-0018
|
|
/// T02). Read at render time so the page shows what has happened.
|
|
journal: games_ground::bot::Journal,
|
|
/// What the meta panel shows about the session (CB-WP-0027 T02).
|
|
/// Written by the driver between games, read at render time.
|
|
meta: RefCell<Vec<String>>,
|
|
/// Where the trial log is written, if this session is a trial.
|
|
trial: Option<std::path::PathBuf>,
|
|
/// Notes so far, so the log is rewritten whole rather than appended
|
|
/// to — a partial write leaves a file that neither a human nor
|
|
/// `make trials` can read.
|
|
notes: RefCell<Vec<TrialNote>>,
|
|
/// Which game of the session is being played, 1-based (ADR-0019 D1).
|
|
/// The journal and the recording are per game; the notes and the
|
|
/// tally are per session, and conflating the two is what made a
|
|
/// second game overwrite the first one's evidence.
|
|
game: std::cell::Cell<usize>,
|
|
}
|
|
|
|
/// One thing a player said, and where they said it (ADR-0014 D3).
|
|
#[derive(Debug, Clone)]
|
|
pub struct TrialNote {
|
|
pub n: usize,
|
|
/// Which game of the session, 1-based (ADR-0019 D3).
|
|
pub game: usize,
|
|
/// Commands played when this was written (CB-WP-0032), so the page
|
|
/// can put the comment where it was made — and, since ADR-0019 D3,
|
|
/// so a reader can reach the position: it is an index into the
|
|
/// recording's own `commands:` list.
|
|
///
|
|
/// **CB-WP-0032 kept this out of the file** because `trials.py`
|
|
/// silently skipped any row that was not five cells, so a new column
|
|
/// would have emptied every log at once. That parser now raises and
|
|
/// accepts both widths, which is what made this column safe to add.
|
|
pub after: usize,
|
|
pub round: u8,
|
|
pub step: String,
|
|
pub state_hash: String,
|
|
pub text: String,
|
|
}
|
|
|
|
/// Where game `n`'s recording goes (ADR-0019 D2).
|
|
///
|
|
/// **Game 1 keeps the path it was given.** GameDesign §5 documents that
|
|
/// exact invocation as the trial protocol, and a scheme that renamed
|
|
/// every file would have made the spec wrong for the common case on the
|
|
/// day it landed. Games 2, 3 … get `-2`, `-3` before the extension.
|
|
///
|
|
/// Nothing is overwritten: a recording is evidence, and `play again`
|
|
/// deleting the previous game's evidence is a data-loss defect on its own.
|
|
pub fn game_path(base: &std::path::Path, n: usize) -> std::path::PathBuf {
|
|
if n <= 1 {
|
|
return base.to_path_buf();
|
|
}
|
|
let stem = base
|
|
.file_stem()
|
|
.map(|s| s.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
let mut name = format!("{stem}-{n}");
|
|
if let Some(ext) = base.extension() {
|
|
name.push('.');
|
|
name.push_str(&ext.to_string_lossy());
|
|
}
|
|
base.with_file_name(name)
|
|
}
|
|
|
|
/// Write the trial log: readable Markdown with one machine-readable block.
|
|
///
|
|
/// **Reusing `FindingRegister.md`'s idiom deliberately** (ADR-0014 D2) —
|
|
/// a table between HTML-comment markers, so a human reads the file and a
|
|
/// tool reads the block, and neither needs the other's cooperation.
|
|
pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<(), String> {
|
|
let mut s = String::new();
|
|
s.push_str("# Trial log\n\n");
|
|
s.push_str(
|
|
"What the player said while playing, bound to the position they said it at\n\
|
|
(CB-WP-0027, ADR-0014). The recording beside this file is the position;\n\
|
|
`state_hash` is what reaches it.\n\n\
|
|
**These are raw notes and they stay here.** Nothing in this file travels to\n\
|
|
`ground-game` (ADR-0014 D4) — a note reaches them only by being promoted to a\n\
|
|
register finding, by a human, with the wording chosen then.\n\n",
|
|
);
|
|
s.push_str("<!-- trial-log:begin -->\n\n");
|
|
// ADR-0019 D3/D4: `game` and `after` are the binding a reader can act
|
|
// on -- replay `after` commands of game `game` and you are standing
|
|
// where the player stood. `state_hash` stays as an integrity check at
|
|
// the end of a game, where it is the one thing the recording carries.
|
|
s.push_str(
|
|
"| n | game | after | round | step | state_hash | comment |\n\
|
|
|---|---|---|---|---|---|---|\n",
|
|
);
|
|
for note in notes {
|
|
// Pipes and newlines would break the table, so they are replaced
|
|
// rather than escaped: the note is prose, and a reader losing a
|
|
// literal `|` matters less than a log nothing can parse.
|
|
let text = note.text.replace(['\n', '\r'], " ").replace('|', "/");
|
|
let _ = writeln!(
|
|
s,
|
|
"| {} | {} | {} | {} | {} | {} | {} |",
|
|
note.n, note.game, note.after, note.round, note.step, note.state_hash, text
|
|
);
|
|
}
|
|
s.push_str("\n<!-- trial-log:end -->\n");
|
|
if let Some(dir) = path.parent() {
|
|
std::fs::create_dir_all(dir).map_err(|e| format!("trial dir: {e}"))?;
|
|
}
|
|
std::fs::write(path, s).map_err(|e| format!("write trial log: {e}"))
|
|
}
|
|
|
|
impl Server {
|
|
/// Where this session's trial log goes. Without one, the note
|
|
/// channel refuses rather than dropping what the player wrote.
|
|
pub fn with_trial(mut self, path: Option<std::path::PathBuf>) -> Self {
|
|
self.trial = path;
|
|
self
|
|
}
|
|
|
|
pub fn bind(port: u16) -> Result<Self, String> {
|
|
let listener = cb_render_html::serve::bind(port).map_err(|e| format!("bind: {e}"))?;
|
|
let port = listener
|
|
.local_addr()
|
|
.map_err(|e| format!("local_addr: {e}"))?
|
|
.port();
|
|
Ok(Self {
|
|
listener,
|
|
guard: Guard::mint(port),
|
|
log: RefCell::new(Vec::new()),
|
|
journal: games_ground::bot::Journal::default(),
|
|
meta: RefCell::new(Vec::new()),
|
|
trial: None,
|
|
notes: RefCell::new(Vec::new()),
|
|
game: std::cell::Cell::new(1),
|
|
})
|
|
}
|
|
|
|
/// The URL to open, token and all. Printed once at start; the page
|
|
/// cannot mint one for itself.
|
|
pub fn url(&self) -> String {
|
|
self.guard.page_url()
|
|
}
|
|
|
|
/// Append a note to the trial log, bound to the position it was
|
|
/// written at (ADR-0014 D2/D3).
|
|
///
|
|
/// **The state hash is the binding.** Round and step orient a reader;
|
|
/// the hash is what lets one *reach* the position, because it is the
|
|
/// same value that makes a session comparable to its replay.
|
|
/// `step` is passed in rather than read off the state (CB-WP-0031),
|
|
/// because a note written after the game has ended is not a note taken
|
|
/// at the final decision point and must not be logged as one. The
|
|
/// position it binds to is genuinely the final state — `round` and
|
|
/// `state_hash` are that state's — but *when the player said it* is a
|
|
/// different fact, and conflating them would file an after-the-fact
|
|
/// reading as an in-play observation.
|
|
///
|
|
/// `"after the end"` cannot collide with a real step: `RoundStep`'s
|
|
/// `Debug` values are bare identifiers (`Select`, `Reveal`, `Resolve`,
|
|
/// `End`), and **`End` is the last step of a round, not the end of the
|
|
/// game** — which is exactly the confusion this label avoids.
|
|
fn record_note(&self, state: &GroundState, step: &str, note: &Note) -> Result<(), String> {
|
|
let Some(path) = self.trial.as_ref() else {
|
|
// No trial log configured: refuse rather than drop. A note
|
|
// that vanishes is worse than a note that was never offered,
|
|
// and this is the failure mode the whole pass exists to avoid.
|
|
return Err("no trial log for this session — start with --trial".into());
|
|
};
|
|
let hash = cb_events::state_hash_hex(state);
|
|
let after = self.journal.borrow().len();
|
|
let game = self.game.get();
|
|
let mut log = self.notes.borrow_mut();
|
|
let n = log.len() + 1;
|
|
log.push(TrialNote {
|
|
n,
|
|
game,
|
|
after,
|
|
round: state.round,
|
|
step: step.to_string(),
|
|
state_hash: hash[..12].to_string(),
|
|
text: note.text.clone(),
|
|
});
|
|
write_trial_log(path, &log)
|
|
}
|
|
|
|
/// Begin the next game (ADR-0019 D1).
|
|
///
|
|
/// **Clearing the journal is the point.** It is shared with the
|
|
/// driver and nothing ever emptied it, so a second game's log opened
|
|
/// with the first game's commands still in it — and a note's `after`,
|
|
/// counted against that journal, indexed into a recording that did
|
|
/// not contain those commands.
|
|
///
|
|
/// The notes are NOT cleared: the trial log is the session's record
|
|
/// (ADR-0019 D4). Only the log *view* is per game.
|
|
pub fn next_game(&self) {
|
|
self.journal.borrow_mut().clear();
|
|
self.game.set(self.game.get() + 1);
|
|
}
|
|
|
|
/// Which game is being played, 1-based.
|
|
pub fn game(&self) -> usize {
|
|
self.game.get()
|
|
}
|
|
|
|
/// Set what the meta panel shows about the session (CB-WP-0027 T02).
|
|
///
|
|
/// Called by the driver between games, so the running tally is visible
|
|
/// **while playing** rather than only on the ending page — which is
|
|
/// where it was, and a tally you only see once the game is over
|
|
/// informs nothing.
|
|
pub fn set_meta(&self, lines: Vec<String>) {
|
|
*self.meta.borrow_mut() = lines;
|
|
}
|
|
|
|
/// The journal the driver appends to; hand it to `play_journaled`.
|
|
pub fn journal(&self) -> games_ground::bot::Journal {
|
|
self.journal.clone()
|
|
}
|
|
|
|
/// The player's own comments, positioned for the log (CB-WP-0032).
|
|
fn log_notes(&self) -> Vec<cb_render_html::doc::LogNote> {
|
|
let game = self.game.get();
|
|
self.notes
|
|
.borrow()
|
|
.iter()
|
|
// THIS game's comments. A note from game 1 placed by its
|
|
// command index into game 2's log would sit against a move
|
|
// nobody made (ADR-0019 D1).
|
|
.filter(|n| n.game == game)
|
|
.map(|n| cb_render_html::doc::LogNote {
|
|
after: n.after,
|
|
round: n.round,
|
|
step: n.step.clone(),
|
|
text: n.text.clone(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The log as the page renders it, in the recorder's vocabulary.
|
|
///
|
|
/// `record::to_step` is reused rather than phrased afresh: what the
|
|
/// player reads is what the scenario file will say. The effects are
|
|
/// the events the command actually produced, and a command that
|
|
/// produced none says so — that is the case CB-WP-0018 was reported
|
|
/// for.
|
|
fn log_lines(&self) -> Vec<cb_render_html::doc::LogLine> {
|
|
self.journal
|
|
.borrow()
|
|
.iter()
|
|
.map(|a| {
|
|
let step = games_ground::record::to_step(a.actor, &a.command);
|
|
let mut what = step.cmd.clone();
|
|
for (k, v) in &step.args {
|
|
let rendered = match v {
|
|
serde_yaml::Value::String(s) => s.clone(),
|
|
other => serde_yaml::to_string(other)
|
|
.unwrap_or_default()
|
|
.trim()
|
|
.to_string(),
|
|
};
|
|
what.push_str(&format!(" {k}={rendered}"));
|
|
}
|
|
cb_render_html::doc::LogLine {
|
|
who: match a.actor {
|
|
cb_kernel::Actor::Player(p) => format!("P{}", p.0 + 1),
|
|
cb_kernel::Actor::System => "the round".to_string(),
|
|
},
|
|
what,
|
|
effects: a.events.iter().map(event_line).collect(),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// What was refused, for the evidence a session leaves behind.
|
|
pub fn refusals(&self) -> Vec<String> {
|
|
self.log.borrow().clone()
|
|
}
|
|
|
|
/// Serve until the page reports something that resolves to a move.
|
|
///
|
|
/// Requests that fail a control are answered and the loop continues —
|
|
/// a refused request must not end someone's game, and must not be
|
|
/// silently indistinguishable from one that did nothing.
|
|
pub fn next_choice(
|
|
&self,
|
|
state: &GroundState,
|
|
seat: PlayerId,
|
|
legal: &[GroundCommand],
|
|
may_pass: bool,
|
|
) -> Result<Choice, String> {
|
|
let view = state.project(Viewer::Player(seat));
|
|
loop {
|
|
let (mut stream, _) = self.listener.accept().map_err(|e| format!("accept: {e}"))?;
|
|
let raw = match read_request(&mut stream) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
respond(&mut stream, 400, "text/plain", &e);
|
|
continue;
|
|
}
|
|
};
|
|
let req = match Request::parse(&raw) {
|
|
Ok(r) => r,
|
|
Err(refusal) => {
|
|
self.log.borrow_mut().push(refusal.to_string());
|
|
respond(&mut stream, 400, "text/plain", &refusal.to_string());
|
|
continue;
|
|
}
|
|
};
|
|
if let Err(refusal) = self.guard.admit(&req) {
|
|
self.log.borrow_mut().push(refusal.to_string());
|
|
respond(&mut stream, 403, "text/plain", &refusal.to_string());
|
|
continue;
|
|
}
|
|
|
|
match (req.method.as_str(), req.path.as_str()) {
|
|
("GET", "/") => {
|
|
let page = cb_render_html::doc::document_with_log(
|
|
&view,
|
|
legal,
|
|
cb_render_html::Endpoints {
|
|
command: &self.guard.endpoint(),
|
|
note: &self.guard.note_endpoint(),
|
|
},
|
|
Some(seat),
|
|
may_pass,
|
|
cb_render_html::doc::Account {
|
|
lines: &self.log_lines(),
|
|
notes: &self.log_notes(),
|
|
},
|
|
// CB-WP-0027 T02: the meta panel's own content.
|
|
// The running tally lives with the driver, so the
|
|
// server is handed the lines rather than the state.
|
|
&self.meta.borrow(),
|
|
);
|
|
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
|
}
|
|
("POST", "/command") => {
|
|
let fact = match PointerFact::parse(&req.body) {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
respond(&mut stream, 400, "text/plain", &e);
|
|
continue;
|
|
}
|
|
};
|
|
if may_pass && fact.down == "pass" && fact.up == "pass" {
|
|
respond(&mut stream, 200, "text/plain", "ok: passed");
|
|
return Ok(Choice::Pass);
|
|
}
|
|
match resolve(&fact, legal, seat) {
|
|
Ok(i) => {
|
|
respond(&mut stream, 200, "text/plain", "ok");
|
|
return Ok(Choice::Command(i));
|
|
}
|
|
// Not an error: the player dragged somewhere that
|
|
// means nothing. Say so and keep the turn.
|
|
Err(why) => respond(&mut stream, 200, "text/plain", &why),
|
|
}
|
|
}
|
|
// CB-WP-0027 T03 / ADR-0014 D1. A SECOND CHANNEL, and it
|
|
// cannot carry a move: `Note::parse` returns a `Note`,
|
|
// `resolve` takes a `PointerFact`, and there is no
|
|
// conversion between them. The game does not advance here
|
|
// and the loop keeps waiting for the seat's actual move.
|
|
("POST", "/note") => match Note::parse(&req.body) {
|
|
Ok(note) => {
|
|
match self.record_note(state, &format!("{:?}", state.step), ¬e) {
|
|
Ok(()) => {
|
|
// 303 so the browser re-GETs the table rather
|
|
// than leaving a form POST in history — a
|
|
// reload would otherwise re-submit the note.
|
|
//
|
|
// WITH THE TOKEN. Redirecting to bare `/` sent
|
|
// the browser to a request control 1 refuses,
|
|
// so the note was saved and the player was
|
|
// shown "no session token" — which reads as
|
|
// the note having failed.
|
|
respond_seeother(&mut stream, &self.guard.page_path());
|
|
}
|
|
Err(e) => respond(&mut stream, 500, "text/plain", &e),
|
|
}
|
|
}
|
|
Err(why) => respond(&mut stream, 400, "text/plain", &why),
|
|
},
|
|
_ => respond(&mut stream, 404, "text/plain", "no such thing here"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Serve the end of the game until the player has seen it.
|
|
///
|
|
/// **The defect this exists for (CB-WP-0018 T01):** `next_choice`
|
|
/// only accepts connections *inside* a decision point, so when
|
|
/// `play()` returned the listener died and the page's post-`ok`
|
|
/// reload was refused. Measured: a game ended normally at 5 rounds
|
|
/// and 30 commands, its whole result went to the terminal, and the
|
|
/// browser got `Connection refused`. **A crash and a win rendered
|
|
/// identically — as nothing.**
|
|
///
|
|
/// `final_view` is `None` when the game ended badly; then `message`
|
|
/// is the reason and the page says the game ended without a result
|
|
/// rather than drawing a table that never happened.
|
|
///
|
|
/// **How it ends, and why that needed deciding:** a server that never
|
|
/// exits is its own defect, and a timeout would race a player reading
|
|
/// the result. It serves until the page tells it the result has been
|
|
/// seen — the terminal page carries a `done` control and posts it —
|
|
/// with `linger` as a bound so an abandoned tab cannot hold the
|
|
/// process open forever.
|
|
///
|
|
/// **`final_state` is what makes the comment box work here**
|
|
/// (CB-WP-0031). This loop had no `/note` arm at all, so a note posted
|
|
/// from the ending page fell through to `404 the game is over` — and
|
|
/// the ending page did not render the box in the first place, so the
|
|
/// channel closed at the moment a player has just learned how it went.
|
|
/// `None` means the game stopped without a position, and then the box
|
|
/// is not offered rather than offered and refused.
|
|
pub fn serve_end(
|
|
&self,
|
|
final_view: Option<&games_ground::view::GroundView>,
|
|
final_state: Option<&GroundState>,
|
|
message: &str,
|
|
linger: std::time::Duration,
|
|
tally: &crate::table::MatchTally,
|
|
) -> Result<EndChoice, String> {
|
|
let deadline = std::time::Instant::now() + linger;
|
|
self.listener
|
|
.set_nonblocking(true)
|
|
.map_err(|e| format!("nonblocking: {e}"))?;
|
|
let outcome = loop {
|
|
if std::time::Instant::now() >= deadline {
|
|
break Ok(EndChoice::Closed);
|
|
}
|
|
let (mut stream, _) = match self.listener.accept() {
|
|
Ok(pair) => pair,
|
|
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
|
std::thread::sleep(std::time::Duration::from_millis(25));
|
|
continue;
|
|
}
|
|
Err(e) => break Err(format!("accept: {e}")),
|
|
};
|
|
stream.set_nonblocking(false).ok();
|
|
let Ok(raw) = read_request(&mut stream) else {
|
|
continue;
|
|
};
|
|
let Ok(req) = Request::parse(&raw) else {
|
|
respond(&mut stream, 400, "text/plain", "bad request");
|
|
continue;
|
|
};
|
|
if let Err(refusal) = self.guard.admit(&req) {
|
|
self.log.borrow_mut().push(refusal.to_string());
|
|
respond(&mut stream, 403, "text/plain", &refusal.to_string());
|
|
continue;
|
|
}
|
|
match (req.method.as_str(), req.path.as_str()) {
|
|
("GET", "/") => {
|
|
let note_to = final_state.map(|_| self.guard.note_endpoint());
|
|
let page = cb_render_html::doc::ending(
|
|
final_view,
|
|
message,
|
|
&self.guard.endpoint(),
|
|
note_to.as_deref(),
|
|
cb_render_html::doc::Account {
|
|
lines: &self.log_lines(),
|
|
notes: &self.log_notes(),
|
|
},
|
|
&series_lines(tally),
|
|
);
|
|
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
|
}
|
|
// CB-WP-0031. The same second channel as in `next_choice`,
|
|
// and it still cannot carry a move — here it could not
|
|
// anyway, because the game is finished.
|
|
//
|
|
// **It does not `break`.** Every other POST here ends the
|
|
// loop; a note must leave the session exactly as it found
|
|
// it, so the player can write a second one, or read the
|
|
// log again, or then press `play again`.
|
|
("POST", "/note") => {
|
|
let Some(state) = final_state else {
|
|
respond(
|
|
&mut stream,
|
|
409,
|
|
"text/plain",
|
|
"the game stopped without a final position, so a note \
|
|
has nothing to bind to",
|
|
);
|
|
continue;
|
|
};
|
|
match Note::parse(&req.body) {
|
|
Ok(note) => match self.record_note(state, "after the end", ¬e) {
|
|
Ok(()) => respond_seeother(&mut stream, &self.guard.page_path()),
|
|
Err(e) => respond(&mut stream, 500, "text/plain", &e),
|
|
},
|
|
Err(why) => respond(&mut stream, 400, "text/plain", &why),
|
|
}
|
|
}
|
|
("POST", "/command") => {
|
|
// CB-WP-0020 T05: the browser could not start a second
|
|
// game without going back to a terminal, which made it
|
|
// a strictly worse client than the CLI it replaces.
|
|
let again = req.body.contains("again");
|
|
respond(
|
|
&mut stream,
|
|
200,
|
|
"text/plain",
|
|
// CB-WP-0024 T01: the reply is what the player
|
|
// reads. "closed" alone left them looking at a
|
|
// live table wondering why nothing happened.
|
|
// The `closed` prefix is what the page's script
|
|
// matches on to seal itself.
|
|
if again {
|
|
"ok: dealing"
|
|
} else {
|
|
"closed \u{2014} the session has ended and the server has stopped. \
|
|
You can close this tab."
|
|
},
|
|
);
|
|
break Ok(if again {
|
|
EndChoice::Again
|
|
} else {
|
|
EndChoice::Closed
|
|
});
|
|
}
|
|
_ => respond(&mut stream, 404, "text/plain", "the game is over"),
|
|
}
|
|
};
|
|
self.listener.set_nonblocking(false).ok();
|
|
outcome
|
|
}
|
|
}
|
|
|
|
/// The running series, phrased for a reader.
|
|
///
|
|
/// **Two lines, because the rules define one game and not a series**
|
|
/// (CB-WP-0024 T04). `personal` sums per seat; `winners` counts games
|
|
/// won; they answer different questions and GROUND says which is *the*
|
|
/// series score nowhere at all. Both are shown and both are named, so
|
|
/// nothing here quietly becomes canon.
|
|
pub fn series_lines(t: &crate::table::MatchTally) -> Vec<String> {
|
|
if t.games <= 1 {
|
|
// One game is not a series, and a "cumulative" panel over a single
|
|
// result would just restate the outcome above it.
|
|
return Vec::new();
|
|
}
|
|
let seats = |m: std::collections::BTreeMap<PlayerId, String>| {
|
|
m.iter()
|
|
.map(|(p, v)| format!("P{} {v}", p.0 + 1))
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
};
|
|
let personal: std::collections::BTreeMap<_, _> = t
|
|
.personal
|
|
.iter()
|
|
.map(|(p, v)| (*p, format!("{v:+}")))
|
|
.collect();
|
|
let wins: std::collections::BTreeMap<_, _> = t
|
|
.personal
|
|
.keys()
|
|
.map(|p| (*p, t.wins.get(p).copied().unwrap_or(0).to_string()))
|
|
.collect();
|
|
vec![
|
|
format!("{} games this session", t.games),
|
|
format!("summed personal score — {}", seats(personal)),
|
|
format!("games won — {}", seats(wins)),
|
|
format!(
|
|
"table cleared its threshold {} of {} games",
|
|
t.group_successes, t.games
|
|
),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod series {
|
|
use crate::table::MatchTally;
|
|
use cb_kernel::PlayerId;
|
|
use games_ground::view::OutcomeView;
|
|
|
|
fn outcome(p1: i32, p2: i32, success: bool, winners: Vec<PlayerId>) -> OutcomeView {
|
|
OutcomeView {
|
|
total: 0,
|
|
threshold: 5,
|
|
group_success: success,
|
|
personal: [(PlayerId(0), p1), (PlayerId(1), p2)].into_iter().collect(),
|
|
coalitions: vec![],
|
|
mastery: None,
|
|
winners,
|
|
}
|
|
}
|
|
|
|
fn after(games: &[OutcomeView]) -> MatchTally {
|
|
let mut t = MatchTally::default();
|
|
for o in games {
|
|
t.record_for_test(o);
|
|
}
|
|
t
|
|
}
|
|
|
|
/// The weakest possible assertion, and the one that catches a tally
|
|
/// reset by `play again`: two games must produce a total that is
|
|
/// neither game's score alone.
|
|
#[test]
|
|
fn two_games_accumulate_rather_than_replacing() {
|
|
let t = after(&[
|
|
outcome(3, 1, true, vec![PlayerId(0)]),
|
|
outcome(2, 4, false, vec![PlayerId(1)]),
|
|
]);
|
|
assert_eq!(t.games, 2);
|
|
assert_eq!(t.personal[&PlayerId(0)], 5, "P1's scores did not sum");
|
|
assert_eq!(t.personal[&PlayerId(1)], 5, "P2's scores did not sum");
|
|
assert_eq!(t.group_successes, 1, "only one game cleared its threshold");
|
|
assert_eq!(t.wins[&PlayerId(0)], 1);
|
|
assert_eq!(t.wins[&PlayerId(1)], 1);
|
|
}
|
|
|
|
/// **The two tallies disagree, and that is the point.** Summed
|
|
/// personal score says the seats are level; games won says the same;
|
|
/// but a third game separates them, and which one is "the series
|
|
/// score" is a question GROUND does not answer. Showing one alone
|
|
/// would be picking a winner the rules never named.
|
|
#[test]
|
|
fn the_two_tallies_can_disagree_about_who_is_ahead() {
|
|
let t = after(&[
|
|
outcome(9, 0, false, vec![PlayerId(0)]),
|
|
outcome(0, 1, false, vec![PlayerId(1)]),
|
|
outcome(0, 1, false, vec![PlayerId(1)]),
|
|
]);
|
|
assert!(
|
|
t.personal[&PlayerId(0)] > t.personal[&PlayerId(1)],
|
|
"P1 leads on summed score"
|
|
);
|
|
assert!(
|
|
t.wins[&PlayerId(1)] > t.wins.get(&PlayerId(0)).copied().unwrap_or(0),
|
|
"P2 leads on games won — the two answers point at different seats"
|
|
);
|
|
}
|
|
|
|
/// One game is not a series: the panel stays absent rather than
|
|
/// restating the outcome directly above it.
|
|
#[test]
|
|
fn a_single_game_shows_no_series_panel() {
|
|
assert!(super::series_lines(&after(&[outcome(1, 1, true, vec![])])).is_empty());
|
|
assert!(
|
|
!super::series_lines(&after(&[
|
|
outcome(1, 1, true, vec![]),
|
|
outcome(1, 1, true, vec![])
|
|
]))
|
|
.is_empty(),
|
|
"two games must produce a panel"
|
|
);
|
|
}
|
|
|
|
/// Both tallies must be named on the page. An unlabelled number would
|
|
/// become "the score" by default, which is the canonisation this
|
|
/// deliberately avoids.
|
|
#[test]
|
|
fn the_page_says_which_tally_is_which() {
|
|
let lines = super::series_lines(&after(&[
|
|
outcome(1, 2, true, vec![PlayerId(1)]),
|
|
outcome(3, 0, false, vec![PlayerId(0)]),
|
|
]));
|
|
let all = lines.join(" | ");
|
|
assert!(all.contains("summed personal score"), "{all}");
|
|
assert!(all.contains("games won"), "{all}");
|
|
assert!(all.contains("cleared its threshold"), "{all}");
|
|
}
|
|
}
|
|
|
|
/// What the player asked for on the ending page.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum EndChoice {
|
|
Closed,
|
|
Again,
|
|
}
|
|
|
|
/// One seat's view of the shared server.
|
|
pub struct SeatPolicy {
|
|
server: Rc<Server>,
|
|
failure: Rc<RefCell<Option<String>>>,
|
|
}
|
|
|
|
impl SeatPolicy {
|
|
pub fn new(server: Rc<Server>, failure: Rc<RefCell<Option<String>>>) -> Self {
|
|
Self { server, failure }
|
|
}
|
|
}
|
|
|
|
impl Policy for SeatPolicy {
|
|
fn name(&self) -> &'static str {
|
|
"browser"
|
|
}
|
|
|
|
fn choose(
|
|
&mut self,
|
|
state: &GroundState,
|
|
seat: PlayerId,
|
|
legal: &[GroundCommand],
|
|
may_pass: bool,
|
|
) -> Choice {
|
|
match self.server.next_choice(state, seat, legal, may_pass) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
// Same shape as the CLI's out-of-input path: record the
|
|
// real reason and return something the driver will
|
|
// reject, rather than inventing a move.
|
|
*self.failure.borrow_mut() = Some(e);
|
|
Choice::Command(usize::MAX)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One event, in words a player can read.
|
|
///
|
|
/// Deliberately terse and derived from the event itself, never from the
|
|
/// state afterwards: a log that re-narrates state is a second
|
|
/// implementation of the rules and will drift from the first.
|
|
fn event_line(e: &games_ground::GroundEvent) -> String {
|
|
use games_ground::GroundEvent as E;
|
|
let p = |x: &cb_kernel::PlayerId| format!("P{}", x.0 + 1);
|
|
match e {
|
|
E::ActionSelected { player, selection } => match (selection.target, selection.problem) {
|
|
(Some(t), _) => format!("{} chose {:?} on {}", p(player), selection.action, p(&t)),
|
|
(_, Some(n)) => format!("{} chose {:?} on problem {n}", p(player), selection.action),
|
|
_ => format!("{} chose {:?}", p(player), selection.action),
|
|
},
|
|
E::Revealed => "selections revealed".into(),
|
|
E::StressSet { player, stress } => format!("{} stress now {stress}", p(player)),
|
|
E::FreedomSpent { player } => format!("{} spent freedom", p(player)),
|
|
E::FreedomReadied { player } => format!("{} freedom ready", p(player)),
|
|
E::RelationFormed { pair, relation } => {
|
|
format!("{} and {} \u{2014} {relation:?}", p(&pair.0), p(&pair.1))
|
|
}
|
|
E::RelationBroken { pair } => format!("{} and {} no longer tied", p(&pair.0), p(&pair.1)),
|
|
E::AttackCancelled { attacker, target } => {
|
|
format!("{}'s attack on {} cancelled", p(attacker), p(target))
|
|
}
|
|
E::ProblemRevealed { problem } => format!("problem {problem} turned face up"),
|
|
E::SolutionDrawn { player, .. } => format!("{} drew a solution", p(player)),
|
|
E::SolutionDiscarded { player, card } => {
|
|
format!("{} spent a {:?} solution", p(player), card.suit)
|
|
}
|
|
E::ProblemClaimed { problem, by } => format!("problem {problem} claimed by {}", p(by)),
|
|
E::ProblemDenied { problem } => format!("problem {problem} denied"),
|
|
E::ProblemProtected { problem } => format!("problem {problem} protected"),
|
|
E::ProblemRestored { problem } => format!("problem {problem} restored"),
|
|
E::ProtectionGained { player } => format!("{} gained protection", p(player)),
|
|
E::BlameRemoved { player, owner } => {
|
|
format!("{} cleared {}'s blame", p(player), p(owner))
|
|
}
|
|
E::FocusPlaced { owner, target } => format!("{} focused on {}", p(owner), p(target)),
|
|
E::FocusFlippedToBlame { owner, target } => {
|
|
format!("{}'s focus on {} became blame", p(owner), p(target))
|
|
}
|
|
E::DarvoTriggered { player } => format!("{} entered DARVO", p(player)),
|
|
E::DarvoAdvanced { player, stage } => format!("{} DARVO \u{2192} {stage:?}", p(player)),
|
|
E::DarvoEnded { player } => format!("{} left DARVO", p(player)),
|
|
E::DarvoTargetChosen { player, .. } => format!("{} named a DARVO target", p(player)),
|
|
E::GroundModeChosen { player, mode, .. } => {
|
|
format!("{} grounded as {mode:?}", p(player))
|
|
}
|
|
E::SupportAnswered { player, response } => {
|
|
format!("{} answered support with {response:?}", p(player))
|
|
}
|
|
E::DeckReshuffled { .. } => "the discard was reshuffled into the deck".into(),
|
|
E::RoundEnded { round, next_lead } => {
|
|
format!("round {round} ended; {} leads next", p(next_lead))
|
|
}
|
|
E::StepAdvanced { step } => format!("step \u{2192} {step:?}"),
|
|
E::GameEnded { .. } => "the game ended".into(),
|
|
}
|
|
}
|
|
|
|
fn read_request(stream: &mut TcpStream) -> Result<String, String> {
|
|
let mut buf = Vec::new();
|
|
let mut chunk = [0u8; 1024];
|
|
loop {
|
|
let n = stream.read(&mut chunk).map_err(|e| format!("read: {e}"))?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
buf.extend_from_slice(&chunk[..n]);
|
|
// Once the head is in, read exactly the declared body and stop —
|
|
// otherwise a keep-alive connection blocks here forever.
|
|
if let Some(head_end) = find(&buf, b"\r\n\r\n") {
|
|
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
|
|
let want: usize = head
|
|
.split("\r\n")
|
|
.find_map(|l| {
|
|
let (k, v) = l.split_once(':')?;
|
|
k.eq_ignore_ascii_case("content-length")
|
|
.then(|| v.trim().parse().ok())?
|
|
})
|
|
.unwrap_or(0);
|
|
if buf.len() >= head_end + 4 + want {
|
|
break;
|
|
}
|
|
}
|
|
if buf.len() > 64 * 1024 {
|
|
return Err("request too large".to_string());
|
|
}
|
|
}
|
|
String::from_utf8(buf).map_err(|_| "request is not UTF-8".to_string())
|
|
}
|
|
|
|
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
|
haystack.windows(needle.len()).position(|w| w == needle)
|
|
}
|
|
|
|
/// 303 See Other, so the browser re-GETs the table after a form POST
|
|
/// rather than leaving the POST in history — a reload would otherwise
|
|
/// re-submit the note and duplicate it.
|
|
fn respond_seeother(stream: &mut TcpStream, to: &str) {
|
|
let head = format!("HTTP/1.1 303 See Other\r\nLocation: {to}\r\nContent-Length: 0\r\n\r\n");
|
|
let _ = stream.write_all(head.as_bytes());
|
|
let _ = stream.flush();
|
|
}
|
|
|
|
fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &str) {
|
|
let reason = match status {
|
|
200 => "OK",
|
|
400 => "Bad Request",
|
|
403 => "Forbidden",
|
|
_ => "Not Found",
|
|
};
|
|
let head = format!(
|
|
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\
|
|
Content-Length: {}\r\nConnection: close\r\n\
|
|
X-Content-Type-Options: nosniff\r\n\r\n",
|
|
body.len()
|
|
);
|
|
let _ = stream.write_all(head.as_bytes());
|
|
let _ = stream.write_all(body.as_bytes());
|
|
let _ = stream.flush();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use games_ground::Action;
|
|
|
|
/// One client thread issuing requests **in order**, each on its own
|
|
/// `Connection: close` socket and each fully read before the next is
|
|
/// sent. Two concurrent clients would race the server's `accept`, and
|
|
/// a test whose outcome depends on which socket wins is worse than no
|
|
/// test.
|
|
/// **The defect CB-WP-0018 T01 exists for.** After the game ends the
|
|
/// browser must get the result, not a refused connection.
|
|
///
|
|
/// Measured before the fix, driving a real game to completion over
|
|
/// HTTP: move 5 accepted, then `GET /` → `[Errno 111] Connection
|
|
/// refused`, while the whole outcome went to a terminal nobody was
|
|
/// reading. A crash and a win rendered identically — as nothing.
|
|
#[test]
|
|
fn the_end_of_the_game_reaches_the_browser() {
|
|
let server = Server::bind(0).expect("bind");
|
|
let port = server.listener.local_addr().unwrap().port();
|
|
let token = server.guard.token().to_string();
|
|
let client = converse(
|
|
port,
|
|
vec![
|
|
format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"),
|
|
format!(
|
|
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
|
|
Origin: http://127.0.0.1:{port}\r\nContent-Length: 17\r\n\r\n\
|
|
down=done&up=done"
|
|
),
|
|
],
|
|
);
|
|
let view = state().project(Viewer::Spectator);
|
|
server
|
|
.serve_end(
|
|
Some(&view),
|
|
Some(&state()),
|
|
"30 commands, hash f6c890a65271",
|
|
std::time::Duration::from_secs(20),
|
|
&crate::table::MatchTally::default(),
|
|
)
|
|
.expect("serve_end");
|
|
let replies = client.join().expect("client");
|
|
|
|
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
|
|
// CB-WP-0028 T06: the heading follows the OUTCOME, so this asks
|
|
// the view rather than hardcoding a word. The old assertion said
|
|
// "game over" and went red when a won game started saying
|
|
// "solved" -- which was the feature working.
|
|
let want = match view.outcome.as_ref() {
|
|
Some(o) if o.group_success => "game solved",
|
|
Some(_) => "game over",
|
|
None => "the game stopped",
|
|
};
|
|
assert!(
|
|
replies[0].contains(want),
|
|
"the heading did not match the outcome; expected {want:?}"
|
|
);
|
|
assert!(
|
|
replies[0].contains("30 commands"),
|
|
"the result did not reach the page"
|
|
);
|
|
// It must not auto-reload into a refused connection, which is how
|
|
// a completed game became a blank tab in the first place.
|
|
//
|
|
// Asserted on BEHAVIOUR, not on source text. The first draft
|
|
// grepped the page for "location.reload" and failed: the ending
|
|
// page reuses `SCRIPT`, whose reload is guarded by
|
|
// `t.indexOf('ok') === 0`. Grepping for the string would have
|
|
// forced a second script to satisfy a test rather than a
|
|
// requirement — the exact weak-control shape ADR-0010 D2 demoted.
|
|
// What matters is that the endpoint cannot answer "ok".
|
|
assert!(
|
|
!replies[1].contains("ok"),
|
|
"the ending endpoint answered something the page reloads on: {}",
|
|
replies[1]
|
|
);
|
|
assert!(replies[1].contains("closed"), "{}", replies[1]);
|
|
}
|
|
|
|
/// CB-WP-0031. The comment box must survive the end of the game.
|
|
///
|
|
/// **Two things go wrong independently and this asserts both.** The
|
|
/// ending page did not render the form, and `serve_end` had no
|
|
/// `/note` arm — so even a hand-built POST fell through to
|
|
/// `404 the game is over`. Fixing either alone leaves the channel
|
|
/// shut, so a test that only checked the markup would have passed
|
|
/// against a server that still refused.
|
|
///
|
|
/// **And the note must not end the session.** Every other POST in
|
|
/// this loop breaks it; a player writing what they thought must be
|
|
/// able to write a second one and then still press `play again`,
|
|
/// which is what the trailing command here proves.
|
|
#[test]
|
|
fn a_note_can_be_written_after_the_game_has_ended() {
|
|
let dir = std::env::temp_dir().join(format!("cb-note-end-{}", std::process::id()));
|
|
let log = dir.join("trial.md");
|
|
let server = Server::bind(0).expect("bind").with_trial(Some(log.clone()));
|
|
let port = server.listener.local_addr().unwrap().port();
|
|
let token = server.guard.token().to_string();
|
|
let client = converse(
|
|
port,
|
|
vec![
|
|
format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"),
|
|
// The body is its own binding and the length is computed
|
|
// from it. Writing `Content-Length: 21` by hand beside a
|
|
// 22-byte body is a test that fails for a reason having
|
|
// nothing to do with the feature.
|
|
post(&token, "/note", "note=so+that+is+why+it"),
|
|
// CB-WP-0032: and it comes BACK, in the log.
|
|
format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"),
|
|
post(&token, "/command", "down=done&up=done"),
|
|
],
|
|
);
|
|
let view = state().project(Viewer::Spectator);
|
|
server
|
|
.serve_end(
|
|
Some(&view),
|
|
Some(&state()),
|
|
"30 commands",
|
|
std::time::Duration::from_secs(20),
|
|
&crate::table::MatchTally::default(),
|
|
)
|
|
.expect("serve_end");
|
|
let replies = client.join().expect("client");
|
|
|
|
assert!(
|
|
replies[0].contains("id=\"cb-note\""),
|
|
"the ending page offered no comment box"
|
|
);
|
|
// 303 back to the TOKEN-CARRYING path. Redirecting to bare `/`
|
|
// is the defect that made a SAVED note read as a failed one.
|
|
assert!(
|
|
replies[1].contains("303"),
|
|
"the note was refused: {}",
|
|
replies[1]
|
|
);
|
|
assert!(
|
|
replies[1].contains(&format!("t={token}")),
|
|
"the redirect dropped the token, which reads as a refusal: {}",
|
|
replies[1]
|
|
);
|
|
// CB-WP-0032. Written, then read back on the very next page —
|
|
// the round trip, not just the write.
|
|
assert!(
|
|
replies[2].contains("so that is why it"),
|
|
"the note was saved but never shown back: {}",
|
|
replies[2]
|
|
);
|
|
assert!(
|
|
replies[2].contains("class=\"note\""),
|
|
"the note was shown without marking it as the player's"
|
|
);
|
|
// The session survived the note.
|
|
assert!(
|
|
replies[3].contains("closed"),
|
|
"the note ended the session, so nothing could follow it: {}",
|
|
replies[2]
|
|
);
|
|
|
|
let written = std::fs::read_to_string(&log).expect("trial log");
|
|
assert!(written.contains("so that is why it"), "{written}");
|
|
// The WHEN is not the final step. `RoundStep::End` is the last
|
|
// step of a round; filing an after-the-fact reading under it
|
|
// would claim the player said this at a decision point.
|
|
assert!(
|
|
written.contains("after the end"),
|
|
"a post-game note was logged as an in-play one: {written}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// The other half of the same decision: no position, no box.
|
|
///
|
|
/// A crashed game has nothing to bind a note to, and offering a box
|
|
/// that would answer 409 is worse than not offering one.
|
|
#[test]
|
|
fn a_game_that_stopped_offers_no_comment_box() {
|
|
let mut s = String::new();
|
|
let page = cb_render_html::doc::ending(
|
|
None,
|
|
"it broke",
|
|
"/c",
|
|
None,
|
|
cb_render_html::doc::Account::of(&[]),
|
|
&[],
|
|
);
|
|
s.push_str(&page);
|
|
assert!(
|
|
!s.contains("id=\"cb-note\""),
|
|
"offered a box with nothing to bind to"
|
|
);
|
|
assert!(
|
|
s.contains("nothing to bind a note to"),
|
|
"did not say why: {s}"
|
|
);
|
|
}
|
|
|
|
/// ADR-0019 D2. Game 1 keeps the path it was given.
|
|
#[test]
|
|
fn only_later_games_get_a_suffixed_recording() {
|
|
let base = std::path::Path::new("trials/2026-08-07-x.yaml");
|
|
// GameDesign §5 documents this exact invocation; renaming it
|
|
// would have made the spec wrong for the common case.
|
|
assert_eq!(game_path(base, 1), base);
|
|
assert_eq!(
|
|
game_path(base, 2),
|
|
std::path::Path::new("trials/2026-08-07-x-2.yaml")
|
|
);
|
|
assert_eq!(
|
|
game_path(base, 3),
|
|
std::path::Path::new("trials/2026-08-07-x-3.yaml")
|
|
);
|
|
// Distinct paths are the whole point: nothing may be overwritten.
|
|
assert_ne!(game_path(base, 2), game_path(base, 3));
|
|
// And the suffix must not COMPOUND. The driver derives every
|
|
// game's path from the ORIGINAL, because deriving it from the
|
|
// previous game's would put game 3 in `x-2-3.yaml`.
|
|
assert_eq!(
|
|
game_path(&game_path(base, 2), 3),
|
|
std::path::Path::new("trials/2026-08-07-x-2-3.yaml"),
|
|
"this is the shape the driver must avoid, and does by keeping the base"
|
|
);
|
|
}
|
|
|
|
/// ADR-0019 D1. The journal belongs to a GAME.
|
|
///
|
|
/// **Nothing ever cleared it.** It is shared with the driver and
|
|
/// lives on the `Server`, which outlives every game, so a second
|
|
/// game's log opened with the first game's commands still in it — and
|
|
/// a note's `after`, counted against that journal, indexed into a
|
|
/// recording that never contained those commands.
|
|
#[test]
|
|
fn a_second_game_does_not_inherit_the_first_ones_log() {
|
|
let server = Server::bind(0).expect("bind");
|
|
let j = server.journal();
|
|
j.borrow_mut().push(games_ground::bot::Applied {
|
|
actor: cb_kernel::Actor::System,
|
|
command: GroundCommand::EndRound,
|
|
events: vec![],
|
|
});
|
|
assert_eq!(server.game(), 1);
|
|
assert_eq!(server.journal().borrow().len(), 1);
|
|
|
|
server.next_game();
|
|
|
|
assert_eq!(server.game(), 2);
|
|
assert!(
|
|
server.journal().borrow().is_empty(),
|
|
"game 2 opened holding game 1's commands"
|
|
);
|
|
}
|
|
|
|
/// A comment belongs to the game it was made in (ADR-0019 D1).
|
|
///
|
|
/// Placed by command index into a LATER game's log, a note from game 1
|
|
/// would sit against a move nobody made — a position stated
|
|
/// confidently and wrong, which is the family ADR-0018 names.
|
|
#[test]
|
|
fn a_comment_from_an_earlier_game_is_not_shown_in_a_later_one() {
|
|
let dir = std::env::temp_dir().join(format!("cb-g-{}", std::process::id()));
|
|
let server = Server::bind(0)
|
|
.expect("bind")
|
|
.with_trial(Some(dir.join("t.md")));
|
|
let st = state();
|
|
|
|
server
|
|
.record_note(
|
|
&st,
|
|
"Select",
|
|
&Note {
|
|
text: "in game one".into(),
|
|
},
|
|
)
|
|
.expect("note 1");
|
|
assert_eq!(server.log_notes().len(), 1, "its own game shows it");
|
|
|
|
server.next_game();
|
|
assert!(
|
|
server.log_notes().is_empty(),
|
|
"game 1's comment leaked into game 2's log"
|
|
);
|
|
|
|
server
|
|
.record_note(
|
|
&st,
|
|
"Select",
|
|
&Note {
|
|
text: "in game two".into(),
|
|
},
|
|
)
|
|
.expect("note 2");
|
|
let shown = server.log_notes();
|
|
assert_eq!(shown.len(), 1);
|
|
assert_eq!(shown[0].text, "in game two");
|
|
|
|
// But the trial log keeps BOTH: it is the session's record, and
|
|
// the game column is what tells them apart (ADR-0019 D4).
|
|
let written = std::fs::read_to_string(dir.join("t.md")).expect("trial log");
|
|
assert!(written.contains("in game one"), "the session lost a note");
|
|
assert!(written.contains("in game two"));
|
|
assert!(
|
|
written.contains("| 1 | 1 |"),
|
|
"game column missing: {written}"
|
|
);
|
|
assert!(
|
|
written.contains("| 2 | 2 |"),
|
|
"second note not in game 2: {written}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// **CB-WP-0020 T05.** "play again" must deal a second game, not just
|
|
/// answer politely and exit — the browser could not start a second
|
|
/// game without going back to a terminal, which made it a strictly
|
|
/// worse client than the CLI it replaces.
|
|
#[test]
|
|
fn play_again_deals_a_second_game() {
|
|
let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let out = SharedOut(buf.clone());
|
|
// ADR-0019 D2: a real recording, so this covers the CALL SITE.
|
|
// The unit test proves `game_path` names files apart; only this
|
|
// proves the driver uses it, which is where the data loss was.
|
|
let dir = std::env::temp_dir().join(format!("cb-again-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).expect("tmp");
|
|
let rec = dir.join("s.yaml");
|
|
let rec_for_thread = rec.clone();
|
|
let game = std::thread::spawn(move || {
|
|
crate::table::play(
|
|
&crate::table::Config {
|
|
seed: 3,
|
|
players: 3,
|
|
human_seats: vec![0],
|
|
bot: "random".into(),
|
|
replay_dir: None,
|
|
record: Some(rec_for_thread),
|
|
serve: Some(0),
|
|
trial: None,
|
|
mode: games_ground::ScoringMode::SharedGround,
|
|
},
|
|
std::io::Cursor::new(Vec::new()),
|
|
out,
|
|
)
|
|
});
|
|
let (port, token) = wait_for_url(&buf);
|
|
|
|
// Play one game out, ask for another, play that one out too.
|
|
let mut deals = 0;
|
|
for round in 0..2 {
|
|
drive_to_end(port, &token);
|
|
deals += 1;
|
|
let want = if round == 0 { "again" } else { "done" };
|
|
let body = format!("down={want}&up={want}");
|
|
let reply = http(
|
|
port,
|
|
&format!(
|
|
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
|
|
Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}",
|
|
body.len()
|
|
),
|
|
);
|
|
if round == 0 {
|
|
assert!(reply.contains("dealing"), "play again refused: {reply}");
|
|
}
|
|
}
|
|
assert_eq!(deals, 2, "the second game was never dealt");
|
|
game.join().expect("game thread").expect("the games ran");
|
|
|
|
// Two games, and they must not be the same deal — "again" that
|
|
// re-dealt the identical game would satisfy a naive count.
|
|
let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string();
|
|
let hashes: Vec<&str> = text
|
|
.match_indices("game over \u{2014} ")
|
|
.map(|(i, _)| &text[i..i + 60])
|
|
.collect();
|
|
assert!(hashes.len() >= 2, "only {} game(s) finished", hashes.len());
|
|
assert_ne!(hashes[0], hashes[1], "play again re-dealt the same game");
|
|
|
|
// ADR-0019 D2. BOTH recordings survive. `play again` used to
|
|
// write game 2 over game 1's file, destroying the evidence a
|
|
// trial exists to produce -- and every note bound to it.
|
|
let second = dir.join("s-2.yaml");
|
|
assert!(rec.exists(), "game 1's recording is gone");
|
|
assert!(second.exists(), "game 2 was never recorded separately");
|
|
assert_ne!(
|
|
std::fs::read_to_string(&rec).expect("g1"),
|
|
std::fs::read_to_string(&second).expect("g2"),
|
|
"the two recordings hold the same game"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// A game that ended badly must say so rather than draw a table for a
|
|
/// game that never happened.
|
|
#[test]
|
|
fn a_game_that_ended_badly_says_so_and_shows_no_table() {
|
|
let page = cb_render_html::doc::ending(
|
|
None,
|
|
"P1 ran out of input",
|
|
"/command?t=x",
|
|
None,
|
|
cb_render_html::doc::Account::of(&[]),
|
|
&[],
|
|
);
|
|
assert!(
|
|
page.contains("P1 ran out of input"),
|
|
"the reason is missing"
|
|
);
|
|
assert!(page.contains("without a result"));
|
|
assert!(
|
|
!page.contains("relationships"),
|
|
"a failed game drew a table anyway"
|
|
);
|
|
}
|
|
|
|
/// A writer the test can read while the game is still running.
|
|
#[derive(Clone)]
|
|
struct SharedOut(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
|
|
|
impl Write for SharedOut {
|
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
self.0.lock().expect("out").extend_from_slice(buf);
|
|
Ok(buf.len())
|
|
}
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Block until the server prints its URL, then return (port, token).
|
|
fn wait_for_url(buf: &std::sync::Arc<std::sync::Mutex<Vec<u8>>>) -> (u16, String) {
|
|
loop {
|
|
let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string();
|
|
if let Some(i) = text.find("http://127.0.0.1:") {
|
|
let rest = &text[i..];
|
|
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
|
let url = &rest[..end];
|
|
let port = url["http://127.0.0.1:".len()..]
|
|
.split('/')
|
|
.next()
|
|
.expect("port")
|
|
.parse()
|
|
.expect("port number");
|
|
return (port, url.split("t=").nth(1).expect("token").to_string());
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
}
|
|
}
|
|
|
|
/// Play the offered moves until the page stops offering any, which is
|
|
/// the ending. Returns the last page.
|
|
fn drive_to_end(port: u16, token: &str) -> String {
|
|
let mut last = String::new();
|
|
for _ in 0..60 {
|
|
last = http(
|
|
port,
|
|
&format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"),
|
|
);
|
|
let keys = cb_render_html::jsrun::droppables(&last);
|
|
let spatial = keys
|
|
.iter()
|
|
.find(|(k, t)| k.starts_with("action-") && t.is_some())
|
|
.map(|(k, t)| {
|
|
(
|
|
k.clone(),
|
|
t.as_ref()
|
|
.expect("checked")
|
|
.split(' ')
|
|
.next()
|
|
.expect("t")
|
|
.to_string(),
|
|
)
|
|
});
|
|
let button = keys
|
|
.iter()
|
|
.find(|(k, _)| k.starts_with("cmd-") || k == "pass")
|
|
.map(|(k, _)| (k.clone(), k.clone()));
|
|
let Some((down, up)) = spatial.or(button) else {
|
|
return last;
|
|
};
|
|
let body = format!("down={down}&up={up}");
|
|
http(
|
|
port,
|
|
&format!(
|
|
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
|
|
Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}",
|
|
body.len()
|
|
),
|
|
);
|
|
}
|
|
last
|
|
}
|
|
|
|
fn http(port: u16, raw: &str) -> String {
|
|
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
|
s.write_all(raw.as_bytes()).expect("write");
|
|
let mut out = String::new();
|
|
let _ = s.read_to_string(&mut out);
|
|
out
|
|
}
|
|
|
|
/// **The chain, not the links.** `the_end_of_the_game_reaches_the_browser`
|
|
/// calls `serve_end` directly, so it passes even when `run_game` never
|
|
/// calls it — proven: deleting that call left it green. That is the
|
|
/// defect class this project keeps meeting (CB-EV-0012: *"every link
|
|
/// was tested and the chain was not"*), and it survived one round of
|
|
/// it here before this test existed.
|
|
///
|
|
/// So: run the real `play()` with a browser seat, drive a real game to
|
|
/// its end over a real socket, and require the last page to be the
|
|
/// ending rather than a refused connection.
|
|
#[test]
|
|
fn a_real_game_played_to_its_end_leaves_the_ending_on_screen() {
|
|
let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let out = SharedOut(buf.clone());
|
|
let game = std::thread::spawn(move || {
|
|
crate::table::play(
|
|
&crate::table::Config {
|
|
seed: 7,
|
|
players: 3,
|
|
human_seats: vec![0],
|
|
bot: "random".into(),
|
|
replay_dir: None,
|
|
record: None,
|
|
serve: Some(0),
|
|
trial: None,
|
|
mode: games_ground::ScoringMode::SharedGround,
|
|
},
|
|
std::io::Cursor::new(Vec::new()),
|
|
out,
|
|
)
|
|
});
|
|
|
|
// The URL is printed as soon as the listener binds.
|
|
let url = loop {
|
|
let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string();
|
|
if let Some(i) = text.find("http://127.0.0.1:") {
|
|
let rest = &text[i..];
|
|
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
|
break rest[..end].to_string();
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
};
|
|
let port: u16 = url["http://127.0.0.1:".len()..]
|
|
.split('/')
|
|
.next()
|
|
.expect("port")
|
|
.parse()
|
|
.expect("port number");
|
|
let token = url.split("t=").nth(1).expect("token").to_string();
|
|
|
|
// Play until the page stops offering moves — which is the ending.
|
|
let mut last = String::new();
|
|
for _ in 0..60 {
|
|
last = http(
|
|
port,
|
|
&format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"),
|
|
);
|
|
let keys = cb_render_html::jsrun::droppables(&last);
|
|
// A spatial move if one is offered; otherwise the numbered
|
|
// fallback or pass, which is what the page offers at steps
|
|
// with no draggable action. Breaking here instead would end
|
|
// the walk mid-game and assert nothing.
|
|
let spatial = keys
|
|
.iter()
|
|
.find(|(k, t)| k.starts_with("action-") && t.is_some())
|
|
.map(|(k, t)| {
|
|
(
|
|
k.clone(),
|
|
t.as_ref()
|
|
.expect("checked")
|
|
.split(' ')
|
|
.next()
|
|
.expect("a target")
|
|
.to_string(),
|
|
)
|
|
});
|
|
let button = keys
|
|
.iter()
|
|
.find(|(k, _)| k.starts_with("cmd-") || k == "pass")
|
|
.map(|(k, _)| (k.clone(), k.clone()));
|
|
let Some((down, up)) = spatial.or(button) else {
|
|
break;
|
|
};
|
|
let body = format!("down={down}&up={up}");
|
|
http(
|
|
port,
|
|
&format!(
|
|
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
|
|
Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}",
|
|
body.len()
|
|
),
|
|
);
|
|
}
|
|
|
|
// Before CB-WP-0018 this GET was a refused connection and the
|
|
// whole result went to a terminal nobody was reading.
|
|
assert!(
|
|
last.contains("game over"),
|
|
"the last page the player saw was not the ending: {}",
|
|
&last[..last.len().min(300)]
|
|
);
|
|
assert!(last.contains("commands, hash"), "no result on the page");
|
|
// CB-WP-0018 T02: the log is on the page, and it came from the
|
|
// journal the driver filled -- not from re-reading the state.
|
|
assert!(last.contains("<h2>log</h2>"), "no log section");
|
|
assert!(
|
|
!last.contains("nothing has happened yet"),
|
|
"a finished game reported an empty log"
|
|
);
|
|
assert!(
|
|
last.contains("select_action"),
|
|
"the log is not in the recorder's vocabulary"
|
|
);
|
|
|
|
// Let the game thread finish: tell it the result has been seen.
|
|
let body = "down=done&up=done";
|
|
http(
|
|
port,
|
|
&format!(
|
|
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
|
|
Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}",
|
|
body.len()
|
|
),
|
|
);
|
|
game.join().expect("game thread").expect("the game ran");
|
|
}
|
|
|
|
fn converse(port: u16, requests: Vec<String>) -> std::thread::JoinHandle<Vec<String>> {
|
|
std::thread::spawn(move || {
|
|
requests
|
|
.into_iter()
|
|
.map(|raw| {
|
|
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
|
s.write_all(raw.as_bytes()).expect("write");
|
|
let mut out = String::new();
|
|
let _ = s.read_to_string(&mut out);
|
|
out
|
|
})
|
|
.collect()
|
|
})
|
|
}
|
|
|
|
fn get(token: &str) -> String {
|
|
format!(
|
|
"GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\
|
|
Sec-Fetch-Site: same-origin\r\n\r\n"
|
|
)
|
|
}
|
|
|
|
/// One form POST. **`Content-Length` is derived from the body**, which
|
|
/// is the whole reason to have this rather than hand-written request
|
|
/// literals: the first draft of the post-game note test wrote
|
|
/// `Content-Length: 21` beside a 22-byte body, the server read 21 of
|
|
/// them, and the note came back `400 unrecognised field` — a failure
|
|
/// that looked exactly like the feature being broken.
|
|
///
|
|
/// `path` because the note channel is a second endpoint (CB-WP-0031).
|
|
fn post(token: &str, path: &str, body: &str) -> String {
|
|
format!(
|
|
"POST {path}?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\
|
|
Sec-Fetch-Site: same-origin\r\nContent-Length: {}\r\n\r\n{body}",
|
|
body.len()
|
|
)
|
|
}
|
|
|
|
fn ground_only() -> Vec<GroundCommand> {
|
|
vec![GroundCommand::SelectAction {
|
|
action: Action::Ground,
|
|
target: None,
|
|
problem: None,
|
|
}]
|
|
}
|
|
|
|
fn state() -> GroundState {
|
|
<GroundState as cb_game_runtime::ScenarioGame>::setup(
|
|
&cb_game_runtime::Setup {
|
|
players: 3,
|
|
preset: "standard-3p".to_string(),
|
|
patch: Default::default(),
|
|
},
|
|
7,
|
|
)
|
|
.expect("setup")
|
|
}
|
|
|
|
/// The whole loop, with a real socket and no browser: the page is
|
|
/// served, a pointer fact is posted, and the seat's choice comes back.
|
|
#[test]
|
|
fn a_drag_posted_over_the_socket_becomes_a_choice() {
|
|
let server = Server::bind(0).expect("bind");
|
|
let port = server.listener.local_addr().unwrap().port();
|
|
let token = server.url().rsplit("t=").next().unwrap().to_string();
|
|
let client = converse(
|
|
port,
|
|
vec![
|
|
get(&token),
|
|
post(&token, "/command", "down=action-ground&up=table"),
|
|
],
|
|
);
|
|
|
|
let choice = server
|
|
.next_choice(&state(), PlayerId(0), &ground_only(), false)
|
|
.expect("a choice");
|
|
assert_eq!(choice, Choice::Command(0));
|
|
|
|
let replies = client.join().expect("client thread");
|
|
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
|
|
assert!(replies[0].contains("GROUND"), "the page was not the table");
|
|
// Through the parser, not a raw attribute match: this assertion
|
|
// read `id="action-ground"` and CB-WP-0016 moved drop keys to
|
|
// `data-drop`, so a substring test drifts silently on the next
|
|
// rename while still describing itself as checking the page.
|
|
assert!(
|
|
cb_render_html::doc::drop_keys(&replies[0]).contains("action-ground"),
|
|
"the offered action was not a drop target on the page"
|
|
);
|
|
assert!(replies[1].contains("200 OK"));
|
|
assert!(server.refusals().is_empty());
|
|
}
|
|
|
|
/// ADR-0007 control 1, end to end rather than in the unit: a page
|
|
/// without the token gets nothing, and the seat keeps its turn.
|
|
///
|
|
/// M-D1-MUT: make `admit` return `Ok(())` unconditionally and this
|
|
/// goes red — the token-less request is served the table. Run
|
|
/// 2026-08-02.
|
|
#[test]
|
|
fn a_token_less_request_over_the_socket_is_refused_and_the_turn_continues() {
|
|
let server = Server::bind(0).expect("bind");
|
|
let port = server.listener.local_addr().unwrap().port();
|
|
let token = server.url().rsplit("t=").next().unwrap().to_string();
|
|
let client = converse(
|
|
port,
|
|
vec![
|
|
"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"
|
|
.to_string(),
|
|
post(&token, "/command", "down=action-ground&up=table"),
|
|
],
|
|
);
|
|
|
|
// The turn survives the refusal and is decided by the good request.
|
|
let choice = server
|
|
.next_choice(&state(), PlayerId(0), &ground_only(), false)
|
|
.expect("a choice");
|
|
assert_eq!(choice, Choice::Command(0));
|
|
|
|
let replies = client.join().expect("client thread");
|
|
assert!(replies[0].contains("403 Forbidden"), "{}", replies[0]);
|
|
assert!(
|
|
!replies[0].contains("GROUND"),
|
|
"the table leaked to a token-less request"
|
|
);
|
|
assert!(replies[1].contains("200 OK"));
|
|
assert_eq!(
|
|
server.refusals(),
|
|
vec!["refused: no session token".to_string()]
|
|
);
|
|
}
|
|
|
|
/// A drag that means nothing must not end the turn, and must say so.
|
|
#[test]
|
|
fn a_meaningless_drag_keeps_the_turn() {
|
|
let server = Server::bind(0).expect("bind");
|
|
let port = server.listener.local_addr().unwrap().port();
|
|
let token = server.url().rsplit("t=").next().unwrap().to_string();
|
|
let client = converse(
|
|
port,
|
|
vec![
|
|
post(&token, "/command", "down=seat-1&up=seat-2"),
|
|
post(&token, "/command", "down=action-ground&up=table"),
|
|
],
|
|
);
|
|
|
|
let choice = server
|
|
.next_choice(&state(), PlayerId(0), &ground_only(), false)
|
|
.expect("a choice");
|
|
assert_eq!(choice, Choice::Command(0));
|
|
|
|
let replies = client.join().expect("client thread");
|
|
assert!(replies[0].contains("200 OK"));
|
|
// CB-WP-0020 T03: the refusal is in the game's words now, not the
|
|
// DOM's. It read `action-attack -> action-attack is not a legal
|
|
// move here` on the surface a player reads when something goes
|
|
// wrong — element ids, the same defect the log had.
|
|
assert!(
|
|
replies[0].contains("not a move you can make")
|
|
|| replies[0].contains("needs to be dropped on"),
|
|
"a meaningless drag must be told it meant nothing, in words: {}",
|
|
replies[0]
|
|
);
|
|
assert!(
|
|
!replies[0].contains("action-"),
|
|
"the refusal leaked an element id: {}",
|
|
replies[0]
|
|
);
|
|
assert!(replies[1].contains("200 OK"));
|
|
}
|
|
|
|
/// **The loop, closed.** The real server serves the real page; a real
|
|
/// JavaScript engine runs the page's own script and produces a pointer
|
|
/// gesture; what it produces goes over a real socket; and the seat's
|
|
/// choice comes back.
|
|
///
|
|
/// Before ADR-0009 every link in that chain was tested and the chain
|
|
/// was not. The page was asserted against as a parsed document and the
|
|
/// socket was driven by synthetic HTTP that this test suite wrote
|
|
/// itself — so a page whose JavaScript sent something else entirely
|
|
/// would have passed everything.
|
|
#[test]
|
|
fn a_gesture_in_javascript_becomes_a_move_in_the_game() {
|
|
let server = Server::bind(0).expect("bind");
|
|
let port = server.listener.local_addr().unwrap().port();
|
|
let token = server.url().rsplit("t=").next().unwrap().to_string();
|
|
|
|
let tok = token.clone();
|
|
let client = std::thread::spawn(move || {
|
|
let token = tok;
|
|
// 1. fetch the page the server actually serves
|
|
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
|
s.write_all(get(&token).as_bytes()).expect("write");
|
|
let mut page = String::new();
|
|
let _ = s.read_to_string(&mut page);
|
|
assert!(page.contains("200 OK"), "{page}");
|
|
|
|
// 2. run ITS script, in a real engine, with a real gesture
|
|
let posts = cb_render_html::jsrun::gesture(&page, "action-ground", "table")
|
|
.expect("the served page's script runs");
|
|
assert_eq!(posts.len(), 1, "{posts:?}");
|
|
|
|
// 3. send exactly what the JavaScript produced — not what this
|
|
// test thinks it should have produced
|
|
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
|
s.write_all(post(&token, "/command", &posts[0].body).as_bytes())
|
|
.expect("write");
|
|
let mut reply = String::new();
|
|
let _ = s.read_to_string(&mut reply);
|
|
(posts[0].clone(), reply)
|
|
});
|
|
|
|
let choice = server
|
|
.next_choice(&state(), PlayerId(0), &ground_only(), false)
|
|
.expect("a choice");
|
|
assert_eq!(choice, Choice::Command(0));
|
|
|
|
let (posted, reply) = client.join().expect("client thread");
|
|
assert_eq!(posted.body, "down=action-ground&up=table");
|
|
// The endpoint the JS used carries the server's own token, which
|
|
// the page cannot mint — so this also proves the token round-trips
|
|
// through the emitted document.
|
|
assert!(posted.url.contains(&token), "{}", posted.url);
|
|
assert!(reply.contains("200 OK"), "{reply}");
|
|
assert!(server.refusals().is_empty(), "{:?}", server.refusals());
|
|
}
|
|
|
|
/// **Hot-seat**: two seats, one browser, one listener — and each seat
|
|
/// is shown its own hand and nobody else's.
|
|
///
|
|
/// This is the deliverable that was closest to being claimed on the
|
|
/// strength of the code path existing. `SeatPolicy` gives every human
|
|
/// seat a handle on one shared `Server`, so turn-taking "obviously"
|
|
/// works; nothing drove more than one seat until this test.
|
|
///
|
|
/// The property that matters is not that two turns happen. It is that
|
|
/// the *projection follows the seat* — the same tab, asked twice,
|
|
/// must show two different hands.
|
|
#[test]
|
|
fn two_seats_take_turns_through_one_listener_and_see_different_hands() {
|
|
let server = Server::bind(0).expect("bind");
|
|
let port = server.listener.local_addr().unwrap().port();
|
|
let token = server.url().rsplit("t=").next().unwrap().to_string();
|
|
|
|
let tok = token.clone();
|
|
let client = std::thread::spawn(move || {
|
|
let mut pages = Vec::new();
|
|
for _ in 0..2 {
|
|
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
|
s.write_all(get(&tok).as_bytes()).expect("write");
|
|
let mut page = String::new();
|
|
let _ = s.read_to_string(&mut page);
|
|
pages.push(page);
|
|
|
|
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
|
s.write_all(post(&tok, "/command", "down=action-ground&up=table").as_bytes())
|
|
.expect("write");
|
|
let mut reply = String::new();
|
|
let _ = s.read_to_string(&mut reply);
|
|
}
|
|
pages
|
|
});
|
|
|
|
let state = state();
|
|
for seat in [0u8, 1] {
|
|
let choice = server
|
|
.next_choice(&state, PlayerId(seat), &ground_only(), false)
|
|
.expect("a choice");
|
|
assert_eq!(choice, Choice::Command(0), "seat P{}", seat + 1);
|
|
}
|
|
|
|
let pages = client.join().expect("client thread");
|
|
assert_eq!(pages.len(), 2);
|
|
for (i, page) in pages.iter().enumerate() {
|
|
let text = cb_render_html::text_of(page);
|
|
assert!(
|
|
text.contains(&format!("viewing as P{}", i + 1)),
|
|
"turn {i} was not served to P{}",
|
|
i + 1
|
|
);
|
|
// K13: exactly one open hand per page, and it is this seat's.
|
|
assert_eq!(
|
|
text.matches("cards)").count(),
|
|
1,
|
|
"turn {i} showed {} open hands",
|
|
text.matches("cards)").count()
|
|
);
|
|
}
|
|
// And the two turns were genuinely different views, not the same
|
|
// page served twice — which is how this test would pass vacuously.
|
|
assert_ne!(pages[0], pages[1], "both turns served an identical page");
|
|
}
|
|
}
|