CB-WP-0028 T01/T02: the cards say what they do

ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-06 17:22:04 +02:00
parent efe9f8f1a9
commit 4b5b601ce0
11 changed files with 767 additions and 42 deletions

View file

@ -276,6 +276,11 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
}
/* CB-WP-0024 T03: the card a seat played, in that seat's area. */
.played{display:block;margin:.3rem 0}
/* CB-WP-0028 T02: the card's own words. The tagline reads as the card's
voice, not as engine chrome. */
.tag{display:block;color:#9cb;font-style:italic;margin:.15rem 0 .3rem}
details summary{cursor:pointer;color:#89a;font-size:.9em;margin-top:.3rem}
details p,details{margin:.2rem 0}
.k{color:#89a}
.nil{color:#c88}
.eff{color:#8c9}
@ -304,6 +309,19 @@ fn target_names(targets: &[String]) -> String {
out.join(", ")
}
/// The edition's title for a Problem, by `hidden_priority` (ADR-0015 D1).
///
/// These columns were in the vendored file from the start and thrown away
/// at parse time — the page showed `Repair 2` for a card that reads
/// *"Missed Deadline"*.
fn problem_title(priority: u32) -> Option<String> {
games_ground::edition::problem_texts("SCN_01")
.ok()?
.into_iter()
.find(|t| u32::from(t.priority) == priority)
.map(|t| t.title)
}
fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) {
let (label, sub, fill) = match p {
ProblemView::FaceDown => ("face down".to_string(), String::new(), "#2a2f3a"),
@ -336,9 +354,13 @@ fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) {
"<g data-drop=\"problem-{priority}\"><rect x=\"{x}\" y=\"10\" width=\"120\" height=\"78\" rx=\"8\" \
fill=\"{fill}\" stroke=\"#5a6b7a\"/>\
<text x=\"{tx}\" y=\"36\" fill=\"#dde\" font-size=\"13\">{label}</text>\
<text x=\"{tx}\" y=\"56\" fill=\"#89a\" font-size=\"11\">priority {priority}</text>\
<text x=\"{tx}\" y=\"56\" fill=\"#89a\" font-size=\"11\">{name}</text>\
<text x=\"{tx}\" y=\"74\" fill=\"#fc9\" font-size=\"11\">{sub}</text></g>",
x = x,
// The card's own name where a priority number used to be. Falls
// back to the number rather than blanking: a missing title must
// not remove the only identifier the player had.
name = esc(&problem_title(priority).unwrap_or_else(|| format!("priority {priority}"))),
tx = x + 10,
label = esc(&label),
sub = esc(&sub),
@ -902,6 +924,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 +981,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(),
);
}
}

View file

@ -714,6 +714,82 @@ mod piles {
}
}
/// 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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
id: CB-WP-0028
kind: product
title: "The table you sit at: the cards' own words, an overhead view, and a game you solve rather than survive"
status: ready
status: active
state_hub_workstream_id: "d37c8671-54e4-447f-af64-56f482483282"
---
@ -67,7 +67,7 @@ Rule coverage is 59/59 and has been for weeks.
```task
id: CB-WP-0028-T01
status: todo
status: done
priority: high
state_hub_task_id: "17e6f38c-1903-4bb2-aa20-37e76c8fc4e0"
```
@ -99,11 +99,33 @@ 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** — `title`, `problem_text`, `front_rules`, `reveal_effect` and
`unresolved_effect` were discarded at parse time. **The cheapest part of
this pass costs no new bytes** and was sitting in the repo for eight days.
And `SCN_01` is hardcoded at `lib.rs:1824`: the edition ships **four**
scenarios and the engine has never dealt three of them.
**ADR-0011's revisit condition is measurably absent**, so the dependency
argument does not get re-run. Across Actions, Solutions, Modes and
Scenarios: **zero doubled quotes, zero embedded newlines.** The hand
reader's only job is comma-in-quoted-field, which it already did.
Vendored `Actions`, `Solutions`, `Modes` — the text a player reads. Not
the production artifacts. **Not `Extensions.csv`**, because it names
content the designer placed *outside* the core, and importing it would
break the as-printed claim — but 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,6 +147,33 @@ 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, and `ProblemText` for the columns already
vendored.
**The GROUND card now explains itself**: *"Regulate. Restore the frame.
Decide."* as the tagline, its GR/OU/ND text behind a disclosure. Problems
show their own titles where a priority number used to be.
**The load-bearing test asserts the text is a substring of the vendored
file**, not equal to a Rust literal — a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends. Plus counts (5 actions, 24 solutions, 3 modes), because a
reader returning one row would pass a "the text is real" test.
**`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 shipped runtime now reads it. **Test machinery and game
content are different things and only one of them is optional.**
**`edition-check` was written for a single-file world** and compared the
first recorded digest against `Problems.csv` regardless of which file it
described. It now checks every file both ways — a vendored file with no
digest fails, and a digest naming an absent file fails — and asserts
ADR-0015 D3's falsifier directly: no doubled quotes, no embedded
newlines.
## Task: an overhead view of a real table
```task