Enforce Salusa free Sardaukar revival penalty
This commit is contained in:
1 parent
1f92affea7
commit
533c651b07
24 files changed
+962
-213
No files matched your search
@@ -16,7 +16,7 @@ import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { casualtyOptions, type Casualties } from '../game/combat';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { homeworldSardaukarGholaBlock } from '../game/homeworld-benefits';
|
||||
import { homeworldSardaukarFreeRevivalBlock } from '../game/homeworld-benefits';
|
||||
|
||||
const emperor = (g: Game) => g.players.find((player) => player.id === 'e')!;
|
||||
const atreides = (g: Game) => g.players.find((player) => player.id === 'a')!;
|
||||
@@ -343,34 +343,40 @@ function gholaThresholdBattle(normal = 1) {
|
||||
return { g, card: card.id };
|
||||
}
|
||||
|
||||
void test('Ghola can raise Salusa before battle commitments and the same battle uses current free Sardaukar support', () => {
|
||||
void test('Low Salusa prohibits free Sardaukar Ghola before battle commitments', () => {
|
||||
const { g: before, card } = gholaThresholdBattle();
|
||||
assert.equal(homeworldSardaukarGholaBlock(before, 'e'), null);
|
||||
assert.equal(viewGame(before, 'e').ghola.eliteRemaining, 1);
|
||||
const done = applyAction(before, 'e', { type: 'card', card, amount: 1, elite: 1 });
|
||||
assert.equal(done.homeworlds!.custody!.salusa!.elite, 2);
|
||||
assert.equal(emperor(done).elites!.tanks, 0);
|
||||
assert.equal(emperor(done).elites!.revived, 1);
|
||||
assert.equal(done.discard.filter((held) => held.id === card).length, 1);
|
||||
assert.equal(viewGame(done, 'e').battle!.ownForces!.eliteFreeSupport, true);
|
||||
assert.equal(viewGame(done, 'e').battle!.ownForces!.elite, 3);
|
||||
assert.equal(emperor(done).spice, 3);
|
||||
const resolved = resolve(done, 6);
|
||||
const reason = homeworldSardaukarFreeRevivalBlock(before, 'e');
|
||||
assert.match(reason!, /prohibits free Sardaukar revival/);
|
||||
assert.equal(viewGame(before, 'e').ghola.eliteBlock, reason);
|
||||
assert.equal(viewGame(before, 'e').ghola.eliteRemaining, 0);
|
||||
const snapshot = structuredClone(before);
|
||||
assert.throws(() => applyAction(before, 'e', { type: 'card', card, amount: 1, elite: 1 }), /prohibits free Sardaukar revival/);
|
||||
assert.deepEqual(before, snapshot);
|
||||
assert.equal(before.discard.filter((held) => held.id === card).length, 0);
|
||||
assert.equal(viewGame(before, 'e').battle!.ownForces!.eliteFreeSupport, undefined);
|
||||
let resolved = resolve(before, 6, 3);
|
||||
if (resolved.decision?.kind === 'battleLosses') {
|
||||
const choice = resolved.decision.options.findIndex(
|
||||
(loss) => loss.normal === 0 && loss.elite === 3,
|
||||
);
|
||||
assert.ok(choice >= 0);
|
||||
resolved = applyAction(reload(resolved), 'e', { type: 'decision', choice });
|
||||
}
|
||||
assert.equal(emperor(resolved).forces['arrakeen:10'], 1);
|
||||
assert.equal(emperor(resolved).spice, 5, 'Only automatic Arrakeen collection adds spice.');
|
||||
assert.equal(resolved.homeworlds!.custody!.salusa!.elite, 2);
|
||||
assert.equal(emperor(resolved).spice, 2, 'The paid battle support leaves only automatic Arrakeen collection.');
|
||||
assert.equal(resolved.homeworlds!.custody!.salusa!.elite, 1);
|
||||
inventory(resolved);
|
||||
});
|
||||
|
||||
void test('the remaining Salusa preview guard protects a sealed paid-star plan until its actual battle completes', () => {
|
||||
void test('the Low Salusa free revival prohibition remains through a sealed plan and completed battle', () => {
|
||||
const { g: before, card } = gholaThresholdBattle();
|
||||
const sealed = applyAction(before, 'e', plan(before, 6, 3));
|
||||
for (const state of [sealed]) {
|
||||
const g = reload(state);
|
||||
const reason = homeworldSardaukarGholaBlock(g, 'e');
|
||||
const reason = homeworldSardaukarFreeRevivalBlock(g, 'e');
|
||||
assert.ok(
|
||||
reason,
|
||||
'The unresolved Salusa timing combination must have an explicit reason.',
|
||||
'The Low Salusa prohibition must have an explicit reason.',
|
||||
);
|
||||
const view = viewGame(g, 'e');
|
||||
assert.equal(view.ghola.eliteRemaining, 0);
|
||||
@@ -406,11 +412,13 @@ void test('the remaining Salusa preview guard protects a sealed paid-star plan u
|
||||
g = applyAction(reload(g), 'e', { type: 'decision', choice });
|
||||
}
|
||||
assert.equal(g.battle, null);
|
||||
assert.equal(homeworldSardaukarGholaBlock(g, 'e'), null);
|
||||
assert.equal(viewGame(g, 'e').ghola.eliteRemaining, 1);
|
||||
g = applyAction(reload(g), 'e', { type: 'card', card, amount: 1, elite: 1 });
|
||||
assert.equal(g.homeworlds!.custody!.salusa!.elite, 2);
|
||||
assert.equal(g.discard.filter((held) => held.id === card).length, 1);
|
||||
assert.match(homeworldSardaukarFreeRevivalBlock(g, 'e')!, /prohibits free Sardaukar revival/);
|
||||
assert.equal(viewGame(g, 'e').ghola.eliteRemaining, 0);
|
||||
const finished = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'e', { type: 'card', card, amount: 1, elite: 1 }), /prohibits free Sardaukar revival/);
|
||||
assert.deepEqual(g, finished);
|
||||
assert.equal(g.homeworlds!.custody!.salusa!.elite, 1);
|
||||
assert.equal(g.discard.filter((held) => held.id === card).length, 0);
|
||||
inventory(g);
|
||||
});
|
||||
|
||||
@@ -455,7 +463,7 @@ void test('the Salusa commitment guard leaves normal-force and eligible-leader G
|
||||
}
|
||||
});
|
||||
|
||||
void test('an opponent-only sealed plan keeps the remaining Salusa timing boundary visible before Emperor submits', () => {
|
||||
void test('an opponent-only sealed plan keeps the Low Salusa free revival prohibition visible before Emperor submits', () => {
|
||||
const fixture = gholaThresholdBattle();
|
||||
const g = applyAction(fixture.g, 'a', {
|
||||
type: 'battlePlan', dial: 0, support: 0, leader: atreides(fixture.g).leaders[0].id,
|
||||
@@ -465,15 +473,15 @@ void test('an opponent-only sealed plan keeps the remaining Salusa timing bounda
|
||||
const view = viewGame(g, 'e');
|
||||
assert.deepEqual(view.battle!.plans, {});
|
||||
assert.equal(view.ghola.eliteRemaining, 0);
|
||||
assert.match(view.ghola.eliteBlock!, /not yet supported/);
|
||||
assert.match(view.ghola.eliteBlock!, /prohibits free Sardaukar revival/);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'e', {
|
||||
type: 'card', card: fixture.card, amount: 1, elite: 1,
|
||||
}), /not yet supported/);
|
||||
}), /prohibits free Sardaukar revival/);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('Salusa Ghola renews an impossible answered Prescience dial before either full plan is sealed', () => {
|
||||
void test('a rejected Low Salusa Ghola leaves an answered Prescience dial binding', () => {
|
||||
const fixture = gholaThresholdBattle(0);
|
||||
const card = fixture.card;
|
||||
let g = fixture.g;
|
||||
@@ -491,19 +499,16 @@ void test('Salusa Ghola renews an impossible answered Prescience dial before eit
|
||||
g = applyAction(g, 'e', { type: 'prescienceAnswer', value: 1 });
|
||||
assert.equal(g.battle!.prescience!.value, 1);
|
||||
assert.deepEqual(g.battle!.plans, {});
|
||||
assert.equal(viewGame(g, 'e').ghola.eliteBlock, null);
|
||||
g = applyAction(g, 'e', { type: 'card', card, amount: 1, elite: 1 });
|
||||
assert.match(viewGame(g, 'e').ghola.eliteBlock!, /prohibits free Sardaukar revival/);
|
||||
const snapshot = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'e', { type: 'card', card, amount: 1, elite: 1 }), /prohibits free Sardaukar revival/);
|
||||
assert.deepEqual(g, snapshot);
|
||||
assert.equal(g.battle!.prescience!.field, 'dial');
|
||||
assert.equal(Object.hasOwn(g.battle!.prescience!, 'value'), false);
|
||||
assert.equal(g.battle!.preparation!.kind, 'prescienceAnswer');
|
||||
assert.equal(g.battle!.preparation!.owner, 'e');
|
||||
assert.throws(() => applyAction(g, 'e', plan(g, 2)), /battle plan is not available/);
|
||||
assert.throws(() => applyAction(g, 'e', { type: 'prescienceAnswer', value: 1 }), /legal battle plan/);
|
||||
g = applyAction(g, 'e', { type: 'prescienceAnswer', value: 2 });
|
||||
const done = resolve(g, 2, 0);
|
||||
assert.equal(g.battle!.prescience!.value, 1);
|
||||
assert.throws(() => applyAction(g, 'e', plan(g, 2)), /battle inspection/);
|
||||
const done = resolve(g, 1);
|
||||
assert.equal(done.lastBattleContext!.winner, 'e');
|
||||
assert.equal(done.homeworlds!.custody!.salusa!.elite, 2);
|
||||
assert.equal(emperor(done).elites!.tanks, 1);
|
||||
assert.equal(done.discard.filter((held) => held.id === card).length, 1);
|
||||
assert.equal(done.homeworlds!.custody!.salusa!.elite, 1);
|
||||
assert.equal(done.discard.filter((held) => held.id === card).length, 0);
|
||||
inventory(done);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createAmbassadors, validateAmbassadors } from '../game/ecaz-ambassadors';
|
||||
import { homeworldRevivalFixture, revivalInventory, revivalPlayer } from './fixture-homeworld-revival';
|
||||
|
||||
/** Real Homeworld setup, with a controlled and conserved phase-5 position,
|
||||
* token placement, alliance, and Emperor casualties. The arrival and Ambassador
|
||||
* entry/acceptance themselves run through the normal engine actions. */
|
||||
function source(stars: 1 | 2, ordinaryTanks: 0 | 2) {
|
||||
let g = homeworldRevivalFixture({ advanced: true, extraSeats: [
|
||||
{ id: 'ec', faction: 'ecaz' }, { id: 'a', faction: 'atreides' },
|
||||
] });
|
||||
g.expansions.push('ecaz');
|
||||
const emperor = revivalPlayer(g, 'e');
|
||||
const deadStars = 5 - stars;
|
||||
emperor.reserves -= deadStars + ordinaryTanks;
|
||||
emperor.tanks += deadStars + ordinaryTanks;
|
||||
emperor.elites!.reserves -= deadStars;
|
||||
emperor.elites!.tanks += deadStars;
|
||||
g.homeworlds!.custody!.salusa!.elite = stars;
|
||||
revivalPlayer(g, 'f').forces = { 'sietch_tabr:14': 12 };
|
||||
revivalPlayer(g, 'ec').ally = 'e';
|
||||
emperor.ally = 'ec';
|
||||
const ambassadors = createAmbassadors(() => 0);
|
||||
const token = ambassadors.tokens.find(candidate => candidate.effect === 'tleilaxu')!;
|
||||
const otherIds = ambassadors.tokens.filter(candidate => candidate.effect !== 'ecaz' && candidate.id !== token.id)
|
||||
.slice(0, 4).map(candidate => candidate.id);
|
||||
ambassadors.cohort = [token.id, ...otherIds];
|
||||
for (const candidate of ambassadors.tokens) {
|
||||
candidate.zone = candidate.effect === 'ecaz' || ambassadors.cohort.includes(candidate.id) ? 'supply' : 'pool';
|
||||
candidate.location = null;
|
||||
}
|
||||
token.zone = 'placed';
|
||||
token.location = 'arrakeen';
|
||||
validateAmbassadors(ambassadors);
|
||||
g.ecazAmbassadors = ambassadors;
|
||||
Object.assign(g, { phase: 5, active: 'a', ready: [], phaseOpening: null, response: null, decision: null });
|
||||
g.movementRemaining = [...g.order];
|
||||
revivalInventory(g);
|
||||
g = applyAction(g, 'a', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 });
|
||||
assert.equal(g.pendingAmbassador?.stage, 'offer');
|
||||
g = applyAction(g, 'ec', { type: 'decision', event: g.pendingAmbassador!.event,
|
||||
trigger: true, beneficiary: 'e', choice: 'effect' });
|
||||
assert.equal(g.pendingAmbassador?.stage, 'revival');
|
||||
assert.equal(g.decision?.player, 'e');
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('Low Salusa blocks a free Ambassador star and returns normal forces to Kaitain', () => {
|
||||
const g = source(1, 2);
|
||||
const offer = viewGame(g, 'e').ambassadorEntry!.revival!;
|
||||
assert.equal(offer.maximum, 2);
|
||||
assert.equal(offer.eliteMaximum, 0);
|
||||
assert.match(offer.eliteBlock!, /Salusa/i);
|
||||
assert.equal(offer.blocked, null);
|
||||
const original = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'e', { type: 'decision', event: g.pendingAmbassador!.event,
|
||||
forces: 1, elite: 1 }), /Salusa/i);
|
||||
assert.deepEqual(g, original);
|
||||
const next = applyAction(g, 'e', { type: 'decision', event: g.pendingAmbassador!.event,
|
||||
forces: 2, elite: 0 });
|
||||
const before = revivalPlayer(g, 'e'), after = revivalPlayer(next, 'e');
|
||||
assert.equal(after.reserves, before.reserves + 2);
|
||||
assert.equal(after.tanks, before.tanks - 2);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks);
|
||||
assert.equal(next.homeworlds!.custody!.salusa!.elite, 1);
|
||||
assert.equal(after.spice, before.spice);
|
||||
revivalInventory(next);
|
||||
});
|
||||
|
||||
void test('High Salusa permits one free Ambassador star and physically returns it to Salusa', () => {
|
||||
const g = source(2, 2);
|
||||
const offer = viewGame(g, 'e').ambassadorEntry!.revival!;
|
||||
assert.equal(offer.maximum, 3);
|
||||
assert.equal(offer.eliteMaximum, 1);
|
||||
assert.equal(offer.eliteBlock, null);
|
||||
const next = applyAction(g, 'e', { type: 'decision', event: g.pendingAmbassador!.event,
|
||||
forces: 2, elite: 1 });
|
||||
const before = revivalPlayer(g, 'e'), after = revivalPlayer(next, 'e');
|
||||
assert.equal(after.reserves, before.reserves + 2);
|
||||
assert.equal(after.tanks, before.tanks - 2);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks - 1);
|
||||
assert.equal(after.elites!.revived, before.elites!.revived + 1);
|
||||
assert.equal(next.homeworlds!.custody!.salusa!.elite, 3);
|
||||
assert.equal(after.spice, before.spice);
|
||||
revivalInventory(next);
|
||||
});
|
||||
|
||||
void test('Low with only stars shows no return button and all bot profiles choose a legal alternative', async () => {
|
||||
const g = source(1, 0);
|
||||
const view = viewGame(g, 'e');
|
||||
assert.equal(view.ambassadorEntry!.revival!.maximum, 0);
|
||||
assert.equal(view.ambassadorEntry!.revival!.eliteMaximum, 0);
|
||||
const hooks = registerHooks({ resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
|
||||
} });
|
||||
const { EcazEntry } = await import('../components/ecaz-entry');
|
||||
hooks.deregister();
|
||||
const markup = renderToStaticMarkup(createElement(EcazEntry, {
|
||||
game: view as unknown as Parameters<typeof EcazEntry>[0]['game'],
|
||||
act: () => {}, busy: false,
|
||||
}));
|
||||
assert.match(markup, /Salusa/);
|
||||
assert.doesNotMatch(markup, /Return 1 force/);
|
||||
assert.match(markup, /Return no forces/);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const choiceView = viewGame(g, 'e');
|
||||
choiceView.players.find(player => player.id === 'e')!.bot = difficulty;
|
||||
const actions = botActions(choiceView);
|
||||
assert.ok(actions.length, difficulty);
|
||||
for (const action of actions) {
|
||||
assert.notEqual(action.elite, 1, difficulty);
|
||||
assert.doesNotThrow(() => applyAction(g, 'e', action), `${difficulty}: ${JSON.stringify(action)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, viewGame, type GameView } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
holdRevivalCard, homeworldRevivalFixture, revivalInventory, revivalPlayer,
|
||||
} from './fixture-homeworld-revival';
|
||||
import { reachAdvancedRevivalOrderActor } from './fixture-advanced-revival-order';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' :
|
||||
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
|
||||
/** Actual Advanced Homeworld setup, then real ordered Charity/Bidding/Revival.
|
||||
* Controlled supplement: move 4 original Sardaukar and 2 normal reserves to
|
||||
* Tanks, leaving one on Salusa; transfer real Ghola/Karama cards from the deck
|
||||
* and stage one native leader dead. No casualty or card purchase is claimed. */
|
||||
function source() {
|
||||
let game = homeworldRevivalFixture({ advanced: true });
|
||||
const p = revivalPlayer(game, 'e');
|
||||
assert.equal(p.reserves, 20);
|
||||
assert.equal(p.elites!.reserves, 5);
|
||||
p.reserves -= 6; p.tanks += 6;
|
||||
p.elites!.reserves -= 4; p.elites!.tanks += 4;
|
||||
game.homeworlds!.custody!.salusa!.elite = 1;
|
||||
const leader = p.leaders[0]; leader.dead = true; leader.deaths = 1;
|
||||
const ghola = holdRevivalCard(game, 'e', 'ghola');
|
||||
const karama = holdRevivalCard(game, 'e', 'karama');
|
||||
revivalInventory(game);
|
||||
game = reachAdvancedRevivalOrderActor(game, 'e');
|
||||
const view = viewGame(game, 'e');
|
||||
assert.ok(view.revival.sardaukarFreeBlock);
|
||||
assert.equal(view.ghola.eliteRemaining, 0);
|
||||
return { game, view, ghola, karama, leader };
|
||||
}
|
||||
const props = (game: GameView) => ({ game, busy: false, send: async () => {}, onExit() {} });
|
||||
const markup = (view: GameView) => renderToStaticMarkup(createElement(GameTable, props(view)));
|
||||
const words = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
|
||||
|
||||
/** Render the real GameTable with the two existing numeric useState selections
|
||||
* controlled for SSR. React's server dispatcher is restored immediately;
|
||||
* this exercises rendered values, not a browser or hydration interaction. */
|
||||
function selectedMarkup(view: GameView, amount: number, elite: number): string {
|
||||
function SelectedTable() {
|
||||
const internals = React as unknown as { __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: {
|
||||
H: { useState: (initial: unknown) => [unknown, unknown] };
|
||||
} };
|
||||
const dispatcher = internals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H;
|
||||
const old = dispatcher.useState;
|
||||
let index = 0;
|
||||
dispatcher.useState = (initial: unknown) => {
|
||||
const pair = old(initial);
|
||||
index++;
|
||||
if (index === 6) return [amount, pair[1]];
|
||||
if (index === 10) return [elite, pair[1]];
|
||||
return pair;
|
||||
};
|
||||
try { return GameTable(props(view)); }
|
||||
finally { dispatcher.useState = old; }
|
||||
}
|
||||
return renderToStaticMarkup(createElement(SelectedTable));
|
||||
}
|
||||
|
||||
void test('actual Low Salusa table shows special-Karama force bounds, reason and native leader alternative', () => {
|
||||
const { game, view, karama, leader } = source();
|
||||
const html = markup(view), start = html.indexOf('Once per game: revive up to three forces');
|
||||
assert.ok(start >= 0);
|
||||
const section = html.slice(start, html.indexOf('</article>', start));
|
||||
assert.ok(section.includes(view.revival.sardaukarFreeBlock!));
|
||||
assert.match(section, new RegExp(`id="special-revive-${karama}"[^>]*max="2"`));
|
||||
assert.match(section, /Sardaukar to revive\s*<input\b[^>]*max="0"/);
|
||||
assert.match(section, /Use special Karama · forces/);
|
||||
assert.ok(words(section).includes(`Revive ${leader.name} with special Karama`));
|
||||
const continued = applyAction(game, 'e', { type: 'card', mode: 'special', card: karama, leader: leader.id });
|
||||
assert.equal(revivalPlayer(continued, 'e').leaders[0].dead, false);
|
||||
});
|
||||
|
||||
void test('actual SSR selected ordinary revival inputs quote solo normal, solo star and mixed groups correctly', () => {
|
||||
const { view } = source();
|
||||
for (const [amount, elite, cost] of [[1, 0, 0], [1, 1, 2], [2, 1, 2]] as const) {
|
||||
const html = selectedMarkup(view, amount, elite), text = words(html);
|
||||
assert.match(html, new RegExp(`id="forces"[^>]*value="${amount}"`));
|
||||
assert.match(html, new RegExp(`Elite forces\\s*<input\\b[^>]*value="${elite}"`));
|
||||
assert.ok(text.includes(`Selected force revival costs ${cost} spice`),
|
||||
`The actual ${amount}/${elite} selector must display ${cost} spice`);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual Ghola inspector explains Low star exclusion while retaining normal and leader targets', () => {
|
||||
const { game, view, ghola, leader } = source();
|
||||
const html = markup(view), start = html.indexOf('<h3>Tleilaxu Ghola</h3>');
|
||||
assert.ok(start >= 0);
|
||||
const section = html.slice(start, html.indexOf('</article>', start));
|
||||
assert.ok(view.ghola.eliteBlock && section.includes(view.ghola.eliteBlock));
|
||||
assert.match(section, new RegExp(`id="ghola-${ghola}"[^>]*max="2"`));
|
||||
assert.match(section, /Elite forces to revive\s*<input\b[^>]*max="0"/);
|
||||
assert.ok(section.includes(leader.name));
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, 'e', { type: 'card', card: ghola, amount: 1, elite: 1 }));
|
||||
assert.deepEqual(game, before);
|
||||
const normal = applyAction(game, 'e', { type: 'card', card: ghola, amount: 1, elite: 0 });
|
||||
assert.equal(revivalPlayer(normal, 'e').tanks, revivalPlayer(game, 'e').tanks - 1);
|
||||
});
|
||||
|
||||
void test('four own-view bots keep legal Low Salusa force and card participation', () => {
|
||||
const { game, karama, ghola } = source();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'e');
|
||||
view.players.find(player => player.id === 'e')!.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.ok(actions.length, `${difficulty} needs a legal continuation`);
|
||||
for (const action of actions) {
|
||||
if (action.type === 'card' && (action.card === karama && action.mode === 'special' || action.card === ghola) && !action.leader)
|
||||
assert.equal(action.elite ?? 0, 0, `${difficulty} cannot freely revive a star`);
|
||||
assert.doesNotThrow(() => applyAction(game, 'e', action), `${difficulty}: ${JSON.stringify(action)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { forceRevivalQuote } from '../game/revival';
|
||||
import {
|
||||
holdRevivalCard, homeworldRevivalFixture,
|
||||
revivalInventory, revivalPlayer,
|
||||
} from './fixture-homeworld-revival';
|
||||
import { reachAdvancedRevivalOrderActor } from './fixture-advanced-revival-order';
|
||||
import {
|
||||
createSalusaGholaFixture, salusaGholaArmy, salusaGholaInventory,
|
||||
} from './fixture-salusa-ghola';
|
||||
|
||||
const emperor = (game: Game) => revivalPlayer(game, 'e');
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
/** Real Advanced Homeworld setup and the existing actual Charity/auction
|
||||
* continuation. The fixture itself stages Charity and Fremen positions. This
|
||||
* supplement explicitly moves conserved Imperial reserves into Tanks: retain
|
||||
* 1 or 2 original stars on Salusa, put the other stars and 2 ordinary forces
|
||||
* into Tanks. It does not claim natural casualties or a played-card purchase. */
|
||||
function source(salusaStars: 1 | 2 = 1) {
|
||||
let game = homeworldRevivalFixture({ advanced: true });
|
||||
const p = emperor(game), deadStars = 5 - salusaStars;
|
||||
assert.equal(p.reserves, 20);
|
||||
assert.equal(p.elites!.reserves, 5);
|
||||
assert.equal(game.homeworlds!.custody!.salusa!.elite, 5);
|
||||
p.reserves -= deadStars + 2;
|
||||
p.tanks += deadStars + 2;
|
||||
p.elites!.reserves -= deadStars;
|
||||
p.elites!.tanks += deadStars;
|
||||
game.homeworlds!.custody!.salusa!.elite = salusaStars;
|
||||
const ghola = holdRevivalCard(game, 'e', 'ghola');
|
||||
const karama = holdRevivalCard(game, 'e', 'karama');
|
||||
revivalInventory(game);
|
||||
game = reachAdvancedRevivalOrderActor(game, 'e');
|
||||
assert.equal(game.phase, 4);
|
||||
assert.ok(!game.revivalOrder || game.revivalOrder.remaining[0] === 'e');
|
||||
revivalInventory(game);
|
||||
return { game, ghola, karama };
|
||||
}
|
||||
|
||||
/** This is a separately controlled legacy-shaped Advanced battleless Revival
|
||||
* with no Homeworld module; the staged physical Tanks match the Low source. */
|
||||
function withoutWorlds() {
|
||||
const player = newPlayer('e', 'Emperor', 'emperor');
|
||||
const game = createGame('SALUSANOWORLD', player, true);
|
||||
game.players.push(newPlayer('f', 'Fremen', 'fremen'));
|
||||
Object.assign(game, { status: 'playing', phase: 4, turn: 2, active: null,
|
||||
order: ['e', 'f'], ready: [], storm: 18, deck: baseDeck() });
|
||||
Object.assign(player, { reserves: 14, tanks: 6, spice: 20,
|
||||
elites: { reserves: 1, tanks: 4, forces: {}, revived: 0 } });
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('low Salusa makes a normal Sardaukar return paid, leaving the free allowance for an ordinary force', () => {
|
||||
const { game: initial } = source();
|
||||
const original = structuredClone(emperor(initial));
|
||||
const view = viewGame(initial, 'e');
|
||||
assert.equal(typeof view.revival.sardaukarFreeBlock, 'string');
|
||||
assert.equal(view.revival.freeRemaining, 1);
|
||||
const quote = forceRevivalQuote(initial, emperor(initial), 1, 1);
|
||||
assert.equal(quote.free, 0);
|
||||
assert.equal(quote.cost, 2);
|
||||
const poor = structuredClone(initial);
|
||||
emperor(poor).spice = 1; // Controlled insufficient-funds boundary.
|
||||
reject(poor, 'e', { type: 'revive', amount: 1, elite: 1 });
|
||||
|
||||
let game = applyAction(reload(initial), 'e', { type: 'revive', amount: 1, elite: 1 });
|
||||
assert.equal(emperor(game).spice, original.spice - 2);
|
||||
assert.equal(emperor(game).freeForcesRevived, 0);
|
||||
assert.equal(emperor(game).revived, 1);
|
||||
assert.equal(emperor(game).elites!.revived, 1);
|
||||
assert.equal(game.homeworlds!.custody!.salusa!.elite, 2);
|
||||
assert.equal(viewGame(game, 'e').revival.sardaukarFreeBlock, null);
|
||||
assert.equal(viewGame(game, 'e').revival.freeRemaining, 1);
|
||||
game = applyAction(reload(game), 'e', { type: 'revive', amount: 1, elite: 0 });
|
||||
assert.equal(emperor(game).spice, original.spice - 2);
|
||||
assert.equal(emperor(game).freeForcesRevived, 1);
|
||||
assert.equal(emperor(game).revived, 2);
|
||||
revivalInventory(game);
|
||||
});
|
||||
|
||||
void test('a mixed Low group spends free allowance on normal forces and pays for its Sardaukar', () => {
|
||||
const { game: initial } = source();
|
||||
const quote = forceRevivalQuote(initial, emperor(initial), 2, 1);
|
||||
assert.equal(quote.free, 1);
|
||||
assert.equal(quote.cost, 2);
|
||||
const before = structuredClone(emperor(initial));
|
||||
const game = applyAction(reload(initial), 'e', { type: 'revive', amount: 2, elite: 1 });
|
||||
assert.equal(emperor(game).spice, before.spice - 2);
|
||||
assert.equal(emperor(game).tanks, before.tanks - 2);
|
||||
assert.equal(emperor(game).elites!.tanks, before.elites!.tanks - 1);
|
||||
assert.equal(emperor(game).freeForcesRevived, 1);
|
||||
assert.equal(emperor(game).revived, 2);
|
||||
assert.equal(game.homeworlds!.custody!.salusa!.elite, 2);
|
||||
revivalInventory(game);
|
||||
});
|
||||
|
||||
void test('actual native Low Salusa battle cannot revive its earned dead Sardaukar with Ghola', () => {
|
||||
const f = createSalusaGholaFixture();
|
||||
assert.deepEqual(salusaGholaArmy(f.game), { normal: 0, elite: 1 });
|
||||
assert.equal(f.game.players.find(player => player.id === f.actor)!.elites!.tanks, 1);
|
||||
const view = viewGame(f.game, f.actor);
|
||||
assert.equal(view.ghola.eliteRemaining, 0);
|
||||
assert.equal(typeof view.ghola.eliteBlock, 'string');
|
||||
reject(f.game, f.actor, f.action);
|
||||
assert.ok(f.game.players.find(player => player.id === f.actor)!.hand.some(card => card.id === f.card));
|
||||
assert.deepEqual(salusaGholaInventory(f.game), f.original);
|
||||
});
|
||||
|
||||
void test('Low Ghola and Emperor special reject free stars while retaining normal-force alternatives', () => {
|
||||
const { game: initial, ghola, karama } = source();
|
||||
reject(initial, 'e', { type: 'card', card: ghola, amount: 1, elite: 1 });
|
||||
reject(initial, 'e', { type: 'card', mode: 'special', card: karama, amount: 1, elite: 1 });
|
||||
assert.notEqual(emperor(initial).specialKaramaUsed, true);
|
||||
assert.equal(initial.discard.some(card => card.id === karama || card.id === ghola), false);
|
||||
const gholaNormal = applyAction(reload(initial), 'e', { type: 'card', card: ghola, amount: 1, elite: 0 });
|
||||
assert.equal(emperor(gholaNormal).tanks, emperor(initial).tanks - 1);
|
||||
assert.equal(emperor(gholaNormal).elites!.tanks, emperor(initial).elites!.tanks);
|
||||
assert.equal(gholaNormal.discard.filter(card => card.id === ghola).length, 1);
|
||||
const specialNormal = applyAction(reload(initial), 'e', { type: 'card', mode: 'special', card: karama, amount: 1, elite: 0 });
|
||||
assert.equal(emperor(specialNormal).tanks, emperor(initial).tanks - 1);
|
||||
assert.equal(emperor(specialNormal).elites!.tanks, emperor(initial).elites!.tanks);
|
||||
assert.equal(emperor(specialNormal).specialKaramaUsed, true);
|
||||
assert.equal(specialNormal.discard.filter(card => card.id === karama).length, 1);
|
||||
revivalInventory(gholaNormal);
|
||||
revivalInventory(specialNormal);
|
||||
});
|
||||
|
||||
void test('Salusa at the high threshold permits free Ghola and three-star special revival again', () => {
|
||||
const { game: initial, ghola, karama } = source(2);
|
||||
assert.equal(viewGame(initial, 'e').revival.sardaukarFreeBlock, null);
|
||||
assert.equal(forceRevivalQuote(initial, emperor(initial), 1, 1).cost, 0);
|
||||
const gholaGame = applyAction(reload(initial), 'e', { type: 'card', card: ghola, amount: 1, elite: 1 });
|
||||
assert.equal(gholaGame.homeworlds!.custody!.salusa!.elite, 3);
|
||||
const specialGame = applyAction(reload(initial), 'e', { type: 'card', mode: 'special', card: karama, amount: 3, elite: 3 });
|
||||
assert.equal(specialGame.homeworlds!.custody!.salusa!.elite, 5);
|
||||
assert.equal(emperor(specialGame).elites!.revived, 0);
|
||||
assert.equal(emperor(specialGame).specialKaramaUsed, true);
|
||||
revivalInventory(gholaGame);
|
||||
revivalInventory(specialGame);
|
||||
});
|
||||
|
||||
void test('legacy Advanced games without Homeworlds retain a free normal Sardaukar return', () => {
|
||||
const initial = withoutWorlds();
|
||||
assert.equal(viewGame(initial, 'e').revival.sardaukarFreeBlock, null);
|
||||
assert.equal(forceRevivalQuote(initial, emperor(initial), 1, 1).free, 1);
|
||||
const game = applyAction(initial, 'e', { type: 'revive', amount: 1, elite: 1 });
|
||||
assert.equal(emperor(game).spice, 20);
|
||||
assert.equal(emperor(game).freeForcesRevived, 1);
|
||||
assert.equal(emperor(game).elites!.reserves, 2);
|
||||
assert.equal(game.homeworlds, undefined);
|
||||
});
|
||||
|
||||
void test('all minimal bot profiles use only legal Low revival and Ghola proposals', () => {
|
||||
const { game } = source();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'e');
|
||||
view.players.find(player => player.id === 'e')!.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.ok(actions.length, `${difficulty} must have a legal continuation`);
|
||||
for (const action of actions) {
|
||||
if (action.type === 'card' && (action.mode === 'special' ||
|
||||
view.ghola.cards.includes(String(action.card))))
|
||||
assert.equal(action.elite ?? 0, 0, `${difficulty} cannot propose a free star`);
|
||||
assert.doesNotThrow(() => applyAction(game, 'e', action), `${difficulty}: ${JSON.stringify(action)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
createSalusaGholaFixture, playSalusaGholaBattle, actSalusaGhola as act,
|
||||
createSalusaGholaFixture, actSalusaGhola as act,
|
||||
advanceSalusaGhola as advance, salusaGholaClean as clean,
|
||||
salusaGholaArmy as army, salusaGholaPlayer as p, salusaGholaInventory as inventory,
|
||||
type SalusaGholaFixture,
|
||||
@@ -28,8 +28,8 @@ function leader(game: Game, actor: string, strongest: boolean) {
|
||||
choices.sort((a,b) => strongest ? b.strength-a.strength : a.strength-b.strength);
|
||||
assert.ok(choices[0]); return choices[0].id;
|
||||
}
|
||||
function finish(f: SalusaGholaFixture, state: Game) {
|
||||
let game = act(state,f.actor,{type:'battlePlan',leader:leader(state,f.actor,true),dial:2,support:0});
|
||||
function finish(f: SalusaGholaFixture, state: Game, dial: 1|2) {
|
||||
let game = act(state,f.actor,{type:'battlePlan',leader:leader(state,f.actor,true),dial,support:0});
|
||||
game = act(game,f.opponent,{type:'battlePlan',leader:leader(game,f.opponent,false),dial:0,support:0});
|
||||
return advance(game,g => !g.battle && clean(g));
|
||||
}
|
||||
@@ -61,65 +61,54 @@ void test('earned Basin-first source answers real Atreides dial Prescience befor
|
||||
assert.deepEqual(inventory(f.game),f.original);
|
||||
});
|
||||
|
||||
void test('actual Ghola invalidates an impossible old odd dial and requires owned legal reanswer before native plans', () => {
|
||||
void test('Low Salusa rejection preserves the original native odd-dial Prescience answer', () => {
|
||||
const f = source(1);
|
||||
let game = act(structuredClone(f.game),f.actor,f.action);
|
||||
assert.deepEqual(army(game),{normal:0,elite:2});
|
||||
let game = structuredClone(f.game);
|
||||
reject(game,f.actor,f.action);
|
||||
assert.deepEqual(army(game),{normal:0,elite:1});
|
||||
assert.equal(game.battle!.event,f.game.battle!.event);
|
||||
assert.equal(game.battle!.prescience!.field,'dial');
|
||||
assert.equal(game.battle!.prescience!.player,'atreides');
|
||||
assert.equal(Object.hasOwn(game.battle!.prescience!,'value'),false);
|
||||
assert.equal(game.battle!.preparation?.kind,'prescienceAnswer');
|
||||
assert.equal(game.battle!.preparation?.owner,f.actor);
|
||||
assert.equal(game.battle!.preparation?.beneficiary,'atreides');
|
||||
assert.deepEqual(game.battle!.plans,{});
|
||||
assert.equal(viewGame(game,f.actor).battle!.ownForces!.eliteFreeSupport,true);
|
||||
assert.equal(game.discard.filter(c => c.id === f.card).length,1);
|
||||
assert.equal(p(game).elites!.tanks,0);
|
||||
reject(game,f.actor,{type:'battlePlan',leader:leader(game,f.actor,true),dial:1,support:0});
|
||||
reject(game,f.actor,{type:'prescienceAnswer',value:1});
|
||||
reject(game,f.opponent,{type:'prescienceAnswer',value:2});
|
||||
game = act(game,f.actor,{type:'prescienceAnswer',value:2});
|
||||
assert.equal(game.battle!.prescience!.value,2);
|
||||
assert.equal(game.battle!.prescience!.value,1);
|
||||
assert.equal(game.battle!.preparation,undefined);
|
||||
assert.equal(p(game).elites!.tanks,0);
|
||||
game = finish(f,game);
|
||||
assert.equal(game.lastBattleContext!.winner,f.actor);
|
||||
assert.deepEqual(game.battle!.plans,{});
|
||||
assert.equal(viewGame(game,f.actor).battle!.ownForces!.eliteFreeSupport,undefined);
|
||||
assert.equal(game.discard.filter(c => c.id === f.card).length,0);
|
||||
assert.equal(p(game).elites!.tanks,1);
|
||||
assert.deepEqual(army(game),{normal:0,elite:2});
|
||||
assert.equal(game.discard.filter(c => c.id === f.card).length,1);
|
||||
reject(game,f.actor,{type:'battlePlan',leader:leader(game,f.actor,true),dial:2,support:0});
|
||||
game = finish(f,game,1);
|
||||
assert.equal(game.lastBattleContext!.winner,f.actor);
|
||||
assert.equal(p(game).elites!.tanks,2);
|
||||
assert.deepEqual(army(game),{normal:0,elite:1});
|
||||
assert.equal(game.discard.filter(c => c.id === f.card).length,0);
|
||||
assert.deepEqual(inventory(game),f.original);
|
||||
});
|
||||
|
||||
void test('actual Ghola retains a still-feasible native dial2 answer and finishes without another inspection', () => {
|
||||
const f = source(2), result = playSalusaGholaBattle(f);
|
||||
void test('Low Salusa rejection retains a native dial2 answer and finishes without another inspection', () => {
|
||||
const f = source(2), before = structuredClone(f.game);
|
||||
assert.equal(f.game.battle!.prescience!.value,2);
|
||||
assert.equal(result.returned.battle!.prescience!.value,2);
|
||||
assert.equal(result.returned.battle!.preparation,undefined);
|
||||
assert.equal(result.returned.battle!.prescience!.player,'atreides');
|
||||
assert.equal(result.afterPlan.battle!.plans[f.actor].dial,2);
|
||||
assert.equal(result.afterPlan.battle!.plans[f.actor].support,0);
|
||||
assert.equal(result.actions.filter(step => step.action.type === 'prescience' ||
|
||||
step.action.type === 'prescienceAnswer').length,0);
|
||||
assert.equal(result.game.lastBattleContext!.winner,f.actor);
|
||||
assert.equal(result.game.discard.filter(c => c.id === f.card).length,1);
|
||||
assert.deepEqual(inventory(result.game),f.original);
|
||||
reject(before,f.actor,f.action);
|
||||
assert.equal(before.battle!.prescience!.value,2);
|
||||
assert.equal(before.battle!.preparation,undefined);
|
||||
const done = finish(f,before,2);
|
||||
assert.equal(done.lastBattleContext!.winner,f.actor);
|
||||
assert.equal(done.discard.filter(c => c.id === f.card).length,0);
|
||||
assert.deepEqual(inventory(done),f.original);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles can supply a legal current Salusa Prescience reanswer', () => {
|
||||
const f = source(1), pending = act(structuredClone(f.game),f.actor,f.action);
|
||||
void test('all four minimal profiles honor the existing Salusa Prescience answer after rejection', () => {
|
||||
const f = source(1), pending = structuredClone(f.game);
|
||||
reject(pending,f.actor,f.action);
|
||||
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
|
||||
const game = structuredClone(pending), view = viewGame(game,f.actor);
|
||||
view.players.find(p => p.id === f.actor)!.bot = profile;
|
||||
const action = botActions(view).find(action => action.type === 'prescienceAnswer');
|
||||
assert.ok(action,`${profile} needs the actual owned reanswer action.`);
|
||||
assert.notEqual(action.value,1);
|
||||
const action = botActions(view).find(action => action.type === 'battlePlan' && action.dial === 1);
|
||||
assert.ok(action,`${profile} needs a legal plan honoring the actual answered dial.`);
|
||||
const next = act(game,f.actor,action);
|
||||
assert.equal(next.battle!.prescience!.value,action.value);
|
||||
assert.equal(next.battle!.preparation,undefined);
|
||||
assert.deepEqual(next.battle!.plans,{});
|
||||
assert.equal(next.discard.filter(c => c.id === f.card).length,1);
|
||||
assert.deepEqual(army(next),{normal:0,elite:2});
|
||||
assert.equal(next.battle!.prescience!.value,1);
|
||||
assert.equal(next.battle!.plans[f.actor].dial,1);
|
||||
assert.equal(next.discard.filter(c => c.id === f.card).length,0);
|
||||
assert.deepEqual(army(next),{normal:0,elite:1});
|
||||
assert.deepEqual(inventory(next),f.original);
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
createSalusaGholaFixture, playSalusaGholaBattle, actSalusaGhola as act,
|
||||
createSalusaGholaFixture, actSalusaGhola as act,
|
||||
advanceSalusaGhola as advance, salusaGholaClean as clean,
|
||||
salusaGholaPlayer as p, salusaGholaArmy as army,
|
||||
salusaGholaInventory as inventory, type SalusaGholaFixture,
|
||||
} from './fixture-salusa-ghola';
|
||||
@@ -56,58 +57,52 @@ void test('earned public Salusa source has one actual Sardaukar casualty and a l
|
||||
assert.match(f.disclosure,/Original public.*No later phase, force, purse, Tank, hand, battle commitment or receipt is installed/);
|
||||
});
|
||||
|
||||
void test('actual uncommitted-battle Ghola returns the dead Sardaukar to Salusa2 and immediately changes the native support view', () => {
|
||||
void test('earned Low Salusa rejects a free Sardaukar Ghola without changing the physical battle or card', () => {
|
||||
const f = fixture(), before = structuredClone(f.game);
|
||||
reject(before,f.opponent,f.action);
|
||||
const game = act(before,f.actor,f.action), player = p(game);
|
||||
assert.deepEqual(army(game),{normal:0,elite:2});
|
||||
assert.equal(player.tanks,p(before).tanks-1);
|
||||
assert.equal(player.elites!.tanks,p(before).elites!.tanks-1);
|
||||
assert.equal(player.reserves,p(before).reserves+1);
|
||||
assert.equal(player.elites!.reserves,p(before).elites!.reserves+1);
|
||||
assert.equal(player.spice,p(before).spice);
|
||||
assert.deepEqual(player.forces,p(before).forces);
|
||||
assert.deepEqual(player.elites!.forces,p(before).elites!.forces);
|
||||
assert.deepEqual(game.battle!.plans,{});
|
||||
assert.equal(game.battle!.event,before.battle!.event);
|
||||
assert.equal(viewGame(game,f.actor).battle!.ownForces!.eliteFreeSupport,true);
|
||||
assert.equal(viewGame(game,f.actor).battle!.ownForces!.elite,2);
|
||||
assert.equal(game.discard.filter(c => c.id === f.card).length,1);
|
||||
assert.equal(player.hand.some(c => c.id === f.card),false);
|
||||
reject(before,f.actor,f.action);
|
||||
const view = viewGame(before,f.actor);
|
||||
assert.match(view.ghola.eliteBlock!,/Salusa.*free Sardaukar/i);
|
||||
assert.equal(view.ghola.eliteRemaining,0);
|
||||
assert.deepEqual(army(before),{normal:0,elite:1});
|
||||
assert.equal(p(before).elites!.tanks,1);
|
||||
assert.deepEqual(before.battle!.plans,{});
|
||||
assert.equal(view.battle!.ownForces!.eliteFreeSupport,undefined);
|
||||
assert.ok(p(before).hand.some(c => c.id === f.card));
|
||||
assert.equal(before.discard.filter(c => c.id === f.card).length,0);
|
||||
assert.deepEqual(inventory(before),f.original);
|
||||
});
|
||||
|
||||
void test('native battle continues legally after the Low Salusa Ghola rejection', () => {
|
||||
const f = fixture(); let game = structuredClone(f.game);
|
||||
reject(game,f.actor,f.action);
|
||||
const own = p(game,f.actor).leaders.filter(l => !l.dead && !l.usedAt && !l.capturedBy).sort((a,b) => b.strength-a.strength)[0];
|
||||
const enemy = p(game,f.opponent).leaders.filter(l => !l.dead && !l.usedAt && !l.capturedBy).sort((a,b) => a.strength-b.strength)[0];
|
||||
assert.ok(own && enemy);
|
||||
game = act(game,f.actor,{type:'battlePlan',leader:own.id,dial:2,support:0});
|
||||
assert.equal(game.battle!.plans[f.actor].dial,2);
|
||||
game = act(game,f.opponent,{type:'battlePlan',leader:enemy.id,dial:0,support:0});
|
||||
game = advance(game,g => !g.battle && clean(g));
|
||||
assert.equal(game.lastBattleContext!.winner,f.actor);
|
||||
assert.equal(game.lastBattleContext!.territory,f.territory);
|
||||
assert.deepEqual(army(game),{normal:0,elite:1});
|
||||
assert.ok(p(game).hand.some(c => c.id === f.card));
|
||||
assert.equal(game.discard.filter(c => c.id === f.card).length,0);
|
||||
assert.deepEqual(inventory(game),f.original);
|
||||
});
|
||||
|
||||
void test('native plans and calls after Salusa Ghola use zero-spice support and settle actual star casualties', () => {
|
||||
const f = fixture(), result = playSalusaGholaBattle(f);
|
||||
assert.deepEqual(army(result.returned),{normal:0,elite:2});
|
||||
assert.equal(result.afterPlan.battle!.plans[f.actor].support,0);
|
||||
assert.equal(result.afterPlan.battle!.plans[f.actor].dial,2);
|
||||
assert.equal(p(result.afterPlan).spice,p(result.returned).spice);
|
||||
assert.equal(result.game.lastBattleContext!.winner,f.actor);
|
||||
assert.equal(result.game.lastBattleContext!.territory,f.territory);
|
||||
assert.equal(result.game.battle,null);
|
||||
assert.equal(p(result.game).elites!.tanks,1);
|
||||
assert.deepEqual(army(result.game),{normal:0,elite:2});
|
||||
assert.equal(result.actions.filter(s => s.action.type === 'battlePlan').length,2);
|
||||
assert.equal(result.actions.filter(s => s.action.type === 'traitorCall').length,2);
|
||||
assert.equal(result.game.discard.filter(c => c.id === f.card).length,1);
|
||||
assert.deepEqual(inventory(result.game),f.original);
|
||||
});
|
||||
|
||||
void test('all four minimal policies propose legal native plans after the actual Salusa Ghola return', () => {
|
||||
const f = fixture(), returned = act(structuredClone(f.game),f.actor,f.action);
|
||||
void test('all four minimal policies propose legal native plans without a blocked Low Salusa Ghola return', () => {
|
||||
const f = fixture();
|
||||
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
|
||||
const game = structuredClone(returned), view = viewGame(game,f.actor);
|
||||
const game = structuredClone(f.game), view = viewGame(game,f.actor);
|
||||
view.players.find(p => p.id === f.actor)!.bot = profile;
|
||||
const plans = botActions(view).filter(action => action.type === 'battlePlan');
|
||||
assert.ok(plans.length,`${profile} needs a legal current Battle Plan.`);
|
||||
for (const plan of plans) {
|
||||
assert.equal(plan.support ?? 0,0);
|
||||
const next = act(game,f.actor,plan);
|
||||
assert.ok(next.battle!.plans[f.actor]);
|
||||
assert.deepEqual(army(next),{normal:0,elite:2});
|
||||
assert.equal(next.discard.filter(c => c.id === f.card).length,1);
|
||||
assert.deepEqual(army(next),{normal:0,elite:1});
|
||||
assert.ok(p(next).hand.some(c => c.id === f.card));
|
||||
assert.deepEqual(inventory(next),f.original);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,28 +30,27 @@ function ownPromise(game: Game, actor: string) {
|
||||
// These are shared control-adapter and minimal-policy checks over the earned
|
||||
// native source. No Ghola amount selector or Battle Plan form is operated here;
|
||||
// GameTable SSR is covered by the integration owner's separate runtime smoke.
|
||||
void test('the exact owned Ghola group remains available when Salusa High preserves the native Yes dial2 promise', () => {
|
||||
void test('the exact owned Ghola group is blocked at Low Salusa while the native Yes dial2 remains', () => {
|
||||
const f = source('dial2'), game = structuredClone(f.game), view = viewGame(game, f.actor);
|
||||
assert.deepEqual(f.action, { type: 'card', card: f.card, amount: 1, elite: 1 });
|
||||
assert.deepEqual(ownPromise(game, f.actor).claim, f.claim);
|
||||
assert.equal(homeworldRevivalActionBlock(view, f.action), null);
|
||||
assert.equal(view.ghola.available, true); assert.ok(view.ghola.cards.includes(f.card));
|
||||
assert.equal(view.ghola.maxForces, 1); assert.equal(view.ghola.eliteRemaining, 1);
|
||||
const returned = applyAction(game, f.actor, f.action);
|
||||
assert.equal(returned.battle?.event, game.battle?.event);
|
||||
assert.deepEqual(ownPromise(returned, f.actor).claim, f.claim);
|
||||
assert.equal(ownPromise(returned, f.actor).answer, true);
|
||||
assert.equal(viewGame(returned, f.actor).battle?.ownForces?.eliteFreeSupport, true);
|
||||
assert.equal(returned.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(returned.players.find(player => player.id === f.actor)!.elites!.tanks, 0);
|
||||
assert.match(homeworldRevivalActionBlock(view, f.action)!, /Salusa.*free Sardaukar/i);
|
||||
assert.equal(view.ghola.available, false); assert.ok(view.ghola.cards.includes(f.card));
|
||||
assert.equal(view.ghola.maxForces, 0); assert.equal(view.ghola.eliteRemaining, 0);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, f.actor, f.action), /Salusa.*free Sardaukar/i);
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(ownPromise(game, f.actor).answer, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 0);
|
||||
assert.equal(game.players.find(player => player.id === f.actor)!.elites!.tanks, 1);
|
||||
});
|
||||
|
||||
void test('the selected Ghola group block agrees with native rejection for an odd-dial or paid-support Yes promise', () => {
|
||||
void test('the selected Ghola group block agrees with Low Salusa rejection for other Yes promises', () => {
|
||||
for (const kind of ['dial1', 'support'] as const) {
|
||||
const f = source(kind), game = structuredClone(f.game), view = viewGame(game, f.actor);
|
||||
const blocked = homeworldRevivalActionBlock(view, f.action);
|
||||
assert.ok(blocked, `${kind}: the actual selected return needs a visible block.`);
|
||||
assert.match(blocked, /Truthtrance|promise/i);
|
||||
assert.match(blocked, /Salusa.*free Sardaukar/i);
|
||||
assert.ok(view.homeworldRevivalBlocks?.some(row => row.source === 'ghola' &&
|
||||
row.amount === f.action.amount && row.elite === f.action.elite && row.reason === blocked));
|
||||
assert.equal(view.ghola.maxForces, 0); assert.equal(view.ghola.eliteRemaining, 0);
|
||||
@@ -67,23 +66,22 @@ void test('the selected Ghola group block agrees with native rejection for an od
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles omit blocked Ghola and retain legal required plans before and after an allowed return', () => {
|
||||
void test('all four minimal profiles omit blocked Ghola and retain legal required plans', () => {
|
||||
for (const kind of ['dial1', 'support', 'dial2'] as const) {
|
||||
const f = source(kind);
|
||||
const start = kind === 'dial2' ? applyAction(f.game, f.actor, f.action) : f.game;
|
||||
const start = f.game;
|
||||
for (const profile of profiles) {
|
||||
const game = structuredClone(start), view = viewGame(game, f.actor);
|
||||
view.players.find(player => player.id === f.actor)!.bot = profile;
|
||||
const actions = botActions(view);
|
||||
assert.ok(actions.length, `${kind}/${profile}: current legal participation remains available.`);
|
||||
assert.ok(actions.every(action => action.type !== 'card' || action.card !== f.card),
|
||||
`${kind}/${profile}: no blocked or already spent Ghola is offered.`);
|
||||
`${kind}/${profile}: the blocked Ghola is not offered.`);
|
||||
const plans = actions.filter(action => action.type === 'battlePlan');
|
||||
assert.ok(plans.length, `${kind}/${profile}: the live promise has a legal current plan.`);
|
||||
for (const plan of plans) {
|
||||
if (kind === 'support') assert.ok(Number(plan.support) >= 1);
|
||||
else assert.equal(plan.dial, kind === 'dial1' ? 1 : 2);
|
||||
if (kind === 'dial2') assert.equal(plan.support ?? 0, 0);
|
||||
const sealed = applyAction(game, f.actor, plan);
|
||||
assert.ok(sealed.battle?.plans[f.actor]);
|
||||
assert.equal(ownPromise(sealed, f.actor).answer, true);
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createSalusaGholaTruthFixture as fixture, actSalusaGholaTruth as act,
|
||||
createSalusaGholaTruthFixture as fixture,
|
||||
salusaGholaTruthPlayer as p, salusaGholaTruthInventory as inventory,
|
||||
salusaGholaTruthPlan as plan, finishSalusaGholaTruthBattle as finish,
|
||||
type SalusaGholaTruthFixture,
|
||||
@@ -32,7 +32,7 @@ function done(game: Game, f: SalusaGholaTruthFixture, gholaUsed: boolean) {
|
||||
assert.deepEqual(inventory(game),f.base.original);
|
||||
}
|
||||
|
||||
void test('earned native Truth Yes dial2 permits Salusa Ghola and preserves its original binding through the actual plan', () => {
|
||||
void test('earned native Truth Yes dial2 survives Low Salusa Ghola rejection and the actual plan', () => {
|
||||
const f = fixture();
|
||||
assert.equal(p(f.base.afterSetup,f.opponent).hand[0].id,f.truthCard);
|
||||
assert.equal(f.base.beforeDeal.players.every(p => p.hand.length === 0),true);
|
||||
@@ -42,29 +42,31 @@ void test('earned native Truth Yes dial2 permits Salusa Ghola and preserves its
|
||||
assert.equal(f.sourceCount,f.sourceActions.length);
|
||||
assert.deepEqual(f.game.battle!.plans,{});
|
||||
binding(f.game,f);
|
||||
const game = act(structuredClone(f.game),f.actor,f.action);
|
||||
assert.deepEqual(army(game),{normal:0,elite:2});
|
||||
assert.equal(p(game).elites!.tanks,0);
|
||||
const game = structuredClone(f.game);
|
||||
reject(game,f.actor,f.action);
|
||||
assert.deepEqual(army(game),{normal:0,elite:1});
|
||||
assert.equal(p(game).elites!.tanks,1);
|
||||
binding(game,f);
|
||||
const result = finish(f,game);
|
||||
assert.equal(result.afterPlan.battle!.plans[f.actor].dial,2);
|
||||
assert.equal(result.afterPlan.battle!.plans[f.actor].support,0);
|
||||
assert.equal(result.revealed.battle!.truthPromises![0].released,undefined);
|
||||
done(result.game,f,true);
|
||||
done(result.game,f,false);
|
||||
});
|
||||
|
||||
void test('earned native Truth No dial1 remains feasible after Salusa Ghola without releasing its aggregate answer', () => {
|
||||
void test('earned native Truth No dial1 remains feasible after Low Salusa rejection', () => {
|
||||
const f = fixture({claim:{kind:'dial',compare:'eq',value:1},answer:'no'});
|
||||
const game = act(structuredClone(f.game),f.actor,f.action);
|
||||
assert.deepEqual(army(game),{normal:0,elite:2});
|
||||
const game = structuredClone(f.game);
|
||||
reject(game,f.actor,f.action);
|
||||
assert.deepEqual(army(game),{normal:0,elite:1});
|
||||
binding(game,f);
|
||||
const result = finish(f,game,plan(game,2,0));
|
||||
assert.equal(result.revealed.battle!.truthPromises![0].answer,false);
|
||||
assert.equal(result.revealed.battle!.truthPromises![0].released,undefined);
|
||||
done(result.game,f,true);
|
||||
done(result.game,f,false);
|
||||
});
|
||||
|
||||
void test('earned native Truth Yes odd1 rejects Salusa Ghola before cost and can finish dial1 before later printed use', () => {
|
||||
void test('earned native Truth Yes odd1 survives Low Salusa rejection and still prohibits free stars afterward', () => {
|
||||
const f = fixture({claim:{kind:'dial',compare:'eq',value:1}});
|
||||
binding(f.game,f);
|
||||
reject(f.game,f.actor,f.action);
|
||||
@@ -75,10 +77,10 @@ void test('earned native Truth Yes odd1 rejects Salusa Ghola before cost and can
|
||||
const result = finish(f,structuredClone(f.game),plan(f.game,1,0));
|
||||
assert.equal(result.afterPlan.battle!.plans[f.actor].dial,1);
|
||||
done(result.game,f,false);
|
||||
const returned = act(result.game,f.actor,f.action);
|
||||
assert.deepEqual(army(returned),{normal:0,elite:2});
|
||||
assert.equal(returned.discard.filter(c => c.id === f.card).length,1);
|
||||
assert.deepEqual(inventory(returned),f.base.original);
|
||||
reject(result.game,f.actor,f.action);
|
||||
assert.deepEqual(army(result.game),{normal:0,elite:1});
|
||||
assert.equal(result.game.discard.filter(c => c.id === f.card).length,0);
|
||||
assert.deepEqual(inventory(result.game),f.base.original);
|
||||
});
|
||||
|
||||
void test('earned native Truth Yes paid support rejects Salusa Ghola while the original supported plan stays legal', () => {
|
||||
@@ -93,35 +95,33 @@ void test('earned native Truth Yes paid support rejects Salusa Ghola while the o
|
||||
done(result.game,f,false);
|
||||
});
|
||||
|
||||
void test('earned grouped Truth Yes odd1 or even2 keeps its live even alternative after Salusa Ghola', () => {
|
||||
void test('earned grouped Truth Yes odd1 or even2 keeps its live alternative after Low Salusa rejection', () => {
|
||||
const f = fixture({claim:{kind:'or',terms:[
|
||||
{kind:'dial',compare:'eq',value:1},{kind:'dial',compare:'eq',value:2},
|
||||
]}});
|
||||
const game = act(structuredClone(f.game),f.actor,f.action);
|
||||
const game = structuredClone(f.game);
|
||||
reject(game,f.actor,f.action);
|
||||
binding(game,f);
|
||||
assert.deepEqual(army(game),{normal:0,elite:2});
|
||||
assert.deepEqual(army(game),{normal:0,elite:1});
|
||||
const result = finish(f,game,plan(game,2,0));
|
||||
assert.deepEqual(result.revealed.battle!.truthPromises![0].claim,f.claim);
|
||||
assert.equal(result.revealed.battle!.truthPromises![0].released,undefined);
|
||||
done(result.game,f,true);
|
||||
done(result.game,f,false);
|
||||
});
|
||||
|
||||
void test('earned Prescience odd1 and native Truth Yes even2 can renew the inspection through actual Salusa Ghola', () => {
|
||||
const f = fixture({inspectionDial:1});
|
||||
void test('earned Prescience and compatible Truth retain their original dial2 after Low Salusa rejection', () => {
|
||||
const f = fixture({inspectionDial:2});
|
||||
assert.ok(f.base.inspection);
|
||||
assert.equal(f.base.inspection.answered.battle!.prescience!.value,1);
|
||||
assert.equal(f.game.battle!.prescience!.value,1);
|
||||
assert.equal(f.base.inspection.answered.battle!.prescience!.value,2);
|
||||
assert.equal(f.game.battle!.prescience!.value,2);
|
||||
binding(f.game,f);
|
||||
let game = act(structuredClone(f.game),f.actor,f.action);
|
||||
assert.deepEqual(army(game),{normal:0,elite:2});
|
||||
const game = structuredClone(f.game);
|
||||
reject(game,f.actor,f.action);
|
||||
assert.deepEqual(army(game),{normal:0,elite:1});
|
||||
binding(game,f);
|
||||
assert.equal(Object.hasOwn(game.battle!.prescience!,'value'),false);
|
||||
assert.equal(game.battle!.preparation?.kind,'prescienceAnswer');
|
||||
assert.equal(game.battle!.preparation?.owner,f.actor);
|
||||
reject(game,f.actor,{type:'prescienceAnswer',value:1});
|
||||
game = act(game,f.actor,{type:'prescienceAnswer',value:2});
|
||||
assert.equal(game.battle!.prescience!.value,2);
|
||||
assert.equal(game.battle!.preparation,undefined);
|
||||
binding(game,f);
|
||||
const result = finish(f,game,plan(game,2,0));
|
||||
done(result.game,f,true);
|
||||
done(result.game,f,false);
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, executeSpecialKaramaIntent, viewGame,
|
||||
type Action, type Game, type SpecialKaramaIntent,
|
||||
} from '../game/engine';
|
||||
import { quoteRevivalResume } from '../game/revival-resume';
|
||||
import {
|
||||
holdRevivalCard, homeworldRevivalFixture, revivalInventory, revivalPlayer,
|
||||
} from './fixture-homeworld-revival';
|
||||
import { reachAdvancedRevivalOrderActor } from './fixture-advanced-revival-order';
|
||||
|
||||
const p = (game: Game) => revivalPlayer(game, 'e');
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
/** Original Advanced Homeworld setup and native ordered phase progression.
|
||||
* This controlled supplement stages four Imperial stars and two normal forces
|
||||
* in Tanks, leaving Salusa at one, and transfers an actual Karama from the
|
||||
* deck. Its later revivalStop is a real accepted action, not installed. */
|
||||
function source() {
|
||||
let game = homeworldRevivalFixture({ advanced: true, tleilaxu: true });
|
||||
const owner = p(game);
|
||||
assert.equal(owner.reserves, 20);
|
||||
assert.equal(owner.elites!.reserves, 5);
|
||||
owner.reserves -= 6; owner.tanks += 6;
|
||||
owner.elites!.reserves -= 4; owner.elites!.tanks += 4;
|
||||
game.homeworlds!.custody!.salusa!.elite = 1;
|
||||
const card = holdRevivalCard(game, 'e', 'karama');
|
||||
revivalInventory(game);
|
||||
game = reachAdvancedRevivalOrderActor(game, 'e');
|
||||
assert.equal(game.phase, 4);
|
||||
assert.ok(!game.decision && !game.response && !game.phaseOpening);
|
||||
assert.ok(viewGame(game, 'e').revival.sardaukarFreeBlock);
|
||||
return { game, card };
|
||||
}
|
||||
|
||||
function request(game: Game) {
|
||||
const pending = applyAction(game, 'e', { type: 'revive', amount: 1, elite: 1 });
|
||||
assert.equal(pending.decision?.kind, 'revivalStop');
|
||||
assert.equal(pending.decision?.player, 't');
|
||||
assert.ok(pending.pendingRevival);
|
||||
return pending;
|
||||
}
|
||||
function allow(state: Game) {
|
||||
let game = applyAction(reload(state), 't', { type: 'decision', decline: true });
|
||||
for (let n = 0; game.response && n < 20; n++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
|
||||
assert.ok(actor);
|
||||
game = applyAction(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response ?? null, null);
|
||||
assert.equal(game.pendingRevival ?? null, null);
|
||||
revivalInventory(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('unstamped pre-policy pending free-star quote resumes its original accepted terms once', () => {
|
||||
const { game } = source();
|
||||
const accepted = request(game);
|
||||
// Controlled historical receipt: represent an already accepted old-policy
|
||||
// free star by changing only the frozen quote on the real pending stop.
|
||||
// This is not presented as a naturally captured pre-upgrade room.
|
||||
const legacy = reload(accepted), receipt = legacy.pendingRevival!;
|
||||
delete receipt.salusaFreePolicy;
|
||||
receipt.free = 1; receipt.cost = 0; receipt.normalCost = 0;
|
||||
assert.equal(receipt.player, 'e');
|
||||
assert.equal(receipt.kind, 'forces');
|
||||
assert.equal(receipt.amount, 1);
|
||||
assert.equal(receipt.elite, 1);
|
||||
const before = structuredClone(legacy), quote = quoteRevivalResume(legacy, 'finish');
|
||||
assert.equal(quote.outcome, 'revive');
|
||||
assert.deepEqual(quote.pending, receipt);
|
||||
assert.deepEqual(legacy, before);
|
||||
const settled = allow(legacy);
|
||||
assert.equal(p(settled).spice, p(legacy).spice);
|
||||
assert.equal(p(settled).tanks, p(legacy).tanks - 1);
|
||||
assert.equal(p(settled).elites!.tanks, p(legacy).elites!.tanks - 1);
|
||||
assert.equal(p(settled).elites!.revived, 1);
|
||||
assert.equal(p(settled).revived, 1);
|
||||
assert.equal(p(settled).freeForcesRevived, 1);
|
||||
assert.equal(settled.homeworlds!.custody!.salusa!.elite, 2);
|
||||
reject(settled, 't', { type: 'decision', decline: true });
|
||||
});
|
||||
|
||||
void test('new stamped Low pending quote settles the paid star, preserving the free allowance', () => {
|
||||
const { game } = source();
|
||||
const accepted = request(game), receipt = accepted.pendingRevival!;
|
||||
assert.equal(receipt.salusaFreePolicy, 1);
|
||||
assert.equal(receipt.free, 0);
|
||||
assert.equal(receipt.normalCost, 2);
|
||||
assert.equal(receipt.cost, 2);
|
||||
const restored = reload(accepted), before = structuredClone(restored);
|
||||
const quote = quoteRevivalResume(restored, 'finish');
|
||||
assert.equal(quote.outcome, 'revive');
|
||||
assert.deepEqual(quote.pending, receipt);
|
||||
assert.deepEqual(restored, before);
|
||||
const settled = allow(restored);
|
||||
assert.equal(p(settled).spice, p(restored).spice - 2);
|
||||
assert.equal(p(settled).tanks, p(restored).tanks - 1);
|
||||
assert.equal(p(settled).elites!.tanks, p(restored).elites!.tanks - 1);
|
||||
assert.equal(p(settled).elites!.revived, 1);
|
||||
assert.equal(p(settled).revived, 1);
|
||||
assert.equal(p(settled).freeForcesRevived, 0);
|
||||
assert.equal(settled.homeworlds!.custody!.salusa!.elite, 2);
|
||||
assert.equal(viewGame(settled, 'e').revival.freeRemaining, 1);
|
||||
reject(settled, 't', { type: 'decision', decline: true });
|
||||
});
|
||||
|
||||
void test('only an internal pre-policy special intent can finish its already accepted Low star selection', () => {
|
||||
const { game, card } = source();
|
||||
const action: Action = { type: 'card', mode: 'special', card, amount: 1, elite: 1 };
|
||||
reject(game, 'e', action);
|
||||
reject(game, 'e', { ...action, legacySalusaIntent: true });
|
||||
reject(game, 'e', { ...action, salusaFreePolicy: undefined });
|
||||
// The old normalized record simulates an already accepted internal Kull
|
||||
// intent. It is not constructed from a player action and does not claim an
|
||||
// actual Kull/Homeworld production path or captured persisted transaction.
|
||||
const legacy: SpecialKaramaIntent = { kind: 'emperorForces', owner: 'e', card,
|
||||
turn: game.turn, phase: game.phase, amount: 1, elite: 1 };
|
||||
const resumed = reload(game), original = structuredClone(resumed);
|
||||
executeSpecialKaramaIntent(resumed, legacy);
|
||||
assert.equal(p(resumed).tanks, p(original).tanks - 1);
|
||||
assert.equal(p(resumed).elites!.tanks, p(original).elites!.tanks - 1);
|
||||
assert.equal(p(resumed).elites!.revived, p(original).elites!.revived);
|
||||
assert.equal(p(resumed).spice, p(original).spice);
|
||||
assert.equal(p(resumed).specialKaramaUsed, true);
|
||||
assert.equal(resumed.discard.filter(held => held.id === card).length, 1);
|
||||
assert.equal(resumed.homeworlds!.custody!.salusa!.elite, 2);
|
||||
revivalInventory(resumed);
|
||||
});
|
||||
Reference in new issue
Block a user