Connect Sapho to ordered Advanced Charity
This commit is contained in:
1 parent
e1596dbcee
commit
28df687bf9
15 files changed
+1080
-38
No files matched your search
@@ -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 }) },
|
||||
|
||||
@@ -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;
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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, []);
|
||||
});
|
||||
Reference in new issue
Block a user