Allow Tleilaxu revival prevention for Low Homeworlds

This commit is contained in:
Quintine committed 2026-10-25 20:08:43 +11:00
1 parent 5bcf5bab48
commit ce676f0e54
13 files changed
+501 -46

No files matched your search

@@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { distance, location, territory } from '../game/board';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldPopulations } from '../game/homeworld-population';
import { nativeShipmentSources } from '../game/homeworld-options';
import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type HomeworldRevivalPreventionStep = TruthMovementStep;
export type HomeworldRevivalPreventionTransition = { before: Game; steps: HomeworldRevivalPreventionStep[]; after: Game };
export const actHomeworldRevivalPrevention = actTruthMovement;
export const homeworldRevivalPreventionPlayer = (game: Game, actor = 'fremen') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const homeworldRevivalPreventionClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
export function homeworldRevivalPreventionInventory(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), 3);
}
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Each original Treachery Card retains one physical slot');
return ids;
}
export const homeworldRevivalPreventionPopulation = (game: Game) => {
const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!)
.find(home => home.native === 'fremen' && home.card === 'southern_hemisphere'); assert.ok(home); return home;
};
/** Original setup and response controls only. The native revivalStop remains
* owned by Tleilaxu; this helper never chooses prevention or its decline. */
export function nextHomeworldRevivalPrevention(game: Game): HomeworldRevivalPreventionStep | null {
if (game.truthtrance) return null;
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'revivalStop') return null;
const fremen = homeworldRevivalPreventionPlayer(game, 'fremen');
if (game.status === 'setup' && game.setupStage === 'forces' && fremen.reserves === 20)
return { actor: fremen.id, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 },
elitePlacements: { 'sietch_tabr:14': 3 } } };
if (game.status === 'playing' && game.turn === 1 && game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: 6 } };
}
if (game.decision?.kind === 'wormRide' || game.decision?.kind === 'wormPlacement')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
}
return nextRicheseStrongholdsNativeStep(game);
}
export const nextHomeworldRevivalPreventionNativeStep = nextHomeworldRevivalPrevention;
export function advanceHomeworldRevivalPrevention(game: Game, until: (state: Game) => boolean,
actions: HomeworldRevivalPreventionStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine Fremen chronology ended before its requested control');
const next = nextHomeworldRevivalPrevention(game);
assert.ok(next, `Choose the original revivalStop or owned control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actHomeworldRevivalPrevention(game, next.actor, next.action, actions);
}
throw Error('The native LOW-Homeworld revival source did not reach its finite boundary');
}
export function passHomeworldRevivalPrevention(game: Game, actions: HomeworldRevivalPreventionStep[] = []): 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 = actHomeworldRevivalPrevention(game, actor.id, { type: 'passResponse' }, actions);
}
assert.ok(!game.response); return game;
}
export function finishHomeworldRevivalPrevention(game: Game, actions: HomeworldRevivalPreventionStep[] = []): Game {
return advanceHomeworldRevivalPrevention(game, state => state.phase === 5 && homeworldRevivalPreventionClean(state), actions);
}
/** Actual public Advanced Fremen/Tleilaxu + Homeworlds/E1. Ship every ordinary
* native Fremen reserve into the actual first Storm and allow native half-loss.
* All three original Fedaykin remain safely in Tabr; no played force, Tank,
* hand, wallet, phase or revival receipt is assigned. */
export function createHomeworldRevivalPreventionFixture(options: { ghola?: boolean } = {}) {
const owner = 'tleilaxu', target = 'fremen', withGhola = options.ghola ?? true;
let game = createGame('HOMEWORLDREVIVALPREVENTION', newPlayer(target, 'Fremen', 'fremen'), true, ['ix']);
joinGame(game, newPlayer(owner, 'Tleilaxu', 'tleilaxu'));
const initial = structuredClone(game), actions: HomeworldRevivalPreventionStep[] = [];
const act = (actor: string, action: Action) => { game = actHomeworldRevivalPrevention(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceHomeworldRevivalPrevention(game, until, actions); };
const transition = (run: () => void): HomeworldRevivalPreventionTransition => {
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, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, ['ix']);
assert.ok(game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => !player.hand.length));
const originalInventory = [...game.deck].map(card => card.id).sort(); assert.equal(originalInventory.length, 47);
const karama = game.deck.find(card => card.effect === 'karama');
const targetCard = game.deck.find(card => withGhola ? card.effect === 'ghola' : card.kind === 'projectile');
assert.ok(karama && targetCard);
const deal = [targetCard.id, karama.id];
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), fremen = homeworldRevivalPreventionPlayer(game, target);
assert.equal(game.storm, 1);
assert.deepEqual(fremen.forces, { 'sietch_tabr:14': 10 });
assert.deepEqual(fremen.elites?.forces, { 'sietch_tabr:14': 3 });
assert.equal(fremen.reserves, 10); assert.equal(fremen.elites?.reserves, 0);
assert.deepEqual(fremen.hand.map(card => card.id), [targetCard.id]);
assert.deepEqual(homeworldRevivalPreventionPlayer(game, owner).hand.map(card => card.id), [karama.id]);
assert.equal(homeworldRevivalPreventionPlayer(game, owner).faceDancers?.length, 3);
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 &&
!['hagga_basin', 'sietch_tabr'].includes(card.territory));
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);
const firstStorm = transition(() => { reach(state => state.turn === 1 && state.phase === 1 && state.storm === 13); });
assert.deepEqual(firstStorm.steps.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [6, 6]);
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && homeworldRevivalPreventionClean(state));
const beforeShipment = structuredClone(game), destination = 'hagga_basin', sector = 13, key = 'hagga_basin:13';
assert.ok(territory('the_great_flat').sectors.some(from => distance(location('the_great_flat', from), key) <= 2));
assert.equal(game.storm, sector);
const homeworldSources = nativeShipmentSources(viewGame(game, target), 10, 0); assert.ok(homeworldSources);
const shipmentStep: HomeworldRevivalPreventionStep = { actor: target, action: {
type: 'ship', amount: 10, elite: 0, territory: destination, sector, allyPayment: 0, homeworldSources,
} };
act(shipmentStep.actor, shipmentStep.action);
const stormProtection = structuredClone(game);
assert.equal(game.response?.kind, 'stormProtection'); assert.equal(game.response?.resume, 'shipment');
assert.equal(homeworldRevivalPreventionPlayer(game, target).reserves, 0);
const halfLoss = transition(() => { reach(homeworldRevivalPreventionClean); });
const afterLoss = structuredClone(game), lost = homeworldRevivalPreventionPlayer(game, target);
assert.equal(lost.spice, homeworldRevivalPreventionPlayer(beforeShipment, target).spice);
assert.equal(lost.tanks, 5); assert.equal(lost.elites?.tanks, 0);
assert.equal(lost.reserves, 0); assert.equal(homeworldRevivalPreventionPopulation(game).side, 'low');
assert.deepEqual(lost.forces, { 'sietch_tabr:14': 10, [key]: 5 });
assert.deepEqual(lost.elites?.forces, { 'sietch_tabr:14': 3 });
reach(state => state.turn === 2 && state.phase === 4 && homeworldRevivalPreventionClean(state));
assert.equal(homeworldRevivalPreventionPlayer(game, target).tanks, 5);
assert.equal(homeworldRevivalPreventionPlayer(game, target).reserves, 0);
assert.equal(homeworldRevivalPreventionPlayer(game, target).revived, 0);
assert.equal(homeworldRevivalPreventionPopulation(game).population, 0);
assert.ok(homeworldRevivalPreventionPlayer(game, owner).hand.some(card => card.id === karama.id));
assert.deepEqual(homeworldRevivalPreventionInventory(game), originalInventory);
const special: Action = { type: 'card', card: karama.id, mode: 'special', target };
const request: Action = { type: 'revive', amount: 4, elite: 0 };
const ghola: Action | null = withGhola ? { type: 'card', card: targetCard.id, amount: 5, elite: 0 } : null;
return { game, source: structuredClone(game), initial, enableHomeworlds, lobby, setup, beforeStartingDeal, afterSetup,
firstStorm, beforeShipment, shipmentStep, stormProtection, halfLoss, afterLoss,
owner, tleilaxu: owner, target, fremen: target, karama: karama.id, card: karama.id, special, action: special,
request, ordinaryRevival: request, ghola: withGhola ? targetCard.id : null, gholaAction: ghola,
destination, territory: destination, sector, location: key, lowPopulation: 0, tanks: 5,
originalInventory, originalCards: originalInventory, originalSpice, deal, dealOrder, spiceOrder,
actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual public Advanced Fremen/Tleilaxu ready/startPreview enables only Homeworlds and the original Ix47 deck. Native Traitor choices, actual three-card Face Dancer deal and Fremen setup place ten original counters including all three Fedaykin in Sietch Tabr14, leaving ten ordinary native reserves at Southern Hemisphere.',
`Only native scalar lotteries, two unopened Treachery positions and four unopened ordinary Spice positions are selected. Tleilaxu receives its original printed Karama; Fremen receives ${withGhola ? 'the original Ghola' : 'an ordinary projectile'}. No held cards, forecast, phase, wallet, forces or casualty receipts are installed.`,
'Two actual first-Storm secret dials of six move the real Storm from1 to13. Hagga Basin13 is within two territories of Great Flat15. After actual Spice/Charity/auction/Revival, Fremen really ships all ten ordinary native reserves there free using explicit Homeworld sources. Native stormProtection is allowed; its exact half-loss sends five ordinary counters to Tanks. The five survivors remain at13 and the three Fedaykin remain safely atTabr14.',
'Real remaining Movement, peaceful Battle, Collection, Mentat, second Storm/Spice/Charity/Bidding reach clean turn-two Revival. Southern Hemisphere stays LOW0 with five actual ordinary Tanks. Original first/next Storm and Homeworld history are retained. The source stops before any four-free request or special Karama, with the original printed low rate and all quotas unused.',
'The caller owns proactive special prevention, the original reactive revivalStop, its explicit decline, all native income responses and any original Ghola action. The source introduces no low-bonus-only return or phase bypass.',
],
};
}
export type HomeworldRevivalPreventionFixture = ReturnType<typeof createHomeworldRevivalPreventionFixture>;
export function requestHomeworldRevivalPrevention(source: Game, fixture: HomeworldRevivalPreventionFixture,
actions: HomeworldRevivalPreventionStep[] = []) {
const before = structuredClone(source), start = actions.length;
const game = actHomeworldRevivalPrevention(source, fixture.target, fixture.request, actions);
assert.equal(game.decision?.kind, 'revivalStop');
return { game, before, after: structuredClone(game), steps: structuredClone(actions.slice(start)) };
}
export function allowHomeworldRevivalPrevention(source: Game, fixture: HomeworldRevivalPreventionFixture,
actions: HomeworldRevivalPreventionStep[] = []) {
const before = structuredClone(source), start = actions.length;
assert.equal(source.decision?.kind, 'revivalStop'); assert.equal(source.decision?.player, fixture.owner);
let game = actHomeworldRevivalPrevention(source, fixture.owner, { type: 'decision', decline: true }, actions);
game = advanceHomeworldRevivalPrevention(game, homeworldRevivalPreventionClean, actions);
return { game, before, after: structuredClone(game), steps: structuredClone(actions.slice(start)) };
}
+28 -20
View File
@@ -360,37 +360,45 @@ void test('actual high Tleilax phase opening permits one ordinary free-income pa
inventory(g);
});
void test('every AI respects the projected low-Homeworld special-Karama boundary and can allow the saved revival', () => {
void test('controlled Low Homeworld keeps its rate while human and minimal policies can prevent the whole return', () => {
let g = setup(['fremen', 'tleilaxu', 'atreides'], true);
position(g, 'fremen', 2, 12);
position(g, 'tleilaxu', 12, 0);
// Advanced identity is established before genuine setup history is signed;
// public Advanced/Homeworld starts remain independently gated.
// Controlled later army/card allocation; the separate native fixture earns
// its actual Low population and Tanks through setup/shipment/Storm losses.
g = enterRevival(g);
const card = hold(g, 'tleilaxu', 'karama');
g = applyAction(g, 'fremen', { type: 'revive', amount: 1, elite: 0 });
assert.equal(g.decision?.kind, 'revivalStop');
const before = reload(g);
assert.match(
viewGame(g, 'tleilaxu').revival.specialKaramaBlock!,
/revival-overlap ruling/,
);
rejected(g, 'tleilaxu', { type: 'card', card, mode: 'special' });
const offer = viewGame(g, 'tleilaxu').tleilaxuSpecialKarama!;
assert.equal(offer.blocked, null);
assert.deepEqual(offer.targets, [{ player: 'fremen', blocked: null }]);
assert.equal(viewGame(g, 'tleilaxu').revival.specialKaramaBlock, null);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(reload(g), 'tleilaxu');
view.players.find((player) => player.id === 'tleilaxu')!.bot = difficulty;
const candidates = botActions(view);
assert.deepEqual(candidates, [{ type: 'decision', decline: true }]);
const allowed = applyAction(reload(g), 'tleilaxu', candidates[0]);
assert.equal(p(allowed, 'fremen').reserves, 3);
assert.equal(p(allowed, 'fremen').tanks, 11);
assert.equal(p(allowed, 'tleilaxu').specialKaramaUsed ?? false, false);
assert.ok(p(allowed, 'tleilaxu').hand.some((held) => held.id === card));
assert.equal(
viewGame(allowed, 'tleilaxu').revival.specialKaramaBlock,
null,
);
inventory(allowed);
const action = botActions(view)[0];
assert.deepEqual(action, { type: 'card', card, mode: 'special', target: 'fremen' });
const stopped = applyAction(reload(g), 'tleilaxu', action);
assert.equal(stopped.pendingRevival ?? null, null);
assert.equal(p(stopped, 'fremen').reserves, 2);
assert.equal(p(stopped, 'fremen').tanks, 12);
assert.equal(p(stopped, 'fremen').revived, 0);
assert.equal(p(stopped, 'fremen').freeForcesRevived, 0);
assert.equal(p(stopped, 'tleilaxu').specialKaramaUsed, true);
assert.equal(p(stopped, 'tleilaxu').spice, p(before, 'tleilaxu').spice);
assert.ok(!p(stopped, 'tleilaxu').hand.some(held => held.id === card));
assert.equal(stopped.discard.filter(held => held.id === card).length, 1);
const recipient = viewGame(stopped, 'fremen');
assert.equal(recipient.revival.prevented, true);
assert.equal(recipient.revival.freeRemaining, 4);
rejected(stopped, 'fremen', { type: 'revive', amount: 1, elite: 0 });
inventory(stopped);
}
const allowed = applyAction(reload(g), 'tleilaxu', { type: 'decision', decline: true });
assert.equal(p(allowed, 'fremen').reserves, 3);
assert.equal(p(allowed, 'fremen').tanks, 11);
assert.ok(p(allowed, 'tleilaxu').hand.some(held => held.id === card));
assert.deepEqual(g, before);
});
@@ -0,0 +1,141 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { freeRevivalRate } from '../game/revival';
import {
createHomeworldRevivalPreventionFixture as fixture,
actHomeworldRevivalPrevention as act,
requestHomeworldRevivalPrevention as request,
allowHomeworldRevivalPrevention as allow,
advanceHomeworldRevivalPrevention as advance,
finishHomeworldRevivalPrevention as finish,
homeworldRevivalPreventionPlayer as player,
homeworldRevivalPreventionClean as clean,
homeworldRevivalPreventionInventory as inventory,
homeworldRevivalPreventionPopulation as population,
} from './fixture-homeworld-revival-prevention';
function reject(game: Game, actor: string, action: Action, reason?: RegExp) {
const before = structuredClone(game);
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
else assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('native LOW Fremen stays an eligible quiet special-Karama target and retains the printed four-free rate', () => {
const f = fixture(), before = structuredClone(player(f.game, f.target)), owner = structuredClone(player(f.game, f.owner));
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4);
assert.equal(population(f.game).population, 0); assert.equal(population(f.game).side, 'low');
assert.equal(before.tanks, 5); assert.equal(before.elites?.tanks, 0);
assert.deepEqual(before.elites?.forces, { 'sietch_tabr:14': 3 });
assert.equal(f.stormProtection.response?.kind, 'stormProtection');
assert.equal(f.stormProtection.response?.amount, 10);
assert.equal(player(f.afterLoss, f.target).forces[f.location], 5);
const view = viewGame(f.game, f.owner), offer = view.tleilaxuSpecialKarama;
assert.equal(offer?.blocked, null); assert.equal(offer?.pending, false);
assert.deepEqual(offer?.cards, [f.karama]);
assert.equal(offer?.targets.find(choice => choice.player === f.target)?.blocked, null);
assert.equal(viewGame(f.game, f.target).revival.specialKaramaBlock, null);
assert.equal(freeRevivalRate(f.game, before), 4);
const stopped = act(f.game, f.owner, f.special), after = player(stopped, f.target);
assert.deepEqual(after, before, 'Preventing the event returns neither the ordinary three nor the independent low increment');
assert.equal(player(stopped, f.owner).spice, owner.spice);
assert.equal(player(stopped, f.owner).specialKaramaUsed, true);
assert.equal(player(stopped, f.owner).hand.some(card => card.id === f.karama), false);
assert.equal(stopped.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(stopped.revivalPrevention, { player: f.target, turn: stopped.turn });
assert.equal(stopped.pendingRevival ?? null, null);
assert.deepEqual(stopped.homeworldRevivalReturn, f.game.homeworldRevivalReturn);
assert.notEqual(stopped.revivalFreeIncome?.[f.target], stopped.turn);
assert.equal(freeRevivalRate(stopped, after), 4);
assert.equal(viewGame(stopped, f.target).revival.homeworldBonus, 1);
assert.equal(viewGame(stopped, f.target).revival.prevented, true);
reject(stopped, f.target, f.request, /prevented/);
reject(stopped, f.owner, f.special);
assert.deepEqual(inventory(stopped), f.originalInventory);
assert.equal(f.sourceActionCount, f.actions.length);
});
void test('the original reactive revivalStop may prevent all four requested free counters before any return, quota or income', () => {
const f = fixture(), before = structuredClone(player(f.game, f.target)), ownerSpice = player(f.game, f.owner).spice;
const pending = request(f.game, f).game;
assert.equal(pending.decision?.kind, 'revivalStop'); assert.equal(pending.decision?.player, f.owner);
assert.equal(pending.pendingRevival?.player, f.target);
assert.equal(pending.pendingRevival?.amount, 4); assert.equal(pending.pendingRevival?.free, 4);
assert.equal(pending.pendingRevival?.cost, 0);
assert.deepEqual(player(pending, f.target), before);
const offer = viewGame(pending, f.owner).tleilaxuSpecialKarama;
assert.equal(offer?.blocked, null); assert.equal(offer?.pending, true);
assert.deepEqual(offer?.targets, [{ player: f.target, blocked: null }]);
const stopped = act(pending, f.owner, f.special);
assert.equal(stopped.pendingRevival ?? null, null); assert.equal(stopped.decision, null);
assert.deepEqual(player(stopped, f.target), before);
assert.equal(player(stopped, f.owner).spice, ownerSpice);
assert.equal(player(stopped, f.owner).specialKaramaUsed, true);
assert.equal(stopped.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(stopped.homeworldRevivalReturn, f.game.homeworldRevivalReturn);
assert.notEqual(stopped.revivalFreeIncome?.[f.target], stopped.turn);
assert.equal(freeRevivalRate(stopped, player(stopped, f.target)), 4);
reject(stopped, f.target, f.request, /prevented/);
assert.deepEqual(inventory(stopped), f.originalInventory);
});
void test('declining that original counter really returns four free normals and pays the separate native free-revival income', () => {
const f = fixture(), before = structuredClone(player(f.game, f.target)), ownerSpice = player(f.game, f.owner).spice;
const pending = request(f.game, f).game;
const allowed = allow(pending, f), after = player(allowed.game, f.target);
assert.equal(after.reserves, 4); assert.equal(after.tanks, 1);
assert.equal(after.spice, before.spice); assert.equal(after.revived, 4); assert.equal(after.freeForcesRevived, 4);
assert.deepEqual(after.forces, before.forces); assert.deepEqual(after.elites, before.elites);
assert.equal(population(allowed.game).side, 'high');
assert.equal(player(allowed.game, f.owner).spice, ownerSpice + 1);
assert.ok(!player(allowed.game, f.owner).specialKaramaUsed);
assert.ok(player(allowed.game, f.owner).hand.some(card => card.id === f.karama));
assert.equal(allowed.game.pendingRevival, null); assert.ok(clean(allowed.game));
assert.equal(allowed.game.revivalFreeIncome?.[f.target], allowed.game.turn);
assert.deepEqual(inventory(allowed.game), f.originalInventory);
});
void test('original Ghola stays blocked during the prevented Revival but works through real readiness after phase4', () => {
const f = fixture(); assert.ok(f.ghola && f.gholaAction);
const stopped = act(f.game, f.owner, f.special), before = structuredClone(player(stopped, f.target));
assert.ok(before.hand.some(card => card.id === f.ghola));
reject(stopped, f.target, f.gholaAction, /prevented.*Ghola/);
assert.equal(viewGame(stopped, f.target).ghola?.available, false);
assert.ok(player(stopped, f.target).hand.some(card => card.id === f.ghola));
const movement = finish(stopped);
assert.equal(movement.turn, 2); assert.equal(movement.phase, 5);
assert.equal(player(movement, f.target).reserves, 0); assert.equal(player(movement, f.target).tanks, 5);
const requested = act(movement, f.target, f.gholaAction);
const completed = advance(requested, clean), after = player(completed, f.target);
assert.equal(after.reserves, 5); assert.equal(after.tanks, 0);
assert.equal(after.spice, before.spice); assert.equal(after.revived, before.revived);
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
assert.deepEqual(after.forces, before.forces); assert.deepEqual(after.elites, before.elites);
assert.equal(after.hand.some(card => card.id === f.ghola), false);
assert.equal(completed.discard.filter(card => card.id === f.ghola).length, 1);
assert.deepEqual(inventory(completed), f.originalInventory);
});
void test('all four existing profiles legally answer the genuine five-Tank reactive stop without changing their thresholds', () => {
const f = fixture(), pending = request(f.game, f).game;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action);
const answered = act(structuredClone(pending), f.owner, action);
const completed = advance(answered, clean), after = player(completed, f.target);
if (action.type === 'card') {
assert.equal(after.reserves, 0); assert.equal(after.tanks, 5); assert.equal(after.revived, 0);
assert.equal(freeRevivalRate(completed, after), 4);
assert.equal(player(completed, f.owner).specialKaramaUsed, true);
} else {
assert.equal(action.type, 'decision'); assert.equal(action.decline, true);
assert.equal(after.reserves, 4); assert.equal(after.tanks, 1); assert.equal(after.revived, 4);
assert.ok(player(completed, f.owner).hand.some(card => card.id === f.karama));
}
assert.deepEqual(inventory(completed), f.originalInventory);
assert.equal(finish(completed).phase, 5);
}
});