Compose Homeworld carried-flight and finite-order disposal with Semuta
This commit is contained in:
1 parent
b8a66a864b
commit
4966b1d062
12 files changed
+752
-2
No files matched your search
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
|
||||
import type { OrnithopterMode } from '../game/ornithopter';
|
||||
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
|
||||
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
|
||||
import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
|
||||
export type SemutaHomeworldOrnithopterFixture = {
|
||||
setup: Game; beforeShipment: Game; afterShipment: Game; beforeFirstMove: Game; game: Game;
|
||||
owner: string; holder: string; card: string; mode: OrnithopterMode; earlyFinish: boolean;
|
||||
origin: string; destination: string; secondDestination: string; tooFarDestination: string;
|
||||
shipment: Action; firstMove: Action; action: Action; moves: number; amount: number;
|
||||
sources: NativeReserveSelections; originalForces: Record<string, number>;
|
||||
actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
};
|
||||
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
|
||||
export function semutaHomeworldOrnithopterStock(game: Game): string[] {
|
||||
return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
|
||||
...game.players.map(p => p.hand), ...(game.ornithopter ? [[game.ornithopter.card]] : [])]
|
||||
.flat().map(card => card.id).sort();
|
||||
}
|
||||
export function semutaHomeworldOrnithopterResources(game: Game) {
|
||||
return { custody: structuredClone(game.homeworlds?.custody), spice: structuredClone(game.spice),
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves,
|
||||
tanks: p.tanks, forces: structuredClone(p.forces), elites: structuredClone(p.elites),
|
||||
shipped: p.shipped, moved: p.moved, ally: p.ally })) };
|
||||
}
|
||||
function destination(game: Game, origin: string, range: number, excluded: string[] = []) {
|
||||
const key = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s)))
|
||||
.find(key => !excluded.includes(key) && splitLocation(key).sector !== game.storm &&
|
||||
game.players.every(p => !(p.forces[key] > 0)) &&
|
||||
gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === range);
|
||||
assert.ok(key, `Original board needs an empty sand destination at range ${range}`);
|
||||
return key;
|
||||
}
|
||||
/** Fresh original setup and paid native CHOAM source counters. Only original
|
||||
* unplayed Spice Card order and conserved physical hand positions are staged. */
|
||||
export function createSemutaHomeworldOrnithopterFixture(
|
||||
mode: OrnithopterMode, advanced = false, earlyFinish = false, fullHolder = false,
|
||||
): SemutaHomeworldOrnithopterFixture {
|
||||
assert.ok(!earlyFinish || mode === 'twoGroups');
|
||||
const native = createSemutaHomeworldChoamSetup(advanced);
|
||||
let game = native.game;
|
||||
const setup = structuredClone(game), actions = native.actions;
|
||||
const staging = ['Conserved original physical Semuta in Richese hand.'];
|
||||
const blows = game.spiceDeck.filter(c => 'territory' in c).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('Reorder two original unplayed non-worm Spice Cards; no replacement deck.');
|
||||
game = advanceSemutaHomeworldChoam(game,
|
||||
g => g.turn === 1 && g.phase === 5 && g.active === native.owner && clean(g), actions);
|
||||
const card = holdSemutaHomeworldChoamCard(game, native.owner, 'Ornithopter');
|
||||
assert.equal(card, 'richese-ornithopter');
|
||||
assert.ok(player(game, native.owner).hand.length <= handLimit(player(game, native.owner)));
|
||||
staging.push('Conserved original Richese cache Ornithopter to CHOAM; acquisition history unchanged.');
|
||||
if (fullHolder) {
|
||||
const holder = player(game, native.holder);
|
||||
while (holder.hand.length < handLimit(holder)) {
|
||||
assert.ok(game.deck[0]);
|
||||
moveSemutaEmperorCard(game, game.deck[0].id, holder.id);
|
||||
}
|
||||
staging.push('Conserved original undealt cards fill Richese to its native hand limit.');
|
||||
}
|
||||
const origin = 'red_chasm:7', amount = mode === 'twoGroups' && !earlyFinish ? 2 : 1;
|
||||
const sources = nativeShipmentSources(viewGame(game, native.owner), amount, 0);
|
||||
assert.ok(sources, 'Native Homeworld reserves must supply the actual counters');
|
||||
const shipment: Action = { type: 'ship', ...splitLocation(origin), amount, elite: 0,
|
||||
homeworldSources: sources, allyPayment: 0 };
|
||||
const beforeShipment = structuredClone(game);
|
||||
game = applySemutaHomeworldChoamStep(game, native.owner, shipment, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, clean, actions);
|
||||
const afterShipment = structuredClone(game), beforeFirstMove = structuredClone(game);
|
||||
const originalForces = structuredClone(player(game, native.owner).forces);
|
||||
const target = destination(game, origin, mode === 'range3' ? 3 : 1);
|
||||
const secondDestination = destination(game, origin, 1, [target]);
|
||||
const tooFarDestination = destination(game, origin, 4);
|
||||
const option = viewGame(game, native.owner).ornithopter;
|
||||
assert.equal(option?.card.id, card);
|
||||
assert.ok(option?.modes.some(m => m.mode === mode && !m.blocked));
|
||||
const firstMove: Action = { type: 'move', from: origin, amount: 1, elite: 0,
|
||||
...splitLocation(target), movementCard: card, ornithopter: mode };
|
||||
let action = firstMove;
|
||||
if (mode === 'twoGroups') {
|
||||
game = applySemutaHomeworldChoamStep(game, native.owner, firstMove, actions);
|
||||
assert.equal(game.ornithopter?.completed, 1);
|
||||
action = earlyFinish ? { type: 'endMovement' } : { type: 'move', from: origin, amount: 1,
|
||||
elite: 0, ...splitLocation(secondDestination), ornithopterEvent: game.ornithopter!.event };
|
||||
}
|
||||
return { setup, beforeShipment, afterShipment, beforeFirstMove, game,
|
||||
owner: native.owner, holder: native.holder, card, mode, earlyFinish, origin,
|
||||
destination: target, secondDestination, tooFarDestination, shipment, firstMove, action,
|
||||
amount, moves: mode === 'twoGroups' && !earlyFinish ? 2 : 1, sources: structuredClone(sources),
|
||||
originalForces, actions, staging };
|
||||
}
|
||||
export function disposeSemutaHomeworldOrnithopter(f: SemutaHomeworldOrnithopterFixture,
|
||||
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
return applySemutaHomeworldChoamStep(state, f.owner, f.action, actions);
|
||||
}
|
||||
/** Real original movement queue continuation; never another CHOAM ground move. */
|
||||
export function finishSemutaHomeworldOrnithopter(state: Game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
return advanceSemutaHomeworldChoam(state, g => g.phase > 5 && clean(g), actions);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
|
||||
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
|
||||
import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
|
||||
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
|
||||
export type SemutaHomeworldSaphoScope = 'onceAround' | 'movement';
|
||||
export type SemutaHomeworldSaphoMode = 'first' | 'last';
|
||||
export type SemutaHomeworldSaphoFixture = {
|
||||
setup: Game; game: Game; scope: SemutaHomeworldSaphoScope; mode: SemutaHomeworldSaphoMode;
|
||||
holder: string; owner: string; card: string; action: Action; event: string;
|
||||
nextActor: string; suffix: string[]; prefix: string[]; originalQueue: string[];
|
||||
lotCard: string | null; actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
};
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Original completed R/E/C/A Homeworld setup, native finite lot or unstarted
|
||||
* Movement queue. Only original unplayed Spice order and physical cards move. */
|
||||
export function createSemutaHomeworldSaphoFixture(
|
||||
scope: SemutaHomeworldSaphoScope, mode: SemutaHomeworldSaphoMode,
|
||||
advanced = false, fullHolder = false,
|
||||
): SemutaHomeworldSaphoFixture {
|
||||
const native = createSemutaHomeworldChoamSetup(advanced);
|
||||
let game = native.game;
|
||||
const setup = structuredClone(game), actions = native.actions;
|
||||
const staging = ['Conserved original physical Semuta in Richese hand.'];
|
||||
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('Reorder two original unplayed non-worm Spice Cards; no replacement stock.');
|
||||
let lotCard: string | null = null;
|
||||
if (scope === 'onceAround') {
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.decision?.kind === 'richeseDeclaration', actions);
|
||||
game = applySemutaHomeworldChoamStep(game, native.holder,
|
||||
{ type: 'decision', event: game.richeseBidding!.event, position: 'first' }, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.decision?.kind === 'richeseCache', actions);
|
||||
assert.equal(game.decision?.kind, 'richeseCache');
|
||||
const selected = game.richeseCache!.find(card => card.id !== 'richese-juice-of-sapho')!;
|
||||
lotCard = selected.id;
|
||||
game = applySemutaHomeworldChoamStep(game, native.holder,
|
||||
{ type: 'decision', event: game.richeseBidding!.event, card: selected.id,
|
||||
method: 'onceAround', direction: 'counterclockwise' }, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, g => !!g.richeseAuction && clean(g), actions);
|
||||
assert.equal(game.richeseAuction!.source, 'cache');
|
||||
assert.equal(game.richeseAuction!.method, 'onceAround');
|
||||
const actor = game.richeseAuction!.active!;
|
||||
assert.ok(player(game, actor).spice >= 1);
|
||||
game = applySemutaHomeworldChoamStep(game, actor,
|
||||
{ type: 'richeseBid', event: game.richeseAuction!.event, amount: 1, allyPayment: 0 }, actions);
|
||||
} else {
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.phase === 5 && clean(g), actions);
|
||||
assert.ok(game.players.every(p => !p.shipped && p.moved === 0));
|
||||
assert.deepEqual(game.movementRemaining, game.order);
|
||||
}
|
||||
assert.equal(game.turn, 1);
|
||||
const originalQueue = scope === 'onceAround' ? [...game.richeseAuction!.order] : [...game.movementRemaining!];
|
||||
const prefix = scope === 'onceAround' ? [...game.richeseAuction!.acted] : [];
|
||||
const remaining = originalQueue.filter(id => !prefix.includes(id));
|
||||
const owner = remaining.find(id => id !== native.holder &&
|
||||
(mode === 'first' ? id !== remaining[0] : id !== remaining.at(-1)));
|
||||
assert.ok(owner, 'A real non-Richese unacted actor changes its selected position');
|
||||
const card = holdSemutaHomeworldChoamCard(game, owner, 'Juice of Sapho');
|
||||
staging.push(`Conserved original printed Juice of Sapho to ${owner}; not an auction acquisition.`);
|
||||
assert.ok(player(game, owner).hand.length <= handLimit(player(game, owner)));
|
||||
if (fullHolder) {
|
||||
const holder = player(game, native.holder);
|
||||
while (holder.hand.length < handLimit(holder)) {
|
||||
assert.ok(game.deck[0]);
|
||||
moveSemutaEmperorCard(game, game.deck[0].id, holder.id);
|
||||
}
|
||||
staging.push('Conserved original undealt cards fill Richese to its native hand limit.');
|
||||
}
|
||||
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === scope && option.mode === mode);
|
||||
assert.ok(option);
|
||||
const action: Action = { type: 'card', card, scope, mode, event: option.event };
|
||||
const suffix = mode === 'first' ? [owner, ...remaining.filter(id => id !== owner)]
|
||||
: [...remaining.filter(id => id !== owner), owner];
|
||||
assert.deepEqual(biddingPhysicalIds(game), biddingPhysicalIds(setup));
|
||||
return { setup, game, scope, mode, holder: native.holder, owner, card, action,
|
||||
event: option.event!, nextActor: suffix[0], suffix, prefix, originalQueue, lotCard, actions, staging };
|
||||
}
|
||||
|
||||
export function disposeSemutaHomeworldSapho(fixture: SemutaHomeworldSaphoFixture, state = fixture.game): Game {
|
||||
return applyAction(state, fixture.owner, fixture.action);
|
||||
}
|
||||
|
||||
/** Finish only this same finite lot: each remaining bidder passes once, leaving
|
||||
* the real earlier one-spice invoice to native quote/payment/acquisition. */
|
||||
export function finishSemutaHomeworldSaphoAuction(state: Game, fixture: SemutaHomeworldSaphoFixture,
|
||||
actions: RicheseSkillsTechStep[] = []): Game {
|
||||
let game = state;
|
||||
while (game.richeseAuction?.event === fixture.event && !game.richeseAuction.outcome) {
|
||||
assert.ok(clean(game));
|
||||
const actor = game.richeseAuction.active!;
|
||||
game = applySemutaHomeworldChoamStep(game, actor,
|
||||
{ type: 'richeseBid', event: fixture.event, amount: null, allyPayment: 0 }, actions);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/** One actual paid original reserve shipment and one ground move, then each
|
||||
* original remaining combined turn ends once through public native controls. */
|
||||
export function finishSemutaHomeworldSaphoMovement(state: Game, fixture: SemutaHomeworldSaphoFixture,
|
||||
actions: RicheseSkillsTechStep[] = []): { game: Game; beforeShipment: Game; afterShipment: Game;
|
||||
origin: string; destination: string; shipment: Action; move: Action } {
|
||||
let game = state;
|
||||
assert.ok(clean(game));
|
||||
assert.equal(game.active, fixture.nextActor);
|
||||
const origin = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s)))
|
||||
.find(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0)));
|
||||
assert.ok(origin);
|
||||
const destination = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s)))
|
||||
.find(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0)) &&
|
||||
gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === 1);
|
||||
assert.ok(destination);
|
||||
const sources = nativeShipmentSources(viewGame(game, fixture.nextActor), 1, 0);
|
||||
assert.ok(sources);
|
||||
const shipment: Action = { type: 'ship', ...splitLocation(origin), amount: 1, elite: 0,
|
||||
homeworldSources: sources, allyPayment: 0 };
|
||||
const beforeShipment = structuredClone(game);
|
||||
game = applySemutaHomeworldChoamStep(game, fixture.nextActor, shipment, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, clean, actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
const move: Action = { type: 'move', from: origin, ...splitLocation(destination), amount: 1, elite: 0 };
|
||||
game = applySemutaHomeworldChoamStep(game, fixture.nextActor, move, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, clean, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.phase > 5 && clean(g), actions);
|
||||
return { game, beforeShipment, afterShipment, origin, destination, shipment, move };
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||
import { gameDistance, splitLocation } from '../game/board';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import type { OrnithopterMode } from '../game/ornithopter';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { advanceSemutaHomeworldChoam, holdSemutaHomeworldChoamCard,
|
||||
reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
|
||||
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
|
||||
import { createSemutaHomeworldOrnithopterFixture, disposeSemutaHomeworldOrnithopter,
|
||||
finishSemutaHomeworldOrnithopter, semutaHomeworldOrnithopterResources as resources,
|
||||
semutaHomeworldOrnithopterStock as stock } from './fixture-semuta-homeworld-ornithopter';
|
||||
|
||||
function assertCounters(game: Game, original: Game) {
|
||||
for (const p of game.players) {
|
||||
const before = player(original, p.id);
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, x) => n + x, 0),
|
||||
before.reserves + before.tanks + Object.values(before.forces).reduce((n, x) => n + x, 0));
|
||||
if (p.elites && before.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((n, x) => n + x, 0),
|
||||
before.elites.reserves + before.elites.tanks + Object.values(before.elites.forces).reduce((n, x) => n + x, 0));
|
||||
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
assert.deepEqual(game.homeworlds?.custody, original.homeworlds?.custody);
|
||||
}
|
||||
const paths: { mode: OrnithopterMode; early: boolean }[] = [
|
||||
{ mode: 'range3', early: false }, { mode: 'twoGroups', early: false }, { mode: 'twoGroups', early: true },
|
||||
];
|
||||
for (const advanced of [false, true]) for (const { mode, early } of paths) {
|
||||
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Ornithopter ${mode}${early ? ' early finish' : ''}`;
|
||||
for (const claim of [false, true]) void test(`${label}: ${claim ? 'claim' : 'pass'} retires once before the original arrival or turn continuation`, () => {
|
||||
const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early);
|
||||
const inventory = stock(f.beforeFirstMove), owner = player(f.afterShipment, f.owner);
|
||||
assert.equal(f.afterShipment.turn, 1);
|
||||
assert.equal(f.afterShipment.phase, 5);
|
||||
assert.equal(owner.spice, player(f.beforeShipment, f.owner).spice - f.amount * 2);
|
||||
assert.equal(owner.reserves, player(f.beforeShipment, f.owner).reserves - f.amount);
|
||||
assert.equal(owner.forces[f.origin], f.amount);
|
||||
assertCounters(f.afterShipment, f.setup);
|
||||
const distance = gameDistance(f.beforeFirstMove, f.origin, f.destination,
|
||||
key => splitLocation(key).sector === f.beforeFirstMove.storm);
|
||||
assert.equal(distance, mode === 'range3' ? 3 : 1);
|
||||
if (mode === 'range3') assert.throws(() => applyAction(f.beforeFirstMove, f.owner,
|
||||
{ ...f.firstMove, movementCard: undefined, ornithopter: undefined }));
|
||||
if (mode === 'twoGroups') {
|
||||
assert.equal(player(f.game, f.owner).forces[f.destination], 1);
|
||||
assert.equal(player(f.game, f.owner).moved, 1);
|
||||
assert.equal(player(f.game, f.owner).hand.some(c => c.id === f.card), false);
|
||||
assert.equal(f.game.discard.some(c => c.id === f.card), false);
|
||||
assert.equal(viewGame(f.game, f.owner).ornithopter?.active?.remaining, 1);
|
||||
assert.deepEqual(viewGame(f.game, f.owner).ornithopter?.active?.cohort?.forces,
|
||||
early ? {} : { [f.origin]: 1 });
|
||||
assert.throws(() => applyAction(f.game, f.owner, { type: 'move', from: f.destination,
|
||||
amount: 1, elite: 0, ...splitLocation(f.origin), ornithopterEvent: f.game.ornithopter!.event }));
|
||||
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, type: 'move',
|
||||
from: f.origin, amount: 1, ...splitLocation(f.secondDestination), ornithopterEvent: 'stale' }));
|
||||
}
|
||||
const uninterrupted = reactSemutaHomeworldChoam(disposeSemutaHomeworldOrnithopter(f,
|
||||
withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder)), f.holder, false);
|
||||
const offered = disposeSemutaHomeworldOrnithopter(f);
|
||||
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.equal(offered.discard.filter(c => c.id === f.card).length, 1);
|
||||
assert.equal(player(offered, f.owner).hand.some(c => c.id === f.card), false);
|
||||
assert.equal(player(offered, f.owner).moved, f.moves);
|
||||
assert.equal(player(offered, f.owner).forces[f.destination], 1);
|
||||
if (mode === 'twoGroups' && !early) assert.equal(player(offered, f.owner).forces[f.secondDestination], 1);
|
||||
assert.equal(player(offered, f.owner).forces[f.origin] ?? 0, 0);
|
||||
assert.equal(offered.active, f.owner, 'Retirement suspends the same original turn, not its successor');
|
||||
assert.deepEqual(offered.movementRemaining, f.game.movementRemaining);
|
||||
assert.deepEqual(stock(offered), inventory);
|
||||
assertCounters(offered, f.setup);
|
||||
assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered);
|
||||
const reloaded = normalizeAutomaticGame(JSON.parse(JSON.stringify(offered)));
|
||||
assert.deepEqual(resources(reloaded), resources(offered));
|
||||
assert.deepEqual(stock(reloaded), inventory);
|
||||
assert.equal(reloaded.active, offered.active);
|
||||
assert.deepEqual(reloaded.movementRemaining, offered.movementRemaining);
|
||||
for (const p of offered.players) assert.deepEqual(viewGame(reloaded, p.id), viewGame(offered, p.id));
|
||||
const frozen = resources(offered), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
assert.throws(() => applyAction(offered, f.owner, { type: 'endMovement' }));
|
||||
assert.throws(() => applyAction(offered, f.owner, f.action));
|
||||
const resumed = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(offered)), f.holder, claim);
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(resources(resumed), frozen, 'Resume cannot replay a paid shipment or ground move');
|
||||
assert.deepEqual(resources(resumed), resources(uninterrupted));
|
||||
assert.equal(resumed.active, uninterrupted.active);
|
||||
assert.deepEqual(resumed.movementRemaining, uninterrupted.movementRemaining);
|
||||
assert.equal(player(resumed, f.holder).hand.filter(c => c.id === f.card).length, claim ? 1 : 0);
|
||||
assert.equal(resumed.discard.filter(c => c.id === f.card).length, claim ? 0 : 1);
|
||||
assert.equal(resumed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0);
|
||||
assert.deepEqual(stock(resumed), inventory);
|
||||
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(resumed, f.owner, f.firstMove));
|
||||
assert.throws(() => applyAction(resumed, f.owner, { type: 'move', from: f.destination,
|
||||
amount: 1, ...splitLocation(f.origin) }));
|
||||
const replay = structuredClone(resumed);
|
||||
replay.pendingTreacheryDiscard = structuredClone(offered.pendingTreacheryDiscard);
|
||||
assert.throws(() => normalizeAutomaticGame(replay));
|
||||
assert.deepEqual(normalizeAutomaticGame(structuredClone(resumed)), resumed);
|
||||
const completed = finishSemutaHomeworldOrnithopter(resumed);
|
||||
assert.equal(completed.turn, 1);
|
||||
assert.equal(completed.phase, 7);
|
||||
assert.equal(player(completed, f.owner).moved, f.moves);
|
||||
assert.equal(player(completed, f.owner).reserves, owner.reserves);
|
||||
assert.deepEqual(player(completed, f.owner).forces, player(offered, f.owner).forces);
|
||||
assert.deepEqual(stock(completed), inventory);
|
||||
assertCounters(completed, f.setup);
|
||||
});
|
||||
|
||||
void test(`${label}: full-hand singleton entitlement is private and original forces cannot be borrowed by another player`, () => {
|
||||
const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early, true);
|
||||
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
|
||||
assert.throws(() => applyAction(f.game, f.holder, f.action));
|
||||
const offered = disposeSemutaHomeworldOrnithopter(f);
|
||||
const neutral = disposeSemutaHomeworldOrnithopter(f, withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder));
|
||||
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
||||
const event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const p of offered.players.filter(p => p.id !== f.holder)) {
|
||||
assert.deepEqual(viewGame(offered, p.id), viewGame(neutral, p.id));
|
||||
assert.equal(viewGame(offered, p.id).semutaReaction?.canCommit, false);
|
||||
assert.throws(() => applyAction(offered, p.id, { type: 'semutaCommit', event }));
|
||||
}
|
||||
assert.throws(() => applyAction(offered, f.holder, { type: 'semutaCommit', event: 'stale' }));
|
||||
const claimed = reactSemutaHomeworldChoam(offered, f.holder, true);
|
||||
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
|
||||
assert.equal(player(claimed, f.holder).hand.some(c => c.id === f.card), true);
|
||||
assert.equal(player(claimed, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), false);
|
||||
const done = finishSemutaHomeworldOrnithopter(claimed);
|
||||
assert.equal(player(done, f.owner).moved, f.moves);
|
||||
assert.deepEqual(stock(done), stock(f.beforeFirstMove));
|
||||
});
|
||||
|
||||
void test(`${label}: all four bot profiles legally claim or pass without a second shipment or repeated group`, () => {
|
||||
const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early);
|
||||
const offered = disposeSemutaHomeworldOrnithopter(f), frozen = resources(offered);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game: Game = structuredClone(offered);
|
||||
const own = viewGame(game, 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');
|
||||
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 original retirement offer');
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id))!.id;
|
||||
game = applyAction(game, actor, { type: 'semutaPass', event: pending.batch.event });
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(game, f.holder).hand.some(c => c.id === f.card), action.type === 'semutaCommit');
|
||||
assert.deepEqual(resources(game), frozen);
|
||||
const done = finishSemutaHomeworldOrnithopter(game);
|
||||
assert.equal(player(done, f.owner).moved, f.moves);
|
||||
assert.deepEqual(stock(done), stock(f.beforeFirstMove));
|
||||
assertCounters(done, f.setup);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld original prior-move, Hajr and fixed maximum-three guards remain real`, () => {
|
||||
const f = createSemutaHomeworldOrnithopterFixture('range3', advanced);
|
||||
const before = structuredClone(f.beforeFirstMove);
|
||||
assert.throws(() => applyAction(before, f.owner, { ...f.firstMove, ...splitLocation(f.tooFarDestination) }));
|
||||
assert.deepEqual(before, f.beforeFirstMove);
|
||||
const moved = applyAction(before, f.owner, { ...f.firstMove, movementCard: undefined,
|
||||
ornithopter: undefined, ...splitLocation(f.secondDestination) });
|
||||
assert.equal(player(moved, f.owner).moved, 1);
|
||||
assert.equal(player(moved, f.owner).hand.some(c => c.id === f.card), true);
|
||||
assert.throws(() => applyAction(moved, f.owner, { ...f.firstMove, from: f.secondDestination,
|
||||
...splitLocation(f.origin) }));
|
||||
const hajrGame = structuredClone(before);
|
||||
const hajr = holdSemutaHomeworldChoamCard(hajrGame, f.owner, 'Hajr');
|
||||
const played = applyAction(hajrGame, f.owner, { type: 'card', card: hajr });
|
||||
const allowed = advanceSemutaHomeworldChoam(played,
|
||||
g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening);
|
||||
assert.equal(player(allowed, f.owner).moved, 0);
|
||||
assert.ok(allowed.hajr.includes(f.owner));
|
||||
assert.throws(() => applyAction(allowed, f.owner, f.firstMove));
|
||||
assert.equal(player(allowed, f.owner).hand.some(c => c.id === f.card), true);
|
||||
assertCounters(allowed, f.setup);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, normalizeAutomaticGame, 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 { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
|
||||
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import { semutaHomeworldChoamMovementResources as resources,
|
||||
withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
|
||||
import { biddingPhysicalIds as stock } from './fixture-homeworld-occupied-bidding';
|
||||
import {
|
||||
createSemutaHomeworldSaphoFixture, disposeSemutaHomeworldSapho,
|
||||
finishSemutaHomeworldSaphoAuction, finishSemutaHomeworldSaphoMovement,
|
||||
type SemutaHomeworldSaphoFixture, type SemutaHomeworldSaphoMode, type SemutaHomeworldSaphoScope,
|
||||
} from './fixture-semuta-homeworld-sapho';
|
||||
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
|
||||
const scopes: SemutaHomeworldSaphoScope[] = ['onceAround', 'movement'];
|
||||
const modes: SemutaHomeworldSaphoMode[] = ['first', 'last'];
|
||||
|
||||
/** Assert the original consumer opportunities/payment, not receipt wiring. */
|
||||
function finishOriginal(state: Game, f: SemutaHomeworldSaphoFixture) {
|
||||
const actions: RicheseSkillsTechStep[] = [];
|
||||
if (f.scope === 'onceAround') {
|
||||
const lot = f.game.richeseAuction!, buyer = lot.bidder!;
|
||||
const funding = viewGame(f.game, buyer).richeseAuction!;
|
||||
assert.equal(lot.bid, 1);
|
||||
assert.deepEqual(state.richeseAuction!.acted, f.prefix);
|
||||
assert.equal(state.richeseAuction!.bidder, buyer);
|
||||
assert.equal(state.richeseAuction!.bid, lot.bid);
|
||||
assert.equal(viewGame(state, buyer).richeseAuction!.ownAvailable, funding.ownAvailable);
|
||||
assert.equal(viewGame(state, buyer).richeseAuction!.ownAllyPayment, funding.ownAllyPayment);
|
||||
assert.throws(() => applyAction(state, buyer,
|
||||
{ type: 'richeseBid', event: f.event, amount: 2, allyPayment: 0 }),
|
||||
'The earlier bidder never receives a second opportunity');
|
||||
const done = finishSemutaHomeworldSaphoAuction(state, f, actions);
|
||||
const expected = f.suffix.filter(id => player(state, id).hand.length < handLimit(player(state, id)));
|
||||
assert.deepEqual(actions.map(step => step.actor), expected);
|
||||
assert.equal(new Set([...f.prefix, ...actions.map(step => step.actor)]).size,
|
||||
f.prefix.length + actions.length);
|
||||
assert.equal(player(done, buyer).hand.filter(card => card.id === f.lotCard).length, 1);
|
||||
assert.equal(player(done, buyer).spice, player(f.game, buyer).spice - 1);
|
||||
assert.equal(player(done, f.holder).spice, player(f.game, f.holder).spice + 1,
|
||||
'Original unoccupied Richese receives exactly the native cache-lot invoice');
|
||||
for (const seat of done.players.filter(seat => ![buyer, f.holder].includes(seat.id)))
|
||||
assert.equal(seat.spice, player(f.game, seat.id).spice);
|
||||
assert.deepEqual(done.homeworlds?.custody, f.setup.homeworlds?.custody);
|
||||
assert.deepEqual(stock(done), stock(f.game));
|
||||
assert.throws(() => applyAction(done, buyer,
|
||||
{ type: 'richeseBid', event: f.event, amount: 2, allyPayment: 0 }));
|
||||
return done;
|
||||
}
|
||||
const result = finishSemutaHomeworldSaphoMovement(state, f, actions), done = result.game;
|
||||
assert.deepEqual(actions.filter(step => step.action.type === 'endMovement').map(step => step.actor), f.suffix,
|
||||
'Every original combined turn finishes exactly once in the reordered public queue');
|
||||
assert.equal(player(result.afterShipment, f.nextActor).spice,
|
||||
player(result.beforeShipment, f.nextActor).spice - 2);
|
||||
assert.equal(player(result.afterShipment, f.nextActor).reserves,
|
||||
player(result.beforeShipment, f.nextActor).reserves - 1);
|
||||
assert.equal(player(result.afterShipment, f.nextActor).forces[result.origin], 1);
|
||||
assert.equal(player(done, f.nextActor).forces[result.origin] ?? 0, 0);
|
||||
assert.equal(player(done, f.nextActor).forces[result.destination], 1);
|
||||
assert.equal(player(done, f.nextActor).moved, 1);
|
||||
assert.equal(done.turn, 1);
|
||||
assert.equal(done.phase, 7);
|
||||
assert.deepEqual(done.homeworlds?.custody, f.setup.homeworlds?.custody);
|
||||
assert.deepEqual(stock(done), stock(f.game));
|
||||
assert.throws(() => applyAction(done, f.nextActor, result.shipment));
|
||||
assert.throws(() => applyAction(done, f.nextActor, result.move));
|
||||
return done;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const scope of scopes) for (const mode of modes) {
|
||||
const label = `${advanced ? 'Advanced' : 'Basic'} original Homeworld Sapho ${scope} ${mode}`;
|
||||
for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claim' : 'pass'} retains reordered unacted opportunities and original funded continuation`, () => {
|
||||
const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced);
|
||||
const original = stock(f.game), paid = resources(f.game);
|
||||
const offered = disposeSemutaHomeworldSapho(f);
|
||||
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.equal(player(offered, f.owner).hand.some(card => card.id === f.card), false);
|
||||
assert.equal(offered.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(stock(offered), original);
|
||||
assert.deepEqual(resources(offered), paid, 'Sapho and its disposal cannot spend the paid prefix or start a turn');
|
||||
assert.deepEqual(offered.order, f.game.order, 'Physical storm order is never rewritten');
|
||||
assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered);
|
||||
if (scope === 'onceAround') {
|
||||
assert.deepEqual(offered.richeseAuction!.order.filter(id => !offered.richeseAuction!.acted.includes(id)), f.suffix);
|
||||
assert.deepEqual(offered.richeseAuction!.acted, f.prefix);
|
||||
assert.deepEqual(offered.richeseAuction!.tieOrder, f.game.richeseAuction!.tieOrder);
|
||||
assert.equal(viewGame(offered, 'a').richeseAuction!.card!.id, f.lotCard,
|
||||
'Atreides retains the real public cache card knowledge');
|
||||
assert.throws(() => applyAction(offered, f.nextActor,
|
||||
{ type: 'richeseBid', event: f.event, amount: null, allyPayment: 0 }));
|
||||
} else {
|
||||
assert.deepEqual(offered.movementRemaining, f.suffix);
|
||||
assert.equal(offered.active, f.game.active, 'The original unstarted turn stays frozen until disposal resolves');
|
||||
assert.ok(offered.players.every(seat => !seat.shipped && seat.moved === 0));
|
||||
assert.throws(() => applyAction(offered, f.game.active!, { type: 'endMovement' }));
|
||||
}
|
||||
const event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const resumed = reactSemutaHomeworldChoam(structuredClone(offered), f.holder, claim);
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
assert.equal(resumed.active, f.nextActor);
|
||||
assert.deepEqual(resources(resumed), paid);
|
||||
assert.deepEqual(stock(resumed), original);
|
||||
assert.equal(player(resumed, f.holder).hand.filter(card => card.id === f.card).length, claim ? 1 : 0);
|
||||
assert.equal(resumed.discard.filter(card => card.id === f.card).length, claim ? 0 : 1);
|
||||
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0);
|
||||
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(resumed, f.owner, f.action), 'The same physical Sapho cannot be spent twice');
|
||||
const done = finishOriginal(resumed, f);
|
||||
assert.deepEqual(done.order, f.game.order);
|
||||
});
|
||||
|
||||
void test(`${label}: original full-hand singleton exchange and private entitlement`, () => {
|
||||
const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced, true);
|
||||
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
|
||||
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
|
||||
const offered = disposeSemutaHomeworldSapho(f);
|
||||
const neutral = disposeSemutaHomeworldSapho(f, absent);
|
||||
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
||||
assert.throws(() => applyAction(neutral, f.holder,
|
||||
{ type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event }));
|
||||
for (const seat of offered.players.filter(seat => seat.id !== f.holder)) {
|
||||
assert.deepEqual(viewGame(offered, seat.id), viewGame(neutral, seat.id));
|
||||
assert.equal(viewGame(offered, seat.id).semutaReaction?.canCommit, false);
|
||||
assert.throws(() => applyAction(offered, seat.id,
|
||||
{ type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event }));
|
||||
}
|
||||
const claimed = reactSemutaHomeworldChoam(offered, f.holder, true);
|
||||
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
|
||||
assert.equal(player(claimed, f.holder).hand.some(card => card.id === f.card), true);
|
||||
assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
|
||||
finishOriginal(claimed, f);
|
||||
});
|
||||
|
||||
void test(`${label}: every minimal bot profile legally claims or passes before the native continuation`, () => {
|
||||
const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced);
|
||||
const offered = disposeSemutaHomeworldSapho(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(offered);
|
||||
const own = viewGame(game, f.holder);
|
||||
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
||||
const proposal = botActions(own)[0];
|
||||
assert.ok(proposal);
|
||||
assert.ok(['semutaCommit', 'semutaPass'].includes(proposal.type));
|
||||
game = applyAction(game, f.holder, proposal);
|
||||
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
|
||||
if (reaction?.stage !== 'offer') throw Error('Missing Semuta offer');
|
||||
const actor = game.players.find(seat => !reaction.passed.includes(seat.id))!.id;
|
||||
game = applyAction(game, actor, { type: 'semutaPass', event: pending.batch.event });
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), proposal.type === 'semutaCommit');
|
||||
const nextView = viewGame(game, f.nextActor);
|
||||
nextView.players.find(seat => seat.id === f.nextActor)!.bot = difficulty;
|
||||
const nextAction = botActions(nextView)[0];
|
||||
assert.ok(nextAction, 'The resumed original actor has a real legal bot continuation');
|
||||
const botContinued = applyAction(structuredClone(game), f.nextActor, nextAction);
|
||||
assert.deepEqual(stock(botContinued), stock(game));
|
||||
finishOriginal(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test(`${label}: stale source events and altered committed queue reject without card/resource loss`, () => {
|
||||
const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced);
|
||||
const before = structuredClone(f.game);
|
||||
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, event: 'expired' }));
|
||||
assert.deepEqual(f.game, before);
|
||||
const offered = disposeSemutaHomeworldSapho(f), tampered = structuredClone(offered);
|
||||
if (scope === 'onceAround') tampered.richeseAuction!.order.reverse();
|
||||
else tampered.movementRemaining!.reverse();
|
||||
const saved = structuredClone(tampered);
|
||||
assert.throws(() => applyAction(tampered, f.holder,
|
||||
{ type: 'semutaCommit', event: tampered.pendingTreacheryDiscard!.batch.event }));
|
||||
assert.deepEqual(tampered, saved);
|
||||
const passed = applyAction(offered, f.holder,
|
||||
{ type: 'semutaPass', event: offered.pendingTreacheryDiscard!.batch.event });
|
||||
assert.throws(() => applyAction(passed, f.holder,
|
||||
{ type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event }));
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const mode of modes) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho ${mode}: actual paid current shipment closes the unstarted Movement window`, () => {
|
||||
const f = createSemutaHomeworldSaphoFixture('movement', mode, advanced);
|
||||
const origin = 'red_chasm:7';
|
||||
const actor = f.game.active!;
|
||||
const sources = nativeShipmentSources(viewGame(f.game, actor), 1, 0);
|
||||
assert.ok(sources);
|
||||
let started = applyAction(f.game, actor, { type: 'ship', ...splitLocation(origin), amount: 1, elite: 0,
|
||||
homeworldSources: sources, allyPayment: 0 });
|
||||
started = advanceSemutaHomeworldChoam(started,
|
||||
game => !game.response && !game.decision && !game.pendingTreacheryDiscard && !game.phaseOpening);
|
||||
assert.equal(player(started, actor).shipped, true);
|
||||
const paid = resources(started), original = stock(started);
|
||||
assert.ok(!viewGame(started, f.owner).saphoOptions.some(option => option.scope === 'movement'));
|
||||
assert.throws(() => applyAction(started, f.owner, f.action));
|
||||
assert.deepEqual(resources(started), paid);
|
||||
assert.deepEqual(stock(started), original);
|
||||
assert.equal(player(started, f.owner).hand.some(card => card.id === f.card), true);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user