Correct Emperor special Sardaukar revival limits
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user