Announce leaderless native Basic battles before plans
Verify and publish checkpoint container / container (push) Successful in 10m14s
Verify and publish checkpoint container / container (push) Successful in 10m14s
This commit is contained in:
1 parent
00e01a7cd2
commit
f73a6e0a9c
9 files changed
+481
-21
No files matched your search
@@ -0,0 +1,79 @@
|
||||
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 { advanceNativeLeaderless, basicNativeLeaderlessFixture, nativeLeaderlessClean,
|
||||
nativeLeaderlessPlayer, type NativeLeaderlessFaction } from './fixture-basic-native-early-leaderless';
|
||||
|
||||
const act = (game: Game, player: string, action: Action) => applyAction(game, player, action);
|
||||
const noLeader: Action = {type:'battlePlan', leader:null, dial:0, support:0, weapon:null, defense:null};
|
||||
const announcements = (game: Game, player: string) => game.log.filter(entry => entry.text ===
|
||||
`${nativeLeaderlessPlayer(game, player).name} has no available leader or Cheap Hero for this battle.`);
|
||||
function atPlanning(faction: NativeLeaderlessFaction, options: {dead?: boolean; hero?: boolean; ghola?: boolean} = {}) {
|
||||
const fixture = basicNativeLeaderlessFixture(faction, options);
|
||||
const game = advanceNativeLeaderless(fixture.game, state => !!state.battle && nativeLeaderlessClean(state) &&
|
||||
!state.battle.preparation && (!state.battle.preLeader || state.battle.preLeader.closed));
|
||||
assert.ok(game.battle && !game.battle.revealed);
|
||||
return {...fixture, game};
|
||||
}
|
||||
function opponentPlan(game: Game, opponent: string, territory: string): Action {
|
||||
const leader = nativeLeaderlessPlayer(game, opponent).leaders.find(candidate =>
|
||||
!candidate.dead && !candidate.capturedBy && (!candidate.usedAt || candidate.usedAt === territory));
|
||||
assert.ok(leader);
|
||||
return {type:'battlePlan', leader:leader.id, dial:0, support:0, weapon:null, defense:null};
|
||||
}
|
||||
|
||||
for (const faction of ['ixians','tleilaxu','choam','richese','ecaz','moritani'] as const)
|
||||
for (const first of ['native','opponent'] as const) void test(
|
||||
`Basic ${faction} announces no eligible leader before ${first} seals a real public battle`, () => {
|
||||
const {game: initial, opponent, territory} = atPlanning(faction);
|
||||
let game = initial;
|
||||
assert.deepEqual(game.battle!.plans, {});
|
||||
assert.ok(game.battle!.basicEarlyLeaderless?.includes(faction));
|
||||
assert.equal(announcements(game, faction).length, 1);
|
||||
assert.ok(viewGame(game, opponent).battle!.earlyLeaderless?.includes(faction));
|
||||
const other = opponentPlan(game, opponent, territory);
|
||||
game = act(game, first === 'native' ? faction : opponent, first === 'native' ? noLeader : other);
|
||||
assert.equal(game.battle!.revealed, false);
|
||||
assert.equal(announcements(game, faction).length, 1);
|
||||
game = act(game, first === 'native' ? opponent : faction, first === 'native' ? other : noLeader);
|
||||
assert.equal(game.battle!.revealed, true);
|
||||
assert.ok(viewGame(game, opponent).battle!.leaderless?.includes(faction));
|
||||
assert.equal(announcements(game, faction).length, 1);
|
||||
});
|
||||
|
||||
void test('Basic native Cheap Hero prevents early leaderless announcement and remains a legal plan', () => {
|
||||
const {game: initial, faction, opponent} = atPlanning('richese', {hero:true});
|
||||
let game = initial;
|
||||
const hero = nativeLeaderlessPlayer(game, faction).hand.find(card => card.kind === 'hero'); assert.ok(hero);
|
||||
assert.equal(announcements(game, faction).length, 0);
|
||||
assert.ok(!(viewGame(game, opponent).battle!.earlyLeaderless ?? []).includes(faction));
|
||||
game = act(game, faction, {...noLeader, leader:hero.id});
|
||||
assert.equal(game.battle!.plans[faction].leader, hero.id);
|
||||
});
|
||||
|
||||
void test('Basic native dead-leader preparation lets Ghola revive before the sealed plans', () => {
|
||||
const fixture = basicNativeLeaderlessFixture('moritani', {dead:true, ghola:true});
|
||||
let game = advanceNativeLeaderless(fixture.game, state => !!state.battle && nativeLeaderlessClean(state) &&
|
||||
!state.battle.preparation && !!state.battle.preLeader && !state.battle.preLeader.closed);
|
||||
const faction = fixture.faction, event = game.battle!.preLeader!.event;
|
||||
assert.equal(announcements(game, faction).length, 0);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, faction); view.players.find(player => player.id === faction)!.bot = difficulty;
|
||||
const action = botActions(view).find(candidate => candidate.type === 'battlePreparationReady');
|
||||
assert.ok(action, difficulty);
|
||||
assert.doesNotThrow(() => act(game, faction, action));
|
||||
}
|
||||
game = act(game, faction, {type:'battlePreparationReady', event});
|
||||
assert.equal(announcements(game, faction).length, 1);
|
||||
const player = nativeLeaderlessPlayer(game, faction), leader = player.leaders.find(item => item.dead);
|
||||
const ghola = player.hand.find(card => card.effect === 'ghola'); assert.ok(leader && ghola);
|
||||
game = act(game, faction, {type:'card', card:ghola.id, leader:leader.id});
|
||||
assert.equal(nativeLeaderlessPlayer(game, faction).leaders.find(item => item.id === leader.id)!.dead, false);
|
||||
assert.ok(!(viewGame(game, fixture.opponent).battle!.earlyLeaderless ?? []).includes(faction));
|
||||
assert.equal(announcements(game, faction).length, 1);
|
||||
game = act(game, faction, {...noLeader, leader:leader.id});
|
||||
game = act(game, fixture.opponent, opponentPlan(game, fixture.opponent, fixture.territory));
|
||||
assert.equal(game.battle!.revealed, true);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { basicNativeLeaderlessOccupy, basicOccupyNoRicheseLeaderless, finishOccupyLeaderlessPreparation } from './fixture-basic-native-leaderless-occupy';
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const) for (const dead of ['selected', 'opponent'] as const)
|
||||
void test(`Basic Ecaz Occupy ${lead} lead opens public dead-leader preparation for ${dead} after lead settles`, () => {
|
||||
const f = basicNativeLeaderlessOccupy({ lead, dead });
|
||||
const b = f.game.battle!;
|
||||
assert.equal(b.ecazOccupy?.lead, f.selected);
|
||||
assert.ok([b.attacker, b.defender].includes(f.selected));
|
||||
assert.ok(![b.attacker, b.defender].includes(f.unselected));
|
||||
assert.equal(b.basicEarlyLeaderlessVersion, 1);
|
||||
assert.equal(b.basicDeadLeaderPreparation, true);
|
||||
assert.ok(b.preLeader && !b.preLeader.closed);
|
||||
assert.ok(!b.preLeader.ready.includes(f.dead!));
|
||||
assert.ok(b.preLeader.ready.every(id => [b.attacker, b.defender].includes(id)));
|
||||
assert.deepEqual(b.plans, {});
|
||||
const game = finishOccupyLeaderlessPreparation(f.game);
|
||||
assert.ok(game.battle);
|
||||
assert.deepEqual(game.battle.plans, {});
|
||||
assert.ok(game.battle.basicEarlyLeaderless?.includes(f.dead!));
|
||||
for (const viewer of game.players)
|
||||
assert.ok(viewGame(game, viewer.id).battle!.earlyLeaderless?.includes(f.dead!));
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Basic Ecaz Occupy ${lead} lead does not open dead-leader preparation for a nonleading ally`, () => {
|
||||
const f = basicNativeLeaderlessOccupy({ lead, dead: 'unselected' });
|
||||
assert.ok(f.game.battle);
|
||||
assert.equal(f.game.battle.basicDeadLeaderPreparation, undefined);
|
||||
assert.ok(f.game.battle.preLeader && !f.game.battle.preLeader.closed,
|
||||
'The original Richese shared preparation is retained');
|
||||
const game = finishOccupyLeaderlessPreparation(f.game);
|
||||
assert.ok(game.battle?.basicEarlyLeaderless?.includes(f.selected));
|
||||
assert.ok(!game.battle?.basicEarlyLeaderless?.includes(f.unselected));
|
||||
});
|
||||
|
||||
void test('An available shared Duke prevents the Ecaz lead from announcing no leader', () => {
|
||||
const f = basicNativeLeaderlessOccupy({ lead: 'ecaz', dead: 'none', duke: true });
|
||||
const game = finishOccupyLeaderlessPreparation(f.game);
|
||||
assert.equal(game.dukeVidal?.controller, f.fixture.ecaz);
|
||||
assert.ok(!game.battle?.basicEarlyLeaderless?.includes(f.fixture.ecaz));
|
||||
assert.ok(game.battle?.basicEarlyLeaderless?.includes(f.fixture.opponent));
|
||||
});
|
||||
|
||||
void test('An available Duke still prevents a notice when every Ecaz native leader is dead', () => {
|
||||
const f = basicNativeLeaderlessOccupy({ lead: 'ecaz', dead: 'selected', duke: true });
|
||||
assert.ok(f.game.battle?.preLeader && !f.game.battle.preLeader.closed,
|
||||
'The original Richese shared preparation remains available');
|
||||
const game = finishOccupyLeaderlessPreparation(f.game);
|
||||
assert.ok(!game.battle?.basicEarlyLeaderless?.includes(f.fixture.ecaz));
|
||||
assert.ok(game.battle?.basicEarlyLeaderless?.includes(f.fixture.opponent));
|
||||
});
|
||||
|
||||
void test('Canceled Ecaz Occupy retains the selected lead and its pre-leader opportunity', () => {
|
||||
const f = basicNativeLeaderlessOccupy({ lead: 'ally', dead: 'selected', cancel: true });
|
||||
assert.equal(f.game.battle?.ecazOccupy?.canceled, true);
|
||||
assert.equal(f.game.battle?.ecazOccupy?.lead, f.selected);
|
||||
assert.equal(f.game.battle?.basicDeadLeaderPreparation, true);
|
||||
const game = finishOccupyLeaderlessPreparation(f.game);
|
||||
assert.ok(game.battle?.basicEarlyLeaderless?.includes(f.selected));
|
||||
});
|
||||
|
||||
void test('Audit-entry Basic Occupy without Richese creates neutral preparation for the final dead-leader combatant', () => {
|
||||
const f = basicOccupyNoRicheseLeaderless();
|
||||
const b = f.game.battle!;
|
||||
assert.equal(b.saphoTimingVersion, undefined);
|
||||
assert.equal(b.ecazOccupy?.lead, f.fixture.ecaz);
|
||||
assert.equal(b.basicDeadLeaderPreparation, true);
|
||||
assert.ok(b.preLeader && !b.preLeader.closed);
|
||||
assert.ok(!b.preLeader.ready.includes(f.fixture.ecaz));
|
||||
assert.ok(b.preLeader.ready.includes(f.fixture.opponent));
|
||||
const game = finishOccupyLeaderlessPreparation(f.game);
|
||||
assert.ok(game.battle?.basicEarlyLeaderless?.includes(f.fixture.ecaz));
|
||||
});
|
||||
|
||||
void test('Audit-entry Occupy with an available Duke does not add an unnecessary dead-leader preparation', () => {
|
||||
const f = basicOccupyNoRicheseLeaderless(true);
|
||||
assert.equal(f.game.battle?.ecazOccupy?.lead, f.fixture.ecaz);
|
||||
assert.equal(f.game.battle?.basicDeadLeaderPreparation, undefined);
|
||||
assert.equal(f.game.battle?.preLeader, undefined);
|
||||
assert.ok(!f.game.battle?.basicEarlyLeaderless?.includes(f.fixture.ecaz));
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { advanceNativeLeaderless, basicNativeLeaderlessFixture, nativeLeaderlessClean,
|
||||
nativeLeaderlessPlayer as player } from './fixture-basic-native-early-leaderless';
|
||||
import { advanceBasicSemutaAmbassador, basicSemutaAmbassadorInventory } from './fixture-basic-semuta-ambassador';
|
||||
|
||||
const announce = (game: Game, id: string) => game.log.filter(entry =>
|
||||
entry.text === `${player(game, id).name} has no available leader or Cheap Hero for this battle.`);
|
||||
|
||||
/** Public setup, real movement and battle choice come from the native fixture.
|
||||
* The tests stage only prior leader use/death and conserved physical cards. */
|
||||
void test('Basic Richese keeps its shared preparation when a battle leader is dead', () => {
|
||||
const fixture = basicNativeLeaderlessFixture('richese', {dead: true, ghola: true});
|
||||
let game = advanceNativeLeaderless(fixture.game, state => !!state.battle && nativeLeaderlessClean(state) &&
|
||||
!state.battle.preparation && !!state.battle.preLeader && !state.battle.preLeader.closed);
|
||||
const battle = game.battle!;
|
||||
assert.equal(battle.basicEarlyLeaderlessVersion, 1);
|
||||
assert.equal(battle.basicDeadLeaderPreparation, true);
|
||||
assert.deepEqual(battle.preLeader!.ready, [], 'Richese Sapho preparation keeps both combatants involved');
|
||||
assert.equal(announce(game, 'richese').length, 0);
|
||||
const event = battle.preLeader!.event;
|
||||
game = applyAction(game, 'richese', {type:'battlePreparationReady', event});
|
||||
assert.equal(game.battle!.preLeader!.closed, false);
|
||||
assert.equal(announce(game, 'richese').length, 0);
|
||||
game = applyAction(game, 'guild', {type:'battlePreparationReady', event});
|
||||
assert.equal(game.battle!.preLeader!.closed, true);
|
||||
assert.equal(announce(game, 'richese').length, 1);
|
||||
const richese = player(game, 'richese'), ghola = richese.hand.find(card => card.effect === 'ghola');
|
||||
const leader = richese.leaders.find(item => item.dead); assert.ok(ghola && leader);
|
||||
game = applyAction(game, 'richese', {type:'card', card:ghola.id, leader:leader.id});
|
||||
game = advanceBasicSemutaAmbassador(game, state => !!state.battle && nativeLeaderlessClean(state));
|
||||
assert.equal(player(game, 'richese').leaders.find(item => item.id === leader.id)!.dead, false);
|
||||
assert.ok(!(viewGame(game, 'guild').battle!.earlyLeaderless ?? []).includes('richese'));
|
||||
});
|
||||
|
||||
void test('Basic Distrans receipt of a Cheap Hero corrects an unsealed early announcement', () => {
|
||||
const fixture = basicNativeLeaderlessFixture('richese');
|
||||
let game = structuredClone(fixture.beforeBattle);
|
||||
const recipient = player(game, 'guild'), donor = player(game, 'emperor');
|
||||
for (const leader of recipient.leaders) {
|
||||
assert.ok(!leader.dead && !leader.capturedBy);
|
||||
leader.usedAt = fixture.territory === 'carthag' ? 'arrakeen' : 'carthag';
|
||||
}
|
||||
// Keep the recipient below its hand limit with no existing Hero, then move
|
||||
// the original Distrans and Hero into the donor's hand without creating a card.
|
||||
for (let i = recipient.hand.length - 1; i >= 0; i--)
|
||||
if (recipient.hand[i].kind === 'hero' || recipient.hand.length >= 4)
|
||||
game.deck.push(...recipient.hand.splice(i, 1));
|
||||
while (donor.hand.length > 2) game.deck.push(donor.hand.pop()!);
|
||||
const distransIndex = game.richeseCache!.findIndex(card => card.effect === 'distrans');
|
||||
assert.ok(distransIndex >= 0);
|
||||
const distrans = game.richeseCache!.splice(distransIndex, 1)[0];
|
||||
const heroIndex = game.deck.findIndex(card => card.kind === 'hero'); assert.ok(heroIndex >= 0);
|
||||
const hero = game.deck.splice(heroIndex, 1)[0];
|
||||
donor.hand.push(distrans, hero);
|
||||
const inventory = basicSemutaAmbassadorInventory(game);
|
||||
game = applyAction(game, fixture.chooser, fixture.choose);
|
||||
game = advanceNativeLeaderless(game, state => !!state.battle && nativeLeaderlessClean(state) &&
|
||||
!state.battle.preparation && (!state.battle.preLeader || state.battle.preLeader.closed));
|
||||
assert.ok(viewGame(game, 'richese').battle!.earlyLeaderless?.includes('guild'));
|
||||
assert.equal(announce(game, 'guild').length, 1);
|
||||
assert.equal(viewGame(game, 'emperor').distrans?.blocked, null);
|
||||
game = applyAction(game, 'emperor', {type:'card', card:distrans.id, target:'guild', give:hero.id});
|
||||
game = advanceBasicSemutaAmbassador(game, state => !!state.battle && nativeLeaderlessClean(state));
|
||||
assert.ok(player(game, 'guild').hand.some(card => card.id === hero.id));
|
||||
assert.ok(!(viewGame(game, 'richese').battle!.earlyLeaderless ?? []).includes('guild'));
|
||||
assert.equal(game.log.filter(entry => entry.text ===
|
||||
`${player(game, 'guild').name} now has an available Cheap Hero for this battle.`).length, 1);
|
||||
assert.deepEqual(basicSemutaAmbassadorInventory(game), inventory);
|
||||
game = applyAction(game, 'guild', {type:'battlePlan', leader:hero.id, dial:0, support:0, weapon:null, defense:null});
|
||||
assert.equal(game.battle!.plans.guild.leader, hero.id);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { actBasicSemutaAmbassador as act } from './fixture-basic-semuta-ambassador';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type NativeLeaderlessFaction = Extract<FactionId, 'ixians' | 'tleilaxu' | 'choam' | 'richese' | 'ecaz' | 'moritani'>;
|
||||
export const nativeLeaderlessPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(candidate => candidate.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const nativeLeaderlessClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function advanceNativeLeaderless(game: Game, until: (state: Game) => boolean): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(step, `No native continuation at ${game.status}/${game.phase}/${game.decision?.kind}`);
|
||||
game = act(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('The native public battle did not reach the requested boundary');
|
||||
}
|
||||
|
||||
/** Fresh public selected-deck setup, original phase progression and paid
|
||||
* shipments create the battle. Only private leader-use/death and conserved
|
||||
* hand/deck history are staged before the genuine chooseBattle action. */
|
||||
export function basicNativeLeaderlessFixture(faction: NativeLeaderlessFaction,
|
||||
options: { dead?: boolean; hero?: boolean; ghola?: boolean } = {}) {
|
||||
const expansions = faction === 'ixians' || faction === 'tleilaxu' ? ['ix'] :
|
||||
faction === 'choam' || faction === 'richese' ? ['choam'] : ['ecaz'];
|
||||
let game = withClassicDiscoveryNexusLottery(() => createGame(`NATIVELEADERLESS-${faction}`,
|
||||
newPlayer(faction, faction, faction), false, expansions));
|
||||
for (const other of ['guild', 'emperor'] as const) joinGame(game, newPlayer(other, other, other));
|
||||
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
|
||||
game = act(game, faction, {type:'start'});
|
||||
const setup = structuredClone(game);
|
||||
game = advanceNativeLeaderless(game, state => state.status === 'playing' && state.phase === 5 && nativeLeaderlessClean(state));
|
||||
const selected = ['pasty_mesa', 'false_wall_west', 'shield_wall'].map(id => {
|
||||
const sectors: Record<string, number[]> = { pasty_mesa:[5,6,7,8], false_wall_west:[16,17,18], shield_wall:[8,9] };
|
||||
return {territory:id, sector:sectors[id].find(value => value !== game.storm)};
|
||||
}).find(row => row.sector !== undefined && game.players.every(player =>
|
||||
Object.keys(player.forces).every(key => !key.startsWith(`${row.territory}:`))));
|
||||
assert.ok(selected && selected.sector !== undefined);
|
||||
const territory = selected.territory, sector = selected.sector;
|
||||
while (game.phase === 5) {
|
||||
if (!nativeLeaderlessClean(game)) {
|
||||
game = advanceNativeLeaderless(game, state => state.phase !== 5 || nativeLeaderlessClean(state));
|
||||
continue;
|
||||
}
|
||||
const actor = game.active!;
|
||||
if (actor === faction || actor === 'guild') {
|
||||
game = act(game, actor, {type:'ship', territory, sector, amount:1, elite:0, allyPayment:0});
|
||||
game = advanceNativeLeaderless(game, state => state.phase !== 5 || state.active === actor && nativeLeaderlessClean(state));
|
||||
}
|
||||
if (game.phase === 5) game = act(game, actor, {type:'endMovement'});
|
||||
}
|
||||
game = advanceNativeLeaderless(game, state => state.phase === 6 && nativeLeaderlessClean(state) && !state.battle && !!state.active);
|
||||
const player = nativeLeaderlessPlayer(game, faction);
|
||||
for (const leader of player.leaders) {
|
||||
assert.ok(!leader.dead && !leader.capturedBy);
|
||||
leader.usedAt = territory === 'carthag' ? 'arrakeen' : 'carthag';
|
||||
}
|
||||
if (options.dead) {
|
||||
player.leaders[0].dead = true; player.leaders[0].deaths = Math.max(1, player.leaders[0].deaths ?? 0);
|
||||
delete player.leaders[0].usedAt;
|
||||
}
|
||||
// Exchange exact original physical cards with the remaining deck; never
|
||||
// create an extra card or increase the player's hand size.
|
||||
const swap = (handIndex: number, predicate: (card: Game['deck'][number]) => boolean) => {
|
||||
const index = game.deck.findIndex(predicate); assert.ok(index >= 0);
|
||||
[player.hand[handIndex], game.deck[index]] = [game.deck[index], player.hand[handIndex]];
|
||||
};
|
||||
for (let i = 0; i < player.hand.length; i++) if (player.hand[i].kind === 'hero')
|
||||
swap(i, card => card.kind !== 'hero' && (!options.ghola || card.effect !== 'ghola'));
|
||||
if (options.hero || options.ghola) {
|
||||
assert.ok(player.hand.length > 0);
|
||||
swap(0, card => options.hero ? card.kind === 'hero' : card.effect === 'ghola');
|
||||
}
|
||||
const chooser = game.active!;
|
||||
const choice = viewGame(game, chooser).battleChoices.find(row => row.territory === territory &&
|
||||
[row.attacker, row.defender].includes(faction) && [row.attacker, row.defender].includes('guild'));
|
||||
assert.ok(choice);
|
||||
const choose: Action = {type:'chooseBattle', territory, target:choice.attacker === chooser ? choice.defender : choice.attacker};
|
||||
const beforeBattle = structuredClone(game);
|
||||
game = act(game, chooser, choose);
|
||||
return {game, setup, beforeBattle, faction, opponent:'guild', territory, sector, chooser, choose};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { acquireDuke } from '../game/duke-vidal';
|
||||
import { basicEcazNativeFixture, nextBasicEcazNativeStep } from './fixture-basic-ecaz-native';
|
||||
import { basicEcazOccupyBattleAction, basicEcazOccupyFixture } from './fixture-basic-ecaz-occupy';
|
||||
import { leaderlessSeat, stageLeaderlessCard } from './fixture-basic-leaderless-harkonnen';
|
||||
|
||||
export type OccupyLeaderlessOptions = {
|
||||
lead: 'ecaz' | 'ally';
|
||||
dead: 'selected' | 'opponent' | 'unselected' | 'none';
|
||||
duke?: boolean;
|
||||
cancel?: boolean;
|
||||
};
|
||||
|
||||
/** Public Basic selected-deck setup and original alliance/coalition admission.
|
||||
* The shared fixture stages conserved board pieces and wallets. This fixture
|
||||
* stages native public leader history and conserves the physical hands. */
|
||||
export function basicNativeLeaderlessOccupy(options: OccupyLeaderlessOptions) {
|
||||
const fixture = basicEcazNativeFixture({ allyFaction: 'choam', opponentFaction: 'guild' });
|
||||
let game = structuredClone(fixture.beforeBattle);
|
||||
const selected = options.lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const unselected = options.lead === 'ecaz' ? fixture.ally : fixture.ecaz;
|
||||
const dead = options.dead === 'selected' ? selected : options.dead === 'opponent' ? fixture.opponent :
|
||||
options.dead === 'unselected' ? unselected : null;
|
||||
for (const actor of [fixture.ecaz, fixture.ally, fixture.opponent]) {
|
||||
stageLeaderlessCard(game, actor, 'none');
|
||||
for (const leader of leaderlessSeat(game, actor).leaders) {
|
||||
leader.dead = actor === dead;
|
||||
leader.deaths = actor === dead ? Math.max(1, leader.deaths) : leader.deaths;
|
||||
if (actor !== dead) leader.usedAt = 'arrakeen';
|
||||
else delete leader.usedAt;
|
||||
}
|
||||
}
|
||||
if (options.duke) {
|
||||
assert.ok(game.dukeVidal);
|
||||
game.dukeVidal = acquireDuke(game.dukeVidal, fixture.ecaz, game.turn, 'ecaz');
|
||||
}
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === fixture.territory);
|
||||
assert.ok(choice);
|
||||
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: fixture.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
|
||||
assert.equal(game.decision?.kind, 'ecazBattleLead');
|
||||
const event = game.decision.event;
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', event, lead: selected });
|
||||
if (options.cancel) {
|
||||
const card = leaderlessSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama');
|
||||
assert.ok(card);
|
||||
game = applyAction(game, fixture.opponent, { type: 'card', mode: 'cancel', card: card.id });
|
||||
}
|
||||
for (let attempts = 0; attempts < 100 && (game.pendingTreacheryDiscard || game.response || game.phaseOpening || game.decision); attempts++) {
|
||||
const next = nextBasicEcazNativeStep(game); assert.ok(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(game.battle && !game.pendingTreacheryDiscard && !game.response && !game.phaseOpening && !game.decision);
|
||||
return { fixture, game, selected, unselected, dead, event };
|
||||
}
|
||||
|
||||
/** Resolve existing Richese/shared preparation without submitting battle plans. */
|
||||
export function finishOccupyLeaderlessPreparation(state: Game): Game {
|
||||
let game = state;
|
||||
for (let attempts = 0; attempts < 100; attempts++) {
|
||||
if (game.battle && !game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed) &&
|
||||
!game.pendingTreacheryDiscard && !game.response && !game.phaseOpening && !game.decision) return game;
|
||||
const next = nextBasicEcazNativeStep(game); assert.ok(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
throw new Error('Native Occupy leader preparation did not finish');
|
||||
}
|
||||
|
||||
/** Supplemental audit-entry fixture without Richese: original setup, worm
|
||||
* alliance and shipments, plus labelled conserved rival and leader staging. */
|
||||
export function basicOccupyNoRicheseLeaderless(duke = false) {
|
||||
const fixture = basicEcazOccupyFixture({ chooseBattle: false });
|
||||
let game = structuredClone(fixture.game);
|
||||
assert.ok(!game.players.some(player => player.faction === 'richese'));
|
||||
stageLeaderlessCard(game, fixture.ecaz, 'none');
|
||||
for (const leader of leaderlessSeat(game, fixture.ecaz).leaders) {
|
||||
leader.dead = true;
|
||||
leader.deaths = Math.max(1, leader.deaths);
|
||||
delete leader.usedAt;
|
||||
}
|
||||
if (duke) {
|
||||
assert.ok(game.dukeVidal);
|
||||
game.dukeVidal = acquireDuke(game.dukeVidal, fixture.ecaz, game.turn, 'ecaz');
|
||||
}
|
||||
const chosen = basicEcazOccupyBattleAction({ ...fixture, game });
|
||||
game = applyAction(game, chosen.actor, chosen.action);
|
||||
assert.equal(game.decision?.kind, 'ecazBattleLead');
|
||||
game = applyAction(game, game.decision.player,
|
||||
{ type: 'decision', event: game.decision.event, lead: fixture.ecaz });
|
||||
for (let attempts = 0; attempts < 100 && (game.pendingTreacheryDiscard || game.response || game.decision); attempts++) {
|
||||
const next = nextBasicEcazNativeStep(game); assert.ok(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(game.battle && !game.pendingTreacheryDiscard && !game.response && !game.decision);
|
||||
return { fixture, game };
|
||||
}
|
||||
Reference in new issue
Block a user