276 lines
8.5 KiB
TypeScript
276 lines
8.5 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeNexusGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
type Action,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { drawNexusCard } from '../game/nexus-cards';
|
|
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
|
import { TERROR_KINDS, type TerrorKind } from '../game/moritani-terror';
|
|
import { TERRITORIES } from '../game/board';
|
|
import { nexusTraitorInventory } from './fixture-nexus-traitors';
|
|
import {
|
|
enterNexusSpice,
|
|
finishNexusSpice,
|
|
orderNexusSpice,
|
|
nexusAllow,
|
|
nexusReload,
|
|
} from './fixture-nexus-cards';
|
|
|
|
export type NexusMoritaniFixture = {
|
|
g: Game;
|
|
owner: string;
|
|
target: string;
|
|
observer: string;
|
|
karama?: string;
|
|
};
|
|
/** Final faction/seat/module choices precede genuine initialization. The later
|
|
* conserved position is staged before actual Collection -> Mentat actions. */
|
|
export function nexusMoritaniFixture(
|
|
options: {
|
|
seatIds?: [string, string, string];
|
|
advanced?: boolean;
|
|
homeworlds?: boolean;
|
|
native?: number;
|
|
karama?: boolean;
|
|
stack?: boolean;
|
|
cardOwner?: string;
|
|
} = {},
|
|
): NexusMoritaniFixture {
|
|
const [owner, target, observer] = options.seatIds ?? ['p', 'q', 'r'];
|
|
let g = createGame(
|
|
'MORITANINEXUS',
|
|
newPlayer(owner, 'Moritani', 'moritani'),
|
|
options.advanced ?? false,
|
|
[],
|
|
);
|
|
joinGame(g, newPlayer(target, 'Guild', 'guild'));
|
|
joinGame(g, newPlayer(observer, 'Fremen', 'fremen'));
|
|
if (options.homeworlds)
|
|
g = applyAction(g, owner, { type: 'homeworlds', enabled: true });
|
|
g.nexusCards = { cards: null, phase: null };
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
g = initializeNexusGameForAudit(g);
|
|
for (let i = 0; g.status === 'setup' && i < 80; i++) {
|
|
let next: Game | undefined;
|
|
for (const p of g.players) {
|
|
const v = viewGame(g, p.id);
|
|
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
|
|
const a = botActions(v)[0];
|
|
if (a) {
|
|
next = applyAction(g, p.id, a);
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`);
|
|
g = next;
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
for (const p of g.players) g.deck.push(...p.hand.splice(0));
|
|
g = enterNexusSpice(g);
|
|
orderNexusSpice(g, ['land', 'land']);
|
|
g = finishNexusSpice(g);
|
|
for (const p of g.players) {
|
|
Object.assign(p, {
|
|
forces: {},
|
|
reserves: 20,
|
|
tanks: 0,
|
|
spice: 20,
|
|
shipped: true,
|
|
moved: 0,
|
|
});
|
|
if (p.elites)
|
|
Object.assign(p.elites, { forces: {}, reserves: 3, tanks: 0 });
|
|
}
|
|
// Complete the first genuine Mentat opportunity before staging turn two.
|
|
// Stack fixtures actually place Sabotage at that first opportunity.
|
|
Object.assign(g, {
|
|
phase: 5,
|
|
active: target,
|
|
movementRemaining: [target],
|
|
order: [owner, target, observer],
|
|
ready: [],
|
|
response: null,
|
|
decision: null,
|
|
phaseOpening: null,
|
|
spice: {},
|
|
storm: 18,
|
|
});
|
|
g = applyAction(g, target, { type: 'endMovement' });
|
|
if (g.decision?.kind === 'grummanCollection')
|
|
g = applyAction(g, owner, {
|
|
type: 'decision',
|
|
event: g.grummanCollection!.event,
|
|
decline: true,
|
|
});
|
|
for (const id of g.order) g = applyAction(g, id, { type: 'ready' });
|
|
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
|
g = options.stack
|
|
? nexusAllow(
|
|
applyAction(g, owner, {
|
|
type: 'decision',
|
|
token: nexusMoritaniToken(g, 'sabotage').id,
|
|
territory: 'arrakeen',
|
|
}),
|
|
)
|
|
: applyAction(g, owner, { type: 'decision', decline: true });
|
|
let nextSpiceOrdered = false;
|
|
for (let step = 0; g.phase !== 2 && step < 80; step++) {
|
|
if (g.phase === 1 && !nextSpiceOrdered) {
|
|
orderNexusSpice(g, ['land', 'land']);
|
|
nextSpiceOrdered = true;
|
|
}
|
|
g = automaticStep(g);
|
|
}
|
|
assert.equal(g.turn, 2);
|
|
assert.equal(g.phase, 2);
|
|
for (const p of g.players) {
|
|
g.deck.push(...p.hand.splice(0));
|
|
Object.assign(p, {
|
|
forces: {},
|
|
reserves: 20,
|
|
tanks: 0,
|
|
spice: 20,
|
|
shipped: true,
|
|
moved: 0,
|
|
});
|
|
if (p.elites)
|
|
Object.assign(p.elites, { forces: {}, reserves: 3, tanks: 0 });
|
|
}
|
|
const native = options.native ?? 8;
|
|
g.players[0].reserves = native;
|
|
g.players[0].forces = { 'polar_sink:0': 20 - native };
|
|
const cards = g.nexusCards!.cards!;
|
|
cards.deck = [
|
|
'moritani',
|
|
...cards.deck.filter((card) => card !== 'moritani'),
|
|
];
|
|
g.nexusCards!.cards = drawNexusCard(cards, options.cardOwner ?? owner, g.players, () => 0);
|
|
const karama =
|
|
options.karama === false
|
|
? undefined
|
|
: holdNexusMoritaniCard(g, target, 'karama').id;
|
|
Object.assign(g, {
|
|
turn: 2,
|
|
phase: 5,
|
|
active: target,
|
|
movementRemaining: [target],
|
|
order: [owner, target, observer],
|
|
ready: [],
|
|
response: null,
|
|
decision: null,
|
|
phaseOpening: null,
|
|
spice: {},
|
|
storm: 18,
|
|
});
|
|
g = applyAction(g, target, { type: 'endMovement' });
|
|
assert.equal(g.phase, 7);
|
|
if (g.decision?.kind === 'grummanCollection')
|
|
g = applyAction(g, owner, {
|
|
type: 'decision',
|
|
event: g.grummanCollection!.event,
|
|
decline: true,
|
|
});
|
|
for (const id of g.order) g = applyAction(g, id, { type: 'ready' });
|
|
assert.equal(g.phase, 8);
|
|
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
|
nexusMoritaniInventory(g);
|
|
return { g, owner, target, observer, karama };
|
|
}
|
|
export function holdNexusMoritaniCard(g: Game, owner: string, kind: string) {
|
|
const i = g.deck.findIndex((c) => c.kind === kind || c.effect === kind);
|
|
assert.ok(i >= 0);
|
|
const card = g.deck.splice(i, 1)[0];
|
|
g.players.find((p) => p.id === owner)!.hand.push(card);
|
|
return card;
|
|
}
|
|
function automaticStep(g: Game): Game {
|
|
for (const p of g.players) {
|
|
const v = viewGame(g, p.id);
|
|
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
|
|
const action = botActions(v)[0];
|
|
if (action) return applyAction(g, p.id, action);
|
|
}
|
|
throw new Error(
|
|
`Phase progression stalled at ${g.phase}/${g.decision?.kind}/${g.response?.kind}`,
|
|
);
|
|
}
|
|
export const nexusMoritaniToken = (g: Game, kind: TerrorKind) =>
|
|
g.moritaniTerror!.tokens.find((token) => token.kind === kind)!;
|
|
export function nexusMoritaniRequest(
|
|
f: NexusMoritaniFixture,
|
|
kind: TerrorKind = 'robbery',
|
|
territory = 'red_chasm',
|
|
g = f.g,
|
|
): Action {
|
|
return {
|
|
type: 'decision',
|
|
token: nexusMoritaniToken(g, kind).id,
|
|
territory,
|
|
nexus: viewGame(g, f.owner).nexusMoritani!.event,
|
|
};
|
|
}
|
|
export function nexusMoritaniInventory(g: Game) {
|
|
nexusTraitorInventory(g);
|
|
if (g.homeworlds) homeworldGameIntegrity(g);
|
|
assert.deepEqual(
|
|
g.moritaniTerror!.tokens.map((t) => t.kind).sort(),
|
|
[...TERROR_KINDS].sort(),
|
|
);
|
|
assert.equal(new Set(g.moritaniTerror!.tokens.map((t) => t.id)).size, 6);
|
|
}
|
|
/** Advance actual phase boundaries with legal bot choices before shipping;
|
|
* signed placement and Nexus phase history are retained unchanged. */
|
|
export function nexusMoritaniMovement(state: Game): Game {
|
|
let g = nexusReload(state);
|
|
const target = g.players.find((p) => p.faction === 'guild')!;
|
|
// Finish actual Mentat, Storm, Spice Blow, Charity, Bidding and Revival.
|
|
// In particular the Nexus phase receipt is created by the real next Storm.
|
|
for (let step = 0; g.phase !== 5 && step < 160; step++) {
|
|
let next: Game | undefined;
|
|
for (const p of g.players) {
|
|
const view = viewGame(g, p.id);
|
|
view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
|
|
const action = botActions(view)[0];
|
|
if (action) {
|
|
next = applyAction(g, p.id, action);
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(
|
|
next,
|
|
`Next-turn progression stalled at ${g.phase}/${g.decision?.kind}/${g.response?.kind}`,
|
|
);
|
|
g = next;
|
|
}
|
|
assert.equal(g.phase, 5);
|
|
for (let step = 0; g.active !== target.id && step < g.players.length; step++)
|
|
g = applyAction(g, g.active!, { type: 'endMovement' });
|
|
assert.equal(g.active, target.id);
|
|
assert.equal(g.players.find((p) => p.id === target.id)!.shipped, false);
|
|
nexusMoritaniInventory(g);
|
|
return g;
|
|
}
|
|
export function nexusMoritaniArrival(
|
|
state: Game,
|
|
territory: string,
|
|
amount = 3,
|
|
): Game {
|
|
let g = nexusMoritaniMovement(state);
|
|
const target = g.players.find((p) => p.faction === 'guild')!;
|
|
const sector = TERRITORIES.find((t) => t.id === territory)!.sectors.find(
|
|
(s) => s !== g.storm,
|
|
)!;
|
|
g = applyAction(g, target.id, { type: 'ship', territory, sector, amount });
|
|
nexusMoritaniInventory(g);
|
|
return g;
|
|
}
|
|
export const nexusMoritaniAllow = nexusAllow;
|
|
export const nexusMoritaniReload = nexusReload;
|