Compare commits
3 commits
efe9f8f1a9
...
eb8aa64c56
| Author | SHA1 | Date | |
|---|---|---|---|
| eb8aa64c56 | |||
| 4d3e30eda4 | |||
| 4b5b601ce0 |
13 changed files with 1461 additions and 133 deletions
|
|
@ -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's own tiebreak</span> {tb}",
|
||||
tb = esc(&tiebreak),
|
||||
);
|
||||
} else {
|
||||
s.push_str(
|
||||
"<br><span class=\"k\">not ranked</span> \
|
||||
this mode'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'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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'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.
|
||||
|
|
|
|||
160
decisions/ADR-0015-the-cards-own-words.md
Normal file
160
decisions/ADR-0015-the-cards-own-words.md
Normal 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 |
|
||||
6
editions/ground-darvo-r0/Actions.csv
Normal file
6
editions/ground-darvo-r0/Actions.csv
Normal 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.
|
||||
|
4
editions/ground-darvo-r0/Modes.csv
Normal file
4
editions/ground-darvo-r0/Modes.csv
Normal 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 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
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
|
|
|||
25
editions/ground-darvo-r0/Solutions.csv
Normal file
25
editions/ground-darvo-r0/Solutions.csv
Normal 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
|
||||
|
163
evidence/CB-EV-0026-the-table-you-sit-at.md
Normal file
163
evidence/CB-EV-0026-the-table-you-sit-at.md
Normal 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 |
|
||||
| 6–7 | 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.
|
||||
|
|
@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue