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(