GR-E03 and GR-E04 played to the end — F14 closed, and the reason they were
Some checks failed
ci / check (push) Failing after 4s
Some checks failed
ci / check (push) Failing after 4s
unplayed was ours Tier S (a fix and a measurement inside a boundary; chaos d8=4 from CB-WP-0029's roll, no override). cb-play built EVERY game with ScoringMode::SharedGround and passed an empty patch. The mode was settable in scenarios and not from the driver, so two of the three shipped modes were unreachable from the only way anyone actually plays. F14 sat open for a week because nobody could reach the thing it was about. --mode added. All three now play out and give DIFFERENT WINNERS FROM IDENTICAL PLAY: shared -> all four seats (mastery 4), common -> P3 alone (top personal scorer), coalitions -> P1+P2 (best Bond network, 4>3>2). Same 37 commands, three answers. AND THEY ANSWER F17'S OPEN QUESTION. I had flagged that ATTACK might earn its place where Blame costs personal score. It does not, in any mode: SHARED GROUND 132/165/190/200 -> identical free but pointless COMMON PROBLEM 59/52/48/44 -> 59/52/48/34 a cost at six seats BONDED COALITIONS 131/134/132/116 -> 59/52/48/34 roughly halved The coalitions row has a mechanism and the data confirms it unprompted. GR-A07 flips a Bond to a Rivalry on Attack, and GR-E04 scores Bond NETWORKS -- so attacking destroys the thing that scores. And the attacking numbers in E04 are IDENTICAL to E03's, which is exactly what that predicts: break every Bond and each seat is a coalition of one, so GR-E04 degenerates into GR-E03. That check was not designed; it fell out. F14 -> applied. F17 strengthened and no longer bounded to co-op: ATTACK has no mode in which it helps, and one where it actively destroys your score. Still framed as a question rather than a verdict. DARVO is the pattern the game is about not falling into, so a self-destructive ATTACK may be the design. What ground-game has to decide is whether the namesake mechanic being unreachable in competent play -- in all three modes -- is intended. make all: exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
f4eeddd726
commit
302fc95c97
6 changed files with 145 additions and 49 deletions
|
|
@ -1,23 +1,36 @@
|
|||
//! F17's reproduction: what is ATTACK worth? (CB-WP-0029 follow-up.)
|
||||
//! F17's reproduction: what is ATTACK worth, in each scoring mode?
|
||||
//!
|
||||
//! The maintainer reported *"there is no incentive to play attacks as long
|
||||
//! as I have positive cards"*. F17 sat as a note because nothing
|
||||
//! demonstrated it. This is the artifact.
|
||||
//!
|
||||
//! GreedyPolicy ranks Attack at 10, below everything (bot.rs). So its zero
|
||||
//! attacks measure OUR HEURISTIC, not the game. The test is a policy
|
||||
//! identical in every other respect with Attack promoted: if it wins as
|
||||
//! often, attacking is neutral; much less, avoiding it is correct play;
|
||||
//! more, and greedy is simply wrong.
|
||||
//! **`GreedyPolicy` plays ATTACK zero times, and that measures our bot.**
|
||||
//! `bot.rs` ranks `Action::Attack => 10`, below everything else. Reporting
|
||||
//! "the game gives no incentive" from a policy programmed to rank attack
|
||||
//! last would be CB-WP-0025's C4 error again — one policy's behaviour
|
||||
//! presented as the game's.
|
||||
//!
|
||||
//! So this varies **exactly one number**: ATTACK's rank in an otherwise
|
||||
//! identical policy. 10 is greedy's. 75 puts it above SUPPORT and below
|
||||
//! INVESTIGATE — *attack when convenient*. 95 puts it above SOLVE —
|
||||
//! *attack whenever legal*.
|
||||
//!
|
||||
//! And it asks the question in **all three scoring modes**, because Blame
|
||||
//! costs *personal* score: ATTACK may be worthless in the co-op mode and
|
||||
//! earn its place in GR-E03 or GR-E04.
|
||||
|
||||
use cb_game_runtime::{ScenarioGame, Setup};
|
||||
use cb_kernel::PlayerId;
|
||||
use games_ground::bot::{play, Choice, GreedyPolicy, Policy};
|
||||
use games_ground::{Action, GroundCommand, GroundState};
|
||||
use games_ground::{Action, GroundCommand, GroundState, ScoringMode};
|
||||
|
||||
/// Greedy's ordering with ATTACK promoted to the top of the action list.
|
||||
/// A copy of the ranking, not a call into it: `GreedyPolicy::rank` is
|
||||
/// private, and the point is to vary exactly one number.
|
||||
/// Greedy's ordering with ATTACK's rank as a parameter.
|
||||
///
|
||||
/// A copy of the ranking rather than a call into it: `GreedyPolicy::rank`
|
||||
/// is private, and the point is to vary one number while holding every
|
||||
/// other preference identical.
|
||||
struct Attacker(i32);
|
||||
|
||||
impl Policy for Attacker {
|
||||
fn name(&self) -> &'static str {
|
||||
"attacker"
|
||||
|
|
@ -27,7 +40,7 @@ impl Policy for Attacker {
|
|||
state: &GroundState,
|
||||
seat: PlayerId,
|
||||
legal: &[GroundCommand],
|
||||
_p: bool,
|
||||
_may_pass: bool,
|
||||
) -> Choice {
|
||||
let gated = state
|
||||
.players
|
||||
|
|
@ -39,7 +52,7 @@ impl Policy for Attacker {
|
|||
action, problem, ..
|
||||
} => match action {
|
||||
Action::Ground if gated => 100,
|
||||
Action::Attack => self.0, // <-- the ONE varying number (greedy: 10)
|
||||
Action::Attack => self.0, // the one varying number
|
||||
Action::Solve
|
||||
if problem
|
||||
.and_then(|p| state.problems.get(&p))
|
||||
|
|
@ -73,14 +86,15 @@ impl Policy for Attacker {
|
|||
}
|
||||
}
|
||||
|
||||
/// `(games, good outcomes, attacks, DARVO armings)` over 200 seeds.
|
||||
fn sweep(
|
||||
name: &str,
|
||||
mode: ScoringMode,
|
||||
players: u8,
|
||||
mk: &dyn Fn(u64, u8) -> Vec<Box<dyn Policy>>,
|
||||
mk: &dyn Fn(u8) -> Vec<Box<dyn Policy>>,
|
||||
) -> (u32, u32, u32, u32) {
|
||||
let (mut games, mut won, mut atk, mut darvo) = (0, 0, 0, 0);
|
||||
for seed in 0..200u64 {
|
||||
let Ok(st) = GroundState::setup(
|
||||
let Ok(mut st) = GroundState::setup(
|
||||
&Setup {
|
||||
players,
|
||||
preset: format!("standard-{players}p"),
|
||||
|
|
@ -90,10 +104,24 @@ fn sweep(
|
|||
) else {
|
||||
continue;
|
||||
};
|
||||
let mut ps = mk(seed, players);
|
||||
st.mode = mode;
|
||||
let mut ps = mk(players);
|
||||
let Ok(g) = play(st, &mut ps) else { continue };
|
||||
games += 1;
|
||||
if g.state.outcome.as_ref().is_some_and(|o| o.group_success) {
|
||||
|
||||
// In the co-op mode the table wins together. In the other two the
|
||||
// question is whether SEAT 0 is among the winners — because that
|
||||
// is what an individual player's incentive turns on, and ATTACK is
|
||||
// an individual's choice.
|
||||
let good = match mode {
|
||||
ScoringMode::SharedGround => g.state.outcome.as_ref().is_some_and(|o| o.group_success),
|
||||
_ => g
|
||||
.state
|
||||
.outcome
|
||||
.as_ref()
|
||||
.is_some_and(|o| o.winners.contains(&PlayerId(0))),
|
||||
};
|
||||
if good {
|
||||
won += 1;
|
||||
}
|
||||
for (_, c) in &g.steps {
|
||||
|
|
@ -111,33 +139,47 @@ fn sweep(
|
|||
}
|
||||
}
|
||||
}
|
||||
let _ = name;
|
||||
(games, won, atk, darvo)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("F17: does ATTACK cost you the game, or does our bot just avoid it?\n");
|
||||
// The gradient, not just the extremes: greedy ranks Attack at 10,
|
||||
// below everything. 75 puts it above SUPPORT and below INVESTIGATE --
|
||||
// "attack when convenient". 95 is "attack whenever legal".
|
||||
println!(" rank=10 (greedy) rank=75 (sometimes) rank=95 (always)");
|
||||
println!("seats won atk darvo won atk darvo won atk darvo");
|
||||
for players in [2u8, 3, 4, 5, 6] {
|
||||
let cell = |r: i32, p: u8| {
|
||||
sweep("x", p, &move |_s, n| {
|
||||
println!("F17 — what is ATTACK worth, in each scoring mode?\n");
|
||||
println!("`won` is group success in SHARED GROUND, and \"seat 0 is among the");
|
||||
println!("winners\" in the other two, because ATTACK is an individual's choice");
|
||||
println!("and that is what an individual's incentive turns on.\n");
|
||||
|
||||
for (label, mode) in [
|
||||
("SHARED GROUND (GR-E02, co-op)", ScoringMode::SharedGround),
|
||||
(
|
||||
"COMMON PROBLEM (GR-E03, semi-co-op)",
|
||||
ScoringMode::CommonProblem,
|
||||
),
|
||||
("BONDED COALITIONS (GR-E04)", ScoringMode::BondedCoalitions),
|
||||
] {
|
||||
println!("{label}");
|
||||
println!(" rank=10 (greedy) rank=75 (sometimes) rank=95 (always)");
|
||||
println!("seats won atk darvo won atk darvo won atk darvo");
|
||||
for players in [2u8, 3, 4, 6] {
|
||||
let (_, gw, ga, gd) = sweep(mode, players, &|n| {
|
||||
(0..n)
|
||||
.map(|_| Box::new(Attacker(r)) as Box<dyn Policy>)
|
||||
.map(|_| Box::new(GreedyPolicy) as Box<dyn Policy>)
|
||||
.collect()
|
||||
})
|
||||
};
|
||||
let (_, gw, ga, gd) = sweep("greedy", players, &|_s, p| {
|
||||
(0..p)
|
||||
.map(|_| Box::new(GreedyPolicy) as Box<dyn Policy>)
|
||||
.collect()
|
||||
});
|
||||
let (_, mw, ma, md) = cell(75, players);
|
||||
let (_, aw, aa, ad) = cell(95, players);
|
||||
println!(" {players}p {gw:>4} {ga:>4} {gd:>5} {mw:>4} {ma:>4} {md:>5} {aw:>4} {aa:>4} {ad:>5}");
|
||||
});
|
||||
let cell = |r: i32| {
|
||||
sweep(mode, players, &move |n| {
|
||||
(0..n)
|
||||
.map(|_| Box::new(Attacker(r)) as Box<dyn Policy>)
|
||||
.collect()
|
||||
})
|
||||
};
|
||||
let (_, mw, ma, md) = cell(75);
|
||||
let (_, aw, aa, ad) = cell(95);
|
||||
println!(
|
||||
" {players}p {gw:>4} {ga:>4} {gd:>5} {mw:>4} {ma:>4} {md:>5} \
|
||||
{aw:>4} {aa:>4} {ad:>5}"
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
println!("\n(200 games per cell; greedy shown as the rank=10 column)");
|
||||
println!("(200 games per cell)");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by
|
|||
| F11 | inert | applied | scenarios/ground/gr-p05-solve-legality.yaml | counterexample | 2026-08-02 | clay-borg |
|
||||
| F12 | degenerate | note | — | — | 2026-08-01 | clay-borg |
|
||||
| F13 | inconsistent | withdrawn | scenarios/ground/gr-e01-threshold-reachable-2p.yaml | counterexample | 2026-08-01 | clay-borg |
|
||||
| F14 | unplayed | note | — | — | 2026-08-01 | clay-borg |
|
||||
| F14 | unplayed | applied | games/ground/examples/attack-value.rs | counterexample | 2026-08-01 | clay-borg |
|
||||
| F15 | underdetermined | note | — | — | 2026-08-05 | clay-borg |
|
||||
| F16 | inconsistent | withdrawn | games/ground/examples/difficulty.rs | counterexample | 2026-08-05 | clay-borg |
|
||||
| F17 | degenerate | raised | games/ground/examples/attack-value.rs | counterexample | 2026-08-06 | ground-game |
|
||||
|
|
@ -151,10 +151,21 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by
|
|||
effects (Stress, Rivalry, DARVO) feed nothing that decides
|
||||
`group_success`.
|
||||
|
||||
**Bounded to SHARED GROUND.** These are co-op games. Blame costs
|
||||
*personal* score, so ATTACK may well earn its place in GR-E03 and
|
||||
GR-E04 — **which have never been played to the end (F14)**, and that is
|
||||
where this question should be asked next.
|
||||
**Asked in all three modes 2026-08-07, and ATTACK earns its place in
|
||||
none of them:**
|
||||
|
||||
| mode | never attack | attack sometimes |
|
||||
|---|---|---|
|
||||
| SHARED GROUND | 132/165/190/200 | **identical** — free but pointless |
|
||||
| COMMON PROBLEM | 59/52/48/44 | 59/52/48/**34** — a cost at six seats |
|
||||
| BONDED COALITIONS | 131/134/132/116 | **59/52/48/34** — roughly halved |
|
||||
|
||||
**The coalitions row has a mechanism the data confirms.** GR-A07 flips a
|
||||
Bond to a Rivalry on Attack, and GR-E04 scores Bond *networks* — so
|
||||
attacking destroys the thing that scores. And the attacking numbers in
|
||||
E04 are **identical** to E03's, which is exactly what that predicts:
|
||||
break every Bond and each seat becomes a coalition of one, so GR-E04
|
||||
degenerates into GR-E03.
|
||||
|
||||
**Not a claim that the game is broken.** DARVO is the pattern the game
|
||||
is *about* not falling into; a self-destructive ATTACK may be the
|
||||
|
|
@ -177,10 +188,14 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by
|
|||
play — a unit test showing the two tallies point at different seats
|
||||
demonstrates only that they can differ, which is arithmetic, not a design
|
||||
defect. Under GameDesign §3.1 it may not be reported until one exists.
|
||||
- **F14 — GR-E03/GR-E04 never played to the end.** Nineteen passes, never
|
||||
played out. `note` until a trial game exists; GROUND-WP-0003 is the
|
||||
playtest that would close it, and GameDesign §5's protocol makes the
|
||||
recording the artifact.
|
||||
- **F14 — GR-E03/GR-E04 never played to the end. Closed 2026-08-07, and
|
||||
the reason they were unplayed was ours.** `cb-play` built every game with
|
||||
`ScoringMode::SharedGround` and passed an **empty patch**, so two of the
|
||||
three shipped modes were unreachable from the only way anyone plays. The
|
||||
mode was patchable in *scenarios* and not from the *driver*. `--mode`
|
||||
added; all three now play to the end and give **different winners from
|
||||
identical play**: shared → all four seats, common → P3 alone, coalitions
|
||||
→ P1+P2. **`applied`** — 800 games per mode in the reproduction.
|
||||
|
||||
### The register's first run found ten answers nobody had collected
|
||||
|
||||
|
|
|
|||
|
|
@ -812,6 +812,7 @@ mod tests {
|
|||
record: None,
|
||||
serve: Some(0),
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
},
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
out,
|
||||
|
|
@ -980,6 +981,7 @@ mod tests {
|
|||
record: None,
|
||||
serve: Some(0),
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
},
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
out,
|
||||
|
|
|
|||
|
|
@ -763,6 +763,7 @@ mod tests {
|
|||
record: Some(dir.join("session.yaml")),
|
||||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
};
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let summary =
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ play:
|
|||
--replay DIR write a .cbreplay bundle of the finished game to DIR
|
||||
--record FILE write the finished game as a scenario YAML
|
||||
--trial FILE write a trial log: what the player said, bound to where
|
||||
--mode M scoring mode: shared (GR-E02), common (GR-E03),
|
||||
coalitions (GR-E04). Default shared.
|
||||
--serve PORT play human seats in a browser on 127.0.0.1:PORT instead
|
||||
of on the terminal; 0 lets the OS pick. Prints a URL
|
||||
carrying a per-process token — without it the page is
|
||||
|
|
@ -107,6 +109,24 @@ fn parse_args(argv: &[String]) -> Result<Mode, String> {
|
|||
}
|
||||
// CB-WP-0027: a trial is a recorded session PLUS what the
|
||||
// player said while playing (GameDesign §5, ADR-0014).
|
||||
// GR-E02..E04, so the other two shipped modes are reachable.
|
||||
"--mode" => {
|
||||
play_flags.push(flag.into());
|
||||
let v = value(i, argv, flag)?;
|
||||
config.mode = match v.to_ascii_lowercase().as_str() {
|
||||
"shared" | "sharedground" | "coop" => games_ground::ScoringMode::SharedGround,
|
||||
"common" | "commonproblem" | "semi" => games_ground::ScoringMode::CommonProblem,
|
||||
"coalitions" | "bondedcoalitions" | "coalition" => {
|
||||
games_ground::ScoringMode::BondedCoalitions
|
||||
}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"unknown --mode {other:?} (shared, common, coalitions)"
|
||||
))
|
||||
}
|
||||
};
|
||||
i += 2;
|
||||
}
|
||||
"--trial" => {
|
||||
play_flags.push(flag.into());
|
||||
config.trial = Some(value(i, argv, flag)?.into());
|
||||
|
|
@ -269,6 +289,7 @@ mod tests {
|
|||
record: None,
|
||||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
};
|
||||
let script = "0\n".repeat(400);
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
|
|
@ -309,6 +330,7 @@ mod tests {
|
|||
record: None,
|
||||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
};
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game");
|
||||
|
|
@ -372,6 +394,7 @@ mod tests {
|
|||
record: None,
|
||||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
};
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
|
||||
|
|
@ -433,6 +456,7 @@ mod tests {
|
|||
record: Some(dir.join("session.yaml")),
|
||||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
};
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let summary = table::play(&config, "".as_bytes(), &mut out).expect("game");
|
||||
|
|
|
|||
|
|
@ -39,6 +39,14 @@ pub struct Config {
|
|||
/// Where the trial log goes (CB-WP-0027). Without it the note channel
|
||||
/// refuses rather than dropping what the player wrote.
|
||||
pub trial: Option<std::path::PathBuf>,
|
||||
/// GR-E02..E04: which scoring mode to play.
|
||||
///
|
||||
/// **The driver could not choose one until now**, which is a large
|
||||
/// part of why GR-E03 and GR-E04 had never been played to the end
|
||||
/// (F14). `setup` builds SharedGround and `run_game` passed an empty
|
||||
/// patch, so two of the three shipped modes were unreachable from the
|
||||
/// only way anyone actually plays.
|
||||
pub mode: games_ground::ScoringMode,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
|
|
@ -52,6 +60,7 @@ impl Default for Config {
|
|||
record: None,
|
||||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -332,7 +341,10 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
preset: format!("standard-{}p", config.players),
|
||||
patch: Default::default(),
|
||||
};
|
||||
let initial = <GroundState as cb_game_runtime::ScenarioGame>::setup(&setup, config.seed)?;
|
||||
let mut initial = <GroundState as cb_game_runtime::ScenarioGame>::setup(&setup, config.seed)?;
|
||||
// GR-E02..E04. Set before the hash is taken, so a recorded session
|
||||
// replays in the mode it was played in.
|
||||
initial.mode = config.mode;
|
||||
let initial_json = serde_json::to_value(&initial).map_err(|e| e.to_string())?;
|
||||
let initial_hash = cb_events::state_hash_hex(&initial);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue