Apply player order to fresh Advanced Revival phases
This commit is contained in:
1 parent
fe6c41ba40
commit
011b6aa096
20 files changed
+1212
-46
No files matched your search
@@ -0,0 +1,225 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { RevivalTurn, FinishRevivalTurn, finishRevivalTurnBlocked, revivalTurnOrderBlock } from '../components/revival-turn';
|
||||
import { RevivalCommerce } from '../components/revival-commerce';
|
||||
import { viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { createAdvancedRevivalOrderFixture, actAdvancedRevivalOrder } from './fixture-advanced-revival-order';
|
||||
|
||||
let original: ReturnType<typeof createAdvancedRevivalOrderFixture> | undefined;
|
||||
function native() {
|
||||
original ??= createAdvancedRevivalOrderFixture();
|
||||
return { ...original, game: structuredClone(original.game) };
|
||||
}
|
||||
function projected() {
|
||||
const source = native();
|
||||
return viewGame(source.game, source.actor);
|
||||
}
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(RevivalTurn, { game, busy, act() {} }));
|
||||
}
|
||||
function commerce(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(RevivalCommerce, { game, busy, act() {} }));
|
||||
}
|
||||
function button(html: string, label: string) {
|
||||
const found = [...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].map(match => match[0])
|
||||
.find(value => value.includes(`>${label}</button>`));
|
||||
assert.ok(found, `The ${label} button is visible`); return found;
|
||||
}
|
||||
function click(game: GameView, busy = false): Action[] {
|
||||
const actions: Action[] = [];
|
||||
FinishRevivalTurn({ game, busy, act(action) { actions.push(action); } }).props.onClick();
|
||||
return actions;
|
||||
}
|
||||
function controlledCommerce(viewer: string, actor = viewer): GameView {
|
||||
const source = native(), game = viewGame(source.game, viewer);
|
||||
const originalOrder = [actor, ...game.players.map(player => player.id).filter(id => id !== actor)];
|
||||
// Controlled personalized order/leader/negotiation cases supplement the
|
||||
// native source below; they install no authoritative Game outcome or pieces.
|
||||
game.revivalOrder = { version: 1, turn: game.turn, original: originalOrder,
|
||||
remaining: [...originalOrder], completed: [], started: false };
|
||||
game.active = actor;
|
||||
game.revival.orderBlocked = actor === viewer ? null : 'Wait for the current Revival actor.';
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('native first Revival renders persistent actor remaining completed and explicit force/leader grouping', () => {
|
||||
const source = native(), game = viewGame(source.game, source.actor), before = structuredClone(game);
|
||||
assert.deepEqual(game.revivalOrder!.original, source.originalOrder);
|
||||
for (let render = 0; render < 3; render++) {
|
||||
const html = markup(game);
|
||||
assert.match(html, /aria-label="Revival turn order"/);
|
||||
assert.ok(html.includes(`Current Revival actor: ${game.players.find(player => player.id === source.actor)!.name}.`));
|
||||
assert.match(html, /Remaining Revival turns:/);
|
||||
assert.match(html, /Completed Revival turns: None yet/);
|
||||
for (const id of game.revivalOrder!.remaining) assert.ok(html.includes(game.players.find(player => player.id === id)!.name));
|
||||
assert.match(html, /aria-label="Provisional Revival grouping"/);
|
||||
assert.match(html, /force, leader and Kwisatz Haderach revival choices together/);
|
||||
assert.match(html, /Tleilaxu can still answer another player’s requests/);
|
||||
assert.doesNotMatch(button(html, 'Finish Revival turn'), /\sdisabled=/);
|
||||
}
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('the actual native Finish callback sends only ready and advances to the next original seat', () => {
|
||||
const source = native(), game = viewGame(source.game, source.actor);
|
||||
assert.deepEqual(click(game, true), []);
|
||||
const actions = click(game);
|
||||
assert.deepEqual(actions, [{ type: 'ready' }]);
|
||||
const next = actAdvancedRevivalOrder(source.game, source.actor, actions[0]);
|
||||
assert.equal(next.phase, 4);
|
||||
assert.deepEqual(next.revivalOrder!.completed, [source.actor]);
|
||||
assert.equal(next.active, next.revivalOrder!.remaining[0]);
|
||||
const finished = viewGame(next, source.actor);
|
||||
assert.match(markup(finished), /Your Revival turn is finished/);
|
||||
assert.match(button(markup(finished), 'Finish Revival turn'), /\sdisabled=/);
|
||||
assert.deepEqual(click(finished), []);
|
||||
assert.deepEqual(click(viewGame(next, next.active!)), [{ type: 'ready' }]);
|
||||
});
|
||||
|
||||
void test('each native seat can explicitly finish once and the last actual control leaves Revival', () => {
|
||||
const source = native(); let game = source.game;
|
||||
const actors: string[] = [];
|
||||
while (game.phase === 4 && actors.length < source.originalOrder.length) {
|
||||
const actor = game.revivalOrder!.remaining[0];
|
||||
const actions = click(viewGame(game, actor));
|
||||
assert.deepEqual(actions, [{ type: 'ready' }]);
|
||||
actors.push(actor); game = actAdvancedRevivalOrder(game, actor, actions[0]);
|
||||
}
|
||||
assert.deepEqual(actors, source.originalOrder);
|
||||
assert.equal(game.phase, 5);
|
||||
assert.equal(markup(viewGame(game, source.owner)), '');
|
||||
});
|
||||
|
||||
void test('genuine Basic and unstamped legacy Advanced views retain their existing controls without this component', () => {
|
||||
const basic = createAdvancedRevivalOrderFixture({ advanced: false });
|
||||
const basicView = viewGame(basic.game, basic.actor);
|
||||
assert.equal(basicView.revivalOrder, null);
|
||||
assert.equal(markup(basicView), '');
|
||||
assert.deepEqual(click(basicView), []);
|
||||
const legacy = projected(); legacy.revivalOrder = null;
|
||||
assert.equal(markup(legacy), ''); assert.deepEqual(click(legacy), []);
|
||||
assert.equal(revivalTurnOrderBlock(legacy), null);
|
||||
});
|
||||
|
||||
void test('a different native viewer sees the current actor and a waiting reason while the actual callback is unavailable', () => {
|
||||
const source = native(), game = viewGame(source.game, source.outOfTurn);
|
||||
const html = markup(game);
|
||||
assert.match(html, /Current Revival actor:/);
|
||||
assert.ok(finishRevivalTurnBlocked(game));
|
||||
assert.match(button(html, 'Finish Revival turn'), /\sdisabled=/);
|
||||
assert.deepEqual(click(game), []);
|
||||
game.revival.orderBlocked = '';
|
||||
assert.deepEqual(click(game), [], 'Empty projected notices do not bypass the actual owner');
|
||||
});
|
||||
|
||||
void test('busy finished and inconsistent current order projections guard the actual callback', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.revivalOrder!.completed.push(game.me); },
|
||||
game => { game.revivalOrder!.remaining = []; },
|
||||
game => { game.revivalOrder!.turn++; },
|
||||
game => { game.turn++; }, game => { game.phase = 5; },
|
||||
game => { game.status = 'finished'; }, game => { game.active = null; },
|
||||
game => { game.revival.orderBlocked = 'The current native turn is unavailable.'; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(); change(game);
|
||||
assert.deepEqual(click(game), []);
|
||||
assert.equal(FinishRevivalTurn({ game, busy: false, act() {} }).props.disabled, true);
|
||||
}
|
||||
assert.deepEqual(click(projected(), true), []);
|
||||
const finished = projected(); finished.revivalOrder!.remaining = [];
|
||||
assert.match(markup(finished), /All Revival turns are finished/);
|
||||
});
|
||||
|
||||
void test('pending Revival response decision automatic action and owned autopilot prevent early finishing', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.response = { kind: 'revivalIncome', owner: game.me, passed: [] }; },
|
||||
game => { game.decision = { kind: 'revivalStop', player: game.me, recipient: game.me, revival: 'forces' }; },
|
||||
game => { game.truthtrance = {} as NonNullable<GameView['truthtrance']>; },
|
||||
game => { game.phaseOpening = { passed: [] }; },
|
||||
game => { game.revival.pending = true; },
|
||||
game => { game.automaticContinuationPending = true; },
|
||||
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(); change(game);
|
||||
assert.deepEqual(click(game), []);
|
||||
assert.match(button(markup(game), 'Finish Revival turn'), /\sdisabled=/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a stale actual callback cannot finish another actor or a new Revival round', () => {
|
||||
for (const anotherRound of [false, true]) {
|
||||
const game = projected(), actions: Action[] = [];
|
||||
const leaf = FinishRevivalTurn({ game, busy: false, act(action) { actions.push(action); } });
|
||||
assert.equal(leaf.props.disabled, false);
|
||||
if (anotherRound) { game.turn++; game.revivalOrder!.turn++; }
|
||||
else {
|
||||
game.revivalOrder!.completed.push(game.me); game.revivalOrder!.remaining.shift();
|
||||
game.active = game.revivalOrder!.remaining[0];
|
||||
}
|
||||
leaf.props.onClick(); assert.deepEqual(actions, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('an unaccepted negotiated request does not remove the current explicit Finish choice', () => {
|
||||
const game = projected();
|
||||
game.revivalRequests[game.me] = { leader: game.players.find(player => player.id === game.me)!.leaders[0].id, price: null };
|
||||
assert.deepEqual(click(game), [{ type: 'ready' }]);
|
||||
assert.doesNotMatch(button(markup(game), 'Finish Revival turn'), /\sdisabled=/);
|
||||
});
|
||||
|
||||
void test('controlled native request accept and withdrawal controls follow own turn without disabling the entire commerce panel', () => {
|
||||
const source = native();
|
||||
for (const ownsTurn of [true, false]) {
|
||||
const game = controlledCommerce(source.owner, ownsTurn ? source.owner : source.responder);
|
||||
const me = game.players.find(player => player.id === game.me)!;
|
||||
me.spice = 50; me.leaders[0].dead = true;
|
||||
game.revivalRequests[game.me] = { leader: me.leaders[0].id, price: 2 };
|
||||
const html = commerce(game);
|
||||
for (const label of ['Accept revival price', 'Withdraw request']) {
|
||||
if (ownsTurn) assert.doesNotMatch(button(html, label), /\sdisabled=/);
|
||||
else assert.match(button(html, label), /\sdisabled=/);
|
||||
}
|
||||
delete game.revivalRequests[game.me];
|
||||
const request = commerce(game);
|
||||
const selector = request.match(/<select\b[^>]*id="revival-request-leader"[^>]*>/)?.[0];
|
||||
assert.ok(selector);
|
||||
if (ownsTurn) assert.doesNotMatch(selector, /\sdisabled=/);
|
||||
else assert.match(selector, /\sdisabled=/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled out-of-turn Tleilaxu can quote decline and support others while their own foreign ghola remains unavailable', () => {
|
||||
const source = native();
|
||||
for (const ownsTurn of [true, false]) {
|
||||
const game = controlledCommerce(source.responder, ownsTurn ? source.responder : source.owner);
|
||||
const me = game.players.find(player => player.id === game.me)!;
|
||||
const other = game.players.find(player => player.id === source.owner)!;
|
||||
me.spice = 50; me.leaders[0].dead = true; me.ally = other.id; other.leaders[0].dead = true;
|
||||
game.revivalRules.expanded = []; game.revivalRules.limitBlocked = false;
|
||||
game.revivalRules.discountBlocked = false; game.revivalRules.allyDiscount = null;
|
||||
game.revivalRequests[other.id] = { leader: other.leaders[0].id, price: null };
|
||||
const html = commerce(game);
|
||||
const ghola = [...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].map(match => match[0]).find(value => value.includes('as a ghola'));
|
||||
assert.ok(ghola);
|
||||
if (ownsTurn) assert.doesNotMatch(ghola, /\sdisabled=/);
|
||||
else assert.match(ghola, /\sdisabled=/);
|
||||
for (const label of ['Quote this price', 'Decline request', 'Allow five revivals', 'Offer ally half-price revival'])
|
||||
assert.doesNotMatch(button(html, label), /\sdisabled=/);
|
||||
for (const label of ['Quote this price', 'Decline request', 'Allow five revivals', 'Offer ally half-price revival'])
|
||||
assert.match(button(commerce(game, true), label), /\sdisabled=/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the order display and actual Finish callback read only public order actor names and owned action availability', () => {
|
||||
const game = projected();
|
||||
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'reserves', 'leaders', 'forces', 'tanks'])
|
||||
Object.defineProperty(player, field, { get() { throw Error(`Revival order read unrelated ${player.id}.${field}`); } });
|
||||
for (const field of ['homeworlds', 'revivalRequests', 'allLeaders'])
|
||||
Object.defineProperty(game, field, { get() { throw Error(`Revival order read unrelated ${field}`); } });
|
||||
assert.match(markup(game), /Current Revival actor:/);
|
||||
assert.deepEqual(click(game), [{ type: 'ready' }]);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
actAdvancedRevivalOrder, advanceAdvancedRevivalOrder, advancedRevivalOrderClean,
|
||||
advancedRevivalOrderInventory, advancedRevivalOrderPlayer, advancedRevivalOrderStocks,
|
||||
createAdvancedRevivalOrderFixture, finishAdvancedRevivalOrder, passAdvancedRevivalOrder,
|
||||
reachAdvancedRevivalOrderActor,
|
||||
} from './fixture-advanced-revival-order';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A refused order action changes no original outcome');
|
||||
}
|
||||
function current(game: Game) {
|
||||
const actor = game.revivalOrder?.remaining[0]; assert.ok(actor); return actor;
|
||||
}
|
||||
|
||||
void test('public Advanced start earns the first Revival queue without replacing original physical state', () => {
|
||||
const f = createAdvancedRevivalOrderFixture();
|
||||
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.equal(f.afterSetup.status, 'playing'); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 4);
|
||||
assert.equal(f.sourceActions.length, f.sourceActionCount); assert.ok(f.sourceActionCount > 15);
|
||||
assert.deepEqual(f.staging, []);
|
||||
assert.ok(f.game.revivalOrder);
|
||||
assert.deepEqual(f.game.revivalOrder, { version: 1, turn: 1,
|
||||
original: f.originalOrder, remaining: f.originalOrder, completed: [], started: false });
|
||||
assert.equal(f.game.active, current(f.game));
|
||||
assert.deepEqual(advancedRevivalOrderInventory(f.game), f.originalInventory);
|
||||
for (const player of f.game.players) {
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
|
||||
assert.deepEqual(viewGame(f.game, player.id).revivalOrder, f.game.revivalOrder);
|
||||
}
|
||||
const view = viewGame(f.game, f.actor); assert.ok(view.revivalOrder);
|
||||
view.revivalOrder.remaining.reverse();
|
||||
assert.deepEqual(f.game.revivalOrder.remaining, f.originalOrder, 'Projected order is a separate public snapshot');
|
||||
});
|
||||
|
||||
void test('native Advanced Revival rejects another participant return and finish before touching the source', () => {
|
||||
const f = createAdvancedRevivalOrderFixture();
|
||||
rejected(f.game, f.outOfTurn, { type: 'ready' });
|
||||
rejected(f.game, f.outOfTurn, { type: 'revive', amount: 1, elite: 0 });
|
||||
assert.equal(f.game.active, f.actor);
|
||||
assert.deepEqual(f.game.revivalOrder?.completed, []);
|
||||
});
|
||||
|
||||
void test('native Advanced participants finish once in original order before Shipment opens', () => {
|
||||
const f = createAdvancedRevivalOrderFixture();
|
||||
let game = reload(f.game);
|
||||
for (let index = 0; index < f.originalOrder.length; index++) {
|
||||
const actor = f.originalOrder[index];
|
||||
assert.equal(current(game), actor); assert.equal(game.active, actor);
|
||||
const before = advancedRevivalOrderStocks(game);
|
||||
game = actAdvancedRevivalOrder(game, actor, { type: 'ready' });
|
||||
assert.deepEqual(advancedRevivalOrderStocks(game), before);
|
||||
if (index + 1 < f.originalOrder.length) {
|
||||
assert.equal(game.phase, 4);
|
||||
assert.deepEqual(game.revivalOrder?.original, f.originalOrder);
|
||||
assert.deepEqual(game.revivalOrder?.completed, f.originalOrder.slice(0, index + 1));
|
||||
assert.deepEqual(game.revivalOrder?.remaining, f.originalOrder.slice(index + 1));
|
||||
rejected(game, actor, { type: 'ready' });
|
||||
rejected(game, actor, { type: 'revive', amount: 1 });
|
||||
}
|
||||
}
|
||||
game = advanceAdvancedRevivalOrder(game, state => state.phase === 5 && advancedRevivalOrderClean(state));
|
||||
assert.equal(game.phase, 5);
|
||||
assert.deepEqual(advancedRevivalOrderInventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('native paid shipment and next Storm earn an ordered force return and separate Tleilaxu income', () => {
|
||||
const f = createAdvancedRevivalOrderFixture({ casualty: true });
|
||||
assert.ok(f.shipment && f.storm); assert.equal(f.game.turn, 2);
|
||||
assert.ok(f.shipment.steps.some(step => step.actor === f.owner && step.action.type === 'ship'));
|
||||
assert.equal(f.storm.before.stormMovementSource?.kind, 'card');
|
||||
assert.equal(f.storm.before.stormMovementSource?.turn, 2);
|
||||
assert.equal(f.storm.before.stormMovementSource?.distance, f.storm.before.stormPending);
|
||||
assert.ok(f.storm.steps.some(step => step.action.type === 'ready'));
|
||||
assert.ok(f.storm.steps.every(step => step.action.type !== 'stormDial'));
|
||||
assert.equal(advancedRevivalOrderPlayer(f.shipment.after, f.owner).forces[f.casualtyLocation], 1);
|
||||
assert.equal(advancedRevivalOrderPlayer(f.storm.after, f.owner).tanks, 1);
|
||||
let game = reachAdvancedRevivalOrderActor(f.game, f.owner);
|
||||
const before = structuredClone(game), original = [...game.revivalOrder!.original];
|
||||
const ownerBefore = advancedRevivalOrderPlayer(before, f.owner);
|
||||
const responderBefore = advancedRevivalOrderPlayer(before, f.responder);
|
||||
game = actAdvancedRevivalOrder(game, f.owner, f.revivalAction);
|
||||
game = advanceAdvancedRevivalOrder(game, state => advancedRevivalOrderClean(state) || state.response?.kind === 'revivalIncome');
|
||||
assert.equal(current(game), f.owner); assert.equal(game.active, f.owner);
|
||||
assert.equal(game.revivalOrder?.started, true);
|
||||
assert.notEqual(f.responder, current(game));
|
||||
// With no possible cancellation the original income settles automatically;
|
||||
// the test must not invent a mandatory acknowledgement or wait for it.
|
||||
if (game.response) {
|
||||
assert.equal(game.response.kind, 'revivalIncome');
|
||||
assert.equal(game.response.owner, f.responder); assert.equal(game.response.amount, 1);
|
||||
rejected(game, f.owner, { type: 'ready' });
|
||||
game = actAdvancedRevivalOrder(game, f.responder, { type: 'passResponse' });
|
||||
game = passAdvancedRevivalOrder(game);
|
||||
}
|
||||
game = advanceAdvancedRevivalOrder(game, advancedRevivalOrderClean);
|
||||
const ownerAfter = advancedRevivalOrderPlayer(game, f.owner);
|
||||
assert.equal(ownerAfter.tanks, 0); assert.equal(ownerAfter.reserves, ownerBefore.reserves + 1);
|
||||
assert.equal(ownerAfter.revived, 1); assert.equal(ownerAfter.freeForcesRevived, 1);
|
||||
assert.equal(ownerAfter.spice, ownerBefore.spice);
|
||||
assert.deepEqual(ownerAfter.elites, ownerBefore.elites, 'An ordinary return does not invent Sardaukar');
|
||||
assert.equal(advancedRevivalOrderPlayer(game, f.responder).spice, responderBefore.spice + 1);
|
||||
assert.deepEqual(game.revivalOrder?.original, original); assert.equal(current(game), f.owner);
|
||||
assert.deepEqual(advancedRevivalOrderInventory(game), f.originalInventory);
|
||||
game = actAdvancedRevivalOrder(game, f.owner, { type: 'ready' });
|
||||
if (game.phase === 4) assert.ok(!game.revivalOrder?.remaining.includes(f.owner));
|
||||
game = finishAdvancedRevivalOrder(game);
|
||||
assert.equal(game.phase, 5);
|
||||
});
|
||||
|
||||
void test('native Tleilaxu grant remains legal outside its own ordered opportunity', () => {
|
||||
const f = createAdvancedRevivalOrderFixture();
|
||||
let game = reachAdvancedRevivalOrderActor(f.game, f.owner);
|
||||
const before = structuredClone(game);
|
||||
game = actAdvancedRevivalOrder(game, f.responder, { type: 'tleilaxuRevivalLimit', target: f.owner });
|
||||
assert.ok(game.revivalRules?.expanded.includes(f.owner));
|
||||
assert.deepEqual(game.revivalOrder, before.revivalOrder);
|
||||
assert.equal(game.active, f.owner);
|
||||
assert.deepEqual(advancedRevivalOrderStocks(game), advancedRevivalOrderStocks(before));
|
||||
});
|
||||
|
||||
void test('public Basic Revival keeps concurrent readiness and has no Advanced order stamp', () => {
|
||||
const f = createAdvancedRevivalOrderFixture({ advanced: false });
|
||||
assert.equal(f.game.revivalOrder, undefined);
|
||||
assert.equal(viewGame(f.game, f.actor).revivalOrder, null);
|
||||
let game = actAdvancedRevivalOrder(f.game, f.outOfTurn, { type: 'ready' });
|
||||
assert.ok(game.ready.includes(f.outOfTurn)); assert.equal(game.phase, 4);
|
||||
game = finishAdvancedRevivalOrder(game);
|
||||
assert.equal(game.phase, 5); assert.equal(game.revivalOrder, undefined);
|
||||
});
|
||||
|
||||
void test('controlled legacy-envelope supplement keeps an already opened unstamped Advanced Revival concurrent', () => {
|
||||
const f = createAdvancedRevivalOrderFixture();
|
||||
// Controlled format supplement only: physical cards/forces/wallets and phase
|
||||
// remain native. Remove the new envelope to model a pre-cutover open phase.
|
||||
const legacy = reload(f.game); delete legacy.revivalOrder; legacy.active = null;
|
||||
assert.equal(viewGame(legacy, f.outOfTurn).revivalOrder, null);
|
||||
let game = actAdvancedRevivalOrder(legacy, f.outOfTurn, { type: 'ready' });
|
||||
assert.equal(game.revivalOrder, undefined); assert.ok(game.ready.includes(f.outOfTurn));
|
||||
game = finishAdvancedRevivalOrder(game);
|
||||
assert.equal(game.phase, 5);
|
||||
game = advanceAdvancedRevivalOrder(game, state => state.turn === 2 && state.phase === 4 && advancedRevivalOrderClean(state));
|
||||
assert.equal(game.revivalOrder?.version, 1); assert.equal(game.revivalOrder?.turn, 2);
|
||||
assert.deepEqual(game.revivalOrder?.original, game.order);
|
||||
});
|
||||
|
||||
void test('four minimal policies offer the current native Finish and no foreign ordinary return or Finish', () => {
|
||||
const f = createAdvancedRevivalOrderFixture();
|
||||
const ownActions = new Set(['ready', 'revive', 'reviveLeader', 'reviveKwisatz',
|
||||
'reviveForeignGhola', 'requestLeaderRevival', 'acceptLeaderRevival']);
|
||||
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
for (const player of f.game.players) {
|
||||
const view = viewGame(f.game, player.id); view.players.find(seat => seat.id === player.id)!.bot = level;
|
||||
const candidates = botActions(view);
|
||||
if (player.id !== f.actor)
|
||||
assert.ok(candidates.every(action => !ownActions.has(action.type)), `${level} waits for ${player.id}'s own opportunity`);
|
||||
else {
|
||||
const finish = candidates.find(action => action.type === 'ready'); assert.ok(finish);
|
||||
assert.doesNotThrow(() => applyAction(f.game, player.id, finish));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled dead-disc supplement keeps requester actions current and the Tleilaxu quote out of turn', () => {
|
||||
const f = createAdvancedRevivalOrderFixture();
|
||||
let game = reachAdvancedRevivalOrderActor(f.game, f.owner);
|
||||
// Only this supplement stages prior disc deaths. No native death chronology
|
||||
// is claimed; queue, physical card custody, wallets and clocks stay original.
|
||||
const leader = advancedRevivalOrderPlayer(game, f.owner).leaders[0]; leader.dead = true; leader.deaths = 1;
|
||||
const foreign = advancedRevivalOrderPlayer(game, 'atreides').leaders[0]; foreign.dead = true; foreign.deaths = 1;
|
||||
rejected(game, 'atreides', { type: 'requestLeaderRevival', leader: foreign.id });
|
||||
rejected(game, 'atreides', { type: 'reviveLeader', leader: foreign.id });
|
||||
game = actAdvancedRevivalOrder(game, f.owner, { type: 'requestLeaderRevival', leader: leader.id });
|
||||
assert.equal(current(game), f.owner); assert.notEqual(f.responder, f.owner);
|
||||
game = actAdvancedRevivalOrder(game, f.responder, { type: 'quoteLeaderRevival', target: f.owner, amount: 0 });
|
||||
assert.equal(game.revivalRequests?.[f.owner]?.price, 0);
|
||||
game = actAdvancedRevivalOrder(game, f.owner, { type: 'acceptLeaderRevival' });
|
||||
game = advanceAdvancedRevivalOrder(game, advancedRevivalOrderClean);
|
||||
assert.equal(advancedRevivalOrderPlayer(game, f.owner).leaders.find(disc => disc.id === leader.id)?.dead, false);
|
||||
assert.equal(current(game), f.owner);
|
||||
});
|
||||
|
||||
void test('controlled prevention supplement retains responder quotes and lets a prevented current requester finish', () => {
|
||||
const f = createAdvancedRevivalOrderFixture();
|
||||
let game = reachAdvancedRevivalOrderActor(f.game,f.owner);
|
||||
// Controlled prior death/prevention stamps supplement the played order;
|
||||
// neither a native death nor a physical Karama-use chronology is claimed.
|
||||
const leader = advancedRevivalOrderPlayer(game,f.owner).leaders[0];
|
||||
leader.dead = true; leader.deaths = 1;
|
||||
game = actAdvancedRevivalOrder(game,f.owner,{type:'requestLeaderRevival',leader:leader.id});
|
||||
game.revivalPrevention = {player:f.responder,turn:game.turn};
|
||||
for (const level of ['Easy','Medium','Hard','Brutal'] as const) {
|
||||
const view = viewGame(game,f.responder); view.players.find(p=>p.id===f.responder)!.bot=level;
|
||||
assert.equal(view.revival.prevented,true);
|
||||
const quote = botActions(view).find(action=>action.type==='quoteLeaderRevival'); assert.ok(quote);
|
||||
assert.equal(applyAction(game,f.responder,quote).active,f.owner);
|
||||
}
|
||||
game.revivalPrevention = {player:f.owner,turn:game.turn};
|
||||
for (const level of ['Easy','Medium','Hard','Brutal'] as const) {
|
||||
const view = viewGame(game,f.owner); view.players.find(p=>p.id===f.owner)!.bot=level;
|
||||
const finish = botActions(view).find(action=>action.type==='ready'); assert.ok(finish);
|
||||
const next=applyAction(game,f.owner,finish);
|
||||
assert.equal(next.revivalRequests?.[f.owner],undefined);
|
||||
assert.ok(next.phase!==4 || !next.revivalOrder?.remaining.includes(f.owner));
|
||||
}
|
||||
});
|
||||
@@ -241,11 +241,19 @@ void test('actual dealt Hajr retires once and the final native end still applies
|
||||
const actor = fixture.actor;
|
||||
const card = game.players.find(p => p.id === actor)!.hand.find(c => c.effect === 'hajr')!;
|
||||
const own = group(game, actor, fixture.lossTerritories);
|
||||
// Advanced Hajr follows a completed movement. Earn that native window with
|
||||
// one ordinary counter; the remaining shared group still has a real loss.
|
||||
game = settleAdvancedSourceResponses(applyAction(game,actor,{type:'move',
|
||||
from:'imperial_basin:9',amount:1,territory:'shield_wall',sector:9,elite:0}));
|
||||
game = applyAction(game, actor, {type: 'card', card: card.id});
|
||||
assert.deepEqual(group(game, actor, fixture.lossTerritories), own);
|
||||
const remaining = group(game,actor,fixture.lossTerritories);
|
||||
assert.equal(remaining.amount,own.amount-1);
|
||||
assert.ok(remaining.amount>0);
|
||||
assert.equal(remaining.tanks,own.tanks);
|
||||
assert.equal(game.discard.filter(c => c.id === card.id).length, 1);
|
||||
assert.ok(game.hajr.includes(actor));
|
||||
game = applyAction(reload(game), actor, {type: 'endMovement'});
|
||||
assert.equal(group(game, actor, fixture.lossTerritories).tanks, own.tanks + own.amount);
|
||||
assert.equal(group(game, actor, fixture.lossTerritories).tanks, own.tanks + remaining.amount);
|
||||
assert.equal(game.players.find(p=>p.id===actor)!.forces['shield_wall:9'],1);
|
||||
assert.equal(game.discard.filter(c => c.id === card.id).length, 1);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { actAdvisorPreparation, advisorPreparationInventory } from './fixture-advisor-preparation';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type AdvancedRevivalOrderStep = { actor: string; action: Action };
|
||||
export type AdvancedRevivalOrderTransition = {
|
||||
before: Game; steps: AdvancedRevivalOrderStep[]; after: Game;
|
||||
};
|
||||
export const actAdvancedRevivalOrder = actAdvisorPreparation;
|
||||
export const advancedRevivalOrderInventory = advisorPreparationInventory;
|
||||
export const advancedRevivalOrderPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor);
|
||||
assert.ok(player, `Missing original Revival seat ${actor}`); return player;
|
||||
};
|
||||
export const advancedRevivalOrderClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.pendingRevival && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function advancedRevivalOrderStocks(game: Game) {
|
||||
return Object.fromEntries(game.players.map(player => [player.id, {
|
||||
reserves: player.reserves, tanks: player.tanks, forces: structuredClone(player.forces),
|
||||
spice: player.spice, revived: player.revived,
|
||||
freeForcesRevived: player.freeForcesRevived, leaderRevived: player.leaderRevived,
|
||||
elites: structuredClone(player.elites),
|
||||
}]));
|
||||
}
|
||||
|
||||
/** Actual native decisions only. Ordered Revival finishes the current seat;
|
||||
* an unstamped Basic/legacy phase retains its original all-seat readiness. */
|
||||
export function nextAdvancedRevivalOrder(game: Game): AdvancedRevivalOrderStep | null {
|
||||
if (advancedRevivalOrderClean(game) && game.status === 'playing') {
|
||||
if (game.phase === 0 && game.stormPending === null) {
|
||||
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
||||
if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1
|
||||
? game.stormDialers.indexOf(actor) === 0 ? game.advanced ? 11 : 10 : 0
|
||||
: 1 } };
|
||||
}
|
||||
if (game.phase === 4 && game.revivalOrder) {
|
||||
const actor = game.revivalOrder.remaining[0];
|
||||
if (actor) return { actor, action: { type: 'ready' } };
|
||||
}
|
||||
}
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'revivalStop')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'guildTiming')
|
||||
return { actor: decision.player, action: { type: 'decision', take: false } };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceAdvancedRevivalOrder(game: Game, until: (state: Game) => boolean,
|
||||
actions: AdvancedRevivalOrderStep[] = []): Game {
|
||||
for (let count = 0; count < 1500; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The genuine Revival source ended before its next control');
|
||||
const step = nextAdvancedRevivalOrder(game);
|
||||
assert.ok(step, `Choose the original control at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actAdvancedRevivalOrder(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The genuine Revival source did not reach its finite boundary');
|
||||
}
|
||||
export function passAdvancedRevivalOrder(game: Game, actions: AdvancedRevivalOrderStep[] = []): Game {
|
||||
for (let count = 0; game.response && count < 60; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actAdvancedRevivalOrder(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.ok(!game.response); return game;
|
||||
}
|
||||
export function finishAdvancedRevivalOrder(game: Game, actions: AdvancedRevivalOrderStep[] = []): Game {
|
||||
return advanceAdvancedRevivalOrder(game, state => state.phase === 5 && advancedRevivalOrderClean(state), actions);
|
||||
}
|
||||
export function reachAdvancedRevivalOrderActor(game: Game, actor: string,
|
||||
actions: AdvancedRevivalOrderStep[] = []): Game {
|
||||
return advanceAdvancedRevivalOrder(game, state => state.phase === 4 && advancedRevivalOrderClean(state) &&
|
||||
(!state.revivalOrder || state.revivalOrder.remaining[0] === actor), actions);
|
||||
}
|
||||
|
||||
/** Genuine public E/A/T + original Ix-deck start. The first source stops at
|
||||
* turn-one Revival. casualty:true additionally earns a paid Emperor normal1
|
||||
* shipment to Hagga13 and its next native Storm death before turn-two Revival.
|
||||
* Only unopened original Spice positions and scalar lotteries are selected.
|
||||
* No played hand, force, wallet, Tanks, phase, active seat or queue is installed.
|
||||
* The supplied Advanced physical9 orders Force Revival; the single native
|
||||
* force/leader opportunity used by this slice is explicitly composed policy. */
|
||||
export function createAdvancedRevivalOrderFixture(options: { advanced?: boolean; casualty?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? true, casualty = options.casualty ?? false;
|
||||
const owner = 'emperor', responder = 'tleilaxu', casualtyLocation = 'hagga_basin:13';
|
||||
let game = createGame('ADVANCEDREVIVALORDER', newPlayer(owner, 'Emperor', 'emperor'), advanced, ['ix']);
|
||||
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(responder, 'Tleilaxu', 'tleilaxu'));
|
||||
const initial = structuredClone(game), actions: AdvancedRevivalOrderStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAdvancedRevivalOrder(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAdvancedRevivalOrder(game, until, actions); };
|
||||
const transition = (run: () => void): AdvancedRevivalOrderTransition => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length));
|
||||
assert.ok(!game.homeworlds && !game.leaderSkills && !game.nexusCards && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled);
|
||||
const originalInventory = advancedRevivalOrderInventory(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
// Four ordinary original blows avoid an incidental Nexus/worm programme.
|
||||
const spiceOrder = [0, 1, 2, 3].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)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.turn === 1 && state.phase === 4 && advancedRevivalOrderClean(state));
|
||||
const firstRevival = structuredClone(game);
|
||||
let shipment: AdvancedRevivalOrderTransition | null = null, storm: AdvancedRevivalOrderTransition | null = null;
|
||||
if (casualty) {
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && advancedRevivalOrderClean(state));
|
||||
shipment = transition(() => {
|
||||
act(owner, { type: 'ship', territory: 'hagga_basin', sector: 13, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(advancedRevivalOrderClean);
|
||||
});
|
||||
assert.equal(advancedRevivalOrderPlayer(game, owner).forces[casualtyLocation], 1);
|
||||
assert.equal(advancedRevivalOrderPlayer(shipment.before, owner).spice - advancedRevivalOrderPlayer(game, owner).spice, 2);
|
||||
reach(state => state.turn === 2 && state.phase === 0 && advancedRevivalOrderClean(state));
|
||||
if (advanced) {
|
||||
assert.ok(game.stormPending !== null && game.stormPending >= 1 && game.stormPending <= 6);
|
||||
assert.equal(game.stormMovementSource?.kind, 'card');
|
||||
assert.equal(game.stormMovementSource?.turn, 2);
|
||||
assert.equal(game.stormMovementSource?.distance, game.stormPending);
|
||||
} else assert.equal(game.stormPending, null);
|
||||
storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1); });
|
||||
assert.equal(advancedRevivalOrderPlayer(game, owner).forces[casualtyLocation], undefined);
|
||||
assert.equal(advancedRevivalOrderPlayer(game, owner).tanks, 1);
|
||||
reach(state => state.turn === 2 && state.phase === 4 && advancedRevivalOrderClean(state));
|
||||
}
|
||||
assert.deepEqual(advancedRevivalOrderInventory(game), originalInventory);
|
||||
const actor = game.revivalOrder?.remaining[0] ?? game.order[0];
|
||||
const outOfTurn = game.players.find(player => player.id !== actor)!.id;
|
||||
return { game, sourceGame: structuredClone(game), owner, responder, actor, outOfTurn,
|
||||
originalOrder: [...game.order], initial, lobby, setup, afterSetup, firstRevival,
|
||||
casualtyLocation, revivalAction: { type: 'revive', amount: 1, elite: 0 } as Action,
|
||||
shipment, storm, spiceOrder, originalInventory, originalSpice,
|
||||
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
|
||||
staging: [] as string[] };
|
||||
}
|
||||
@@ -72,6 +72,8 @@ function nativeStep(game: Game): Game {
|
||||
}
|
||||
if (game.phase === 3 && game.auction) return applyAction(game, game.auction.active, {type: 'passBid'});
|
||||
if (game.phase === 5) return applyAction(game, game.active!, {type: 'endMovement'});
|
||||
if (game.phase === 4 && game.revivalOrder?.remaining[0])
|
||||
return applyAction(game,game.revivalOrder.remaining[0],{type:'ready'});
|
||||
// A native Richese lot can own Bidding without an ordinary auction row.
|
||||
if (game.phase !== 3 && (game.phase !== 6 || !game.active)) {
|
||||
const seat = game.players.find(p => !game.ready.includes(p.id));
|
||||
|
||||
@@ -144,6 +144,10 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
|
||||
}
|
||||
if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } };
|
||||
if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
|
||||
if (game.phase === 4 && game.revivalOrder) {
|
||||
const actor = game.revivalOrder.remaining[0];
|
||||
return actor ? {actor,action:{type:'ready'}} : null;
|
||||
}
|
||||
if ([1, 2, 4, 7, 8].includes(game.phase) || (game.phase === 0 && game.stormPending !== null)) {
|
||||
const actor = game.players.find(p => !game.ready.includes(p.id))?.id;
|
||||
if (actor) return { actor, action: { type: 'ready' } };
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createRevivalOrder as create, finishRevivalOrder as finish, revivalOrderBlock as blocked,
|
||||
startRevivalOrder as start, validateRevivalOrder as validate,
|
||||
RevivalOrderError, type RevivalOrder, type RevivalOrderContext,
|
||||
} from '../game/revival-order';
|
||||
|
||||
const context = (ready: string[] = []): RevivalOrderContext => ({ turn: 3,
|
||||
order: ['first', 'next', 'last'], ready });
|
||||
function rejects(context: RevivalOrderContext, frame: RevivalOrder, run: () => unknown) {
|
||||
const beforeContext = structuredClone(context), beforeFrame = structuredClone(frame);
|
||||
assert.throws(run, RevivalOrderError);
|
||||
assert.deepEqual(context, beforeContext); assert.deepEqual(frame, beforeFrame);
|
||||
}
|
||||
|
||||
void test('a Revival frame retains the supplied physical order and resumes only its completed prefix', () => {
|
||||
const current = context(), frame = create(current);
|
||||
assert.deepEqual(frame, { version: 1, turn: 3, original: ['first', 'next', 'last'],
|
||||
remaining: ['first', 'next', 'last'], completed: [], started: false });
|
||||
validate(current, frame);
|
||||
assert.notEqual(frame.original, current.order); assert.notEqual(frame.remaining, current.order);
|
||||
assert.notEqual(frame.original, frame.remaining); assert.notEqual(frame.completed, current.ready);
|
||||
const resumed = context(['first']), continuation = create(resumed);
|
||||
validate(resumed, continuation);
|
||||
assert.deepEqual(continuation.remaining, ['next', 'last']); assert.deepEqual(continuation.completed, ['first']);
|
||||
assert.equal(blocked(resumed, continuation, 'next'), null);
|
||||
assert.match(blocked(resumed, continuation, 'first')!, /finished/);
|
||||
assert.match(blocked(resumed, continuation, 'last')!, /Wait/);
|
||||
assert.match(blocked(resumed, continuation, 'foreign')!, /seated/);
|
||||
});
|
||||
|
||||
void test('only the current faction may start and repeated accepted self requests remain an immutable no-op stamp', () => {
|
||||
const current = context(), frame = create(current), before = structuredClone(frame);
|
||||
assert.equal(blocked(current, frame, 'first'), null);
|
||||
rejects(current, frame, () => start(current, frame, 'next'));
|
||||
rejects(current, frame, () => start(current, frame, 'foreign'));
|
||||
const started = start(current, frame, 'first'); validate(current, started);
|
||||
assert.equal(started.started, true); assert.deepEqual(frame, before);
|
||||
assert.notEqual(started, frame);
|
||||
for (const key of ['original', 'remaining', 'completed'] as const) assert.notEqual(started[key], frame[key]);
|
||||
const repeated = start(current, started, 'first');
|
||||
assert.deepEqual(repeated, started); assert.notEqual(repeated, started);
|
||||
for (const key of ['original', 'remaining', 'completed'] as const) assert.notEqual(repeated[key], started[key]);
|
||||
repeated.remaining.pop(); repeated.original.pop(); repeated.completed.push('changed-output-only');
|
||||
assert.deepEqual(started.remaining, ['first', 'next', 'last']); assert.deepEqual(started.completed, []);
|
||||
assert.deepEqual(current.order, ['first', 'next', 'last']); assert.deepEqual(current.ready, []);
|
||||
});
|
||||
|
||||
void test('finishing either a pass or a started opportunity advances one physical seat and clears the next start stamp', () => {
|
||||
const current = context(), frame = create(current), afterPass = finish(current, frame, 'first');
|
||||
assert.equal(afterPass.started, false); assert.deepEqual(afterPass.remaining, ['next', 'last']);
|
||||
assert.deepEqual(afterPass.completed, ['first']); validate(context(['first']), afterPass);
|
||||
rejects(current, frame, () => finish(current, frame, 'next'));
|
||||
rejects(current, frame, () => finish(current, frame, 'foreign'));
|
||||
const secondContext = context(['first']), second = start(secondContext, afterPass, 'next');
|
||||
const afterRequest = finish(secondContext, second, 'next');
|
||||
validate(context(['first', 'next']), afterRequest);
|
||||
assert.deepEqual(afterRequest.remaining, ['last']); assert.deepEqual(afterRequest.completed, ['first', 'next']);
|
||||
assert.equal(afterRequest.started, false); assert.equal(second.started, true);
|
||||
assert.deepEqual(second.remaining, ['next', 'last']); assert.deepEqual(current.ready, []);
|
||||
});
|
||||
|
||||
void test('the final completion is a legal unstarted transient and cannot reopen or finish a completed faction', () => {
|
||||
const current = context(['first', 'next']), frame = start(current, create(current), 'last');
|
||||
const done = finish(current, frame, 'last'), completedContext = context(['first', 'next', 'last']);
|
||||
validate(completedContext, done);
|
||||
assert.deepEqual(done.remaining, []); assert.deepEqual(done.completed, completedContext.order); assert.equal(done.started, false);
|
||||
assert.deepEqual(create(completedContext), done);
|
||||
for (const player of completedContext.order) {
|
||||
assert.match(blocked(completedContext, done, player)!, /complete/);
|
||||
rejects(completedContext, done, () => start(completedContext, done, player));
|
||||
rejects(completedContext, done, () => finish(completedContext, done, player));
|
||||
}
|
||||
rejects(completedContext, done, () => validate(completedContext, { ...done, started: true }));
|
||||
});
|
||||
|
||||
void test('invalid turn, physical roster and non-prefix ready contexts reject before constructing an order', () => {
|
||||
for (const current of [
|
||||
{ ...context(), turn: 0 }, { ...context(), turn: 1.5 }, { ...context(), turn: NaN },
|
||||
{ ...context(), order: ['only'] }, { ...context(), order: ['a', 'b', 'c', 'd', 'e', 'f', 'g'] },
|
||||
{ ...context(), order: ['first', 'first'] }, { ...context(), order: ['first', ' '] },
|
||||
context(['next']), context(['first', 'last']), context(['first', 'first']), context(['foreign']),
|
||||
context(['first', 'next', 'last', 'foreign']),
|
||||
]) assert.throws(() => create(current), RevivalOrderError);
|
||||
for (const current of [null, [], { turn: 1, order: ['a', 'b'] },
|
||||
{ turn: 1, order: 'a,b', ready: [] }, { turn: 1, order: ['a', 2], ready: [] }])
|
||||
assert.throws(() => create(current as unknown as RevivalOrderContext), RevivalOrderError);
|
||||
const six = { turn: 1, order: ['i', 't', 'c', 'r', 'e', 'm'], ready: [] };
|
||||
validate(six, create(six));
|
||||
});
|
||||
|
||||
void test('malformed, stale or rearranged frames cannot replace the original ordered partition', () => {
|
||||
const current = context(['first']), frame = create(current);
|
||||
const withoutStarted = { ...frame } as Partial<RevivalOrder>; delete withoutStarted.started;
|
||||
for (const value of [
|
||||
null, [], withoutStarted, { ...frame, extra: true }, { ...frame, version: 2 }, { ...frame, turn: 2 },
|
||||
{ ...frame, original: ['next', 'first', 'last'] }, { ...frame, original: ['first', 'next', 'next'] },
|
||||
{ ...frame, remaining: ['last', 'next'] }, { ...frame, remaining: ['next'] },
|
||||
{ ...frame, completed: [] }, { ...frame, completed: ['first', 'next'] },
|
||||
{ ...frame, started: 'yes' }, { ...frame, remaining: ['next', 'foreign'] },
|
||||
]) rejects(current, frame, () => validate(current, value as unknown as RevivalOrder));
|
||||
rejects(current, frame, () => start(current, frame, ''));
|
||||
rejects(current, frame, () => finish(current, frame, ' '));
|
||||
rejects(current, frame, () => validate({ ...current, turn: 4 }, frame));
|
||||
rejects(current, frame, () => validate({ ...current, order: ['next', 'first', 'last'], ready: [] }, frame));
|
||||
});
|
||||
|
||||
void test('finish consumes the pre-finish ready prefix and returns detached arrays for the atomic caller update', () => {
|
||||
const current = context(['first']), frame = start(current, create(current), 'next');
|
||||
rejects(current, frame, () => finish({ ...current, ready: ['first', 'next'] }, frame, 'next'));
|
||||
rejects(current, frame, () => finish(current, frame, 'first'));
|
||||
const next = finish(current, frame, 'next');
|
||||
for (const key of ['original', 'remaining', 'completed'] as const) assert.notEqual(next[key], frame[key]);
|
||||
validate({ ...current, ready: [...current.ready, 'next'] }, next);
|
||||
next.original.pop(); next.remaining.pop(); next.completed.pop();
|
||||
assert.deepEqual(frame.original, ['first', 'next', 'last']); assert.deepEqual(frame.remaining, ['next', 'last']);
|
||||
assert.deepEqual(frame.completed, ['first']); assert.deepEqual(current.ready, ['first']);
|
||||
});
|
||||
Reference in new issue
Block a user