Files
Dune/tests/nexus-emperor-extra-continuation.test.ts
T

503 lines
15 KiB
TypeScript

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);
});