Initial commit

This commit is contained in:
Quintine committed 2026-09-08 07:37:52 +10:00
commit 8c709fb52f
771 files changed
+222983

No files matched your search

+723
View File
@@ -0,0 +1,723 @@
/**
* Offline, deterministic Advanced base-faction calibration through the shared setup initializer. Never import into the app/server:
* this harness replaces crypto.getRandomValues only within its own Node process.
* Run: node --import tsx tools/ai-calibration-advanced.ts --seed 20260909 --out /tmp/dune-ai-calibration-advanced.json
*/
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
applyAction,
initializeBaseGameForAudit,
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';
import { baseDeck } from '../game/cards';
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', 20260923) >>> 0;
const gameLimit = positive('--games', 456);
const maxActions = positive('--max-actions', 6000);
if (gameLimit > 456) throw new Error('This design contains 456 games.');
const output = resolve(
argument('--out', '/tmp/dune-ai-calibration-advanced.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-advanced.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);
let uuidWord = generator(masterSeed ^ 0xa5a5a5a5);
const cryptoObject = globalThis.crypto;
const originalUuid = Object.getOwnPropertyDescriptor(
cryptoObject,
'randomUUID',
);
Object.defineProperty(cryptoObject, 'randomUUID', {
configurable: true,
value: () => {
const hex = Array.from({ length: 4 }, () =>
uuidWord().toString(16).padStart(8, '0'),
)
.join('')
.split('');
hex[12] = '4';
hex[16] = ((parseInt(hex[16], 16) & 3) | 8).toString(16);
const id = hex.join('');
return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
},
});
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;
circle: number;
faction: FactionId;
level: Difficulty;
};
type Outcome =
| 'complete'
| 'deadlock'
| 'action-limit'
| 'exception'
| 'invariant';
type Row = {
index: number;
seed: number;
block: number;
shift: number;
replicate: number;
direction: 1 | -1;
rosterMask: number;
setupActions: number;
setupTransitions: string[];
jsonRoundTrips: number;
rejectionSamples: {
player: string;
level: Difficulty;
rank: number;
action: object;
reason: string;
context: object;
}[];
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;
acceptedByLevel: Record<Difficulty, number>;
firstCandidatesByLevel: Record<Difficulty, number>;
firstRejectedByLevel: Record<Difficulty, number>;
acceptedCandidateRanks: Record<string, number>;
acceptedTraceHash?: string;
finalStateHash?: string;
failure?: string;
context?: object;
};
const rows: Row[] = [];
const baseFactions = FACTIONS.filter((f) => f.expansion === 'base').map(
(f) => f.id,
);
const expectedCards = baseDeck()
.map((c) => c.id)
.sort();
function invariant(g: Game) {
if (!g.advanced || g.expansions.length || g.techTokens)
throw new Error('Configuration changed outside Advanced base scope');
const undealtSetup =
g.setupStage === 'prediction' || g.setupStage === 'traitors';
if (g.setupStage && g.players.some((p) => p.hand.length > 0))
throw new Error('Treachery dealt before force setup completes');
if (
undealtSetup &&
g.players.some(
(p) => p.spice !== 0 || Object.keys(p.forces).length || p.elites,
)
)
throw new Error(
'Starting spice or forces exposed before traitor choices finish',
);
if (
g.setupStage === 'prediction' &&
g.players.some((p) => p.traitors.length || p.traitorChoices.length)
)
throw new Error('Private traitors dealt before prediction');
const heldCards = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
...(g.auction?.cards.slice(
g.auction.index + (g.currentAuctionSale?.origin === 'normal' ? 1 : 0),
) ?? []),
];
if (
new Set(heldCards.map((c) => c.id)).size !== heldCards.length ||
JSON.stringify(heldCards.map((c) => c.id).sort()) !==
JSON.stringify(expectedCards)
)
throw new Error('Physical base treachery inventory changed');
if (g.setupStage !== 'prediction') {
const traitors = [
...(g.traitorReserve ?? []),
...g.players.flatMap((p) => [...p.traitors, ...p.traitorChoices]),
];
if (
new Set(traitors).size !== traitors.length ||
JSON.stringify(traitors.slice().sort()) !==
JSON.stringify(
g.players.flatMap((p) => p.leaders.map((l) => l.id)).sort(),
)
)
throw new Error('Participating traitor inventory changed');
}
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}`);
if (p.faction === 'emperor' || p.faction === 'fremen') {
const e = p.elites;
if (!undealtSetup && !e)
throw new Error(`Missing Advanced elites: ${p.id}`);
if (e) {
const total =
e.reserves +
e.tanks +
Object.values(e.forces).reduce((a, b) => a + b, 0);
if (
total !== (p.faction === 'emperor' ? 5 : 3) ||
e.reserves > p.reserves ||
e.tanks > p.tanks ||
e.revived > 1 ||
![e.reserves, e.tanks, e.revived, ...Object.values(e.forces)].every(
(n) => Number.isSafeInteger(n) && n >= 0,
) ||
Object.entries(e.forces).some(
([key, amount]) => amount > (p.forces[key] ?? 0),
)
)
throw new Error(`Elite conservation/quota: ${p.id}`);
}
} else if (p.elites)
throw new Error(`Unexpected base elite force type: ${p.id}`);
if (
p.leaders.length !== 5 ||
new Set(p.leaders.map((l) => l.id)).size !== 5 ||
p.leaders.some((l) => !Number.isSafeInteger(l.deaths) || l.deaths < 0)
)
throw new Error(`Native leader inventory: ${p.id}`);
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,
setupStage: g.setupStage ?? null,
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,
block: number,
shift: number,
replicate: number,
direction: 1 | -1,
rosterMask: number,
): Row {
const roster = baseFactions.filter((_, i) => rosterMask & (1 << i));
const count = roster.length;
// 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) +
Math.imul(replicate, 0xc2b2ae35) +
(direction === -1 ? 0x27d4eb2f : 0)) >>>
0;
randomWord = generator(seed);
uuidWord = generator(seed ^ 0xa5a5a5a5);
const code = `S${seed.toString(36).padStart(7, '0')}`;
const seats: Seat[] = Array.from({ length: count }, (_, seat) => ({
id: `seat-${seat}`,
seat,
circle: ((block + seat) % 6) + 1,
faction: roster[direction === 1 ? seat : count - 1 - seat],
level: DIFFICULTIES[(seat + shift) % DIFFICULTIES.length],
}));
const row: Row = {
index,
seed,
block,
shift,
replicate,
direction,
rosterMask,
setupActions: 0,
setupTransitions: [],
jsonRoundTrips: 0,
rejectionSamples: [],
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,
acceptedByLevel: { Easy: 0, Medium: 0, Hard: 0, Brutal: 0 },
firstCandidatesByLevel: { Easy: 0, Medium: 0, Hard: 0, Brutal: 0 },
firstRejectedByLevel: { Easy: 0, Medium: 0, Hard: 0, Brutal: 0 },
acceptedCandidateRanks: {},
};
const gameStart = Date.now();
const trace = createHash('sha256');
let g = createGame(
code,
newPlayer(seats[0].id, seats[0].faction, seats[0].faction),
true,
);
try {
// Assign each genuine lobby seat before the next joins; this also works with all six circles occupied.
for (const [i, seat] of seats.entries()) {
if (i) joinGame(g, newPlayer(seat.id, seat.faction, seat.faction));
if (g.playerPositions![seat.id] !== seat.circle)
g = applyAction(g, seat.id, {
type: 'seatPosition',
position: seat.circle,
});
}
for (const seat of seats) g = applyAction(g, seat.id, { type: 'ready' });
for (const [i, p] of g.players.entries()) p.bot = seats[i].level;
// Offline-only initializer calls the same staged production setup; the public Advanced start gate stays closed.
const lobbyHash = JSON.stringify(g);
const initialized = initializeBaseGameForAudit(g);
if (JSON.stringify(g) !== lobbyHash)
throw new Error('Audit initializer mutated the source lobby');
g = initialized;
invariant(g);
row.setupTransitions.push(g.setupStage ?? g.status);
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)) {
const view = viewGame(g, p.id);
for (const other of view.players.filter((s) => s.id !== p.id))
if (
other.hand !== undefined ||
other.traitors !== undefined ||
other.traitorChoices !== undefined ||
other.prediction !== undefined
)
throw new Error(
'Private projection exposed another seat’s hidden setup/hand information',
);
if ('deck' in view || 'spiceDeck' in view || 'traitorReserve' in view)
throw new Error('Private deck/reserve projected');
const viewSnapshot = JSON.stringify(view);
const actions = botActions(view);
if (JSON.stringify(view) !== viewSnapshot)
throw new Error('Candidate generation mutated the player projection');
for (const [rank, action] of actions.entries()) {
if (rank === 0) row.firstCandidatesByLevel[p.bot!]++;
try {
next = applyAction(g, p.id, action);
if (g.status === 'setup') row.setupActions++;
row.acceptedByLevel[p.bot!]++;
const rankKey = String(rank + 1);
row.acceptedCandidateRanks[rankKey] =
(row.acceptedCandidateRanks[rankKey] ?? 0) + 1;
trace.update(JSON.stringify({ player: p.id, action }) + '\n');
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++;
if (row.rejectionSamples.length < 8)
row.rejectionSamples.push({
player: p.id,
level: p.bot!,
rank: rank + 1,
action,
reason,
context: context(g),
});
if (row.rejected === 1) {
const diagnostic = `${output}.first-rejection-${index}.json`;
writeFileSync(
diagnostic,
JSON.stringify(
{
index,
seed,
rosterMask,
seats,
state: g,
player: p.id,
action,
reason,
},
null,
2,
),
);
console.log(
JSON.stringify({
problem: 'rejected-candidate',
index,
diagnostic,
player: p.id,
level: p.bot,
reason,
context: context(g),
}),
);
}
if (rank === 0) row.firstRejectedByLevel[p.bot!]++;
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;
}
const previousStage = g.setupStage ?? g.status;
g = next;
row.accepted++;
const currentStage = g.setupStage ?? g.status;
if (
previousStage !== currentStage &&
(previousStage === 'prediction' ||
previousStage === 'traitors' ||
previousStage === 'forces')
)
row.setupTransitions.push(currentStage);
// Subsequent real actions continue from serialized state, not merely a comparison-only clone.
if (previousStage !== currentStage || row.accepted % 25 === 0) {
const beforeViews = g.players.map((p) =>
JSON.stringify(viewGame(g, p.id)),
);
const restored = JSON.parse(JSON.stringify(g)) as Game;
if (
JSON.stringify(g) !== JSON.stringify(restored) ||
JSON.stringify(beforeViews) !==
JSON.stringify(
restored.players.map((p) =>
JSON.stringify(viewGame(restored, p.id)),
),
)
)
throw new Error(
'JSON continuation changed state or private projections',
);
g = restored;
row.jsonRoundTrips++;
}
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);
if (row.outcome !== 'complete')
writeFileSync(
`${output}.failure-${index}.json`,
JSON.stringify({ row, state: g }, null, 2),
);
row.turn = g.turn;
row.phase = g.phase;
row.elapsedMs = Date.now() - gameStart;
row.acceptedTraceHash = trace.digest('hex');
row.finalStateHash = createHash('sha256')
.update(JSON.stringify(g))
.digest('hex');
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],
),
),
setupActions: subset.reduce((sum, r) => sum + r.setupActions, 0),
jsonRoundTrips: subset.reduce((sum, r) => sum + r.jsonRoundTrips, 0),
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,
),
acceptedActions: subset.reduce(
(sum, r) => sum + r.acceptedByLevel[level],
0,
),
firstCandidates: subset.reduce(
(sum, r) => sum + r.firstCandidatesByLevel[level],
0,
),
firstRejected: subset.reduce(
(sum, r) => sum + r.firstRejectedByLevel[level],
0,
),
rejectedCandidates: subset.reduce(
(sum, r) => sum + r.rejectedByLevel[level],
0,
),
},
];
}),
),
};
}
try {
// Interleave counts so a 20-game pilot covers one roster of each size at every shift.
const buckets = [2, 3, 4, 5, 6].map((count) =>
Array.from({ length: 64 }, (_, mask) => mask).filter(
(mask) => baseFactions.filter((_, i) => mask & (1 << i)).length === count,
),
);
const masks: number[] = [];
for (let i = 0; buckets.some((b) => i < b.length); i++)
for (const bucket of buckets)
if (bucket[i] !== undefined) masks.push(bucket[i]);
outer: for (let replicate = 0; replicate < 2; replicate++)
for (const [block, mask] of masks.entries())
for (let shift = 0; shift < 4; shift++) {
if (rows.length >= gameLimit) break outer;
const direction = replicate === 0 ? 1 : -1;
const row = play(rows.length, block, shift, replicate, direction, mask);
rows.push(row);
if (rows.length % 4 === 0 || row.outcome !== 'complete' || row.rejected)
console.log(
JSON.stringify({
progress: rows.length,
of: gameLimit,
replicate,
block,
mask,
players: row.players,
last: {
outcome: row.outcome,
turn: row.turn,
accepted: row.accepted,
rejected: row.rejected,
failure: row.failure,
context: row.context,
},
elapsedSeconds: Math.round((Date.now() - started) / 1000),
}),
);
// Preserve the first failing sample and pause this study for a real correction; never mask it as a clean batch.
if (row.outcome !== 'complete' || row.rejected) break outer;
}
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: true,
initializer:
'initializeBaseGameForAudit (shared staged production initializer; public Advanced gate remains closed)',
expansions: [],
techTokens: false,
playerCounts: [2, 3, 4, 5, 6],
rosterSubsets: 57,
completedRequestedSample: rows.length === gameLimit,
replicates: 2,
factionDirections: [1, -1],
rotatedPhysicalCircles: true,
},
rng: 'Mulberry32 engine shuffle stream plus independent seeded UUID stream; each four-shift deal block resets both',
sources,
sourceFilesChangedDuringRun: sources.combined !== currentSources.combined,
finalSources: currentSources,
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)),
]),
),
factionAppearances: Object.fromEntries(
baseFactions.map((f) => [
f,
rows.reduce(
(sum, r) => sum + r.seats.filter((s) => s.faction === f).length,
0,
),
]),
),
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.length !== gameLimit ||
rows.some((r) => r.outcome !== 'complete' || r.rejected) ||
result.sourceFilesChangedDuringRun
)
process.exitCode = 1;
} finally {
if (originalUuid)
Object.defineProperty(cryptoObject, 'randomUUID', originalUuid);
else Reflect.deleteProperty(cryptoObject, 'randomUUID');
if (originalRandom)
Object.defineProperty(cryptoObject, 'getRandomValues', originalRandom);
else Reflect.deleteProperty(cryptoObject, 'getRandomValues');
}
+499
View File
@@ -0,0 +1,499 @@
/**
* 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-stone.ts --seed 20260909 --out /tmp/dune-ai-calibration-stone.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', 20260909) >>> 0;
const gameLimit = positive('--games', 96);
const maxActions = positive('--max-actions', 6000);
if (gameLimit > 96) throw new Error('This balanced design contains 96 games.');
const output = resolve(
argument('--out', '/tmp/dune-ai-calibration-stone.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-stone.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);
let uuidWord = generator(masterSeed ^ 0xa5a5a5a5);
const cryptoObject = globalThis.crypto;
const originalUuid = Object.getOwnPropertyDescriptor(
cryptoObject,
'randomUUID',
);
Object.defineProperty(cryptoObject, 'randomUUID', {
configurable: true,
value: () => {
const hex = Array.from({ length: 4 }, () =>
uuidWord().toString(16).padStart(8, '0'),
)
.join('')
.split('');
hex[12] = '4';
hex[16] = ((parseInt(hex[16], 16) & 3) | 8).toString(16);
const id = hex.join('');
return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
},
});
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;
circle: number;
faction: FactionId;
level: Difficulty;
};
type Outcome =
| 'complete'
| 'deadlock'
| 'action-limit'
| 'exception'
| 'invariant';
type Row = {
index: number;
seed: number;
block: number;
shift: number;
replicate: number;
direction: 1 | -1;
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;
acceptedByLevel: Record<Difficulty, number>;
firstCandidatesByLevel: Record<Difficulty, number>;
firstRejectedByLevel: Record<Difficulty, number>;
acceptedCandidateRanks: Record<string, number>;
acceptedTraceHash?: string;
finalStateHash?: string;
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,
block: number,
shift: number,
replicate: number,
direction: 1 | -1,
): Row {
const count = 4;
// 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) +
Math.imul(replicate, 0xc2b2ae35) +
(direction === -1 ? 0x27d4eb2f : 0)) >>>
0;
randomWord = generator(seed);
uuidWord = generator(seed ^ 0xa5a5a5a5);
const code = `S${seed.toString(36).padStart(7, '0')}`;
const seats: Seat[] = Array.from({ length: count }, (_, seat) => ({
id: `seat-${seat}`,
seat,
circle: ((block + seat) % 6) + 1,
faction: baseFactions[(block + direction * seat + 6) % 6],
level: DIFFICULTIES[(seat + shift) % DIFFICULTIES.length],
}));
const row: Row = {
index,
seed,
block,
shift,
replicate,
direction,
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,
acceptedByLevel: { Easy: 0, Medium: 0, Hard: 0, Brutal: 0 },
firstCandidatesByLevel: { Easy: 0, Medium: 0, Hard: 0, Brutal: 0 },
firstRejectedByLevel: { Easy: 0, Medium: 0, Hard: 0, Brutal: 0 },
acceptedCandidateRanks: {},
};
const gameStart = Date.now();
const trace = createHash('sha256');
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));
// Move to the assigned physical circles through genuine lobby actions.
// Two spare circles permit cycles without overwriting another player's seat.
for (
let moves = 0;
seats.some((s) => g.playerPositions![s.id] !== s.circle);
moves++
) {
if (moves > 20) throw new Error('Circle assignment did not converge');
const occupied = new Set(Object.values(g.playerPositions!));
const direct = seats.find(
(s) => g.playerPositions![s.id] !== s.circle && !occupied.has(s.circle),
);
const seat =
direct ?? seats.find((s) => g.playerPositions![s.id] !== s.circle)!;
const position =
direct?.circle ?? [1, 2, 3, 4, 5, 6].find((n) => !occupied.has(n))!;
g = applyAction(g, seat.id, { type: 'seatPosition', position });
}
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 [rank, action] of botActions(viewGame(g, p.id)).entries()) {
if (rank === 0) row.firstCandidatesByLevel[p.bot!]++;
try {
next = applyAction(g, p.id, action);
row.acceptedByLevel[p.bot!]++;
const rankKey = String(rank + 1);
row.acceptedCandidateRanks[rankKey] =
(row.acceptedCandidateRanks[rankKey] ?? 0) + 1;
trace.update(JSON.stringify({ player: p.id, action }) + '\n');
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++;
if (rank === 0) row.firstRejectedByLevel[p.bot!]++;
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;
row.acceptedTraceHash = trace.digest('hex');
row.finalStateHash = createHash('sha256')
.update(JSON.stringify(g))
.digest('hex');
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,
),
acceptedActions: subset.reduce(
(sum, r) => sum + r.acceptedByLevel[level],
0,
),
firstCandidates: subset.reduce(
(sum, r) => sum + r.firstCandidatesByLevel[level],
0,
),
firstRejected: subset.reduce(
(sum, r) => sum + r.firstRejectedByLevel[level],
0,
),
rejectedCandidates: subset.reduce(
(sum, r) => sum + r.rejectedByLevel[level],
0,
),
},
];
}),
),
};
}
try {
outer: for (let replicate = 0; replicate < 2; replicate++)
for (const direction of [1, -1] as const)
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, block, shift, replicate, direction);
rows.push(row);
if (rows.length % 4 === 0 || row.outcome !== 'complete')
console.log(
JSON.stringify({
progress: rows.length,
of: gameLimit,
replicate,
direction,
block,
last: {
outcome: row.outcome,
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,
players: 4,
replicates: 2,
factionDirections: [1, -1],
rotatedPhysicalCircles: true,
},
rng: 'Mulberry32 engine shuffle stream plus independent seeded UUID stream; each four-shift deal block resets both',
sources,
sourceFilesChangedDuringRun: sources.combined !== currentSources.combined,
finalSources: currentSources,
elapsedMs: Date.now() - started,
aggregate: statistics(rows),
byPlayerCount: Object.fromEntries(
[4].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') ||
result.sourceFilesChangedDuringRun
)
process.exitCode = 1;
} finally {
if (originalUuid)
Object.defineProperty(cryptoObject, 'randomUUID', originalUuid);
else Reflect.deleteProperty(cryptoObject, 'randomUUID');
if (originalRandom)
Object.defineProperty(cryptoObject, 'getRandomValues', originalRandom);
else Reflect.deleteProperty(cryptoObject, 'getRandomValues');
}
+396
View File
@@ -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');
}
+22
View File
@@ -0,0 +1,22 @@
from pathlib import Path
from urllib.request import Request, urlopen
sources = {
'base': 'https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf',
'ix-tleilaxu': 'https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf',
'choam-richese': 'https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf',
'ecaz-moritani': 'https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf',
'faq': 'https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf',
}
root = Path('/tmp/dune-rules')
root.mkdir(exist_ok=True)
for name, url in sources.items():
try:
with urlopen(Request(url, headers={'User-Agent': 'Mozilla/5.0'}), timeout=30) as response:
data = response.read()
if not data.startswith(b'%PDF'):
raise ValueError('Response is not a PDF')
(root / f'{name}.pdf').write_bytes(data)
print(name, len(data), flush=True)
except Exception as error:
print(name, str(error), flush=True)
+29
View File
@@ -0,0 +1,29 @@
"""Convert the MIT-licensed Truthsayer geometry; see THIRD_PARTY_NOTICES.md."""
import json, math
from pathlib import Path
src=json.load(open('/tmp/dune-config.json'))['generated']
rocks={'false_wall_south','false_wall_east','false_wall_west','shield_wall','pasty_mesa','sihaya_ridge','hole_in_the_rock','rim_wall_west','harg_pass'}
strongholds={'arrakeen','carthag','tueks_sietch','sietch_tabr','habbanya_ridge_sietch'}
rename={'habbanya_ridge_sietch':'Habbanya Sietch','tueks_sietch':"Tuek’s Sietch"}
result=[]
for id,sectors in src['location_regions'].items():
if id=='arrakis':continue
pts=src['territories']['polygons'][id]
result.append({'id':id,'name':rename.get(id,id.replace('_',' ').title()),'sectors':[0] if id=='polar_sink' else sorted(int(x[1:]) for x in sectors),'type':'polar' if id=='polar_sink' else 'stronghold' if id in strongholds else 'rock' if id in rocks else 'sand','points':pts,'center':[round(sum(p[i] for p in pts)/len(pts),1) for i in range(2)],'neighbors':sorted(set(b if a==id else a for a,b in src['neighbors'] if (a==id or b==id) and a!='arrakis' and b!='arrakis'))})
Path('game/board-data.json').write_text(json.dumps(result,separators=(',',':'))+'\n')
graph={}
for id,entries in src['neighborhoods'].items():
if id=='arrakis':continue
if id=='polar_sink':
graph['polar_sink:0']=sorted(set(f'{dest}:{int(sec[1:])}' for entry in entries.values() for dest,secs in entry.items() if dest not in ('arrakis','polar_sink') for sec in secs))
else:
for sec,entry in entries.items():
key=f'{id}:{int(sec[1:])}'
graph[key]=sorted(set('polar_sink:0' if dest=='polar_sink' else f'{dest}:{int(s[1:])}' for dest,secs in entry.items() if dest!='arrakis' for s in secs if dest!=id or s!=sec))
# Enforce symmetric board edges. The source approximates connectivity geometrically;
# printed-board verification remains required before claiming full compliance.
for a,edges in list(graph.items()):
for b in edges:
if b in graph and a not in graph[b]:graph[b].append(a)
Path('game/board-graph.json').write_text(json.dumps(graph,separators=(',',':'))+'\n')
Path('THIRD_PARTY_NOTICES.md').write_text('Board geometry and connectivity adapted from https://github.com/marekyggdrasil/truthsayer (main). No raster artwork is included.\n\n'+Path('/tmp/truthsayer-license').read_text())
+1
View File
@@ -0,0 +1 @@
{"name":"dune-arrakis-local","compatibility_date":"2026-05-15","d1_databases":[{"binding":"DB","database_name":"site-creator-d1","database_id":"00000000-0000-4000-8000-000000000000","migrations_dir":"../drizzle"}]}