298 lines
14 KiB
TypeScript
298 lines
14 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { createGame, newPlayer, type Game, type Player } from '../game/engine';
|
|
import { createNexusCards, discardNexusCard, drawNexusCard, validateNexusCards } from '../game/nexus-cards';
|
|
import {
|
|
createFremenNexusRevival,
|
|
fremenNexusRevivalEvent,
|
|
fremenNexusRevivalOffer,
|
|
fremenNexusRevivalSignature,
|
|
upgradeFremenNexusRevival,
|
|
validateFremenNexusRevival,
|
|
type FremenNexusRevival,
|
|
} from '../game/nexus-fremen-revival';
|
|
import { forceRevivalQuote, forceRevivalRemaining, newRevivalRules, normalRevivalAllowance } from '../game/revival';
|
|
|
|
/** Resolved-facts unit context, not a native gameplay chronology. Custody still
|
|
* comes from the original inventory/draw API, never an edited private hand. */
|
|
function fixture(faction: Player['faction'] = 'guild', advanced = true): Game {
|
|
const g = createGame('FQUOTA', newPlayer('q', 'Quota owner', faction), advanced);
|
|
g.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('h', 'Harkonnen', 'harkonnen'));
|
|
if (faction === 'atreides') g.players[1] = newPlayer('a', 'Guild', 'guild');
|
|
g.status = 'playing';
|
|
g.phase = 4;
|
|
g.turn = 2;
|
|
g.revivalRules = newRevivalRules();
|
|
const p = g.players[0];
|
|
p.reserves = 14;
|
|
p.tanks = 6;
|
|
p.spice = 17;
|
|
p.revived = 0;
|
|
p.freeForcesRevived = 0;
|
|
if (p.elites) {
|
|
p.elites.tanks = 0;
|
|
p.elites.revived = 0;
|
|
}
|
|
const cards = createNexusCards(g.players, () => 0.5);
|
|
cards.deck = ['fremen', ...cards.deck.filter(card => card !== 'fremen')];
|
|
g.nexusCards = { cards: drawNexusCard(cards, 'q', g.players, () => 0.5), phase: null };
|
|
return g;
|
|
}
|
|
|
|
/** Install the engine-owned receipt/permission contract on a copy so these
|
|
* tests consume the shared ordinary arithmetic without introducing a new API. */
|
|
function granted(g: Game) {
|
|
const receipt = createFremenNexusRevival(g, 'q');
|
|
const result = structuredClone(g);
|
|
result.revivalRules!.nexusFremen = { turn: result.turn, owners: ['q'] };
|
|
result.nexusFremenRevivalHistory = [receipt];
|
|
result.nexusFremenRevivalEvents = [receipt.event];
|
|
result.nexusCards!.cards = discardNexusCard(result.nexusCards!.cards!, 'q', result.players);
|
|
return result;
|
|
}
|
|
|
|
void test('a quota receipt changes no physical pieces, ledgers, wallet or custody', () => {
|
|
const g = fixture();
|
|
const before = structuredClone(g);
|
|
const record = createFremenNexusRevival(g, 'q');
|
|
assert.equal(record.version, 2);
|
|
assert.equal(record.revivedAtUse, 0);
|
|
assert.equal(record.freeAtUse, 0);
|
|
assert.equal(Object.hasOwn(record, 'legacyReturn'), false);
|
|
assert.equal(Object.hasOwn(record, 'before'), false);
|
|
assert.equal(Object.hasOwn(record, 'after'), false);
|
|
validateFremenNexusRevival(g, record);
|
|
assert.deepEqual(g, before);
|
|
const result = granted(g);
|
|
assert.deepEqual(result.players, before.players);
|
|
validateNexusCards(result.nexusCards!.cards!, result.players);
|
|
assert.equal(result.nexusCards!.cards!.hands.q, null);
|
|
assert.equal(result.nexusCards!.cards!.discard.includes('fremen'), true);
|
|
assert.equal(fremenNexusRevivalOffer(result, 'q'), null);
|
|
assert.equal(fremenNexusRevivalOffer(g, 'a'), null);
|
|
});
|
|
|
|
void test('partial Tanks and already-used native free returns leave legal partial quota', () => {
|
|
const g = fixture();
|
|
const p = g.players[0];
|
|
p.revived = 1;
|
|
p.freeForcesRevived = 1;
|
|
p.tanks = 2;
|
|
p.reserves = 18;
|
|
const offer = fremenNexusRevivalOffer(g, 'q')!;
|
|
assert.equal(offer.blocked, null);
|
|
assert.equal(offer.freeRemaining, 2);
|
|
assert.equal(offer.maxForces, 2);
|
|
const result = granted(g);
|
|
assert.equal(forceRevivalRemaining(result, result.players[0]), 2);
|
|
assert.deepEqual(forceRevivalQuote(result, result.players[0], 1), { free: 1, normalCost: 0, cost: 0 });
|
|
assert.deepEqual(forceRevivalQuote(result, result.players[0], 2), { free: 2, normalCost: 0, cost: 0 });
|
|
// Actual ordinary free usage is shared with later partial requests.
|
|
result.players[0].revived++;
|
|
result.players[0].freeForcesRevived!++;
|
|
result.players[0].tanks--;
|
|
result.players[0].reserves++;
|
|
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRemaining, 1);
|
|
assert.equal(forceRevivalQuote(result, result.players[0], 1).cost, 0);
|
|
});
|
|
|
|
|
|
void test('native higher quotas survive and never justify a useless Fremen discard', () => {
|
|
const g = fixture('atreides');
|
|
g.recruits = { turn: g.turn, player: 'q', card: 'ecaz-recruits' };
|
|
assert.equal(normalRevivalAllowance(g, g.players[0]).freeRate, 4);
|
|
assert.match(fremenNexusRevivalOffer(g, 'q')!.blocked!, /already at least three/);
|
|
assert.throws(() => createFremenNexusRevival(g, 'q'));
|
|
const result = structuredClone(g);
|
|
result.revivalRules!.nexusFremen = { turn: g.turn, owners: ['q'] };
|
|
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRate, 4);
|
|
assert.equal(normalRevivalAllowance(result, result.players[0]).limit, 7);
|
|
const single = fixture();
|
|
single.players[0].tanks = 1;
|
|
assert.match(fremenNexusRevivalOffer(single, 'q')!.blocked!, /cannot improve/);
|
|
});
|
|
|
|
void test('Recruits doubles the native rate, not the fixed quota, and retains its seven-force limit', () => {
|
|
const g = fixture('emperor');
|
|
g.recruits = { turn: g.turn, player: 'q', card: 'ecaz-recruits' };
|
|
assert.equal(normalRevivalAllowance(g, g.players[0]).freeRate, 2);
|
|
assert.equal(fremenNexusRevivalOffer(g, 'q')!.blocked, null);
|
|
const result = granted(g);
|
|
const allowance = normalRevivalAllowance(result, result.players[0]);
|
|
assert.equal(allowance.limit, 7);
|
|
assert.equal(allowance.freeRate, 3);
|
|
assert.equal(allowance.freeRemaining, 3);
|
|
assert.equal(forceRevivalQuote(result, result.players[0], 4).cost, 2);
|
|
result.players[0].revived = 3;
|
|
result.players[0].freeForcesRevived = 3;
|
|
assert.equal(forceRevivalQuote(result, result.players[0], 1).cost, 2);
|
|
});
|
|
|
|
void test('Cyborgs retain unlimited typed eligibility while Advanced Sardaukar retain their one-return cap', () => {
|
|
const ix = fixture('ixians');
|
|
ix.expansions = ['ix'];
|
|
ix.players[0].tanks = 4;
|
|
ix.players[0].elites!.tanks = 3;
|
|
ix.players[0].elites!.revived = 1;
|
|
assert.equal(fremenNexusRevivalOffer(ix, 'q')!.maxForces, 3);
|
|
assert.equal(fremenNexusRevivalOffer(ix, 'q')!.blocked, null);
|
|
const result = granted(ix);
|
|
assert.deepEqual(forceRevivalQuote(result, result.players[0], 3, 3, 3), { free: 3, normalCost: 0, cost: 0 });
|
|
result.recruits = { turn: result.turn, player: 'q', card: 'ecaz-recruits' };
|
|
assert.equal(forceRevivalQuote(result, result.players[0], 4, 3, 2).cost, 3);
|
|
const emperor = fixture('emperor');
|
|
emperor.players[0].tanks = 3;
|
|
emperor.players[0].elites = { reserves: 2, tanks: 3, forces: {}, revived: 0 };
|
|
emperor.players[0].elites!.tanks = 3;
|
|
assert.equal(fremenNexusRevivalOffer(emperor, 'q')!.maxForces, 1);
|
|
emperor.players[0].elites!.revived = 1;
|
|
assert.ok(fremenNexusRevivalOffer(emperor, 'q')!.blocked);
|
|
emperor.advanced = false;
|
|
assert.equal(fremenNexusRevivalOffer(emperor, 'q')!.maxForces, 3);
|
|
assert.equal(fremenNexusRevivalOffer(emperor, 'q')!.blocked, null);
|
|
});
|
|
|
|
void test('ordinary free blocking and Tleilaxu prevention apply both before and after the grant', () => {
|
|
for (const denial of ['free', 'prevention'] as const) {
|
|
const g = fixture();
|
|
if (denial === 'free') g.revivalRules!.freeBlocked = ['q'];
|
|
else g.revivalPrevention = { turn: g.turn, player: 'q' };
|
|
const before = structuredClone(g);
|
|
assert.ok(fremenNexusRevivalOffer(g, 'q')!.blocked);
|
|
assert.throws(() => createFremenNexusRevival(g, 'q'));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
const result = granted(fixture());
|
|
result.revivalRules!.freeBlocked = ['q'];
|
|
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRemaining, 0);
|
|
assert.equal(forceRevivalQuote(result, result.players[0], 1).cost, 2);
|
|
});
|
|
|
|
void test('ally, pending interaction, wrong phase and stale use cannot spend another grant', () => {
|
|
const changes: Array<(g: Game) => void> = [
|
|
g => { g.players[0].ally = 'a'; g.players[1].ally = 'q'; },
|
|
g => { g.phase = 5; },
|
|
g => { g.pendingRevival = { player: 'q' } as NonNullable<Game['pendingRevival']>; },
|
|
g => { g.nexusFremenRevivalEvents = [fremenNexusRevivalEvent(g.turn, 'q')]; },
|
|
g => { g.revivalRules!.nexusFremen = { turn: g.turn, owners: ['q'] }; },
|
|
];
|
|
for (const change of changes) {
|
|
const g = fixture();
|
|
change(g);
|
|
const before = structuredClone(g);
|
|
assert.ok(fremenNexusRevivalOffer(g, 'q')!.blocked);
|
|
assert.throws(() => createFremenNexusRevival(g, 'q'));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
const present = fixture();
|
|
present.players.push(newPlayer('f', 'Fremen', 'fremen'));
|
|
assert.equal(fremenNexusRevivalOffer(present, 'q'), null);
|
|
});
|
|
|
|
void test('unknown legacy free usage fails closed, while untouched zero use is unambiguous', () => {
|
|
const g = fixture();
|
|
delete g.players[0].freeForcesRevived;
|
|
g.players[0].revived = 1;
|
|
const before = structuredClone(g);
|
|
assert.match(fremenNexusRevivalOffer(g, 'q')!.blocked!, /usage is unknown/);
|
|
assert.throws(() => createFremenNexusRevival(g, 'q'), /usage is unknown/);
|
|
assert.deepEqual(g, before);
|
|
g.players[0].revived = 0;
|
|
assert.equal(createFremenNexusRevival(g, 'q').freeAtUse, 0);
|
|
assert.equal(g.players[0].freeForcesRevived, undefined);
|
|
});
|
|
|
|
void test('the quota expires with its turn and grants neither force-limit nor leader allowance', () => {
|
|
const result = granted(fixture('choam'));
|
|
assert.equal(normalRevivalAllowance(result, result.players[0]).limit, 20);
|
|
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRate, 3);
|
|
assert.equal(result.players[0].leaderRevived, false);
|
|
result.turn++;
|
|
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRate, 0);
|
|
assert.equal(normalRevivalAllowance(result, result.players[0]).limit, 20);
|
|
const ordinary = granted(fixture());
|
|
assert.equal(normalRevivalAllowance(ordinary, ordinary.players[0]).limit, 3);
|
|
});
|
|
|
|
type LegacyReceipt = Omit<FremenNexusRevival,
|
|
'version' | 'revivedAtUse' | 'freeAtUse' | 'policy' | 'legacyReturn'> &
|
|
NonNullable<FremenNexusRevival['legacyReturn']>;
|
|
|
|
function legacy(g: Game, elite = 0): LegacyReceipt {
|
|
const before = { reserves: 14, tanks: 6, eliteReserves: 0, eliteTanks: elite,
|
|
eliteRevived: 0, revived: 0, freeForcesRevived: 0, spice: 17 };
|
|
const record: LegacyReceipt = {
|
|
event: fremenNexusRevivalEvent(g.turn, 'q'), owner: 'q', turn: g.turn,
|
|
phase: 4, faction: g.players[0].faction, advanced: g.advanced, elite, before,
|
|
after: { ...before, reserves: 17, tanks: 3, eliteReserves: elite, eliteTanks: 0,
|
|
eliteRevived: elite, revived: 3, freeForcesRevived: 3 }, signature: '',
|
|
};
|
|
record.signature = JSON.stringify({ ...record, signature: undefined });
|
|
return record;
|
|
}
|
|
|
|
void test('immutable legacy adoption retains the completed physical facts and exhausted quota', () => {
|
|
const g = fixture('emperor');
|
|
const old = legacy(g, 1);
|
|
g.players[0].revived = 3;
|
|
g.players[0].freeForcesRevived = 3;
|
|
const before = structuredClone(g), oldBefore = structuredClone(old);
|
|
const record = upgradeFremenNexusRevival(g, old);
|
|
assert.deepEqual(record.legacyReturn, { elite: old.elite, before: old.before, after: old.after });
|
|
assert.notEqual(record.legacyReturn!.before, old.before);
|
|
assert.notEqual(record.legacyReturn!.after, old.after);
|
|
assert.equal(record.revivedAtUse, 0);
|
|
assert.equal(record.freeAtUse, 0);
|
|
assert.equal(upgradeFremenNexusRevival(g, record), record);
|
|
assert.deepEqual(old, oldBefore);
|
|
assert.deepEqual(g, before);
|
|
const adopted = structuredClone(g);
|
|
adopted.revivalRules!.nexusFremen = { turn: g.turn, owners: ['q'] };
|
|
assert.equal(normalRevivalAllowance(adopted, adopted.players[0]).freeRemaining, 0);
|
|
assert.equal(forceRevivalRemaining(adopted, adopted.players[0]), 0);
|
|
});
|
|
|
|
void test('legacy signatures, exact shape, conservation and original Emperor cap must validate before adoption', () => {
|
|
for (const change of [
|
|
(old: LegacyReceipt) => { old.after.spice++; },
|
|
(old: LegacyReceipt) => { old.after.tanks++; },
|
|
(old: LegacyReceipt) => { old.before.revived = 1; },
|
|
(old: LegacyReceipt) => { old.before.reserves = 20; },
|
|
(old: LegacyReceipt) => { old.elite = 2; old.before.eliteTanks = 2; old.after.eliteReserves = 2; old.after.eliteRevived = 2; },
|
|
]) {
|
|
const g = fixture('emperor');
|
|
const old = legacy(g, 1);
|
|
change(old);
|
|
old.signature = JSON.stringify({ ...old, signature: undefined });
|
|
const before = structuredClone(g), oldBefore = structuredClone(old);
|
|
assert.throws(() => upgradeFremenNexusRevival(g, old), /original physical return/);
|
|
assert.deepEqual(g, before);
|
|
assert.deepEqual(old, oldBefore);
|
|
}
|
|
const g = fixture();
|
|
const old = legacy(g);
|
|
assert.throws(() => upgradeFremenNexusRevival(g, { ...old, extra: true }));
|
|
assert.throws(() => upgradeFremenNexusRevival(g, { ...old, signature: 'tampered' }));
|
|
assert.throws(() => upgradeFremenNexusRevival(fixture('ixians'), legacy(fixture('ixians'), 1)));
|
|
});
|
|
|
|
void test('malformed version-two source receipts reject without normalization or mutation', () => {
|
|
const g = fixture();
|
|
const receipt = createFremenNexusRevival(g, 'q');
|
|
const malformed: unknown[] = [
|
|
{ ...receipt, version: 1 }, { ...receipt, signature: 'tampered' },
|
|
{ ...receipt, extra: true }, { ...receipt, freeAtUse: 1 },
|
|
{ ...receipt, revivedAtUse: -1 }, { ...receipt, turn: g.turn + 1 },
|
|
{ ...receipt, phase: 5 }, { ...receipt, policy: 'direct-return' },
|
|
{ ...receipt, owner: 'missing' }, { ...receipt, legacyReturn: undefined },
|
|
];
|
|
for (const input of malformed) {
|
|
const record = input as FremenNexusRevival;
|
|
if (record.signature !== 'tampered') record.signature = fremenNexusRevivalSignature(record);
|
|
const before = structuredClone(g), recordBefore = structuredClone(record);
|
|
assert.throws(() => upgradeFremenNexusRevival(g, record));
|
|
assert.deepEqual(g, before);
|
|
assert.deepEqual(record, recordBefore);
|
|
}
|
|
});
|