Files
Dune/tests/recruits-engine.test.ts
T
Quintine 0e3d6a250e Make Fremen allied revival grants cancelable
Bind the original Fremen/ally/turn grant before Karama allowance or prevention.
A prevented grant leaves intrinsic free rates and prior payments intact. Permit
late grants under the explicit provisional Recruits current-rate policy, with
shared controls, minimal bot support and native public chronology evidence.
Complete rules and browser/deployed acceptance remain open.
2026-10-24 18:16:03 +11:00

299 lines
12 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeBaseGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { recruitsPlayAction } from '../game/recruits';
import { normalRevivalAllowance, newRevivalRules } from '../game/revival';
import { botActions } from '../game/bots';
import { recruitsGame } from './fixture-recruits';
const play = { type: 'card', card: 'ecaz-recruits' } as const;
function rejectUnchanged(g: Game, player: string, action: Action = play) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, player, action));
assert.deepEqual(g, before);
}
function physical(g: Game) {
return [
...g.deck,
...g.discard,
...g.players.flatMap((player) => player.hand),
...(g.ixSetupCards ?? []),
...(g.ixAuction?.cards ?? []),
...(g.auction?.cards.slice(g.auction.index) ?? []),
];
}
function settleRevival(state: Game) {
let g = state;
for (let step = 0; g.pendingRevival && step < 20; step++) {
let next: Game | undefined;
for (const player of g.players) {
const view = viewGame(g, player.id);
view.players.find((candidate) => candidate.id === player.id)!.bot = 'Medium';
const action = botActions(view)[0];
if (action) {
next = applyAction(g, player.id, action);
break;
}
}
assert.ok(next, 'Pending revival must retain a legal continuation.');
g = next;
}
assert.equal(g.pendingRevival, null);
return g;
}
void test('genuine Basic and Advanced setup owns exactly one of all three independent cards and projects only the holder play', () => {
for (const advanced of [false, true]) {
const g = recruitsGame(advanced);
const cards = physical(g).filter((card) => card.id.startsWith('ecaz-'));
assert.deepEqual(cards.map((card) => card.id).sort(), [
'ecaz-harass-withdraw',
'ecaz-recruits',
'ecaz-reinforcements',
]);
const owner = g.players.find((player) => player.hand.some((card) => card.id === play.card))!;
for (const player of g.players) {
const preview = viewGame(g, player.id).recruitsPreview;
assert.ok(preview);
assert.equal(preview.active, false);
assert.deepEqual(
recruitsPlayAction(preview),
player.id === owner.id ? play : null,
);
assert.equal(preview.play === null, player.id !== owner.id);
}
}
});
void test('playing Recruits discards its exact card and publishes effective rates and independent limits', () => {
for (const advanced of [false, true]) {
const before = recruitsGame(advanced);
const g = applyAction(before, 'ch', play);
assert.deepEqual(g.recruits, { turn: 2, player: 'ch', card: play.card });
assert.equal(g.players.find((player) => player.id === 'ch')!.hand.some((card) => card.id === play.card), false);
assert.equal(g.discard.filter((card) => card.id === play.card).length, 1);
const preview = viewGame(JSON.parse(JSON.stringify(g)), 'at').recruitsPreview!;
assert.deepEqual(
Object.fromEntries(preview.rates.map((row) => [row.player, [row.freeRate, row.limit]])),
{ at: [4, 7], fr: [6, 7], tl: [4, 20], ch: [0, 20] },
);
assert.equal(preview.active, true);
assert.equal(preview.playedBy, 'ch');
assert.equal(preview.grantRevivalBlocked, null);
}
});
void test('the accepted card changes the authoritative normal revival quote and free ledger', () => {
let g = applyAction(recruitsGame(true), 'ch', play);
const player = g.players.find((candidate) => candidate.id === 'at')!;
const before = { reserves: player.reserves, tanks: player.tanks, spice: player.spice };
g = settleRevival(applyAction(g, player.id, { type: 'revive', amount: 4, elite: 0 }));
const after = g.players.find((candidate) => candidate.id === player.id)!;
assert.equal(after.reserves, before.reserves + 4);
assert.equal(after.tanks, before.tanks - 4);
assert.equal(after.spice, before.spice);
assert.equal(after.revived, 4);
assert.equal(after.freeForcesRevived, 4);
assert.equal(viewGame(g, player.id).revival.forcesRemaining, 3);
rejectUnchanged(g, player.id, { type: 'revive', amount: 4, elite: 0 });
});
void test('provisional late-paid Recruits preserves payment and permits only the remaining recorded free allowance', () => {
for (const advanced of [false, true]) {
let g = settleRevival(applyAction(recruitsGame(advanced), 'at', { type: 'revive', amount: 3, elite: 0 }));
const before = g.players.find(player => player.id === 'at')!;
assert.equal(before.revived, 3);
assert.equal(before.freeForcesRevived, 2);
assert.equal(before.spice, 18);
const reserves = before.reserves, tanks = before.tanks;
g = applyAction(g, 'ch', play);
assert.equal(viewGame(g, 'at').revival.freeRemaining, 2);
assert.equal(viewGame(g, 'at').revival.forcesRemaining, 4);
assert.equal(g.players.find(player => player.id === 'at')!.spice, 18);
g = settleRevival(applyAction(g, 'at', { type: 'revive', amount: 2, elite: 0 }));
const after = g.players.find(player => player.id === 'at')!;
assert.equal(after.spice, 18);
assert.equal(after.revived, 5);
assert.equal(after.freeForcesRevived, 4);
assert.equal(after.reserves, reserves + 2);
assert.equal(after.tanks, tanks - 2);
assert.equal(viewGame(g, 'at').revival.freeRemaining, 0);
assert.equal(g.discard.filter(card => card.id === play.card).length, 1);
}
});
void test('free prevention remains zero and canceled unlimited faction caps compose to seven', () => {
const g = recruitsGame();
g.revivalRules = {
...newRevivalRules(),
freeBlocked: ['at'],
choamBlocked: true,
limitBlocked: true,
};
const active = applyAction(g, 'ch', play);
const rows = Object.fromEntries(
viewGame(active, 'at').recruitsPreview!.rates.map((row) => [row.player, row]),
);
assert.equal(rows.at.freeRate, 0);
assert.equal(rows.at.limit, 7);
assert.equal(rows.tl.limit, 7);
assert.equal(rows.ch.limit, 7);
});
void test('Fremen ally grant uses the same current doubled rate before or after Recruits', () => {
let g = recruitsGame();
const fremen = g.players.find((player) => player.id === 'fr')!;
const atreides = g.players.find((player) => player.id === 'at')!;
fremen.ally = atreides.id;
atreides.ally = fremen.id;
g = applyAction(g, fremen.id, { type: 'grantRevival' });
while (g.response) {
const actor = g.players.find(p => !g.response!.passed.includes(p.id))!;
g = applyAction(g, actor.id, { type: 'passResponse' });
}
g = applyAction(g, 'ch', play);
assert.deepEqual(
viewGame(g, 'at').recruitsPreview!.rates.find((row) => row.player === 'at'),
{ player: 'at', freeRate: 6, limit: 7 },
);
const late = recruitsGame();
late.players.find((player) => player.id === 'fr')!.ally = 'at';
late.players.find((player) => player.id === 'at')!.ally = 'fr';
let active = applyAction(late, 'ch', play);
active = applyAction(active, 'fr', { type: 'grantRevival' });
while (active.response) {
const actor = active.players.find(p => !active.response!.passed.includes(p.id))!;
active = applyAction(active, actor.id, { type: 'passResponse' });
}
assert.deepEqual(viewGame(active, 'at').recruitsPreview!.rates.find(row => row.player === 'at'),
{ player: 'at', freeRate: 6, limit: 7 });
});
void test('activation rejects pending, unknown and inconsistent revival histories without consuming the card', () => {
const cases = [
(g: Game) => {
g.decision = { kind: 'revivalStop', player: 'at', recipient: 'at', revival: 'forces' };
},
(g: Game) => { const p = g.players[0]; p.revived = 1; delete p.freeForcesRevived; },
(g: Game) => { const p = g.players[0]; p.revived = 1; p.freeForcesRevived = 2; },
];
for (const arrange of cases) {
const g = recruitsGame();
arrange(g);
assert.ok(viewGame(g, 'ch').recruitsPreview!.play?.blocked);
rejectUnchanged(g, 'ch');
}
});
void test('same-turn Box recovery cannot replay or consume Recruits', () => {
const g = applyAction(recruitsGame(), 'ch', play);
const card = g.discard.find((candidate) => candidate.id === play.card)!;
g.discard = g.discard.filter((candidate) => candidate.id !== play.card);
g.players.find((player) => player.id === 'ch')!.hand.push(card);
assert.equal(viewGame(g, 'ch').recruitsPreview!.play?.blocked?.includes('already active'), true);
rejectUnchanged(g, 'ch');
});
void test('a ready owner may still play before Revival closes, and the receipt expires on the next turn', () => {
let g = recruitsGame();
for (const player of g.players) {
player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
g.ready = ['ch'];
g = applyAction(g, 'ch', play);
assert.equal(g.recruits?.turn, 2);
for (let step = 0; g.status === 'playing' && g.turn === 2 && step < 1000; step++) {
let next: Game | undefined;
for (const player of g.players) {
const view = viewGame(g, player.id);
view.players.find((candidate) => candidate.id === player.id)!.bot = 'Medium';
const action = botActions(view)[0];
if (action) {
next = applyAction(g, player.id, action);
break;
}
}
assert.ok(next, 'The active effect must retain a legal path to turn end.');
g = next;
}
assert.equal(g.turn, 3);
assert.equal(g.recruits, null);
assert.equal(viewGame(g, 'at').recruitsPreview?.active, false);
});
void test('legacy known-zero ledgers are initialized atomically when Recruits is accepted', () => {
const g = recruitsGame();
for (const player of g.players) delete player.freeForcesRevived;
const active = applyAction(g, 'ch', play);
assert.ok(active.players.every((player) => player.freeForcesRevived === 0));
assert.doesNotThrow(() => viewGame(JSON.parse(JSON.stringify(active)), 'at'));
});
void test('a sold Recruits card is counted once while its normal auction continuation is open', () => {
const g = recruitsGame();
const card = g.players.find((player) => player.id === 'ch')!.hand.find((candidate) => candidate.id === play.card)!;
g.phase = 3;
g.auction = { cards: [card], index: 0, bid: 1, bidder: 'ch', active: 'ch', passed: [], opener: 0, peekKnown: false };
g.currentAuctionSale = { winner: 'ch', amount: 1, free: false, origin: 'normal', seller: null };
g.response = { kind: 'harkonnenBonus', owner: 'ch', passed: [] };
assert.doesNotThrow(() => viewGame(g, 'at'));
});
void test('ordinary audit setup cannot silently activate a forged Ecaz variant marker', () => {
const lobby = createGame('NOECAZV', newPlayer('at', 'Atreides', 'atreides'));
joinGame(lobby, newPlayer('fr', 'Fremen', 'fremen'));
for (const player of lobby.players) player.ready = true;
lobby.ecazTreachery = true;
rejectUnchanged(lobby, 'at', { type: 'start' });
assert.throws(() => initializeBaseGameForAudit(lobby), /audit initializer supports/);
});
void test('ordinary revival math remains unchanged while Recruits is inactive', () => {
const g = recruitsGame();
const player = g.players.find((candidate) => candidate.id === 'at')!;
g.revivalRules = newRevivalRules();
g.freeRevival = [player.id];
player.revived = 2;
player.freeForcesRevived = 0;
assert.equal(normalRevivalAllowance(g, player).freeRemaining, 1);
g.recruits = { turn: g.turn, player: 'ch', card: 'ecaz-recruits' };
assert.equal(normalRevivalAllowance(g, player).freeRemaining, 5);
});
void test('preview rejects missing, duplicate and forged physical variant cards', () => {
for (const corrupt of [
(g: Game) => { g.deck = g.deck.filter((card) => card.id !== 'ecaz-reinforcements'); },
(g: Game) => { g.deck.push({ ...physical(g).find((card) => card.id === 'ecaz-harass-withdraw')! }); },
(g: Game) => {
const card = physical(g).find((candidate) => candidate.id === 'ecaz-reinforcements')!;
card.effect = 'recruits';
},
]) {
const g = recruitsGame();
corrupt(g);
assert.throws(() => viewGame(g, 'at'), /inventory/);
}
});
void test('seat-control changes cannot persist a malformed variant save', () => {
const g = recruitsGame();
g.deck = g.deck.filter((card) => card.id !== 'ecaz-reinforcements');
rejectUnchanged(g, 'at', { type: 'setAutopilot', difficulty: 'Medium' });
});