Queue a committed Terror entry whose arrival also opened its own
interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor
choice, or Fremen storm protection — in the additive
Game.pendingArrivalReaction list, and open the oldest queued entry from
settleAutomaticContinuations once nothing is pending. Payment, force
transfer and the original movement never replay; a Terror entry already in
flight and every Ambassador combination still reject.
Both withdrawn mixed E3 programme pairs are green: Moritani 9/9 and Ecaz
14/14, stable across 20 consecutive full-file runs. Their recorded resume
constraints became real fixture repairs, and the two restored consumer
omissions were corrected against the engine rather than re-pinned.
Repair pre-existing failures inherited at 45e6084 against current engine
behavior: stale Discovery and module gates in the native skills, Ix and
expansion-faction prototypes, the advanced Moritani skill boundary, the
Richese Nexus betrayal sale-credit expectation, one incidental gate
message and one flaky Collection-readiness assertion that reproduced at
HEAD.
Offline suite 7,793/7,793 with typecheck and lint clean. No game, seat,
saved-state, deployment, public-start or played-save conversion changes.
645 lines
21 KiB
TypeScript
645 lines
21 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { runInNewContext } from 'node:vm';
|
|
import ts from 'typescript';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Game,
|
|
type Action,
|
|
} from '../game/engine';
|
|
import { baseDeck, spiceDeck } from '../game/cards';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { createHomeworldCustody } from '../game/homeworld-custody';
|
|
import {
|
|
homeworldContext,
|
|
homeworldGameIntegrity,
|
|
} from '../game/homeworld-game';
|
|
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
|
import {
|
|
createRicheseNoField,
|
|
deployRicheseNoField,
|
|
} from '../game/richese-no-field';
|
|
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
|
|
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
/** Explicit Moritani runtime audit setup: real card/token identities and typed
|
|
* force custody, without claiming that the full expansion start is enabled. */
|
|
function fixture(
|
|
reserves = 7,
|
|
entrant: FactionId = 'emperor',
|
|
advanced = false,
|
|
enabled = true,
|
|
) {
|
|
const g = createGame(
|
|
'GRUMMANENTRY',
|
|
newPlayer('m', 'Moritani', 'moritani'),
|
|
advanced,
|
|
);
|
|
g.players.push(
|
|
newPlayer('e', 'Entrant', entrant),
|
|
newPlayer('a', 'Observer', 'atreides'),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'e',
|
|
order: ['e', 'm', 'a'],
|
|
movementRemaining: ['e', 'm', 'a'],
|
|
deck: baseDeck(),
|
|
spiceDeck: spiceDeck(),
|
|
phaseOpening: null,
|
|
});
|
|
for (const p of g.players) {
|
|
Object.assign(p, {
|
|
forces: {},
|
|
reserves: 20,
|
|
tanks: 0,
|
|
spice: 20,
|
|
hand: [],
|
|
traitors: [],
|
|
traitorChoices: [],
|
|
});
|
|
const special =
|
|
p.faction === 'emperor' ? 5 : p.faction === 'fremen' ? 3 : 0;
|
|
if (special)
|
|
p.elites = { reserves: special, tanks: 0, forces: {}, revived: 0 };
|
|
}
|
|
player(g, 'm').reserves = reserves;
|
|
player(g, 'm').forces = { 'polar_sink:0': 20 - reserves };
|
|
g.moritaniTerror = createTerrorState(() => 0);
|
|
const token = g.moritaniTerror.tokens.find((t) => t.kind === 'robbery')!;
|
|
g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1);
|
|
if (enabled)
|
|
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
|
|
homeworldGameIntegrity(g);
|
|
return g;
|
|
}
|
|
const ship = (g: Game, amount: number, elite = 0) =>
|
|
applyAction(g, 'e', {
|
|
type: 'ship',
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
amount,
|
|
elite,
|
|
});
|
|
function reject(g: Game, id: string, action: Action, match: RegExp) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, id, action), match);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
function markerObserver(g: Game) {
|
|
const view = viewGame(g, 'a');
|
|
// Each independently accepted move creates a random selection fence. Its
|
|
// identity is unrelated to the marker's hidden value; retain all other data.
|
|
assert.ok(view.richeseNoField?.event);
|
|
view.richeseNoField!.event = 'accepted-marker-event';
|
|
return view;
|
|
}
|
|
|
|
void test('low Grumman skips two-force entry and permits three in both modes without consuming Terror', () => {
|
|
for (const advanced of [false, true])
|
|
for (const reserves of [7, 8])
|
|
for (const amount of [2, 3]) {
|
|
const initial = fixture(reserves, 'emperor', advanced);
|
|
const token = initial.moritaniTerror!.tokens.find(
|
|
(t) => t.status === 'placed',
|
|
)!;
|
|
const result = ship(initial, amount);
|
|
assert.equal(!!result.pendingTerrorEntry, reserves >= 8 || amount >= 3);
|
|
assert.equal(player(result, 'e').forces['arrakeen:10'], amount);
|
|
assert.equal(player(result, 'e').reserves, 20 - amount);
|
|
assert.equal(
|
|
result.moritaniTerror!.tokens.find((t) => t.id === token.id)!.status,
|
|
'placed',
|
|
);
|
|
homeworldGameIntegrity(result);
|
|
if (result.pendingTerrorEntry) {
|
|
assert.equal(result.pendingTerrorEntry.amount, amount);
|
|
const declined = applyAction(reload(result), 'm', {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
assert.equal(declined.pendingTerrorEntry, null);
|
|
assert.equal(player(declined, 'e').reserves, 20 - amount);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('disabled Homeworlds preserve ordinary Terror on a one-force entry', () => {
|
|
const result = ship(fixture(7, 'emperor', false, false), 1);
|
|
assert.equal(result.pendingTerrorEntry?.amount, 1);
|
|
assert.equal(viewGame(result, 'm').terrorEntry?.canReveal, true);
|
|
});
|
|
|
|
void test('elite counters are included once and existing destination forces do not satisfy the entry threshold', () => {
|
|
for (const amount of [2, 3]) {
|
|
const g = fixture(7, 'emperor', true);
|
|
const p = player(g, 'e');
|
|
p.elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
|
|
p.reserves = 15;
|
|
p.forces = { 'arrakeen:10': 5 };
|
|
const result = ship(g, amount, 2);
|
|
assert.equal(!!result.pendingTerrorEntry, amount === 3);
|
|
assert.equal(player(result, 'e').forces['arrakeen:10'], 5 + amount);
|
|
assert.equal(player(result, 'e').elites!.forces['arrakeen:10'], 2);
|
|
homeworldGameIntegrity(result);
|
|
}
|
|
});
|
|
|
|
void test('Bene Gesserit advisors count as entering forces while two remain below Grumman minimum', () => {
|
|
for (const amount of [2, 3]) {
|
|
const g = fixture(7, 'beneGesserit', true);
|
|
player(g, 'a').reserves--;
|
|
player(g, 'a').forces = { 'arrakeen:10': 1 };
|
|
player(g, 'e').reserves--;
|
|
player(g, 'e').forces = { 'arrakeen:10': 1 };
|
|
player(g, 'e').advisors = { arrakeen: {} };
|
|
const result = ship(g, amount);
|
|
assert.ok(player(result, 'e').advisors?.arrakeen);
|
|
assert.equal(!!result.pendingTerrorEntry, amount === 3);
|
|
homeworldGameIntegrity(result);
|
|
}
|
|
});
|
|
|
|
void test('a saved reveal and alliance offer recheck low Grumman without mutating the committed arrival', () => {
|
|
const g = ship(fixture(8), 2);
|
|
player(g, 'm').reserves--;
|
|
player(g, 'm').forces['polar_sink:0']++;
|
|
const restored = normalizeAutomaticGame(reload(g));
|
|
const view = viewGame(restored, 'm').terrorEntry!;
|
|
assert.equal(view.canReveal, false);
|
|
assert.match(view.revealBlocked!, /three forces/);
|
|
assert.equal(view.canOfferAlliance, false);
|
|
reject(restored, 'm', { type: 'decision', reveal: true }, /three forces/);
|
|
reject(
|
|
restored,
|
|
'm',
|
|
{ type: 'decision', alliance: true },
|
|
/mandatory effect/,
|
|
);
|
|
const declined = applyAction(restored, 'm', {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
assert.equal(player(declined, 'e').reserves, 18);
|
|
assert.equal(player(declined, 'e').forces['arrakeen:10'], 2);
|
|
});
|
|
|
|
void test('concealed zero, three and five No-Field shipments have the same public one-force Terror eligibility', () => {
|
|
const views = [];
|
|
for (const value of [0, 3, 5]) {
|
|
const g = fixture(7, 'richese');
|
|
g.expansions = ['choam'];
|
|
const p = player(g, 'e');
|
|
p.noField = createRicheseNoField(['zero', 'three', 'five']);
|
|
p.noFieldEvent = 'same-private-event';
|
|
const token = p.noField.tokens.find((t) => t.value === value)!;
|
|
const result = applyAction(g, 'e', {
|
|
type: 'ship',
|
|
noField: token.id,
|
|
event: p.noFieldEvent,
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
});
|
|
assert.equal(result.pendingTerrorEntry ?? null, null);
|
|
assert.equal(player(result, 'e').reserves, 20);
|
|
assert.equal(player(result, 'e').noField!.deployed!.tokenId, token.id);
|
|
assert.equal(player(result, 'e').spice, 19);
|
|
homeworldGameIntegrity(result);
|
|
views.push(markerObserver(result));
|
|
}
|
|
assert.deepEqual(views[0], views[1]);
|
|
assert.deepEqual(views[1], views[2]);
|
|
});
|
|
|
|
void test('a moved No-Field counts once with two physical forces, independently of its hidden value', () => {
|
|
for (const amount of [0, 1, 2]) {
|
|
const views = [];
|
|
for (const value of [0, 3, 5]) {
|
|
const g = fixture(7, 'richese');
|
|
g.expansions = ['choam'];
|
|
const p = player(g, 'e');
|
|
p.noField = createRicheseNoField(['zero', 'three', 'five']);
|
|
const token = p.noField.tokens.find((t) => t.value === value)!;
|
|
p.noField = deployRicheseNoField(p.noField, {
|
|
tokenId: token.id,
|
|
controller: p.id,
|
|
location: { territory: 'imperial_basin', sector: 10 },
|
|
});
|
|
p.noFieldEvent = 'same-private-event';
|
|
p.forces = { 'imperial_basin:10': 2 };
|
|
p.reserves = 18;
|
|
p.shipped = true;
|
|
const result = applyAction(g, 'e', {
|
|
type: 'move',
|
|
forces: amount ? { 'imperial_basin:10': amount } : {},
|
|
noField: token.id,
|
|
event: p.noFieldEvent,
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
});
|
|
assert.equal(!!result.pendingTerrorEntry, amount === 2);
|
|
if (result.pendingTerrorEntry)
|
|
assert.equal(result.pendingTerrorEntry.amount, 3);
|
|
assert.equal(player(result, 'e').reserves, 18);
|
|
assert.equal(player(result, 'e').noField!.deployed!.tokenId, token.id);
|
|
homeworldGameIntegrity(result);
|
|
views.push(markerObserver(result));
|
|
}
|
|
assert.deepEqual(views[0], views[1]);
|
|
assert.deepEqual(views[1], views[2]);
|
|
}
|
|
});
|
|
|
|
function ambassadorFixture(effect: 'guild' | 'fremen', reserves = 7) {
|
|
let g = fixture(reserves, 'emperor', true);
|
|
g.players[2] = newPlayer('a', 'Ecaz', 'ecaz');
|
|
Object.assign(player(g, 'a'), {
|
|
reserves: 16,
|
|
forces: { 'imperial_basin:10': 4 },
|
|
spice: 20,
|
|
hand: [],
|
|
traitors: [],
|
|
});
|
|
const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit');
|
|
Object.assign(bg, {
|
|
reserves: 19,
|
|
forces: { 'carthag:11': 1 },
|
|
hand: [],
|
|
traitors: [],
|
|
});
|
|
g.players.push(bg);
|
|
g.order.push(bg.id);
|
|
g.moritaniTerror!.tokens.find(
|
|
(token) => token.status === 'placed',
|
|
)!.location = 'carthag';
|
|
const inventory = createAmbassadors(() => 0);
|
|
const token = inventory.tokens.find(
|
|
(candidate) => candidate.effect === effect,
|
|
)!;
|
|
inventory.cohort = [
|
|
token.id,
|
|
...inventory.tokens
|
|
.filter(
|
|
(candidate) => candidate.effect !== 'ecaz' && candidate.id !== token.id,
|
|
)
|
|
.slice(0, 4)
|
|
.map((candidate) => candidate.id),
|
|
];
|
|
for (const candidate of inventory.tokens) {
|
|
candidate.zone =
|
|
candidate.effect === 'ecaz' || inventory.cohort.includes(candidate.id)
|
|
? 'supply'
|
|
: 'pool';
|
|
candidate.location = null;
|
|
}
|
|
g.ecazAmbassadors = placeAmbassador(inventory, token.id, {
|
|
turn: 1,
|
|
availableSpice: 20,
|
|
destination: {
|
|
id: 'arrakeen',
|
|
stronghold: true,
|
|
inStorm: false,
|
|
allowed: true,
|
|
},
|
|
}).state;
|
|
player(g, 'e').reserves = 19;
|
|
player(g, 'e').forces = { 'imperial_basin:10': 1 };
|
|
player(g, 'e').shipped = true;
|
|
g = applyAction(g, 'e', {
|
|
type: 'move',
|
|
forces: { 'imperial_basin:10': 1 },
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
});
|
|
assert.equal(g.pendingAmbassador?.stage, 'offer');
|
|
g = applyAction(g, 'a', {
|
|
type: 'decision',
|
|
event: g.pendingAmbassador!.event,
|
|
trigger: true,
|
|
beneficiary: 'a',
|
|
});
|
|
assert.equal(
|
|
g.pendingAmbassador?.stage,
|
|
effect === 'guild' ? 'ship' : 'move',
|
|
);
|
|
homeworldGameIntegrity(g);
|
|
return g;
|
|
}
|
|
|
|
void test('Guild and Fremen Ambassador views expose the legal two-force cap and server rejects larger batches before mutation', () => {
|
|
for (const effect of ['guild', 'fremen'] as const) {
|
|
const g = ambassadorFixture(effect);
|
|
const entry = viewGame(g, 'a').ambassadorEntry!;
|
|
const destinations =
|
|
effect === 'guild'
|
|
? entry.shipment!.destinations
|
|
: entry.movement!.sources.find(
|
|
(source) => source.territory === 'imperial_basin',
|
|
)!.destinations;
|
|
const destination = destinations.find(
|
|
(candidate) => candidate.territory === 'carthag',
|
|
)!;
|
|
assert.equal(destination.blocked, null);
|
|
assert.equal(destination.maximum, 2);
|
|
const action: Action = {
|
|
type: 'decision',
|
|
event: entry.event,
|
|
territory: 'carthag',
|
|
sector: 11,
|
|
...(effect === 'guild'
|
|
? { amount: 3 }
|
|
: { forces: { 'imperial_basin:10': 3 } }),
|
|
};
|
|
reject(g, 'a', action, /simultaneous Intrusion and Terror/);
|
|
const legal = applyAction(reload(g), 'a', {
|
|
...action,
|
|
...(effect === 'guild'
|
|
? { amount: 2 }
|
|
: { forces: { 'imperial_basin:10': 2 } }),
|
|
});
|
|
assert.equal(legal.decision?.kind, 'intrusion');
|
|
assert.equal(legal.pendingTerrorEntry ?? null, null);
|
|
assert.equal(player(legal, 'a').forces['carthag:11'], 2);
|
|
homeworldGameIntegrity(legal);
|
|
const high = viewGame(ambassadorFixture(effect, 8), 'a').ambassadorEntry!;
|
|
const highDestinations =
|
|
effect === 'guild'
|
|
? high.shipment!.destinations
|
|
: high.movement!.sources.find(
|
|
(source) => source.territory === 'imperial_basin',
|
|
)!.destinations;
|
|
assert.match(
|
|
highDestinations.find((candidate) => candidate.territory === 'carthag')!
|
|
.blocked!,
|
|
/simultaneous Intrusion and Terror/,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('all four bots respect the projected count cap for both Ambassador routes', () => {
|
|
for (const effect of ['guild', 'fremen'] as const)
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = ambassadorFixture(effect);
|
|
const view = viewGame(g, 'a');
|
|
view.players.find((p) => p.id === 'a')!.bot = difficulty;
|
|
const entry = view.ambassadorEntry!;
|
|
if (effect === 'guild')
|
|
entry.shipment!.destinations = entry.shipment!.destinations.filter(
|
|
(d) => d.territory === 'carthag',
|
|
);
|
|
else
|
|
for (const source of entry.movement!.sources)
|
|
source.destinations = source.destinations.filter(
|
|
(d) => d.territory === 'carthag',
|
|
);
|
|
const action = botActions(view)[0];
|
|
assert.ok(action);
|
|
const amount =
|
|
effect === 'guild'
|
|
? action.amount
|
|
: Object.values(action.forces as Record<string, number>).reduce(
|
|
(sum, n) => sum + n,
|
|
0,
|
|
);
|
|
assert.equal(amount, 2);
|
|
const result = applyAction(g, 'a', action);
|
|
assert.equal(result.decision?.kind, 'intrusion');
|
|
assert.equal(player(result, 'a').forces['carthag:11'], 2);
|
|
}
|
|
});
|
|
|
|
void test('movement preflight commits each Intrusion arrival and defers its Terror entry behind the pending Intrusion', () => {
|
|
for (const reserves of [7, 8]) {
|
|
const g = fixture(reserves, 'emperor', true);
|
|
const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit');
|
|
bg.reserves = 19;
|
|
bg.forces = { 'arrakeen:10': 1 };
|
|
g.players.push(bg);
|
|
g.order.push(bg.id);
|
|
const p = player(g, 'e');
|
|
p.reserves = 17;
|
|
p.forces = { 'imperial_basin:10': 3 };
|
|
p.shipped = true;
|
|
const move: Action = {
|
|
type: 'move',
|
|
forces: { 'imperial_basin:10': 2 },
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
};
|
|
if (reserves === 7) {
|
|
const result = applyAction(g, 'e', move);
|
|
assert.ok(result.decision);
|
|
assert.equal(result.pendingTerrorEntry ?? null, null);
|
|
assert.equal(player(result, 'e').forces['arrakeen:10'], 2);
|
|
homeworldGameIntegrity(result);
|
|
} else {
|
|
const result = applyAction(g, 'e', move);
|
|
assert.equal(result.decision?.kind, 'intrusion');
|
|
assert.equal(result.pendingArrivalReaction?.[0]?.amount, 2,
|
|
'The committed entry waits for the Intrusion choice.');
|
|
assert.equal(result.pendingTerrorEntry ?? null, null);
|
|
assert.equal(player(result, 'e').forces['arrakeen:10'], 2);
|
|
assert.equal(player(result, 'e').forces['imperial_basin:10'], 1);
|
|
homeworldGameIntegrity(result);
|
|
}
|
|
const three = applyAction(g, 'e', { ...move, forces: { 'imperial_basin:10': 3 } });
|
|
assert.equal(three.decision?.kind, 'intrusion');
|
|
assert.equal(three.pendingArrivalReaction?.[0]?.amount, 3,
|
|
'The whole committed group is one deferred entry.');
|
|
assert.equal(three.pendingTerrorEntry ?? null, null);
|
|
assert.equal(player(three, 'e').forces['arrakeen:10'], 3);
|
|
homeworldGameIntegrity(three);
|
|
}
|
|
});
|
|
|
|
void test('modern Terror receipts reject altered original count, actor, location and cause before view, action or normalization', () => {
|
|
const g = ship(fixture(8), 2);
|
|
player(g, 'm').reserves--;
|
|
player(g, 'm').forces['polar_sink:0']++;
|
|
assert.equal(typeof g.pendingTerrorEntry!.entrySignature, 'string');
|
|
const edits: ((entry: NonNullable<Game['pendingTerrorEntry']>) => void)[] = [
|
|
(entry) => {
|
|
entry.amount = 3;
|
|
},
|
|
(entry) => {
|
|
entry.amount = -1;
|
|
},
|
|
(entry) => {
|
|
entry.elite = 1;
|
|
},
|
|
(entry) => {
|
|
entry.entrant = 'a';
|
|
},
|
|
(entry) => {
|
|
entry.token = 'another-token';
|
|
},
|
|
(entry) => {
|
|
entry.territory = 'carthag';
|
|
entry.sector = 11;
|
|
},
|
|
(entry) => {
|
|
entry.cause = 'movement';
|
|
},
|
|
(entry) => {
|
|
entry.turn++;
|
|
},
|
|
(entry) => {
|
|
entry.phase = 1;
|
|
},
|
|
(entry) => {
|
|
entry.resume = 'wormRide';
|
|
},
|
|
(entry) => {
|
|
entry.ambassadorEvent = 'fake-parent';
|
|
},
|
|
(entry) => {
|
|
entry.entrySignature = 'forged';
|
|
},
|
|
];
|
|
for (const edit of edits) {
|
|
const corrupted = reload(g);
|
|
edit(corrupted.pendingTerrorEntry!);
|
|
const before = structuredClone(corrupted);
|
|
assert.throws(
|
|
() => viewGame(corrupted, 'm'),
|
|
/original public arrival receipt/,
|
|
);
|
|
assert.throws(
|
|
() => normalizeAutomaticGame(corrupted),
|
|
/original public arrival receipt/,
|
|
);
|
|
reject(
|
|
corrupted,
|
|
'm',
|
|
{ type: 'decision', reveal: true },
|
|
/original public arrival receipt/,
|
|
);
|
|
assert.deepEqual(corrupted, before);
|
|
}
|
|
// Missing receipts remain the explicit older-save boundary, not fabricated
|
|
// evidence derived from the two currently present forces.
|
|
const legacy = reload(g);
|
|
delete legacy.pendingTerrorEntry!.entrySignature;
|
|
assert.equal(viewGame(legacy, 'm').terrorEntry!.canReveal, false);
|
|
assert.equal(
|
|
normalizeAutomaticGame(legacy).pendingTerrorEntry!.entrySignature,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
void test('the original Terror receipt survives alliance cancellation and rejects corruption in actual suspended discard cleanup', () => {
|
|
const allianceSetup = fixture(8);
|
|
const karamaIndex = allianceSetup.deck.findIndex(
|
|
(card) => card.effect === 'karama',
|
|
);
|
|
assert.ok(karamaIndex >= 0);
|
|
const karama = allianceSetup.deck.splice(karamaIndex, 1)[0];
|
|
player(allianceSetup, 'a').hand.push(karama);
|
|
const alliance = ship(allianceSetup, 2);
|
|
const signature = alliance.pendingTerrorEntry!.entrySignature;
|
|
const offered = applyAction(alliance, 'm', {
|
|
type: 'decision',
|
|
alliance: true,
|
|
});
|
|
assert.equal(offered.pendingTerrorEntry!.entrySignature, signature);
|
|
assert.ok(
|
|
['allianceResponse', 'allianceReply'].includes(
|
|
offered.pendingTerrorEntry!.stage,
|
|
),
|
|
);
|
|
assert.equal(offered.response?.kind, 'moritaniAlliance');
|
|
const canceled = applyAction(offered, 'a', {
|
|
type: 'card',
|
|
card: karama.id,
|
|
mode: 'cancel',
|
|
});
|
|
assert.equal(canceled.pendingTerrorEntry!.stage, 'offer');
|
|
assert.equal(canceled.pendingTerrorEntry!.allianceBlocked, true);
|
|
assert.equal(canceled.pendingTerrorEntry!.entrySignature, signature);
|
|
const corruptedOffer = reload(offered);
|
|
corruptedOffer.pendingTerrorEntry!.amount = 3;
|
|
assert.throws(
|
|
() => viewGame(corruptedOffer, 'e'),
|
|
/original public arrival receipt/,
|
|
);
|
|
|
|
const initial = fixture(7);
|
|
const robbery = initial.moritaniTerror!.tokens.find(
|
|
(token) => token.status === 'placed',
|
|
)!;
|
|
const sabotage = initial.moritaniTerror!.tokens.find(
|
|
(token) => token.kind === 'sabotage',
|
|
)!;
|
|
robbery.status = 'available';
|
|
robbery.location = null;
|
|
sabotage.status = 'placed';
|
|
sabotage.location = 'arrakeen';
|
|
player(initial, 'e').hand.push(initial.deck.shift()!);
|
|
const arrived = ship(initial, 3);
|
|
// Observe the production dispatcher before its public wrapper drains the
|
|
// durable discard. Normalization and corruption checks use public exports.
|
|
const observed: {
|
|
applyActionInner?: (g: Game, id: string, action: Action) => Game;
|
|
} = {};
|
|
runInNewContext(
|
|
ts.transpileModule(
|
|
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
|
|
'\nexport { applyActionInner };\n',
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText,
|
|
{
|
|
exports: observed,
|
|
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
|
|
crypto,
|
|
structuredClone,
|
|
TextEncoder,
|
|
JSON,
|
|
},
|
|
);
|
|
const pending = reload(
|
|
observed.applyActionInner!(arrived, 'm', {
|
|
type: 'decision',
|
|
reveal: true,
|
|
}),
|
|
);
|
|
const continuation = pending.pendingTreacheryDiscard!.continuation;
|
|
assert.equal(continuation.kind, 'terrorDiscard');
|
|
if (continuation.kind !== 'terrorDiscard')
|
|
throw Error('Missing actual Terror discard.');
|
|
assert.equal(
|
|
continuation.entry.entrySignature,
|
|
arrived.pendingTerrorEntry!.entrySignature,
|
|
);
|
|
assert.equal(pending.pendingTerrorEntry, null);
|
|
const completed = normalizeAutomaticGame(reload(pending));
|
|
assert.equal(completed.pendingTreacheryDiscard, null);
|
|
continuation.entry.amount = 2;
|
|
const before = structuredClone(pending);
|
|
assert.throws(
|
|
() => viewGame(pending, 'm'),
|
|
/original public arrival receipt/,
|
|
);
|
|
assert.throws(
|
|
() => normalizeAutomaticGame(pending),
|
|
/original public arrival receipt/,
|
|
);
|
|
assert.deepEqual(pending, before);
|
|
});
|