Compose original Homeworld battle card disposal with Semuta
This commit is contained in:
1 parent
4966b1d062
commit
fbf77d0f80
12 files changed
+953
-2
No files matched your search
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
|
||||
createSemutaHomeworldChoamSetup } from './fixture-semuta-homeworld-choam';
|
||||
import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
|
||||
export type SemutaHomeworldLoserFixture = {
|
||||
setup: Game; game: Game; beforeBattle: Game; owner: string; winner: string; holder: string;
|
||||
location: string; cards: string[]; winnerCards: string[]; action: Action; actor: string; support: number;
|
||||
winnerLeader: string; loserLeader: string; actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
shipments: { actor: string; action: Action; before: Game; after: Game; price: number }[];
|
||||
};
|
||||
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
|
||||
|
||||
/** Native continuation, except that the real end-of-Bidding gate is answered
|
||||
* with the entitled seat's own spending-free ready mode so no wallet is
|
||||
* decided by an unrelated generic bot policy. */
|
||||
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', 'The original Homeworld game must reach the requested boundary');
|
||||
const end = game.biddingEnd;
|
||||
const seat = end && clean(game)
|
||||
? end.owners.find(id => !end.ready.includes(id)) : undefined;
|
||||
if (seat) {
|
||||
game = applySemutaHomeworldChoamStep(game, seat,
|
||||
{ type: 'biddingEnd', event: end!.event, mode: 'ready' }, actions);
|
||||
continue;
|
||||
}
|
||||
game = advanceSemutaHomeworldChoam(game, g => until(g) || !!g.biddingEnd, actions);
|
||||
}
|
||||
assert.ok(until(game), 'The native Homeworld continuation must reach its boundary');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Original completed native setup; only printed hands and unplayed Spice order
|
||||
* are positioned. Every force, wallet, battle control and casualty is native. */
|
||||
export function createSemutaHomeworldLoserFixture(advanced = false, fullHolder = false): SemutaHomeworldLoserFixture {
|
||||
const native = createSemutaHomeworldChoamSetup(advanced);
|
||||
let game = native.game;
|
||||
const setup = structuredClone(game), actions = native.actions;
|
||||
const holder = native.holder, owner = native.owner, winner = native.canceller;
|
||||
const location = 'red_chasm:7';
|
||||
const staging = ['Conserved original Semuta in Richese hand.'];
|
||||
staging.push('Answer the real end-of-Bidding gate with each entitled seat\'s own spending-free ready mode.');
|
||||
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('Position two original unplayed non-worm Spice Cards.');
|
||||
const shipments: SemutaHomeworldLoserFixture['shipments'] = [];
|
||||
for (const [actor, amount, price] of [[winner, 4, 8], [owner, 1, 2]] as const) {
|
||||
game = advance(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, 'Actual native custody must supply the normal shipment');
|
||||
const action: Action = { type: 'ship', ...splitLocation(location), amount, elite: 0,
|
||||
homeworldSources: sources, allyPayment: 0 };
|
||||
const before = structuredClone(game);
|
||||
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
|
||||
game = advance(game, clean, actions);
|
||||
assert.equal(player(game, actor).spice, player(before, actor).spice - price);
|
||||
assert.equal(player(game, actor).reserves, player(before, actor).reserves - amount);
|
||||
assert.equal(player(game, actor).forces[location], amount);
|
||||
shipments.push({ actor, action, before, after: structuredClone(game), price });
|
||||
game = applySemutaHomeworldChoamStep(game, actor, { type: 'endMovement' }, actions);
|
||||
}
|
||||
game = advance(game, g => g.phase === 6 && clean(g), actions);
|
||||
const allocated: string[] = [];
|
||||
const give = (actor: string, kind: 'projectile' | 'shield') => {
|
||||
const card = treacheryDeck(game.expansions).find(c => c.kind === kind && !allocated.includes(c.id));
|
||||
assert.ok(card);
|
||||
allocated.push(card.id);
|
||||
return moveSemutaEmperorCard(game, card.id, actor);
|
||||
};
|
||||
const winnerCards = [give(winner, 'projectile'), give(winner, 'shield')];
|
||||
const cards = [give(owner, 'projectile'), give(owner, 'shield')];
|
||||
staging.push(`Conserved distinct original projectile/Shield pairs: ${winnerCards.join(', ')} to Emperor; ${cards.join(', ')} to CHOAM.`);
|
||||
if (fullHolder) {
|
||||
while (player(game, holder).hand.length < handLimit(player(game, holder))) {
|
||||
assert.ok(game.deck[0]);
|
||||
moveSemutaEmperorCard(game, game.deck[0].id, holder);
|
||||
}
|
||||
staging.push('Conserved original undealt cards fill Richese to four.');
|
||||
}
|
||||
for (const seat of game.players) assert.ok(seat.hand.length <= handLimit(seat));
|
||||
assert.ok(player(game, holder).hand.some(c => c.id === SEMUTA_DRUG_ID));
|
||||
const beforeBattle = structuredClone(game);
|
||||
const actor = game.active!;
|
||||
assert.ok(actor === owner || actor === winner, 'Choose only the actual original phase-six battle actor');
|
||||
game = applySemutaHomeworldChoamStep(game, actor,
|
||||
{ type: 'chooseBattle', territory: splitLocation(location).territory, target: actor === winner ? owner : winner }, actions);
|
||||
game = advance(game,
|
||||
g => !!g.battle && !g.battle.preparation && g.battle.preLeader?.closed !== false && clean(g), actions);
|
||||
const strongest = player(game, winner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
|
||||
const weakest = player(game, owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(strongest && weakest && strongest.strength + 1 > weakest.strength);
|
||||
const support = advanced ? 1 : 0;
|
||||
assert.ok(player(game, winner).spice >= support, 'The native budget must cover the Advanced spice support');
|
||||
game = applySemutaHomeworldChoamStep(game, winner, { type: 'battlePlan', dial: 1,
|
||||
support, leader: strongest.id, weapon: winnerCards[0], defense: winnerCards[1] }, actions);
|
||||
game = applySemutaHomeworldChoamStep(game, owner, { type: 'battlePlan', dial: 0,
|
||||
support: 0, leader: weakest.id, weapon: cards[0], defense: cards[1] }, actions);
|
||||
// Use the public native voter order; stop before its last real response.
|
||||
let final: RicheseSkillsTechStep | undefined;
|
||||
for (let n = 0; n < 100; n++) {
|
||||
const next = nextRicheseSkillsTechNativeStep(game);
|
||||
if (next.action.type === 'traitorCall') {
|
||||
const voters = viewGame(game, game.host).battle!.traitorVoters;
|
||||
if (voters.every(id => id === next.actor || game.battle!.traitorCalls[id] !== undefined)) {
|
||||
final = next;
|
||||
break;
|
||||
}
|
||||
}
|
||||
game = applySemutaHomeworldChoamStep(game, next.actor, next.action, actions);
|
||||
}
|
||||
assert.ok(final, 'Native final traitor-pass control must resolve the original battle');
|
||||
return { setup, game, beforeBattle, owner, winner, holder, location, cards, winnerCards, support,
|
||||
actor: final.actor, action: final.action, winnerLeader: strongest.id, loserLeader: weakest.id,
|
||||
actions, staging, shipments };
|
||||
}
|
||||
|
||||
export function disposeSemutaHomeworldLoser(f: SemutaHomeworldLoserFixture,
|
||||
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
|
||||
}
|
||||
|
||||
/** Keep the winner's held played cards, then use original income/market controls
|
||||
* to enter this same turn's Collection exactly once. */
|
||||
export function finishSemutaHomeworldLoser(state: Game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
return advance(state, g => g.phase === 7 && clean(g), actions ?? []);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { semutaEmperorPlayer as player, moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
|
||||
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
|
||||
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
|
||||
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
|
||||
export type SemutaHomeworldWinnerKind = 'mandatory' | 'selected';
|
||||
export type SemutaHomeworldWinnerFixture = {
|
||||
kind: SemutaHomeworldWinnerKind; setup: Game; beforeBattle: Game; game: Game;
|
||||
owner: string; holder: string; winner: string; loser: string; location: string;
|
||||
card: string; kept: string; projectile: string; worthless: string;
|
||||
winnerLeader: string; loserLeader: string; actor: string; action: Action;
|
||||
actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
shipments: { actor: string; action: Action; before: Game; after: Game }[];
|
||||
earlierEvents: string[];
|
||||
};
|
||||
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
|
||||
export function semutaHomeworldWinnerStock(g: Game) {
|
||||
return [g.deck, g.discard, g.richeseCache ?? [], g.richeseRemoved ?? [],
|
||||
...g.players.map(p => p.hand)].flat().map(c => c.id).sort();
|
||||
}
|
||||
export function semutaHomeworldWinnerResources(g: Game) {
|
||||
return { custody: structuredClone(g.homeworlds?.custody),
|
||||
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) })) };
|
||||
}
|
||||
function passOffer(state: Game, actions?: RicheseSkillsTechStep[]) {
|
||||
let g = state;
|
||||
const pending = g.pendingTreacheryDiscard;
|
||||
assert.equal(pending?.reaction?.stage, 'offer');
|
||||
const event = pending!.batch.event;
|
||||
for (const seat of state.players) {
|
||||
if (!g.pendingTreacheryDiscard || g.pendingTreacheryDiscard.batch.event !== event) break;
|
||||
const reaction = g.pendingTreacheryDiscard.reaction;
|
||||
assert.ok(reaction?.stage === 'offer');
|
||||
if (!reaction.passed.includes(seat.id))
|
||||
g = applySemutaHomeworldChoamStep(g, seat.id, { type: 'semutaPass', event }, actions);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Original first-turn game: native paid shipments and plans; only conserved
|
||||
* physical hands and two unplayed non-worm Spice Cards are positioned. */
|
||||
export function createSemutaHomeworldWinnerFixture(
|
||||
kind: SemutaHomeworldWinnerKind, advanced = false, fullHolder = false,
|
||||
): SemutaHomeworldWinnerFixture {
|
||||
const native = createSemutaHomeworldChoamSetup(advanced);
|
||||
let game = native.game;
|
||||
const setup = structuredClone(game), actions = native.actions;
|
||||
const owner = native.owner, holder = native.holder, loser = native.canceller;
|
||||
const staging = ['Conserved original physical Semuta held by Richese.'];
|
||||
const battleBlow = game.spiceDeck.find(c => 'territory' in c && c.territory === 'red_chasm');
|
||||
const otherBlow = game.spiceDeck.find(c => 'territory' in c && c !== battleBlow);
|
||||
assert.ok(battleBlow && otherBlow);
|
||||
const blows = [battleBlow, otherBlow];
|
||||
for (const [index, card] of blows.entries())
|
||||
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
staging.push('Reorder the original Red Chasm Spice Card and another non-worm card, proving real Collection income after cleanup.');
|
||||
const shipments: SemutaHomeworldWinnerFixture['shipments'] = [];
|
||||
for (const [actor, amount] of [[loser, 1], [owner, 4]] 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, player(before, actor).spice - 2 * amount);
|
||||
assert.equal(player(game, actor).reserves, player(before, actor).reserves - amount);
|
||||
game = applySemutaHomeworldChoamStep(game, actor, { type: 'endMovement' }, actions);
|
||||
}
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.phase === 6 && clean(g), actions);
|
||||
const projectile = holdSemutaHomeworldChoamCard(game, owner, 'Crysknife');
|
||||
const kept = holdSemutaHomeworldChoamCard(game, owner, 'Shield');
|
||||
const worthless = holdSemutaHomeworldChoamCard(game, loser, 'Baliset');
|
||||
const cheap = kind === 'mandatory' ? holdSemutaHomeworldChoamCard(game, owner, 'Cheap Hero') : null;
|
||||
staging.push('Conserved printed Crysknife and Shield to CHOAM; Worthless to Emperor.' +
|
||||
(cheap ? ' Conserved printed Cheap Hero 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 physical cards fill the Richese hand to its native limit.');
|
||||
}
|
||||
assert.ok(player(game, owner).hand.length <= handLimit(player(game, owner)));
|
||||
const beforeBattle = structuredClone(game);
|
||||
const chooser = game.active;
|
||||
assert.ok(chooser && [owner, loser].includes(chooser));
|
||||
const target = chooser === owner ? loser : owner;
|
||||
game = applySemutaHomeworldChoamStep(game, chooser,
|
||||
{ type: 'chooseBattle', territory: 'red_chasm', target }, actions);
|
||||
game = advanceSemutaHomeworldChoam(game,
|
||||
g => !!g.battle?.preLeader?.closed && clean(g), actions);
|
||||
const winnerLeader = cheap ?? player(game, owner).leaders.find(l => !l.dead && !l.usedAt)!.id;
|
||||
const loserLeader = player(game, loser).leaders.find(l => !l.dead && !l.usedAt)!.id;
|
||||
for (const actor of [game.battle!.attacker, game.battle!.defender]) {
|
||||
const action: Action = actor === owner
|
||||
? { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0,
|
||||
leader: winnerLeader, weapon: projectile, defense: kept }
|
||||
: { type: 'battlePlan', dial: 0, support: 0, leader: loserLeader, weapon: worthless };
|
||||
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
|
||||
}
|
||||
const voters = [game.battle!.attacker, game.battle!.defender];
|
||||
game = applySemutaHomeworldChoamStep(game, voters[0], { type: 'traitorCall', call: false }, actions);
|
||||
const finalResponse: Action = { type: 'traitorCall', call: false };
|
||||
const earlierEvents: string[] = [];
|
||||
let action = finalResponse;
|
||||
if (kind === 'selected') {
|
||||
game = applySemutaHomeworldChoamStep(game, voters[1], finalResponse, actions);
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
earlierEvents.push(game.pendingTreacheryDiscard.batch.event);
|
||||
game = passOffer(game, actions);
|
||||
}
|
||||
game = advanceSemutaHomeworldChoam(game,
|
||||
g => g.decision?.kind === 'battleCards', actions);
|
||||
action = { type: 'decision', discard: [projectile] };
|
||||
}
|
||||
return { kind, setup, beforeBattle, game, owner, holder, winner: owner, loser,
|
||||
location: 'red_chasm:7', card: cheap ?? projectile, kept, projectile, worthless,
|
||||
winnerLeader, loserLeader, actor: kind === 'mandatory' ? voters[1] : owner,
|
||||
action, actions, staging, shipments, earlierEvents };
|
||||
}
|
||||
|
||||
export function disposeSemutaHomeworldWinner(f: SemutaHomeworldWinnerFixture,
|
||||
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
|
||||
if (f.kind === 'mandatory' && game.pendingTreacheryDiscard?.continuation.kind === 'battleResolved')
|
||||
game = passOffer(game, actions);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Retain unselected original winner cards, complete the real CHOAM market and
|
||||
* reach Collection without ever replaying battle or editing the native clock. */
|
||||
export function finishSemutaHomeworldWinner(state: Game,
|
||||
actions?: RicheseSkillsTechStep[]): Game {
|
||||
const game = advanceSemutaHomeworldChoam(state, g => g.phase === 7 && clean(g), actions);
|
||||
assert.equal(game.turn, state.turn);
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
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 { splitLocation } from '../game/board';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import {
|
||||
createSemutaHomeworldLoserFixture, disposeSemutaHomeworldLoser, finishSemutaHomeworldLoser,
|
||||
type SemutaHomeworldLoserFixture,
|
||||
} from './fixture-semuta-homeworld-battle-loser';
|
||||
|
||||
function offerEvent(game: Game): string {
|
||||
const pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending, 'A fresh public discard offer must be open');
|
||||
return pending.batch.event;
|
||||
}
|
||||
|
||||
function claimLoser(f: SemutaHomeworldLoserFixture, state: Game, card: string): Game {
|
||||
const event = offerEvent(state);
|
||||
return applyAction(applyAction(state, f.holder, { type: 'semutaCommit', event }),
|
||||
f.holder, { type: 'semutaSelect', event, card });
|
||||
}
|
||||
|
||||
function passLoser(state: Game): Game {
|
||||
const event = offerEvent(state);
|
||||
let game = state;
|
||||
for (const seat of state.players)
|
||||
game = applyAction(game, seat.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
|
||||
function cleanupDecision(game: Game) {
|
||||
const decision = game.decision;
|
||||
assert.ok(decision && decision.kind === 'battleCards',
|
||||
'The winning side keeps its held played cards under its own native cleanup');
|
||||
return decision;
|
||||
}
|
||||
|
||||
/** The public combatant facts every seat shares after the losers cede cards. */
|
||||
function assertSettled(f: SemutaHomeworldLoserFixture, game: Game) {
|
||||
const { winner, loser } = { winner: player(game, f.winner), loser: player(game, f.owner) };
|
||||
assert.equal(winner.forces[f.location], 3, 'The winner keeps exactly its surviving dialed force');
|
||||
assert.equal(winner.reserves, player(f.game, f.winner).reserves, 'Shipment already left the native reserve pool');
|
||||
assert.equal(loser.forces[f.location] ?? 0, 0, 'The entire losing force leaves the board');
|
||||
assert.equal(loser.tanks, 1, 'Exactly one losing force is destroyed to Tanks');
|
||||
assert.equal(loser.reserves, player(f.game, f.owner).reserves);
|
||||
for (const seat of game.players) {
|
||||
const view = viewGame(game, seat.id);
|
||||
assert.equal(view.players.find(p => p.id === f.winner)!.forces[f.location], 3);
|
||||
assert.equal(view.players.find(p => p.id === f.owner)!.forces[f.location] ?? 0, 0);
|
||||
assert.equal(view.players.find(p => p.id === f.owner)!.tanks, 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const label = `${advanced ? 'Advanced' : 'Basic'} original paid Homeworld battle`;
|
||||
|
||||
void test(`${label}: native shipments and battle produce the public losing-card batch before any Semuta offer`, () => {
|
||||
const f = createSemutaHomeworldLoserFixture(advanced);
|
||||
assert.equal(f.setup.status, 'playing');
|
||||
assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.game.phase, 6);
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null, 'The losing batch has not been offered yet');
|
||||
assert.equal(f.game.decision ?? null, null);
|
||||
assert.ok([f.winner, f.owner].includes(f.game.active!));
|
||||
assert.equal(splitLocation(f.location).territory, 'red_chasm');
|
||||
assert.equal(new Set([f.winner, f.owner, f.holder]).size, 3);
|
||||
assert.equal(new Set([...f.cards, ...f.winnerCards]).size, 4, 'Four distinct physical weapon/defense cards');
|
||||
assert.equal(player(f.game, f.owner).hand.find(c => c.id === f.cards[0])!.kind, 'projectile');
|
||||
assert.equal(player(f.game, f.owner).hand.find(c => c.id === f.cards[1])!.kind, 'shield');
|
||||
assert.equal(player(f.game, f.winner).hand.find(c => c.id === f.winnerCards[0])!.kind, 'projectile');
|
||||
assert.equal(player(f.game, f.winner).hand.find(c => c.id === f.winnerCards[1])!.kind, 'shield');
|
||||
assert.ok(f.cards.every(id => !f.game.discard.some(c => c.id === id)));
|
||||
assert.equal(f.beforeBattle.phase, f.game.phase);
|
||||
assert.deepEqual(f.beforeBattle.players.map(p => p.forces), f.game.players.map(p => p.forces));
|
||||
|
||||
const voters = viewGame(f.game, f.game.host).battle!.traitorVoters;
|
||||
assert.equal(f.actor, voters[voters.length - 1], 'The last real traitor response belongs to the final public voter');
|
||||
assert.equal(f.action.type, 'traitorCall');
|
||||
assert.ok([f.winner, f.owner].includes(f.actor));
|
||||
|
||||
assert.equal(f.shipments.length, 2);
|
||||
const [winnerShip, loserShip] = f.shipments;
|
||||
assert.equal(winnerShip.actor, f.winner);
|
||||
assert.equal(loserShip.actor, f.owner);
|
||||
assert.equal(winnerShip.action.type, 'ship');
|
||||
assert.equal(loserShip.action.type, 'ship');
|
||||
assert.equal(winnerShip.action.amount, 4);
|
||||
assert.equal(loserShip.action.amount, 1);
|
||||
assert.equal(winnerShip.price, 8);
|
||||
assert.equal(loserShip.price, 2);
|
||||
for (const ship of f.shipments) {
|
||||
const amount = ship.action.amount;
|
||||
assert.ok(typeof amount === 'number');
|
||||
assert.equal(player(ship.before, ship.actor).spice - player(ship.after, ship.actor).spice, ship.price);
|
||||
assert.equal(ship.price, 2 * amount);
|
||||
assert.equal(player(ship.before, ship.actor).reserves - player(ship.after, ship.actor).reserves, amount);
|
||||
assert.equal(player(ship.after, ship.actor).forces[f.location], amount);
|
||||
assert.deepEqual(player(ship.after, ship.actor).elites, player(ship.before, ship.actor).elites,
|
||||
'Only ordinary native reserves depart, not Sardaukar');
|
||||
assert.deepEqual(ship.after.homeworlds?.custody, ship.before.homeworlds?.custody,
|
||||
'Kaitain supply comes from the native reserve pool, not staged custody');
|
||||
}
|
||||
assert.equal(player(f.game, f.winner).forces[f.location], 4);
|
||||
assert.equal(player(f.game, f.owner).forces[f.location], 1);
|
||||
|
||||
const done = disposeSemutaHomeworldLoser(f);
|
||||
assert.equal(done.phase, 6);
|
||||
assert.equal(done.turn, 1);
|
||||
assert.equal(done.lastBattleContext!.winner, f.winner);
|
||||
assert.equal(done.lastBattleContext!.territory, 'red_chasm');
|
||||
const pending = done.pendingTreacheryDiscard;
|
||||
assert.ok(pending);
|
||||
assert.equal(pending.batch.cause, 'battle:mandatory');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id, entry.discardedBy]).sort((a, b) => a[0].localeCompare(b[0])),
|
||||
f.cards.map(id => [id, f.owner]).sort((a, b) => a[0].localeCompare(b[0])), 'The batch is exactly the losing player\'s two played cards');
|
||||
assert.equal(pending.reaction?.stage, 'offer');
|
||||
for (const card of f.cards) assert.ok(done.discard.some(c => c.id === card));
|
||||
assert.ok(player(done, f.winner).hand.every(card => !f.cards.includes(card.id)));
|
||||
assert.equal(player(done, f.owner).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(done, f.owner).tanks, 1);
|
||||
assert.equal(player(done, f.winner).forces[f.location], 4,
|
||||
'The winner\'s own casualties belong to the original continuation, not the offer');
|
||||
assert.equal(player(done, f.winner).spice, player(f.game, f.winner).spice - f.support);
|
||||
assert.equal(f.support, advanced ? 1 : 0);
|
||||
assert.equal(player(done, f.winner).reserves, 16);
|
||||
assert.equal(player(done, f.owner).reserves, 19);
|
||||
assert.equal(player(done, f.holder).hand.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
||||
assert.deepEqual(done.homeworlds?.custody, f.game.homeworlds?.custody);
|
||||
for (const seat of done.players) {
|
||||
const view = viewGame(done, seat.id);
|
||||
assert.equal(view.semutaReaction?.canCommit, seat.id === f.holder);
|
||||
if (seat.id !== f.holder) assert.deepEqual(view.semutaReaction?.candidates, []);
|
||||
}
|
||||
assert.throws(() => applyAction(done, f.actor, f.action), 'The resolving battle control cannot be replayed');
|
||||
});
|
||||
|
||||
for (const fullHolder of [false, true]) {
|
||||
const holderLabel = fullHolder ? ' at the Richese hand limit' : '';
|
||||
|
||||
void test(`${label}${holderLabel}: Richese claims one actual public losing card through Commit then Select`, () => {
|
||||
const f = createSemutaHomeworldLoserFixture(advanced, fullHolder);
|
||||
const done = disposeSemutaHomeworldLoser(f);
|
||||
const event = offerEvent(done);
|
||||
if (fullHolder)
|
||||
assert.equal(player(done, f.holder).hand.length, handLimit(player(done, f.holder)));
|
||||
|
||||
const holderOffer = viewGame(done, f.holder).semutaReaction!;
|
||||
assert.equal(holderOffer.stage, 'offer');
|
||||
assert.equal(holderOffer.passed, false);
|
||||
assert.deepEqual(holderOffer.candidates, [], 'Commitment precedes any private card selection');
|
||||
const committed = applyAction(done, f.holder, { type: 'semutaCommit', event });
|
||||
const selection = committed.pendingTreacheryDiscard?.reaction;
|
||||
assert.equal(selection?.stage, 'select');
|
||||
assert.deepEqual([...selection!.candidates].sort(), [...f.cards].sort(),
|
||||
'The private choice is exactly the two public losing cards');
|
||||
assert.deepEqual(viewGame(committed, f.winner).semutaReaction?.candidates, []);
|
||||
assert.deepEqual(viewGame(committed, f.owner).semutaReaction?.candidates, []);
|
||||
const snapshot = JSON.stringify(committed);
|
||||
assert.throws(() => applyAction(committed, f.winner, { type: 'semutaSelect', event, card: f.cards[0] }));
|
||||
assert.throws(() => applyAction(committed, f.holder,
|
||||
{ type: 'semutaSelect', event, card: f.winnerCards[0] }));
|
||||
assert.equal(JSON.stringify(committed), snapshot, 'An illegal selection changes nothing');
|
||||
|
||||
const selected = claimLoser(f, done, f.cards[0]);
|
||||
assert.equal(selected.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(player(selected, f.holder).hand.filter(c => c.id === f.cards[0]).length, 1);
|
||||
assert.equal(player(selected, f.holder).hand.some(c => c.id === f.cards[1]), false);
|
||||
assert.ok(selected.discard.some(c => c.id === f.cards[1]), 'The unselected card stays discarded');
|
||||
assert.ok(selected.discard.some(c => c.id === SEMUTA_DRUG_ID));
|
||||
assert.equal(player(selected, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), false);
|
||||
if (fullHolder)
|
||||
assert.equal(player(selected, f.holder).hand.length, handLimit(player(selected, f.holder)));
|
||||
assertSettled(f, selected);
|
||||
const cleanup = cleanupDecision(selected);
|
||||
assert.equal(cleanup.player, f.winner);
|
||||
assert.deepEqual([...cleanup.cards].sort(), [...f.winnerCards].sort(),
|
||||
'Only the winner\'s own held played cards are at stake');
|
||||
|
||||
const kept = applyAction(selected, f.winner, { type: 'decision', discard: [] });
|
||||
assert.ok(f.winnerCards.every(card => player(kept, f.winner).hand.some(c => c.id === card)),
|
||||
'Held played cards stay with the winner by default');
|
||||
assert.throws(() => applyAction(kept, f.winner, { type: 'decision', discard: [] }),
|
||||
'The original cleanup is consumed exactly once');
|
||||
const finished = finishSemutaHomeworldLoser(kept);
|
||||
assert.equal(finished.phase, 7);
|
||||
assert.equal(finished.turn, 1);
|
||||
assert.ok(!finished.phaseOpening && !finished.response && !finished.decision && !finished.pendingTreacheryDiscard);
|
||||
assert.equal(player(finished, f.holder).hand.filter(c => c.id === f.cards[0]).length, 1);
|
||||
assertSettled(f, finished);
|
||||
assert.equal(player(finished, f.winner).reserves, 16);
|
||||
assert.equal(player(finished, f.owner).tanks, 1);
|
||||
assert.equal(player(finished, f.owner).reserves, 19);
|
||||
assert.throws(() => applyAction(finished, f.holder, { type: 'semutaCommit', event }),
|
||||
'The resolved losing batch cannot be claimed twice');
|
||||
assert.throws(() => applyAction(finished, f.holder, { type: 'semutaSelect', event, card: f.cards[1] }));
|
||||
assert.throws(() => applyAction(finished, f.actor, f.action));
|
||||
});
|
||||
|
||||
void test(`${label}${holderLabel}: passing conserves the printed Semuta and both public losing cards`, () => {
|
||||
const f = createSemutaHomeworldLoserFixture(advanced, fullHolder);
|
||||
const done = disposeSemutaHomeworldLoser(f);
|
||||
const event = offerEvent(done);
|
||||
const holderPassed = applyAction(done, f.holder, { type: 'semutaPass', event });
|
||||
assert.throws(() => applyAction(holderPassed, f.holder, { type: 'semutaPass', event }),
|
||||
'One seat answers this fresh discard exactly once');
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: 'stale-discard' }));
|
||||
const declined = passLoser(done);
|
||||
assert.equal(declined.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(player(declined, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), true);
|
||||
assert.ok(f.cards.every(card => declined.discard.some(c => c.id === card)));
|
||||
assertSettled(f, declined);
|
||||
assert.deepEqual([...cleanupDecision(declined).cards].sort(), [...f.winnerCards].sort());
|
||||
const finished = finishSemutaHomeworldLoser(applyAction(declined, f.winner, { type: 'decision', discard: [] }));
|
||||
assert.equal(finished.phase, 7);
|
||||
assert.ok(f.cards.every(card => finished.discard.some(c => c.id === card)));
|
||||
assert.equal(player(finished, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), true);
|
||||
assertSettled(f, finished);
|
||||
assert.throws(() => applyAction(finished, f.holder, { type: 'semutaPass', event }));
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${label}: the winner's held played cards are kept or discarded under its own original cleanup`, () => {
|
||||
const f = createSemutaHomeworldLoserFixture(advanced);
|
||||
const done = disposeSemutaHomeworldLoser(f);
|
||||
const mandatory = offerEvent(done);
|
||||
const selected = claimLoser(f, done, f.cards[1]);
|
||||
assert.equal(player(selected, f.holder).hand.filter(c => c.id === f.cards[1]).length, 1);
|
||||
assert.ok(selected.discard.some(c => c.id === f.cards[0]));
|
||||
const discarded = applyAction(selected, f.winner, { type: 'decision', discard: [f.winnerCards[0]] });
|
||||
assert.equal(player(discarded, f.winner).hand.some(c => c.id === f.winnerCards[0]), false);
|
||||
assert.ok(discarded.discard.some(c => c.id === f.winnerCards[0]));
|
||||
assert.ok(player(discarded, f.winner).hand.some(c => c.id === f.winnerCards[1]),
|
||||
'The unselected played card is retained');
|
||||
const winnerOffer = discarded.pendingTreacheryDiscard;
|
||||
assert.ok(winnerOffer, 'A discarded winner card opens its own fresh public offer');
|
||||
assert.notEqual(winnerOffer.batch.event, mandatory, 'Each public card batch has its own event');
|
||||
assert.deepEqual(winnerOffer.batch.entries.map(entry => [entry.card.id, entry.discardedBy]),
|
||||
[[f.winnerCards[0], f.winner]]);
|
||||
const resolved = passLoser(discarded);
|
||||
assert.equal(resolved.pendingTreacheryDiscard ?? null, null);
|
||||
const finished = finishSemutaHomeworldLoser(resolved);
|
||||
assert.equal(finished.phase, 7);
|
||||
assert.ok(finished.discard.some(c => c.id === f.winnerCards[0]));
|
||||
assert.ok(player(finished, f.winner).hand.some(c => c.id === f.winnerCards[1]));
|
||||
assertSettled(f, finished);
|
||||
assert.throws(() => applyAction(finished, f.holder,
|
||||
{ type: 'semutaCommit', event: mandatory }));
|
||||
});
|
||||
|
||||
void test(`${label}: every legal bot profile claims through the original controls to the same battlefield`, () => {
|
||||
const baseline = (() => {
|
||||
const f = createSemutaHomeworldLoserFixture(advanced);
|
||||
const selected = claimLoser(f, disposeSemutaHomeworldLoser(f), f.cards[0]);
|
||||
return { f, game: applyAction(selected, f.winner, { type: 'decision', discard: [] }) };
|
||||
})();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const f = createSemutaHomeworldLoserFixture(advanced);
|
||||
const done = disposeSemutaHomeworldLoser(f);
|
||||
const bidders = structuredClone(done);
|
||||
player(bidders, f.holder).bot = difficulty;
|
||||
const commit = botActions(viewGame(bidders, f.holder))[0];
|
||||
assert.ok(commit);
|
||||
assert.ok(commit.type === 'semutaCommit' || commit.type === 'semutaPass');
|
||||
let game = applyAction(bidders, f.holder, commit);
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
const select = botActions(viewGame(game, f.holder))[0];
|
||||
assert.ok(select);
|
||||
assert.ok(select.type === 'semutaSelect' || select.type === 'semutaPass');
|
||||
game = applyAction(game, f.holder, select);
|
||||
}
|
||||
if (game.pendingTreacheryDiscard) game = passLoser(game);
|
||||
const cleanup = cleanupDecision(game);
|
||||
game = applyAction(game, cleanup.player, { type: 'decision', discard: [] });
|
||||
const finished = finishSemutaHomeworldLoser(game);
|
||||
assert.equal(finished.phase, 7);
|
||||
assertSettled(f, finished);
|
||||
assert.deepEqual(finished.players.map(seat => [seat.id, seat.forces, seat.tanks, seat.reserves]),
|
||||
baseline.game.players.map(seat => [seat.id, seat.forces, seat.tanks, seat.reserves]),
|
||||
`${difficulty} resolves the identical original casualties`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
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 { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
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 {
|
||||
createSemutaHomeworldWinnerFixture, disposeSemutaHomeworldWinner,
|
||||
finishSemutaHomeworldWinner, semutaHomeworldWinnerResources as resources,
|
||||
semutaHomeworldWinnerStock as stock, type SemutaHomeworldWinnerFixture,
|
||||
} from './fixture-semuta-homeworld-battle-winner';
|
||||
|
||||
function assertOriginalBattle(f: SemutaHomeworldWinnerFixture, game: Game) {
|
||||
assert.equal(game.turn, 1);
|
||||
assert.equal(game.lastBattleContext?.winner, f.winner);
|
||||
assert.equal(game.lastBattleContext?.result, 'normal');
|
||||
assert.equal(game.lastBattleContext?.territory, 'red_chasm');
|
||||
assert.ok(!game.battle, 'The accepted original battle is not reopened');
|
||||
assert.equal(player(game, f.winner).forces[f.location], 3);
|
||||
assert.equal(player(game, f.winner).reserves, 16);
|
||||
assert.equal(player(game, f.winner).tanks, 1);
|
||||
assert.equal(player(game, f.winner).battleLosses,
|
||||
player(f.beforeBattle, f.winner).battleLosses + 1);
|
||||
assert.equal(player(game, f.loser).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(game, f.loser).reserves, 19);
|
||||
assert.equal(player(game, f.loser).tanks, 1);
|
||||
assert.equal(player(game, f.loser).leaders.find(l => l.id === f.loserLeader)?.dead, true);
|
||||
assert.equal(player(f.beforeBattle, f.loser).leaders.find(l => l.id === f.loserLeader)?.dead, false);
|
||||
assert.equal(player(game, f.winner).spice,
|
||||
player(f.beforeBattle, f.winner).spice - Number(game.advanced) +
|
||||
player(f.beforeBattle, f.loser).leaders.find(l => l.id === f.loserLeader)!.strength,
|
||||
'Only original supported dial cost and killed-leader income change CHOAM spice');
|
||||
assert.equal(player(game, f.loser).spice, player(f.beforeBattle, f.loser).spice,
|
||||
'Emperor dials and pays zero; no CHOAM opponent battle income is invented');
|
||||
assert.deepEqual(game.homeworlds?.custody, f.shipments[1].after.homeworlds?.custody,
|
||||
'Original paid native Homeworld withdrawal remains unchanged by battle cleanup');
|
||||
for (const seat of game.players.filter(p => ![f.winner, f.loser].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.reserves, before.reserves);
|
||||
assert.equal(seat.tanks, before.tanks);
|
||||
assert.equal(seat.spice, before.spice);
|
||||
}
|
||||
assert.deepEqual(stock(game), stock(f.setup));
|
||||
}
|
||||
|
||||
function assertExchange(f: SemutaHomeworldWinnerFixture, pending: Game, game: Game, claim: boolean) {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.deepEqual(resources(game), resources(pending), 'Semuta never repeats accepted combat or casualties');
|
||||
assert.deepEqual(game.lastBattleContext, pending.lastBattleContext);
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.card), false);
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.kept), true);
|
||||
if (f.kind === 'mandatory')
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.projectile), true);
|
||||
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.card).length, Number(claim));
|
||||
assert.equal(game.discard.filter(c => c.id === f.card).length, Number(!claim));
|
||||
assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim);
|
||||
assert.equal(game.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claim));
|
||||
assert.equal(game.discard.filter(c => c.id === f.worthless).length, 1,
|
||||
'Earlier losing Worthless remains in its original discard, not the later offer');
|
||||
for (const seat of game.players) {
|
||||
const expected = player(pending, seat.id).hand.map(c => c.id)
|
||||
.filter(id => seat.id !== f.holder || !claim || id !== SEMUTA_DRUG_ID);
|
||||
if (seat.id === f.holder && claim) expected.push(f.card);
|
||||
assert.deepEqual(seat.hand.map(c => c.id).sort(), expected.sort(),
|
||||
'Exactly the chosen card exchanges with Semuta; every other physical hand card stays put');
|
||||
}
|
||||
assertOriginalBattle(f, game);
|
||||
assert.equal(game.decision?.kind, f.kind === 'mandatory' ? 'battleCards' : 'choamMarket');
|
||||
assert.equal(game.decision?.player, f.owner);
|
||||
}
|
||||
|
||||
function finishOnce(f: SemutaHomeworldWinnerFixture, state: Game) {
|
||||
const expected = resources(state), context = structuredClone(state.lastBattleContext);
|
||||
const collection = quoteSpiceCollection(state);
|
||||
assert.equal(collection.receipts.find(receipt => receipt.player === f.owner)!.desert, 6,
|
||||
'Three surviving ordinary counters collect six from the original eight-spice blow');
|
||||
for (const receipt of collection.receipts)
|
||||
expected.players.find(p => p.id === receipt.player)!.spice = receipt.balance;
|
||||
const game = finishSemutaHomeworldWinner(state);
|
||||
assert.equal(game.phase, 7);
|
||||
assert.equal(game.turn, 1);
|
||||
assert.ok(!game.decision && !game.response && !game.pendingTreacheryDiscard);
|
||||
assert.deepEqual(resources(game), expected,
|
||||
'Original Collection credits its board once; battle casualties and death income do not repeat');
|
||||
assert.deepEqual(game.spice, collection.spice);
|
||||
assert.deepEqual(game.lastBattleContext, context);
|
||||
assert.throws(() => applyAction(game, f.owner, { type: 'decision', done: true }),
|
||||
'The original native CHOAM market cannot close twice');
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const kind of ['mandatory', 'selected'] as const) {
|
||||
const label = `${advanced ? 'Advanced' : 'Basic'} paid Homeworld winner ${kind}`;
|
||||
for (const claim of [false, true]) void test(`${label}: ${claim ? 'claim' : 'all-pass'} exact fresh card before one native cleanup`, () => {
|
||||
const f = createSemutaHomeworldWinnerFixture(kind, advanced);
|
||||
for (const shipment of f.shipments) {
|
||||
assert.equal(shipment.action.type, 'ship');
|
||||
assert.equal(player(shipment.after, shipment.actor).spice,
|
||||
player(shipment.before, shipment.actor).spice - (shipment.actor === f.loser ? 2 : 8));
|
||||
assert.equal(player(shipment.after, shipment.actor).reserves,
|
||||
player(shipment.before, shipment.actor).reserves - (shipment.actor === f.loser ? 1 : 4));
|
||||
assert.deepEqual(player(shipment.after, shipment.actor).elites, player(shipment.before, shipment.actor).elites,
|
||||
'These quotes ship ordinary native counters, not Sardaukar');
|
||||
}
|
||||
const pending = disposeSemutaHomeworldWinner(f);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.card]);
|
||||
assert.equal(pending.discard.filter(c => c.id === f.card).length, 1);
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
|
||||
assertOriginalBattle(f, pending);
|
||||
assert.throws(() => applyAction(pending, f.actor, f.action), 'Accepted original disposal cannot replay');
|
||||
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', discard: [] }));
|
||||
assert.throws(() => applyAction(pending, f.loser, { type: 'ready' }));
|
||||
assert.throws(() => applyAction(pending, f.loser, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(pending, f.holder, { type: 'semutaCommit', event: `${event}-retired` }));
|
||||
const acknowledged = applyAction(pending, f.loser, { type: 'semutaPass', event });
|
||||
assert.deepEqual(resources(acknowledged), resources(pending));
|
||||
assert.equal(viewGame(acknowledged, f.holder).semutaReaction?.canCommit, true,
|
||||
'A nonholder acknowledgement does not advance cleanup or close holder entitlement');
|
||||
assert.throws(() => applyAction(acknowledged, f.loser, { type: 'semutaPass', event }));
|
||||
assert.throws(() => applyAction(acknowledged, f.loser, { type: 'semutaCommit', event }));
|
||||
const done = claim
|
||||
? applyAction(acknowledged, f.holder, { type: 'semutaCommit', event })
|
||||
: reactSemutaHomeworldChoam(structuredClone(pending), f.holder, false);
|
||||
assertExchange(f, pending, done, claim);
|
||||
for (const action of [{ type: 'semutaCommit', event }, { type: 'semutaPass', event },
|
||||
{ type: 'semutaSelect', event, card: f.card }])
|
||||
assert.throws(() => applyAction(done, f.holder, action));
|
||||
const collected = finishOnce(f, done);
|
||||
assert.equal(player(collected, f.owner).hand.some(c => c.id === f.kept), true);
|
||||
assert.throws(() => applyAction(collected, f.holder, { type: 'semutaCommit', event }));
|
||||
});
|
||||
|
||||
void test(`${label}: full native Richese hand exchanges atomically and entitlement stays private`, () => {
|
||||
const f = createSemutaHomeworldWinnerFixture(kind, advanced, true);
|
||||
assert.equal(player(f.game, f.holder).hand.length, 4);
|
||||
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
|
||||
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
|
||||
const pending = disposeSemutaHomeworldWinner(f);
|
||||
const neutral = disposeSemutaHomeworldWinner(f, absent);
|
||||
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(JSON.parse(JSON.stringify(viewGame(pending, seat.id))),
|
||||
JSON.parse(JSON.stringify(viewGame(neutral, seat.id))),
|
||||
'Nonholders cannot infer the private Semuta entitlement from consumer views');
|
||||
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
|
||||
}
|
||||
const claimed = reactSemutaHomeworldChoam(pending, f.holder, true);
|
||||
assertExchange(f, pending, claimed, true);
|
||||
assert.equal(player(claimed, f.holder).hand.length, 4);
|
||||
const passed = reactSemutaHomeworldChoam(neutral, f.holder, false);
|
||||
assert.deepEqual(resources(claimed), resources(passed));
|
||||
finishOnce(f, claimed);
|
||||
finishOnce(f, passed);
|
||||
});
|
||||
|
||||
void test(`${label}: original selected multi-card batch requires a private exact one-card commitment`, () => {
|
||||
const f = createSemutaHomeworldWinnerFixture(kind, advanced, true);
|
||||
let originalCleanup = f.game;
|
||||
if (kind === 'mandatory') {
|
||||
const mandatory = disposeSemutaHomeworldWinner(f);
|
||||
originalCleanup = reactSemutaHomeworldChoam(mandatory, f.holder, false);
|
||||
}
|
||||
assert.equal(originalCleanup.decision?.kind, 'battleCards');
|
||||
const pending = applyAction(originalCleanup, f.owner,
|
||||
{ type: 'decision', discard: [f.projectile, f.kept] });
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(viewGame(pending, f.holder).semutaReaction?.candidates, []);
|
||||
assert.equal(player(pending, f.owner).hand.some(c => c.id === f.projectile || c.id === f.kept), false);
|
||||
assert.throws(() => applyAction(pending, f.holder, { type: 'semutaSelect', event, card: f.projectile }));
|
||||
const committed = applyAction(pending, f.holder, { type: 'semutaCommit', event });
|
||||
assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
|
||||
assert.deepEqual(viewGame(committed, f.holder).semutaReaction?.candidates.map(c => c.id).sort(),
|
||||
[f.projectile, f.kept].sort());
|
||||
for (const seat of committed.players.filter(p => p.id !== f.holder)) {
|
||||
assert.deepEqual(viewGame(committed, seat.id).semutaReaction?.candidates, []);
|
||||
assert.throws(() => applyAction(committed, seat.id, { type: 'semutaSelect', event, card: f.projectile }));
|
||||
}
|
||||
assert.throws(() => applyAction(committed, f.holder, { type: 'semutaPass', event }));
|
||||
assert.throws(() => applyAction(committed, f.holder, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(committed, f.holder, { type: 'semutaSelect', event, card: f.worthless }));
|
||||
assert.deepEqual(resources(committed), resources(pending));
|
||||
for (const chosen of [f.projectile, f.kept]) {
|
||||
const done = applyAction(structuredClone(committed), f.holder, { type: 'semutaSelect', event, card: chosen });
|
||||
const other = chosen === f.projectile ? f.kept : f.projectile;
|
||||
assert.equal(player(done, f.holder).hand.filter(c => c.id === chosen).length, 1);
|
||||
assert.equal(player(done, f.holder).hand.length, 4);
|
||||
assert.equal(done.discard.filter(c => c.id === chosen).length, 0);
|
||||
assert.equal(done.discard.filter(c => c.id === other).length, 1);
|
||||
assert.equal(done.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
||||
assert.equal(done.discard.filter(c => c.id === f.worthless).length, 1);
|
||||
if (kind === 'mandatory') assert.equal(done.discard.filter(c => c.id === f.card).length, 1);
|
||||
assert.deepEqual(resources(done), resources(pending));
|
||||
assert.deepEqual(stock(done), stock(f.setup));
|
||||
const remaining = player(pending, f.holder).hand.map(c => c.id).filter(id => id !== SEMUTA_DRUG_ID);
|
||||
assert.deepEqual(player(done, f.holder).hand.map(c => c.id).sort(), [...remaining, chosen].sort());
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'semutaSelect', event, card: other }));
|
||||
finishOnce(f, done);
|
||||
}
|
||||
});
|
||||
|
||||
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: legal minimum ${label} consumer and native continuation`, () => {
|
||||
const f = createSemutaHomeworldWinnerFixture(kind, advanced);
|
||||
const pending = disposeSemutaHomeworldWinner(f);
|
||||
const own = viewGame(pending, f.holder);
|
||||
own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(own)[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 });
|
||||
}
|
||||
}
|
||||
assertExchange(f, pending, game, action.type === 'semutaCommit');
|
||||
finishOnce(f, game);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} original losing, mandatory winning and selected cleanup are distinct fresh events`, () => {
|
||||
const f = createSemutaHomeworldWinnerFixture('mandatory', advanced);
|
||||
let game = applyAction(f.game, f.actor, f.action);
|
||||
const losingEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.worthless]);
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.card), true,
|
||||
'Mandatory winner Hero is still physically reserved through the earlier losing offer');
|
||||
assert.equal(player(game, f.owner).forces[f.location], 4, 'Winner casualties follow the first offer');
|
||||
assert.equal(player(game, f.owner).tanks, 0);
|
||||
assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), true);
|
||||
game = reactSemutaHomeworldChoam(game, f.holder, false);
|
||||
const mandatoryEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
assert.notEqual(mandatoryEvent, losingEvent);
|
||||
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.card]);
|
||||
assertOriginalBattle(f, game);
|
||||
assert.throws(() => applyAction(game, f.holder, { type: 'semutaCommit', event: losingEvent }));
|
||||
assert.throws(() => applyAction(game, f.loser, { type: 'semutaPass', event: losingEvent }));
|
||||
game = reactSemutaHomeworldChoam(game, f.holder, false);
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
assert.equal(game.decision?.player, f.owner);
|
||||
assert.throws(() => applyAction(game, f.loser, { type: 'decision', discard: [f.projectile] }));
|
||||
game = applyAction(game, f.owner, { type: 'decision', discard: [f.projectile] });
|
||||
const selectedEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
assert.notEqual(selectedEvent, losingEvent);
|
||||
assert.notEqual(selectedEvent, mandatoryEvent);
|
||||
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.projectile]);
|
||||
assert.throws(() => applyAction(game, f.holder, { type: 'semutaCommit', event: mandatoryEvent }));
|
||||
const before = resources(game);
|
||||
game = reactSemutaHomeworldChoam(game, f.holder, true);
|
||||
assert.deepEqual(resources(game), before);
|
||||
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.projectile).length, 1);
|
||||
assert.equal(game.discard.filter(c => c.id === f.card).length, 1);
|
||||
assert.equal(game.discard.filter(c => c.id === f.worthless).length, 1);
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.kept), true);
|
||||
finishOnce(f, game);
|
||||
});
|
||||
Reference in new issue
Block a user