Files
Dune/tests/advisors.test.ts
T
Quintine 3aca64412f Offer Karama against mandatory advisor ground conversions
Keep completed moves while allowing native advisor-to-fighter conversion to be prevented after empty arrival or the last outsider's ground departure. Bind each queued response to its actual whole group and movement. Shared settlement honors the explicit provisional continuously-alone cancellation duration; setup remains automatic and non-ground producers remain open.

Validation: 16 related files199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Native34/36-action public Advanced sources earn original setup/counters/Karama. Existing standard controls render; no new component-callback, browser/deployed or full-mode certificate. No saved-room/D1/restart/deployment operation.
2026-10-24 19:44:12 +11:00

594 lines
20 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
createGame,
newPlayer,
joinGame,
applyAction,
viewGame,
battles,
type Game,
} from '../game/engine';
import { isAdvisor, fighterCount } from '../game/advisors';
import { baseDeck, spiceDeck } from '../game/cards';
/** Transfer fixture cards from the actual deck; never duplicate another seat's hand. */
function setBattleHand(g: Game, id: string, ids: string[]) {
const owner = g.players.find((p) => p.id === id)!;
g.deck.push(...owner.hand);
owner.hand = [];
owner.hand = ids.map((cardId) => {
assert.ok(
!g.players.some((p) => p.hand.some((card) => card.id === cardId)),
`Fixture card ${cardId} is already held by another seat`,
);
const index = g.deck.findIndex((card) => card.id === cardId);
assert.ok(
index >= 0,
`Physical fixture card ${cardId} must be in the deck`,
);
return g.deck.splice(index, 1)[0];
});
}
function fixture() {
let g = createGame('ADVISOR2', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'a', { type: 'start' });
g = applyAction(g, 'b', { type: 'predict', faction: 'atreides', turn: 3 });
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
g.advanced = true;
g.phase = 5;
g.active = 'a';
g.order = ['a', 'b', 'h'];
g.movementRemaining = [...g.order];
g.storm = 18;
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.spice = 20;
g.deck.push(...p.hand);
p.hand = [];
p.traitors = [];
}
return g;
}
function army(
g: Game,
id: string,
forces: Record<string, number>,
advisors: string[] = [],
) {
const p = g.players.find((p) => p.id === id)!;
p.forces = forces;
p.reserves = 20 - Object.values(forces).reduce((s, n) => s + n, 0);
p.advisors = Object.fromEntries(advisors.map((t) => [t, {}]));
}
function allow(state: Game) {
let g = state;
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
function ready(state: Game) {
let g = state;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function cancel(g: Game) {
assert.ok(g.response);
assert.equal(g.players[0].hand[0].effect, 'karama');
return applyAction(g, 'a', {
type: 'card',
mode: 'cancel',
card: g.players[0].hand[0].id,
});
}
function move(
g: Game,
id: string,
from: string,
territory: string,
sector: number,
amount: number,
extra = {},
) {
return applyAction(g, id, {
type: 'move',
from,
territory,
sector,
amount,
...extra,
});
}
void test('advisors coexist without battle, blocking another faction entry or contesting a stronghold win', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 2, 'carthag:11': 1, 'sietch_tabr:14': 1 });
army(g, 'b', { 'arrakeen:10': 1 }, ['arrakeen']);
assert.deepEqual(battles(g), []);
g.active = 'h';
const shipped = applyAction(g, 'h', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 1,
});
assert.equal(shipped.players[2].forces['arrakeen:10'], 1);
g.phase = 7;
g = ready(g);
assert.deepEqual(g.winner, ['a']);
});
void test('advisors collect no ground spice, stronghold income or ornithopter benefit', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 1, 'red_chasm:7': 1 });
army(g, 'b', { 'arrakeen:10': 1, 'red_chasm:7': 2 }, [
'arrakeen',
'red_chasm',
]);
g.spice = { 'red_chasm:7': 8 };
g.active = 'h';
g.movementRemaining = ['h'];
g = applyAction(g, 'h', { type: 'endMovement' });
assert.equal(g.phase, 7);
assert.equal(g.players[1].spice, 20);
assert.equal(g.players[0].spice, 25); // two city income + three collected
assert.equal(g.spice['red_chasm:7'], 5);
g.phase = 5;
g.active = 'b';
assert.throws(
() => move(g, 'b', 'red_chasm:7', 'shield_wall', 8, 1),
/more than 1/,
);
});
void test('advisor-only forces cannot play Family Atomics', () => {
const g = fixture();
army(g, 'a', { 'imperial_basin:10': 1 });
army(g, 'b', { 'imperial_basin:10': 1 }, ['imperial_basin']);
g.phase = 0;
const atomics = baseDeck().find((c) => c.effect === 'atomics')!;
g.players[1].hand = [atomics];
assert.throws(
() => applyAction(g, 'b', { type: 'card', card: atomics.id }),
/forces on or adjacent/,
);
});
void test('direct shipments match existing advisors, while a new direct shipment enters as fighters', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 1, 'carthag:11': 1 });
army(g, 'h', { 'arrakeen:10': 1 });
army(g, 'b', { 'arrakeen:10': 1 }, ['arrakeen']);
g.active = 'b';
const joined = applyAction(g, 'b', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 2,
});
assert.equal(joined.players[1].forces['arrakeen:10'], 3);
assert.equal(isAdvisor(joined.players[1], 'arrakeen'), true);
g = applyAction(g, 'b', {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 2,
});
assert.equal(isAdvisor(g.players[1], 'carthag'), false);
assert.equal(fighterCount(g.players[1], 'carthag'), 2);
});
void test('moving fighters into existing advisors matches their stance, and the last outsider leaving automatically promotes advisors', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 1 });
army(g, 'b', { 'arrakeen:10': 1, 'imperial_basin:10': 2 }, ['arrakeen']);
g.active = 'b';
g = move(g, 'b', 'imperial_basin:10', 'arrakeen', 10, 2);
assert.equal(g.players[1].forces['arrakeen:10'], 3);
assert.equal(fighterCount(g.players[1], 'arrakeen'), 0);
g.active = 'a';
g = move(g, 'a', 'arrakeen:10', 'imperial_basin', 10, 1);
assert.equal(isAdvisor(g.players[1], 'arrakeen'), false);
assert.equal(fighterCount(g.players[1], 'arrakeen'), 3);
});
void test('empty and occupied advisor arrivals offer conversion cancellation after the committed movement', () => {
const g = fixture();
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
army(g, 'a', { 'arrakeen:10': 1 });
army(g, 'h', { 'imperial_basin:10': 1 });
army(g, 'b', { 'arrakeen:10': 2 }, ['arrakeen']);
g.active = 'b';
const moved = move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 2, {
fighters: true,
});
assert.equal(moved.response?.kind, 'advisorFlip');
const canceled = cancel(moved);
assert.equal(isAdvisor(canceled.players[1], 'imperial_basin'), true);
assert.equal(canceled.players[1].moved, 1);
assert.equal(canceled.players[1].forces['arrakeen:10'], undefined);
assert.equal(isAdvisor(allow(moved).players[1], 'imperial_basin'), false);
g.players[2].forces = {};
const empty = move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 2);
assert.equal(isAdvisor(empty.players[1], 'imperial_basin'), true);
assert.equal(empty.response?.kind, 'advisorFlip');
assert.ok(empty.response?.automaticAdvisorFlip);
assert.equal(isAdvisor(allow(empty).players[1], 'imperial_basin'), false);
const held = cancel(empty);
assert.equal(isAdvisor(held.players[1], 'imperial_basin'), true);
assert.ok(held.players[1].advisors?.imperial_basin.aloneFlipHold);
assert.equal(held.players[1].moved, 1);
});
void test('intrusion without advisor accompaniment restores the same decision and private views', () => {
const g = fixture();
army(g, 'b', { 'arrakeen:10': 20 });
const pending = applyAction(g, 'a', {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 2,
});
assert.equal(pending.decision?.kind, 'intrusion');
assert.equal(Object.hasOwn(pending.decision!, 'followup'), false);
const restored = JSON.parse(JSON.stringify(pending)) as Game;
for (const player of pending.players)
assert.deepEqual(viewGame(restored, player.id), viewGame(pending, player.id));
});
void test('intrusion resolves before free accompaniment, with a separate cancelable token flip and same-turn advisor lock', () => {
let g = fixture();
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
army(g, 'b', { 'arrakeen:10': 1 });
g = applyAction(g, 'a', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 2,
});
assert.equal(g.decision?.kind, 'intrusion');
assert.throws(
() => applyAction(g, 'a', { type: 'endMovement' }),
/pending decision/,
);
g = applyAction(g, 'b', { type: 'decision', accept: true });
assert.equal(g.response?.kind, 'advisorFlip');
const denied = cancel(g);
assert.equal(fighterCount(denied.players[1], 'arrakeen'), 1);
assert.equal(denied.decision?.kind, 'advisor');
g = allow(g);
assert.equal(g.decision?.kind, 'advisor');
g = allow(
applyAction(g, 'b', { type: 'decision', accept: true, accompany: true }),
);
assert.equal(g.players[1].forces['arrakeen:10'], 2);
assert.equal(g.players[1].advisors!.arrakeen.lockedTurn, 1);
assert.equal(g.players[1].reserves, 18);
assert.equal(viewGame(g, 'h').players[1].advisors!.arrakeen.lockedTurn, 1);
});
void test('ordinary movement and repeated entry both trigger intrusion; declining does not consume a later intrusion', () => {
let g = fixture();
army(g, 'b', { 'arrakeen:10': 2 });
army(g, 'a', { 'arrakeen:10': 1, 'imperial_basin:10': 2 });
g = move(g, 'a', 'imperial_basin:10', 'arrakeen', 10, 1);
assert.equal(g.decision?.kind, 'intrusion');
g = applyAction(g, 'b', { type: 'decision', accept: false });
g.active = 'h';
g = applyAction(g, 'h', {
type: 'ship',
territory: 'imperial_basin',
sector: 10,
amount: 1,
});
g = applyAction(g, 'b', { type: 'decision', accept: false });
// Use the existing opponent again; a third fighter faction is correctly blocked.
g.active = 'a';
g.players[0].moved = 0;
g = move(g, 'a', 'imperial_basin:10', 'arrakeen', 10, 1);
assert.equal(g.decision?.kind, 'intrusion');
});
void test('pre-shipment declaration excludes allies and storm-locked battles, and each accepted territory has a cancellation window', () => {
let g = fixture();
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
army(g, 'a', { 'arrakeen:10': 1, 'carthag:11': 1 });
army(g, 'b', { 'arrakeen:10': 3, 'carthag:11': 2 }, ['arrakeen', 'carthag']);
g.storm = 11;
g.phase = 4;
g = allow(ready(g));
assert.deepEqual(g.decision, {
kind: 'advisorBattle',
player: 'b',
territories: ['arrakeen'],
});
assert.throws(
() =>
applyAction(g, 'b', {
type: 'decision',
accept: true,
territory: 'carthag',
}),
/cannot prepare/,
);
g = applyAction(g, 'b', {
type: 'decision',
accept: true,
territory: 'arrakeen',
});
g = cancel(g);
assert.equal(g.decision, null);
assert.equal(g.active, 'a');
assert.equal(isAdvisor(g.players[1], 'arrakeen'), true);
g.phase = 4;
g.ready = [];
g.players[1].ally = 'a';
g.players[0].ally = 'b';
g = allow(ready(g));
assert.equal(g.decision, null);
});
void test('a lasgun/shield explosion kills bystanding advisors but ordinary battles leave them untouched', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 2 });
army(g, 'h', { 'arrakeen:10': 2 });
army(g, 'b', { 'arrakeen:10': 2 }, ['arrakeen']);
g.phase = 6;
g = allow(
applyAction(g, 'a', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'h',
}),
);
g = applyAction(g, 'a', { type: 'declineBattlePower' });
g = applyAction(g, 'a', { type: 'decision', decline: true });
const shield = baseDeck().find((c) => c.kind === 'shield')!;
const lasgun = baseDeck().find((c) => c.kind === 'lasgun')!;
setBattleHand(g, 'a', [shield.id]);
setBattleHand(g, 'h', [lasgun.id]);
const prepared = structuredClone(g);
for (const explosion of [false, true]) {
let fight = applyAction(prepared, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
defense: explosion ? shield.id : null,
});
fight = applyAction(fight, 'h', {
type: 'battlePlan',
dial: 0,
leader: 'harkonnen-0',
weapon: explosion ? lasgun.id : null,
});
fight = applyAction(fight, 'a', { type: 'traitorCall', call: false });
fight = applyAction(fight, 'h', { type: 'traitorCall', call: false });
assert.equal(fight.players[1].tanks, explosion ? 2 : 0);
assert.equal(
fight.players[1].forces['arrakeen:10'] ?? 0,
explosion ? 0 : 2,
);
}
});
void test('new advisors cannot flip into occupied fighters during their arrival turn, and the lock survives a move', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 1, 'imperial_basin:10': 1 });
army(g, 'b', { 'arrakeen:10': 2 }, ['arrakeen']);
g.players[1].advisors!.arrakeen.lockedTurn = g.turn;
g.active = 'b';
const before = structuredClone(g);
assert.throws(
() =>
move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 1, { fighters: true }),
/cannot flip/,
);
assert.deepEqual(g, before);
g = move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 1);
assert.equal(g.players[1].advisors!.imperial_basin.lockedTurn, g.turn);
const joined = structuredClone(before);
joined.players[1].forces['imperial_basin:10'] = 1;
assert.throws(
() => move(joined, 'b', 'arrakeen:10', 'imperial_basin', 10, 1),
/cannot become fighters/,
);
});
void test('an unlocked ordinary advisor move keeps the same private and public views after JSON', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 1, 'imperial_basin:10': 1 });
army(g, 'b', { 'arrakeen:10': 2, 'imperial_basin:10': 1 }, [
'arrakeen', 'imperial_basin',
]);
g.active = 'b';
g = move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 1);
assert.deepEqual(g.players[1].advisors?.imperial_basin, {});
for (const player of g.players)
assert.deepEqual(viewGame(g, player.id), viewGame(JSON.parse(JSON.stringify(g)) as Game, player.id));
});
void test('advisor setup waits for Fremen, follows their placement, and becomes fighters only when alone', () => {
for (const occupied of [false, true]) {
let g = createGame('ADSETUP2', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(g, newPlayer('f', 'Fremen', 'fremen'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'a', { type: 'start' });
g.advanced = true;
g = applyAction(g, 'b', { type: 'predict', faction: 'atreides', turn: 3 });
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
g.storm = 18;
army(g, 'a', occupied ? { 'arrakeen:10': 1 } : {});
army(g, 'h', {});
assert.equal(g.setupStage, 'forces');
const before = structuredClone(g);
assert.throws(
() =>
applyAction(g, 'b', {
type: 'advisorSetup',
territory: 'arrakeen',
sector: 10,
}),
/Wait for the current setup step: forces/,
);
assert.deepEqual(g, before);
g = applyAction(g, 'f', {
type: 'fremenSetup',
placements: { sietch_tabr: 10 },
});
assert.equal(g.status, 'setup');
g = applyAction(g, 'b', {
type: 'advisorSetup',
territory: 'arrakeen',
sector: 10,
});
assert.equal(g.status, 'playing');
assert.equal(g.players[1].reserves, 19);
assert.equal(g.players[1].forces['arrakeen:10'], 1);
assert.equal(isAdvisor(g.players[1], 'arrakeen'), occupied);
}
});
void test('worm-ride intrusion completes before the spice sequence proceeds, including a canceled flip', () => {
let g = fixture();
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
g.players[2].faction = 'fremen';
army(g, 'h', { 'red_chasm:7': 2 });
army(g, 'b', { 'imperial_basin:10': 2 });
g.phase = 1;
g.turn = 2;
g.nexus = true;
g.decision = { kind: 'wormRide', player: 'h', territory: 'red_chasm' };
g = applyAction(g, 'h', {
type: 'decision',
accept: true,
territory: 'imperial_basin',
sector: 10,
forces: { 'red_chasm:7': 2 },
});
assert.equal(g.phase, 1);
assert.equal(g.decision?.kind, 'intrusion');
g = applyAction(g, 'b', { type: 'decision', accept: true });
assert.equal(g.response?.advisorResume, 'wormRide');
const canceled = cancel(g);
assert.equal(canceled.phase, 2);
assert.equal(canceled.nexus, false);
assert.equal(fighterCount(canceled.players[1], 'imperial_basin'), 2);
const accepted = allow(g);
assert.equal(accepted.phase, 2);
assert.equal(isAdvisor(accepted.players[1], 'imperial_basin'), true);
});
void test('advisors die in storm and obsolete stance records are removed without altering force conservation', () => {
let g = fixture();
army(g, 'a', { 'red_chasm:7': 2 });
army(g, 'b', { 'red_chasm:7': 3 }, ['red_chasm']);
g.phase = 0;
g.storm = 6;
g.stormPending = 1;
g = ready(g);
assert.equal(g.players[1].tanks, 3);
assert.equal(g.players[1].advisors!.red_chasm, undefined);
assert.equal(g.players[1].reserves + g.players[1].tanks, 20);
assert.equal(g.players[0].tanks, 2);
});
void test('Guild cross-shipping matches existing advisors and preserves their arrival lock', () => {
let g = fixture();
g.players[2].faction = 'guild';
g.players[2].ally = 'b';
g.players[1].ally = 'h';
army(g, 'a', { 'arrakeen:10': 1, 'red_chasm:7': 1 });
army(g, 'b', { 'arrakeen:10': 2, 'red_chasm:7': 1 }, [
'arrakeen',
'red_chasm',
]);
g.players[1].advisors!.arrakeen.lockedTurn = g.turn;
g.active = 'b';
g = allow(
applyAction(g, 'b', {
type: 'guildShip',
from: 'arrakeen:10',
territory: 'red_chasm',
sector: 7,
amount: 2,
}),
);
assert.equal(g.players[1].advisors!.red_chasm.lockedTurn, g.turn);
assert.equal(g.players[1].advisors!.arrakeen, undefined);
assert.equal(g.players[1].forces['red_chasm:7'], 3);
assert.deepEqual(
viewGame(JSON.parse(JSON.stringify(g)), 'a').players[1].advisors,
viewGame(g, 'a').players[1].advisors,
);
});
void test('an unlocked Guild advisor arrival projects identically after JSON continuation', () => {
let g = fixture();
g.players[2].faction = 'guild';
g.players[2].ally = 'b';
g.players[1].ally = 'h';
army(g, 'a', { 'red_chasm:7': 1 });
army(g, 'b', { 'arrakeen:10': 2, 'red_chasm:7': 1 }, ['arrakeen', 'red_chasm']);
g.active = 'b';
g = allow(applyAction(g, 'b', {
type: 'guildShip', from: 'arrakeen:10', territory: 'red_chasm',
sector: 7, amount: 2,
}));
assert.deepEqual(g.players[1].advisors?.red_chasm, {});
assert.deepEqual(viewGame(g, 'a'), viewGame(JSON.parse(JSON.stringify(g)) as Game, 'a'));
});
void test('accompanying advisors may select a valid sector of the shipment territory, with no reserve loss on a rejected choice', () => {
let g = fixture();
g = applyAction(g, 'a', {
type: 'ship',
territory: 'imperial_basin',
sector: 10,
amount: 1,
});
assert.equal(g.decision?.kind, 'advisor');
const before = structuredClone(g);
assert.throws(
() =>
applyAction(g, 'b', {
type: 'decision',
accept: true,
accompany: true,
sector: 17,
}),
/sector belonging/,
);
assert.deepEqual(g, before);
g = allow(
applyAction(g, 'b', {
type: 'decision',
accept: true,
accompany: true,
sector: 11,
}),
);
assert.equal(g.players[1].forces['imperial_basin:11'], 1);
assert.equal(g.players[1].reserves, 19);
assert.equal(isAdvisor(g.players[1], 'imperial_basin'), true);
});
void test('worms devour peaceful advisors and clean their territory stance', () => {
let g = fixture();
const land = spiceDeck().filter((c) => 'territory' in c);
const worm = spiceDeck().find((c) => 'worm' in c)!;
const target = land[0];
const key = `${target.territory}:${target.sector}`;
army(g, 'a', { [key]: 1 });
army(g, 'b', { [key]: 2 }, [target.territory]);
g.phase = 1;
g.turn = 2;
g.spiceDiscard = [[target], []];
g.spiceDeck = [worm, land[1]];
g = ready(g);
assert.equal(g.players[1].tanks, 2);
assert.equal(g.players[1].advisors![target.territory], undefined);
assert.equal(g.players[1].reserves, 18);
});