Compose original Homeworld battle reward cancellation costs with Semuta
This commit is contained in:
1 parent
afe3008cae
commit
ec574ab0f5
12 files changed
+910
-1
No files matched your search
@@ -0,0 +1,159 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, joinGame, newPlayer, initializeSemutaGameForAudit, handLimit,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import { applySemutaHomeworldChoamStep } from './fixture-semuta-homeworld-choam';
|
||||
import { advanceSemutaHomeworldEmperorShare, nextSemutaHomeworldEmperorShareStep }
|
||||
from './fixture-semuta-homeworld-emperor-share';
|
||||
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
|
||||
export type SemutaHomeworldCaptureKaramaFixture = {
|
||||
initial: Game; setup: Game; beforeBattle: Game; acceptedBattle: Game;
|
||||
nativecaptureOffer: Game | null; originalresponse: Game | null;
|
||||
gameBeforeCost: Game; game: Game;
|
||||
paidShipment: { actor: string; action: Action; before: Game; after: Game };
|
||||
actor: string; owner: string; holder: string; captor: string; victim: string;
|
||||
winner: string; loser: string; location: string; card: string;
|
||||
winnerLeader: string; loserLeader: string; action: Action;
|
||||
actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
};
|
||||
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
|
||||
|
||||
export function semutaHomeworldCaptureKaramaStock(g: Game): string[] {
|
||||
return [g.deck, g.discard, g.richeseCache ?? [], g.richeseRemoved ?? [],
|
||||
...g.players.map(p => p.hand)].flat().map(c => c.id).sort();
|
||||
}
|
||||
export function semutaHomeworldCaptureKaramaResources(g: Game) {
|
||||
return { custody: structuredClone(g.homeworlds?.custody), spice: structuredClone(g.spice),
|
||||
players: g.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves,
|
||||
tanks: p.tanks, forces: structuredClone(p.forces), elites: structuredClone(p.elites),
|
||||
battleLosses: p.battleLosses, leaders: structuredClone(p.leaders),
|
||||
traitors: [...p.traitors], ally: p.ally })),
|
||||
context: structuredClone(g.lastBattleContext), tech: structuredClone(g.techTokens) };
|
||||
}
|
||||
|
||||
/** Genuine first-turn R/C/E/A/H native setup. Only physical hand positions and
|
||||
* still-unplayed Spice Card order are staged; no clocks or counters are assigned. */
|
||||
export function createSemutaHomeworldCaptureKaramaFixture(
|
||||
fullHolder = false, advanced = true,
|
||||
): SemutaHomeworldCaptureKaramaFixture {
|
||||
let game = createGame('SEMUTAHOMECAPTURE', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const [id, name, faction] of [
|
||||
['c', 'CHOAM', 'choam'], ['e', 'Emperor', 'emperor'],
|
||||
['a', 'Atreides', 'atreides'], ['h', 'Harkonnen', 'harkonnen'],
|
||||
] as const) joinGame(game, newPlayer(id, name, faction));
|
||||
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
|
||||
const act = (actor: string, action: Action) => {
|
||||
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
|
||||
};
|
||||
act(game.host, { type: 'homeworlds', enabled: true });
|
||||
for (const seat of game.players) act(seat.id, { type: 'ready' });
|
||||
const initial = structuredClone(game);
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
game = advanceSemutaHomeworldEmperorShare(game, g => g.status === 'playing', actions);
|
||||
const setup = structuredClone(game);
|
||||
const blows = game.spiceDeck.filter(c => 'territory' in c).slice(0, 2);
|
||||
assert.equal(blows.length, 2);
|
||||
for (const [i, card] of blows.entries())
|
||||
game.spiceDeck.splice(i, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
staging.push('Reorder two original still-unplayed non-worm Spice Cards.');
|
||||
game = advanceSemutaHomeworldEmperorShare(game,
|
||||
g => g.phase === 5 && g.active === 'e' && clean(g), actions);
|
||||
assert.equal(game.turn, 1);
|
||||
assert.equal(player(game, 'h').forces['carthag:11'], 10);
|
||||
assert.equal(player(game, 'e').spice, player(setup, 'e').spice,
|
||||
'Explicit original Bidding readiness must leave the Emperor funding untouched for the real paid deployment');
|
||||
const sources = nativeShipmentSources(viewGame(game, 'e'), 2, 0);
|
||||
assert.ok(sources);
|
||||
const shipment: Action = { type: 'ship', territory: 'carthag', sector: 11,
|
||||
amount: 2, elite: 0, homeworldSources: sources, allyPayment: 0 };
|
||||
const beforeShipment = structuredClone(game);
|
||||
act('e', shipment);
|
||||
game = advanceSemutaHomeworldEmperorShare(game, clean, actions);
|
||||
const paidShipment = { actor: 'e', action: shipment, before: beforeShipment, after: structuredClone(game) };
|
||||
assert.equal(player(game, 'e').spice, player(beforeShipment, 'e').spice - 2);
|
||||
assert.equal(player(game, 'e').reserves, player(beforeShipment, 'e').reserves - 2);
|
||||
assert.deepEqual(player(game, 'e').elites, player(beforeShipment, 'e').elites);
|
||||
act('e', { type: 'endMovement' });
|
||||
game = advanceSemutaHomeworldEmperorShare(game, g => g.phase === 6 && clean(g), actions);
|
||||
// Later conserved custody, not an auction award or a copied physical face.
|
||||
game.deck.push(...player(game, 'r').hand.splice(0));
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
||||
const existingKarama = player(game, 'e').hand.find(c => c.effect === 'karama');
|
||||
const card = existingKarama?.id ?? moveSemutaEmperorCard(game,
|
||||
semutaEmperorCardId(game, c => c.effect === 'karama'), 'e');
|
||||
assert.ok(player(game, 'e').hand.length <= handLimit(player(game, 'e')));
|
||||
if (fullHolder) while (player(game, 'r').hand.length < handLimit(player(game, 'r'))) {
|
||||
assert.ok(game.deck[0]);
|
||||
moveSemutaEmperorCard(game, game.deck[0].id, 'r');
|
||||
}
|
||||
staging.push(`Conserved original ${SEMUTA_DRUG_ID} to Richese and printed ${card} to Emperor; ` +
|
||||
(fullHolder ? 'Richese filled to four using original undealt cards.' : 'Richese holds only Semuta.'));
|
||||
const beforeBattle = structuredClone(game);
|
||||
const chooser = game.active;
|
||||
assert.ok(chooser && ['e', 'h'].includes(chooser));
|
||||
act(chooser, { type: 'chooseBattle', territory: 'carthag', target: chooser === 'e' ? 'h' : 'e' });
|
||||
game = advanceSemutaHomeworldEmperorShare(game,
|
||||
g => !!g.battle?.preLeader?.closed && clean(g), actions);
|
||||
const winnerLeader = player(game, 'h').leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => b.strength - a.strength)[0].id;
|
||||
const loserLeader = player(game, 'e').leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength)[0].id;
|
||||
assert.ok(player(game, 'h').leaders.find(l => l.id === winnerLeader)!.strength + 1 >
|
||||
player(game, 'e').leaders.find(l => l.id === loserLeader)!.strength);
|
||||
const battle = game.battle!;
|
||||
for (const actor of [battle.attacker, battle.defender]) act(actor,
|
||||
{ type: 'battlePlan', dial: actor === 'h' ? 1 : 0,
|
||||
support: advanced && actor === 'h' ? 1 : 0,
|
||||
leader: actor === 'h' ? winnerLeader : loserLeader });
|
||||
for (const actor of [battle.attacker, battle.defender]) act(actor, { type: 'traitorCall', call: false });
|
||||
const acceptedBattle = structuredClone(game);
|
||||
let nativecaptureOffer: Game | null = null, originalresponse: Game | null = null;
|
||||
if (advanced) {
|
||||
game = advanceSemutaHomeworldEmperorShare(game, g => g.decision?.kind === 'captureOffer', actions);
|
||||
assert.equal(game.decision?.player, 'h');
|
||||
nativecaptureOffer = structuredClone(game);
|
||||
act('h', { type: 'decision', accept: true });
|
||||
assert.equal(game.response?.kind, 'capture');
|
||||
assert.equal(game.response?.owner, 'h');
|
||||
originalresponse = structuredClone(game);
|
||||
} else {
|
||||
game = advanceSemutaHomeworldEmperorShare(game, g => g.phase === 7 && clean(g), actions);
|
||||
assert.equal(game.pendingCapture, null);
|
||||
}
|
||||
return { initial, setup, beforeBattle, acceptedBattle, nativecaptureOffer, originalresponse,
|
||||
gameBeforeCost: game, game, paidShipment, actor: 'e', owner: 'e', holder: 'r',
|
||||
captor: 'h', victim: 'e', winner: 'h', loser: 'e', location: 'carthag:11', card,
|
||||
winnerLeader, loserLeader, action: { type: 'card', mode: 'cancel', card }, actions, staging };
|
||||
}
|
||||
|
||||
export function disposeSemutaHomeworldCaptureKarama(f: SemutaHomeworldCaptureKaramaFixture,
|
||||
state = f.gameBeforeCost, actions?: RicheseSkillsTechStep[]): Game {
|
||||
return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
|
||||
}
|
||||
|
||||
/** Stop before the original final Battle/market close; quote Collection before
|
||||
* its native credits, never quote an already-collected phase-seven board twice. */
|
||||
export function finishSemutaHomeworldCaptureKarama(state: Game, actions: RicheseSkillsTechStep[] = []) {
|
||||
assert.equal(state.pendingTreacheryDiscard, null,
|
||||
'Resolve the original printed-cost offer before the native Collection continuation');
|
||||
let game = state;
|
||||
for (let n = 0; n < 1600; n++) {
|
||||
assert.equal(game.turn, state.turn);
|
||||
assert.equal(game.phase, 6);
|
||||
const beforeCollection = game;
|
||||
const collection = quoteSpiceCollection(beforeCollection);
|
||||
const next = nextSemutaHomeworldEmperorShareStep(game);
|
||||
game = applySemutaHomeworldChoamStep(game, next.actor, next.action, actions);
|
||||
if (game.phase === 7) {
|
||||
game = advanceSemutaHomeworldEmperorShare(game, clean, actions);
|
||||
return { beforeCollection, collection, game };
|
||||
}
|
||||
}
|
||||
throw new Error('Original native capture cleanup must reach Collection');
|
||||
}
|
||||
Reference in new issue
Block a user