Files
Dune/tests/juice-of-sapho-engine.test.ts
T
Quintine 2d89ddb6e1 Allow Sapho first while preserving unspent Guild timing
At a clean unstarted non-Guild movement boundary, Sapho FIRST now reorders the remaining turn queue and reopens the native Advanced Guild choice. Guild may take its turn ahead or wait while all non-Guild turns retain their relative order. Protect an already granted current Guild turn, keep canceled timing locked and preserve completed turns, payments and last protection. Existing discard continuations and minimal policy options need no new state or signature format. Explain the provisional precedence in the controls and reference.

Validation:7-file97/97, types/lint/build and six original-action/rendered-table programmes. Actual cache1 Sapho, original Guild wait/take, paid completed Richese prefix, Emperor2/Guild1 shipments and native phase completion. An actually dealt Karama cancels TAKE without its privilege being revived; a granted current turn cannot be displaced. Four profiles make eight legal holder/Guild proposals. Corrected an obsolete first exclusion and a fixture that mistook a later response pass for a repeated movement turn; exact failures and then the whole batch pass.

This nonconflicting composition is provisional. Actual competing first, partly completed turns, other ordered actions, direct combined Semuta claim/capture, full modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
2026-10-18 13:56:29 +11:00

720 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 waits or finishes, retaining a granted current Guild turn', () => {
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');
const first = applyAction(deferred, 'e', action(deferred, 'first'));
assert.deepEqual(first.movementRemaining, ['e', 'a', 'g']);
assert.equal(first.active, null);
assert.equal(first.decision?.kind, 'guildTiming');
assert.equal(first.guildTimingGranted, deferred.guildTimingGranted);
assert.equal(first.guildTimingLocked, deferred.guildTimingLocked);
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');
assert.ok(viewGame(g, 'e').saphoOptions.some(option => option.scope === 'movement' && option.mode === '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'));
});