diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index c436109..35c43a8 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -56,7 +56,7 @@ fn cards(list: &[games_ground::SolutionCard]) -> String { /// The only JavaScript in the project. See the module docs. pub const SCRIPT: &str = r#" (function () { - var down = null, held = null, marked = [], ghost = null; + var down = null, held = null, marked = [], ghost = null, ghostLabel = ''; function node(e) { var n = e.target; @@ -70,6 +70,19 @@ pub const SCRIPT: &str = r#" // wrote. This matches on them. It does not compute, infer, filter or // default one -- a script that pattern-matched ids to guess what is // legal would be forbidden even though the visible result is identical. + // ADR-0010 D1 again: `data-descs` is written by Rust, in step with + // `data-targets`. The script pairs them by index and renders one. It + // does not compose a description from an id. + function describeOf(held, key) { + if (!held) { return null; } + var t = held.getAttribute('data-targets'); + var d = held.getAttribute('data-descs'); + if (!t || !d) { return null; } + var i = t.split(' ').indexOf(key); + var all = d.split('|'); + return i >= 0 && i < all.length ? all[i] : null; + } + function mark(n) { var spec = n.getAttribute('data-targets'); if (!spec) { return; } @@ -88,6 +101,7 @@ pub const SCRIPT: &str = r#" if (held) { held.classList.remove('held'); held = null; } if (ghost && ghost.parentNode) { ghost.parentNode.removeChild(ghost); } ghost = null; + ghostLabel = ''; down = null; } @@ -102,7 +116,8 @@ pub const SCRIPT: &str = r#" if (n.getAttribute('data-targets')) { ghost = document.createElement('div'); ghost.id = 'cb-ghost'; - ghost.textContent = n.textContent; + ghostLabel = n.textContent; + ghost.textContent = ghostLabel; ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; document.body.appendChild(ghost); @@ -113,6 +128,11 @@ pub const SCRIPT: &str = r#" if (!ghost) { return; } ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; + // The explanation, beside the pointer and therefore beside the + // target it is over (CB-WP-0018 T03). + var over = describeOf(held, key(node(e))); + ghost.textContent = over || ghostLabel; + ghost.className = over ? 'over' : ''; }); document.addEventListener('pointercancel', clear); @@ -165,11 +185,15 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf} .dropok{outline:2px dashed #9cf;outline-offset:3px;background:#1d2a33} .dropok text{fill:#cfe} /* The ghost that follows the pointer, so a drag is not invisible. */ +#cb-ghost.over{background:#1d3347;border-color:#9cf;color:#cfe} #cb-ghost{position:fixed;pointer-events:none;z-index:9;padding:.3rem .5rem; border-radius:6px;background:#243;border:1px solid #5a7;color:#dde; font:13px ui-monospace,monospace;box-shadow:0 6px 16px #000b; transform:translate(-50%,-140%)} .k{color:#89a} +.nil{color:#c88} +.eff{color:#8c9} +#cb-log{max-height:16rem;overflow-y:auto;font-size:13px} #cb-status{margin-top:1rem;color:#fc9;min-height:1.2em} svg{background:#1b1e26;border:1px solid #445;border-radius:6px} "; @@ -405,6 +429,18 @@ pub fn document( endpoint: &str, seat: Option, may_pass: bool, +) -> String { + document_with_log(view, legal, endpoint, seat, may_pass, &[]) +} + +/// The table, plus the game log (CB-WP-0018 T02). +pub fn document_with_log( + view: &GroundView, + legal: &[games_ground::GroundCommand], + endpoint: &str, + seat: Option, + may_pass: bool, + log: &[LogLine], ) -> String { let mut s = String::with_capacity(8192); let _ = write!( @@ -431,6 +467,25 @@ pub fn document( }, ); + body(&mut s, view); + move_section(&mut s, legal, seat, may_pass); + log_section(&mut s, log); + let _ = write!( + s, + "
ready
\ + \ + ", + endpoint = json_string(endpoint), + ); + s +} + +/// The table itself: problems, relationships, seats, solutions, outcome. +/// +/// Factored out of [`document`] so [`ending`] shows the SAME table rather +/// than a second rendering of it — two renderings of one state is how +/// they drift. +fn body(s: &mut String, view: &GroundView) { s.push_str( "

problems

", ); @@ -440,7 +495,7 @@ pub fn document( ); } for (i, (priority, p)) in view.problems.iter().enumerate() { - problem_svg(&mut s, *priority, p, 10 + (i as i32) * 130); + problem_svg(s, *priority, p, 10 + (i as i32) * 130); } s.push_str(""); @@ -449,7 +504,7 @@ pub fn document( s.push_str("

seats

"); for (id, p) in &view.players { - player_card(&mut s, *id, p, view); + player_card(s, *id, p, view); } s.push_str("
"); @@ -511,7 +566,16 @@ pub fn document( winners = esc(&winners), ); } +} +/// The "your move" section: action cards, numbered fallbacks, the table, +/// and pass. Emits nothing when the seat has nothing legal to do. +fn move_section( + s: &mut String, + legal: &[games_ground::GroundCommand], + seat: Option, + may_pass: bool, +) { if !legal.is_empty() { s.push_str("

your move

"); for a in [ @@ -529,24 +593,32 @@ pub fn document( // wherever the real target set was narrower, which is almost // everywhere: Investigate is legal on problems 2 and 3 but // not 1 (CB-WP-0017). - let targets: Vec = seat + // CB-WP-0018 T03: targets and their meanings, in step, both + // written by Rust. ADR-0010 D1 forbids the page composing the + // second from the first. + let offered: Vec<(String, String)> = seat .map(|seat| { legal .iter() - .filter_map(|c| crate::input::affordance(c, seat)) - .filter(|(f, _)| *f == action_id(a)) - .map(|(_, t)| t) + .filter_map(|c| { + crate::input::affordance(c, seat) + .filter(|(f, _)| *f == action_id(a)) + .map(|(_, t)| (t, crate::input::describe(c, seat))) + }) .collect() }) .unwrap_or_default(); + let targets: Vec = offered.iter().map(|(t, _)| t.clone()).collect(); + let descs: Vec = offered.iter().map(|(_, d)| d.clone()).collect(); if !targets.is_empty() { let _ = write!( s, "
{a:?}
\ + data-targets=\"{targets}\" data-descs=\"{descs}\">{a:?}
\ onto {names}
", id = action_id(a), targets = esc(&targets.join(" ")), + descs = esc(&descs.join("|")), names = esc(&target_names(&targets)), ); } @@ -572,14 +644,6 @@ pub fn document( "
pass \u{2014} decline to act
", ); } - - let _ = write!( - s, - "
ready
\ - ", - json_string(endpoint), - ); - s } /// Quote a string as a JSON literal, so a token can never end the script. @@ -622,6 +686,86 @@ fn json_string(s: &str) -> String { /// element could ever be `seat-0` — the relationship-graph circle took it /// and the seat card the instruction text points at went without. A seat /// is drawn twice and both drawings are the seat. +/// One line of the game log: what was done, and what it produced. +/// +/// Built by the caller from `bot::Applied` so this crate stays free of +/// the driver, and phrased in the **recorder's** vocabulary via +/// `record::to_step` — CB-WP-0018 T02 forbids a fourth phrasing, because +/// what the player reads should be what the scenario file will say. +pub struct LogLine { + pub who: String, + pub what: String, + /// Rendered effects. **Empty is the case that matters:** a command + /// that produced no events is the one the player cannot otherwise + /// account for. + pub effects: Vec, +} + +/// The game log, newest last, with the empty case spelled out. +fn log_section(s: &mut String, log: &[LogLine]) { + s.push_str("

log

"); + if log.is_empty() { + s.push_str("nothing has happened yet"); + } + for line in log { + let _ = write!( + s, + "
{who} {what}", + who = esc(&line.who), + what = esc(&line.what), + ); + if line.effects.is_empty() { + // The silence CB-WP-0018 was reported for, said out loud. + s.push_str(" \u{2014} no effect"); + } else { + for e in &line.effects { + let _ = write!(s, "
\u{2192} {}", esc(e)); + } + } + s.push_str("
"); + } + s.push_str("
"); +} + +/// The page a finished game leaves behind (CB-WP-0018 T01). +/// +/// `view` is `None` when the game ended badly: then there is no result to +/// draw and saying so is the whole point. Drawing a table for a game that +/// crashed would be the same lie the empty page told, dressed up. +/// +/// **This page does not auto-reload.** The old one reloaded on `ok` and +/// the reload was refused, which is how a completed game became a blank +/// tab. +pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[LogLine]) -> String { + let mut s = String::with_capacity(4096); + let _ = write!( + s, + "\ + \ + GROUND — game over\ +

game over

{msg}
", + msg = esc(message), + ); + match view { + Some(v) => body(&mut s, v), + None => { + s.push_str( + "
The game ended without a result, so there \ + is no final table to show. The reason is above.
", + ); + } + } + log_section(&mut s, log); + let _ = write!( + s, + "
close \u{2014} I have read this
\ +
the game is over
\ + ", + endpoint = json_string(endpoint), + ); + s +} + pub fn drop_keys(html: &str) -> std::collections::BTreeSet { let mut out = std::collections::BTreeSet::new(); let mut rest = html; diff --git a/crates/cb-render-html/src/input.rs b/crates/cb-render-html/src/input.rs index 8e2e604..6351740 100644 --- a/crates/cb-render-html/src/input.rs +++ b/crates/cb-render-html/src/input.rs @@ -96,6 +96,40 @@ pub fn affordance(command: &GroundCommand, seat: cb_kernel::PlayerId) -> Option< } } +/// What a drop would mean, in a sentence. +/// +/// **ADR-0010 Decision 1 puts this in Rust.** The page may render it; it +/// may not compose it. A script that assembled "Attack P2" from an id and +/// a seat name would be deriving a game fact, and would be wrong the +/// moment a command meant something the ids do not say. +pub fn describe(command: &GroundCommand, seat: cb_kernel::PlayerId) -> String { + let who = |p: cb_kernel::PlayerId| format!("P{}", p.0 + 1); + match command { + GroundCommand::SelectAction { + action, + target, + problem, + } => { + let head = match action { + Action::Attack => "Attack", + Action::Support => "Support", + Action::Solve => "Solve", + Action::Investigate => "Investigate", + Action::Ground => "Ground", + }; + match (target, problem) { + (Some(t), _) => format!("commit {head} against {}", who(*t)), + (_, Some(n)) => format!("commit {head} on problem {n}"), + _ => format!("commit {head}, untargeted"), + } + } + GroundCommand::SpendFreedom => { + format!("{} spends freedom to act again", who(seat)) + } + other => format!("{other:?}"), + } +} + /// Resolve a pointer fact to an index into the legal list. /// /// Returns `Err` rather than a default when nothing matches: a drag that diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs index 5713ee3..4a84f60 100644 --- a/crates/cb-render-html/src/jsrun.rs +++ b/crates/cb-render-html/src/jsrun.rs @@ -64,13 +64,14 @@ var __all = []; // A DOM node with a real classList and real attributes. CB-WP-0016 found // that a stub too thin to express a failure is how the failure survives; // the previous stub could not express class-toggling at all. -function __mk(key, targets, text) { +function __mk(key, targets, text, descs) { var n = { parentNode: null, textContent: text || key, style: {}, _cls: {}, - _attr: { 'data-drop': key, 'data-targets': targets || null }, + _attr: { 'data-drop': key, 'data-targets': targets || null, + 'data-descs': descs || null }, getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; }, setAttribute: function (a, v) { this._attr[a] = v; }, classList: { @@ -81,7 +82,7 @@ function __mk(key, targets, text) { }; return n; } -function __register(key, targets) { var n = __mk(key, targets); __all.push(n); return n; } +function __register(key, targets, descs) { var n = __mk(key, targets, key, descs); __all.push(n); return n; } var document = { body: __body, @@ -103,7 +104,9 @@ function fetch(url, opts) { // every one of them (CB-EV-0014 section 2). function __down(k) { __handlers['pointerdown']({ target: __find(k), clientX: 1, clientY: 2 }); } function __up(k) { __handlers['pointerup']({ target: __find(k), clientX: 3, clientY: 4 }); } -function __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9 }); } } +function __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9, target: __mk(null, null, "") }); } } +function __moveOver(k) { __handlers['pointermove']({ clientX: 5, clientY: 5, target: __find(k) }); } +function __ghostText() { return __body.children.length ? __body.children[0].textContent : ""; } function __cancel() { __handlers['pointercancel']({ target: __find(null) }); } function __find(k) { for (var i = 0; i < __all.length; i++) { @@ -230,12 +233,29 @@ pub fn prepare(ctx: &quick_js::Context, html: &str) -> Result<(), String> { Ok(()) } +/// The `data-descs` of the element carrying `key`, if any. +pub fn descriptions(html: &str, key: &str) -> Option { + let needle = format!("data-drop=\"{key}\""); + let i = html.find(&needle)?; + let tail = &html[i..]; + let end = tail.find('>')?; + let k = tail[..end].find("data-descs=\"")?; + let t = &tail[k + 12..]; + t.find('"').map(|e| t[..e].to_string()) +} + fn register(ctx: &quick_js::Context, html: &str) -> Result<(), String> { for (key, targets) in droppables(html) { - let call = match targets { - Some(t) => format!("__register({}, {});", json_lit(&key), json_lit(&t)), - None => format!("__register({}, null);", json_lit(&key)), - }; + // Descriptions are registered too: the stub could not express + // `data-descs` at all, so the first version of the description + // test failed against a DOM that simply did not carry them. + let descs = descriptions(html, &key); + let call = format!( + "__register({}, {}, {});", + json_lit(&key), + targets.map(|t| json_lit(&t)).unwrap_or("null".into()), + descs.map(|d| json_lit(&d)).unwrap_or("null".into()), + ); ctx.eval(&call) .map_err(|e| format!("register {key}: {e}"))?; } @@ -274,6 +294,15 @@ mod tests { target: Some(PlayerId(1)), problem: None, }, + // TWO attack targets on purpose. With one, an off-by-one in + // the target/description pairing is a no-op and the mutations + // that should catch it survive — a fixture too thin to express + // the failure, which is how the failure survives (CB-EV-0014). + GroundCommand::SelectAction { + action: Action::Attack, + target: Some(PlayerId(2)), + problem: None, + }, ]; crate::doc::document( &crate::testfix::view(Some(PlayerId(0))), @@ -536,6 +565,59 @@ mod tests { } } + /// **ADR-0010 D1 for descriptions.** Dragging over a legal target + /// shows the sentence Rust wrote **for that pair** — not one the page + /// assembled, and not a neighbouring pair's. + #[test] + fn dragging_over_a_target_shows_the_description_rust_wrote_for_it() { + let html = page(); + let ctx = ctx_for(&html); + + // What Rust wrote on the card, read back out of the document. + let card = droppables(&html) + .into_iter() + .find(|(k, _)| k == "action-attack") + .expect("the attack card"); + let targets = card.1.expect("targets"); + let descs = descriptions(&html, "action-attack").expect("descs"); + let i = targets + .split(' ') + .position(|t| t == "seat-1") + .expect("seat-1 is a target"); + let want = descs.split('|').nth(i).expect("a description").to_string(); + assert!(!want.is_empty(), "the description is blank"); + + ctx.eval("__down('action-attack'); __moveOver('seat-1');") + .expect("drag over"); + let shown: String = ctx.eval_as("__ghostText()").expect("ghost text"); + assert_eq!(shown, want); + + // Over nothing droppable it falls back to the label, rather than + // keeping a stale explanation pointing at the wrong element. + ctx.eval("__move();").expect("move away"); + let away: String = ctx.eval_as("__ghostText()").expect("ghost text"); + assert_ne!(away, want, "the explanation stuck after leaving the target"); + } + + /// Every advertised target has a description; a card offering three + /// drops and two sentences would silently mispair them. + #[test] + fn every_advertised_target_carries_its_own_description() { + let html = page(); + for (key, targets) in droppables(&html) { + let Some(targets) = targets else { continue }; + let descs = descriptions(&html, &key) + .unwrap_or_else(|| panic!("{key} advertises targets but no descriptions")); + assert_eq!( + targets.split(' ').count(), + descs.split('|').count(), + "{key}: {} target(s) but {} description(s)", + targets.split(' ').count(), + descs.split('|').count() + ); + } + } + #[test] fn both_script_blocks_are_extracted_in_order() { let found = scripts(&page()); diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 1b15091..58898b7 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -573,5 +573,59 @@ mod affordances { } } +#[cfg(test)] +mod gamelog { + //! CB-WP-0018 T02. + + use crate::doc::{document_with_log, LogLine}; + use cb_kernel::PlayerId; + + fn line(effects: &[&str]) -> LogLine { + LogLine { + who: "P1".into(), + what: "select_action action=SOLVE problem=1".into(), + effects: effects.iter().map(|s| (*s).to_string()).collect(), + } + } + + fn page(log: &[LogLine]) -> String { + document_with_log( + &crate::testfix::view(Some(PlayerId(0))), + &[], + "/command?t=x", + Some(PlayerId(0)), + false, + log, + ) + } + + /// **The case the pass was reported for.** A SOLVE that cannot be + /// fulfilled produces no events, and a log built only from events + /// would render nothing for it — reproducing the silence the + /// maintainer hit when the same move did nothing three rounds + /// running. + #[test] + fn a_command_that_produced_nothing_says_so() { + let html = page(&[line(&[])]); + assert!( + html.contains("no effect"), + "a command with no events rendered as if it had done something" + ); + let text = crate::text_of(&html); + assert!(text.contains("select_action action=SOLVE problem=1")); + } + + #[test] + fn effects_are_listed_and_an_empty_log_says_it_is_empty() { + let text = crate::text_of(&page(&[line(&["problem 1 claimed by P1"])])); + assert!(text.contains("problem 1 claimed by P1")); + assert!( + !text.contains("no effect"), + "a command WITH effects was marked as having none" + ); + assert!(crate::text_of(&page(&[])).contains("nothing has happened yet")); + } +} + #[cfg(test)] mod testfix; diff --git a/evidence/CB-EV-0016-the-browser-is-a-client.md b/evidence/CB-EV-0016-the-browser-is-a-client.md new file mode 100644 index 0000000..d78a419 --- /dev/null +++ b/evidence/CB-EV-0016-the-browser-is-a-client.md @@ -0,0 +1,182 @@ +# CB-EV-0016 — the browser is a client, and window 1's verdict + +CB-WP-0018 T04. Measured 2026-08-03 at `e8bb726`+. Pass kind `product`, +tier **M** (structural M — changes the loop's own constraints; chaos d4=3, +no override). **Declaration 1 of chaos window 2**, opened by this pass. + +--- + +## 1. A finished game was indistinguishable from a crash + +Reported: *"after some time i get an empty page back. I guess the game +crashes or ends but that is unclear as the ui disappears."* + +Reproduced before touching anything, by driving a real game to completion +over HTTP: + +``` +move 5 accepted → ok +GET / → [Errno 111] Connection refused +``` + +The game had **ended normally** — 5 rounds, 30 commands — and its entire +result went to a terminal nobody was reading. `next_choice` only accepts +connections *inside* a human decision point, so when `play()` returned the +listener died and the page's post-`ok` reload was refused. + +**The browser was a second-class client.** The outcome, the scores, the +winners, and every error `run_game` can return were invisible to the only +interface a player uses. Now: an 8,998-byte page reading *"GROUND — game +over … 30 commands, hash f6c890a65271"*, with the final table and the log. + +It serves until the page posts `done` — the ending carries a *"close — I +have read this"* control — with a 600 s linger, because a server that +never exits is its own defect and a timeout would race a player reading +the result. + +## 2. The control had to be built twice, and the first was worthless + +`the_end_of_the_game_reaches_the_browser` calls `serve_end` directly. It +passes. **Deleting the call from `run_game` left it green.** + +That is CB-EV-0012's finding recurring almost verbatim — *"every link was +tested and the chain was not"* — and it survived one full round of +mutation here before anyone noticed, because the mutation was run and the +verdict was read as "no coverage gap" rather than "the test is in the +wrong place". + +`a_real_game_played_to_its_end_leaves_the_ending_on_screen` runs the real +`play()` with a browser seat, drives a real game to its end over a real +socket, and requires the last page to be the ending. Under the same +mutation it goes red — and its failure message prints an **empty page**, +which is precisely the symptom that was reported. + +**A weak assertion of mine, caught by itself.** The first version grepped +the ending page for `location.reload`. The page reuses `SCRIPT`, whose +reload is guarded by `t.indexOf('ok') === 0`, and the ending endpoint +answers `closed` — so the grep would have forced a second script into +existence to satisfy a test rather than a requirement. That is the +source-text control shape ADR-0010 D2 demoted three passes ago, reappearing +in my own hands. + +## 3. The card report, and what it actually was + +Reported: *"the cards I play by pulling them on a target will not be +removed … we will need a discard pile."* + +**A discard pile already existed** — `solution_discard` on `GroundState`, +`SolutionDiscarded` removing the card from the hand, and the page already +rendering `deck N remaining / discard …`. Building one would have been +building a thing that was there, and the only reason that did not happen is +that the code was read before the work started. + +What was being dragged are **action** cards. The five GROUND actions are +not cards and are correctly never consumed; solution cards leave the hand +at **Resolve**, because a selection is a face-down commit. + +But the report pointed at something real. Measured live: + +| move | hand | discard | +|---|---|---| +| 1 · Investigate → problem-2 | 2 cards | none | +| 2 · Investigate → problem-3 | **3 cards** | none | +| 3–5 · Solve → problem-1 | **4 cards, unchanged** | none | + +Investigate draws, correctly. **Solve was played three times and did +nothing, three times, in silence** — GR-A02's resolver `continue`s when the +problem is face-down, and `legal_commands` offers Solve on every face-up +problem without consulting the hand. + +**Raised for `ground-game`, not decided here** (INTENT defers game +semantics): *should SOLVE be selectable against a face-down problem, or +against a suit the seat cannot match?* A face-down commit you cannot +fulfil is a plausible bluff in a commit/reveal game with DARVO, which is +exactly why it is not this repo's call. + +## 4. The log, and the limit of what it can honestly say + +`bot::Journal` — `Applied { actor, command, events }` appended by the +driver through the new `play_journaled`; `play` delegates with `None`, so +nothing existing changed. `BotGame.events` is the same information but only +after `play` returns, which is no use to a page rendered mid-game. + +Phrased with `record::to_step`, so *what the player reads is what the +scenario file will say*, and all 29 `GroundEvent` variants render in words +rather than `{:?}`. + +**A command that produced no events says `no effect`**, and the mutation +removing that branch goes red. + +**The honest limit:** the reported SOLVE case does *not* render as `no +effect`, because the SOLVE resolves inside the system's `resolve` command, +which does produce events for other seats. The player now sees the +selection and sees no claim follow it — a large improvement on silence, but +still an inference. Making it explicit would require the renderer to decide +*why* a rule did nothing, which is a second implementation of the rules and +is what this task's own control forbids. Left as an inference deliberately. + +## 5. The explanation, and two mutations that a thin fixture defeated + +Every advertised target now carries a sentence Rust wrote for **that pair** +(`data-descs`, in step with `data-targets`), shown at the pointer while +dragging over it. ADR-0010 D1 binds: the page renders it, never composes +it. + +Both mutations — showing a neighbouring pair's text, and letting targets +and descriptions fall out of step — **initially survived**, because the +test fixture's Attack card had exactly **one** target, where an off-by-one +shift and a truncation are both no-ops. + +That is CB-EV-0014's lesson again, one level in: *a stub too thin to +express a failure is how the failure survives*. The fixture now offers two +attack targets on purpose, and both mutations go red. + +## 6. Window 1's verdict, and a rate change on n=2 + +Twelve declarations, two overrides, one each way, **both changed the +outcome** — so window 1's retirement condition was not met and the +mechanism is kept. The full table is in `InnerLoopReference.md` §Chaos +roll. + +**Rate dropped d4 → d8; window 2 opened at 12 declarations; new retirement +condition: retire if an override changes nothing twice running.** + +**The weakest part of this pass, stated plainly: it is a rate change argued +from n=2.** The alternative — keep d4 for a second window and decide with +four points — was live, and was rejected only because a quarter of all +declarations is a large standing tax to pay for evidence. So window 2 +carries a falsifier: **if it produces no override at all, that is evidence +the rate went too far**, not that the mechanism is healthy. A window that +cannot fire cannot be evaluated, which is the exact failure d10 had. + +## 7. Cost + +| pass | kind | responses | cost | $/response | +|---|---|---|---|---| +| CB-WP-0016 | product | 64 | $14.93 | 0.233 | +| **CB-WP-0017** | product | 40 | **$9.48** | 0.237 | +| CB-WP-0018 | product | *provisional — not quoted* | | | + +Read by **re-running `make status` at the moment of writing**, which is +CB-EV-0015 §6's correction applied for the first time: quoting a figure +remembered from earlier in a session defeats the rule even when the +boundary is right. CB-WP-0017 was reported at $5.19/23 mid-flight and +settled at **$9.48/40** — 83% higher. **Six for six, always low.** + +**Meta budget 0% `[ok]`**, all three trailing passes product. + +## 8. Open + +- **INTENT stage 1: the human check.** Three runs, three defects no test + could reach. Everything in this pass is verified by tests, mutation and a + live socket; nothing perceptual is. +- **For `ground-game`:** should SOLVE be selectable against a face-down + problem or an unmatchable suit? §3. +- **The self-quoting rule** now has both halves recorded but is still not + written into the loop spec. +- **AM-4b's scope defect (408,237 uncounted lines)** and its proc-macro + share. +- **`python3` as a toolchain dependency was never argued.** +- **AM-4a cannot survive stage 2** — 1,741,979 against 161,000. +- **ADR-0007 D3's acquisition rule** and **D5** remain unratified; + ADR-0010 rests on the latter. diff --git a/games/ground/src/bot.rs b/games/ground/src/bot.rs index b160058..551cad9 100644 --- a/games/ground/src/bot.rs +++ b/games/ground/src/bot.rs @@ -409,10 +409,23 @@ const MAX_ATTEMPTS: usize = 8; /// Seats are matched to `policies` by index: `PlayerId(n)` gets /// `policies[n]`. pub fn play<'a>( - mut state: GroundState, + state: GroundState, policies: &mut [Box], ) -> Result { - let mut log = Log::default(); + play_journaled(state, policies, None) +} + +/// The same, appending every applied command and its events to `journal` +/// as it goes, for a caller rendering the game while it runs. +pub fn play_journaled<'a>( + mut state: GroundState, + policies: &mut [Box], + journal: Option, +) -> Result { + let mut log = Log { + journal, + ..Log::default() + }; let seats: Vec = state.players.keys().copied().collect(); for seat in &seats { @@ -504,11 +517,33 @@ pub fn play<'a>( }) } +/// One command and everything it produced. +/// +/// **The empty case is the load-bearing one** (CB-WP-0018 T02). GR-A02's +/// resolver silently `continue`s when a SOLVE cannot be fulfilled, so a +/// player can select it three rounds running and change nothing. A journal +/// built only from events would show nothing for those and reproduce the +/// silence; keeping the command with an empty `events` is what lets a +/// reader say *"this happened and did nothing"*. +#[derive(Debug, Clone)] +pub struct Applied { + pub actor: Actor, + pub command: GroundCommand, + pub events: Vec, +} + +/// A live account of a game in progress, shared with whoever is watching. +/// +/// `BotGame.events` is the same information but only after `play` returns, +/// which is no use to a page rendered mid-game. +pub type Journal = std::rc::Rc>>; + /// What the driver accumulates while a game runs. #[derive(Default)] struct Log { events: Vec, steps: Vec<(Actor, GroundCommand)>, + journal: Option, } /// Offer one seat its legal commands and apply what the policy picks. @@ -573,6 +608,13 @@ fn apply( for event in &produced { state.fold(event); } + if let Some(j) = &log.journal { + j.borrow_mut().push(Applied { + actor, + command: cmd.clone(), + events: produced.clone(), + }); + } log.events.extend(produced); log.steps.push((actor, cmd.clone())); Ok(()) diff --git a/gates.toml b/gates.toml index 680c6d4..9fde325 100644 --- a/gates.toml +++ b/gates.toml @@ -120,15 +120,15 @@ retire_if = "two passes run with no finding while artifacts keep growing — tha id = "CHAOS" name = "the chaos roll" target = "" -checks = "d4 on each tier declaration, 12-declaration calibration window" +checks = "d8 on each tier declaration, 12-declaration calibration window (window 2, opened 2026-08-03; window 1 ran at d4)" added = "2026-07-30" -review_by = "2026-09-30" +review_by = "2026-11-30" caught = [ "CB-WP-0011: first fire in 6 declarations — d4=4 rolled stage 1 from structural L to S; the deleted survey would have opened on 2D toolkits while the existing text renderer was showing 24 of 41 view fields (CB-EV-0009 §1)", "CB-WP-0017: d4=4 — second override in twelve, and the first to roll UP (structural S → M). It bought ADR-0010: the script's widening from 'it does one thing' to holding a drag, following the pointer and marking other elements would otherwise have landed under a tier-S provenance paragraph, silently outgrowing ADR-0007 D5. The ADR's own finding is that the permitted and forbidden designs are indistinguishable from outside, which demoted the vocabulary grep to a cheap first line and produced the two behavioural controls that replaced it", "CB-WP-0012: d4=1, no override — and the contrast is the entry. Tier L at full weight deleted its own structural trigger: adversarial review withdrew the capability port the declaration was made to build (ADR-0007 D2), and corrected the survey's headline claim by 85x (128x -> 1.5x, CB-EV-0010 §2). Two passes on one subject at two tiers, priced: 0.123 $/response at L against 0.099 at S (CB-EV-0010 §5)", ] -retire_if = "the window closes with no overridden tier producing a different outcome than the argued one — the evaluation this window exists to make possible" +retire_if = "an override changes nothing twice running (window 2 condition, CB-WP-0018 T04). Window 1's condition — no override changing the outcome — was NOT met: both did, so the mechanism was kept and the rate dropped d4 → d8 instead" # VERDICT, CB-EV-0015 §5 (window closed 2026-08-02, 12 declarations, 2 overrides). # Not retired: both overrides changed the outcome. CB-WP-0011 (L→S) bought a # defect in the existing renderer that the deleted survey would have walked diff --git a/specs/InnerLoop.md b/specs/InnerLoop.md index c41965c..1de6699 100644 --- a/specs/InnerLoop.md +++ b/specs/InnerLoop.md @@ -120,17 +120,22 @@ are never skipped for code-producing work. | **M** | Survey and ADR merged into one document; review optional | Touches a canonical interface, adds/updates an external dependency, **or changes whether or how the loop constrains its own operation** — budgets, gates, review requirements, or these tier rules (v1.6, ADR-0006 D5) | | **S** | One provenance paragraph in the commit message | Everything else (utilities, fixes, refactors inside a boundary) | -**The chaos roll.** After deriving the structural tier, roll **d4** -(`shuf -i 1-4 -n 1`). On a **4**, the tier is instead picked uniformly at +**The chaos roll.** After deriving the structural tier, roll **d8** +(`shuf -i 1-8 -n 1`). On an **8**, the tier is instead picked uniformly at random (`shuf -e S M L -n 1`), overriding the structural derivation — up or down. -> **Calibration window, opened 2026-07-31, running to 12 tier -> declarations** (declaration 4 of 12 as of 2026-08-01). The rate was -> raised from d10 to d4 because at d10 the mechanism never fired and -> so prevented its own evaluation. Record the roll every time, -> including when it changes nothing (`tier: L (structural L, chaos 4)`). -> Rationale, cost estimate and the two dead rolls: +> **Window 1 closed 2026-08-02** at 12 declarations, 2 overrides, one each +> way, and **both changed the outcome** — so the mechanism was kept and +> the rate dropped d4 → d8 (CB-EV-0015 §5, CB-EV-0016 §4). +> +> **Window 2, opened 2026-08-03 at d8**, running to 12 declarations. +> Retirement condition: **retire if an override changes nothing twice +> running.** +> +> Record the roll every time, including when it changes nothing +> (`tier: L (structural L, chaos 8)`). Why the rate fell, why n=2 makes +> that the weakest part of the decision, and the dead rolls: > `specs/InnerLoopReference.md` §Chaos roll — calibration. Chaos limits: a rolled-down tier relaxes *process* weight only. Invariants diff --git a/specs/InnerLoopReference.md b/specs/InnerLoopReference.md index bf2b529..e60f773 100644 --- a/specs/InnerLoopReference.md +++ b/specs/InnerLoopReference.md @@ -195,3 +195,35 @@ four implementation rules the pass earned, and the requirement that evidence state what it does not support. Rationale and the failures behind each: `history/260731-inner-loop-retrospective.md`. + +## Chaos roll — window 1's verdict and the d4 → d8 change + +*(CB-WP-0018 T04, 2026-08-03. Full argument in `evidence/CB-EV-0015.md` §5 +and `evidence/CB-EV-0016.md` §4.)* + +Window 1 ran 2026-07-31 → 2026-08-02, twelve declarations, at d4 after an +earlier d10 that never fired and so prevented its own evaluation. + +**Two overrides, one in each direction, and both changed the outcome**, so +window 1's retirement condition — *"the window closes with no overridden +tier producing a different outcome than the argued one"* — was not met: + +| pass | roll | what the override bought | +|---|---|---| +| CB-WP-0011 | structural L → **S** | the deleted survey would have opened on 2D toolkits; the pass instead found the existing text renderer showing 24 of 41 view fields. Priced on the same subject: 0.099 $/response at S against 0.123 at L | +| CB-WP-0017 | structural S → **M** | ADR-0010. At tier S the page's script would have grown from *"it does one thing"* to holding a drag, following the pointer and marking other elements under a one-paragraph commit note, silently outgrowing ADR-0007 D5 | + +**Why the rate fell.** Both were informative *because they were rare*. At +d4 the mechanism overrides a quarter of all declarations, at which point it +stops being a calibration on the tier table and becomes a second tier +table. d8 keeps the mechanism and restores its rarity. + +**The weakest part of this decision, stated plainly:** it is a rate change +argued from **n=2**. The alternative — keep d4 for a second window and +decide with four data points — was live and was rejected only because a +quarter of declarations is a large standing tax to pay for evidence. + +So window 2 carries a falsifier: **if it produces no override at all, that +is evidence the rate went too far**, not evidence the mechanism is +healthy. A window that cannot fire cannot be evaluated, which is the exact +failure d10 had. diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index c900dd9..083c32f 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -19,7 +19,7 @@ use std::rc::Rc; use cb_game_runtime::{Project, Viewer}; use cb_kernel::PlayerId; -use cb_render_html::{document, resolve, Guard, PointerFact, Request}; +use cb_render_html::{resolve, Guard, PointerFact, Request}; use games_ground::bot::{Choice, Policy}; use games_ground::{GroundCommand, GroundState}; @@ -28,6 +28,9 @@ pub struct Server { listener: TcpListener, guard: Guard, log: RefCell>, + /// 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, } impl Server { @@ -41,6 +44,7 @@ impl Server { listener, guard: Guard::mint(port), log: RefCell::new(Vec::new()), + journal: games_ground::bot::Journal::default(), }) } @@ -50,6 +54,47 @@ impl Server { self.guard.page_url() } + /// The journal the driver appends to; hand it to `play_journaled`. + pub fn journal(&self) -> games_ground::bot::Journal { + self.journal.clone() + } + + /// 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 { + 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 { self.log.borrow().clone() @@ -93,7 +138,14 @@ impl Server { match (req.method.as_str(), req.path.as_str()) { ("GET", "/") => { - let page = document(&view, legal, &self.guard.endpoint(), Some(seat), may_pass); + let page = cb_render_html::doc::document_with_log( + &view, + legal, + &self.guard.endpoint(), + Some(seat), + may_pass, + &self.log_lines(), + ); respond(&mut stream, 200, "text/html; charset=utf-8", &page); } ("POST", "/command") => { @@ -122,6 +174,82 @@ impl Server { } } } + + /// 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. + pub fn serve_end( + &self, + final_view: Option<&games_ground::view::GroundView>, + message: &str, + linger: std::time::Duration, + ) -> Result<(), 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(()); + } + 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 page = cb_render_html::doc::ending( + final_view, + message, + &self.guard.endpoint(), + &self.log_lines(), + ); + respond(&mut stream, 200, "text/html; charset=utf-8", &page); + } + ("POST", "/command") => { + respond(&mut stream, 200, "text/plain", "closed"); + break Ok(()); + } + _ => respond(&mut stream, 404, "text/plain", "the game is over"), + } + }; + self.listener.set_nonblocking(false).ok(); + outcome + } } /// One seat's view of the shared server. @@ -161,6 +289,67 @@ impl Policy for SeatPolicy { } } +/// 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 { let mut buf = Vec::new(); let mut chunk = [0u8; 1024]; @@ -225,6 +414,226 @@ mod tests { /// 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), + "30 commands, hash f6c890a65271", + std::time::Duration::from_secs(20), + ) + .expect("serve_end"); + let replies = client.join().expect("client"); + + assert!(replies[0].contains("200 OK"), "{}", replies[0]); + assert!(replies[0].contains("game over"), "the page did not say so"); + 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]); + } + + /// 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", &[]); + 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>>); + + impl Write for SharedOut { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().expect("out").extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + 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), + }, + 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("

log

"), "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) -> std::thread::JoinHandle> { std::thread::spawn(move || { requests diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index 41a0937..61d80c4 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -181,6 +181,29 @@ impl Policy for HumanPolicy<'_, R, W> { } } +/// How long the terminal page stays available for an abandoned tab. +/// +/// A server that never exits is its own defect; a short timeout races a +/// player reading the result. So it serves until the page posts `done`, +/// with this only as the bound. +const END_LINGER: std::time::Duration = std::time::Duration::from_secs(600); + +/// Show the browser that the game ended badly, then return the error. +/// +/// CB-WP-0018 T01: this path used to `return Err(...)` straight to a +/// terminal nobody was reading, and the browser got a refused connection — +/// identical to a normal win. An error the only interface cannot see is +/// not reported. +fn end_badly( + server: &Option>, + msg: String, +) -> Result { + if let Some(s) = server { + let _ = s.serve_end(None, &msg, END_LINGER); + } + Err(msg) +} + fn bot_policy<'a>(kind: &str, seed: u64) -> Result, String> { match kind { "greedy" => Ok(Box::new(GreedyPolicy)), @@ -244,20 +267,29 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( } } - let result = games_ground::bot::play(initial, &mut policies); + // CB-WP-0018 T02: the browser seat's page renders the journal as it + // fills, so a player sees what each command produced -- including the + // commands that produced nothing. + let result = match &server { + Some(srv) => games_ground::bot::play_journaled(initial, &mut policies, Some(srv.journal())), + None => 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), + (_, Some(msg)) => return end_badly(&server, msg), (Err(BotError::IllegalChoice { seat, offered, .. }), None) => { - return Err(format!( - "{} chose a command outside the {offered} offered", - seat_name(seat) - )) + return end_badly( + &server, + format!( + "{} chose a command outside the {offered} offered", + seat_name(seat) + ), + ) } - (Err(e), None) => return Err(e.to_string()), + (Err(e), None) => return end_badly(&server, e.to_string()), (Ok(game), None) => game, }; @@ -287,6 +319,15 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( let _ = w.flush(); drop(w); + // CB-WP-0018 T01: the browser sees the end of its own game. Measured + // before this: a game ended at 5 rounds / 30 commands, the result went + // to stdout, and the page's post-`ok` reload got Connection refused. + if let Some(srv) = &server { + let ended = game.state.project(Viewer::Spectator); + let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]); + let _ = srv.serve_end(Some(&ended), &msg, END_LINGER); + } + let scenario = games_ground::record::to_scenario( "ground/cb-play-session", config.seed, diff --git a/workplans/CB-WP-0018-the-browser-is-a-client.md b/workplans/CB-WP-0018-the-browser-is-a-client.md new file mode 100644 index 0000000..e125086 --- /dev/null +++ b/workplans/CB-WP-0018-the-browser-is-a-client.md @@ -0,0 +1,325 @@ +--- +id: CB-WP-0018 +kind: product +title: "The browser is a client: game over, a log, and where a drop goes" +status: done +--- + +# Purpose + +``` +structural tier M (changes how the loop constrains its own operation: + the chaos rate d4 → d8 and a second calibration + window, owed by CB-EV-0015 §5) +chaos d4 = 3 → no override +declared tier M +``` + +**Declaration 1 of the second chaos window** — opened by T04 below, at the +rate T04 sets. This declaration was rolled at the old d4, because the rate +changes when the decision lands, not retroactively. + +## The defect: a finished game is indistinguishable from a crash + +Reported: *"after some time i get an empty page back. I guess the game +crashes or ends but that is unclear as the ui disappears."* + +Reproduced by driving a real game to completion over HTTP: + +``` +move 5 accepted → ok +GET / → URLError: [Errno 111] Connection refused +``` + +The game **ended normally** — 5 rounds, 30 commands — and everything it +produced went to the terminal: + +``` +coalition [P2] score 0 +coalition [P3] score 0 +winners +game over — 30 commands, hash f6c890a65271 +``` + +The browser got nothing, because `next_choice` only accepts connections +*inside* a human decision point. When `play()` returns, `run_game` writes +the outcome to stdout and the process exits; the listener dies and the +page's post-`ok` reload is refused. + +**The browser is a second-class client.** Every terminal outcome — the +result, the scores, the winners, and every error `run_game` can return — +is invisible to the only interface a player is actually using. A crash and +a win render identically: nothing. + +This is the same defect class as CB-WP-0016's silent drop. The system +refuses to say what happened and the player is left to infer it. + +## Task: serve the end of the game + +```task +id: CB-WP-0018-T01 +status: done +priority: high +``` + +When `play()` returns — **`Ok` or `Err`** — the browser must be told, on +the page, in terms a player understands. + +- **A win** shows the outcome the spectator projection already carries: + totals, threshold, coalitions, mastery, winners. +- **An error** shows that the game ended badly and why. `run_game` + currently returns `Err(String)` to a terminal nobody is reading. +- The final page must **not** auto-reload into a refused connection. + +The listener has to outlive the game, which it does not today. Decide how +it ends — a timeout, an explicit close, or serving until interrupted — and +say why; a server that never exits is its own defect. + +**Controls:** a test that plays a game to its end through the socket and +asserts the final GET returns a page naming the outcome, **not** a refused +connection. And the error path, driven by a game that fails, asserting the +reason reaches the page. + +**Done 2026-08-03.** `Server::serve_end` plus `doc::ending`, wired into +both of `run_game`'s exits. Verified live: where the browser used to get +`Connection refused` it now gets an 8,998-byte page reading *"GROUND — +game over … 30 commands, hash f6c890a65271"* with the final table. + +**How it ends:** it serves until the page posts `done` — the ending page +carries a *"close — I have read this"* control — with a 600 s linger as +the bound, so an abandoned tab cannot hold the process open and a player +reading the result is not raced by a timeout. + +`document()` was split into `body()` and `move_section()` so the ending +shows the **same** table rather than a second rendering of it; two +renderings of one state is how they drift. + +**The control had to be built twice, and the first one was worthless.** +`the_end_of_the_game_reaches_the_browser` calls `serve_end` directly — +and deleting the call from `run_game` left it **green**. It tested the +link and not the chain, which is CB-EV-0012's finding recurring +(*"every link was tested and the chain was not"*). + +`a_real_game_played_to_its_end_leaves_the_ending_on_screen` runs the real +`play()` with a browser seat, drives a real game to its end over a real +socket, and requires the last page to be the ending. Under the same +mutation it goes red — and prints an **empty page**, which is exactly the +symptom that was reported. + +A second control: a game that ended badly says why and draws **no** table, +because drawing a table for a game that never happened is the same lie the +empty page told. + +**And a weak assertion of mine, caught by itself.** The first version +grepped the page for `location.reload`. The ending page reuses `SCRIPT`, +whose reload is guarded by `t.indexOf('ok') === 0`, and the ending +endpoint answers `closed` — so the grep would have forced a second script +to satisfy a test rather than a requirement. That is the source-text +control shape ADR-0010 D2 demoted. It now asserts the endpoint cannot +answer `ok`. + +## The second report, and what it actually is + +Reported: *"the cards I play by pulling them on a target will not be +removed, that is wrong i guess we will need a discard pile."* + +**A discard pile already exists** — `solution_discard` on `GroundState`, +`SolutionDiscarded` removes the card from the hand and pushes it there, +and the page already renders `deck N remaining / discard …`. Building one +would have been building a thing that is there. + +What the maintainer dragged were **action** cards. The five GROUND actions +are not cards and are correctly never consumed. Solution cards leave the +hand at **Resolve**, not at Select, because a selection is a face-down +commit. + +But the report is pointing at something real. Measured over a live game: + +| move | hand | discard | +|---|---|---| +| 1 · Investigate → problem-2 | 2 cards | none | +| 2 · Investigate → problem-3 | **3 cards** | none | +| 3 · Solve → problem-1 | 4 cards | none | +| 4 · Solve → problem-1 | **4 cards** | none | +| 5 · Solve → problem-1 | **4 cards** | none | + +Investigate draws, correctly. **Solve was played three times and did +nothing, three times, in silence.** GR-A02's resolver `continue`s when the +problem is face-down, already claimed, denied, or when the seat holds no +card of the matching suit — and `legal_commands` offers Solve on every +face-up problem without consulting the hand, while the page offers it on +problem-1 which was still face-down. + +**Whether that is a rule gap or intended is not this repo's call.** A +face-down commit you cannot fulfil is a plausible bluff in a commit/reveal +game with DARVO, and INTENT defers game semantics to `ground-game`. What +*is* this repo's call is that a move which provably does nothing is +offered, accepted, and never accounted for. That is the log's job (T02) +and the explanation's job (T03), and it is now the concrete case both are +measured against. + +Raised for `ground-game`, not decided here: **should SOLVE be selectable +against a face-down problem, or against a suit the seat cannot match?** + +## Task: a game log, and what to do next + +```task +id: CB-WP-0018-T02 +status: done +priority: high +``` + +Requested: *"a game log about the events that have been generated and a +hint of what the next move/options are."* + +The events exist — `validate` produces them and `record::to_scenario` +already turns them into a scenario file. The page shows none of them, so a +player sees state change with no account of why. + +- **The log** is the event stream, in the recorder's vocabulary. Do not + invent a fourth phrasing: `cb-play`'s `describe()` exists precisely so + *"what the player reads is what the scenario file will say"*, and the + HTML renderer currently uses `{c:?}` Debug instead. Reuse it or say why + it cannot be reused. +- **The hint** is what the seat may do now, which is `legal` — already in + hand, already rendered as cards by CB-WP-0017. The hint is the *step* + context that makes those cards make sense: what the round is waiting + for, and what happens when it stops waiting. + +**Control:** the log must be derived from the events the aggregate +produced, not re-narrated from the state. A log that describes the state +after the fact is a second implementation of the rules and will drift. + +**The case it must handle**, from the measurement above: three SOLVEs that +produced no events at all. A log built only from events would show nothing +for them and reproduce the silence. So the log must distinguish *"this +command produced these events"* from *"this command produced none"* — +the second is the one the player needs and the harder one to render. + +**Done 2026-08-03.** `bot::Journal` — a shared list of `Applied { actor, +command, events }` the driver appends to, via the new `play_journaled`. +`play` delegates to it with `None`, so nothing existing changed. The +journal exists because `BotGame.events` only appears *after* `play` +returns, which is no use to a page rendered mid-game. + +The log is phrased with `record::to_step` — the recorder's vocabulary, so +what the player reads is what the scenario file will say — and every one +of the 29 `GroundEvent` variants now renders in words rather than `{:?}`. +From a live game: + +``` +P1 select_action action=INVESTIGATE problem=2 + → P1 chose Investigate on problem 2 +the round resolve + → problem 2 turned face up + → P1 drew a solution + → step → Resolve +the round end_round + → round 2 ended; P2 leads next +``` + +**A command that produced no events says `no effect`**, which is the whole +point; the mutation that drops that branch goes red. + +**An honest limitation.** The reported case — SOLVE doing nothing — is +*not* rendered as `no effect`, because the SOLVE is resolved inside the +system's `resolve` command, which does produce events for other seats. The +player now sees the selection and sees no claim follow it, which is a +large improvement on silence but is still an inference. Making it explicit +would mean the renderer deciding *why* a rule did nothing, which is a +second implementation of the rules — exactly what this task's control +forbids. Left as an inference deliberately, and owed to `ground-game` as +the question of whether the move should be offered at all. + +## Task: say where a drop goes, and what it means + +```task +id: CB-WP-0018-T03 +status: done +priority: high +``` + +Requested: *"an overlay arrow pointing to the droptarget element with an +explanation showing up besides it, when we drag on it."* + +CB-WP-0017 marks every legal target. What it does not do is say what any +particular drop **means** — the player sees five outlined boxes and must +still guess which one does what. + +So the explanation is the substance and the arrow is the delivery. Both +need the meaning to exist as data first, and **ADR-0010 Decision 1 binds +here**: the description of what `action-attack → seat-1` does must be +written by Rust into the page. The script may render it on hover; it may +not compose it. + +**Controls:** every advertised target carries a description, and the +description a target shows is the one Rust wrote for *that* pair — a +mutation that shows a neighbouring pair's text must go red. The set +equality property from ADR-0010 D2 must still hold. + +**Done 2026-08-03.** `input::describe` writes a sentence per legal +command; `data-descs` carries them in step with `data-targets`; the ghost +already following the pointer shows the one for whatever legal target is +under it, so the explanation appears beside the target without an overlay +layer to keep aligned. + +**Both mutations initially SURVIVED**, because the fixture's Attack card +had exactly **one** target — where an off-by-one shift and a truncation +are both no-ops. That is CB-EV-0014's lesson one level in: *a fixture too +thin to express a failure is how the failure survives*. The fixture now +offers two attack targets on purpose and both go red. + +## Task: close the chaos change, and the evidence + +```task +id: CB-WP-0018-T04 +status: done +priority: high +``` + +CB-EV-0015 §5 recommended and did not make the change, on the grounds that +it alters how the loop constrains its own operation and was therefore owed +a tier-M declaration. This is it. + +**Make it or reject it**, in `specs/InnerLoop.md` and `gates.toml`: + +- rate **d4 → d8**; +- a **second window of 12** declarations; +- new retirement condition: *retire if an override changes nothing twice + running*. + +The argument to test rather than assume: both overrides were informative +*because they were rare*. If that is right the rate should fall; if the +real driver was that they landed on passes with something to find, the +rate is irrelevant and the recommendation is wrong. Say which, and note +that n=2 is thin evidence for a rate change — including the possibility +that the honest answer is to keep d4 for another window and decide with +four data points instead of two. + +Then `evidence/CB-EV-0016-*.md`: + +- **What the maintainer's three reports have cost and bought**, now that + there are three in a row and every one found something no test did. +- **Whether stage 1 closes.** +- **Quote CB-WP-0017's cost by re-running the instrument**, not from + memory — CB-EV-0015 §6 found that quoting a remembered figure defeats + the rule even when the boundary is right, and this is the first pass + that can apply that correction. + +**Done 2026-08-03.** +[CB-EV-0016](../evidence/CB-EV-0016-the-browser-is-a-client.md). `make +all` exits 0. + +- **Chaos rate d4 → d8, window 2 open at 12 declarations**, retiring if an + override changes nothing twice running. Window 1's condition was not + met — both overrides changed the outcome — so the mechanism is kept. + **The weakest part of the decision is that it is a rate change argued + from n=2**, and window 2 therefore carries a falsifier: no override at + all is evidence the rate went too far. +- The rationale moved to `InnerLoopReference.md` because `InnerLoop.md` + hit 401 lines and the loadability gate fired — fixed structurally, per + the standing precedent that limits are not raised. +- **CB-WP-0017 settled at $9.48/40** against $5.19/23 reported mid-flight, + 83% higher. Six for six, always low. Read by re-running the instrument + at the moment of quoting, which is CB-EV-0015 §6's correction applied + for the first time.