Files
Dune/tests/homeworld-revival-return.test.ts

525 lines
20 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { newPlayer, type Game } from '../game/engine';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { quoteHomeworldRevivalDeployment } from '../game/homeworld-revival-deployment';
import {
makeHomeworldRevivalReturn,
makeHomeworldRevivalProgress,
validateHomeworldRevivalReturn,
homeworldRevivalResumeSignature,
homeworldRevivalArrivalSignature,
makeSouthernRevivalAllocation,
southernRevivalRemaining,
southernRevivalSummary,
currentHomeworldRevivalArrival,
currentHomeworldRevivalGroup,
openSouthernRevivalChoice,
commitSouthernRevivalChoice,
completeSouthernRevivalChild,
appendHomeworldRevivalAmbassador,
completeHomeworldRevivalAmbassador,
type HomeworldRevivalReturn,
} from '../game/homeworld-revival-return';
function southernFixture(stars = 3) {
const { g, original } = fixture();
g.advanced = false;
original.group.elite = stars;
original.quote.elite = stars;
g.players[0].elites!.reserves = stars;
const root = makeSouthernRevivalAllocation(original);
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
return { g, root, original };
}
function openSouthern(root: HomeworldRevivalReturn, event: string, response: Game['response'] = null) {
root.resumeResponse = structuredClone(response);
root.resumeSignature = homeworldRevivalResumeSignature(response);
openSouthernRevivalChoice(root, event);
}
void test('Southern v2 allocates three original stars to distinct current groups and reserves while holding one original income', () => {
const { g, root } = southernFixture();
validateHomeworldRevivalReturn(g, root);
assert.equal(currentHomeworldRevivalArrival(root), null);
assert.equal(currentHomeworldRevivalGroup(root), null);
const response: Game['response'] = { kind: 'revivalIncome', owner: 'v', recipient: 'p', amount: 1, passed: [] };
openSouthern(root, 'child-1', response);
const original = structuredClone({ event: root.event, source: root.source, group: root.group, quote: root.quote,
signature: root.signature, resumeResponse: root.resumeResponse, resumeSignature: root.resumeSignature });
const destinations = ['arrakeen:10', 'polar_sink:0', 'decline'];
for (const [index, destination] of destinations.entries()) {
const event = `child-${index + 1}`;
assert.equal(currentHomeworldRevivalArrival(root)!.event, event);
assert.equal(currentHomeworldRevivalGroup(root), null);
commitSouthernRevivalChoice(root, event, { amount: 1, destination });
assert.deepEqual(currentHomeworldRevivalGroup(root), { amount: 1, elite: 1 });
if (destination !== 'decline') { g.players[0].reserves--; g.players[0].elites!.reserves--; }
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
validateHomeworldRevivalReturn(g, root);
completeSouthernRevivalChild(root, event, index < 2 ? `child-${index + 2}` : undefined);
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
validateHomeworldRevivalReturn(g, root);
}
assert.equal(root.stage, 'complete');
assert.deepEqual(southernRevivalSummary(root), { total: 3, remaining: 0, placed: 2, kept: 1 });
assert.equal(g.players[0].elites!.reserves, 1);
assert.equal(root.allocation!.children.length, 3);
assert.deepEqual({ event: root.event, source: root.source, group: root.group, quote: root.quote,
signature: root.signature, resumeResponse: root.resumeResponse, resumeSignature: root.resumeSignature }, original);
validateHomeworldRevivalReturn(g, JSON.parse(JSON.stringify(root)) as HomeworldRevivalReturn);
});
void test('Southern v2 is a fresh Basic multi-star grant while minimal legacy identities and original whole frames remain unchanged', () => {
const legacy = fixture(), before = JSON.stringify(legacy.frame);
assert.equal(currentHomeworldRevivalArrival(legacy.frame), legacy.frame);
assert.deepEqual(currentHomeworldRevivalGroup(legacy.frame), { amount: 2, elite: 2 });
const minimal = { event: 'legacy', turn: 1, phase: 4, stage: 'arrival' as const, destination: 'arrakeen:10' };
assert.equal(currentHomeworldRevivalArrival(minimal), minimal);
assert.equal(currentHomeworldRevivalArrival(null), null);
assert.equal(currentHomeworldRevivalArrival(undefined), null);
assert.equal(JSON.stringify(legacy.frame), before);
assert.equal('allocation' in legacy.frame, false);
assert.throws(() => makeSouthernRevivalAllocation(fixture('tleilax').original));
const one = fixture().original; one.group.elite = 1; one.quote.elite = 1;
assert.throws(() => makeSouthernRevivalAllocation(one));
const { g, root } = southernFixture(2);
assert.throws(() => validateHomeworldRevivalReturn({ ...g, advanced: true }, root));
validateHomeworldRevivalReturn(g, root);
});
void test('Southern child selection rejects stale events, duplicate decisions and over-allocation before changing the root', () => {
const { g, root } = southernFixture(2);
openSouthern(root, 'child-1');
for (const selection of [{ amount: 0, destination: 'decline' }, { amount: 3, destination: 'arrakeen:10' },
{ amount: 1.5, destination: 'polar_sink:0' }, { amount: 1, destination: '' }]) {
const before = structuredClone(root);
assert.throws(() => commitSouthernRevivalChoice(root, 'child-1', selection));
assert.deepEqual(root, before);
}
commitSouthernRevivalChoice(root, 'child-1', { amount: 1, destination: 'decline' });
const before = structuredClone(root);
for (const next of [undefined, 'child-1', root.event]) {
assert.throws(() => completeSouthernRevivalChild(root, 'child-1', next));
assert.deepEqual(root, before);
}
completeSouthernRevivalChild(root, 'child-1', 'child-2');
assert.equal(southernRevivalRemaining(root), 1);
assert.throws(() => commitSouthernRevivalChoice(root, 'child-1', { amount: 1, destination: 'decline' }));
assert.throws(() => commitSouthernRevivalChoice(root, 'child-2', { amount: 2, destination: 'decline' }));
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
validateHomeworldRevivalReturn(g, root);
const changed = structuredClone(root); changed.allocation!.children[0].amount = 2;
assert.throws(() => makeHomeworldRevivalProgress(changed));
const regranted = structuredClone(root); regranted.quote.afterPopulation++;
assert.throws(() => makeHomeworldRevivalProgress(regranted));
});
void test('Southern children retain their own Ambassador completion before the next unique decision', () => {
const { g, root } = southernFixture(2);
openSouthern(root, 'child-1');
commitSouthernRevivalChoice(root, 'child-1', { amount: 1, destination: 'arrakeen:10' });
g.players[0].reserves--; g.players[0].elites!.reserves--;
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
const ambassador = { event: 'child-1-ambassador', entrant: 'p', territory: 'arrakeen', sector: 10, turn: 2, phase: 4 };
appendHomeworldRevivalAmbassador(root, ambassador);
const pending = { ...ambassador, revivalEvent: 'child-1' } as NonNullable<Game['pendingAmbassador']>;
validateHomeworldRevivalReturn({ ...g, pendingAmbassador: pending }, root);
assert.throws(() => completeSouthernRevivalChild(root, 'child-1', 'child-2'));
completeHomeworldRevivalAmbassador(root, ambassador.event);
validateHomeworldRevivalReturn({ ...g, pendingAmbassador: null }, root);
completeSouthernRevivalChild(root, 'child-1', 'child-2');
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
assert.equal(root.allocation!.children[0].ambassadors![0].completed, true);
assert.equal(currentHomeworldRevivalArrival(root)!.event, 'child-2');
assert.throws(() => completeHomeworldRevivalAmbassador(root, ambassador.event));
validateHomeworldRevivalReturn({ ...g, pendingAmbassador: null }, root);
});
function fixture(
kind: 'fedaykin' | 'tleilax' = 'fedaykin',
source: 'normal' | 'emperorExtra' | 'ghola' | 'nexusTleilaxu' = 'normal',
) {
const players = [
newPlayer('p', 'Owner', kind === 'fedaykin' ? 'fremen' : 'tleilaxu'),
newPlayer('v', 'Other', kind === 'fedaykin' ? 'tleilaxu' : 'harkonnen'),
];
players[0].reserves = kind === 'fedaykin' ? 3 : 9;
if (kind === 'fedaykin')
players[0].elites = { reserves: 0, tanks: 3, forces: {}, revived: 0 };
const before = {
advanced: true,
players,
homeworlds: {
custody: createHomeworldCustody(
homeworldContext({ advanced: true, players }),
),
},
};
const group = {
amount: 3,
elite: kind === 'fedaykin' ? 2 : 0,
free: source === 'normal' ? 2 : 0,
};
const after = structuredClone(before);
after.players[0].reserves += group.amount;
if (after.players[0].elites) after.players[0].elites.reserves += group.elite;
const quote = quoteHomeworldRevivalDeployment(
before,
after,
'p',
source,
group,
)!;
const original = {
event: 'return-1',
turn: 2,
phase: 4,
player: 'p',
source,
...(source === 'ghola' ? { card: 'ghola-physical-1' } : {}),
group,
quote,
};
const frame = makeHomeworldRevivalReturn(original);
return {
g: {
...after,
turn: 2,
phase: 4,
...({
homeworldRevivalProgress: makeHomeworldRevivalProgress(frame),
} as Pick<Game, 'homeworldRevivalProgress'>),
},
original,
frame,
};
}
function choose(
g: Pick<Game, 'homeworldRevivalProgress'>,
frame: HomeworldRevivalReturn,
response: Game['response'] = null,
) {
frame.stage = 'choice';
frame.resumeResponse = structuredClone(response);
frame.resumeSignature = homeworldRevivalResumeSignature(response);
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
return frame;
}
void test('typed revival receipt survives every source and JSON round trip without aliasing its quote', () => {
for (const source of ['normal', 'emperorExtra', 'ghola', 'nexusTleilaxu'] as const) {
const { g, original, frame } = fixture('fedaykin', source);
const copy = JSON.parse(JSON.stringify(frame)) as HomeworldRevivalReturn;
validateHomeworldRevivalReturn(g, copy);
assert.equal(copy.stage, 'waiting');
original.group.elite = 1;
original.quote.elite = 1;
assert.equal(copy.group.elite, 2);
assert.equal(frame.quote.elite, 2);
validateHomeworldRevivalReturn(g, frame);
}
const { g, frame } = fixture('tleilax');
assert.equal(frame.quote.normal, 2);
validateHomeworldRevivalReturn(g, frame);
});
void test('original fields, eligible subset and unknown saved fields cannot be changed', () => {
const { g, frame } = fixture();
const mutations: ((f: HomeworldRevivalReturn) => void)[] = [
(f) => {
f.event = 'another';
},
(f) => {
f.turn++;
},
(f) => {
f.phase++;
},
(f) => {
f.player = 'v';
},
(f) => {
f.source = 'ghola';
f.card = 'invented';
},
(f) => {
f.group.elite--;
},
(f) => {
f.group.amount++;
},
(f) => {
f.quote.elite--;
},
(f) => {
f.quote.beforePopulation++;
},
(f) => {
f.quote.afterPopulation++;
},
(f) => {
f.quote.blocked = 'Unresolved';
},
(f) => {
Object.assign(f, { unexpected: true });
},
];
for (const mutate of mutations) {
const copy = structuredClone(frame);
mutate(copy);
const unchanged = JSON.stringify({ g, copy });
assert.throws(
() => validateHomeworldRevivalReturn(g, copy),
/original typed revival/,
);
assert.equal(JSON.stringify({ g, copy }), unchanged);
}
});
void test('constructor rejects impossible quotes, free source mismatches and inconsistent populations', () => {
const { original } = fixture();
for (const patch of [
{ group: { amount: 0, elite: 0, free: 0 } },
{ group: { amount: 3, elite: NaN, free: 2 } },
{ group: { amount: 3, elite: 4, free: 2 } },
{ group: { amount: 3, elite: 2, free: 4 } },
{ quote: { ...original.quote, normal: 1 } },
{ quote: { ...original.quote, elite: 1 } },
{ quote: { ...original.quote, beforePopulation: 3, afterPopulation: 5 } },
{ quote: { ...original.quote, blocked: 'Timing unresolved' } },
{ source: 'emperorExtra' },
{ source: 'ghola' },
{ card: 'unrelated' },
{ source: 'unknown' },
{ phase: -1 },
{ turn: 1.5 },
])
assert.throws(
() =>
makeHomeworldRevivalReturn({
...original,
...patch,
} as typeof original),
/original typed revival/,
);
const tleilax = fixture('tleilax').original;
assert.throws(() =>
makeHomeworldRevivalReturn({
...tleilax,
quote: { ...tleilax.quote, normal: 3 },
}),
);
assert.throws(() =>
makeHomeworldRevivalReturn({
...tleilax,
source: 'ghola',
card: 'ghola',
group: { ...tleilax.group, free: 0 },
}),
);
});
void test('only the eligible typed reserve subset remains reserved; current high population is not recomputed', () => {
const { g, frame } = fixture();
g.players[0].reserves = 2;
g.players[0].elites!.reserves = 2;
validateHomeworldRevivalReturn(g, frame);
choose(g, frame);
validateHomeworldRevivalReturn(g, frame);
g.players[0].elites!.reserves = 1;
assert.throws(() => validateHomeworldRevivalReturn(g, frame));
const tleilax = fixture('tleilax');
tleilax.g.players[0].reserves = 2;
validateHomeworldRevivalReturn(tleilax.g, tleilax.frame);
tleilax.g.players[0].reserves = 1;
assert.throws(() => validateHomeworldRevivalReturn(tleilax.g, tleilax.frame));
});
void test('waiting, choice and settled stages require their specific response and destination evidence', () => {
const { g, frame } = fixture();
assert.throws(() =>
validateHomeworldRevivalReturn(g, { ...frame, resumeResponse: null }),
);
assert.throws(() =>
validateHomeworldRevivalReturn(g, { ...frame, destination: 'decline' }),
);
assert.throws(() =>
validateHomeworldRevivalReturn(g, { ...frame, stage: 'choice' }),
);
choose(g, frame);
validateHomeworldRevivalReturn(g, frame);
assert.throws(() =>
validateHomeworldRevivalReturn(g, { ...frame, destination: 'sietchTabr' }),
);
assert.throws(() =>
validateHomeworldRevivalReturn(g, { ...frame, stage: 'arrival' }),
);
frame.stage = 'arrival';
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
frame.destination = 'sietchTabr';
frame.ambassadors = [];
frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
g.players[0].reserves = 0;
g.players[0].elites!.reserves = 0;
validateHomeworldRevivalReturn(g, frame);
g.turn++;
assert.throws(() => validateHomeworldRevivalReturn(g, frame));
frame.stage = 'complete';
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
g.phase = 0;
validateHomeworldRevivalReturn(
g,
JSON.parse(JSON.stringify(frame)) as HomeworldRevivalReturn,
);
frame.destination = 'decline';
frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
validateHomeworldRevivalReturn(g, frame);
});
void test('only original-recipient Tleilaxu revival income can be suspended, with Ghola fixed at one', () => {
const { g, frame } = fixture('fedaykin', 'ghola');
const response: Game['response'] = {
kind: 'revivalIncome',
owner: 'v',
recipient: 'p',
amount: 1,
passed: [],
intent: 'market-event',
};
choose(g, frame, response);
validateHomeworldRevivalReturn(g, frame);
response.passed.push('p');
assert.deepEqual(frame.resumeResponse!.passed, []);
const mutations = [
{ amount: 2 },
{ recipient: 'v' },
{ owner: 'p' },
{ kind: 'guildIncome' },
{ passed: ['absent'] },
{ passed: ['p', 'p'] },
];
for (const patch of mutations) {
const copy = structuredClone(frame);
copy.resumeResponse = { ...copy.resumeResponse!, ...patch } as NonNullable<
Game['response']
>;
copy.resumeSignature = homeworldRevivalResumeSignature(copy.resumeResponse);
assert.throws(() => validateHomeworldRevivalReturn(g, copy));
}
frame.resumeResponse!.amount = 2;
assert.throws(() => validateHomeworldRevivalReturn(g, frame));
});
void test('response proof is order-independent JSON but changing passed players requires new proof', () => {
const { g, frame } = fixture();
choose(g, frame, {
kind: 'revivalIncome',
owner: 'v',
recipient: 'p',
amount: 2,
passed: [],
});
const response = frame.resumeResponse!;
frame.resumeResponse = {
passed: response.passed,
amount: response.amount,
recipient: response.recipient,
owner: response.owner,
kind: response.kind,
};
validateHomeworldRevivalReturn(g, frame);
frame.resumeResponse.passed.push('p');
assert.throws(() => validateHomeworldRevivalReturn(g, frame));
assert.throws(() =>
homeworldRevivalResumeSignature(undefined as unknown as Game['response']),
);
});
void test('validation reads no hands, spice, Tanks, force locations or visitor secrets and does not mutate frozen receipts', () => {
const { g, frame } = fixture();
choose(g, frame);
for (const player of g.players) {
for (const field of ['hand', 'spice', 'tanks', 'forces', 'traitors'])
Object.defineProperty(player, field, {
get() {
throw new Error(`Secret ${field}`);
},
});
if (player.elites)
for (const field of ['tanks', 'forces', 'revived'])
Object.defineProperty(player.elites, field, {
get() {
throw new Error(`Secret elite ${field}`);
},
});
}
Object.defineProperty(g.homeworlds.custody, 'visitors', {
get() {
throw new Error('Visitor secrets');
},
});
Object.freeze(frame.group);
Object.freeze(frame.quote);
Object.freeze(frame);
validateHomeworldRevivalReturn(g, frame);
assert.throws(() => validateHomeworldRevivalReturn(g, null));
assert.throws(() => validateHomeworldRevivalReturn(g, undefined));
const empty = { ...g, homeworldRevivalProgress: undefined };
validateHomeworldRevivalReturn(empty, null);
validateHomeworldRevivalReturn(empty, undefined);
});
void test('new progress cannot be removed, rewound, mismatched or downgraded to a legacy frame', () => {
const { g, frame } = fixture();
assert.equal(frame.progressVersion, 1);
choose(g, frame);
for (const edit of [
(state: typeof g, _saved: HomeworldRevivalReturn) => {
delete state.homeworldRevivalProgress;
},
(state: typeof g, _saved: HomeworldRevivalReturn) => {
state.homeworldRevivalProgress!.stage = 'waiting';
},
(state: typeof g, _saved: HomeworldRevivalReturn) => {
state.homeworldRevivalProgress!.event = 'another';
},
(_state: typeof g, saved: HomeworldRevivalReturn) => {
delete saved.progressVersion;
},
(state: typeof g, saved: HomeworldRevivalReturn) => {
delete state.homeworldRevivalProgress;
delete saved.progressVersion;
},
]) {
const state = structuredClone(g),
saved = structuredClone(frame);
edit(state, saved);
const before = JSON.stringify({ state, saved });
assert.throws(() => validateHomeworldRevivalReturn(state, saved));
assert.equal(JSON.stringify({ state, saved }), before);
}
});
void test('completed legacy v0 signature bytes remain readable past their turn without creating progress', () => {
const { g, frame } = fixture();
choose(g, frame);
frame.stage = 'complete';
frame.destination = 'decline';
frame.ambassadors = [];
// This is the exact pre-version original tuple, not a migrated receipt.
const oldSignature = JSON.parse(frame.signature) as unknown[];
oldSignature.splice(-2);
frame.signature = JSON.stringify(oldSignature);
delete frame.progressVersion;
delete g.homeworldRevivalProgress;
frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
g.turn++;
g.phase = 1;
const before = JSON.stringify({ g, frame });
validateHomeworldRevivalReturn(g, frame);
assert.equal(JSON.stringify({ g, frame }), before);
assert.equal(g.homeworldRevivalProgress, undefined);
});