From 8eadb6c963cd76c39752a7abbc81c67f5926d074 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 6 Aug 2026 21:51:35 +0200 Subject: [PATCH] fix: the game was unplayable, and the note redirect was refused Tier S (fixes inside a boundary; chaos d8=7, no override). Four observations from play, three of them caused by CB-WP-0028 -- and make all was green for all of them. THE NOTE BUG, WHICH I GOT WRONG TWICE. The first fix put the token in the form's action, and that worked. But the 303 afterwards pointed at bare `/` with no token, so the note WAS SAVED and then the browser followed a redirect control 1 refuses. The player sees "no session token" for a note that already landed. A redirect is a request the browser makes on your behalf and is subject to every control the others are. I had tested the POST and stopped there -- the same mistake as the first fix, one step further along. Guard::page_path() now carries the token, and the test asserts the redirect target is ADMITTED rather than merely non-empty. WHY DRAGGING BROKE, WHICH WAS NOT THE DRAG. The gesture logic was fine: the JS harness posts correctly against the served page, and all 14 drop targets are present. The table was 620px tall, which pushed the action cards a full screen below the Problems -- and you cannot drag between two things that are never on screen together. Now 440, with a test asserting the declared height stays under 460 and saying why. That is a proxy for a browser layout, not the property itself, and the test says so. Seats sat ON the ellipse: they were placed at 0.83 of the table radius, which is inside it. Now outside, asserted numerically at 2 through 6 seats against the ellipse equation rather than eyeballed. And the `table` drop target was a separate CARD among the move buttons, which is exactly why a player looking at a picture of a table could not find anywhere to drop. The drawn ellipse is the drop zone now, and a test asserts there is EXACTLY ONE table target and that it is the drawn one -- two elements claiming to be the table is worse than none. The gap this exposes is the one CB-EV-0026 named a day earlier: every test asserted the DOM was correct, and it was. Nothing asserted the page was usable, and the drag test passes on a page you cannot physically drag on. What is added here are proxies a browser-less test can check. make all: exit 0. 61 render tests, 26 cb-play. Verified over real HTTP rather than by inspection: note POST 303, the redirect carries the token, following it returns 200, the table declares 440, one drop zone, seats at (380,421)/(113,112)/(647,112) against a table of rx=200 ry=118, and both notes reached the trial log. Co-Authored-By: Claude Opus 5 --- crates/cb-render-html/src/doc.rs | 85 +++++++++++++++----------- crates/cb-render-html/src/lib.rs | 95 ++++++++++++++++++++++++++---- crates/cb-render-html/src/serve.rs | 33 +++++++++++ tools/cb-play/src/hotseat.rs | 8 ++- 4 files changed, 175 insertions(+), 46 deletions(-) diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index fae1353..00ae0dc 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -467,18 +467,24 @@ fn piles_body(out: &mut String, view: &GroundView) { /// and drawing the same thing twice is how the two drift. fn table_svg(view: &GroundView) -> String { let n = view.players.len().max(1); - let (cx, cy) = (380.0f64, 300.0f64); - let seat_r = 240.0f64; + // Compact on purpose. The first version was 760x620, which pushed the + // action cards a full screen below the Problems -- and you cannot drag + // between two things that are never on screen together, which made the + // game unplayable. Height is the constraint, not width. + let (cx, cy) = (380.0f64, 215.0f64); + let (rx, ry) = (200.0f64, 118.0f64); + // Seats sit OUTSIDE the table, as people do. The first version put + // them at 0.83 of the ellipse and they sat on it. + let (sx, sy) = (rx + 108.0, ry + 88.0); let pos: Vec<(PlayerId, f64, f64)> = view .players .keys() .enumerate() .map(|(i, p)| { - // Start at the bottom, not the top: the viewer sits nearest - // the reader, which is where you sit at a real table. + // Start at the bottom: the viewer sits nearest the reader. let a = std::f64::consts::TAU * (i as f64) / (n as f64) + std::f64::consts::FRAC_PI_2; - (*p, cx + seat_r * a.cos(), cy + seat_r * a.sin() * 0.72) + (*p, cx + sx * a.cos(), cy + sy * a.sin()) }) .collect(); let find = |p: PlayerId| { @@ -488,12 +494,22 @@ fn table_svg(view: &GroundView) -> String { }; let mut s = String::from( - "\ - ", + "", + ); + + // THE TABLE IS THE DROP ZONE. `table` was an abstract id with no + // picture; the player looked for somewhere to drop GROUND and there + // was nowhere. Now the surface you can see is the surface you drop on. + let _ = write!( + s, + "\ + the table", + ty = cy + ry - 8.0, ); - // Relations first, so seats draw over them. for (pair, rel) in &view.relations { if let (Some((x1, y1)), Some((x2, y2))) = (find(pair.0), find(pair.1)) { let colour = match rel { @@ -503,7 +519,7 @@ fn table_svg(view: &GroundView) -> String { let _ = write!( s, "\ + stroke=\"{colour}\" stroke-width=\"2\" stroke-opacity=\"0.7\"/>\ {rel:?}", mx = (x1 + x2) / 2.0, @@ -512,22 +528,28 @@ fn table_svg(view: &GroundView) -> String { } } - // The middle of the table: Problems, then the two stacks under them. - let span = (view.problems.len().max(1) as f64) * 130.0; + // On the table: Problems across the middle, the two stacks under them. + let count = view.problems.len().max(1) as f64; + let scale = (2.0 * rx * 0.92 / (count * 130.0)).min(0.82); let _ = write!( s, - "", - x = cx - span / 2.0, + "", + x = cx - count * 130.0 * scale / 2.0, + y = cy - ry + 24.0, ); for (i, (priority, p)) in view.problems.iter().enumerate() { problem_svg(&mut s, *priority, p, (i as i32) * 130); } s.push_str(""); - let _ = write!(s, "", x = cx - 150.0); + let _ = write!( + s, + "", + x = cx - 300.0 * 0.62 / 2.0, + y = cy + 8.0, + ); piles_body(&mut s, view); s.push_str(""); - // Seats, each with what it played in front of it. for (p, x, y) in &pos { let is_viewer = view.viewer == Some(*p); let focus = view @@ -536,34 +558,31 @@ fn table_svg(view: &GroundView) -> String { .map(|f| format!(" \u{2192}{}", seat_name(*f))) .unwrap_or_default(); let pv = &view.players[p]; - // The played card sits between the seat and the centre. - let (dx, dy) = ((cx - x) * 0.30, (cy - y) * 0.30); + // The played card sits between the seat and the table. if let Some(sel) = view.selections.get(p) { let _ = write!( s, - "", - px = x + dx - 26.0, - py = y + dy - 17.0, + "", + px = x + (cx - x) * 0.34 - 23.0, + py = y + (cy - y) * 0.34 - 15.0, ); - played_inner(&mut s, sel); + played_svg(&mut s, sel); s.push_str(""); } let _ = write!( s, "\ - \ {name}{you}\ stress {stress}{focus}", raw = p.0, - // The viewer's own seat is thicker and blue: a table where you - // cannot find yourself is worse than a list. stroke = if is_viewer { "#9cf" } else { "#5a6b7a" }, sw = if is_viewer { 3 } else { 2 }, t1 = y - 2.0, - t2 = y + 14.0, + t2 = y + 13.0, name = seat_name(*p), you = if is_viewer { " (you)" } else { "" }, stress = pv.stress, @@ -610,11 +629,6 @@ const CARD_BACK: &str = "\ "; -/// The played card without its own ``, for placing on the table. -fn played_inner(out: &mut String, sel: &SelectionView) { - played_svg(out, sel); -} - fn played_svg(out: &mut String, sel: &SelectionView) { let s = match sel { SelectionView::Hidden => { @@ -1053,10 +1067,11 @@ fn move_section( ); } } - s.push_str( - "
the table \u{2014} drop here for an \ - untargeted action
", - ); + // CB-WP-0028: the table's drop zone is THE TABLE, drawn above. + // This was a separate card labelled "the table" sitting among the + // move buttons, which is why a player looking at a picture of a + // table could not find anywhere to drop. + s.push_str(""); } if may_pass { s.push_str( diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 662d39d..a58d230 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -721,6 +721,18 @@ mod overhead_table { use cb_kernel::PlayerId; use games_ground::GroundState; + /// Seat circle centres, read out of the emitted SVG. + fn seat_centres(html: &str) -> Vec<(f64, f64)> { + html.match_indices(" games_ground::view::GroundView { GroundState::setup( &Setup { @@ -743,15 +755,7 @@ mod overhead_table { let v = view_of(players); let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false); - let seats: Vec<(f64, f64)> = html - .match_indices(" 70.0, + d > 64.0, "{players}p: two seats are {d:.0}px apart and the circles are r=34 — \ they overlap" ); @@ -800,6 +804,77 @@ mod overhead_table { ); } + /// **Observation 1: the seats were ON the table, not around it.** + /// Every seat circle must lie outside the ellipse. + #[test] + fn the_seats_sit_outside_the_table_not_on_it() { + for players in 2..=6u8 { + let v = view_of(players); + let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false); + let (cx, cy, rx, ry) = (380.0f64, 215.0f64, 200.0f64, 118.0f64); + for (x, y) in seat_centres(&html) { + // Outside an ellipse: (dx/rx)^2 + (dy/ry)^2 > 1, with the + // seat's own radius kept clear of the rim. + let d = ((x - cx) / (rx + 30.0)).powi(2) + ((y - cy) / (ry + 30.0)).powi(2); + assert!( + d > 1.0, + "{players}p: a seat at ({x:.0},{y:.0}) is on or inside the table" + ); + } + } + } + + /// **Observations 3 and 4: the table you can see is the table you drop + /// on, and the game must be playable.** A `table` drop target that is + /// not the drawn table is why a player could not find where to drop. + #[test] + fn the_drawn_table_is_the_drop_target_and_there_is_only_one() { + let v = view_of(3); + let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false); + assert_eq!( + html.matches("data-drop=\"table\"").count(), + 1, + "two elements claim to be the table; a player cannot tell which to use" + ); + let table = html + .split("aria-label=\"the table, seen from above\"") + .nth(1) + .and_then(|s| s.split("").next()) + .expect("one table svg"); + assert!( + table.contains("data-drop=\"table\""), + "the drop target is not on the drawn table" + ); + assert!( + table.contains(" String { + format!("/?t={}", self.token) + } + pub fn page_url(&self) -> String { format!("{}/?t={}", self.origin, self.token) } @@ -245,6 +256,28 @@ mod tests { /// M-D1-MUT: delete the `None => Err(Refusal::MissingToken)` arm in /// `admit` (return `Ok(())` instead) and this goes red with /// `a token-less request was admitted`. Run 2026-08-02. + /// **The bug a player reported as "I can't save notes".** The note + /// POST succeeded and the 303 sent the browser to bare `/`, which + /// control 1 refuses — so the note was stored and the player was shown + /// "no session token", which reads as the save having failed. + /// + /// A redirect is a request the browser makes on your behalf, and it + /// is subject to every control the others are. + #[test] + fn the_page_path_a_redirect_uses_carries_the_token() { + let g = guard(); + let path = g.page_path(); + assert!( + path.contains(g.token()), + "a redirect to {path:?} would be refused for want of a token" + ); + // And it is admitted, which is the property that matters. + let req = req(&format!( + "GET {path} HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n" + )); + assert!(g.admit(&req).is_ok(), "the redirect target is refused"); + } + #[test] fn a_token_less_request_is_refused() { let g = guard(); diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index 0dd359c..9ac0913 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -290,7 +290,13 @@ impl Server { // 303 so the browser re-GETs the table rather // than leaving a form POST in history — a // reload would otherwise re-submit the note. - respond_seeother(&mut stream, "/"); + // + // WITH THE TOKEN. Redirecting to bare `/` sent + // the browser to a request control 1 refuses, + // so the note was saved and the player was + // shown "no session token" — which reads as + // the note having failed. + respond_seeother(&mut stream, &self.guard.page_path()); } Err(e) => respond(&mut stream, 500, "text/plain", &e), },