Announce leaderless Basic battle plans
Verify and publish checkpoint container / container (push) Successful in 10m50s
Verify and publish checkpoint container / container (push) Successful in 10m50s
This commit is contained in:
1 parent
3f4925e6e1
commit
2bac45b294
6 files changed
+221
-3
No files matched your search
@@ -17,6 +17,17 @@ open mode gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
|
||||||
|
**Basic leaderless battle announcement — 29 October. Prototyped / Partial.**
|
||||||
|
The publisher's Basic rules require a player without a usable leader or Cheap
|
||||||
|
Hero to announce it. New Basic plans now make that public when sealed; other
|
||||||
|
plan details stay private until reveal. The five-file related batch passes 31
|
||||||
|
cases, including real Basic battle actions with staged leader availability,
|
||||||
|
Cheap Hero, rendered controls, saved announcement and four minimal bot plans;
|
||||||
|
type checking, lint and production build pass. Already-sealed Basic plans in
|
||||||
|
older saves do not acquire a new announcement. Sealing may occur after the
|
||||||
|
opponent's plan, so earlier announcement timing and deployed acceptance remain
|
||||||
|
open. [Source and scope](RULE_DECISIONS.md#basic-leaderless-battle-announcement--29-october-2026).
|
||||||
|
|
||||||
**Basic Semuta after Ixian ally replacement with independent Ecaz cards —
|
**Basic Semuta after Ixian ally replacement with independent Ecaz cards —
|
||||||
29 October. Prototyped / Partial.** The existing neutral private-discard offer
|
29 October. Prototyped / Partial.** The existing neutral private-discard offer
|
||||||
now covers a supported paid ordinary Ixian ally replacement when the independent
|
now covers a supported paid ordinary Ixian ally replacement when the independent
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Rule decision index
|
# Rule decision index
|
||||||
|
|
||||||
|
## Basic leaderless battle announcement — 29 October 2026
|
||||||
|
|
||||||
|
The [publisher base rulebook, printed p. 10](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
|
||||||
|
requires a player unable to play a leader or Cheap Hero to announce that fact.
|
||||||
|
New Basic leaderless plans now publish the announcement when sealed, using the
|
||||||
|
existing Advanced timing, while the rest of the plan stays private. Sealing is
|
||||||
|
an online interpretation of the announcement timing; it may occur after the
|
||||||
|
other player has already sealed, which remains an open timing limitation. An
|
||||||
|
already-sealed Basic plan from an older save is not retroactively announced.
|
||||||
|
[Runtime](../tests/basic-leaderless-announcement.test.ts) and
|
||||||
|
[controls](../tests/basic-leaderless-announcement-controls.test.tsx).
|
||||||
|
|
||||||
## Basic Semuta after Ixian ally replacement with independent cards — 29 October 2026
|
## Basic Semuta after Ixian ally replacement with independent cards — 29 October 2026
|
||||||
|
|
||||||
The existing private Semuta exchange now applies to the discarded card from a
|
The existing private Semuta exchange now applies to the discarded card from a
|
||||||
|
|||||||
+6
-2
@@ -983,6 +983,8 @@ export type Battle = {
|
|||||||
attacker: string;
|
attacker: string;
|
||||||
defender: string;
|
defender: string;
|
||||||
plans: Record<string, Plan>;
|
plans: Record<string, Plan>;
|
||||||
|
/** Basic declarations recorded only when a new leaderless plan is sealed. */
|
||||||
|
basicLeaderlessAnnouncements?: string[];
|
||||||
revealed: boolean;
|
revealed: boolean;
|
||||||
traitorCalls: Record<string, boolean>;
|
traitorCalls: Record<string, boolean>;
|
||||||
traitorDecisionPromises?: TraitorDecisionPromise[];
|
traitorDecisionPromises?: TraitorDecisionPromise[];
|
||||||
@@ -47147,8 +47149,10 @@ function applyActionInner(
|
|||||||
'Wait for the plan requested by special prescience.',
|
'Wait for the plan requested by special prescience.',
|
||||||
);
|
);
|
||||||
b.plans[id] = validatePlan(g, p, action);
|
b.plans[id] = validatePlan(g, p, action);
|
||||||
if (g.advanced && b.plans[id].leader === null)
|
if (b.plans[id].leader === null) {
|
||||||
|
if (!g.advanced) (b.basicLeaderlessAnnouncements ??= []).push(id);
|
||||||
log(g, `${p.name} announced a Battle Plan without a leader or Cheap Hero; no battle cards can be played.`);
|
log(g, `${p.name} announced a Battle Plan without a leader or Cheap Hero; no battle cards can be played.`);
|
||||||
|
}
|
||||||
if (b.fullPlan?.target === id) {
|
if (b.fullPlan?.target === id) {
|
||||||
recordFullPlanInspection(g, b.fullPlan.owner);
|
recordFullPlanInspection(g, b.fullPlan.owner);
|
||||||
}
|
}
|
||||||
@@ -49029,7 +49033,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
|||||||
submitted: Object.keys(b.plans),
|
submitted: Object.keys(b.plans),
|
||||||
leaderless: g.advanced
|
leaderless: g.advanced
|
||||||
? Object.keys(b.plans).filter(playerId => b.plans[playerId].leader === null)
|
? Object.keys(b.plans).filter(playerId => b.plans[playerId].leader === null)
|
||||||
: [],
|
: (b.basicLeaderlessAnnouncements ?? []).filter(playerId => b.plans[playerId]?.leader === null),
|
||||||
plans: b.revealed
|
plans: b.revealed
|
||||||
? b.plans
|
? b.plans
|
||||||
: b.plans[id]
|
: b.plans[id]
|
||||||
|
|||||||
+1
-1
@@ -2839,7 +2839,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
summary: 'Build a legal combination and decide what to keep after winning.',
|
summary: 'Build a legal combination and decide what to keep after winning.',
|
||||||
steps: [
|
steps: [
|
||||||
'Choose one weapon, one defense, both, or neither. A worthless card can occupy a weapon or defense slot.',
|
'Choose one weapon, one defense, both, or neither. A worthless card can occupy a weapon or defense slot.',
|
||||||
'Choose an available leader or Cheap Hero when required. Without either, battle cards cannot be played. In Advanced, sealing a legal leaderless plan announces that fact to the whole table before reveal; its dial, spice and other choices remain sealed. Refresh retains the same announcement without submitting another plan.',
|
'Choose an available leader or Cheap Hero when required. Without either, battle cards cannot be played. In Basic and Advanced, sealing a legal leaderless plan announces that fact to the whole table before reveal; its dial, spice and other choices remain sealed. Refresh retains the same announcement without submitting another plan.',
|
||||||
'On Arrakis, a surviving leader remains in its battle territory for the rest of the Battle phase. A later Lasgun–Shield explosion there also kills previously surviving leader discs still present, even if a different leader fights that later battle. The Basic application is provisional; a successful Traitor call prevents the explosion.',
|
'On Arrakis, a surviving leader remains in its battle territory for the rest of the Battle phase. A later Lasgun–Shield explosion there also kills previously surviving leader discs still present, even if a different leader fights that later battle. The Basic application is provisional; a successful Traitor call prevents the explosion.',
|
||||||
'A loser normally discards all played cards. If allied with Moritani and the battle has a winner, the defeated ally may retain one played card that a winner could keep. A winner may discard selected played cards; Cheap Heroes are discarded after use.',
|
'A loser normally discards all played cards. If allied with Moritani and the battle has a winner, the defeated ally may retain one played card that a winner could keep. A winner may discard selected played cards; Cheap Heroes are discarded after use.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { registerHooks } from 'node:module';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||||
|
import { createBattleTerritoryExplosionFixture } from './fixture-battle-territory-explosion';
|
||||||
|
|
||||||
|
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();
|
||||||
|
const render = (game: Game, actor: string, busy = false) =>
|
||||||
|
renderToStaticMarkup(createElement(GameTable, {game:viewGame(game, actor), busy,
|
||||||
|
send:async () => { assert.fail('Reading the announcement must not submit an action.'); }, onExit() {}}));
|
||||||
|
|
||||||
|
/** Public Basic setup, native shipments and a completed first battle create
|
||||||
|
* the real second Guild/Emperor fight. Stage only Guild's leader availability
|
||||||
|
* and a conserved Hero hand transfer when needed for the paired control. */
|
||||||
|
function ready(cheapHero: boolean) {
|
||||||
|
const f = createBattleTerritoryExplosionFixture({advanced:false});
|
||||||
|
const game = structuredClone(f.game);
|
||||||
|
assert.equal(game.advanced,false); assert.ok(game.battle && !game.battle.revealed);
|
||||||
|
assert.deepEqual(new Set([game.battle.attacker,game.battle.defender]),new Set(['guild','emperor']));
|
||||||
|
const guild = game.players.find(player => player.id === 'guild'); assert.ok(guild);
|
||||||
|
// These living discs were already used in another territory; no deaths or
|
||||||
|
// replacements are fabricated. The first real battle remains in history.
|
||||||
|
for (const leader of guild.leaders) leader.usedAt = 'carthag';
|
||||||
|
if (cheapHero) {
|
||||||
|
let hero = guild.hand.find(card => card.kind === 'hero');
|
||||||
|
if (!hero) {
|
||||||
|
for (const zone of [game.deck,game.discard,...game.players.filter(player => player.id !== 'guild').map(player => player.hand)]) {
|
||||||
|
const index = zone.findIndex(card => card.kind === 'hero');
|
||||||
|
if (index >= 0) { [hero] = zone.splice(index,1); guild.hand.push(hero); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(hero,'One real physical Cheap Hero must be available');
|
||||||
|
return {game, hero:hero.id};
|
||||||
|
}
|
||||||
|
// A real cheap hero held by Guild would make a null leader illegal; return
|
||||||
|
// any such card to the original deck rather than deleting it.
|
||||||
|
const heroes = guild.hand.filter(card => card.kind === 'hero');
|
||||||
|
guild.hand = guild.hand.filter(card => card.kind !== 'hero');
|
||||||
|
game.deck.push(...heroes);
|
||||||
|
return {game,hero:null};
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('a Basic sealed leaderless plan announces publicly and keeps the plan private across rendering and reload', () => {
|
||||||
|
const f = ready(false);
|
||||||
|
const game = applyAction(f.game,'guild',{type:'battlePlan',dial:1,support:0,leader:null,weapon:null,defense:null});
|
||||||
|
assert.ok(game.battle && !game.battle.revealed);
|
||||||
|
assert.deepEqual(game.battle.basicLeaderlessAnnouncements,['guild']);
|
||||||
|
assert.equal(viewGame(game,'guild').battle!.plans.guild.dial,1);
|
||||||
|
for (const id of ['emperor','atreides']) {
|
||||||
|
const view = viewGame(game,id);
|
||||||
|
assert.deepEqual(view.battle!.leaderless,['guild']);
|
||||||
|
assert.deepEqual(view.battle!.plans,{});
|
||||||
|
assert.deepEqual(view.battle!.cards,[]);
|
||||||
|
for (const busy of [false,true]) {
|
||||||
|
const html = render(game,id,busy);
|
||||||
|
assert.match(html,/Guild announced a Battle Plan without a leader or Cheap Hero\./);
|
||||||
|
assert.match(html,/No battle cards can be played; other plan choices remain sealed\./);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const saved = JSON.stringify(game), restored = JSON.parse(saved) as Game;
|
||||||
|
assert.deepEqual(viewGame(restored,'emperor').battle!.leaderless,['guild']);
|
||||||
|
assert.match(render(restored,'atreides'),/Guild announced a Battle Plan without a leader or Cheap Hero\./);
|
||||||
|
assert.equal(JSON.stringify(restored),saved);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a real Cheap Hero plan does not announce the faction as leaderless', () => {
|
||||||
|
const f = ready(true); assert.ok(f.hero);
|
||||||
|
const game = applyAction(f.game,'guild',{type:'battlePlan',dial:1,support:0,leader:f.hero,weapon:null,defense:null});
|
||||||
|
assert.ok(game.battle && !game.battle.revealed);
|
||||||
|
assert.ok(!game.battle.basicLeaderlessAnnouncements?.includes('guild'));
|
||||||
|
for (const id of ['emperor','atreides']) {
|
||||||
|
const view = viewGame(game,id);
|
||||||
|
assert.deepEqual(view.battle!.leaderless,[]);
|
||||||
|
assert.deepEqual(view.battle!.plans,{});
|
||||||
|
assert.deepEqual(view.battle!.cards,[]);
|
||||||
|
assert.doesNotMatch(render(game,id),/Guild announced a Battle Plan without a leader or Cheap Hero/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import { createBattleTerritoryExplosionFixture } from './fixture-battle-territory-explosion';
|
||||||
|
|
||||||
|
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||||
|
const seat = (game: Game, id: string) => {
|
||||||
|
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
||||||
|
};
|
||||||
|
const announce = (game: Game) => game.log.filter(entry => entry.text.includes('announced a Battle Plan without a leader or Cheap Hero'));
|
||||||
|
const leaderless: Action = {type:'battlePlan', dial:0, support:0, leader:null, weapon:null, defense:null};
|
||||||
|
|
||||||
|
/** Public three-seat Basic source plays both entries and the first battle, then
|
||||||
|
* chooses the real second contest in Imperial Basin. Only the Emperor's five
|
||||||
|
* leader-use locations are staged as previously used in another territory;
|
||||||
|
* the actual second battle, dealt hands and all plans stay native. */
|
||||||
|
function fixture() {
|
||||||
|
const source = createBattleTerritoryExplosionFixture({advanced:false});
|
||||||
|
const game = structuredClone(source.game);
|
||||||
|
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
|
||||||
|
assert.equal(game.phase, 6); assert.ok(game.battle && !game.battle.revealed);
|
||||||
|
assert.deepEqual([game.battle.attacker, game.battle.defender].sort(), ['emperor', 'guild']);
|
||||||
|
assert.equal(seat(game, 'emperor').hand.some(card => card.kind === 'hero'), false);
|
||||||
|
for (const leader of seat(game, 'emperor').leaders) leader.usedAt = 'carthag';
|
||||||
|
return {game, source};
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('Basic actual battle announces the newly sealed leaderless plan without exposing its dial or hand', () => {
|
||||||
|
const f = fixture(), initial = f.game, beforeLogs = announce(initial).length;
|
||||||
|
let game = act(initial, 'emperor', leaderless);
|
||||||
|
assert.equal(game.battle?.revealed, false);
|
||||||
|
assert.deepEqual(game.battle?.basicLeaderlessAnnouncements, ['emperor']);
|
||||||
|
assert.equal(announce(game).length, beforeLogs + 1);
|
||||||
|
for (const viewer of ['guild', 'atreides']) {
|
||||||
|
const battle = viewGame(game, viewer).battle!;
|
||||||
|
assert.deepEqual(battle.leaderless, ['emperor']);
|
||||||
|
assert.deepEqual(battle.plans, {}); assert.deepEqual(battle.cards, []);
|
||||||
|
}
|
||||||
|
assert.equal(viewGame(game, 'emperor').battle!.plans.emperor.dial, 0);
|
||||||
|
const saved = JSON.stringify(game); game = JSON.parse(saved);
|
||||||
|
assert.deepEqual(viewGame(game, 'guild').battle!.leaderless, ['emperor']);
|
||||||
|
assert.deepEqual(viewGame(game, 'atreides').battle!.plans, {});
|
||||||
|
assert.equal(JSON.stringify(game), saved);
|
||||||
|
assert.throws(() => act(game, 'emperor', leaderless));
|
||||||
|
assert.equal(JSON.stringify(game), saved);
|
||||||
|
game = act(game, 'guild', {type:'battlePlan', dial:0, support:0,
|
||||||
|
leader:f.source.guildLeader, weapon:null, defense:null});
|
||||||
|
assert.equal(game.battle?.revealed, true);
|
||||||
|
assert.equal(announce(game).length, beforeLogs + 1);
|
||||||
|
assert.deepEqual(viewGame(game, 'atreides').battle!.leaderless, ['emperor']);
|
||||||
|
assert.equal(viewGame(game, 'atreides').battle!.plans.emperor.dial, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a held Cheap Hero prevents a Basic leaderless plan and its legal use does not announce one', () => {
|
||||||
|
const {game} = fixture();
|
||||||
|
const heroIndex = game.deck.findIndex(card => card.kind === 'hero'); assert.ok(heroIndex >= 0);
|
||||||
|
const [hero] = game.deck.splice(heroIndex, 1); seat(game, 'emperor').hand.push(hero);
|
||||||
|
const before = structuredClone(game), logs = announce(game).length;
|
||||||
|
assert.throws(() => act(game, 'emperor', leaderless));
|
||||||
|
assert.deepEqual(game, before);
|
||||||
|
const planned = act(game, 'emperor', {...leaderless, leader:hero.id});
|
||||||
|
assert.equal(announce(planned).length, logs);
|
||||||
|
assert.deepEqual(viewGame(planned, 'guild').battle!.leaderless, []);
|
||||||
|
assert.deepEqual(viewGame(planned, 'guild').battle!.plans, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('an already-sealed Basic legacy plan receives no synthetic announcement', () => {
|
||||||
|
const {game} = fixture();
|
||||||
|
// Explicit legacy supplement: install the plan format an older save could
|
||||||
|
// contain, without the new announcement receipt or a historical log entry.
|
||||||
|
game.battle!.plans.emperor = {dial:0, support:0, leader:null, weapon:null, defense:null};
|
||||||
|
const saved = JSON.stringify(game);
|
||||||
|
assert.equal(game.battle!.basicLeaderlessAnnouncements, undefined);
|
||||||
|
assert.deepEqual(viewGame(game, 'guild').battle!.leaderless, []);
|
||||||
|
assert.deepEqual(viewGame(game, 'atreides').battle!.plans, {});
|
||||||
|
assert.equal(JSON.stringify(game), saved);
|
||||||
|
const restored: Game = JSON.parse(saved);
|
||||||
|
assert.deepEqual(viewGame(restored, 'guild').battle!.leaderless, []);
|
||||||
|
assert.equal(announce(restored).length, announce(game).length);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('all four minimal bot profiles seal a legal Basic leaderless plan and announce it', () => {
|
||||||
|
const {game} = fixture();
|
||||||
|
for (const difficulty of DIFFICULTIES) {
|
||||||
|
const view = viewGame(game, 'emperor');
|
||||||
|
view.players.find(player => player.id === 'emperor')!.bot = difficulty;
|
||||||
|
const actions = botActions(view).filter(action => action.type === 'battlePlan');
|
||||||
|
assert.ok(actions.length > 0, `${difficulty} must offer a legal plan`);
|
||||||
|
const next = act(structuredClone(game), 'emperor', actions[0]);
|
||||||
|
assert.equal(next.battle!.plans.emperor.leader, null);
|
||||||
|
assert.equal(next.battle!.plans.emperor.weapon, null);
|
||||||
|
assert.equal(next.battle!.plans.emperor.defense, null);
|
||||||
|
assert.deepEqual(viewGame(next, 'guild').battle!.leaderless, ['emperor']);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user