Files
Dune/tests/shipment-promises-recovery.test.ts
T
Quintine 32791ed5e2 Support typed Advanced Truthtrance shipment commitments
Extend the existing reserve-shipment promise pipeline to base Advanced
games without Guild or optional modules. Share scope between parser, save
validation and UI. Reuse canonical Ghola normal/elite revival eligibility
and retain live BG conversion responses while proving conditional private
feasibility. Hide future-price guidance until pending responses finish.
Show exact Sardaukar/Fedaykin allocation in private executable steps.

Validation: types, lint and all 4,192 offline tests pass; production build
and all 40 HTTP tests pass. 41 focused shipment tests include 15 new cases
covering genuine Advanced setup, all five non-Guild factions and four bot
profiles, typed Ghola quota, BG allowance/cancellation/alternatives,
private views, saved continuations and production SQLite duplicate races.
Independent review found and resolved optional-module scope and pending
price-guidance issues. Browser rooms EUZZ8D4M and GWNCVCAP verified an AI No
route and human Yes with four-force/one-Sardaukar Ghola then six-force
shipment. Phone settlement controls and refresh passed. Both existing
browser room hashes/versions preserved through HTTP tests; 30 rooms added.

Full source-bound report: 20260913-advanced-shipment-final; fingerprint
6c2f29d9b3e61bf6b9544ee3a95c47538abfb05e1c3b2ae9d0a603ef0cef38dd
Broad checks ran once after focused fixes/review. No full-game AI study
or complete Advanced mode acceptance is claimed; all existing release
gates and pending Guild settlement questions remain intact.
2026-09-13 11:08:29 +10:00

829 lines
28 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 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(
advanced = false,
faction: 'emperor' | 'beneGesserit' = 'emperor',
) {
const store = unitStore();
const made = await store.rooms.createRoom('Asker', 'atreides', false, []);
const code = made.view.code;
const target = await store.rooms.joinRoom(code, 'Shipper', faction);
const observer = await store.rooms.joinRoom(code, 'Observer', 'harkonnen');
const seats = await Promise.all(
[made.token, target.token!, observer.token!].map((token) =>
store.rooms.authenticate(code, token),
),
);
let initial = await store.rooms.readRoom(code);
if (advanced) {
initial.advanced = true; // Set the requested mode on the still-uninitialized lobby.
initial.players.forEach((p) => {
p.ready = true;
});
initial = engine.initializeBaseGameForAudit(initial);
for (let n = 0; initial.status === 'setup' && n < 50; n++) {
const id = engine.viewGame(initial, initial.host).setupPending[0];
assert.ok(id, 'Genuine Advanced setup retains a decision owner.');
const view = engine.viewGame(initial, id);
view.players.find((p) => p.id === id)!.bot = 'Hard';
const action = bots.botActions(view)[0];
assert.ok(action, 'Genuine Advanced setup has a legal next decision.');
initial = engine.applyAction(initial, id, action);
}
assert.equal(initial.status, 'playing');
}
Object.assign(initial, {
status: 'playing',
phase: 5,
turn: 2,
active: seats[1].playerId,
order: [seats[1].playerId, seats[0].playerId, seats[2].playerId],
movementRemaining: [
seats[1].playerId,
seats[0].playerId,
seats[2].playerId,
],
storm: 18,
deck: baseDeck(),
response: null,
decision: null,
phaseOpening: null,
});
for (const p of initial.players) {
p.hand = [];
p.spice = 10;
p.forces = {};
p.reserves = 20;
p.tanks = 0;
if (p.elites) {
p.elites.forces = {};
p.elites.reserves = p.faction === 'emperor' ? 5 : 3;
p.elites.tanks = 0;
p.elites.revived = 0;
}
if (p.advisors) p.advisors = {};
p.shipped = false;
p.moved = 0;
if (!advanced) p.traitors = [p.leaders[0].id];
p.traitorChoices = [];
}
initial.players[1].forces = { 'polar_sink:0': 2 };
initial.players[1].reserves = 18;
const hold = (index: number, name: string) => {
const at = initial.deck.findIndex((card) => card.name === name);
assert.ok(at >= 0, name);
const card = initial.deck.splice(at, 1)[0];
initial.players[index].hand.push(card);
return card;
};
const card = hold(0, 'Truthtrance');
hold(1, 'Shield');
hold(2, 'Gom Jabbar');
store.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(initial), initial.version, code);
store.writes.length = 0;
return { ...store, code, seats, initial, card };
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
async function act(f: Fixture, index: number, action: engine.Action) {
const current = await f.restart().readRoom(f.code);
await f.restart().act(f.code, f.seats[index], current.version, action, clock);
return f.restart().readRoom(f.code);
}
const claim = { kind: 'shipment', territory: 'carthag', minimum: 6 } as const;
async function priority(f: Fixture) {
await act(f, 0, { type: 'card', card: f.card.id });
for (const index of [1, 2]) await act(f, index, { type: 'truthPass' });
return f.restart().readRoom(f.code);
}
async function ask(f: Fixture) {
await priority(f);
return act(f, 0, {
type: 'truthAsk',
question: { ...claim, target: f.seats[1].playerId },
});
}
async function restored(f: Fixture) {
const before = await f.restart().readRoom(f.code);
const writes = f.writes.length;
await f.restart().continueRoomAutomatic(f.code, clock);
assert.equal(
f.writes.length,
writes,
'Refresh must not answer or ship on behalf of a human.',
);
assert.deepEqual(await f.restart().readRoom(f.code), before);
return before;
}
function inventory(g: engine.Game) {
return [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((card) => card.id)
.sort();
}
async function privateViews(
f: Fixture,
answering: boolean,
completion: boolean,
) {
const state = await f.restart().readRoom(f.code);
for (const [index, seat] of f.seats.entries()) {
const view = await f.restart().readSeatView(f.code, seat);
assert.deepEqual(
view.truthShipmentAnswers,
index === 1 && answering ? ['yes', 'no'] : null,
);
if (index === 1 && completion)
assert.ok(view.shipmentCompletion?.actions.length);
else assert.equal(view.shipmentCompletion, null);
assert.deepEqual(view.shipmentPromises, state.shipmentPromises ?? []);
for (const p of view.players) {
if (p.id === seat.playerId) {
assert.deepEqual(p.hand, state.players[index].hand);
continue;
}
for (const key of [
'spice',
'hand',
'handCount',
'traitors',
'traitorChoices',
'bribes',
])
assert.equal(key in p, false, `Other seat disclosed ${key}`);
}
assert.equal('deck' in view, false);
}
}
async function rejectsWithoutWrite(
f: Fixture,
index: number,
action: engine.Action,
pattern?: RegExp,
) {
const before = await f.restart().readRoom(f.code);
const writes = f.writes.length;
const attempt = f
.restart()
.act(f.code, f.seats[index], before.version, action, clock);
if (pattern) await assert.rejects(attempt, pattern);
else await assert.rejects(attempt);
assert.deepEqual(await f.restart().readRoom(f.code), before);
assert.equal(f.writes.length, writes);
}
function barrier() {
let release!: () => void;
const waiting = new Promise<void>((resolve) => {
release = resolve;
});
let arrivals = 0;
return async () => {
if (++arrivals === 2) release();
await waiting;
};
}
async function race(
f: Fixture,
index: number,
version: number,
actions: engine.Action[],
) {
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const outcomes = await Promise.allSettled(
actions.map((action) =>
f.restart().act(f.code, f.seats[index], version, action, clock),
),
);
f.hooks.beforeWrite = undefined;
assert.equal(
outcomes.filter((outcome) => outcome.status === 'fulfilled').length,
1,
);
assert.equal(
outcomes.filter((outcome) => outcome.status === 'rejected').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
return outcomes;
}
function assertSingleAnswer(f: Fixture, g: engine.Game, answer: boolean) {
assert.equal(g.truthtrance, null);
assert.equal(g.shipmentPromises?.length, 1);
assert.equal(g.shipmentPromises![0].answer, answer);
assert.equal(g.shipmentPromises![0].player, f.seats[1].playerId);
assert.equal(g.shipmentPromises![0].asker, f.seats[0].playerId);
assert.equal(g.shipmentPromises![0].turn, 2);
assert.equal(g.shipmentPromises![0].territory, 'carthag');
assert.equal(g.shipmentPromises![0].minimum, 6);
assert.equal(g.truthHistory?.length, 1);
assert.equal(g.truthHistory![0].answer, answer ? 'yes' : 'no');
assert.deepEqual(g.truthHistory![0].question, {
...claim,
target: f.seats[1].playerId,
});
assert.equal(g.discard.filter((card) => card.id === f.card.id).length, 1);
assert.equal(
g.players[0].hand.some((card) => card.id === f.card.id),
false,
);
assert.deepEqual(inventory(g), inventory(f.initial));
}
void test('shipment question and accepted promise survive fresh room modules with only target-private answers and completion choices', async () => {
const f = await fixture();
try {
const unasked = await priority(f);
await restored(f);
for (const minimum of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1])
await rejectsWithoutWrite(f, 0, {
type: 'truthAsk',
question: { ...claim, target: f.seats[1].playerId, minimum },
});
await rejectsWithoutWrite(f, 0, {
type: 'truthAsk',
question: {
...claim,
target: f.seats[1].playerId,
territory: 'not-a-territory',
},
});
await rejectsWithoutWrite(f, 0, {
type: 'truthAsk',
question: { ...claim, target: f.seats[2].playerId },
});
await rejectsWithoutWrite(f, 2, {
type: 'truthAsk',
question: { ...claim, target: f.seats[1].playerId },
});
assert.deepEqual(await f.restart().readRoom(f.code), unasked);
await act(f, 0, {
type: 'truthAsk',
question: { ...claim, target: f.seats[1].playerId },
});
const pending = await restored(f);
await privateViews(f, true, false);
const poorTarget = structuredClone(pending);
poorTarget.players[1].spice = 0;
assert.deepEqual(
engine.viewGame(poorTarget, f.seats[1].playerId).truthShipmentAnswers,
['no'],
);
for (const index of [0, 2])
assert.deepEqual(
engine.viewGame(poorTarget, f.seats[index].playerId),
engine.viewGame(pending, f.seats[index].playerId),
'Rival projections must not disclose whether private funding permits Yes.',
);
for (const index of [0, 2])
await rejectsWithoutWrite(
f,
index,
{ type: 'truthAnswer', answer: 'yes' },
/questioned player/,
);
await rejectsWithoutWrite(f, 1, { type: 'truthAnswer', answer: 'unknown' });
assert.deepEqual(pending.players, f.initial.players);
const accepted = await act(f, 1, { type: 'truthAnswer', answer: 'yes' });
assertSingleAnswer(f, accepted, true);
assert.equal(accepted.players[1].shipped, false);
assert.deepEqual(accepted.players[1], f.initial.players[1]);
await privateViews(f, false, true);
assert.deepEqual(await restored(f), accepted);
await rejectsWithoutWrite(f, 1, { type: 'truthAnswer', answer: 'yes' });
} finally {
f.hooks.beforeWrite = undefined;
f.sqlite.close();
}
});
void test('persisted Yes rejects evasion and a duplicated compatible reserve shipment pays, arrives and fulfills exactly once', async () => {
const f = await fixture();
try {
// These actions are legal before the promise, so their later rejection is meaningful.
const attempts: engine.Action[] = [
{ type: 'endMovement' },
{
type: 'move',
forces: { 'polar_sink:0': 1 },
territory: 'imperial_basin',
sector: 11,
},
{ type: 'ship', territory: 'carthag', sector: 11, amount: 5 },
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 6 },
];
for (const action of attempts)
assert.doesNotThrow(() =>
engine.applyAction(f.initial, f.seats[1].playerId, action),
);
await ask(f);
await act(f, 1, { type: 'truthAnswer', answer: 'yes' });
const accepted = await restored(f);
for (const action of attempts)
await rejectsWithoutWrite(f, 1, action, /Truthtrance|promise/i);
const action: engine.Action = {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 7,
};
await race(f, 1, accepted.version, [action, action]);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, accepted.version + 1);
assertSingleAnswer(f, done, true);
assert.equal(done.shipmentPromises![0].fulfilled, true);
assert.notEqual(done.shipmentPromises![0].released, true);
assert.equal(done.players[1].spice, 3);
assert.equal(done.players[1].reserves, 11);
assert.equal(done.players[1].forces['carthag:11'], 7);
assert.equal(done.players[1].forces['polar_sink:0'], 2);
assert.equal(done.players[1].shipped, true);
const writes = f.writes.length;
for (const version of [accepted.version, done.version])
await assert.rejects(
f.restart().act(f.code, f.seats[1], version, action, clock),
);
assert.equal(f.writes.length, writes);
assert.deepEqual(await restored(f), done);
await privateViews(f, false, false);
} finally {
f.hooks.beforeWrite = undefined;
f.sqlite.close();
}
});
void test('competing persisted Yes and No answers bind only the winning CAS and retain its exact shipment restriction', async () => {
for (const firstAnswer of ['yes', 'no'] as const) {
const f = await fixture();
try {
await ask(f);
const pending = await restored(f);
const answers = [firstAnswer, firstAnswer === 'yes' ? 'no' : 'yes'];
const outcomes = await race(
f,
1,
pending.version,
answers.map((answer) => ({ type: 'truthAnswer', answer })),
);
const winningIndex = outcomes.findIndex(
(outcome) => outcome.status === 'fulfilled',
);
const yes = answers[winningIndex] === 'yes';
const accepted = await restored(f);
assert.equal(accepted.version, pending.version + 1);
assertSingleAnswer(f, accepted, yes);
assert.equal(accepted.players[1].shipped, false);
const matching: engine.Action = {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 6,
};
const smaller: engine.Action = {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 5,
};
await rejectsWithoutWrite(
f,
1,
yes ? smaller : matching,
/Truthtrance|promise/i,
);
if (!yes)
assert.doesNotThrow(() =>
engine.applyAction(accepted, f.seats[1].playerId, {
type: 'endMovement',
}),
);
const shipped = await act(f, 1, yes ? matching : smaller);
assertSingleAnswer(f, shipped, yes);
assert.equal(shipped.players[1].forces['carthag:11'], yes ? 6 : 5);
assert.equal(shipped.players[1].spice, yes ? 4 : 5);
assert.deepEqual(await restored(f), shipped);
} finally {
f.hooks.beforeWrite = undefined;
f.sqlite.close();
}
}
});
void test('corrupt persisted promise records and pending shipment questions reject before any answer, discard or CAS write', async () => {
const f = await fixture();
try {
await ask(f);
const pending = await restored(f);
const accepted = await act(f, 1, { type: 'truthAnswer', answer: 'yes' });
const corruptions: ((g: engine.Game) => void)[] = [
(g) => {
g.shipmentPromises![0].minimum = 0;
},
(g) => {
g.shipmentPromises![0].minimum = 1.5;
},
(g) => {
g.shipmentPromises![0].turn = g.turn + 1;
},
(g) => {
g.shipmentPromises![0].player = 'missing-seat';
},
(g) => {
g.shipmentPromises![0].asker = g.shipmentPromises![0].player;
},
(g) => {
g.shipmentPromises![0].territory = 'missing-territory';
},
(g) => {
g.shipmentPromises![0].released = true;
g.shipmentPromises![0].fulfilled = true;
},
(g) => {
g.players[1].shipped = true;
},
(g) => {
g.active = f.seats[2].playerId;
},
(g) => {
g.phase = 6;
},
(g) => {
g.advanced = true;
g.players[2].faction = 'guild';
},
(g) => {
g.expansions = ['ix'];
},
];
for (const corrupt of corruptions) {
const bad = structuredClone(accepted);
corrupt(bad);
f.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(bad), bad.version, f.code);
assert.throws(
() => engine.normalizeAutomaticGame(bad),
/shipment promise/i,
);
for (const seat of f.seats)
await assert.rejects(
f.restart().readSeatView(f.code, seat),
/shipment promise/i,
);
await rejectsWithoutWrite(
f,
1,
{ type: 'endMovement' },
/shipment promise/i,
);
}
const questionCorruptions: ((g: engine.Game) => void)[] = [
(g) => {
Object.assign(g.truthtrance!.question!, { minimum: 0 });
},
(g) => {
Object.assign(g.truthtrance!.question!, { minimum: 1.5 });
},
(g) => {
Object.assign(g.truthtrance!.question!, {
target: f.seats[2].playerId,
});
},
(g) => {
g.truthtrance!.queue[0].player = 'missing-holder';
},
(g) => {
g.truthtrance!.queue[0].card = 'missing-card';
},
(g) => {
g.truthtrance!.queue[0] = {
player: f.seats[2].playerId,
card: g.players[2].hand[0].id,
};
},
];
for (const corrupt of questionCorruptions) {
const bad = structuredClone(pending);
corrupt(bad);
f.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(bad), bad.version, f.code);
await rejectsWithoutWrite(f, 1, { type: 'truthAnswer', answer: 'yes' });
assert.throws(() => engine.normalizeAutomaticGame(bad));
for (const seat of f.seats)
await assert.rejects(f.restart().readSeatView(f.code, seat));
}
} finally {
f.hooks.beforeWrite = undefined;
f.sqlite.close();
}
});
void test('older-turn and fulfilled shipment promises remain readable without reactivation, while absent legacy fields remain absent', async () => {
const f = await fixture();
try {
const legacy = await restored(f);
assert.equal(legacy.shipmentPromises, undefined);
for (const seat of f.seats) {
const view = await f.restart().readSeatView(f.code, seat);
assert.deepEqual(view.shipmentPromises, []);
assert.equal(view.shipmentCompletion, null);
}
assert.equal(
(await f.restart().readRoom(f.code)).shipmentPromises,
undefined,
);
await ask(f);
await act(f, 1, { type: 'truthAnswer', answer: 'yes' });
const accepted = await restored(f);
const expired = structuredClone(accepted);
expired.turn++;
f.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(expired), expired.version, f.code);
const normalized = engine.normalizeAutomaticGame(expired);
assert.deepEqual(normalized.shipmentPromises, expired.shipmentPromises);
assert.deepEqual(normalized.truthHistory, expired.truthHistory);
assert.deepEqual(engine.normalizeAutomaticGame(normalized), normalized);
for (const seat of f.seats)
assert.equal(
(await f.restart().readSeatView(f.code, seat)).shipmentCompletion,
null,
);
const newShipment = await act(f, 1, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 5,
});
assert.equal(newShipment.players[1].forces['carthag:11'], 5);
assert.deepEqual(newShipment.shipmentPromises, accepted.shipmentPromises);
assert.deepEqual(newShipment.truthHistory, accepted.truthHistory);
assert.deepEqual(inventory(newShipment), inventory(accepted));
assert.deepEqual(await restored(f), newShipment);
// A genuine completed record remains valid after leaving Shipment & Movement.
f.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(accepted), accepted.version, f.code);
const completed = await act(f, 1, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 6,
});
completed.phase = 6;
f.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(completed), completed.version, f.code);
assert.equal(completed.shipmentPromises![0].fulfilled, true);
for (const seat of f.seats) {
const view = await f.restart().readSeatView(f.code, seat);
assert.deepEqual(view.shipmentPromises, completed.shipmentPromises);
assert.equal(view.shipmentCompletion, null);
}
assert.deepEqual(
engine.normalizeAutomaticGame(completed).shipmentPromises,
completed.shipmentPromises,
);
assert.deepEqual(await restored(f), completed);
} finally {
f.sqlite.close();
}
});
void test('Advanced typed Ghola preparation and its shipment survive room reload and duplicate submissions', async () => {
const f = await fixture(true);
try {
const staged = await f.restart().readRoom(f.code),
p = staged.players[1];
p.reserves = 2;
p.tanks = 5;
p.forces = { 'polar_sink:0': 13 };
p.spice = 6;
p.elites = {
reserves: 0,
tanks: 2,
forces: { 'polar_sink:0': 3 },
revived: 0,
};
const at = staged.deck.findIndex((c) => c.effect === 'ghola');
assert.ok(at >= 0);
const ghola = staged.deck.splice(at, 1)[0];
p.hand.push(ghola);
f.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(staged), staged.version, f.code);
await ask(f);
await restored(f);
const accepted = await act(f, 1, { type: 'truthAnswer', answer: 'yes' });
const preparation = engine.viewGame(accepted, p.id).shipmentCompletion!
.actions[0];
assert.equal(preparation.card, ghola.id);
assert.equal(preparation.amount, 4);
assert.equal(preparation.elite, 1);
await race(f, 1, accepted.version, [preparation, preparation]);
const revived = await restored(f);
assert.equal(revived.players[1].reserves, 6);
assert.equal(revived.players[1].elites!.revived, 1);
assert.equal(revived.discard.filter((c) => c.id === ghola.id).length, 1);
const shipment = engine.viewGame(revived, p.id).shipmentCompletion!
.actions[0];
assert.equal(shipment.type, 'ship');
assert.equal(shipment.elite, 1);
await race(f, 1, revived.version, [shipment, shipment]);
const arrived = await restored(f);
assert.equal(arrived.players[1].spice, 0);
assert.equal(arrived.players[1].reserves, 0);
assert.equal(
arrived.players[1].forces[`carthag:${Number(shipment.sector)}`],
6,
);
assert.equal(
arrived.players[1].elites!.forces[`carthag:${Number(shipment.sector)}`],
1,
);
assert.equal(arrived.shipmentPromises![0].fulfilled, true);
assert.deepEqual(inventory(arrived), inventory(staged));
for (const other of [0, 2]) {
const view = engine.viewGame(arrived, f.seats[other].playerId);
assert.equal(view.shipmentCompletion, null);
assert.equal('hand' in view.players[1], false);
}
} finally {
f.sqlite.close();
}
});
void test('Advanced BG pending conversion keeps its promise through restart and commits or releases once', async () => {
for (const canceled of [false, true]) {
const f = await fixture(true, 'beneGesserit');
try {
const staged = await f.restart().readRoom(f.code),
p = staged.players[1];
p.spice = 3;
p.forces = { 'carthag:11': 1 };
p.reserves = 19;
p.advisors = { carthag: {} };
const hold = (seat: number, effect: 'worthless' | 'karama') => {
const at = staged.deck.findIndex((c) =>
effect === 'worthless' ? c.kind === 'worthless' : c.effect === effect,
);
assert.ok(at >= 0);
const card = staged.deck.splice(at, 1)[0];
staged.players[seat].hand.push(card);
return card.id;
};
const conversion = hold(1, 'worthless'),
cancel = hold(0, 'karama');
staged.players[2].forces = { 'carthag:11': 2 };
staged.players[2].reserves = 18;
f.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(staged), staged.version, f.code);
await ask(f);
const accepted = await act(f, 1, { type: 'truthAnswer', answer: 'yes' });
await act(
f,
1,
engine.viewGame(accepted, p.id).shipmentCompletion!.actions[0],
);
let pending = await restored(f);
assert.equal(pending.response?.kind, 'worthlessKarama');
assert.equal(pending.shipmentPromises![0].released, undefined);
assert.equal(
engine.viewGame(pending, p.id).shipmentCompletion,
null,
'A pending discounted route is not yet an executable shipment or a current price.',
);
await rejectsWithoutWrite(f, 1, { type: 'endMovement' });
if (canceled) {
const action = { type: 'card', card: cancel, mode: 'cancel' };
await race(f, 0, pending.version, [action, action]);
pending = await restored(f);
assert.equal(pending.shipmentPromises![0].released, true);
assert.equal(pending.players[1].spice, 3);
assert.equal(pending.players[1].shipped, false);
} else {
for (let n = 0; pending.response && n < 10; n++) {
const seat = pending.players.findIndex(
(player) => !pending.response!.passed.includes(player.id),
);
assert.ok(seat >= 0);
pending = await act(f, seat, { type: 'passResponse' });
}
assert.equal(pending.response, null);
await restored(f);
const shipment = engine.viewGame(pending, p.id).shipmentCompletion!
.actions[0];
await race(f, 1, pending.version, [shipment, shipment]);
pending = await restored(f);
assert.equal(pending.shipmentPromises![0].fulfilled, true);
assert.equal(pending.players[1].spice, 0);
assert.ok(pending.players[1].advisors!.carthag);
}
assert.equal(
pending.discard.filter((c) => c.id === conversion).length,
1,
);
assert.deepEqual(inventory(pending), inventory(staged));
} finally {
f.sqlite.close();
}
}
});