Files
Dune/tests/smuggler-shipment-recovery.test.ts
Quintine 9381ef140d Connect optional Smuggler reserve shipment and saved interception
Prototype one free accompanying force into an empty territory with explicit opt-out, shared human/AI pricing, physical elite custody and Guild recipient accounting. Bind pending quotes to their unique saved Guild decision; preserve legacy unstamped full-price declarations. Smuggler battle collection and combined modules remain unfinished and gated. Repair a randomly dealt-out Karama fixture by relocating its actual physical card without changing inventory.

Validation: 70 focused tests; typecheck, lint, 4477 offline tests, production build and 43 HTTP tests passed. Independent rules/persistence review completed, including interruption and cancellation. Browser checked the price toggle, three arrivals for two spice, and refresh with unchanged private cards. Two genuine four-profile Basic/Advanced samples completed without rejected candidates but did not use Smuggler.

Preserved all 427 opening rooms and original seat/recovery rows (461 rooms after QA/HTTP). SQLite integrity and foreign keys passed. Server remained healthy after the reported power outage; no restart or reset.

Verified source tree: bf6e02bf6873ca6d6b01c83481489f55b2c7db0184a7056364343440dae88803
2026-09-13 20:19:33 +10:00

308 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import type { Action } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { richeseCards } from '../game/richese-cards';
import { unitStore } from './fixture-nexus-room-store';
import { smugglerShipmentGame } from './smuggler-shipment-fixture';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture(card: 'box' | 'karama' | null = null, ownerSpice = 3) {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
const created = await store.rooms.createRoom(
'Smuggler',
'emperor',
false,
[],
);
const joined = await store.rooms.joinRoom(
created.view.code,
'Guild',
'guild',
);
const code = created.view.code;
const tokens = [created.token!, joined.token!];
const original = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
for (const [index, seat] of original.entries())
sqlite
.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?')
.run(index === 0 ? 'p' : 'h', code, seat.playerId);
const seats = await Promise.all(
tokens.map((token) => store.restart().authenticate(code, token)),
);
const state = smugglerShipmentGame('emperor', true);
state.code = code;
state.version = (await store.rooms.readRoom(code)).version;
state.players[0].spice = ownerSpice;
state.players[1].spice = 10;
let stagedCard = null;
if (card === 'box') {
stagedCard = richeseCards().find(
(candidate) => candidate.effect === 'nullentropyBox',
)!;
state.players[0].hand.push(stagedCard);
state.discard.push(...state.deck.splice(0, 4));
} else if (card === 'karama') {
const index = state.deck.findIndex(
(candidate) => candidate.effect === 'karama',
);
assert.ok(index >= 0);
stagedCard = state.deck.splice(index, 1)[0];
state.players[1].hand.push(stagedCard);
}
sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(state), state.version, code);
store.writes.length = 0;
return { ...store, sqlite, code, seats, state, stagedCard };
}
async function declare(
f: Awaited<ReturnType<typeof fixture>>,
smuggler: boolean | undefined,
) {
const action: Action = {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 3,
elite: 2,
...(smuggler === undefined ? {} : { smuggler }),
};
await f.restart().act(f.code, f.seats[0], f.state.version, action, clock);
return f.restart().readRoom(f.code);
}
void test('SQLite restart preserves a Smuggler quote and concurrent Guild allows commit it once', async () => {
const f = await fixture();
try {
const pending = await declare(f, true);
assert.equal(pending.decision?.kind, 'guildShipment');
assert.equal(pending.decision?.player, 'h');
assert.deepEqual(pending.pendingShipment?.smuggler, {
leader: pending.leaderSkills!.assignments.find(
(assignment) => assignment.owner === 'p',
)!.leader,
amount: 3,
});
assert.equal(pending.pendingShipment?.cost, 2);
assert.equal(pending.players[0].spice, 3);
assert.equal(pending.players[0].reserves, 20);
for (const seat of f.seats) {
const view = await f.restart().readSeatView(f.code, seat);
assert.deepEqual(view.decision, pending.decision);
assert.equal('pendingShipment' in view, false);
const other = view.players.find((player) => player.id !== seat.playerId)!;
assert.equal('spice' in other, false);
assert.equal('hand' in other, false);
}
let arrivals = 0;
let release!: () => void;
const barrier = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await barrier;
};
f.writes.length = 0;
const allow: Action = { type: 'decision', allow: true };
const attempts = await Promise.allSettled([
f.rooms.act(f.code, f.seats[1], pending.version, allow, clock),
f.restart().act(f.code, f.seats[1], pending.version, allow, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(
attempts.filter((attempt) => attempt.status === 'fulfilled').length,
1,
);
assert.equal(
attempts.filter((attempt) => attempt.status === 'rejected').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, pending.version + 1);
assert.equal(done.pendingShipment, null);
assert.equal(done.decision, null);
assert.equal(done.players[0].spice, 1);
assert.equal(done.players[0].reserves, 17);
assert.equal(done.players[0].elites!.reserves, 3);
assert.equal(done.players[0].forces['arrakeen:10'], 3);
assert.equal(done.players[0].elites!.forces['arrakeen:10'], 2);
assert.equal(done.players[1].spice, 12);
assert.equal(
done.log.filter((entry) => entry.automatic?.name === 'Smuggler shipment')
.length,
1,
);
const writes = f.writes.length;
await assert.rejects(() =>
f.restart().act(f.code, f.seats[1], pending.version, allow, clock),
);
assert.equal(f.writes.length, writes);
assert.deepEqual(await f.restart().readRoom(f.code), done);
} finally {
f.sqlite.close();
}
});
void test('a restored shipment without the new Smuggler stamp retains its full quoted price', async () => {
const f = await fixture();
try {
const pending = await declare(f, undefined);
assert.equal(pending.decision?.kind, 'guildShipment');
assert.equal(pending.pendingShipment?.smuggler, undefined);
assert.equal(pending.pendingShipment?.cost, 3);
delete pending.pendingShipment!.turn;
f.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(pending), pending.version, f.code);
const restored = await f.restart().readRoom(f.code);
assert.equal(restored.pendingShipment?.turn, undefined);
await f
.restart()
.act(
f.code,
f.seats[1],
restored.version,
{ type: 'decision', allow: true },
clock,
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.players[0].spice, 0);
assert.equal(done.players[0].reserves, 17);
assert.equal(done.players[1].spice, 13);
assert.ok(
!done.log.some((entry) => entry.automatic?.name === 'Smuggler shipment'),
);
} finally {
f.sqlite.close();
}
});
void test('a paid Box search can suspend and restore the saved Guild decision before shipment', async () => {
const f = await fixture('box', 5);
try {
const pending = await declare(f, true);
const source = structuredClone(pending.pendingShipment);
await f
.restart()
.act(
f.code,
f.seats[0],
pending.version,
{ type: 'card', card: f.stagedCard!.id },
clock,
);
const searching = await f.restart().readRoom(f.code);
assert.equal(searching.decision?.kind, 'nullentropy');
assert.equal(
searching.pendingNullentropy?.resume.decision?.kind,
'guildShipment',
);
assert.deepEqual(searching.pendingShipment, source);
assert.equal(searching.players[0].spice, 3);
assert.equal(searching.players[0].reserves, 20);
for (const seat of f.seats) await f.restart().readSeatView(f.code, seat);
await f.restart().act(
f.code,
f.seats[0],
searching.version,
{
type: 'decision',
event: searching.pendingNullentropy!.event,
card: searching.discard[0].id,
},
clock,
);
const restored = await f.restart().readRoom(f.code);
assert.equal(restored.pendingNullentropy, null);
assert.equal(restored.decision?.kind, 'guildShipment');
assert.deepEqual(restored.pendingShipment, source);
assert.equal(restored.players[0].reserves, 20);
await f
.restart()
.act(
f.code,
f.seats[1],
restored.version,
{ type: 'decision', allow: true },
clock,
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.pendingShipment, null);
assert.equal(done.players[0].spice, 1);
assert.equal(done.players[0].reserves, 17);
assert.equal(done.players[1].spice, 12);
assert.equal(
done.log.filter((entry) => entry.automatic?.name === 'Smuggler shipment')
.length,
1,
);
} finally {
f.sqlite.close();
}
});
void test('Guild cancellation survives restart without charging or moving the Smuggler shipment', async () => {
const f = await fixture('karama');
try {
const pending = await declare(f, true);
await f
.restart()
.act(
f.code,
f.seats[1],
pending.version,
{ type: 'card', mode: 'special', card: f.stagedCard!.id },
clock,
);
const stopped = await f.restart().readRoom(f.code);
assert.equal(stopped.version, pending.version + 1);
assert.equal(stopped.pendingShipment, null);
assert.equal(stopped.decision, null);
assert.equal(stopped.players[0].shipped, true);
assert.equal(stopped.players[0].spice, 3);
assert.equal(stopped.players[0].reserves, 20);
assert.equal(stopped.players[0].forces['arrakeen:10'], undefined);
assert.equal(stopped.players[1].spice, 10);
assert.ok(stopped.discard.some((card) => card.id === f.stagedCard!.id));
assert.ok(
!stopped.log.some(
(entry) => entry.automatic?.name === 'Smuggler shipment',
),
);
const writes = f.writes.length;
await assert.rejects(() =>
f
.restart()
.act(
f.code,
f.seats[1],
pending.version,
{ type: 'card', mode: 'special', card: f.stagedCard!.id },
clock,
),
);
assert.equal(f.writes.length, writes);
assert.deepEqual(await f.restart().readRoom(f.code), stopped);
} finally {
f.sqlite.close();
}
});