From 4b5b601ce0cc037dad8e9c2989f21ffabcaefc47 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 6 Aug 2026 17:22:04 +0200 Subject: [PATCH] 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 --- crates/cb-render-html/src/doc.rs | 52 +++- crates/cb-render-html/src/lib.rs | 76 ++++++ decisions/ADR-0015-the-cards-own-words.md | 160 +++++++++++ editions/ground-darvo-r0/Actions.csv | 6 + editions/ground-darvo-r0/Modes.csv | 4 + editions/ground-darvo-r0/PROVENANCE.md | 24 +- editions/ground-darvo-r0/Solutions.csv | 25 ++ games/ground/src/edition.rs | 269 +++++++++++++++++++ games/ground/src/lib.rs | 8 +- tools/edition-check.py | 130 ++++++--- workplans/CB-WP-0028-the-table-you-sit-at.md | 55 +++- 11 files changed, 767 insertions(+), 42 deletions(-) create mode 100644 decisions/ADR-0015-the-cards-own-words.md create mode 100644 editions/ground-darvo-r0/Actions.csv create mode 100644 editions/ground-darvo-r0/Modes.csv create mode 100644 editions/ground-darvo-r0/Solutions.csv diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index cb3f9ca..60bbdde 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -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 { + 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) { "\ {label}\ - priority {priority}\ + {name}\ {sub}", 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 { + 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, "
{a:?}
\ - onto {names}
", + {tagline}\ + onto {names}{rules}", 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!( + "
what it does{}
", + esc(&c.rules_text) + )) + .unwrap_or_default(), ); } } diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 2885d15..5178bb0 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -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 = 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 = 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 { diff --git a/decisions/ADR-0015-the-cards-own-words.md b/decisions/ADR-0015-the-cards-own-words.md new file mode 100644 index 0000000..4fdfced --- /dev/null +++ b/decisions/ADR-0015-the-cards-own-words.md @@ -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 | diff --git a/editions/ground-darvo-r0/Actions.csv b/editions/ground-darvo-r0/Actions.csv new file mode 100644 index 0000000..f1a8808 --- /dev/null +++ b/editions/ground-darvo-r0/Actions.csv @@ -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. diff --git a/editions/ground-darvo-r0/Modes.csv b/editions/ground-darvo-r0/Modes.csv new file mode 100644 index 0000000..a48c063 --- /dev/null +++ b/editions/ground-darvo-r0/Modes.csv @@ -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 diff --git a/editions/ground-darvo-r0/PROVENANCE.md b/editions/ground-darvo-r0/PROVENANCE.md index f0158bb..02356bd 100644 --- a/editions/ground-darvo-r0/PROVENANCE.md +++ b/editions/ground-darvo-r0/PROVENANCE.md @@ -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 diff --git a/editions/ground-darvo-r0/Solutions.csv b/editions/ground-darvo-r0/Solutions.csv new file mode 100644 index 0000000..408dab6 --- /dev/null +++ b/editions/ground-darvo-r0/Solutions.csv @@ -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 diff --git a/games/ground/src/edition.rs b/games/ground/src/edition.rs index 39b5649..a834f4e 100644 --- a/games/ground/src/edition.rs +++ b/games/ground/src/edition.rs @@ -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, 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, + rows: Vec>, +} + +impl Table { + fn parse(csv: &str, what: &str) -> Result { + let mut lines = csv.lines(); + let header = lines.next().ok_or_else(|| format!("{what} is empty"))?; + let cols: Vec = 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 { + 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, 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, String> { + card_texts(ACTIONS_CSV, "Actions.csv", "action_id", "tagline") +} + +/// The Solution cards. `microcopy` is this deck's tagline column. +pub fn solutions() -> Result, 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, 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 { .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)" + ); + } +} diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 5f01712..d9feb83 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -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 diff --git a/tools/edition-check.py b/tools/edition-check.py index 8caffe6..79fd599 100755 --- a/tools/edition-check.py +++ b/tools/edition-check.py @@ -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 ` 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 ` 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: diff --git a/workplans/CB-WP-0028-the-table-you-sit-at.md b/workplans/CB-WP-0028-the-table-you-sit-at.md index 971898f..95931f3 100644 --- a/workplans/CB-WP-0028-the-table-you-sit-at.md +++ b/workplans/CB-WP-0028-the-table-you-sit-at.md @@ -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