Resume printed native specials through Semuta cost and cashout stages

This commit is contained in:
Quintine committed 2026-10-16 01:47:32 +11:00
1 parent 44c2bf0da4
commit 8a2df97be8
14 files changed
+656 -35

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
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 };