Files
Dune/tests/ecaz-ambassadors.test.ts
2026-09-08 07:37:52 +10:00

374 lines
13 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
AMBASSADOR_EFFECTS,
blockAmbassadorPlacement,
canTriggerAmbassador,
copiedAmbassadorEffects,
createAmbassadors,
destroyAmbassador,
placeAmbassador,
replenishAmbassadors,
triggerAmbassador,
validateAmbassadors,
type AmbassadorState,
type AmbassadorEffect,
type AmbassadorEntryContext,
} from '../game/ecaz-ambassadors';
const create = () => createAmbassadors(() => 0);
const idFor = (state: AmbassadorState, effect: AmbassadorEffect) =>
state.tokens.find((token) => token.effect === effect)!.id;
const reload = (state: AmbassadorState): AmbassadorState =>
JSON.parse(JSON.stringify(state));
function place(
state: AmbassadorState,
id: string,
turn = 1,
destination = 'arrakeen',
availableSpice = 100,
) {
return placeAmbassador(state, id, {
turn,
availableSpice,
destination: {
id: destination,
stronghold: true,
inStorm: false,
allowed: true,
},
});
}
function finishCohort(state: AmbassadorState, turn = 1) {
let next = state;
for (const id of state.cohort)
next = triggerAmbassador(place(next, id, turn).state, id);
return next;
}
function rejectUnchanged(state: AmbassadorState, action: () => unknown) {
const before = structuredClone(state);
assert.throws(action);
assert.deepEqual(state, before);
}
void test('setup has eleven unique physical identities: reusable Ecaz plus a random five-token cohort', () => {
const state = create();
assert.equal(state.tokens.length, 11);
assert.equal(new Set(state.tokens.map((t) => t.id)).size, 11);
assert.equal(new Set(state.tokens.map((t) => t.effect)).size, 11);
assert.equal(
AMBASSADOR_EFFECTS.includes('moritani' as AmbassadorEffect),
false,
);
assert.deepEqual([...AMBASSADOR_EFFECTS].sort(), [
'atreides',
'beneGesserit',
'choam',
'ecaz',
'emperor',
'fremen',
'guild',
'harkonnen',
'ixians',
'richese',
'tleilaxu',
]);
assert.equal(state.tokens.filter((t) => t.zone === 'supply').length, 6);
assert.equal(state.tokens.filter((t) => t.zone === 'pool').length, 5);
assert.equal(state.cohort.length, 5);
assert.ok(!state.cohort.includes(idFor(state, 'ecaz')));
assert.ok(state.tokens.every((t) => t.location === null));
assert.equal(state.placement, null);
assert.ok(Object.isFrozen(AMBASSADOR_EFFECTS));
const different = createAmbassadors(() => 0.999);
assert.notDeepEqual(different.cohort, state.cohort);
assert.deepEqual(
different.tokens.map((t) => [t.id, t.effect]),
state.tokens.map((t) => [t.id, t.effect]),
);
assert.doesNotThrow(() => validateAmbassadors(reload(state)));
});
void test('placement pays incremental one-through-N costs, consumes supplied tokens and cannot relocate', () => {
let state = create();
const initial = structuredClone(state);
for (let index = 0; index < 5; index++) {
const result = place(
state,
state.cohort[index],
1,
`stronghold-${index}`,
index + 1,
);
assert.equal(result.cost, index + 1);
state = result.state;
}
assert.deepEqual(initial, create());
assert.equal(state.placement?.count, 5);
const placed = state.cohort[0];
rejectUnchanged(state, () => place(state, placed, 1, 'other-stronghold'));
rejectUnchanged(state, () =>
place(state, idFor(state, 'ecaz'), 1, 'stronghold-0'),
);
rejectUnchanged(state, () =>
place(state, idFor(state, 'ecaz'), 1, 'other-stronghold', 5),
);
const sixth = place(state, idFor(state, 'ecaz'), 1, 'other-stronghold', 6);
assert.equal(sixth.cost, 6);
});
void test('placement respects explicit map/storm authorization and exact spice without inventing mobile permission', () => {
const state = create();
const token = state.cohort[0];
for (const destination of [
{ id: 'arrakeen', stronghold: true, inStorm: true, allowed: true },
{ id: 'imperial_basin', stronghold: false, inStorm: false, allowed: true },
{
id: 'hidden_mobile_stronghold',
stronghold: true,
inStorm: false,
allowed: false,
},
{ id: '', stronghold: true, inStorm: false, allowed: true },
])
rejectUnchanged(state, () =>
placeAmbassador(state, token, {
turn: 1,
availableSpice: 10,
destination,
}),
);
for (const spice of [0, -1, 0.5, NaN, Infinity])
rejectUnchanged(state, () => place(state, token, 1, 'arrakeen', spice));
const pool = state.tokens.find((t) => t.zone === 'pool')!;
rejectUnchanged(state, () => place(state, pool.id));
});
void test('turn-stamped blocking preserves spent placement count, rejects stale turns and resets on the next turn', () => {
const first = place(create(), idFor(create(), 'ecaz'), 4).state;
const blocked = blockAmbassadorPlacement(first, 4);
assert.deepEqual(blocked.placement, { turn: 4, count: 1, blocked: true });
assert.equal(first.placement?.blocked, false);
rejectUnchanged(blocked, () =>
place(blocked, blocked.cohort[0], 4, 'carthag'),
);
rejectUnchanged(blocked, () =>
place(blocked, blocked.cohort[0], 3, 'carthag'),
);
rejectUnchanged(blocked, () => blockAmbassadorPlacement(blocked, 3));
const next = place(reload(blocked), blocked.cohort[0], 5, 'carthag', 1);
assert.equal(next.cost, 1);
assert.deepEqual(next.state.placement, { turn: 5, count: 1, blocked: false });
const newBlock = blockAmbassadorPlacement(first, 5);
assert.deepEqual(newBlock.placement, { turn: 5, count: 0, blocked: true });
});
void test('storm or explosion destruction returns supply without completing the random cohort or refunding placement history', () => {
let state = create();
const id = state.cohort[0];
state = place(state, id).state;
const restored = destroyAmbassador(state, id);
assert.equal(restored.tokens.find((t) => t.id === id)?.zone, 'supply');
assert.equal(restored.tokens.find((t) => t.id === id)?.location, null);
assert.deepEqual(restored.cohort, state.cohort);
assert.deepEqual(restored.placement, state.placement);
assert.equal(state.tokens.find((t) => t.id === id)?.zone, 'placed');
rejectUnchanged(restored, () => replenishAmbassadors(restored, () => 0));
rejectUnchanged(restored, () => destroyAmbassador(restored, id));
const again = place(restored, id, 1, 'carthag');
assert.equal(again.cost, 2);
});
void test('Ecaz triggers return to supply, ordinary cohort tokens become used, and Bene Gesserit becomes permanently removed', () => {
let state = create();
const ecaz = idFor(state, 'ecaz');
const bg = idFor(state, 'beneGesserit');
assert.ok(state.cohort.includes(bg));
const originalCohort = [...state.cohort];
for (let count = 0; count < 2; count++) {
state = triggerAmbassador(place(state, ecaz).state, ecaz);
assert.equal(state.tokens.find((t) => t.id === ecaz)?.zone, 'supply');
assert.deepEqual(state.cohort, originalCohort);
}
state = triggerAmbassador(place(state, bg).state, bg);
assert.equal(state.tokens.find((t) => t.id === bg)?.zone, 'removed');
rejectUnchanged(state, () => triggerAmbassador(state, bg));
const ordinary = state.cohort.find((id) => id !== bg)!;
state = triggerAmbassador(place(state, ordinary).state, ordinary);
assert.equal(state.tokens.find((t) => t.id === ordinary)?.zone, 'used');
rejectUnchanged(state, () => place(state, ordinary));
rejectUnchanged(state, () => replenishAmbassadors(state, () => 0));
});
void test('only five completed random triggers replenish, preserving removed BG, Ecaz placement, and physical identity across repeated JSON cycles', () => {
let state = create();
const ecaz = idFor(state, 'ecaz');
const bg = idFor(state, 'beneGesserit');
state = place(state, ecaz, 1, 'carthag').state;
const identities = state.tokens.map((t) => [t.id, t.effect]);
for (let turn = 1; turn <= 4; turn++) {
state = finishCohort(state, turn);
assert.ok(
state.cohort.every((id) =>
['used', 'removed'].includes(
state.tokens.find((t) => t.id === id)!.zone,
),
),
);
const before = structuredClone(state);
const next = replenishAmbassadors(reload(state), () => 0);
assert.deepEqual(state, before);
assert.equal(next.cohort.length, 5);
assert.ok(!next.cohort.includes(bg));
assert.equal(next.tokens.find((t) => t.id === bg)?.zone, 'removed');
assert.deepEqual(
next.tokens.find((t) => t.id === ecaz),
state.tokens.find((t) => t.id === ecaz),
);
assert.deepEqual(next.placement, state.placement);
assert.deepEqual(
next.tokens.map((t) => [t.id, t.effect]),
identities,
);
assert.equal(next.tokens.filter((t) => t.zone === 'used').length, 0);
rejectUnchanged(next, () => replenishAmbassadors(next, () => 0));
state = next;
}
});
void test('invalid random draws reject setup or replenishment without consuming existing custody', () => {
for (const draw of [-0.01, 1, NaN, Infinity]) {
assert.throws(() => createAmbassadors(() => draw));
const state = finishCohort(create());
rejectUnchanged(state, () => replenishAmbassadors(state, () => draw));
}
const state = finishCohort(create());
let draws = 0;
rejectUnchanged(state, () =>
replenishAmbassadors(state, () => (++draws === 2 ? 1 : 0)),
);
});
void test('malformed inventories, cohorts, zones and placement stamps fail validation', () => {
const mutations: ((state: AmbassadorState) => void)[] = [
(s) => {
s.tokens.pop();
},
(s) => {
s.tokens[1].id = s.tokens[0].id;
},
(s) => {
s.tokens[1].effect = 'ecaz';
},
(s) => {
s.cohort[0] = s.cohort[1];
},
(s) => {
s.cohort[0] = idFor(s, 'ecaz');
},
(s) => {
s.tokens.find((t) => t.id === s.cohort[0])!.zone = 'pool';
},
(s) => {
s.tokens.find((t) => t.zone === 'pool')!.zone = 'supply';
},
(s) => {
s.tokens[0].zone = 'used';
},
(s) => {
s.tokens[0].location = 'arrakeen';
},
(s) => {
s.tokens.find((t) => t.effect === 'beneGesserit')!.zone = 'used';
},
(s) => {
s.placement = { turn: 0, count: 0, blocked: false };
},
(s) => {
s.placement = { turn: 1, count: 0.5, blocked: false };
},
];
for (const mutate of mutations) {
const state = create();
mutate(state);
rejectUnchanged(state, () => validateAmbassadors(state));
}
});
void test('BG copy eligibility follows the original cohort through placement, destruction, use and permanent removal', () => {
let state = create();
const expected = ['atreides', 'ixians', 'richese', 'guild', 'tleilaxu'];
assert.deepEqual(copiedAmbassadorEffects(state), expected);
const bg = idFor(state, 'beneGesserit');
const ordinary = state.cohort.find((id) => id !== bg)!;
state = place(state, ordinary).state;
assert.deepEqual(copiedAmbassadorEffects(state), expected);
state = destroyAmbassador(state, ordinary);
assert.deepEqual(copiedAmbassadorEffects(state), expected);
state = triggerAmbassador(place(state, ordinary).state, ordinary);
state = triggerAmbassador(place(state, bg).state, bg);
assert.equal(state.tokens.find((token) => token.id === bg)?.zone, 'removed');
assert.deepEqual(copiedAmbassadorEffects(reload(state)), expected);
const before = structuredClone(state);
const detached = copiedAmbassadorEffects(state);
detached.pop();
assert.deepEqual(state, before);
assert.deepEqual(copiedAmbassadorEffects(state), expected);
});
void test('copy eligibility rejects malformed custody without changing input and changes only after a new cohort', () => {
const initial = create();
const malformed = reload(initial);
malformed.cohort[0] = idFor(malformed, 'ecaz');
rejectUnchanged(malformed, () => copiedAmbassadorEffects(malformed));
const completed = finishCohort(initial);
const before = structuredClone(completed);
copiedAmbassadorEffects(completed);
assert.deepEqual(completed, before);
const next = replenishAmbassadors(completed, () => 0.999);
assert.notDeepEqual(
copiedAmbassadorEffects(next),
copiedAmbassadorEffects(completed),
);
assert.ok(!copiedAmbassadorEffects(next).includes('ecaz'));
});
void test('Ambassador entry eligibility excludes owner, ally, advisors and the physical marker faction only', () => {
const context: AmbassadorEntryContext = {
owner: 'ecaz-seat',
ally: 'ally-seat',
entrant: 'visitor-seat',
entrantFaction: 'harkonnen',
advisors: false,
effect: 'beneGesserit',
};
const before = structuredClone(context);
assert.equal(canTriggerAmbassador(context), true);
assert.equal(
canTriggerAmbassador({ ...context, entrant: context.owner }),
false,
);
assert.equal(
canTriggerAmbassador({ ...context, entrant: context.ally! }),
false,
);
assert.equal(canTriggerAmbassador({ ...context, advisors: true }), false);
assert.equal(
canTriggerAmbassador({ ...context, effect: 'harkonnen' }),
false,
);
assert.equal(canTriggerAmbassador({ ...context, ally: null }), true);
assert.equal(
canTriggerAmbassador({ ...context, entrantFaction: 'moritani' }),
true,
);
assert.deepEqual(context, before);
// E3 p.15: Harkonnen can trigger BG, which may then copy Harkonnen's effect.
const state = createAmbassadors(() => 0.999);
assert.ok(copiedAmbassadorEffects(state).includes('harkonnen'));
assert.equal(
canTriggerAmbassador({ ...context, effect: 'beneGesserit' }),
true,
);
});