Compose original Homeworld shared-benefit cancellation costs with Semuta
This commit is contained in:
1 parent
8c5b6c8b55
commit
afe3008cae
12 files changed
+851
-1
No files matched your search
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, handLimit, initializeSemutaGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
|
||||
import { applySemutaHomeworldChoamStep } from './fixture-semuta-homeworld-choam';
|
||||
|
||||
export type SemutaHomeworldBgCharityFixture = {
|
||||
initial: Game; setup: Game; gameBeforeBGclaim: Game; game: Game;
|
||||
originalIncomeResponse: Game; originalResponse: Game | null;
|
||||
actor: string; owner: string; holder: string; payer: string; target: string;
|
||||
card: string; amount: number; claim: Action; action: Action; event: string | null;
|
||||
actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
};
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Native human continuation: decline markets, allow responses, and ready the
|
||||
* original queues. Never use the optional paid Kaitain end-of-Bidding control. */
|
||||
function advance(state: Game, until: (game: Game) => boolean, actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; !until(game) && n < 1600; n++) {
|
||||
assert.notEqual(game.status, 'finished');
|
||||
let next: RicheseSkillsTechStep;
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
|
||||
if (pending && reaction?.stage === 'offer') next = {
|
||||
actor: game.players.find(p => !reaction.passed.includes(p.id))!.id,
|
||||
action: { type: 'semutaPass', event: pending.batch.event },
|
||||
};
|
||||
else if (game.decision?.kind === 'choamMarket') next = {
|
||||
actor: game.decision.player, action: { type: 'decision', done: true },
|
||||
};
|
||||
else if (game.biddingEnd && clean(game)) next = {
|
||||
actor: game.biddingEnd.owners.find(id => !game.biddingEnd!.ready.includes(id))!,
|
||||
action: { type: 'biddingEnd', event: game.biddingEnd.event, mode: 'ready' },
|
||||
};
|
||||
else next = nextRicheseSkillsTechNativeStep(game);
|
||||
game = applySemutaHomeworldChoamStep(game, next.actor, next.action, actions);
|
||||
}
|
||||
assert.ok(until(game), 'Original native controls must reach the BG charity boundary');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Five original ready participants, actual prediction/force setup and first
|
||||
* CHOAM income. Only conserved physical cards and unplayed Spice order stage. */
|
||||
export function createSemutaHomeworldBgCharityFixture(
|
||||
advanced = true, fullHolder = false,
|
||||
): SemutaHomeworldBgCharityFixture {
|
||||
let game = createGame('SEMUTAHWBG', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
||||
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 = advance(game, g => g.status === 'playing', actions);
|
||||
const setup = structuredClone(game);
|
||||
const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 2);
|
||||
assert.equal(blows.length, 2);
|
||||
for (const [position, card] of blows.entries())
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
staging.push('Order two original still-unplayed non-worm Spice Cards.');
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
||||
const card = moveSemutaEmperorCard(game, semutaEmperorCardId(game, c => c.effect === 'karama'), 'e');
|
||||
staging.push('Conserved original Semuta to Richese and printed Karama to Emperor.');
|
||||
if (fullHolder) {
|
||||
const holder = semutaEmperorPlayer(game, 'r');
|
||||
while (holder.hand.length < handLimit(holder)) {
|
||||
assert.ok(game.deck[0]);
|
||||
moveSemutaEmperorCard(game, game.deck[0].id, holder.id);
|
||||
}
|
||||
staging.push('Conserved undealt stock fills the original Richese hand to four.');
|
||||
}
|
||||
game = advance(game, g => g.response?.kind === 'choamCharity', actions);
|
||||
const originalIncomeResponse = structuredClone(game);
|
||||
game = advance(game, g => g.phase === 2 && clean(g), actions);
|
||||
const gameBeforeBGclaim = structuredClone(game), claim: Action = { type: 'charity' };
|
||||
if (advanced) game = applySemutaHomeworldChoamStep(game, 'b', claim, actions);
|
||||
assert.equal(semutaEmperorPlayer(game, 'b').spice, 5);
|
||||
assert.equal(semutaEmperorPlayer(game, 'c').spice, 12);
|
||||
if (advanced) {
|
||||
assert.equal(game.response?.kind, 'bgCharity');
|
||||
assert.equal(game.response?.amount, 2);
|
||||
assert.ok(viewGame(game, 'e').responseControls?.cancelCards.includes(card));
|
||||
}
|
||||
return { initial, setup, gameBeforeBGclaim, originalIncomeResponse,
|
||||
originalResponse: advanced ? structuredClone(game) : null, game,
|
||||
actor: 'e', owner: 'b', holder: 'r', payer: 'c', target: 'a', card,
|
||||
amount: advanced ? game.response!.amount! : viewGame(game, 'b').charity.amount,
|
||||
claim, action: { type: 'card', mode: 'cancel', card }, event: null, actions, staging };
|
||||
}
|
||||
|
||||
/** Spend the real printed card before the bound original BG denial. */
|
||||
export function disposeSemutaHomeworldBgCharity(
|
||||
f: SemutaHomeworldBgCharityFixture, state = f.game, actions?: RicheseSkillsTechStep[],
|
||||
): Game {
|
||||
const game = applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
|
||||
f.event = game.pendingTreacheryDiscard?.batch.event ?? null;
|
||||
return game;
|
||||
}
|
||||
|
||||
export function reactSemutaHomeworldBgCharity(
|
||||
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;
|
||||
}
|
||||
|
||||
/** Continue the same original native charity close into usable Bidding. */
|
||||
export function finishSemutaHomeworldBgCharity(
|
||||
state: Game, actions?: RicheseSkillsTechStep[],
|
||||
): Game {
|
||||
assert.ok(!state.pendingTreacheryDiscard && state.response?.kind !== 'bgCharity');
|
||||
return advance(state, g => g.phase === 3 && clean(g), actions);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { Card } from '../game/cards';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
|
||||
type SemutaEmperorShareKind } from './fixture-semuta-emperor-share';
|
||||
|
||||
export type SemutaHomeworldEmperorShareFixture = {
|
||||
kind: SemutaEmperorShareKind; game: Game; setup: Game; beforeShipment: Game;
|
||||
paidShipment: Game; beforeWeather: Game; weather: Game; death: Game; worm: Game;
|
||||
alliance: Game; beforeBenefit: Game; beforeDeclaration: Game;
|
||||
owner: string; holder: string; recipient: string;
|
||||
canceller: string; karama: string; karamaCard: Card; weatherCard: string;
|
||||
shipment: Action; weatherAction: Action; declare: Action; cancel: Action;
|
||||
actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
};
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||||
!game.pendingTreacheryDiscard;
|
||||
|
||||
export function nextSemutaHomeworldEmperorShareStep(game: Game): RicheseSkillsTechStep {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
|
||||
if (pending && reaction?.stage === 'offer') return {
|
||||
actor: game.players.find(p => !reaction.passed.includes(p.id))!.id,
|
||||
action: { type: 'semutaPass', event: pending.batch.event },
|
||||
};
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.biddingEnd) {
|
||||
const end = game.biddingEnd;
|
||||
return { actor: end.owners.find(id => !end.ready.includes(id))!,
|
||||
action: { type: 'biddingEnd', event: end.event, mode: 'ready' } };
|
||||
}
|
||||
if (game.decision?.kind === 'choamMarket') return {
|
||||
actor: game.decision.player, action: { type: 'decision', done: true },
|
||||
};
|
||||
// The Homeworld CHOAM Mentat window is optional: decline it so its special
|
||||
// effects never disturb the deployed Atreides counter before the storm.
|
||||
if (game.decision?.kind === 'choamMentat') return {
|
||||
actor: game.decision.player, action: { type: 'decision', done: true },
|
||||
};
|
||||
return nextRicheseSkillsTechNativeStep(game);
|
||||
}
|
||||
export function advanceSemutaHomeworldEmperorShare(state: Game, until: (game: Game) => boolean,
|
||||
actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; !until(game) && n < 2000; n++) {
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaHomeworldEmperorShareStep(game);
|
||||
actions?.push(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game), 'Original share continuation reaches the requested native boundary');
|
||||
return game;
|
||||
}
|
||||
export function semutaHomeworldEmperorShareStock(game: Game): string[] {
|
||||
return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
|
||||
...game.players.map(p => p.hand)].flat().map(card => card.id).sort();
|
||||
}
|
||||
export function semutaHomeworldEmperorShareResources(game: Game) {
|
||||
return { custody: structuredClone(game.homeworlds?.custody), emperorExtra: { ...game.emperorExtra },
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: { ...p.forces },
|
||||
reserves: p.reserves, tanks: p.tanks, elites: structuredClone(p.elites),
|
||||
revived: p.revived, freeForcesRevived: p.freeForcesRevived,
|
||||
battleLosses: p.battleLosses, ally: p.ally, allySinceTurn: p.allySinceTurn })) };
|
||||
}
|
||||
function orderUnplayedSpice(game: Game, kinds: ('land' | 'worm')[]): void {
|
||||
for (const [position, kind] of kinds.entries()) {
|
||||
const at = game.spiceDeck.findIndex((card, i) => i >= position &&
|
||||
(kind === 'land' ? 'territory' in card : 'worm' in card));
|
||||
assert.ok(at >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Original R/C/E/A setup, paid three-counter deployment, real Weather casualties
|
||||
* and mutual worm Nexus alliance. Only physical cards/unplayed Spice order move. */
|
||||
export function createSemutaHomeworldEmperorShareFixture(
|
||||
kind: SemutaEmperorShareKind, advanced = false, fullHolder = false,
|
||||
): SemutaHomeworldEmperorShareFixture {
|
||||
let game = createGame('SEMUTAHWSHARE', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
|
||||
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
|
||||
const reach = (until: (g: Game) => boolean) => {
|
||||
game = advanceSemutaHomeworldEmperorShare(game, until, actions);
|
||||
};
|
||||
const act = (actor: string, action: Action) => {
|
||||
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
||||
};
|
||||
reach(g => g.status === 'playing');
|
||||
const setup = structuredClone(game);
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
||||
orderUnplayedSpice(game, ['land', 'land']);
|
||||
staging.push('Original physical Semuta to Richese; first two unplayed original Spice Cards are non-worm.');
|
||||
reach(g => g.phase === 5 && g.active === 'a' && clean(g));
|
||||
const beforeShipment = structuredClone(game);
|
||||
const homeworldSources = nativeShipmentSources(viewGame(game, 'a'), 3, 0);
|
||||
assert.ok(homeworldSources);
|
||||
const shipment: Action = { type: 'ship', territory: 'red_chasm', sector: 7,
|
||||
amount: 3, elite: 0, homeworldSources, allyPayment: 0 };
|
||||
act('a', shipment);
|
||||
reach(clean);
|
||||
const paidShipment = structuredClone(game);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').spice, semutaEmperorPlayer(beforeShipment, 'a').spice - 6);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').forces['red_chasm:7'], 3);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').reserves, 7);
|
||||
act('a', { type: 'endMovement' });
|
||||
reach(g => g.turn === 2 && g.phase === 0 && clean(g));
|
||||
const weatherCard = semutaEmperorCardId(game, card => card.effect === 'weather');
|
||||
moveSemutaEmperorCard(game, weatherCard, 'a');
|
||||
staging.push('Conserved original printed Weather Control to Atreides before the second native Storm.');
|
||||
const beforeWeather = structuredClone(game);
|
||||
assert.equal(game.storm, 1);
|
||||
const weatherAction: Action = { type: 'card', card: weatherCard, amount: 6 };
|
||||
act('a', weatherAction);
|
||||
reach(clean);
|
||||
const weather = structuredClone(game);
|
||||
assert.equal(game.stormPending, 6);
|
||||
assert.equal(game.stormMovementSource?.kind, 'weather');
|
||||
reach(g => g.phase === 1 && clean(g));
|
||||
const death = structuredClone(game);
|
||||
assert.equal(game.storm, 7);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').forces['red_chasm:7'] ?? 0, 0);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').forces['arrakeen:10'], 10);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').tanks, 3);
|
||||
assert.equal(semutaEmperorPlayer(game, 'e').spice, 10);
|
||||
assert.ok(semutaEmperorPlayer(game, 'r').hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
orderUnplayedSpice(game, ['worm', 'land', 'land']);
|
||||
staging.push('The next original unplayed worm opens the second-turn Nexus; later original land cards close Spice Blow.');
|
||||
reach(g => g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g));
|
||||
const worm = structuredClone(game);
|
||||
act('e', { type: 'alliance', target: 'a' });
|
||||
act('a', { type: 'alliance', target: 'e' });
|
||||
const alliance = structuredClone(game);
|
||||
reach(g => g.phase === (kind === 'emperorGift' ? 2 : 4) && clean(g));
|
||||
const beforeBenefit = structuredClone(game);
|
||||
// Conserved late hand positioning follows every preceding committed market pool.
|
||||
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
||||
const karama = semutaEmperorCardId(game, card => card.effect === 'karama');
|
||||
moveSemutaEmperorCard(game, karama, 'a');
|
||||
if (fullHolder) while (semutaEmperorPlayer(game, 'r').hand.length < handLimit(semutaEmperorPlayer(game, 'r'))) {
|
||||
assert.ok(game.deck[0]); moveSemutaEmperorCard(game, game.deck[0].id, 'r');
|
||||
}
|
||||
const karamaCard = semutaEmperorPlayer(game, 'a').hand.find(card => card.id === karama)!;
|
||||
staging.push('Conserved original printed Karama to Atreides and Semuta to Richese; optional full hand uses original undealt stock.');
|
||||
assert.deepEqual(semutaHomeworldEmperorShareStock(game), semutaHomeworldEmperorShareStock(setup));
|
||||
assert.equal(semutaEmperorPlayer(game, 'e').spice, 10);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').tanks, 3);
|
||||
assert.equal(semutaEmperorPlayer(game, 'a').revived, 0);
|
||||
// Real UI entry point for the Emperor's own declaration: every conserved card
|
||||
// is already positioned, and only the declared benefit response is missing.
|
||||
const beforeDeclaration = structuredClone(game);
|
||||
const declare: Action = { type: kind, amount: kind === 'emperorGift' ? 3 : 2 };
|
||||
act('e', declare);
|
||||
assert.equal(game.response?.kind, kind);
|
||||
return { kind, game, setup, beforeShipment, paidShipment, beforeWeather, weather, death,
|
||||
worm, alliance, beforeBenefit, beforeDeclaration,
|
||||
owner: 'e', holder: 'r', recipient: 'a', canceller: 'a',
|
||||
karama, karamaCard, weatherCard, shipment, weatherAction,
|
||||
declare,
|
||||
cancel: { type: 'card', mode: 'cancel', card: karama }, actions, staging };
|
||||
}
|
||||
|
||||
/** Stop at the paid printed cancellation receipt, before its native denial. */
|
||||
export function disposeSemutaHomeworldEmperorShare(f: SemutaHomeworldEmperorShareFixture,
|
||||
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
assert.equal(state.response?.kind, f.kind);
|
||||
actions?.push({ actor: f.canceller, action: f.cancel });
|
||||
return applyAction(state, f.canceller, f.cancel);
|
||||
}
|
||||
export function reactSemutaHomeworldEmperorShare(state: Game, holder: string, claim: boolean): Game {
|
||||
const event = state.pendingTreacheryDiscard!.batch.event;
|
||||
if (claim) return applyAction(state, holder, { type: 'semutaCommit', event });
|
||||
let game = state;
|
||||
for (const p of state.players) game = applyAction(game, p.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
/** The still-legal ordinary return this fixture exercises at that revival window. */
|
||||
export function semutaHomeworldEmperorOrdinaryRevive(game: Game, playerId: string): number {
|
||||
const seat = semutaEmperorPlayer(game, playerId);
|
||||
const limit = Math.min(forceRevivalRemaining(game, seat), seat.tanks);
|
||||
return Array.from({ length: limit }, (_, index) => index + 1)
|
||||
.filter(count => forceRevivalQuote(game, seat, count).cost <= seat.spice).at(-1) ?? 0;
|
||||
}
|
||||
/** Use the same original controls. Revival additionally exercises the still-legal
|
||||
* ordinary Atreides return of whatever two-cost revivals remain that turn. */
|
||||
export function finishSemutaHomeworldEmperorShare(state: Game, f: SemutaHomeworldEmperorShareFixture,
|
||||
actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = advanceSemutaHomeworldEmperorShare(state, clean, actions);
|
||||
if (f.kind === 'emperorRevival') {
|
||||
const amount = semutaHomeworldEmperorOrdinaryRevive(game, f.recipient);
|
||||
assert.ok(amount > 0, 'The original Tanks still support an ordinary revival this turn');
|
||||
const revive: Action = { type: 'revive', amount, elite: 0 };
|
||||
actions?.push({ actor: f.recipient, action: revive });
|
||||
game = applyAction(game, f.recipient, revive);
|
||||
}
|
||||
return advanceSemutaHomeworldEmperorShare(game,
|
||||
g => g.turn === 2 && g.phase === (f.kind === 'emperorGift' ? 3 : 5) && clean(g), actions);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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 { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
|
||||
import { semutaHomeworldChoamMovementResources as resources,
|
||||
semutaHomeworldChoamMovementStock as stock } from './fixture-semuta-homeworld-choam-movement';
|
||||
import { withoutSemutaHomeworldEconomicHolder } from './fixture-semuta-homeworld-economic-karama';
|
||||
import {
|
||||
createSemutaHomeworldBgCharityFixture, disposeSemutaHomeworldBgCharity,
|
||||
reactSemutaHomeworldBgCharity, finishSemutaHomeworldBgCharity,
|
||||
type SemutaHomeworldBgCharityFixture,
|
||||
} from './fixture-semuta-homeworld-bg-charity';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected wrong-owner, stale or replay actions have no effect');
|
||||
}
|
||||
|
||||
function assertNative(f: SemutaHomeworldBgCharityFixture) {
|
||||
assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.game.phase, 2);
|
||||
assert.equal(player(f.setup, f.payer).spice, 2);
|
||||
assert.equal(player(f.originalIncomeResponse, f.payer).spice, 2);
|
||||
assert.equal(f.originalIncomeResponse.response?.kind, 'choamCharity');
|
||||
assert.equal(player(f.game, f.payer).spice, 12, 'Actual five-player income is ten, once');
|
||||
assert.deepEqual(f.game.choamCharity, { turn: 1, canceled: false });
|
||||
assert.equal(player(f.setup, f.owner).spice, 5);
|
||||
assert.equal(player(f.game, f.owner).spice, 5, 'BG is genuinely wealthy, never staged poor');
|
||||
assert.deepEqual(f.game.homeworlds?.custody, f.setup.homeworlds?.custody);
|
||||
for (const seat of f.game.players) {
|
||||
assert.equal(seat.reserves, player(f.setup, seat.id).reserves);
|
||||
assert.equal(seat.tanks, player(f.setup, seat.id).tanks);
|
||||
assert.deepEqual(seat.forces, player(f.setup, seat.id).forces);
|
||||
assert.deepEqual(seat.elites, player(f.setup, seat.id).elites);
|
||||
if (seat.id !== f.payer) assert.equal(seat.spice, player(f.setup, seat.id).spice);
|
||||
}
|
||||
assert.deepEqual(stock(f.game), stock(f.setup), 'Native stock is physically conserved');
|
||||
const own = viewGame(f.gameBeforeBGclaim, f.owner);
|
||||
assert.equal(own.charity.homeworld, 0, 'High Wallach gives no fabricated low-population bonus');
|
||||
assert.equal(own.charity.payer, f.payer);
|
||||
if (f.game.advanced) {
|
||||
assert.equal(own.charity.amount, 2);
|
||||
assert.equal(f.amount, 2);
|
||||
assert.equal(f.game.response?.kind, 'bgCharity');
|
||||
assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.response?.amount, 2);
|
||||
assert.equal(f.game.response?.charityHomeworld, 0);
|
||||
assert.notEqual(player(f.gameBeforeBGclaim, f.owner).charityTurn, 1);
|
||||
assert.equal(player(f.game, f.owner).charityTurn, 1, 'Actual claim consumes the allowance before its response');
|
||||
} else {
|
||||
assert.equal(own.charity.amount, 0);
|
||||
assert.equal(f.originalResponse, null);
|
||||
assert.equal(f.game.response, null);
|
||||
assert.notEqual(player(f.game, f.owner).charityTurn, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDenied(f: SemutaHomeworldBgCharityFixture, game: Game, claimed: boolean) {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.notEqual(game.response?.kind, 'bgCharity');
|
||||
assert.equal(game.turn, 1);
|
||||
assert.equal(game.phase, 2);
|
||||
assert.deepEqual(resources(game), resources(f.game), 'Denial and recovery neither pay BG nor debit CHOAM or move forces/custody');
|
||||
assert.deepEqual(stock(game), stock(f.game));
|
||||
assert.equal(player(game, f.owner).spice, 5);
|
||||
assert.equal(player(game, f.payer).spice, 12);
|
||||
assert.equal(player(game, f.owner).charityTurn, 1);
|
||||
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
|
||||
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
|
||||
assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), claimed);
|
||||
assert.equal(player(game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID),
|
||||
!claimed && player(f.game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claimed));
|
||||
for (const seat of game.players)
|
||||
assert.equal(seat.hand.length, player(f.game, seat.id).hand.length - Number(seat.id === f.actor));
|
||||
reject(game, f.owner, f.claim);
|
||||
reject(game, f.actor, f.action);
|
||||
if (claimed) reject(game, f.holder, f.action);
|
||||
}
|
||||
|
||||
function assertSuffix(f: SemutaHomeworldBgCharityFixture, game: Game) {
|
||||
const next = finishSemutaHomeworldBgCharity(game);
|
||||
assert.equal(next.status, 'playing');
|
||||
assert.equal(next.phase, 3);
|
||||
assert.equal(next.turn, 1);
|
||||
assert.ok(!next.pendingTreacheryDiscard && !next.response && !next.decision && !next.phaseOpening);
|
||||
assert.deepEqual(resources(next), resources(f.game), 'Original native close never restores the denied two spice');
|
||||
assert.equal(player(next, f.owner).charityTurn, 1);
|
||||
assert.equal(viewGame(next, f.owner).charity.payer, f.payer);
|
||||
assert.equal(player(next, f.payer).spice, 12);
|
||||
reject(next, f.owner, f.claim);
|
||||
reject(next, f.actor, f.action);
|
||||
const nextAction = nextRicheseSkillsTechNativeStep(next);
|
||||
const after = applyAction(next, nextAction.actor, nextAction.action);
|
||||
assert.equal(after.turn, 1);
|
||||
assert.equal(after.phase, 3);
|
||||
assert.equal(player(after, f.owner).spice, 5);
|
||||
assert.equal(player(after, f.payer).spice, 12);
|
||||
}
|
||||
|
||||
for (const claimed of [false, true]) void test(`Advanced native BG charity: ${claimed ? 'claim' : 'pass'} pays the printed Karama once and denies the original two spice`, () => {
|
||||
const f = createSemutaHomeworldBgCharityFixture();
|
||||
assertNative(f);
|
||||
reject(f.game, f.holder, f.action);
|
||||
const game = disposeSemutaHomeworldBgCharity(f), cost = game.pendingTreacheryDiscard;
|
||||
assert.ok(cost && cost.reaction?.stage === 'offer');
|
||||
assert.equal(cost.continuation.kind, 'karamaBgCharityDiscard');
|
||||
assert.equal(cost.batch.cause, 'karama:bgCharity');
|
||||
assert.deepEqual(cost.batch.entries.map(entry => ({ card: entry.card.id, actor: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.card, actor: f.actor, face: true }]);
|
||||
if (cost.continuation.kind === 'karamaBgCharityDiscard') {
|
||||
assert.deepEqual(cost.continuation.use.response, f.originalResponse!.response);
|
||||
assert.deepEqual(cost.continuation.resume.response, f.originalResponse!.response);
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
assert.equal(player(game, f.owner).charityTurn, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
reject(game, f.owner, f.claim);
|
||||
reject(game, f.actor, f.action);
|
||||
reject(game, f.actor, { type: 'passResponse' });
|
||||
reject(game, f.target, { type: 'semutaCommit', event: cost.batch.event });
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${cost.batch.event}:stale` });
|
||||
const done = reactSemutaHomeworldBgCharity(JSON.parse(JSON.stringify(game)), f.holder, claimed);
|
||||
assertDenied(f, done, claimed);
|
||||
reject(done, f.holder, { type: 'semutaCommit', event: cost.batch.event });
|
||||
reject(done, f.target, { type: 'semutaPass', event: cost.batch.event });
|
||||
assertSuffix(f, done);
|
||||
});
|
||||
|
||||
void test('Advanced native BG charity: full four-card Richese exchanges one card and keeps the absent-Semuta boundary private', () => {
|
||||
const f = createSemutaHomeworldBgCharityFixture(true, true);
|
||||
assertNative(f);
|
||||
assert.equal(player(f.game, f.holder).hand.length, 4);
|
||||
assert.equal(handLimit(player(f.game, f.holder)), 4);
|
||||
const game = disposeSemutaHomeworldBgCharity(f);
|
||||
const neutral = disposeSemutaHomeworldBgCharity(f, withoutSemutaHomeworldEconomicHolder(f.game, f.holder));
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
||||
reject(neutral, f.holder, { type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event });
|
||||
for (const seat of game.players.filter(p => p.id !== f.holder)) {
|
||||
assert.deepEqual(viewGame(game, seat.id), viewGame(neutral, seat.id));
|
||||
assert.equal(viewGame(game, seat.id).semutaReaction?.canCommit, false);
|
||||
}
|
||||
const done = reactSemutaHomeworldBgCharity(game, f.holder, true);
|
||||
assertDenied(f, done, true);
|
||||
assert.equal(player(done, f.holder).hand.length, 4);
|
||||
const passed = reactSemutaHomeworldBgCharity(neutral, f.holder, false);
|
||||
assertDenied({ ...f, game: withoutSemutaHomeworldEconomicHolder(f.game, f.holder) }, passed, false);
|
||||
assert.deepEqual(resources(passed), resources(done));
|
||||
assertSuffix(f, done);
|
||||
});
|
||||
|
||||
void test('Basic native wealthy BG cannot create an extra charity response or spend its cancel cost', () => {
|
||||
const f = createSemutaHomeworldBgCharityFixture(false);
|
||||
assertNative(f);
|
||||
reject(f.game, f.owner, f.claim);
|
||||
reject(f.game, f.actor, f.action);
|
||||
assert.equal(player(f.game, f.actor).hand.some(card => card.id === f.card), true);
|
||||
assert.equal(f.game.discard.some(card => card.id === f.card), false);
|
||||
assert.ok(!f.game.pendingTreacheryDiscard && !f.game.response);
|
||||
const next = finishSemutaHomeworldBgCharity(f.game);
|
||||
assert.equal(next.phase, 3);
|
||||
assert.deepEqual(resources(next), resources(f.game));
|
||||
assert.equal(player(next, f.owner).spice, 5);
|
||||
assert.equal(player(next, f.payer).spice, 12);
|
||||
assert.notEqual(player(next, f.owner).charityTurn, 1);
|
||||
});
|
||||
|
||||
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: actual Semuta and native next BG controls preserve the canceled charity allowance`, () => {
|
||||
const f = createSemutaHomeworldBgCharityFixture();
|
||||
let game = disposeSemutaHomeworldBgCharity(f);
|
||||
const own = viewGame(game, f.holder);
|
||||
own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const reaction = botActions(own)[0];
|
||||
assert.ok(reaction && (reaction.type === 'semutaCommit' || reaction.type === 'semutaPass'));
|
||||
game = applyAction(game, f.holder, reaction);
|
||||
if (game.pendingTreacheryDiscard) game = reactSemutaHomeworldBgCharity(game, f.holder, false);
|
||||
assertDenied(f, game, reaction.type === 'semutaCommit');
|
||||
const nextView = viewGame(game, f.owner);
|
||||
nextView.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||||
const next = botActions(nextView)[0];
|
||||
assert.ok(next);
|
||||
const after = applyAction(game, f.owner, next);
|
||||
assert.equal(player(after, f.owner).charityTurn, 1);
|
||||
assert.equal(player(after, f.owner).spice, 5);
|
||||
assert.equal(player(after, f.payer).spice, 12);
|
||||
assertSuffix(f, after);
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import type { SemutaEmperorShareKind } from './fixture-semuta-emperor-share';
|
||||
import {
|
||||
createSemutaHomeworldEmperorShareFixture, disposeSemutaHomeworldEmperorShare,
|
||||
finishSemutaHomeworldEmperorShare, reactSemutaHomeworldEmperorShare,
|
||||
semutaHomeworldEmperorOrdinaryRevive,
|
||||
semutaHomeworldEmperorShareResources as resources,
|
||||
semutaHomeworldEmperorShareStock as stock,
|
||||
type SemutaHomeworldEmperorShareFixture,
|
||||
} from './fixture-semuta-homeworld-emperor-share';
|
||||
|
||||
const kinds: SemutaEmperorShareKind[] = ['emperorGift', 'emperorRevival'];
|
||||
const shareOf = (kind: SemutaEmperorShareKind) => kind === 'emperorGift' ? 'gift' : 'revival';
|
||||
const endPhase = (kind: SemutaEmperorShareKind) => kind === 'emperorGift' ? 3 : 5;
|
||||
|
||||
/** The denied share spends the printed Karama once and moves no benefit. */
|
||||
function assertConsumedDenial(game: Game, f: SemutaHomeworldEmperorShareFixture) {
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length +
|
||||
player(game, f.holder).hand.filter(card => card.id === f.karama).length, 1,
|
||||
'The exact spent cost is either still discarded or recovered once');
|
||||
assert.equal(player(game, f.canceller).hand.some(card => card.id === f.karama), false);
|
||||
assert.equal(game.emperorExtra[f.recipient] ?? 0, 0, 'No extra-revival allowance is granted');
|
||||
assert.deepEqual(resources(game), resources(f.beforeBenefit),
|
||||
'A denied share changes no wallet, force, Tank, custody or revival ledger');
|
||||
assert.equal(game.storm, f.death.storm, 'The original storm history is retained');
|
||||
assert.equal(player(game, f.recipient).allySinceTurn, player(f.alliance, f.recipient).allySinceTurn,
|
||||
'The original turn-two alliance stamp is retained');
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const label = advanced ? 'Advanced' : 'Basic';
|
||||
for (const kind of kinds) {
|
||||
void test(`${label} Homeworld: original Emperor ${shareOf(kind)} declaration accepts the real Atreides Karama cost`, () => {
|
||||
const f = createSemutaHomeworldEmperorShareFixture(kind, advanced);
|
||||
assert.deepEqual(resources(f.game), resources(f.beforeBenefit),
|
||||
'Declaring the share spends nothing before the printed cancellation');
|
||||
assert.deepEqual(stock(f.game), stock(f.setup));
|
||||
assert.equal(f.game.turn, 2);
|
||||
assert.equal(f.game.phase, kind === 'emperorGift' ? 2 : 4);
|
||||
assert.equal(f.beforeWeather.storm, 1);
|
||||
assert.equal(f.weather.stormMovementSource?.kind, 'weather');
|
||||
assert.equal(f.weather.stormMovementSource?.distance, 6);
|
||||
assert.equal(f.game.storm, 7);
|
||||
assert.equal(player(f.paidShipment, f.recipient).spice, player(f.beforeShipment, f.recipient).spice - 6);
|
||||
assert.equal(player(f.paidShipment, f.recipient).forces['red_chasm:7'], 3);
|
||||
assert.equal(player(f.death, f.recipient).forces['red_chasm:7'] ?? 0, 0);
|
||||
assert.equal(player(f.death, f.recipient).tanks, 3);
|
||||
assert.equal(player(f.death, f.recipient).forces['arrakeen:10'], 10);
|
||||
assert.equal(f.worm.nexus, true);
|
||||
assert.equal(player(f.alliance, f.owner).ally, f.recipient);
|
||||
assert.equal(player(f.alliance, f.recipient).ally, f.owner);
|
||||
assert.equal(Boolean(f.game.nexus), false, 'The original worm Nexus has resolved');
|
||||
assert.equal(f.game.response?.kind, kind);
|
||||
assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.response?.recipient, f.recipient);
|
||||
assert.equal(f.game.response?.amount, kind === 'emperorGift' ? 3 : 2);
|
||||
assert.deepEqual(viewGame(f.game, f.canceller).responseControls?.cancelCards, [f.karama],
|
||||
'The allied recipient holds the exact original printed cancellation');
|
||||
for (const seat of f.game.players.filter(seat => seat.id !== f.canceller))
|
||||
assert.equal(viewGame(f.game, seat.id).responseControls?.cancelCards.length ?? 0, 0,
|
||||
'No other original seat can spend the allied printed cancellation');
|
||||
assert.equal(player(f.game, f.owner).spice, 10);
|
||||
|
||||
const receipt = disposeSemutaHomeworldEmperorShare(f);
|
||||
const pending = receipt.pendingTreacheryDiscard!;
|
||||
assert.equal(pending.continuation.kind, 'karamaEmperorShareDiscard');
|
||||
assert.equal(pending.batch.cause, `karama:${kind}`);
|
||||
assert.deepEqual(pending.batch.entries,
|
||||
[{ card: f.karamaCard, discardedBy: f.canceller, publicFace: true }]);
|
||||
assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.equal(receipt.response, null);
|
||||
assert.equal(receipt.decision, null);
|
||||
assert.equal(viewGame(receipt, f.holder).semutaReaction?.canCommit, true);
|
||||
for (const seat of receipt.players.filter(seat => seat.id !== f.holder))
|
||||
assert.equal(viewGame(receipt, seat.id).semutaReaction?.canCommit, false,
|
||||
'Only the original Semuta holder receives the neutral offer');
|
||||
assertConsumedDenial(receipt, f);
|
||||
});
|
||||
|
||||
for (const claim of [false, true]) void test(`${label} Homeworld: Semuta ${claim ? 'claim' : 'pass'} decides the spent Karama and denies the ${shareOf(kind)}`, () => {
|
||||
const f = createSemutaHomeworldEmperorShareFixture(kind, advanced);
|
||||
const receipt = disposeSemutaHomeworldEmperorShare(f);
|
||||
const sequence = receipt.pendingTreacheryDiscard!.sequence;
|
||||
const event = receipt.pendingTreacheryDiscard!.batch.event;
|
||||
assert.deepEqual(resources(receipt), resources(f.beforeBenefit));
|
||||
const done = reactSemutaHomeworldEmperorShare(receipt, f.holder, claim);
|
||||
assert.equal(done.resolvedTreacheryDiscardSequence, sequence);
|
||||
assert.equal(done.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(done, f.holder).hand.some(card => card.id === f.karama), claim);
|
||||
assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!claim));
|
||||
assert.equal(player(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
|
||||
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
|
||||
assertConsumedDenial(done, f);
|
||||
assert.equal(player(done, f.owner).spice, player(f.beforeBenefit, f.owner).spice);
|
||||
assert.equal(player(done, f.recipient).spice, player(f.beforeBenefit, f.recipient).spice);
|
||||
assert.equal(player(done, f.recipient).forces['arrakeen:10'], 10);
|
||||
assert.equal(player(done, f.recipient).forces['red_chasm:7'] ?? 0, 0);
|
||||
assert.equal(player(done, f.recipient).tanks, 3);
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(done, f.canceller, f.cancel),
|
||||
'The spent printed Karama cannot cancel again');
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'alliance', target: f.owner }),
|
||||
'No second alliance is offered outside the original worm Nexus');
|
||||
assert.deepEqual(stock(done), stock(f.setup));
|
||||
const finished = finishSemutaHomeworldEmperorShare(structuredClone(done), f);
|
||||
assert.equal(finished.turn, 2);
|
||||
assert.equal(finished.phase, endPhase(kind));
|
||||
assert.equal(finished.storm, 7);
|
||||
assert.equal(player(finished, f.recipient).forces['arrakeen:10'], 10);
|
||||
assert.equal(player(finished, f.recipient).forces['red_chasm:7'] ?? 0, 0);
|
||||
assert.equal(player(finished, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
|
||||
assert.deepEqual(stock(finished), stock(f.setup));
|
||||
if (kind === 'emperorRevival') {
|
||||
const legal = semutaHomeworldEmperorOrdinaryRevive(done, f.recipient);
|
||||
assert.equal(legal, 3);
|
||||
assert.equal(player(finished, f.recipient).revived, 3,
|
||||
'All three real storm casualties return through ordinary revival');
|
||||
assert.equal(player(finished, f.recipient).freeForcesRevived, 2,
|
||||
'Only the printed two ordinary free returns are granted');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${label} Homeworld: the paid Emperor share still settles natively when the allied cancellation is declined`, () => {
|
||||
for (const kind of kinds) {
|
||||
const f = createSemutaHomeworldEmperorShareFixture(kind, advanced);
|
||||
const before = player(f.game, f.owner).spice;
|
||||
const settled = applyAction(f.game, f.canceller, { type: 'passResponse' });
|
||||
if (kind === 'emperorGift') {
|
||||
assert.equal(player(settled, f.owner).spice, before - 3);
|
||||
assert.equal(player(settled, f.recipient).spice, player(f.game, f.recipient).spice + 3);
|
||||
} else {
|
||||
assert.equal(settled.phase, 4);
|
||||
assert.equal(settled.emperorExtra[f.recipient] ?? 0, 2,
|
||||
'The accepted extra revival really grants its two-revival allowance');
|
||||
}
|
||||
assert.equal(player(settled, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true);
|
||||
assert.equal(player(settled, f.recipient).forces['arrakeen:10'], 10);
|
||||
assert.equal(settled.storm, 7);
|
||||
const finished = finishSemutaHomeworldEmperorShare(settled, f);
|
||||
assert.equal(finished.turn, 2);
|
||||
assert.equal(finished.phase, endPhase(kind));
|
||||
if (kind === 'emperorRevival')
|
||||
assert.equal(player(finished, f.recipient).revived,
|
||||
semutaHomeworldEmperorOrdinaryRevive(settled, f.recipient),
|
||||
'Only the ordinary return the remaining Tanks and allowance permit happens');
|
||||
}
|
||||
});
|
||||
|
||||
for (const kind of kinds) void test(`${label} Homeworld: full Richese hand still exchanges the Karama privately and finishes the original ${shareOf(kind)} step`, () => {
|
||||
const f = createSemutaHomeworldEmperorShareFixture(kind, advanced, true);
|
||||
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
|
||||
const receipt = disposeSemutaHomeworldEmperorShare(f);
|
||||
assert.deepEqual(viewGame(f.game, f.canceller).responseControls?.cancelCards, [f.karama],
|
||||
'The allied recipient keeps its exact printed cancellation with a full holder hand');
|
||||
assert.equal(viewGame(receipt, f.holder).semutaReaction?.canCommit, true);
|
||||
const claimed = reactSemutaHomeworldEmperorShare(receipt, f.holder, true);
|
||||
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
|
||||
assert.equal(player(claimed, f.holder).hand.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
|
||||
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
||||
assertConsumedDenial(claimed, f);
|
||||
assert.equal(finishSemutaHomeworldEmperorShare(claimed, f).phase, endPhase(kind));
|
||||
});
|
||||
|
||||
void test(`${label} Homeworld: every minimal bot profile legally finishes the spent Karama receipt`, () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const f = createSemutaHomeworldEmperorShareFixture('emperorRevival', advanced);
|
||||
const receipt = disposeSemutaHomeworldEmperorShare(f);
|
||||
const own = viewGame(structuredClone(receipt), f.holder);
|
||||
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(own)[0];
|
||||
assert.ok(action, `${difficulty} has an entitled receipt action`);
|
||||
assert.ok(['semutaCommit', 'semutaPass'].includes(action.type));
|
||||
let game: Game = structuredClone(receipt);
|
||||
game = applyAction(game, f.holder, action);
|
||||
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
|
||||
if (reaction?.stage !== 'offer') throw Error('Missing neutral discard offer');
|
||||
game = applyAction(game, game.players.find(seat => !reaction.passed.includes(seat.id))!.id,
|
||||
{ type: 'semutaPass', event: pending.batch.event });
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assertConsumedDenial(game, f);
|
||||
assert.equal(player(game, f.recipient).tanks, 3);
|
||||
assert.equal(player(game, f.recipient).forces['arrakeen:10'], 10);
|
||||
const finished = finishSemutaHomeworldEmperorShare(game, f);
|
||||
assert.equal(finished.phase, 5);
|
||||
const legal = semutaHomeworldEmperorOrdinaryRevive(game, f.recipient);
|
||||
assert.equal(legal, 3);
|
||||
assert.equal(player(finished, f.recipient).revived, 3,
|
||||
'The three ordinary returns remain legal after the denied extra revival');
|
||||
assert.equal(player(finished, f.recipient).freeForcesRevived, 2);
|
||||
assert.equal(finished.emperorExtra[f.recipient] ?? 0, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user