Files
Dune/tests/richese-allied-no-field-recovery.test.ts
T
2026-09-08 07:37:52 +10:00

377 lines
13 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { webcrypto } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import * as engine from '../game/engine';
import * as bots from '../game/bots';
import { baseDeck } from '../game/cards';
import {
createRicheseNoField,
deployRicheseNoField,
} from '../game/richese-no-field';
import type * as Rooms from '../db/rooms';
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
function unitStore(runBots = bots.runBots) {
const sqlite = new DatabaseSync(':memory:');
const hooks: { beforeWrite?: () => Promise<void> } = {};
const writes: { expected: number; changes: number }[] = [];
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((f) => f.endsWith('.sql'))
.sort())
sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
class Statement {
values: (string | number | null)[] = [];
constructor(readonly sql: string) {}
bind(...values: (string | number | null)[]) {
this.values = values;
return this;
}
async first() {
return sqlite.prepare(this.sql).get(...this.values) ?? null;
}
async run() {
const continuation = this.sql.startsWith('UPDATE rooms SET state');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
);
if (continuation)
writes.push({ expected: Number(this.values[3]), changes });
return { meta: { changes } };
}
}
const database = {
prepare: (sql: string) => new Statement(sql),
batch: async (statements: Statement[]) => {
sqlite.exec('BEGIN');
try {
const results = [];
for (const statement of statements) results.push(await statement.run());
sqlite.exec('COMMIT');
return results;
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
function loadRooms() {
const exports = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports,
crypto: webcrypto,
TextEncoder,
structuredClone,
// Keep persisted JSON values in the test realm for strict structural assertions.
JSON,
require: (name: string) => {
if (name === 'cloudflare:workers') return { env: { DB: database } };
if (name === '@/game/engine') return engine;
if (name === '@/game/bots') return { ...bots, runBots };
throw new Error('Unexpected module ' + name);
},
},
);
return exports as typeof Rooms;
}
return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes };
}
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture(guild = false) {
const store = unitStore();
const created = await store.rooms.createRoom('Richese', 'guild', false, []);
const code = created.view.code;
const joined = await store.rooms.joinRoom(code, 'Ally', 'atreides');
const third = await store.rooms.joinRoom(code, 'Other', 'emperor');
const owner = await store.rooms.authenticate(code, created.token);
const ally = await store.rooms.authenticate(code, joined.token!);
const other = await store.rooms.authenticate(code, third.token!);
const stored = await store.rooms.readRoom(code);
const initial = engine.createGame(
code,
engine.newPlayer(owner.playerId, 'Richese', 'richese'),
guild,
['choam'],
);
initial.players.push(
engine.newPlayer(ally.playerId, 'Ally', 'atreides'),
engine.newPlayer(other.playerId, 'Other', guild ? 'guild' : 'emperor'),
);
initial.version = stored.version;
initial.status = 'playing';
initial.phase = 5;
initial.turn = 2;
initial.active = ally.playerId;
initial.order = initial.players.map((p) => p.id);
initial.storm = 18;
initial.deck = baseDeck();
initial.discard = [];
initial.players.forEach((p) => {
p.spice = 10;
p.hand = [];
p.forces = {};
p.reserves = 20;
});
initial.players[0].ally = ally.playerId;
initial.players[1].ally = owner.playerId;
initial.players[0].noField = createRicheseNoField([
'concealed-zero',
'concealed-three',
'concealed-five',
]);
initial.players[0].noFieldEvent = 'token-generation';
initial.players[0].noField = deployRicheseNoField(
initial.players[0].noField,
{
tokenId: 'concealed-three',
controller: owner.playerId,
location: { territory: 'imperial_basin', sector: 10 },
},
);
const k = initial.deck.findIndex((c) => c.effect === 'karama');
initial.players[2].hand.push(...initial.deck.splice(k, 1));
function save(g: engine.Game) {
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
}
save(initial);
store.writes.length = 0;
async function action(
auth: Rooms.SeatAuth,
value: engine.Action,
rooms = store.rooms,
) {
const state = await rooms.readRoom(code);
return rooms.act(code, auth, state.version, value, clock);
}
const offer: engine.Action = {
type: 'offerRicheseNoField',
token: 'concealed-five',
event: 'token-generation',
territory: 'arrakeen',
sector: 10,
payer: owner.playerId,
};
return { ...store, owner, ally, other, code, initial, save, action, offer };
}
function barrier() {
let release!: () => void;
const ready = new Promise<void>((resolve) => {
release = resolve;
});
let n = 0;
return async () => {
if (++n === 2) release();
await ready;
};
}
function oneCommit(
results: PromiseSettledResult<unknown>[],
writes: { changes: number }[],
) {
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.equal(results.filter((r) => r.status === 'rejected').length, 1);
assert.deepEqual(
writes.map((w) => w.changes).sort((a, b) => a - b),
[0, 1],
);
}
function conservation(g: engine.Game) {
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
}
void test('restarted authenticated consent is private to the two allies and concurrent accept commits only one pending shipment', async () => {
const f = await fixture();
try {
await f.action(f.owner, f.offer);
const offered = await f.rooms.readRoom(f.code);
const restarted = f.restart();
const recipient = await restarted.readSeatView(f.code, f.ally);
const unrelated = await restarted.readSeatView(f.code, f.other);
assert.equal(recipient.richeseNoField!.allyOffer!.value, 5);
assert.equal(recipient.richeseNoField!.allyOffer!.amount, 5);
assert.equal(recipient.richeseNoField!.private, null);
assert.equal(unrelated.richeseNoField!.allyOffer, null);
for (const token of ['concealed-zero', 'concealed-three', 'concealed-five'])
assert.equal(JSON.stringify(unrelated).includes(token), false);
const choice = {
type: 'decision',
event: offered.richeseAllyOffer!.event,
accept: true,
elite: 0,
};
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const results = await Promise.allSettled([
restarted.act(f.code, f.ally, offered.version, choice, clock),
f.rooms.act(f.code, f.ally, offered.version, choice, clock),
]);
f.hooks.beforeWrite = undefined;
oneCommit(results, f.writes);
const pending = await restarted.readRoom(f.code);
assert.equal(pending.version, offered.version + 1);
assert.equal(pending.response?.kind, 'richeseNoField');
assert.equal(
pending.players[0].noField!.deployed!.tokenId,
'concealed-three',
);
assert.equal(pending.players[0].spice, 10);
assert.equal(pending.players[1].reserves, 20);
await assert.rejects(
restarted.act(f.code, f.ally, pending.version, choice, clock),
);
assert.deepEqual(await restarted.readRoom(f.code), pending);
await restarted.continueRoomAutomatic(f.code, clock);
assert.deepEqual(await restarted.readRoom(f.code), pending);
conservation(pending);
} finally {
f.sqlite.close();
}
});
void test('duplicate cancellation after restart returns allied shipment custody once and preserves ordinary shipment recovery', async () => {
const f = await fixture();
try {
await f.action(f.owner, f.offer);
let state = await f.rooms.readRoom(f.code);
await f.action(f.ally, {
type: 'decision',
event: state.richeseAllyOffer!.event,
accept: true,
elite: 0,
});
state = await f.rooms.readRoom(f.code);
const resumed = f.restart(),
cancel = {
type: 'card',
mode: 'cancel',
card: state.players[2].hand[0].id,
};
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const results = await Promise.allSettled([
resumed.act(f.code, f.other, state.version, cancel, clock),
f.rooms.act(f.code, f.other, state.version, cancel, clock),
]);
f.hooks.beforeWrite = undefined;
oneCommit(results, f.writes);
const stopped = await resumed.readRoom(f.code);
assert.equal(
stopped.players[0].noField!.deployed!.tokenId,
'concealed-three',
);
assert.equal(stopped.players[0].spice, 10);
assert.equal(stopped.players[1].shipped, false);
assert.equal(stopped.pendingShipment, null);
assert.equal(stopped.discard.filter((c) => c.id === cancel.card).length, 1);
await f.action(
f.ally,
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 },
resumed,
);
const recovered = await resumed.readRoom(f.code);
assert.equal(recovered.players[1].forces['arrakeen:10'], 2);
assert.equal(recovered.players[1].spice, 8);
assert.equal(recovered.players[0].reserves, 20);
conservation(recovered);
} finally {
f.sqlite.close();
}
});
void test('stale private consent can decline after reload and stale Guild commitment aborts under CAS without reveal or payment', async () => {
const f = await fixture(true);
try {
await f.action(f.owner, f.offer);
let offered = await f.rooms.readRoom(f.code);
const event = offered.richeseAllyOffer!.event;
offered.players[0].noFieldEvent = 'obsolete-consent';
f.save(offered);
let resumed = f.restart();
assert.match(
(await resumed.readSeatView(f.code, f.ally)).richeseNoField!.allyOffer!
.blocked!,
/stale/,
);
await f.action(f.ally, { type: 'decision', event, decline: true }, resumed);
const declined = await resumed.readRoom(f.code);
assert.equal(declined.richeseAllyOffer, null);
assert.equal(declined.players[0].spice, 10);
assert.equal(
declined.players[0].noField!.deployed!.tokenId,
'concealed-three',
);
await f.action(f.owner, { ...f.offer, event: 'obsolete-consent' }, resumed);
offered = await resumed.readRoom(f.code);
await f.action(
f.ally,
{
type: 'decision',
event: offered.richeseAllyOffer!.event,
accept: true,
elite: 0,
},
resumed,
);
await f.action(f.other, { type: 'passResponse' }, resumed);
const pending = await resumed.readRoom(f.code);
const foreign = await resumed.readSeatView(f.code, f.other);
assert.equal(
foreign.decision?.kind === 'guildShipment'
? foreign.decision.amount
: null,
1,
);
assert.equal(foreign.richeseNoField!.allyOffer, null);
pending.players[1].reserves = 2;
pending.players[1].tanks = 18;
f.save(pending);
resumed = f.restart();
const allow = { type: 'decision', allow: true };
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const results = await Promise.allSettled([
resumed.act(f.code, f.other, pending.version, allow, clock),
f.rooms.act(f.code, f.other, pending.version, allow, clock),
]);
f.hooks.beforeWrite = undefined;
oneCommit(results, f.writes);
const aborted = await resumed.readRoom(f.code);
assert.equal(aborted.version, pending.version + 1);
assert.equal(aborted.pendingShipment, null);
assert.equal(aborted.players[1].shipped, false);
assert.equal(aborted.players[0].spice, 10);
assert.equal(
aborted.players[0].noField!.deployed!.tokenId,
'concealed-three',
);
assert.deepEqual(aborted.players[1].forces, {});
conservation(aborted);
await resumed.continueRoomAutomatic(f.code, clock);
assert.deepEqual(await resumed.readRoom(f.code), aborted);
} finally {
f.sqlite.close();
}
});