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 {