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:
1 parent
06f46c6f79
commit
9381ef140d
18 files changed
+1015
-25
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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() {},
|
||||
}),
|
||||
),
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user