Add dead leader battle preparation
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
bcca08cac5
commit
e439f3f7b6
8 files changed
+386
-21
No files matched your search
@@ -97,7 +97,10 @@ export function BattleLeaderOpportunity({ game, act, busy }: PreparationProps) {
|
||||
<p>
|
||||
Both combatants have an opportunity to use available preparations before
|
||||
either commits a leader. Continue when you are ready to choose leaders.
|
||||
{' '}A held Juice of Sapho can make you aggressor during this opportunity.
|
||||
{' '}You can use a held Ghola from your hand to revive an eligible leader
|
||||
before continuing.
|
||||
{game.players.some(player => player.faction === 'richese') &&
|
||||
' A held Juice of Sapho can make you aggressor during this opportunity.'}
|
||||
</p>
|
||||
<ul>
|
||||
{combatants.map((player) => (
|
||||
|
||||
+18
-2
@@ -17,6 +17,21 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic dead-leader battle preparation — 29 October. Prototyped / Partial.**
|
||||
Fresh classic Basic battles without Harkonnen open the existing neutral
|
||||
pre-leader opportunity when a combatant has a dead leader and no available
|
||||
living leader. The trigger does not depend on private Hero or Ghola holdings;
|
||||
unaffected opponents are already ready. After battle powers, a still-leaderless
|
||||
player is announced before plans. A later legal Ghola revival corrects the
|
||||
current notice without changing sealed plans. Real battle journeys with staged
|
||||
leader and conserved card state cover mixed and dead leaders, both combatants,
|
||||
Prescience, Ghola before and after readiness, sealed plans and all four legal
|
||||
bot profiles. Eight related files pass 59 cases, including 15 new runtime and
|
||||
control cases; type checking, lint and the production build pass. The ordering
|
||||
is provisional; Harkonnen's concealed captures, other factions and modules,
|
||||
and deployed acceptance remain open.
|
||||
[Scope](RULE_DECISIONS.md#basic-leaderless-battle-announcement--29-october-2026).
|
||||
|
||||
**Basic market Ghola with printed Kull — 29 October. Prototyped / Partial.**
|
||||
The default printed-Kull preview now works with the Semuta market-Ghola
|
||||
reaction. Kull decline, success and a distinct counter preserve the parked
|
||||
@@ -185,8 +200,9 @@ finishes first, no confirmation is added and sealing does not announce twice.
|
||||
Real battle selection with staged prior leader use covers both plan orders,
|
||||
Prescience, Voice and its Karama cancellation, public controls, Cheap Hero,
|
||||
dead-leader exclusions and minimal bots. The related seven-file batch passes
|
||||
38 cases; type checking, lint and the production build pass. Revival-capable and other profiles still announce only
|
||||
at seal, so earlier timing remains open there; deployed acceptance is unverified.
|
||||
38 cases; type checking, lint and the production build pass. The newer
|
||||
dead-leader preparation above extends part of the formerly excluded
|
||||
revival-capable scope; other profiles and deployed acceptance remain open.
|
||||
[Scope](RULE_DECISIONS.md#basic-leaderless-battle-announcement--29-october-2026).
|
||||
|
||||
**Basic leaderless battle announcement — 29 October. Prototyped / Partial.**
|
||||
|
||||
+15
-2
@@ -65,6 +65,16 @@ Great Maker rides share this destination check; Polar Sink remains clear.
|
||||
|
||||
## Basic leaderless battle announcement — 29 October 2026
|
||||
|
||||
In fresh classic Basic games without Harkonnen, a publicly unavailable living
|
||||
leader pool containing at least one dead leader now opens a neutral pre-leader
|
||||
preparation for that combatant, independent of their private hand. An unaffected
|
||||
opponent is already ready. The player can use Ghola from their hand or continue;
|
||||
after battle powers resolve, an inability is announced before either plan. If a
|
||||
later public Ghola revival changes the available leader, the current notice is
|
||||
withdrawn and a correction logged; already sealed plans stay sealed. This is a
|
||||
provisional online timing composition, not a publisher ruling. Captures and other
|
||||
faction/module combinations remain open.
|
||||
|
||||
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.
|
||||
A fresh classic Basic battle without optional modules now announces before plans
|
||||
@@ -73,10 +83,13 @@ Cheap Hero is held. This clean case has no dead leader to revive or private
|
||||
card-transfer preparation. The public notice gives no card identity or plan
|
||||
choice. The same plan does not announce twice. Other cases retain the existing
|
||||
seal-time announcement, which can occur after an opponent has sealed; their
|
||||
earlier timing remains open. Neither a deadline nor an extra confirmation is
|
||||
introduced, and old battles keep their saved behavior. This online ordering is
|
||||
earlier timing remains open except for the newer dead-leader case above. The
|
||||
original no-death case introduces neither a deadline nor an extra confirmation;
|
||||
old battles keep their saved behavior. This online ordering is
|
||||
a provisional interpretation rather than a specific publisher timing ruling.
|
||||
[Early runtime](../tests/basic-early-leaderless.test.ts),
|
||||
[dead-leader runtime](../tests/basic-early-leaderless-dead.test.ts),
|
||||
[dead-leader controls](../tests/basic-early-leaderless-dead-controls.test.tsx),
|
||||
[early controls](../tests/basic-early-leaderless-controls.test.tsx),
|
||||
[Voice cases](../tests/basic-early-leaderless-voice.test.ts),
|
||||
[seal-time runtime](../tests/basic-leaderless-announcement.test.ts) and
|
||||
|
||||
+33
-12
@@ -991,6 +991,8 @@ export type Battle = {
|
||||
/** Fresh clean Basic battles announce a bounded no-revival case before plans. */
|
||||
basicEarlyLeaderlessVersion?: 1;
|
||||
basicEarlyLeaderless?: string[];
|
||||
/** Fresh classic battles with a neutral dead-leader preparation opportunity. */
|
||||
basicDeadLeaderPreparation?: true;
|
||||
revealed: boolean;
|
||||
traitorCalls: Record<string, boolean>;
|
||||
traitorDecisionPromises?: TraitorDecisionPromise[];
|
||||
@@ -43024,9 +43026,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
charityOrderIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
/** The published inability is safe before a plan only when no leader can be
|
||||
* revived and this clean Basic roster cannot privately transfer a Cheap Hero.
|
||||
* Other cases retain the original seal-time announcement. */
|
||||
/** Publish a clean Basic inability once preparation and battle powers finish. */
|
||||
function announceEarlyBasicLeaderless(g: Game) {
|
||||
const b = g.battle;
|
||||
if (!b || b.basicEarlyLeaderlessVersion !== 1 || g.status !== 'playing' || g.advanced ||
|
||||
@@ -43034,11 +43034,20 @@ function announceEarlyBasicLeaderless(g: Game) {
|
||||
g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama ||
|
||||
g.pendingKull || g.pendingTreacheryDiscard || g.pendingNullentropy) return;
|
||||
for (const id of [b.attacker, b.defender]) {
|
||||
if (b.plans[id] || b.basicEarlyLeaderless?.includes(id)) continue;
|
||||
if (b.plans[id]) continue;
|
||||
const p = getPlayer(g, id), leaders = controlledLeaders(g, p);
|
||||
if (!leaders.length || leaders.some(l => l.dead || l.capturedBy || !l.usedAt || l.usedAt === b.territory) ||
|
||||
p.hand.some(c => c.kind === 'hero' && !(b.voice?.target === id && !b.voice.must &&
|
||||
playedVoiceMatch(c, 'leader', b.voice.kind)))) continue;
|
||||
const availableLeader = leaders.some(l => !l.dead && !l.capturedBy && (!l.usedAt || l.usedAt === b.territory));
|
||||
const availableHero = p.hand.some(c => c.kind === 'hero' && !(b.voice?.target === id && !b.voice.must &&
|
||||
playedVoiceMatch(c, 'leader', b.voice.kind)));
|
||||
if (b.basicEarlyLeaderless?.includes(id)) {
|
||||
if (b.basicDeadLeaderPreparation && availableLeader) {
|
||||
b.basicEarlyLeaderless = b.basicEarlyLeaderless.filter(player => player !== id);
|
||||
log(g, `${p.name} now has an available leader for this battle.`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!leaders.length || availableLeader || availableHero ||
|
||||
!b.basicDeadLeaderPreparation && leaders.some(l => l.dead || l.capturedBy)) continue;
|
||||
(b.basicEarlyLeaderless ??= []).push(id);
|
||||
log(g, `${p.name} has no available leader or Cheap Hero for this battle.`);
|
||||
}
|
||||
@@ -47358,15 +47367,27 @@ function applyActionInner(
|
||||
'Combined Occupy requires source-selected native factions; optional overlays remain gated.');
|
||||
}
|
||||
const battleEvent = crypto.randomUUID();
|
||||
const earlyBasic = !g.advanced && g.expansions.length === 0 && !g.ecazTreachery && !g.techTokens &&
|
||||
!g.strongholdCards && !g.leaderSkills && !g.homeworlds && !g.nexusCards && !g.discoveryEnabled &&
|
||||
!g.sandtrout && !g.coalitionState && g.players.every(seat =>
|
||||
['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction));
|
||||
// The candidate depends only on public native leaders, never either hand.
|
||||
// Harkonnen's concealed captures need a separate public-projection rule.
|
||||
const deadLeaderCandidates = earlyBasic && !byFaction(g, 'harkonnen')
|
||||
? [choice.attacker, choice.defender].filter(playerId => {
|
||||
const leaders = getPlayer(g, playerId).leaders;
|
||||
return leaders.some(leader => leader.dead) && !leaders.some(leader =>
|
||||
!leader.dead && !leader.capturedBy && (!leader.usedAt || leader.usedAt === choice.territory));
|
||||
}) : [];
|
||||
g.battle = {
|
||||
...choice,
|
||||
chooserEvent: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0),
|
||||
event: battleEvent,
|
||||
...(!g.advanced && g.expansions.length === 0 && !g.ecazTreachery && !g.techTokens &&
|
||||
!g.strongholdCards && !g.leaderSkills && !g.homeworlds && !g.nexusCards && !g.discoveryEnabled &&
|
||||
!g.sandtrout && !g.coalitionState && g.players.every(seat =>
|
||||
['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction))
|
||||
? { basicEarlyLeaderlessVersion: 1 as const } : {}),
|
||||
...(earlyBasic ? { basicEarlyLeaderlessVersion: 1 as const } : {}),
|
||||
...(deadLeaderCandidates.length ? { basicDeadLeaderPreparation: true as const,
|
||||
preLeader: { event: battleEvent,
|
||||
ready: [choice.attacker, choice.defender].filter(playerId => !deadLeaderCandidates.includes(playerId)),
|
||||
closed: false } } : {}),
|
||||
stoneAllocationVersion: 1,
|
||||
...(g.leaderSkills ? { smugglerCollectionVersion: 1 as const } : {}),
|
||||
...(g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
|
||||
|
||||
+1
-1
@@ -2847,7 +2847,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'Build a legal combination and decide what to keep after winning.',
|
||||
steps: [
|
||||
'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 fresh classic Basic battles without optional modules, when all your leaders are alive but unavailable elsewhere and you lack a legal Cheap Hero, your inability is announced before plans. Other Basic and Advanced leaderless plans announce on sealing; 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 supported fresh classic Basic battles, an inability is announced before plans. When dead leaders may be revived, a preparation opportunity lets you play Ghola from your hand before continuing; a later revival updates the public notice. Other Basic and Advanced leaderless plans announce on sealing; 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.',
|
||||
'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.',
|
||||
],
|
||||
|
||||
@@ -100,14 +100,15 @@ void test('both combatants and an observer see the early Basic leaderless status
|
||||
});
|
||||
|
||||
for (const kind of ['hero','ghola'] as const) void test(
|
||||
`a real held ${kind} prevents a premature Basic leaderless status`, () => {
|
||||
`a held ${kind} has the correct Basic leaderless status after preparation`, () => {
|
||||
const game = battle(kind);
|
||||
assert.equal(player(game,'guild').hand.some(card => kind === 'hero' ? card.kind === 'hero' : card.effect === 'ghola'),true);
|
||||
for (const id of ['guild','emperor','atreides']) {
|
||||
const view = viewGame(game,id);
|
||||
assert.ok(!(view.battle!.earlyLeaderless ?? []).includes('guild'));
|
||||
assert.equal((view.battle!.earlyLeaderless ?? []).includes('guild'),kind === 'ghola');
|
||||
assert.deepEqual(view.battle!.plans,{}); assert.deepEqual(view.battle!.cards,[]);
|
||||
assert.doesNotMatch(render(game,id),/Guild currently has no available leader or Cheap Hero/);
|
||||
if (kind === 'ghola') assert.match(render(game,id),/Guild currently has no available leader or Cheap Hero/);
|
||||
else assert.doesNotMatch(render(game,id),/Guild currently has no available leader or Cheap Hero/);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
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 type { GameView } from '../game/engine';
|
||||
|
||||
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 { BattleLeaderOpportunity } = await import('../components/residual-poison');
|
||||
aliases.deregister();
|
||||
|
||||
/** This component consumes only public readiness, roster and interaction state. */
|
||||
function view(options: {richese?: boolean; ready?: boolean; interrupted?: boolean} = {}) {
|
||||
return {
|
||||
me:'a', players:[{id:'a',name:'Atreides',faction:'atreides'},
|
||||
{id:'g',name:'Guild',faction:'guild'},
|
||||
...(options.richese ? [{id:'r',name:'Richese',faction:'richese'}] : [])],
|
||||
battle:{attacker:'a',defender:'g',preLeader:{event:'battle',ready:options.ready ? ['a'] : [],closed:false}},
|
||||
response:options.interrupted ? {kind:'voice'} : null,
|
||||
truthtrance:null, decision:null, phaseOpening:null,
|
||||
} as unknown as GameView;
|
||||
}
|
||||
const render = (game:GameView, busy = false) => renderToStaticMarkup(
|
||||
createElement(BattleLeaderOpportunity, {game,busy,act() { assert.fail('Rendering must not act.'); }}),
|
||||
);
|
||||
|
||||
void test('the neutral pre-leader opportunity explains Ghola without claiming a player holds it', () => {
|
||||
const html = render(view());
|
||||
assert.match(html, /use a held Ghola from your hand to revive an eligible leader/);
|
||||
assert.match(html, /Ready to choose leaders/);
|
||||
assert.doesNotMatch(html, /Juice of Sapho/);
|
||||
assert.doesNotMatch(html, /You hold|has a Ghola/);
|
||||
});
|
||||
|
||||
void test('Sapho guidance appears only when Richese is seated', () => {
|
||||
assert.match(render(view({richese:true})), /A held Juice of Sapho can make you aggressor/);
|
||||
assert.doesNotMatch(render(view()), /Juice of Sapho/);
|
||||
});
|
||||
|
||||
void test('ready, busy and interrupted states retain the existing shared-stage controls', () => {
|
||||
assert.match(render(view({ready:true})), /You are ready\. Waiting for the other combatant/);
|
||||
assert.match(render(view(), true), /<button[^>]*disabled=""/);
|
||||
const interrupted = render(view({interrupted:true}));
|
||||
assert.match(interrupted, /Resolve the current interaction before continuing/);
|
||||
assert.match(interrupted, /<button[^>]*disabled=""/);
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { Card } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createBattleTerritoryExplosionFixture } from './fixture-battle-territory-explosion';
|
||||
|
||||
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
const player = (game: Game, id: string) => {
|
||||
const seat = game.players.find(row => row.id === id); assert.ok(seat); return seat;
|
||||
};
|
||||
let native: ReturnType<typeof createBattleTerritoryExplosionFixture> | undefined;
|
||||
const source = () => native ??= createBattleTerritoryExplosionFixture({advanced:false});
|
||||
const leaderless: Action = {type:'battlePlan', leader:null, dial:0, support:0, weapon:null, defense:null};
|
||||
const announcements = (game: Game) => game.log.filter(entry =>
|
||||
entry.text === `${player(game, 'emperor').name} has no available leader or Cheap Hero for this battle.`);
|
||||
|
||||
/** Real public Basic setup, movement and first battle. Only Emperor's leader
|
||||
* death/usage and conserved hand/deck custody are staged before choosing the
|
||||
* actual second battle. Hand size remains the same for private-card variants. */
|
||||
function beforeChoice(death: 'all' | 'mixed', card: 'none' | 'ghola' | 'hero' = 'none') {
|
||||
const game = structuredClone(source().afterFirst), emperor = player(game,'emperor');
|
||||
assert.equal(game.phase,6); assert.equal(game.battle,null);
|
||||
for (const [index, leader] of emperor.leaders.entries()) {
|
||||
leader.usedAt = 'carthag';
|
||||
if (death === 'all' || index === 0) {
|
||||
leader.dead = true; leader.deaths = Math.max(1, leader.deaths ?? 0); delete leader.usedAt;
|
||||
}
|
||||
}
|
||||
// Put any accidental Hero/Ghola back into the deck, replacing it with an
|
||||
// ordinary physical card so variant views retain the same hand count.
|
||||
const privateChoice = (candidate: Card) => candidate.kind === 'hero' || candidate.effect === 'ghola';
|
||||
for (let index = 0; index < emperor.hand.length; index++) if (privateChoice(emperor.hand[index])) {
|
||||
const replacement = game.deck.findIndex(candidate => !privateChoice(candidate)); assert.ok(replacement >= 0);
|
||||
[emperor.hand[index], game.deck[replacement]] = [game.deck[replacement], emperor.hand[index]];
|
||||
}
|
||||
if (card !== 'none') {
|
||||
const index = game.deck.findIndex(candidate => card === 'hero' ? candidate.kind === 'hero' : candidate.effect === 'ghola');
|
||||
assert.ok(index >= 0); assert.ok(emperor.hand.length > 0);
|
||||
[emperor.hand[0], game.deck[index]] = [game.deck[index], emperor.hand[0]];
|
||||
}
|
||||
const chooser = game.active; assert.ok(chooser);
|
||||
const choice = viewGame(game,chooser).battleChoices.find(row => row.territory === 'imperial_basin' &&
|
||||
[row.attacker,row.defender].includes('emperor') && [row.attacker,row.defender].includes('guild'));
|
||||
assert.ok(choice);
|
||||
const action: Action = {type:'chooseBattle', territory:choice.territory,
|
||||
target:choice.attacker === chooser ? choice.defender : choice.attacker};
|
||||
return {game,chooser,action};
|
||||
}
|
||||
function selected(death: 'all' | 'mixed', card: 'none' | 'ghola' | 'hero' = 'none') {
|
||||
const f = beforeChoice(death, card), game = act(f.game,f.chooser,f.action), battle = game.battle;
|
||||
assert.ok(battle && !battle.revealed && battle.preLeader && !battle.preLeader.closed);
|
||||
assert.ok(!battle.preLeader.ready.includes('emperor'));
|
||||
assert.ok(battle.preLeader.ready.includes('guild'));
|
||||
assert.equal(announcements(game).length,0);
|
||||
return {...f,game};
|
||||
}
|
||||
function ready(game:Game) {
|
||||
const event = game.battle?.preLeader?.event; assert.ok(event);
|
||||
return act(game,'emperor',{type:'battlePreparationReady',event});
|
||||
}
|
||||
const guildPlan = ():Action => ({type:'battlePlan', leader:source().guildLeader,
|
||||
dial:0, support:0, weapon:null, defense:null});
|
||||
|
||||
for (const death of ['all','mixed'] as const) void test(
|
||||
`${death} dead leaders wait for neutral preparation before the public Basic announcement`, () => {
|
||||
let {game} = selected(death);
|
||||
assert.deepEqual(game.battle!.plans,{});
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game,'guild',guildPlan()));
|
||||
assert.deepEqual(game,before);
|
||||
const event = game.battle!.preLeader!.event;
|
||||
game = ready(game);
|
||||
assert.equal(game.battle!.preLeader!.closed,true);
|
||||
assert.equal(announcements(game).length,1);
|
||||
assert.deepEqual(viewGame(game,'guild').battle!.earlyLeaderless,['emperor']);
|
||||
const closed = structuredClone(game);
|
||||
assert.throws(() => act(game,'emperor',{type:'battlePreparationReady',event}));
|
||||
assert.deepEqual(game,closed);
|
||||
game = act(game,'guild',guildPlan());
|
||||
game = act(game,'emperor',leaderless);
|
||||
assert.equal(game.battle!.revealed,true);
|
||||
assert.equal(announcements(game).length,1);
|
||||
},
|
||||
);
|
||||
|
||||
void test('Ghola and Cheap Hero holdings do not change the public preparation opportunity', () => {
|
||||
const games = (['none','ghola','hero'] as const).map(card => selected('all',card).game);
|
||||
for (const viewer of ['guild','atreides']) {
|
||||
const projected = games.map(game => {
|
||||
const view = viewGame(game,viewer), battle = view.battle!;
|
||||
return {ready:battle.preLeader?.ready, closed:battle.preLeader?.closed,
|
||||
earlyLeaderless:battle.earlyLeaderless, plans:battle.plans, cards:battle.cards,
|
||||
handCounts:view.players.map(seat => seat.handCount)};
|
||||
});
|
||||
assert.deepEqual(projected[1],projected[0]); assert.deepEqual(projected[2],projected[0]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a physical Ghola can revive a leader during preparation and suppress the announcement', () => {
|
||||
let {game} = selected('mixed','ghola');
|
||||
const emperor = player(game,'emperor'), leader = emperor.leaders.find(candidate => candidate.dead);
|
||||
const ghola = emperor.hand.find(candidate => candidate.effect === 'ghola'); assert.ok(leader && ghola);
|
||||
game = act(game,'emperor',{type:'card',card:ghola.id,leader:leader.id});
|
||||
assert.equal(player(game,'emperor').leaders.find(candidate => candidate.id === leader.id)?.dead,false);
|
||||
assert.equal(game.discard.filter(card => card.id === ghola.id).length,1);
|
||||
assert.equal(announcements(game).length,0);
|
||||
game = ready(game);
|
||||
assert.equal(announcements(game).length,0);
|
||||
game = act(game,'guild',guildPlan());
|
||||
game = act(game,'emperor',{...leaderless,leader:leader.id});
|
||||
assert.equal(game.battle!.revealed,true);
|
||||
});
|
||||
|
||||
for (const opponentSeals of [false,true]) void test(
|
||||
`late Ghola corrects the early announcement ${opponentSeals ? 'after the opponent seals' : 'before either plan'}`, () => {
|
||||
let {game} = selected('mixed','ghola');
|
||||
const leader = player(game,'emperor').leaders.find(candidate => candidate.dead);
|
||||
const ghola = player(game,'emperor').hand.find(candidate => candidate.effect === 'ghola'); assert.ok(leader && ghola);
|
||||
game = ready(game);
|
||||
assert.deepEqual(viewGame(game,'guild').battle!.earlyLeaderless,['emperor']);
|
||||
assert.equal(announcements(game).length,1);
|
||||
if (opponentSeals) game = act(game,'guild',guildPlan());
|
||||
const sealed = structuredClone(game.battle!.plans.guild ?? null);
|
||||
game = act(game,'emperor',{type:'card',card:ghola.id,leader:leader.id});
|
||||
assert.deepEqual(game.battle!.plans.guild ?? null,sealed);
|
||||
assert.equal(game.battle!.revealed,false);
|
||||
assert.equal(player(game,'emperor').leaders.find(candidate => candidate.id === leader.id)?.dead,false);
|
||||
assert.ok(!(viewGame(game,'guild').battle!.earlyLeaderless ?? []).includes('emperor'));
|
||||
assert.equal(game.log.filter(entry => entry.text ===
|
||||
`${player(game,'emperor').name} now has an available leader for this battle.`).length,1);
|
||||
assert.equal(announcements(game).length,1);
|
||||
if (!opponentSeals) game = act(game,'guild',guildPlan());
|
||||
game = act(game,'emperor',{...leaderless,leader:leader.id});
|
||||
assert.equal(game.battle!.revealed,true);
|
||||
},
|
||||
);
|
||||
|
||||
void test('both combatants with dead leaders must finish the neutral window before either plan', () => {
|
||||
const f = beforeChoice('mixed');
|
||||
const guild = player(f.game,'guild');
|
||||
for (const leader of guild.leaders) {
|
||||
leader.dead = true; leader.deaths = Math.max(1,leader.deaths ?? 0); delete leader.usedAt;
|
||||
}
|
||||
for (let index = 0; index < guild.hand.length; index++) if (guild.hand[index].kind === 'hero') {
|
||||
const replacement = f.game.deck.findIndex(card => card.kind !== 'hero'); assert.ok(replacement >= 0);
|
||||
[guild.hand[index],f.game.deck[replacement]] = [f.game.deck[replacement],guild.hand[index]];
|
||||
}
|
||||
let game = act(f.game,f.chooser,f.action);
|
||||
const event = game.battle?.preLeader?.event; assert.ok(event);
|
||||
assert.deepEqual(game.battle!.preLeader!.ready,[]);
|
||||
game = act(game,'guild',{type:'battlePreparationReady',event});
|
||||
assert.equal(game.battle!.preLeader!.closed,false);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game,'guild',leaderless));
|
||||
assert.deepEqual(game,before);
|
||||
game = act(game,'emperor',{type:'battlePreparationReady',event});
|
||||
assert.equal(game.battle!.preLeader!.closed,true);
|
||||
assert.deepEqual(new Set(viewGame(game,'atreides').battle!.earlyLeaderless),new Set(['emperor','guild']));
|
||||
game = act(game,'guild',leaderless);
|
||||
game = act(game,'emperor',leaderless);
|
||||
assert.equal(game.battle!.revealed,true);
|
||||
});
|
||||
|
||||
void test('dead-leader readiness precedes completion of Atreides Prescience and its announcement', () => {
|
||||
let game = structuredClone(source().beforeFirst);
|
||||
const chooser = game.active; assert.ok(chooser);
|
||||
const choice = viewGame(game,chooser).battleChoices.find(row => row.territory === 'imperial_basin' &&
|
||||
[row.attacker,row.defender].includes('atreides')); assert.ok(choice);
|
||||
const target = choice.attacker === 'atreides' ? choice.defender : choice.attacker;
|
||||
const seat = player(game,target);
|
||||
for (const [index, leader] of seat.leaders.entries()) {
|
||||
leader.usedAt = 'carthag';
|
||||
if (index === 0) { leader.dead = true; leader.deaths = Math.max(1,leader.deaths ?? 0); delete leader.usedAt; }
|
||||
}
|
||||
for (let index = 0; index < seat.hand.length; index++) if (seat.hand[index].kind === 'hero') {
|
||||
const replacement = game.deck.findIndex(card => card.kind !== 'hero'); assert.ok(replacement >= 0);
|
||||
[seat.hand[index],game.deck[replacement]] = [game.deck[replacement],seat.hand[index]];
|
||||
}
|
||||
game = act(game,chooser,{type:'chooseBattle',territory:choice.territory,
|
||||
target:choice.attacker === chooser ? choice.defender : choice.attacker});
|
||||
assert.equal(game.battle?.preparation?.kind,'prescience');
|
||||
const event = game.battle?.preLeader?.event; assert.ok(event);
|
||||
assert.ok(!game.battle!.preLeader!.ready.includes(target));
|
||||
assert.ok(!(viewGame(game,'atreides').battle!.earlyLeaderless ?? []).includes(target));
|
||||
game = act(game,target,{type:'battlePreparationReady',event});
|
||||
assert.equal(game.battle!.preLeader!.closed,true);
|
||||
assert.equal(game.battle?.preparation?.kind,'prescience');
|
||||
assert.ok(!(viewGame(game,'atreides').battle!.earlyLeaderless ?? []).includes(target));
|
||||
game = act(game,'atreides',{type:'declineBattlePower'});
|
||||
assert.equal(game.battle?.preparation ?? null,null);
|
||||
assert.deepEqual(viewGame(game,'atreides').battle!.earlyLeaderless,[target]);
|
||||
const atreidesLeader = player(game,'atreides').leaders.find(leader =>
|
||||
!leader.dead && !leader.capturedBy && (!leader.usedAt || leader.usedAt === choice.territory));
|
||||
assert.ok(atreidesLeader);
|
||||
game = act(game,'atreides',{...leaderless,leader:atreidesLeader.id});
|
||||
game = act(game,target,leaderless);
|
||||
assert.equal(game.battle!.revealed,true);
|
||||
});
|
||||
|
||||
void test('Ghola after an owned leaderless plan preserves the sealed plan and its public announcement', () => {
|
||||
let {game} = selected('mixed','ghola');
|
||||
const leader = player(game,'emperor').leaders.find(candidate => candidate.dead);
|
||||
const ghola = player(game,'emperor').hand.find(candidate => candidate.effect === 'ghola'); assert.ok(leader && ghola);
|
||||
game = ready(game);
|
||||
game = act(game,'emperor',leaderless);
|
||||
const sealed = structuredClone(game.battle!.plans.emperor);
|
||||
assert.deepEqual(viewGame(game,'guild').battle!.leaderless,['emperor']);
|
||||
game = act(game,'emperor',{type:'card',card:ghola.id,leader:leader.id});
|
||||
assert.equal(player(game,'emperor').leaders.find(candidate => candidate.id === leader.id)?.dead,false);
|
||||
assert.deepEqual(game.battle!.plans.emperor,sealed);
|
||||
assert.deepEqual(viewGame(game,'guild').battle!.leaderless,['emperor']);
|
||||
assert.equal(game.log.filter(entry => entry.text ===
|
||||
`${player(game,'emperor').name} now has an available leader for this battle.`).length,0);
|
||||
game = act(game,'guild',guildPlan());
|
||||
assert.equal(game.battle!.revealed,true);
|
||||
assert.deepEqual(game.battle!.plans.emperor,sealed);
|
||||
});
|
||||
|
||||
void test('a held Cheap Hero remains a legal leader and is not announced as leaderless', () => {
|
||||
let {game} = selected('all','hero');
|
||||
const hero = player(game,'emperor').hand.find(card => card.kind === 'hero'); assert.ok(hero);
|
||||
game = ready(game);
|
||||
assert.equal(announcements(game).length,0);
|
||||
game = act(game,'emperor',{...leaderless,leader:hero.id});
|
||||
assert.equal(game.battle!.plans.emperor.leader,hero.id);
|
||||
assert.equal(announcements(game).length,0);
|
||||
});
|
||||
|
||||
void test('older already-open unstamped battles do not acquire a preparation wait', () => {
|
||||
let {game} = selected('all');
|
||||
delete game.battle!.basicEarlyLeaderlessVersion;
|
||||
delete game.battle!.preLeader;
|
||||
const saved = JSON.stringify(game); game = JSON.parse(saved);
|
||||
assert.equal(game.battle!.preLeader,undefined);
|
||||
game = act(game,'guild',guildPlan());
|
||||
assert.equal(game.battle!.revealed,false);
|
||||
assert.equal(announcements(game).length,0);
|
||||
});
|
||||
|
||||
void test('all four bot profiles can finish the neutral preparation window', () => {
|
||||
const {game} = selected('mixed');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game,'emperor');
|
||||
view.players.find(seat => seat.id === 'emperor')!.bot = difficulty;
|
||||
const action = botActions(view).find(candidate => candidate.type === 'battlePreparationReady');
|
||||
assert.ok(action,`${difficulty} must leave preparation legally`);
|
||||
const next = act(structuredClone(game),'emperor',action);
|
||||
assert.equal(next.battle!.preLeader!.closed,true);
|
||||
assert.equal(announcements(next).length,1);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user