Announce eligible Basic leaderless battles before plans
Verify and publish checkpoint container / container (push) Successful in 10m34s
Verify and publish checkpoint container / container (push) Successful in 10m34s
This commit is contained in:
1 parent
2bac45b294
commit
685bd8bdfc
8 files changed
+417
-11
No files matched your search
@@ -0,0 +1,113 @@
|
||||
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('A status must not submit a game action.'); },onExit() {}}));
|
||||
const player = (game: Game, id: string) => {
|
||||
const seat = game.players.find(row => row.id === id); assert.ok(seat); return seat;
|
||||
};
|
||||
|
||||
/** Real public Basic setup, native movements, first battle and native second
|
||||
* chooser. Before choosing that second battle, stage only Guild's prior leader
|
||||
* usage/deaths and conserved exact Hero/Ghola custody for each control case. */
|
||||
function battle(kind: 'none' | 'hero' | 'ghola'): Game {
|
||||
const source = createBattleTerritoryExplosionFixture({advanced:false});
|
||||
let game = structuredClone(source.afterFirst);
|
||||
assert.equal(game.advanced,false); assert.equal(game.phase,6); assert.equal(game.battle,null);
|
||||
const guild = player(game,'guild');
|
||||
for (const leader of guild.leaders) {
|
||||
if (kind === 'ghola') { leader.dead = true; leader.deaths = Math.max(1,leader.deaths ?? 0); }
|
||||
else leader.usedAt = 'carthag';
|
||||
}
|
||||
// Remove an accidental Guild Hero without fabricating or deleting a card.
|
||||
const heroes = guild.hand.filter(card => card.kind === 'hero');
|
||||
guild.hand = guild.hand.filter(card => card.kind !== 'hero'); game.deck.push(...heroes);
|
||||
if (kind !== 'none') {
|
||||
const needed = kind === 'hero' ? (card: {kind:string}) => card.kind === 'hero'
|
||||
: (card: {effect?:string}) => card.effect === 'ghola';
|
||||
let held = guild.hand.some(needed);
|
||||
if (!held) for (const zone of [game.deck,game.discard,...game.players.filter(seat => seat.id !== 'guild').map(seat => seat.hand)]) {
|
||||
const index = zone.findIndex(needed);
|
||||
if (index >= 0) { guild.hand.push(zone.splice(index,1)[0]); held = true; break; }
|
||||
}
|
||||
assert.ok(held, `Retain the real physical ${kind} card`);
|
||||
}
|
||||
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);
|
||||
game = applyAction(game,chooser,{type:'chooseBattle',territory:'imperial_basin',
|
||||
target:chooser === 'emperor' ? 'guild' : 'emperor'});
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (game.response) {
|
||||
const actor = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(actor);
|
||||
game = applyAction(game,actor.id,{type:'passResponse'});
|
||||
} else if (game.battle?.preparation) {
|
||||
game = applyAction(game,game.battle.preparation.owner,{type:'declineBattlePower'});
|
||||
} else if (game.battle?.preLeader && !game.battle.preLeader.closed) {
|
||||
const actor = [game.battle.attacker,game.battle.defender].find(id => !game.battle!.preLeader!.ready.includes(id)); assert.ok(actor);
|
||||
game = applyAction(game,actor,{type:'battlePreparationReady',event:game.battle.preLeader.event});
|
||||
} else break;
|
||||
}
|
||||
assert.ok(game.battle && !game.battle.revealed && !game.battle.preparation && !game.response);
|
||||
assert.deepEqual(game.battle.plans,{});
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('both combatants and an observer see the early Basic leaderless status without sealed details', () => {
|
||||
const game = battle('none'), before = JSON.stringify(game);
|
||||
for (const id of ['guild','emperor','atreides']) {
|
||||
const view = viewGame(game,id);
|
||||
assert.deepEqual(view.battle!.earlyLeaderless,['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 currently has no available leader or Cheap Hero\./);
|
||||
assert.doesNotMatch(html,/Guild announced a Battle Plan without a leader or Cheap Hero/);
|
||||
}
|
||||
}
|
||||
assert.equal(JSON.stringify(game),before);
|
||||
const reloaded = JSON.parse(before) as Game;
|
||||
assert.deepEqual(viewGame(reloaded,'atreides').battle!.earlyLeaderless,['guild']);
|
||||
assert.match(render(reloaded,'emperor'),/Guild currently has no available leader or Cheap Hero\./);
|
||||
const sealed = applyAction(reloaded,'guild',{type:'battlePlan',dial:1,support:0,leader:null,weapon:null,defense:null});
|
||||
assert.equal(sealed.battle?.revealed,false);
|
||||
assert.deepEqual(viewGame(sealed,'emperor').battle!.plans,{});
|
||||
const sealedHtml = render(sealed,'emperor');
|
||||
assert.match(sealedHtml,/Guild announced a Battle Plan without a leader or Cheap Hero\./);
|
||||
assert.doesNotMatch(sealedHtml,/Guild currently has no available leader or Cheap Hero/);
|
||||
});
|
||||
|
||||
for (const kind of ['hero','ghola'] as const) void test(
|
||||
`a real held ${kind} prevents a premature Basic leaderless status`, () => {
|
||||
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.deepEqual(view.battle!.plans,{}); assert.deepEqual(view.battle!.cards,[]);
|
||||
assert.doesNotMatch(render(game,id),/Guild currently has no available leader or Cheap Hero/);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { advanceTruthTurnPower, createTruthTurnPowerFixture, truthTurnPowerClean } from './fixture-truth-turn-power';
|
||||
|
||||
const act = (game: Game, player: string, action: Action) => applyAction(game, player, action);
|
||||
const seat = (game: Game, id: string) => {
|
||||
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
||||
};
|
||||
const notices = (game: Game) => game.log.filter(entry =>
|
||||
entry.text === 'Atreides has no available leader or Cheap Hero for this battle.');
|
||||
const nullPlan: Action = {type:'battlePlan', leader:null, dial:0, support:0, weapon:null, defense:null};
|
||||
|
||||
let fixture: ReturnType<typeof createTruthTurnPowerFixture> | undefined;
|
||||
const source = () => fixture ??= createTruthTurnPowerFixture({power:'voice', remote:false});
|
||||
|
||||
/** A real public Basic game: BG has shipped into Atreides' Arrakeen. Stage
|
||||
* only Atreides' alive leader-use history and custody of one physical Cheap
|
||||
* Hero before making the real battle choice and native Voice decision. */
|
||||
function beforeVoice() {
|
||||
const f = source();
|
||||
let game = advanceTruthTurnPower(structuredClone(f.shipment.after), state =>
|
||||
state.phase === 6 && !!state.active && !state.battle && truthTurnPowerClean(state), [], true);
|
||||
const target = seat(game, 'atreides');
|
||||
assert.ok(target.leaders.every(leader => !leader.dead && !leader.capturedBy));
|
||||
for (const leader of target.leaders) leader.usedAt = 'carthag';
|
||||
const heroIndex = game.deck.findIndex(card => card.kind === 'hero'); assert.ok(heroIndex >= 0);
|
||||
const hero = game.deck.splice(heroIndex, 1)[0]; target.hand.push(hero);
|
||||
const chooser = game.active!;
|
||||
const choice = viewGame(game, chooser).battleChoices.find(row => row.territory === 'arrakeen' &&
|
||||
[row.attacker,row.defender].includes('atreides') && [row.attacker,row.defender].includes('beneGesserit'));
|
||||
assert.ok(choice);
|
||||
game = act(game, chooser, {type:'chooseBattle', territory:choice.territory,
|
||||
target:choice.attacker === chooser ? choice.defender : choice.attacker});
|
||||
assert.equal(game.battle?.basicEarlyLeaderlessVersion, 1);
|
||||
assert.equal(game.battle?.preparation?.kind, 'voice');
|
||||
assert.equal(game.battle.preparation.owner, 'beneGesserit');
|
||||
assert.equal(viewGame(game, 'emperor').battle!.earlyLeaderless, undefined);
|
||||
assert.equal(notices(game).length, 0);
|
||||
return {game, hero:hero.id, karama:f.karama};
|
||||
}
|
||||
|
||||
function finishPreparation(game: Game) {
|
||||
for (let count = 0; count < 20 && (game.response || game.battle?.preparation); count++) {
|
||||
const step = nextStrongholdFactionsNativeStep(game); assert.ok(step);
|
||||
game = act(game, step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
assert.equal(game.battle?.preparation ?? null, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('Basic Voice forbidding a held Cheap Hero announces leaderlessness only after preparation', () => {
|
||||
let {game} = beforeVoice();
|
||||
game = act(game, 'beneGesserit', {type:'voice', kind:'hero', must:false});
|
||||
if (game.response || game.battle?.preparation) {
|
||||
assert.equal(viewGame(game, 'emperor').battle!.earlyLeaderless, undefined);
|
||||
assert.equal(notices(game).length, 0);
|
||||
}
|
||||
game = finishPreparation(game);
|
||||
assert.equal(game.battle!.voice?.target, 'atreides');
|
||||
assert.deepEqual(game.battle!.plans, {});
|
||||
for (const viewer of ['atreides','beneGesserit','emperor'])
|
||||
assert.deepEqual(viewGame(game, viewer).battle!.earlyLeaderless, ['atreides']);
|
||||
assert.equal(notices(game).length, 1);
|
||||
game = act(game, 'atreides', nullPlan);
|
||||
assert.equal(game.battle!.plans.atreides.leader, null);
|
||||
assert.equal(notices(game).length, 1);
|
||||
});
|
||||
|
||||
void test('Basic Karama cancellation of Voice leaves the Cheap Hero legal and no early notice', () => {
|
||||
const {hero, karama, game: initial} = beforeVoice();
|
||||
let game = initial;
|
||||
assert.ok(seat(game, 'emperor').hand.some(card => card.id === karama));
|
||||
game = act(game, 'beneGesserit', {type:'voice', kind:'hero', must:false});
|
||||
assert.equal(game.response?.kind, 'voice');
|
||||
game = act(game, 'emperor', {type:'card', card:karama, mode:'cancel'});
|
||||
game = finishPreparation(game);
|
||||
assert.equal(game.battle!.voice, undefined);
|
||||
assert.equal(viewGame(game, 'emperor').battle!.earlyLeaderless, undefined);
|
||||
assert.equal(notices(game).length, 0);
|
||||
game = act(game, 'atreides', {...nullPlan, leader:hero});
|
||||
assert.equal(game.battle!.plans.atreides.leader, hero);
|
||||
assert.equal(notices(game).length, 0);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
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 announcements = (game: Game, id: string) =>
|
||||
game.log.filter(entry => entry.text === `${seat(game, id).name} has no available leader or Cheap Hero for this battle.`);
|
||||
let nativeSource: ReturnType<typeof createBattleTerritoryExplosionFixture> | undefined;
|
||||
const source = () => nativeSource ??= createBattleTerritoryExplosionFixture({advanced:false});
|
||||
const leaderless: Action = {type:'battlePlan', leader:null, dial:0, support:0, weapon:null, defense:null};
|
||||
|
||||
/** A genuine public Basic Emperor/Atreides/Guild setup plays real movement and
|
||||
* the first battle. On the earned next-battle source only the Emperor's five
|
||||
* leader-use locations, and an optional conserved Cheap Hero/death history,
|
||||
* are staged before the actual second battle choice. */
|
||||
function beforeChoice(options: {hero?: boolean; dead?: boolean} = {}) {
|
||||
const game = structuredClone(source().afterFirst);
|
||||
assert.equal(game.advanced, false); assert.equal(game.phase, 6); assert.equal(game.battle, null);
|
||||
const emperor = seat(game, 'emperor');
|
||||
assert.ok(emperor.leaders.every(leader => !leader.dead && !leader.capturedBy));
|
||||
for (const leader of emperor.leaders) leader.usedAt = 'carthag';
|
||||
if (options.dead) {
|
||||
emperor.leaders[0].dead = true; emperor.leaders[0].deaths = Math.max(1, emperor.leaders[0].deaths);
|
||||
}
|
||||
if (options.hero) {
|
||||
const index = game.deck.findIndex(card => card.kind === 'hero'); assert.ok(index >= 0);
|
||||
emperor.hand.push(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
const chooser = game.active!;
|
||||
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(options: {hero?: boolean; dead?: boolean} = {}) {
|
||||
const f = beforeChoice(options), game = act(f.game, f.chooser, f.action);
|
||||
assert.ok(game.battle && !game.battle.revealed && !game.battle.preparation && !game.response && !game.decision);
|
||||
assert.equal(game.battle.basicEarlyLeaderlessVersion, 1);
|
||||
return {...f, game};
|
||||
}
|
||||
const guildPlan = () => ({type:'battlePlan', leader:source().guildLeader, dial:0, support:0, weapon:null, defense:null});
|
||||
|
||||
for (const first of ['emperor', 'guild'] as const) void test(
|
||||
`Basic early leaderless announcement precedes either plan when ${first} seals first`, () => {
|
||||
let {game} = selected();
|
||||
assert.deepEqual(game.battle!.plans, {});
|
||||
assert.deepEqual(game.battle!.basicEarlyLeaderless, ['emperor']);
|
||||
assert.equal(announcements(game, 'emperor').length, 1);
|
||||
for (const viewer of ['emperor','guild','atreides']) {
|
||||
const battle = viewGame(game, viewer).battle!;
|
||||
assert.deepEqual(battle.earlyLeaderless, ['emperor']);
|
||||
assert.deepEqual(battle.plans, {}); assert.deepEqual(battle.cards, []);
|
||||
}
|
||||
const saved = JSON.stringify(game); game = JSON.parse(saved);
|
||||
assert.equal(JSON.stringify(game), saved);
|
||||
assert.deepEqual(viewGame(game, 'atreides').battle!.earlyLeaderless, ['emperor']);
|
||||
game = act(game, first, first === 'emperor' ? leaderless : guildPlan());
|
||||
assert.equal(game.battle!.revealed, false);
|
||||
assert.equal(announcements(game, 'emperor').length, 1);
|
||||
assert.deepEqual(viewGame(game, 'atreides').battle!.plans, {});
|
||||
assert.deepEqual(viewGame(game, 'atreides').battle!.earlyLeaderless,
|
||||
first === 'emperor' ? undefined : ['emperor']);
|
||||
if (first === 'emperor') assert.deepEqual(viewGame(game, 'guild').battle!.leaderless, ['emperor']);
|
||||
const duplicate = structuredClone(game);
|
||||
assert.throws(() => act(game, first, first === 'emperor' ? leaderless : guildPlan()));
|
||||
assert.deepEqual(game, duplicate);
|
||||
game = act(game, first === 'emperor' ? 'guild' : 'emperor', first === 'emperor' ? guildPlan() : leaderless);
|
||||
assert.equal(game.battle!.revealed, true);
|
||||
assert.equal(announcements(game, 'emperor').length, 1);
|
||||
assert.deepEqual(viewGame(game, 'atreides').battle!.leaderless, ['emperor']);
|
||||
},
|
||||
);
|
||||
|
||||
void test('a held Cheap Hero prevents the early announcement and remains a legal plan', () => {
|
||||
const {game} = selected({hero:true});
|
||||
assert.equal(game.battle!.basicEarlyLeaderless?.includes('emperor') ?? false, false);
|
||||
assert.equal(viewGame(game, 'guild').battle!.earlyLeaderless, undefined);
|
||||
assert.equal(announcements(game, 'emperor').length, 0);
|
||||
const hero = seat(game, 'emperor').hand.find(card => card.kind === 'hero'); assert.ok(hero);
|
||||
const before = structuredClone(game); assert.throws(() => act(game, 'emperor', leaderless));
|
||||
assert.deepEqual(game, before);
|
||||
const next = act(game, 'emperor', {...leaderless, leader:hero.id});
|
||||
assert.deepEqual(viewGame(next, 'guild').battle!.leaderless, []);
|
||||
assert.equal(announcements(next, 'emperor').length, 0);
|
||||
});
|
||||
|
||||
void test('a dead leader leaves no early announcement while an owned Ghola may change eligibility', () => {
|
||||
const {game} = selected({dead:true});
|
||||
assert.equal(game.battle!.basicEarlyLeaderless?.includes('emperor') ?? false, false);
|
||||
assert.equal(viewGame(game, 'guild').battle!.earlyLeaderless, undefined);
|
||||
assert.equal(announcements(game, 'emperor').length, 0);
|
||||
});
|
||||
|
||||
void test('older unstamped Basic battles do not acquire a synthetic early announcement', () => {
|
||||
let {game} = selected();
|
||||
// Simulate a prior already-open battle: no new-version marker, receipt or
|
||||
// newly created log entry. Keep all other real battle and piece state.
|
||||
delete game.battle!.basicEarlyLeaderlessVersion; delete game.battle!.basicEarlyLeaderless;
|
||||
game.log = game.log.filter(entry => entry.text !== `${seat(game, 'emperor').name} has no available leader or Cheap Hero for this battle.`);
|
||||
const saved = JSON.stringify(game); game = JSON.parse(saved);
|
||||
assert.equal(viewGame(game, 'guild').battle!.earlyLeaderless, undefined);
|
||||
assert.equal(JSON.stringify(game), saved);
|
||||
game = act(game, 'guild', guildPlan());
|
||||
assert.equal(viewGame(game, 'atreides').battle!.earlyLeaderless, undefined);
|
||||
assert.equal(announcements(game, 'emperor').length, 0);
|
||||
});
|
||||
|
||||
void test('pending Basic Prescience resolves before the early announcement', () => {
|
||||
let game = structuredClone(source().beforeFirst);
|
||||
const chooser = game.active!;
|
||||
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;
|
||||
assert.ok(seat(game, target).leaders.every(leader => !leader.dead && !leader.capturedBy));
|
||||
assert.ok(!seat(game, target).hand.some(card => card.kind === 'hero'));
|
||||
for (const leader of seat(game, target).leaders) leader.usedAt = 'carthag';
|
||||
game = act(game, chooser, {type:'chooseBattle', territory:choice.territory,
|
||||
target:choice.attacker === chooser ? choice.defender : choice.attacker});
|
||||
assert.equal(game.battle?.preparation?.kind, 'prescience');
|
||||
assert.equal(viewGame(game, 'atreides').battle!.earlyLeaderless, undefined);
|
||||
assert.equal(announcements(game, target).length, 0);
|
||||
game = act(game, 'atreides', {type:'declineBattlePower'});
|
||||
assert.equal(game.battle?.preparation ?? null, null);
|
||||
assert.deepEqual(viewGame(game, 'atreides').battle!.earlyLeaderless, [target]);
|
||||
assert.equal(announcements(game, target).length, 1);
|
||||
});
|
||||
|
||||
void test('all four minimal bots seal legally after a public early Basic announcement', () => {
|
||||
const {game} = selected();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'emperor');
|
||||
view.players.find(player => player.id === 'emperor')!.bot = difficulty;
|
||||
const plans = botActions(view).filter(action => action.type === 'battlePlan');
|
||||
assert.ok(plans.length > 0, `${difficulty} must offer a legal plan`);
|
||||
const next = act(structuredClone(game), 'emperor', plans[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.equal(announcements(next, 'emperor').length, 1);
|
||||
assert.deepEqual(viewGame(next, 'guild').battle!.leaderless, ['emperor']);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user