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
@@ -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