Add server-derived physical draw counts above the board after setup begins. Counts exclude hands, auction pools, faction caches and discards; the client receives only two numbers, without deck identities or order. Lobby counts are absent, zero remains explicit and existing refill/custody rules are unchanged. An internal guide explains pile boundaries, empty/refill behavior, first-turn worm return and existing authorized inspection. Nexus counts remain separate. Independent review found no material issue. Six focused cases cover genuine Basic setup, every viewer, private-field exclusion, JSON/input immutability, actual auction draws and refill, physical custody, Spice refill with Thumper annotations excluded, skipped worm return and connected busy/empty/lobby UI. The initial refill assertion assumed a single card; it was corrected to account for a legal randomized chain of worms before a territory. The original broad check caught seven view-equality failures in privacy fixtures that also changed the now-public deck count. Their four test files now vary hidden allocations/identities at fixed public counts, retain whole-view assertions and check physical custody. The Sapho fixture now returns its swapped card rather than losing it. A reviewed CHOAM correction keeps its owner empty during donor1to2 by staging a hidden discard; the original oracle test remains intact. All70 affected focused tests pass. Types, lint, all 4990 offline tests, production build and 49 HTTP tests passed on stable source. Browser QA reused the existing three-human Basic Nexus without any game action. Treachery29 and Spice19 matched saved physical arrays; 390px layout fit within339px without internal overflow, with16px body/14px labels. The internal guide worked by click and Enter, and Back/refresh restored the same counts. Normal viewport restored and preview retained. Screenshot capture timed out; full visual, screen-reader,200% text and combined-game acceptance stay open. All 1499 opening rooms, 3171 seats and auxiliary rows are unchanged. SQLite integrity and foreign keys pass; isolated HTTP rooms are new. Healthy server reused without restart, migration, reset or recurring automation. Full AI refinement remains deferred and no mode/publication gate was opened. Private evidence: 20260921-deck-status. Verified source tree: 091be8f49f9b637b9a79da43271cbffdff5cf8c9f653484afa7041c71af88e98
619 lines
21 KiB
TypeScript
619 lines
21 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createElement } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeBaseGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck, leaders } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { JuiceOfSapho } from '../components/juice-of-sapho';
|
|
|
|
const card = 'richese-juice-of-sapho';
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
function initial(advanced = false) {
|
|
let g = createGame(
|
|
'SAPHOENGINE',
|
|
newPlayer('a', 'Atreides', 'atreides'),
|
|
advanced,
|
|
);
|
|
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
|
|
joinGame(g, newPlayer('g', 'Guild', 'guild'));
|
|
g.players.forEach((p) => {
|
|
p.ready = true;
|
|
});
|
|
g = advanced
|
|
? initializeBaseGameForAudit(g)
|
|
: applyAction(g, 'a', { type: 'start' });
|
|
for (const p of g.players)
|
|
if (p.traitorChoices.length)
|
|
g = applyAction(g, p.id, {
|
|
type: 'traitor',
|
|
leader: p.traitorChoices[0],
|
|
});
|
|
assert.equal(g.status, 'playing');
|
|
Object.assign(g, {
|
|
turn: 2,
|
|
storm: 18,
|
|
response: null,
|
|
decision: null,
|
|
phaseOpening: null,
|
|
order: ['a', 'e', 'g'],
|
|
ready: [],
|
|
deck: baseDeck(),
|
|
discard: [],
|
|
richeseCache: richeseCards(),
|
|
});
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 20;
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
}
|
|
return g;
|
|
}
|
|
function hold(g: Game, id: string) {
|
|
const at = g.richeseCache!.findIndex((c) => c.id === card);
|
|
assert.ok(at >= 0);
|
|
g.players
|
|
.find((p) => p.id === id)!
|
|
.hand.push(g.richeseCache!.splice(at, 1)[0]);
|
|
}
|
|
function movement(owner = 'e', advanced = false) {
|
|
let g = initial(advanced);
|
|
g.phase = 4;
|
|
hold(g, owner);
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
assert.equal(g.phase, 5);
|
|
return g;
|
|
}
|
|
function auction(owner = 'e', method = 'onceAround', fullHand = false) {
|
|
let g = initial();
|
|
g.players[2].faction = 'richese';
|
|
g.players[2].leaders = leaders('richese');
|
|
hold(g, owner);
|
|
if (fullHand)
|
|
g.players.find((p) => p.id === owner)!.hand.push(...g.deck.splice(0, 3));
|
|
g.phase = 2;
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
g = applyAction(g, 'g', {
|
|
type: 'decision',
|
|
event: g.richeseBidding!.event,
|
|
position: 'first',
|
|
});
|
|
g = applyAction(g, 'g', {
|
|
type: 'decision',
|
|
event: g.richeseBidding!.event,
|
|
card: g.richeseCache![0].id,
|
|
method,
|
|
direction: 'counterclockwise',
|
|
});
|
|
assert.equal(g.richeseAuction!.method, method);
|
|
return g;
|
|
}
|
|
function action(
|
|
g: Game,
|
|
mode: 'first' | 'last',
|
|
scope = g.phase === 5 ? 'movement' : 'onceAround',
|
|
): Action {
|
|
return {
|
|
type: 'card',
|
|
card,
|
|
mode,
|
|
scope,
|
|
event:
|
|
scope === 'movement' ? `movement:${g.turn}` : g.richeseAuction!.event,
|
|
};
|
|
}
|
|
function reject(g: Game, owner: string, a: Action) {
|
|
const before = reload(g);
|
|
assert.throws(() => applyAction(g, owner, a));
|
|
assert.deepEqual(reload(g), before);
|
|
}
|
|
function end(g: Game) {
|
|
return applyAction(g, g.active!, { type: 'endMovement' });
|
|
}
|
|
function bid(g: Game, owner: string, amount: number | null) {
|
|
return applyAction(g, owner, {
|
|
type: 'richeseBid',
|
|
event: g.richeseAuction!.event,
|
|
amount,
|
|
});
|
|
}
|
|
|
|
void test('Once Around first changes only the still-unstarted lot and discards exactly once after JSON recovery', () => {
|
|
const before = auction();
|
|
assert.notEqual(before.richeseAuction!.active, 'e');
|
|
const request = action(before, 'first');
|
|
const g = applyAction(reload(before), 'e', request);
|
|
assert.equal(g.richeseAuction!.active, 'e');
|
|
assert.equal(g.active, 'e');
|
|
assert.deepEqual(g.order, before.order);
|
|
assert.deepEqual(g.richeseAuction!.tieOrder, before.richeseAuction!.tieOrder);
|
|
assert.equal(g.discard.filter((c) => c.id === card).length, 1);
|
|
assert.equal(
|
|
g.players[1].hand.some((c) => c.id === card),
|
|
false,
|
|
);
|
|
reject(g, 'e', request);
|
|
const next = bid(reload(g), 'e', 2);
|
|
assert.equal(next.richeseAuction!.bid, 2);
|
|
assert.deepEqual(next.richeseAuction!.acted, ['e']);
|
|
});
|
|
|
|
void test('Once Around last preserves prior bids and permits a final outbid after Richese', () => {
|
|
let g = auction();
|
|
const first = g.richeseAuction!.active!;
|
|
assert.notEqual(first, 'e');
|
|
g = bid(g, first, 2);
|
|
const lotCard = g.richeseAuction!.cardId;
|
|
g = applyAction(g, 'e', action(g, 'last'));
|
|
assert.equal(g.richeseAuction!.bid, 2);
|
|
assert.equal(g.richeseAuction!.bidder, first);
|
|
assert.deepEqual(g.richeseAuction!.acted, [first]);
|
|
assert.equal(g.richeseAuction!.active, 'g');
|
|
g = bid(g, 'g', 3);
|
|
assert.equal(g.richeseAuction!.active, 'e');
|
|
reject(g, 'e', {
|
|
type: 'richeseBid',
|
|
event: g.richeseAuction!.event,
|
|
amount: 3,
|
|
});
|
|
g = bid(reload(g), 'e', 4);
|
|
assert.ok(g.players[1].hand.some((c) => c.id === lotCard));
|
|
assert.equal(g.players[1].spice, 16);
|
|
assert.equal(g.players[2].spice, 24);
|
|
assert.deepEqual(g.order, ['a', 'e', 'g']);
|
|
});
|
|
|
|
void test('Once Around rejects late first, completed opportunity, no-op and stale event without card loss', () => {
|
|
let g = auction();
|
|
g = bid(g, g.richeseAuction!.active!, 1);
|
|
reject(g, 'e', action(g, 'first'));
|
|
reject(g, 'e', { ...action(g, 'last'), event: 'expired' });
|
|
g = bid(g, 'e', 2);
|
|
reject(g, 'e', action(g, 'last'));
|
|
const alreadyLast = auction('g');
|
|
reject(alreadyLast, 'g', action(alreadyLast, 'last'));
|
|
const alreadyFirst = auction('a');
|
|
reject(alreadyFirst, 'a', action(alreadyFirst, 'first'));
|
|
});
|
|
|
|
void test('Silent bidding has no Sapho ordering options or reordered storm tie outcome', () => {
|
|
let g = auction('e', 'silent');
|
|
assert.deepEqual(viewGame(g, 'e').saphoOptions, []);
|
|
reject(g, 'e', action(g, 'last'));
|
|
const lot = g.richeseAuction!.cardId;
|
|
for (const id of ['g', 'e', 'a']) g = bid(g, id, 2);
|
|
assert.ok(g.players[0].hand.some((c) => c.id === lot));
|
|
});
|
|
|
|
void test('Basic movement first/last preserve physical order and give each faction precisely one combined turn', () => {
|
|
for (const mode of ['first', 'last'] as const) {
|
|
const before = movement();
|
|
let g = applyAction(before, 'e', action(before, mode));
|
|
const acted: string[] = [];
|
|
while (g.phase === 5) {
|
|
acted.push(g.active!);
|
|
g = end(reload(g));
|
|
}
|
|
assert.deepEqual(
|
|
acted,
|
|
mode === 'first' ? ['e', 'a', 'g'] : ['a', 'g', 'e'],
|
|
);
|
|
assert.deepEqual(g.order, before.order);
|
|
assert.deepEqual(g.movementRemaining, []);
|
|
assert.equal(g.discard.filter((c) => c.id === card).length, 1);
|
|
}
|
|
});
|
|
|
|
void test('The already-current player cannot spend Sapho on first, while last works at the next clean boundary', () => {
|
|
let g = end(movement());
|
|
reject(g, 'e', action(g, 'first'));
|
|
g = applyAction(g, 'e', action(g, 'last'));
|
|
assert.deepEqual(g.movementRemaining, ['g', 'e']);
|
|
assert.equal(g.active, 'g');
|
|
});
|
|
|
|
void test('Sapho first reorders only the unstarted movement suffix after a real completed shipment', () => {
|
|
let g = movement('g');
|
|
g = applyAction(g, 'a', {
|
|
type: 'ship',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
amount: 2,
|
|
});
|
|
g = end(g);
|
|
assert.equal(g.active, 'e');
|
|
const completed = reload(g).players[0];
|
|
assert.equal(completed.shipped, true);
|
|
assert.equal(completed.reserves, 18);
|
|
const before = reload(g);
|
|
const controls = renderToStaticMarkup(
|
|
createElement(JuiceOfSapho, {
|
|
game: viewGame(g, 'g'),
|
|
act: () => {},
|
|
busy: false,
|
|
}),
|
|
);
|
|
assert.match(
|
|
controls,
|
|
/Take your movement turn first and discard Juice of Sapho/,
|
|
);
|
|
assert.match(controls, /Completed turns stay completed/);
|
|
assert.equal(
|
|
renderToStaticMarkup(
|
|
createElement(JuiceOfSapho, {
|
|
game: viewGame(g, 'e'),
|
|
act: () => {},
|
|
busy: false,
|
|
}),
|
|
),
|
|
'',
|
|
);
|
|
const request = action(g, 'first');
|
|
g = applyAction(reload(g), 'g', request);
|
|
assert.deepEqual(g.movementRemaining, ['g', 'e']);
|
|
assert.equal(g.active, 'g');
|
|
assert.deepEqual(g.order, before.order);
|
|
assert.deepEqual(g.players[0], completed);
|
|
assert.deepEqual(g.players[1], before.players[1]);
|
|
assert.equal(g.discard.filter((c) => c.id === card).length, 1);
|
|
assert.equal(
|
|
g.players[2].hand.some((c) => c.id === card),
|
|
false,
|
|
);
|
|
reject(g, 'g', request);
|
|
g = end(reload(g));
|
|
assert.equal(g.active, 'e');
|
|
g = end(reload(g));
|
|
assert.notEqual(g.phase, 5);
|
|
assert.deepEqual(g.movementRemaining, []);
|
|
assert.deepEqual(g.players[0], completed);
|
|
|
|
const startedNext = applyAction(before, 'e', {
|
|
type: 'ship',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
amount: 1,
|
|
});
|
|
assert.deepEqual(viewGame(startedNext, 'g').saphoOptions, []);
|
|
reject(startedNext, 'g', request);
|
|
});
|
|
|
|
void test('Genuine Advanced setup permits Sapho first after Guild finishes, retaining unspent Guild priority beforehand', () => {
|
|
let g = movement('e', true);
|
|
assert.equal(g.players[1].elites!.reserves, 5);
|
|
assert.equal(g.decision?.kind, 'guildTiming');
|
|
reject(g, 'e', action(g, 'first'));
|
|
const deferred = applyAction(reload(g), 'g', {
|
|
type: 'decision',
|
|
take: false,
|
|
});
|
|
assert.equal(deferred.active, 'a');
|
|
reject(deferred, 'e', action(deferred, 'first'));
|
|
|
|
g = applyAction(g, 'g', { type: 'decision', take: true });
|
|
assert.equal(g.active, 'g');
|
|
reject(g, 'e', action(g, 'first'));
|
|
g = applyAction(g, 'g', {
|
|
type: 'ship',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
amount: 2,
|
|
});
|
|
assert.equal(g.decision?.kind, 'guildShipment');
|
|
g = applyAction(g, 'g', { type: 'decision', allow: true });
|
|
g = end(g);
|
|
const completed = reload(g).players[2];
|
|
assert.equal(g.active, 'a');
|
|
assert.deepEqual(g.movementRemaining, ['a', 'e']);
|
|
g = applyAction(reload(g), 'e', action(g, 'first'));
|
|
assert.equal(g.active, 'e');
|
|
assert.deepEqual(g.movementRemaining, ['e', 'a']);
|
|
assert.deepEqual(g.players[2], completed);
|
|
g = end(reload(g));
|
|
assert.equal(g.active, 'a');
|
|
g = end(reload(g));
|
|
assert.notEqual(g.phase, 5);
|
|
assert.deepEqual(g.players[2], completed);
|
|
assert.equal(g.discard.filter((c) => c.id === card).length, 1);
|
|
});
|
|
|
|
void test('Sapho first cannot move a queued participant with a spent shipment, movement or preparation', () => {
|
|
for (const kind of ['shipment', 'movement', 'hajr'] as const) {
|
|
const g = end(movement('g'));
|
|
if (kind === 'shipment') g.players[2].shipped = true;
|
|
if (kind === 'movement') g.players[2].moved = 1;
|
|
if (kind === 'hajr') g.hajr.push('g');
|
|
assert.deepEqual(viewGame(g, 'g').saphoOptions, []);
|
|
reject(g, 'g', action(g, 'first'));
|
|
}
|
|
});
|
|
|
|
void test('All four AI profiles use the private Sapho first option after a completed movement prefix', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let g = movement('g');
|
|
g = applyAction(g, 'a', {
|
|
type: 'ship',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
amount: 2,
|
|
});
|
|
g = end(g);
|
|
const view = viewGame(g, 'g');
|
|
view.players.find((p) => p.id === 'g')!.bot = difficulty;
|
|
const untouched = structuredClone(view);
|
|
const proposals = botActions(view);
|
|
assert.deepEqual(view, untouched);
|
|
assert.deepEqual(proposals, [action(g, 'first')]);
|
|
for (const id of ['a', 'e'])
|
|
assert.deepEqual(viewGame(g, id).saphoOptions, []);
|
|
const next = applyAction(reload(g), 'g', proposals[0]);
|
|
assert.equal(next.active, 'g');
|
|
assert.deepEqual(next.players[0], g.players[0]);
|
|
assert.equal(next.discard.filter((c) => c.id === card).length, 1);
|
|
}
|
|
});
|
|
|
|
void test('Shipment and movement commitments both prevent splitting the active combined turn', () => {
|
|
let g = movement();
|
|
g = applyAction(g, 'a', {
|
|
type: 'ship',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
amount: 1,
|
|
});
|
|
assert.equal(g.players[0].shipped, true);
|
|
assert.deepEqual(viewGame(g, 'e').saphoOptions, []);
|
|
reject(g, 'e', action(g, 'last'));
|
|
let moved = movement();
|
|
moved.players[0].forces = { 'arrakeen:10': 1 };
|
|
moved.players[0].reserves = 19;
|
|
moved = applyAction(moved, 'a', {
|
|
type: 'move',
|
|
from: 'arrakeen:10',
|
|
territory: 'imperial_basin',
|
|
sector: 10,
|
|
amount: 1,
|
|
});
|
|
assert.equal(moved.players[0].moved, 1);
|
|
reject(moved, 'e', action(moved, 'last'));
|
|
reject(moved, 'e', action(moved, 'first'));
|
|
});
|
|
|
|
void test('Completed holder and already-first/already-last movement no-ops cannot consume Sapho', () => {
|
|
const first = movement('a');
|
|
reject(first, 'a', action(first, 'first'));
|
|
const last = movement('g');
|
|
reject(last, 'g', action(last, 'last'));
|
|
const completed = end(first);
|
|
assert.deepEqual(viewGame(completed, 'a').saphoOptions, []);
|
|
reject(completed, 'a', action(completed, 'last'));
|
|
});
|
|
|
|
void test('Advanced Guild remains before Sapho last across repeated deferrals and saved-state normalization', () => {
|
|
let g = movement('e', true);
|
|
assert.equal(g.decision?.kind, 'guildTiming');
|
|
reject(g, 'e', action(g, 'last'));
|
|
g = applyAction(g, 'g', { type: 'decision', take: false });
|
|
assert.equal(g.active, 'a');
|
|
reject(g, 'e', action(g, 'first'));
|
|
g = applyAction(g, 'e', action(g, 'last'));
|
|
assert.deepEqual(g.movementRemaining, ['a', 'g', 'e']);
|
|
assert.equal(
|
|
g.decision,
|
|
null,
|
|
'an unchanged current actor must not reopen the resolved Guild choice',
|
|
);
|
|
assert.equal(g.active, 'a');
|
|
g = end(reload(g));
|
|
if (g.decision?.kind === 'guildTiming')
|
|
g = applyAction(g, 'g', { type: 'decision', take: false });
|
|
assert.equal(g.active, 'g');
|
|
g = normalizeAutomaticGame(reload(g));
|
|
assert.equal(g.active, 'g');
|
|
g = end(g);
|
|
assert.equal(g.active, 'e');
|
|
g = end(g);
|
|
assert.notEqual(g.phase, 5);
|
|
assert.deepEqual(g.order, ['a', 'e', 'g']);
|
|
});
|
|
|
|
void test('Sapho last respects an already granted early Guild turn, including Guild itself moving to last', () => {
|
|
for (const holder of ['a', 'g']) {
|
|
let g = movement(holder, true);
|
|
g = applyAction(g, 'g', { type: 'decision', take: true });
|
|
assert.equal(g.active, 'g');
|
|
assert.equal(g.guildTimingGranted, true);
|
|
assert.equal(g.decision, null);
|
|
assert.equal(g.response, null);
|
|
assert.deepEqual(g.movementRemaining, ['a', 'e', 'g']);
|
|
const heldView = viewGame(g, holder);
|
|
assert.ok(
|
|
heldView.saphoOptions.some(
|
|
(option) => option.scope === 'movement' && option.mode === 'last',
|
|
),
|
|
);
|
|
assert.equal(
|
|
heldView.saphoOptions.some((option) => option.mode === 'first'),
|
|
false,
|
|
);
|
|
reject(g, holder, action(g, 'first'));
|
|
g = applyAction(reload(g), holder, action(g, 'last'));
|
|
assert.equal(
|
|
g.guildTimingGranted,
|
|
true,
|
|
'Sapho does not revoke the resolved Guild power',
|
|
);
|
|
assert.equal(
|
|
g.decision,
|
|
null,
|
|
'last priority does not reopen Guild timing',
|
|
);
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.active, holder === 'a' ? 'g' : 'a');
|
|
const expected = holder === 'a' ? ['g', 'e', 'a'] : ['a', 'e', 'g'];
|
|
assert.deepEqual(g.movementRemaining, expected);
|
|
assert.equal(g.saphoMovementLast?.player, holder);
|
|
const acted: string[] = [];
|
|
while (g.phase === 5) {
|
|
assert.equal(g.decision, null);
|
|
acted.push(g.active!);
|
|
g = end(normalizeAutomaticGame(reload(g)));
|
|
}
|
|
assert.deepEqual(acted, expected);
|
|
assert.deepEqual(g.order, ['a', 'e', 'g']);
|
|
assert.deepEqual(g.movementRemaining, []);
|
|
assert.equal(g.saphoMovementLast, null);
|
|
assert.equal(g.discard.filter((c) => c.id === card).length, 1);
|
|
}
|
|
});
|
|
|
|
void test('early Guild timing makes the previous penultimate faction actually last without spending Sapho', () => {
|
|
let g = movement('e', true);
|
|
g = applyAction(g, 'g', { type: 'decision', take: true });
|
|
assert.equal(g.active, 'g');
|
|
assert.deepEqual(g.movementRemaining, ['a', 'e', 'g']);
|
|
assert.deepEqual(viewGame(g, 'e').saphoOptions, []);
|
|
reject(g, 'e', action(g, 'last'));
|
|
assert.ok(g.players[1].hand.some((c) => c.id === card));
|
|
const acted: string[] = [];
|
|
while (g.phase === 5) {
|
|
acted.push(g.active!);
|
|
g = end(g);
|
|
}
|
|
assert.deepEqual(acted, ['g', 'a', 'e']);
|
|
});
|
|
|
|
void test('a granted early Guild turn becomes ineligible for Sapho interruption after shipment starts', () => {
|
|
for (const holder of ['a', 'g']) {
|
|
let g = movement(holder, true);
|
|
g = applyAction(g, 'g', { type: 'decision', take: true });
|
|
g = applyAction(g, 'g', {
|
|
type: 'ship',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
amount: 1,
|
|
});
|
|
if (g.decision?.kind === 'guildShipment')
|
|
g = applyAction(g, 'g', { type: 'decision', allow: true });
|
|
assert.equal(g.players[2].shipped, true);
|
|
assert.equal(g.active, 'g');
|
|
assert.deepEqual(viewGame(g, holder).saphoOptions, []);
|
|
reject(g, holder, action(g, 'last'));
|
|
assert.ok(
|
|
g.players.find((p) => p.id === holder)!.hand.some((c) => c.id === card),
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('a started movement preparation locks the turn before forces are shipped or moved', () => {
|
|
for (const effect of ['hajr', 'karama']) {
|
|
let g = movement();
|
|
const index = g.deck.findIndex((c) => c.effect === effect);
|
|
const preparation = g.deck.splice(index, 1)[0];
|
|
g.players[0].hand.push(preparation);
|
|
g = applyAction(g, 'a', {
|
|
type: 'card',
|
|
card: preparation.id,
|
|
...(effect === 'karama' ? { mode: 'shipment' } : {}),
|
|
});
|
|
assert.equal(g.players[0].moved, 0);
|
|
assert.equal(g.players[0].shipped, false);
|
|
assert.deepEqual(viewGame(g, 'e').saphoOptions, []);
|
|
reject(g, 'e', action(g, 'last'));
|
|
}
|
|
});
|
|
|
|
void test('saved last protection rejects corrupt phase, event, owner and duplicate or reordered queues without repair', () => {
|
|
const before = movement();
|
|
const valid = applyAction(before, 'e', action(before, 'last'));
|
|
assert.deepEqual(normalizeAutomaticGame(reload(valid)), reload(valid));
|
|
const corruptions: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.saphoMovementLast!.turn--;
|
|
},
|
|
(g) => {
|
|
g.saphoMovementLast!.event = 'movement:expired';
|
|
},
|
|
(g) => {
|
|
g.saphoMovementLast!.player = 'foreign';
|
|
},
|
|
(g) => {
|
|
g.phase = 6;
|
|
},
|
|
(g) => {
|
|
g.movementRemaining = ['a', 'e', 'g'];
|
|
},
|
|
(g) => {
|
|
g.movementRemaining = ['a', 'a', 'e'];
|
|
},
|
|
(g) => {
|
|
g.movementRemaining = null;
|
|
},
|
|
];
|
|
for (const corrupt of corruptions) {
|
|
const g = reload(valid);
|
|
corrupt(g);
|
|
const saved = reload(g);
|
|
assert.throws(() => normalizeAutomaticGame(g));
|
|
assert.throws(() => applyAction(g, 'a', { type: 'endMovement' }));
|
|
assert.deepEqual(g, saved);
|
|
}
|
|
});
|
|
|
|
void test('discarding Sapho creates eligibility for a full-hand holder excluded from an open Once Around lot', () => {
|
|
for (const mode of ['first', 'last'] as const) {
|
|
let g = auction('e', 'onceAround', true);
|
|
assert.equal(g.players[1].hand.length, 4);
|
|
assert.equal(g.richeseAuction!.eligible.includes('e'), false);
|
|
const sold = g.richeseAuction!.cardId;
|
|
assert.ok(viewGame(g, 'e').saphoOptions.some((o) => o.mode === mode));
|
|
g = applyAction(g, 'e', action(g, mode));
|
|
assert.equal(g.players[1].hand.length, 3);
|
|
assert.ok(g.richeseAuction!.eligible.includes('e'));
|
|
assert.deepEqual(g.richeseAuction!.tieOrder, g.order);
|
|
if (mode === 'first') {
|
|
assert.equal(g.richeseAuction!.active, 'e');
|
|
g = bid(g, 'e', 1);
|
|
while (g.richeseAuction && !g.richeseAuction.outcome)
|
|
g = bid(g, g.richeseAuction.active!, null);
|
|
} else {
|
|
assert.equal(g.richeseAuction!.order.at(-1), 'e');
|
|
while (g.richeseAuction!.active !== 'e')
|
|
g = bid(g, g.richeseAuction!.active!, 1 + g.richeseAuction!.bid);
|
|
g = bid(g, 'e', 1 + g.richeseAuction!.bid);
|
|
}
|
|
assert.ok(g.players[1].hand.some((c) => c.id === sold));
|
|
assert.equal(g.players[1].hand.length, 4);
|
|
assert.equal(g.discard.filter((c) => c.id === card).length, 1);
|
|
}
|
|
});
|
|
|
|
void test('Sapho options are owner-only and do not reveal hidden cards through another player view', () => {
|
|
const withCard = movement();
|
|
assert.ok(viewGame(withCard, 'e').saphoOptions.length);
|
|
assert.deepEqual(viewGame(withCard, 'a').saphoOptions, []);
|
|
const without = reload(withCard);
|
|
// Swap identities at fixed public draw/hand sizes and preserve the physical Sapho.
|
|
const replacement = without.deck.pop()!;
|
|
without.deck.push(...without.players[1].hand.splice(0));
|
|
without.players[1].hand.push(replacement);
|
|
assert.equal(without.deck.length, withCard.deck.length);
|
|
assert.deepEqual([...without.deck, ...without.players[1].hand].map(c => c.id).sort(), [...withCard.deck, ...withCard.players[1].hand].map(c => c.id).sort());
|
|
assert.deepEqual(viewGame(without, 'a'), viewGame(withCard, 'a'));
|
|
assert.deepEqual(viewGame(without, 'e').saphoOptions, []);
|
|
reject(withCard, 'a', action(withCard, 'last'));
|
|
});
|