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

+3 -1
View File
@@ -5,11 +5,13 @@ export function EliteCount({
value,
onChange,
max,
min = 0,
label = 'Elite forces included',
}: {
value: number;
onChange: (value: number) => void;
max: number;
min?: number;
label?: string;
}) {
const id = useId();
@@ -19,7 +21,7 @@ export function EliteCount({
<Input
id={id}
type="number"
min={0}
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
+8 -8
View File
@@ -6327,7 +6327,9 @@ export function GameTable({
<>
<p className="muted">
Once per game: revive up to three forces or one
leader for free.
leader for free. These returns do not use your
ordinary revival allowances, including the normal
one-Sardaukar limit.
</p>
<label htmlFor={`special-revive-${c.id}`}>
Forces to revive
@@ -6346,12 +6348,8 @@ export function GameTable({
<EliteCount
label="Sardaukar to revive"
value={eliteAmount}
max={Math.min(
1,
amount,
me.elites.tanks,
1 - me.elites.revived,
)}
min={Math.max(0, amount - (me.tanks - me.elites.tanks))}
max={Math.min(amount, me.elites.tanks)}
onChange={setEliteAmount}
/>
)}
@@ -6365,7 +6363,9 @@ export function GameTable({
amount,
elite: eliteAmount,
},
!me.tanks,
!Number.isSafeInteger(amount) || amount < 1 || amount > Math.min(3, me.tanks) ||
!Number.isSafeInteger(eliteAmount) || eliteAmount < Math.max(0, amount - (me.tanks - (me.elites?.tanks ?? 0))) ||
eliteAmount > Math.min(amount, me.elites?.tanks ?? 0),
)}
{me.leaders
.filter(
+8
View File
@@ -20,6 +20,14 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Emperor special revival and Sardaukar — 27 October. Prototyped / Partial.**
The Advanced Emperor's special Karama can return up to three actual Sardaukar
without consuming the ordinary one-per-turn elite revival allowance. The
special retains its real card cost, Tanks and native reserve routing; ordinary
and other revival sources keep their separate limits. Existing saved counters
are not reclassified. Wider combinations and final/deployed acceptance remain
open. [Source interpretation](RULE_DECISIONS.md#emperor-special-revival-and-sardaukar-limit--27-october-2026).
**Advanced printed Kull lobby selection — 27 October. Prototyped / Partial.**
The host can explicitly select the existing Advanced printed-Kull preview for
CHOAM and classic factions with the CHOAM and Ix decks and no optional modules.
+8
View File
@@ -1,5 +1,13 @@
# Dune implementation status
## 27 October 2026 — Emperor special revival and Sardaukar
The Advanced Emperor's special Karama may return up to three physical
Sardaukar without using the ordinary one-per-turn elite allowance. Native
Tanks, reserve routing and the one-use card cost remain authoritative; saved
historical counters are not rewritten. Normal and other revival sources retain
their own limits. [Source interpretation](RULE_DECISIONS.md#emperor-special-revival-and-sardaukar-limit--27-october-2026).
## 27 October 2026 — Advanced printed Kull lobby selection
The host can explicitly select the existing Advanced printed-Kull prototype
+15
View File
@@ -1,5 +1,20 @@
# Rule decision index
## Emperor special revival and Sardaukar limit — 27 October 2026
The authorized supplied rulebook, physical p. 31 (printed p. 29), exempts
the Emperor's special Karama revival from turn revival limits. Physical p. 9
(printed p. 7) and p. 31 give ordinary Sardaukar a one-per-turn limit. Apply
the specific exemption to that limit as well: up to three actual Sardaukar may
return through the special power without spending the ordinary elite allowance.
This is a source-composition interpretation, not an official GF9 FAQ. Ordinary
revival, Ghola and allied extra revival retain their existing limits. Existing
saved counters are not retroactively reclassified.
The Homeworld Salusa Secundus low-population penalty prohibits free Sardaukar
revival; its interaction with this special power remains unresolved and is not
certified by this interpretation.
[Runtime evidence](../tests/emperor-special-revival-exemption-runtime.test.ts).
## Sabotage gift overflow — 27 October 2026
Provisionally let a recipient whose hand is full receive Moritani's optional
+2 -5
View File
@@ -1703,11 +1703,8 @@ function policyActions(g: GameView): Action[] {
leader: dead.id,
},
];
const elite = Math.min(
me.elites?.tanks ?? 0,
eliteRevivalRemaining(me, g.advanced),
);
const amount = Math.min(3, me.tanks - (me.elites?.tanks ?? 0) + elite);
const amount = Math.min(3, me.tanks);
const elite = Math.min(amount, me.elites?.tanks ?? 0);
if (amount === 3 || (level === 0 && amount > 0))
return [
{ type: 'card', mode: 'special', card: specialCard.id, amount, elite },
-5
View File
@@ -39214,10 +39214,6 @@ export function prepareSpecialKaramaIntent(
}
const n = integer(action.amount, 1, Math.min(3, p.tanks), 'Forces');
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
requireRule(
elite <= eliteRevivalRemaining(p, g.advanced),
'Only one elite force may be revived per turn.',
);
return { ...base, kind: 'emperorForces', amount: n, elite };
} else if (p.faction === 'harkonnen') {
requireRule(
@@ -39578,7 +39574,6 @@ function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: Spec
p.tanks -= n;
if (p.elites) {
p.elites.tanks -= elite;
p.elites.revived += elite;
}
log(g, `${p.name} used special Karama to revive ${n} forces for free.`);
}
+2 -2
View File
@@ -92,8 +92,8 @@ export const FACTION_RULES: Record<
advanced: [
'Five Sardaukar forces have a printed battle strength of two each, except against Fremen, where they count as ordinary forces.',
'Spice support applies per token. A supported Sardaukar contributes two strength; an unsupported Sardaukar contributes one, except when fighting Fremen.',
'Once per game during Revival, spend Karama to revive up to three of your own forces or one of your own dead leaders for free, separately from normal revival allowances. The one-Sardaukar-per-turn limit still applies.',
'An elite token counts as one force for revival. Only one Sardaukar may be revived per turn.',
'Once per game during Revival, spend Karama to revive up to three of your own forces or one of your own dead leaders for free. These special returns do not use normal revival allowances, including the ordinary one-Sardaukar limit.',
'An elite token counts as one force for ordinary revival. Only one Sardaukar may be ordinarily revived per turn; the Emperor’s special Karama is separate.',
],
},
fremen: {
+2 -2
View File
@@ -4320,7 +4320,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The combat engine now distinguishes ordinary and elite tokens, accepts half-strength dials and seals spice support with the battle plan. The unfinished Advanced preview is available for the six classic factions while other Advanced rules are completed.',
'One spice supports one token at its full strength. Unsupported tokens fight at half strength. Fremen uses full strength without spice; Sardaukar counts as ordinary against Fremen.',
'After resolution, casualties must match both the dial and spice spent. One legal ordinary/elite allocation settles automatically; several legal allocations ask the winner to choose. The losing army is destroyed. Only a traitor winner avoids its committed spice payment.',
'Elite tokens are tracked in reserves, territories and tanks. Normal revival, Ghola and the Emperor’s extra revival all share the limit of one elite revival per faction per turn.',
'Elite tokens are tracked in reserves, territories and tanks. Normal revival, Ghola and the Emperor’s extra allied revival share the limit of one elite revival per faction per turn. The Emperor’s own special Karama revival is separate and does not consume that limit.',
'Atreides privately tracks losses and can add KH to a sealed plan after seven battle losses. The accompanying leader or Cheap Hero gains two strength if it survives, and cannot turn traitor. KH can be used in only one territory per turn.',
'Before sealing plans, the table offers separate Karama responses for doubled elite strength and Fremen’s free spice support. Canceling elite strength makes those tokens fight as ordinary forces for this battle; it does not change their token type. Canceling free support makes Fremen pay spice or fight at half strength. Sardaukar already fights as ordinary against Fremen.',
'The KH cancellation window does not disclose Atreides’ activation status or selected plan. Canceling prevents both its bonus and its traitor protection in this battle.',
@@ -4659,7 +4659,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'After Voice and ordinary Prescience finish, Atreides may spend its Advanced special Karama to inspect either combatant’s entire committed plan, even outside its own battle. Target seals first; only Atreides gets actual dial, leader, physical played cards, support and KH. The provisional supplied-source distinction includes No-Field plans: ordinary dial Prescience is still barred, but the separate special reads the sealed plan without revealing/materializing its marker early. Allies receive no automatic inspection.',
'The inspected plan remains privately visible in Atreides’ action panel while the other combatant prepares a plan. No inspection acknowledgement is required. This also applies when Atreides is outside the battle helping an ally; that ally does not receive the inspection automatically. Both committed plans trigger the public reveal, which ends the private inspection. The inspected plan stays committed. Atreides may retain the power by declining this battle’s offer. A canceled ordinary prescience request does not remove the special power.',
'The after-preparation offer, target-first commitment and private-read-versus-public-No-Field-reveal order remain explicit provisional timing interpretations, not a newly retrieved combined FAQ or certification of complete Karama/expansion interactions.',
'During Revival, Emperor may revive up to three of its own forces or one of its own dead leaders for free. This is separate from normal revival allowances. The shared one-Sardaukar-per-turn limit still applies.',
'During Revival, Emperor may revive up to three of its own forces or one of its own dead leaders for free. These special returns do not use normal revival allowances, including the ordinary one-Sardaukar limit. Only real forces in the Tanks may return.',
'During Bidding, Harkonnen chooses another player and takes up to four unseen cards. Inspect the combined hand, then return exactly the same number of cards. Newly taken cards may be included in the return.',
'Harkonnen may temporarily exceed eight cards during this choice. The return restores normal limits. Other players see the exchange count, but only each hand’s owner sees its cards.',
'If every card in the combined hand must return, the return happens automatically with a brief notice. Larger hands retain their return choice. Your Harkonnen inspection keeps the cards actually drawn privately readable after the exchange and after refresh; this records what you saw then, not the target’s current hand. Older saved forced returns record the cards held before returning them without claiming which were originally drawn.',
@@ -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);
}
});