Route Emperor Nexus extra revival through native pending settlement
This commit is contained in:
1 parent
022040972c
commit
22948ff6e4
27 files changed
+1901
-268
No files matched your search
@@ -53,6 +53,7 @@ for (const advanced of [false, true]) {
|
||||
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
|
||||
if (kind === 'emperor-revival') {
|
||||
const receipt = paid.nexusEmperorSecretHistory![0];
|
||||
assert.ok(receipt.after, 'The requested Nexus return must settle before physical receipt consumption.');
|
||||
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
|
||||
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
|
||||
assert.equal(receipt.before.spice, receipt.after.spice);
|
||||
|
||||
@@ -3,7 +3,6 @@ import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { emperorNexusModeSupported } from '../game/nexus-emperor-secret-ally';
|
||||
import { nexusEmperorRevivalAction } from '../game/nexus-emperor-secret-ally-options';
|
||||
import { nexusFremenRevivalAction } from '../game/nexus-fremen-revival-options';
|
||||
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
|
||||
@@ -108,6 +107,7 @@ for (const configuration of configurations) for (const kind of ['emperor-revival
|
||||
assert.equal(history[0].turn, 2); assert.equal(history[0].phase, 4);
|
||||
if (kind === 'emperor-revival') {
|
||||
const receipt = paid.nexusEmperorSecretHistory![0];
|
||||
assert.ok(receipt.after, 'The requested Nexus return must settle before physical receipt consumption.');
|
||||
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
|
||||
assert.equal(receipt.before.spice, receipt.after.spice);
|
||||
} else {
|
||||
@@ -289,23 +289,14 @@ for (const configuration of [configurations[0], configurations[1], configuration
|
||||
});
|
||||
}
|
||||
|
||||
void test('original Nexus/Discovery Emperor purchase rejects unrelated profiles and preserves completed return integrity', () => {
|
||||
void test('completed Discovery Emperor extra return rejects altered ordinary usage', () => {
|
||||
const initial = initializeDiscoveryClassicNexusEffectsSetup({ advanced: true, tech: true, strongholds: true });
|
||||
const fixture = createDiscoveryClassicNexusEffectsFixture({ initial });
|
||||
discoveryClassicNexusEffectsInventory(fixture.game);
|
||||
// Explicit adversarial compositions, not histories claimed to be natural play.
|
||||
for (const change of [
|
||||
(game: Game) => { game.expansions = ['ix']; },
|
||||
(game: Game) => { game.players.find(player => player.id === fixture.opponent)!.faction = 'emperor'; },
|
||||
(game: Game) => { game.advanced = false; },
|
||||
(game: Game) => { game.semutaPreview = true; },
|
||||
]) {
|
||||
const invalid = structuredClone(fixture.game); change(invalid);
|
||||
assert.equal(emperorNexusModeSupported(invalid), false);
|
||||
rejected(invalid, fixture.actor, fixture.paymentAction);
|
||||
}
|
||||
const paid = payDiscoveryClassicNexusEffectsFixture(fixture);
|
||||
const tampered = structuredClone(paid); tampered.nexusEmperorSecretHistory![0].after.revived++;
|
||||
const tampered = structuredClone(paid), after = tampered.nexusEmperorSecretHistory![0].after;
|
||||
assert.ok(after);
|
||||
after.revived++;
|
||||
rejected(tampered, fixture.actor, { type: 'ready' });
|
||||
discoveryClassicNexusEffectsInventory(paid);
|
||||
});
|
||||
@@ -185,6 +185,7 @@ for (const advanced of [false, true]) for (const kind of ['emperor', 'fremen'] a
|
||||
assert.equal(history.length, 1); assert.equal(history[0].owner, fixture.owner);
|
||||
if (kind === 'emperor') {
|
||||
const receipt = paid.nexusEmperorSecretHistory![0];
|
||||
assert.ok(receipt.after, 'The requested Nexus return must settle before physical receipt consumption.');
|
||||
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
|
||||
} else {
|
||||
const grant = paid.nexusFremenRevivalHistory![0];
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { location, territory } from '../game/board';
|
||||
import { emperorNexusEvent } from '../game/nexus-emperor-secret-ally';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import { frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules';
|
||||
import {
|
||||
createSingleNexusE1Setup, nextSingleNexusE1Step,
|
||||
openSingleNexusE1Battle, revealSingleNexusE1Plans, shipSingleNexusE1,
|
||||
singleNexusE1Clean, singleNexusE1Inventory, singleNexusE1Player,
|
||||
type SingleNexusE1Step,
|
||||
} from './fixture-single-nexus-e1';
|
||||
import { nexusTleilaxuSecretAllyInventory } from './fixture-nexus-tleilaxu-secret-ally';
|
||||
|
||||
export type NexusEmperorExtraOptions = {
|
||||
advanced?: boolean;
|
||||
profile?: NexusEmperorExtraProfile;
|
||||
tech?: boolean;
|
||||
homeworlds?: boolean;
|
||||
};
|
||||
export type NexusEmperorExtraProfile = 'classic' | 'tleilaxu' | 'ixians' | 'fremen';
|
||||
export type NexusEmperorExtraFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeShipment: Game;
|
||||
afterShipment: Game;
|
||||
beforeBattle: Game;
|
||||
afterBattle: Game;
|
||||
beforeBlow: Game;
|
||||
beforeDraw: Game;
|
||||
afterDraw: Game;
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
partner: string;
|
||||
profile: NexusEmperorExtraProfile;
|
||||
actions: SingleNexusE1Step[];
|
||||
event: string;
|
||||
};
|
||||
export const nexusEmperorExtraPlayer = singleNexusE1Player;
|
||||
export const nexusEmperorExtraClean = (game: Game): boolean =>
|
||||
singleNexusE1Clean(game) && !game.pendingRevival;
|
||||
export function nexusEmperorExtraInventory(game: Game): void {
|
||||
if (game.expansions.length) singleNexusE1Inventory(game);
|
||||
else nexusTleilaxuSecretAllyInventory(game);
|
||||
}
|
||||
export function stepNexusEmperorExtra(
|
||||
game: Game, step: SingleNexusE1Step, actions?: SingleNexusE1Step[],
|
||||
): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
/** A live native stop is never decided by the producer: callers choose Allow or Prevent. */
|
||||
export function advanceNexusEmperorExtra(
|
||||
game: Game, until: (game: Game) => boolean, actions?: SingleNexusE1Step[],
|
||||
): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
assert.notEqual(game.decision?.kind, 'revivalStop', 'Choose the real Tleilaxu Allow/Prevent control');
|
||||
const next = game.turn === 1 && game.phase === 5 && game.decision?.kind === 'guildTiming' &&
|
||||
game.homeworlds && game.players.some(p => p.faction === 'fremen')
|
||||
? { actor: game.decision.player, action: { type: 'decision', take: true } }
|
||||
: nextSingleNexusE1Step(game);
|
||||
assert.ok(next, `Original Emperor chronology stops at ${game.turn}/${game.phase}/${game.decision?.kind ?? 'plan'}`);
|
||||
game = stepNexusEmperorExtra(game, next, actions);
|
||||
}
|
||||
throw Error('Original Emperor chronology did not reach its boundary');
|
||||
}
|
||||
export function settleNexusEmperorExtra(game: Game): Game {
|
||||
return advanceNexusEmperorExtra(game, nexusEmperorExtraClean);
|
||||
}
|
||||
function classicSetup(advanced: boolean, options: NexusEmperorExtraOptions) {
|
||||
let game = createGame('NEXUSEMPEROREXTRA', newPlayer('atreides', 'Atreides', 'atreides'), advanced);
|
||||
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
joinGame(game, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
|
||||
game.nexusCards = { cards: null, phase: null };
|
||||
const actions: SingleNexusE1Step[] = [];
|
||||
for (const [type, enabled] of [['homeworlds', options.homeworlds], ['techTokens', options.tech]] as const)
|
||||
if (enabled) game = stepNexusEmperorExtra(game,
|
||||
{ actor: game.host, action: { type, enabled: true } }, actions);
|
||||
const initial = structuredClone(game);
|
||||
for (const player of game.players)
|
||||
game = stepNexusEmperorExtra(game, { actor: player.id, action: { type: 'ready' } }, actions);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeNexusGameForAudit(game));
|
||||
game = advanceNexusEmperorExtra(game, state => state.status === 'playing', actions);
|
||||
return { initial, afterSetup: game, actions };
|
||||
}
|
||||
|
||||
/** Original Basic/Advanced setup and deals, paid shipment/battle casualties,
|
||||
* turn-two Shai-Hulud/alliance/closing draw, and quiet native Revival. Only
|
||||
* original undealt singleton order and scoped scalar lottery are controlled.
|
||||
* No hand, army, Tanks, wallet, alliance, phase, skill or history is rewritten. */
|
||||
export function createNexusEmperorExtraFixture(
|
||||
options: NexusEmperorExtraOptions = {},
|
||||
): NexusEmperorExtraFixture {
|
||||
const advanced = options.advanced ?? true, profile = options.profile ?? 'classic';
|
||||
assert.ok(profile !== 'fremen' || options.homeworlds,
|
||||
'The Fedaykin source uses original Homeworld setup and physical starred reserves.');
|
||||
const source = profile === 'classic' ? classicSetup(advanced, options) : createSingleNexusE1Setup({
|
||||
native: profile === 'fremen' ? 'tleilaxu' : profile, advanced,
|
||||
roster: profile === 'fremen' ? ['tleilaxu', 'guild', 'fremen'] : [profile, 'guild', 'atreides'],
|
||||
skills: false, homeworlds: options.homeworlds ?? false, discovery: false, tech: options.tech ?? false, strongholds: false,
|
||||
startingKinds: ['worthless', 'worthless', 'worthless'],
|
||||
startingEffects: [profile === 'tleilaxu' || profile === 'fremen' ? 'karama' : undefined],
|
||||
});
|
||||
const { initial, afterSetup } = source, actions = [...source.actions];
|
||||
let game = structuredClone(afterSetup);
|
||||
const owner = profile === 'ixians' ? 'ixians' : profile === 'fremen' ? 'fremen' : 'atreides';
|
||||
const opponent = profile === 'ixians' ? 'atreides' : 'guild';
|
||||
const partner = game.players.find(player => player.id !== owner && player.id !== opponent)!.id;
|
||||
assert.ok(!game.players.some(player => player.faction === 'emperor'));
|
||||
assert.ok(game.players.every(player => player.hand.length > 0 && player.tanks === 0));
|
||||
// Printed land blows keep the first full turn free of an earlier Nexus draw.
|
||||
frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'hagga_basin', 0);
|
||||
if (advanced)
|
||||
frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'broken_land', 1);
|
||||
const destination = profile === 'fremen' ? 'sietch_tabr' : 'arrakeen';
|
||||
const shipper = profile === 'ixians' ? owner : opponent;
|
||||
game = advanceNexusEmperorExtra(game,
|
||||
state => state.turn === 1 && state.phase === 5 && state.active === shipper && nexusEmperorExtraClean(state), actions);
|
||||
const beforeShipment = structuredClone(game);
|
||||
const arrival = shipSingleNexusE1(game, shipper, destination, profile === 'ixians' ? 8 : 1,
|
||||
profile === 'ixians' ? 3 : 0, actions);
|
||||
game = arrival.after;
|
||||
if (profile === 'fremen') {
|
||||
game = advanceNexusEmperorExtra(game,
|
||||
state => state.phase === 5 && state.active === owner && nexusEmperorExtraClean(state), actions);
|
||||
game = shipSingleNexusE1(game, owner, destination, 6, 1, actions).after;
|
||||
}
|
||||
const afterShipment = structuredClone(game);
|
||||
assert.ok(nexusEmperorExtraPlayer(game, shipper).spice < nexusEmperorExtraPlayer(beforeShipment, shipper).spice);
|
||||
const target = location(destination, territory(destination).sectors[0]);
|
||||
if (profile !== 'fremen') assert.equal(nexusEmperorExtraPlayer(game, 'atreides').forces[target], 10);
|
||||
game = openSingleNexusE1Battle(game, owner, opponent, destination, actions);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const ownLeader = nexusEmperorExtraPlayer(game, owner).leaders.filter(leader => !leader.dead)
|
||||
.sort((a, b) => profile === 'ixians' ? a.strength - b.strength : b.strength - a.strength)[0];
|
||||
const otherLeader = nexusEmperorExtraPlayer(game, opponent).leaders.filter(leader => !leader.dead)
|
||||
.sort((a, b) => profile === 'ixians' ? b.strength - a.strength : a.strength - b.strength)[0];
|
||||
assert.ok(ownLeader && otherLeader);
|
||||
game = revealSingleNexusE1Plans(game, [
|
||||
{ actor: owner, action: { type: 'battlePlan',
|
||||
dial: profile === 'ixians' ? 0 : profile === 'fremen' ? advanced ? 6 : 5 : 8,
|
||||
support: advanced && profile !== 'ixians' && profile !== 'fremen' ? 8 : 0,
|
||||
leader: ownLeader.id, weapon: null, defense: null } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: otherLeader.id, weapon: null, defense: null } },
|
||||
], actions);
|
||||
for (let count = 0; game.battle || !nexusEmperorExtraClean(game); count++) {
|
||||
assert.ok(count < 600);
|
||||
let next = nextSingleNexusE1Step(game);
|
||||
if (!next) {
|
||||
const decision = game.decision;
|
||||
assert.ok(decision);
|
||||
let action: Parameters<typeof applyAction>[2];
|
||||
if (decision.kind === 'ixSubstitution') action = { type: 'decision', decline: true };
|
||||
else if (decision.kind === 'faceDance') action = { type: 'decision', reveal: false };
|
||||
else if (decision.kind === 'battleCards') action = { type: 'decision', discard: [] };
|
||||
else if (decision.kind === 'techToken') action = { type: 'decision', token: decision.choices[0] };
|
||||
else throw Error(`Consume the real ${decision.kind} before continuing`);
|
||||
next = { actor: decision.player, action };
|
||||
}
|
||||
game = stepNexusEmperorExtra(game, next, actions);
|
||||
}
|
||||
const afterBattle = structuredClone(game);
|
||||
assert.equal(nexusEmperorExtraPlayer(game, owner).tanks, profile === 'fremen' ? 5 : 8);
|
||||
if (profile === 'ixians') assert.equal(nexusEmperorExtraPlayer(game, owner).elites!.tanks, 3);
|
||||
if (profile === 'fremen') assert.equal(nexusEmperorExtraPlayer(game, owner).elites!.tanks, 1);
|
||||
nexusEmperorExtraInventory(game);
|
||||
game = advanceNexusEmperorExtra(game, state => state.turn === 2 && state.phase === 1 &&
|
||||
nexusEmperorExtraClean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow &&
|
||||
!state.nexus && state.ready.length === 0, actions);
|
||||
const beforeBlow = structuredClone(game);
|
||||
frontNativeHomeworldSpice(game, card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper, 0);
|
||||
frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'rock_outcroppings', 1);
|
||||
if (advanced)
|
||||
frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'oh_gap', 2);
|
||||
game = advanceNexusEmperorExtra(game, state => !!state.nexus && !state.spiceWindow &&
|
||||
!state.spiceResolution && nexusEmperorExtraClean(state), actions);
|
||||
for (const [actor, ally] of [[opponent, partner], [partner, opponent]])
|
||||
game = stepNexusEmperorExtra(game, { actor, action: { type: 'alliance', target: ally } }, actions);
|
||||
game = advanceNexusEmperorExtra(game, state => state.nexusCards?.phase?.stage === 'drawing' &&
|
||||
nexusEmperorExtraClean(state), actions);
|
||||
assert.deepEqual(game.nexusCards!.phase!.eligible, [owner]);
|
||||
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('emperor');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
const beforeDraw = structuredClone(game);
|
||||
game = stepNexusEmperorExtra(game, { actor: owner, action: { type: 'nexusCardChoice',
|
||||
turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } }, actions);
|
||||
const afterDraw = structuredClone(game);
|
||||
game = advanceNexusEmperorExtra(game, state => state.turn === 2 && state.phase === 4 &&
|
||||
nexusEmperorExtraClean(state), actions);
|
||||
assert.equal(game.nexusCards!.cards!.hands[owner], 'emperor');
|
||||
assert.equal(nexusEmperorExtraPlayer(game, owner).ally, null);
|
||||
assert.equal(nexusEmperorExtraPlayer(game, owner).tanks, profile === 'fremen' ? 5 : 8);
|
||||
assert.ok(viewGame(game, owner).nexusEmperorSecretAlly);
|
||||
const event = emperorNexusEvent(game.turn, game.phase, owner, game.nexusEmperorSecretHistory?.length ?? 0);
|
||||
nexusEmperorExtraInventory(game);
|
||||
return { initial, afterSetup, beforeShipment, afterShipment, beforeBattle, afterBattle,
|
||||
beforeBlow, beforeDraw, afterDraw, game, owner, opponent, partner, profile, actions, event };
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export type SingleNexusE1Options = {
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
startingKinds?: readonly Card['kind'][];
|
||||
startingEffects?: readonly Card['effect'][];
|
||||
collectorSkill?: LeaderSkillId;
|
||||
};
|
||||
export type SingleNexusE1Setup = {
|
||||
@@ -189,7 +190,9 @@ export function createSingleNexusE1Setup(options: SingleNexusE1Options = {}): Si
|
||||
const cards = treacheryDeck(g.expansions), remaining = [...cards], prefix: Card[] = [];
|
||||
for (const [i, p] of g.players.entries()) for (let j = 0; j < (p.faction === 'harkonnen' ? 2 : 1); j++) {
|
||||
const kind = options.startingKinds?.[i] ?? (i < 5 ? 'worthless' : 'shield');
|
||||
const at = remaining.findIndex(c => c.kind === kind); assert.ok(at >= 0); prefix.push(remaining.splice(at, 1)[0]);
|
||||
const effect = options.startingEffects?.[i];
|
||||
const at = remaining.findIndex(c => effect ? c.effect === effect : c.kind === kind);
|
||||
assert.ok(at >= 0); prefix.push(remaining.splice(at, 1)[0]);
|
||||
}
|
||||
const rolls = [...(skills ? permutation(skillSource, skillOrder) : []),
|
||||
...(discovery ? Array<number>(7).fill(0xffffffff) : []), ...Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff),
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
type Game,
|
||||
type Player,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
createNexusCards,
|
||||
discardNexusCard,
|
||||
drawNexusCard,
|
||||
} from '../game/nexus-cards';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import {
|
||||
createEmperorNexusRevival,
|
||||
emperorNexusEvent,
|
||||
emperorNexusSignature,
|
||||
settleEmperorNexusRevival,
|
||||
type NexusEmperorRevival,
|
||||
type NexusEmperorRevivalRequest,
|
||||
} from '../game/nexus-emperor-secret-ally';
|
||||
import { newRevivalRules, type PendingRevival } from '../game/revival';
|
||||
import { quoteRevivalResume, RevivalResumeError } from '../game/revival-resume';
|
||||
import {
|
||||
continuationCustody,
|
||||
quoteRevivalCancellation,
|
||||
RevivalCancellationError,
|
||||
} from '../game/revival-cancellation';
|
||||
import {
|
||||
createHomeworldCustody,
|
||||
type HomeworldCustody,
|
||||
} from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import {
|
||||
quoteHomeworldRevivalDeployment,
|
||||
type HomeworldRevivalDeploymentGroup,
|
||||
type HomeworldRevivalDeploymentSource,
|
||||
} from '../game/homeworld-revival-deployment';
|
||||
import {
|
||||
makeHomeworldRevivalProgress,
|
||||
makeHomeworldRevivalReturn,
|
||||
validateHomeworldRevivalReturn,
|
||||
} from '../game/homeworld-revival-return';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
||||
const json = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
|
||||
/** Resolved-facts unit context, not a native gameplay chronology. Nexus custody
|
||||
* still uses the original inventory/draw/discard APIs and the real source receipt. */
|
||||
function fixture(
|
||||
options: { advanced?: boolean; tleilaxu?: boolean; consumed?: boolean; declared?: boolean; ordinaryUsed?: number } = {},
|
||||
) {
|
||||
const advanced = options.advanced ?? false;
|
||||
const g = createGame(
|
||||
'NEXUSEMPEROREXTRA',
|
||||
newPlayer('p', 'Fremen', 'fremen'),
|
||||
advanced,
|
||||
options.tleilaxu ? ['ix'] : [],
|
||||
);
|
||||
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
if (options.tleilaxu) joinGame(g, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
Object.assign(g, {
|
||||
status: 'playing',
|
||||
phase: 4,
|
||||
turn: 2,
|
||||
order: g.players.map((p) => p.id),
|
||||
revivalRules: newRevivalRules(),
|
||||
freeRevival: [],
|
||||
});
|
||||
for (const p of g.players)
|
||||
Object.assign(p, { hand: [], spice: 40, tanks: 10, reserves: 10, forces: {} });
|
||||
player(g, 'p').revived = options.ordinaryUsed ?? 0;
|
||||
player(g, 'p').freeForcesRevived = options.ordinaryUsed ?? 0;
|
||||
let cards = createNexusCards(g.players, () => 0.5);
|
||||
cards.deck = ['emperor', ...cards.deck.filter((card) => card !== 'emperor')];
|
||||
cards = drawNexusCard(cards, 'p', g.players, () => 0);
|
||||
g.nexusCards = { cards, phase: null };
|
||||
const receipt = createEmperorNexusRevival(g, 'p', 0);
|
||||
if (options.declared !== false) {
|
||||
g.nexusEmperorSecretHistory = [receipt];
|
||||
g.nexusEmperorSecretEvents = [receipt.event];
|
||||
if (options.consumed !== false)
|
||||
g.nexusCards.cards = discardNexusCard(g.nexusCards.cards!, 'p', g.players);
|
||||
}
|
||||
return { g, receipt };
|
||||
}
|
||||
function pending(receipt: NexusEmperorRevivalRequest): PendingRevival {
|
||||
return {
|
||||
player: 'p',
|
||||
kind: 'forces',
|
||||
amount: 3,
|
||||
elite: receipt.elite,
|
||||
free: 3,
|
||||
cost: 0,
|
||||
normalCost: 0,
|
||||
checks: [],
|
||||
nexusEmperorEvent: receipt.event,
|
||||
};
|
||||
}
|
||||
|
||||
void test('the requested source quotes the original stop and finish continuations without repricing', () => {
|
||||
const advanced = fixture({ advanced: true, tleilaxu: true });
|
||||
advanced.g.techTokens = createTechTokens(advanced.g.players);
|
||||
const stop = json(advanced.g);
|
||||
const stopQuote = quoteRevivalResume(
|
||||
{ ...advanced.g, pendingRevival: pending(advanced.receipt) },
|
||||
'stop',
|
||||
);
|
||||
assert.equal(stopQuote.outcome, 'decision');
|
||||
assert.deepEqual(json(advanced.g), stop, 'quoting changes nothing');
|
||||
|
||||
const finishQuote = quoteRevivalResume(
|
||||
{ ...advanced.g, pendingRevival: pending(advanced.receipt) },
|
||||
'finish',
|
||||
);
|
||||
assert.equal(finishQuote.outcome, 'revive');
|
||||
assert.deepEqual(finishQuote.freeIncome, { player: 'p', turn: 2 });
|
||||
assert.deepEqual(finishQuote.techIncome, {
|
||||
id: 'axlotl',
|
||||
owner: 't',
|
||||
spice: 1,
|
||||
triggeredTurn: 2,
|
||||
});
|
||||
assert.deepEqual(finishQuote.nextResponse, {
|
||||
kind: 'revivalIncome',
|
||||
owner: 't',
|
||||
recipient: 'p',
|
||||
amount: 1,
|
||||
passed: [],
|
||||
});
|
||||
// The fixed grant is exactly three free counters at no ordinary cost.
|
||||
assert.equal(finishQuote.pending.amount, 3);
|
||||
assert.equal(finishQuote.pending.free, 3);
|
||||
assert.equal(finishQuote.pending.cost, 0);
|
||||
assert.equal(finishQuote.pending.normalCost, 0);
|
||||
});
|
||||
|
||||
void test('ordinary caps, blocks and Recruits state never block or reprice the independent three', () => {
|
||||
const { g, receipt } = fixture({ ordinaryUsed: 20 });
|
||||
const state = structuredClone({
|
||||
p: player(g, 'p'),
|
||||
spice: player(g, 'p').spice,
|
||||
});
|
||||
Object.assign(g, {
|
||||
revivalRules: {
|
||||
...newRevivalRules(),
|
||||
limitBlocked: true,
|
||||
freeBlocked: ['p'],
|
||||
},
|
||||
freeRevival: ['p'],
|
||||
recruits: { turn: g.turn, player: 'p', card: 'ecaz-recruits' },
|
||||
emperorExtra: { p: 0 },
|
||||
});
|
||||
const before = json(g);
|
||||
const quote = quoteRevivalResume({ ...g, pendingRevival: pending(receipt) }, 'finish');
|
||||
assert.equal(quote.outcome, 'revive');
|
||||
assert.deepEqual(json(g), before, 'no counter, wallet or rule state moves');
|
||||
assert.equal(player(g, 'p').revived, 20);
|
||||
assert.equal(player(g, 'p').freeForcesRevived, 20);
|
||||
assert.equal(g.emperorExtra!.p, 0, 'the native Emperor-extra allowance is untouched');
|
||||
assert.equal(player(g, 'p').spice, state.spice, 'no ordinary price is charged');
|
||||
assert.equal(player(g, 'p').tanks, 10, 'the physical Tanks move only on the real return');
|
||||
continuationCustody({ ...g, pendingRevival: null }, player(g, 'p'), pending(receipt));
|
||||
});
|
||||
|
||||
void test('native physical and typed caps still apply to the extra return', () => {
|
||||
const { g, receipt } = fixture();
|
||||
const p = player(g, 'p');
|
||||
p.elites = { reserves: 5, tanks: 2, forces: {}, revived: 0 };
|
||||
p.tanks = 4;
|
||||
const extra = { ...pending(receipt), elite: 3 };
|
||||
assert.throws(
|
||||
() => continuationCustody({ ...g, pendingRevival: null }, p, extra),
|
||||
/ordinary and elite revival groups/,
|
||||
);
|
||||
p.tanks = 2;
|
||||
assert.throws(
|
||||
() => continuationCustody({ ...g, pendingRevival: null }, p, pending(receipt)),
|
||||
/no longer in the tanks/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('malformed, stale, foreign, missing, unconsumed and outside-request sources are rejected immutably', () => {
|
||||
const base = fixture();
|
||||
const state = json(base.g);
|
||||
const cases: ((g: Game, receipt: NexusEmperorRevivalRequest) => PendingRevival)[] = [
|
||||
(g, receipt) => ({ ...pending(receipt), amount: 2 }),
|
||||
(g, receipt) => ({ ...pending(receipt), free: 2 }),
|
||||
(g, receipt) => ({ ...pending(receipt), elite: 1 }),
|
||||
(g, receipt) => ({ ...pending(receipt), cost: 1 }),
|
||||
(g, receipt) => ({ ...pending(receipt), player: 'a' }),
|
||||
(g, receipt) => ({ ...pending(receipt), payer: 'a' }),
|
||||
(g, receipt) => ({ ...pending(receipt), emperorExtra: true }),
|
||||
(g, receipt) => ({ ...pending(receipt), nexusEmperorEvent: receipt.event + 'x' }),
|
||||
(g) => {
|
||||
g.nexusEmperorSecretHistory = undefined;
|
||||
g.nexusEmperorSecretEvents = undefined;
|
||||
return pending(base.receipt);
|
||||
},
|
||||
(g) => {
|
||||
g.nexusEmperorSecretHistory![0].signature += 'x';
|
||||
return pending(base.receipt);
|
||||
},
|
||||
(g) => {
|
||||
g.nexusEmperorSecretHistory![0].sequence = 1;
|
||||
return pending(base.receipt);
|
||||
},
|
||||
(g) => {
|
||||
g.nexusEmperorSecretHistory![0].elite = 1;
|
||||
return pending(base.receipt);
|
||||
},
|
||||
(g) => {
|
||||
player(g, 'a').ally = 'p';
|
||||
player(g, 'p').ally = 'a';
|
||||
return pending(base.receipt);
|
||||
},
|
||||
(g) => {
|
||||
player(g, 'p').tanks = 9;
|
||||
return pending(base.receipt);
|
||||
},
|
||||
(g) => {
|
||||
player(g, 'h').faction = 'emperor';
|
||||
return pending(base.receipt);
|
||||
},
|
||||
];
|
||||
for (const build of cases) {
|
||||
const g = json(base.g);
|
||||
const request = build(g, json(base.receipt));
|
||||
const snapshot = json(g);
|
||||
assert.throws(
|
||||
() => quoteRevivalResume({ ...g, pendingRevival: request }, 'finish'),
|
||||
RevivalResumeError,
|
||||
);
|
||||
assert.deepEqual(json(g), snapshot, 'a rejected source mutates nothing');
|
||||
}
|
||||
const unconsumed = fixture({ consumed: false });
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteRevivalResume(
|
||||
{ ...unconsumed.g, pendingRevival: pending(unconsumed.receipt) },
|
||||
'finish',
|
||||
),
|
||||
/consumed physical card/,
|
||||
);
|
||||
const stale: NexusEmperorRevivalRequest = {
|
||||
...json(base.receipt),
|
||||
turn: 1,
|
||||
event: emperorNexusEvent(1, 4, 'p', 0),
|
||||
signature: '',
|
||||
};
|
||||
stale.signature = emperorNexusSignature(stale);
|
||||
const staleGame = json(base.g);
|
||||
staleGame.nexusEmperorSecretHistory = [stale];
|
||||
staleGame.nexusEmperorSecretEvents = [stale.event];
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteRevivalResume(
|
||||
{ ...staleGame, pendingRevival: pending(stale) },
|
||||
'finish',
|
||||
),
|
||||
RevivalResumeError,
|
||||
);
|
||||
assert.deepEqual(json(base.g), state, 'the fixture survives every rejected read');
|
||||
});
|
||||
|
||||
void test('a settled returned or denied source no longer authorizes another pending revival', () => {
|
||||
const returned = fixture();
|
||||
const p = player(returned.g, 'p');
|
||||
p.tanks -= 3;
|
||||
p.reserves += 3;
|
||||
returned.g.nexusEmperorSecretHistory![0] = settleEmperorNexusRevival(
|
||||
returned.g,
|
||||
json(returned.g.nexusEmperorSecretHistory![0]),
|
||||
'returned',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteRevivalResume(
|
||||
{ ...returned.g, pendingRevival: pending(returned.receipt) },
|
||||
'finish',
|
||||
),
|
||||
RevivalResumeError,
|
||||
);
|
||||
const denied = fixture();
|
||||
denied.g.nexusEmperorSecretHistory![0] = settleEmperorNexusRevival(
|
||||
denied.g,
|
||||
json(denied.g.nexusEmperorSecretHistory![0]),
|
||||
'denied',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteRevivalResume(
|
||||
{ ...denied.g, pendingRevival: pending(denied.receipt) },
|
||||
'finish',
|
||||
),
|
||||
RevivalResumeError,
|
||||
);
|
||||
});
|
||||
|
||||
void test('ordinary benefit cancellations do not reach the independent extra source', () => {
|
||||
const { g, receipt } = fixture({ advanced: true, tleilaxu: true });
|
||||
for (const kind of ['revivalLimit', 'revivalDiscount'] as const) {
|
||||
const request = pending(receipt);
|
||||
const snapshot = json(g);
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteRevivalCancellation(
|
||||
{ ...g, pendingRevival: request },
|
||||
{ kind, owner: 't', recipient: 'p' },
|
||||
),
|
||||
RevivalCancellationError,
|
||||
);
|
||||
assert.deepEqual(json(g), snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
void test('ordinary free and paid requests keep their original continuation', () => {
|
||||
const { g } = fixture({ declared: false });
|
||||
const ordinary: PendingRevival = {
|
||||
player: 'p',
|
||||
kind: 'forces',
|
||||
amount: 3,
|
||||
elite: 0,
|
||||
free: 3,
|
||||
cost: 0,
|
||||
normalCost: 0,
|
||||
checks: [],
|
||||
};
|
||||
assert.equal(
|
||||
quoteRevivalResume({ ...g, pendingRevival: ordinary }, 'finish').outcome,
|
||||
'revive',
|
||||
);
|
||||
player(g, 'p').tanks = 2;
|
||||
assert.equal(
|
||||
quoteRevivalResume({ ...g, pendingRevival: ordinary }, 'finish').outcome,
|
||||
'fremenLimit',
|
||||
);
|
||||
});
|
||||
|
||||
type HomeworldFixture = {
|
||||
advanced: boolean;
|
||||
players: Player[];
|
||||
homeworlds: { custody: HomeworldCustody };
|
||||
};
|
||||
function homeworldFixture(
|
||||
faction: 'fremen' | 'tleilaxu',
|
||||
reserves: number,
|
||||
): HomeworldFixture {
|
||||
const players = [
|
||||
newPlayer('p', 'Native', faction),
|
||||
newPlayer('v', 'Visitor', 'harkonnen'),
|
||||
];
|
||||
players[0].reserves = reserves;
|
||||
if (faction === 'fremen')
|
||||
players[0].elites = { reserves: 0, tanks: 3, forces: {}, revived: 0 };
|
||||
const context = { advanced: true, players };
|
||||
return {
|
||||
...context,
|
||||
homeworlds: { custody: createHomeworldCustody(homeworldContext(context)) },
|
||||
};
|
||||
}
|
||||
function deposited(
|
||||
before: HomeworldFixture,
|
||||
group: HomeworldRevivalDeploymentGroup,
|
||||
) {
|
||||
const after = structuredClone(before);
|
||||
after.players[0].reserves += group.amount;
|
||||
if (after.players[0].elites) after.players[0].elites.reserves += group.elite;
|
||||
if (after.homeworlds.custody.salusa)
|
||||
after.homeworlds.custody.salusa.elite += group.elite;
|
||||
return after;
|
||||
}
|
||||
|
||||
void test('the typed Homeworld source accepts the canonical exact-three free group for Fremen', () => {
|
||||
const sources: HomeworldRevivalDeploymentSource[] = [
|
||||
'normal',
|
||||
'emperorExtra',
|
||||
'ghola',
|
||||
'nexusTleilaxu',
|
||||
'nexusEmperor',
|
||||
];
|
||||
for (const source of sources) {
|
||||
const before = homeworldFixture('fremen', 3);
|
||||
before.players[0].elites!.reserves = 1;
|
||||
const group = {
|
||||
amount: 3,
|
||||
elite: 2,
|
||||
free: source === 'normal' || source === 'nexusEmperor' ? 3 : 0,
|
||||
};
|
||||
const after = deposited(before, group);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldRevivalDeployment(before, after, 'p', source, group),
|
||||
{
|
||||
kind: 'fedaykin',
|
||||
normal: 0,
|
||||
elite: 2,
|
||||
beforePopulation: 3,
|
||||
afterPopulation: 6,
|
||||
blocked: null,
|
||||
},
|
||||
source,
|
||||
);
|
||||
}
|
||||
// Tleilax free-revival deployment is still ordinary Free Revival only: the
|
||||
// independent zero-spice source never fabricates a Tleilaxu free return.
|
||||
for (const source of ['nexusEmperor', 'emperorExtra', 'ghola'] as const) {
|
||||
const before = homeworldFixture('tleilaxu', 9);
|
||||
const group = {
|
||||
amount: 3,
|
||||
elite: 0,
|
||||
free: source === 'nexusEmperor' ? 3 : 0,
|
||||
};
|
||||
assert.equal(
|
||||
quoteHomeworldRevivalDeployment(
|
||||
before,
|
||||
deposited(before, group),
|
||||
'p',
|
||||
source,
|
||||
group,
|
||||
),
|
||||
null,
|
||||
source,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the Homeworld source rejects any non-canonical group and a mismatched frame immutably', () => {
|
||||
const before = homeworldFixture('fremen', 3);
|
||||
before.players[0].elites!.reserves = 1;
|
||||
const group = { amount: 3, elite: 2, free: 3 };
|
||||
const after = deposited(before, group);
|
||||
const snapshot = structuredClone({ before, after });
|
||||
for (const bad of [
|
||||
{ ...group, free: 2 },
|
||||
{ ...group, free: 0 },
|
||||
{ ...group, amount: 2 },
|
||||
]) {
|
||||
assert.throws(() =>
|
||||
quoteHomeworldRevivalDeployment(
|
||||
before,
|
||||
deposited(before, bad),
|
||||
'p',
|
||||
'nexusEmperor',
|
||||
bad,
|
||||
),
|
||||
);
|
||||
}
|
||||
assert.deepEqual({ before, after }, snapshot);
|
||||
|
||||
const quote = quoteHomeworldRevivalDeployment(
|
||||
before,
|
||||
after,
|
||||
'p',
|
||||
'nexusEmperor',
|
||||
group,
|
||||
)!;
|
||||
const original = {
|
||||
event: 'nx-emperor-return',
|
||||
turn: 2,
|
||||
phase: 4,
|
||||
player: 'p',
|
||||
source: 'nexusEmperor' as const,
|
||||
group,
|
||||
quote,
|
||||
};
|
||||
const frame = makeHomeworldRevivalReturn(original);
|
||||
const g = {
|
||||
...after,
|
||||
turn: 2,
|
||||
phase: 4,
|
||||
homeworldRevivalProgress: makeHomeworldRevivalProgress(frame),
|
||||
};
|
||||
validateHomeworldRevivalReturn(g, frame);
|
||||
validateHomeworldRevivalReturn(g, json(frame));
|
||||
for (const bad of [
|
||||
{ ...group, free: 2 },
|
||||
{ ...group, amount: 4 },
|
||||
]) {
|
||||
const tampered = { ...json(frame), group: bad } as typeof frame;
|
||||
assert.throws(() => validateHomeworldRevivalReturn(g, tampered));
|
||||
}
|
||||
assert.throws(() =>
|
||||
makeHomeworldRevivalReturn({
|
||||
...original,
|
||||
group: { amount: 3, elite: 0, free: 2 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
void test('the stored extra receipt round-trips as strict requested evidence', () => {
|
||||
const { g, receipt } = fixture();
|
||||
const copy = json(receipt) as NexusEmperorRevival;
|
||||
assert.deepEqual(copy, receipt);
|
||||
assert.equal(copy.version, 2);
|
||||
assert.equal(copy.stage, 'requested');
|
||||
assert.equal(copy.after, null);
|
||||
assert.equal(copy.signature, emperorNexusSignature(copy));
|
||||
assert.equal(g.nexusEmperorSecretEvents![0], receipt.event);
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
emperorNexusPools, validateEmperorNexusRevival,
|
||||
} from '../game/nexus-emperor-secret-ally';
|
||||
import { forceRevivalQuote, forceRevivalRemaining, revivalPrevented } from '../game/revival';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { homeworldRevivalDeploymentChoice } from '../game/homeworld-revival-deployment-options';
|
||||
import {
|
||||
advanceNexusEmperorExtra, createNexusEmperorExtraFixture, nexusEmperorExtraClean,
|
||||
nexusEmperorExtraInventory, nexusEmperorExtraPlayer as player, settleNexusEmperorExtra,
|
||||
} from './fixture-nexus-emperor-extra';
|
||||
|
||||
function grant(game: Game, owner: string, elite = 0): Action {
|
||||
const offer = viewGame(game, owner).nexusEmperorSecretAlly;
|
||||
assert.ok(offer);
|
||||
return { type: 'nexusEmperorRevive', event: offer.event, elite };
|
||||
}
|
||||
function receipt(game: Game, stage: 'requested' | 'returned' | 'denied') {
|
||||
assert.equal(game.nexusEmperorSecretHistory?.length, 1);
|
||||
const record = game.nexusEmperorSecretHistory![0];
|
||||
assert.ok('version' in record && record.version === 2);
|
||||
assert.equal(record.stage, stage);
|
||||
validateEmperorNexusRevival(game, record);
|
||||
return record;
|
||||
}
|
||||
function spent(game: Game, owner: string): void {
|
||||
assert.equal(game.nexusCards!.cards!.hands[owner], null);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
|
||||
assert.equal(viewGame(game, owner).nexusEmperorSecretAlly, null);
|
||||
nexusEmperorExtraInventory(game);
|
||||
}
|
||||
function rejected(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function allow(game: Game, native: string): Game {
|
||||
assert.equal(game.decision?.kind, 'revivalStop');
|
||||
assert.equal(game.decision!.player, native);
|
||||
return settleNexusEmperorExtra(applyAction(game, native, { type: 'decision', decline: true }));
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const band = advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${band} genuine Emperor grant returns exactly three without a leader, payment or ordinary quota`, () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced });
|
||||
const before = player(f.game, f.owner), beforePools = emperorNexusPools(before);
|
||||
const action = grant(f.game, f.owner);
|
||||
rejected(f.game, f.opponent, action);
|
||||
rejected(f.game, f.partner, action);
|
||||
rejected(f.game, f.owner, { type: 'nexusEmperorRevive', event: 'stale', elite: 0 });
|
||||
rejected(f.game, f.owner, { type: 'nexusEmperorRevive', event: f.event, elite: 1 });
|
||||
const returned = settleNexusEmperorExtra(applyAction(f.game, f.owner, action));
|
||||
const holder = player(returned, f.owner), record = receipt(returned, 'returned');
|
||||
assert.equal(record.event, f.event);
|
||||
assert.deepEqual(record.before, beforePools);
|
||||
assert.deepEqual(record.after, { ...beforePools, reserves: before.reserves + 3, tanks: before.tanks - 3 });
|
||||
assert.deepEqual(emperorNexusPools(holder), record.after);
|
||||
assert.deepEqual(holder.leaders, before.leaders);
|
||||
assert.equal(holder.leaderRevived, before.leaderRevived);
|
||||
assert.deepEqual(returned.emperorExtra, f.game.emperorExtra);
|
||||
assert.equal(returned.pendingRevival, null);
|
||||
spent(returned, f.owner);
|
||||
rejected(returned, f.owner, action);
|
||||
});
|
||||
|
||||
void test(`${band} extra-first preserves the full ordinary free/paid quote and real phase continuation`, () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced });
|
||||
const quote = forceRevivalQuote(f.game, player(f.game, f.owner), 3);
|
||||
const extra = settleNexusEmperorExtra(applyAction(f.game, f.owner, grant(f.game, f.owner)));
|
||||
assert.deepEqual(forceRevivalQuote(extra, player(extra, f.owner), 3), quote);
|
||||
assert.equal(forceRevivalRemaining(extra, player(extra, f.owner)), 3);
|
||||
const ordinary = settleNexusEmperorExtra(applyAction(extra, f.owner, { type: 'revive', amount: 3 }));
|
||||
const holder = player(ordinary, f.owner);
|
||||
assert.equal(holder.reserves, player(f.game, f.owner).reserves + 6);
|
||||
assert.equal(holder.tanks, player(f.game, f.owner).tanks - 6);
|
||||
assert.equal(holder.revived, 3);
|
||||
assert.equal(holder.freeForcesRevived, quote.free);
|
||||
assert.equal(holder.spice, player(extra, f.owner).spice - quote.cost);
|
||||
rejected(ordinary, f.owner, { type: 'revive', amount: 1 });
|
||||
receipt(ordinary, 'returned');
|
||||
const next = advanceNexusEmperorExtra(ordinary, state => state.turn === 3 && state.phase === 4 && nexusEmperorExtraClean(state));
|
||||
assert.equal(player(next, f.owner).tanks, holder.tanks);
|
||||
assert.equal(player(next, f.owner).revived, 0);
|
||||
assert.equal(player(next, f.owner).freeForcesRevived, 0);
|
||||
receipt(next, 'returned');
|
||||
const nextQuote = forceRevivalQuote(next, player(next, f.owner), 2);
|
||||
const later = settleNexusEmperorExtra(applyAction(next, f.owner, { type: 'revive', amount: 2 }));
|
||||
assert.equal(player(later, f.owner).tanks, 0);
|
||||
assert.equal(player(later, f.owner).spice, player(next, f.owner).spice - nextQuote.cost);
|
||||
spent(later, f.owner);
|
||||
});
|
||||
|
||||
void test(`${band} three prior ordinary counters do not reduce the independent printed three grant`, () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced });
|
||||
const ordinary = settleNexusEmperorExtra(applyAction(f.game, f.owner, { type: 'revive', amount: 3 }));
|
||||
assert.equal(forceRevivalRemaining(ordinary, player(ordinary, f.owner)), 0);
|
||||
const before = emperorNexusPools(player(ordinary, f.owner));
|
||||
const extra = settleNexusEmperorExtra(applyAction(ordinary, f.owner, grant(ordinary, f.owner)));
|
||||
assert.deepEqual(emperorNexusPools(player(extra, f.owner)), { ...before, reserves: before.reserves + 3, tanks: before.tanks - 3 });
|
||||
assert.equal(player(extra, f.owner).revived, 3);
|
||||
assert.deepEqual(extra.emperorExtra, ordinary.emperorExtra);
|
||||
receipt(extra, 'returned');
|
||||
rejected(extra, f.owner, { type: 'revive', amount: 1 });
|
||||
spent(extra, f.owner);
|
||||
});
|
||||
|
||||
void test(`${band} genuine single-E1 Ix counters retain native typed custody and available-count caps`, () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced, profile: 'ixians' });
|
||||
const ordinary = settleNexusEmperorExtra(applyAction(f.game, f.owner, { type: 'revive', amount: 1, elite: 1 }));
|
||||
const before = player(ordinary, f.owner), beforePools = emperorNexusPools(before);
|
||||
assert.equal(before.elites!.tanks, 2);
|
||||
rejected(ordinary, f.owner, grant(ordinary, f.owner, 3));
|
||||
const extra = settleNexusEmperorExtra(applyAction(ordinary, f.owner, grant(ordinary, f.owner, 2)));
|
||||
assert.deepEqual(emperorNexusPools(player(extra, f.owner)), {
|
||||
...beforePools, reserves: before.reserves + 3, tanks: before.tanks - 3,
|
||||
eliteReserves: before.elites!.reserves + 2, eliteTanks: 0, eliteRevived: before.elites!.revived + 2,
|
||||
});
|
||||
assert.equal(player(extra, f.owner).revived, 1);
|
||||
assert.equal(player(extra, f.owner).elites!.revived, 3, 'Ix follows its native count cap, not the Advanced Fedaykin cap');
|
||||
assert.equal(forceRevivalRemaining(extra, player(extra, f.owner)), 2);
|
||||
const continued = settleNexusEmperorExtra(applyAction(extra, f.owner, { type: 'revive', amount: 1, elite: 0 }));
|
||||
assert.equal(player(continued, f.owner).revived, 2);
|
||||
assert.equal(player(continued, f.owner).tanks, 3);
|
||||
receipt(continued, 'returned');
|
||||
spent(continued, f.owner);
|
||||
});
|
||||
}
|
||||
|
||||
void test('native Tleilaxu Allow waits for original physical return, awards free income once, and triggers real Axlotl custody', () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced: true, profile: 'tleilaxu', tech: true });
|
||||
const native = f.partner, beforePools = emperorNexusPools(player(f.game, f.owner));
|
||||
const nativeSpice = player(f.game, native).spice;
|
||||
const declared = applyAction(f.game, f.owner, grant(f.game, f.owner));
|
||||
const requested = receipt(declared, 'requested');
|
||||
assert.deepEqual(requested.before, beforePools);
|
||||
assert.equal(requested.after, null);
|
||||
assert.deepEqual(emperorNexusPools(player(declared, f.owner)), beforePools);
|
||||
assert.equal(player(declared, native).spice, nativeSpice);
|
||||
assert.deepEqual(declared.techTokens, f.game.techTokens);
|
||||
spent(declared, f.owner);
|
||||
const returned = allow(declared, native);
|
||||
receipt(returned, 'returned');
|
||||
assert.equal(player(returned, f.owner).reserves, beforePools.reserves + 3);
|
||||
assert.equal(player(returned, f.owner).tanks, beforePools.tanks - 3);
|
||||
assert.equal(player(returned, native).spice, nativeSpice + 1);
|
||||
assert.equal(returned.revivalFreeIncome![f.owner], returned.turn);
|
||||
assert.equal(returned.techTokens!.axlotl.triggeredTurn, returned.turn);
|
||||
assert.equal(returned.techTokens!.axlotl.spice, ownedTech(returned.techTokens!, returned.techTokens!.axlotl.owner!).length);
|
||||
const tokens = structuredClone(returned.techTokens);
|
||||
const ordinary = allow(applyAction(returned, f.owner, { type: 'revive', amount: 2 }), native);
|
||||
assert.equal(player(ordinary, native).spice, nativeSpice + 1, 'Later ordinary free returns cannot award a second bank income');
|
||||
assert.deepEqual(ordinary.techTokens, tokens, 'The same physical Axlotl token does not accrue twice this turn');
|
||||
assert.equal(player(ordinary, f.owner).revived, 2);
|
||||
assert.equal(player(ordinary, f.owner).freeForcesRevived, 2);
|
||||
assert.equal(player(ordinary, f.owner).tanks, 3);
|
||||
receipt(ordinary, 'returned');
|
||||
spent(ordinary, f.owner);
|
||||
});
|
||||
|
||||
void test('native Tleilaxu prior free return earns once; the later extra source does not earn or consume ordinary quota again', () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced: true, profile: 'tleilaxu' });
|
||||
const ordinary = allow(applyAction(f.game, f.owner, { type: 'revive', amount: 2 }), f.partner);
|
||||
const nativeSpice = player(ordinary, f.partner).spice, before = emperorNexusPools(player(ordinary, f.owner));
|
||||
const declared = applyAction(ordinary, f.owner, grant(ordinary, f.owner));
|
||||
receipt(declared, 'requested');
|
||||
const extra = allow(declared, f.partner);
|
||||
assert.deepEqual(emperorNexusPools(player(extra, f.owner)), { ...before, reserves: before.reserves + 3, tanks: before.tanks - 3 });
|
||||
assert.equal(player(extra, f.partner).spice, nativeSpice);
|
||||
assert.equal(forceRevivalRemaining(extra, player(extra, f.owner)), 1);
|
||||
const price = forceRevivalQuote(extra, player(extra, f.owner), 1);
|
||||
assert.equal(price.free, 0);
|
||||
const paid = allow(applyAction(extra, f.owner, { type: 'revive', amount: 1 }), f.partner);
|
||||
assert.equal(player(paid, f.owner).spice, player(extra, f.owner).spice - price.cost);
|
||||
assert.equal(player(paid, f.partner).spice, nativeSpice + price.cost);
|
||||
assert.equal(player(paid, f.owner).revived, 3);
|
||||
assert.equal(player(paid, f.owner).freeForcesRevived, 2);
|
||||
receipt(paid, 'returned');
|
||||
spent(paid, f.owner);
|
||||
});
|
||||
|
||||
void test('native Tleilaxu Prevent spends the actual two cards with no Nexus refund, force return, Tech accrual or income', () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced: true, profile: 'tleilaxu', tech: true });
|
||||
const before = emperorNexusPools(player(f.game, f.owner)), nativeSpice = player(f.game, f.partner).spice;
|
||||
const karama = player(f.game, f.partner).hand.find(card => card.effect === 'karama');
|
||||
assert.ok(karama);
|
||||
const declared = applyAction(f.game, f.owner, grant(f.game, f.owner));
|
||||
receipt(declared, 'requested');
|
||||
const denied = settleNexusEmperorExtra(applyAction(declared, f.partner, { type: 'card', card: karama.id, mode: 'special' }));
|
||||
const record = receipt(denied, 'denied');
|
||||
assert.deepEqual(record.after, before);
|
||||
assert.deepEqual(emperorNexusPools(player(denied, f.owner)), before);
|
||||
assert.equal(player(denied, f.partner).spice, nativeSpice);
|
||||
assert.equal(player(denied, f.partner).specialKaramaUsed, true);
|
||||
assert.equal(denied.discard.filter(card => card.id === karama.id).length, 1);
|
||||
assert.ok(!player(denied, f.partner).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(denied.techTokens, f.game.techTokens);
|
||||
assert.deepEqual(denied.revivalFreeIncome, f.game.revivalFreeIncome);
|
||||
assert.deepEqual(denied.emperorExtra, f.game.emperorExtra);
|
||||
assert.equal(denied.pendingRevival, null);
|
||||
assert.equal(denied.decision, null);
|
||||
assert.equal(denied.response, null);
|
||||
assert.ok(revivalPrevented(denied, f.owner));
|
||||
rejected(denied, f.owner, { type: 'revive', amount: 1 });
|
||||
spent(denied, f.owner);
|
||||
const next = advanceNexusEmperorExtra(denied, state => state.turn === 3 && state.phase === 4 && nexusEmperorExtraClean(state));
|
||||
assert.equal(player(next, f.owner).tanks, before.tanks);
|
||||
assert.equal(revivalPrevented(next, f.owner), false);
|
||||
receipt(next, 'denied');
|
||||
spent(next, f.owner);
|
||||
});
|
||||
|
||||
void test('Basic native Tleilaxu returns the extra source without an Advanced stop but keeps real once-only free income', () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced: false, profile: 'tleilaxu' });
|
||||
const nativeSpice = player(f.game, f.partner).spice;
|
||||
const declared = applyAction(f.game, f.owner, grant(f.game, f.owner));
|
||||
assert.notEqual(declared.decision?.kind, 'revivalStop');
|
||||
const extra = settleNexusEmperorExtra(declared);
|
||||
receipt(extra, 'returned');
|
||||
assert.equal(player(extra, f.owner).tanks, 5);
|
||||
assert.equal(player(extra, f.owner).revived, 0);
|
||||
assert.equal(player(extra, f.owner).freeForcesRevived, 0);
|
||||
assert.equal(player(extra, f.partner).spice, nativeSpice + 1);
|
||||
const ordinary = settleNexusEmperorExtra(applyAction(extra, f.owner, { type: 'revive', amount: 2 }));
|
||||
assert.equal(player(ordinary, f.partner).spice, nativeSpice + 1);
|
||||
assert.equal(player(ordinary, f.owner).revived, 2);
|
||||
assert.equal(player(ordinary, f.owner).freeForcesRevived, 2);
|
||||
receipt(ordinary, 'returned');
|
||||
spent(ordinary, f.owner);
|
||||
});
|
||||
|
||||
void test('the saved native stop cannot replay a different or repriced independent source', () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced: true, profile: 'tleilaxu' });
|
||||
const requested = applyAction(f.game, f.owner, grant(f.game, f.owner));
|
||||
for (const mutate of [
|
||||
(game: Game) => { game.pendingRevival!.amount = 4; },
|
||||
(game: Game) => { game.pendingRevival!.cost = 1; },
|
||||
(game: Game) => { game.pendingRevival!.nexusEmperorEvent = 'different-source'; },
|
||||
(game: Game) => { game.nexusEmperorSecretHistory![0].signature = 'unsigned'; },
|
||||
]) {
|
||||
const changed = structuredClone(requested);
|
||||
mutate(changed);
|
||||
rejected(changed, f.partner, { type: 'decision', decline: true });
|
||||
}
|
||||
assert.equal(player(requested, f.owner).tanks, 8);
|
||||
receipt(requested, 'requested');
|
||||
spent(requested, f.owner);
|
||||
});
|
||||
|
||||
void test('original bot policy applies a legal independent Emperor grant on the genuine native chronology', () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced: true });
|
||||
const view = viewGame(f.game, f.owner);
|
||||
view.players.find(seat => seat.id === f.owner)!.bot = 'Easy';
|
||||
const [action] = botActions(view);
|
||||
assert.ok(action);
|
||||
const returned = settleNexusEmperorExtra(applyAction(f.game, f.owner, action));
|
||||
receipt(returned, 'returned');
|
||||
assert.equal(player(returned, f.owner).tanks, player(f.game, f.owner).tanks - 3);
|
||||
assert.equal(player(returned, f.owner).revived, 0);
|
||||
spent(returned, f.owner);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} actual Nexus-returned Fedaykin deploys once through its original Homeworld continuation`, () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced, profile: 'fremen', homeworlds: true });
|
||||
const before = structuredClone(player(f.game, f.owner));
|
||||
const nativeBefore = player(f.game, f.partner).spice;
|
||||
let game = applyAction(f.game, f.owner, grant(f.game, f.owner, 1));
|
||||
if (game.decision?.kind === 'revivalStop')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
assert.equal(player(game, f.owner).tanks, before.tanks - 3);
|
||||
assert.equal(player(game, f.owner).reserves, before.reserves + 3);
|
||||
assert.equal(player(game, f.owner).elites!.tanks, 0);
|
||||
assert.equal(player(game, f.owner).elites!.revived, 1);
|
||||
assert.equal(player(game, f.owner).revived, 0);
|
||||
assert.equal(player(game, f.owner).freeForcesRevived, 0);
|
||||
receipt(game, 'returned');
|
||||
const view = viewGame(game, f.owner);
|
||||
const target = view.homeworldRevivalDeployment?.destinations.find(option => !option.blocked);
|
||||
assert.ok(target);
|
||||
const action = homeworldRevivalDeploymentChoice(view, target.id).action;
|
||||
assert.ok(action);
|
||||
const originalStars = player(game, f.owner).elites!.forces[target.id] ?? 0;
|
||||
const placed = settleNexusEmperorExtra(applyAction(game, f.owner, action));
|
||||
assert.equal(player(placed, f.owner).elites!.forces[target.id], originalStars + 1);
|
||||
assert.equal(player(placed, f.owner).reserves, before.reserves + 2);
|
||||
assert.equal(player(placed, f.owner).spice, before.spice);
|
||||
assert.equal(player(placed, f.owner).revived, 0);
|
||||
assert.equal(player(placed, f.owner).freeForcesRevived, 0);
|
||||
assert.equal(player(placed, f.partner).spice, nativeBefore + 1);
|
||||
receipt(placed, 'returned');
|
||||
rejected(placed, f.owner, action);
|
||||
spent(placed, f.owner);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import { createNexusCards, discardNexusCard } from '../game/nexus-cards';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||
import { createLeaderSkills } from '../game/leader-skills';
|
||||
import { createHomeworldCustody } from '../game/homeworld-custody';
|
||||
import {
|
||||
createEmperorNexusRevival,
|
||||
emperorNexusEvent,
|
||||
emperorNexusModeSupported,
|
||||
emperorNexusPools,
|
||||
emperorNexusSignature,
|
||||
quoteEmperorNexusRevival,
|
||||
settleEmperorNexusRevival,
|
||||
validateEmperorNexusRevival,
|
||||
type NexusEmperorRevival,
|
||||
type NexusEmperorRevivalRequest,
|
||||
} from '../game/nexus-emperor-secret-ally';
|
||||
import { newRevivalRules, normalRevivalAllowance } from '../game/revival';
|
||||
|
||||
/** Focused unit state, not a genuine game trace or runtime admission fixture. */
|
||||
function sample(faction: FactionId = 'atreides', advanced = true, nativeTleilaxu = false) {
|
||||
const g = createGame('EXTRASOURCE', newPlayer('owner', 'Holder', faction), advanced);
|
||||
const rival: FactionId = faction === 'atreides' ? 'harkonnen' : 'atreides';
|
||||
g.players.push(newPlayer('rival', 'Rival', rival));
|
||||
g.players.push(newPlayer('third', 'Third', nativeTleilaxu ? 'tleilaxu' : 'guild'));
|
||||
g.expansions = [...new Set(g.players.map(p => FACTIONS.find(f => f.id === p.faction)!.expansion)
|
||||
.filter(expansion => expansion !== 'base'))];
|
||||
g.status = 'playing';
|
||||
g.phase = 4;
|
||||
g.turn = 3;
|
||||
g.revivalRules = newRevivalRules();
|
||||
const p = g.players[0];
|
||||
p.tanks = 6;
|
||||
p.reserves -= 6;
|
||||
if (faction === 'fremen' && advanced)
|
||||
p.elites = { reserves: 1, tanks: 2, forces: {}, revived: 0 };
|
||||
if (faction === 'ixians') {
|
||||
p.elites!.reserves = 4;
|
||||
p.elites!.tanks = 3;
|
||||
}
|
||||
const cards = createNexusCards(g.players, () => 0.5);
|
||||
cards.deck = cards.deck.filter(card => card !== 'emperor');
|
||||
cards.hands.owner = 'emperor';
|
||||
g.nexusCards = { cards, phase: null };
|
||||
return g;
|
||||
}
|
||||
|
||||
function commit(g: Game, receipt: NexusEmperorRevivalRequest) {
|
||||
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, receipt.owner, g.players);
|
||||
(g.nexusEmperorSecretHistory ??= []).push(receipt);
|
||||
(g.nexusEmperorSecretEvents ??= []).push(receipt.event);
|
||||
}
|
||||
|
||||
/** Model the native physical producer before income and optional deployment. */
|
||||
function physicalReturn(g: Game, receipt: NexusEmperorRevivalRequest) {
|
||||
const p = g.players.find(p => p.id === receipt.owner)!;
|
||||
p.reserves += 3;
|
||||
p.tanks -= 3;
|
||||
if (p.elites) {
|
||||
p.elites.reserves += receipt.elite;
|
||||
p.elites.tanks -= receipt.elite;
|
||||
p.elites.revived += receipt.elite;
|
||||
}
|
||||
}
|
||||
|
||||
function signed<T extends NexusEmperorRevival>(receipt: T): T {
|
||||
receipt.signature = emperorNexusSignature(receipt);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
void test('declaration is a pure signed exact-three source and cannot settle before custody is committed', () => {
|
||||
const g = sample();
|
||||
const before = structuredClone(g);
|
||||
const quote = quoteEmperorNexusRevival(g, 'owner');
|
||||
assert.equal(quote.blocked, null);
|
||||
assert.deepEqual(quote.eliteOptions, [0]);
|
||||
const receipt = createEmperorNexusRevival(g, 'owner', 0);
|
||||
assert.equal(receipt.version, 2);
|
||||
assert.equal(receipt.stage, 'requested');
|
||||
assert.equal(receipt.after, null);
|
||||
assert.equal(receipt.event, quote.event);
|
||||
assert.equal(receipt.sequence, 0);
|
||||
assert.equal(receipt.signature, emperorNexusSignature(receipt));
|
||||
assert.deepEqual(receipt.before, emperorNexusPools(g.players[0]));
|
||||
validateEmperorNexusRevival(g, receipt);
|
||||
assert.deepEqual(g, before);
|
||||
assert.throws(() => settleEmperorNexusRevival(g, receipt, 'returned'));
|
||||
assert.throws(() => createEmperorNexusRevival(g, 'owner', 1));
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('native typed Tanks determine exact-three choices and Ix Cyborgs are not capped at one', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const ix = sample('ixians', advanced);
|
||||
ix.players[0].elites!.revived = 4;
|
||||
assert.equal(emperorNexusModeSupported(ix), false);
|
||||
assert.equal(quoteEmperorNexusRevival(ix, 'owner').blocked, null);
|
||||
assert.deepEqual(quoteEmperorNexusRevival(ix, 'owner').eliteOptions, [0, 1, 2, 3]);
|
||||
assert.equal(createEmperorNexusRevival(ix, 'owner', 3).elite, 3);
|
||||
}
|
||||
const basicFremen = sample('fremen', false);
|
||||
basicFremen.players[0].elites = { reserves: 0, tanks: 3, forces: {}, revived: 2 };
|
||||
assert.deepEqual(quoteEmperorNexusRevival(basicFremen, 'owner').eliteOptions, [0, 1, 2, 3]);
|
||||
assert.equal(createEmperorNexusRevival(basicFremen, 'owner', 3).elite, 3);
|
||||
const fremen = sample('fremen');
|
||||
assert.deepEqual(quoteEmperorNexusRevival(fremen, 'owner').eliteOptions, [0, 1]);
|
||||
fremen.players[0].elites!.revived = 1;
|
||||
assert.deepEqual(quoteEmperorNexusRevival(fremen, 'owner').eliteOptions, [0]);
|
||||
assert.throws(() => createEmperorNexusRevival(fremen, 'owner', 1));
|
||||
fremen.players[0].tanks = 3;
|
||||
assert.deepEqual(quoteEmperorNexusRevival(fremen, 'owner').eliteOptions, []);
|
||||
assert.notEqual(quoteEmperorNexusRevival(fremen, 'owner').blocked, null);
|
||||
const short = sample();
|
||||
short.players[0].tanks = 2;
|
||||
assert.deepEqual(quoteEmperorNexusRevival(short, 'owner').eliteOptions, []);
|
||||
assert.throws(() => createEmperorNexusRevival(short, 'owner', 0));
|
||||
});
|
||||
|
||||
void test('original native modules allow revival independently of classic purchase admission', () => {
|
||||
for (const faction of ['atreides', 'ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const) {
|
||||
const g = sample(faction);
|
||||
g.techTokens = createTechTokens();
|
||||
g.strongholdCards = createStrongholdCards();
|
||||
assert.equal(quoteEmperorNexusRevival(g, 'owner').blocked, null, faction);
|
||||
const before = structuredClone(g);
|
||||
createEmperorNexusRevival(g, 'owner', 0);
|
||||
assert.deepEqual(g, before);
|
||||
if (faction !== 'atreides') assert.equal(emperorNexusModeSupported(g), false);
|
||||
g.homeworlds = { custody: createHomeworldCustody({
|
||||
advanced: g.advanced,
|
||||
players: g.players.map(p => ({
|
||||
id: p.id, faction: p.faction, reserves: p.reserves,
|
||||
eliteReserves: p.elites?.reserves ?? 0,
|
||||
})),
|
||||
}) };
|
||||
assert.equal(quoteEmperorNexusRevival(g, 'owner').blocked, null, `${faction} with Homeworlds`);
|
||||
g.discoveryEnabled = true;
|
||||
assert.equal(quoteEmperorNexusRevival(g, 'owner').blocked, null, `${faction} with Discovery`);
|
||||
g.leaderSkills = createLeaderSkills(() => 0.5);
|
||||
assert.equal(quoteEmperorNexusRevival(g, 'owner').blocked, null, `${faction} with Skills`);
|
||||
}
|
||||
});
|
||||
|
||||
void test('ordinary high rates, expanded ceilings, Recruits and ordinary free blocking do not reprice the grant', () => {
|
||||
for (const faction of ['atreides', 'fremen', 'choam'] as const) {
|
||||
for (const blocked of [false, true]) {
|
||||
const g = sample(faction);
|
||||
g.revivalRules!.expanded.push('owner');
|
||||
g.freeRevival.push('owner');
|
||||
g.recruits = { turn: g.turn, player: 'owner', card: 'ecaz-recruits' };
|
||||
if (blocked) g.revivalRules!.freeBlocked = ['owner'];
|
||||
g.players[0].revived = 5;
|
||||
g.players[0].freeForcesRevived = 3;
|
||||
const allowance = normalRevivalAllowance(g, g.players[0]);
|
||||
const rules = structuredClone(g.revivalRules);
|
||||
const receipt = createEmperorNexusRevival(g, 'owner', 0);
|
||||
commit(g, receipt);
|
||||
physicalReturn(g, receipt);
|
||||
const returned = settleEmperorNexusRevival(g, receipt, 'returned');
|
||||
assert.equal(returned.stage, 'returned');
|
||||
assert.deepEqual(returned.after, { ...receipt.before,
|
||||
reserves: receipt.before.reserves + 3, tanks: receipt.before.tanks - 3 });
|
||||
assert.deepEqual(normalRevivalAllowance(g, g.players[0]), allowance);
|
||||
assert.deepEqual(g.revivalRules, rules);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('native prevention remains distinct from ordinary free blocking and source timing/custody stay strict', () => {
|
||||
const original = sample('atreides', true, true);
|
||||
original.revivalRules!.freeBlocked = ['owner'];
|
||||
assert.equal(quoteEmperorNexusRevival(original, 'owner').blocked, null);
|
||||
original.revivalPrevention = { player: 'owner', turn: original.turn };
|
||||
assert.notEqual(quoteEmperorNexusRevival(original, 'owner').blocked, null);
|
||||
assert.throws(() => createEmperorNexusRevival(original, 'owner', 0));
|
||||
for (const change of [
|
||||
(g: Game) => { g.nexusCards!.cards!.hands.owner = null; },
|
||||
(g: Game) => { g.players[0].ally = 'rival'; },
|
||||
(g: Game) => { g.players.push(newPlayer('emperor', 'Native', 'emperor')); },
|
||||
(g: Game) => { g.phase = 5; },
|
||||
(g: Game) => { g.status = 'setup'; },
|
||||
(g: Game) => { g.pendingCapture = { player: 'owner', loser: 'rival', territory: 'arrakeen' }; },
|
||||
]) {
|
||||
const g = sample();
|
||||
change(g);
|
||||
assert.notEqual(quoteEmperorNexusRevival(g, 'owner').blocked, null);
|
||||
assert.throws(() => createEmperorNexusRevival(g, 'owner', 0));
|
||||
}
|
||||
assert.notEqual(quoteEmperorNexusRevival(sample(), 'unknown').blocked, null);
|
||||
});
|
||||
|
||||
void test('returned and denied receipts capture actual typed physical facts without moving or refunding anything', () => {
|
||||
for (const outcome of ['returned', 'denied'] as const) {
|
||||
const g = sample('ixians', true, true);
|
||||
const receipt = createEmperorNexusRevival(g, 'owner', 3);
|
||||
commit(g, receipt);
|
||||
if (outcome === 'returned') physicalReturn(g, receipt);
|
||||
const beforeSettlement = structuredClone(g);
|
||||
const result = settleEmperorNexusRevival(g, receipt, outcome);
|
||||
assert.equal(result.stage, outcome);
|
||||
assert.equal(result.signature, emperorNexusSignature(result));
|
||||
assert.deepEqual(result.after, emperorNexusPools(g.players[0]));
|
||||
assert.deepEqual(g, beforeSettlement);
|
||||
assert.equal(receipt.stage, 'requested');
|
||||
assert.equal(receipt.after, null);
|
||||
assert.equal(g.nexusCards!.cards!.hands.owner, null);
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
|
||||
if (outcome === 'denied') assert.deepEqual(result.after, receipt.before);
|
||||
else assert.deepEqual(result.after, { ...receipt.before,
|
||||
reserves: receipt.before.reserves + 3, tanks: receipt.before.tanks - 3,
|
||||
eliteReserves: receipt.before.eliteReserves + 3, eliteTanks: receipt.before.eliteTanks - 3,
|
||||
eliteRevived: receipt.before.eliteRevived + 3 });
|
||||
assert.throws(() => settleEmperorNexusRevival(g, result, outcome));
|
||||
// Historical evidence survives later ordinary ledger, wallet and custody changes.
|
||||
g.turn++;
|
||||
g.phase = 5;
|
||||
g.players[0].spice += 10;
|
||||
g.players[0].revived += 2;
|
||||
g.players[0].freeForcesRevived = 2;
|
||||
g.players[0].reserves--;
|
||||
validateEmperorNexusRevival(g, result);
|
||||
}
|
||||
});
|
||||
|
||||
void test('settlement rejects wrong actual amount, typed composition, ordinary ledgers and premature income', () => {
|
||||
for (const change of [
|
||||
(g: Game) => { g.players[0].reserves--; g.players[0].tanks++; },
|
||||
(g: Game) => { g.players[0].elites!.reserves--; g.players[0].elites!.tanks++; },
|
||||
(g: Game) => { g.players[0].elites!.revived--; },
|
||||
(g: Game) => { g.players[0].revived++; },
|
||||
(g: Game) => { g.players[0].freeForcesRevived = 1; },
|
||||
(g: Game) => { g.players[0].spice++; },
|
||||
]) {
|
||||
const g = sample('ixians');
|
||||
const receipt = createEmperorNexusRevival(g, 'owner', 3);
|
||||
commit(g, receipt);
|
||||
physicalReturn(g, receipt);
|
||||
change(g);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => settleEmperorNexusRevival(g, receipt, 'returned'));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
const g = sample();
|
||||
const receipt = createEmperorNexusRevival(g, 'owner', 0);
|
||||
commit(g, receipt);
|
||||
physicalReturn(g, receipt);
|
||||
assert.throws(() => settleEmperorNexusRevival(g, receipt, 'denied'));
|
||||
});
|
||||
|
||||
void test('receipt stage, signature, independent sequence and event markers cannot be fabricated or silently repaired', () => {
|
||||
const g = sample();
|
||||
const receipt = createEmperorNexusRevival(g, 'owner', 0);
|
||||
commit(g, receipt);
|
||||
for (const change of [
|
||||
(r: NexusEmperorRevivalRequest) => { r.signature = 'unsigned'; },
|
||||
(r: NexusEmperorRevivalRequest) => { r.sequence++; r.signature = emperorNexusSignature(r); },
|
||||
(r: NexusEmperorRevivalRequest) => { r.event = emperorNexusEvent(r.turn, 4, 'rival', r.sequence); r.signature = emperorNexusSignature(r); },
|
||||
]) {
|
||||
const bad = structuredClone(receipt);
|
||||
change(bad);
|
||||
assert.throws(() => validateEmperorNexusRevival(g, bad));
|
||||
}
|
||||
const withAfter = signed({ ...receipt, after: receipt.before } as unknown as NexusEmperorRevivalRequest);
|
||||
assert.throws(() => validateEmperorNexusRevival(g, withAfter));
|
||||
const finalWithoutAfter = signed({ ...receipt, stage: 'returned', after: null } as unknown as NexusEmperorRevival);
|
||||
assert.throws(() => validateEmperorNexusRevival(g, finalWithoutAfter));
|
||||
for (const change of [
|
||||
(state: Game) => { state.nexusEmperorSecretEvents![0] = 'wrong'; },
|
||||
(state: Game) => { state.nexusEmperorSecretHistory![0] = signed({ ...receipt, before: { ...receipt.before, spice: 999 } }); },
|
||||
(state: Game) => { state.nexusCards!.cards!.discard = []; },
|
||||
]) {
|
||||
const bad = structuredClone(g);
|
||||
change(bad);
|
||||
assert.throws(() => settleEmperorNexusRevival(bad, receipt, 'denied'));
|
||||
}
|
||||
});
|
||||
|
||||
void test('strict old physical receipts stay readable as history, never become expansion source permissions', () => {
|
||||
const g = sample();
|
||||
const receipt = createEmperorNexusRevival(g, 'owner', 0);
|
||||
const { version: _version, stage: _stage, ...identity } = receipt;
|
||||
const old: NexusEmperorRevival = signed({ ...identity, after: {
|
||||
...receipt.before, reserves: receipt.before.reserves + 3, tanks: receipt.before.tanks - 3,
|
||||
} });
|
||||
const before = structuredClone(g);
|
||||
validateEmperorNexusRevival(g, old);
|
||||
assert.deepEqual(g, before);
|
||||
assert.throws(() => settleEmperorNexusRevival(g, old, 'returned'));
|
||||
g.players[0].spice += 5;
|
||||
g.players[0].revived = 3;
|
||||
g.turn++;
|
||||
validateEmperorNexusRevival(g, old);
|
||||
const badLedger = signed({ ...old, after: { ...old.after!, revived: old.before.revived + 3 } });
|
||||
assert.throws(() => validateEmperorNexusRevival(g, badLedger));
|
||||
const expanded = sample('ixians');
|
||||
const fabricated = signed({ ...old, faction: 'ixians' as const });
|
||||
assert.throws(() => validateEmperorNexusRevival(expanded, fabricated));
|
||||
const metadata = signed({ ...old, version: 1 } as unknown as NexusEmperorRevival);
|
||||
assert.throws(() => validateEmperorNexusRevival(g, metadata));
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { NexusEmperorSecretAlly } from '../components/nexus-emperor-secret-ally';
|
||||
import type { GameView } from '../game/engine';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
function view(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
me: 'a',
|
||||
players: [
|
||||
{ id: 'a', name: 'House A', faction: 'atreides', ally: null },
|
||||
{ id: 'b', name: 'House B', faction: 'harkonnen', ally: null },
|
||||
],
|
||||
status: 'playing',
|
||||
phase: 4,
|
||||
response: null,
|
||||
truthtrance: null,
|
||||
phaseOpening: null,
|
||||
automaticContinuationPending: false,
|
||||
nexusCards: { card: 'emperor', waiting: [] },
|
||||
nexusTraitors: { pending: null },
|
||||
nexusEmperorSecretAlly: {
|
||||
event: 'emperor-1',
|
||||
revival: { blocked: null, eliteOptions: [0, 1] },
|
||||
purchase: null,
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(NexusEmperorSecretAlly, { game, act() {}, busy }));
|
||||
}
|
||||
|
||||
void test('eligible private Emperor card shows revival explanation and legal elite selector', () => {
|
||||
const html = markup(view());
|
||||
assert.match(html, /revive 3 additional forces for free/);
|
||||
assert.match(html, /Elite forces to revive/);
|
||||
assert.match(html, /value="0"/);
|
||||
assert.match(html, /value="1"/);
|
||||
assert.match(html, /Revive 3 additional forces/);
|
||||
});
|
||||
|
||||
void test('stale, nonowner, blocked and busy states stay guarded', () => {
|
||||
assert.equal(markup(view({ phase: 5 })), '');
|
||||
assert.equal(markup(view({ me: 'b', nexusEmperorSecretAlly: null })), '');
|
||||
assert.equal(markup(view({ nexusCards: { card: 'emperor', waiting: ['b'] } })), '');
|
||||
const blocked = markup(view({ nexusEmperorSecretAlly: {
|
||||
event: 'emperor-1', revival: { blocked: 'Elite revival is unavailable.', eliteOptions: [] }, purchase: null,
|
||||
} }));
|
||||
assert.match(blocked, /Elite revival is unavailable/);
|
||||
assert.match(blocked, /disabled=""/);
|
||||
assert.match(markup(view({ nexusEmperorSecretAlly: {
|
||||
event: 'emperor-1', revival: { blocked: null, eliteOptions: [1] }, purchase: null,
|
||||
} }), true), /disabled=""/);
|
||||
});
|
||||
|
||||
aliases.deregister();
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
emperorNexusPhysical,
|
||||
} from './nexus-emperor-secret-ally-fixture';
|
||||
import { nexusReject, nexusReload, nexusReady } from './fixture-nexus-cards';
|
||||
import { nexusTraitorFixture } from './fixture-nexus-traitors';
|
||||
|
||||
void test('a later real Ghola return and phase advance preserve historical Nexus proof without replaying its physical grant', () => {
|
||||
const f = nexusEmperorSecretAllyFixture();
|
||||
@@ -226,43 +225,6 @@ void test('ordinary Fremen aid to the other allied seat does not suppress this u
|
||||
assert.deepEqual(done.freeRevival, granted.freeRevival);
|
||||
});
|
||||
|
||||
void test('combined modules and applicable revival suppression stay explicit, while native and Betrayal modes never expose Secret Ally', () => {
|
||||
const f = nexusEmperorSecretAllyFixture();
|
||||
for (const damage of [
|
||||
(g: Game) => {
|
||||
g.expansions = ['ix'];
|
||||
},
|
||||
(g: Game) => {
|
||||
g.discoveryEnabled = true;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.revivalRules!.expanded = [f.owner];
|
||||
},
|
||||
(g: Game) => {
|
||||
g.revivalRules!.freeBlocked = [f.owner];
|
||||
},
|
||||
(g: Game) => {
|
||||
g.revivalPrevention = { player: f.owner, turn: g.turn };
|
||||
},
|
||||
]) {
|
||||
const g = nexusReload(f.g);
|
||||
damage(g);
|
||||
nexusReject(g, f.owner, f.action, /Nexus|Discover|revival/);
|
||||
}
|
||||
for (const native of [false, true]) {
|
||||
const g = nexusTraitorFixture({
|
||||
ownerFaction: native ? 'emperor' : 'atreides',
|
||||
opponentFaction: native ? 'guild' : 'emperor',
|
||||
phase: 4,
|
||||
});
|
||||
const cards = g.nexusCards!.cards!,
|
||||
old = cards.hands.p!;
|
||||
cards.deck[cards.deck.indexOf('emperor')] = old;
|
||||
cards.hands.p = 'emperor';
|
||||
assert.equal(viewGame(g, 'p').nexusEmperorSecretAlly, null);
|
||||
nexusReject(g, 'p', f.action);
|
||||
}
|
||||
});
|
||||
|
||||
void test('JSON restoration keeps signed source history private and rejects missing markers, duplicate uses and changed physical quotes', () => {
|
||||
const f = nexusEmperorSecretAllyFixture(),
|
||||
@@ -286,7 +248,9 @@ void test('JSON restoration keeps signed source history private and rejects miss
|
||||
g.nexusEmperorSecretEvents!.push(g.nexusEmperorSecretEvents![0]);
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusEmperorSecretHistory![0].after.reserves++;
|
||||
const after = g.nexusEmperorSecretHistory![0].after;
|
||||
assert.ok(after);
|
||||
after.reserves++;
|
||||
},
|
||||
(g: Game) => {
|
||||
const r = g.nexusEmperorSecretHistory![0];
|
||||
|
||||
@@ -87,6 +87,7 @@ for (const config of configurations) {
|
||||
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
|
||||
if (kind === 'emperor-revival') {
|
||||
const receipt = paid.nexusEmperorSecretHistory![0];
|
||||
assert.ok(receipt.after, 'The requested Nexus return must settle before physical receipt consumption.');
|
||||
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
|
||||
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
|
||||
assert.equal(receipt.before.spice, receipt.after.spice);
|
||||
|
||||
Reference in new issue
Block a user