Files
Dune/tests/fixture-semuta-special-karama.ts

181 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };