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
This commit is contained in:
Quintine committed 2026-09-13 20:19:33 +10:00
1 parent 06f46c6f79
commit 9381ef140d
18 files changed
+1015 -25

No files matched your search

+26 -4
View File
@@ -20,6 +20,29 @@ import { enterDiscoveryCollection } from './fixture-discovery';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
function giveExistingCard(
game: Game,
owner: string,
predicate: (card: Game['deck'][number]) => boolean,
) {
const recipient = game.players.find((player) => player.id === owner)!;
const zones = [game.deck, game.discard, ...game.players.map((p) => p.hand)];
for (const zone of zones) {
const index = zone.findIndex(predicate);
if (index < 0) continue;
const [card] = zone.splice(index, 1);
recipient.hand.push(card);
return card;
}
assert.fail('The initialized physical deck has no Karama card.');
}
function physicalCardIds(game: Game) {
return [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand)]
.map((card) => card.id)
.sort();
}
function initialized(
factions: [FactionId, FactionId, FactionId],
expansions: string[] = [],
@@ -116,10 +139,9 @@ function pendingAdvisorEntry(
const beneGesserit = game.players[1];
beneGesserit.reserves--;
beneGesserit.forces['cistern:0'] = 1;
const karama = game.deck.find((card) => card.effect === 'karama');
assert.ok(karama);
game.deck = game.deck.filter((card) => card.id !== karama.id);
actor.hand.push(karama);
const custody = physicalCardIds(game);
giveExistingCard(game, actor.id, (card) => card.effect === 'karama');
assert.deepEqual(physicalCardIds(game), custody);
game = nextTurn(game);
game = applyAction(game, actor.id, entryAction(game));
assert.equal(game.discoveryEntry?.stage, 'arrival');
+36
View File
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { SmugglerShipmentChoice } from '../components/smuggler-shipment';
void test('Smuggler controls preserve a real opt-out and distinguish total physical forces from the priced count', () => {
const quote = { leader: 'emperor-0', amount: 3 };
const render = (use: boolean, busy = false) =>
renderToStaticMarkup(
createElement(SmugglerShipmentChoice, {
quote,
use,
busy,
onChange() {},
}),
);
const enabled = render(true);
assert.match(enabled, /checked=""/);
assert.match(enabled, /price is for 2/);
assert.match(enabled, /All 3 selected forces leave reserves/);
assert.doesNotMatch(render(false), /checked=""/);
assert.match(render(false), /ordinary price applies to every selected force/);
assert.match(render(true, true), /disabled=""/);
assert.equal(
renderToStaticMarkup(
createElement(SmugglerShipmentChoice, {
quote: null,
use: true,
busy: false,
onChange() {},
}),
),
'',
);
});
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import {
applyAction,
createGame,
newPlayer,
joinGame,
initializeLeaderSkillsGameForAudit,
viewGame,
type Game,
} from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import type { FactionId } from '../game/catalog';
import { botActions } from '../game/bots';
/** Genuine module setup followed by a conserved, explicitly staged shipment turn. */
export function smugglerShipmentGame(
faction: FactionId = 'emperor',
advanced = false,
): Game {
let g = createGame('SMUGSHIP', newPlayer('p', 'Smuggler', faction), advanced);
joinGame(
g,
newPlayer('h', 'Opponent', faction === 'guild' ? 'emperor' : 'guild'),
);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === 'smuggler');
let cursor = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = cursor-- === index ? 0 : 0xffffffff;
return array;
});
try {
g = initializeLeaderSkillsGameForAudit(g);
} finally {
mock.restoreAll();
}
for (let i = 0; g.status === 'setup' && i < 60; i++) {
let accepted = false;
for (const p of g.players) {
const view = viewGame(g, p.id);
const skills = view.leaderSkills!;
const options =
g.setupStage === 'leaderSkills' && skills.offer
? [
{
type: 'leaderSkill',
event: skills.offer.event,
skill: p.id === 'p' ? 'smuggler' : skills.offer.cards[0],
leader: skills.eligibleLeaders[0].id,
},
]
: g.setupStage === 'traitors' && p.traitorChoices.length
? [{ type: 'traitor', leader: p.traitorChoices[0] }]
: g.setupStage === 'prediction' && p.faction === 'beneGesserit'
? [{ type: 'predict', faction: g.players[1].faction, turn: 3 }]
: g.setupStage === 'forces' &&
p.faction === 'fremen' &&
p.reserves === 20
? [
{
type: 'fremenSetup',
placements: {
sietch_tabr: 10,
false_wall_south: 0,
false_wall_west: 0,
},
},
]
: g.setupStage === 'forces' &&
p.faction === 'beneGesserit' &&
!p.advisorSetup
? [
{
type: 'advisorSetup',
territory: 'arrakeen',
sector: 10,
},
]
: botActions(view);
for (const a of options) {
try {
g = applyAction(g, p.id, a);
accepted = true;
break;
} catch {
/* Other seats can be waiting. */
}
}
if (accepted) break;
}
assert.ok(accepted, `setup ${g.setupStage}`);
}
assert.equal(g.status, 'playing');
Object.assign(g, {
phase: 5,
turn: 2,
storm: 18,
active: 'p',
order: ['p', 'h'],
movementRemaining: ['p', 'h'],
ready: [],
response: null,
decision: null,
phaseOpening: null,
});
for (const p of g.players) {
g.deck.push(...p.hand);
p.hand = [];
p.forces = {};
p.reserves = 20;
p.tanks = 0;
p.shipped = false;
p.moved = 0;
p.advisors = {};
if (p.elites) p.elites = { ...p.elites, forces: {}, reserves: 5, tanks: 0 };
}
return g;
}
+307
View File
@@ -0,0 +1,307 @@
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();
}
});
+274
View File
@@ -0,0 +1,274 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction,
viewGame,
normalizeAutomaticGame,
type Game,
type Action,
} from '../game/engine';
import { quoteSmugglerShipment } from '../game/smuggler-shipment';
import { reserveShipmentCost } from '../game/shipment-price';
import { validateLeaderSkills } from '../game/leader-skills';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { smugglerShipmentGame } from './smuggler-shipment-fixture';
import { territory } from '../game/board';
const ship: Action = {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 3,
smuggler: true,
};
function conserve(g: Game) {
validateLeaderSkills(g.leaderSkills!, g.players);
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
if (p.elites)
assert.equal(
p.elites.reserves +
p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
5,
);
}
}
function reject(g: Game, actor: string, action: Action) {
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, actor, action));
assert.equal(JSON.stringify(g), before);
}
void test('Smuggler prices the total physical shipment as one fewer force and sends the actual payment to Guild', () => {
const game = smugglerShipmentGame();
game.players[0].spice = 2;
const before = game.players[1].spice;
const sent = applyAction(game, 'p', ship);
assert.equal(sent.players[0].spice, 0);
assert.equal(sent.players[0].reserves, 17);
assert.equal(sent.players[0].forces['arrakeen:10'], 3);
assert.equal(sent.players[1].spice, before + 2);
assert.ok(sent.log.some((l) => l.automatic?.name === 'Smuggler shipment'));
assert.deepEqual(
normalizeAutomaticGame(JSON.parse(JSON.stringify(sent))),
sent,
);
reject(sent, 'p', ship);
conserve(sent);
const desert = smugglerShipmentGame();
desert.players[0].spice = 4;
const arrived = applyAction(desert, 'p', {
...ship,
territory: 'wind_pass',
sector: 14,
});
assert.equal(arrived.players[0].spice, 0);
conserve(arrived);
});
void test('Smuggler requires an accompanying force and an entirely empty territory including advisors', () => {
const game = smugglerShipmentGame();
assert.equal(quoteSmugglerShipment(game, 'p', 'arrakeen', 1), null);
game.players[0].spice = 0;
reject(game, 'p', { ...ship, amount: 1 });
for (const owner of ['p', 'h']) {
const occupied = smugglerShipmentGame();
const p = occupied.players.find((p) => p.id === owner)!;
p.forces['wind_pass:15'] = 1;
p.reserves--;
assert.equal(quoteSmugglerShipment(occupied, 'p', 'wind_pass', 3), null);
}
const advisors = smugglerShipmentGame('beneGesserit', true);
advisors.players[0].forces['arrakeen:10'] = 1;
advisors.players[0].reserves--;
advisors.players[0].advisors = { arrakeen: { lockedTurn: advisors.turn } };
assert.equal(quoteSmugglerShipment(advisors, 'p', 'arrakeen', 3), null);
const hidden = viewGame(smugglerShipmentGame(), 'p');
hidden.leaderSkills!.assignments.find((a) => a.owner === 'p')!.faceUp = false;
assert.equal(quoteSmugglerShipment(hidden, 'p', 'arrakeen', 3), null);
for (const unavailable of [
{ dead: true },
{ capturedBy: 'h' },
{ gholaBy: 'h' },
]) {
const absent = smugglerShipmentGame();
const leader = absent.players[0].leaders.find(
(l) =>
l.id ===
absent.leaderSkills!.assignments.find((a) => a.owner === 'p')!.leader,
)!;
Object.assign(leader, unavailable);
assert.equal(quoteSmugglerShipment(absent, 'p', 'arrakeen', 3), null);
}
});
void test('Guild rounding follows the free force, while on-planet Fremen reinforcement has no Smuggler bonus', () => {
const guild = smugglerShipmentGame('guild');
guild.players[0].spice = 1;
const done = applyAction(guild, 'p', ship);
assert.equal(done.players[0].forces['arrakeen:10'], 3);
assert.equal(done.players[0].spice, 0);
conserve(done);
const fremen = smugglerShipmentGame('fremen');
assert.equal(quoteSmugglerShipment(fremen, 'p', 'the_great_flat', 5), null);
const reinforced = applyAction(fremen, 'p', {
...ship,
smuggler: false,
territory: 'the_great_flat',
sector: territory('the_great_flat').sectors.find((s) => s !== fremen.storm),
amount: 5,
});
assert.equal(reinforced.players[0].reserves, 15);
assert.ok(
!reinforced.log.some((l) => l.automatic?.name === 'Smuggler shipment'),
);
conserve(reinforced);
});
void test('Advanced pending shipment freezes the free companion through JSON and preserves elite counters', () => {
const game = smugglerShipmentGame('emperor', true);
game.players[0].spice = 2;
const pending = applyAction(game, 'p', { ...ship, elite: 2 });
assert.equal(pending.decision?.kind, 'guildShipment');
assert.equal(pending.pendingShipment?.smuggler?.amount, 3);
assert.equal(pending.players[0].reserves, 20);
assert.equal(pending.players[0].spice, 2);
for (const corrupt of [
(g: Game) => {
g.pendingShipment!.smuggler!.amount = 4;
},
(g: Game) => {
g.pendingShipment!.smuggler!.leader = 'stale';
},
(g: Game) => {
delete g.pendingShipment!.smuggler;
},
(g: Game) => {
g.decision = null;
},
(g: Game) => {
g.decision!.player = 'p';
},
(g: Game) => {
if (g.decision?.kind === 'guildShipment') g.decision.amount = 4;
},
(g: Game) => {
g.pendingShipment = null;
},
(g: Game) => {
g.players[1].forces['arrakeen:10'] = 1;
g.players[1].reserves--;
},
]) {
const bad = structuredClone(pending);
corrupt(bad);
assert.throws(() => viewGame(bad, 'p'));
assert.throws(() => normalizeAutomaticGame(bad));
reject(bad, 'h', { type: 'decision', allow: true });
}
const done = applyAction(JSON.parse(JSON.stringify(pending)), 'h', {
type: 'decision',
allow: true,
});
assert.equal(done.players[0].reserves, 17);
assert.equal(done.players[0].elites!.reserves, 3);
assert.equal(done.players[0].elites!.forces['arrakeen:10'], 2);
assert.equal(done.players[0].spice, 0);
conserve(done);
});
void test('all four bots use the free companion through the shared public quote without hidden resources', () => {
for (const difficulty of DIFFICULTIES) {
const g = smugglerShipmentGame();
g.players[0].spice = 1;
const view = viewGame(g, 'p');
view.players[0].bot = difficulty;
const offers = botActions(view).filter(
(a) => a.type === 'ship' && Number(a.amount) >= 2,
);
assert.ok(offers.length, difficulty);
for (const action of offers) {
const quote = quoteSmugglerShipment(
view,
'p',
String(action.territory),
Number(action.amount),
);
assert.ok(quote);
assert.equal(
reserveShipmentCost(
{ faction: 'emperor', halfRate: false },
'stronghold',
Number(action.amount) - 1,
),
1,
);
const sent = applyAction(g, 'p', action);
conserve(sent);
}
}
});
void test('declining the optional free companion preserves ordinary payments and old pending quotes', () => {
for (const value of [false, undefined]) {
const game = smugglerShipmentGame();
game.players[0].spice = 3;
const guildBefore = game.players[1].spice;
const done = applyAction(game, 'p', { ...ship, smuggler: value });
assert.equal(done.players[0].spice, 0);
assert.equal(done.players[1].spice, guildBefore + 3);
assert.equal(done.players[0].forces['arrakeen:10'], 3);
assert.ok(!done.log.some((l) => l.automatic?.name === 'Smuggler shipment'));
conserve(done);
}
const game = smugglerShipmentGame('emperor', true);
game.players[0].spice = 3;
const old = applyAction(game, 'p', { ...ship, smuggler: undefined });
assert.equal(old.pendingShipment?.smuggler, undefined);
assert.equal(old.pendingShipment?.cost, 3);
const resumed = applyAction(JSON.parse(JSON.stringify(old)), 'h', {
type: 'decision',
allow: true,
});
assert.equal(resumed.players[0].spice, 0);
conserve(resumed);
});
void test('a truthful shipment promise uses the free companion in its executable completion', () => {
let g = smugglerShipmentGame();
g.players[0].spice = 2;
const index = g.deck.findIndex((card) => card.effect === 'truthtrance');
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
g.players[1].hand.push(card);
g = applyAction(g, 'h', { type: 'card', card: card.id });
while (g.truthtrance?.stage === 'priority') {
const actor = g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!;
g = applyAction(g, actor.id, { type: 'truthPass' });
}
g = applyAction(g, 'h', {
type: 'truthAsk',
question: {
kind: 'shipment',
target: 'p',
territory: 'arrakeen',
minimum: 3,
},
});
assert.ok(viewGame(g, 'p').truthShipmentAnswers?.includes('yes'));
g = applyAction(g, 'p', { type: 'truthAnswer', answer: 'yes' });
const action = viewGame(JSON.parse(JSON.stringify(g)), 'p').shipmentCompletion
?.actions[0];
assert.equal(action?.type, 'ship');
assert.equal(action?.smuggler, true);
const done = applyAction(g, 'p', action!);
assert.equal(done.shipmentPromises?.[0].fulfilled, true);
assert.equal(done.players[0].spice, 0);
assert.equal(done.players[0].forces['arrakeen:10'], 3);
conserve(done);
});