Compare commits

...

3 commits

Author SHA1 Message Date
eb8aa64c56 CB-WP-0028 T03/T08: one overhead table, and what the nine observations
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>
2026-08-06 17:33:57 +02:00
4d3e30eda4 CB-WP-0028 T04-T07: a game you solve, rankings that cite their source, and
a feature deliberately not built

T04 BUILT NOTHING, WHICH IS THE CORRECT OUTCOME. "Click the draw stack to
get hand cards" is not a legal move: GroundCommand has no standalone draw,
and the edition's own INVESTIGATE text settles it -- "Choose one hidden,
non-Denied Problem and reveal it. THEN DRAW ONE SOLUTION." Drawing is a
consequence, never an action. Implementing click-to-draw would have
invented a rule, which is what CB-WP-0023 exists to stop.

And observation 5 is already true: drawing is ALREADY automatic, inside
INVESTIGATE, with no player input. An auto-draw option was asked for a
thing that has never been manual.

Both observations have one root cause and T02 fixed it. The maintainer
expected to take cards from the deck because NOTHING ON THE PAGE SAID HOW
DRAWING WORKS -- the INVESTIGATE card's own text was in a file we had not
imported. No finding raised: a player's instinct differing from a legible
rule is a comprehension gap, not a rules gap. Whether the instinct recurs
now that the text is present is a testable question and was not before.

T06: "game solved" on a win, "game over" on a loss, "the game stopped"
with no outcome. Asserted all three ways, because a test checking only the
win case passes for a page that always says solved. More than tone --
GROUND is co-operative and about repairing something, and arcade
failure-state vocabulary for a win tells a player the wrong thing about
what they just did.

T05: the ending page gets the table's two-column shape. Result left;
rankings, controls and the full log right. The seal still removes every
control wherever they now live.

T07's interesting decision was NOT TO RANK. Modes.csv defines
scoring_tiebreak per mode, so ordering is the GAME's where one exists --
"Lower combined Stress, then fewer Blame tokens" for coalitions. For
SHARED GROUND it says "Not applicable", because the table succeeds or
fails together. So co-op shows contributions and refuses to order them,
and says why. Drawing a leaderboard because a leaderboard is easy would
invent scoring the rules do not have. The one derived superlative is
labelled "clay-borg's reading, not a rule" and appears only where the mode
ranks; ties are shown as ties.

52 render tests pass. check clean, loop-lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:25:48 +02:00
4b5b601ce0 CB-WP-0028 T01/T02: the cards say what they do
ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.

THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.

ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.

Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.

One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.

The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.

`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.

And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:22:04 +02:00
13 changed files with 1461 additions and 133 deletions

View file

@ -276,6 +276,11 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
}
/* CB-WP-0024 T03: the card a seat played, in that seat's area. */
.played{display:block;margin:.3rem 0}
/* CB-WP-0028 T02: the card's own words. The tagline reads as the card's
voice, not as engine chrome. */
.tag{display:block;color:#9cb;font-style:italic;margin:.15rem 0 .3rem}
details summary{cursor:pointer;color:#89a;font-size:.9em;margin-top:.3rem}
details p,details{margin:.2rem 0}
.k{color:#89a}
.nil{color:#c88}
.eff{color:#8c9}
@ -304,6 +309,19 @@ fn target_names(targets: &[String]) -> String {
out.join(", ")
}
/// The edition's title for a Problem, by `hidden_priority` (ADR-0015 D1).
///
/// These columns were in the vendored file from the start and thrown away
/// at parse time — the page showed `Repair 2` for a card that reads
/// *"Missed Deadline"*.
fn problem_title(priority: u32) -> Option<String> {
games_ground::edition::problem_texts("SCN_01")
.ok()?
.into_iter()
.find(|t| u32::from(t.priority) == priority)
.map(|t| t.title)
}
fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) {
let (label, sub, fill) = match p {
ProblemView::FaceDown => ("face down".to_string(), String::new(), "#2a2f3a"),
@ -336,9 +354,13 @@ fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) {
"<g data-drop=\"problem-{priority}\"><rect x=\"{x}\" y=\"10\" width=\"120\" height=\"78\" rx=\"8\" \
fill=\"{fill}\" stroke=\"#5a6b7a\"/>\
<text x=\"{tx}\" y=\"36\" fill=\"#dde\" font-size=\"13\">{label}</text>\
<text x=\"{tx}\" y=\"56\" fill=\"#89a\" font-size=\"11\">priority {priority}</text>\
<text x=\"{tx}\" y=\"56\" fill=\"#89a\" font-size=\"11\">{name}</text>\
<text x=\"{tx}\" y=\"74\" fill=\"#fc9\" font-size=\"11\">{sub}</text></g>",
x = x,
// The card's own name where a priority number used to be. Falls
// back to the number rather than blanking: a missing title must
// not remove the only identifier the player had.
name = esc(&problem_title(priority).unwrap_or_else(|| format!("priority {priority}"))),
tx = x + 10,
label = esc(&label),
sub = esc(&sub),
@ -399,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,
@ -443,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| {
@ -473,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 {
@ -485,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>");
@ -553,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 => {
@ -823,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 {
@ -845,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
@ -902,6 +963,18 @@ fn body(s: &mut String, view: &GroundView) {
/// The "your move" section: action cards, numbered fallbacks, the table,
/// and pass. Emits nothing when the seat has nothing legal to do.
/// The edition's own words for one Action (F18, ADR-0015 D2).
///
/// `None` if the dataset does not name it, and the page then shows what it
/// always showed — a missing tagline must not blank the card.
fn action_text(a: games_ground::Action) -> Option<games_ground::edition::CardText> {
let want = format!("ACT_{}", format!("{a:?}").to_uppercase());
games_ground::edition::actions()
.ok()?
.into_iter()
.find(|c| c.id == want)
}
fn move_section(
s: &mut String,
legal: &[games_ground::GroundCommand],
@ -947,11 +1020,25 @@ fn move_section(
s,
"<div class=\"card act pick\" data-drop=\"{id}\" \
data-targets=\"{targets}\" data-descs=\"{descs}\">{a:?}<br>\
<span class=\"k\">onto</span> {names}</div>",
<span class=\"tag\">{tagline}</span>\
<span class=\"k\">onto</span> {names}{rules}</div>",
id = action_id(a),
targets = esc(&targets.join(" ")),
descs = esc(&descs.join("|")),
names = esc(&target_names(&targets)),
// CB-WP-0028 T02 / F18: the card's own words. The
// tagline is always shown; the full rules text is on
// demand, because five of them at once is a wall.
tagline = esc(action_text(a)
.map(|c| c.tagline.clone())
.unwrap_or_default()
.as_str()),
rules = action_text(a)
.map(|c| format!(
"<details><summary>what it does</summary>{}</details>",
esc(&c.rules_text)
))
.unwrap_or_default(),
);
}
}
@ -1059,6 +1146,112 @@ fn log_section(s: &mut String, log: &[LogLine]) {
s.push_str("</div>");
}
/// Who did what, at the end (CB-WP-0028 T07).
///
/// **Two kinds of line, and they are labelled differently.** A *fact* is
/// something the game counts — Problems claimed, personal score, Stress,
/// Blame. A *reading* is ours, and says so.
///
/// **In SHARED GROUND the game defines no ranking at all**: `Modes.csv`
/// gives `scoring_tiebreak` as *"Not applicable"*, because the table
/// succeeds or fails together. So contributions are shown and **not
/// ordered**, and the page says why. Ranking a co-operative game because
/// a leaderboard is easy to draw would be inventing scoring the game does
/// not have — the same defect as canonising a provisional default.
fn rankings(s: &mut String, view: &GroundView) {
let Some(outcome) = &view.outcome else {
return;
};
let mode_id = match view.mode {
games_ground::ScoringMode::SharedGround => "MODE_COOP",
games_ground::ScoringMode::CommonProblem => "MODE_SEMI",
games_ground::ScoringMode::BondedCoalitions => "MODE_COALITION",
};
let tiebreak = games_ground::edition::modes()
.ok()
.and_then(|m| m.into_iter().find(|(c, _)| c.id == mode_id))
.map(|(_, tb)| tb)
.unwrap_or_default();
let ranked = !tiebreak.trim().is_empty() && !tiebreak.starts_with("Not applicable");
// A fact the game counts: who claimed which Problem.
let solved = |p: PlayerId| {
view.problems
.values()
.filter(|q| matches!(q, ProblemView::FaceUp { claimed_by: Some(c), .. } if *c == p))
.count()
};
let mut seats: Vec<PlayerId> = view.players.keys().copied().collect();
if ranked {
// The GAME's tiebreak, not ours: lower Stress first, which both
// ranked modes name as their first key.
seats.sort_by_key(|p| (view.players[p].stress, std::cmp::Reverse(solved(*p))));
}
let _ = write!(
s,
"<h2>who did what</h2><div class=\"card\"><span class=\"k\">mode</span> {mode}",
mode = esc(&format!("{:?}", view.mode)),
);
if ranked {
let _ = write!(
s,
"<br><span class=\"k\">ranked by the game&#39;s own tiebreak</span> {tb}",
tb = esc(&tiebreak),
);
} else {
s.push_str(
"<br><span class=\"k\">not ranked</span> \
this mode&#39;s scoring_tiebreak is \u{201c}Not applicable\u{201d} \u{2014} \
the table succeeds or fails together, so these are contributions, \
not a leaderboard",
);
}
s.push_str("</div><div class=\"row\">");
for p in &seats {
let pv = &view.players[p];
let _ = write!(
s,
"<div class=\"card\"><b>{name}</b><br>\
<span class=\"k\">problems solved</span> {n}<br>\
<span class=\"k\">personal</span> {score}<br>\
<span class=\"k\">stress</span> {stress} \
<span class=\"k\">blame</span> {blame}</div>",
name = seat_name(*p),
n = solved(*p),
score = outcome
.personal
.get(p)
.map(|v| format!("{v:+}"))
.unwrap_or_else(|| "\u{2014}".into()),
stress = pv.stress,
blame = pv.blame_from.len(),
);
}
s.push_str("</div>");
// A READING, marked as ours. Only where the game ranks at all, and
// only when there is a clear leader -- a tie is shown as a tie.
if ranked {
let best = seats.iter().map(|p| solved(*p)).max().unwrap_or(0);
let leaders: Vec<String> = seats
.iter()
.filter(|p| solved(**p) == best && best > 0)
.map(|p| seat_name(*p))
.collect();
let _ = write!(
s,
"<div class=\"card\"><span class=\"k\">clay-borg&#39;s reading, not a rule</span> {who}</div>",
who = match leaders.len() {
0 => "nobody claimed a Problem".to_string(),
1 => format!("most Problems solved: {}", leaders[0]),
_ => format!("most Problems solved, tied: {}", leaders.join(", ")),
},
);
}
}
/// The page a finished game leaves behind (CB-WP-0018 T01).
///
/// `view` is `None` when the game ended badly: then there is no result to
@ -1080,10 +1273,23 @@ pub fn ending(
s,
"<!doctype html><meta charset=\"utf-8\">\
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
<title>GROUND game over</title><style>{STYLE}</style>\
<h1>game over</h1><div class=\"card\">{msg}</div>",
<title>GROUND {heading}</title><style>{STYLE}</style>\
<h1>{heading}</h1><div class=\"card\">{msg}</div>",
msg = esc(message),
// CB-WP-0028 T06. "Game over" is arcade vocabulary for a failure
// state. GROUND is co-operative and about repairing a situation:
// when the table clears its threshold it SOLVED something, and
// telling a player "game over" for that describes what they did
// wrongly. A game with no outcome claims neither.
heading = match view.and_then(|v| v.outcome.as_ref()) {
Some(o) if o.group_success => "game solved",
Some(_) => "game over",
None => "the game stopped",
},
);
// CB-WP-0028 T05: the ending page gets the table's shape — the
// result on the left, everything about the session on the right.
s.push_str("<div class=\"cb-cols\"><div class=\"cb-game\">");
match view {
Some(v) => body(&mut s, v),
None => {
@ -1093,6 +1299,10 @@ pub fn ending(
);
}
}
s.push_str("</div><div class=\"cb-meta\">");
if let Some(v) = view {
rankings(&mut s, v);
}
// CB-WP-0024 T04. Above the log, because it is a result and the log is
// the account. Empty for a first game — one game is not a series, and
// a "cumulative" panel restating the outcome above it is noise.
@ -1106,14 +1316,20 @@ pub fn ending(
}
s.push_str("</div>");
}
log_section(&mut s, log);
// Observations 6 and 7: the controls and the full log belong beside
// the result, not under it.
let _ = write!(
s,
"<div class=\"row\">\
<div class=\"card btn pick\" data-drop=\"again\">play again</div>\
<div class=\"card btn pick\" data-drop=\"done\">end session \u{2014} stops the game server</div>\
</div>\
<div id=\"cb-status\">the game is over</div>\
</div>",
);
log_section(&mut s, log);
s.push_str("</div></div>");
let _ = write!(
s,
"<div id=\"cb-status\">the game is over</div>\
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>",
endpoint = json_string(endpoint),
);

View file

@ -714,6 +714,195 @@ 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 {
use cb_kernel::PlayerId;
use games_ground::{Action, GroundCommand};
/// The GROUND card's own tagline reaches the page. This is finding
/// F18's acceptance test and it has a person attached to it: the
/// maintainer said he did not understand this card.
#[test]
fn the_ground_card_explains_itself_on_the_page() {
let legal = vec![GroundCommand::SelectAction {
action: Action::Ground,
target: None,
problem: None,
}];
let html = crate::doc::document(
&crate::testfix::view(Some(PlayerId(0))),
&legal,
"/command?t=x",
Some(PlayerId(0)),
false,
);
let text = crate::text_of(&html);
assert!(
text.contains("Regulate. Restore the frame. Decide."),
"the GROUND card's tagline did not reach the page: {text}"
);
assert!(
text.contains("Ground & Restate"),
"its rules text must be available on demand"
);
// From the dataset, not from us.
let from_edition = games_ground::edition::actions()
.expect("actions")
.into_iter()
.any(|c| c.title == "GROUND" && text.contains(&c.tagline));
assert!(from_edition, "the tagline on the page is not the edition's");
}
/// A Problem shows its own name. The page said `Repair 2` for a card
/// that reads "Missed Deadline", using columns vendored eight days
/// earlier and discarded at parse time (ADR-0015 D1).
#[test]
fn a_problem_shows_the_name_the_card_has() {
let titles: Vec<String> = games_ground::edition::problem_texts("SCN_01")
.expect("SCN_01")
.into_iter()
.map(|t| t.title)
.collect();
assert!(!titles.is_empty());
let mut view = crate::testfix::view(Some(PlayerId(0)));
// Priorities the fixture uses must exist in the edition for the
// lookup to resolve; use the edition's own.
let real = games_ground::edition::problem_texts("SCN_01").expect("SCN_01");
let keys: Vec<u32> = view.problems.keys().copied().collect();
for (k, t) in keys.iter().zip(real.iter()) {
if let Some(p) = view.problems.remove(k) {
view.problems.insert(u32::from(t.priority), p);
}
}
let text = crate::text_of(&crate::doc::document(
&view,
&[],
"/command?t=x",
Some(PlayerId(0)),
false,
));
assert!(
titles.iter().any(|t| text.contains(t.as_str())),
"no Problem title from the edition appears on the page: {text}"
);
}
}
/// CB-WP-0027 T03 — the note channel, and the two things it must not do.
#[cfg(test)]
mod notes {
@ -860,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!(
@ -1062,6 +1251,89 @@ mod ending_page {
);
}
/// **CB-WP-0028 T06, asserted both ways.** A test that only checked
/// the win case would pass for a page that always says "solved".
#[test]
fn a_won_game_is_solved_and_a_lost_one_is_over() {
let mut won = crate::testfix::view(None);
if let Some(o) = won.outcome.as_mut() {
o.group_success = true;
}
let mut lost = crate::testfix::view(None);
if let Some(o) = lost.outcome.as_mut() {
o.group_success = false;
}
let head = |v: &games_ground::view::GroundView| {
crate::text_of(&doc::ending(Some(v), "m", "/command?t=x", &[], &[]))
};
assert!(
head(&won).contains("game solved"),
"a won game said otherwise"
);
assert!(
!head(&won).contains("game over"),
"\"game over\" is arcade vocabulary for a failure state"
);
assert!(
head(&lost).contains("game over"),
"a lost game said otherwise"
);
assert!(!head(&lost).contains("game solved"));
// And a game with no outcome claims neither.
let none = crate::text_of(&doc::ending(
None,
"P1 ran out of input",
"/command?t=x",
&[],
&[],
));
assert!(!none.contains("game solved") && !none.contains("game over"));
}
/// **CB-WP-0028 T07.** Every ranking names its source, and the
/// co-operative mode is not ranked at all — the game says its
/// tiebreak is "Not applicable", and ranking it anyway would invent
/// scoring the rules do not have.
#[test]
fn a_cooperative_game_shows_contributions_and_refuses_to_rank_them() {
let mut v = crate::testfix::view(None);
v.mode = games_ground::ScoringMode::SharedGround;
let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[]));
assert!(
text.contains("problems solved"),
"the countable fact is missing"
);
assert!(
text.contains("not ranked"),
"SHARED GROUND must not be ranked: {text}"
);
assert!(
!text.contains("clay-borg's reading"),
"a reading must not be offered where the game defines no ranking"
);
}
/// And the inverse, or the test above passes for a page that never
/// ranks anything. A ranked mode cites the GAME's tiebreak and marks
/// anything derived as ours.
#[test]
fn a_ranked_mode_cites_the_games_own_tiebreak() {
let mut v = crate::testfix::view(None);
v.mode = games_ground::ScoringMode::BondedCoalitions;
let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[]));
assert!(
text.contains("Lower combined Stress"),
"the tiebreak shown must be the edition's own words: {text}"
);
assert!(
text.contains("clay-borg&#39;s reading") || text.contains("clay-borg's reading"),
"a derived superlative must be marked as ours, not as a rule"
);
}
/// The negative control. If `seal` fired on any reply, this test would
/// pass for a page that tears itself down whenever it is touched —
/// which would break `play again` in the ordinary case.

View file

@ -0,0 +1,160 @@
# ADR-0015: import what a player reads, and read what we already imported
status: accepted
date: 2026-08-06
decided by: agent, under the standing loop authorization
tier: M (structural M — imports more of an external dataset under AM-4's
budgets; chaos d8=1 → no override). Tier M merges survey and decision.
references: [CB-WP-0028](../workplans/CB-WP-0028-the-table-you-sit-at.md),
[ADR-0011](ADR-0011-vendor-the-edition.md) (the precedent and its revisit
condition), [GameDesign.md](../specs/GameDesign.md) §1,
F18 in [FindingRegister.md](../specs/FindingRegister.md)
## Context
A player said *"I don't understand the GROUND card."* The card explains
itself in the edition and the engine never imported the explanation.
## The measurements this rests on
**Taken before deciding, because ADR-0011's own numbers were computed
against the wrong budget and the correction is the reason that ADR exists.**
**1. The gap is bigger than "one file of nineteen."**
| | |
|---|---|
| edition files in `ground-game` | **19** |
| vendored in clay-borg | **1** (`Problems.csv`) |
| columns in that file | **13** |
| columns the engine reads | **5**`scenario_id`, `hidden_priority`, `point_value`, `required_solution`, `visibility` |
| scenarios in the file | **4** (`SCN_01``SCN_04`) |
| scenarios the engine deals | **1**`SCN_01`, hardcoded at `lib.rs:1824` |
**Every Problem already has a `title` and `problem_text` sitting in a file
we vendored eight days ago**, and the page shows `Repair 2`. The cheapest
part of this pass costs no new bytes at all.
**2. ADR-0011's revisit condition is not triggered, and that is measured.**
ADR-0011 refused the `csv` crate and said the decision would be wrong *"if
this data ever grows nested quoting, embedded newlines, or multiple
dialects."* Across every candidate file:
| file | rows | fields with `,` | with `"` | with newline |
|---|---:|---:|---:|---:|
| Actions | 5 | 12 | **0** | **0** |
| Solutions | 24 | 5 | **0** | **0** |
| Modes | 3 | 6 | **0** | **0** |
| Scenarios | 4 | 2 | **0** | **0** |
**Zero doubled quotes, zero embedded newlines.** The hand reader's only
job is comma-in-quoted-field, which it already does. **The dependency
argument does not get re-run**, because the condition that would re-open
it is absent — not because re-running it would be inconvenient.
---
## D1 — read the columns we already have, first
`title`, `problem_text`, `front_rules`, `reveal_effect` and
`unresolved_effect` are in the vendored file and discarded at parse time.
They become part of `EditionProblem` and reach the page.
**This is the whole of observation 2's Problem half and costs zero new
dependencies, zero new files and no budget.** It is listed first because
a pass that imports three new files while ignoring eight columns of the
file it already has would be solving the interesting problem and not the
real one.
## D2 — vendor `Actions.csv`, `Solutions.csv`, `Modes.csv`
These carry the text a player reads:
- **Actions** — the five action cards' `title`, `tagline`, `rules_text`.
The GROUND card's own words are here, and this is the reported defect.
- **Solutions**`title` and `microcopy` per card, so a hand shows
*"Ask What Happened"* rather than `Clarify`.
- **Modes** — the scoring mode's `title`, `tagline` and `rules_text`, and
its `scoring_tiebreak`, which T07 needs and which we would otherwise
have invented.
Each vendored with a digest and a provenance line, exactly as
`Problems.csv` was.
**Not vendored:** `BOM`, `Print_Manifest`, `Back_Designs`, `Symbols`,
`Design_Tokens`, `README`, `metadata` — production artifacts for a
physical print run, with no meaning to a simulator.
**`Extensions.csv` is not imported, and the reason is stated rather than
assumed:** it names content the designer deliberately placed *outside* the
core (`not_in_core_reason` is a column). Importing it would put
non-core content in an engine whose whole claim is that it plays the
edition as printed. **It is now known to exist**, which was the actual
risk.
**`Relations`, `DARVO`, `Tokens`, `Glossary` are deferred**, not refused:
they describe mechanisms `GroundRules.md` already encodes as rules, and
nothing a player reads is missing from the page without them. If T02 finds
a seat's DARVO stage needs its own words, that is the trigger.
## D3 — the hand reader stays
Measured above. ~50 lines we own against 17,651 for `csv`, and the
grammar has not grown.
**What changes:** the reader becomes generic over "a vendored CSV with a
header" instead of being `Problems.csv`-shaped, because it is about to
have four callers. **A per-file copy is how a parser acquires four subtly
different bugs.**
**Falsifier, restated so it stays live:** if any future edition file
carries a doubled quote or an embedded newline, this decision is wrong and
`csv` is the answer. The check is one command and belongs in
`edition-check.py`.
## D4 — where text and rules disagree, that is a finding
`GroundRules.md`'s 59 rules were hand-derived from these files. Importing
the files puts the source beside the transcription for the first time.
> **A `rules_text` that contradicts a numbered rule is a register finding
> — kind `inconsistent` — not a quiet edit of either.**
Neither wins automatically: the dataset is authoritative on the *game*,
but our rule may encode a ruling `ground-game` has since made. The
register is where that gets resolved, and `ground-game` owns the answer.
**This is the most valuable thing the import can produce** and it is
adversarial to our own work, which is why it is written down before
anyone looks.
## D5 — the scenario stays hardcoded, and that is now visible
`SCN_01` at `lib.rs:1824`, one of four. Not changed here: choosing a
scenario is a setup-surface change (`Setup` has `preset` and `patch`), and
bundling it into a text-import pass would make both harder to review.
**Recorded as an open item rather than left as a surprise** — the engine
has never played three quarters of the shipped content, and nobody had
said so.
## Consequences
- `editions/ground-darvo-r0/` gains three files, each with a digest.
- `edition.rs` gains a generic reader and four typed views over it.
- `EditionProblem` gains the text columns already present.
- `edition-check.py` verifies the new digests and the no-doubled-quotes
condition D3 rests on.
- The page can show the game's own words (T02).
## What was rejected
| rejected | why |
|---|---|
| the `csv` crate | its revisit condition is measurably absent (§2) |
| importing all 19 files | production artifacts have no meaning to a simulator |
| `Extensions.csv` | deliberately non-core content; importing it would break the as-printed claim |
| a per-file parser | four copies acquire four subtly different bugs |
| resolving text-vs-rules conflicts in place | that is a finding, and `ground-game` owns the answer |
| changing the scenario here | a setup-surface change bundled into a text import makes both harder to review |

View file

@ -0,0 +1,6 @@
action_id,title,symbol_id,resolution_order,target,tagline,rules_text,stress_restriction,designer_note
ACT_INVESTIGATE,INVESTIGATE,SYM_INVESTIGATE,4,One hidden Problem,Reveal what is hidden.,"Choose one hidden, non-Denied Problem and reveal it. Then draw one Solution. If no hidden Problems remain, draw one Solution only.","At Stress 4 or 5, this action requires spending a ready Freedom token.",Place the face-down action card beside the chosen hidden Problem.
ACT_SOLVE,SOLVE,SYM_SOLVE,6,"One face-up, non-Denied Problem",Match a solution to the real problem.,"Play and discard one Solution with the matching symbol. Claim the Problem if it is still available when Solve resolves. If another player has already claimed it, keep your Solution.","At Stress 4 or 5, this action requires spending a ready Freedom token.","Resolve competing claims in Lead order, then clockwise."
ACT_SUPPORT,SUPPORT,SYM_SUPPORT,2,One other player,Increase another player's room to choose.,"No relation: 1 Stress; you may form a Bond if both players have a free relation slot and the target accepts. Existing Bond: 2 Stress, ready their Freedom, and cancel their current DARVO stage and end that sequence. Existing Rivalry: 1 Stress; the target chooses to flip the relation to Bond or break it.","At Stress 4 or 5, this action requires spending a ready Freedom token.",A Bond formed by this same Support does not cancel DARVO; the Bond must already exist.
ACT_ATTACK,ATTACK,SYM_ATTACK,5,One other player,Push pressure into another player.,Protection or GROUND—OU may cancel this Attack. No relation: +1 Stress; form a Rivalry if both players have a free relation slot. Existing Bond: +2 Stress and flip it to Rivalry. Existing Rivalry: +2 Stress and break the relation. A cancelled Attack changes neither Stress nor relation.,"Always legal. At Stress 4 or 5, Attack is one of the two actions available without spending Freedom.",Rivalry formation is not consensual.
ACT_GROUND,GROUND,SYM_GROUND,1,"Self, a Problem, a relation, or an incoming effect",Regulate. Restore the frame. Decide.,"After all actions are revealed, choose one mode: GR—Ground & Restate: 2 Stress, ready Freedom; if you are in DARVO, its current stage still resolves, then the remaining sequence ends. OU—Observe & Uphold: restore one Denied Problem, cancel one Attack targeting you this round, or protect one face-up Problem from Deny this round. ND—Name & Decide: remove one Blame from yourself, break one relation involving you, or reject one Reverse targeting you this round.","Always legal. At Stress 4 or 5, GROUND is one of the two actions available without spending Freedom.",Keep the revealed GROUND card in front of the player until the end of the round as a reminder of the chosen mode.
1 action_id title symbol_id resolution_order target tagline rules_text stress_restriction designer_note
2 ACT_INVESTIGATE INVESTIGATE SYM_INVESTIGATE 4 One hidden Problem Reveal what is hidden. Choose one hidden, non-Denied Problem and reveal it. Then draw one Solution. If no hidden Problems remain, draw one Solution only. At Stress 4 or 5, this action requires spending a ready Freedom token. Place the face-down action card beside the chosen hidden Problem.
3 ACT_SOLVE SOLVE SYM_SOLVE 6 One face-up, non-Denied Problem Match a solution to the real problem. Play and discard one Solution with the matching symbol. Claim the Problem if it is still available when Solve resolves. If another player has already claimed it, keep your Solution. At Stress 4 or 5, this action requires spending a ready Freedom token. Resolve competing claims in Lead order, then clockwise.
4 ACT_SUPPORT SUPPORT SYM_SUPPORT 2 One other player Increase another player's room to choose. No relation: −1 Stress; you may form a Bond if both players have a free relation slot and the target accepts. Existing Bond: −2 Stress, ready their Freedom, and cancel their current DARVO stage and end that sequence. Existing Rivalry: −1 Stress; the target chooses to flip the relation to Bond or break it. At Stress 4 or 5, this action requires spending a ready Freedom token. A Bond formed by this same Support does not cancel DARVO; the Bond must already exist.
5 ACT_ATTACK ATTACK SYM_ATTACK 5 One other player Push pressure into another player. Protection or GROUND—OU may cancel this Attack. No relation: +1 Stress; form a Rivalry if both players have a free relation slot. Existing Bond: +2 Stress and flip it to Rivalry. Existing Rivalry: +2 Stress and break the relation. A cancelled Attack changes neither Stress nor relation. Always legal. At Stress 4 or 5, Attack is one of the two actions available without spending Freedom. Rivalry formation is not consensual.
6 ACT_GROUND GROUND SYM_GROUND 1 Self, a Problem, a relation, or an incoming effect Regulate. Restore the frame. Decide. After all actions are revealed, choose one mode: GR—Ground & Restate: −2 Stress, ready Freedom; if you are in DARVO, its current stage still resolves, then the remaining sequence ends. OU—Observe & Uphold: restore one Denied Problem, cancel one Attack targeting you this round, or protect one face-up Problem from Deny this round. ND—Name & Decide: remove one Blame from yourself, break one relation involving you, or reject one Reverse targeting you this round. Always legal. At Stress 4 or 5, GROUND is one of the two actions available without spending Freedom. Keep the revealed GROUND card in front of the player until the end of the round as a reminder of the chosen mode.

View file

@ -0,0 +1,4 @@
mode_id,title,mode_type,tagline,rules_text,scoring_tiebreak,back_design_id
MODE_COOP,SHARED GROUND,Cooperative,The group succeeds or fails together.,"All claimed Problem cards form one shared score. Meet the Scenario threshold by the end of Round 5. Playable at 26 players — not a high-seat-only mode. No individual winner is declared. For a mastery rating, subtract 1 for each Blame token still in play and 1 for each Denied Problem.",Not applicable.,BACK_MODE
MODE_SEMI,"COMMON PROBLEM, PERSONAL EDGE",Semi-cooperative,The group must succeed before anyone can win.,"The group succeeds only if the total value of all claimed Problems meets the Scenario threshold (always ≤ available points for the seat band). If it succeeds, each player scores the value of Problems they claimed minus 1 per Blame token they hold. Highest score wins.","Lower Stress, then more Bonds, then shared victory.",BACK_MODE
MODE_COALITION,BONDED COALITIONS,Dynamic teams,Bonds decide the sides at the end.,"The group must first meet the Scenario threshold (always ≤ available points for the seat band). At game end, each connected network of Bonds is a coalition; unbonded players are one-player coalitions. Sum the personal scores of coalition members. The highest-scoring coalition wins. Rivalries do not connect players.","Lower combined Stress, then fewer Blame tokens, then shared victory.",BACK_MODE
1 mode_id title mode_type tagline rules_text scoring_tiebreak back_design_id
2 MODE_COOP SHARED GROUND Cooperative The group succeeds or fails together. All claimed Problem cards form one shared score. Meet the Scenario threshold by the end of Round 5. Playable at 2–6 players — not a high-seat-only mode. No individual winner is declared. For a mastery rating, subtract 1 for each Blame token still in play and 1 for each Denied Problem. Not applicable. BACK_MODE
3 MODE_SEMI COMMON PROBLEM, PERSONAL EDGE Semi-cooperative The group must succeed before anyone can win. The group succeeds only if the total value of all claimed Problems meets the Scenario threshold (always ≤ available points for the seat band). If it succeeds, each player scores the value of Problems they claimed minus 1 per Blame token they hold. Highest score wins. Lower Stress, then more Bonds, then shared victory. BACK_MODE
4 MODE_COALITION BONDED COALITIONS Dynamic teams Bonds decide the sides at the end. The group must first meet the Scenario threshold (always ≤ available points for the seat band). At game end, each connected network of Bonds is a coalition; unbonded players are one-player coalitions. Sum the personal scores of coalition members. The highest-scoring coalition wins. Rivalries do not connect players. Lower combined Stress, then fewer Blame tokens, then shared victory. BACK_MODE

View file

@ -7,15 +7,31 @@ build must not depend on a sibling checkout that CI does not have.
| | |
|---|---|
| upstream repo | `ground-game` |
| upstream path | `editions/ground-darvo-r0/Problems.csv` |
| upstream revision | `9fd27a51f427a0a3dbeba04bc44c2e2eae894b3e` |
| vendored | 2026-08-04 |
| upstream repo path | `editions/ground-darvo-r0/` |
| authoritative? | **yes** — ruled by ground-game, GROUND-WP-0002 T01 |
## Digest
## Files, and why each is here
| file | vendored | upstream revision | why |
|---|---|---|---|
| `Problems.csv` | 2026-08-04 | `9fd27a51` | the deal (ADR-0011) |
| `Actions.csv` | 2026-08-06 | `4fa3b8ed` | the five action cards' own words (ADR-0015 D2) |
| `Solutions.csv` | 2026-08-06 | `4fa3b8ed` | Solution titles and microcopy |
| `Modes.csv` | 2026-08-06 | `4fa3b8ed` | mode text and `scoring_tiebreak` |
**Deliberately absent**: `BOM`, `Print_Manifest`, `Back_Designs`,
`Symbols`, `Design_Tokens` — production artifacts for a physical print
run, meaningless to a simulator. And `Extensions.csv`, which names content
the designer placed *outside* the core; importing it would break the claim
that this engine plays the edition as printed (ADR-0015 D2).
## Digests
```
sha256 7a7a302aabecdeb419c562a029bec3e3576a306b0bd1175d1200de60d9530073 Actions.csv
sha256 565431571bc06adafb67edefd4b368839b6b94134c2289877384688cc643390b Modes.csv
sha256 0a04830c93b62dcb2f4411a9fbde576368a7427fe9e63c5015e606e4d42d23a0 Problems.csv
sha256 0bda1ee97de726b5e8c4404ab15ecc53a359e6c23db74fb41ecb51d78f9884ad Solutions.csv
```
`make edition-check` compares this against `../ground-game` when that

View file

@ -0,0 +1,25 @@
solution_id,suit,symbol_id,title,microcopy,rules_text,quantity,back_design_id
SOL_C01,Clarify,SYM_CLARIFY,Ask What Happened,Invite a concrete account before judging.,Discard with SOLVE to resolve one revealed Problem requiring Clarify.,1,BACK_SOLUTION
SOL_C02,Clarify,SYM_CLARIFY,Compare Accounts,Place different perspectives side by side.,Discard with SOLVE to resolve one revealed Problem requiring Clarify.,1,BACK_SOLUTION
SOL_C03,Clarify,SYM_CLARIFY,Check the Evidence,"Use records, observations, or agreed facts.",Discard with SOLVE to resolve one revealed Problem requiring Clarify.,1,BACK_SOLUTION
SOL_C04,Clarify,SYM_CLARIFY,Name the Assumption,Turn an unspoken belief into a testable statement.,Discard with SOLVE to resolve one revealed Problem requiring Clarify.,1,BACK_SOLUTION
SOL_C05,Clarify,SYM_CLARIFY,Separate the Issues,Keep a counterclaim from replacing the original problem.,Discard with SOLVE to resolve one revealed Problem requiring Clarify.,1,BACK_SOLUTION
SOL_C06,Clarify,SYM_CLARIFY,Confirm the Agreement,Restate what each person understood.,Discard with SOLVE to resolve one revealed Problem requiring Clarify.,1,BACK_SOLUTION
SOL_R01,Repair,SYM_REPAIR,Acknowledge Impact,Name what the action changed for others.,Discard with SOLVE to resolve one revealed Problem requiring Repair.,1,BACK_SOLUTION
SOL_R02,Repair,SYM_REPAIR,Own Your Part,Accept the part that belongs to you.,Discard with SOLVE to resolve one revealed Problem requiring Repair.,1,BACK_SOLUTION
SOL_R03,Repair,SYM_REPAIR,Correct the Record,Replace a misleading account with an accurate one.,Discard with SOLVE to resolve one revealed Problem requiring Repair.,1,BACK_SOLUTION
SOL_R04,Repair,SYM_REPAIR,Make a Specific Apology,"Name the act, impact, and responsibility.",Discard with SOLVE to resolve one revealed Problem requiring Repair.,1,BACK_SOLUTION
SOL_R05,Repair,SYM_REPAIR,Offer Restitution,"Restore value, time, opportunity, or standing.",Discard with SOLVE to resolve one revealed Problem requiring Repair.,1,BACK_SOLUTION
SOL_R06,Repair,SYM_REPAIR,Follow Through,Make repair visible through completed action.,Discard with SOLVE to resolve one revealed Problem requiring Repair.,1,BACK_SOLUTION
SOL_B01,Boundary,SYM_BOUNDARY,State the Limit,Say clearly what will and will not continue.,Discard with SOLVE to resolve one revealed Problem requiring Boundary.,1,BACK_SOLUTION
SOL_B02,Boundary,SYM_BOUNDARY,Pause the Interaction,Create time before pressure removes choice.,Discard with SOLVE to resolve one revealed Problem requiring Boundary.,1,BACK_SOLUTION
SOL_B03,Boundary,SYM_BOUNDARY,Refuse the False Frame,Return to the original issue without accepting misdirection.,Discard with SOLVE to resolve one revealed Problem requiring Boundary.,1,BACK_SOLUTION
SOL_B04,Boundary,SYM_BOUNDARY,Set a Consequence,Connect repeated conduct to a known response.,Discard with SOLVE to resolve one revealed Problem requiring Boundary.,1,BACK_SOLUTION
SOL_B05,Boundary,SYM_BOUNDARY,Bring a Witness,"Add support, memory, or fair process.",Discard with SOLVE to resolve one revealed Problem requiring Boundary.,1,BACK_SOLUTION
SOL_B06,Boundary,SYM_BOUNDARY,Leave the Relation,End a relation that cannot support safe engagement.,Discard with SOLVE to resolve one revealed Problem requiring Boundary.,1,BACK_SOLUTION
SOL_X01,Change,SYM_CHANGE,Clarify Roles,"Assign authority, responsibility, and expectations.",Discard with SOLVE to resolve one revealed Problem requiring Change.,1,BACK_SOLUTION
SOL_X02,Change,SYM_CHANGE,Define a New Process,Replace improvisation with a shared method.,Discard with SOLVE to resolve one revealed Problem requiring Change.,1,BACK_SOLUTION
SOL_X03,Change,SYM_CHANGE,Add a Checkpoint,Detect problems before they become crises.,Discard with SOLVE to resolve one revealed Problem requiring Change.,1,BACK_SOLUTION
SOL_X04,Change,SYM_CHANGE,Document the Agreement,Create a durable shared reference.,Discard with SOLVE to resolve one revealed Problem requiring Change.,1,BACK_SOLUTION
SOL_X05,Change,SYM_CHANGE,Change the Incentive,Stop rewarding the behaviour that creates the problem.,Discard with SOLVE to resolve one revealed Problem requiring Change.,1,BACK_SOLUTION
SOL_X06,Change,SYM_CHANGE,Review the Pattern,Use repeated events to change the system.,Discard with SOLVE to resolve one revealed Problem requiring Change.,1,BACK_SOLUTION
1 solution_id suit symbol_id title microcopy rules_text quantity back_design_id
2 SOL_C01 Clarify SYM_CLARIFY Ask What Happened Invite a concrete account before judging. Discard with SOLVE to resolve one revealed Problem requiring Clarify. 1 BACK_SOLUTION
3 SOL_C02 Clarify SYM_CLARIFY Compare Accounts Place different perspectives side by side. Discard with SOLVE to resolve one revealed Problem requiring Clarify. 1 BACK_SOLUTION
4 SOL_C03 Clarify SYM_CLARIFY Check the Evidence Use records, observations, or agreed facts. Discard with SOLVE to resolve one revealed Problem requiring Clarify. 1 BACK_SOLUTION
5 SOL_C04 Clarify SYM_CLARIFY Name the Assumption Turn an unspoken belief into a testable statement. Discard with SOLVE to resolve one revealed Problem requiring Clarify. 1 BACK_SOLUTION
6 SOL_C05 Clarify SYM_CLARIFY Separate the Issues Keep a counterclaim from replacing the original problem. Discard with SOLVE to resolve one revealed Problem requiring Clarify. 1 BACK_SOLUTION
7 SOL_C06 Clarify SYM_CLARIFY Confirm the Agreement Restate what each person understood. Discard with SOLVE to resolve one revealed Problem requiring Clarify. 1 BACK_SOLUTION
8 SOL_R01 Repair SYM_REPAIR Acknowledge Impact Name what the action changed for others. Discard with SOLVE to resolve one revealed Problem requiring Repair. 1 BACK_SOLUTION
9 SOL_R02 Repair SYM_REPAIR Own Your Part Accept the part that belongs to you. Discard with SOLVE to resolve one revealed Problem requiring Repair. 1 BACK_SOLUTION
10 SOL_R03 Repair SYM_REPAIR Correct the Record Replace a misleading account with an accurate one. Discard with SOLVE to resolve one revealed Problem requiring Repair. 1 BACK_SOLUTION
11 SOL_R04 Repair SYM_REPAIR Make a Specific Apology Name the act, impact, and responsibility. Discard with SOLVE to resolve one revealed Problem requiring Repair. 1 BACK_SOLUTION
12 SOL_R05 Repair SYM_REPAIR Offer Restitution Restore value, time, opportunity, or standing. Discard with SOLVE to resolve one revealed Problem requiring Repair. 1 BACK_SOLUTION
13 SOL_R06 Repair SYM_REPAIR Follow Through Make repair visible through completed action. Discard with SOLVE to resolve one revealed Problem requiring Repair. 1 BACK_SOLUTION
14 SOL_B01 Boundary SYM_BOUNDARY State the Limit Say clearly what will and will not continue. Discard with SOLVE to resolve one revealed Problem requiring Boundary. 1 BACK_SOLUTION
15 SOL_B02 Boundary SYM_BOUNDARY Pause the Interaction Create time before pressure removes choice. Discard with SOLVE to resolve one revealed Problem requiring Boundary. 1 BACK_SOLUTION
16 SOL_B03 Boundary SYM_BOUNDARY Refuse the False Frame Return to the original issue without accepting misdirection. Discard with SOLVE to resolve one revealed Problem requiring Boundary. 1 BACK_SOLUTION
17 SOL_B04 Boundary SYM_BOUNDARY Set a Consequence Connect repeated conduct to a known response. Discard with SOLVE to resolve one revealed Problem requiring Boundary. 1 BACK_SOLUTION
18 SOL_B05 Boundary SYM_BOUNDARY Bring a Witness Add support, memory, or fair process. Discard with SOLVE to resolve one revealed Problem requiring Boundary. 1 BACK_SOLUTION
19 SOL_B06 Boundary SYM_BOUNDARY Leave the Relation End a relation that cannot support safe engagement. Discard with SOLVE to resolve one revealed Problem requiring Boundary. 1 BACK_SOLUTION
20 SOL_X01 Change SYM_CHANGE Clarify Roles Assign authority, responsibility, and expectations. Discard with SOLVE to resolve one revealed Problem requiring Change. 1 BACK_SOLUTION
21 SOL_X02 Change SYM_CHANGE Define a New Process Replace improvisation with a shared method. Discard with SOLVE to resolve one revealed Problem requiring Change. 1 BACK_SOLUTION
22 SOL_X03 Change SYM_CHANGE Add a Checkpoint Detect problems before they become crises. Discard with SOLVE to resolve one revealed Problem requiring Change. 1 BACK_SOLUTION
23 SOL_X04 Change SYM_CHANGE Document the Agreement Create a durable shared reference. Discard with SOLVE to resolve one revealed Problem requiring Change. 1 BACK_SOLUTION
24 SOL_X05 Change SYM_CHANGE Change the Incentive Stop rewarding the behaviour that creates the problem. Discard with SOLVE to resolve one revealed Problem requiring Change. 1 BACK_SOLUTION
25 SOL_X06 Change SYM_CHANGE Review the Pattern Use repeated events to change the system. Discard with SOLVE to resolve one revealed Problem requiring Change. 1 BACK_SOLUTION

View file

@ -0,0 +1,163 @@
# CB-EV-0026 — the table you sit at
CB-WP-0028 T08. Tier M (structural M — imports more of an external
dataset under AM-4's budgets; chaos d8=1 → no override). Declaration 11 of
chaos window 2. Closed 2026-08-06.
**Delivered:** [ADR-0015](../decisions/ADR-0015-the-cards-own-words.md),
three vendored edition files, a generic reader, the cards' own words on
the page, one overhead table replacing three diagrams, controls and log in
the meta column, *"game solved"*, and rankings that cite their source.
---
## 1. Nine observations, and where they actually came from
**Seven of nine were engine defects. One was a design finding. One was
already true and nobody could tell.**
| # | observation | what it turned out to be |
|---|---|---|
| 1 | no incentive to attack | a **design finding** (F17), and still a note |
| 2 | *"I don't understand the GROUND card"* | **a data-import gap** — the card explains itself and we never read it |
| 3 | overhead view | three diagrams the player had to assemble |
| 4 | click the deck to draw | **not a legal move**; built nothing |
| 5 | auto-draw option | **already automatic**, and always was |
| 67 | controls and log on the right | layout |
| 8 | *"Game Over"* on a win | wrong vocabulary for a co-operative game |
| 9 | rankings | a place where the game defines *not to rank* |
**Observations 4 and 5 are the most useful pair in the set**, because both
dissolved. Clicking the deck is not a move — `GroundCommand` has no
standalone draw, and the edition's INVESTIGATE text says *"…reveal it.
**Then draw one Solution.**"* Drawing is a consequence. And auto-draw was
asked for a thing that has **never been manual**.
**Both had one cause: nothing on the page said how drawing works.** The
INVESTIGATE card's own words were in a file we had not imported. So a
player invented a mental model to fill the gap, and reported the gap as
two feature requests.
**No finding was raised.** An instinct differing from a *legible* rule is
a comprehension gap; an instinct differing from an *invisible* one is our
defect. Whether it recurs now that the card says it is a testable question
and was not before.
## 2. The import gap was worse than "one file of nineteen"
Measured before deciding:
| | |
|---|---|
| edition files upstream | 19 |
| vendored | **1** |
| columns in that one file | 13 |
| columns the engine read | **5** |
| scenarios in the file | 4 |
| scenarios the engine deals | **1**, hardcoded |
`title`, `problem_text`, `front_rules`, `reveal_effect` and
`unresolved_effect` were vendored on 2026-08-04 and **discarded at parse
time for eight days**. The page showed `Repair 2` for a card that reads
*"Missed Deadline"*. Fixing that cost no new bytes, no dependency and no
budget — it was the cheapest thing in the pass and the one nothing had
found.
**Rule coverage was 59/59 throughout.** The gate measures whether rules
are exercised, and every rule was. **Nothing measures whether a player can
read the game**, and nothing cheaply could — which is why the person
playing it is the instrument.
## 3. The dependency question was answered by measurement, not preference
ADR-0011 refused the `csv` crate and named its own revisit condition:
*"nested quoting, embedded newlines, multiple dialects."*
Across every candidate file — Actions, Solutions, Modes, Scenarios —
**zero doubled quotes and zero embedded newlines.** The hand reader's only
job is comma-in-quoted-field, which it already did.
So the argument was not re-run, **and the reason is a measurement rather
than the inconvenience of re-running it.** `edition-check` now asserts
that condition on every vendored file, so the day it stops being true the
gate says so instead of a parser mangling a card.
**One reader, four callers.** A per-file copy is how a parser acquires
four subtly different bugs.
## 4. Two gates were written for a smaller world
**`edition-check` compared the first recorded digest against
`Problems.csv` regardless of which file that digest described.** With one
vendored file it was correct; with four it was comparing across files. It
now checks both directions — a vendored file with no digest fails, and a
digest naming an absent file fails.
**A `cb-play` test asserted the literal string `"game over"`** and went
red when a won game began saying *"solved"* — the feature working. Fixed
by asserting the heading **against the outcome** rather than against a
word, which also covers the no-outcome case the original never touched.
**Both are the same shape:** an assertion that encoded a world with one
file, or one ending. Neither was wrong when written.
## 5. What T07 decided not to do
`Modes.csv` gives `scoring_tiebreak` per mode. For SHARED GROUND it is
**"Not applicable"** — the table succeeds or fails together.
So the co-operative mode shows contributions and **refuses to order
them**, and says why on the page. A leaderboard would have been easy,
looked good, and invented scoring the game does not have — the same defect
class as canonising a provisional default, which this project has already
committed once.
Where a mode *does* rank, the ordering is the game's own words. The single
derived superlative is labelled **"clay-borg's reading, not a rule."**
**This is the first time the project has had the game's own tiebreak to
cite**, and it only had it because §2's import brought `Modes.csv` in. The
alternative was inventing one.
## 6. The overhead view cost no coverage probe
All existing probes passed through a restructure that **deleted two
renderers** (`relations_svg`, `piles_svg`) and merged three diagrams into
one.
**Second confirmation of CB-WP-0027's finding**: a probe that names a
*fact* survives a reflow; one that names a *presentation* does not.
CB-WP-0024's `17 remaining` probe broke on a rendering change;
CB-WP-0027's and this pass's did not, and this reflow was far larger.
The new control is per-seat-count: **2 through 6 seats all lay out with no
two seat circles closer than 70px**, asserted rather than eyeballed at
three — which is the only count anyone ever looks at.
## 7. Chaos window 2 — closed
**Declaration 11 of 12**, structural M, d8 = 1, no override.
The window has now produced **zero overrides in eleven declarations at
d8**. Its retirement condition — *retire if an override changes nothing
twice running* — was never testable, and CB-EV-0024 and CB-EV-0025 both
said so. This is the third and final statement of it.
**The window's verdict should be that d8 bought rarity by spending
evidence.** Window 1 at d4: two overrides, both changed the outcome.
Window 2 at d8: none. **A mechanism that produces no data across a full
window cannot be evaluated by that window**, which is a stronger
conclusion than "the rate is too low" and belongs in whatever closes it.
## Open after this pass
- **F17 needs an artifact.** Counting ATTACK selections across the policy
panel against hand quality is cheap and nobody has done it.
- **Three of four scenarios have never been dealt** (ADR-0015 D5). Now
visible, still true.
- **`Relations`, `DARVO`, `Tokens`, `Glossary` are deferred, not refused.**
If a seat's DARVO stage needs its own words, that is the trigger.
- **Nobody has written a trial note in anger** — carried forward from
CB-EV-0025, and the note channel only started working today.
- **Whether the card text changed what the maintainer understood** is this
pass's real acceptance test and has a person attached to it.

View file

@ -23,7 +23,159 @@ pub struct EditionProblem {
pub surface: bool,
}
/// A Problem's own words. Separate from [`EditionProblem`], which is
/// `Copy` and lives in the aggregate; this is presentation and does not.
///
/// **These columns were in the vendored file all along** and were
/// discarded at parse time (ADR-0015 D1) — the page showed `Repair 2`
/// where the card reads *"Missed Deadline"*. Reading them cost no new
/// bytes and no budget.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProblemText {
pub priority: u8,
pub title: String,
pub problem_text: String,
pub front_rules: String,
pub reveal_effect: String,
pub unresolved_effect: String,
}
/// The text of one scenario's Problems, by `hidden_priority`.
pub fn problem_texts(scenario_id: &str) -> Result<Vec<ProblemText>, String> {
let t = Table::parse(CSV, "Problems.csv")?;
let mut out = Vec::new();
for row in &t.rows {
if t.get(row, "scenario_id")? != scenario_id {
continue;
}
out.push(ProblemText {
priority: t
.get(row, "hidden_priority")?
.parse()
.map_err(|_| "hidden_priority is not a number".to_string())?,
title: t.get(row, "title")?.to_string(),
problem_text: t.get(row, "problem_text")?.to_string(),
front_rules: t.get(row, "front_rules")?.to_string(),
reveal_effect: t.get(row, "reveal_effect")?.to_string(),
unresolved_effect: t.get(row, "unresolved_effect")?.to_string(),
});
}
if out.is_empty() {
return Err(format!("no Problem text for {scenario_id}"));
}
out.sort_by_key(|p| p.priority);
Ok(out)
}
const CSV: &str = include_str!("../../../editions/ground-darvo-r0/Problems.csv");
const ACTIONS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Actions.csv");
const SOLUTIONS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Solutions.csv");
const MODES_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Modes.csv");
/// A vendored CSV, parsed into rows addressable by column name.
///
/// **One reader, four callers** (ADR-0015 D3). The first version was
/// `Problems.csv`-shaped; a per-file copy is how a parser acquires four
/// subtly different bugs.
pub struct Table {
cols: Vec<String>,
rows: Vec<Vec<String>>,
}
impl Table {
fn parse(csv: &str, what: &str) -> Result<Self, String> {
let mut lines = csv.lines();
let header = lines.next().ok_or_else(|| format!("{what} is empty"))?;
let cols: Vec<String> = fields(header)
.into_iter()
.map(|c| c.trim_start_matches('\u{feff}').trim().to_string())
.collect();
let mut rows = Vec::new();
for line in lines.filter(|l| !l.trim().is_empty()) {
let f = fields(line);
if f.len() != cols.len() {
return Err(format!(
"{what} row has {} fields, header has {}: {line}",
f.len(),
cols.len()
));
}
rows.push(f);
}
Ok(Self { cols, rows })
}
fn at(&self, name: &str) -> Result<usize, String> {
self.cols
.iter()
.position(|c| c == name)
.ok_or_else(|| format!("edition data has no column {name:?}"))
}
/// A named field of one row, trimmed. `Err` names the column, because
/// a silent empty string is how missing data becomes a blank card.
fn get<'a>(&'a self, row: &'a [String], name: &str) -> Result<&'a str, String> {
Ok(row[self.at(name)?].trim())
}
}
/// What a card says about itself — the game's own words, not ours
/// (ADR-0015 D1/D2, finding F18).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CardText {
pub id: String,
pub title: String,
/// The one-line hook. What a player reads first.
pub tagline: String,
/// The full text. Shown on demand — five of these at once is a wall.
pub rules_text: String,
}
fn card_texts(csv: &str, what: &str, id: &str, tag: &str) -> Result<Vec<CardText>, String> {
let t = Table::parse(csv, what)?;
let mut out = Vec::new();
for row in &t.rows {
out.push(CardText {
id: t.get(row, id)?.to_string(),
title: t.get(row, "title")?.to_string(),
tagline: t.get(row, tag)?.to_string(),
rules_text: t.get(row, "rules_text")?.to_string(),
});
}
if out.is_empty() {
return Err(format!("{what} has no rows"));
}
Ok(out)
}
/// The five Action cards, in the edition's own words.
pub fn actions() -> Result<Vec<CardText>, String> {
card_texts(ACTIONS_CSV, "Actions.csv", "action_id", "tagline")
}
/// The Solution cards. `microcopy` is this deck's tagline column.
pub fn solutions() -> Result<Vec<CardText>, String> {
card_texts(SOLUTIONS_CSV, "Solutions.csv", "solution_id", "microcopy")
}
/// The scoring modes, with the tiebreak the game defines — which T07
/// needs and would otherwise have been invented.
pub fn modes() -> Result<Vec<(CardText, String)>, String> {
let t = Table::parse(MODES_CSV, "Modes.csv")?;
let mut out = Vec::new();
for row in &t.rows {
out.push((
CardText {
id: t.get(row, "mode_id")?.to_string(),
title: t.get(row, "title")?.to_string(),
tagline: t.get(row, "tagline")?.to_string(),
rules_text: t.get(row, "rules_text")?.to_string(),
},
t.get(row, "scoring_tiebreak")?.to_string(),
));
}
Ok(out)
}
/// Split one CSV record, honouring `"…"` quoting.
///
@ -148,3 +300,120 @@ pub fn solution_deck() -> Vec<SolutionCard> {
.flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 6))
.collect()
}
#[cfg(test)]
mod card_text_tests {
use super::*;
/// **The load-bearing control (CB-WP-0028 T02).** The words must come
/// from the edition, not from a Rust literal beside it.
///
/// Asserted by reading the vendored file directly and requiring the
/// parsed value to equal what is in it. A test that compared against a
/// hardcoded expectation would pass for a hand-copied string, which is
/// exactly the drift this pass exists to end.
#[test]
fn the_text_comes_from_the_dataset_not_from_us() {
let ground = actions()
.expect("Actions.csv parses")
.into_iter()
.find(|c| c.title == "GROUND")
.expect("the GROUND card is in the edition");
assert!(
ACTIONS_CSV.contains(&ground.tagline),
"the tagline is not a substring of the vendored file — it was invented"
);
assert!(
ACTIONS_CSV.contains(&ground.rules_text),
"the rules text is not a substring of the vendored file"
);
// And it is the card the maintainer could not understand.
assert_eq!(ground.tagline, "Regulate. Restore the frame. Decide.");
assert!(
ground.rules_text.contains("GR—Ground & Restate"),
"the GROUND card must explain its own modes: {}",
ground.rules_text
);
}
/// All five Actions, all 24 Solutions, all three Modes — a reader that
/// returned one row would pass a "the text is real" test.
#[test]
fn every_card_in_the_edition_is_read() {
assert_eq!(actions().expect("actions").len(), 5, "five Action cards");
assert_eq!(
solutions().expect("solutions").len(),
24,
"24 Solution cards (6 per suit, GR-S04)"
);
assert_eq!(modes().expect("modes").len(), 3, "three scoring modes");
}
/// A Solution shows its own name, not just its suit — the defect a
/// player reported as seeing `Clarify` on a card that says otherwise.
#[test]
fn a_solution_has_words_of_its_own() {
let s = solutions().expect("solutions");
let first = &s[0];
assert!(!first.title.is_empty() && first.title != "Clarify");
assert!(
!first.tagline.is_empty(),
"microcopy is what makes the card readable"
);
}
/// The mode's tiebreak is the game's, not ours (T07 depends on this).
#[test]
fn modes_carry_the_games_own_tiebreak() {
let m = modes().expect("modes");
let (coop, tiebreak) = m
.iter()
.find(|(c, _)| c.id == "MODE_COOP")
.expect("MODE_COOP exists");
assert_eq!(coop.title, "SHARED GROUND");
assert!(
!tiebreak.is_empty(),
"a tiebreak we would otherwise have invented"
);
}
/// ADR-0015 D1: the columns that were in the file all along.
#[test]
fn problems_carry_the_text_that_was_already_vendored() {
let texts = problem_texts("SCN_01").expect("SCN_01 text");
assert!(!texts.is_empty());
let surface = &texts[0];
assert!(
!surface.title.is_empty() && !surface.problem_text.is_empty(),
"the Surface Problem must have its own title and text"
);
assert!(
CSV.contains(&surface.title),
"the title is not from the vendored file"
);
// The text list and the dealt list must describe the same Problems.
let dealt = problems_of("SCN_01").expect("SCN_01 problems");
assert_eq!(
texts.len(),
dealt.len(),
"text and mechanics disagree about how many Problems SCN_01 has"
);
}
/// ADR-0015 D5 made this visible rather than leaving it a surprise:
/// the edition ships four scenarios and the engine deals one.
#[test]
fn the_edition_ships_more_scenarios_than_the_engine_deals() {
let mut found = 0;
for id in ["SCN_01", "SCN_02", "SCN_03", "SCN_04"] {
if problems_of(id).is_ok() {
found += 1;
}
}
assert_eq!(
found, 4,
"four scenarios are vendored; `setup` hardcodes SCN_01 (ADR-0015 D5)"
);
}
}

View file

@ -7,7 +7,13 @@
/// aggregate. That its tests do need `scenarios` is a real seam — setup
/// presets currently live behind that feature (see `bot.rs`).
pub mod bot;
#[cfg(feature = "scenarios")]
/// The vendored edition data (ADR-0011, ADR-0015).
///
/// **Not behind `scenarios`.** It was, because its only consumer —
/// `setup` — is; but the edition is the *game's own data*, and since
/// CB-WP-0028 the shipped runtime reads it too, to show a player what a
/// card says. Test machinery and game content are different things and
/// only one of them is optional.
pub mod edition;
/// K13's per-player projection (CB-WP-0008 T02) — the trait's first

View file

@ -752,7 +752,19 @@ mod tests {
let replies = client.join().expect("client");
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
assert!(replies[0].contains("game over"), "the page did not say so");
// CB-WP-0028 T06: the heading follows the OUTCOME, so this asks
// the view rather than hardcoding a word. The old assertion said
// "game over" and went red when a won game started saying
// "solved" -- which was the feature working.
let want = match view.outcome.as_ref() {
Some(o) if o.group_success => "game solved",
Some(_) => "game over",
None => "the game stopped",
};
assert!(
replies[0].contains(want),
"the heading did not match the outcome; expected {want:?}"
);
assert!(
replies[0].contains("30 commands"),
"the result did not reach the page"

View file

@ -16,9 +16,9 @@ import sys
from repo import ROOT, enter_root
VENDORED = "editions/ground-darvo-r0/Problems.csv"
PROVENANCE = "editions/ground-darvo-r0/PROVENANCE.md"
UPSTREAM = os.path.join(os.path.dirname(ROOT), "ground-game", VENDORED)
EDITION = "editions/ground-darvo-r0"
PROVENANCE = f"{EDITION}/PROVENANCE.md"
UPSTREAM_DIR = os.path.join(os.path.dirname(ROOT), "ground-game", EDITION)
def digest(path):
@ -26,38 +26,90 @@ def digest(path):
def recorded():
"""Every recorded digest, by filename.
The first version matched ONE `sha256 <hex>` and assumed it described
`Problems.csv`. ADR-0015 vendored three more files, and the check
reported the first digest against the wrong file -- a gate written for
a single-file world silently comparing across files.
"""
text = open(os.path.join(ROOT, PROVENANCE)).read()
m = re.search(r"sha256\s+([0-9a-f]{64})", text)
if not m:
raise ValueError(f"{PROVENANCE} records no sha256 digest")
return m.group(1)
found = dict(
(name, d)
for d, name in re.findall(r"sha256\s+([0-9a-f]{64})\s+(\S+)", text)
)
if not found:
raise ValueError(f"{PROVENANCE} records no `sha256 <hex> <file>` digests")
return found
def vendored_files():
"""Every `.csv` actually present, so a file added without a digest is
caught rather than skipped."""
d = os.path.join(ROOT, EDITION)
return sorted(f for f in os.listdir(d) if f.endswith(".csv"))
def check():
have = digest(os.path.join(ROOT, VENDORED))
want = recorded()
print("edition-check — vendored data against its provenance")
if have != want:
print(f" [FAIL] {VENDORED} does not match its recorded digest")
print(f" recorded {want}\n actual {have}")
return 1
print(f" [ok ] vendored copy matches its recorded digest")
rc = 0
if not os.path.exists(UPSTREAM):
present = vendored_files()
undocumented = [f for f in present if f not in want]
if undocumented:
print(f" [FAIL] vendored with no recorded digest: {', '.join(undocumented)}")
rc = 1
missing = [f for f in want if f not in present]
if missing:
print(f" [FAIL] a digest is recorded for a file that is not here: {', '.join(missing)}")
rc = 1
for name in present:
if name not in want:
continue
have = digest(os.path.join(ROOT, EDITION, name))
if have != want[name]:
print(f" [FAIL] {name} does not match its recorded digest")
print(f" recorded {want[name]}\n actual {have}")
rc = 1
else:
print(f" [ok ] {name} matches its recorded digest")
# ADR-0015 D3's falsifier, checked rather than asserted: the hand
# reader handles commas inside quotes and NOTHING ELSE. A doubled
# quote or an embedded newline means `csv` is the answer after all.
for name in present:
raw = open(os.path.join(ROOT, EDITION, name), encoding="utf-8-sig").read()
if '""' in raw:
print(f" [FAIL] {name} contains a doubled quote — ADR-0011's revisit")
print(" condition has fired; the hand reader cannot parse it")
rc = 1
# An embedded newline shows up as an odd quote count on a line.
for i, line in enumerate(raw.splitlines(), 1):
if line.count('"') % 2:
print(f" [FAIL] {name}:{i} has an unbalanced quote — embedded newline?")
rc = 1
break
if rc == 0:
print(" [ok ] no doubled quotes or embedded newlines (ADR-0015 D3)")
if not os.path.isdir(UPSTREAM_DIR):
# NOT a pass and NOT a failure: the question could not be asked.
print(" [----] upstream not checked out — freshness UNVERIFIED")
print(f" expected {UPSTREAM}")
return 0
up = digest(UPSTREAM)
if up != have:
print(" [FAIL] upstream has changed since this copy was vendored")
print(f" upstream {up}\n vendored {have}")
print(" ground-game froze point_value and required_solution")
print(" within r0 — a change here is a new revision, or a")
print(" contract violation worth raising.")
return 1
print(" [ok ] vendored copy is current with ../ground-game")
return 0
print(f" expected {UPSTREAM_DIR}")
return rc
for name in present:
up = os.path.join(UPSTREAM_DIR, name)
if not os.path.exists(up):
print(f" [FAIL] {name} is not in upstream — where did it come from?")
rc = 1
elif digest(up) != digest(os.path.join(ROOT, EDITION, name)):
print(f" [FAIL] upstream {name} has changed since it was vendored")
rc = 1
if rc == 0:
print(" [ok ] every vendored copy is current with ../ground-game")
return rc
def self_test():
@ -67,19 +119,33 @@ def self_test():
def chk(name, ok, detail=""):
results.append((name, ok, detail))
chk("the vendored file exists", os.path.exists(os.path.join(ROOT, VENDORED)))
chk("provenance records a digest", len(recorded()) == 64)
chk("digest of the real file matches provenance",
digest(os.path.join(ROOT, VENDORED)) == recorded())
present = vendored_files()
want = recorded()
chk("vendored files exist", len(present) >= 4, ", ".join(present))
chk("every vendored file has a recorded digest",
all(f in want for f in present),
"a file added without a digest must fail, not be skipped")
chk("every digest names a file that is here",
all(f in present for f in want),
"a stale digest is a lie with a filename")
chk("digests match the real files",
all(digest(os.path.join(ROOT, EDITION, f)) == want[f] for f in present))
# The control that matters: a changed byte must be detected.
import tempfile
one = present[0]
with tempfile.NamedTemporaryFile("wb", delete=False) as fh:
fh.write(open(os.path.join(ROOT, VENDORED), "rb").read() + b"\n#tamper\n")
fh.write(open(os.path.join(ROOT, EDITION, one), "rb").read() + b"\n#tamper\n")
tampered = fh.name
chk("a tampered copy has a different digest",
digest(tampered) != recorded(), "otherwise the check is decoration")
digest(tampered) != want[one], "otherwise the check is decoration")
os.unlink(tampered)
# ADR-0015 D3's condition must be DETECTABLE, or asserting its absence
# in `check()` proves nothing.
chk("a doubled quote would be detected", '""' in 'a,""b""', "the pattern check itself")
chk("an unbalanced quote would be detected", 'a,"b'.count('"') % 2 == 1)
print("edition-check self-test (positive control)")
ok = True
for name, passed, det in results:

View file

@ -2,7 +2,7 @@
id: CB-WP-0028
kind: product
title: "The table you sit at: the cards' own words, an overhead view, and a game you solve rather than survive"
status: ready
status: done
state_hub_workstream_id: "d37c8671-54e4-447f-af64-56f482483282"
---
@ -39,35 +39,22 @@ split matters because they go to different places.
## Observation 2 is a data-import gap, and it reframes the pass
*"I don't understand the GROUND card or why other cards should be played
to the table"* reads as a design problem. It is not. **The card explains
itself in the dataset and we never imported the explanation.**
*"I don't understand the GROUND card"* reads as a design problem. It is
not: `Actions.csv` carries that card's tagline (*"Regulate. Restore the
frame. Decide."*) and its full rules text, and **we vendored one file of
nineteen**. Everything else the engine knows is a hand-transcription into
`GroundRules.md`'s 59 rules — enough to *play* the game, and nothing to
*read*. The page shows `Clarify` where the card says *"Ask What Happened."*
`ground-game/editions/*/Actions.csv`, the GROUND row:
> **tagline:** *"Regulate. Restore the frame. Decide."*
> **rules_text:** *"After all actions are revealed, choose one mode:
> GR—Ground & Restate: 2 Stress, ready Freedom… OU—Observe & Uphold…"*
**We vendored one file of nineteen.** `editions/ground-darvo-r0/` holds
`Problems.csv` and nothing else; `Actions`, `Solutions`, `Modes`,
`Scenarios`, `Relations`, `DARVO`, `Tokens`, `Glossary` and the rest live
only in `ground-game`. Everything the engine knows about them is a
**hand-transcription into `GroundRules.md`'s 59 numbered rules** — which
is enough to *play* the game and gives a player nothing to *read*.
So the page shows `Clarify` where the card says **"Ask What Happened —
Invite a concrete account before judging."**
**This is the most valuable thing in the pass**, and it was found by a
player saying he did not understand something rather than by any gate.
Rule coverage is 59/59 and has been for weeks.
**Found by a player saying he did not understand something.** Rule
coverage is 59/59 and has been for weeks. See F18 and
[ADR-0015](../decisions/ADR-0015-the-cards-own-words.md).
## Task: decide what else to import, and what it costs
```task
id: CB-WP-0028-T01
status: todo
status: done
priority: high
state_hub_task_id: "17e6f38c-1903-4bb2-aa20-37e76c8fc4e0"
```
@ -99,11 +86,27 @@ Decide:
core. Decide whether it is imported at all; the answer is probably no,
but *"we did not know it existed"* must not be the reason.
**Done 2026-08-06.**
[ADR-0015](../decisions/ADR-0015-the-cards-own-words.md), six decisions.
**The gap is bigger than "one file of nineteen", and the measurement is
the decision.** Of the file we *did* vendor the engine reads **5 of 13
columns**; the discarded ones are player-facing text that had been in the
repo for eight days. And `SCN_01` is hardcoded — the edition ships **four**
scenarios and the engine has never dealt three.
**ADR-0011's revisit condition is measurably absent** — zero doubled
quotes, zero embedded newlines across every candidate file — so the
dependency argument does not get re-run. Vendored `Actions`, `Solutions`,
`Modes`. Not the production artifacts, and **not `Extensions.csv`**, which
names deliberately non-core content; it is now known to exist, which was
the real risk.
## Task: the cards say what they do
```task
id: CB-WP-0028-T02
status: todo
status: done
priority: high
state_hub_task_id: "aa4453d2-a84e-4afc-8534-1feb835c4d5b"
```
@ -125,11 +128,26 @@ from the edition — not from a phrase we invented.
- **the page stays readable.** Five action cards with full rules text is a
wall; the tagline is the default and the rules text is on demand.
**Done 2026-08-06.** One `Table` reader with four callers (a per-file
copy is how a parser acquires four subtly different bugs), `CardText` for
Actions/Solutions/Modes, `ProblemText` for the columns already vendored.
The GROUND card now explains itself; Problems show their own titles.
**The load-bearing test asserts the text is a substring of the vendored
file**, not equal to a Rust literal — a hardcoded expectation would pass
for a hand-copied string, which is the drift this ends.
**`edition` came out from behind `#[cfg(feature = "scenarios")]`**: it was
gated because its only consumer was, but the edition is the game's own
data and the runtime now reads it. **Test machinery and game content are
different things and only one is optional.** `edition-check`, written for
a single-file world, now checks every file both ways.
## Task: an overhead view of a real table
```task
id: CB-WP-0028-T03
status: todo
status: done
priority: high
state_hub_task_id: "2ff677ac-80ff-4618-ad7c-05b62c8f80c8"
```
@ -155,11 +173,30 @@ as a circle and once as a row. **One table, not two diagrams.**
- 2 through 6 seats all lay out without overlap, asserted per seat count
rather than eyeballed at 3.
**Done 2026-08-06.** 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.
The new control is per seat count: **no two seat circles closer than 70px
at 2, 3, 4, 5 or 6 seats** — asserted rather than eyeballed at three,
which is the only count anyone ever looks at. Plus: the viewer's seat is
labelled *and* visually distinct, and a spectator is not given one.
## Task: take cards from the stack, or let the table do it
```task
id: CB-WP-0028-T04
status: todo
status: done
priority: medium
state_hub_task_id: "1106c60e-112b-401f-b854-eeec566ef3d1"
```
@ -185,11 +222,38 @@ because SOLVE was offered where it could not act.
the same seed produces the same end-state hash, or it is not automation
but a rules change.
**Done 2026-08-06 — and NOTHING WAS BUILT, which is the correct outcome.**
**Clicking the deck is not a legal move.** `GroundCommand` has no
standalone draw, and the edition's own INVESTIGATE text settles it:
> *"Choose one hidden, non-Denied Problem and reveal it. **Then draw one
> Solution.** If no hidden Problems remain, draw one Solution only."*
Drawing is a **consequence** of INVESTIGATE, never an action. Implementing
a click-to-draw would have invented a rule — which is what CB-WP-0023
exists to stop.
**And observation 5 is already true.** Drawing is *already* automatic: it
happens inside INVESTIGATE with no player input at all. The maintainer
asked for an auto-draw option for a thing that has never been manual.
**Both observations have the same root cause, and T02 fixed it.** He
expected to take cards from the deck because **nothing on the page said
how drawing works** — the INVESTIGATE card's own text was in a file we had
not imported. It now reads on the card.
**No finding raised.** The rules are clear and were simply not visible; a
player's instinct differing from a legible rule is a comprehension gap,
not a rules gap. Worth ground-game knowing as design feedback if the
instinct recurs with the text present — **which is now a testable
question and was not before.**
## Task: the meta column takes the controls and the log
```task
id: CB-WP-0028-T05
status: todo
status: done
priority: medium
state_hub_task_id: "6c78e44e-38fc-451b-b1a0-205b1d079ca0"
```
@ -207,11 +271,16 @@ not for the ending page.
- the ending page's existing tests pass unchanged or the change is a
regression.
**Done 2026-08-06.** The ending page gets the table's two-column shape:
result on the left, rankings + controls + the full log on the right. The
seal still removes every control wherever it now lives, and the existing
ending tests passed unchanged.
## Task: a game you solve
```task
id: CB-WP-0028-T06
status: todo
status: done
priority: medium
state_hub_task_id: "df0a967e-636b-4fe5-9926-4d73030a87d7"
```
@ -231,11 +300,16 @@ win tells the player the wrong thing about what they just did.
- the no-outcome case (a game that ended badly) keeps its own wording and
claims neither.
**Done 2026-08-06.** `game solved` on a win, `game over` on a loss, **the
game stopped** when there is no outcome — asserted all three ways, because
a test checking only the win case passes for a page that always says
"solved".
## Task: who did what — rankings that are not invented
```task
id: CB-WP-0028-T07
status: todo
status: done
priority: medium
state_hub_task_id: "ad6e6f6f-e531-494c-b8a9-18dfdd14681c"
```
@ -262,11 +336,28 @@ silently canonised.
- a ranking that no seat leads (nobody solved anything) renders as that,
not as an empty list.
**Done 2026-08-06, and the interesting decision was not to rank.**
`Modes.csv` gives `scoring_tiebreak` per mode, so the ordering is **the
game's own** where it defines one — *"Lower combined Stress, then fewer
Blame tokens"* for coalitions. **For SHARED GROUND it says "Not
applicable"**, because the table succeeds or fails together.
So in co-op the page shows **contributions and refuses to order them**,
and says why. Drawing a leaderboard because a leaderboard is easy would
have invented scoring the rules do not have — the same defect as
canonising a provisional default.
Facts the game counts (Problems claimed, personal score, Stress, Blame)
are shown plainly. The one derived superlative is labelled **"clay-borg's
reading, not a rule"** and appears only where the mode ranks at all. Ties
are shown as ties.
## Task: evidence
```task
id: CB-WP-0028-T08
status: todo
status: done
priority: medium
state_hub_task_id: "a20a529e-0edf-49cc-9c80-7dba3b76cff5"
```
@ -284,3 +375,25 @@ state_hub_task_id: "a20a529e-0edf-49cc-9c80-7dba3b76cff5"
- **What the overhead view cost the coverage gate**, against CB-WP-0027's
finding that a probe naming a fact survives a reflow.
- **Quote CB-WP-0027's cost by re-running the instrument.**
**Done 2026-08-06.**
[CB-EV-0026](../evidence/CB-EV-0026-the-table-you-sit-at.md).
- **Seven of nine observations were engine defects**, one was a design
finding, and 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*, and **nothing measures whether a player can read
the game.**
- **Two gates were written for a smaller world**`edition-check`
compared one digest across four files, and a `cb-play` test asserted the
literal `"game over"` and went red when a won game said *"solved"*.
Neither was wrong when written.
- **Chaos window 2 closes with zero overrides in eleven declarations.**
Third and final statement: d8 bought rarity by spending evidence, and a
mechanism producing no data across a full window cannot be evaluated by
it.