180 lines
10 KiB
TypeScript
180 lines
10 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
validateSpiritualAdvisorSource, SpiritualAdvisorError,
|
|
type SpiritualAdvisorContext, type SpiritualAdvisorSource,
|
|
} from '../game/spiritual-advisor';
|
|
import {
|
|
quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError,
|
|
type AutomaticAdvisorFlipContext, type AutomaticAdvisorFlipSource,
|
|
} from '../game/advisor-automatic-flip';
|
|
|
|
/** Minimal controlled public arrival contexts. Original force-return and
|
|
* Ambassador receipts remain the engine's separate authority. */
|
|
function context(): SpiritualAdvisorContext {
|
|
return { advanced: true, status: 'playing', turn: 3, phase: 4,
|
|
players: [
|
|
{ id: 'bg', faction: 'beneGesserit', forces: {} },
|
|
{ id: 't', faction: 'tleilaxu', forces: { 'arrakeen:10': 2 } },
|
|
],
|
|
homeworldRevivalReturn: { event: 'original-return', turn: 3, phase: 4, stage: 'arrival', destination: 'arrakeen:10' },
|
|
};
|
|
}
|
|
function source(patch: Partial<SpiritualAdvisorSource> = {}): SpiritualAdvisorSource {
|
|
return { event: 'original-advisor-opportunity', owner: 'bg', turn: 3, phase: 4,
|
|
territory: 'polar_sink', sector: 0, amount: 1, revivalEvent: 'original-return', ...patch };
|
|
}
|
|
|
|
void test('provisional Revival SEND permits native Spiritual Advisors only against the original board arrival parent', () => {
|
|
for (const advanced of [false, true]) for (const amount of [1, 2]) {
|
|
const ctx = context(); ctx.advanced = advanced;
|
|
for (const receipt of [source({ amount }), source({ amount, territory: 'arrakeen', sector: 10 })]) {
|
|
const before = structuredClone({ ctx, receipt });
|
|
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, receipt));
|
|
assert.deepEqual({ ctx, receipt }, before);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('missing, foreign, stale or completed Revival parents cannot authorize a phase-four source', () => {
|
|
const changes: ((ctx: SpiritualAdvisorContext) => void)[] = [
|
|
ctx => { delete ctx.homeworldRevivalReturn; },
|
|
ctx => { ctx.homeworldRevivalReturn!.event = 'foreign-return'; },
|
|
ctx => { ctx.homeworldRevivalReturn!.turn = 2; },
|
|
ctx => { ctx.homeworldRevivalReturn!.phase = 5; },
|
|
...(['waiting', 'choice', 'complete'] as const).map(stage =>
|
|
(ctx: SpiritualAdvisorContext) => { ctx.homeworldRevivalReturn!.stage = stage; }),
|
|
...[undefined, 'decline', 'reserves', 'homeworld:tleilaxu', 'missing:0', 'arrakeen:9', 'arrakeen:010'].map(destination =>
|
|
(ctx: SpiritualAdvisorContext) => { ctx.homeworldRevivalReturn!.destination = destination; }),
|
|
];
|
|
for (const change of changes) {
|
|
const ctx = context(), receipt = source(); change(ctx);
|
|
const before = structuredClone({ ctx, receipt });
|
|
assert.throws(() => validateSpiritualAdvisorSource(ctx, receipt), SpiritualAdvisorError);
|
|
assert.deepEqual({ ctx, receipt }, before);
|
|
}
|
|
});
|
|
|
|
void test('non-Polar accompaniment must use the original arrival territory while Polar remains the fallback', () => {
|
|
const ctx = context();
|
|
assert.throws(() => validateSpiritualAdvisorSource(ctx, source({ territory: 'carthag', sector: 11 })), SpiritualAdvisorError);
|
|
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source()));
|
|
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source({ territory: 'arrakeen', sector: 10 })));
|
|
assert.throws(() => validateSpiritualAdvisorSource(ctx, source({ territory: 'homeworld:tleilaxu', sector: 0 })), SpiritualAdvisorError);
|
|
});
|
|
|
|
void test('Revival source keeps current native BG ownership and exact optional-field timing', () => {
|
|
const ctx = context();
|
|
for (const receipt of [source({ owner: 't' }), source({ revivalEvent: 'foreign-return' }),
|
|
source({ revivalEvent: '' }), source({ revivalEvent: undefined }), source({ turn: 2 }), source({ phase: 5 })])
|
|
assert.throws(() => validateSpiritualAdvisorSource(ctx, receipt), SpiritualAdvisorError);
|
|
for (const phase of [1, 3, 5, 8]) {
|
|
const old = source({ phase }); delete old.revivalEvent;
|
|
const original = { ...ctx, phase };
|
|
assert.doesNotThrow(() => validateSpiritualAdvisorSource(original, old));
|
|
assert.deepEqual(Object.keys(old).sort(), ['amount', 'event', 'owner', 'phase', 'sector', 'territory', 'turn']);
|
|
assert.throws(() => validateSpiritualAdvisorSource(original, { ...old, revivalEvent: 'original-return' }), SpiritualAdvisorError);
|
|
}
|
|
});
|
|
|
|
function guildChildContext(): SpiritualAdvisorContext {
|
|
const ctx = context();
|
|
ctx.pendingSpiritualAdvisor = { event: 'original-advisor-opportunity', turn: 3, phase: 4,
|
|
decision: { player: 'bg', destination: 'carthag:11', ambassadorEvent: 'original-ambassador', revivalEvent: 'original-return' } };
|
|
ctx.pendingAmbassador = { event: 'original-ambassador', revivalEvent: 'original-return', turn: 3, phase: 4, stage: 'arrival' };
|
|
return ctx;
|
|
}
|
|
|
|
void test('actual Guild Ambassador child provenance binds accompaniment to its own captured board destination', () => {
|
|
const ctx = guildChildContext(), receipt = source({ territory: 'carthag', sector: 11 });
|
|
const before = structuredClone({ ctx, receipt });
|
|
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, receipt));
|
|
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source()));
|
|
assert.throws(() => validateSpiritualAdvisorSource(ctx, source({ territory: 'arrakeen', sector: 10 })), SpiritualAdvisorError);
|
|
assert.deepEqual({ ctx, receipt }, before);
|
|
const changes: ((ctx: SpiritualAdvisorContext) => void)[] = [
|
|
ctx => { delete ctx.pendingSpiritualAdvisor; },
|
|
ctx => { delete ctx.pendingAmbassador; },
|
|
ctx => { ctx.pendingSpiritualAdvisor!.event = 'foreign-opportunity'; },
|
|
ctx => { ctx.pendingSpiritualAdvisor!.turn = 2; },
|
|
ctx => { ctx.pendingSpiritualAdvisor!.phase = 5; },
|
|
ctx => { ctx.pendingSpiritualAdvisor!.decision.player = 't'; },
|
|
ctx => { ctx.pendingSpiritualAdvisor!.decision.ambassadorEvent = 'foreign-ambassador'; },
|
|
ctx => { ctx.pendingSpiritualAdvisor!.decision.revivalEvent = 'foreign-return'; },
|
|
ctx => { ctx.pendingSpiritualAdvisor!.decision.destination = 'homeworld:tleilaxu'; },
|
|
ctx => { ctx.pendingAmbassador!.revivalEvent = 'foreign-return'; },
|
|
ctx => { ctx.pendingAmbassador!.turn = 2; },
|
|
ctx => { ctx.pendingAmbassador!.phase = 5; },
|
|
ctx => { ctx.pendingAmbassador!.stage = 'offer'; },
|
|
];
|
|
for (const change of changes) {
|
|
const original = guildChildContext(); change(original);
|
|
assert.throws(() => validateSpiritualAdvisorSource(original, receipt), SpiritualAdvisorError);
|
|
}
|
|
});
|
|
|
|
function automatic() {
|
|
const ctx: AutomaticAdvisorFlipContext = { advanced: true, status: 'playing', turn: 3, phase: 4,
|
|
players: [
|
|
{ id: 'bg', faction: 'beneGesserit', forces: { 'polar_sink:0': 1 }, advisors: { polar_sink: {} } },
|
|
{ id: 't', faction: 'tleilaxu', forces: { 'arrakeen:10': 2 }, moved: 1 },
|
|
], homeworldRevivalReturn: context().homeworldRevivalReturn,
|
|
};
|
|
const receipt: Extract<AutomaticAdvisorFlipSource, { producer: 'spiritualArrival' }> = {
|
|
event: 'original-automatic-occurrence', owner: 'bg', territory: 'polar_sink', turn: 3, phase: 4,
|
|
forces: { 'polar_sink:0': 1 }, producer: 'spiritualArrival', spiritual: source(),
|
|
};
|
|
return { ctx, receipt };
|
|
}
|
|
|
|
void test('a committed phase-four Spiritual arrival can capture and validate its exact alone conversion', () => {
|
|
const { ctx, receipt } = automatic();
|
|
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), [{ player: 'bg', territory: 'polar_sink', forces: { 'polar_sink:0': 1 } }]);
|
|
ctx.players[0].advisors = { polar_sink: { aloneFlipHold: receipt.event } };
|
|
const before = structuredClone({ ctx, receipt });
|
|
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, receipt));
|
|
assert.deepEqual({ ctx, receipt }, before);
|
|
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []);
|
|
const group = { event: receipt.event, owner: receipt.owner, territory: receipt.territory,
|
|
turn: receipt.turn, phase: receipt.phase, forces: receipt.forces };
|
|
assert.throws(() => validateAutomaticAdvisorFlip(ctx,
|
|
{ ...group, producer: 'groundMovement', mover: 't', move: 1 }), AutomaticAdvisorFlipError);
|
|
assert.throws(() => validateAutomaticAdvisorFlip(ctx, { ...group, producer: 'specialRelocation',
|
|
relocation: { event: 'original-relocation', kind: 'ambassador', player: 't', origin: 'carthag', destination: 'arrakeen', sector: 10 } }),
|
|
AutomaticAdvisorFlipError);
|
|
});
|
|
|
|
void test('phase-four automatic conversion delegates its parent, completed placement and surviving-group checks', () => {
|
|
const changes: ((ctx: AutomaticAdvisorFlipContext) => void)[] = [
|
|
ctx => { delete ctx.homeworldRevivalReturn; },
|
|
ctx => { ctx.homeworldRevivalReturn!.event = 'foreign-return'; },
|
|
ctx => { ctx.homeworldRevivalReturn!.stage = 'complete'; },
|
|
ctx => { ctx.homeworldRevivalReturn!.destination = 'homeworld:tleilaxu'; },
|
|
ctx => { ctx.players[0].forces = {}; },
|
|
ctx => { ctx.players[1].forces = { 'polar_sink:0': 1 }; },
|
|
];
|
|
for (const change of changes) {
|
|
const { ctx, receipt } = automatic();
|
|
ctx.players[0].advisors = { polar_sink: { aloneFlipHold: receipt.event } };
|
|
change(ctx);
|
|
const before = structuredClone({ ctx, receipt });
|
|
assert.throws(() => validateAutomaticAdvisorFlip(ctx, receipt), AutomaticAdvisorFlipError);
|
|
assert.deepEqual({ ctx, receipt }, before);
|
|
}
|
|
});
|
|
|
|
void test('phase-four automatic conversion forwards the actual Guild child provenance to the Spiritual validator', () => {
|
|
const { ctx, receipt } = automatic(), child = guildChildContext();
|
|
ctx.pendingSpiritualAdvisor = child.pendingSpiritualAdvisor;
|
|
ctx.pendingAmbassador = child.pendingAmbassador;
|
|
ctx.players[0].forces = { 'carthag:11': 1 };
|
|
ctx.players[0].advisors = { carthag: { aloneFlipHold: receipt.event } };
|
|
receipt.territory = 'carthag'; receipt.forces = { 'carthag:11': 1 };
|
|
receipt.spiritual = source({ territory: 'carthag', sector: 11 });
|
|
const before = structuredClone({ ctx, receipt });
|
|
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, receipt));
|
|
assert.deepEqual({ ctx, receipt }, before);
|
|
ctx.pendingAmbassador!.revivalEvent = 'foreign-return';
|
|
assert.throws(() => validateAutomaticAdvisorFlip(ctx, receipt), AutomaticAdvisorFlipError);
|
|
});
|