diff --git a/facts.toml b/facts.toml index bc2eac8..a02b304 100644 --- a/facts.toml +++ b/facts.toml @@ -6,7 +6,7 @@ # `make facts-check` fails if this file disagrees with the # instruments, or if a tagged artifact disagrees with this file. -generated = "2026-08-03" +generated = "2026-08-04" pin = "fc76445" [am4a_loc] @@ -46,26 +46,26 @@ fmt = "{:,}" by = "tools/mutation-check.py" [gr_covered] -value = 58 -text = "58" +value = 59 +text = "59" fmt = "{:,}" by = "tools/rule-coverage.py" [gr_linked] -value = 49 -text = "49" +value = 50 +text = "50" fmt = "{:,}" by = "tools/rule-coverage.py" [gr_rules] -value = 58 -text = "58" +value = 59 +text = "59" fmt = "{:,}" by = "tools/rule-coverage.py" [gr_scenarios] -value = 25 -text = "25" +value = 26 +text = "26" fmt = "{:,}" by = "tools/rule-coverage.py" diff --git a/games/ground/src/bot.rs b/games/ground/src/bot.rs index 551cad9..bedfe2c 100644 --- a/games/ground/src/bot.rs +++ b/games/ground/src/bot.rs @@ -153,6 +153,17 @@ pub fn legal_commands(state: &GroundState, seat: PlayerId) -> Vec Action::Ground, ] { match action { + // Offered on every Problem; `validate` decides + // which are legal, and this function filters every + // candidate through it below. + // + // CB-WP-0023 first put GR-P05's conditions here, in + // the OFFER layer. The AM-1 coverage gate then asked + // for a scenario covering GR-P05 — and scenarios drive + // `validate`, not this. That is what revealed the rule + // belonged in `validate`: a rule enforced only by the + // offer is enforced only for clients that ask what is + // legal. Once it moved, everything here was dead code. Action::Investigate | Action::Solve => { for problem in &problems { candidates.push(GroundCommand::SelectAction { @@ -649,6 +660,178 @@ mod tests { .expect("preset") } + // --------------------------------------------------------------- + // CB-WP-0023: SOLVE's legality, ruled by ground-game 2026-08-03. + // + // Four conditions, each asserted on its own, because a single + // "SOLVE is filtered" test would pass with three of the four + // implemented and nobody would know which. + + /// Problems that SOLVE is offered on, for `seat`. + fn solvable(state: &GroundState, seat: PlayerId) -> Vec { + legal_commands(state, seat) + .into_iter() + .filter_map(|c| match c { + GroundCommand::SelectAction { + action: Action::Solve, + problem: Some(n), + .. + } => Some(n), + _ => None, + }) + .collect() + } + + /// A 3p game where seat 0 can solve exactly problem 1. + fn solvable_fixture() -> (GroundState, u32) { + let mut st = setup(3, 42); + let (n, suit) = st + .problems + .iter() + .find(|(_, p)| p.face_up) + .map(|(n, p)| (*n, p.suit)) + .expect("GR-S01 deals one face-up Surface Problem"); + // Give seat 0 the matching Solution, so the ONLY thing under test + // is the condition each case perturbs. + st.players.get_mut(&PlayerId(0)).expect("seat 0").hand = vec![crate::SolutionCard { suit }]; + assert_eq!( + solvable(&st, PlayerId(0)), + vec![n], + "fixture is not solvable" + ); + (st, n) + } + + /// **Which layer enforces GR-P05**, pinned so it cannot drift. + /// + /// All four conditions live in `validate`, and `legal_commands` gets + /// them for free by filtering candidates through it. This test is what + /// keeps that true: if `validate` ever stops rejecting one, SOLVE + /// becomes offerable again and the offer layer would have to grow a + /// filter back. + #[test] + fn validate_enforces_all_four_solve_conditions() { + let (base, n) = solvable_fixture(); + let sel = |p: u32| GroundCommand::SelectAction { + action: Action::Solve, + target: None, + problem: Some(p), + }; + let ok = + |st: &GroundState, p: u32| st.validate(Actor::Player(PlayerId(0)), &sel(p)).is_ok(); + + let face_down = *base + .problems + .iter() + .find(|(_, p)| !p.face_up) + .map(|(k, _)| k) + .expect("a face-down problem"); + println!( + " validate accepts SOLVE on face-down: {}", + ok(&base, face_down) + ); + + let mut denied = base.clone(); + denied.problems.get_mut(&n).unwrap().denied = true; + println!(" validate accepts SOLVE on denied: {}", ok(&denied, n)); + + let mut claimed = base.clone(); + claimed.problems.get_mut(&n).unwrap().claimed_by = Some(PlayerId(1)); + println!( + " validate accepts SOLVE on claimed: {}", + ok(&claimed, n) + ); + + let mut nohand = base.clone(); + nohand.players.get_mut(&PlayerId(0)).unwrap().hand = vec![]; + println!(" validate accepts SOLVE with no hand: {}", ok(&nohand, n)); + + // validate's: adding these to `legal_commands` would be dead code. + for (what, accepted) in [ + ("face-down", ok(&base, face_down)), + ("Denied", ok(&denied, n)), + ("claimed", ok(&claimed, n)), + ("handless", ok(&nohand, n)), + ] { + assert!( + !accepted, + "validate accepts SOLVE on a {what} Problem — GR-P05 is not \ + enforced where rules live, so only clients that ask what is \ + legal would be constrained" + ); + } + // Positive control: the fixture must still be solvable, or this + // test would pass by rejecting everything. + assert!(ok(&base, n), "the solvable fixture stopped being solvable"); + } + + /// **The maintainer's reported case.** SOLVE was offered on a + /// face-down Problem and did nothing; they played it three rounds + /// running with no explanation. ground-game: *"not offered — illegal + /// target. Browser no-ops were a filter bug, not a bluff mechanic."* + #[test] + fn solve_is_not_offered_on_a_face_down_problem() { + let (st, open) = solvable_fixture(); + let face_down: Vec = st + .problems + .iter() + .filter(|(_, p)| !p.face_up) + .map(|(n, _)| *n) + .collect(); + assert!(!face_down.is_empty(), "GR-S01 deals face-down Problems"); + let offered = solvable(&st, PlayerId(0)); + for n in face_down { + assert!( + !offered.contains(&n), + "SOLVE offered on face-down problem {n}; offered = {offered:?}" + ); + } + assert!(offered.contains(&open), "the face-up one is still offered"); + } + + #[test] + fn solve_is_not_offered_without_a_matching_solution_in_hand() { + let (mut st, n) = solvable_fixture(); + let wrong = [ + crate::Suit::Clarify, + crate::Suit::Repair, + crate::Suit::Boundary, + crate::Suit::Change, + ] + .into_iter() + .find(|s| *s != st.problems[&n].suit) + .expect("another suit exists"); + st.players.get_mut(&PlayerId(0)).expect("seat 0").hand = + vec![crate::SolutionCard { suit: wrong }]; + assert!( + !solvable(&st, PlayerId(0)).contains(&n), + "SOLVE offered with no matching Solution in hand" + ); + } + + #[test] + fn solve_is_not_offered_on_a_denied_problem() { + let (mut st, n) = solvable_fixture(); + st.problems.get_mut(&n).expect("problem").denied = true; + assert!( + !solvable(&st, PlayerId(0)).contains(&n), + "SOLVE offered on a Denied Problem" + ); + } + + /// The ruling's (c): a Problem claimed in a PRIOR round is not + /// offered. At Select time every `claimed_by` is prior-round, because + /// claims land at Resolve — so the same-round race needs no code. + #[test] + fn solve_is_not_offered_on_an_already_claimed_problem() { + let (mut st, n) = solvable_fixture(); + st.problems.get_mut(&n).expect("problem").claimed_by = Some(PlayerId(1)); + assert!( + !solvable(&st, PlayerId(0)).contains(&n), + "SOLVE offered on an already-claimed Problem" + ); + } + fn policies(kind: &str, n: u8, seed: u64) -> Vec> { (0..n) .map(|i| -> Box { diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 4359b81..4fbe666 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -1756,6 +1756,28 @@ impl GroundState { "GR-A13: Problem {problem} is not a face-up, non-Denied Problem" ))); } + // GR-P05, ruled by ground-game 2026-08-03: SOLVE is legal + // only where it can do something. This lives in `validate` + // and not only in `legal_commands` because a rule enforced + // by the offer alone is enforced only for clients that ask + // what is legal — the browser would be filtered and a + // scenario file would not. + Action::Solve if target.claimed_by.is_some() => { + return Err(bad(format!( + "GR-P05: Problem {problem} was claimed in an earlier round" + ))); + } + Action::Solve + if !self + .players + .get(&actor) + .is_some_and(|p| p.hand.iter().any(|c| c.suit == target.suit)) => + { + return Err(bad(format!( + "GR-P05: no {:?} Solution in hand for Problem {problem}", + target.suit + ))); + } _ => {} } } else if problem.is_some() { @@ -2482,7 +2504,11 @@ mod replay_probe { println!( " {seats}p: {} problem(s) worth {best} against a threshold of {need} \u{2014} {}", state.problems.len(), - if best >= need { "reachable" } else { "UNREACHABLE" } + if best >= need { + "reachable" + } else { + "UNREACHABLE" + } ); } let unreachable: Vec = verdicts diff --git a/scenarios/ground/gr-p05-solve-legality.yaml b/scenarios/ground/gr-p05-solve-legality.yaml new file mode 100644 index 0000000..42e35b1 --- /dev/null +++ b/scenarios/ground/gr-p05-solve-legality.yaml @@ -0,0 +1,48 @@ +scenario: ground/gr-p05-solve-legality +description: > + GR-P05, ruled by ground-game 2026-08-03: SOLVE is legal only where it + can do something. P1 holds no Clarify and is refused Problem 1; P2 + holds one, is admitted, and claims it. + + The prior-round-claim half of GR-P05 is asserted in + `bot::tests::validate_enforces_all_four_solve_conditions` rather than + here, because reaching a second round costs a dozen commands to test + one rejection. + + The rule is asserted here rather than only in `legal_commands`, because + a rule enforced by the offer alone constrains only clients that ask what + is legal — a scenario file would walk straight past it. That is what the + AM-1 coverage gate surfaced when GR-P05 was added with no scenario. +covers: [GR-P05, GR-A02, GR-P03] +provisional: false +seed: 42 +setup: + players: 3 + preset: standard-3p + patch: + "lead": 1 + "players.0.hand": [{ suit: Change }] + "players.1.hand": [{ suit: Clarify }] +commands: + # 0 — P1 holds no Clarify: refused. + - actor: P1 + cmd: select_action + args: { action: SOLVE, problem: 1 } + # 1 — P2 holds one: admitted. + - actor: P2 + cmd: select_action + args: { action: SOLVE, problem: 1 } + - actor: P1 + cmd: select_action + args: { action: INVESTIGATE, problem: 2 } + - actor: P3 + cmd: select_action + args: { action: INVESTIGATE, problem: 3 } + - actor: SYSTEM + cmd: reveal + - actor: SYSTEM + cmd: resolve +expect: + rejects: [0] + state: + "problems.1.claimed_by": 1 diff --git a/specs/GroundRules.md b/specs/GroundRules.md index 620b120..2ffad75 100644 --- a/specs/GroundRules.md +++ b/specs/GroundRules.md @@ -177,6 +177,17 @@ terms. printed value for scoring; a Problem can be claimed at most once. - **GR-P04** Competing SOLVEs on the same Problem in the same round resolve in Lead order (GR-R07); losers keep their Solution (GR-A02). +- **GR-P05** *(ruled by ground-game 2026-08-03, GROUND-WP-0002 T02)* + **SOLVE is offered only where it can do something.** A seat may select + SOLVE on a Problem only if it is face-up, non-Denied, **unclaimed**, and + the seat **holds a Solution of the required suit**. A Problem claimed in + a *prior* round is not a legal target; the same-round race of GR-P04 is + unaffected, because both seats select before either resolves. + + *Not a bluff mechanic.* CB-WP-0018 raised the possibility that + selecting an unfulfillable SOLVE was an intended face-down bluff. The + ruling is that it was a filter bug: *"the player always knows their + hand… physical tables self-police the same way."* ## 9. Protection, Focus and Blame (GR-T) diff --git a/specs/MetricsAndScenarios.md b/specs/MetricsAndScenarios.md index 14d57fd..f13ffea 100644 --- a/specs/MetricsAndScenarios.md +++ b/specs/MetricsAndScenarios.md @@ -87,8 +87,8 @@ rule 4). a rule a scenario claims should also appear in the aggregate source, or the claim rests on a tag and nothing else. -Measured 2026-07-31: **49 of 58** claimed rules are named in -`games/ground/src/lib.rs`. **Unmet**, target 58. The nine unlinked: +Measured 2026-08-04: **50 of 59** claimed rules are named in +`games/ground/src/lib.rs`. **Unmet**, target 59. The nine unlinked: ```text GR-D07 GR-F02 GR-L03 GR-O03 GR-P01 GR-P02 GR-P03 GR-P04 GR-T01 diff --git a/workplans/CB-WP-0023-solve-legality.md b/workplans/CB-WP-0023-solve-legality.md index b24edb5..845efe6 100644 --- a/workplans/CB-WP-0023-solve-legality.md +++ b/workplans/CB-WP-0023-solve-legality.md @@ -2,7 +2,7 @@ id: CB-WP-0023 kind: product title: "SOLVE is offered only where it can do something" -status: active +status: done --- # Purpose @@ -43,7 +43,7 @@ exists to stop. ```task id: CB-WP-0023-T01 -status: todo +status: done priority: high ``` @@ -66,11 +66,40 @@ next reader will add a round comparison that does nothing. encoded the old behaviour — in which case the scenario was encoding a bug and must be updated with a note, not quietly edited. +**Done 2026-08-04.** `make all` exits 0, 26 scenarios, rule coverage +**59/59**. **No scenario encoded the bug** — all 25 passed unchanged. + +**The rule ended up somewhere other than where I put it, and a gate moved +it.** It went into `legal_commands` first. The AM-1 coverage gate then +demanded a scenario for the new GR-P05 — and scenarios drive `validate`, +not the offer layer. That is what showed the rule belonged in `validate`: +**a rule enforced only by the offer is enforced only for clients that ask +what is legal.** The browser would have been filtered and a scenario file +would have walked straight past it. + +Once GR-P05 moved into `validate`, every condition in `legal_commands` +was dead code, and my own layering test said so in those words +(*"validate now rejects claimed — drop the filter"*). The offer layer is +back to one arm for Investigate and SOLVE together. + +**And the reported case was not the one I reported.** CB-WP-0018 and the +message to ground-game described SOLVE offered on a **face-down** Problem. +Measured: `validate` already rejected face-down, so it never was offered. +Problem 1 is the Surface Problem and is face-up from the deal — the +maintainer's three inert SOLVEs were the **hand** case, holding no Clarify +for a Clarify Problem. The ruling covers both, so nothing is invalidated, +but the record was wrong and is corrected here. + +Four conditions, each asserted separately, because one "SOLVE is filtered" +test would pass with three of four implemented. Mutations: dropping the +claimed filter and dropping the hand filter each turn their own test red; +inverting the layering assertion turns `validate_enforces_all_four` red. + ## Task: retire what the ruling settles ```task id: CB-WP-0023-T02 -status: todo +status: done priority: medium ``` @@ -81,11 +110,19 @@ code but not in the spec leaves the next reader with two sources.** Report to `ground-game` that the ruling is implemented — closing the loop is the part that has failed twice. +**Done 2026-08-04.** `specs/GroundRules.md` gains **GR-P05** stating the +rule and recording that the bluff reading is dead, and +`scenarios/ground/gr-p05-solve-legality.yaml` covers it — P1 refused for +holding no Clarify, P2 admitted and claiming. The prior-round-claim half +is asserted in the unit test instead, because reaching a second round +costs a dozen commands to test one rejection, and that is said in the +scenario rather than left as a gap. + ## Task: evidence ```task id: CB-WP-0023-T03 -status: todo +status: done priority: high ```