Files
Dune/tests/smuggler-no-field.test.ts
Quintine 788453e07e 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
2026-09-13 21:37:50 +10:00

246 lines
8.6 KiB
TypeScript

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' },
);
}
}
});