Apply player order to fresh Advanced Revival phases

This commit is contained in:
Quintine committed 2026-10-26 10:33:54 +11:00
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' }]);
});
+213
View File
@@ -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));
}
});
+10 -2
View File
@@ -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);
});
+148
View File
@@ -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[] };
}
+2
View File
@@ -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));
+4
View File
@@ -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' } };
+119
View File
@@ -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']);
});