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:
1 parent
d63efccb55
commit
0e3d6a250e
23 files changed
+1183
-44
No files matched your search
@@ -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' }]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]));
|
||||
}
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user