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
246 lines
8.6 KiB
TypeScript
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' },
|
|
);
|
|
}
|
|
}
|
|
});
|