Complete Advanced Revival suspension and solo win ranking
This commit is contained in:
1 parent
812657bb35
commit
3c62c284a7
17 files changed
+598
-47
No files matched your search
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, newPlayer, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { quoteVictory } from '../game/victory-quote';
|
||||
|
||||
/** Controlled public board using five printed strongholds, one physically
|
||||
* placed mobile stronghold, and the original three-token stronghold bonus. */
|
||||
function position(advanced = true): Game {
|
||||
const g = createGame('VICTORYORDER', newPlayer('ix', 'Ixians', 'ixians'), advanced);
|
||||
g.players.push(newPlayer('at', 'Atreides', 'atreides'));
|
||||
g.players.push(newPlayer('em', 'Emperor', 'emperor'));
|
||||
g.players.push(newPlayer('bg', 'Bene Gesserit', 'beneGesserit'));
|
||||
Object.assign(g, { status: 'playing', phase: 8, turn: 2, storm: 18,
|
||||
order: g.players.map(p => p.id), ready: [], phaseOpening: null,
|
||||
deck: baseDeck(), active: null, mobileStronghold: { location: 'red_chasm:7' },
|
||||
techTokens: createTechTokens() });
|
||||
for (const p of g.players) Object.assign(p, { hand: [], forces: {},
|
||||
reserves: 20, spice: 20, traitors: [], traitorChoices: [] });
|
||||
return g;
|
||||
}
|
||||
function holds(g: Game, id: string, ...names: string[]) {
|
||||
const player = g.players.find(p => p.id === id)!;
|
||||
player.forces = Object.fromEntries(names.map(name => [
|
||||
`${name}:${territory(name).sectors[0]}`, 1,
|
||||
]));
|
||||
}
|
||||
function tech(g: Game, id: string) {
|
||||
for (const token of Object.values(g.techTokens!)) token.owner = id;
|
||||
}
|
||||
function actual(g: Game) {
|
||||
let result = structuredClone(g);
|
||||
result.phase = 7;
|
||||
for (const p of result.players) result = applyAction(result, p.id, { type: 'ready' });
|
||||
return result;
|
||||
}
|
||||
|
||||
void test('Advanced awards the unallied higher qualified stronghold count, including a mobile and Tech stronghold', () => {
|
||||
const g = position();
|
||||
holds(g, 'ix', 'arrakeen', 'carthag', 'sietch_tabr');
|
||||
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
|
||||
holds(g, 'at', 'tueks_sietch', 'habbanya_ridge_sietch');
|
||||
tech(g, 'at');
|
||||
const before = structuredClone(g);
|
||||
assert.deepEqual(quoteVictory(g).winner, ['ix']);
|
||||
assert.deepEqual(actual(g).winner, ['ix']);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('equal qualified Advanced solo stronghold counts still share the win', () => {
|
||||
const g = position();
|
||||
holds(g, 'ix', 'arrakeen', 'carthag');
|
||||
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
|
||||
holds(g, 'at', 'sietch_tabr', 'tueks_sietch', 'habbanya_ridge_sietch');
|
||||
assert.deepEqual(quoteVictory(g).winner, ['ix', 'at']);
|
||||
assert.deepEqual(actual(g).winner, ['ix', 'at']);
|
||||
});
|
||||
|
||||
void test('the complete Tech set is one extra stronghold for comparing qualified solos', () => {
|
||||
const g = position();
|
||||
holds(g, 'ix', 'arrakeen', 'carthag');
|
||||
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
|
||||
holds(g, 'at', 'sietch_tabr', 'tueks_sietch', 'habbanya_ridge_sietch');
|
||||
tech(g, 'at');
|
||||
assert.deepEqual(quoteVictory(g).winner, ['at']);
|
||||
assert.deepEqual(actual(g).winner, ['at']);
|
||||
});
|
||||
|
||||
void test('Basic retains its existing shared-win policy for unequal qualified solos', () => {
|
||||
const g = position(false);
|
||||
holds(g, 'ix', 'arrakeen', 'carthag', 'sietch_tabr');
|
||||
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
|
||||
holds(g, 'at', 'tueks_sietch', 'habbanya_ridge_sietch');
|
||||
tech(g, 'at');
|
||||
assert.deepEqual(quoteVictory(g).winner, ['ix', 'at']);
|
||||
assert.deepEqual(actual(g).winner, ['ix', 'at']);
|
||||
});
|
||||
|
||||
void test('a qualifying alliance and solo still share an Advanced win regardless of their totals', () => {
|
||||
const g = position();
|
||||
g.players[1].ally = 'em'; g.players[2].ally = 'at';
|
||||
holds(g, 'at', 'arrakeen', 'carthag');
|
||||
holds(g, 'em', 'sietch_tabr');
|
||||
tech(g, 'at');
|
||||
holds(g, 'ix', 'tueks_sietch', 'habbanya_ridge_sietch');
|
||||
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
|
||||
assert.deepEqual(quoteVictory(g).winner, ['ix', 'at', 'em']);
|
||||
assert.deepEqual(actual(g).winner, ['ix', 'at', 'em']);
|
||||
});
|
||||
|
||||
void test('Bene Gesserit prediction uses the actual higher solo winner, not the lower qualifier', () => {
|
||||
const g = position();
|
||||
holds(g, 'ix', 'arrakeen', 'carthag', 'sietch_tabr');
|
||||
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
|
||||
holds(g, 'at', 'tueks_sietch', 'habbanya_ridge_sietch');
|
||||
tech(g, 'at');
|
||||
g.players[3].prediction = { faction: 'atreides', turn: 2 };
|
||||
assert.deepEqual(quoteVictory(g).winner, ['ix']);
|
||||
assert.deepEqual(actual(g).winner, ['ix']);
|
||||
g.players[3].prediction = { faction: 'ixians', turn: 2 };
|
||||
assert.deepEqual(quoteVictory(g).winner, ['bg']);
|
||||
assert.deepEqual(actual(g).winner, ['bg']);
|
||||
});
|
||||
@@ -95,6 +95,17 @@ void test('actual clean and queued callbacks send only the original five action
|
||||
}
|
||||
});
|
||||
|
||||
void test('own started Revival pause explains retained allowances and sends the native action', () => {
|
||||
const game = projected('last');
|
||||
const option: Option = { scope: 'revival', event: 'controlled-own-revival-event', mode: 'last', resumeOwn: true };
|
||||
game.saphoOptions = [option];
|
||||
const html = panel(game);
|
||||
assert.match(html, /Resume your revival last and discard Juice of Sapho/);
|
||||
assert.match(html, /Returned pieces, costs and used revival allowances remain unchanged/);
|
||||
assert.deepEqual(press(option), [{ type: 'card', card: sapho!.id, scope: 'revival', event: option.event, mode: 'last' }]);
|
||||
assert.deepEqual(press(option, true), []);
|
||||
});
|
||||
|
||||
void test('Basic unstamped and completed controlled views offer no Revival order button even with stale projected options', () => {
|
||||
const basic = projected(); basic.advanced = false; basic.revivalOrder = null;
|
||||
const legacy = projected(); legacy.revivalOrder = null;
|
||||
@@ -113,8 +124,8 @@ void test('Basic unstamped and completed controlled views offer no Revival order
|
||||
void test('unavailable ordered Revival guidance keeps completed turns and the current started actor intact', () => {
|
||||
const game = projected(); game.saphoOptions = [];
|
||||
const html = panel(game);
|
||||
assert.match(html, /only among remaining unstarted Revival turns/);
|
||||
assert.match(html, /A player who has started finishes their Revival choices unchanged/);
|
||||
assert.match(html, /pause your own started turn and resume it last/);
|
||||
assert.match(html, /Another player finishes any started turn first/);
|
||||
assert.match(html, /already-held positions and committed cards are unavailable/);
|
||||
assert.match(revival(game), /Juice of Sapho may change the remaining order; completed Revival turns stay finished/);
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ void test('all four minimal profiles propose an accepted native queued Revival o
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled conserved-card supplement refuses to split the genuine started owner opportunity', () => {
|
||||
void test('controlled conserved-card supplement can pause the genuine started owner opportunity', () => {
|
||||
const f = fixture({ started: true }), game = structuredClone(f.game);
|
||||
// This supplement conserves the actual paid card but does not claim a native
|
||||
// transfer to Richese. Its force return and started opportunity remain native.
|
||||
@@ -122,9 +122,13 @@ void test('controlled conserved-card supplement refuses to split the genuine sta
|
||||
player(game, f.current).hand.push(donor.hand.splice(index, 1)[0]);
|
||||
assert.equal(game.revivalOrder?.started, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.deepEqual(viewGame(game, f.current).saphoOptions.filter(option => option.scope === 'revival'), []);
|
||||
const borrowed = action(f.game, 'last');
|
||||
reject(game, f.current, borrowed);
|
||||
reject(game, f.current, { ...borrowed, event: 'stale-revival-order-event' });
|
||||
const option = viewGame(game, f.current).saphoOptions.find(option => option.scope === 'revival' && option.mode === 'last');
|
||||
assert.ok(option && option.scope === 'revival' && option.resumeOwn);
|
||||
const paused = act(game, f.current, {type:'card', card:f.card, scope:'revival', mode:'last', event:option.event});
|
||||
assert.equal(paused.revivalOrder?.remaining.at(-1), f.current);
|
||||
assert.equal(player(paused, f.current).revived, 1);
|
||||
});
|
||||
|
||||
for (const claim of [false, true]) {
|
||||
|
||||
@@ -57,7 +57,10 @@ void test('a started current actor locks its entire opportunity while another fa
|
||||
assert.notEqual(event(ctx, frame), old);
|
||||
rejects(ctx, frame, () => reorder(ctx, frame, { event: old, player: 'd', mode: 'first' }));
|
||||
rejects(ctx, frame, () => use(ctx, frame, 'a', 'first'));
|
||||
rejects(ctx, frame, () => use(ctx, frame, 'a', 'last'));
|
||||
const paused = use(ctx, frame, 'a', 'last');
|
||||
assert.equal(paused.suspended, 'a');
|
||||
assert.deepEqual(paused.order.remaining, ['b', 'c', 'd', 'a']);
|
||||
assert.equal(paused.order.started, false);
|
||||
const first = use(ctx, frame, 'd', 'first');
|
||||
assert.equal(first.afterCurrent, 'a'); assert.equal(first.order.started, true);
|
||||
assert.deepEqual(first.order.remaining, ['a', 'd', 'b', 'c']);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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 { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
actSaphoRevivalOrder as act, advanceSaphoRevivalOrder as advance,
|
||||
createSaphoRevivalOrderFixture as fixture, createSaphoRevivalSemutaFixture,
|
||||
finishSaphoRevivalOrder as finish, reactSaphoRevivalSemuta,
|
||||
saphoRevivalOrderClean as clean, saphoRevivalOrderInventory as inventory,
|
||||
saphoRevivalOrderPlayer as player,
|
||||
} from './fixture-sapho-revival-order';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
/** Original Advanced setup, real purchased Sapho, Richese shipment, Storm
|
||||
* casualty and its free revival. Only the card's owner is controlled: the
|
||||
* physical purchased card is moved from Emperor to the current Richese seat. */
|
||||
function source() {
|
||||
const f = fixture({ started: true });
|
||||
const game = structuredClone(f.game);
|
||||
const donor = player(game, f.owner);
|
||||
const index = donor.hand.findIndex(card => card.id === f.card);
|
||||
assert.ok(index >= 0);
|
||||
player(game, f.current).hand.push(...donor.hand.splice(index, 1));
|
||||
assert.equal(game.revivalOrder?.started, true);
|
||||
assert.equal(player(game, f.current).revived, 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
return { f, game };
|
||||
}
|
||||
|
||||
function ownLast(game: Game, actor: string, card: string): Action {
|
||||
const option = viewGame(game, actor).saphoOptions.find(row => row.scope === 'revival' && row.mode === 'last');
|
||||
assert.ok(option && option.scope === 'revival' && option.resumeOwn);
|
||||
return { type: 'card', card, scope: 'revival', mode: 'last', event: option.event };
|
||||
}
|
||||
|
||||
function resume(game: Game, actor: string): Game {
|
||||
return advance(game, state => state.phase === 4 && clean(state) && state.revivalOrder?.remaining[0] === actor);
|
||||
}
|
||||
|
||||
void test('Advanced own Revival suspension retains an earned free return and resumes once after the remaining players', () => {
|
||||
const { f, game: original } = source();
|
||||
const before = structuredClone(player(original, f.current));
|
||||
const expected = [...original.revivalOrder!.remaining.slice(1), f.current];
|
||||
const declaration = ownLast(original, f.current, f.card);
|
||||
reject(original, f.current, { ...declaration, event: 'stale-revival-event' });
|
||||
let game = act(original, f.current, declaration);
|
||||
assert.deepEqual(game.revivalOrder?.remaining, expected);
|
||||
assert.equal(game.revivalOrder?.version, 3);
|
||||
assert.equal(game.revivalOrder?.started, false);
|
||||
assert.deepEqual(game.order, original.order);
|
||||
assert.equal(player(game, f.current).reserves, before.reserves);
|
||||
assert.equal(player(game, f.current).tanks, before.tanks);
|
||||
assert.equal(player(game, f.current).revived, before.revived);
|
||||
assert.equal(player(game, f.current).freeForcesRevived, before.freeForcesRevived);
|
||||
assert.equal(player(game, f.current).spice, before.spice);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
reject(game, f.current, declaration);
|
||||
game = resume(game, f.current);
|
||||
assert.equal(game.revivalOrder?.started, true);
|
||||
assert.equal(player(game, f.current).revived, before.revived);
|
||||
assert.equal(player(game, f.current).freeForcesRevived, before.freeForcesRevived);
|
||||
game = act(game, f.current, { type: 'ready' });
|
||||
game = finish(game);
|
||||
assert.equal(game.phase, 5);
|
||||
assert.deepEqual(game.order, original.order);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('Advanced own Revival suspension preserves paid force and leader usage on return', () => {
|
||||
const { f, game: original } = source();
|
||||
let game = structuredClone(original);
|
||||
// Controlled physical supplement: move two Richese reserves to the Tanks,
|
||||
// and mark its existing five leader discs dead once to open their native
|
||||
// revival cycle. The paid return and leader revival below are real actions.
|
||||
const current = player(game, f.current);
|
||||
assert.ok(current.reserves >= 2);
|
||||
current.reserves -= 2;
|
||||
current.tanks += 2;
|
||||
for (const leader of current.leaders) { leader.dead = true; leader.deaths = 1; }
|
||||
// Richese receives two free force returns; the native source already used
|
||||
// one. Complete its remaining free return before making a paid one.
|
||||
const beforeFree = structuredClone(current);
|
||||
game = advance(act(game, f.current, { type: 'revive', amount: 1, elite: 0 }), clean);
|
||||
assert.equal(player(game, f.current).spice, beforeFree.spice);
|
||||
assert.equal(player(game, f.current).revived, beforeFree.revived + 1);
|
||||
const beforePaid = structuredClone(player(game, f.current));
|
||||
game = advance(act(game, f.current, { type: 'revive', amount: 1, elite: 0 }), clean);
|
||||
assert.equal(player(game, f.current).spice, beforePaid.spice - 2);
|
||||
assert.equal(player(game, f.current).revived, beforePaid.revived + 1);
|
||||
assert.equal(player(game, f.current).freeForcesRevived, beforePaid.freeForcesRevived);
|
||||
const eligible = viewGame(game, f.current).revival.leaders.find(row => row.affordable);
|
||||
assert.ok(eligible);
|
||||
game = advance(act(game, f.current, { type: 'reviveLeader', leader: eligible.id }), clean);
|
||||
assert.equal(player(game, f.current).leaderRevived, true);
|
||||
const used = structuredClone(player(game, f.current));
|
||||
const revivalAvailable = viewGame(game, f.current).revival.forcesRemaining;
|
||||
game = act(game, f.current, ownLast(game, f.current, f.card));
|
||||
game = resume(game, f.current);
|
||||
assert.equal(player(game, f.current).spice, used.spice);
|
||||
assert.equal(player(game, f.current).reserves, used.reserves);
|
||||
assert.equal(player(game, f.current).tanks, used.tanks);
|
||||
assert.equal(player(game, f.current).revived, used.revived);
|
||||
assert.equal(player(game, f.current).freeForcesRevived, used.freeForcesRevived);
|
||||
assert.equal(player(game, f.current).leaderRevived, true);
|
||||
assert.equal(viewGame(game, f.current).revival.forcesRemaining, revivalAvailable);
|
||||
assert.ok(player(game, f.current).leaders.some(leader => leader.id === eligible.id && !leader.dead));
|
||||
reject(game, f.current, { type: 'reviveLeader', leader: eligible.id });
|
||||
game = finish(act(game, f.current, { type: 'ready' }));
|
||||
assert.equal(game.phase, 5);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('All four minimal bot profiles can select the projected Advanced own Revival suspension', () => {
|
||||
const { f, game } = source();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.current);
|
||||
view.players.find(seat => seat.id === f.current)!.bot = difficulty;
|
||||
const action = botActions(view).find(row => row.type === 'card' && row.card === f.card && row.scope === 'revival');
|
||||
assert.ok(action, `${difficulty} proposes the owned Sapho action`);
|
||||
const next = act(game, f.current, action);
|
||||
assert.equal(next.revivalOrder?.remaining.at(-1), f.current);
|
||||
assert.equal(player(next, f.current).revived, player(game, f.current).revived);
|
||||
assert.equal(next.discard.filter(card => card.id === f.card).length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Semuta reaction completes before a suspended Advanced Revival continues', () => {
|
||||
const f = createSaphoRevivalSemutaFixture();
|
||||
assert.equal(f.nativeAcquisition, false);
|
||||
// This separate Homeworld/Semuta source uses its documented conserved-card
|
||||
// setup. Finish earlier actors only until a non-Semuta owner can start a
|
||||
// real Revival with at least one later participant.
|
||||
let game = advance(structuredClone(f.game), state => clean(state) && !!state.revivalOrder &&
|
||||
state.revivalOrder.remaining.length > 1 && state.revivalOrder.remaining[0] !== f.holder);
|
||||
const actor = game.revivalOrder!.remaining[0];
|
||||
if (actor !== f.owner) {
|
||||
const donor = player(game, f.owner);
|
||||
const index = donor.hand.findIndex(card => card.id === f.card);
|
||||
assert.ok(index >= 0);
|
||||
player(game, actor).hand.push(...donor.hand.splice(index, 1));
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
// Controlled leader deaths open the native revival; the paid action and
|
||||
// subsequent discard/reaction/queue are all actual engine transitions.
|
||||
for (const leader of player(game, actor).leaders) { leader.dead = true; leader.deaths = 1; }
|
||||
const eligible = viewGame(game, actor).revival.leaders.find(row => row.affordable);
|
||||
assert.ok(eligible);
|
||||
game = advance(act(game, actor, { type: 'reviveLeader', leader: eligible.id }), clean);
|
||||
const before = structuredClone(player(game, actor));
|
||||
const paused = act(game, actor, ownLast(game, actor, f.card));
|
||||
assert.equal(paused.pendingTreacheryDiscard?.continuation?.kind, 'saphoRevivalDiscard');
|
||||
assert.ok(paused.pendingTreacheryDiscard?.reaction);
|
||||
const queue = structuredClone(paused.revivalOrder);
|
||||
reject(paused, paused.revivalOrder!.remaining[0], { type: 'ready' });
|
||||
game = reactSaphoRevivalSemuta(paused, f.holder, false);
|
||||
game = advance(game, clean);
|
||||
assert.deepEqual(game.revivalOrder, queue);
|
||||
assert.equal(player(game, actor).leaderRevived, true);
|
||||
assert.equal(player(game, actor).spice, before.spice);
|
||||
game = resume(game, actor);
|
||||
assert.equal(game.revivalOrder?.started, true);
|
||||
assert.equal(player(game, actor).leaderRevived, true);
|
||||
assert.equal(player(game, actor).spice, before.spice);
|
||||
assert.equal(finish(act(game, actor, { type: 'ready' })).phase, 5);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createRevivalOrder, finishRevivalOrder, reorderSaphoRevivalOrder,
|
||||
saphoRevivalOrderEvent, startRevivalOrder, validateRevivalOrder,
|
||||
type RevivalOrder, type RevivalOrderContext,
|
||||
} from '../game/revival-order';
|
||||
|
||||
const context = (ready: string[] = []): RevivalOrderContext => ({ turn: 4,
|
||||
order: ['a', 'b', 'c', 'd'], ready });
|
||||
function reorder(ctx: RevivalOrderContext, order: RevivalOrder, player: string, mode: 'first' | 'last') {
|
||||
return reorderSaphoRevivalOrder(ctx, order, { event: saphoRevivalOrderEvent(ctx, order), player, mode });
|
||||
}
|
||||
|
||||
void test('started Revival suspends last and resumes the same started opportunity after intervening turns', () => {
|
||||
const ctx = context(), original = startRevivalOrder(ctx, createRevivalOrder(ctx), 'a');
|
||||
const choice = reorder(ctx, original, 'a', 'last');
|
||||
assert.equal(choice.suspended, 'a'); assert.equal(choice.afterCurrent, undefined);
|
||||
assert.equal(choice.order.version, 3); assert.ok(choice.order.version === 3);
|
||||
assert.deepEqual(choice.order.remaining, ['b', 'c', 'd', 'a']);
|
||||
assert.deepEqual(choice.order.paused, ['a']); assert.equal(choice.order.started, false);
|
||||
assert.equal(original.version, 1); assert.equal(original.started, true);
|
||||
let order: RevivalOrder = choice.order;
|
||||
for (const [index, player] of ['b', 'c', 'd'].entries()) {
|
||||
const before = context(['b', 'c', 'd'].slice(0, index));
|
||||
order = finishRevivalOrder(before, startRevivalOrder(before, order, player), player);
|
||||
}
|
||||
assert.equal(order.version, 3); assert.ok(order.version === 3);
|
||||
assert.deepEqual(order.remaining, ['a']); assert.deepEqual(order.paused, ['a']);
|
||||
assert.equal(order.started, true);
|
||||
const finished = finishRevivalOrder(context(['b', 'c', 'd']), order, 'a');
|
||||
validateRevivalOrder(context(['b', 'c', 'd', 'a']), finished);
|
||||
assert.ok(finished.version === 3); assert.deepEqual(finished.paused, []);
|
||||
assert.equal(finished.started, false);
|
||||
});
|
||||
|
||||
void test('suspension preserves earlier version-two uses and a later reordered paused turn retains its started lock', () => {
|
||||
const ctx = context();
|
||||
const first = reorder(ctx, createRevivalOrder(ctx), 'd', 'first').order;
|
||||
assert.equal(first.version, 2);
|
||||
const active = startRevivalOrder(ctx, first, 'd');
|
||||
const suspended = reorder(ctx, active, 'd', 'last').order;
|
||||
assert.ok(first.version === 2 && suspended.version === 3);
|
||||
assert.deepEqual(suspended.uses[0], first.uses[0]);
|
||||
assert.deepEqual(suspended.paused, ['d']);
|
||||
const resumed = reorder(ctx, suspended, 'd', 'first').order;
|
||||
assert.ok(resumed.version === 3); assert.equal(resumed.started, true);
|
||||
assert.equal(resumed.remaining[0], 'd'); assert.deepEqual(resumed.paused, ['d']);
|
||||
const restored = JSON.parse(JSON.stringify(resumed)) as RevivalOrder;
|
||||
validateRevivalOrder(ctx, restored);
|
||||
const done = finishRevivalOrder(ctx, restored, 'd');
|
||||
assert.ok(done.version === 3); assert.deepEqual(done.paused, []);
|
||||
validateRevivalOrder(context(['d']), done);
|
||||
});
|
||||
|
||||
void test('suspension rejects stale, no-op and forged receipts without changing either input', () => {
|
||||
const ctx = context(), fresh = createRevivalOrder(ctx);
|
||||
const old = saphoRevivalOrderEvent(ctx, fresh), active = startRevivalOrder(ctx, fresh, 'a');
|
||||
const before = structuredClone(active);
|
||||
assert.throws(() => reorderSaphoRevivalOrder(ctx, active, { event: old, player: 'a', mode: 'last' }));
|
||||
assert.throws(() => reorder(ctx, active, 'a', 'first'));
|
||||
assert.deepEqual(active, before);
|
||||
const frame = reorder(ctx, active, 'a', 'last').order; assert.ok(frame.version === 3);
|
||||
for (const bad of [
|
||||
{ ...frame, paused: [] }, { ...frame, paused: ['a', 'a'] },
|
||||
{ ...frame, paused: ['foreign'] }, { ...frame, remaining: ['a', 'b', 'c', 'd'] },
|
||||
{ ...frame, uses: frame.uses.map(use => ({ ...use, suspended: undefined })) },
|
||||
]) assert.throws(() => validateRevivalOrder(ctx, bad as RevivalOrder));
|
||||
assert.deepEqual(active, before);
|
||||
});
|
||||
Reference in new issue
Block a user