Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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"}]}
|
||||
Reference in new issue
Block a user