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>
185 lines
6.5 KiB
Rust
185 lines
6.5 KiB
Rust
//! 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` 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, ScoringMode};
|
|
|
|
/// 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"
|
|
}
|
|
fn choose(
|
|
&mut self,
|
|
state: &GroundState,
|
|
seat: PlayerId,
|
|
legal: &[GroundCommand],
|
|
_may_pass: bool,
|
|
) -> Choice {
|
|
let gated = state
|
|
.players
|
|
.get(&seat)
|
|
.is_some_and(|p| p.stress >= 4 && !p.freedom_gate_lifted);
|
|
let rank = |c: &GroundCommand| -> i32 {
|
|
match c {
|
|
GroundCommand::SelectAction {
|
|
action, problem, ..
|
|
} => match action {
|
|
Action::Ground if gated => 100,
|
|
Action::Attack => self.0, // the one varying number
|
|
Action::Solve
|
|
if problem
|
|
.and_then(|p| state.problems.get(&p))
|
|
.is_some_and(|p| p.claimed_by.is_some()) =>
|
|
{
|
|
5
|
|
}
|
|
Action::Solve => 90,
|
|
Action::Investigate => 80,
|
|
Action::Support => 70,
|
|
Action::Ground => 60,
|
|
},
|
|
GroundCommand::SpendFreedom => {
|
|
if gated {
|
|
96
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
GroundCommand::Reveal | GroundCommand::Resolve | GroundCommand::EndRound => -1,
|
|
_ => 50,
|
|
}
|
|
};
|
|
let mut best = 0usize;
|
|
for (i, c) in legal.iter().enumerate() {
|
|
if rank(c) > rank(&legal[best]) {
|
|
best = i;
|
|
}
|
|
}
|
|
Choice::Command(best)
|
|
}
|
|
}
|
|
|
|
/// `(games, good outcomes, attacks, DARVO armings)` over 200 seeds.
|
|
fn sweep(
|
|
mode: ScoringMode,
|
|
players: u8,
|
|
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(mut st) = GroundState::setup(
|
|
&Setup {
|
|
players,
|
|
preset: format!("standard-{players}p"),
|
|
patch: Default::default(),
|
|
},
|
|
seed,
|
|
) else {
|
|
continue;
|
|
};
|
|
st.mode = mode;
|
|
let mut ps = mk(players);
|
|
let Ok(g) = play(st, &mut ps) else { continue };
|
|
games += 1;
|
|
|
|
// 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 {
|
|
if let GroundCommand::SelectAction {
|
|
action: Action::Attack,
|
|
..
|
|
} = c
|
|
{
|
|
atk += 1;
|
|
}
|
|
}
|
|
for e in &g.events {
|
|
if matches!(e, games_ground::GroundEvent::DarvoTriggered { .. }) {
|
|
darvo += 1;
|
|
}
|
|
}
|
|
}
|
|
(games, won, atk, darvo)
|
|
}
|
|
|
|
fn main() {
|
|
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(GreedyPolicy) as Box<dyn Policy>)
|
|
.collect()
|
|
});
|
|
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!("(200 games per cell)");
|
|
}
|