Integrate Smuggler companion with owned Richese No-Field

Connect the GF9 FAQ combination: one free physical reserve force accompanies an owned concealed No-Field at its unchanged one-marker price. Preserve zero/three/five token values, actual supply at later reveal, optional human choice and all four legal AI paths. Genuine CHOAM & Richese Leader Skills development setup adds the two sourced battle cards without duplicating their Ix identities. Public mode and publication gates stay closed; allied and remaining combined routes are unfinished.

Bind the private declaration across initial No-Field cancellation and later Advanced Guild interception, including saved interruptions. Validate before projection/actions/normalization, hide the internal proof, reject altered declarations immutably, and withdraw the companion once only on successful commitment. Independent rules/privacy/persistence review found no blocker. Preserve other audit setup gates despite expanded deck assembly. Repair the existing randomized recovery test with a genuine seeded Rihani offer and explicit human choice, replacing an inappropriate AI-policy call on a human seat. Initial Karama and later Guild stop retain their distinct existing opportunity outcomes.

Validation: 72 focused regressions plus 10 deck tests; typecheck, lint, 4520 offline tests, production build and 43 HTTP tests passed. SQLite restart, concurrent allowance/cancellation and exact physical custody passed. Browser genuine assignment, private token selection, opt-in, one-spice shipment, refresh, five-force reveal and final refresh preserved six physical Arrakeen forces, 14 reserves, zero Tanks, four spice, Crysknife and Soo-Soo Sook. Genuine four-profile Basic/Advanced games completed in 77/116 actions with 3/4 JSON restores and no rejected candidates; Advanced exercised one companion shipment and later reveal.

After the outage the existing server was healthy. Preserved all 529 opening rooms and original seat/recovery records (563 after QA/HTTP). SQLite integrity and foreign keys passed. No restart, database reset or automation.

Verified source tree: c2aee4138bcb46a05814a340d62eadace3470247ca620844eb0375e78ea4d6c4
This commit is contained in:
Quintine committed 2026-09-13 21:37:50 +10:00
1 parent bf8036dfc1
commit 788453e07e
20 files changed
+1213 -51

No files matched your search

+1 -1
View File
@@ -277,7 +277,7 @@ void test('the eight supported base and Ix faction setup paths conserve native a
}
});
void test('the four factions requiring unfinished expansion decks reject genuine Homeworld setup without replacing decks or lobby metadata', () => {
void test('unprototyped CHOAM/Richese and Ecaz/Moritani combinations reject genuine Homeworld setup without replacing decks or lobby metadata', () => {
for (const roster of [
['choam', 'richese'],
['ecaz', 'moritani'],
+12 -1
View File
@@ -109,9 +109,20 @@ void test('the Ix expansion has fourteen distinct cards and combines into a fort
baseDeck().some((c) => c.name === 'Basilia Weapon'),
false,
);
for (const expansion of ['choam', 'ecaz', 'unknown'])
for (const expansion of ['ecaz', 'unknown'])
assert.throws(() => treacheryDeck([expansion]), /not implemented/);
});
void test('CHOAM & Richese adds or replaces the same two battle cards without duplicating physical custody', () => {
for (const expansions of [['choam'], ['ix', 'choam'], ['choam', 'ix']]) {
const cards = treacheryDeck(expansions);
assert.equal(cards.length, expansions.includes('ix') ? 47 : 35);
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
assert.equal(cards.filter(c => c.kind === 'poisonTooth').length, 1);
assert.equal(cards.filter(c => c.kind === 'artillery').length, 1);
assert.equal(cards.filter(c => c.effect === 'karama').length, 2);
assert.equal(cards.find(c => c.kind === 'poisonTooth')!.id, 'ix-poison-tooth');
}
});
void test('deck factories return independent physical cards and retain the earlier Ix identifiers', () => {
const a = treacheryDeck(['ix']),
b = treacheryDeck(['ix']);
+41 -15
View File
@@ -1,7 +1,11 @@
import test from 'node:test';
import { mock } from 'node:test';
import assert from 'node:assert/strict';
import type { DatabaseSync } from 'node:sqlite';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import {
LEADER_SKILL_CARDS,
type LeaderSkillId,
} from '../game/leader-skill-cards';
import {
initializeLeaderSkillsGameForAudit,
type Action,
@@ -9,7 +13,6 @@ import {
} from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { botActions } from '../game/bots';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
@@ -42,7 +45,7 @@ function concurrentBarrier() {
};
}
async function fixture() {
async function fixture(firstSkill?: LeaderSkillId) {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
const created = await store.rooms.createRoom(
@@ -69,7 +72,22 @@ async function fixture() {
.act(code, auth, room.version, { type: 'ready' }, clock);
}
const ready = await store.restart().readRoom(code);
const initialized = initializeLeaderSkillsGameForAudit(ready);
const targetIndex = firstSkill
? LEADER_SKILL_CARDS.findIndex((card) => card.id === firstSkill)
: -1;
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
if (firstSkill)
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
return array;
});
let initialized: Game;
try {
initialized = initializeLeaderSkillsGameForAudit(ready);
} finally {
mock.restoreAll();
}
if (firstSkill) assert.equal(initialized.leaderSkills!.deck[0], firstSkill);
sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(initialized), initialized.version, code);
@@ -278,13 +296,17 @@ void test('real setup completion preserves prediction, starting hands, assignmen
});
void test('battle death and Ghola revival preserve one private replacement skill through restart and concurrent selection', async () => {
const f = await fixture();
const f = await fixture('rihani-decipherer');
try {
let game = await act(f, 0, {
type: 'predict',
faction: 'atreides',
turn: 4,
});
assert.equal(
game.leaderSkills!.offers[f.auths[0].playerId].cards[0],
'rihani-decipherer',
);
for (const [index, auth] of f.auths.entries()) {
const seat = await f.restart().readSeatView(f.code, auth);
const offer = seat.leaderSkills!.offer!;
@@ -410,16 +432,20 @@ void test('battle death and Ghola revival preserve one private replacement skill
1,
);
assertSkillCustody(game);
// The randomly assigned winning skill can require casualty or Traitor choices
// before card cleanup. Resolve those real choices before testing Ghola revival.
for (let step = 0; step < 3 && ['rihani', 'sukRescue'].includes(game.decision?.kind ?? ''); step++) {
const owner = f.auths.findIndex((auth) => auth.playerId === game.decision!.player);
assert.ok(owner >= 0);
const seat = await f.restart().readSeatView(f.code, f.auths[owner]);
const [choice] = botActions(seat);
assert.ok(choice, 'the winning skill must have a legal saved continuation');
game = await act(f, owner, choice);
}
// The seeded winner has a real saved Rihani choice before card cleanup.
assert.equal(game.decision?.kind, 'rihani');
const owner = f.auths.findIndex(
(auth) => auth.playerId === game.decision!.player,
);
assert.ok(owner >= 0);
const seat = await f.restart().readSeatView(f.code, f.auths[owner]);
assert.equal(seat.automaticContinuationPending, false);
assert.equal(seat.decision?.kind, 'rihani');
game = await act(f, owner, {
type: 'decision',
event: seat.decision.event,
draw: false,
});
if (game.decision?.kind === 'battleCards')
game = await act(
f,
+2 -2
View File
@@ -154,7 +154,7 @@ void test('the public collection and ten full card topics are searchable, linked
);
});
void test('base, Ix and Ecaz presentations and deck activation are unchanged', () => {
void test('base, Ix and Ecaz presentations stay unchanged as the two CHOAM deck replacements become available', () => {
for (const card of [...baseDeck(), ...ixDeck()]) {
const presentation = cardPresentation(card);
assert.equal(presentation.availability, undefined);
@@ -173,7 +173,7 @@ void test('base, Ix and Ecaz presentations and deck activation are unchanged', (
}
assert.equal(treacheryDeck().length, 33);
assert.equal(treacheryDeck(['ix']).length, 47);
assert.throws(() => treacheryDeck(['choam']), /not implemented/);
assert.equal(treacheryDeck(['choam']).length, 35);
assert.throws(() => treacheryDeck(['richese']), /not implemented/);
});
+128
View File
@@ -0,0 +1,128 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
RicheseNoFieldControls,
SmugglerNoFieldChoice,
} from '../components/richese-no-field';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { applyAction, viewGame, type Game } from '../game/engine';
import { presenceAt } from '../game/force-presence';
import {
smugglerNoFieldAction,
smugglerNoFieldGame,
} from './smuggler-no-field-fixture';
function markup(game: Game, viewer = 'r', destination = 'arrakeen') {
return renderToStaticMarkup(
createElement(RicheseNoFieldControls, {
game: viewGame(game, viewer),
act() {},
busy: false,
destination,
sector: destination === 'arrakeen' ? 10 : 9,
allyPayment: '',
}),
);
}
void test('the private No-Field panel offers an unchecked free real-force companion without changing the marker price', () => {
const html = markup(smugglerNoFieldGame());
assert.match(html, /Private No-Field inventory/);
for (const value of [0, 3, 5])
assert.match(html, new RegExp(`No-Field ${value}`));
assert.match(
html,
/Use Smuggler to add 1 real reserve force at no added cost/,
);
assert.doesNotMatch(html, /type="checkbox" checked=""/);
assert.match(
html,
/No-Field token still uses the normal one-force shipment price/,
);
assert.match(html, /Ship concealed No-Field [035] · 1 spice/);
assert.match(
html,
/Smuggler companion is a separate real force that leaves reserves immediately/,
);
const optedIn = renderToStaticMarkup(
createElement(SmugglerNoFieldChoice, {
quote: { leader: 'richese-0', amount: 1 },
use: true,
busy: false,
onChange() {},
}),
);
assert.match(optedIn, /type="checkbox" checked=""/);
assert.match(optedIn, /hidden forces remain in reserves until revelation/);
assert.match(markup(smugglerNoFieldGame(true)), /Use Smuggler/);
});
void test('the companion choice follows empty-territory and real-reserve gates and remains owner-private', () => {
const occupied = smugglerNoFieldGame();
occupied.players.find((player) => player.id === 'h')!.forces = {
'arrakeen:9': 1,
};
occupied.players.find((player) => player.id === 'h')!.reserves = 19;
assert.doesNotMatch(markup(occupied), /Use Smuggler/);
const emptyReserves = smugglerNoFieldGame();
emptyReserves.players.find((player) => player.id === 'r')!.reserves = 0;
assert.doesNotMatch(markup(emptyReserves), /Use Smuggler/);
const privateGame = smugglerNoFieldGame();
assert.equal(markup(privateGame, 'h'), '');
assert.equal(viewGame(privateGame, 'h').richeseNoField!.private, null);
assert.doesNotMatch(markup(privateGame, 'h'), /No-Field [035]/);
});
void test('all four bot profiles add the legal companion to owned No-Field shipments', () => {
for (const advanced of [false, true])
for (const difficulty of DIFFICULTIES) {
const game = smugglerNoFieldGame(advanced);
const view = viewGame(game, 'r');
view.players.find((player) => player.id === 'r')!.bot = difficulty;
const noFieldActions = botActions(view).filter(
(action) => action.type === 'ship' && action.noField,
);
const candidates = noFieldActions.filter(
(action) => action.smuggler === true,
);
assert.ok(
candidates.length,
`${advanced ? 'Advanced' : 'Basic'} ${difficulty} omitted the companion`,
);
assert.ok(
noFieldActions.every((action) => action.smuggler === true),
`${advanced ? 'Advanced' : 'Basic'} ${difficulty} left a legal owned No-Field candidate unaugmented`,
);
const destination = String(candidates[0].territory);
let settled = applyAction(game, 'r', candidates[0]);
if (settled.decision?.kind === 'guildShipment')
settled = applyAction(settled, settled.decision.player, {
type: 'decision',
allow: true,
});
assert.equal(settled.players[0].reserves, 19);
assert.equal(presenceAt(settled.players[0], destination), 2);
}
});
void test('all profiles reveal a zero No-Field before moving when its real companion is alongside it', () => {
for (const difficulty of DIFFICULTIES) {
let game = smugglerNoFieldGame();
game = applyAction(game, 'r', smugglerNoFieldAction(game, 0, true));
game.active = 'r';
const view = viewGame(game, 'r');
view.players.find((player) => player.id === 'r')!.bot = difficulty;
const reveal = botActions(view)[0];
assert.equal(reveal.type, 'revealNoField', difficulty);
game = applyAction(game, 'r', reveal);
assert.equal(game.players[0].noField!.deployed, null);
assert.equal(game.players[0].reserves, 19);
assert.equal(presenceAt(game.players[0], 'arrakeen'), 1);
}
});
+122
View File
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import {
applyAction,
createGame,
newPlayer,
joinGame,
initializeLeaderSkillsGameForAudit,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { botActions } from '../game/bots';
/** Genuine Richese + Leader Skills setup, then an explicitly conserved shipment scenario. */
export function smugglerNoFieldGame(advanced = false): Game {
let g = createGame(
'SMUGNOFL',
newPlayer('r', 'Richese', 'richese'),
advanced,
['choam'],
);
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(g, newPlayer('g', 'Guild', '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 < 80; i++) {
let accepted = false;
for (const p of g.players) {
const view = viewGame(g, p.id);
const skills = view.leaderSkills!;
const options: Action[] =
g.setupStage === 'leaderSkills' && skills.offer
? [
{
type: 'leaderSkill',
event: skills.offer.event,
skill: p.id === 'r' ? 'smuggler' : skills.offer.cards[0],
leader: skills.eligibleLeaders[0].id,
},
]
: g.setupStage === 'traitors' && p.traitorChoices.length
? [{ type: 'traitor', leader: p.traitorChoices[0] }]
: botActions(view);
for (const a of options) {
try {
g = applyAction(g, p.id, a);
accepted = true;
break;
} catch {
/* Other seats may be waiting. */
}
}
if (accepted) break;
}
assert.ok(accepted, `setup ${g.setupStage}`);
}
assert.equal(g.status, 'playing');
assert.ok(g.players[0].noField);
Object.assign(g, {
phase: 5,
turn: 2,
storm: 18,
active: 'r',
order: ['r', 'h', 'g'],
movementRemaining: ['r', 'h', 'g'],
ready: [],
response: null,
decision: null,
phaseOpening: null,
});
for (const p of g.players) {
g.deck.push(...p.hand);
Object.assign(p, {
hand: [],
forces: {},
reserves: 20,
tanks: 0,
spice: 10,
shipped: false,
moved: 0,
advisors: {},
});
}
return g;
}
export function smugglerNoFieldAction(
g: Game,
value: 0 | 3 | 5 = 5,
smuggler = true,
): Action {
const p = g.players.find((p) => p.id === 'r')!;
return {
type: 'ship',
noField: p.noField!.tokens.find((t) => t.value === value)!.id,
event: p.noFieldEvent,
territory: 'arrakeen',
sector: 10,
smuggler,
};
}
export function conserveNoFieldForces(g: Game) {
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
p.id,
);
}
+316
View File
@@ -0,0 +1,316 @@
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 { unitStore } from './fixture-nexus-room-store';
import { placeFixtureHand } from './fixture-hand';
import {
conserveNoFieldForces,
smugglerNoFieldAction,
smugglerNoFieldGame,
} from './smuggler-no-field-fixture';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture(advanced = false, canceler: 'h' | 'g' | null = null) {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
const created = await store.rooms.createRoom(
'Richese Smuggler',
'richese',
advanced,
['choam'],
);
const joined = await store.rooms.joinRoom(
created.view.code,
'Harkonnen',
'harkonnen',
);
const guild = await store.rooms.joinRoom(created.view.code, 'Guild', 'guild');
const code = created.view.code;
const tokens = [created.token!, joined.token!, guild.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(['r', 'h', 'g'][index], code, seat.playerId);
const seats = await Promise.all(
tokens.map((token) => store.restart().authenticate(code, token)),
);
const state = smugglerNoFieldGame(advanced);
state.code = code;
state.version = (await store.rooms.readRoom(code)).version;
let karama = null;
if (canceler) {
karama = [
...state.deck,
...state.discard,
...state.players.flatMap((player) => player.hand),
].find((card) => card.effect === 'karama')!;
assert.ok(karama);
placeFixtureHand(state, canceler === 'h' ? 1 : 2, [karama]);
}
const action = smugglerNoFieldAction(state);
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, action, karama };
}
function barrier() {
let release!: () => void;
const ready = new Promise<void>((resolve) => {
release = resolve;
});
let arrivals = 0;
return async () => {
if (++arrivals === 2) release();
await ready;
};
}
function committedOnce(
results: PromiseSettledResult<unknown>[],
writes: { changes: number }[],
) {
assert.equal(
results.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.equal(
results.filter((result) => result.status === 'rejected').length,
1,
);
assert.deepEqual(
writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
}
void test('restart preserves the concealed companion through the No-Field and Guild stages, then concurrent allowance commits it once', async () => {
const f = await fixture(true, 'g');
try {
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
const initial = await f.restart().readRoom(f.code);
assert.equal(initial.response?.kind, 'richeseNoField');
assert.deepEqual(initial.pendingShipment?.smugglerCompanion, {
leader: initial.leaderSkills!.assignments.find(
(assignment) => assignment.owner === 'r',
)!.leader,
amount: 1,
});
assert.equal(initial.pendingShipment?.amount, 1);
assert.equal(initial.pendingShipment?.cost, 1);
assert.equal(initial.players[0].reserves, 20);
assert.deepEqual(initial.players[0].forces, {});
assert.equal(initial.players[0].noField!.deployed, null);
for (const seat of f.seats) {
const view = await f.restart().readSeatView(f.code, seat);
assert.equal(JSON.stringify(view).includes('noFieldSkillProof'), false);
assert.equal('pendingShipment' in view, false);
if (seat.playerId !== 'r')
assert.equal(
JSON.stringify(view).includes(String(f.action.noField)),
false,
);
}
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const passes = await Promise.allSettled([
f.rooms.act(
f.code,
f.seats[2],
initial.version,
{ type: 'passResponse' },
clock,
),
f
.restart()
.act(
f.code,
f.seats[2],
initial.version,
{ type: 'passResponse' },
clock,
),
]);
delete f.hooks.beforeWrite;
committedOnce(passes, f.writes);
const guildDecision = await f.restart().readRoom(f.code);
assert.equal(guildDecision.decision?.kind, 'guildShipment');
assert.deepEqual(
guildDecision.pendingShipment?.smugglerCompanion,
initial.pendingShipment?.smugglerCompanion,
);
assert.equal(guildDecision.players[0].reserves, 20);
assert.deepEqual(guildDecision.players[0].forces, {});
for (const seat of f.seats) {
const view = await f.restart().readSeatView(f.code, seat);
assert.equal(
JSON.stringify(view.decision).includes('noFieldSkillProof'),
false,
);
}
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const allow: Action = { type: 'decision', allow: true };
const allowances = await Promise.allSettled([
f.rooms.act(f.code, f.seats[2], guildDecision.version, allow, clock),
f.restart().act(f.code, f.seats[2], guildDecision.version, allow, clock),
]);
delete f.hooks.beforeWrite;
committedOnce(allowances, f.writes);
const shipped = await f.restart().readRoom(f.code);
assert.equal(shipped.pendingShipment, null);
assert.equal(shipped.decision, null);
assert.equal(shipped.players[0].reserves, 19);
assert.equal(shipped.players[0].forces['arrakeen:10'], 1);
assert.equal(shipped.players[0].spice, 9);
assert.equal(shipped.players[2].spice, 11);
assert.equal(
shipped.log.filter(
(entry) => entry.automatic?.name === 'No-Field shipment',
).length,
1,
);
conserveNoFieldForces(shipped);
await f.restart().act(
f.code,
f.seats[0],
shipped.version,
{
type: 'revealNoField',
event: shipped.players[0].noFieldEvent,
token: shipped.players[0].noField!.deployed!.tokenId,
},
clock,
);
const revealed = await f.restart().readRoom(f.code);
assert.equal(revealed.players[0].reserves, 14);
assert.equal(revealed.players[0].forces['arrakeen:10'], 6);
conserveNoFieldForces(revealed);
} finally {
f.sqlite.close();
}
});
void test('concurrent restored No-Field cancellation leaves the marker and companion in reserve custody', async () => {
const f = await fixture(false, 'h');
try {
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
const pending = await f.restart().readRoom(f.code);
assert.equal(pending.response?.kind, 'richeseNoField');
const cancel: Action = {
type: 'card',
mode: 'cancel',
card: f.karama!.id,
};
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const cancellations = await Promise.allSettled([
f.rooms.act(f.code, f.seats[1], pending.version, cancel, clock),
f.restart().act(f.code, f.seats[1], pending.version, cancel, clock),
]);
delete f.hooks.beforeWrite;
committedOnce(cancellations, f.writes);
const canceled = await f.restart().readRoom(f.code);
assert.equal(canceled.pendingShipment, null);
assert.equal(canceled.response, null);
assert.equal(canceled.players[0].reserves, 20);
assert.deepEqual(canceled.players[0].forces, {});
assert.equal(canceled.players[0].spice, 10);
assert.equal(canceled.players[0].shipped, false);
assert.equal(canceled.players[0].noField!.deployed, null);
assert.equal(canceled.players[0].noField!.lastShipped, null);
assert.equal(
canceled.discard.filter((card) => card.id === f.karama!.id).length,
1,
);
conserveNoFieldForces(canceled);
} finally {
f.sqlite.close();
}
});
void test('restored Guild cancellation consumes the shipment opportunity without withdrawing the companion', async () => {
const f = await fixture(true, 'g');
try {
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
let pending = await f.restart().readRoom(f.code);
assert.equal(pending.response?.kind, 'richeseNoField');
await f
.restart()
.act(
f.code,
f.seats[2],
pending.version,
{ type: 'passResponse' },
clock,
);
pending = await f.restart().readRoom(f.code);
assert.equal(pending.decision?.kind, 'guildShipment');
assert.equal(pending.players[0].reserves, 20);
await f.restart().act(
f.code,
f.seats[2],
pending.version,
{
type: 'card',
mode: 'special',
card: f.karama!.id,
target: 'r',
},
clock,
);
const canceled = await f.restart().readRoom(f.code);
assert.equal(canceled.pendingShipment, null);
assert.equal(canceled.decision, null);
assert.equal(canceled.players[0].reserves, 20);
assert.deepEqual(canceled.players[0].forces, {});
assert.equal(canceled.players[0].spice, 10);
assert.equal(canceled.players[0].shipped, true);
assert.equal(canceled.players[0].noField!.deployed, null);
assert.equal(canceled.players[0].noField!.lastShipped, null);
assert.equal(canceled.players[2].specialKaramaUsed, true);
assert.equal(
canceled.discard.filter((card) => card.id === f.karama!.id).length,
1,
);
conserveNoFieldForces(canceled);
} finally {
f.sqlite.close();
}
});
void test('a restored No-Field declaration without the Smuggler flag retains legacy token-only custody', async () => {
const f = await fixture();
try {
const { smuggler: _unused, ...legacy } = f.action;
await f.rooms.act(f.code, f.seats[0], f.state.version, legacy, clock);
const shipped = await f.restart().readRoom(f.code);
assert.equal(shipped.pendingShipment, null);
assert.equal(shipped.players[0].reserves, 20);
assert.deepEqual(shipped.players[0].forces, {});
assert.equal(shipped.players[0].spice, 9);
assert.ok(shipped.players[0].noField!.deployed);
assert.equal(
shipped.log.some((entry) => entry.text.includes('free Smuggler force')),
false,
);
conserveNoFieldForces(shipped);
} finally {
f.sqlite.close();
}
});
+245
View File
@@ -0,0 +1,245 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction,
viewGame,
normalizeAutomaticGame,
type Game,
type Action,
} from '../game/engine';
import { quoteSmugglerNoField } from '../game/smuggler-no-field';
import { presenceAt } from '../game/force-presence';
import {
smugglerNoFieldGame,
smugglerNoFieldAction,
conserveNoFieldForces,
} from './smuggler-no-field-fixture';
function reject(g: Game, actor: string, action: Action) {
const original = JSON.stringify(g);
assert.throws(() => applyAction(g, actor, action));
assert.equal(JSON.stringify(g), original);
}
function karama(g: Game, player = 'h') {
const i = g.deck.findIndex((c) => c.effect === 'karama');
assert.ok(i >= 0);
const card = g.deck.splice(i, 1)[0];
g.players.find((p) => p.id === player)!.hand.push(card);
return card.id;
}
void test('each hidden No-Field value ships one separate Smuggler force at the unchanged marker price', () => {
for (const value of [0, 3, 5] as const) {
const original = smugglerNoFieldGame();
const action = smugglerNoFieldAction(original, value);
const shipped = applyAction(original, 'r', action);
assert.equal(shipped.players[0].spice, 9);
assert.equal(shipped.players[2].spice, 11);
assert.equal(shipped.players[0].reserves, 19);
assert.equal(shipped.players[0].forces['arrakeen:10'], 1);
assert.equal(presenceAt(shipped.players[0], 'arrakeen'), 2);
assert.equal(shipped.players[0].noField!.deployed!.tokenId, action.noField);
assert.equal(original.players[0].reserves, 20);
conserveNoFieldForces(shipped);
const other = JSON.stringify(viewGame(shipped, 'h'));
assert.equal(other.includes(String(action.noField)), false);
assert.equal(other.includes('noFieldSkillProof'), false);
const restored = JSON.parse(JSON.stringify(shipped));
const revealed = applyAction(restored, 'r', {
type: 'revealNoField',
event: restored.players[0].noFieldEvent,
token: action.noField,
});
assert.equal(revealed.players[0].forces['arrakeen:10'], 1 + value);
assert.equal(revealed.players[0].reserves, 19 - value);
conserveNoFieldForces(revealed);
reject(revealed, 'r', action);
}
});
void test('a scarce reserve pays the companion once; later No-Field reveal uses only the remaining supply', () => {
for (const reserves of [1, 2]) {
const g = smugglerNoFieldGame();
g.players[0].reserves = reserves;
g.players[0].tanks = 20 - reserves;
let done = applyAction(g, 'r', smugglerNoFieldAction(g));
done = applyAction(done, 'r', {
type: 'revealNoField',
event: done.players[0].noFieldEvent,
token: done.players[0].noField!.deployed!.tokenId,
});
assert.equal(done.players[0].forces['arrakeen:10'], reserves);
assert.equal(done.players[0].reserves, 0);
conserveNoFieldForces(done);
}
const empty = smugglerNoFieldGame();
empty.players[0].reserves = 0;
empty.players[0].tanks = 20;
reject(empty, 'r', smugglerNoFieldAction(empty));
const plain = applyAction(empty, 'r', smugglerNoFieldAction(empty, 0, false));
assert.equal(plain.players[0].reserves, 0);
assert.equal(plain.players[0].spice, 9);
assert.deepEqual(plain.players[0].forces, {});
conserveNoFieldForces(plain);
});
void test('optional decline and desert pricing preserve the full one-marker price', () => {
for (const smuggler of [false, undefined]) {
const g = smugglerNoFieldGame();
const done = applyAction(g, 'r', { ...smugglerNoFieldAction(g), smuggler });
assert.equal(done.players[0].reserves, 20);
assert.equal(done.players[0].spice, 9);
assert.deepEqual(done.players[0].forces, {});
}
const g = smugglerNoFieldGame();
const done = applyAction(g, 'r', {
...smugglerNoFieldAction(g),
territory: 'wind_pass',
sector: 14,
});
assert.equal(done.players[0].spice, 8);
assert.equal(done.players[2].spice, 12);
assert.equal(done.players[0].forces['wind_pass:14'], 1);
conserveNoFieldForces(done);
});
void test('Smuggler companion requires native living public custody and a wholly empty territory', () => {
const g = smugglerNoFieldGame();
assert.ok(quoteSmugglerNoField(g, 'r', 'arrakeen'));
for (const owner of ['r', 'h']) {
const occupied = smugglerNoFieldGame();
const p = occupied.players.find((p) => p.id === owner)!;
p.reserves--;
p.forces['wind_pass:15'] = 1;
assert.equal(quoteSmugglerNoField(occupied, 'r', 'wind_pass'), null);
reject(occupied, 'r', {
...smugglerNoFieldAction(occupied),
territory: 'wind_pass',
sector: 14,
});
}
const hidden = viewGame(g, 'r');
hidden.leaderSkills!.assignments.find((a) => a.owner === 'r')!.faceUp = false;
assert.equal(quoteSmugglerNoField(hidden, 'r', 'arrakeen'), null);
for (const absent of [
{ dead: true },
{ capturedBy: 'h' },
{ gholaBy: 'h' },
]) {
const unavailable = smugglerNoFieldGame();
const leader = unavailable.players[0].leaders.find(
(l) =>
l.id ===
unavailable.leaderSkills!.assignments.find((a) => a.owner === 'r')!
.leader,
)!;
Object.assign(leader, absent);
assert.equal(quoteSmugglerNoField(unavailable, 'r', 'arrakeen'), null);
}
});
void test('initial Karama cancellation preserves the companion, spice, token history and ordinary shipment opportunity', () => {
const g = smugglerNoFieldGame();
const card = karama(g);
const pending = applyAction(g, 'r', smugglerNoFieldAction(g));
assert.equal(pending.response?.kind, 'richeseNoField');
assert.equal(pending.pendingShipment?.amount, 1);
assert.equal(pending.pendingShipment?.smugglerCompanion?.amount, 1);
const canceled = applyAction(JSON.parse(JSON.stringify(pending)), 'h', {
type: 'card',
mode: 'cancel',
card,
});
assert.equal(canceled.players[0].reserves, 20);
assert.equal(canceled.players[0].spice, 10);
assert.equal(canceled.players[0].noField!.lastShipped, null);
assert.equal(canceled.players[0].shipped, false);
const fallback = applyAction(canceled, 'r', {
type: 'ship',
amount: 1,
territory: 'arrakeen',
sector: 10,
});
assert.equal(fallback.players[0].reserves, 19);
conserveNoFieldForces(fallback);
});
void test('Advanced Guild allows the saved companion once and hides its private declaration proof', () => {
const g = smugglerNoFieldGame(true);
const action = smugglerNoFieldAction(g);
const pending = applyAction(g, 'r', action);
assert.equal(pending.decision?.kind, 'guildShipment');
assert.equal(pending.pendingShipment?.amount, 1);
assert.equal(pending.players[0].reserves, 20);
for (const p of pending.players) {
const view = JSON.stringify(viewGame(pending, p.id));
assert.equal(view.includes('noFieldSkillProof'), false);
if (p.id !== 'r')
assert.equal(view.includes(String(action.noField)), false);
}
const done = applyAction(JSON.parse(JSON.stringify(pending)), 'g', {
type: 'decision',
allow: true,
});
assert.equal(done.players[0].reserves, 19);
assert.equal(done.players[0].forces['arrakeen:10'], 1);
conserveNoFieldForces(done);
reject(done, 'g', { type: 'decision', allow: true });
});
void test('both saved No-Field stages reject changed companion, proof, destination and missing continuation before mutation', () => {
for (const advanced of [false, true]) {
const g = smugglerNoFieldGame(advanced);
if (!advanced) karama(g);
const pending = applyAction(g, 'r', smugglerNoFieldAction(g));
const corruptions: ((g: Game) => void)[] = [
(x) => {
delete x.pendingShipment!.smugglerCompanion;
},
(x) => {
x.pendingShipment!.smugglerCompanion!.leader = 'stale';
},
(x) => {
delete x.pendingShipment!.noFieldSkillProof;
},
(x) => {
x.pendingShipment!.noFieldSkillProof = 'stale';
},
(x) => {
x.pendingShipment!.amount = 2;
},
(x) => {
x.pendingShipment!.cost = 0;
},
(x) => {
x.pendingShipment!.territory = 'carthag';
},
(x) => {
x.pendingShipment = null;
},
(x) => {
x.response = null;
x.decision = null;
},
(x) => {
x.players[0].reserves = 0;
x.players[0].tanks = 20;
},
(x) => {
x.players[1].reserves--;
x.players[1].forces['arrakeen:10'] = 1;
},
];
for (const corrupt of corruptions) {
const bad = structuredClone(pending);
corrupt(bad);
assert.throws(() => viewGame(bad, 'r'));
assert.throws(() => normalizeAutomaticGame(bad));
reject(
bad,
advanced ? 'g' : 'h',
advanced ? { type: 'decision', allow: true } : { type: 'pass' },
);
}
}
});