CB-WP-0028 T03/T08: one overhead table, and what the nine observations
Some checks failed
ci / check (push) Has been cancelled
Some checks failed
ci / check (push) Has been cancelled
turned out to be T03. One table_svg: seats around an elliptical table starting at the BOTTOM -- the viewer sits nearest the reader, as at a real table -- Problems and both stacks in the middle, each seat's played card between it and the centre, relations drawn between seats. Two renderers were DELETED: relations_svg and piles_svg. The task said one table not two diagrams, and leaving the old ones would have meant drawing the same thing twice and letting them drift. No coverage probe cost, through a restructure that merged three diagrams and removed two functions. Second confirmation of CB-WP-0027's finding: a probe naming a FACT survives a reflow, one naming a PRESENTATION does not. CB-WP-0024's "17 remaining" broke on a rendering change; this far larger reflow broke nothing. The new control is per seat count -- no two seat circles closer than 70px at 2 through 6 -- asserted rather than eyeballed at three, which is the only count anyone ever looks at. T08 (CB-EV-0026). Seven of nine observations were engine defects, one was a design finding, one was already true and nobody could tell. Observations 4 and 5 both dissolved and had ONE cause: nothing on the page said how drawing works, so a player built a mental model to fill the gap and reported the gap as two feature requests. The import gap was worse than "one of nineteen" -- 5 of 13 columns read from the file we DID vendor, discarded at parse time for eight days. Rule coverage was 59/59 throughout. The gate measures whether rules are EXERCISED; nothing measures whether a player can READ the game, and nothing cheaply could, which is why the person playing it is the instrument. TWO GATES WERE WRITTEN FOR A SMALLER WORLD, and neither was wrong when written. edition-check compared one recorded digest against Problems.csv regardless of which file it described -- correct with one vendored file, comparing across files with four. And a cb-play test asserted the literal "game over" and went red when a won game said "solved", which was T06 working; it now asserts the heading against the OUTCOME and covers the no-outcome case the original never touched. Chaos window 2 closes with zero overrides in eleven declarations at d8. Third and final statement of it: d8 bought rarity by spending evidence, and a mechanism producing no data across a full window cannot be evaluated by that window. make all: exit 0. 57 render tests, 26 cb-play, loop-lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4d3e30eda4
commit
eb8aa64c56
5 changed files with 459 additions and 122 deletions
|
|
@ -421,24 +421,10 @@ fn pile_svg(out: &mut String, x: i32, label: &str, count: usize, face: &str, not
|
|||
);
|
||||
}
|
||||
|
||||
/// The draw stack and the discard stack, as objects on the table.
|
||||
///
|
||||
/// Both numbers come from the projection — `solution_deck_len` and
|
||||
/// `solution_discard` — and are never recomputed here.
|
||||
///
|
||||
/// **The reshuffle is real and is the U4 default**, confirmed by
|
||||
/// ground-game on 2026-08-03: when the deck runs out, the discard is
|
||||
/// reshuffled into it deterministically; if both are empty the draw is
|
||||
/// skipped (`games/ground/src/lib.rs` `draw_solution`). So the pile shows
|
||||
/// the state in which the *next* draw will trigger it. It does not claim a
|
||||
/// reshuffle has happened — the view carries no such flag, and the event
|
||||
/// already reads out in the log.
|
||||
fn piles_svg(out: &mut String, view: &GroundView) {
|
||||
fn piles_body(out: &mut String, view: &GroundView) {
|
||||
let deck = view.solution_deck_len;
|
||||
let discard = view.solution_discard.len();
|
||||
let will_reshuffle = deck == 0 && discard > 0;
|
||||
|
||||
out.push_str("<svg viewBox=\"0 0 300 110\" width=\"300\" height=\"110\" role=\"img\">");
|
||||
pile_svg(
|
||||
out,
|
||||
10,
|
||||
|
|
@ -465,27 +451,34 @@ fn piles_svg(out: &mut String, view: &GroundView) {
|
|||
<path d=\"M118 40 l4 6 -7 1z\" fill=\"#fc9\"/>",
|
||||
);
|
||||
}
|
||||
out.push_str("</svg>");
|
||||
|
||||
// The discard is public — it has been played (GR-S04 hides only the
|
||||
// deck) — so its contents stay readable as text beside the picture.
|
||||
let _ = write!(
|
||||
out,
|
||||
"<div><span class=\"k\">discard</span> {}</div>",
|
||||
esc(&cards(&view.solution_discard)),
|
||||
);
|
||||
}
|
||||
|
||||
fn relations_svg(view: &GroundView) -> String {
|
||||
/// The table, seen from above (CB-WP-0028 T03).
|
||||
///
|
||||
/// **One diagram, not three.** The seats were already placed on a circle
|
||||
/// for the relationship graph, while the Problems were a row above it and
|
||||
/// the piles a separate picture below — three views of one table, and the
|
||||
/// player had to assemble them. Now: seats around the edge, the Problems
|
||||
/// and the two stacks in the middle, each seat's played card in front of
|
||||
/// it, relations drawn between seats.
|
||||
///
|
||||
/// The existing `problem_svg` and `pile_svg` are reused rather than
|
||||
/// reimplemented — they carry the tokens the coverage gate probes for,
|
||||
/// 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, r) = (200.0f64, 150.0f64, 110.0f64);
|
||||
let (cx, cy) = (380.0f64, 300.0f64);
|
||||
let seat_r = 240.0f64;
|
||||
|
||||
let pos: Vec<(PlayerId, f64, f64)> = view
|
||||
.players
|
||||
.keys()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let a = std::f64::consts::TAU * (i as f64) / (n as f64) - std::f64::consts::FRAC_PI_2;
|
||||
(*p, cx + r * a.cos(), cy + r * a.sin())
|
||||
// Start at the bottom, not the top: the viewer sits nearest
|
||||
// the reader, which is where you sit at a real table.
|
||||
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)
|
||||
})
|
||||
.collect();
|
||||
let find = |p: PlayerId| {
|
||||
|
|
@ -495,8 +488,12 @@ fn relations_svg(view: &GroundView) -> String {
|
|||
};
|
||||
|
||||
let mut s = String::from(
|
||||
"<svg width=\"400\" height=\"300\" role=\"img\" aria-label=\"relationship graph\">",
|
||||
"<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\"/>",
|
||||
);
|
||||
|
||||
// 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 {
|
||||
|
|
@ -507,32 +504,70 @@ fn relations_svg(view: &GroundView) -> String {
|
|||
s,
|
||||
"<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
|
||||
stroke=\"{colour}\" stroke-width=\"2\"/>\
|
||||
<text x=\"{mx:.0}\" y=\"{my:.0}\" fill=\"{colour}\" font-size=\"10\">{rel:?}</text>",
|
||||
<text x=\"{mx:.0}\" y=\"{my:.0}\" fill=\"{colour}\" font-size=\"10\" \
|
||||
text-anchor=\"middle\">{rel:?}</text>",
|
||||
mx = (x1 + x2) / 2.0,
|
||||
my = (y1 + y2) / 2.0 - 3.0,
|
||||
my = (y1 + y2) / 2.0 - 4.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The middle of the table: Problems, then the two stacks under them.
|
||||
let span = (view.problems.len().max(1) as f64) * 130.0;
|
||||
let _ = write!(
|
||||
s,
|
||||
"<g transform=\"translate({x:.0},170)\">",
|
||||
x = cx - span / 2.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);
|
||||
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
|
||||
.focus
|
||||
.get(p)
|
||||
.map(|f| format!(" \u{2192}{}", seat_name(*f)));
|
||||
.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);
|
||||
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,
|
||||
);
|
||||
played_inner(&mut s, sel);
|
||||
s.push_str("</g>");
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
"<g data-drop=\"seat-{raw}\"><circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"26\" fill=\"#1b1e26\" \
|
||||
stroke=\"{stroke}\" stroke-width=\"2\"/>\
|
||||
<text x=\"{x:.0}\" y=\"{ty:.0}\" fill=\"#dde\" font-size=\"12\" \
|
||||
text-anchor=\"middle\">{name}</text>\
|
||||
<text x=\"{x:.0}\" y=\"{ty2:.0}\" fill=\"#89a\" font-size=\"10\" \
|
||||
text-anchor=\"middle\">{focus}</text></g>",
|
||||
"<g data-drop=\"seat-{raw}\">\
|
||||
<circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"34\" 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" },
|
||||
ty = y + 2.0,
|
||||
ty2 = y + 16.0,
|
||||
sw = if is_viewer { 3 } else { 2 },
|
||||
t1 = y - 2.0,
|
||||
t2 = y + 14.0,
|
||||
name = seat_name(*p),
|
||||
focus = esc(focus.as_deref().unwrap_or("")),
|
||||
you = if is_viewer { " (you)" } else { "" },
|
||||
stress = pv.stress,
|
||||
focus = esc(&focus),
|
||||
);
|
||||
}
|
||||
s.push_str("</svg>");
|
||||
|
|
@ -575,6 +610,11 @@ 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 => {
|
||||
|
|
@ -845,21 +885,15 @@ fn meta_section(s: &mut String, meta: &[String], note_to: &str) {
|
|||
/// 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(
|
||||
"<h2>problems</h2><svg width=\"760\" height=\"100\" role=\"img\" aria-label=\"problems\">",
|
||||
);
|
||||
// CB-WP-0028 T03: one overhead view — seats around the edge, the
|
||||
// Problems and both stacks in the middle, each seat's played card in
|
||||
// front of it. This replaces three separate diagrams the player had to
|
||||
// assemble: a Problems row, a relationship circle, and a piles picture.
|
||||
s.push_str("<h2>the table</h2>");
|
||||
if view.problems.is_empty() {
|
||||
s.push_str(
|
||||
"<text x=\"12\" y=\"52\" fill=\"#89a\" font-size=\"12\">no problems in play</text>",
|
||||
);
|
||||
s.push_str("<div class=\"card\">no problems in play</div>");
|
||||
}
|
||||
for (i, (priority, p)) in view.problems.iter().enumerate() {
|
||||
problem_svg(s, *priority, p, 10 + (i as i32) * 130);
|
||||
}
|
||||
s.push_str("</svg>");
|
||||
|
||||
s.push_str("<h2>relationships</h2>");
|
||||
s.push_str(&relations_svg(view));
|
||||
s.push_str(&table_svg(view));
|
||||
|
||||
s.push_str("<h2>seats</h2><div class=\"row\">");
|
||||
for (id, p) in &view.players {
|
||||
|
|
@ -867,8 +901,13 @@ fn body(s: &mut String, view: &GroundView) {
|
|||
}
|
||||
s.push_str("</div>");
|
||||
|
||||
s.push_str("<h2>solutions</h2>");
|
||||
piles_svg(s, view);
|
||||
// The discard's contents stay as text: it is public and readable,
|
||||
// and the stack on the table shows only how many.
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div><span class=\"k\">discard</span> {}</div>",
|
||||
esc(&cards(&view.solution_discard)),
|
||||
);
|
||||
|
||||
if let Some(o) = &view.outcome {
|
||||
let personal = o
|
||||
|
|
|
|||
|
|
@ -714,6 +714,119 @@ mod piles {
|
|||
}
|
||||
}
|
||||
|
||||
/// CB-WP-0028 T03 — one overhead table, not three diagrams.
|
||||
#[cfg(test)]
|
||||
mod overhead_table {
|
||||
use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer};
|
||||
use cb_kernel::PlayerId;
|
||||
use games_ground::GroundState;
|
||||
|
||||
fn view_of(players: u8) -> games_ground::view::GroundView {
|
||||
GroundState::setup(
|
||||
&Setup {
|
||||
players,
|
||||
preset: format!("standard-{players}p"),
|
||||
patch: Default::default(),
|
||||
},
|
||||
7,
|
||||
)
|
||||
.expect("preset")
|
||||
.project(Viewer::Player(PlayerId(0)))
|
||||
}
|
||||
|
||||
/// **Every seat count lays out, and no two seats land on each other.**
|
||||
/// Asserted per count rather than eyeballed at three, which is the
|
||||
/// only count anyone ever looks at.
|
||||
#[test]
|
||||
fn two_through_six_seats_all_lay_out_without_overlap() {
|
||||
for players in 2..=6u8 {
|
||||
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();
|
||||
assert_eq!(
|
||||
seats.len(),
|
||||
players as usize,
|
||||
"{players}p: expected one circle per seat, got {seats:?}"
|
||||
);
|
||||
for (i, a) in seats.iter().enumerate() {
|
||||
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,
|
||||
"{players}p: two seats are {d:.0}px apart and the circles are r=34 — \
|
||||
they overlap"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A table where you cannot find yourself is worse than a list.
|
||||
#[test]
|
||||
fn the_viewers_own_seat_is_marked() {
|
||||
let v = view_of(4);
|
||||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||||
assert!(
|
||||
crate::text_of(&html).contains("P1 (you)"),
|
||||
"the viewer's seat is not identifiable"
|
||||
);
|
||||
assert!(
|
||||
html.contains("stroke=\"#9cf\" stroke-width=\"3\""),
|
||||
"the viewer's seat should also be visually distinct, not only labelled"
|
||||
);
|
||||
// A spectator has no seat to mark, and must not claim one.
|
||||
let spec = GroundState::setup(
|
||||
&Setup {
|
||||
players: 4,
|
||||
preset: "standard-4p".into(),
|
||||
patch: Default::default(),
|
||||
},
|
||||
7,
|
||||
)
|
||||
.expect("preset")
|
||||
.project(Viewer::Spectator);
|
||||
assert!(
|
||||
!crate::text_of(&crate::doc::document(&spec, &[], "/x", None, false)).contains("(you)"),
|
||||
"a spectator was given a seat"
|
||||
);
|
||||
}
|
||||
|
||||
/// The three diagrams became one: the relationship circle and the
|
||||
/// piles picture are gone as separate views, and their content is on
|
||||
/// the table.
|
||||
#[test]
|
||||
fn the_stacks_and_the_relations_are_on_the_table() {
|
||||
let v = view_of(3);
|
||||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||||
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("draw pile:"),
|
||||
"the draw stack is not on the table"
|
||||
);
|
||||
assert!(
|
||||
table.contains("discard pile:"),
|
||||
"the discard is not on the table"
|
||||
);
|
||||
assert!(
|
||||
!html.contains("relationship graph"),
|
||||
"the old separate relationship diagram is still being drawn"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// CB-WP-0028 T02 — the cards say what they do, in the edition's words.
|
||||
#[cfg(test)]
|
||||
mod card_words {
|
||||
|
|
@ -936,7 +1049,7 @@ mod two_columns {
|
|||
.and_then(|s| s.split("class=\"cb-meta\"").next())
|
||||
.expect("a game column followed by a meta column");
|
||||
assert!(
|
||||
game.contains("<h2>problems</h2>"),
|
||||
game.contains("<h2>the table</h2>"),
|
||||
"the table must be in the game column"
|
||||
);
|
||||
assert!(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue