715 lines
26 KiB
TypeScript
715 lines
26 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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
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 first after a prior bid moves only unacted bidders and preserves the committed bid', () => {
|
|
let g = auction('g');
|
|
const first = g.richeseAuction!.active!;
|
|
g = bid(g, first, 1);
|
|
const before = reload(g);
|
|
assert.ok(viewGame(g, 'g').saphoOptions.some((o) => o.scope === 'onceAround' && o.mode === 'first'));
|
|
g = applyAction(reload(g), 'g', action(g, 'first'));
|
|
assert.deepEqual(g.richeseAuction!.acted, [first]);
|
|
assert.equal(g.richeseAuction!.bid, 1);
|
|
assert.equal(g.richeseAuction!.bidder, first);
|
|
assert.deepEqual(g.richeseAuction!.tieOrder, before.richeseAuction!.tieOrder);
|
|
assert.equal(g.richeseAuction!.active, 'g');
|
|
assert.equal(g.discard.filter((c) => c.id === card).length, 1);
|
|
g = bid(reload(g), 'g', 2);
|
|
assert.equal(g.richeseAuction!.active, 'e');
|
|
assert.deepEqual(g.richeseAuction!.acted, [first, 'g']);
|
|
const sold = g.richeseAuction!.cardId;
|
|
g = bid(g, 'e', 3);
|
|
assert.ok(g.players[1].hand.some((c) => c.id === sold));
|
|
assert.equal(g.players[1].spice, 17);
|
|
});
|
|
|
|
void test('Once Around rejects no-op first, completed opportunity, no-op last 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('Semuta pauses a clean Once Around Sapho order and preserves remaining bidders', () => {
|
|
for (const mode of ['first', 'last'] as const) {
|
|
let g = auction('e', 'onceAround', true);
|
|
g.semutaPreview = true;
|
|
g.expansions = ['choam'];
|
|
const semuta = g.richeseCache!.findIndex(c => c.id === SEMUTA_DRUG_ID);
|
|
assert.ok(semuta >= 0);
|
|
g.players[0].hand.push(g.richeseCache!.splice(semuta, 1)[0]);
|
|
const original = reload(g), lotEvent = g.richeseAuction!.event;
|
|
g = applyAction(g, 'e', action(g, mode));
|
|
const batchEvent = g.pendingTreacheryDiscard!.batch.event;
|
|
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.equal(g.richeseAuction?.event, lotEvent);
|
|
assert.equal(g.richeseAuction?.eligible.includes('e'), true);
|
|
assert.equal(g.players[1].hand.length, 3);
|
|
assert.equal(viewGame(g, 'a').semutaReaction?.canCommit, true);
|
|
assert.equal(viewGame(g, 'g').semutaReaction?.canCommit, false);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
|
|
for (const [index, change] of [
|
|
(saved: Game) => { saved.richeseAuction!.order.reverse(); },
|
|
(saved: Game) => { saved.richeseAuction!.bid = 5; },
|
|
(saved: Game) => { saved.richeseAuction!.active = mode === 'first' ? 'a' : 'e'; saved.active = saved.richeseAuction!.active; },
|
|
(saved: Game) => { saved.richeseBidding!.position = 'last'; },
|
|
(saved: Game) => { saved.players[1].hand.pop(); },
|
|
].entries()) {
|
|
const corrupt = reload(g), before = reload(corrupt);
|
|
change(corrupt);
|
|
const unchanged = reload(corrupt);
|
|
assert.throws(() => applyAction(corrupt, 'a', { type: 'semutaCommit', event: batchEvent }), `corruption ${index}`);
|
|
assert.deepEqual(corrupt, unchanged);
|
|
assert.notDeepEqual(corrupt, before);
|
|
}
|
|
let declined = reload(g);
|
|
for (const id of ['a', 'e', 'g'])
|
|
declined = applyAction(declined, id, { type: 'semutaPass', event: batchEvent });
|
|
assert.equal(declined.pendingTreacheryDiscard, null);
|
|
assert.equal(declined.discard.filter(c => c.id === card).length, 1);
|
|
const claimed = applyAction(reload(g), 'a', { type: 'semutaCommit', event: batchEvent });
|
|
assert.equal(claimed.pendingTreacheryDiscard, null);
|
|
assert.deepEqual(claimed.richeseAuction, declined.richeseAuction);
|
|
assert.equal(claimed.players[0].hand.filter(c => c.id === card).length, 1);
|
|
assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
|
assert.equal(claimed.richeseAuction?.acted.length, original.richeseAuction?.acted.length);
|
|
assert.throws(() => applyAction(claimed, 'a', { type: 'semutaCommit', event: batchEvent }));
|
|
const next = claimed.richeseAuction!.active!;
|
|
const continued = bid(claimed, next, 1);
|
|
assert.equal(continued.richeseAuction?.acted.includes(next), true);
|
|
}
|
|
});
|
|
|
|
void test('a reserved Black Market Semuta cannot claim an auction Sapho discard', () => {
|
|
let g = auction('e');
|
|
g.semutaPreview = true;
|
|
g.expansions = ['choam'];
|
|
const semuta = g.richeseCache!.findIndex(c => c.id === SEMUTA_DRUG_ID);
|
|
assert.ok(semuta >= 0);
|
|
g.players[2].hand.push(g.richeseCache!.splice(semuta, 1)[0]);
|
|
g.richeseAuction!.source = 'blackMarket';
|
|
g.richeseAuction!.cardId = SEMUTA_DRUG_ID;
|
|
g = applyAction(g, 'e', action(g, 'first'));
|
|
const event = g.pendingTreacheryDiscard!.batch.event;
|
|
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.equal(viewGame(g, 'g').semutaReaction?.canCommit, false);
|
|
assert.match(viewGame(g, 'g').semutaReaction?.blocked ?? '', /Black Market/);
|
|
const before = reload(g);
|
|
assert.throws(() => applyAction(g, 'g', { type: 'semutaCommit', event }));
|
|
assert.deepEqual(reload(g), before);
|
|
for (const id of ['a', 'e', 'g'])
|
|
g = applyAction(g, id, { type: 'semutaPass', event });
|
|
assert.equal(g.pendingTreacheryDiscard, null);
|
|
assert.equal(g.players[2].hand.filter(c => c.id === SEMUTA_DRUG_ID).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'));
|
|
});
|