181 lines
11 KiB
TypeScript
181 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import {
|
||
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
|
||
viewGame, type Action, type Game,
|
||
} from '../game/engine';
|
||
import type { FactionId } from '../game/catalog';
|
||
import { TERRITORIES, MOBILE_LOCATION, mobileRoutes } from '../game/board';
|
||
import { presenceAt } from '../game/force-presence';
|
||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||
import { createSemutaGuildTransport } from './fixture-semuta-guild-transport';
|
||
import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
|
||
import { moveSemutaEmperorCard, passSemutaSpecialDiscards } from './fixture-semuta-emperor-share';
|
||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||
|
||
export type SemutaSpecialStep = { actor: string; action: Action };
|
||
const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!;
|
||
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard;
|
||
|
||
/** Two original paid auctions deliberately give Semuta to the shipper and the
|
||
* printed Karama to native Guild. No played hand, wallet, force or phase is assigned. */
|
||
export function createOriginalSemutaSpecialGuild(homeworlds = false) {
|
||
const original = createSemutaGuildTransport({ route: homeworlds ? 'world' : 'reserve' });
|
||
let game = structuredClone(original.setup);
|
||
const actions: SemutaSpecialStep[] = [];
|
||
const act = (actor: string, action: Action) => {
|
||
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
||
};
|
||
const reach = (until: (state: Game) => boolean) => {
|
||
for (let n = 0; !until(game) && n < 1600; n++) {
|
||
const next = nextSemutaGuildIncomeNativeStep(game); act(next.actor, next.action);
|
||
}
|
||
assert.ok(until(game));
|
||
};
|
||
const at = game.deck.findIndex(c => c.effect === 'karama'); assert.ok(at >= 0);
|
||
const card = game.deck.splice(at, 1)[0]; game.deck.unshift(card);
|
||
for (const position of [0, 1]) {
|
||
const index = game.spiceDeck.findIndex((c, i) => i >= position && 'territory' in c);
|
||
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||
}
|
||
reach(state => state.decision?.kind === 'richeseCache');
|
||
act('r', { type: 'decision', event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID,
|
||
method: 'onceAround', direction: 'counterclockwise' });
|
||
reach(state => !!state.richeseAuction && !state.richeseAuction.outcome &&
|
||
state.richeseAuction.active === 'a' && clean(state));
|
||
act('a', { type: 'richeseBid', event: game.richeseAuction!.event, amount: 1 });
|
||
reach(state => player(state, 'a').hand.some(c => c.id === SEMUTA_DRUG_ID) && clean(state));
|
||
const boughtSemuta = structuredClone(game);
|
||
reach(state => !!state.auction && state.auction.index === 0 && state.auction.active === 'g' && clean(state));
|
||
act('g', { type: 'bid', amount: 1 });
|
||
reach(state => player(state, 'g').hand.some(c => c.id === card.id) && clean(state));
|
||
const boughtKarama = structuredClone(game);
|
||
reach(state => state.phase === 5 && state.active === 'a' && clean(state));
|
||
const beforeShipment = structuredClone(game);
|
||
let shipment: Action, target: string;
|
||
if (homeworlds) {
|
||
const quote = homeworldShipmentChoice(viewGame(game, 'a'), 'homeworld:emperor',
|
||
{ 'homeworld:atreides': { normal: 2, elite: 0 } });
|
||
assert.ok(quote.action, quote.blocked ?? undefined); shipment = quote.action; target = 'homeworld:emperor';
|
||
} else {
|
||
const destination = TERRITORIES.find(t => t.type === 'sand' &&
|
||
t.sectors.some(s => s !== game.storm) && game.players.every(p => presenceAt(p, t.id) === 0));
|
||
assert.ok(destination); const sector = destination.sectors.find(s => s !== game.storm)!;
|
||
shipment = { type: 'ship', territory: destination.id, sector, amount: 2, elite: 0, allyPayment: 0 };
|
||
target = `${destination.id}:${sector}`;
|
||
}
|
||
act('a', shipment);
|
||
assert.equal(game.decision?.kind, homeworlds ? 'homeworldShipmentGuild' : 'guildShipment');
|
||
return { original, actions, beforeShipment, beforeCost: structuredClone(game), game,
|
||
boughtSemuta, boughtKarama, homeworlds, shipment, target,
|
||
owner: 'g', holder: 'a', shipper: 'a', karama: card.id, semuta: SEMUTA_DRUG_ID,
|
||
action: { type: 'card', mode: 'special', card: card.id } as Action,
|
||
provenance: [
|
||
'Original printed Semuta setup; only original undealt lottery and conserved unplayed deck order selected.',
|
||
'Atreides pays1 for the actual public cache Semuta; Guild pays1 for the actual normal Karama auction.',
|
||
'Original Atreides shipment and actual native interception; no played cards/forces/wallets/phase staged.',
|
||
] };
|
||
}
|
||
|
||
export function reactSemutaSpecial(game: Game, holder: string, claim: boolean, card?: string): Game {
|
||
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
||
assert.ok(pending.reaction?.stage === 'offer'); const event = pending.batch.event;
|
||
if (!claim) {
|
||
const reaction = pending.reaction;
|
||
for (const seat of game.players.filter(p => !reaction.passed.includes(p.id)))
|
||
game = applyAction(game, seat.id, { type: 'semutaPass', event });
|
||
return game;
|
||
}
|
||
game = applyAction(game, holder, { type: 'semutaCommit', event });
|
||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||
assert.ok(card, 'Private multi-card recovery needs the caller’s actual selection.');
|
||
assert.ok(viewGame(game, holder).semutaReaction!.candidates.some(c => c.id === card));
|
||
game = applyAction(game, holder, { type: 'semutaSelect', event, card });
|
||
}
|
||
return game;
|
||
}
|
||
|
||
export type ControlledSemutaSpecialKind = 'emperorForces' | 'emperorLeader' | 'choam' |
|
||
'richese' | 'ixians' | 'tleilaxu' | 'fremen' | 'atreides' | 'harkonnen';
|
||
|
||
/** CONTROLLED native-effect fixture: genuine native Semuta setup, then conserved
|
||
* later hands/armies/wallets and phase are staged to isolate each printed boundary.
|
||
* These are not original-action acquisition or full-game claims. */
|
||
export function createControlledSemutaSpecial(kind: ControlledSemutaSpecialKind) {
|
||
const faction: FactionId = kind === 'emperorForces' || kind === 'emperorLeader' ? 'emperor' : kind;
|
||
let game = createGame('CONTROLLEDSPECIALSEMUTA', newPlayer('p', 'Owner', faction), true,
|
||
['choam', ...(['ixians', 'tleilaxu'].includes(faction) ? ['ix' as const] : [])]);
|
||
joinGame(game, newPlayer('r', faction === 'richese' ? 'Guild' : 'Richese', faction === 'richese' ? 'guild' : 'richese'));
|
||
joinGame(game, newPlayer('h', 'Holder', faction === 'atreides' ? 'guild' : 'atreides'));
|
||
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
|
||
game = initializeSemutaGameForAudit(game);
|
||
for (let n = 0; game.status === 'setup' && n < 300; n++) {
|
||
const next = nextSemutaGuildIncomeNativeStep(game); game = applyAction(game, next.actor, next.action);
|
||
}
|
||
assert.equal(game.status, 'playing');
|
||
for (const p of game.players) {
|
||
game.deck.push(...p.hand.splice(0));
|
||
p.forces = {}; p.reserves = 20; p.tanks = 0; p.spice = 20; p.shipped = false; p.moved = 0;
|
||
if (p.elites) {
|
||
p.elites.reserves += p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0);
|
||
p.elites.forces = {}; p.elites.tanks = 0; p.elites.revived = 0;
|
||
}
|
||
}
|
||
const karama = game.deck.find(c => c.effect === 'karama'); assert.ok(karama);
|
||
moveSemutaEmperorCard(game, karama.id, 'p'); moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'h');
|
||
game.phase = 4; game.active = 'p'; game.order = ['p', 'r', 'h'];
|
||
game.movementRemaining = ['p', 'r', 'h']; game.ready = [];
|
||
game.response = null; game.decision = null; game.phaseOpening = null;
|
||
game.storm = 18; game.spiceWindow = null; game.spiceResolution = null; game.spice = {};
|
||
const owner = player(game, 'p'), holder = player(game, 'h');
|
||
const action: Action = { type: 'card', mode: 'special', card: karama.id };
|
||
let selected: string[] = [], acquired: string | undefined, target: string | undefined;
|
||
if (kind === 'emperorForces') {
|
||
owner.reserves = 17; owner.tanks = 3; action.amount = 2; action.elite = 0;
|
||
} else if (kind === 'emperorLeader') {
|
||
owner.leaders[0].dead = true; action.leader = owner.leaders[0].id;
|
||
} else if (kind === 'choam') {
|
||
game.phase = 3;
|
||
const cards = game.deck.filter(c => c.effect !== 'karama' && c.id !== SEMUTA_DRUG_ID).slice(0, 2);
|
||
for (const card of cards) moveSemutaEmperorCard(game, card.id, 'p');
|
||
selected = cards.map(c => c.id); action.cards = selected;
|
||
} else if (kind === 'richese') {
|
||
game.phase = 3; acquired = game.richeseCache![0].id; action.acquire = acquired;
|
||
} else if (kind === 'ixians') {
|
||
game.phase = 5; game.mobileStronghold = { location: 'polar_sink:0' };
|
||
owner.forces = { [MOBILE_LOCATION]: 1 }; owner.reserves = 19;
|
||
action.route = mobileRoutes(game, 2)[0]; action.collect = false;
|
||
} else if (kind === 'tleilaxu') {
|
||
holder.reserves = 18; holder.tanks = 2; game.active = 'h';
|
||
game = applyAction(game, 'h', { type: 'revive', amount: 2, elite: 0 });
|
||
assert.equal(game.decision?.kind, 'revivalStop');
|
||
} else if (kind === 'fremen') {
|
||
game.phase = 1;
|
||
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm));
|
||
assert.ok(destination); const sector = destination.sectors.find(s => s !== game.storm)!;
|
||
target = `${destination.id}:${sector}`; holder.forces[target] = 2; holder.reserves = 18;
|
||
action.territory = destination.id;
|
||
} else if (kind === 'atreides') {
|
||
game.phase = 6; owner.forces = { 'arrakeen:10': 2 }; owner.reserves = 18;
|
||
holder.forces = { 'arrakeen:10': 2 }; holder.reserves = 18;
|
||
game = applyAction(game, 'p', { type: 'chooseBattle', territory: 'arrakeen', target: 'h' });
|
||
for (let n = 0; n < 40 && (game.response || game.battle?.preparation); n++) {
|
||
if (game.response) game = applyAction(game,
|
||
game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
|
||
else game = applyAction(game, game.battle!.preparation!.owner, { type: 'declineBattlePower' });
|
||
}
|
||
assert.equal(game.decision?.kind, 'fullPlanOffer'); action.target = 'h';
|
||
} else {
|
||
game.phase = 3; target = 'h'; action.target = target; action.amount = 1;
|
||
const cards = game.deck.filter(c => c.effect !== 'karama' && c.id !== SEMUTA_DRUG_ID).slice(0, 2);
|
||
assert.equal(cards.length, 2);
|
||
moveSemutaEmperorCard(game, cards[0].id, 'p'); selected = [cards[0].id];
|
||
moveSemutaEmperorCard(game, cards[1].id, 'h');
|
||
}
|
||
return { kind, beforeCost: structuredClone(game), game, action, owner: 'p', holder: 'h',
|
||
karama: karama.id, semuta: SEMUTA_DRUG_ID, selected, acquired, target,
|
||
provenance: ['CONTROLLED genuine native Semuta setup, then conserved later hands/armies/wallets/phase.',
|
||
'Actual native battle/revival declarations where needed; actual printed activation and Semuta reactions thereafter.'] };
|
||
}
|
||
|
||
export { passSemutaSpecialDiscards };
|