Allow Advanced BG preparation in phases2–5 at quiet boundaries, preserving original phase/auction/readiness and native eligibility. Close a current-turn receipt only on committed shipments, including marker and Sneak Attack entry. Retain the legacy phase5 queue and qualify the per-territory prevention policy. Connect persistent controls, minimal bot support and focused native evidence. Complete modes and browser/deployed acceptance remain open.
350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { type FactionId } from '../game/catalog';
|
|
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
|
import { createTechTokens } from '../game/tech-tokens';
|
|
|
|
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
const decide = (g: Game, extra: Omit<Action, 'type'>) =>
|
|
applyAction(g, 'm', { type: 'decision', ...extra });
|
|
function fixture(
|
|
kind: 'assassination' | 'sneakAttack' = 'assassination',
|
|
entrant: FactionId = 'emperor',
|
|
) {
|
|
const g = createGame('TERROR02', newPlayer('m', 'Moritani', 'moritani'));
|
|
g.players.push(
|
|
newPlayer('e', 'Entrant', entrant),
|
|
newPlayer('a', 'Atreides', 'atreides'),
|
|
);
|
|
g.status = 'playing';
|
|
g.phase = 5;
|
|
g.turn = 2;
|
|
g.storm = 18;
|
|
g.active = 'e';
|
|
g.order = ['e', 'm', 'a'];
|
|
g.movementRemaining = [...g.order];
|
|
for (const p of g.players) {
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
p.spice = 20;
|
|
p.hand = [];
|
|
}
|
|
g.moritaniTerror = createTerrorState(() => 0);
|
|
const token = g.moritaniTerror.tokens.find((t) => t.kind === kind)!;
|
|
g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1);
|
|
return g;
|
|
}
|
|
const enter = (g: Game) =>
|
|
applyAction(g, 'e', {
|
|
type: 'ship',
|
|
amount: 2,
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
});
|
|
const offered = (kind: 'assassination' | 'sneakAttack' = 'assassination') =>
|
|
enter(fixture(kind));
|
|
function rejected(g: Game, action: Action) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, 'm', action));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
const totalForces = (g: Game, id: string) => {
|
|
const p = player(g, id);
|
|
return (
|
|
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0)
|
|
);
|
|
};
|
|
|
|
void test('Assassination selects exactly one live ordinary leader, spends its token and leaves the client no victim choice', () => {
|
|
const g = offered();
|
|
const original = structuredClone(player(g, 'e').leaders);
|
|
assert.equal(original.length, 5);
|
|
const done = decide(g, {
|
|
reveal: true,
|
|
leader: 'not-a-leader-or-a-valid-choice',
|
|
});
|
|
const victims = player(done, 'e').leaders.filter((leader) => leader.dead);
|
|
assert.equal(victims.length, 1);
|
|
assert.ok(original.some((leader) => leader.id === victims[0].id));
|
|
assert.equal(victims[0].deaths, 1);
|
|
assert.equal(player(done, 'm').spice, 20 + victims[0].strength);
|
|
assert.equal(done.pendingTerrorEntry, null);
|
|
assert.equal(done.decision, null);
|
|
assert.equal(
|
|
done.moritaniTerror!.tokens.find((t) => t.kind === 'assassination')!.status,
|
|
'removed',
|
|
);
|
|
assert.deepEqual(player(g, 'e').leaders, original);
|
|
for (const leader of player(done, 'e').leaders.filter(
|
|
(leader) => !leader.dead,
|
|
))
|
|
assert.deepEqual(
|
|
leader,
|
|
original.find((l) => l.id === leader.id),
|
|
);
|
|
rejected(reload(done), { type: 'decision', reveal: true });
|
|
});
|
|
|
|
void test('each native leader is eligible when alone alive; death count and battle-use marker settle exactly once', () => {
|
|
for (let index = 0; index < 5; index++) {
|
|
const initial = fixture();
|
|
player(initial, 'e').leaders.forEach((leader, i) => {
|
|
leader.dead = i !== index;
|
|
leader.deaths = i === index ? 2 : 1;
|
|
leader.usedAt = 'carthag';
|
|
});
|
|
const g = enter(initial);
|
|
const before = structuredClone(player(g, 'e').leaders);
|
|
const done = decide(reload(g), { reveal: true });
|
|
const victim = player(done, 'e').leaders[index];
|
|
assert.equal(victim.dead, true);
|
|
assert.equal(victim.deaths, 3);
|
|
assert.equal(victim.usedAt, undefined);
|
|
assert.equal(player(done, 'm').spice, 20 + before[index].strength);
|
|
for (let other = 0; other < 5; other++)
|
|
if (other !== index)
|
|
assert.deepEqual(player(done, 'e').leaders[other], before[other]);
|
|
rejected(reload(done), { type: 'decision', reveal: true });
|
|
}
|
|
});
|
|
|
|
void test('Assassination pays the printed three for Zoal', () => {
|
|
const initial = fixture('assassination', 'tleilaxu');
|
|
const zoal = player(initial, 'e').leaders.find(
|
|
(leader) => leader.name === 'Zoal',
|
|
)!;
|
|
assert.ok(zoal);
|
|
for (const leader of player(initial, 'e').leaders)
|
|
leader.dead = leader.id !== zoal.id;
|
|
const done = decide(enter(initial), { reveal: true });
|
|
assert.equal(player(done, 'm').spice, 23);
|
|
assert.equal(
|
|
player(done, 'e').leaders.find((leader) => leader.id === zoal.id)!.dead,
|
|
true,
|
|
);
|
|
});
|
|
|
|
void test('malformed capture and Ghola assignments reject atomically without disclosing rival token faces', () => {
|
|
for (const field of ['capturedBy', 'gholaBy'] as const) {
|
|
for (const foreign of [false, true]) {
|
|
const initial = fixture();
|
|
if (foreign) player(initial, 'a').leaders[0][field] = 'e';
|
|
else player(initial, 'e').leaders[0][field] = 'a';
|
|
const g = enter(initial);
|
|
assert.equal('canReveal' in viewGame(g, 'e').terrorEntry!, false);
|
|
assert.equal('kind' in viewGame(g, 'a').terrorEntry!, false);
|
|
rejected(g, { type: 'decision', reveal: true });
|
|
const declined = decide(g, { decline: true });
|
|
assert.equal(declined.pendingTerrorEntry, null);
|
|
assert.deepEqual(declined.players, g.players);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('Assassination is inspectable only by its owner before revelation', () => {
|
|
const g = offered();
|
|
assert.equal(viewGame(g, 'm').terrorEntry!.kind, 'assassination');
|
|
assert.equal(viewGame(g, 'm').terrorEntry!.canReveal, true);
|
|
for (const id of ['e', 'a']) {
|
|
assert.equal('kind' in viewGame(g, id).terrorEntry!, false);
|
|
assert.equal('canReveal' in viewGame(g, id).terrorEntry!, false);
|
|
assert.ok(
|
|
viewGame(g, id).moritaniTerror!.tokens.every(
|
|
(token) => !('kind' in token),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('Sneak Attack deploys up to five reserves for free and does not consume ordinary shipment or movement', () => {
|
|
const g = offered('sneakAttack');
|
|
const owner = player(g, 'm');
|
|
owner.shipped = true;
|
|
owner.moved = 1;
|
|
const revealed = decide(g, { reveal: true });
|
|
assert.equal(revealed.pendingTerrorEntry?.stage, 'sneakAttack');
|
|
assert.equal(
|
|
revealed.moritaniTerror!.tokens.find(
|
|
(token) => token.kind === 'sneakAttack',
|
|
)!.status,
|
|
'removed',
|
|
);
|
|
assert.equal(player(revealed, 'm').reserves, 20);
|
|
const done = decide(reload(revealed), { amount: 5 });
|
|
assert.equal(player(done, 'm').forces['arrakeen:10'], 5);
|
|
assert.equal(player(done, 'm').reserves, 15);
|
|
assert.equal(player(done, 'm').spice, 20);
|
|
assert.equal(player(done, 'm').shipped, true);
|
|
assert.equal(player(done, 'm').moved, 1);
|
|
assert.equal(totalForces(done, 'm'), totalForces(g, 'm'));
|
|
assert.equal(done.pendingTerrorEntry, null);
|
|
assert.equal(done.decision, null);
|
|
assert.deepEqual(player(done, 'e'), player(g, 'e'));
|
|
rejected(reload(done), { type: 'decision', amount: 5 });
|
|
});
|
|
|
|
void test('Sneak Attack rejects invalid amounts without spending forces and zero completes without a new arrival', () => {
|
|
const revealed = decide(offered('sneakAttack'), { reveal: true });
|
|
for (const amount of [-1, 6, 1.5, '2', null, undefined, Infinity, NaN])
|
|
rejected(revealed, { type: 'decision', amount });
|
|
const limited = reload(revealed);
|
|
player(limited, 'm').reserves = 2;
|
|
rejected(limited, { type: 'decision', amount: 3 });
|
|
const deployed = decide(limited, { amount: 2 });
|
|
assert.equal(player(deployed, 'm').reserves, 0);
|
|
assert.equal(player(deployed, 'm').forces['arrakeen:10'], 2);
|
|
const none = decide(reload(revealed), { amount: 0 });
|
|
assert.deepEqual(none.players, revealed.players);
|
|
assert.equal(none.pendingTerrorEntry, null);
|
|
assert.equal(none.decision, null);
|
|
const empty = reload(revealed);
|
|
player(empty, 'm').reserves = 0;
|
|
rejected(empty, { type: 'decision', amount: 1 });
|
|
assert.equal(decide(empty, { amount: 0 }).pendingTerrorEntry, null);
|
|
});
|
|
|
|
void test('Sneak Attack respects storm, printed sector, and two-enemy stronghold occupancy', () => {
|
|
const revealed = decide(offered('sneakAttack'), { reveal: true });
|
|
const storm = reload(revealed);
|
|
storm.storm = 10;
|
|
rejected(storm, { type: 'decision', amount: 1 });
|
|
assert.equal(decide(storm, { amount: 0 }).pendingTerrorEntry, null);
|
|
const invalid = reload(revealed);
|
|
invalid.pendingTerrorEntry!.sector = 9;
|
|
rejected(invalid, { type: 'decision', amount: 1 });
|
|
const initial = fixture('sneakAttack');
|
|
player(initial, 'a').forces = { 'arrakeen:10': 1 };
|
|
player(initial, 'a').reserves = 19;
|
|
const crowded = decide(enter(initial), { reveal: true });
|
|
rejected(crowded, { type: 'decision', amount: 1 });
|
|
assert.equal(decide(crowded, { amount: 0 }).pendingTerrorEntry, null);
|
|
});
|
|
|
|
void test('Sneak Attack reinforces a stronghold already occupied by Moritani without duplicating its prior forces', () => {
|
|
const initial = fixture('sneakAttack');
|
|
player(initial, 'm').forces = { 'arrakeen:10': 2 };
|
|
player(initial, 'm').reserves = 18;
|
|
const g = decide(enter(initial), { reveal: true });
|
|
const done = decide(reload(g), { amount: 3 });
|
|
assert.equal(player(done, 'm').forces['arrakeen:10'], 5);
|
|
assert.equal(player(done, 'm').reserves, 15);
|
|
assert.equal(totalForces(done, 'm'), 20);
|
|
});
|
|
|
|
|
|
void test('unknown native leader identities cannot bypass Assassination; newly verified rosters are eligible', () => {
|
|
const unknown = fixture();
|
|
player(unknown, 'e').leaders[0].id = 'not-a-catalog-leader';
|
|
const offeredUnknown = enter(unknown);
|
|
rejected(offeredUnknown, { type: 'decision', reveal: true });
|
|
for (const faction of ['choam', 'richese'] as const) {
|
|
const known = enter(fixture('assassination', faction));
|
|
assert.equal(viewGame(known, 'm').terrorEntry!.canReveal, true);
|
|
const malformed = fixture('assassination', faction);
|
|
player(malformed, 'e').leaders[0].id = 'unverified-expansion-leader';
|
|
const missing = enter(malformed);
|
|
rejected(missing, { type: 'decision', reveal: true });
|
|
}
|
|
});
|
|
|
|
|
|
void test('Sneak Attack rejects an altered client sector and accrues Heighliners once for a positive entry while allowing zero', () => {
|
|
const revealed = decide(offered('sneakAttack'), { reveal: true });
|
|
rejected(revealed, { type: 'decision', amount: 1, sector: 9 });
|
|
const valid = decide(revealed, { amount: 1, sector: 10 });
|
|
assert.equal(player(valid, 'm').forces['arrakeen:10'], 1);
|
|
const technology = reload(revealed);
|
|
technology.techTokens = createTechTokens();
|
|
technology.techTokens.heighliners.owner = 'a';
|
|
technology.techTokens.heighliners.spice = 2;
|
|
const accrued = decide(technology, { amount: 1, sector: 10 });
|
|
assert.equal(player(accrued, 'm').forces['arrakeen:10'], 1);
|
|
assert.equal(accrued.techTokens!.heighliners.triggeredTurn, accrued.turn);
|
|
assert.equal(accrued.techTokens!.heighliners.spice, 1);
|
|
const passed = decide(technology, { amount: 0 });
|
|
assert.deepEqual(passed.techTokens, technology.techTokens);
|
|
assert.deepEqual(passed.players, technology.players);
|
|
assert.equal(passed.pendingTerrorEntry, null);
|
|
});
|
|
|
|
void test('Sneak Attack resolves the committed entry and then offers the Bene Gesserit arrival reactions', () => {
|
|
const initial = fixture('sneakAttack', 'beneGesserit');
|
|
initial.advanced = true;
|
|
player(initial, 'e').forces = { 'arrakeen:10': 1 };
|
|
player(initial, 'e').reserves = 19;
|
|
const revealed = decide(enter(initial), { reveal: true });
|
|
const done = decide(revealed, { amount: 1 });
|
|
assert.equal(player(done, 'm').forces['arrakeen:10'], 1);
|
|
assert.equal(player(done, 'm').reserves, 19);
|
|
assert.equal(done.pendingTerrorEntry, null);
|
|
assert.equal(done.decision?.kind, 'intrusion');
|
|
assert.equal(done.decision?.player, 'e');
|
|
const zero = decide(revealed, { amount: 0 });
|
|
assert.deepEqual(zero.players, revealed.players);
|
|
assert.equal(zero.pendingTerrorEntry, null);
|
|
assert.equal(zero.decision, null);
|
|
});
|
|
|
|
void test('declining the Bene Gesserit intrusion still offers the free advisor shipment and completes the entry', () => {
|
|
const initial = fixture('sneakAttack', 'beneGesserit');
|
|
initial.advanced = true;
|
|
player(initial, 'e').forces = { 'arrakeen:10': 1 };
|
|
player(initial, 'e').reserves = 19;
|
|
const revealed = decide(enter(initial), { reveal: true });
|
|
const sneaked = decide(revealed, { amount: 2 });
|
|
assert.equal(sneaked.decision?.kind, 'intrusion');
|
|
const declined = applyAction(sneaked, 'e', { type: 'decision', accept: false });
|
|
assert.equal(declined.decision?.kind, 'advisor');
|
|
assert.equal(declined.decision?.player, 'e');
|
|
const offered = applyAction(declined, 'e', {
|
|
type: 'decision',
|
|
accept: true,
|
|
amount: 1,
|
|
});
|
|
assert.equal(offered.response ?? null, null);
|
|
assert.equal(offered.pendingTerrorEntry, null);
|
|
assert.equal(player(offered, 'm').forces['arrakeen:10'], 2);
|
|
assert.equal(
|
|
player(offered, 'e').forces['polar_sink:0'] ?? 0,
|
|
1,
|
|
'The declined intrusion still lets the Bene Gesserit send one free reserve force.',
|
|
);
|
|
});
|
|
|
|
|
|
void test('only a positive Sneak Attack after ground entry closes native advisor preparation', () => {
|
|
// Controlled native entry board; the separate advisor fixture owns public
|
|
// setup chronology. No shipment occurs before the actual ground/Terror path.
|
|
for (const amount of [0, 1]) {
|
|
const g = fixture('sneakAttack');
|
|
g.advanced = true;
|
|
g.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
|
g.order.push('b');
|
|
g.movementRemaining!.push('b');
|
|
g.advisorPreparation = { turn: g.turn, firstShipment: null, deniedTerritories: [] };
|
|
player(g, 'e').forces = { 'imperial_basin:10': 1 };
|
|
player(g, 'e').reserves = 19;
|
|
const entered = applyAction(g, 'e', { type: 'move', from: 'imperial_basin:10',
|
|
territory: 'arrakeen', sector: 10, amount: 1 });
|
|
assert.equal(entered.advisorPreparation?.firstShipment, null);
|
|
assert.equal(player(entered, 'e').shipped, true, 'Ground movement spends the allowance without shipping');
|
|
const revealed = decide(entered, { reveal: true });
|
|
const done = decide(revealed, { amount });
|
|
assert.deepEqual(done.advisorPreparation?.firstShipment,
|
|
amount ? { player: 'm', producer: 'sneakAttack' } : null);
|
|
assert.equal(player(done, 'm').reserves, 20 - amount);
|
|
assert.equal(player(done, 'm').forces['arrakeen:10'] ?? 0, amount);
|
|
assert.equal(totalForces(done, 'm'), 20);
|
|
if (amount) assert.equal(done.decision?.kind, 'advisor', 'The clock closes before accompaniment');
|
|
}
|
|
});
|