171 lines
5.4 KiB
TypeScript
171 lines
5.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
|
|
import {
|
|
nexusRicheseFixture,
|
|
holdNexusRicheseCard,
|
|
type NexusRicheseFixtureOptions,
|
|
} from './fixture-nexus-richese';
|
|
import { nexusTraitorInventory } from './fixture-nexus-traitors';
|
|
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
|
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
|
|
import { orderNexusSpice } from './fixture-nexus-cards';
|
|
import { botActions } from '../game/bots';
|
|
|
|
export type NexusGuildCunningFixture = {
|
|
g: Game;
|
|
owner: string;
|
|
target: string;
|
|
observer: string;
|
|
karama?: string;
|
|
};
|
|
/** Reuse genuine final native Guild setup, then exchange two physical Nexus
|
|
* identities before any Cunning declaration; no grant or progress is invented. */
|
|
export function nexusGuildCunningFixture(
|
|
options: Pick<
|
|
NexusRicheseFixtureOptions,
|
|
| 'seatIds'
|
|
| 'advanced'
|
|
| 'homeworlds'
|
|
| 'spice'
|
|
| 'karama'
|
|
| 'opponentFaction'
|
|
> = {},
|
|
): NexusGuildCunningFixture {
|
|
const f = nexusRicheseFixture({ ...options, ownerFaction: 'guild' });
|
|
const cards = f.g.nexusCards!.cards!,
|
|
index = cards.deck.indexOf('guild');
|
|
assert.ok(index >= 0);
|
|
assert.equal(cards.hands[f.owner], 'richese');
|
|
cards.deck[index] = 'richese';
|
|
cards.hands[f.owner] = 'guild';
|
|
nexusGuildCunningInventory(f.g);
|
|
return f;
|
|
}
|
|
/** Allow only the already declared shipment and its ordinary response chain. */
|
|
export function settleGuildCunningShipment(g: Game): Game {
|
|
if (g.decision?.kind === 'guildShipment')
|
|
g = applyAction(g, g.decision.player, { type: 'decision', allow: true });
|
|
return nexusAllow(g);
|
|
}
|
|
export function originalGuildCunningTurn(
|
|
f: NexusGuildCunningFixture,
|
|
move = true,
|
|
g = f.g,
|
|
): Game {
|
|
g = settleGuildCunningShipment(
|
|
applyAction(g, f.owner, {
|
|
type: 'ship',
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
amount: 5,
|
|
}),
|
|
);
|
|
if (move)
|
|
g = applyAction(g, f.owner, {
|
|
type: 'move',
|
|
from: 'arrakeen:10',
|
|
territory: 'hagga_basin',
|
|
sector: 12,
|
|
amount: 5,
|
|
});
|
|
nexusGuildCunningInventory(g);
|
|
return g;
|
|
}
|
|
export function nexusGuildCunningRequest(
|
|
f: NexusGuildCunningFixture,
|
|
g = f.g,
|
|
): Action {
|
|
return {
|
|
type: 'endMovement',
|
|
nexus: viewGame(g, f.owner).nexusGuildCunning!.offer!.event,
|
|
};
|
|
}
|
|
export function holdGuildCunningCard(g: Game, owner: string, kind: string) {
|
|
const held = g.players
|
|
.find((p) => p.id === owner)!
|
|
.hand.find((c) => c.kind === kind || c.effect === kind);
|
|
return held ?? holdNexusRicheseCard(g, owner, kind);
|
|
}
|
|
export function nexusGuildCunningInventory(g: Game) {
|
|
// The shared card census also checks a pre-Homeworld aggregate force total.
|
|
// Count foreign visitors in that detached census; validate actual typed
|
|
// Homeworld custody against the unchanged game separately.
|
|
const census = structuredClone(g);
|
|
for (const p of census.players)
|
|
p.reserves += Object.values(g.homeworlds?.custody?.visitors ?? {}).reduce(
|
|
(sum, visitors) => {
|
|
const group = visitors[p.id];
|
|
return sum + (group?.normal ?? 0) + (group?.elite ?? 0);
|
|
},
|
|
0,
|
|
);
|
|
nexusTraitorInventory(census);
|
|
if (g.homeworlds) homeworldGameIntegrity(g);
|
|
}
|
|
export const nexusGuildCunningReload = nexusReload;
|
|
export const nexusGuildCunningAllow = nexusAllow;
|
|
|
|
/** Place an actual native Terror token at the first Mentat, then complete the
|
|
* next Storm/Spice/auction/Revival before returning the Guild's real turn. */
|
|
export function nexusGuildCunningAllianceFixture(
|
|
seatIds?: [string, string, string],
|
|
): NexusGuildCunningFixture {
|
|
const f = nexusGuildCunningFixture({ seatIds, opponentFaction: 'moritani' });
|
|
// Reserve this actual card before the intervening real auction can distribute
|
|
// all copies. The later scenario reuses this owned identity rather than
|
|
// assuming a needed card remains in the randomly shuffled draw pile.
|
|
holdGuildCunningCard(f.g, f.owner, 'hajr');
|
|
let g = f.g;
|
|
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
|
|
assert.equal(g.phase, 7);
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
|
const token = g.moritaniTerror!.tokens.find((t) => t.kind === 'robbery')!;
|
|
g = nexusAllow(
|
|
applyAction(g, f.target, {
|
|
type: 'decision',
|
|
token: token.id,
|
|
territory: 'carthag',
|
|
}),
|
|
);
|
|
let ordered = false;
|
|
for (
|
|
let step = 0;
|
|
!(
|
|
g.turn === 2 &&
|
|
g.phase === 5 &&
|
|
!g.response &&
|
|
!g.decision &&
|
|
!g.phaseOpening
|
|
) && step < 180;
|
|
step++
|
|
) {
|
|
if (g.turn === 2 && g.phase === 1 && !ordered) {
|
|
orderNexusSpice(g, ['land', 'land']);
|
|
ordered = true;
|
|
}
|
|
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,
|
|
`Guild alliance fixture stalled at ${g.turn}/${g.phase}/${g.decision?.kind}`,
|
|
);
|
|
g = next;
|
|
}
|
|
assert.equal(g.turn, 2);
|
|
assert.equal(g.phase, 5);
|
|
while (g.active !== f.owner)
|
|
g = applyAction(g, g.active!, { type: 'endMovement' });
|
|
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'guild');
|
|
nexusGuildCunningInventory(g);
|
|
return { ...f, g };
|
|
}
|