Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Offline, deterministic base-game calibration. Never import into the app/server:
|
||||
* this harness replaces crypto.getRandomValues only within its own Node process.
|
||||
* Run: node --import tsx tools/ai-calibration.ts --seed 20260906 --out /tmp/dune-ai-calibration.json
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
RuleError,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
function argument(name: string, fallback: string): string {
|
||||
const index = args.indexOf(name);
|
||||
if (index < 0) return fallback;
|
||||
if (!args[index + 1] || args[index + 1].startsWith('--'))
|
||||
throw new Error(`Missing value for ${name}`);
|
||||
return args[index + 1];
|
||||
}
|
||||
function positive(name: string, fallback: number): number {
|
||||
const value = Number(argument(name, String(fallback)));
|
||||
if (!Number.isSafeInteger(value) || value <= 0)
|
||||
throw new Error(`${name} must be a positive safe integer`);
|
||||
return value;
|
||||
}
|
||||
for (let i = 0; i < args.length; i += 2)
|
||||
if (!['--seed', '--games', '--max-actions', '--out'].includes(args[i]))
|
||||
throw new Error(`Unknown argument ${args[i]}`);
|
||||
const masterSeed = positive('--seed', 20260906) >>> 0;
|
||||
const gameLimit = positive('--games', 120);
|
||||
const maxActions = positive('--max-actions', 6000);
|
||||
if (gameLimit > 120)
|
||||
throw new Error('This balanced design contains 120 games.');
|
||||
const output = resolve(argument('--out', '/tmp/dune-ai-calibration.json'));
|
||||
const started = Date.now();
|
||||
|
||||
function sourceFingerprint() {
|
||||
const files = readdirSync('game')
|
||||
.filter((name) => name.endsWith('.ts'))
|
||||
.sort()
|
||||
.map((name) => `game/${name}`);
|
||||
files.push('tools/ai-calibration.ts');
|
||||
const hashes = Object.fromEntries(
|
||||
files.map((file) => [
|
||||
file,
|
||||
createHash('sha256').update(readFileSync(file)).digest('hex'),
|
||||
]),
|
||||
);
|
||||
return {
|
||||
combined: createHash('sha256').update(JSON.stringify(hashes)).digest('hex'),
|
||||
files: hashes,
|
||||
};
|
||||
}
|
||||
const sources = sourceFingerprint();
|
||||
|
||||
// Mulberry32 produces deterministic 32-bit words for the engine's shuffle API.
|
||||
function generator(seed: number) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let word = Math.imul(state ^ (state >>> 15), state | 1);
|
||||
word ^= word + Math.imul(word ^ (word >>> 7), word | 61);
|
||||
return (word ^ (word >>> 14)) >>> 0;
|
||||
};
|
||||
}
|
||||
let randomWord = generator(masterSeed);
|
||||
const cryptoObject = globalThis.crypto;
|
||||
const originalRandom = Object.getOwnPropertyDescriptor(
|
||||
cryptoObject,
|
||||
'getRandomValues',
|
||||
);
|
||||
Object.defineProperty(cryptoObject, 'getRandomValues', {
|
||||
configurable: true,
|
||||
value: <T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (!(array instanceof Uint32Array))
|
||||
throw new Error(
|
||||
'Calibration only supports the engine Uint32Array RNG contract.',
|
||||
);
|
||||
for (let i = 0; i < array.length; i++) array[i] = randomWord();
|
||||
return array;
|
||||
},
|
||||
});
|
||||
|
||||
type Seat = { id: string; seat: number; faction: FactionId; level: Difficulty };
|
||||
type Outcome =
|
||||
| 'complete'
|
||||
| 'deadlock'
|
||||
| 'action-limit'
|
||||
| 'exception'
|
||||
| 'invariant';
|
||||
type Row = {
|
||||
index: number;
|
||||
seed: number;
|
||||
block: number;
|
||||
shift: number;
|
||||
players: number;
|
||||
code: string;
|
||||
seats: Seat[];
|
||||
outcome: Outcome;
|
||||
turn: number;
|
||||
phase: number;
|
||||
accepted: number;
|
||||
rejected: number;
|
||||
rejectionReasons: Record<string, number>;
|
||||
rejectedByLevel: Record<Difficulty, number>;
|
||||
winner: string[];
|
||||
elapsedMs: number;
|
||||
failure?: string;
|
||||
context?: object;
|
||||
};
|
||||
const rows: Row[] = [];
|
||||
const baseFactions = FACTIONS.filter((f) => f.expansion === 'base').map(
|
||||
(f) => f.id,
|
||||
);
|
||||
function invariant(g: Game) {
|
||||
for (const p of g.players) {
|
||||
const onBoard = Object.values(p.forces).reduce((a, b) => a + b, 0);
|
||||
if (p.reserves + p.tanks + onBoard !== 20)
|
||||
throw new Error(`Force conservation: ${p.id}/${p.faction}`);
|
||||
if (
|
||||
![p.reserves, p.tanks, ...Object.values(p.forces), p.spice].every(
|
||||
(n) => Number.isSafeInteger(n) && n >= 0,
|
||||
)
|
||||
)
|
||||
throw new Error(`Nonnegative integer resources: ${p.id}/${p.faction}`);
|
||||
const temporary =
|
||||
g.decision?.kind === 'handExchange' && g.decision.player === p.id
|
||||
? g.decision.count
|
||||
: 0;
|
||||
if (p.hand.length > (p.faction === 'harkonnen' ? 8 : 4) + temporary)
|
||||
throw new Error(`Hand limit: ${p.id}/${p.faction}`);
|
||||
}
|
||||
}
|
||||
function context(g: Game) {
|
||||
return {
|
||||
status: g.status,
|
||||
turn: g.turn,
|
||||
phase: g.phase,
|
||||
active: g.active,
|
||||
decision: g.decision?.kind ?? null,
|
||||
decisionPlayer: g.decision?.player ?? null,
|
||||
response: g.response?.kind ?? null,
|
||||
responseOwner: g.response?.owner ?? null,
|
||||
ready: g.ready,
|
||||
battle: g.battle
|
||||
? {
|
||||
territory: g.battle.territory,
|
||||
revealed: g.battle.revealed,
|
||||
plans: Object.keys(g.battle.plans),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
function play(index: number, count: number, block: number, shift: number): Row {
|
||||
// The four shifts in a roster block share deal seed/code and stable seat IDs.
|
||||
const seed =
|
||||
(masterSeed +
|
||||
Math.imul(count, 0x9e3779b9) +
|
||||
Math.imul(block, 0x85ebca6b)) >>>
|
||||
0;
|
||||
randomWord = generator(seed);
|
||||
const code = `C${seed.toString(36).padStart(7, '0')}`;
|
||||
const seats: Seat[] = Array.from({ length: count }, (_, seat) => ({
|
||||
id: `seat-${seat}`,
|
||||
seat,
|
||||
faction: baseFactions[(block + seat) % 6],
|
||||
level: DIFFICULTIES[(seat + shift) % DIFFICULTIES.length],
|
||||
}));
|
||||
const row: Row = {
|
||||
index,
|
||||
seed,
|
||||
block,
|
||||
shift,
|
||||
players: count,
|
||||
code,
|
||||
seats,
|
||||
outcome: 'action-limit',
|
||||
turn: 1,
|
||||
phase: 0,
|
||||
accepted: 0,
|
||||
rejected: 0,
|
||||
rejectionReasons: {},
|
||||
rejectedByLevel: { Easy: 0, Medium: 0, Hard: 0, Brutal: 0 },
|
||||
winner: [],
|
||||
elapsedMs: 0,
|
||||
};
|
||||
const gameStart = Date.now();
|
||||
let g = createGame(
|
||||
code,
|
||||
newPlayer(seats[0].id, seats[0].faction, seats[0].faction),
|
||||
);
|
||||
try {
|
||||
for (const seat of seats.slice(1))
|
||||
joinGame(g, newPlayer(seat.id, seat.faction, seat.faction));
|
||||
for (const [i, p] of g.players.entries()) {
|
||||
p.bot = seats[i].level;
|
||||
p.ready = true;
|
||||
}
|
||||
// Go through the real supported start gate; no advanced/expansion injection.
|
||||
g = applyAction(g, g.host, { type: 'start' });
|
||||
for (let step = 0; step < maxActions; step++) {
|
||||
let next: Game | undefined;
|
||||
// Match runBots' player/candidate order while counting its caught RuleErrors.
|
||||
for (const p of g.players.filter((p) => p.bot)) {
|
||||
for (const action of botActions(viewGame(g, p.id))) {
|
||||
try {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
const mode =
|
||||
typeof action.mode === 'string' ? `/${action.mode}` : '';
|
||||
const reason = `${action.type}${mode}: ${error.message}`;
|
||||
row.rejected++;
|
||||
row.rejectedByLevel[p.bot!]++;
|
||||
row.rejectionReasons[reason] =
|
||||
(row.rejectionReasons[reason] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
if (next) break;
|
||||
}
|
||||
if (!next) {
|
||||
row.outcome = 'deadlock';
|
||||
row.context = context(g);
|
||||
break;
|
||||
}
|
||||
g = next;
|
||||
row.accepted++;
|
||||
try {
|
||||
invariant(g);
|
||||
} catch (error) {
|
||||
row.outcome = 'invariant';
|
||||
row.failure = String(error);
|
||||
row.context = context(g);
|
||||
break;
|
||||
}
|
||||
if (g.status === 'finished') {
|
||||
row.outcome = 'complete';
|
||||
row.winner = [...g.winner];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
row.outcome = 'exception';
|
||||
row.failure = String(error);
|
||||
row.context = context(g);
|
||||
}
|
||||
if (row.outcome === 'action-limit') row.context = context(g);
|
||||
row.turn = g.turn;
|
||||
row.phase = g.phase;
|
||||
row.elapsedMs = Date.now() - gameStart;
|
||||
return row;
|
||||
}
|
||||
function statistics(subset: Row[]) {
|
||||
const completed = subset.filter((r) => r.outcome === 'complete');
|
||||
return {
|
||||
games: subset.length,
|
||||
complete: completed.length,
|
||||
outcomes: Object.fromEntries(
|
||||
['complete', 'deadlock', 'action-limit', 'exception', 'invariant'].map(
|
||||
(status) => [status, subset.filter((r) => r.outcome === status).length],
|
||||
),
|
||||
),
|
||||
acceptedActions: subset.reduce((sum, r) => sum + r.accepted, 0),
|
||||
rejectedCandidates: subset.reduce((sum, r) => sum + r.rejected, 0),
|
||||
gamesWithRejections: subset.filter((r) => r.rejected > 0).length,
|
||||
meanCompletedTurns: completed.length
|
||||
? completed.reduce((sum, r) => sum + r.turn, 0) / completed.length
|
||||
: null,
|
||||
turnHistogram: Object.fromEntries(
|
||||
Array.from({ length: 10 }, (_, i) => [
|
||||
i + 1,
|
||||
completed.filter((r) => r.turn === i + 1).length,
|
||||
]),
|
||||
),
|
||||
byLevel: Object.fromEntries(
|
||||
DIFFICULTIES.map((level) => {
|
||||
const appearances = subset.flatMap((r) =>
|
||||
r.seats.filter((s) => s.level === level).map((s) => ({ r, s })),
|
||||
);
|
||||
const completeSeats = appearances.filter(
|
||||
({ r }) => r.outcome === 'complete',
|
||||
);
|
||||
const winning = completeSeats.filter(({ r, s }) =>
|
||||
r.winner.includes(s.id),
|
||||
);
|
||||
return [
|
||||
level,
|
||||
{
|
||||
appearances: appearances.length,
|
||||
completedAppearances: completeSeats.length,
|
||||
winsIncludingShared: winning.length,
|
||||
winRateIncludingShared: completeSeats.length
|
||||
? winning.length / completeSeats.length
|
||||
: null,
|
||||
fractionalWinCredit: winning.reduce(
|
||||
(sum, { r }) => sum + 1 / r.winner.length,
|
||||
0,
|
||||
),
|
||||
rejectedCandidates: subset.reduce(
|
||||
(sum, r) => sum + r.rejectedByLevel[level],
|
||||
0,
|
||||
),
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
try {
|
||||
outer: for (let count = 2; count <= 6; count++)
|
||||
for (let block = 0; block < 6; block++)
|
||||
for (let shift = 0; shift < 4; shift++) {
|
||||
if (rows.length >= gameLimit) break outer;
|
||||
const row = play(rows.length, count, block, shift);
|
||||
rows.push(row);
|
||||
if (rows.length % 12 === 0 || row.outcome !== 'complete')
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
progress: rows.length,
|
||||
of: gameLimit,
|
||||
last: {
|
||||
outcome: row.outcome,
|
||||
players: count,
|
||||
seed: row.seed,
|
||||
shift,
|
||||
turn: row.turn,
|
||||
accepted: row.accepted,
|
||||
rejected: row.rejected,
|
||||
failure: row.failure,
|
||||
context: row.context,
|
||||
},
|
||||
elapsedSeconds: Math.round((Date.now() - started) / 1000),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const currentSources = sourceFingerprint();
|
||||
const reasons: Record<string, number> = {};
|
||||
for (const row of rows)
|
||||
for (const [reason, count] of Object.entries(row.rejectionReasons))
|
||||
reasons[reason] = (reasons[reason] ?? 0) + count;
|
||||
const result = {
|
||||
settings: {
|
||||
masterSeed,
|
||||
games: gameLimit,
|
||||
maxActions,
|
||||
output,
|
||||
advanced: false,
|
||||
expansions: [],
|
||||
techTokens: false,
|
||||
},
|
||||
rng: 'Mulberry32; one seeded Uint32Array engine stream per roster block; shifts reset same seed',
|
||||
sources,
|
||||
sourceFilesChangedDuringRun: sources.combined !== currentSources.combined,
|
||||
elapsedMs: Date.now() - started,
|
||||
aggregate: statistics(rows),
|
||||
byPlayerCount: Object.fromEntries(
|
||||
[2, 3, 4, 5, 6].map((n) => [
|
||||
n,
|
||||
statistics(rows.filter((r) => r.players === n)),
|
||||
]),
|
||||
),
|
||||
rejectionReasons: Object.fromEntries(
|
||||
Object.entries(reasons).sort((a, b) => b[1] - a[1]),
|
||||
),
|
||||
rows,
|
||||
};
|
||||
writeFileSync(output, `${JSON.stringify(result, null, 2)}\n`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
output,
|
||||
elapsedMs: result.elapsedMs,
|
||||
aggregate: result.aggregate,
|
||||
sourceFilesChangedDuringRun: result.sourceFilesChangedDuringRun,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (rows.some((r) => r.outcome !== 'complete')) process.exitCode = 1;
|
||||
} finally {
|
||||
if (originalRandom)
|
||||
Object.defineProperty(cryptoObject, 'getRandomValues', originalRandom);
|
||||
else Reflect.deleteProperty(cryptoObject, 'getRandomValues');
|
||||
}
|
||||
Reference in new issue
Block a user