Correct Emperor special Sardaukar revival limits

This commit is contained in:
Quintine committed 2026-10-27 22:12:36 +11:00
1 parent b750a3d3eb
commit ac4302a8ed
14 files changed
+269 -33

No files matched your search

@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createGame, newPlayer, viewGame, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
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();
/** Controlled projection of a live Advanced Revival turn; runtime tests own
* the actual force deaths, physical revival, and later ordinary allowance. */
function game(tanks: number, eliteTanks: number, alreadyRevived: number): Game {
const game = createGame('EMPERORREVIVALUI', newPlayer('emperor', 'Emperor', 'emperor'), true);
game.players.push(newPlayer('atreides', 'Atreides', 'atreides'));
Object.assign(game, { status: 'playing', phase: 4, turn: 2, order: ['emperor', 'atreides'], active: 'emperor' });
const emperor = game.players[0];
emperor.tanks = tanks;
emperor.reserves = 20 - tanks;
emperor.elites = { tanks: eliteTanks, reserves: 5 - eliteTanks, forces: {}, revived: alreadyRevived };
const deck = baseDeck();
const index = deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0);
emperor.hand = [deck.splice(index, 1)[0]];
game.deck = deck;
return game;
}
function markup(state: Game, seat = 'emperor') {
return renderToStaticMarkup(createElement(GameTable, { game: viewGame(state, seat), busy: false,
send: async () => {}, onExit() {} }));
}
function specialControls(html: string) {
const start = html.indexOf('Once per game: revive up to three forces');
assert.ok(start >= 0, 'The Emperor owns a visible special Karama control');
const end = html.indexOf('Use special Karama · forces', start);
assert.ok(end > start);
return html.slice(start, end + 'Use special Karama · forces'.length + 50);
}
function eliteInput(html: string): string {
const input = html.match(/Sardaukar to revive\s*(<input\b[^>]*>)/)?.[1];
assert.ok(input, 'The actual Sardaukar selector is visible');
return input;
}
void test('Emperor special UI includes available Sardaukar despite an already used ordinary starred allowance', () => {
const html = specialControls(markup(game(3, 3, 1)));
assert.match(html, /ordinary revival allowances, including the normal\s*one-Sardaukar limit/);
assert.match(html, /Forces to revive\s*<input\b[^>]*max="3"/);
const input = eliteInput(html);
assert.match(input, /\bmin="1"/);
assert.match(input, /\bmax="1"/);
assert.match(input, /\bvalue="0"/);
assert.match(html, /disabled=""[^>]*>Use special Karama · forces/,
'The initial zero-elite selection is invalid when only Sardaukar are available');
});
void test('special UI respects physical stocks and does not expose another player’s hand controls', () => {
const mixed = specialControls(markup(game(3, 2, 1)));
const mixedInput = eliteInput(mixed);
assert.match(mixedInput, /\bmin="0"/);
assert.match(mixedInput, /\bmax="1"/);
assert.doesNotMatch(mixed, /disabled=""[^>]*>Use special Karama · forces/);
const noElites = eliteInput(specialControls(markup(game(2, 0, 1))));
assert.match(noElites, /\bmax="0"/);
assert.doesNotMatch(markup(game(3, 3, 1), 'atreides'), /Use special Karama · forces/);
});
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
actAdvancedRevivalOrder as act,
advanceAdvancedRevivalOrder as advance,
advancedRevivalOrderClean as clean,
advancedRevivalOrderInventory as inventory,
advancedRevivalOrderPlayer as player,
type AdvancedRevivalOrderStep,
} from './fixture-advanced-revival-order';
/** A genuine Advanced lobby, native setup, paid shipment and Storm deaths.
* Only original unopened starting-deal and Spice card positions are selected;
* no played hand, force, Tank, revival counter or clock is assigned. */
function source() {
let game = createGame('EMPERORSPECIAL', newPlayer('emperor', 'Emperor', 'emperor'), true, ['ix']);
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
const actions: AdvancedRevivalOrderStep[] = [];
const send = (actor: string, action: Action) => { game = act(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advance(game, until, actions); };
for (const seat of game.players) send(seat.id, { type: 'ready' });
send('emperor', { type: 'start', advancedPreview: true });
assert.equal(game.status, 'setup');
assert.ok(game.players.every(seat => seat.hand.length === 0));
const originalCards = inventory(game);
const index = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0);
[game.deck[0], game.deck[index]] = [game.deck[index], game.deck[0]];
const karama = game.deck[0].id;
reach(state => state.status === 'playing');
assert.ok(player(game, 'emperor').hand.some(card => card.id === karama));
for (let position = 0; position < 4; position++) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(from, 1)[0]);
}
reach(state => state.turn === 1 && state.phase === 5 && state.active === 'emperor' && clean(state));
const beforeShipment = structuredClone(game);
send('emperor', { type: 'ship', territory: 'hagga_basin', sector: 13, amount: 3, elite: 3, allyPayment: 0 });
reach(clean);
assert.equal(player(game, 'emperor').elites?.forces['hagga_basin:13'], 3);
assert.equal(player(beforeShipment, 'emperor').spice - player(game, 'emperor').spice, 6);
reach(state => state.turn === 2 && state.phase === 1);
assert.equal(player(game, 'emperor').tanks, 3);
assert.equal(player(game, 'emperor').elites?.tanks, 3);
assert.equal(player(game, 'emperor').elites?.revived, 0);
reach(state => state.turn === 2 && state.phase === 4 && clean(state) &&
(!state.revivalOrder || state.revivalOrder.remaining[0] === 'emperor'));
assert.deepEqual(inventory(game), originalCards);
return { game, karama, originalCards, actions };
}
function special(game: Game, card: string, amount: number, elite: number): Game {
return act(game, 'emperor', { type: 'card', mode: 'special', card, amount, elite });
}
function ordinary(game: Game): Game {
return advance(act(game, 'emperor', { type: 'revive', amount: 1, elite: 1 }), clean);
}
function reject(game: Game, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, 'emperor', action));
assert.deepEqual(game, before);
}
void test('Advanced Emperor may revive two extra Sardaukar after an ordinary elite revival', () => {
const f = source();
let game = ordinary(f.game);
assert.equal(player(game, 'emperor').elites?.revived, 1);
assert.equal(player(game, 'emperor').elites?.tanks, 2);
const before = structuredClone(player(game, 'emperor'));
game = advance(special(game, f.karama, 2, 2), clean);
assert.equal(player(game, 'emperor').elites?.tanks, 0);
assert.equal(player(game, 'emperor').elites?.reserves, before.elites!.reserves + 2);
assert.equal(player(game, 'emperor').elites?.revived, 1);
assert.equal(player(game, 'emperor').revived, before.revived);
assert.equal(player(game, 'emperor').spice, before.spice);
assert.equal(player(game, 'emperor').specialKaramaUsed, true);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('Advanced Emperor retains ordinary elite allowance after special two-Sardaukar revival', () => {
const f = source();
let game = advance(special(f.game, f.karama, 2, 2), clean);
assert.equal(player(game, 'emperor').elites?.tanks, 1);
assert.equal(player(game, 'emperor').elites?.revived, 0);
assert.equal(player(game, 'emperor').revived, 0);
game = ordinary(game);
assert.equal(player(game, 'emperor').elites?.tanks, 0);
assert.equal(player(game, 'emperor').elites?.revived, 1);
assert.equal(player(game, 'emperor').revived, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('Advanced Emperor may use all three special slots for Sardaukar without exceeding real Tanks', () => {
const f = source();
reject(f.game, { type: 'card', mode: 'special', card: f.karama, amount: 4, elite: 4 });
const game = advance(special(f.game, f.karama, 3, 3), clean);
assert.equal(player(game, 'emperor').tanks, 0);
assert.equal(player(game, 'emperor').elites?.tanks, 0);
assert.equal(player(game, 'emperor').elites?.reserves, 5);
assert.equal(player(game, 'emperor').elites?.revived, 0);
assert.equal(player(game, 'emperor').revived, 0);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('the minimal bot can use the special revival with three actual Sardaukar', () => {
const f = source(), view = viewGame(f.game, 'emperor');
view.players.find(seat => seat.id === 'emperor')!.bot = 'Easy';
const action = botActions(view).find(candidate => candidate.type === 'card' && candidate.mode === 'special');
assert.ok(action);
assert.equal(action.amount, 3);
assert.equal(action.elite, 3);
const game = advance(act(f.game, 'emperor', action), clean);
assert.equal(player(game, 'emperor').elites?.tanks, 0);
assert.equal(player(game, 'emperor').elites?.revived, 0);
assert.deepEqual(inventory(game), f.originalCards);
});
+1 -1
View File
@@ -195,7 +195,7 @@ void test('Emperor force revival commits once after serialization and never duri
executeSpecialKaramaIntent(g, JSON.parse(JSON.stringify(intent)));
assert.equal(g.players[0].tanks, 2);
assert.equal(g.players[0].reserves, 18);
assert.equal(g.players[0].elites!.revived, 1);
assert.equal(g.players[0].elites!.revived, 0);
assert.equal(g.players[0].specialKaramaUsed, true);
assert.equal(g.discard.filter((c) => c.id === intent.card).length, 1);
const after = structuredClone(g);
+7 -5
View File
@@ -267,7 +267,7 @@ void test('special power usage persists across turns and does not disable ordina
card: karmas[1].id,
});
});
void test('Emperor special revival is free, separate from normal quota and preserves the one-elite-per-turn limit', () => {
void test('Emperor special revival is free and separate from the ordinary force and elite quotas', () => {
const before = stock(fixture());
before.players[1].revived = 3;
const spice = before.players[1].spice;
@@ -276,14 +276,16 @@ void test('Emperor special revival is free, separate from normal quota and prese
assert.equal(g.players[1].reserves, 18);
assert.equal(g.players[1].elites!.tanks, 1);
assert.equal(g.players[1].elites!.reserves, 4);
assert.equal(g.players[1].elites!.revived, 1);
assert.equal(g.players[1].elites!.revived, 0);
assert.equal(g.players[1].revived, 3);
assert.equal(g.players[1].spice, spice);
assert.equal(g.response, null);
const already = stock(fixture());
already.players[1].elites!.revived = 1;
assert.throws(() => revive(already), /one elite/);
assert.equal(already.players[1].specialKaramaUsed, undefined);
assert.equal(revive(already).players[1].elites!.revived, 1);
const two = revive(already, { amount: 2, elite: 2 });
assert.equal(two.players[1].elites!.tanks, 0);
assert.equal(two.players[1].elites!.revived, 1);
assert.equal(
revive(already, { amount: 3, elite: 0 }).players[1].elites!.revived,
1,
@@ -313,7 +315,7 @@ void test('Emperor invalid special revivals preserve the card and usage and cann
for (const extra of [
{ amount: 4 },
{ amount: 0 },
{ amount: 2, elite: 2 },
{ amount: 2, elite: 3 },
{ amount: 1, target: 'g' },
]) {
assert.throws(() => revive(g, extra));
+11 -4
View File
@@ -48,13 +48,20 @@ void test('native quiet Revival permits any player before a request and spends t
void test('ready, self and empty-Tanks targets do not narrow quiet declaration permission', () => {
const f = fixture();
const ready = act(f.game, f.observer, { type: 'ready' });
for (const target of [f.owner, f.observer]) {
assert.equal(player(ready, target).tanks, 0);
// Advanced Revival is ordered. Finish the actual current seat instead of
// assuming that Emperor may finish ahead of the public queue.
const first = f.game.revivalOrder?.remaining[0];
assert.ok(first);
assert.ok(f.game.revivalOrder!.remaining.length > 1);
const ready = act(f.game, first, { type: 'ready' });
assert.ok(ready.ready.includes(first));
for (const target of [f.owner, f.observer]) assert.equal(player(ready, target).tanks, 0);
for (const target of new Set([first, f.owner, f.observer])) {
const tanks = player(ready, target).tanks;
const chosen = act(ready, f.owner, { ...f.special, target });
assert.equal(chosen.revivalPrevention?.player, target);
assert.deepEqual(chosen.ready, ready.ready);
assert.equal(player(chosen, target).tanks, 0);
assert.equal(player(chosen, target).tanks, tanks);
}
});