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.
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { botArrivalBlock } from '../game/bot-arrival';
|
|
import { botActions } from '../game/bots';
|
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
|
import { createHomeworldCustody } from '../game/homeworld-custody';
|
|
import { homeworldContext } from '../game/homeworld-game';
|
|
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
|
import {
|
|
arrivalMove,
|
|
arrivalPlayer,
|
|
movementArrivalGame,
|
|
} from './deferred-movement-arrival-fixture';
|
|
import {
|
|
addShipmentArrivalAmbassador,
|
|
shipmentArrivalAction,
|
|
shipmentArrivalGame,
|
|
richeseNoFieldArrivalAction,
|
|
} from './fixture-deferred-shipment-arrival';
|
|
|
|
const overlap = /combined with another arrival reaction/;
|
|
const profiles = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
|
|
|
|
function ownView(game: Game, profile: (typeof profiles)[number] = 'Medium') {
|
|
const view = viewGame(game, game.active!);
|
|
view.players.find((player) => player.id === view.me)!.bot = profile;
|
|
return view;
|
|
}
|
|
|
|
void test('all four profiles avoid the reproduced Ambassador/Terror entries and keep an executable alternative', () => {
|
|
for (const shipment of [false, true]) {
|
|
const game = movementArrivalGame();
|
|
arrivalPlayer(game, game.active!).shipped = !shipment;
|
|
const action: Action = shipment ? shipmentArrivalAction : arrivalMove(game);
|
|
const before = JSON.stringify(game);
|
|
assert.throws(() => applyAction(game, game.active!, action), overlap);
|
|
for (const profile of profiles) {
|
|
const view = ownView(game, profile);
|
|
assert.match(botArrivalBlock(view, action) ?? '', overlap);
|
|
const actions = botActions(view);
|
|
assert.ok(actions.length, profile);
|
|
assert.equal(
|
|
actions.some(
|
|
(candidate) =>
|
|
['ship', 'move'].includes(candidate.type) &&
|
|
candidate.territory === 'carthag',
|
|
),
|
|
false,
|
|
);
|
|
const next = applyAction(game, view.me, actions[0]);
|
|
assert.ok(next);
|
|
assert.deepEqual(botActions(JSON.parse(JSON.stringify(view))), actions);
|
|
}
|
|
assert.equal(JSON.stringify(game), before);
|
|
}
|
|
});
|
|
|
|
void test('Homeworld-sourced AI shipments avoid unsupported paired entry reactions', () => {
|
|
const game = movementArrivalGame();
|
|
game.homeworlds = { custody: createHomeworldCustody(homeworldContext(game)) };
|
|
arrivalPlayer(game, 'tleilaxu').shipped = false;
|
|
const action: Action = {
|
|
type: 'ship',
|
|
territory: 'carthag',
|
|
sector: 11,
|
|
amount: 1,
|
|
elite: 0,
|
|
homeworldSources: { 'homeworld:tleilaxu': { normal: 1, elite: 0 } },
|
|
};
|
|
assert.throws(() => applyAction(game, 'tleilaxu', action), overlap);
|
|
const before = JSON.stringify(game);
|
|
for (const profile of profiles) {
|
|
const view = ownView(game, profile);
|
|
assert.match(botArrivalBlock(view, action) ?? '', overlap);
|
|
const choices = botActions(view);
|
|
assert.ok(choices.length, profile);
|
|
assert.equal(choices.some(candidate => candidate.type === 'ship' &&
|
|
candidate.territory === 'carthag'), false, profile);
|
|
assert.ok(applyAction(game, view.me, choices[0]));
|
|
}
|
|
assert.equal(JSON.stringify(game), before);
|
|
game.moritaniTerror = createTerrorState(() => 0.2);
|
|
assert.equal(botArrivalBlock(ownView(game), action), null);
|
|
});
|
|
|
|
void test('Guild cross-planet transport ships into a Terror arrival from its committed income response', () => {
|
|
const game = shipmentArrivalGame();
|
|
const bg = arrivalPlayer(game, 'bg');
|
|
const guild = arrivalPlayer(game, 'guild');
|
|
game.active = bg.id;
|
|
bg.shipped = false;
|
|
bg.ally = guild.id;
|
|
guild.ally = bg.id;
|
|
bg.forces = { 'sietch_tabr:14': 2 };
|
|
bg.reserves = 18;
|
|
const terror = createTerrorState(() => 0.2);
|
|
game.moritaniTerror = placeTerror(terror, terror.tokens[0].id, 'carthag', 2);
|
|
const action: Action = { type: 'guildShip', from: 'sietch_tabr:14', amount: 2,
|
|
territory: 'carthag', sector: 11 };
|
|
const before = JSON.stringify(game);
|
|
// Guild income from the ally went to the bank with nothing left pending, so the
|
|
// supported Terror entry opens in the same commit as the shipped forces.
|
|
const shipped = applyAction(game, bg.id, action);
|
|
const arrived = arrivalPlayer(shipped, 'bg');
|
|
assert.deepEqual(arrived.forces, { 'carthag:11': 2 });
|
|
assert.equal(arrived.shipped, true);
|
|
assert.equal(shipped.decision?.kind, 'moritaniTerror');
|
|
assert.equal(shipped.pendingTerrorEntry?.cause, 'guildTransport');
|
|
assert.equal(shipped.pendingArrivalReaction ?? null, null);
|
|
assert.equal(shipped.response ?? null, null);
|
|
assert.match(shipped.log.at(-1)!.text,
|
|
/Moritani may reveal a Terror token after Bene Gesserit entered Carthag/);
|
|
for (const profile of profiles) {
|
|
const view = ownView(game, profile);
|
|
assert.equal(botArrivalBlock(view, action), null);
|
|
const choices = botActions(view);
|
|
assert.ok(choices.length, profile);
|
|
assert.equal(choices.some(candidate => candidate.type === 'guildShip' &&
|
|
candidate.territory === 'carthag'), false, profile);
|
|
assert.ok(applyAction(game, bg.id, choices[0]));
|
|
}
|
|
assert.equal(JSON.stringify(game), before);
|
|
const funded = JSON.parse(before) as Game;
|
|
funded.aid[guild.id] = { recipient: bg.id, amount: 1 };
|
|
const guildPaid = { ...action, allyPayment: 1 };
|
|
assert.equal(botArrivalBlock(ownView(funded), guildPaid), null);
|
|
const paid = applyAction(funded, bg.id, guildPaid);
|
|
assert.equal(paid.decision?.kind, 'moritaniTerror');
|
|
assert.equal(paid.pendingTerrorEntry?.cause, 'guildTransport');
|
|
});
|
|
|
|
void test('single reactions and matching-faction Ambassador immunity remain playable', () => {
|
|
for (const tokens of ['ambassador', 'terror'] as const) {
|
|
const game = movementArrivalGame('tleilaxu', tokens);
|
|
assert.equal(botArrivalBlock(ownView(game), arrivalMove(game)), null);
|
|
assert.equal(
|
|
applyAction(game, game.active!, arrivalMove(game)).decision?.kind,
|
|
'choamMovement',
|
|
);
|
|
for (const profile of profiles)
|
|
assert.ok(
|
|
botActions(ownView(game, profile)).some(
|
|
(action) => action.type === 'move' && action.territory === 'carthag',
|
|
),
|
|
);
|
|
}
|
|
const fremen = movementArrivalGame('fremen');
|
|
assert.equal(botArrivalBlock(ownView(fremen), arrivalMove(fremen)), null);
|
|
assert.ok(applyAction(fremen, fremen.active!, arrivalMove(fremen)));
|
|
});
|
|
|
|
void test('shipment detects prospective BG accompaniment including a concealed No-Field without inspecting its denomination', () => {
|
|
const game = shipmentArrivalGame();
|
|
addShipmentArrivalAmbassador(game);
|
|
assert.match(
|
|
botArrivalBlock(ownView(game), shipmentArrivalAction) ?? '',
|
|
overlap,
|
|
);
|
|
assert.throws(
|
|
() => applyAction(game, game.active!, shipmentArrivalAction),
|
|
overlap,
|
|
);
|
|
game.active = 'richese';
|
|
arrivalPlayer(game, 'richese').shipped = false;
|
|
const marker = richeseNoFieldArrivalAction(game);
|
|
assert.match(botArrivalBlock(ownView(game), marker) ?? '', overlap);
|
|
assert.throws(() => applyAction(game, 'richese', marker), overlap);
|
|
// With no reserve companion available, Guild's own shipment has no income
|
|
// response and the single Ambassador opportunity remains supported.
|
|
const bg = arrivalPlayer(game, 'bg');
|
|
bg.forces = { 'polar_sink:0': 20 };
|
|
bg.reserves = 0;
|
|
game.active = 'guild';
|
|
assert.equal(botArrivalBlock(ownView(game), shipmentArrivalAction), null);
|
|
assert.ok(applyAction(game, 'guild', shipmentArrivalAction));
|
|
});
|
|
|
|
void test('Guild income preview preserves a shipment funded entirely by the Guild ally', () => {
|
|
const game = shipmentArrivalGame();
|
|
addShipmentArrivalAmbassador(game);
|
|
const bg = arrivalPlayer(game, 'bg');
|
|
bg.reserves = 0;
|
|
bg.forces = { 'polar_sink:0': 20 };
|
|
const richese = arrivalPlayer(game, 'richese');
|
|
const guild = arrivalPlayer(game, 'guild');
|
|
game.active = richese.id;
|
|
richese.shipped = false;
|
|
richese.ally = guild.id;
|
|
guild.ally = richese.id;
|
|
game.aid[guild.id] = { recipient: richese.id, amount: 2 };
|
|
const ownPaid = { ...shipmentArrivalAction, amount: 4, allyPayment: 0 };
|
|
assert.match(botArrivalBlock(ownView(game), ownPaid) ?? '', overlap);
|
|
assert.throws(() => applyAction(game, richese.id, ownPaid), overlap);
|
|
const guildPaid = { ...ownPaid, allyPayment: 2 };
|
|
assert.equal(botArrivalBlock(ownView(game), guildPaid), null);
|
|
assert.ok(applyAction(game, richese.id, guildPaid));
|
|
richese.spice = 0;
|
|
const automaticShare: Action = { ...guildPaid };
|
|
delete automaticShare.allyPayment;
|
|
assert.equal(botArrivalBlock(ownView(game), automaticShare), null);
|
|
assert.ok(applyAction(game, richese.id, automaticShare));
|
|
});
|
|
|
|
void test('Advanced BG fighter intrusion is distinguished from advisor presence', () => {
|
|
const game = shipmentArrivalGame();
|
|
addShipmentArrivalAmbassador(game);
|
|
const guild = arrivalPlayer(game, 'guild');
|
|
guild.forces = { 'arrakeen:10': 3 };
|
|
guild.reserves = 17;
|
|
guild.shipped = true;
|
|
const bg = arrivalPlayer(game, 'bg');
|
|
bg.forces = { 'carthag:11': 1 };
|
|
bg.reserves = 19;
|
|
const move = {
|
|
type: 'move',
|
|
from: 'arrakeen:10',
|
|
territory: 'carthag',
|
|
sector: 11,
|
|
amount: 3,
|
|
};
|
|
assert.match(botArrivalBlock(ownView(game), move) ?? '', overlap);
|
|
assert.throws(() => applyAction(game, guild.id, move), overlap);
|
|
bg.advisors = { carthag: {} };
|
|
// Advisors need another faction present or automatic stance settlement
|
|
// correctly makes this lone group fighters before either quote.
|
|
const moritani = arrivalPlayer(game, 'moritani');
|
|
moritani.forces = { 'carthag:11': 1 };
|
|
moritani.reserves = 19;
|
|
assert.equal(botArrivalBlock(ownView(game), move), null);
|
|
assert.ok(applyAction(game, guild.id, move));
|
|
});
|
|
|
|
void test('arrival filtering never reads hidden rival resources or Terror faces and does not mutate the view', () => {
|
|
const game = movementArrivalGame();
|
|
const view = ownView(game);
|
|
const original = JSON.stringify(view);
|
|
for (const player of view.players.filter((player) => player.id !== view.me))
|
|
for (const field of ['hand', 'spice', 'traitors', 'faceDancers'])
|
|
Object.defineProperty(player, field, {
|
|
configurable: true,
|
|
get: () => {
|
|
throw Error(`Private ${field} read`);
|
|
},
|
|
});
|
|
for (const token of view.moritaniTerror!.tokens)
|
|
Object.defineProperty(token, 'kind', {
|
|
configurable: true,
|
|
get: () => {
|
|
throw Error('Hidden Terror face read');
|
|
},
|
|
});
|
|
assert.match(botArrivalBlock(view, arrivalMove(game)) ?? '', overlap);
|
|
assert.match(botArrivalBlock(view, shipmentArrivalAction) ?? '', overlap);
|
|
for (const player of view.players.filter((player) => player.id !== view.me))
|
|
for (const field of ['hand', 'spice', 'traitors', 'faceDancers'])
|
|
Reflect.deleteProperty(player, field);
|
|
// Restore public removed-token faces from the original before comparing.
|
|
view.moritaniTerror = JSON.parse(original).moritaniTerror;
|
|
assert.equal(JSON.stringify(view), original);
|
|
});
|