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
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user