Connect Sapho to ordered Advanced Charity

This commit is contained in:
Quintine committed 2026-10-27 11:42:00 +11:00
1 parent e1596dbcee
commit 28df687bf9
15 files changed
+1080 -38

No files matched your search

+6
View File
@@ -5,6 +5,7 @@ import {
charityOrderBlock as block,
createCharityOrder as create,
finishCharityOrder as finish,
startCharityOrder as start,
validateCharityOrder as validate,
type CharityOrder,
type CharityOrderContext,
@@ -23,6 +24,9 @@ function rejects(current: CharityOrderContext, frame: CharityOrder, action: () =
void test('Charity preserves the actual rotated First Player order rather than roster order', () => {
const current = context(), frame = create(current);
assert.deepEqual(frame, { version: 1, turn: 3, order: ['c', 'a', 'b'], remaining: ['c', 'a', 'b'] });
assert.equal(JSON.stringify(frame), '{"version":1,"turn":3,"order":["c","a","b"],"remaining":["c","a","b"]}');
assert.deepEqual(start(current, frame, 'c'), frame);
assert.equal(Object.hasOwn(start(current, frame, 'c'), 'started'), false);
validate(current, frame);
assert.equal(block(current, frame, 'c'), null);
assert.match(block(current, frame, 'a')!, /Wait/);
@@ -36,6 +40,7 @@ void test('Charity preserves the actual rotated First Player order rather than r
void test('only the current seat finishes, consuming the pre-finish prefix before the caller appends ready', () => {
const current = context(), frame = create(current);
rejects(current, frame, () => finish(current, frame, 'a'));
rejects(current, frame, () => start(current, frame, 'a'));
rejects(current, frame, () => finish(current, frame, 'foreign'));
const next = finish(current, frame, 'c');
assert.deepEqual(next.remaining, ['a', 'b']);
@@ -72,6 +77,7 @@ void test('the native context requires actual unique seats, their permutation an
{ ...base, order: ['c', 'a'] }, { ...base, order: ['c', 'a', 'a'] },
{ ...base, order: ['c', 'a', 'foreign'] }, context(['a']), context(['c', 'b']),
context(['c', 'c']), context(['c', 'a', 'b', 'foreign']),
{ ...base, claimed: ['foreign'] }, { ...base, claimed: ['c', 'c'] },
]) assert.throws(() => create(current), CharityOrderError);
for (const current of [null, [], { ...base, players: undefined }, { ...base, order: 'c,a,b' },
{ ...base, ready: undefined }, { ...base, order: Array.from({ length: 3 }) },
+150
View File
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { saphoCharityOrderEvent } from '../game/charity-order';
import { createSaphoRevivalOrderFixture, nextSaphoRevivalOrder, actSaphoRevivalOrder,
saphoRevivalOrderInventory, saphoRevivalOrderStocks, saphoRevivalOrderPlayer,
saphoRevivalOrderClean, createSaphoRevivalSemutaFixture, reactSaphoRevivalSemuta,
REVIVAL_SAPHO, type SaphoRevivalOrderStep } from './fixture-sapho-revival-order';
import { createSaphoStartedMovementFixture } from './fixture-sapho-started-movement';
export type SaphoCharityOrderStep = SaphoRevivalOrderStep;
export const CHARITY_SAPHO = REVIVAL_SAPHO;
export const actSaphoCharityOrder = actSaphoRevivalOrder;
export const saphoCharityOrderInventory = saphoRevivalOrderInventory;
export const saphoCharityOrderStocks = saphoRevivalOrderStocks;
export const saphoCharityOrderPlayer = saphoRevivalOrderPlayer;
export const saphoCharityOrderClean = saphoRevivalOrderClean;
export const saphoCharityOrderReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
export const nextSaphoCharityOrder = nextSaphoRevivalOrder;
export function advanceSaphoCharityOrder(game: Game, until: (state: Game) => boolean,
actions: SaphoCharityOrderStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Sapho/Charity chronology ended before its requested boundary');
const step = nextSaphoCharityOrder(game); assert.ok(step, 'Complete the current native control first');
game = actSaphoCharityOrder(game, step.actor, step.action, actions);
}
throw Error('The original Sapho/Charity chronology did not reach its finite boundary');
}
export function finishSaphoCharityOrder(game: Game, actions: SaphoCharityOrderStep[] = []): Game {
return advanceSaphoCharityOrder(game, state => state.phase !== 2, actions);
}
export function finishSaphoCharityTurn(game: Game, actions: SaphoCharityOrderStep[] = []): Game {
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
return actSaphoCharityOrder(game, actor, { type: 'ready' }, actions);
}
export function saphoCharityOrderAction(game: Game, mode: 'first' | 'last', owner = 'emperor'): Action {
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === 'charity' && option.mode === mode);
assert.ok(option, `The actual unfinished Charity queue offers Sapho ${mode}`);
return { type: 'card', card: CHARITY_SAPHO, scope: option.scope, event: option.event, mode: option.mode };
}
export function currentSaphoCharityEvent(game: Game): string {
assert.ok(game.charityOrder);
return saphoCharityOrderEvent({ turn: game.turn, order: game.order, players: game.players, ready: game.ready,
claimed: game.players.filter(player => player.charityTurn === game.turn).map(player => player.id) }, game.charityOrder);
}
/** Actual ordinary prepayment empties the current purse, followed by its one
* native claim. Neither a low purse nor a started marker is installed. */
export function claimSaphoCharityOrder(game: Game, actor = game.charityOrder?.remaining[0] ?? '',
actions: SaphoCharityOrderStep[] = []) {
const before = structuredClone(game), from = actions.length;
const recipient = game.players.find(player => player.id !== actor && player.id === 'atreides')?.id ??
game.players.find(player => player.id !== actor)?.id;
assert.ok(recipient); const paid = saphoCharityOrderPlayer(game, actor).spice;
if (paid) game = actSaphoCharityOrder(game, actor, { type: 'bribe', target: recipient, amount: paid }, actions);
assert.equal(saphoCharityOrderPlayer(game, actor).spice, 0);
game = actSaphoCharityOrder(game, actor, { type: 'charity' }, actions);
assert.equal(saphoCharityOrderPlayer(game, actor).charityTurn, game.turn);
assert.equal(saphoCharityOrderPlayer(game, actor).spice, 2);
return { game, before, recipient, paid, steps: structuredClone(actions.slice(from)), actions };
}
/** Actual original cache1 acquisition is inherited unchanged. The Advanced
* source continues its real first Revival and Movement without shipping, then
* native Storm2 and dual Spice to current Charity. Basic uses the original
* native started-Movement source, including its already paid Polar1 shipment.
*/
export function createSaphoCharityOrderFixture(options: { advanced?: boolean; started?: boolean } = {}) {
const advanced = options.advanced ?? true, started = options.started ?? false;
assert.ok(advanced || !started, 'The started ordered source belongs to Advanced Charity');
const inherited = advanced ? createSaphoRevivalOrderFixture()
: createSaphoStartedMovementFixture({ advanced: false });
const inheritedActions = structuredClone(inherited.actions), actions = [...inheritedActions];
let game = structuredClone(inherited.game);
const continuationSpiceOrder = advanced ? [] : [0].map(position => {
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
game = advanceSaphoCharityOrder(game,
state => state.turn === 2 && state.phase === 2 && saphoCharityOrderClean(state), actions);
const beforeClaim = structuredClone(game), owner = inherited.owner;
const current = game.charityOrder?.remaining[0] ?? game.order[0]; assert.ok(current);
if (advanced) {
assert.equal(current, 'richese'); assert.equal(game.active, current);
assert.equal(game.charityOrder?.version, 1);
assert.deepEqual(game.charityOrder?.remaining, game.order);
} else assert.equal(game.charityOrder ?? null, null);
const claim = started ? claimSaphoCharityOrder(game, current, actions) : null;
if (claim) game = claim.game;
assert.ok(saphoCharityOrderPlayer(game, owner).hand.some(card => card.id === CHARITY_SAPHO));
assert.deepEqual(saphoCharityOrderInventory(game), inherited.originalInventory);
if (advanced) {
assert.ok(viewGame(game, owner).saphoOptions.some(option => option.scope === 'charity' && option.mode === 'first'));
assert.ok(viewGame(game, owner).saphoOptions.some(option => option.scope === 'charity' && option.mode === 'last'));
}
return { game, sourceGame: structuredClone(game), advanced, started, owner, current, card: CHARITY_SAPHO,
inherited, beforeClaim, claim, acquisition: inherited.acquisition, continuationSpiceOrder,
lobby: inherited.lobby, setup: inherited.setup, beforeStartingDeal: inherited.beforeStartingDeal,
afterSetup: inherited.afterSetup, originalInventory: inherited.originalInventory,
inheritedActions, continuation: structuredClone(actions.slice(inheritedActions.length)), actions,
sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited: inheritedActions.length, acquisition: inherited.acquisition.steps.length,
continuation: actions.length - inheritedActions.length, claim: claim?.steps.length ?? 0, source: actions.length },
sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor/Guild setup with the native CHOAM/Richese deck. ` +
'Emperor acquires the original physical Sapho through an actual cache auction bid1 and bank payment; no held-card exchange is staged. Only original unopened deal/land-Spice positions and scalar lotteries are selected. ' +
(advanced ? 'Append original quiet Revival/Guild WAIT/Movement/Collection/Mentat and actual card Storm2 controls to the first original turn2 Charity frame. '
: 'Retain the original paid Polar1 shipment, select one original unopened next land-Spice position, and append actual remaining turns, second dials and Charity controls. ') +
(started ? 'Richese then really bribes away its actual full purse and claims ordinary Charity2; the accepted claim alone establishes its started current opportunity. ' : '') +
'Counts derive from actual inherited and continuation action arrays. No purse, phase, queue, claim marker, hand, army or discard is installed.' };
}
export type SaphoCharityOrderFixture = ReturnType<typeof createSaphoCharityOrderFixture>;
/** Conserved frame-only UNIT supplement, not historical saved-game evidence. */
export function unstampedSaphoCharitySupplement(game: Game): Game {
const old = saphoCharityOrderReload(game); delete old.charityOrder; return old;
}
/** CONTROLLED physical acquisition supplement inherited unchanged: Sapho and
* Semuta were conserved into their original holders rather than bought. Start
* at its actual turn2 Revival, then advance FORWARD through turn3 Charity.
* No custody is reassigned here; only the next two original unopened land
* Spice positions are selected before their actual later draws.
*/
export function createSaphoCharitySemutaFixture() {
const inherited = createSaphoRevivalSemutaFixture();
const inheritedActions = structuredClone(inherited.actions), actions = [...inheritedActions];
let game = structuredClone(inherited.game);
assert.equal(game.turn, 2); assert.equal(game.phase, 4);
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
game = advanceSaphoCharityOrder(game,
state => state.turn === 3 && state.phase === 2 && saphoCharityOrderClean(state), actions);
const option = viewGame(game, inherited.owner).saphoOptions.find(option => option.scope === 'charity'); assert.ok(option);
assert.ok(option.mode === 'first' || option.mode === 'last');
const action = saphoCharityOrderAction(game, option.mode, inherited.owner);
assert.deepEqual(saphoCharityOrderInventory(game), inherited.originalInventory);
return { game, sourceGame: structuredClone(game), owner: inherited.owner, holder: inherited.holder,
current: game.charityOrder!.remaining[0], card: CHARITY_SAPHO, mode: option.mode, action,
nativeAcquisition: false, inherited, spiceOrder, originalInventory: inherited.originalInventory,
inheritedActions, continuation: structuredClone(actions.slice(inheritedActions.length)), actions,
sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited: inheritedActions.length, continuation: actions.length - inheritedActions.length,
source: actions.length },
staging: [...inherited.staging,
'CONTROLLED acquisition remains inherited. Actual native turn2 Revival-to-turn3 Charity progression supplies the new scope; no rewind, hand, purse, queue, claim or live discard event is installed.'] };
}
export const reactSaphoCharitySemuta = reactSaphoRevivalSemuta;
+162
View File
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { Children, createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { CharityClaim } from '../components/charity-claim';
import { viewGame, type Action, type GameView } from '../game/engine';
import {
createSaphoCharityOrderFixture, actSaphoCharityOrder, saphoCharityOrderAction,
saphoCharityOrderPlayer, type SaphoCharityOrderFixture,
} from './fixture-sapho-charity-order';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { JuiceOfSapho, SaphoOrderButton } = await import('../components/juice-of-sapho');
aliases.deregister();
const sources = new Map<string, SaphoCharityOrderFixture>();
function native(advanced = true, started = false) {
const key = `${advanced}:${started}`;
let source = sources.get(key);
if (!source) { source = createSaphoCharityOrderFixture({ advanced, started }); sources.set(key, source); }
return { ...source, game: structuredClone(source.game) };
}
function panel(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(JuiceOfSapho, { game, busy, act() {} }));
}
function charity(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(CharityClaim, { game, busy, act() {} }));
}
function optionFor(game: GameView, mode: 'first' | 'last') {
const option = game.saphoOptions.find(candidate => candidate.scope === 'charity' && candidate.mode === mode);
assert.ok(option?.scope === 'charity');
return option;
}
function pressSapho(card: string, option: ReturnType<typeof optionFor>, busy = false) {
const sent: Action[] = [];
SaphoOrderButton({ card, option, busy, act(action) { sent.push(action); } }).props.onClick();
return sent;
}
type ClickProps = { children?: ReactNode; disabled?: boolean; onClick?: () => void; 'data-testid'?: string };
function findButton(node: ReactNode, id: string): ReactElement<ClickProps> | null {
for (const child of Children.toArray(node)) {
if (!isValidElement<ClickProps>(child)) continue;
if (child.props['data-testid'] === id) return child;
const nested = findButton(child.props.children, id);
if (nested) return nested;
}
return null;
}
function pressCharity(game: GameView, id: 'charity-claim-submit' | 'charity-turn-finish', busy = false) {
const sent: Action[] = [];
const tree = CharityClaim({ game, busy, act(action) { sent.push(action); } });
const button = findButton(tree, id); assert.ok(button);
button.props.onClick?.();
return { sent, disabled: button.props.disabled };
}
void test('native clean Charity first and last render their scope and submit only five fields; busy cannot play', () => {
for (const mode of ['first', 'last'] as const) {
const source = native(), game = viewGame(source.game, source.owner), option = optionFor(game, mode);
const html = panel(game);
assert.match(html, new RegExp(`Take your Charity turn ${mode} and discard Juice of Sapho`));
assert.match(html, /Completed Charity turns stay finished/);
assert.match(html, /changing order does not grant another claim or reopen completed turns/);
assert.doesNotMatch(html, /Provisional queued Charity order/);
assert.equal(option.afterCurrent, undefined);
const sent = pressSapho(source.card, option), action = sent[0]; assert.ok(action);
assert.deepEqual(sent, [saphoCharityOrderAction(source.game, mode, source.owner)]);
assert.deepEqual(Object.keys(action).sort(), ['card', 'event', 'mode', 'scope', 'type']);
assert.deepEqual(pressSapho(source.card, option, true), []);
const after = actSaphoCharityOrder(source.game, source.owner, action);
assert.ok(after.charityOrder?.version === 2);
assert.equal(mode === 'first' ? after.charityOrder.remaining[0] : after.charityOrder.remaining.at(-1), source.owner);
}
});
void test('native claimed current actor stays first while Sapho first or last queues only its later tail', () => {
for (const mode of ['first', 'last'] as const) {
const source = native(true, true), game = viewGame(source.game, source.owner), option = optionFor(game, mode);
const currentName = game.players.find(player => player.id === source.current)!.name;
assert.equal(option.afterCurrent, source.current);
for (let render = 0; render < 2; render++) {
const html = panel(game);
assert.match(html, /aria-label="Provisional queued Charity order"/);
assert.ok(html.includes(`${currentName} finishes the current Charity turn unchanged`));
assert.match(html, new RegExp(`Your ${mode} position applies among later unstarted Charity turns`));
assert.match(html, /Completed turns and claims stay finished; each player still has only one Charity claim/);
}
const sent = pressSapho(source.card, option), action = sent[0]; assert.ok(action);
assert.deepEqual(Object.keys(action).sort(), ['card', 'event', 'mode', 'scope', 'type']);
const purse = saphoCharityOrderPlayer(source.game, source.current).spice;
const after = actSaphoCharityOrder(source.game, source.owner, action);
assert.ok(after.charityOrder?.version === 2);
assert.equal(after.charityOrder.remaining[0], source.current);
assert.equal(after.charityOrder.remaining[mode === 'first' ? 1 : after.charityOrder.remaining.length - 1], source.owner);
assert.equal(saphoCharityOrderPlayer(after, source.current).spice, purse);
assert.equal(saphoCharityOrderPlayer(after, source.current).charityTurn, after.turn);
}
});
void test('actual v2 Charity callbacks wait, claim once and Finish without reopening a completed turn', () => {
const source = native();
let game = actSaphoCharityOrder(source.game, source.owner, saphoCharityOrderAction(source.game, 'first', source.owner));
const actor = game.charityOrder?.remaining[0]; assert.equal(actor, source.owner); assert.ok(actor);
const recipient = game.players.find(player => player.id !== actor && player.id === 'atreides')?.id; assert.ok(recipient);
const purse = saphoCharityOrderPlayer(game, actor).spice; assert.ok(purse > 0);
game = actSaphoCharityOrder(game, actor, { type: 'bribe', target: recipient, amount: purse });
const current = viewGame(game, actor), waiting = viewGame(game, source.current);
assert.match(charity(current), /Juice of Sapho changed the remaining Charity order/);
assert.match(charity(current), /It is your Charity turn/);
assert.match(charity(waiting), /Waiting for .* to finish Charity/);
assert.deepEqual(pressCharity(waiting, 'charity-claim-submit'), { sent: [], disabled: true });
assert.deepEqual(pressCharity(waiting, 'charity-turn-finish'), { sent: [], disabled: true });
assert.deepEqual(pressCharity(current, 'charity-claim-submit', true), { sent: [], disabled: true });
const claim = pressCharity(current, 'charity-claim-submit');
assert.deepEqual(claim, { sent: [{ type: 'charity' }], disabled: false });
game = actSaphoCharityOrder(game, actor, claim.sent[0]!);
assert.equal(saphoCharityOrderPlayer(game, actor).spice, 2);
const claimed = viewGame(game, actor);
assert.deepEqual(pressCharity(claimed, 'charity-claim-submit'), { sent: [], disabled: true });
assert.deepEqual(pressCharity(claimed, 'charity-turn-finish', true), { sent: [], disabled: true });
const finish = pressCharity(claimed, 'charity-turn-finish');
assert.deepEqual(finish, { sent: [{ type: 'ready' }], disabled: false });
game = actSaphoCharityOrder(game, actor, finish.sent[0]!);
const completed = viewGame(game, actor);
assert.match(charity(completed), /Your Charity turn is complete/);
assert.deepEqual(pressCharity(completed, 'charity-turn-finish'), { sent: [], disabled: true });
});
void test('Basic unstamped and genuinely completed views filter stale Charity options without adding Finish', () => {
const source = native(), original = viewGame(source.game, source.owner), stale = optionFor(original, 'first');
const basicSource = native(false), basic = viewGame(basicSource.game, basicSource.owner);
const unstamped = viewGame(source.game, source.owner);
unstamped.charityOrder = null;
let completedGame = source.game;
for (let count = 0; count < completedGame.players.length && completedGame.charityOrder?.remaining.includes(source.owner); count++) {
const actor = completedGame.charityOrder.remaining[0]; assert.ok(actor);
completedGame = actSaphoCharityOrder(completedGame, actor, { type: 'ready' });
}
const completed = viewGame(completedGame, source.owner);
assert.ok(completed.charityOrder && !completed.charityOrder.remaining.includes(source.owner));
// Stale public projection supplements only: the native Basic/completed
// sources keep their actual custody, while the unstamped display is synthetic.
for (const game of [basic, unstamped, completed]) {
game.saphoOptions = [stale];
assert.doesNotMatch(panel(game), /Take your Charity turn (first|last) and discard/);
assert.match(panel(game), /existing timing|Completed turns stay finished/);
}
assert.doesNotMatch(charity(basic), /Finish Charity turn/);
assert.doesNotMatch(charity(unstamped), /Finish Charity turn/);
});
+192
View File
@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actSaphoCharityOrder as act, advanceSaphoCharityOrder as advance,
createSaphoCharityOrderFixture as fixture, claimSaphoCharityOrder as claim,
createSaphoCharitySemutaFixture, reactSaphoCharitySemuta,
currentSaphoCharityEvent as currentEvent, finishSaphoCharityOrder as finish,
finishSaphoCharityTurn as finishTurn, nextSaphoCharityOrder as next,
saphoCharityOrderAction as action, saphoCharityOrderClean as clean,
saphoCharityOrderInventory as inventory, saphoCharityOrderStocks as stocks,
saphoCharityOrderPlayer as player, saphoCharityOrderReload as reload,
unstampedSaphoCharitySupplement as unstamped, type SaphoCharityOrderStep } from './fixture-sapho-charity-order';
function reject(game: Game, actor: string, proposed: Action) {
const before = reload(game); assert.throws(() => applyAction(game, actor, proposed)); assert.deepEqual(game, before);
}
function expected(game: Game, owner: string, mode: 'first' | 'last') {
const frame = game.charityOrder; assert.ok(frame);
const current = frame.remaining[0];
const locked = current && player(game, current).charityTurn === game.turn ? [current] : [];
const others = frame.remaining.filter(actor => actor !== owner && !locked.includes(actor));
return [...locked, ...(mode === 'first' ? [owner, ...others] : [...others, owner])];
}
void test('native cache1 Sapho changes fresh Advanced Charity first or last while retaining physical First Player order', () => {
const f = fixture();
assert.equal(f.lobby.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.ok(f.beforeStartingDeal.players.every(seat => !seat.hand.length));
assert.equal(f.acquisition.bid.actor, f.owner); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.owner).spice - player(f.acquisition.after, f.owner).spice, 1);
assert.equal(f.sourceCounts.inherited, f.inheritedActions.length);
assert.equal(f.sourceCounts.acquisition, f.acquisition.steps.length);
assert.equal(f.sourceCounts.continuation, f.continuation.length);
assert.equal(f.sourceCounts.source, f.sourceActions.length);
assert.deepEqual(f.sourceActions, [...f.inheritedActions, ...f.continuation]);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 2); assert.equal(f.game.charityOrder?.version, 1);
for (const mode of ['first', 'last'] as const) {
const wanted = expected(f.game, f.owner, mode), physicalOrder = [...f.game.order], before = stocks(f.game);
const proposed = action(f.game, mode);
assert.deepEqual(Object.keys(proposed).sort(), ['card', 'event', 'mode', 'scope', 'type']);
assert.equal(proposed.event, currentEvent(f.game));
const game = act(reload(f.game), f.owner, proposed);
assert.equal(game.charityOrder?.version, 2); assert.deepEqual(game.charityOrder?.remaining, wanted);
assert.deepEqual(game.order, physicalOrder); assert.equal(game.active, wanted[0]);
assert.deepEqual(game.ready, []); assert.deepEqual(stocks(game), before);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(game, f.owner).hand.some(card => card.id === f.card), false);
assert.deepEqual(inventory(game), f.originalInventory);
}
});
void test('an actual accepted current Charity claim stays ahead of both Sapho tails and cannot be claimed twice', () => {
const f = fixture({ started: true }); assert.ok(f.claim);
assert.equal(f.claim.paid, player(f.beforeClaim, f.current).spice);
assert.ok(f.claim.steps.some(step => step.action.type === 'bribe'));
assert.ok(f.claim.steps.some(step => step.action.type === 'charity'));
assert.equal(player(f.game, f.current).charityTurn, f.game.turn);
assert.equal(player(f.game, f.current).spice, 2);
assert.equal(f.game.charityOrder?.version, 1, 'A claim preserves original v1 bytes; accepted Sapho creates v2');
assert.deepEqual(f.game.charityOrder, f.beforeClaim.charityOrder);
for (const mode of ['first', 'last'] as const) {
const option = viewGame(f.game, f.owner).saphoOptions.find(option => option.scope === 'charity' && option.mode === mode);
assert.ok(option); assert.equal((option as typeof option & { afterCurrent?: string }).afterCurrent, f.current);
const wanted = expected(f.game, f.owner, mode), before = stocks(f.game);
let game = act(reload(f.game), f.owner, action(f.game, mode));
assert.equal(game.active, f.current); assert.deepEqual(game.charityOrder?.remaining, wanted);
assert.equal(game.charityOrder?.remaining[0], f.current); assert.deepEqual(stocks(game), before);
reject(game, f.current, { type: 'charity' });
game = finishTurn(game);
assert.equal(game.active, wanted[1]); assert.ok(game.ready.includes(f.current));
assert.equal(player(game, f.current).spice, 2); assert.equal(player(game, f.current).charityTurn, game.turn);
}
});
void test('the real Sapho holder cannot split its own started Charity or choose its existing no-op position', () => {
const f = fixture(); let game = advance(reload(f.game), state => clean(state) && state.charityOrder?.remaining[0] === f.owner);
assert.equal(game.active, f.owner);
assert.equal(viewGame(game, f.owner).saphoOptions.some(option => option.scope === 'charity' && option.mode === 'first'), false);
reject(game, f.owner, { type: 'card', card: f.card, scope: 'charity', event: currentEvent(game), mode: 'first' });
game = claim(game, f.owner).game;
assert.equal(player(game, f.owner).charityTurn, game.turn);
assert.deepEqual(viewGame(game, f.owner).saphoOptions.filter(option => option.scope === 'charity'), []);
for (const mode of ['first', 'last'] as const)
reject(game, f.owner, { type: 'card', card: f.card, scope: 'charity', event: currentEvent(game), mode });
assert.ok(player(game, f.owner).hand.some(card => card.id === f.card));
assert.equal(game.discard.some(card => card.id === f.card), false);
});
void test('stale, foreign and finished Charity requests reject before cost while an accepted packet disposes Sapho once', () => {
const f = fixture(), old = action(f.game, 'last');
reject(f.game, f.owner, { ...old, event: 'old-charity-boundary' });
reject(f.game, 'atreides', old); reject(f.game, f.owner, { ...old, afterCurrent: f.current });
let game = finishTurn(reload(f.game));
assert.notEqual(currentEvent(game), old.event); reject(game, f.owner, old);
game = act(game, f.owner, action(game, 'first'));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); reject(game, f.owner, old);
assert.deepEqual(inventory(game), f.originalInventory);
let completed = advance(reload(f.game), state => clean(state) && state.charityOrder?.remaining[0] === f.owner);
const beforeFinish = action(completed, 'last'); completed = finishTurn(completed);
assert.ok(completed.ready.includes(f.owner));
assert.deepEqual(viewGame(completed, f.owner).saphoOptions.filter(option => option.scope === 'charity'), []);
reject(completed, f.owner, beforeFinish);
assert.ok(player(completed, f.owner).hand.some(card => card.id === f.card));
});
void test('genuine Basic and explicitly frame-only unstamped Advanced controls have no Sapho Charity scope', () => {
const basic = fixture({ advanced: false });
assert.equal(basic.game.advanced, false); assert.equal(basic.game.charityOrder ?? null, null);
assert.deepEqual(viewGame(basic.game, basic.owner).saphoOptions.filter(option => option.scope === 'charity'), []);
reject(basic.game, basic.owner, { type: 'card', card: basic.card, scope: 'charity', event: 'no-native-charity-frame', mode: 'first' });
assert.deepEqual(inventory(basic.game), basic.originalInventory);
const f = fixture(), game = unstamped(f.game), { charityOrder: removed, ...rest } = f.game;
assert.ok(removed); assert.deepEqual(game, rest, 'This labelled unit supplement removes only the frame');
assert.deepEqual(viewGame(game, f.owner).saphoOptions.filter(option => option.scope === 'charity'), []);
reject(game, f.owner, action(f.game, 'first'));
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('all four minimal profiles use native Sapho and finish each remaining current Charity opportunity once', () => {
const f = fixture({ started: true }), physicalOrder = [...f.game.order];
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
const view = viewGame(f.game, f.owner); view.players.find(seat => seat.id === f.owner)!.bot = difficulty;
const proposed = botActions(view).find(action => action.type === 'card' && action.scope === 'charity');
assert.ok(proposed, `${difficulty} proposes the owned physical Sapho's legal native scope`);
const actions: SaphoCharityOrderStep[] = [];
let game = act(reload(f.game), f.owner, proposed, actions);
const wanted = [...game.charityOrder!.remaining], completions: string[] = [];
for (let count = 0; game.phase === 2 && count < 50; count++) {
if (!clean(game)) {
const step = next(game); assert.ok(step); game = act(game, step.actor, step.action, actions); continue;
}
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
for (const other of game.players.filter(player => player.id !== actor)) {
const waiting = viewGame(game, other.id); waiting.players.find(seat => seat.id === other.id)!.bot = difficulty;
assert.equal(botActions(waiting).some(action => action.type === 'charity' || action.type === 'ready'), false);
}
const own = viewGame(game, actor); own.players.find(seat => seat.id === actor)!.bot = difficulty;
const choice = botActions(own).find(action => action.type === 'charity' || action.type === 'ready');
assert.ok(choice); if (choice.type === 'ready') completions.push(actor);
game = act(game, actor, choice, actions);
}
game = finish(game, actions);
assert.equal(game.phase, 3); assert.deepEqual(completions, wanted);
assert.equal(new Set(completions).size, wanted.length); assert.deepEqual(game.order, physicalOrder);
assert.equal(player(game, f.current).spice, 2); assert.equal(player(game, f.current).charityTurn, 2);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalInventory);
}
});
for (const claimDiscard of [false, true]) {
void test(`controlled Semuta acquisition supplement ${claimDiscard ? 'claims' : 'passes'} actual Sapho Charity disposal and preserves the reordered opportunity`, () => {
const f = createSaphoCharitySemutaFixture();
assert.equal(f.nativeAcquisition, false); assert.ok(f.staging.length > 0);
assert.equal(f.inherited.game.turn, 2); assert.equal(f.inherited.game.phase, 4);
assert.equal(f.game.turn, 3); assert.equal(f.game.phase, 2);
assert.equal(f.sourceCounts.inherited, f.inheritedActions.length);
assert.equal(f.sourceCounts.continuation, f.continuation.length);
assert.equal(f.sourceCounts.source, f.sourceActions.length);
assert.deepEqual(f.sourceActions, [...f.inheritedActions, ...f.continuation]);
const before = stocks(f.game), physicalOrder = [...f.game.order];
const paused = act(reload(f.game), f.owner, f.action);
assert.equal(paused.pendingTreacheryDiscard?.continuation?.kind, 'saphoCharityDiscard');
assert.equal(paused.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(paused.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(paused, f.owner).hand.some(card => card.id === f.card), false);
const frame = structuredClone(paused.charityOrder), completed = [...paused.ready], current = paused.active;
assert.ok(frame); assert.equal(frame.version, 2);
assert.equal(current, frame.remaining[0]); assert.deepEqual(stocks(paused), before);
for (const seat of paused.players)
assert.deepEqual(viewGame(paused, seat.id).saphoOptions.filter(option => option.scope === 'charity'), []);
let game = reactSaphoCharitySemuta(reload(paused), f.holder, claimDiscard);
game = advance(game, clean);
assert.deepEqual(game.charityOrder, frame); assert.deepEqual(game.ready, completed);
assert.equal(game.active, current); assert.deepEqual(game.order, physicalOrder);
assert.deepEqual(stocks(game), before); assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(player(game, f.owner).hand.some(card => card.id === f.card), false);
if (claimDiscard) {
assert.ok(player(game, f.holder).hand.some(card => card.id === f.card));
assert.equal(player(game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
assert.equal(game.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(game.discard.some(card => card.id === f.card), false);
} else assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
game = finish(game);
assert.equal(game.phase, 3); assert.deepEqual(game.order, physicalOrder);
assert.deepEqual(stocks(game), before); assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(game.discard.filter(card => card.id === (claimDiscard ? SEMUTA_DRUG_ID : f.card)).length, 1);
});
}
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
CharityOrderError,
charityOrderBlock as block,
createCharityOrder as create,
finishCharityOrder as finish,
reorderSaphoCharityOrder as reorder,
saphoCharityOrderEvent as event,
startCharityOrder as start,
validateCharityOrder as validate,
type CharityOrder,
type CharityOrderContext,
} from '../game/charity-order';
function context(ready: readonly string[] = [], claimed?: readonly string[]): CharityOrderContext {
return { turn: 5, order: ['a', 'b', 'c', 'd'],
players: ['a', 'b', 'c', 'd'].map(id => ({ id })), ready,
...(claimed === undefined ? {} : { claimed }) };
}
function use(ctx: CharityOrderContext, frame: CharityOrder, player: string, mode: 'first' | 'last') {
return reorder(ctx, frame, { event: event(ctx, frame), player, mode });
}
function rejects(ctx: CharityOrderContext, frame: CharityOrder, action: () => unknown): void {
const before = structuredClone({ ctx, frame });
assert.throws(action, CharityOrderError);
assert.deepEqual({ ctx, frame }, before);
}
void test('native claim receipts start version-one Charity without adding or changing stored fields', () => {
const ctx = context([], []), frame = create(ctx), beforeEvent = event(ctx, frame);
const quoted = start(ctx, frame, 'a');
assert.equal(JSON.stringify(quoted), '{"version":1,"turn":5,"order":["a","b","c","d"],"remaining":["a","b","c","d"]}');
assert.notEqual(quoted, frame);
const claimed = context([], ['a']);
validate(claimed, quoted);
assert.notEqual(event(claimed, quoted), beforeEvent);
const done = finish(claimed, quoted, 'a');
assert.equal(JSON.stringify(done), '{"version":1,"turn":5,"order":["a","b","c","d"],"remaining":["b","c","d"]}');
validate(context(['a'], ['a']), done);
assert.equal(event(context(['a'], ['a']), done), event(context(['a'], []), done),
'a completed seat claim does not start the next opportunity');
});
void test('unstarted first and last upgrade only actual changed opportunities and retain physical order', () => {
const ctx = context([], []), frame = create(ctx), originalEvent = event(ctx, frame);
const first = use(ctx, frame, 'd', 'first');
assert.equal(first.order.version, 2);
assert.equal(first.afterCurrent, undefined);
assert.deepEqual(first.order.remaining, ['d', 'a', 'b', 'c']);
assert.deepEqual(first.order.order, ctx.order);
assert.ok(first.order.version === 2);
assert.deepEqual(first.order.uses, [{ event: originalEvent, player: 'd', mode: 'first', completed: [], started: false }]);
const last = use(ctx, first.order, 'd', 'last');
assert.deepEqual(last.order.remaining, ['a', 'b', 'c', 'd']);
assert.notEqual(event(ctx, last.order), originalEvent, 'independent uses retain a new revision even when queue returns');
validate(ctx, last.order);
assert.equal(frame.version, 1);
});
void test('an accepted claim, including canceled BG Charity, locks the current actor ahead of a reordered tail', () => {
const fresh = context([], []), frame = create(fresh), stale = event(fresh, frame);
// claimed records native acceptance, not the eventual payout or cancellation outcome.
const claimed = context([], ['a']);
rejects(claimed, frame, () => reorder(claimed, frame, { event: stale, player: 'd', mode: 'first' }));
rejects(claimed, frame, () => use(claimed, frame, 'a', 'first'));
rejects(claimed, frame, () => use(claimed, frame, 'a', 'last'));
const first = use(claimed, frame, 'd', 'first');
assert.equal(first.afterCurrent, 'a');
assert.deepEqual(first.order.remaining, ['a', 'd', 'b', 'c']);
assert.ok(first.order.version === 2 && first.order.started);
assert.equal(block(claimed, first.order, 'a'), null);
assert.match(block(claimed, first.order, 'd')!, /Wait/);
const last = use(claimed, first.order, 'b', 'last');
assert.equal(last.afterCurrent, 'a');
assert.deepEqual(last.order.remaining, ['a', 'd', 'c', 'b']);
rejects(claimed, first.order, () => use(claimed, first.order, 'd', 'first'));
validate(claimed, last.order);
});
void test('version-two native claims and finishes advance actual reordered completions without changing physical order', () => {
const initial = context([], []), first = use(initial, create(initial), 'd', 'first').order;
const pendingClaim = start(initial, first, 'd');
assert.ok(pendingClaim.version === 2 && pendingClaim.started);
const accepted = context([], ['d']);
validate(accepted, pendingClaim);
const afterD = finish(accepted, pendingClaim, 'd'), later = context(['d'], ['d']);
validate(later, afterD);
assert.ok(afterD.version === 2);
assert.deepEqual(afterD.completed, ['d']);
assert.equal(afterD.started, false);
const changed = use(later, afterD, 'a', 'last').order;
assert.deepEqual(changed.remaining, ['b', 'c', 'a']);
const afterB = finish(later, changed, 'b'), completed = context(['d', 'b'], ['d']);
validate(completed, afterB);
assert.deepEqual(afterB.order, ['a', 'b', 'c', 'd']);
assert.deepEqual(afterB.remaining, ['c', 'a']);
assert.match(block(completed, afterB, 'd')!, /finished/);
rejects(completed, afterB, () => finish(completed, afterB, 'd'));
});
void test('no-op, completed, foreign or stale Sapho choices cannot spend a scheduling opportunity', () => {
const ctx = context([], []), frame = create(ctx);
rejects(ctx, frame, () => use(ctx, frame, 'a', 'first'));
rejects(ctx, frame, () => use(ctx, frame, 'd', 'last'));
rejects(ctx, frame, () => use(ctx, frame, 'foreign', 'first'));
const changed = use(ctx, frame, 'd', 'first').order;
rejects(ctx, changed, () => reorder(ctx, changed, { event: event(ctx, frame), player: 'b', mode: 'last' }));
const afterD = finish(ctx, changed, 'd'), later = context(['d'], []);
rejects(later, afterD, () => use(later, afterD, 'd', 'first'));
rejects(later, afterD, () => validate({ ...later, turn: 6 }, afterD));
assert.ok(afterD.version === 2);
rejects(later, afterD, () => validate(later, { ...afterD, started: true }));
rejects(later, afterD, () => validate(context(['d'], ['a']), afterD));
});
void test('independent repeated uses and native transitions retain detached history and survive JSON', () => {
const ctx = context([], []);
let frame = create(ctx);
for (let index = 0; index < 4; index++) frame = use(ctx, frame, 'b', index % 2 ? 'last' : 'first').order;
assert.ok(frame.version === 2);
assert.equal(frame.uses.length, 4);
assert.equal(new Set(frame.uses.map(use => use.event)).size, 4);
const restored = JSON.parse(JSON.stringify(frame)) as CharityOrder;
validate(ctx, restored);
assert.equal(event(ctx, restored), event(ctx, frame));
const changed = use(ctx, restored, 'd', 'first').order;
assert.ok(changed.version === 2 && restored.version === 2);
assert.notEqual(changed.order, restored.order);
assert.notEqual(changed.remaining, restored.remaining);
assert.notEqual(changed.completed, restored.completed);
assert.notEqual(changed.uses[0].completed, restored.uses[0].completed);
changed.order.pop();
changed.remaining.pop();
changed.completed.push('output-only');
changed.uses[0].completed.push('output-only');
validate(ctx, restored);
assert.deepEqual(ctx.ready, []);
assert.deepEqual(ctx.claimed, []);
});