fix: the game was unplayable, and the note redirect was refused
Some checks failed
ci / check (push) Failing after 3s

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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-06 21:51:35 +02:00
parent ab364a0319
commit 8eadb6c963
4 changed files with 175 additions and 46 deletions

View file

@ -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(
"<svg viewBox=\"0 0 760 620\" width=\"100%\" style=\"max-width:760px\" \
role=\"img\" aria-label=\"the table, seen from above\">\
<ellipse cx=\"380\" cy=\"300\" rx=\"290\" ry=\"215\" fill=\"#171b23\" stroke=\"#2a3140\"/>",
"<svg viewBox=\"0 0 760 440\" width=\"100%\" style=\"max-width:760px\" \
role=\"img\" aria-label=\"the table, seen from above\">",
);
// 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,
"<g data-drop=\"table\"><ellipse cx=\"{cx}\" cy=\"{cy}\" rx=\"{rx}\" ry=\"{ry}\" \
fill=\"#171b23\" stroke=\"#2a3140\" stroke-width=\"2\"/>\
<text x=\"{cx}\" y=\"{ty}\" fill=\"#556\" font-size=\"10\" \
text-anchor=\"middle\">the table</text></g>",
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,
"<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
stroke=\"{colour}\" stroke-width=\"2\"/>\
stroke=\"{colour}\" stroke-width=\"2\" stroke-opacity=\"0.7\"/>\
<text x=\"{mx:.0}\" y=\"{my:.0}\" fill=\"{colour}\" font-size=\"10\" \
text-anchor=\"middle\">{rel:?}</text>",
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,
"<g transform=\"translate({x:.0},170)\">",
x = cx - span / 2.0,
"<g transform=\"translate({x:.0},{y:.0}) scale({scale:.3})\">",
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("</g>");
let _ = write!(s, "<g transform=\"translate({x:.0},290)\">", x = cx - 150.0);
let _ = write!(
s,
"<g transform=\"translate({x:.0},{y:.0}) scale(0.62)\">",
x = cx - 300.0 * 0.62 / 2.0,
y = cy + 8.0,
);
piles_body(&mut s, view);
s.push_str("</g>");
// 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,
"<g transform=\"translate({px:.0},{py:.0}) scale(0.62)\">",
px = x + dx - 26.0,
py = y + dy - 17.0,
"<g transform=\"translate({px:.0},{py:.0}) scale(0.55)\">",
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("</g>");
}
let _ = write!(
s,
"<g data-drop=\"seat-{raw}\">\
<circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"34\" fill=\"#1b1e26\" \
<circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"32\" fill=\"#1b1e26\" \
stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\
<text x=\"{x:.0}\" y=\"{t1:.0}\" fill=\"#dde\" font-size=\"12\" \
text-anchor=\"middle\">{name}{you}</text>\
<text x=\"{x:.0}\" y=\"{t2:.0}\" fill=\"#89a\" font-size=\"10\" \
text-anchor=\"middle\">stress {stress}{focus}</text></g>",
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 = "<svg viewBox=\"0 0 84 56\" width=\"84\" height=\"56\" c
<rect x=\"2\" y=\"2\" width=\"80\" height=\"52\" rx=\"7\" fill=\"#2a2f3a\" stroke=\"#5a6b7a\"/>\
<path d=\"M14 14 h56 M14 28 h56 M14 42 h56\" stroke=\"#3d4756\" stroke-width=\"3\"/></svg>";
/// The played card without its own `<svg>`, 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(
"</div><div class=\"card\" data-drop=\"table\">the table \u{2014} drop here for an \
untargeted action</div>",
);
// 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("</div>");
}
if may_pass {
s.push_str(

View file

@ -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("<circle cx=\"")
.filter_map(|(i, _)| {
let rest = &html[i + 12..];
let (x, rest) = rest.split_once("\" cy=\"")?;
let (y, _) = rest.split_once('"')?;
Some((x.parse().ok()?, y.parse().ok()?))
})
.collect()
}
fn view_of(players: u8) -> 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("<circle cx=\"")
.filter_map(|(i, _)| {
let rest = &html[i + 12..];
let (x, rest) = rest.split_once("\" cy=\"")?;
let (y, _) = rest.split_once('"')?;
Some((x.parse().ok()?, y.parse().ok()?))
})
.collect();
let seats = seat_centres(&html);
assert_eq!(
seats.len(),
players as usize,
@ -761,7 +765,7 @@ mod overhead_table {
for b in &seats[i + 1..] {
let d = ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
assert!(
d > 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("</svg>").next())
.expect("one table svg");
assert!(
table.contains("data-drop=\"table\""),
"the drop target is not on the drawn table"
);
assert!(
table.contains("<ellipse"),
"the drop target should be the surface itself"
);
}
/// **The reason the game became unplayable.** The table was 620px
/// tall, so the action cards sat a screen below the Problems — and you
/// cannot drag between two things never on screen together.
///
/// Asserted on the declared height, which is the only thing a test
/// without a browser can see, and that limit is said out loud rather
/// than implied.
#[test]
fn the_table_is_short_enough_to_drag_from() {
let v = view_of(6);
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
let vb = html
.split("viewBox=\"0 0 760 ")
.nth(1)
.and_then(|s| s.split('"').next())
.expect("the table declares a viewBox");
let h: f64 = vb.parse().expect("a number");
assert!(
h <= 460.0,
"the table is {h}px tall; the action cards end up off-screen and \
dragging to a Problem becomes impossible"
);
}
/// The three diagrams became one: the relationship circle and the
/// piles picture are gone as separate views, and their content is on
/// the table.

View file

@ -169,6 +169,17 @@ impl Guard {
format!("/note?t={}", self.token)
}
/// The page's own path, token and all — for a `Location:` header.
///
/// **A redirect back to bare `/` is refused**, because control 1
/// requires the token on every request including the one the browser
/// makes for you. CB-WP-0027's note form saved the note and then sent
/// the browser somewhere it was not allowed to go, which reads to a
/// player as "the note failed".
pub fn page_path(&self) -> 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();

View file

@ -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),
},