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.
This commit is contained in:
Quintine committed 2026-10-24 18:16:03 +11:00
1 parent d63efccb55
commit 0e3d6a250e
23 files changed
+1183 -44

No files matched your search

+220
View File
@@ -0,0 +1,220 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type FremenRevivalGrantStep = { actor: string; action: Action };
export type FremenRevivalGrantTransition = {
before: Game; steps: FremenRevivalGrantStep[]; after: Game;
};
export const fremenRevivalGrantPlayer = (game: Game, id: string) => {
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
};
export const fremenRevivalGrantClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
export const fremenRevivalGrantInventory = (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;
};
/** Scope original scalar shuffle choices only; UUIDs and other byte entropy stay
* native. No card, force, wallet or historical event is installed after a deal. */
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 actFremenRevivalGrant(game: Game, actor: string, action: Action,
actions: FremenRevivalGrantStep[] = []): Game {
const next = originalScalarOrder(() => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
/** Advance native controls only. No voluntary revival, grant, Recruits play,
* alliance or private battle plan is selected by the generic continuation. */
export function nextFremenRevivalGrantStep(game: Game): FremenRevivalGrantStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.status === 'setup' && game.setupStage === 'forces' &&
fremenRevivalGrantPlayer(game, 'fremen').reserves === 20)
return { actor: 'fremen', action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 } } };
if (game.decision?.kind === 'wormRide' || game.decision?.kind === 'wormPlacement')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
if (game.decision?.kind === 'guildTiming')
return { actor: game.decision.player, action: { type: 'decision', take: false } };
}
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceFremenRevivalGrant(game: Game, until: (state: Game) => boolean,
actions: FremenRevivalGrantStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine Fremen grant chronology ended before its requested control');
const next = nextFremenRevivalGrantStep(game);
assert.ok(next, `Supply the original control at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actFremenRevivalGrant(game, next.actor, next.action, actions);
}
throw Error('The native Fremen grant chronology did not reach its finite boundary');
}
export function passFremenRevivalGrantResponses(game: Game,
actions: FremenRevivalGrantStep[] = []): 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 = actFremenRevivalGrant(game, actor.id, { type: 'passResponse' }, actions);
}
assert.ok(!game.response); return game;
}
export function cloneFremenRevivalGrant(game: Game): Game {
assert.ok(fremenRevivalGrantClean(game), 'Clone a clean original Revival choice boundary');
return structuredClone(game);
}
/** Actual public classic setup, a paid Tabr invasion, six native casualties per
* combatant, a later original Shai-Hulud and accepted Nexus alliance. Stop at
* clean turn-two Revival BEFORE grant or optional Recruits activation. Only
* unopened physical card/Spice positions and native scalar shuffles are chosen. */
export function createFremenRevivalGrantFixture(options: {
advanced?: boolean; recruits?: boolean; priorRevival?: boolean;
} = {}) {
const advanced = options.advanced ?? false, recruits = options.recruits ?? false;
const fremen = 'fremen', ally = 'atreides', observer = 'emperor';
let game = createGame('FREMENREVIVALGRANT', newPlayer(fremen, 'Fremen', 'fremen'), advanced);
joinGame(game, newPlayer(ally, 'Atreides', 'atreides'));
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: FremenRevivalGrantStep[] = [];
const act = (actor: string, action: Action) => { game = actFremenRevivalGrant(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceFremenRevivalGrant(game, until, actions); };
const transition = (run: () => void): FremenRevivalGrantTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
if (recruits) act(game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
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.equal(game.advanced, advanced);
assert.equal(!!game.ecazTreachery, recruits);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalInventory = fremenRevivalGrantInventory(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');
const recruitsCard = recruits ? game.deck.find(card => card.id === RECRUITS_CARD_ID) : null;
assert.equal(ordinary.length, 2); assert.ok(karama); assert.ok(!recruits || recruitsCard);
const startingCards = [recruitsCard ?? 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);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), startingCards.map(card => card.id));
assert.equal(fremenRevivalGrantPlayer(game, fremen).forces['sietch_tabr:14'], 10);
assert.ok(fremenRevivalGrantPlayer(game, observer).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 &&
card.territory !== 'sietch_tabr');
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 === 5 && state.active === ally && fremenRevivalGrantClean(state));
const shipment = transition(() => {
act(ally, { type: 'ship', territory: 'sietch_tabr', sector: 14, amount: 6, elite: 0, allyPayment: 0 });
reach(fremenRevivalGrantClean);
});
assert.equal(fremenRevivalGrantPlayer(game, ally).forces['sietch_tabr:14'], 6);
assert.equal(fremenRevivalGrantPlayer(shipment.before, ally).spice - fremenRevivalGrantPlayer(game, ally).spice, 6);
reach(state => state.turn === 1 && state.phase === 6 && fremenRevivalGrantClean(state) && !!state.active);
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === 'sietch_tabr'); assert.ok(choice);
const battleStep: FremenRevivalGrantStep = { actor: choice.chooser,
action: { type: 'chooseBattle', territory: 'sietch_tabr',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep.actor, battleStep.action);
const plans: FremenRevivalGrantStep[] = [];
for (const actor of [fremen, ally]) {
reach(state => !!state.battle && fremenRevivalGrantClean(state) &&
!state.battle.preparation && state.battle.preLeader?.closed !== false);
const leaders = fremenRevivalGrantPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
const leader = actor === fremen ? leaders.at(-1) : leaders[0]; assert.ok(leader);
const step: FremenRevivalGrantStep = { actor, action: { type: 'battlePlan',
leader: leader.id, dial: actor === fremen ? 6 : 0, support: 0, weapon: null, defense: null } };
plans.push(step); act(step.actor, step.action);
}
reach(state => !state.battle && fremenRevivalGrantClean(state));
const afterBattle = structuredClone(game);
assert.equal(fremenRevivalGrantPlayer(game, fremen).tanks, 6);
assert.equal(fremenRevivalGrantPlayer(game, ally).tanks, 6);
reach(state => state.turn === 2 && state.phase === 1 && fremenRevivalGrantClean(state) &&
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
const beforeWormOrder = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(wormIndex >= 0);
const worm = game.spiceDeck.splice(wormIndex, 1)[0]; game.spiceDeck.unshift(worm);
const wormOrder = { position: 0, from: wormIndex, card: structuredClone(worm) };
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeWormOrder);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && fremenRevivalGrantClean(state));
const alliance = transition(() => {
act(fremen, { type: 'alliance', target: ally });
act(ally, { type: 'alliance', target: fremen });
});
assert.equal(fremenRevivalGrantPlayer(game, fremen).ally, ally);
assert.equal(fremenRevivalGrantPlayer(game, ally).ally, fremen);
reach(state => state.turn === 2 && state.phase === 4 && fremenRevivalGrantClean(state));
const beforePriorRevival = structuredClone(game);
let priorRevival: FremenRevivalGrantTransition | null = null;
if (options.priorRevival) priorRevival = transition(() => {
act(ally, { type: 'revive', amount: 3, elite: 0 });
reach(fremenRevivalGrantClean);
});
assert.equal(game.turn, 2); assert.equal(game.phase, 4); assert.ok(fremenRevivalGrantClean(game));
assert.equal(fremenRevivalGrantPlayer(game, ally).tanks, options.priorRevival ? 3 : 6);
assert.equal(fremenRevivalGrantPlayer(game, ally).revived, options.priorRevival ? 3 : 0);
assert.equal(fremenRevivalGrantPlayer(game, ally).freeForcesRevived, options.priorRevival ? 2 : 0);
assert.ok(!options.priorRevival ||
fremenRevivalGrantPlayer(beforePriorRevival, ally).spice - fremenRevivalGrantPlayer(game, ally).spice === 2);
assert.ok(!game.freeRevival.includes(ally));
assert.ok(!game.recruits);
assert.ok(fremenRevivalGrantPlayer(game, observer).hand.some(card => card.id === karama.id));
assert.deepEqual(fremenRevivalGrantInventory(game), originalInventory);
const grant: Action = { type: 'grantRevival' };
const recruitsPlay: Action | null = recruits ? { type: 'card', card: RECRUITS_CARD_ID } : null;
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, beforeBattle, afterBattle,
beforePriorRevival, shipment, battleStep, plans, alliance, priorRevival,
fremen, owner: fremen, ally, beneficiary: ally, observer, counterOwner: observer,
karama: karama.id, recruits: recruitsCard?.id ?? null, recruitsPlay,
grant, grantStep: { actor: fremen, action: grant } satisfies FremenRevivalGrantStep,
recruitsStep: recruitsPlay ? { actor: fremen, action: recruitsPlay } satisfies FremenRevivalGrantStep : null,
ordinaryRevival: { type: 'revive', amount: 3, elite: 0 } satisfies Action,
advanced, actions, sourceActionCount: actions.length, originalInventory, originalSpice,
dealOrder, spiceOrder, wormOrder,
staging: [
'Actual public classic Fremen/Atreides/Emperor ready/start and native private setup. Optional independent three-card variant is selected before start; Advanced uses the original explicit preview action.',
'Select original scalar shuffle order and three original unopened Treachery positions. Emperor receives its original Karama; optional Recruits is physically dealt to Fremen. No played hand is replaced or later card acquired.',
'Native Fremen setup puts ten ordinary counters at Tabr. Atreides actually pays six to ship six original reserves there. Both seal unarmed native leader plans; Fremen really dials six and wins. Original casualties put six counters from each faction into Tanks.',
'Order two original unopened ordinary first-turn Spice Cards and, only before actual turn-two Spice Blow, one original unopened Shai-Hulud. The actual Nexus permits Fremen proposal and Atreides acceptance; intervening native Storm, market-free Bidding, worm and readiness controls execute.',
options.priorRevival
? 'At the real second Revival, Atreides actually returns three normal counters before the grant: two recorded free and one paid at two spice. Payment, usage and native Tanks remain earned state.'
: 'At the real second Revival, neither ally has returned any counter this phase.',
'Stop before Fremen grant or optional Recruits play. No alliance, phase, wallet, army, denial stamp, response or receipt is assigned, and no grant/Recruits settlement policy is asserted.',
],
};
}
export type FremenRevivalGrantFixture = ReturnType<typeof createFremenRevivalGrantFixture>;
@@ -0,0 +1,148 @@
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 { FremenRevivalGrant } from '../components/fremen-revival-grant';
import {
createGame,
newPlayer,
viewGame,
type Action,
type GameView,
} from '../game/engine';
// These client fixtures exercise public grant projections. Engine source tests
// own the actual alliance, Karama response and physical revival transitions.
function projected(): GameView {
const game = createGame('FREMENGRANTCONTROLS', newPlayer('f', 'Fremen', 'fremen'));
game.players.push(newPlayer('a', 'Ally', 'emperor'));
Object.assign(game, {
status: 'playing', phase: 4, turn: 3, order: ['f', 'a'],
});
game.players[0].ally = 'a';
game.players[1].ally = 'f';
const view = viewGame(game, 'f');
view.fremenRevivalGrant = { recipient: 'a', granted: false, blocked: null };
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(FremenRevivalGrant, {
game, busy, act() {},
}));
}
function click(node: ReactNode): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{
children?: ReactNode;
onClick?: () => void;
'data-testid'?: string;
}>(child)) continue;
if (child.props['data-testid'] === 'fremen-revival-grant-submit' && child.props.onClick) {
child.props.onClick();
return true;
}
if (click(child.props.children)) return true;
}
return false;
}
function grant(game: GameView, busy = false): Action[] {
const actions: Action[] = [];
click(FremenRevivalGrant({ game, busy, act(action) { actions.push(action); } }));
return actions;
}
void test('native Fremen grant renders its ally and submits the original grant action once', () => {
const game = projected();
const html = markup(game);
assert.match(html, /aria-label="Fremen ally free revival"/);
assert.match(html, /Grant Ally an ordinary free force revival rate of three this phase/);
assert.match(html, /Grant ally free revival/);
assert.doesNotMatch(html, /disabled=""/);
assert.deepEqual(grant(game), [{ type: 'grantRevival' }]);
});
void test('the persistent grant guide names native three, provisional Recruits current rates, prior free use and no refunds', () => {
const html = markup(projected());
assert.match(html, /replaces their native ordinary rate rather than adding three/);
assert.match(html, /Ordinary total revival and special-force limits still apply/);
assert.match(html, /Provisional current-rate policy/);
assert.match(html, /Recruits doubles the current granted rate, including applicable bonuses,\s*before caps/);
assert.match(html, /Later grants use the same current-rate calculation/);
assert.match(html, /your ally’s revival controls show the actual remaining allowance/);
assert.match(html, /Previously recorded free returns use the allowance/);
assert.match(html, /Earlier paid returns keep their payments and count toward the total revival limit/);
assert.match(html, /no refund or retroactive reclassification/);
assert.match(html, /Another player may use Karama before the original alliance grant takes effect/);
assert.match(html, /Canceling blocks this Fremen grant to the current ally for this Revival/);
assert.match(html, /the ally retains its intrinsic rate and Recruits/);
assert.match(html, /Completed revivals and payments stay unchanged/);
assert.match(html, /response timing and prevention scope are provisional/);
});
void test('busy, Truthtrance and pending Nexus block the actual callback', () => {
const game = projected();
assert.deepEqual(grant(game, true), []);
assert.match(markup(game, true), /<button\b[^>]*\sdisabled=""/);
const truthtrance = structuredClone(game);
truthtrance.truthtrance = {} as NonNullable<GameView['truthtrance']>;
assert.deepEqual(grant(truthtrance), []);
assert.match(markup(truthtrance), /<button\b[^>]*\sdisabled=""/);
const nexus = structuredClone(game);
nexus.nexusTraitors = {
offer: null,
pending: {} as NonNullable<NonNullable<GameView['nexusTraitors']>['pending']>,
};
assert.deepEqual(grant(nexus), []);
assert.match(markup(nexus), /<button\b[^>]*\sdisabled=""/);
});
void test('blocked and granted projections retain readable status and guidance without permitting replay', () => {
for (const status of ['blocked', 'granted'] as const) {
const game = projected();
if (status === 'blocked') game.fremenRevivalGrant!.blocked = 'Karama prevents this ally grant for the rest of this phase.';
if (status === 'granted') game.fremenRevivalGrant!.granted = true;
for (let render = 0; render < 3; render++) {
const html = markup(game);
assert.match(html, /Grant ally free revival/);
assert.match(html, /<button\b[^>]*\sdisabled=""/);
assert.match(html, /Provisional current-rate policy/);
if (status === 'blocked') {
assert.match(html, /Karama prevents this ally grant for the rest of this phase/);
assert.match(html, /aria-describedby="fremen-revival-grant-unavailable"/);
} else {
assert.match(html, /Free revival has been granted to Ally for this phase/);
}
assert.deepEqual(grant(game), []);
}
}
});
void test('the grant control uses only its native owner, actual ally and current Revival projection', () => {
for (const field of ['phase', 'status', 'viewer', 'faction', 'ally', 'recipient', 'offer'] as const) {
const game = projected();
if (field === 'phase') game.phase = 5;
if (field === 'status') game.status = 'lobby';
if (field === 'viewer') game.me = 'a';
if (field === 'faction') game.players[0].faction = 'emperor';
if (field === 'ally') game.players[0].ally = null;
if (field === 'recipient') game.fremenRevivalGrant!.recipient = 'someone-else';
if (field === 'offer') game.fremenRevivalGrant = null;
assert.equal(markup(game), '', field);
assert.deepEqual(grant(game), [], field);
}
});
void test('grant guidance never reads the ally wallet, Tanks or private revival use', () => {
const game = projected();
const ally = game.players.find((player) => player.id === 'a')!;
for (const field of ['spice', 'tanks', 'deadForces', 'revived', 'freeForcesRevived']) {
Object.defineProperty(ally, field, {
get() { throw new Error(`Grant control read private ally ${field}`); },
});
}
assert.match(markup(game), /Grant Ally an ordinary free force revival rate of three/);
assert.deepEqual(grant(game), [{ type: 'grantRevival' }]);
});
+98
View File
@@ -0,0 +1,98 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { viewGame } from '../game/engine';
import {
createFremenRevivalGrantFixture as fixture,
actFremenRevivalGrant as act,
passFremenRevivalGrantResponses as pass,
advanceFremenRevivalGrant as advance,
fremenRevivalGrantClean as clean,
fremenRevivalGrantPlayer as player,
fremenRevivalGrantInventory as inventory,
} from './fixture-fremen-revival-grant';
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} original allied grant waits for its native Karama response then permits three free`, () => {
const f = fixture({ advanced }), before = structuredClone(f.game);
let g = act(f.game, f.fremen, f.grant);
assert.equal(g.response?.kind, 'fremenRevivalGrant');
assert.deepEqual(g.response?.fremenGrant, { turn: 2, owner: f.fremen, recipient: f.ally });
assert.equal(g.freeRevival.includes(f.ally), false);
assert.ok(viewGame(g, f.observer).responseControls?.cancelCards.includes(f.karama));
assert.throws(() => act(g, f.ally, { type: 'revive', amount: 3, elite: 0 }), /response|power|interaction/i);
g = pass(g);
assert.equal(g.freeRevival.filter(id => id === f.ally).length, 1);
const spice = player(g, f.ally).spice;
g = advance(act(g, f.ally, { type: 'revive', amount: 3, elite: 0 }), clean);
assert.equal(player(g, f.ally).spice, spice);
assert.equal(player(g, f.ally).revived, 3);
assert.equal(player(g, f.ally).freeForcesRevived, 3);
assert.deepEqual(inventory(g), f.originalInventory);
assert.deepEqual(f.game, before);
assert.throws(() => act(g, f.fremen, f.grant), /already been granted/);
});
void test(`${advanced ? 'Advanced' : 'Basic'} real Karama denies only the original allied grant and cannot be bypassed by redeclaring`, () => {
const f = fixture({ advanced });
let g = act(f.game, f.fremen, f.grant);
g = act(g, f.observer, { type: 'card', card: f.karama, mode: 'cancel' });
assert.ok(!g.response);
assert.equal(g.freeRevival.includes(f.ally), false);
assert.deepEqual(g.revivalRules?.fremenGrantBlocked, [f.ally]);
assert.equal(g.revivalRules?.freeBlocked?.includes(f.ally) ?? false, false);
assert.equal(viewGame(g, f.ally).revival.freeRemaining, 2);
assert.equal(g.discard.filter(c => c.id === f.karama).length, 1);
const before = structuredClone(g);
assert.throws(() => act(g, f.fremen, f.grant), /Karama prevented/);
assert.deepEqual(g, before);
g = advance(act(g, f.ally, { type: 'revive', amount: 2, elite: 0 }), clean);
assert.equal(player(g, f.ally).freeForcesRevived, 2);
assert.deepEqual(inventory(g), f.originalInventory);
});
void test(`${advanced ? 'Advanced' : 'Basic'} grant prevention expires through the next actual Revival opening`, () => {
const f = fixture({ advanced });
let g = act(f.game, f.fremen, f.grant);
g = act(g, f.observer, { type: 'card', card: f.karama, mode: 'cancel' });
g = advance(g, state => state.turn === 3 && state.phase === 4 && clean(state));
assert.equal(player(g, f.fremen).ally, f.ally);
assert.equal(g.revivalRules?.fremenGrantBlocked?.includes(f.ally) ?? false, false);
assert.equal(viewGame(g, f.fremen).fremenRevivalGrant?.blocked, null);
g = pass(act(g, f.fremen, f.grant));
assert.equal(g.freeRevival.filter(id => id === f.ally).length, 1);
assert.deepEqual(inventory(g), f.originalInventory);
});
void test(`${advanced ? 'Advanced' : 'Basic'} later Recruits grant preserves earned paid/free history and increases the current allowance`, () => {
const f = fixture({ advanced, recruits: true, priorRevival: true });
assert.ok(f.recruitsPlay);
let g = act(f.game, f.fremen, f.recruitsPlay);
const spice = player(g, f.ally).spice;
assert.equal(viewGame(g, f.ally).revival.freeRemaining, 2);
assert.equal(viewGame(g, f.fremen).fremenRevivalGrant?.blocked, null);
g = pass(act(g, f.fremen, f.grant));
assert.equal(viewGame(g, f.ally).revival.freeRemaining, 4);
assert.equal(player(g, f.ally).spice, spice);
assert.equal(player(g, f.ally).revived, 3);
assert.equal(player(g, f.ally).freeForcesRevived, 2);
g = advance(act(g, f.ally, { type: 'revive', amount: 3, elite: 0 }), clean);
assert.equal(player(g, f.ally).spice, spice);
assert.equal(player(g, f.ally).revived, 6);
assert.equal(player(g, f.ally).freeForcesRevived, 5);
assert.equal(player(g, f.ally).tanks, 0);
assert.equal(g.discard.filter(c => c.id === f.recruits).length, 1);
assert.deepEqual(inventory(g), f.originalInventory);
});
void test(`${advanced ? 'Advanced' : 'Basic'} prevention after Recruits leaves intrinsic doubled free returns usable`, () => {
const f = fixture({ advanced, recruits: true });
assert.ok(f.recruitsPlay);
let g = act(f.game, f.fremen, f.recruitsPlay);
g = act(g, f.fremen, f.grant);
g = act(g, f.observer, { type: 'card', card: f.karama, mode: 'cancel' });
assert.ok(!g.response);
assert.equal(viewGame(g, f.ally).revival.freeRemaining, 4);
const spice = player(g, f.ally).spice;
g = advance(act(g, f.ally, { type: 'revive', amount: 4, elite: 0 }), clean);
assert.equal(player(g, f.ally).spice, spice);
assert.equal(player(g, f.ally).freeForcesRevived, 4);
assert.equal(g.freeRevival.includes(f.ally), false);
assert.deepEqual(inventory(g), f.originalInventory);
});
}
+187
View File
@@ -0,0 +1,187 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer, type Game } from '../game/engine';
import { newRevivalRules } from '../game/revival';
import {
FremenRevivalGrantError,
fremenRevivalGrantBlock,
projectFremenRevivalGrant,
quoteFremenRevivalGrant,
validateFremenRevivalGrant,
type FremenRevivalGrantSource,
} from '../game/fremen-revival-grant';
function fixture(recruits = false): Game {
const g = createGame('FREMENGRANTQUOTE', newPlayer('f', 'Fremen', 'fremen'));
g.players.push(
newPlayer('a', 'Ally', 'atreides'),
newPlayer('e', 'Emperor', 'emperor'),
);
Object.assign(g, { status: 'playing', phase: 4, turn: 3 });
g.players[0].ally = 'a';
g.players[1].ally = 'f';
g.players[1].revived = 0;
g.freeRevival = [];
g.revivalRules = newRevivalRules();
if (recruits) g.recruits = { turn: g.turn, player: 'e', card: 'ecaz-recruits' };
return g;
}
function rejects(g: Game, owner = 'f') {
const before = structuredClone(g);
assert.equal(typeof fremenRevivalGrantBlock(g, owner), 'string');
assert.throws(() => quoteFremenRevivalGrant(g, owner), FremenRevivalGrantError);
assert.deepEqual(g, before);
}
void test('a current native Fremen grant quotes only its original turn and mutual ally without mutation', () => {
const g = fixture(), before = structuredClone(g);
assert.equal(fremenRevivalGrantBlock(g, 'f'), null);
assert.deepEqual(quoteFremenRevivalGrant(g, 'f'), { turn: 3, owner: 'f', recipient: 'a' });
validateFremenRevivalGrant(g, quoteFremenRevivalGrant(g, 'f'));
assert.deepEqual(projectFremenRevivalGrant(g, 'f'), { recipient: 'a', granted: false, blocked: null });
assert.deepEqual(g, before);
});
void test('quote validation remains usable within the grant’s own response without a busy gate', () => {
const g = fixture(), source = quoteFremenRevivalGrant(g, 'f');
// The focused quote needs no response fields; the engine owns live priority.
g.response = { kind: 'fremenRevivalGrant', owner: 'f', recipient: 'a', fremenGrant: source, passed: [] };
const before = structuredClone(g);
validateFremenRevivalGrant(g, source);
assert.equal(fremenRevivalGrantBlock(g, 'f'), null);
assert.deepEqual(g, before);
});
void test('already committed and canceled grants cannot be repeated for the same recipient', () => {
for (const canceled of [false, true]) {
const g = fixture(), source = quoteFremenRevivalGrant(g, 'f');
if (canceled) g.revivalRules!.fremenGrantBlocked = ['a'];
else g.freeRevival.push('a');
rejects(g);
assert.throws(() => validateFremenRevivalGrant(g, source), FremenRevivalGrantError);
const projected = projectFremenRevivalGrant(g, 'f')!;
assert.equal(projected.granted, !canceled);
assert.match(projected.blocked!, canceled ? /Karama prevented/ : /already been granted/);
}
const other = fixture();
other.revivalRules!.fremenGrantBlocked = ['e'];
assert.equal(fremenRevivalGrantBlock(other, 'f'), null);
});
void test('grant cancellation never changes intrinsic prevention, rates, payments or recipient resources', () => {
const g = fixture(true);
g.revivalRules!.fremenGrantBlocked = ['a'];
g.revivalRules!.freeBlocked = ['e'];
g.players[1].revived = 3;
g.players[1].freeForcesRevived = 2;
const before = structuredClone(g);
rejects(g);
projectFremenRevivalGrant(g, 'f');
assert.deepEqual(g, before);
assert.deepEqual(g.revivalRules!.freeBlocked, ['e']);
});
void test('a grant needs playing Revival, a valid turn and the unique native Fremen owner', () => {
for (const change of [
(g: Game) => { g.status = 'lobby'; },
(g: Game) => { g.phase = 5; },
(g: Game) => { g.turn = 0; },
(g: Game) => { g.turn = 1.5; },
(g: Game) => { g.players[0].faction = 'guild'; },
(g: Game) => { g.players[2].faction = 'fremen'; },
(g: Game) => { g.players.push(structuredClone(g.players[0])); },
]) {
const g = fixture(); change(g); rejects(g);
}
rejects(fixture(), 'a');
rejects(fixture(), 'missing');
});
void test('a missing, self, duplicate or nonreciprocal ally cannot receive the grant', () => {
for (const change of [
(g: Game) => { g.players[0].ally = null; },
(g: Game) => { g.players[0].ally = 'missing'; },
(g: Game) => { g.players[0].ally = 'f'; },
(g: Game) => { g.players[1].ally = null; },
(g: Game) => { g.players[1].ally = 'e'; },
(g: Game) => { g.players.push(structuredClone(g.players[1])); },
]) {
const g = fixture(); change(g); rejects(g);
}
});
void test('saved source validation rejects a changed turn, original ally or client-retargeted recipient', () => {
const original = fixture(), source = quoteFremenRevivalGrant(original, 'f');
for (const change of [
(g: Game) => { g.turn++; },
(g: Game) => { g.players[0].ally = 'e'; g.players[2].ally = 'f'; },
(g: Game) => { g.players[1].ally = null; },
]) {
const g = structuredClone(original); change(g);
const before = structuredClone(g);
assert.throws(() => validateFremenRevivalGrant(g, source), FremenRevivalGrantError);
assert.deepEqual(g, before);
}
assert.throws(() => validateFremenRevivalGrant(original, { ...source, recipient: 'e' }), FremenRevivalGrantError);
});
void test('malformed saved sources reject with the same focused error before mutation', () => {
const g = fixture(), source = quoteFremenRevivalGrant(g, 'f'), before = structuredClone(g);
for (const malformed of [
null, [], {}, { ...source, turn: -1 }, { ...source, owner: '' },
{ ...source, recipient: '' }, { ...source, owner: 'a' },
{ ...source, amount: 6 }, { owner: 'f', recipient: 'a' },
]) assert.throws(() => validateFremenRevivalGrant(g, malformed as FremenRevivalGrantSource), FremenRevivalGrantError);
assert.deepEqual(g, before);
});
void test('active Recruits accepts a later grant with actual prior free usage and retains all prior accounting', () => {
for (const [revived, free] of [[0, undefined], [0, 0], [3, 2], [4, 4]]) {
const g = fixture(true);
g.players[1].revived = revived!;
g.players[1].freeForcesRevived = free;
const before = structuredClone(g);
assert.equal(fremenRevivalGrantBlock(g, 'f'), null);
assert.deepEqual(quoteFremenRevivalGrant(g, 'f'), { turn: 3, owner: 'f', recipient: 'a' });
assert.deepEqual(g, before);
}
});
void test('active Recruits rejects missing prior free history and malformed beneficiary ledgers', () => {
const usage: [number, number | undefined][] = [
[1, undefined], [-1, 0], [0.5, 0], [NaN, 0], [Infinity, 0],
[2, -1], [2, 0.5], [2, NaN], [2, Infinity], [2, 3],
];
for (const [revived, free] of usage) {
const g = fixture(true);
g.players[1].revived = revived;
g.players[1].freeForcesRevived = free;
rejects(g);
const projected = projectFremenRevivalGrant(g, 'f')!;
assert.match(projected.blocked!, /usage is inconsistent or unknown/);
assert.deepEqual(Object.keys(projected).sort(), ['blocked', 'granted', 'recipient']);
}
});
void test('inactive and expired Recruits retain the ordinary grant’s existing legacy usage boundary', () => {
for (const expired of [false, true]) {
const g = fixture(expired);
if (g.recruits) g.recruits.turn--;
g.players[1].revived = 2;
delete g.players[1].freeForcesRevived;
assert.equal(fremenRevivalGrantBlock(g, 'f'), null);
}
});
void test('projection is owner-only and reads no recipient wallet, Tanks or hand', () => {
const g = fixture(true);
for (const key of ['spice', 'tanks', 'hand'] as const)
Object.defineProperty(g.players[1], key, {
get() { throw new Error(`Grant projection read private ${key}`); },
});
assert.deepEqual(projectFremenRevivalGrant(g, 'f'), { recipient: 'a', granted: false, blocked: null });
for (const viewer of ['a', 'e', 'missing', '']) assert.equal(projectFremenRevivalGrant(g, viewer), null);
g.players[0].ally = null;
assert.equal(projectFremenRevivalGrant(g, 'f'), null);
});
+4 -2
View File
@@ -149,7 +149,7 @@ for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
});
}
void test('all AI profiles skip the unresolved late Fremen grant while using other legal revival choices', () => {
void test('all AI profiles retain the legal late Fremen grant after Recruits', () => {
let g = ownerGame();
g.players[0].ally = 'fr'; g.players[1].ally = 'at';
g = applyAction(g, 'at', { type: 'card', card: 'ecaz-recruits' });
@@ -158,7 +158,9 @@ void test('all AI profiles skip the unresolved late Fremen grant while using oth
view.players.find((p) => p.id === 'fr')!.bot = level;
const actions = botActions(view);
assert.ok(actions.length);
assert.ok(!actions.some((action) => action.type === 'grantRevival'));
const grant = actions.find(action => action.type === 'grantRevival');
assert.ok(grant);
assert.doesNotThrow(() => applyAction(g, 'fr', grant));
assert.doesNotThrow(() => applyAction(g, 'fr', actions[0]));
}
});
+14 -4
View File
@@ -88,7 +88,7 @@ void test('playing Recruits discards its exact card and publishes effective rate
);
assert.equal(preview.active, true);
assert.equal(preview.playedBy, 'ch');
assert.equal(preview.grantRevivalBlocked?.includes('before Recruits'), true);
assert.equal(preview.grantRevivalBlocked, null);
}
});
@@ -149,13 +149,17 @@ void test('free prevention remains zero and canceled unlimited faction caps comp
assert.equal(rows.ch.limit, 7);
});
void test('prior settled Fremen ally grant is doubled, while a grant after activation rejects immutably', () => {
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'),
@@ -165,8 +169,14 @@ void test('prior settled Fremen ally grant is doubled, while a grant after activ
const late = recruitsGame();
late.players.find((player) => player.id === 'fr')!.ally = 'at';
late.players.find((player) => player.id === 'at')!.ally = 'fr';
const active = applyAction(late, 'ch', play);
rejectUnchanged(active, 'fr', { type: 'grantRevival' });
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', () => {