282 lines
14 KiB
TypeScript
282 lines
14 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import type * as Rooms from '../db/rooms';
|
|
import type { RoomsClock, SeatAuth } from '../db/rooms';
|
|
import { viewGame, type Action } from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
|
|
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
|
import { unitStore } from './fixture-nexus-room-store';
|
|
|
|
const clock: RoomsClock = { now: () => 45000, sleep: async () => {} };
|
|
|
|
async function room(t: test.TestContext, ecazFirst: boolean) {
|
|
const store = unitStore();
|
|
t.after(() => store.sqlite.close());
|
|
const created = await store.rooms.createRoom('Ecaz', 'ecaz', false, ['ecaz']);
|
|
const code = created.view.code;
|
|
const moritani = await store.rooms.joinRoom(code, 'Moritani', 'moritani');
|
|
const entrant = await store.rooms.joinRoom(code, 'Entrant', 'harkonnen');
|
|
const tokens = [created.token, moritani.token!, entrant.token!];
|
|
const auth = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token)));
|
|
const [ec, m, incoming] = auth.map((seat) => seat.playerId);
|
|
const g = await store.rooms.readRoom(code);
|
|
Object.assign(g, {
|
|
status: 'playing', phase: 5, turn: 3, storm: 18, active: incoming,
|
|
order: ecazFirst ? [ec, m, incoming] : [m, ec, incoming], deck: baseDeck(),
|
|
});
|
|
g.movementRemaining = [...g.order];
|
|
for (const player of g.players)
|
|
Object.assign(player, { hand: [], spice: 20, forces: {}, reserves: 20, shipped: false, moved: 0 });
|
|
const ambassadors = createAmbassadors(() => 0.2);
|
|
const emperor = ambassadors.tokens.find((token) => token.effect === 'emperor')!;
|
|
ambassadors.cohort = [emperor.id, ...ambassadors.tokens
|
|
.filter((token) => token.effect !== 'ecaz' && token.id !== emperor.id)
|
|
.slice(0, 4).map((token) => token.id)];
|
|
for (const token of ambassadors.tokens) {
|
|
token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id) ? 'supply' : 'pool';
|
|
token.location = null;
|
|
}
|
|
g.ecazAmbassadors = placeAmbassador(ambassadors, emperor.id, {
|
|
turn: 2, availableSpice: 20,
|
|
destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true },
|
|
}).state;
|
|
const terror = createTerrorState(() => 0.2);
|
|
const robbery = terror.tokens.find((token) => token.kind === 'robbery')!;
|
|
g.moritaniTerror = placeTerror(terror, robbery.id, 'arrakeen', 2);
|
|
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
|
.run(JSON.stringify(g), g.version, code);
|
|
const ship: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 };
|
|
const offered = await store.rooms.act(code, auth[2], g.version, ship, clock);
|
|
assert.deepEqual(offered.arrivalOverlap, {
|
|
first: ecazFirst ? 'ambassador' : 'terror',
|
|
active: ecazFirst ? 'ambassador' : 'terror',
|
|
entrant: incoming, territory: 'arrakeen',
|
|
});
|
|
store.writes.length = 0;
|
|
return { ...store, code, tokens, auth, ids: { ec, m, incoming }, ship, emperor: emperor.id, robbery: robbery.id };
|
|
}
|
|
interface Fixture {
|
|
sqlite: DatabaseSync;
|
|
restart: () => typeof Rooms;
|
|
code: string;
|
|
tokens: string[];
|
|
auth: SeatAuth[];
|
|
ids: { ec: string; m: string; incoming: string };
|
|
ship: Action;
|
|
emperor: string;
|
|
robbery: string;
|
|
writes: { expected: number; changes: number }[];
|
|
}
|
|
const rows = (f: { sqlite: DatabaseSync }) => f.sqlite.prepare('SELECT state, version FROM rooms ORDER BY code').all();
|
|
|
|
async function saved(f: Fixture) {
|
|
const g = await f.restart().readRoom(f.code);
|
|
const before = rows(f);
|
|
for (let index = 0; index < f.tokens.length; index++) {
|
|
const rooms = f.restart();
|
|
const auth = await rooms.authenticate(f.code, f.tokens[index]);
|
|
const view = await rooms.readSeatView(f.code, auth);
|
|
assert.deepEqual(view, viewGame(g, auth.playerId));
|
|
assert.equal('pendingArrivalOverlap' in view, false);
|
|
assert.equal('pendingTerrorEntry' in view, false);
|
|
assert.equal('kind' in view.moritaniTerror!.tokens.find((token) => token.id === f.robbery)!, index === 1 ||
|
|
g.moritaniTerror!.tokens.find((token) => token.id === f.robbery)!.status === 'removed');
|
|
assert.equal(JSON.stringify(view.arrivalOverlap).includes(f.robbery), false);
|
|
assert.equal(JSON.stringify(view.arrivalOverlap).includes(g.pendingArrivalOverlap?.signature ?? 'unavailable'), false);
|
|
}
|
|
await f.restart().continueRoomAutomatic(f.code, clock);
|
|
assert.deepEqual(rows(f), before);
|
|
return g;
|
|
}
|
|
|
|
for (const ecazFirst of [true, false]) {
|
|
void test(`SQLite CAS restores ${ecazFirst ? 'Ecaz-first' : 'Moritani-first'} two-stage arrival without replaying shipment`, async (t) => {
|
|
const f = await room(t, ecazFirst);
|
|
let g = await saved(f);
|
|
const version = g.version;
|
|
const signature = g.pendingArrivalOverlap!.signature;
|
|
const wrongOwner = ecazFirst ? 1 : 0;
|
|
const firstOwner = ecazFirst ? 0 : 1;
|
|
const first: Action = ecazFirst
|
|
? { type: 'decision', event: g.pendingAmbassador!.event, trigger: true, beneficiary: f.ids.ec }
|
|
: { type: 'decision', reveal: true };
|
|
const before = rows(f);
|
|
await assert.rejects(f.restart().act(f.code, f.auth[wrongOwner], version, first, clock));
|
|
await assert.rejects(f.restart().act(f.code, f.auth[firstOwner], version - 1, first, clock), /table changed/);
|
|
assert.deepEqual(rows(f), before);
|
|
assert.equal(f.writes.length, 0);
|
|
await f.restart().act(f.code, f.auth[firstOwner], version, first, clock);
|
|
g = await saved(f);
|
|
assert.equal(g.pendingArrivalOverlap!.signature, signature);
|
|
assert.equal(g.pendingArrivalOverlap!.entrant, f.ids.incoming);
|
|
assert.equal(g.players[2].forces['arrakeen:10'], 2);
|
|
assert.equal(g.players[2].reserves, 18);
|
|
assert.equal(g.players[2].spice, 18);
|
|
if (!ecazFirst) {
|
|
assert.equal(g.pendingTerrorEntry?.stage, 'robbery');
|
|
await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', choice: 'spice' }, clock);
|
|
g = await saved(f);
|
|
assert.equal(g.pendingArrivalOverlap?.stage, 'second');
|
|
assert.equal(g.decision?.kind, 'ecazAmbassador');
|
|
} else {
|
|
assert.equal(g.pendingArrivalOverlap?.stage, 'second');
|
|
assert.equal(g.decision?.kind, 'moritaniTerror');
|
|
}
|
|
const prior = rows(f);
|
|
await assert.rejects(f.restart().act(f.code, f.auth[firstOwner], g.version, first, clock));
|
|
assert.deepEqual(rows(f), prior);
|
|
if (ecazFirst) {
|
|
await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', reveal: true }, clock);
|
|
g = await saved(f);
|
|
assert.equal(g.pendingTerrorEntry?.entrant, f.ids.incoming);
|
|
await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', choice: 'spice' }, clock);
|
|
} else {
|
|
await f.restart().act(f.code, f.auth[0], g.version, {
|
|
type: 'decision', event: g.pendingAmbassador!.event, trigger: true, beneficiary: f.ids.ec,
|
|
}, clock);
|
|
}
|
|
g = await saved(f);
|
|
assert.equal(g.pendingArrivalOverlap ?? null, null);
|
|
assert.equal(g.pendingAmbassador ?? null, null);
|
|
assert.equal(g.pendingTerrorEntry ?? null, null);
|
|
assert.equal(g.decision, null);
|
|
assert.equal(g.players[2].spice, 9);
|
|
assert.equal(g.players[1].spice, 29);
|
|
assert.equal(g.players[0].spice, 25);
|
|
assert.equal(g.players[2].forces['arrakeen:10'], 2);
|
|
assert.equal(g.ecazAmbassadors!.tokens.find((token) => token.id === f.emperor)!.zone, 'used');
|
|
assert.equal(g.moritaniTerror!.tokens.find((token) => token.id === f.robbery)!.status, 'removed');
|
|
const finished = rows(f);
|
|
await assert.rejects(f.restart().act(f.code, f.auth[2], g.version, f.ship, clock));
|
|
await assert.rejects(f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', choice: 'spice' }, clock));
|
|
assert.deepEqual(rows(f), finished);
|
|
});
|
|
}
|
|
|
|
const ride: Action = {
|
|
type: 'decision', accept: true, territory: 'arrakeen', sector: 10,
|
|
forces: { 'imperial_basin:10': 2 },
|
|
};
|
|
|
|
/** A phase-one Fremen ride into the shared stronghold, with a second ride still queued. */
|
|
async function wormRoom(t: test.TestContext, advanced: boolean) {
|
|
const store = unitStore();
|
|
t.after(() => store.sqlite.close());
|
|
const created = await store.rooms.createRoom('Ecaz', 'ecaz', advanced, ['ecaz']);
|
|
const code = created.view.code;
|
|
const moritani = await store.rooms.joinRoom(code, 'Moritani', 'moritani');
|
|
const rider = await store.rooms.joinRoom(code, 'Rider', 'fremen');
|
|
const tokens = [created.token, moritani.token!, rider.token!];
|
|
const auth = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token)));
|
|
const [ec, m, incoming] = auth.map((seat) => seat.playerId);
|
|
const g = await store.rooms.readRoom(code);
|
|
Object.assign(g, {
|
|
status: 'playing', phase: 1, turn: 3, storm: 18, active: null,
|
|
order: [m, ec, incoming], deck: baseDeck(),
|
|
});
|
|
g.movementRemaining = [...g.order];
|
|
for (const player of g.players)
|
|
Object.assign(player, { hand: [], spice: 20, forces: {}, reserves: 20, shipped: false, moved: 0 });
|
|
g.players[2].forces = { 'imperial_basin:10': 3, 'hagga_basin:12': 1 };
|
|
g.players[2].reserves = 16;
|
|
const ambassadors = createAmbassadors(() => 0.2);
|
|
const emperor = ambassadors.tokens.find((token) => token.effect === 'emperor')!;
|
|
ambassadors.cohort = [emperor.id, ...ambassadors.tokens
|
|
.filter((token) => token.effect !== 'ecaz' && token.id !== emperor.id)
|
|
.slice(0, 4).map((token) => token.id)];
|
|
for (const token of ambassadors.tokens) {
|
|
token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id) ? 'supply' : 'pool';
|
|
token.location = null;
|
|
}
|
|
g.ecazAmbassadors = placeAmbassador(ambassadors, emperor.id, {
|
|
turn: 2, availableSpice: 20,
|
|
destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true },
|
|
}).state;
|
|
const terror = createTerrorState(() => 0.2);
|
|
const robbery = terror.tokens.find((token) => token.kind === 'robbery')!;
|
|
g.moritaniTerror = placeTerror(terror, robbery.id, 'arrakeen', 2);
|
|
g.decision = { kind: 'wormRide', player: incoming, territory: 'imperial_basin' };
|
|
g.wormRides = ['hagga_basin'];
|
|
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
|
.run(JSON.stringify(g), g.version, code);
|
|
store.writes.length = 0;
|
|
return {
|
|
...store, code, tokens, auth, ids: { ec, m, incoming },
|
|
robbery: robbery.id, emperor: emperor.id,
|
|
};
|
|
}
|
|
|
|
for (const advanced of [false, true]) {
|
|
void test(`SQLite CAS restores a ${advanced ? 'Advanced' : 'Basic'} Fremen worm ride through both arrival reactions`, async (t) => {
|
|
const f = await wormRoom(t, advanced);
|
|
const read = async () => f.restart().readRoom(f.code);
|
|
let g = await read();
|
|
const version = g.version;
|
|
const rideOffered = await f.restart().act(f.code, f.auth[2], version, ride, clock);
|
|
assert.equal('pendingArrivalOverlap' in rideOffered, false);
|
|
g = await read();
|
|
assert.equal(g.pendingArrivalOverlap?.resume, 'wormRide');
|
|
assert.equal(g.pendingArrivalOverlap?.cause, 'wormRide');
|
|
assert.equal(g.pendingArrivalOverlap?.first, 'terror');
|
|
assert.equal(g.pendingTerrorEntry?.cause, 'wormRide');
|
|
assert.equal(g.pendingTerrorEntry?.resume, 'none');
|
|
assert.deepEqual(g.wormRides, ['hagga_basin']);
|
|
assert.equal(g.players[2].forces['arrakeen:10'], 2);
|
|
const spiceAfterRide = g.players[2].spice;
|
|
const views = await Promise.all(f.tokens.map(async (token) => {
|
|
const rooms = f.restart();
|
|
const auth = await rooms.authenticate(f.code, token);
|
|
const view = await rooms.readSeatView(f.code, auth);
|
|
assert.deepEqual(view, viewGame(await read(), auth.playerId));
|
|
assert.equal('pendingArrivalOverlap' in view, false);
|
|
assert.equal('pendingTerrorEntry' in view, false);
|
|
return view;
|
|
}));
|
|
assert.deepEqual(views[0].arrivalOverlap, {
|
|
first: 'terror', active: 'terror', entrant: f.ids.incoming, territory: 'arrakeen',
|
|
});
|
|
for (const index of [0, 1, 2]) {
|
|
const token = views[index].moritaniTerror!.tokens.find(
|
|
(candidate) => candidate.id === f.robbery,
|
|
)!;
|
|
assert.equal('kind' in token, index === 1);
|
|
assert.equal('terrorEntry' in views[index], true);
|
|
assert.equal('kind' in (views[index].terrorEntry ?? {}), index === 1);
|
|
}
|
|
const before = rows(f);
|
|
await assert.rejects(f.restart().act(f.code, f.auth[2], g.version, ride, clock));
|
|
assert.deepEqual(rows(f), before);
|
|
await f.restart().continueRoomAutomatic(f.code, clock);
|
|
assert.deepEqual(rows(f), before);
|
|
g = await read();
|
|
await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', reveal: true }, clock);
|
|
g = await read();
|
|
assert.equal(g.pendingTerrorEntry?.stage, 'robbery');
|
|
await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', choice: 'spice' }, clock);
|
|
g = await read();
|
|
assert.equal(g.pendingTerrorEntry, null);
|
|
assert.equal(g.pendingArrivalOverlap?.stage, 'second');
|
|
assert.equal(g.decision?.kind, 'ecazAmbassador');
|
|
await f.restart().act(f.code, f.auth[0], g.version, {
|
|
type: 'decision', event: g.pendingAmbassador!.event, decline: true,
|
|
}, clock);
|
|
g = await read();
|
|
assert.equal(g.pendingArrivalOverlap, null);
|
|
assert.equal(g.pendingAmbassador, null);
|
|
assert.equal(g.pendingTerrorEntry, null);
|
|
assert.deepEqual(g.wormRides, []);
|
|
assert.deepEqual(g.decision, {
|
|
kind: 'wormRide', player: f.ids.incoming, territory: 'hagga_basin',
|
|
});
|
|
assert.equal(g.players[2].forces['arrakeen:10'], 2);
|
|
assert.equal(g.players[2].forces['imperial_basin:10'], 1);
|
|
const stolen = spiceAfterRide - g.players[2].spice;
|
|
assert.equal(stolen, 10, 'Robbery takes spice once');
|
|
assert.equal(g.players[1].spice, 30, 'Moritani keeps the stolen spice exactly once');
|
|
assert.equal(g.moritaniTerror!.tokens.find((token) => token.kind === 'robbery')!.status, 'removed');
|
|
assert.equal(g.ecazAmbassadors!.tokens.find((token) => token.effect === 'emperor')!.zone, 'placed');
|
|
});
|
|
}
|