198 lines
8.7 KiB
TypeScript
198 lines
8.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
battleLeaderStrength,
|
|
createAuditorLeader,
|
|
leaders,
|
|
type Leader,
|
|
} from '../game/cards';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { acquireDuke, captureDuke, createDukeVidal, type DukeState } from '../game/duke-vidal';
|
|
import {
|
|
moritaniTerrorAssassinationPool,
|
|
moritaniTerrorAssassinationValue,
|
|
} from '../game/moritani-terror-assassination';
|
|
|
|
type TerrorFixture = {
|
|
advanced: boolean;
|
|
players: { id: string; faction: FactionId; leaders: Leader[] }[];
|
|
dukeVidal: DukeState;
|
|
};
|
|
function fixture(): TerrorFixture {
|
|
return {
|
|
advanced: true,
|
|
players: (['atreides', 'harkonnen', 'tleilaxu', 'ecaz', 'moritani', 'choam'] as const)
|
|
.map((faction) => ({ id: faction, faction, leaders: leaders(faction) })),
|
|
dukeVidal: createDukeVidal(),
|
|
};
|
|
}
|
|
const seat = (g: TerrorFixture, id: string) =>
|
|
g.players.find((player) => player.id === id)!;
|
|
const ids = (pool: readonly Leader[]) => pool.map((leader) => leader.id);
|
|
|
|
void test('ordinary eligibility retains all five living native discs without mutating custody', () => {
|
|
const g = fixture();
|
|
g.advanced = false;
|
|
const owner = seat(g, 'atreides');
|
|
const before = structuredClone(g);
|
|
const first = moritaniTerrorAssassinationPool(g, owner.id);
|
|
assert.deepEqual(ids(first), ['atreides-0', 'atreides-1', 'atreides-2', 'atreides-3', 'atreides-4']);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
|
|
void test('native discs controlled elsewhere are excluded and the captor receives the actual foreign disc', () => {
|
|
const g = fixture();
|
|
const native = seat(g, 'atreides').leaders;
|
|
native[0].capturedBy = 'harkonnen';
|
|
native[1].gholaBy = 'tleilaxu';
|
|
const before = structuredClone(g);
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'atreides')), ['atreides-2', 'atreides-3', 'atreides-4']);
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')), [
|
|
'atreides-0', 'harkonnen-0', 'harkonnen-1', 'harkonnen-2', 'harkonnen-3', 'harkonnen-4',
|
|
]);
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'tleilaxu')), [
|
|
'atreides-1', 'tleilaxu-0', 'tleilaxu-1', 'tleilaxu-2', 'tleilaxu-3', 'tleilaxu-4',
|
|
]);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
|
|
void test('capture overrides foreign-Ghola control without moving or copying the native physical disc', () => {
|
|
const g = fixture();
|
|
const ghola = seat(g, 'atreides').leaders[2];
|
|
ghola.gholaBy = 'tleilaxu';
|
|
ghola.capturedBy = 'harkonnen';
|
|
ghola.concealed = { captor: 'harkonnen', controller: 'tleilaxu', dead: false, deaths: 1 };
|
|
ghola.deaths = 1;
|
|
ghola.usedAt = 'carthag';
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'tleilaxu')), ids(seat(g, 'tleilaxu').leaders));
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')), [ghola.id, ...ids(seat(g, 'harkonnen').leaders)]);
|
|
});
|
|
|
|
void test('used living discs remain eligible while dead native and foreign discs do not', () => {
|
|
const g = fixture();
|
|
const native = seat(g, 'harkonnen').leaders;
|
|
native.forEach((leader) => { leader.dead = true; leader.deaths = 2; });
|
|
native[3].dead = false;
|
|
native[3].usedAt = 'arrakeen';
|
|
const foreign = seat(g, 'atreides').leaders[0];
|
|
foreign.capturedBy = 'harkonnen';
|
|
foreign.dead = true;
|
|
foreign.deaths = 1;
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')), [native[3].id]);
|
|
foreign.dead = false;
|
|
foreign.usedAt = 'carthag';
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')), [foreign.id, native[3].id]);
|
|
});
|
|
|
|
void test('the shared Duke is included once for actual server control, never merely for Ecaz identity', () => {
|
|
const g = fixture();
|
|
g.dukeVidal = acquireDuke(g.dukeVidal, 'moritani', 2, 'moritani');
|
|
g.dukeVidal.leader.usedAt = 'arrakeen';
|
|
const before = structuredClone(g);
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'moritani')),
|
|
[...ids(seat(g, 'moritani').leaders), g.dukeVidal.leader.id]);
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'ecaz')), ids(seat(g, 'ecaz').leaders));
|
|
assert.deepEqual(g, before);
|
|
});
|
|
|
|
void test('a genuinely captured shared Duke follows the native captor rather than his original tenure', () => {
|
|
const g = fixture();
|
|
g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal, 'moritani', 2, 'moritani'),
|
|
'harkonnen', 'moritani', 2, 'arrakeen');
|
|
const before = structuredClone(g);
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'moritani')), ids(seat(g, 'moritani').leaders));
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')),
|
|
[...ids(seat(g, 'harkonnen').leaders), g.dukeVidal.leader.id]);
|
|
assert.deepEqual(g, before, 'quoting does not reveal the captive or change tenure');
|
|
});
|
|
|
|
void test('empty actual control returns an empty array without changing hidden custody or requiring a victim', () => {
|
|
const g = fixture();
|
|
for (const leader of seat(g, 'atreides').leaders) {
|
|
leader.dead = true;
|
|
leader.deaths = 1;
|
|
}
|
|
const concealed = seat(g, 'atreides').leaders[0];
|
|
concealed.dead = false;
|
|
concealed.capturedBy = 'harkonnen';
|
|
concealed.concealed = { captor: 'harkonnen', controller: 'atreides', dead: false, deaths: 1 };
|
|
const before = structuredClone(g);
|
|
assert.deepEqual(moritaniTerrorAssassinationPool(g, 'atreides'), []);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
|
|
void test('set-aside and dead Duke do not receive faction fallback control', () => {
|
|
const g = fixture();
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'ecaz')), ids(seat(g, 'ecaz').leaders));
|
|
g.dukeVidal = acquireDuke(g.dukeVidal, 'ecaz', 1, 'ecaz');
|
|
g.dukeVidal.leader.dead = true;
|
|
g.dukeVidal.leader.deaths = 1;
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'ecaz')), ids(seat(g, 'ecaz').leaders));
|
|
});
|
|
|
|
void test('the actual optional Auditor participates without replacing any of the native five', () => {
|
|
const g = fixture();
|
|
const choam = seat(g, 'choam');
|
|
const ordinary = [...choam.leaders];
|
|
const auditor = createAuditorLeader();
|
|
auditor.usedAt = 'sietch_tabr';
|
|
choam.leaders.push(auditor);
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'choam')), [...ids(ordinary), auditor.id]);
|
|
assert.equal(moritaniTerrorAssassinationValue(auditor), 2);
|
|
auditor.dead = true;
|
|
auditor.deaths = 1;
|
|
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'choam')), ids(ordinary));
|
|
g.advanced = false;
|
|
assert.throws(() => moritaniTerrorAssassinationPool(g, 'choam'));
|
|
});
|
|
|
|
void test('printed bounty preserves Zoal three and shared Duke six, not copied battle values', () => {
|
|
const zoal = leaders('tleilaxu')[0];
|
|
const opponent = leaders('fremen')[0];
|
|
assert.equal(battleLeaderStrength(zoal, opponent), 7);
|
|
assert.equal(moritaniTerrorAssassinationValue(zoal), 3);
|
|
assert.equal(moritaniTerrorAssassinationValue(createDukeVidal().leader), 6);
|
|
assert.throws(() => moritaniTerrorAssassinationValue({ ...zoal, strength: 7 }));
|
|
});
|
|
|
|
void test('missing, duplicated, extra and altered physical native discs reject rather than distort probability', () => {
|
|
const changes: ((g: TerrorFixture) => void)[] = [
|
|
(g) => { seat(g, 'atreides').leaders.pop(); },
|
|
(g) => { seat(g, 'atreides').leaders[1] = { ...seat(g, 'atreides').leaders[0] }; },
|
|
(g) => { seat(g, 'atreides').leaders.push({ ...seat(g, 'atreides').leaders[0] }); },
|
|
(g) => { seat(g, 'atreides').leaders[0].strength = 99; },
|
|
(g) => { seat(g, 'atreides').leaders[0].name = 'Invented disc'; },
|
|
(g) => { seat(g, 'atreides').leaders[0].faction = 'ecaz'; },
|
|
(g) => { seat(g, 'atreides').leaders[0].id = 'atreides-invented'; },
|
|
(g) => { seat(g, 'atreides').leaders[0].controller = 'ecaz'; },
|
|
(g) => { seat(g, 'atreides').leaders[0].capturedBy = 'missing-seat'; },
|
|
(g) => { seat(g, 'atreides').leaders[0].gholaBy = 'ecaz'; },
|
|
(g) => { g.players[1].id = g.players[0].id; },
|
|
];
|
|
for (const change of changes) {
|
|
const g = fixture();
|
|
change(g);
|
|
const before = structuredClone(g);
|
|
assert.throws(() => moritaniTerrorAssassinationPool(g, 'atreides'));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|
|
|
|
void test('shared-Duke roster copies, altered identity and forbidden foreign-Ghola sources reject', () => {
|
|
const changes: ((g: TerrorFixture) => void)[] = [
|
|
(g) => { seat(g, 'ecaz').leaders.push(g.dukeVidal.leader); },
|
|
(g) => { seat(g, 'ecaz').leaders[0] = { ...g.dukeVidal.leader }; },
|
|
(g) => { g.dukeVidal.leader.id = 'fake-duke'; },
|
|
(g) => { g.dukeVidal.leader.strength = 7; },
|
|
(g) => { g.dukeVidal.leader.gholaBy = 'tleilaxu'; },
|
|
(g) => { g.dukeVidal.controller = 'missing-seat'; },
|
|
];
|
|
for (const change of changes) {
|
|
const g = fixture();
|
|
change(g);
|
|
const before = structuredClone(g);
|
|
assert.throws(() => moritaniTerrorAssassinationPool(g, 'ecaz'));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|