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
123 lines
3.2 KiB
TypeScript
123 lines
3.2 KiB
TypeScript
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,
|
|
);
|
|
}
|