diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 35c43a8..c436109 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, ghostLabel = ''; + var down = null, held = null, marked = [], ghost = null; function node(e) { var n = e.target; @@ -70,19 +70,6 @@ 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; } @@ -101,7 +88,6 @@ 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; } @@ -116,8 +102,7 @@ pub const SCRIPT: &str = r#" if (n.getAttribute('data-targets')) { ghost = document.createElement('div'); ghost.id = 'cb-ghost'; - ghostLabel = n.textContent; - ghost.textContent = ghostLabel; + ghost.textContent = n.textContent; ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; document.body.appendChild(ghost); @@ -128,11 +113,6 @@ 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); @@ -185,15 +165,11 @@ 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} "; @@ -429,18 +405,6 @@ 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!( @@ -467,25 +431,6 @@ pub fn document_with_log( }, ); - 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

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

seats

"); for (id, p) in &view.players { - player_card(s, *id, p, view); + player_card(&mut s, *id, p, view); } s.push_str("
"); @@ -566,16 +511,7 @@ fn body(s: &mut String, view: &GroundView) { 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 [ @@ -593,32 +529,24 @@ fn move_section( // 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). - // 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 + let targets: Vec = seat .map(|seat| { legal .iter() - .filter_map(|c| { - crate::input::affordance(c, seat) - .filter(|(f, _)| *f == action_id(a)) - .map(|(_, t)| (t, crate::input::describe(c, seat))) - }) + .filter_map(|c| crate::input::affordance(c, seat)) + .filter(|(f, _)| *f == action_id(a)) + .map(|(_, t)| t) .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}\">{a:?}
\ onto {names}
", id = action_id(a), targets = esc(&targets.join(" ")), - descs = esc(&descs.join("|")), names = esc(&target_names(&targets)), ); } @@ -644,6 +572,14 @@ fn move_section( "
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. @@ -686,86 +622,6 @@ 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 6351740..8e2e604 100644 --- a/crates/cb-render-html/src/input.rs +++ b/crates/cb-render-html/src/input.rs @@ -96,40 +96,6 @@ 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 4a84f60..5713ee3 100644 --- a/crates/cb-render-html/src/jsrun.rs +++ b/crates/cb-render-html/src/jsrun.rs @@ -64,14 +64,13 @@ 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, descs) { +function __mk(key, targets, text) { var n = { parentNode: null, textContent: text || key, style: {}, _cls: {}, - _attr: { 'data-drop': key, 'data-targets': targets || null, - 'data-descs': descs || null }, + _attr: { 'data-drop': key, 'data-targets': targets || null }, getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; }, setAttribute: function (a, v) { this._attr[a] = v; }, classList: { @@ -82,7 +81,7 @@ function __mk(key, targets, text, descs) { }; return n; } -function __register(key, targets, descs) { var n = __mk(key, targets, key, descs); __all.push(n); return n; } +function __register(key, targets) { var n = __mk(key, targets); __all.push(n); return n; } var document = { body: __body, @@ -104,9 +103,7 @@ 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, 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 __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9 }); } } function __cancel() { __handlers['pointercancel']({ target: __find(null) }); } function __find(k) { for (var i = 0; i < __all.length; i++) { @@ -233,29 +230,12 @@ 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) { - // 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()), - ); + let call = match targets { + Some(t) => format!("__register({}, {});", json_lit(&key), json_lit(&t)), + None => format!("__register({}, null);", json_lit(&key)), + }; ctx.eval(&call) .map_err(|e| format!("register {key}: {e}"))?; } @@ -294,15 +274,6 @@ 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))), @@ -565,59 +536,6 @@ 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 58898b7..1b15091 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -573,59 +573,5 @@ 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 deleted file mode 100644 index d78a419..0000000 --- a/evidence/CB-EV-0016-the-browser-is-a-client.md +++ /dev/null @@ -1,182 +0,0 @@ -# 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 551cad9..b160058 100644 --- a/games/ground/src/bot.rs +++ b/games/ground/src/bot.rs @@ -409,23 +409,10 @@ const MAX_ATTEMPTS: usize = 8; /// Seats are matched to `policies` by index: `PlayerId(n)` gets /// `policies[n]`. pub fn play<'a>( - state: GroundState, - policies: &mut [Box], -) -> Result { - 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 mut log = Log::default(); let seats: Vec = state.players.keys().copied().collect(); for seat in &seats { @@ -517,33 +504,11 @@ pub fn play_journaled<'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. @@ -608,13 +573,6 @@ 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 9fde325..680c6d4 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 = "d8 on each tier declaration, 12-declaration calibration window (window 2, opened 2026-08-03; window 1 ran at d4)" +checks = "d4 on each tier declaration, 12-declaration calibration window" added = "2026-07-30" -review_by = "2026-11-30" +review_by = "2026-09-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 = "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" +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" # 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 1de6699..c41965c 100644 --- a/specs/InnerLoop.md +++ b/specs/InnerLoop.md @@ -120,22 +120,17 @@ 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 **d8** -(`shuf -i 1-8 -n 1`). On an **8**, the tier is instead picked uniformly at +**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 random (`shuf -e S M L -n 1`), overriding the structural derivation — up or down. -> **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: +> **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: > `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 e60f773..bf2b529 100644 --- a/specs/InnerLoopReference.md +++ b/specs/InnerLoopReference.md @@ -195,35 +195,3 @@ 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 083c32f..c900dd9 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::{resolve, Guard, PointerFact, Request}; +use cb_render_html::{document, resolve, Guard, PointerFact, Request}; use games_ground::bot::{Choice, Policy}; use games_ground::{GroundCommand, GroundState}; @@ -28,9 +28,6 @@ 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 { @@ -44,7 +41,6 @@ impl Server { listener, guard: Guard::mint(port), log: RefCell::new(Vec::new()), - journal: games_ground::bot::Journal::default(), }) } @@ -54,47 +50,6 @@ 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() @@ -138,14 +93,7 @@ impl Server { match (req.method.as_str(), req.path.as_str()) { ("GET", "/") => { - let page = cb_render_html::doc::document_with_log( - &view, - legal, - &self.guard.endpoint(), - Some(seat), - may_pass, - &self.log_lines(), - ); + let page = document(&view, legal, &self.guard.endpoint(), Some(seat), may_pass); respond(&mut stream, 200, "text/html; charset=utf-8", &page); } ("POST", "/command") => { @@ -174,82 +122,6 @@ 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. @@ -289,67 +161,6 @@ 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]; @@ -414,226 +225,6 @@ 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 61d80c4..41a0937 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -181,29 +181,6 @@ 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)), @@ -267,29 +244,20 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( } } - // 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), - }; + let result = games_ground::bot::play(initial, &mut policies); // A human seat that ran out of input reports *that*, not the // out-of-range index it had to return to get here. let human_failure = failure.borrow().clone(); let game = match (result, human_failure) { - (_, Some(msg)) => return end_badly(&server, msg), + (_, Some(msg)) => return Err(msg), (Err(BotError::IllegalChoice { seat, offered, .. }), None) => { - return end_badly( - &server, - format!( - "{} chose a command outside the {offered} offered", - seat_name(seat) - ), - ) + return Err(format!( + "{} chose a command outside the {offered} offered", + seat_name(seat) + )) } - (Err(e), None) => return end_badly(&server, e.to_string()), + (Err(e), None) => return Err(e.to_string()), (Ok(game), None) => game, }; @@ -319,15 +287,6 @@ 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 deleted file mode 100644 index e125086..0000000 --- a/workplans/CB-WP-0018-the-browser-is-a-client.md +++ /dev/null @@ -1,325 +0,0 @@ ---- -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.