Files
Dune/tests/juice-of-sapho-engine.test.ts
T

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'));
});