Connect genuine Ix expansion setup to playable prototypes
Add an offline Ix initializer through the real setup pipeline, supporting Basic and Advanced play, base factions, Ixians and Tleilaxu with the full 47-card deck and 22-card spice deck. Preserve all normal start and publication gates. Add a reusable local prototype starter: back up the named fresh ready lobby, initialize it once by exact version/state comparison and preserve seat/recovery tables and other rooms. Update the existing checklist with a separate prototype stage and document functional scope, source interpretations and later refinement. Six new tests cover all-profile private setup, exact typed starting forces, Face Dancer/treachery custody, JSON continuation, two fixed-seed complete games and local SQLite write/rejection boundaries. Independent review caught a missing JSON version update, now fixed and covered; focused checking fixed one test typing error before the stable broad check. No new rule interpretation was introduced. Validation: types, lint and 4,211 offline tests passed (123.237s); production build passed (6.543s); all 40 HTTP tests passed (23.578s). Source-bound report: 20260913-ix-prototype-final/report.json, fingerprint 3b8f295593bcb314bb72308d81739006b11d949d1e5906dbf6ce0704b15ebd72. Browser W5LE6VFB: human Ixian + Medium Tleilaxu + Hard Atreides from genuine setup, private starting-card refresh, Weather Control, mobile stronghold placement, then owned Brutal autopilot. Atreides won on turn 1; this short complete journey is not proof of every card or faction interaction. Finished private hand restored at phone width; expanded Poison Tooth was readable. Fixed-seed offline games 2026091301 (Basic) and 2026091302 (Advanced) both completed with both Ix factions. All 64 opening room versions/hashes are unchanged. One browser room and 30 HTTP rooms were added, totaling 95. Reused the running server; no restart or automation. Aggregate goal counters: 59,524,858 to 59,976,567 at completed verification, 451,709 tokens before final Git overhead. Two bounded workhorse helpers handled inventory/tests and independent review; one stable full check/build/HTTP run. Full game scope remains active and unfinished; future work prioritizes missing functional prototypes before exhaustive refinement and final acceptance.
This commit is contained in:
1 parent
194471e906
commit
5a76ae0410
12 files changed
+736
-9
No files matched your search
@@ -0,0 +1,367 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
||||
import { spiceDeck, treacheryDeck } from '../game/cards';
|
||||
import { MOBILE_LOCATION } from '../game/board';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeIxGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
|
||||
const ROSTER: [string, FactionId][] = [
|
||||
['i', 'ixians'],
|
||||
['t', 'tleilaxu'],
|
||||
['a', 'atreides'],
|
||||
['h', 'harkonnen'],
|
||||
];
|
||||
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
function withEngineSeed<T>(seed: number, fn: () => T): T {
|
||||
const cryptoObject = globalThis.crypto;
|
||||
const originalRandom = Object.getOwnPropertyDescriptor(
|
||||
cryptoObject,
|
||||
'getRandomValues',
|
||||
);
|
||||
const originalUuid = Object.getOwnPropertyDescriptor(
|
||||
cryptoObject,
|
||||
'randomUUID',
|
||||
);
|
||||
const randomWord = generator(seed);
|
||||
const uuidWord = generator(seed ^ 0xa5a5a5a5);
|
||||
Object.defineProperty(cryptoObject, 'getRandomValues', {
|
||||
configurable: true,
|
||||
value: <T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (!(array instanceof Uint32Array))
|
||||
throw new Error(
|
||||
`Ix prototype seed ${seed} supports only the engine Uint32Array RNG contract.`,
|
||||
);
|
||||
for (let i = 0; i < array.length; i++) array[i] = randomWord();
|
||||
return array;
|
||||
},
|
||||
});
|
||||
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)}`;
|
||||
},
|
||||
});
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (originalRandom)
|
||||
Object.defineProperty(cryptoObject, 'getRandomValues', originalRandom);
|
||||
else Reflect.deleteProperty(cryptoObject, 'getRandomValues');
|
||||
if (originalUuid)
|
||||
Object.defineProperty(cryptoObject, 'randomUUID', originalUuid);
|
||||
else Reflect.deleteProperty(cryptoObject, 'randomUUID');
|
||||
}
|
||||
}
|
||||
|
||||
function lobby(advanced = false, roster = ROSTER): Game {
|
||||
const [first, ...rest] = roster;
|
||||
let g = createGame(
|
||||
'IXPROTOTYPE',
|
||||
newPlayer(first[0], first[0], first[1]),
|
||||
advanced,
|
||||
['ix'],
|
||||
);
|
||||
for (const [id, faction] of rest) joinGame(g, newPlayer(id, id, faction));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
return g;
|
||||
}
|
||||
|
||||
function candidates(g: Game, id: string, difficulty: Difficulty) {
|
||||
const view = viewGame(g, id);
|
||||
view.players.find((p) => p.id === id)!.bot = difficulty;
|
||||
const before = structuredClone(view);
|
||||
const actions = botActions(view);
|
||||
assert.deepEqual(view, before);
|
||||
for (const action of actions)
|
||||
assert.doesNotThrow(
|
||||
() => applyAction(g, id, action),
|
||||
`${difficulty}/${g.setupStage}/${g.decision?.kind}/${id}: ${JSON.stringify(action)}`,
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
function reachIxChoice(state: Game, difficulty: Difficulty): Game {
|
||||
let g = state;
|
||||
for (let step = 0; g.status === 'setup' && step < 24; step++) {
|
||||
if (g.decision?.kind === 'ixSetup') return g;
|
||||
let progressed = false;
|
||||
for (const p of g.players) {
|
||||
const actions = candidates(g, p.id, difficulty);
|
||||
if (!actions.length) continue;
|
||||
g = applyAction(g, p.id, actions[0]);
|
||||
progressed = true;
|
||||
break;
|
||||
}
|
||||
assert.ok(progressed, `Ix setup stalled at ${g.setupStage}.`);
|
||||
}
|
||||
assert.fail('Ix setup did not reach its private starting-card choice.');
|
||||
}
|
||||
|
||||
function assertTreacheryInventory(g: Game) {
|
||||
const cards = [
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
...(g.ixSetupCards ?? []),
|
||||
...(g.ixAuction?.cards ?? []),
|
||||
...(g.auction?.cards.slice(g.auction.index) ?? []),
|
||||
];
|
||||
const expected = treacheryDeck(['ix'])
|
||||
.map((card) => card.id)
|
||||
.sort();
|
||||
assert.equal(cards.length, 47);
|
||||
assert.equal(new Set(cards.map((card) => card.id)).size, 47);
|
||||
assert.deepEqual(cards.map((card) => card.id).sort(), expected);
|
||||
}
|
||||
|
||||
function assertSetupResult(g: Game) {
|
||||
assert.equal(g.status, 'playing');
|
||||
assert.equal(g.setupStage ?? null, null);
|
||||
assert.equal(g.ixSetupCards, null);
|
||||
assert.ok(g.phaseOpening);
|
||||
assert.deepEqual(g.expansions, ['ix']);
|
||||
assert.equal(g.players.find((p) => p.id === 'i')!.hand.length, 1);
|
||||
assert.equal(g.players.find((p) => p.id === 't')!.hand.length, 1);
|
||||
assert.equal(g.players.find((p) => p.id === 'a')!.hand.length, 1);
|
||||
assert.equal(g.players.find((p) => p.id === 'h')!.hand.length, 2);
|
||||
const ixians = g.players.find((p) => p.id === 'i')!;
|
||||
assert.equal(ixians.spice, 10);
|
||||
assert.equal(ixians.reserves, 14);
|
||||
assert.deepEqual(ixians.forces, { [MOBILE_LOCATION]: 6 });
|
||||
assert.equal(ixians.elites?.reserves, 4);
|
||||
assert.deepEqual(ixians.elites!.forces, { [MOBILE_LOCATION]: 3 });
|
||||
assert.equal(g.mobileStronghold?.location, null);
|
||||
const tleilaxu = g.players.find((p) => p.id === 't')!;
|
||||
assert.equal(tleilaxu.spice, 5);
|
||||
assert.equal(tleilaxu.reserves, 20);
|
||||
assert.deepEqual(tleilaxu.forces, {});
|
||||
assert.equal(tleilaxu.traitors.length, 0);
|
||||
assert.equal(tleilaxu.faceDancers?.length, 3);
|
||||
assert.equal(
|
||||
new Set(tleilaxu.faceDancers?.map((card) => card.leader)).size,
|
||||
3,
|
||||
);
|
||||
for (const owner of g.players) {
|
||||
const own = viewGame(g, owner.id).players.find((p) => p.id === owner.id)!;
|
||||
assert.deepEqual(own.hand, owner.hand);
|
||||
assert.deepEqual(own.traitors, owner.traitors);
|
||||
if (owner.id === 't') assert.deepEqual(own.faceDancers, owner.faceDancers);
|
||||
for (const observer of g.players.filter((p) => p.id !== owner.id)) {
|
||||
const hidden = viewGame(g, observer.id).players.find(
|
||||
(p) => p.id === owner.id,
|
||||
)!;
|
||||
assert.equal(hidden.hand, undefined);
|
||||
assert.equal(hidden.traitors, undefined);
|
||||
assert.equal(hidden.faceDancers, undefined);
|
||||
}
|
||||
}
|
||||
const traitors = [
|
||||
...(g.traitorReserve ?? []),
|
||||
...g.players.flatMap((p) => p.traitors),
|
||||
...(tleilaxu.faceDancers ?? []).map((card) => card.leader),
|
||||
];
|
||||
assert.equal(traitors.length, 21);
|
||||
assert.equal(new Set(traitors).size, 21);
|
||||
assertTreacheryInventory(g);
|
||||
}
|
||||
|
||||
function rejectInitializer(g: Game, pattern?: RegExp) {
|
||||
const before = structuredClone(g);
|
||||
if (pattern) assert.throws(() => initializeIxGameForAudit(g), pattern);
|
||||
else assert.throws(() => initializeIxGameForAudit(g));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
function finishGame(initial: Game, seed: number): Game {
|
||||
let g = initial;
|
||||
for (let batch = 0; batch < 240 && g.status !== 'finished'; batch++) {
|
||||
g = runBots(reload(g));
|
||||
assert.ok(
|
||||
g.status === 'finished' || g.botsPending,
|
||||
`Ix prototype seed ${seed} stalled at turn ${g.turn}, phase ${g.phase}.`,
|
||||
);
|
||||
}
|
||||
assert.equal(g.status, 'finished', `Ix prototype seed ${seed}.`);
|
||||
assert.ok(g.winner.length > 0, `Ix prototype seed ${seed} has no winner.`);
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('all AI profiles complete genuine private Ix setup through JSON continuation', () => {
|
||||
for (const difficulty of DIFFICULTIES)
|
||||
for (const advanced of [false, true]) {
|
||||
const initial = lobby(advanced);
|
||||
const before = structuredClone(initial);
|
||||
let g = initializeIxGameForAudit(initial);
|
||||
assert.deepEqual(initial, before);
|
||||
assert.equal(g.status, 'setup');
|
||||
assert.equal(g.deck.length, 47);
|
||||
assert.equal(g.spiceDeck.length, 22);
|
||||
assert.equal(g.spiceDeck.filter((card) => 'sandtrout' in card).length, 1);
|
||||
assert.equal(g.spiceDeck.length, spiceDeck(true).length);
|
||||
assert.ok(g.players.every((p) => p.hand.length === 0));
|
||||
|
||||
g = reachIxChoice(g, difficulty);
|
||||
assert.equal(g.ixSetupCards?.length, ROSTER.length);
|
||||
const ids = g.ixSetupCards!.map((card) => card.id);
|
||||
assert.deepEqual(viewGame(g, 'i').ixTechnology?.setup, g.ixSetupCards);
|
||||
for (const observer of ['t', 'a', 'h']) {
|
||||
const view = viewGame(g, observer);
|
||||
assert.equal(view.ixTechnology, null);
|
||||
const serialized = JSON.stringify(view);
|
||||
for (const id of ids) assert.equal(serialized.includes(id), false);
|
||||
}
|
||||
|
||||
const rejected = structuredClone(g);
|
||||
assert.throws(() =>
|
||||
applyAction(g, 't', { type: 'decision', card: ids[0] }),
|
||||
);
|
||||
assert.deepEqual(g, rejected);
|
||||
assert.throws(() =>
|
||||
applyAction(g, 'i', { type: 'decision', card: 'missing-card' }),
|
||||
);
|
||||
assert.deepEqual(g, rejected);
|
||||
|
||||
const restored = reload(g);
|
||||
const choice = candidates(restored, 'i', difficulty)[0];
|
||||
assert.equal(choice.type, 'decision');
|
||||
g = applyAction(restored, 'i', choice);
|
||||
assertSetupResult(g);
|
||||
}
|
||||
});
|
||||
|
||||
for (const [advanced, seed] of [
|
||||
[false, 2026091301],
|
||||
[true, 2026091302],
|
||||
] as const)
|
||||
void test(`genuine ${advanced ? 'Advanced' : 'Basic'} Ix prototype seed ${seed} completes with mixed AI and conserved pieces`, () =>
|
||||
withEngineSeed(seed, () => {
|
||||
let g = lobby(advanced);
|
||||
g.players.forEach((p, index) => {
|
||||
p.bot = DIFFICULTIES[index];
|
||||
});
|
||||
g = initializeIxGameForAudit(g);
|
||||
assert.equal(g.status, 'setup');
|
||||
assert.equal(g.deck.length, 47);
|
||||
assert.equal(g.spiceDeck.length, 22);
|
||||
g = finishGame(g, seed);
|
||||
assertTreacheryInventory(g);
|
||||
for (const p of g.players)
|
||||
assert.equal(
|
||||
p.reserves +
|
||||
p.tanks +
|
||||
Object.values(p.forces).reduce((sum, count) => sum + count, 0),
|
||||
20,
|
||||
`${p.id} in seed ${seed}`,
|
||||
);
|
||||
const spiceCards = [...g.spiceDeck, ...g.spiceDiscard.flat()].filter(
|
||||
(card) => !('worm' in card && card.thumper),
|
||||
);
|
||||
assert.equal(
|
||||
spiceCards.length + Number(g.sandtrout),
|
||||
22,
|
||||
`Spice deck in seed ${seed}`,
|
||||
);
|
||||
assert.equal(
|
||||
spiceCards.filter((card) => 'sandtrout' in card).length +
|
||||
Number(g.sandtrout),
|
||||
1,
|
||||
`Sandtrout in seed ${seed}`,
|
||||
);
|
||||
}));
|
||||
|
||||
void test('the Ix prototype keeps public gates and rejects unsupported or nonempty lobbies without mutation', () => {
|
||||
const publicLobby = lobby(false, [
|
||||
['a', 'atreides'],
|
||||
['h', 'harkonnen'],
|
||||
]);
|
||||
const publicBefore = structuredClone(publicLobby);
|
||||
assert.throws(
|
||||
() => applyAction(publicLobby, publicLobby.host, { type: 'start' }),
|
||||
/Expansion rules are still being implemented/,
|
||||
);
|
||||
assert.deepEqual(publicLobby, publicBefore);
|
||||
|
||||
const absent = createGame('NOIXPROTO', newPlayer('a', 'a', 'atreides'));
|
||||
absent.players.push(newPlayer('h', 'h', 'harkonnen'));
|
||||
absent.players.forEach((p) => (p.ready = true));
|
||||
rejectInitializer(absent, /requires exactly/);
|
||||
|
||||
const combined = createGame(
|
||||
'COMBINEDPROTO',
|
||||
newPlayer('a', 'a', 'atreides'),
|
||||
false,
|
||||
['ix', 'choam'],
|
||||
);
|
||||
combined.players.push(newPlayer('h', 'h', 'harkonnen'));
|
||||
combined.players.forEach((p) => (p.ready = true));
|
||||
rejectInitializer(combined, /requires exactly/);
|
||||
|
||||
const unsupported = createGame(
|
||||
'FACTIONPROTO',
|
||||
newPlayer('c', 'c', 'choam'),
|
||||
false,
|
||||
['ix'],
|
||||
);
|
||||
unsupported.players.push(newPlayer('a', 'a', 'atreides'));
|
||||
unsupported.players.forEach((p) => (p.ready = true));
|
||||
rejectInitializer(unsupported, /base, Ixian and Tleilaxu/);
|
||||
|
||||
const moduleFactories: Array<[string, (g: Game) => Game]> = [
|
||||
[
|
||||
'tech',
|
||||
(g) => applyAction(g, g.host, { type: 'techTokens', enabled: true }),
|
||||
],
|
||||
[
|
||||
'homeworld',
|
||||
(g) => applyAction(g, g.host, { type: 'homeworlds', enabled: true }),
|
||||
],
|
||||
[
|
||||
'stronghold',
|
||||
(g) => applyAction(g, g.host, { type: 'strongholdCards', enabled: true }),
|
||||
],
|
||||
['nexus', (g) => ({ ...g, nexusCards: { cards: null, phase: null } })],
|
||||
];
|
||||
for (const [name, enable] of moduleFactories) {
|
||||
let g = enable(lobby(name === 'stronghold'));
|
||||
for (const p of g.players)
|
||||
if (!p.ready) g = applyAction(g, p.id, { type: 'ready' });
|
||||
rejectInitializer(g, /without optional modules/);
|
||||
}
|
||||
|
||||
const occupied = lobby();
|
||||
occupied.deck.push(treacheryDeck(['ix'])[0]);
|
||||
rejectInitializer(occupied, /cannot redeal/);
|
||||
|
||||
const initialized = initializeIxGameForAudit(lobby());
|
||||
rejectInitializer(initialized, /fresh lobby/);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createGame, joinGame, newPlayer } from '../game/engine';
|
||||
import { startIxPrototypeRoom } from '../tools/prototype-room';
|
||||
|
||||
function fixture(t: test.TestContext) {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
t.after(() => db.close());
|
||||
db.exec(
|
||||
'CREATE TABLE rooms(code TEXT PRIMARY KEY, state TEXT, version INTEGER, updated_at INTEGER); CREATE TABLE seats(room_code TEXT, player_id TEXT, token_hash TEXT);',
|
||||
);
|
||||
const game = createGame(
|
||||
'PROTOTYP',
|
||||
newPlayer('i', 'Ix player', 'ixians'),
|
||||
true,
|
||||
);
|
||||
game.expansions = ['ix'];
|
||||
joinGame(game, newPlayer('t', 'Tleilaxu player', 'tleilaxu'));
|
||||
game.players.forEach((player) => {
|
||||
player.ready = true;
|
||||
});
|
||||
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(
|
||||
game.code,
|
||||
JSON.stringify(game),
|
||||
7,
|
||||
100,
|
||||
);
|
||||
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(
|
||||
'KEEPME00',
|
||||
'{"unchanged":true}',
|
||||
3,
|
||||
50,
|
||||
);
|
||||
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(
|
||||
game.code,
|
||||
'i',
|
||||
'unchanged-private-session',
|
||||
);
|
||||
return { db, game };
|
||||
}
|
||||
|
||||
void test('local prototype starts the named ready lobby once, retaining every seat and other room', (t) => {
|
||||
const { db } = fixture(t);
|
||||
const seats = db.prepare('SELECT * FROM seats').all();
|
||||
const other = db
|
||||
.prepare('SELECT * FROM rooms WHERE code = ?')
|
||||
.get('KEEPME00');
|
||||
const result = startIxPrototypeRoom(db, 'PROTOTYP', 7);
|
||||
assert.equal(result.version, 8);
|
||||
assert.equal(result.status, 'setup');
|
||||
const saved = db
|
||||
.prepare('SELECT state, version FROM rooms WHERE code = ?')
|
||||
.get('PROTOTYP')!;
|
||||
assert.equal(JSON.parse(saved.state as string).version, saved.version);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
|
||||
assert.deepEqual(
|
||||
db.prepare('SELECT * FROM rooms WHERE code = ?').get('KEEPME00'),
|
||||
other,
|
||||
);
|
||||
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
assert.throws(() => startIxPrototypeRoom(db, 'PROTOTYP', 7), /changed/);
|
||||
assert.throws(() => startIxPrototypeRoom(db, 'PROTOTYP', 8), /fresh lobby/);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
|
||||
});
|
||||
|
||||
void test('unready, wrong identity and incompatible lobbies fail without writing any room or seat', (t) => {
|
||||
const { db, game } = fixture(t);
|
||||
for (const mutate of [
|
||||
() => {
|
||||
game.players[0].ready = false;
|
||||
},
|
||||
() => {
|
||||
game.players[0].ready = true;
|
||||
game.code = 'NOTMATCH';
|
||||
},
|
||||
() => {
|
||||
game.code = 'PROTOTYP';
|
||||
game.expansions = [];
|
||||
},
|
||||
]) {
|
||||
mutate();
|
||||
db.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(
|
||||
JSON.stringify(game),
|
||||
'PROTOTYP',
|
||||
);
|
||||
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
assert.throws(() => startIxPrototypeRoom(db, 'PROTOTYP', 7));
|
||||
assert.deepEqual(
|
||||
db.prepare('SELECT * FROM rooms ORDER BY code').all(),
|
||||
before,
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare('SELECT count(*) AS count FROM seats').get()!.count,
|
||||
1,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user