Enforce Salusa free Sardaukar revival penalty

This commit is contained in:
Quintine committed 2026-10-27 23:01:54 +11:00
1 parent 1f92affea7
commit 533c651b07
24 files changed
+962 -213

No files matched your search

+44 -39
View File
@@ -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);
});
+122
View File
@@ -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)}`);
}
}
});
+178
View File
@@ -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)}`);
}
}
});
+35 -46
View File
@@ -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);
}
});
+35 -40
View File
@@ -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);
}
}
+15 -17
View File
@@ -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);
+31 -31
View File
@@ -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);
});
+135
View File
@@ -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);
});