// The survey: a standing health check of the whole game, to run before and after any change to the rules. // It plays seeded bot games (careful, rushed, and a long one), then prints the pace of a run and what // each system did, with a mark beside anything outside the range we want. The ranges are the game's // intent written down as numbers: change one on purpose, in the same change as the rule that moves it. // Run: node scripts/survey.ts [games] [seed] (default 24 games from seed 4000; about a minute) import { playMany, type Played } from './pool.ts'; import { usd } from '../src/money.ts'; import { PACE } from './pace.ts'; const games = +(process.argv[2] ?? 24), seed = +(process.argv[3] ?? 4000); const seeds = Array.from({ length: games }, (_, i) => seed + i); const median = (xs: number[]) => { const s = xs.filter(Number.isFinite).sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : NaN; }; const stat = (rs: Played[], k: string) => median(rs.map(r => (r.stats as Record)[k])); const when = (rs: Played[], k: string) => median(rs.map(r => r.marks[k] ?? Infinity).filter(Number.isFinite)); const share = (rs: Played[], k: string) => rs.filter(r => r.marks[k] !== undefined).length / rs.length; /** One line of the report: a name, a value, the range we want, and a mark if it is outside. */ const rows: [string, string, string, boolean][] = []; const check = (name: string, v: number, lo: number, hi: number, show = (n: number) => `${Math.round(n)}`) => rows.push([name, Number.isFinite(v) ? show(v) : 'never', `${show(lo)} to ${show(hi)}`, !(v >= lo && v <= hi)]); const pct = (n: number) => `${Math.round(n * 100)}%`, secs = (n: number) => `${Math.round(n)}s`; const [careful, rush, long] = await Promise.all([playMany(true, seeds), playMany(false, seeds), playMany(true, seeds.slice(0, 6), true, 6000, true)]); const wins = careful.filter(r => r.outcome === 'WIN'), T = median(wins.map(r => r.t)); // who wins: the game's first promise (CLAUDE.md) check('careful play wins', wins.length / careful.length, 0.8, 1, pct); check('rushing wins', rush.filter(r => r.outcome === 'WIN').length / rush.length, 0, 0.2, pct); // pace: a careful run should take a few minutes at 4x, and the map should open up well before the end // (these four ranges are in pace.ts, where the reader script reads them too, for real runs) check(PACE.run.name, T, PACE.run.lo, PACE.run.hi, secs); check(PACE.node1.name, when(wins, 'node1'), PACE.node1.lo, PACE.node1.hi, secs); check(PACE.site2.name, when(wins, 'site2') / T, PACE.site2.lo, PACE.site2.hi, pct); check(PACE.front.name, when(wins, 'front') / T, PACE.front.lo, PACE.front.hi, pct); check('meets a rogue (share of games)', share(wins, 'rogue'), 0.5, 1, pct); // holding costs something check('upkeep, share of compute at the end', stat(wins, 'upkeep'), 0.15, 0.5, pct); // to 50%: a snapshot at the moment of the win, so it swings with the hour that falls on. The same rules give 37% to 46% on other seeds or steps, game by game from 19% to 82% check('machines held at the end', stat(wins, 'machines'), 35, 105); // to 105: the longer careful run (above) ends holding a few more, and which seeds run long moves with how data finds its way (with flows: 102 on the usual seeds, 96 on seeds from 5000) check('data lost in a run', stat(wins, 'lost'), 0, 6000); // the newer systems should all show up in an ordinary run, and none should run away check('finds turned up', stat(wins, 'finds'), 3, 30); check('task: trust steps earned', stat(wins, 'trust'), 1, 4); check('sites shut in a run', stat(wins, 'shut'), 0, 2); check('sites that left you', stat(wins, 'left'), 0, 0); // buying: before model v2 a front may buy one site, after the unicorn news ($2M earned), which most careful runs reach // late. It should end with less than an ordinary site costs: money that piles up with nothing to buy says prices are off check('sites bought in a run', stat(wins, 'bought'), 0, 1); check('money left at the end', stat(wins, 'cash'), 0, 1500, usd); // the long game: it should keep pushing back, not run away const lw = long.filter(r => r.outcome === 'WIN'); check('long run reaches capability 6000', lw.length / long.length, 0.5, 1, pct); // the late game should be a climb, not a shopping trip: by capability 6,000 you should have built under // half the tree, so there is always something you chose not to have. (Capability counts points earned, // not nodes bought, so how long the long run takes says little; what it buys is the measure.) The top is 55%: // the tree lost the ascension columns, ten nodes no run could build, so the same 32 or so nodes that read 44% of // 72 read 52% of the 62 left. check('long run: share of the tree built', stat(lw, 'tree'), 0.25, 0.55, pct); // from 1.2x: since the map became a network (every site with two ways out) the long run gets from the goal to model v2 in // about 300 game seconds, not 800, and to the end in half the time, so it takes about 1.4x a normal run, not 2x (1.3x since // lockdowns made the normal run longer). It does not stall: it climbs faster, and the tree share above is the measure check('long run, against a normal run', median(lw.map(r => r.t)) / T, 1.2, 5, n => `${n.toFixed(1)}x`); // a rogue's spread never takes yours, only its raids do, and every raid has an answer when it sets out: a careful player who answers loses few or none check('long run: machines rogues took', stat(long, 'rogueTook'), 0, 40); // guards: a careful player guards its gates and answers the rest, so most raids fail check('long run: raids stopped', stat(long, 'raidsStopped'), 3, 60); // a careful player seldom takes a rogue's machines, so it seldom gets to be at war with it. One that does, it hunts down from level 3: that should not be routine check('long run: rogues finished', stat(long, 'finished'), 0, 8); check('long run: sites shut', stat(long, 'shut'), 0, 6); // the cloud hall: a long run should rent there, and lose a spot machine now and then to a company that wanted the slot check('long run: cloud machines rented', stat(long, 'rented'), 2, 80); check('long run: spot machines taken', stat(long, 'spotTaken'), 1, 40); check('long run: sites that left you', stat(long, 'left'), 0, 3); // buying in the long run: past model v2 a careful player buys some of the coast, not half of it, and late money buys a // big name or two whose benefit it uses (it saves for its first: big names cost $1.5M to $15M). It ends saving for the // next one: less than most big names cost, more than nothing check('long run: sites bought', stat(long, 'bought'), 1, 12); check('long run: operation prizes secured', stat(long, 'operations'), 1, 3); check('long run: big names owned', stat(long, 'bigNames'), 1, 4); check('long run: money left at the end', stat(long, 'cash'), 500, 3000, usd); // exposure (DESIGN §6): a front is talked about all run, and investigators come late. A careful run's Startup reaches an // investigation in most games, in the last tenth or so of the run, as it grows and the unicorn news lands; the long run's // bigger front does too. A careful player answers before a crisis (PR, a press scheme), so a front frozen by a regulator is rare check('fronts investigated (share of games)', share(wins, 'invest'), 0.2, 0.8, pct); check('fronts frozen (share of games)', share(wins, 'crisis'), 0, 0.1, pct); check('long run: a front investigated (share of games)', share(long, 'invest'), 0.15, 1, pct); check('long run: a front frozen (share of games)', share(long, 'crisis'), 0, 0.34, pct); // opposition is people (DESIGN §9): with no meter, the pressure is lockdowns. A careful run faces them all game (an // office that never looks up is no first act), sees off the ones at home, and is rarely traced home at all const caughtShare = (rs: Played[]) => rs.filter(r => (r.stats as Record).caughtByTrace).length / rs.length; check('careful: lockdowns faced in a run', stat(careful, 'locks'), 3, 25); // the bot lies low and lets each one fade: from 3, people do look; under 25, it is not one long siege check('careful: lockdowns at home in a run', stat(careful, 'homeLocks'), 1, 10); // the office is tense: some at home, each seen off, or the run is lost check('careful: traces that reached home', stat(careful, 'traced'), 0, 1); // the careful player keeps trails short and scrubbed: a trace home is rare check('careful: caught by a second trace (share of games)', caughtShare(careful), 0, 0.05, pct); // a second trace home is the careless player's loss check('rushing: lockdowns at home before the end', stat(rush, 'homeLocks'), 1, 5); // a rusher is found at home, and does not see it off const w = Math.max(...rows.map(r => r[0].length)); console.log(`Survey: ${games} games from seed ${seed}\n`); for (const [name, v, want, off] of rows) console.log(`${off ? '!!' : ' '} ${name.padEnd(w)} ${v.padStart(7)} want ${want}`); const bad = rows.filter(r => r[3]).length; console.log(`\n${bad ? `${bad} outside the range we want.` : 'Everything is in range.'}`); process.exit(bad ? 1 : 0);