CB-WP-0034: who you are bonding with
Some checks failed
ci / check (push) Failing after 4s

Reported three times across three sessions, two days apart, and it
survived a whole UI rebuild: "i cant see whos support i accept".

It was not styling. The move button's label was format!("{c:?}"), so the
player read `RespondToSupport { response: AcceptBond }` — Rust struct
syntax with no name in it. And the command does not carry the
counterparty, so nothing rendering it alone could have said who; it comes
off the view, as whoever played Support at this seat.

Four more seat-panel fields had the same defect, including `support
AcceptBond` — the one the report names. CB-WP-0020 fixed exactly this for
selections and left its four neighbours as they were.

command_label has no catch-all arm, and that earned its keep before any
test ran: GroundChoice::RejectReverse and SupportResponse::BreakRivalry
both failed to compile — two moves that would have shipped as struct
dumps. An offer the view cannot see is said to be unseen rather than given
an invented name.

The finding underneath: the coverage probe that exists to prove every view
field reaches the PLAYER was matching "player: Some(PlayerId(1))" and
"members: [PlayerId(1)". It was certifying the defect as coverage and
would have gone red had anyone fixed it. Second confirmation of
CB-WP-0024's finding, from the sharper side: a probe naming Debug output
does not merely fail to protect, it pins the defect in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-07 17:49:06 +02:00
parent edab11c0e4
commit c2a7e40b67
6 changed files with 495 additions and 16 deletions

View file

@ -889,17 +889,39 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
}
);
}
// CB-WP-0034. These four were `{:?}` on a player surface, which is
// the defect `selection_words` above was written for -- fixed once,
// for one field, with its four neighbours left as they were.
//
// The `support` line is the one the maintainer reported: it read
// `support AcceptBond`, which names the answer and not the person.
if let Some(m) = view.ground_modes.get(&id) {
let _ = write!(out, "<span class=\"k\">ground mode</span> {m:?}<br>");
let _ = write!(
out,
"<span class=\"k\">ground mode</span> {}<br>",
esc(ground_mode_label(m))
);
}
if let Some(c) = view.ground_choices.get(&id) {
let _ = write!(out, "<span class=\"k\">ground choice</span> {c:?}<br>");
let _ = write!(
out,
"<span class=\"k\">ground choice</span> {}<br>",
esc(&ground_choice_label(c))
);
}
if let Some(r) = view.support_responses.get(&id) {
let _ = write!(out, "<span class=\"k\">support</span> {r:?}<br>");
let _ = write!(
out,
"<span class=\"k\">support</span> {}<br>",
esc(&support_words(r, supporter_of(view, Some(id))))
);
}
if let Some(t) = view.darvo_targets.get(&id) {
let _ = write!(out, "<span class=\"k\">darvo target</span> {t:?}<br>");
let _ = write!(
out,
"<span class=\"k\">darvo target</span> {}<br>",
esc(&darvo_target_words(t))
);
}
out.push_str("</div>");
}
@ -989,7 +1011,7 @@ pub fn document_with_log(
// game rather than part of it.
s.push_str("<div class=\"cb-cols\"><div class=\"cb-game\">");
body(&mut s, view);
move_section(&mut s, legal, seat, may_pass);
move_section(&mut s, view, legal, seat, may_pass);
s.push_str("</div><div class=\"cb-meta\">");
meta_section(&mut s, meta, note_to);
log_section(&mut s, log);
@ -1114,9 +1136,19 @@ fn body(s: &mut String, view: &GroundView) {
} else {
o.coalitions
.iter()
.map(|c| format!("{c:?}"))
.map(|c| {
format!(
"{} (score {})",
c.members
.iter()
.map(|m| seat_name(*m))
.collect::<Vec<_>>()
.join(" + "),
c.score
)
})
.collect::<Vec<_>>()
.join(" ")
.join(", ")
};
let _ = write!(
s,
@ -1159,8 +1191,175 @@ fn action_text(a: games_ground::Action) -> Option<games_ground::edition::CardTex
.find(|c| c.id == want)
}
/// Who offered this seat their Support (CB-WP-0034).
///
/// **Read off the view, because the command does not carry it.**
/// `RespondToSupport { response }` names the answer and not the person,
/// so no rendering of that command alone can say who — which is why the
/// page could not say it either.
fn supporter_of(view: &GroundView, seat: Option<PlayerId>) -> Option<PlayerId> {
let me = seat?;
view.selections.iter().find_map(|(p, sel)| match sel {
games_ground::view::SelectionView::Shown(s)
if s.action == games_ground::Action::Support && s.target == Some(me) =>
{
Some(*p)
}
_ => None,
})
}
/// The edition's names for the three GROUND modes (GR-A10..A12).
fn ground_mode_label(m: &games_ground::GroundMode) -> &'static str {
use games_ground::GroundMode as M;
match m {
M::Gr => "GROUND \u{2014} Ground & Restate",
M::Ou => "GROUND \u{2014} Observe & Uphold",
M::Nd => "GROUND \u{2014} Name & Decide",
}
}
/// A DARVO target, in words.
fn darvo_target_words(t: &games_ground::DarvoTarget) -> String {
match (t.player, t.problem) {
(Some(p), Some(q)) => format!("{}, Problem {q}", seat_name(p)),
(Some(p), None) => seat_name(p),
(None, Some(q)) => format!("Problem {q}"),
(None, None) => "nothing".to_string(),
}
}
/// What a seat answered a Support with, **and to whom**.
fn support_words(r: &games_ground::SupportResponse, who: Option<PlayerId>) -> String {
use games_ground::SupportResponse as R;
// Branch on whether the seat is KNOWN before phrasing, rather than
// substituting a noun phrase into a possessive: the first draft read
// "accepted a seat this view cannot show's Support".
let Some(who) = who.map(seat_name) else {
let what = match r {
R::AcceptBond => "accepted the Support offered \u{2014} formed a Bond",
R::DeclineBond => "declined the Support offered",
R::FlipToBond => "accepted the Support offered \u{2014} Rivalry became a Bond",
R::BreakRivalry => "broke the Rivalry",
};
return format!("{what} (this view does not show which seat)");
};
match r {
R::AcceptBond => format!("accepted {who}\u{2019}s Support \u{2014} Bond with {who}"),
R::DeclineBond => format!("declined {who}\u{2019}s Support"),
R::FlipToBond => format!("accepted {who}\u{2019}s Support \u{2014} Rivalry became a Bond"),
R::BreakRivalry => format!("broke the Rivalry with {who}"),
}
}
/// A GROUND sub-choice, in words, naming whoever it touches.
fn ground_choice_label(c: &games_ground::GroundChoice) -> String {
use games_ground::GroundChoice as G;
match c {
G::RestoreProblem { problem } => format!("restore Problem {problem}"),
G::CancelAttack { attacker } => {
format!("cancel {}\u{2019}s Attack on you", seat_name(*attacker))
}
G::ProtectProblem { problem } => format!("protect Problem {problem} from Deny"),
G::RemoveBlame { owner } => format!("remove {}\u{2019}s Blame token", seat_name(*owner)),
G::BreakRelation { with } => format!("break your relation with {}", seat_name(*with)),
G::RejectReverse => "reject the Reverse aimed at you".to_string(),
}
}
/// A move button\u2019s words \u2014 and **who it is aimed at** (CB-WP-0034).
///
/// The label was `format!("{c:?}")`. A player deciding whether to accept a
/// Bond read `RespondToSupport { response: AcceptBond }` \u2014 Rust struct
/// syntax with no name anywhere in it \u2014 which is why *"i cant see whos
/// support i accept"* was reported three times across three sessions and
/// survived a whole UI rebuild.
///
/// **Exhaustive on purpose: there is no catch-all arm.** A new command
/// must be given words or this crate stops compiling. A `_ =>` here is
/// how `Debug` output comes back one variant at a time.
///
/// **These are clay-borg\u2019s words, not the edition\u2019s**, and the
/// distinction matters (ADR-0015). The Action cards above carry the
/// game\u2019s own `rules_text`; nothing in the vendored files names these
/// control moves, and `Glossary` is still unvendored (F18). If it lands,
/// this is a caller to revisit.
fn command_label(
c: &games_ground::GroundCommand,
view: &GroundView,
seat: Option<PlayerId>,
) -> String {
use games_ground::GroundCommand as C;
match c {
C::SelectAction {
action,
target,
problem,
} => {
let mut out = format!("{action:?}").to_uppercase();
if let Some(t) = target {
let _ = write!(out, " on {}", seat_name(*t));
}
if let Some(p) = problem {
let _ = write!(out, ", Problem {p}");
}
out
}
C::SpendFreedom => {
"spend your Freedom token \u{2014} act despite the Stress gate".to_string()
}
C::ChooseGroundMode { mode, choice } => {
let named = ground_mode_label(mode);
match choice {
Some(ch) => format!("{named}: {}", ground_choice_label(ch)),
None => named.to_string(),
}
}
// The one the report was about.
C::RespondToSupport { response } => {
use games_ground::SupportResponse as R;
// Not a guess. If the offer is not visible in this view, the
// page says so rather than inventing a name or quietly
// dropping the question -- a confidently wrong seat is worse
// than an honest gap (ADR-0018).
let Some(who) = supporter_of(view, seat).map(seat_name) else {
let what = match response {
R::AcceptBond => "accept the Support offered to you \u{2014} form a Bond",
R::DeclineBond => "decline the Support offered to you",
R::FlipToBond => {
"accept the Support offered to you \u{2014} turn your Rivalry into a Bond"
}
R::BreakRivalry => "break the Rivalry",
};
return format!("{what} (this view does not show which seat)");
};
match response {
R::AcceptBond => {
format!("accept {who}\u{2019}s Support \u{2014} form a Bond with {who}")
}
R::DeclineBond => {
format!("decline {who}\u{2019}s Support \u{2014} the Stress still applies")
}
R::FlipToBond => {
format!("accept {who}\u{2019}s Support \u{2014} turn your Rivalry into a Bond")
}
R::BreakRivalry => {
format!("break your Rivalry with {who}")
}
}
}
C::ChooseDarvoTarget { target } => {
format!("DARVO target: {}", darvo_target_words(target))
}
C::Reveal => "reveal all selections".to_string(),
C::Resolve => "resolve the revealed Actions".to_string(),
C::EndRound => "end the round".to_string(),
}
}
fn move_section(
s: &mut String,
view: &GroundView,
legal: &[games_ground::GroundCommand],
seat: Option<PlayerId>,
may_pass: bool,
@ -1233,7 +1432,7 @@ fn move_section(
let _ = write!(
s,
"<div class=\"card btn tap\" data-drop=\"cmd-{i}\">{}</div>",
esc(&format!("{c:?}"))
esc(&command_label(c, view, seat))
);
}
}