fix: the game was unplayable, and the note redirect was refused
Some checks failed
ci / check (push) Failing after 3s
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:
parent
ab364a0319
commit
8eadb6c963
4 changed files with 175 additions and 46 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue