374 lines
13 KiB
TypeScript
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,
|
|
);
|
|
});
|