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');
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, handLimit, initializeSemutaGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { isAuditorLeader } from '../game/choam-auditor';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import { applySemutaHomeworldChoamStep,
|
||||
holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
|
||||
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
import { advanceSemutaHomeworldEmperorShare as advanceSemutaHomeworldChoam } from './fixture-semuta-homeworld-emperor-share';
|
||||
|
||||
export type SemutaHomeworldChoamBattleKaramaScope = 'income' | 'audit';
|
||||
export type SemutaHomeworldChoamBattleKaramaFixture = {
|
||||
scope: SemutaHomeworldChoamBattleKaramaScope;
|
||||
initial: Game; setup: Game; beforeBattle: Game; acceptedBattle: Game;
|
||||
afterPaidBattle: Game; nativeIncome: Game; auditOffer?: Game;
|
||||
originalResponse: Game; gameBeforeCost: Game; game: Game;
|
||||
shipments: { actor: string; action: Action; before: Game; after: Game }[];
|
||||
actor: string; action: Action; declaration?: Action;
|
||||
card: string; karama: string; amount: number; canceller: string; owner: string;
|
||||
holder: string; winner: string; loser: string; location: string;
|
||||
winnerLeader: string; loserLeader: string; usedIds: string[];
|
||||
unplayedPrivateCard: string; projectile?: string; snooper?: string;
|
||||
actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
};
|
||||
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
|
||||
|
||||
/** Original Advanced R/E/C/A setup and paid first-turn battle. Only conserved
|
||||
* physical card positions and still-unplayed Spice Card order are staged. */
|
||||
export function createSemutaHomeworldChoamBattleKaramaFixture(
|
||||
scope: SemutaHomeworldChoamBattleKaramaScope, fullHolder = false,
|
||||
): SemutaHomeworldChoamBattleKaramaFixture {
|
||||
let game = createGame('SEMUTAHOMEWORLDCHOAMBATTLE', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
|
||||
game = applySemutaHomeworldChoamStep(game, game.host, { type: 'homeworlds', enabled: true }, actions);
|
||||
for (const seat of game.players)
|
||||
game = applySemutaHomeworldChoamStep(game, seat.id, { type: 'ready' }, actions);
|
||||
const initial = structuredClone(game);
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.status === 'playing', actions);
|
||||
const setup = structuredClone(game);
|
||||
const owner = 'c', canceller = 'e', holder = 'r';
|
||||
const red = game.spiceDeck.find(c => 'territory' in c && c.territory === 'red_chasm');
|
||||
const other = game.spiceDeck.find(c => 'territory' in c && c !== red);
|
||||
assert.ok(red && other);
|
||||
for (const [index, card] of [red, other].entries())
|
||||
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
staging.push('Position two original unplayed non-worm Spice Cards, first Red Chasm; no clock or resource staging.');
|
||||
const shipments: SemutaHomeworldChoamBattleKaramaFixture['shipments'] = [];
|
||||
for (const [actor, amount] of [[canceller, 4], [owner, 1]] as const) {
|
||||
game = advanceSemutaHomeworldChoam(game,
|
||||
g => g.phase === 5 && g.active === actor && clean(g), actions);
|
||||
assert.equal(game.turn, 1);
|
||||
const sources = nativeShipmentSources(viewGame(game, actor), amount, 0);
|
||||
assert.ok(sources);
|
||||
const action: Action = { type: 'ship', territory: 'red_chasm', sector: 7,
|
||||
amount, elite: 0, homeworldSources: sources, allyPayment: 0 };
|
||||
const before = structuredClone(game);
|
||||
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, clean, actions);
|
||||
shipments.push({ actor, action, before, after: structuredClone(game) });
|
||||
assert.equal(player(game, actor).spice, 10 - amount * 2);
|
||||
game = applySemutaHomeworldChoamStep(game, actor, { type: 'endMovement' }, actions);
|
||||
}
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.phase === 6 && clean(g), actions);
|
||||
// Late physical staging cannot alter native readiness, shipment or battle funding.
|
||||
for (const actor of [canceller, owner, holder]) game.deck.push(...player(game, actor).hand.splice(0));
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, holder);
|
||||
const karama = holdSemutaHomeworldChoamCard(game, canceller, 'Karama');
|
||||
const unplayedPrivateCard = holdSemutaHomeworldChoamCard(game, canceller, 'Hajr');
|
||||
const projectile = scope === 'audit' ? holdSemutaHomeworldChoamCard(game, canceller, 'Crysknife') : undefined;
|
||||
const snooper = scope === 'audit' ? holdSemutaHomeworldChoamCard(game, owner, 'Snooper') : undefined;
|
||||
staging.push('Conserve original physical Semuta to Richese, Karama and unused Hajr to Emperor; audit only: Crysknife to Emperor and nonprojectile Snooper to CHOAM.');
|
||||
if (fullHolder) {
|
||||
while (player(game, holder).hand.length < handLimit(player(game, holder))) {
|
||||
const card = game.deck.find(c => c.id !== SEMUTA_DRUG_ID);
|
||||
assert.ok(card);
|
||||
moveSemutaEmperorCard(game, card.id, holder);
|
||||
}
|
||||
staging.push('Conserved undealt cards fill Richese to its native hand limit.');
|
||||
}
|
||||
const winnerLeader = player(game, canceller).leaders.find(l => l.name === 'Hasimir Fenring')!.id;
|
||||
const loserLeader = player(game, owner).leaders.find(l => scope === 'audit'
|
||||
? isAuditorLeader(l) : !isAuditorLeader(l) && !l.dead && !l.usedAt)!.id;
|
||||
const beforeBattle = structuredClone(game);
|
||||
const chooser = game.active;
|
||||
assert.ok(chooser && [owner, canceller].includes(chooser));
|
||||
game = applySemutaHomeworldChoamStep(game, chooser,
|
||||
{ type: 'chooseBattle', territory: 'red_chasm', target: chooser === owner ? canceller : owner }, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, g => !!g.battle?.preLeader?.closed && clean(g), actions);
|
||||
for (const actor of [game.battle!.attacker, game.battle!.defender]) {
|
||||
const action: Action = actor === canceller
|
||||
? { type: 'battlePlan', dial: 2, support: 2, leader: winnerLeader, ...(projectile ? { weapon: projectile } : {}) }
|
||||
: { type: 'battlePlan', dial: 0, support: 0, leader: loserLeader, ...(snooper ? { defense: snooper } : {}) };
|
||||
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
|
||||
}
|
||||
const acceptedBattle = structuredClone(game);
|
||||
// Every earlier losing/winning Semuta event is passed by genuine controls.
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.response?.kind === 'choamBattleIncome', actions);
|
||||
const afterPaidBattle = structuredClone(game), nativeIncome = structuredClone(game);
|
||||
assert.equal(game.pendingChoamBattleIncome?.amount, 1);
|
||||
assert.equal(player(game, owner).spice, 8);
|
||||
assert.equal(player(game, canceller).spice, scope === 'audit' ? 2 : 0);
|
||||
let auditOffer: Game | undefined, declaration: Action | undefined;
|
||||
if (scope === 'audit') {
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.decision?.kind === 'choamAudit', actions);
|
||||
auditOffer = structuredClone(game);
|
||||
assert.equal(player(game, owner).spice, 9);
|
||||
const decision = game.decision;
|
||||
assert.ok(decision?.kind === 'choamAudit');
|
||||
declaration = { type: 'decision', event: decision.event, audit: true };
|
||||
game = applySemutaHomeworldChoamStep(game, owner, declaration, actions);
|
||||
}
|
||||
assert.equal(game.response?.kind, scope === 'income' ? 'choamBattleIncome' : 'choamAudit');
|
||||
assert.ok(viewGame(game, canceller).responseControls?.cancelCards.includes(karama));
|
||||
const originalResponse = structuredClone(game), gameBeforeCost = structuredClone(game);
|
||||
return { scope, initial, setup, beforeBattle, acceptedBattle, afterPaidBattle, nativeIncome,
|
||||
auditOffer, originalResponse, gameBeforeCost, game, shipments,
|
||||
actor: canceller, action: { type: 'card', mode: 'cancel', card: karama }, declaration,
|
||||
card: karama, karama, amount: 1, canceller, owner, holder, winner: canceller,
|
||||
loser: owner, location: 'red_chasm:7', winnerLeader, loserLeader,
|
||||
usedIds: [...(game.pendingAuditor?.usedCards ?? [])], unplayedPrivateCard, projectile, snooper,
|
||||
actions, staging };
|
||||
}
|
||||
|
||||
export function disposeSemutaHomeworldChoamBattleKarama(
|
||||
f: SemutaHomeworldChoamBattleKaramaFixture, state = f.game, actions?: RicheseSkillsTechStep[],
|
||||
): Game {
|
||||
return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
|
||||
}
|
||||
|
||||
export function reactSemutaHomeworldChoamBattleKarama(
|
||||
state: Game, holder: string, claim: boolean, actions?: RicheseSkillsTechStep[],
|
||||
): Game {
|
||||
const pending = state.pendingTreacheryDiscard, reaction = pending?.reaction;
|
||||
assert.ok(pending && reaction?.stage === 'offer');
|
||||
const event = pending.batch.event;
|
||||
if (claim) return applySemutaHomeworldChoamStep(state, holder, { type: 'semutaCommit', event }, actions);
|
||||
let game = state;
|
||||
for (const seat of state.players.filter(p => !reaction.passed.includes(p.id)))
|
||||
game = applySemutaHomeworldChoamStep(game, seat.id, { type: 'semutaPass', event }, actions);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Quote real Collection before the final native market close, not a second
|
||||
* Collection quote on an already credited phase-seven snapshot. */
|
||||
export function finishSemutaHomeworldChoamBattleKarama(state: Game, actions?: RicheseSkillsTechStep[]) {
|
||||
let game = advanceSemutaHomeworldChoam(state, g => g.decision?.kind === 'choamMarket', actions);
|
||||
assert.equal(game.turn, state.turn);
|
||||
const beforeCollection = structuredClone(game), collection = quoteSpiceCollection(game);
|
||||
const actor = game.decision!.player, action: Action = { type: 'decision', done: true };
|
||||
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.phase === 7 && clean(g), actions);
|
||||
assert.equal(game.turn, state.turn);
|
||||
return { game, beforeCollection, collection, actor, action };
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
|
||||
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
|
||||
import {
|
||||
createSemutaHomeworldCaptureKaramaFixture, disposeSemutaHomeworldCaptureKarama,
|
||||
finishSemutaHomeworldCaptureKarama, semutaHomeworldCaptureKaramaResources as resources,
|
||||
semutaHomeworldCaptureKaramaStock as stock, type SemutaHomeworldCaptureKaramaFixture,
|
||||
} from './fixture-semuta-homeworld-capture-karama';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected controls must not mutate the original native source');
|
||||
}
|
||||
|
||||
function originalBattle(f: SemutaHomeworldCaptureKaramaFixture, g: Game) {
|
||||
const h = player(g, f.captor), e = player(g, f.victim);
|
||||
assert.equal(g.turn, 1);
|
||||
assert.equal(g.lastBattleContext?.winner, f.captor);
|
||||
assert.equal(g.lastBattleContext?.result, 'normal');
|
||||
assert.equal(g.lastBattleContext?.territory, 'carthag');
|
||||
assert.equal(g.battle, null);
|
||||
assert.equal(player(f.setup, f.captor).forces[f.location], 10);
|
||||
assert.equal(player(f.beforeBattle, f.captor).forces[f.location], 10);
|
||||
assert.equal(h.forces[f.location], 9);
|
||||
assert.equal(h.reserves, 10);
|
||||
assert.equal(h.tanks, 1);
|
||||
assert.equal(h.battleLosses, player(f.beforeBattle, f.captor).battleLosses + 1);
|
||||
assert.equal(e.forces[f.location] ?? 0, 0);
|
||||
assert.equal(e.reserves, 18);
|
||||
assert.equal(e.tanks, 2);
|
||||
assert.equal(e.battleLosses, player(f.beforeBattle, f.victim).battleLosses + 2);
|
||||
assert.equal(h.leaders.find(l => l.id === f.winnerLeader)?.dead, false);
|
||||
assert.equal(e.leaders.find(l => l.id === f.loserLeader)?.dead, false);
|
||||
assert.equal(h.spice, player(f.beforeBattle, f.captor).spice - 1,
|
||||
'Only the original Harkonnen supported dial leaves its wallet; no fabricated bounty is created');
|
||||
assert.equal(e.spice, player(f.beforeBattle, f.victim).spice,
|
||||
'The original Emperor payer receives no battle income for this one-spice support');
|
||||
assert.deepEqual(g.homeworlds?.custody, f.paidShipment.after.homeworlds?.custody);
|
||||
for (const seat of g.players.filter(p => ![f.captor, f.victim].includes(p.id))) {
|
||||
const before = player(f.beforeBattle, seat.id);
|
||||
assert.deepEqual(seat.forces, before.forces);
|
||||
assert.deepEqual(seat.elites, before.elites);
|
||||
assert.equal(seat.spice, before.spice);
|
||||
assert.equal(seat.reserves, before.reserves);
|
||||
assert.equal(seat.tanks, before.tanks);
|
||||
}
|
||||
assert.deepEqual(stock(g), stock(f.setup));
|
||||
}
|
||||
|
||||
function unchangedPrivateSource(f: SemutaHomeworldCaptureKaramaFixture, g: Game) {
|
||||
for (const seat of g.players) {
|
||||
assert.deepEqual(seat.leaders, player(f.gameBeforeCost, seat.id).leaders,
|
||||
'No candidate is sampled, captured, concealed, transferred, killed or rewarded by a canceled capture');
|
||||
assert.deepEqual(seat.traitors, player(f.gameBeforeCost, seat.id).traitors);
|
||||
assert.ok(seat.leaders.every(l => !l.capturedBy && !l.concealed));
|
||||
}
|
||||
assert.deepEqual(g.traitorReserve, f.gameBeforeCost.traitorReserve);
|
||||
assert.deepEqual(g.homeworldOccupationHistory, f.gameBeforeCost.homeworldOccupationHistory);
|
||||
assert.deepEqual(g.homeworldOccupiedIncomeHistory, f.gameBeforeCost.homeworldOccupiedIncomeHistory);
|
||||
assert.deepEqual(g.homeworldOccupiedPercentageLedger, f.gameBeforeCost.homeworldOccupiedPercentageLedger);
|
||||
assert.deepEqual(g.tupileIntelligence, f.gameBeforeCost.tupileIntelligence);
|
||||
assert.deepEqual(g.giediCollection, f.gameBeforeCost.giediCollection);
|
||||
}
|
||||
|
||||
function pausedCost(f: SemutaHomeworldCaptureKaramaFixture, g: Game) {
|
||||
originalBattle(f, g);
|
||||
unchangedPrivateSource(f, g);
|
||||
assert.equal(f.nativecaptureOffer?.decision?.kind, 'captureOffer');
|
||||
assert.equal(f.nativecaptureOffer?.decision?.player, f.captor);
|
||||
assert.equal(f.originalresponse?.response?.kind, 'capture');
|
||||
assert.equal(f.originalresponse?.response?.owner, f.captor);
|
||||
assert.deepEqual(g.pendingCapture,
|
||||
{ player: f.captor, loser: f.victim, territory: 'carthag' });
|
||||
assert.equal(g.response, null, 'Printed cancellation response is detached while Semuta owns controls');
|
||||
const pending = g.pendingTreacheryDiscard;
|
||||
assert.ok(pending);
|
||||
assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.equal(pending.continuation.kind, 'karamaBattleDiscard');
|
||||
assert.equal(pending.batch.cause, 'karama:capture');
|
||||
assert.deepEqual(pending.batch.entries.map(e => ({ card: e.card.id, player: e.discardedBy, publicFace: e.publicFace })),
|
||||
[{ card: f.card, player: f.actor, publicFace: true }]);
|
||||
assert.equal(player(g, f.actor).hand.some(c => c.id === f.card), false);
|
||||
assert.equal(g.discard.filter(c => c.id === f.card).length, 1);
|
||||
assert.equal(viewGame(g, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.equal(g.decision?.kind === 'capturedLeader', false);
|
||||
assert.deepEqual(resources(g), resources(f.gameBeforeCost));
|
||||
}
|
||||
|
||||
function canceledExchange(f: SemutaHomeworldCaptureKaramaFixture, pending: Game, g: Game, claim: boolean) {
|
||||
originalBattle(f, g);
|
||||
unchangedPrivateSource(f, g);
|
||||
assert.equal(g.pendingCapture, null);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.pendingTreacheryDiscard, null);
|
||||
assert.notEqual(g.decision?.kind, 'capturedLeader');
|
||||
assert.deepEqual(resources(g), resources(pending));
|
||||
for (const seat of g.players) {
|
||||
const expected = player(pending, seat.id).hand.map(c => c.id);
|
||||
if (seat.id === f.holder && claim) expected.splice(expected.indexOf(SEMUTA_DRUG_ID), 1, f.card);
|
||||
assert.deepEqual(seat.hand.map(c => c.id).sort(), expected.sort(),
|
||||
'Only Richese exchanges its actual Semuta for Emperor’s spent printed Karama; no native hand draws');
|
||||
}
|
||||
assert.equal(player(g, f.actor).hand.some(c => c.id === f.card), false);
|
||||
assert.equal(player(g, f.holder).hand.filter(c => c.id === f.card).length, Number(claim));
|
||||
assert.equal(g.discard.filter(c => c.id === f.card).length, Number(!claim));
|
||||
assert.equal(g.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claim));
|
||||
assert.equal(player(g, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim);
|
||||
}
|
||||
|
||||
function finishOnce(f: SemutaHomeworldCaptureKaramaFixture, state: Game) {
|
||||
const finished = finishSemutaHomeworldCaptureKarama(state);
|
||||
const expected = resources(finished.beforeCollection);
|
||||
for (const receipt of finished.collection.receipts)
|
||||
expected.players.find(p => p.id === receipt.player)!.spice = receipt.balance;
|
||||
expected.spice = finished.collection.spice;
|
||||
assert.equal(finished.collection.receipts.find(r => r.player === f.captor)!.strongholds, 2,
|
||||
'Nine surviving Harkonnen counters on Carthag collect the two original stronghold points');
|
||||
assert.equal(finished.collection.receipts.find(r => r.player === f.captor)!.desert, 0,
|
||||
'The winner has no original desert presence to collect');
|
||||
assert.equal(finished.game.phase, 7);
|
||||
assert.equal(finished.game.turn, 1);
|
||||
assert.deepEqual(resources(finished.game), expected,
|
||||
'Only original Collection balances change: paid shipment, accepted casualties and living leaders never repeat');
|
||||
assert.deepEqual(stock(finished.game), stock(f.setup));
|
||||
assert.deepEqual(finished.game.players.map(p => p.hand), state.players.map(p => p.hand));
|
||||
assert.deepEqual(finished.game.players.map(p => p.traitors), state.players.map(p => p.traitors));
|
||||
assert.equal(finished.game.pendingCapture, null);
|
||||
rejected(finished.game, f.captor, { type: 'decision', accept: true });
|
||||
rejected(finished.game, f.actor, f.action);
|
||||
return finished.game;
|
||||
}
|
||||
|
||||
for (const fullHolder of [false, true]) for (const claim of [false, true]) {
|
||||
void test(`Advanced native Homeworld capture cost: ${fullHolder ? 'full four' : 'thin'} Richese ${claim ? 'claim' : 'all-pass'} cancels capture before one original Collection`, () => {
|
||||
const f = createSemutaHomeworldCaptureKaramaFixture(fullHolder);
|
||||
const paid = f.paidShipment;
|
||||
assert.equal(player(paid.before, f.victim).reserves, 20);
|
||||
assert.equal(player(paid.after, f.victim).reserves, 18);
|
||||
assert.equal(player(paid.after, f.victim).forces[f.location], 2);
|
||||
assert.equal(player(paid.after, f.victim).spice, player(paid.before, f.victim).spice - 2);
|
||||
assert.deepEqual(player(paid.after, f.victim).elites, player(paid.before, f.victim).elites);
|
||||
const pending = disposeSemutaHomeworldCaptureKarama(f);
|
||||
pausedCost(f, pending);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
rejected(pending, f.actor, f.action);
|
||||
rejected(pending, f.actor, { type: 'passResponse' });
|
||||
rejected(pending, f.captor, { type: 'decision', accept: true });
|
||||
rejected(pending, f.captor, { type: 'decision', mode: 'execute' });
|
||||
rejected(pending, f.captor, { type: 'semutaCommit', event });
|
||||
rejected(pending, f.actor, { type: 'semutaCommit', event });
|
||||
rejected(pending, f.holder, { type: 'semutaCommit', event: `${event}-retired` });
|
||||
rejected(pending, f.holder, { type: 'semutaSelect', event, card: f.card });
|
||||
const acknowledged = applyAction(pending, f.actor, { type: 'semutaPass', event });
|
||||
assert.deepEqual(resources(acknowledged), resources(pending));
|
||||
assert.equal(viewGame(acknowledged, f.holder).semutaReaction?.canCommit, true);
|
||||
rejected(acknowledged, f.actor, { type: 'semutaPass', event });
|
||||
const done = reactSemutaHomeworldChoam(claim ? acknowledged : pending, f.holder, claim);
|
||||
canceledExchange(f, pending, done, claim);
|
||||
assert.equal(player(done, f.holder).hand.length, fullHolder ? handLimit(player(done, f.holder)) : 1);
|
||||
for (const action of [{ type: 'semutaCommit', event }, { type: 'semutaPass', event },
|
||||
{ type: 'semutaSelect', event, card: f.card }, f.action]) rejected(done, f.holder, action);
|
||||
const collected = finishOnce(f, done);
|
||||
rejected(collected, f.holder, { type: 'semutaCommit', event });
|
||||
});
|
||||
}
|
||||
|
||||
void test('Native capture cost private forks expose only the holder’s current entitlement, not leaders or Giedi/Traitor custody', () => {
|
||||
const f = createSemutaHomeworldCaptureKaramaFixture(true);
|
||||
const absent = withoutSemutaHomeworldChoamMovementHolder(f.gameBeforeCost, f.holder);
|
||||
const pending = disposeSemutaHomeworldCaptureKarama(f);
|
||||
const neutral = disposeSemutaHomeworldCaptureKarama(f, absent);
|
||||
pausedCost(f, pending);
|
||||
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
||||
for (const seat of pending.players.filter(p => p.id !== f.holder)) {
|
||||
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(viewGame(pending, seat.id))),
|
||||
JSON.parse(JSON.stringify(viewGame(neutral, seat.id))),
|
||||
'A conserved hidden Richese card replacement cannot reveal entitlement to another actor');
|
||||
assert.deepEqual(viewGame(pending, seat.id).semutaReaction?.candidates, []);
|
||||
}
|
||||
const event = neutral.pendingTreacheryDiscard!.batch.event;
|
||||
rejected(neutral, f.holder, { type: 'semutaCommit', event });
|
||||
const claimed = reactSemutaHomeworldChoam(pending, f.holder, true);
|
||||
const passed = reactSemutaHomeworldChoam(neutral, f.holder, false);
|
||||
assert.deepEqual(resources(claimed), resources(passed));
|
||||
canceledExchange(f, pending, claimed, true);
|
||||
finishOnce(f, claimed);
|
||||
finishOnce(f, passed);
|
||||
});
|
||||
|
||||
void test('Native capture cost refuses changed source, original owner, casualty context and clock without granting a claim', () => {
|
||||
const f = createSemutaHomeworldCaptureKaramaFixture();
|
||||
const pending = disposeSemutaHomeworldCaptureKarama(f);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
const mutations: ((g: Game) => void)[] = [
|
||||
g => { g.pendingCapture!.player = f.victim; },
|
||||
g => { g.pendingCapture!.loser = 'a'; },
|
||||
g => { g.pendingCapture!.territory = 'arrakeen'; },
|
||||
g => { g.pendingCapture = null; },
|
||||
g => { g.phase = 7; },
|
||||
g => { g.advanced = false; },
|
||||
g => { player(g, f.captor).forces[f.location]++; },
|
||||
g => { player(g, f.victim).tanks--; },
|
||||
g => { g.lastBattleContext!.winner = f.victim; },
|
||||
g => { g.lastBattleContext!.territory = 'arrakeen'; },
|
||||
g => {
|
||||
const c = g.pendingTreacheryDiscard!.continuation;
|
||||
assert.equal(c.kind, 'karamaBattleDiscard');
|
||||
if (c.kind !== 'karamaBattleDiscard') throw Error('Expected native printed cost');
|
||||
c.use.response.owner = f.victim;
|
||||
},
|
||||
g => { g.pendingTreacheryDiscard!.batch.entries[0].discardedBy = f.captor; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const changed = structuredClone(pending);
|
||||
mutate(changed);
|
||||
rejected(changed, f.holder, { type: 'semutaCommit', event });
|
||||
rejected(changed, f.actor, { type: 'semutaPass', event });
|
||||
}
|
||||
pausedCost(f, pending);
|
||||
});
|
||||
|
||||
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: legal minimum original capture-cost consumer retires the reward without capture`, () => {
|
||||
const f = createSemutaHomeworldCaptureKaramaFixture(true);
|
||||
const pending = disposeSemutaHomeworldCaptureKarama(f);
|
||||
pausedCost(f, pending);
|
||||
const view = viewGame(pending, f.holder);
|
||||
view.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action);
|
||||
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass');
|
||||
let game = applyAction(pending, f.holder, action);
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
const event = game.pendingTreacheryDiscard.batch.event;
|
||||
for (const seat of game.players) {
|
||||
if (!game.pendingTreacheryDiscard) break;
|
||||
const reaction = game.pendingTreacheryDiscard.reaction;
|
||||
assert.ok(reaction?.stage === 'offer');
|
||||
if (!reaction.passed.includes(seat.id)) game = applyAction(game, seat.id, { type: 'semutaPass', event });
|
||||
}
|
||||
}
|
||||
canceledExchange(f, pending, game, action.type === 'semutaCommit');
|
||||
finishOnce(f, game);
|
||||
});
|
||||
|
||||
void test('Basic companion original Homeworld battle never produces native capture or a fabricated cancel response', () => {
|
||||
const f = createSemutaHomeworldCaptureKaramaFixture(false, false);
|
||||
assert.equal(f.acceptedBattle.pendingCapture, null);
|
||||
assert.equal(f.nativecaptureOffer, null);
|
||||
assert.equal(f.originalresponse, null);
|
||||
assert.equal(f.game.pendingCapture, null);
|
||||
assert.equal(f.game.phase, 7);
|
||||
assert.equal(f.game.lastBattleContext?.winner, f.captor);
|
||||
assert.equal(player(f.game, f.captor).forces[f.location], 9);
|
||||
assert.equal(player(f.game, f.victim).tanks, 2);
|
||||
assert.deepEqual(f.game.players.map(p => p.leaders), f.acceptedBattle.players.map(p => p.leaders));
|
||||
assert.equal(f.game.discard.some(c => c.id === f.card), false);
|
||||
assert.equal(player(f.game, f.actor).hand.some(c => c.id === f.card), true);
|
||||
rejected(f.game, f.actor, f.action);
|
||||
rejected(f.game, f.captor, { type: 'decision', accept: true });
|
||||
assert.deepEqual(stock(f.game), stock(f.setup));
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { auditCandidates } from '../game/choam-auditor';
|
||||
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import { semutaHomeworldWinnerStock as stock } from './fixture-semuta-homeworld-battle-winner';
|
||||
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
|
||||
import {
|
||||
createSemutaHomeworldChoamBattleKaramaFixture as create,
|
||||
disposeSemutaHomeworldChoamBattleKarama as dispose,
|
||||
reactSemutaHomeworldChoamBattleKarama as react,
|
||||
finishSemutaHomeworldChoamBattleKarama as finish,
|
||||
type SemutaHomeworldChoamBattleKaramaFixture,
|
||||
} from './fixture-semuta-homeworld-choam-battle-karama';
|
||||
|
||||
function resources(game: Game) {
|
||||
return { custody: structuredClone(game.homeworlds?.custody),
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves,
|
||||
tanks: p.tanks, forces: structuredClone(p.forces), elites: structuredClone(p.elites),
|
||||
leaders: structuredClone(p.leaders), battleLosses: p.battleLosses,
|
||||
revived: p.revived, freeForcesRevived: p.freeForcesRevived, ally: p.ally })) };
|
||||
}
|
||||
|
||||
function assertOriginalEconomics(f: SemutaHomeworldChoamBattleKaramaFixture) {
|
||||
assert.equal(f.setup.advanced, true);
|
||||
assert.deepEqual(stock(f.game), [...treacheryDeck(['choam']), ...richeseCards()].map(c => c.id).sort());
|
||||
for (const [index, amount] of [4, 1].entries()) {
|
||||
const shipment = f.shipments[index];
|
||||
assert.equal(player(shipment.before, shipment.actor).spice, 10);
|
||||
assert.equal(player(shipment.after, shipment.actor).spice, 10 - 2 * amount);
|
||||
assert.equal(player(shipment.after, shipment.actor).reserves, player(shipment.before, shipment.actor).reserves - amount);
|
||||
assert.equal(player(shipment.after, shipment.actor).forces[f.location], amount);
|
||||
assert.deepEqual(player(shipment.after, 'a').forces, player(f.setup, 'a').forces);
|
||||
assert.deepEqual(player(shipment.after, 'r').forces, player(f.setup, 'r').forces);
|
||||
}
|
||||
const state = f.acceptedBattle, battle = state.battle!;
|
||||
const combatant = (id: string): ResolutionCombatant => {
|
||||
const p = player(state, id), plan = battle.plans[id];
|
||||
return { id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
|
||||
plan, leader: p.leaders.find(l => l.id === plan.leader),
|
||||
forces: { normal: p.forces[f.location], elite: 0, normalFixedHalf: false,
|
||||
eliteStrength: 2, freeSupport: false } };
|
||||
};
|
||||
const quote = quoteBattleResolution({ advanced: true, typedCasualties: true,
|
||||
turn: state.turn, territory: battle.territory,
|
||||
attacker: combatant(battle.attacker), defender: combatant(battle.defender),
|
||||
voters: viewGame(state, state.host).battle!.traitorVoters.map(id => ({ id,
|
||||
beneficiary: id, called: false, traitors: player(state, id).traitors })),
|
||||
participants: state.players, physicalCards: [state.deck, state.discard,
|
||||
state.richeseCache ?? [], state.richeseRemoved ?? [], ...state.players.map(p => p.hand)].flat(),
|
||||
pendingAuditorPresent: false, pendingRetentionPresent: false });
|
||||
assert.equal(quote.result, 'normal');
|
||||
assert.equal(quote.winner, f.canceller);
|
||||
assert.deepEqual(quote.payments.find(p => p.player === f.canceller), {
|
||||
player: f.canceller, cost: 2, ownPayment: 2, allyPayment: 0,
|
||||
donor: null, bankSupport: 0, freeByTraitor: false });
|
||||
assert.equal(quote.payments.find(p => p.player === f.owner)!.ownPayment, 0);
|
||||
assert.deepEqual(quote.choamIncome, { owner: f.owner, amount: 1 });
|
||||
assert.deepEqual(f.nativeIncome.pendingChoamBattleIncome, quote.choamIncome);
|
||||
assert.equal(player(f.afterPaidBattle, f.canceller).spice, 2 - 2 + (quote.bounty?.amount ?? 0));
|
||||
assert.equal(player(f.afterPaidBattle, f.canceller).forces[f.location], 2);
|
||||
assert.equal(player(f.afterPaidBattle, f.canceller).tanks, player(f.beforeBattle, f.canceller).tanks + 2);
|
||||
assert.equal(player(f.afterPaidBattle, f.owner).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(f.afterPaidBattle, f.owner).tanks, player(f.beforeBattle, f.owner).tanks + 1);
|
||||
assert.deepEqual(f.afterPaidBattle.lastBattle, [f.canceller, f.owner]);
|
||||
assert.equal(f.afterPaidBattle.lastBattleContext!.winner, f.canceller);
|
||||
assert.equal(player(f.afterPaidBattle, f.canceller).leaders.find(l => l.id === f.winnerLeader)!.strength, 6);
|
||||
assert.equal(player(f.afterPaidBattle, f.owner).leaders.find(l => l.id === f.loserLeader)!.dead, f.scope === 'audit');
|
||||
if (f.scope === 'audit') {
|
||||
assert.deepEqual(quote.bounty, { player: f.canceller, amount: 2 });
|
||||
assert.equal(f.game.pendingAuditor!.survived, false);
|
||||
assert.equal(player(f.game, f.owner).spice, 9);
|
||||
assert.equal(player(f.nativeIncome, f.owner).spice, 8);
|
||||
assert.deepEqual(f.usedIds, [f.projectile]);
|
||||
assert.ok(player(f.game, f.canceller).hand.some(c => c.id === f.projectile));
|
||||
assert.deepEqual(auditCandidates(player(f.game, f.canceller).hand, f.usedIds).map(c => c.id).sort(),
|
||||
[f.card, f.unplayedPrivateCard].sort());
|
||||
assert.equal(viewGame(f.game, f.owner).auditor?.count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDenied(f: SemutaHomeworldChoamBattleKaramaFixture, game: Game, claim: boolean) {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.ok(!game.pendingChoamBattleIncome);
|
||||
assert.ok(!game.pendingAuditor);
|
||||
assert.ok(!game.auditorInsight);
|
||||
assert.ok(!game.response);
|
||||
assert.equal(game.decision?.kind, 'choamMarket');
|
||||
assert.equal(game.decision!.player, f.owner);
|
||||
assert.deepEqual(resources(game), resources(f.gameBeforeCost));
|
||||
assert.deepEqual(stock(game), stock(f.setup));
|
||||
assert.deepEqual(game.lastBattleContext, f.gameBeforeCost.lastBattleContext);
|
||||
assert.equal(player(game, f.owner).spice, f.scope === 'income' ? 8 : 9);
|
||||
assert.equal(player(game, f.canceller).spice, f.scope === 'income' ? 0 : 2);
|
||||
assert.equal(player(game, f.canceller).hand.some(c => c.id === f.card), false);
|
||||
assert.equal(player(game, f.holder).hand.some(c => c.id === f.card), claim);
|
||||
assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim);
|
||||
assert.equal(game.discard.filter(c => c.id === f.card).length, Number(!claim));
|
||||
assert.equal(game.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claim));
|
||||
assert.ok(player(game, f.canceller).hand.some(c => c.id === f.unplayedPrivateCard));
|
||||
if (f.projectile) assert.ok(player(game, f.canceller).hand.some(c => c.id === f.projectile));
|
||||
if (f.snooper) assert.equal(game.discard.filter(c => c.id === f.snooper).length, 1);
|
||||
assert.throws(() => applyAction(game, f.canceller, f.action));
|
||||
if (f.declaration) assert.throws(() => applyAction(game, f.owner, f.declaration!));
|
||||
if (claim) assert.throws(() => applyAction(game, f.holder, f.action), 'Recovered Karama cannot replay a completed reward');
|
||||
}
|
||||
|
||||
function assertCollection(f: SemutaHomeworldChoamBattleKaramaFixture, state: Game) {
|
||||
const completed = finish(state);
|
||||
assert.equal(completed.beforeCollection.decision!.player, f.owner);
|
||||
assert.equal(completed.game.phase, 7);
|
||||
assert.equal(completed.game.turn, state.turn);
|
||||
for (const receipt of completed.collection.receipts)
|
||||
assert.equal(player(completed.game, receipt.player).spice, receipt.balance);
|
||||
assert.equal(completed.collection.receipts.find(r => r.player === f.canceller)!.collected, 4);
|
||||
assert.equal(player(completed.game, f.owner).spice, f.scope === 'income' ? 8 : 9);
|
||||
assert.deepEqual(completed.game.spice, completed.collection.spice);
|
||||
assert.deepEqual(stock(completed.game), stock(state));
|
||||
for (const seat of completed.game.players) {
|
||||
assert.deepEqual(seat.forces, player(state, seat.id).forces);
|
||||
assert.equal(seat.reserves, player(state, seat.id).reserves);
|
||||
assert.equal(seat.tanks, player(state, seat.id).tanks);
|
||||
}
|
||||
assert.throws(() => applyAction(completed.game, completed.actor, completed.action), 'Original market close cannot credit Collection twice');
|
||||
}
|
||||
|
||||
for (const scope of ['income', 'audit'] as const) {
|
||||
for (const full of [false, true]) for (const claim of [false, true])
|
||||
void test(`Advanced Homeworld CHOAM ${scope}: ${full ? 'full' : 'thin'} Richese ${claim ? 'claims' : 'passes'} paid Emperor Karama before native denial`, () => {
|
||||
const f = create(scope, full);
|
||||
assertOriginalEconomics(f);
|
||||
const pending = dispose(f), cost = pending.pendingTreacheryDiscard;
|
||||
assert.ok(cost, 'Actual printed cancellation cost pauses before denying the original reward');
|
||||
assert.equal(cost.continuation.kind, 'karamaBattleDiscard');
|
||||
assert.deepEqual(cost.batch.entries.map(c => ({ card: c.card.id, owner: c.discardedBy })), [{ card: f.card, owner: f.canceller }]);
|
||||
assert.equal(pending.discard.filter(c => c.id === f.card).length, 1);
|
||||
assert.deepEqual(resources(pending), resources(f.gameBeforeCost));
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.throws(() => applyAction(pending, f.canceller, f.action));
|
||||
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', done: true }));
|
||||
assert.throws(() => applyAction(pending, f.owner, { type: 'ready' }));
|
||||
assert.throws(() => applyAction(pending, f.owner, { type: 'semutaCommit', event: cost.batch.event }));
|
||||
assert.throws(() => applyAction(pending, f.holder, { type: 'semutaCommit', event: `${cost.batch.event}-stale` }));
|
||||
const done = react(pending, f.holder, claim);
|
||||
assertDenied(f, done, claim);
|
||||
if (full) assert.equal(player(done, f.holder).hand.length, handLimit(player(done, f.holder)));
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }));
|
||||
assert.throws(() => applyAction(done, f.owner, { type: 'semutaPass', event: cost.batch.event }));
|
||||
assertCollection(f, done);
|
||||
});
|
||||
|
||||
for (const full of [false, true]) void test(`Advanced Homeworld CHOAM ${scope}: ${full ? 'full' : 'thin'} private original Semuta entitlement never leaks to other seats`, () => {
|
||||
const f = create(scope, full), pending = dispose(f);
|
||||
const neutral = dispose(f, withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder));
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
||||
for (const seat of pending.players.filter(p => p.id !== f.holder)) {
|
||||
assert.deepEqual(viewGame(pending, seat.id), viewGame(neutral, seat.id));
|
||||
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
|
||||
assert.throws(() => applyAction(pending, seat.id, { type: 'semutaCommit', event: pending.pendingTreacheryDiscard!.batch.event }));
|
||||
}
|
||||
const claimed = react(pending, f.holder, true), passed = react(neutral, f.holder, false);
|
||||
assertDenied(f, claimed, true);
|
||||
assert.ok(!passed.pendingTreacheryDiscard && !passed.response && !passed.pendingAuditor && !passed.auditorInsight);
|
||||
assert.equal(passed.decision?.kind, 'choamMarket');
|
||||
assert.equal(passed.discard.filter(c => c.id === f.card).length, 1);
|
||||
assert.equal(player(passed, f.holder).hand.some(c => c.id === f.card), false);
|
||||
assert.deepEqual(resources(claimed), resources(passed));
|
||||
if (scope === 'audit') {
|
||||
assert.deepEqual(auditCandidates(player(claimed, f.canceller).hand, f.usedIds).map(c => c.id), [f.unplayedPrivateCard]);
|
||||
assert.ok(!viewGame(claimed, f.owner).auditor);
|
||||
assert.ok(!viewGame(claimed, f.owner).auditorInsight);
|
||||
}
|
||||
assertCollection(f, claimed);
|
||||
});
|
||||
|
||||
for (const difficulty of DIFFICULTIES) void test(`${difficulty} legally handles Advanced Homeworld ${scope} cancellation then original CHOAM market`, () => {
|
||||
const f = create(scope), pending = dispose(f), own = viewGame(pending, f.holder);
|
||||
own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(own)[0];
|
||||
assert.ok(action && (action.type === 'semutaCommit' || action.type === 'semutaPass'));
|
||||
let game = applyAction(pending, f.holder, action);
|
||||
if (game.pendingTreacheryDiscard) game = react(game, f.holder, false);
|
||||
assertDenied(f, game, action.type === 'semutaCommit');
|
||||
const closing = viewGame(game, f.owner);
|
||||
closing.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||||
const close = botActions(closing)[0];
|
||||
assert.ok(close);
|
||||
const collection = quoteSpiceCollection(game);
|
||||
const completed = applyAction(game, f.owner, close);
|
||||
assert.equal(completed.phase, 7);
|
||||
assert.equal(player(completed, f.owner).spice, f.scope === 'income' ? 8 : 9);
|
||||
assert.equal(player(completed, f.canceller).forces[f.location], 2);
|
||||
assert.equal(player(completed, f.owner).tanks, 1);
|
||||
for (const receipt of collection.receipts)
|
||||
assert.equal(player(completed, receipt.player).spice, receipt.balance);
|
||||
assert.deepEqual(completed.spice, collection.spice);
|
||||
assert.deepEqual(completed.lastBattleContext, game.lastBattleContext);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user