Allow Salusa Ghola before battle commitments

This commit is contained in:
Quintine committed 2026-10-27 15:49:56 +11:00
1 parent b5fb6aeb64
commit 1bb7a99836
12 files changed
+483 -14

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { nativeShipmentSources } from '../game/homeworld-options';
import { actFamilyAtomics } from './fixture-family-atomics';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type SalusaGholaStep = StrongholdFactionsNativeStep;
export const actSalusaGhola = actFamilyAtomics;
export const salusaGholaPlayer = (game: Game, actor = 'emperor') => {
const p = game.players.find(p => p.id === actor); assert.ok(p); return p;
};
export const salusaGholaClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingShipment;
export function salusaGholaArmy(game: Game) {
const home = homeworldForceGroups(homeworldContext(game),game.homeworlds!.custody!)
.find(home => home.id === 'homeworld:emperor:salusa'); assert.ok(home);
return structuredClone(home.forces.emperor);
}
export function salusaGholaInventory(game: Game) {
homeworldGameIntegrity(game);
const cards = [...game.deck,...game.discard,...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index+Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(c => c.id).sort();
assert.deepEqual(cards,treacheryDeck([]).map(c => c.id).sort());
const traits = [...(game.traitorReserve ?? []),...game.players.flatMap(p => [...p.traitors,...p.traitorChoices])].sort();
const spice = [...game.spiceDeck,...game.spiceDiscard.flat()].map(c => JSON.stringify(c)).sort();
return {cards,traitors:traits,spice};
}
/** Native controls only, including current ordered Charity/Revival. A live
* unsealed plan and Ghola declaration remain caller choices. */
export function nextSalusaGhola(game: Game): SalusaGholaStep|null {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'guildTiming')
return {actor:game.decision.player,action:{type:'decision',take:false}};
if (game.decision?.kind === 'caladanReinforcement')
return {actor:game.decision.player,action:{type:'decision',event:game.decision.event,decline:true}};
if (game.decision?.kind === 'battleLosses' && game.decision.player === 'emperor') {
const choice = game.decision.options.findIndex(loss => loss.normal === 0 && loss.elite === 1);
assert.ok(choice >= 0,'The actual dial must offer exactly one Sardaukar casualty.');
return {actor:'emperor',action:{type:'decision',choice}};
}
if (game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed) {
const actor = [game.battle.attacker,game.battle.defender]
.find(id => !game.battle!.outcomeWindow!.ready.includes(id)); assert.ok(actor);
return {actor,action:{type:'battleOutcomeReady',event:game.battle.outcomeWindow.event}};
}
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceSalusaGhola(game: Game, until: (game: Game) => boolean,
actions: SalusaGholaStep[] = []): Game {
for (let n = 0; n < 1300; n++) {
if (until(game)) return game;
assert.notEqual(game.status,'finished');
const next = nextSalusaGhola(game);
assert.ok(next,`Choose the current native Ghola/plan action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actSalusaGhola(game,next.actor,next.action,actions);
}
assert.fail('The genuine Salusa/Ghola source did not reach its finite boundary.');
}
const planReady = (game: Game) => salusaGholaClean(game) && !!game.battle &&
!game.battle.revealed && !game.battle.preparation &&
(!game.battle.preLeader || game.battle.preLeader.closed);
function livingLeader(game: Game, actor: string, strongest: boolean) {
const leaders = salusaGholaPlayer(game,actor).leaders.filter(l => !l.dead && !l.usedAt && !l.capturedBy);
leaders.sort((a,b) => strongest ? b.strength-a.strength : a.strength-b.strength);
assert.ok(leaders[0]); return leaders[0].id;
}
/** Earned public one-turn source. Initial hands/Spice order are selected only
* while unopened; native setup, payments, typed shipments, split movement and
* the earlier combat casualty create every later resource and battle field. */
export function createSalusaGholaFixture() {
const actor = 'emperor', atreides = 'atreides', guild = 'guild';
let game = createGame('SALUSAGHOLA',newPlayer(actor,'Emperor','emperor'),true);
joinGame(game,newPlayer(atreides,'Atreides','atreides'));
joinGame(game,newPlayer(guild,'Guild','guild'));
const initial = structuredClone(game), actions: SalusaGholaStep[] = [];
const act = (owner: string, action: Action) => {game = actSalusaGhola(game,owner,action,actions);};
const reach = (until: (game: Game) => boolean) => {game = advanceSalusaGhola(game,until,actions);};
act(actor,{type:'homeworlds',enabled:true});
for (const p of game.players) act(p.id,{type:'ready'});
const lobby = structuredClone(game);
act(actor,{type:'start',advancedPreview:true});
const setup = structuredClone(game);
assert.equal(game.status,'setup'); assert.ok(game.players.every(p => p.hand.length === 0));
const ghola = game.deck.find(c => c.effect === 'ghola');
const shield = game.deck.find(c => c.kind === 'shield'), projectile = game.deck.find(c => c.kind === 'projectile');
assert.ok(ghola && shield && projectile);
const selected = [ghola,shield,projectile], dealOrder = selected.map((card,position) => {
const from = game.deck.findIndex(c => c.id === card.id); assert.ok(from >= position);
[game.deck[position],game.deck[from]] = [game.deck[from],game.deck[position]];
return {position,from,card:card.id};
});
const beforeDeal = structuredClone(game);
reach(g => g.status === 'playing');
const afterSetup = structuredClone(game), setupActions = structuredClone(actions);
assert.deepEqual(game.players.flatMap(p => p.hand.map(c => c.id)),selected.map(c => c.id));
assert.deepEqual(salusaGholaArmy(game),{normal:0,elite:5});
const original = salusaGholaInventory(game);
const spiceOrder = [0,1].map(position => {
const from = game.spiceDeck.findIndex((card,index) => index >= position &&
'territory' in card && !card.discovery && card.territory !== 'imperial_basin');
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from,1);
game.spiceDeck.splice(position,0,card); return {position,from,card:structuredClone(card)};
});
reach(g => g.phase === 5 && salusaGholaClean(g));
const shipments: {before:Game;after:Game;steps:SalusaGholaStep[]}[] = [];
let movement: {before:Game;after:Game;steps:SalusaGholaStep[]}|undefined;
for (let n = 0; game.phase === 5 && n < 200; n++) {
if (!salusaGholaClean(game)) {
const next = nextSalusaGhola(game); assert.ok(next); act(next.actor,next.action); continue;
}
const owner = game.active!;
if (owner === actor || owner === guild) {
const before = structuredClone(game), start = actions.length;
const amount = owner === actor ? 4 : 1, elite = owner === actor ? 4 : 0;
const homeworldSources = nativeShipmentSources(viewGame(game,owner),amount,elite); assert.ok(homeworldSources);
act(owner,{type:'ship',territory:'imperial_basin',sector:9,amount,elite,allyPayment:0,homeworldSources});
reach(salusaGholaClean);
shipments.push({before,after:structuredClone(game),steps:structuredClone(actions.slice(start))});
if (owner === actor) {
const beforeMove = structuredClone(game), at = actions.length;
act(owner,{type:'move',from:'imperial_basin:9',territory:'arrakeen',sector:10,amount:2,elite:2});
reach(salusaGholaClean);
movement = {before:beforeMove,after:structuredClone(game),steps:structuredClone(actions.slice(at))};
}
}
act(owner,{type:'endMovement'});
}
assert.ok(movement); assert.equal(game.phase,6);
reach(g => salusaGholaClean(g) && !g.battle && !!g.active);
const beforeFirstBattle = structuredClone(game), firstTerritory = game.active === guild ? 'imperial_basin' : 'arrakeen';
const firstOpponent = firstTerritory === 'arrakeen' ? atreides : guild;
const firstChooser = game.active!;
assert.ok(viewGame(game,firstChooser).battleChoices.some(c => c.territory === firstTerritory));
const firstAt = actions.length;
act(firstChooser,{type:'chooseBattle',territory:firstTerritory,target:firstChooser === actor ? firstOpponent : actor});
const firstPreparation = structuredClone(game);
reach(planReady);
const firstPlans = {[actor]:{leader:livingLeader(game,actor,true),dial:2,support:1},
[firstOpponent]:{leader:livingLeader(game,firstOpponent,false),dial:0,support:0}};
const firstBeforePlans = structuredClone(game);
for (const owner of [game.battle!.attacker,game.battle!.defender])
act(owner,{type:'battlePlan',...firstPlans[owner]});
reach(g => !g.battle && salusaGholaClean(g));
const firstResolution = structuredClone(game), firstActions = structuredClone(actions.slice(firstAt));
assert.equal(game.lastBattleContext?.winner,actor);
assert.equal(salusaGholaPlayer(game).elites!.tanks,1);
assert.deepEqual(salusaGholaArmy(game),{normal:0,elite:1});
const secondTerritory = firstTerritory === 'arrakeen' ? 'imperial_basin' : 'arrakeen';
const opponent = firstOpponent === atreides ? guild : atreides;
reach(g => !g.battle && salusaGholaClean(g) && !!g.active);
const beforeSecondBattle = structuredClone(game), chooser = game.active!;
assert.ok(viewGame(game,chooser).battleChoices.some(c => c.territory === secondTerritory));
act(chooser,{type:'chooseBattle',territory:secondTerritory,target:chooser === actor ? opponent : actor});
const preparation = structuredClone(game);
reach(planReady);
assert.deepEqual(game.battle!.plans,{});
assert.ok(salusaGholaPlayer(game).hand.some(c => c.id === ghola.id));
assert.equal(salusaGholaPlayer(game).elites!.tanks,1);
assert.equal(viewGame(game,actor).battle!.ownForces!.elite,2);
assert.deepEqual(salusaGholaInventory(game),original);
return {game,source:structuredClone(game),actor,card:ghola.id,
action:{type:'card',card:ghola.id,amount:1,elite:1} as Action,opponent,
territory:secondTerritory,firstTerritory,firstOpponent,firstPlans,
initial,lobby,setup,beforeDeal,afterSetup,dealOrder,spiceOrder,original,setupActions,
shipments,movement,beforeFirstBattle,firstPreparation,firstBeforePlans,firstResolution,firstActions,
beforeSecondBattle,preparation,actions,sourceActions:actions,sourceCount:actions.length,
disclosure:'Original public Advanced E/A/G with Homeworlds enabled before Ready/start, base33 and no optional modules. Only unopened starting cards, two land Spice positions and scalar lotteries selected. Real typed E4stars desert shipment costs8 and leaves Salusa1; real two-star movement creates Arrakeen contest, and real Guild1 shipment creates Basin contest. The first actual E win uses paid support1/dial2 and kills one real Sardaukar. Stop at the other actual live unsealed battle with Ghola held, Salusa1, tanks1star and two fighting stars. No later phase, force, purse, Tank, hand, battle commitment or receipt is installed.'};
}
export type SalusaGholaFixture = ReturnType<typeof createSalusaGholaFixture>;
export function playSalusaGholaBattle(f: SalusaGholaFixture, source = f.game) {
let game = structuredClone(source); const actions: SalusaGholaStep[] = [];
const before = structuredClone(game);
game = actSalusaGhola(game,f.actor,f.action,actions);
game = advanceSalusaGhola(game,planReady,actions);
const returned = structuredClone(game), leader = livingLeader(game,f.actor,true);
const enemy = livingLeader(game,f.opponent,false);
game = actSalusaGhola(game,f.actor,{type:'battlePlan',leader,dial:2,support:0},actions);
const afterPlan = structuredClone(game);
game = actSalusaGhola(game,f.opponent,{type:'battlePlan',leader:enemy,dial:0,support:0},actions);
game = advanceSalusaGhola(game,g => !g.battle && salusaGholaClean(g),actions);
return {game,before,returned,afterPlan,actions};
}
+43 -4
View File
@@ -343,10 +343,29 @@ function gholaThresholdBattle(normal = 1) {
return { g, card: card.id };
}
void test('unsupported Ghola crossing of Salusa during battle rejects before and after a sealed paid-star plan without corrupting the saved decision', () => {
void test('Ghola can raise Salusa before battle commitments and the same battle uses current free Sardaukar support', () => {
const { g: before, card } = gholaThresholdBattle();
assert.equal(homeworldSardaukarGholaBlock(before, 'e'), null);
assert.equal(viewGame(before, 'e').ghola.eliteRemaining, 1);
const done = applyAction(before, 'e', { type: 'card', card, amount: 1, elite: 1 });
assert.equal(done.homeworlds!.custody!.salusa!.elite, 2);
assert.equal(emperor(done).elites!.tanks, 0);
assert.equal(emperor(done).elites!.revived, 1);
assert.equal(done.discard.filter((held) => held.id === card).length, 1);
assert.equal(viewGame(done, 'e').battle!.ownForces!.eliteFreeSupport, true);
assert.equal(viewGame(done, 'e').battle!.ownForces!.elite, 3);
assert.equal(emperor(done).spice, 3);
const resolved = resolve(done, 6);
assert.equal(emperor(resolved).forces['arrakeen:10'], 1);
assert.equal(emperor(resolved).spice, 5, 'Only automatic Arrakeen collection adds spice.');
assert.equal(resolved.homeworlds!.custody!.salusa!.elite, 2);
inventory(resolved);
});
void test('the remaining Salusa preview guard protects a sealed paid-star plan until its actual battle completes', () => {
const { g: before, card } = gholaThresholdBattle();
const sealed = applyAction(before, 'e', plan(before, 6, 3));
for (const state of [before, sealed]) {
for (const state of [sealed]) {
const g = reload(state);
const reason = homeworldSardaukarGholaBlock(g, 'e');
assert.ok(
@@ -395,9 +414,11 @@ void test('unsupported Ghola crossing of Salusa during battle rejects before and
inventory(g);
});
void test('the temporary Salusa threshold gate leaves normal-force and eligible-leader Ghola choices available during battle', () => {
void test('the Salusa commitment guard leaves normal-force and eligible-leader Ghola choices available during battle', () => {
for (const kind of ['normal', 'leader'] as const) {
const { g: before, card } = gholaThresholdBattle();
const fixture = gholaThresholdBattle();
const before = applyAction(fixture.g, 'e', plan(fixture.g, 6, 3));
const card = fixture.card;
const player = emperor(before);
let command: Action;
let leaderId: string | undefined;
@@ -434,6 +455,24 @@ void test('the temporary Salusa threshold gate leaves normal-force and eligible-
}
});
void test('an opponent-only sealed plan keeps the remaining Salusa timing boundary visible before Emperor submits', () => {
const fixture = gholaThresholdBattle();
const g = applyAction(fixture.g, 'a', {
type: 'battlePlan', dial: 0, support: 0, leader: atreides(fixture.g).leaders[0].id,
});
assert.equal(g.battle!.plans.e, undefined);
assert.ok(g.battle!.plans.a);
const view = viewGame(g, 'e');
assert.deepEqual(view.battle!.plans, {});
assert.equal(view.ghola.eliteRemaining, 0);
assert.match(view.ghola.eliteBlock!, /not yet supported/);
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'e', {
type: 'card', card: fixture.card, amount: 1, elite: 1,
}), /not yet supported/);
assert.deepEqual(g, before);
});
void test('Salusa threshold protection also preserves an answered Prescience dial before either full plan is sealed', () => {
const fixture = gholaThresholdBattle(0);
const card = fixture.card;
+114
View File
@@ -0,0 +1,114 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
createSalusaGholaFixture, playSalusaGholaBattle, actSalusaGhola as act,
salusaGholaPlayer as p, salusaGholaArmy as army,
salusaGholaInventory as inventory, type SalusaGholaFixture,
} from './fixture-salusa-ghola';
let earned: SalusaGholaFixture|undefined;
const fixture = () => earned ??= createSalusaGholaFixture();
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game,actor,action));
assert.deepEqual(game,before);
}
void test('earned public Salusa source has one actual Sardaukar casualty and a later live commitment-free battle', () => {
const f = fixture();
assert.equal(f.initial.status,'lobby');
assert.deepEqual(f.initial.players.map(p => p.faction),['emperor','atreides','guild']);
assert.ok(f.lobby.homeworlds); assert.equal(f.setup.status,'setup');
assert.ok(f.beforeDeal.players.every(p => p.hand.length === 0));
assert.equal(f.afterSetup.advanced,true);
assert.deepEqual(f.afterSetup.expansions,[]);
assert.ok(!f.afterSetup.nexusCards && !f.afterSetup.leaderSkills && !f.afterSetup.discoveryEnabled);
assert.equal(p(f.afterSetup).hand[0].id,f.card);
assert.deepEqual(army(f.afterSetup),{normal:0,elite:5});
assert.equal(f.original.cards.length,33);
assert.equal(f.sourceCount,f.sourceActions.length);
const ship = f.shipments.find(s => s.steps[0].actor === f.actor)!;
assert.ok(ship);
assert.equal(ship.steps[0].action.type,'ship');
assert.equal(ship.steps[0].action.amount,4);
assert.equal(ship.steps[0].action.elite,4);
assert.equal(p(ship.before).spice-p(ship.after).spice,8);
assert.deepEqual(army(ship.after),{normal:0,elite:1});
assert.equal(f.movement.steps[0].action.type,'move');
assert.equal(f.movement.steps[0].action.amount,2);
assert.equal(f.movement.steps[0].action.elite,2);
assert.ok(f.firstActions.filter(s => s.action.type === 'battlePlan').length === 2);
assert.equal(f.firstResolution.lastBattleContext!.winner,f.actor);
assert.equal(p(f.firstResolution).tanks,1);
assert.equal(p(f.firstResolution).elites!.tanks,1);
assert.equal(p(f.firstBeforePlans).spice-p(f.firstResolution).spice,1);
assert.equal(f.game.battle!.territory,f.territory);
assert.deepEqual(f.game.battle!.plans,{});
assert.equal(f.game.battle!.revealed,false);
assert.equal(f.game.truthtrance ?? null,null);
assert.equal(f.game.turnActionPromises,undefined);
assert.equal(viewGame(f.game,f.actor).battle!.ownForces!.elite,2);
assert.equal(viewGame(f.game,f.actor).battle!.ownForces!.eliteFreeSupport,undefined);
assert.deepEqual(army(f.game),{normal:0,elite:1});
assert.deepEqual(inventory(f.game),f.original);
assert.match(f.disclosure,/Original public.*No later phase, force, purse, Tank, hand, battle commitment or receipt is installed/);
});
void test('actual uncommitted-battle Ghola returns the dead Sardaukar to Salusa2 and immediately changes the native support view', () => {
const f = fixture(), before = structuredClone(f.game);
reject(before,f.opponent,f.action);
const game = act(before,f.actor,f.action), player = p(game);
assert.deepEqual(army(game),{normal:0,elite:2});
assert.equal(player.tanks,p(before).tanks-1);
assert.equal(player.elites!.tanks,p(before).elites!.tanks-1);
assert.equal(player.reserves,p(before).reserves+1);
assert.equal(player.elites!.reserves,p(before).elites!.reserves+1);
assert.equal(player.spice,p(before).spice);
assert.deepEqual(player.forces,p(before).forces);
assert.deepEqual(player.elites!.forces,p(before).elites!.forces);
assert.deepEqual(game.battle!.plans,{});
assert.equal(game.battle!.event,before.battle!.event);
assert.equal(viewGame(game,f.actor).battle!.ownForces!.eliteFreeSupport,true);
assert.equal(viewGame(game,f.actor).battle!.ownForces!.elite,2);
assert.equal(game.discard.filter(c => c.id === f.card).length,1);
assert.equal(player.hand.some(c => c.id === f.card),false);
reject(game,f.actor,f.action);
assert.deepEqual(inventory(game),f.original);
});
void test('native plans and calls after Salusa Ghola use zero-spice support and settle actual star casualties', () => {
const f = fixture(), result = playSalusaGholaBattle(f);
assert.deepEqual(army(result.returned),{normal:0,elite:2});
assert.equal(result.afterPlan.battle!.plans[f.actor].support,0);
assert.equal(result.afterPlan.battle!.plans[f.actor].dial,2);
assert.equal(p(result.afterPlan).spice,p(result.returned).spice);
assert.equal(result.game.lastBattleContext!.winner,f.actor);
assert.equal(result.game.lastBattleContext!.territory,f.territory);
assert.equal(result.game.battle,null);
assert.equal(p(result.game).elites!.tanks,1);
assert.deepEqual(army(result.game),{normal:0,elite:2});
assert.equal(result.actions.filter(s => s.action.type === 'battlePlan').length,2);
assert.equal(result.actions.filter(s => s.action.type === 'traitorCall').length,2);
assert.equal(result.game.discard.filter(c => c.id === f.card).length,1);
assert.deepEqual(inventory(result.game),f.original);
});
void test('all four minimal policies propose legal native plans after the actual Salusa Ghola return', () => {
const f = fixture(), returned = act(structuredClone(f.game),f.actor,f.action);
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
const game = structuredClone(returned), view = viewGame(game,f.actor);
view.players.find(p => p.id === f.actor)!.bot = profile;
const plans = botActions(view).filter(action => action.type === 'battlePlan');
assert.ok(plans.length,`${profile} needs a legal current Battle Plan.`);
for (const plan of plans) {
assert.equal(plan.support ?? 0,0);
const next = act(game,f.actor,plan);
assert.ok(next.battle!.plans[f.actor]);
assert.deepEqual(army(next),{normal:0,elite:2});
assert.equal(next.discard.filter(c => c.id === f.card).length,1);
assert.deepEqual(inventory(next),f.original);
}
}
});