Open native advisor preparation before the first shipment
Allow Advanced BG preparation in phases2–5 at quiet boundaries, preserving original phase/auction/readiness and native eligibility. Close a current-turn receipt only on committed shipments, including marker and Sneak Attack entry. Retain the legacy phase5 queue and qualify the per-territory prevention policy. Connect persistent controls, minimal bot support and focused native evidence. Complete modes and browser/deployed acceptance remain open.
This commit is contained in:
1 parent
0e3d6a250e
commit
660b0a0c25
18 files changed
+1269
-13
No files matched your search
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AdvisorPreparation } from '../components/advisor-preparation';
|
||||
import {
|
||||
createGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Action,
|
||||
type GameView,
|
||||
} from '../game/engine';
|
||||
|
||||
// Client-only public projections isolate persistent controls. The source suite
|
||||
// exercises actual advisor groups, window closure and Karama continuations.
|
||||
function projected(): GameView {
|
||||
const game = createGame('ADVISORPREPARATIONCONTROLS', newPlayer('b', 'Bene Gesserit', 'beneGesserit'), true);
|
||||
game.players.push(newPlayer('a', 'Atreides', 'atreides'));
|
||||
Object.assign(game, {
|
||||
status: 'playing', phase: 2, turn: 3, order: ['b', 'a'],
|
||||
});
|
||||
const view = viewGame(game, 'b');
|
||||
view.advisorPreparation = { blocked: null, territories: ['arrakeen', 'carthag'] };
|
||||
return view;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(AdvisorPreparation, {
|
||||
game, busy, act() {},
|
||||
}));
|
||||
}
|
||||
|
||||
function click(node: ReactNode, place: string): boolean {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{
|
||||
children?: ReactNode;
|
||||
onClick?: () => void;
|
||||
'data-testid'?: string;
|
||||
}>(child)) continue;
|
||||
if (child.props['data-testid'] === `advisor-preparation-${place}` && child.props.onClick) {
|
||||
child.props.onClick();
|
||||
return true;
|
||||
}
|
||||
if (click(child.props.children, place)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function prepare(game: GameView, place = 'arrakeen', busy = false): Action[] {
|
||||
const actions: Action[] = [];
|
||||
click(AdvisorPreparation({ game, busy, act(action) { actions.push(action); } }), place);
|
||||
return actions;
|
||||
}
|
||||
|
||||
void test('the native Advanced owner can prepare each projected territory with its exact original action', () => {
|
||||
const game = projected();
|
||||
const html = markup(game);
|
||||
assert.match(html, /aria-label="Bene Gesserit advisor preparation"/);
|
||||
assert.match(html, /Prepare advisors · Arrakeen/);
|
||||
assert.match(html, /Prepare advisors · Carthag/);
|
||||
assert.doesNotMatch(html, /disabled=""/);
|
||||
assert.deepEqual(prepare(game), [{ type: 'prepareAdvisors', territory: 'arrakeen' }]);
|
||||
assert.deepEqual(prepare(game, 'carthag'), [{ type: 'prepareAdvisors', territory: 'carthag' }]);
|
||||
assert.deepEqual(prepare(game, 'sietch_tabr'), []);
|
||||
});
|
||||
|
||||
void test('the persistent guide names the printed window and keeps native choices and independent flips separate', () => {
|
||||
const html = markup(projected());
|
||||
assert.match(html, /After Spice Blow and any Nexus/);
|
||||
assert.match(html, /until the first actual shipment has occurred/);
|
||||
assert.match(html, /Charity, Bidding, Revival and the opening of Shipment and Movement/);
|
||||
assert.match(html, /turn all your advisors there into fighters, across every sector/);
|
||||
assert.match(html, /Storm, ordinary allied occupation and fresh-advisor restrictions\s*still apply/);
|
||||
assert.match(html, /Provisional Karama policy: a canceled preparation blocks that territory until this window ends/);
|
||||
assert.match(html, /Other eligible territories remain available/);
|
||||
assert.match(html, /Intrusion, Nexus conversion and automatic advisor flips remain independent/);
|
||||
assert.match(html, /ordinary advisor chooser during Shipment and Movement remains available/);
|
||||
assert.match(html, /Declining that choice does not close this preparation window/);
|
||||
});
|
||||
|
||||
void test('busy, Truthtrance and pending Nexus block the actual buttons without removing their guide', () => {
|
||||
const game = projected();
|
||||
assert.deepEqual(prepare(game, 'arrakeen', true), []);
|
||||
assert.equal((markup(game, true).match(/<button\b[^>]*\sdisabled=""/g) ?? []).length, 2);
|
||||
const truthtrance = structuredClone(game);
|
||||
truthtrance.truthtrance = {} as NonNullable<GameView['truthtrance']>;
|
||||
assert.deepEqual(prepare(truthtrance), []);
|
||||
const nexus = structuredClone(game);
|
||||
nexus.nexusTraitors = {
|
||||
offer: null,
|
||||
pending: {} as NonNullable<NonNullable<GameView['nexusTraitors']>['pending']>,
|
||||
};
|
||||
assert.deepEqual(prepare(nexus), []);
|
||||
assert.match(markup(nexus), /Provisional Karama policy/);
|
||||
});
|
||||
|
||||
void test('current response, decision and phase-opening priority defensively prevent preparation', () => {
|
||||
for (const field of ['response', 'decision', 'phaseOpening'] as const) {
|
||||
const game = projected();
|
||||
if (field === 'response') game.response = {} as NonNullable<GameView['response']>;
|
||||
if (field === 'decision') game.decision = {} as NonNullable<GameView['decision']>;
|
||||
if (field === 'phaseOpening') game.phaseOpening = {} as NonNullable<GameView['phaseOpening']>;
|
||||
assert.deepEqual(prepare(game), [], field);
|
||||
const html = markup(game);
|
||||
assert.equal((html.match(/<button\b[^>]*\sdisabled=""/g) ?? []).length, 2, field);
|
||||
assert.match(html, /Finish the current response, decision or phase opening before preparing advisors/, field);
|
||||
}
|
||||
});
|
||||
|
||||
void test('projected blocks remain readable and authoritative across renders while unavailable territories never gain buttons', () => {
|
||||
const game = projected();
|
||||
game.advisorPreparation!.blocked = 'Finish the pending revival before preparing advisors.';
|
||||
for (let render = 0; render < 3; render++) {
|
||||
const html = markup(game);
|
||||
assert.match(html, /Finish the pending revival before preparing advisors/);
|
||||
assert.match(html, /aria-describedby="advisor-preparation-unavailable"/);
|
||||
assert.match(html, /Provisional Karama policy/);
|
||||
assert.deepEqual(prepare(game), []);
|
||||
}
|
||||
game.advisorPreparation = { blocked: null, territories: [] };
|
||||
assert.match(markup(game), /No advisor territory is currently eligible for preparation/);
|
||||
assert.doesNotMatch(markup(game), /<button\b/);
|
||||
assert.deepEqual(prepare(game), []);
|
||||
});
|
||||
|
||||
void test('only the native Advanced owner sees an open phase-two-through-five preparation descriptor', () => {
|
||||
for (const phase of [2, 3, 4, 5]) {
|
||||
const game = projected();
|
||||
game.phase = phase;
|
||||
assert.match(markup(game), /Prepare advisors for battle/);
|
||||
assert.deepEqual(prepare(game), [{ type: 'prepareAdvisors', territory: 'arrakeen' }]);
|
||||
}
|
||||
for (const field of ['basic', 'viewer', 'faction', 'before', 'after', 'closed', 'status'] as const) {
|
||||
const game = projected();
|
||||
if (field === 'basic') game.advanced = false;
|
||||
if (field === 'viewer') game.me = 'a';
|
||||
if (field === 'faction') game.players[0].faction = 'atreides';
|
||||
if (field === 'before') game.phase = 1;
|
||||
if (field === 'after') game.phase = 6;
|
||||
if (field === 'closed') game.advisorPreparation = null;
|
||||
if (field === 'status') game.status = 'lobby';
|
||||
assert.equal(markup(game), '', field);
|
||||
assert.deepEqual(prepare(game), [], field);
|
||||
}
|
||||
});
|
||||
|
||||
void test('preparation controls never inspect either seat’s private wallets, cards or force counts', () => {
|
||||
const game = projected();
|
||||
for (const player of game.players) {
|
||||
for (const field of ['spice', 'hand', 'forces', 'elites', 'advisors', 'tanks']) {
|
||||
Object.defineProperty(player, field, {
|
||||
get() { throw new Error(`Preparation read ${player.id} private ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
assert.match(markup(game), /Prepare advisors · Arrakeen/);
|
||||
assert.deepEqual(prepare(game), [{ type: 'prepareAdvisors', territory: 'arrakeen' }]);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import {
|
||||
createAdvisorPreparationFixture as fixture,
|
||||
actAdvisorPreparation as act,
|
||||
passAdvisorPreparationResponses as pass,
|
||||
advisorPreparationPlayer as player,
|
||||
advisorPreparationInventory as inventory,
|
||||
shipAdvisorPreparation as ship,
|
||||
} from './fixture-advisor-preparation';
|
||||
|
||||
for (const phase of [2, 3, 4] as const) {
|
||||
void test(`native phase${phase} advisor preparation preserves the original phase and controls after allowance`, () => {
|
||||
const f = fixture({ phase }), before = structuredClone(f.game);
|
||||
assert.equal(isAdvisor(player(f.game, f.bg), 'arrakeen'), true);
|
||||
assert.ok(viewGame(f.game, f.bg).advisorPreparation?.territories.includes('arrakeen'));
|
||||
let g = act(f.game, f.bg, f.preparation);
|
||||
assert.equal(g.response?.kind, 'advisorFlip');
|
||||
assert.equal(g.response?.advisorResume, 'preparation');
|
||||
assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), true);
|
||||
assert.ok(viewGame(g, f.observer).responseControls?.cancelCards.includes(f.karama));
|
||||
g = pass(g);
|
||||
assert.equal(g.phase, phase);
|
||||
assert.equal(g.active, before.active);
|
||||
assert.deepEqual(g.auction, before.auction);
|
||||
assert.deepEqual(g.ready, before.ready);
|
||||
assert.deepEqual(g.movementRemaining, before.movementRemaining);
|
||||
assert.deepEqual(g.players.map(p => [p.id, p.forces, p.reserves, p.spice, p.shipped, p.moved]),
|
||||
before.players.map(p => [p.id, p.forces, p.reserves, p.spice, p.shipped, p.moved]));
|
||||
assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), false);
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
assert.deepEqual(f.game, before);
|
||||
});
|
||||
void test(`native phase${phase} Karama prevents only that preparation without moving the interrupted phase`, () => {
|
||||
const f = fixture({ phase });
|
||||
let g = act(f.game, f.bg, f.preparation);
|
||||
g = act(g, f.observer, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
assert.ok(!g.response);
|
||||
assert.equal(g.phase, phase);
|
||||
assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), true);
|
||||
assert.deepEqual(g.advisorPreparation?.deniedTerritories, ['arrakeen']);
|
||||
assert.deepEqual(g.auction, f.game.auction);
|
||||
assert.deepEqual(g.ready, f.game.ready);
|
||||
assert.equal(g.discard.filter(c => c.id === f.karama).length, 1);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => act(g, f.bg, f.preparation), /eligible|prevent/i);
|
||||
assert.deepEqual(g, before);
|
||||
assert.match(viewGame(g, f.bg).advisorPreparation?.blocked ?? '', /Karama/);
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
});
|
||||
}
|
||||
void test('declining the native phase5 chooser leaves the printed window open for a later declaration', () => {
|
||||
const f = fixture({ phase: 5, declineOffer: true });
|
||||
assert.equal(f.game.decision, null);
|
||||
const active = f.game.active;
|
||||
assert.ok(active);
|
||||
const g = pass(act(f.game, f.bg, f.preparation));
|
||||
assert.equal(g.phase, 5);
|
||||
assert.equal(g.active, active);
|
||||
assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), false);
|
||||
assert.equal(g.advisorPreparation?.firstShipment, null);
|
||||
assert.deepEqual(g.movementRemaining, f.game.movementRemaining);
|
||||
});
|
||||
void test('the first actual native shipment closes preparation before its arrival followups', () => {
|
||||
const f = fixture({ phase: 5, declineOffer: true });
|
||||
const shipped = ship(f.game), g = shipped.after;
|
||||
assert.deepEqual(g.advisorPreparation?.firstShipment, { player: f.game.active, producer: 'ordinary' });
|
||||
assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), true);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => act(g, f.bg, f.preparation), /first shipment/);
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
});
|
||||
void test('native canceled phase5 preparation cannot be bypassed through the new manual control', () => {
|
||||
const f = fixture({ phase: 5 });
|
||||
let g = act(f.game, f.bg, { type: 'decision', accept: true, territory: 'arrakeen' });
|
||||
assert.ok(g.response?.advisorPreparationSource);
|
||||
g = act(g, f.observer, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
assert.ok(!g.response);
|
||||
assert.equal(g.phase, 5);
|
||||
assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), true);
|
||||
assert.deepEqual(g.advisorPreparation?.deniedTerritories, ['arrakeen']);
|
||||
assert.throws(() => act(g, f.bg, f.preparation), /eligible|prevent/i);
|
||||
});
|
||||
void test('ending an unshipped native turn leaves the printed preparation window open', () => {
|
||||
const f = fixture({ phase: 5, declineOffer: true });
|
||||
assert.ok(f.game.active);
|
||||
let g = act(f.game, f.game.active, { type: 'endMovement' });
|
||||
assert.equal(g.phase, 5);
|
||||
assert.equal(g.movementRemaining!.includes(f.game.active), false);
|
||||
assert.equal(g.advisorPreparation?.firstShipment, null);
|
||||
const active = g.active;
|
||||
g = pass(act(g, f.bg, f.preparation));
|
||||
assert.equal(g.active, active);
|
||||
assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), false);
|
||||
});
|
||||
void test('a changed preparation source rejects before a real Karama is spent', () => {
|
||||
const f = fixture();
|
||||
for (const change of ['turn', 'territory', 'stance']) {
|
||||
const g = act(f.game, f.bg, f.preparation);
|
||||
assert.ok(g.response?.advisorPreparationSource);
|
||||
if (change === 'turn') g.response.advisorPreparationSource.turn++;
|
||||
if (change === 'territory') g.response.advisorPreparationSource.territory = 'carthag';
|
||||
if (change === 'stance') g.response.advisors = true;
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => act(g, f.observer, { type: 'card', card: f.karama, mode: 'cancel' }), /source|owner|territory/);
|
||||
assert.deepEqual(g, before);
|
||||
assert.ok(player(g, f.observer).hand.some(c => c.id === f.karama));
|
||||
assert.equal(g.discard.some(c => c.id === f.karama), false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||
import { quoteAdvisorBattleOffer } from '../game/movement-phase-quote';
|
||||
import {
|
||||
AdvisorPreparationError,
|
||||
projectAdvisorPreparationWindow,
|
||||
quoteAdvisorPreparation,
|
||||
quoteAdvisorPreparationWindow,
|
||||
validateAdvisorPreparation,
|
||||
type AdvisorPreparationSource,
|
||||
type AdvisorPreparationWindow,
|
||||
} from '../game/advisor-preparation-window';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find(seat => seat.id === id)!;
|
||||
function fixture(): Game {
|
||||
const g = createGame('ADVISORWINDOW', newPlayer('b', 'Bene Gesserit', 'beneGesserit'), true);
|
||||
g.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
Object.assign(g, { status: 'playing', phase: 2, turn: 3, storm: 18, order: ['h', 'b', 'a'] });
|
||||
for (const p of g.players) p.forces = {};
|
||||
player(g, 'b').forces = { 'arrakeen:10': 2, 'carthag:11': 1, 'sietch_tabr:14': 1 };
|
||||
player(g, 'b').advisors = { arrakeen: {}, carthag: {}, sietch_tabr: {} };
|
||||
player(g, 'a').forces = { 'arrakeen:10': 2, 'carthag:11': 2 };
|
||||
g.advisorPreparation = { turn: 3, firstShipment: null, deniedTerritories: [] };
|
||||
return g;
|
||||
}
|
||||
|
||||
function blocked(g: Game, owner = 'b') {
|
||||
const before = structuredClone(g), quote = quoteAdvisorPreparationWindow(g, owner);
|
||||
assert.equal(typeof quote.blocked, 'string');
|
||||
assert.deepEqual(quote.territories, []);
|
||||
assert.throws(() => quoteAdvisorPreparation(g, owner, 'arrakeen'), AdvisorPreparationError);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
void test('each phase after Spice Blow through the first-shipment deadline reuses native eligibility and releases', () => {
|
||||
for (const phase of [2, 3, 4, 5]) {
|
||||
const g = fixture(); g.phase = phase;
|
||||
const before = structuredClone(g), native = quoteAdvisorBattleOffer(g);
|
||||
assert.deepEqual(quoteAdvisorPreparationWindow(g, 'b'), {
|
||||
blocked: null, territories: native.territories, released: native.released,
|
||||
});
|
||||
assert.deepEqual(quoteAdvisorPreparation(g, 'b', 'arrakeen'), { turn: 3, owner: 'b', territory: 'arrakeen' });
|
||||
assert.deepEqual(native.territories, ['arrakeen', 'carthag']);
|
||||
assert.deepEqual(native.released, [{ player: 'b', territory: 'sietch_tabr' }]);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('receipt absence, prior turns and stages outside the native Advanced window cannot infer a clock', () => {
|
||||
for (const change of [
|
||||
(g: Game) => { delete g.advisorPreparation; },
|
||||
(g: Game) => { g.advisorPreparation!.turn--; },
|
||||
(g: Game) => { g.advisorPreparation!.turn++; },
|
||||
(g: Game) => { g.status = 'lobby'; },
|
||||
(g: Game) => { g.advanced = false; },
|
||||
(g: Game) => { g.phase = 1; },
|
||||
(g: Game) => { g.phase = 6; },
|
||||
(g: Game) => { g.phase = 2.5; },
|
||||
(g: Game) => { g.turn = 0; },
|
||||
]) {
|
||||
const g = fixture(); change(g); blocked(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('each actual shipment producer closes the window without consulting player movement or skip flags', () => {
|
||||
const producers: NonNullable<AdvisorPreparationWindow['firstShipment']>['producer'][] = [
|
||||
'ordinary', 'guildTransport', 'homeworld', 'junction', 'ambassador', 'sneakAttack',
|
||||
];
|
||||
for (const producer of producers) {
|
||||
const g = fixture();
|
||||
g.advisorPreparation!.firstShipment = { player: 'h', producer };
|
||||
blocked(g);
|
||||
assert.match(quoteAdvisorPreparationWindow(g, 'b').blocked!, /first shipment/);
|
||||
}
|
||||
const g = fixture();
|
||||
for (const p of g.players) { p.shipped = true; p.moved = 2; }
|
||||
assert.equal(quoteAdvisorPreparationWindow(g, 'b').blocked, null);
|
||||
});
|
||||
|
||||
void test('malformed current receipts cannot create or reopen preparation', () => {
|
||||
const original = fixture();
|
||||
for (const receipt of [
|
||||
null, [], {}, { turn: 3, firstShipment: null },
|
||||
{ turn: 3, firstShipment: null, deniedTerritories: ['arrakeen', 'arrakeen'] },
|
||||
{ turn: 3, firstShipment: null, deniedTerritories: [''] },
|
||||
{ turn: 3, firstShipment: undefined, deniedTerritories: [] },
|
||||
{ turn: 3, firstShipment: { player: 'missing', producer: 'ordinary' }, deniedTerritories: [] },
|
||||
{ turn: 3, firstShipment: { player: 'h', producer: 'skip' }, deniedTerritories: [] },
|
||||
]) {
|
||||
const g = structuredClone(original);
|
||||
g.advisorPreparation = receipt as AdvisorPreparationWindow;
|
||||
blocked(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('only the unique native Bene Gesserit owner receives preparation choices', () => {
|
||||
const g = fixture();
|
||||
for (const owner of ['a', 'h', 'missing', '']) {
|
||||
blocked(g, owner);
|
||||
assert.equal(projectAdvisorPreparationWindow(g, owner), null);
|
||||
}
|
||||
const duplicate = fixture();
|
||||
duplicate.players.push(structuredClone(player(duplicate, 'b')));
|
||||
blocked(duplicate);
|
||||
assert.equal(projectAdvisorPreparationWindow(duplicate, 'b'), null);
|
||||
});
|
||||
|
||||
void test('a denied territory stays unavailable without suppressing another native preparation opportunity', () => {
|
||||
const g = fixture();
|
||||
g.advisorPreparation!.deniedTerritories = ['arrakeen'];
|
||||
const before = structuredClone(g), quote = quoteAdvisorPreparationWindow(g, 'b');
|
||||
assert.equal(quote.blocked, null);
|
||||
assert.deepEqual(quote.territories, ['carthag']);
|
||||
assert.throws(() => quoteAdvisorPreparation(g, 'b', 'arrakeen'), AdvisorPreparationError);
|
||||
assert.deepEqual(quoteAdvisorPreparation(g, 'b', 'carthag'), { turn: 3, owner: 'b', territory: 'carthag' });
|
||||
assert.deepEqual(g, before);
|
||||
g.advisorPreparation!.deniedTerritories.push('carthag');
|
||||
blocked(g);
|
||||
});
|
||||
|
||||
void test('storm, allied presence and fresh-advisor locks retain the native quote’s exclusions', () => {
|
||||
for (const change of [
|
||||
(g: Game) => { g.storm = 10; },
|
||||
(g: Game) => { player(g, 'b').advisors!.arrakeen = { lockedTurn: g.turn }; },
|
||||
(g: Game) => {
|
||||
player(g, 'b').ally = 'h'; player(g, 'h').ally = 'b';
|
||||
player(g, 'h').forces = { 'arrakeen:10': 1 };
|
||||
},
|
||||
]) {
|
||||
const g = fixture(); change(g);
|
||||
const before = structuredClone(g), native = quoteAdvisorBattleOffer(g);
|
||||
assert.deepEqual(quoteAdvisorPreparationWindow(g, 'b'), {
|
||||
blocked: null, territories: ['carthag'], released: native.released,
|
||||
});
|
||||
assert.throws(() => quoteAdvisorPreparation(g, 'b', 'arrakeen'), AdvisorPreparationError);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('source validation binds the original owner, territory, turn and still-current board eligibility', () => {
|
||||
const original = fixture(), source = quoteAdvisorPreparation(original, 'b', 'arrakeen');
|
||||
validateAdvisorPreparation(original, source);
|
||||
for (const change of [
|
||||
(g: Game) => { g.turn++; },
|
||||
(g: Game) => { g.advisorPreparation!.firstShipment = { player: 'a', producer: 'ordinary' }; },
|
||||
(g: Game) => { g.advisorPreparation!.deniedTerritories.push('arrakeen'); },
|
||||
(g: Game) => { g.storm = 10; },
|
||||
(g: Game) => { delete player(g, 'b').advisors!.arrakeen; },
|
||||
(g: Game) => { player(g, 'a').forces = { 'carthag:11': 2 }; },
|
||||
]) {
|
||||
const g = structuredClone(original); change(g);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => validateAdvisorPreparation(g, source), AdvisorPreparationError);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
assert.throws(() => validateAdvisorPreparation(original, { ...source, owner: 'a' }), AdvisorPreparationError);
|
||||
assert.throws(() => validateAdvisorPreparation(original, { ...source, territory: 'sietch_tabr' }), AdvisorPreparationError);
|
||||
});
|
||||
|
||||
void test('malformed declarations fail with the focused error before any mutation', () => {
|
||||
const g = fixture(), source = quoteAdvisorPreparation(g, 'b', 'arrakeen'), before = structuredClone(g);
|
||||
for (const malformed of [
|
||||
null, [], {}, { ...source, turn: -1 }, { ...source, owner: '' },
|
||||
{ ...source, territory: '' }, { ...source, amount: 1 },
|
||||
]) assert.throws(() => validateAdvisorPreparation(g, malformed as AdvisorPreparationSource), AdvisorPreparationError);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('public-board failures stay in the focused quote without returning partial choices', () => {
|
||||
const g = fixture(); g.order = ['b', 'b', 'h'];
|
||||
blocked(g);
|
||||
const malformed = fixture();
|
||||
player(malformed, 'b').advisors!.arrakeen = { lockedTurn: -1 };
|
||||
blocked(malformed);
|
||||
});
|
||||
|
||||
void test('the pure source quote does not gate its own response on live priority or reset an invoice', () => {
|
||||
const g = fixture(), source = quoteAdvisorPreparation(g, 'b', 'arrakeen');
|
||||
g.response = { kind: 'advisorFlip', owner: 'b', location: 'arrakeen', advisors: false, passed: [] };
|
||||
const before = structuredClone(g);
|
||||
validateAdvisorPreparation(g, source);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('owner projection contains no counts, private resources, receipt payload or unrelated seat choices', () => {
|
||||
const g = fixture();
|
||||
for (const p of g.players)
|
||||
for (const key of ['spice', 'tanks', 'hand', 'leaders'] as const)
|
||||
Object.defineProperty(p, key, { get() { throw new Error(`Preparation read private ${key}`); } });
|
||||
assert.deepEqual(projectAdvisorPreparationWindow(g, 'b'), {
|
||||
blocked: null, territories: ['arrakeen', 'carthag'],
|
||||
});
|
||||
assert.deepEqual(Object.keys(projectAdvisorPreparationWindow(g, 'b')!).sort(), ['blocked', 'territories']);
|
||||
assert.equal(projectAdvisorPreparationWindow(g, 'a'), null);
|
||||
g.advanced = false;
|
||||
assert.equal(projectAdvisorPreparationWindow(g, 'b'), null);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type AdvisorPreparationStep = { actor: string; action: Action };
|
||||
export type AdvisorPreparationTransition = {
|
||||
before: Game; steps: AdvisorPreparationStep[]; after: Game;
|
||||
};
|
||||
export const advisorPreparationPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const advisorPreparationClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
|
||||
export const advisorPreparationInventory = (game: Game) => {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Original physical Treachery Cards retain distinct current slots');
|
||||
return ids;
|
||||
};
|
||||
|
||||
/** Select original scalar shuffle order only. UUIDs and other byte entropy stay
|
||||
* native; no played hand, army, advisor stance, phase or resource is replaced. */
|
||||
function originalScalarOrder<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actAdvisorPreparation(game: Game, actor: string, action: Action,
|
||||
actions: AdvisorPreparationStep[] = []): Game {
|
||||
const next = originalScalarOrder(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
/** Genuine native continuation. The original preparation offer is caller-owned;
|
||||
* setup and later accompaniment choices never fabricate a stance. */
|
||||
export function nextAdvisorPreparationStep(game: Game): AdvisorPreparationStep | null {
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
if (game.decision?.kind === 'advisorBattle') return null;
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' &&
|
||||
!advisorPreparationPlayer(game, 'beneGesserit').advisorSetup)
|
||||
return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
|
||||
if (game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')
|
||||
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||
}
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceAdvisorPreparation(game: Game, until: (state: Game) => boolean,
|
||||
actions: AdvisorPreparationStep[] = []): Game {
|
||||
for (let count = 0; count < 1400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The genuine advisor chronology ended before its requested boundary');
|
||||
const next = nextAdvisorPreparationStep(game);
|
||||
assert.ok(next, `Choose the original advisor control at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actAdvisorPreparation(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native advisor chronology did not reach its finite boundary');
|
||||
}
|
||||
export function passAdvisorPreparationResponses(game: Game,
|
||||
actions: AdvisorPreparationStep[] = []): 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 = actAdvisorPreparation(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.ok(!game.response); return game;
|
||||
}
|
||||
export function cloneAdvisorPreparation(game: Game): Game {
|
||||
assert.ok(!game.phaseOpening && !game.response && !game.truthtrance && !game.pendingTreacheryDiscard,
|
||||
'Clone an original available advisor choice boundary');
|
||||
return structuredClone(game);
|
||||
}
|
||||
|
||||
/** Fresh public Advanced setup: prediction, private Traitors, and the actual
|
||||
* advisorSetup at occupied Arrakeen. Native first Storm, dual Spice and original
|
||||
* later phase controls execute. Stop before any direct preparation or shipment;
|
||||
* only original unopened card/Spice positions and scalar shuffles are chosen. */
|
||||
export function createAdvisorPreparationFixture(options: {
|
||||
phase?: 2 | 3 | 4 | 5; declineOffer?: boolean;
|
||||
} = {}) {
|
||||
const phase = options.phase ?? 2, bg = 'beneGesserit', atreides = 'atreides', emperor = 'emperor';
|
||||
let game = createGame('ADVISORPREPARATION', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
|
||||
joinGame(game, newPlayer(atreides, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), actions: AdvisorPreparationStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAdvisorPreparation(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorPreparation(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalInventory = advisorPreparationInventory(game);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
|
||||
const karama = game.deck.find(card => card.effect === 'karama');
|
||||
assert.equal(ordinary.length, 2); assert.ok(karama);
|
||||
const startingCards = [ordinary[0], ordinary[1], karama];
|
||||
const dealOrder = startingCards.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let advisorSetup: AdvisorPreparationTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextAdvisorPreparationStep(game); assert.ok(next);
|
||||
const before = next.action.type === 'advisorSetup' ? structuredClone(game) : null;
|
||||
act(next.actor, next.action);
|
||||
if (before) advisorSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), startingCards.map(card => card.id));
|
||||
assert.equal(advisorPreparationPlayer(game, bg).forces['arrakeen:10'], 1);
|
||||
assert.ok(advisorPreparationPlayer(game, bg).advisors?.arrakeen);
|
||||
assert.equal(advisorPreparationPlayer(game, atreides).forces['arrakeen:10'], 10);
|
||||
assert.ok(advisorPreparationPlayer(game, emperor).hand.some(card => card.id === karama.id));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[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 === 2 && advisorPreparationClean(state));
|
||||
const afterSpice = structuredClone(game);
|
||||
if (phase === 3) reach(state => state.phase === 3 && advisorPreparationClean(state) && !!state.auction);
|
||||
if (phase === 4) reach(state => state.phase === 4 && advisorPreparationClean(state));
|
||||
let openingOffer: Game | null = null, decline: AdvisorPreparationTransition | null = null;
|
||||
if (phase === 5) {
|
||||
reach(state => state.phase === 5 && !state.phaseOpening && !state.response && state.decision?.kind === 'advisorBattle');
|
||||
openingOffer = structuredClone(game);
|
||||
if (options.declineOffer) {
|
||||
const before = structuredClone(game), start = actions.length;
|
||||
act(bg, { type: 'decision', accept: false });
|
||||
reach(state => state.phase === 5 && advisorPreparationClean(state) && !!state.active);
|
||||
decline = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
}
|
||||
}
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, phase);
|
||||
assert.equal(advisorPreparationPlayer(game, bg).forces['arrakeen:10'], 1);
|
||||
assert.ok(advisorPreparationPlayer(game, bg).advisors?.arrakeen);
|
||||
assert.equal(advisorPreparationPlayer(game, atreides).forces['arrakeen:10'], 10);
|
||||
assert.ok(game.players.every(player => !player.shipped && player.moved === 0));
|
||||
assert.ok(advisorPreparationPlayer(game, emperor).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(advisorPreparationInventory(game), originalInventory);
|
||||
const preparation: Action = { type: 'prepareAdvisors', territory: 'arrakeen' };
|
||||
const shipmentAction: Action = { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 };
|
||||
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup, afterSpice,
|
||||
openingOffer, decline, bg, owner: bg, atreides, emperor, observer: emperor, counterOwner: emperor,
|
||||
karama: karama.id, phase, territory: 'arrakeen', sector: 10, location: 'arrakeen:10', preparation,
|
||||
preparationStep: { actor: bg, action: preparation } satisfies AdvisorPreparationStep,
|
||||
declineOffer: { type: 'decision', accept: false } satisfies Action,
|
||||
shipmentAction, shipmentStep: game.active ? { actor: game.active, action: shipmentAction } satisfies AdvisorPreparationStep : null,
|
||||
endMovement: { type: 'endMovement' } satisfies Action,
|
||||
actions, sourceActionCount: actions.length, originalInventory, originalSpice, dealOrder, spiceOrder,
|
||||
staging: [
|
||||
'Actual public three-seat Advanced Bene Gesserit/Atreides/Emperor ready/start, prediction and private Traitor choices. Starting advisorSetup really puts one BG counter into occupied Arrakeen10, so its native advisor stance remains.',
|
||||
'Select only original scalar shuffle order, three original unopened Treachery positions and two original unopened ordinary Spice positions. Emperor is actually dealt its original printed Karama; no later held-card acquisition or hand replacement is staged.',
|
||||
'Actually complete native first Storm and both first-turn Spice blows. Requested Charity, first native auction, Revival or initial Shipment controls are reached by their original readiness, peek and auction responses. No force, wallet, played phase, stance, clock or receipt is assigned.',
|
||||
options.declineOffer && phase === 5
|
||||
? 'Actually decline the original first Shipment advisorBattle offer, then stop at an active native turn whose shipment and movement remain unused.'
|
||||
: 'Stop before the requested preparation or first actual shipment; an original phase5 advisorBattle offer remains caller-owned when requested.',
|
||||
'The direct preparation and positive shipment actions are returned without being executed. The separate shipAdvisorPreparation helper submits one real paid positive shipment from the current actor, then declines any native accompaniment through actual controls.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type AdvisorPreparationFixture = ReturnType<typeof createAdvisorPreparationFixture>;
|
||||
|
||||
/** A native skip/end is separate from this real positive shipment. The caller
|
||||
* first resolves preparation and reaches an actual unshipped active turn. */
|
||||
export function shipAdvisorPreparation(source: Game,
|
||||
actions: AdvisorPreparationStep[] = []): AdvisorPreparationTransition {
|
||||
assert.equal(source.phase, 5); assert.ok(advisorPreparationClean(source) && source.active);
|
||||
const actor = source.active; assert.equal(advisorPreparationPlayer(source, actor).shipped, false);
|
||||
const before = structuredClone(source), start = actions.length;
|
||||
const action: Action = { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 };
|
||||
let game = actAdvisorPreparation(source, actor, action, actions);
|
||||
game = advanceAdvisorPreparation(game, advisorPreparationClean, actions);
|
||||
assert.equal(advisorPreparationPlayer(game, actor).forces['polar_sink:0'],
|
||||
(advisorPreparationPlayer(before, actor).forces['polar_sink:0'] ?? 0) + 1);
|
||||
assert.ok(advisorPreparationPlayer(game, actor).spice < advisorPreparationPlayer(before, actor).spice);
|
||||
assert.equal(advisorPreparationPlayer(game, actor).shipped, true);
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: game };
|
||||
}
|
||||
@@ -320,3 +320,30 @@ void test('declining the Bene Gesserit intrusion still offers the free advisor s
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
void test('only a positive Sneak Attack after ground entry closes native advisor preparation', () => {
|
||||
// Controlled native entry board; the separate advisor fixture owns public
|
||||
// setup chronology. No shipment occurs before the actual ground/Terror path.
|
||||
for (const amount of [0, 1]) {
|
||||
const g = fixture('sneakAttack');
|
||||
g.advanced = true;
|
||||
g.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
||||
g.order.push('b');
|
||||
g.movementRemaining!.push('b');
|
||||
g.advisorPreparation = { turn: g.turn, firstShipment: null, deniedTerritories: [] };
|
||||
player(g, 'e').forces = { 'imperial_basin:10': 1 };
|
||||
player(g, 'e').reserves = 19;
|
||||
const entered = applyAction(g, 'e', { type: 'move', from: 'imperial_basin:10',
|
||||
territory: 'arrakeen', sector: 10, amount: 1 });
|
||||
assert.equal(entered.advisorPreparation?.firstShipment, null);
|
||||
assert.equal(player(entered, 'e').shipped, true, 'Ground movement spends the allowance without shipping');
|
||||
const revealed = decide(entered, { reveal: true });
|
||||
const done = decide(revealed, { amount });
|
||||
assert.deepEqual(done.advisorPreparation?.firstShipment,
|
||||
amount ? { player: 'm', producer: 'sneakAttack' } : null);
|
||||
assert.equal(player(done, 'm').reserves, 20 - amount);
|
||||
assert.equal(player(done, 'm').forces['arrakeen:10'] ?? 0, amount);
|
||||
assert.equal(totalForces(done, 'm'), 20);
|
||||
if (amount) assert.equal(done.decision?.kind, 'advisor', 'The clock closes before accompaniment');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user