Implement Ix High paid Cyborg revival bonus
This commit is contained in:
1 parent
86608f7ea9
commit
052737d0f9
18 files changed
+929
-12
No files matched your search
@@ -0,0 +1,230 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { MOBILE_LOCATION } from '../game/board';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { homeworldPopulations } from '../game/homeworld-population';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { actTruthMovement } from './fixture-truth-movement';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type IxHomeworldRevivalBonusStep = { actor: string; action: Action };
|
||||
export type IxHomeworldRevivalBonusTransition = { before: Game; steps: IxHomeworldRevivalBonusStep[]; after: Game };
|
||||
export const actIxHomeworldRevivalBonus = actTruthMovement;
|
||||
export const ixHomeworldRevivalBonusPlayer = (game: Game, actor = 'ixians') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const ixHomeworldRevivalBonusClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
|
||||
export const ixHomeworldRevivalBonusCards = (game: Game) => {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? []),
|
||||
...game.players.flatMap(player => player.hand)];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Every original Treachery Card retains one physical slot');
|
||||
return ids;
|
||||
};
|
||||
export function ixHomeworldRevivalBonusInventory(game: Game) {
|
||||
homeworldGameIntegrity(game);
|
||||
const sum = (counts: Record<string, number>) => Object.values(counts).reduce((total, count) => total + count, 0);
|
||||
for (const player of game.players) {
|
||||
const visitors = Object.values(game.homeworlds!.custody!.visitors)
|
||||
.map(groups => groups[player.id] ?? { normal: 0, elite: 0 });
|
||||
assert.equal(player.reserves + player.tanks + sum(player.forces) +
|
||||
visitors.reduce((total, group) => total + group.normal + group.elite, 0), 20);
|
||||
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) +
|
||||
visitors.reduce((total, group) => total + group.elite, 0), player.faction === 'ixians' ? 7 : 5);
|
||||
}
|
||||
return ixHomeworldRevivalBonusCards(game);
|
||||
}
|
||||
export const ixHomeworldRevivalBonusPopulation = (game: Game, owner = 'ixians') => {
|
||||
const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find(home => home.native === owner && home.card === 'ix'); assert.ok(home); return home;
|
||||
};
|
||||
export const ixHomeworldRevivalBonusNativeForces = (game: Game, owner = 'ixians') => {
|
||||
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find(home => home.native === owner && !home.secondary); assert.ok(home); return home.forces[owner];
|
||||
};
|
||||
|
||||
/** Only original native controls. Private battle plans and revival declarations
|
||||
* remain explicit; the original Caladan winner may decline its extra force. */
|
||||
export function nextIxHomeworldRevivalBonus(game: Game): IxHomeworldRevivalBonusStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'mobileStronghold') return { actor: decision.player, action: decision.placement
|
||||
? { type: 'decision', location: 'rock_outcroppings:14' } : { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'ixAuction') {
|
||||
const card = game.ixAuction!.cards[0]; assert.ok(card);
|
||||
return { actor: decision.player, action: { type: 'decision', card: card.id, position: 'bottom' } };
|
||||
}
|
||||
if (decision?.kind === 'caladanReinforcement')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
|
||||
if (decision?.kind === 'fullPlanOffer' || decision?.kind === 'revivalStop')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
}
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export const nextIxHomeworldRevivalBonusNativeStep = nextIxHomeworldRevivalBonus;
|
||||
export function advanceIxHomeworldRevivalBonus(game: Game, until: (state: Game) => boolean,
|
||||
actions: IxHomeworldRevivalBonusStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Ixian chronology ended before its requested control');
|
||||
const next = nextIxHomeworldRevivalBonus(game);
|
||||
assert.ok(next, `Choose the original private plan or Truthtrance at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actIxHomeworldRevivalBonus(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The genuine Ixian Homeworld source did not reach its finite boundary');
|
||||
}
|
||||
export function passIxHomeworldRevivalBonus(game: Game, actions: IxHomeworldRevivalBonusStep[] = []): Game {
|
||||
for (let count = 0; game.response && count < 60; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actIxHomeworldRevivalBonus(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.ok(!game.response); return game;
|
||||
}
|
||||
export function finishIxHomeworldRevivalBonus(game: Game, actions: IxHomeworldRevivalBonusStep[] = []): Game {
|
||||
return advanceIxHomeworldRevivalBonus(game, state => state.phase === 5 && ixHomeworldRevivalBonusClean(state), actions);
|
||||
}
|
||||
|
||||
/** Actual public Basic/Advanced Ix/Atreides/Emperor with Homeworlds. Native
|
||||
* first-Storm HMS setup retains its six counters. A paid five-force invasion
|
||||
* then really loses one Cyborg and four Suboids while native Ix remains HIGH.
|
||||
* Stop at the actual next Revival, before the caller's paid-Cyborg request. */
|
||||
export function createIxHomeworldRevivalBonusFixture(options: {
|
||||
advanced?: boolean; ghola?: boolean; priorFree?: boolean;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, owner = 'ixians', opponent = 'atreides', observer = 'emperor';
|
||||
let game = createGame('IXHOMEWORLDREVIVALBONUS', newPlayer(owner, 'Ixians', 'ixians'), advanced, ['ix']);
|
||||
joinGame(game, newPlayer(opponent, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), actions: IxHomeworldRevivalBonusStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actIxHomeworldRevivalBonus(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceIxHomeworldRevivalBonus(game, until, actions); };
|
||||
const transition = (run: () => void): IxHomeworldRevivalBonusTransition => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
|
||||
assert.deepEqual(game.expansions, ['ix']); assert.ok(game.homeworlds);
|
||||
assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
|
||||
!game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
const originalInventory = ixHomeworldRevivalBonusCards(game); assert.equal(originalInventory.length, 47);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 3);
|
||||
assert.equal(ordinary.length, 3);
|
||||
const gholaCard = options.ghola ? game.deck.find(card => card.effect === 'ghola') : null;
|
||||
assert.ok(!options.ghola || gholaCard);
|
||||
const selected = [gholaCard ?? ordinary[0], ordinary[1], ordinary[2]];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ixSetup');
|
||||
const beforeDraft = structuredClone(game);
|
||||
const startingDraft = transition(() => { act(owner, { type: 'decision', card: selected[0].id }); });
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).forces[MOBILE_LOCATION], 6);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).elites?.forces[MOBILE_LOCATION], 3);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).reserves, 14);
|
||||
assert.equal(ixHomeworldRevivalBonusPopulation(game, owner).side, 'high');
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1, 2, 3].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'mobileStronghold' && state.decision.placement);
|
||||
const hmsPlacement = transition(() => { act(owner, { type: 'decision', location: 'rock_outcroppings:14' }); });
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && ixHomeworldRevivalBonusClean(state));
|
||||
const beforeShipment = structuredClone(game), homeworldSources = nativeShipmentSources(viewGame(game, owner), 5, 1);
|
||||
assert.ok(homeworldSources);
|
||||
const shipmentStep: IxHomeworldRevivalBonusStep = { actor: owner, action: {
|
||||
type: 'ship', amount: 5, elite: 1, territory: 'arrakeen', sector: 10, allyPayment: 0, homeworldSources,
|
||||
} };
|
||||
const shipment = transition(() => { act(shipmentStep.actor, shipmentStep.action); reach(ixHomeworldRevivalBonusClean); });
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(beforeShipment, owner).spice - ixHomeworldRevivalBonusPlayer(game, owner).spice, 5);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).forces['arrakeen:10'], 5);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).elites?.forces['arrakeen:10'], 1);
|
||||
assert.equal(ixHomeworldRevivalBonusPopulation(game, owner).population, 9);
|
||||
reach(state => state.phase === 6 && ixHomeworldRevivalBonusClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices
|
||||
.find(choice => choice.territory === 'arrakeen'); assert.ok(choice);
|
||||
const battleStep: IxHomeworldRevivalBonusStep = { actor: choice.chooser, action: {
|
||||
type: 'chooseBattle', territory: 'arrakeen',
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
|
||||
} };
|
||||
act(battleStep.actor, battleStep.action);
|
||||
const beforePreparation = structuredClone(game);
|
||||
reach(state => !!state.battle && ixHomeworldRevivalBonusClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false);
|
||||
const beforePlans = structuredClone(game);
|
||||
const plans: IxHomeworldRevivalBonusStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', leader: 'ixians-4', dial: 0, support: 0, weapon: null, defense: null } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: 'atreides-0', dial: 0, support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
for (const step of plans) act(step.actor, step.action);
|
||||
const revealed = structuredClone(game);
|
||||
reach(state => !state.battle && ixHomeworldRevivalBonusClean(state));
|
||||
const afterBattle = structuredClone(game);
|
||||
assert.equal(game.lastBattleContext?.winner, opponent);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).tanks, 5);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).elites?.tanks, 1);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, opponent).tanks, 0);
|
||||
assert.equal(ixHomeworldRevivalBonusPopulation(game, owner).side, 'high');
|
||||
reach(state => state.turn === 2 && state.phase === 4 && ixHomeworldRevivalBonusClean(state));
|
||||
const beforePriorFree = structuredClone(game);
|
||||
const priorFree = options.priorFree ? transition(() => {
|
||||
act(owner, { type: 'revive', amount: 1, elite: 0 }); reach(ixHomeworldRevivalBonusClean);
|
||||
}) : null;
|
||||
const paidAction: Action = options.priorFree
|
||||
? { type: 'revive', amount: 2, elite: 1, freeElite: 0 }
|
||||
: { type: 'revive', amount: 3, elite: 1, freeElite: 0 };
|
||||
const freeCyborg: Action = { type: 'revive', amount: 1, elite: 1, freeElite: 1 };
|
||||
const suboidAction: Action = { type: 'revive', amount: 2, elite: 0, freeElite: 0 };
|
||||
const gholaAction: Action | null = gholaCard ? { type: 'card', card: gholaCard.id, amount: 1, elite: 1 } : null;
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).tanks, options.priorFree ? 4 : 5);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).elites?.tanks, 1);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).revived, options.priorFree ? 1 : 0);
|
||||
assert.equal(ixHomeworldRevivalBonusPlayer(game, owner).forces[MOBILE_LOCATION], 6);
|
||||
assert.deepEqual(ixHomeworldRevivalBonusInventory(game), originalInventory);
|
||||
return { game, source: structuredClone(game), initial, enableHomeworlds, lobby, setup, beforeStartingDeal,
|
||||
beforeDraft, startingDraft, afterSetup, hmsPlacement, beforeShipment, shipmentStep, shipment,
|
||||
beforeBattle, battleStep, beforePreparation, beforePlans, plans, planSteps: plans, revealed, afterBattle,
|
||||
beforePriorFree, priorFree, advanced, owner, ixians: owner, opponent, atreides: opponent, observer, emperor: observer,
|
||||
territory: 'arrakeen', location: 'arrakeen:10', event: game.lastBattleContext!.event,
|
||||
originalInventory, originalCards: originalInventory, originalSpice, dealOrder, spiceOrder,
|
||||
action: paidAction, paidAction, freeCyborg, suboidAction, ghola: gholaCard?.id ?? null, gholaAction,
|
||||
amount: options.priorFree ? 2 : 3, elite: 1, freeElite: 0, cost: 5, bonus: 2,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Actual public Basic/Advanced Ixians/Atreides/Emperor lobby enables only Homeworlds, then performs native ready/start with the original Ix47 deck. No optional Skills, Nexus, Discovery, Tech or Stronghold cards are introduced.',
|
||||
'Choose only original scalar shuffles, three unopened Treachery positions and four unopened ordinary Spice positions. Original private Traitor choices and Ixian three-card starting draft really occur. The requested Ghola variant selects the actual original Ghola in that draft; the default selects an ordinary card so existing normal bot revival candidates remain visible.',
|
||||
'Native Ixian setup puts three Cyborgs and three Suboids in the HMS and fourteen counters at Ix. Complete the first actual safe Storm and place the HMS at Rock Outcroppings14 through its original decision, then actual Spice/Charity/Bidding/Revival controls.',
|
||||
'Ixians pay five own spice to ship one original Cyborg and four original Suboids from the explicit native Homeworld pool into Atreides’ native ten Arrakeen counters. Ix population becomes nine and stays HIGH. Actual movement endings, native battle choice, original preparation, two zero-dial unarmed plans and declined Traitors produce the Atreides victory. All five invaded counters actually enter Tanks; Atreides loses none and declines its optional Caladan reinforcement.',
|
||||
'Real Collection/Mentat, second Storm/Spice/Charity/Bidding reach clean turn-two Revival. The HMS and its original six typed counters stay intact. No phase, force, wallet, casualty, hand, revival ledger, occupation or receipt is installed.',
|
||||
options.priorFree
|
||||
? 'The optional prior-free branch really returns one ordinary Suboid before the source. Its base/free ledgers remain recorded; the returned paid request uses the two remaining ordinary slots and still has two physical bonus Suboids available.'
|
||||
: 'Stop before any return. The original paid request is amount3/elite1/freeElite0: one ordinary free Suboid, one paid Suboid and one paid Cyborg for five spice. The requested bonus is additional to its three ordinary returns, using the two other real Suboid Tanks under the root’s explicitly provisional quota/shortage composition.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type IxHomeworldRevivalBonusFixture = ReturnType<typeof createIxHomeworldRevivalBonusFixture>;
|
||||
|
||||
export function reviveIxHomeworldRevivalBonus(source: Game, fixture: IxHomeworldRevivalBonusFixture,
|
||||
action: Action = fixture.action, actions: IxHomeworldRevivalBonusStep[] = []) {
|
||||
const before = structuredClone(source), start = actions.length;
|
||||
let game = actIxHomeworldRevivalBonus(source, fixture.owner, action, actions);
|
||||
const requested = structuredClone(game);
|
||||
game = advanceIxHomeworldRevivalBonus(game, ixHomeworldRevivalBonusClean, actions);
|
||||
return { game, before, requested, after: structuredClone(game), steps: structuredClone(actions.slice(start)) };
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { MOBILE_LOCATION } from '../game/board';
|
||||
import { forceRevivalQuote } from '../game/revival';
|
||||
import { quoteIxSuboidBonus } from '../game/ix-homeworld-revival';
|
||||
import {
|
||||
createIxHomeworldRevivalBonusFixture as fixture,
|
||||
reviveIxHomeworldRevivalBonus as revive,
|
||||
ixHomeworldRevivalBonusPlayer as player,
|
||||
ixHomeworldRevivalBonusInventory as inventory,
|
||||
ixHomeworldRevivalBonusPopulation as population,
|
||||
ixHomeworldRevivalBonusNativeForces as nativeForces,
|
||||
finishIxHomeworldRevivalBonus as finish,
|
||||
} from './fixture-ix-homeworld-revival-bonus';
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} actual paid Cyborg return adds two real Suboids beyond the ordinary three-force quota`, () => {
|
||||
const f = fixture({ advanced }), before = structuredClone(player(f.game, f.owner));
|
||||
const source = structuredClone(f.game), homeBefore = nativeForces(f.game, f.owner);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4);
|
||||
assert.equal(population(f.game, f.owner).population, 9);
|
||||
assert.equal(population(f.game, f.owner).side, 'high');
|
||||
assert.equal(before.tanks, 5); assert.equal(before.elites?.tanks, 1);
|
||||
assert.equal(f.afterBattle.lastBattleContext?.winner, f.opponent);
|
||||
assert.equal(player(f.afterBattle, f.opponent).tanks, 0);
|
||||
assert.equal(f.shipment.steps[0].action.type, 'ship');
|
||||
assert.ok(f.actions.some(step => step.action.type === 'homeworlds' && step.action.enabled === true));
|
||||
assert.ok(f.actions.some(step => step.action.type === 'start'));
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.equal(viewGame(f.game, f.owner).revival.ixSuboidBonusEnabled, true);
|
||||
assert.equal(viewGame(f.game, f.opponent).revival.ixSuboidBonusEnabled, false);
|
||||
const quote = forceRevivalQuote(f.game, before, 3, 1, 0);
|
||||
assert.deepEqual(quote, { free: 1, normalCost: 5, cost: 5 });
|
||||
assert.equal(quoteIxSuboidBonus({ enabled: true, amount: 3, elite: 1, free: quote.free,
|
||||
freeElite: 0, normalTanks: before.tanks - before.elites!.tanks }), 2);
|
||||
const result = revive(f.game, f), after = player(result.game, f.owner);
|
||||
assert.deepEqual(f.game, source, 'The original source remains available for the other native branches');
|
||||
assert.equal(after.reserves, before.reserves + 5);
|
||||
assert.equal(after.elites?.reserves, before.elites!.reserves + 1);
|
||||
assert.equal(after.tanks, 0); assert.equal(after.elites?.tanks, 0);
|
||||
assert.equal(after.revived, 3, 'Only the declared three forces consume normal revival allowance');
|
||||
assert.equal(after.freeForcesRevived, 1, 'Additional Suboids do not consume the ordinary free quota');
|
||||
assert.equal(after.elites?.revived, 1);
|
||||
assert.equal(before.spice - after.spice, 5);
|
||||
assert.deepEqual(nativeForces(result.game, f.owner), { normal: homeBefore.normal + 4, elite: homeBefore.elite + 1 });
|
||||
assert.equal(after.forces[MOBILE_LOCATION], 6);
|
||||
assert.equal(after.elites?.forces[MOBILE_LOCATION], 3);
|
||||
assert.equal(viewGame(result.game, f.owner).revival.forcesRemaining, 0);
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
const movement = finish(result.game);
|
||||
assert.equal(movement.turn, 2); assert.equal(movement.phase, 5);
|
||||
assert.equal(player(movement, f.owner).reserves, after.reserves);
|
||||
assert.equal(player(movement, f.owner).tanks, 0);
|
||||
assert.deepEqual(inventory(movement), f.originalInventory);
|
||||
});
|
||||
}
|
||||
|
||||
void test('an actual earlier free Suboid remains completed and the later paid Cyborg bonus uses no extra ordinary slots', () => {
|
||||
const f = fixture({ priorFree: true }), before = structuredClone(player(f.game, f.owner));
|
||||
assert.ok(f.priorFree);
|
||||
assert.equal(player(f.beforePriorFree, f.owner).tanks, 5);
|
||||
assert.equal(before.tanks, 4); assert.equal(before.revived, 1); assert.equal(before.freeForcesRevived, 1);
|
||||
const result = revive(f.game, f), after = player(result.game, f.owner);
|
||||
assert.equal(after.reserves - before.reserves, 4);
|
||||
assert.equal(after.elites!.reserves - before.elites!.reserves, 1);
|
||||
assert.equal(after.tanks, 0); assert.equal(after.elites?.tanks, 0);
|
||||
assert.equal(before.spice - after.spice, 5);
|
||||
assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 1);
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('a genuine free Cyborg return gives no extra Suboids or paid invoice', () => {
|
||||
const f = fixture(), before = structuredClone(player(f.game, f.owner));
|
||||
const result = revive(f.game, f, f.freeCyborg), after = player(result.game, f.owner);
|
||||
assert.equal(after.reserves, before.reserves + 1);
|
||||
assert.equal(after.elites?.reserves, before.elites!.reserves + 1);
|
||||
assert.equal(after.tanks, 4); assert.equal(after.elites?.tanks, 0);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.revived, 1); assert.equal(after.freeForcesRevived, 1);
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('ordinary Suboid revivals create no bonus and a later paid Cyborg returns only the two still in Tanks', () => {
|
||||
const f = fixture(), before = structuredClone(player(f.game, f.owner));
|
||||
const suboids = revive(f.game, f, f.suboidAction).game;
|
||||
const middle = structuredClone(player(suboids, f.owner));
|
||||
assert.equal(middle.reserves, before.reserves + 2);
|
||||
assert.equal(middle.elites?.reserves, before.elites?.reserves);
|
||||
assert.equal(middle.tanks, 3); assert.equal(middle.elites?.tanks, 1);
|
||||
assert.equal(before.spice - middle.spice, 2);
|
||||
assert.equal(middle.revived, 2); assert.equal(middle.freeForcesRevived, 1);
|
||||
const paidCyborg: Action = { type: 'revive', amount: 1, elite: 1, freeElite: 0 };
|
||||
const result = revive(suboids, f, paidCyborg), after = player(result.game, f.owner);
|
||||
assert.equal(after.reserves - middle.reserves, 3);
|
||||
assert.equal(after.elites!.reserves - middle.elites!.reserves, 1);
|
||||
assert.equal(middle.spice - after.spice, 3);
|
||||
assert.equal(after.tanks, 0); assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 1);
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('the original privately drafted Ghola returns its Cyborg without the paid-revival bonus or normal allowance use', () => {
|
||||
const f = fixture({ ghola: true }), before = structuredClone(player(f.game, f.owner));
|
||||
assert.ok(f.ghola && f.gholaAction);
|
||||
assert.equal(f.startingDraft.steps[0].action.card, f.ghola);
|
||||
assert.ok(before.hand.some(card => card.id === f.ghola));
|
||||
const result = revive(f.game, f, f.gholaAction), after = player(result.game, f.owner);
|
||||
assert.equal(after.reserves, before.reserves + 1);
|
||||
assert.equal(after.elites?.reserves, before.elites!.reserves + 1);
|
||||
assert.equal(after.tanks, 4); assert.equal(after.elites?.tanks, 0);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.revived, before.revived);
|
||||
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
|
||||
assert.equal(after.hand.some(card => card.id === f.ghola), false);
|
||||
assert.equal(result.game.discard.filter(card => card.id === f.ghola).length, 1);
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('all four existing Basic profiles retain legal original revival choices without strategic tuning', () => {
|
||||
const f = fixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(f.game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'revive');
|
||||
assert.ok(action, `${difficulty} proposes an ordinary revival from the genuine native source`);
|
||||
const result = revive(structuredClone(f.game), f, action);
|
||||
const before = player(f.game, f.owner), after = player(result.game, f.owner);
|
||||
assert.ok(after.tanks < before.tanks);
|
||||
assert.ok(after.spice >= 0 && after.spice <= before.spice);
|
||||
assert.ok(after.revived > before.revived && after.revived <= 3);
|
||||
assert.equal(population(result.game, f.owner).side, 'high');
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
assert.equal(finish(result.game).phase, 5);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer } from '../game/engine';
|
||||
import { createHomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { snapshotHomeworldRevival } from '../game/homeworld-benefits';
|
||||
import { quoteRevivalResume } from '../game/revival-resume';
|
||||
import { quoteRevivalCancellation, continuationCustody } from '../game/revival-cancellation';
|
||||
import { newRevivalRules, forceRevivalQuote } from '../game/revival';
|
||||
import { quoteIxSuboidBonus } from '../game/ix-homeworld-revival';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import {
|
||||
createIxHomeworldRevivalBonusFixture as fixture,
|
||||
actIxHomeworldRevivalBonus as act,
|
||||
advanceIxHomeworldRevivalBonus as advance,
|
||||
ixHomeworldRevivalBonusClean as clean,
|
||||
ixHomeworldRevivalBonusPlayer as player,
|
||||
ixHomeworldRevivalBonusInventory as inventory,
|
||||
reviveIxHomeworldRevivalBonus as revive,
|
||||
} from './fixture-ix-homeworld-revival-bonus';
|
||||
|
||||
void test('a genuinely drafted Ghola leaves only one physical bonus Suboid after the selected base group', () => {
|
||||
const f = fixture({ ghola: true });
|
||||
let game = act(f.game, f.owner, { type: 'card', card: f.ghola!, amount: 2, elite: 0 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game).tanks, 3);
|
||||
const before = structuredClone(player(game));
|
||||
const returned = revive(game, f, { type: 'revive', amount: 2, elite: 1, freeElite: 0 }).game;
|
||||
assert.equal(player(returned).reserves - before.reserves, 3);
|
||||
assert.equal(before.spice - player(returned).spice, 3);
|
||||
assert.equal(player(returned).revived, 2);
|
||||
assert.equal(player(returned).tanks, 0);
|
||||
assert.match(returned.log.at(-1)!.text, /1 physical Suboid for free/);
|
||||
inventory(returned);
|
||||
});
|
||||
|
||||
void test('controlled prior La La La permits the paid Cyborg without a new free bonus', () => {
|
||||
const f = fixture();
|
||||
// Isolate an already effective restriction, not a claim of earning CHOAM's card.
|
||||
f.game.revivalRules!.freeBlocked = [f.owner];
|
||||
const before = structuredClone(player(f.game));
|
||||
const result = revive(f.game, f, { type: 'revive', amount: 1, elite: 1, freeElite: 0 }).game;
|
||||
assert.equal(player(result).reserves - before.reserves, 1);
|
||||
assert.equal(player(result).tanks, 4);
|
||||
assert.equal(player(result).spice, before.spice - 3);
|
||||
assert.equal(player(result).freeForcesRevived, 0);
|
||||
inventory(result);
|
||||
});
|
||||
|
||||
void test('controlled active Recruits quote changes the ordinary free rate without multiplying the physical bonus', () => {
|
||||
const f = fixture();
|
||||
// Quote-only composition test; no unplayed card is offered to applyAction.
|
||||
f.game.recruits = { turn: f.game.turn, player: f.owner, card: 'ecaz-recruits' };
|
||||
const before = structuredClone(f.game), owner = player(f.game);
|
||||
const quote = forceRevivalQuote(f.game, owner, 3, 1, 0);
|
||||
assert.equal(quote.free, 2);
|
||||
assert.equal(quote.cost, 3);
|
||||
assert.equal(quoteIxSuboidBonus({ enabled: true, amount: 3, elite: 1,
|
||||
free: quote.free, freeElite: 0, normalTanks: owner.tanks - owner.elites!.tanks }), 2);
|
||||
assert.deepEqual(f.game, before);
|
||||
});
|
||||
|
||||
/** Controlled physical contexts for pure pending-quote tests. These do not
|
||||
* claim public setup, card play or saved-game recovery evidence. */
|
||||
function pendingContext() {
|
||||
const game = createGame('IXBONUSPENDING', newPlayer('ix', 'Ixians', 'ixians'), false, ['ix']);
|
||||
joinGame(game, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
Object.assign(game, { status: 'playing', phase: 4, turn: 2, revivalRules: newRevivalRules() });
|
||||
const ix = game.players[0], tleilaxu = game.players[1];
|
||||
Object.assign(ix, { reserves: 9, tanks: 5, forces: { 'polar_sink:0': 6 },
|
||||
revived: 1, freeForcesRevived: 1, spice: 8,
|
||||
elites: { reserves: 3, tanks: 1, forces: { 'polar_sink:0': 3 }, revived: 0 } });
|
||||
Object.assign(tleilaxu, { reserves: 12, tanks: 0, forces: { 'polar_sink:0': 8 } });
|
||||
game.homeworlds = { custody: createHomeworldCustody(homeworldContext(game)) };
|
||||
game.homeworldRevival = snapshotHomeworldRevival(game);
|
||||
game.techTokens = createTechTokens(game.players);
|
||||
game.pendingRevival = { player: 'ix', kind: 'forces', amount: 1, elite: 1,
|
||||
free: 0, normalCost: 3, cost: 3, checks: [], ixSuboidBonus: 2 };
|
||||
return game;
|
||||
}
|
||||
|
||||
const income = (quote: ReturnType<typeof quoteRevivalResume>) =>
|
||||
quote.nextResponse?.kind === 'revivalIncome' ? quote.nextResponse.amount : undefined;
|
||||
|
||||
void test('bonus-only free event shares existing Tleilaxu and Axlotl receipts without spending normal quota', () => {
|
||||
const game = pendingContext(), before = structuredClone(game);
|
||||
const quote = quoteRevivalResume(game, 'finish');
|
||||
assert.equal(quote.outcome, 'revive');
|
||||
assert.deepEqual(quote.nextResponse, { kind: 'revivalIncome', owner: 't', recipient: 'ix', amount: 4, passed: [] });
|
||||
assert.deepEqual(quote.freeIncome, { player: 'ix', turn: 2 });
|
||||
assert.equal(quote.techIncome?.spice, 1);
|
||||
assert.deepEqual(game, before);
|
||||
game.revivalFreeIncome = { ix: game.turn };
|
||||
game.techTokens!.axlotl.triggeredTurn = game.turn;
|
||||
const repeated = quoteRevivalResume(game, 'finish');
|
||||
assert.equal(income(repeated), 3);
|
||||
assert.equal(repeated.techIncome, undefined);
|
||||
game.homeworldRevival!.tleilaxu.low = true;
|
||||
game.revivalFreeIncome = {};
|
||||
assert.equal(income(quoteRevivalResume(game, 'finish')), 3);
|
||||
});
|
||||
|
||||
void test('discount cancellation retains the physical grant only on a funded original return', () => {
|
||||
const game = pendingContext();
|
||||
game.pendingRevival!.cost = 2;
|
||||
const discounted = quoteRevivalResume(game, 'finish');
|
||||
assert.equal(discounted.pending.ixSuboidBonus, 2);
|
||||
assert.equal(income(discounted), 3);
|
||||
const full = quoteRevivalCancellation(game, { kind: 'revivalDiscount', owner: 't', recipient: 'ix' });
|
||||
assert.equal(full.outcome, 'revive');
|
||||
assert.equal(full.pending?.cost, 3);
|
||||
assert.equal(full.pending?.ixSuboidBonus, 2);
|
||||
game.players[0].spice = 2;
|
||||
assert.equal(quoteRevivalCancellation(game,
|
||||
{ kind: 'revivalDiscount', owner: 't', recipient: 'ix' }).outcome, 'unfunded');
|
||||
game.pendingRevival!.cost = 3;
|
||||
const unfunded = quoteRevivalResume(game, 'finish');
|
||||
assert.equal(unfunded.outcome, 'unfunded');
|
||||
assert.equal(unfunded.techIncome, undefined);
|
||||
assert.equal(unfunded.nextResponse, undefined);
|
||||
});
|
||||
|
||||
void test('malformed frozen bonus rejects before physical accounting and old pending requests gain no retrofit', () => {
|
||||
const game = pendingContext();
|
||||
game.pendingRevival!.ixSuboidBonus = 1;
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => continuationCustody(game, game.players[0], game.pendingRevival!), /pending Ix bonus/);
|
||||
assert.deepEqual(game, before);
|
||||
delete game.pendingRevival!.ixSuboidBonus;
|
||||
const old = quoteRevivalResume(game, 'finish');
|
||||
assert.equal(old.pending.ixSuboidBonus, undefined);
|
||||
assert.equal(old.techIncome, undefined);
|
||||
assert.equal(income(old), 3);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { IxHomeworldRevivalBonus } from '../components/ix-homeworld-revival';
|
||||
|
||||
void test('the automatic Ix quote persists and describes the selected physical bonus', () => {
|
||||
const props = { enabled: true, amount: 3, elite: 1, free: 1,
|
||||
freeElite: 0, normalTanks: 4 };
|
||||
const html = renderToStaticMarkup(createElement(IxHomeworldRevivalBonus, props));
|
||||
assert.match(html, /also returns 2 Suboids for free/);
|
||||
assert.match(html, /do not use normal revival allowance/);
|
||||
assert.match(html, /aria-live="polite"/);
|
||||
assert.doesNotMatch(html, /button|timeout|Continue/);
|
||||
const one = renderToStaticMarkup(createElement(IxHomeworldRevivalBonus,
|
||||
{ ...props, normalTanks: 3 }));
|
||||
assert.match(one, /also returns 1 Suboid for free/);
|
||||
assert.doesNotMatch(one, /1 Suboids/);
|
||||
});
|
||||
|
||||
void test('free Cyborg selection quotes zero and stale selections remain readable', () => {
|
||||
const props = { enabled: true, amount: 3, elite: 1, free: 1,
|
||||
freeElite: 1, normalTanks: 4 };
|
||||
assert.match(renderToStaticMarkup(createElement(IxHomeworldRevivalBonus, props)),
|
||||
/also returns 0 Suboids/);
|
||||
assert.match(renderToStaticMarkup(createElement(IxHomeworldRevivalBonus,
|
||||
{ ...props, elite: 4 })), /Select an available revival group/);
|
||||
assert.equal(renderToStaticMarkup(createElement(IxHomeworldRevivalBonus,
|
||||
{ ...props, enabled: false })), '');
|
||||
const blocked = renderToStaticMarkup(createElement(IxHomeworldRevivalBonus,
|
||||
{ ...props, blocked: true }));
|
||||
assert.match(blocked, /La La La prevents/);
|
||||
assert.match(blocked, /Paid Cyborg revival remains available/);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { newPlayer, type Game } from '../game/engine';
|
||||
import { createHomeworldCustody, quoteHomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import {
|
||||
ixHomeworldRevivalEnabled, quoteIxSuboidBonus, IxHomeworldRevivalError,
|
||||
type IxSuboidBonusInput,
|
||||
} from '../game/ix-homeworld-revival';
|
||||
|
||||
/** Controlled physical population context, not an earned game chronology. */
|
||||
function populationContext(population = 5, advanced = false): Pick<Game, 'advanced' | 'players' | 'homeworlds'> {
|
||||
const players = [newPlayer('ix', 'Ixians', 'ixians'), newPlayer('other', 'Guild', 'guild')];
|
||||
const native = players[0];
|
||||
native.reserves = population;
|
||||
native.tanks = 20 - population;
|
||||
native.elites!.reserves = Math.min(population, 2);
|
||||
native.elites!.tanks = 7 - native.elites!.reserves;
|
||||
return { advanced, players, homeworlds: { custody: createHomeworldCustody(homeworldContext({ advanced, players })) } };
|
||||
}
|
||||
|
||||
void test('native Ix High activates at five current counters in Basic and Advanced without another gate', () => {
|
||||
for (const advanced of [false, true]) for (const population of [0, 4, 5, 6, 14]) {
|
||||
const context = populationContext(population, advanced), before = structuredClone(context);
|
||||
assert.equal(ixHomeworldRevivalEnabled(context, 'ix'), population >= 5);
|
||||
assert.deepEqual(context, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('absent Homeworld custody and nonnative viewers do not receive the Ix revival benefit', () => {
|
||||
const context = populationContext();
|
||||
assert.equal(ixHomeworldRevivalEnabled(context, 'other'), false);
|
||||
assert.equal(ixHomeworldRevivalEnabled(context, 'missing'), false);
|
||||
assert.equal(ixHomeworldRevivalEnabled({ ...context, homeworlds: undefined }, 'ix'), false);
|
||||
assert.equal(ixHomeworldRevivalEnabled({ ...context, homeworlds: { custody: null } }, 'ix'), false);
|
||||
});
|
||||
|
||||
void test('foreign visitors do not raise Ix population and native counters visiting elsewhere do not count', () => {
|
||||
const context = populationContext(4);
|
||||
const visit = quoteHomeworldCustody(homeworldContext(context), context.homeworlds!.custody!, [
|
||||
{ homeworld: 'homeworld:guild', player: 'other', withdraw: { normal: 8, elite: 0 }, deposit: { normal: 0, elite: 0 } },
|
||||
{ homeworld: 'homeworld:ixians', player: 'other', withdraw: { normal: 0, elite: 0 }, deposit: { normal: 8, elite: 0 } },
|
||||
]);
|
||||
for (const next of visit.players) context.players.find(player => player.id === next.id)!.reserves = next.reserves;
|
||||
context.homeworlds!.custody = visit.state;
|
||||
assert.equal(ixHomeworldRevivalEnabled(context, 'ix'), false);
|
||||
assert.equal(ixHomeworldRevivalEnabled(context, 'other'), false);
|
||||
|
||||
const high = populationContext(5);
|
||||
const departed = quoteHomeworldCustody(homeworldContext(high), high.homeworlds!.custody!, [
|
||||
{ homeworld: 'homeworld:ixians', player: 'ix', withdraw: { normal: 1, elite: 0 }, deposit: { normal: 0, elite: 0 } },
|
||||
{ homeworld: 'homeworld:guild', player: 'ix', withdraw: { normal: 0, elite: 0 }, deposit: { normal: 1, elite: 0 } },
|
||||
]);
|
||||
for (const next of departed.players) high.players.find(player => player.id === next.id)!.reserves = next.reserves;
|
||||
high.homeworlds!.custody = departed.state;
|
||||
assert.equal(ixHomeworldRevivalEnabled(high, 'ix'), false);
|
||||
});
|
||||
|
||||
const group = (patch: Partial<IxSuboidBonusInput> = {}): IxSuboidBonusInput => ({
|
||||
enabled: true, amount: 1, elite: 1, free: 0, freeElite: 0, normalTanks: 6, ...patch,
|
||||
});
|
||||
|
||||
void test('each paid Cyborg adds two physical Suboids while free Cyborgs and ordinary returns add none', () => {
|
||||
assert.equal(quoteIxSuboidBonus(group()), 2);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 2, elite: 2 })), 4);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 1, elite: 1, free: 1, freeElite: 1 })), 0);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 3, elite: 0, free: 1, normalTanks: 6 })), 0);
|
||||
assert.equal(quoteIxSuboidBonus(group({ enabled: false })), 0);
|
||||
});
|
||||
|
||||
void test('mixed free type allocation counts only paid Cyborgs and reserves the declared ordinary returns first', () => {
|
||||
const input = group({ amount: 4, elite: 3, free: 2, freeElite: 1, normalTanks: 7 });
|
||||
const before = structuredClone(input);
|
||||
assert.equal(quoteIxSuboidBonus(input), 4);
|
||||
assert.deepEqual(input, before);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 3, elite: 2, free: 1, freeElite: 0, normalTanks: 5 })), 4);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 4, elite: 2, free: 1, freeElite: 1, normalTanks: 5 })), 2);
|
||||
});
|
||||
|
||||
void test('physical Suboid shortages give a partial or zero bonus without replacing the declared group', () => {
|
||||
assert.equal(quoteIxSuboidBonus(group({ normalTanks: 1 })), 1);
|
||||
assert.equal(quoteIxSuboidBonus(group({ normalTanks: 0 })), 0);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 3, elite: 1, normalTanks: 3 })), 1);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 3, elite: 1, normalTanks: 2 })), 0);
|
||||
assert.equal(quoteIxSuboidBonus(group({ amount: 20, elite: 7, free: 1, normalTanks: 13 })), 0);
|
||||
});
|
||||
|
||||
void test('malformed counts and impossible free type allocations reject even when the benefit is disabled', () => {
|
||||
const invalid: Partial<IxSuboidBonusInput>[] = [
|
||||
{ amount: 0 }, { amount: 21 }, { elite: 2 }, { free: 2 },
|
||||
{ freeElite: 1 }, { amount: 2, elite: 0, free: 1, freeElite: 1 },
|
||||
{ amount: 2, elite: 2, free: 1, freeElite: 0 },
|
||||
{ amount: 3, elite: 1, normalTanks: 1 },
|
||||
];
|
||||
for (const field of ['amount', 'elite', 'free', 'freeElite', 'normalTanks'] as const)
|
||||
for (const value of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) invalid.push({ [field]: value });
|
||||
for (const enabled of [false, true]) for (const patch of invalid) {
|
||||
const input = group({ ...patch, enabled }), before = structuredClone(input);
|
||||
assert.throws(() => quoteIxSuboidBonus(input), IxHomeworldRevivalError);
|
||||
assert.deepEqual(input, before);
|
||||
}
|
||||
assert.throws(() => quoteIxSuboidBonus({ ...group(), enabled: 'yes' as unknown as boolean }), IxHomeworldRevivalError);
|
||||
});
|
||||
Reference in new issue
Block a user