Prototype Emperor Nexus shortage handling

This commit is contained in:
Quintine committed 2026-10-27 16:57:12 +11:00
1 parent b620cea873
commit e5c7e338e8
31 files changed
+1057 -95

No files matched your search

+15
View File
@@ -68,6 +68,18 @@ export function nexusFixture(
export function nexusReady(state: Game): Game {
let g = state;
// Native ordered phases finish their actual current actor, rather than the
// roster order used by legacy simultaneous readiness. Never skip a response.
const phase = state.phase;
const ordered = () => phase === 2 ? g.charityOrder : phase === 4 ? g.revivalOrder : undefined;
if (ordered() && !g.phaseOpening && !g.response && !g.decision && !g.truthtrance) {
for (let pass = 0; pass < state.players.length && g.phase === phase && ordered(); pass++) {
const owner = ordered()!.remaining[0]; assert.ok(owner);
g = applyAction(g, owner, {type:'ready'});
if (g.phaseOpening || g.response || g.decision || g.truthtrance) break;
}
return g;
}
for (const p of state.players)
if (!g.ready.includes(p.id)) g = applyAction(g, p.id, { type: 'ready' });
return g;
@@ -95,6 +107,9 @@ export function enterNexusSpice(state: Game, nextTurn = false): Game {
stormDialers: g.players.slice(0, 2).map((p) => p.id),
stormDials: {},
});
// This controlled next-turn jump skips the previous Charity phase; its
// ordered frame cannot describe the staged new Storm boundary.
delete g.charityOrder;
}
assert.equal(g.phase, 0);
for (const id of g.stormDialers)
+3
View File
@@ -69,6 +69,9 @@ export function nexusSardaukarFixture(
decision: null,
phaseOpening: null,
});
// The parent fixture explicitly stages Battle rather than playing through
// Charity; discard only that earlier phase's ordered fixture frame.
delete g.charityOrder;
g = applyAction(g, owner.id, {
type: 'chooseBattle',
territory: 'pasty_mesa',
+129
View File
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { combatArmy } from '../game/combat-location';
import {
drawClassicNexusReaction, reactionAction, reactionPlayer,
} from './fixture-classic-independent-nexus-reactions';
import { advanceClassicIndependentNexus, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
import { nextClassicDiscoveryNexusStep, classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
export type NexusShortCunningStep = ClassicIndependentNexusStep;
export const actNexusShortCunning = reactionAction;
export const nexusShortCunningPlayer = reactionPlayer;
export const nexusShortCunningClean = (game: Game) => classicDiscoveryNexusClean(game) &&
!game.pendingShipment && !game.pendingGuildBetrayal;
export function nexusShortCunningInventory(game: 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,baseDeck().map(c => c.id).sort());
const native = game.nexusCards!.cards!;
const nexus = [...native.deck,...native.discard,...Object.values(native.hands).filter(c => c !== null)].sort();
assert.deepEqual(nexus,[...NEXUS_FACTIONS].sort());
assert.equal(new Set(nexus).size,nexus.length);
const traitors = [...(game.traitorReserve ?? []),...game.players.flatMap(p => [...p.traitors,...p.traitorChoices])].sort();
const spice = [...game.spiceDeck,...game.spiceDiscard.flat()].map(c => JSON.stringify(c)).sort();
for (const p of game.players)
assert.equal(p.reserves+p.tanks+Object.values(p.forces).reduce((sum,n) => sum+n,0),20);
return {cards,nexus,traitors,spice};
}
export function advanceNexusShortCunning(game: Game, until: (game: Game) => boolean,
actions: NexusShortCunningStep[] = []): Game {
return advanceClassicIndependentNexus(game,until,actions);
}
const planReady = (game: Game) => nexusShortCunningClean(game) && !!game.battle &&
!game.battle.revealed && !game.battle.preparation && game.battle.preLeader?.closed !== false;
/** Original public Advanced A/G/E Nexus-only source. The real turn-two worm,
* reciprocal A/G alliance and closing Nexus draw give unallied E its own card;
* the only force producer is E's actual smaller normal shipment to Arrakeen.
* Initial Treachery/Nexus/Spice positions and scalar lotteries are selected
* while unused. No played hand, army, wallet, phase or receipt is installed. */
export function createNexusShortCunningFixture(options: {amount?:2|4;karama?:boolean} = {}) {
const amount = options.amount ?? 2, actor = 'emperor', opponent = 'atreides';
const cards = baseDeck(), counter = cards.find(c => c.effect === 'karama')!;
const shield = cards.find(c => c.kind === 'shield')!, projectile = cards.find(c => c.kind === 'projectile')!;
const ordinary = cards.find(c => c.kind === 'worthless')!;
const base = drawClassicNexusReaction('emperor',actor,{advanced:true,independent:false,
roster:['atreides','guild','emperor'],startingCards:{
atreides:[options.karama === false ? ordinary.id : counter.id],guild:[projectile.id],emperor:[shield.id],
}});
let game = base.game; const actions = [...base.actions];
const original = nexusShortCunningInventory(game);
assert.equal(game.nexusCards!.cards!.hands[actor],'emperor');
assert.equal(reactionPlayer(game,actor).ally,null);
assert.equal(reactionPlayer(game,opponent).ally,'guild');
game = advanceNexusShortCunning(game,g => g.phase === 5 && g.active === actor &&
nexusShortCunningClean(g),actions);
const beforeShipment = structuredClone(game), shipmentAt = actions.length;
game = actNexusShortCunning(game,actor,{type:'ship',territory:'arrakeen',sector:10,
amount,elite:0,allyPayment:0},actions);
game = advanceNexusShortCunning(game,nexusShortCunningClean,actions);
const afterShipment = structuredClone(game), shipment = {
before:beforeShipment,after:afterShipment,steps:structuredClone(actions.slice(shipmentAt)),
cost:reactionPlayer(beforeShipment,actor).spice-reactionPlayer(afterShipment,actor).spice,
};
assert.equal(shipment.cost,amount);
assert.deepEqual(combatArmy(game,actor,'arrakeen'),{normal:amount,elite:0});
game = advanceNexusShortCunning(game,g => g.phase === 6 && !!g.active &&
nexusShortCunningClean(g),actions);
const beforeBattle = structuredClone(game), chooser = game.active!;
assert.ok(viewGame(game,chooser).battleChoices.some(c => c.territory === 'arrakeen'));
game = actNexusShortCunning(game,chooser,{type:'chooseBattle',territory:'arrakeen',
target:chooser === actor ? opponent : actor},actions);
const preparation = structuredClone(game);
game = advanceNexusShortCunning(game,planReady,actions);
const leader = reactionPlayer(game,actor).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a,b) => b.strength-a.strength)[0];
const enemy = reactionPlayer(game,opponent).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a,b) => a.strength-b.strength)[0];
assert.ok(leader && enemy);
assert.deepEqual(game.battle!.plans,{});
assert.deepEqual(combatArmy(game,actor,'arrakeen'),{normal:amount,elite:0});
assert.ok(!game.homeworlds && !game.leaderSkills && !game.discoveryEnabled &&
!game.techTokens && !game.strongholdCards && !game.ecazTreachery);
assert.deepEqual(nexusShortCunningInventory(game),original);
const event = viewGame(game,actor).nexusSardaukar!.offer!.event;
return {game,source:structuredClone(game),actor,owner:actor,opponent,amount,event,
card:'emperor' as const,karama:options.karama === false ? null : counter.id,
action:{type:'nexusSardaukar',event} as Action,leader:leader.id,opponentLeader:enemy.id,
plan:{type:'battlePlan',leader:leader.id,dial:2,support:1} as Action,
canceledPlan:{type:'battlePlan',leader:leader.id,dial:1,support:1} as Action,
opponentPlan:{type:'battlePlan',leader:enemy.id,dial:0,support:0} as Action,
key:'arrakeen:10',territory:'arrakeen',base,shipment,beforeBattle,preparation,original,
actions,sourceActions:actions,sourceCount:actions.length,
disclosure:'Original public Advanced A/G/E with Nexus opt-in before Ready/start and base33 only. Actual first turn, turn-two worm, reciprocal A/G alliance and original closing draw earn the unique Emperor card. Only unused original Treachery/Nexus/Spice positions and scalar lotteries are selected. E really ships the requested short ordinary group to original A10 Arrakeen, at its actual city price. No played hand, wallet, force, phase, alliance, Nexus holding, battle, promise or receipt is installed.'};
}
export type NexusShortCunningFixture = ReturnType<typeof createNexusShortCunningFixture>;
export function resolveNexusShortCunning(f: NexusShortCunningFixture, canceled = false, source = f.game) {
const actions: NexusShortCunningStep[] = [];
let game = actNexusShortCunning(structuredClone(source),f.actor,f.action,actions);
const declared = structuredClone(game);
if (canceled) {
assert.ok(f.karama);
assert.equal(game.response?.kind,'nexusSardaukar');
game = actNexusShortCunning(game,f.opponent,{type:'card',card:f.karama,mode:'cancel'},actions);
} else game = advanceNexusShortCunning(game,nexusShortCunningClean,actions);
assert.equal(game.nexusSardaukarLast?.stage,canceled ? 'canceled' : 'active');
return {game,declared,actions};
}
export function finishNexusShortCunning(f: NexusShortCunningFixture, source: Game,
plan: Action = f.plan, actions: NexusShortCunningStep[] = []) {
let game = actNexusShortCunning(source,f.actor,plan,actions);
const afterPlan = structuredClone(game);
game = actNexusShortCunning(game,f.opponent,f.opponentPlan,actions);
const revealed = structuredClone(game);
for (let n = 0; game.battle && n < 100; n++) {
const choice = game.decision?.kind === 'battleLosses' && game.decision.player === f.actor
? game.decision.options.findIndex(loss => loss.normal === 1 && loss.elite === 0) : -1;
const next = choice >= 0 ? {actor:f.actor,action:{type:'decision',choice} as Action}
: nextClassicDiscoveryNexusStep(game); assert.ok(next);
game = actNexusShortCunning(game,next.actor,next.action,actions);
}
assert.equal(game.battle,null);
return {game,afterPlan,revealed,actions};
}
+153
View File
@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import { normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { emperorNexusRevivalCount } from '../game/nexus-emperor-secret-ally';
import {
createClassicIndependentNexusFixture, advanceClassicIndependentNexus, type ClassicIndependentNexusStep,
} from './fixture-classic-independent-nexus';
import {
applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep,
} from './fixture-discovery-classic-nexus';
export type NexusShortRevivalStep = ClassicIndependentNexusStep;
export const actNexusShortRevival = (game: Game, actor: string, action: Action,
actions: NexusShortRevivalStep[] = []) => applyClassicDiscoveryNexusStep(game,{actor,action},actions);
export const nexusShortRevivalPlayer = (game: Game, actor = 'atreides') => {
const p = game.players.find(p => p.id === actor); assert.ok(p); return p;
};
export const nexusShortRevivalClean = (game: Game) => classicDiscoveryNexusClean(game) &&
!game.pendingRevival && !game.pendingShipment && !game.pendingGuildBetrayal;
export function nexusShortRevivalInventory(game: 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,baseDeck().map(c => c.id).sort());
const state = game.nexusCards!.cards!;
const nexus = [...state.deck,...state.discard,...Object.values(state.hands).filter(card => card !== null)].sort();
assert.deepEqual(nexus,[...NEXUS_FACTIONS].sort());
const traitors = [...(game.traitorReserve ?? []),...game.players.flatMap(p => [...p.traitors,...p.traitorChoices])].sort();
const spice = [...game.spiceDeck,...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
for (const p of game.players)
assert.equal(p.reserves+p.tanks+Object.values(p.forces).reduce((sum,n) => sum+n,0),20);
return {cards,nexus,traitors,spice};
}
export function nextNexusShortRevival(game: Game, losses?:1|2): NexusShortRevivalStep|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 === 'battleLosses' && game.decision.player === 'atreides' && losses) {
const choice = game.decision.options.findIndex(loss => loss.normal === losses && loss.elite === 0);
assert.ok(choice >= 0,'Select the actual paid ordinary loss promised by this source.');
return {actor:'atreides',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 nextClassicDiscoveryNexusStep(game);
}
export function advanceNexusShortRevival(game: Game, until: (game: Game) => boolean,
actions: NexusShortRevivalStep[] = [], losses?:1|2): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status,'finished');
if (game.pendingGuildBetrayal) {
game = advanceClassicIndependentNexus(game,g => !g.pendingGuildBetrayal,actions);
continue;
}
const step = nextNexusShortRevival(game,losses);
assert.ok(step,`Choose the native original plan/control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actNexusShortRevival(game,step.actor,step.action,actions);
}
assert.fail('The genuine short-revival chronology did not reach its finite boundary.');
}
/** Actual public A/G/H Nexus-only setup and battle-to-revival producer. Initial
* unopened deals/Spice order/Nexus draw and scalar entropy are the only choices
* outside actions. Dead counters, card holding, alliance, phase and allowances
* are all native results; no played resource or receipt is installed. */
export function createNexusShortRevivalFixture(options: {amount?:1|2} = {}) {
const amount = options.amount ?? 1, actor = 'atreides', guild = 'guild', harkonnen = 'harkonnen';
const cards = baseDeck(), shield = cards.find(c => c.kind === 'shield')!, projectile = cards.find(c => c.kind === 'projectile')!;
const worthless = cards.find(c => c.kind === 'worthless')!, snooper = cards.find(c => c.kind === 'snooper')!;
const base = createClassicIndependentNexusFixture({advanced:true,independent:false,
roster:['atreides','guild','harkonnen'],startingCards:{atreides:[shield.id],guild:[projectile.id],
harkonnen:[worthless.id,snooper.id]}});
let game = base.game; const actions = [...base.actions];
const original = nexusShortRevivalInventory(game), afterSetup = structuredClone(game);
const act = (owner: string, action: Action) => {game = actNexusShortRevival(game,owner,action,actions);};
const reach = (until: (game: Game) => boolean) => {game = advanceNexusShortRevival(game,until,actions,amount);};
const spiceOrder: {position:number;from:number;card:Game['spiceDeck'][number]}[] = [];
for (const [position,worm] of [false,false,true,false,false].entries()) {
const from = game.spiceDeck.findIndex((card,index) => index >= position &&
(worm ? 'worm' in card : 'territory' in card)); assert.ok(from >= position);
const [card] = game.spiceDeck.splice(from,1); game.spiceDeck.splice(position,0,card);
spiceOrder.push({position,from,card:structuredClone(card)});
}
reach(g => g.turn === 1 && g.phase === 5 && g.active === guild && nexusShortRevivalClean(g));
const beforeShipment = structuredClone(game), shipmentAt = actions.length;
act(guild,{type:'ship',territory:'arrakeen',sector:10,amount:1,elite:0,allyPayment:0});
reach(nexusShortRevivalClean);
const shipment = {before:beforeShipment,after:structuredClone(game),steps:structuredClone(actions.slice(shipmentAt))};
assert.equal(nexusShortRevivalPlayer(game,guild).forces['arrakeen:10'],1);
reach(g => g.turn === 1 && g.phase === 6 && !!g.active && nexusShortRevivalClean(g));
const beforeBattle = structuredClone(game), battleAt = actions.length, chooser = game.active!;
assert.ok(viewGame(game,chooser).battleChoices.some(choice => choice.territory === 'arrakeen'));
act(chooser,{type:'chooseBattle',territory:'arrakeen',target:chooser === actor ? guild : actor});
reach(g => nexusShortRevivalClean(g) && !!g.battle && !g.battle.revealed &&
!g.battle.preparation && g.battle.preLeader?.closed !== false);
const own = nexusShortRevivalPlayer(game,actor).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a,b) => b.strength-a.strength)[0];
const other = nexusShortRevivalPlayer(game,guild).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a,b) => a.strength-b.strength)[0]; assert.ok(own && other);
const plans: Record<string, {leader:string;dial:number;support:number}> = {
[actor]:{leader:own.id,dial:amount,support:amount},[guild]:{leader:other.id,dial:0,support:0},
};
for (const owner of [game.battle!.attacker,game.battle!.defender]) act(owner,{type:'battlePlan',...plans[owner]});
reach(g => !g.battle && nexusShortRevivalClean(g));
const battleResolution = structuredClone(game), battleActions = structuredClone(actions.slice(battleAt));
assert.equal(game.lastBattleContext!.winner,actor);
assert.equal(nexusShortRevivalPlayer(game,actor).tanks,amount);
assert.equal(nexusShortRevivalPlayer(game,actor).forces['arrakeen:10'],10-amount);
reach(g => g.turn === 2 && g.nexus && !g.spiceWindow && !g.spiceResolution && nexusShortRevivalClean(g));
const nexus = structuredClone(game), allianceAt = actions.length;
act(guild,{type:'alliance',target:harkonnen}); act(harkonnen,{type:'alliance',target:guild});
const alliance = {steps:structuredClone(actions.slice(allianceAt)),after:structuredClone(game)};
reach(g => g.nexusCards?.phase?.stage === 'drawing');
const state = game.nexusCards!.cards!, from = state.deck.indexOf('emperor'); assert.ok(from >= 0);
state.deck.unshift(state.deck.splice(from,1)[0]);
const beforeDraw = structuredClone(game), drawAt = actions.length;
act(actor,{type:'nexusCardChoice',turn:game.turn,card:state.hands[actor],choice:'draw',ownRedraws:0});
const afterDraw = structuredClone(game), drawActions = structuredClone(actions.slice(drawAt));
assert.equal(game.nexusCards!.cards!.hands[actor],'emperor');
reach(g => g.turn === 2 && g.phase === 4 && nexusShortRevivalClean(g) &&
(!g.revivalOrder || g.revivalOrder.remaining[0] === actor));
assert.equal(nexusShortRevivalPlayer(game,actor).tanks,amount);
assert.equal(nexusShortRevivalPlayer(game,actor).revived,0);
const offer = viewGame(game,actor).nexusEmperorSecretAlly; assert.ok(offer);
assert.equal(offer.revival.amount,amount); assert.equal(offer.revival.blocked,null);
assert.deepEqual(nexusShortRevivalInventory(game),original);
return {game,source:structuredClone(game),actor,amount,event:offer.event,
action:{type:'nexusEmperorRevive',event:offer.event,elite:0} as Action,
base,afterSetup,shipment,beforeBattle,plans,battleResolution,battleActions,nexus,alliance,
beforeDraw,afterDraw,drawActions,nexusOrder:{from,card:'emperor' as const},
beforeRevival:structuredClone(game),spiceOrder,original,actions,sourceActions:actions,sourceCount:actions.length,
disclosure:'Original public Advanced A/G/H Nexus-only base33 setup, selected unopened four-card deal, five original Spice positions and scalar lotteries. Guild really ships1 to original A10 Arrakeen; real A leader5/dial1or2/paid support1or2 wins and creates exactly the requested one/two ordinary Tanks. Actual next-turn worm and G–H reciprocal alliance leave A unallied; original unused Emperor Nexus position is selected before A’s real closing draw. Native phase controls and current Revival queue stop before A ordinary return. No played hand, purse, army, Tank, phase, alliance, Nexus holding, quota or receipt is installed.'};
}
export type NexusShortRevivalFixture = ReturnType<typeof createNexusShortRevivalFixture>;
export function applyNexusShortRevival(f: NexusShortRevivalFixture, source = f.game) {
const actions: NexusShortRevivalStep[] = [], before = structuredClone(source);
let game = actNexusShortRevival(structuredClone(source),f.actor,f.action,actions);
const declared = structuredClone(game);
game = normalizeAutomaticGame(game);
assert.equal(game.nexusEmperorSecretHistory!.at(-1)!.stage,'returned');
assert.equal(emperorNexusRevivalCount(game.nexusEmperorSecretHistory!.at(-1)!),f.amount);
assert.equal(nexusShortRevivalPlayer(game,f.actor).tanks,0);
assert.deepEqual(nexusShortRevivalInventory(game),f.original);
return {game,before,declared,actions};
}
+22 -9
View File
@@ -42,6 +42,19 @@ function allow(game: Game, native: string): Game {
assert.equal(game.decision!.player, native);
return settleNexusEmperorExtra(applyAction(game, native, { type: 'decision', decline: true }));
}
function ownRevival(game: Game, owner: string): Game {
for (let n = 0; game.revivalOrder && game.revivalOrder.remaining[0] !== owner && n < game.players.length; n++) {
assert.equal(game.phase,4);
assert.ok(game.revivalOrder.remaining.includes(owner),'The original owner must still have an unfinished Revival turn.');
game = settleNexusEmperorExtra(applyAction(game,game.revivalOrder.remaining[0],{type:'ready'}));
}
assert.ok(!game.revivalOrder || game.revivalOrder.remaining[0] === owner);
return game;
}
function ordinaryRevive(game: Game, owner: string, action: Action): Game {
assert.equal(action.type,'revive');
return applyAction(ownRevival(game,owner),owner,action);
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced' : 'Basic';
@@ -73,7 +86,7 @@ for (const advanced of [false, true]) {
const extra = settleNexusEmperorExtra(applyAction(f.game, f.owner, grant(f.game, f.owner)));
assert.deepEqual(forceRevivalQuote(extra, player(extra, f.owner), 3), quote);
assert.equal(forceRevivalRemaining(extra, player(extra, f.owner)), 3);
const ordinary = settleNexusEmperorExtra(applyAction(extra, f.owner, { type: 'revive', amount: 3 }));
const ordinary = settleNexusEmperorExtra(ordinaryRevive(extra, f.owner, { type: 'revive', amount: 3 }));
const holder = player(ordinary, f.owner);
assert.equal(holder.reserves, player(f.game, f.owner).reserves + 6);
assert.equal(holder.tanks, player(f.game, f.owner).tanks - 6);
@@ -88,7 +101,7 @@ for (const advanced of [false, true]) {
assert.equal(player(next, f.owner).freeForcesRevived, 0);
receipt(next, 'returned');
const nextQuote = forceRevivalQuote(next, player(next, f.owner), 2);
const later = settleNexusEmperorExtra(applyAction(next, f.owner, { type: 'revive', amount: 2 }));
const later = settleNexusEmperorExtra(ordinaryRevive(next, f.owner, { type: 'revive', amount: 2 }));
assert.equal(player(later, f.owner).tanks, 0);
assert.equal(player(later, f.owner).spice, player(next, f.owner).spice - nextQuote.cost);
spent(later, f.owner);
@@ -96,7 +109,7 @@ for (const advanced of [false, true]) {
void test(`${band} three prior ordinary counters do not reduce the independent printed three grant`, () => {
const f = createNexusEmperorExtraFixture({ advanced });
const ordinary = settleNexusEmperorExtra(applyAction(f.game, f.owner, { type: 'revive', amount: 3 }));
const ordinary = settleNexusEmperorExtra(ordinaryRevive(f.game, f.owner, { type: 'revive', amount: 3 }));
assert.equal(forceRevivalRemaining(ordinary, player(ordinary, f.owner)), 0);
const before = emperorNexusPools(player(ordinary, f.owner));
const extra = settleNexusEmperorExtra(applyAction(ordinary, f.owner, grant(ordinary, f.owner)));
@@ -110,7 +123,7 @@ for (const advanced of [false, true]) {
void test(`${band} genuine single-E1 Ix counters retain native typed custody and available-count caps`, () => {
const f = createNexusEmperorExtraFixture({ advanced, profile: 'ixians' });
const ordinary = settleNexusEmperorExtra(applyAction(f.game, f.owner, { type: 'revive', amount: 1, elite: 1 }));
const ordinary = settleNexusEmperorExtra(ordinaryRevive(f.game, f.owner, { type: 'revive', amount: 1, elite: 1 }));
const before = player(ordinary, f.owner), beforePools = emperorNexusPools(before);
assert.equal(before.elites!.tanks, 2);
rejected(ordinary, f.owner, grant(ordinary, f.owner, 3));
@@ -122,7 +135,7 @@ for (const advanced of [false, true]) {
assert.equal(player(extra, f.owner).revived, 1);
assert.equal(player(extra, f.owner).elites!.revived, 3, 'Ix follows its native count cap, not the Advanced Fedaykin cap');
assert.equal(forceRevivalRemaining(extra, player(extra, f.owner)), 2);
const continued = settleNexusEmperorExtra(applyAction(extra, f.owner, { type: 'revive', amount: 1, elite: 0 }));
const continued = settleNexusEmperorExtra(ordinaryRevive(extra, f.owner, { type: 'revive', amount: 1, elite: 0 }));
assert.equal(player(continued, f.owner).revived, 2);
assert.equal(player(continued, f.owner).tanks, 3);
receipt(continued, 'returned');
@@ -151,7 +164,7 @@ void test('native Tleilaxu Allow waits for original physical return, awards free
assert.equal(returned.techTokens!.axlotl.triggeredTurn, returned.turn);
assert.equal(returned.techTokens!.axlotl.spice, ownedTech(returned.techTokens!, returned.techTokens!.axlotl.owner!).length);
const tokens = structuredClone(returned.techTokens);
const ordinary = allow(applyAction(returned, f.owner, { type: 'revive', amount: 2 }), native);
const ordinary = allow(ordinaryRevive(returned, f.owner, { type: 'revive', amount: 2 }), native);
assert.equal(player(ordinary, native).spice, nativeSpice + 1, 'Later ordinary free returns cannot award a second bank income');
assert.deepEqual(ordinary.techTokens, tokens, 'The same physical Axlotl token does not accrue twice this turn');
assert.equal(player(ordinary, f.owner).revived, 2);
@@ -163,7 +176,7 @@ void test('native Tleilaxu Allow waits for original physical return, awards free
void test('native Tleilaxu prior free return earns once; the later extra source does not earn or consume ordinary quota again', () => {
const f = createNexusEmperorExtraFixture({ advanced: true, profile: 'tleilaxu' });
const ordinary = allow(applyAction(f.game, f.owner, { type: 'revive', amount: 2 }), f.partner);
const ordinary = allow(ordinaryRevive(f.game, f.owner, { type: 'revive', amount: 2 }), f.partner);
const nativeSpice = player(ordinary, f.partner).spice, before = emperorNexusPools(player(ordinary, f.owner));
const declared = applyAction(ordinary, f.owner, grant(ordinary, f.owner));
receipt(declared, 'requested');
@@ -173,7 +186,7 @@ void test('native Tleilaxu prior free return earns once; the later extra source
assert.equal(forceRevivalRemaining(extra, player(extra, f.owner)), 1);
const price = forceRevivalQuote(extra, player(extra, f.owner), 1);
assert.equal(price.free, 0);
const paid = allow(applyAction(extra, f.owner, { type: 'revive', amount: 1 }), f.partner);
const paid = allow(ordinaryRevive(extra, f.owner, { type: 'revive', amount: 1 }), f.partner);
assert.equal(player(paid, f.owner).spice, player(extra, f.owner).spice - price.cost);
assert.equal(player(paid, f.partner).spice, nativeSpice + price.cost);
assert.equal(player(paid, f.owner).revived, 3);
@@ -224,7 +237,7 @@ void test('Basic native Tleilaxu returns the extra source without an Advanced st
assert.equal(player(extra, f.owner).revived, 0);
assert.equal(player(extra, f.owner).freeForcesRevived, 0);
assert.equal(player(extra, f.partner).spice, nativeSpice + 1);
const ordinary = settleNexusEmperorExtra(applyAction(extra, f.owner, { type: 'revive', amount: 2 }));
const ordinary = settleNexusEmperorExtra(ordinaryRevive(extra, f.owner, { type: 'revive', amount: 2 }));
assert.equal(player(ordinary, f.partner).spice, nativeSpice + 1);
assert.equal(player(ordinary, f.owner).revived, 2);
assert.equal(player(ordinary, f.owner).freeForcesRevived, 2);
+6 -4
View File
@@ -110,12 +110,14 @@ void test('native typed Tanks determine exact-three choices and Ix Cyborgs are n
assert.deepEqual(quoteEmperorNexusRevival(fremen, 'owner').eliteOptions, [0]);
assert.throws(() => createEmperorNexusRevival(fremen, 'owner', 1));
fremen.players[0].tanks = 3;
assert.deepEqual(quoteEmperorNexusRevival(fremen, 'owner').eliteOptions, []);
assert.notEqual(quoteEmperorNexusRevival(fremen, 'owner').blocked, null);
assert.equal(quoteEmperorNexusRevival(fremen, 'owner').amount, 1);
assert.deepEqual(quoteEmperorNexusRevival(fremen, 'owner').eliteOptions, [0]);
assert.equal(createEmperorNexusRevival(fremen, 'owner', 0).amount, 1);
const short = sample();
short.players[0].tanks = 2;
assert.deepEqual(quoteEmperorNexusRevival(short, 'owner').eliteOptions, []);
assert.throws(() => createEmperorNexusRevival(short, 'owner', 0));
assert.equal(quoteEmperorNexusRevival(short, 'owner').amount, 2);
assert.deepEqual(quoteEmperorNexusRevival(short, 'owner').eliteOptions, [0]);
assert.equal(createEmperorNexusRevival(short, 'owner', 0).amount, 2);
});
void test('original native modules allow revival independently of classic purchase admission', () => {
+1 -1
View File
@@ -65,7 +65,7 @@ void test('Fremen bot choices preserve real elite limits and unavailable grants
assert.equal(nexusEmperorRevivalAction(viewGame(g, owner), 2), null);
assert.equal(nexusEmperorRevivalAction(viewGame(g, observer), 0), null);
}
const { g, owner } = nexusEmperorSecretAllyFixture({ tanks: 2 });
const { g, owner } = nexusEmperorSecretAllyFixture({ tanks: 0 });
assert.ok(
!botActions(viewGame(g, owner)).some(
(action) => action.type === 'nexusEmperorRevive',
+51 -18
View File
@@ -8,6 +8,7 @@ import {
} from '../game/engine';
import {
emperorNexusPools,
emperorNexusRevivalCount,
emperorNexusSignature,
} from '../game/nexus-emperor-secret-ally';
import { forceRevivalRemaining, freeRevivalRemaining } from '../game/revival';
@@ -17,6 +18,16 @@ import {
} from './nexus-emperor-secret-ally-fixture';
import { nexusReject, nexusReload, nexusReady } from './fixture-nexus-cards';
function ownRevival(game: Game, owner: string): Game {
for (let n = 0; game.revivalOrder && game.revivalOrder.remaining[0] !== owner && n < game.players.length; n++) {
assert.equal(game.phase,4);
assert.ok(game.revivalOrder.remaining.includes(owner),'Finish only native turns preceding the still-unfinished owner.');
game = normalizeAutomaticGame(applyAction(game,game.revivalOrder.remaining[0],{type:'ready'}));
}
assert.ok(!game.revivalOrder || game.revivalOrder.remaining[0] === owner);
return game;
}
void test('a later real Ghola return and phase advance preserve historical Nexus proof without replaying its physical grant', () => {
const f = nexusEmperorSecretAllyFixture();
const card = f.g.deck.find((c) => c.effect === 'ghola')!;
@@ -77,7 +88,7 @@ for (const advanced of [false, true])
assert.equal(g.phase, 4);
assert.equal(g.response, null);
assert.equal(g.decision, null);
const ordinary = applyAction(g, f.owner, {
const ordinary = applyAction(ownRevival(g,f.owner), f.owner, {
type: 'revive',
amount: 3,
elite: 0,
@@ -93,7 +104,7 @@ for (const advanced of [false, true])
void test('Emperor additional return still works after the ordinary three-force and free allowances are exhausted', () => {
const f = nexusEmperorSecretAllyFixture();
const paid = applyAction(f.g, f.owner, {
const paid = applyAction(ownRevival(f.g,f.owner), f.owner, {
type: 'revive',
amount: 3,
elite: 0,
@@ -123,6 +134,7 @@ void test('Advanced Fedaykin use typed Tanks and share the actual one-per-turn c
let g: Game;
if (nexusFirst) {
g = applyAction(f.g, f.owner, f.action);
g = ownRevival(g,f.owner);
nexusReject(
g,
f.owner,
@@ -131,7 +143,7 @@ void test('Advanced Fedaykin use typed Tanks and share the actual one-per-turn c
);
g = applyAction(g, f.owner, { type: 'revive', amount: 3, elite: 0 });
} else {
g = applyAction(f.g, f.owner, { type: 'revive', amount: 3, elite: 1 });
g = applyAction(ownRevival(f.g,f.owner), f.owner, { type: 'revive', amount: 3, elite: 1 });
assert.deepEqual(
viewGame(g, f.owner).nexusEmperorSecretAlly!.revival.eliteOptions,
[0],
@@ -145,23 +157,44 @@ void test('Advanced Fedaykin use typed Tanks and share the actual one-per-turn c
}
});
void test('short total or typed groups are explicitly unavailable without spending a card or fabricating partial forces', () => {
for (const options of [
{ tanks: 2 },
{ advanced: true, owner: 'p' as const, tanks: 3, eliteTanks: 2 },
void test('short eligible totals return every available counter while zero and exhausted typed caps remain blocked', () => {
for (const entry of [
{options:{tanks:1},amount:1,elite:0,choices:[0]},
{options:{tanks:2},amount:2,elite:0,choices:[0]},
{options:{advanced:true,owner:'p' as const,tanks:3,eliteTanks:2},amount:2,elite:1,choices:[1]},
]) {
const f = nexusEmperorSecretAllyFixture(options);
assert.deepEqual(
viewGame(f.g, f.owner).nexusEmperorSecretAlly!.revival.eliteOptions,
[],
);
assert.match(
viewGame(f.g, f.owner).nexusEmperorSecretAlly!.revival.blocked!,
/exactly three/,
);
nexusReject(f.g, f.owner, f.action, /exactly three/);
assert.equal(f.g.nexusCards!.cards!.hands[f.owner], 'emperor');
const f = nexusEmperorSecretAllyFixture(entry.options), before = emperorNexusPools(f.g.players.find(p => p.id === f.owner)!);
const physical = emperorNexusPhysical(f.g), quote = viewGame(f.g,f.owner).nexusEmperorSecretAlly!.revival;
assert.equal(quote.amount,entry.amount);
assert.equal(quote.blocked,null);
assert.deepEqual(quote.eliteOptions,entry.choices);
const done = applyAction(f.g,f.owner,{...f.action,elite:entry.elite});
assert.deepEqual(emperorNexusPools(done.players.find(p => p.id === f.owner)!),{
...before,reserves:before.reserves+entry.amount,tanks:before.tanks-entry.amount,
eliteReserves:before.eliteReserves+entry.elite,eliteTanks:before.eliteTanks-entry.elite,
eliteRevived:before.eliteRevived+entry.elite,
});
assert.equal(emperorNexusRevivalCount(done.nexusEmperorSecretHistory!.at(-1)!),entry.amount);
assert.equal(done.nexusCards!.cards!.hands[f.owner],null);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'emperor').length,1);
assert.deepEqual(emperorNexusPhysical(done),physical);
}
const empty = nexusEmperorSecretAllyFixture({tanks:0});
assert.equal(viewGame(empty.g,empty.owner).nexusEmperorSecretAlly!.revival.amount,0);
assert.deepEqual(viewGame(empty.g,empty.owner).nexusEmperorSecretAlly!.revival.eliteOptions,[]);
nexusReject(empty.g,empty.owner,empty.action,/eligible/i);
assert.equal(empty.g.nexusCards!.cards!.hands[empty.owner],'emperor');
const typed = nexusEmperorSecretAllyFixture({advanced:true,owner:'p',tanks:2,eliteTanks:2});
const used = applyAction(ownRevival(typed.g,typed.owner),typed.owner,{type:'revive',amount:1,elite:1});
const remaining = used.players.find(p => p.id === typed.owner)!;
assert.equal(remaining.elites!.tanks,1);
assert.equal(remaining.elites!.revived,1);
const exhausted = viewGame(used,typed.owner).nexusEmperorSecretAlly!.revival;
assert.equal(exhausted.amount,0);
assert.deepEqual(exhausted.eliteOptions,[]);
nexusReject(used,typed.owner,typed.action,/eligible/i);
assert.equal(used.nexusCards!.cards!.hands[typed.owner],'emperor');
});
void test('only the private absent-Emperor holder gets revival choices; nonholders retain ordinary phase progress', () => {
+168
View File
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, createGame, newPlayer, viewGame, type Game, type GameView } from '../game/engine';
import { createNexusShortRevivalFixture } from './fixture-nexus-short-revival';
import { FACTIONS, type FactionId } from '../game/catalog';
import { createNexusCards, discardNexusCard } from '../game/nexus-cards';
import {
createEmperorNexusRevival, emperorNexusPools, emperorNexusRevivalAmount,
emperorNexusRevivalCount, emperorNexusSignature, quoteEmperorNexusRevival,
settleEmperorNexusRevival, validateEmperorNexusRevival,
type NexusEmperorRevival, type NexusEmperorRevivalRequest,
} from '../game/nexus-emperor-secret-ally';
import { nexusEmperorRevivalAction } from '../game/nexus-emperor-secret-ally-options';
import { NexusEmperorSecretAlly } from '../components/nexus-emperor-secret-ally';
/** Controlled pure-rule pools and projected controls, not native gameplay
* chronology. Physical settlement below models the native producer before
* income or Homeworld deployment; root's native source covers that path. */
function sample(tanks = 2, faction: FactionId = 'atreides', advanced = true,
eliteTanks = 0, eliteRevived = 0): Game {
const g = createGame('SHORTEMPEROR', newPlayer('owner', 'Holder', faction), advanced);
g.players.push(newPlayer('rival', 'Rival', faction === 'atreides' ? 'harkonnen' : 'atreides'));
g.players.push(newPlayer('third', 'Third', 'guild'));
g.expansions = [...new Set(g.players.map(p => FACTIONS.find(f => f.id === p.faction)!.expansion)
.filter(expansion => expansion !== 'base'))];
g.status = 'playing'; g.phase = 4; g.turn = 2;
const p = g.players[0];
p.reserves -= tanks; p.tanks = tanks;
p.revived = 4; p.freeForcesRevived = 2;
if (faction === 'fremen' || faction === 'ixians')
p.elites = { reserves: (faction === 'ixians' ? 7 : 3) - eliteTanks,
tanks: eliteTanks, forces: {}, revived: eliteRevived };
const cards = createNexusCards(g.players, () => 0.5);
cards.deck = cards.deck.filter(card => card !== 'emperor'); cards.hands.owner = 'emperor';
g.nexusCards = { cards, phase: null };
return g;
}
function commit(g: Game, receipt: NexusEmperorRevivalRequest) {
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, receipt.owner, g.players);
g.nexusEmperorSecretHistory = [receipt]; g.nexusEmperorSecretEvents = [receipt.event];
}
function physicalReturn(g: Game, receipt: NexusEmperorRevivalRequest) {
const p = g.players[0], amount = emperorNexusRevivalCount(receipt);
p.reserves += amount; p.tanks -= amount;
if (p.elites) {
p.elites.reserves += receipt.elite; p.elites.tanks -= receipt.elite;
p.elites.revived += receipt.elite;
}
}
const html = (game: GameView, busy = false) =>
renderToStaticMarkup(createElement(NexusEmperorSecretAlly, { game, busy, act() {} }));
void test('the current maximal return stays three when eligible and shortens only for actual Tanks or native typed limits', () => {
for (const amount of [1, 2, 3, 5]) {
const g = sample(amount), offered = quoteEmperorNexusRevival(g, 'owner');
assert.equal(offered.amount, Math.min(3, amount));
assert.deepEqual(offered.eliteOptions, [0]); assert.equal(offered.blocked, null);
}
const capped = sample(3, 'fremen', true, 2);
assert.equal(emperorNexusRevivalAmount(capped.players[0], true), 2);
assert.deepEqual(quoteEmperorNexusRevival(capped, 'owner').eliteOptions, [1]);
capped.players[0].elites!.revived = 1;
assert.equal(quoteEmperorNexusRevival(capped, 'owner').amount, 1);
assert.deepEqual(quoteEmperorNexusRevival(capped, 'owner').eliteOptions, [0]);
const exhausted = sample(2, 'fremen', true, 2, 1);
assert.equal(quoteEmperorNexusRevival(exhausted, 'owner').amount, 0);
assert.deepEqual(quoteEmperorNexusRevival(exhausted, 'owner').eliteOptions, []);
assert.throws(() => createEmperorNexusRevival(exhausted, 'owner', 0), /at least one eligible force/);
const basic = sample(3, 'fremen', false, 2, 1);
assert.equal(quoteEmperorNexusRevival(basic, 'owner').amount, 3);
assert.deepEqual(quoteEmperorNexusRevival(basic, 'owner').eliteOptions, [2]);
const ix = sample(3, 'ixians', true, 3, 4);
assert.equal(quoteEmperorNexusRevival(ix, 'owner').amount, 3);
assert.deepEqual(quoteEmperorNexusRevival(ix, 'owner').eliteOptions, [3]);
});
void test('short requested receipts settle only the actual typed group and preserve ordinary ledgers and purse', () => {
for (const amount of [1, 2] as const) for (const outcome of ['returned', 'denied'] as const) {
const g = sample(amount), before = emperorNexusPools(g.players[0]);
const receipt = createEmperorNexusRevival(g, 'owner', 0);
assert.equal(receipt.amount, amount); assert.equal(receipt.stage, 'requested');
assert.equal(emperorNexusRevivalCount(receipt), amount);
assert.deepEqual(emperorNexusPools(g.players[0]), before, 'declaration is a pure source quote');
commit(g, receipt);
if (outcome === 'returned') physicalReturn(g, receipt);
const result = settleEmperorNexusRevival(g, receipt, outcome);
assert.equal(result.stage, outcome); assert.equal(result.amount, amount);
assert.deepEqual(result.after, outcome === 'denied' ? before : {
...before, reserves: before.reserves + amount, tanks: before.tanks - amount,
});
assert.equal(result.after!.revived, before.revived);
assert.equal(result.after!.freeForcesRevived, before.freeForcesRevived);
assert.equal(result.after!.spice, before.spice);
}
const typed = sample(3, 'fremen', true, 2);
const receipt = createEmperorNexusRevival(typed, 'owner', 1);
assert.equal(receipt.amount, 2); commit(typed, receipt); physicalReturn(typed, receipt);
const returned = settleEmperorNexusRevival(typed, receipt, 'returned');
assert.equal(returned.after!.tanks, 1); assert.equal(returned.after!.eliteTanks, 1);
assert.equal(returned.after!.eliteRevived, 1);
});
void test('a short receipt cannot choose less than the available group or disguise a three-force record', () => {
const g = sample(2), receipt = createEmperorNexusRevival(g, 'owner', 0);
for (const amount of [0, 1, 3, undefined]) {
const malformed = { ...receipt, amount, signature: '' } as unknown as NexusEmperorRevival;
malformed.signature = emperorNexusSignature(malformed);
assert.throws(() => validateEmperorNexusRevival(g, malformed));
}
const unrecorded = { ...receipt };
delete unrecorded.amount; unrecorded.signature = emperorNexusSignature(unrecorded);
assert.throws(() => validateEmperorNexusRevival(g, unrecorded));
});
void test('full-three requests retain the previous shape and old completed receipts still mean three', () => {
const g = sample(5), receipt = createEmperorNexusRevival(g, 'owner', 0);
assert.equal(Object.hasOwn(receipt, 'amount'), false);
assert.equal(emperorNexusRevivalCount(receipt), 3);
const previous = {
kind: 'revival' as const, version: 2 as const, event: receipt.event, owner: 'owner', turn: 2,
phase: 4 as const, sequence: 0, faction: 'atreides' as const, advanced: true, elite: 0,
before: emperorNexusPools(g.players[0]), after: null, stage: 'requested' as const, signature: '',
};
previous.signature = emperorNexusSignature(previous);
assert.deepEqual(receipt, previous);
const legacy: NexusEmperorRevival = {
kind: 'revival', event: receipt.event, owner: receipt.owner, turn: receipt.turn,
phase: 4, sequence: 0, faction: receipt.faction, advanced: receipt.advanced, elite: 0,
before: receipt.before,
after: { ...receipt.before, reserves: receipt.before.reserves + 3, tanks: receipt.before.tanks - 3 },
signature: '',
};
legacy.signature = emperorNexusSignature(legacy);
validateEmperorNexusRevival(g, legacy); assert.equal(emperorNexusRevivalCount(legacy), 3);
});
void test('short controls display the quoted count and keep the exact native packet without a client-selected quantity', () => {
for (const amount of [1, 2] as const) {
const f = createNexusShortRevivalFixture({amount}), g = f.game;
const view = viewGame(g, f.actor), offered = view.nexusEmperorSecretAlly!;
assert.equal(offered.revival.amount, amount);
assert.deepEqual(nexusEmperorRevivalAction(view, 0), {
type: 'nexusEmperorRevive', event: offered.event, elite: 0,
});
const text = html(view);
assert.match(text, new RegExp(`request ${amount} additional force`));
assert.match(text, /Provisional short-group policy/); assert.match(text, /not publisher clarification/);
assert.match(text, /beyond ordinary force\/free allowances/);
assert.match(html(view, true), /disabled=""/);
assert.equal(nexusEmperorRevivalAction(view, amount + 1), null);
// Explicit stale projection supplement; no stale network snapshot is claimed.
const stale = structuredClone(view); stale.nexusEmperorSecretAlly!.revival.event += '-stale';
assert.equal(nexusEmperorRevivalAction(stale, 0), null);
assert.equal(nexusEmperorRevivalAction(viewGame(g, 'guild'), 0), null);
}
const f = createNexusShortRevivalFixture({amount:1});
const empty = applyAction(f.game, f.actor, {type:'revive',amount:1,elite:0});
const view = viewGame(empty, f.actor);
assert.equal(nexusEmperorRevivalAction(view, 0), null);
assert.match(html(view), /No eligible forces to revive/); assert.match(html(view), /disabled=""/);
// Controlled projected denial supplement; no Tleilaxu event is manufactured.
const blocked = viewGame(f.game, f.actor);
blocked.nexusEmperorSecretAlly!.revival.blocked = 'Native Tleilaxu prevention blocks this revival.';
assert.equal(nexusEmperorRevivalAction(blocked, 0), null);
assert.match(html(blocked), /Native Tleilaxu prevention/);
});
+1 -1
View File
@@ -101,7 +101,7 @@ void test('Emperor control retains actor, Advanced, unsealed plan and interrupti
mutate(v);
assert.equal(nexusSardaukarAction(v, v.nexusSardaukar!.offer!.event), null);
}
for (const options of [{ normal: 4 }, { starred: 1 }]) {
for (const options of [{ starred: 1 }]) {
const v = viewGame(nexusSardaukarFixture(options), 'p');
const offer = v.nexusSardaukar!.offer!;
assert.ok(offer.blocked);
+3 -3
View File
@@ -91,7 +91,7 @@ void test('Sardaukar receipt binds the original five-counter battle role without
assert.throws(() => validateNexusSardaukar(g, bad as typeof receipt));
}
assert.throws(() => createNexusSardaukar(g, 'q', 'battle', 'pasty_mesa', 7));
assert.throws(() => createNexusSardaukar(g, 'p', 'battle', 'pasty_mesa', 4));
assert.throws(() => createNexusSardaukar(g, 'p', 'battle', 'pasty_mesa', 0));
const changed = g.players.map((p) => ({ id: p.id, faction: p.faction }));
changed[1].id = 'another-seat';
assert.throws(() =>
@@ -191,8 +191,8 @@ void test('the completed grant expires before a second battle in the same turn',
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
});
void test('Basic, short groups, actual Sardaukar, foreign actors and stale or repeated declarations reject unchanged', () => {
for (const options of [{ advanced: false }, { normal: 4 }, { starred: 1 }]) {
void test('Basic, actual Sardaukar, foreign actors and stale or repeated declarations reject unchanged', () => {
for (const options of [{ advanced: false }, { starred: 1 }]) {
const g = nexusSardaukarFixture(options);
assert.ok(viewGame(g, 'p').nexusSardaukar!.offer!.blocked);
nexusReject(g, 'p', request(g), /Basic|Advanced|five|5|Sardaukar|ruling/i);
+3
View File
@@ -86,6 +86,9 @@ function fixture(salusa: 1 | 2 | 5 = 5) {
ready: [],
storm: 18,
});
// This conserved controlled Battle setup skips the actual Charity turns;
// their ordered frame does not belong to the newly staged phase.
delete g.charityOrder;
g = applyAction(g, 'p', {
type: 'chooseBattle',
territory: 'pasty_mesa',
+113
View File
@@ -0,0 +1,113 @@
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 { nexusSardaukarAction } from '../game/nexus-sardaukar-options';
import {
createNexusShortCunningFixture, resolveNexusShortCunning as resolve,
finishNexusShortCunning as finish, actNexusShortCunning as act,
nexusShortCunningPlayer as p, nexusShortCunningInventory as inventory,
type NexusShortCunningFixture,
} from './fixture-nexus-short-cunning';
const sources = new Map<number,NexusShortCunningFixture>();
function fixture(amount:2|4 = 2) {
let f = sources.get(amount);
if (!f) {f = createNexusShortCunningFixture({amount}); sources.set(amount,f);}
return f;
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game,actor,action)); assert.deepEqual(game,before);
}
void test('native short Cunning source earns its original Emperor Nexus and pays only for two or four ordinary counters', () => {
for (const amount of [2,4] as const) {
const f = fixture(amount);
assert.equal(f.base.lobby.status,'lobby');
assert.ok(f.base.lobby.nexusCards);
assert.equal(f.base.setup.status,'setup');
assert.equal(f.base.beforeDraw.turn,2);
assert.equal(f.base.afterDraw.nexusCards!.cards!.hands[f.actor],'emperor');
assert.equal(p(f.game,f.actor).ally,null);
assert.equal(p(f.game,f.opponent).ally,'guild');
assert.equal(f.shipment.steps[0].action.amount,amount);
assert.equal(f.shipment.steps[0].action.elite,0);
assert.equal(f.shipment.cost,amount);
assert.equal(p(f.game,f.actor).forces[f.key],amount);
assert.equal(p(f.game,f.actor).elites!.forces[f.key] ?? 0,0);
assert.deepEqual(f.game.battle!.plans,{});
assert.equal(f.sourceCount,f.sourceActions.length);
assert.equal(f.original.cards.length,33);
assert.deepEqual(inventory(f.game),f.original);
assert.match(f.disclosure,/Original public.*No played hand, wallet, force, phase/);
}
});
for (const amount of [2,4] as const)
void test(`native Emperor Cunning counts all ${amount} short ordinary fighters and loses one physical normal exactly once`, () => {
const f = fixture(amount), before = structuredClone(p(f.game,f.actor)), use = resolve(f);
const quote = viewGame(use.game,f.actor).battle!.ownForces!;
assert.deepEqual([quote.normal,quote.elite,quote.temporaryElite],[amount,0,amount]);
assert.equal(viewGame(f.game,f.actor).nexusSardaukar!.shortCount,amount);
assert.equal(viewGame(use.game,f.actor).nexusSardaukar!.shortCount,amount);
const receipt = use.game.nexusSardaukarHistory![0].receipt;
assert.equal(receipt.normal,amount); assert.equal(receipt.count,amount);
assert.equal(receipt.shortGroup,true); assert.equal(receipt.elite,0);
assert.deepEqual(p(use.game,f.actor).elites,before.elites);
reject(use.game,f.actor,f.action);
const result = finish(f,use.game), done = p(result.game,f.actor);
assert.equal(result.game.lastBattleContext!.winner,f.actor);
assert.equal(done.forces[f.key],amount-1);
assert.equal(done.tanks,before.tanks+1);
assert.equal(done.reserves,before.reserves);
assert.deepEqual(done.elites,before.elites);
assert.equal(result.game.nexusSardaukarHistory!.length,1);
assert.equal(result.game.nexusSardaukarHistory![0].casualties!.outcome,'complete');
assert.ok(result.game.nexusSardaukarHistory![0].casualties!.options.every(loss => loss.elite === 0));
assert.equal(result.game.nexusCards!.cards!.discard.filter(c => c === 'emperor').length,1);
assert.deepEqual(inventory(result.game),f.original);
});
void test('original opposing Karama cancels a short Cunning declaration without refund or conversion', () => {
const f = fixture(), before = structuredClone(p(f.game,f.actor)), use = resolve(f,true);
assert.equal(use.declared.response?.kind,'nexusSardaukar');
assert.equal(use.declared.nexusCards!.cards!.hands[f.actor],null);
assert.equal(viewGame(use.game,f.actor).battle!.ownForces!.temporaryElite ?? 0,0);
assert.equal(use.game.nexusSardaukarHistory![0].stage,'canceled');
assert.equal(use.game.nexusSardaukarHistory![0].receipt.count,2);
assert.deepEqual(p(use.game,f.actor).forces,before.forces);
assert.deepEqual(p(use.game,f.actor).elites,before.elites);
assert.equal(p(use.game,f.actor).spice,before.spice);
assert.equal(use.game.discard.filter(c => c.id === f.karama).length,1);
const result = finish(f,use.game,f.canceledPlan);
assert.equal(result.game.lastBattleContext!.winner,f.actor);
assert.equal(p(result.game,f.actor).forces[f.key],1);
assert.equal(p(result.game,f.actor).tanks,before.tanks+1);
assert.deepEqual(p(result.game,f.actor).elites,before.elites);
assert.equal(result.game.nexusCards!.cards!.discard.filter(c => c === 'emperor').length,1);
assert.deepEqual(inventory(result.game),f.original);
});
void test('short Cunning exact adapter rejects stale owners and all four minimal policies can declare and plan legally', () => {
const f = fixture();
assert.deepEqual(nexusSardaukarAction(viewGame(f.game,f.actor),f.event),f.action);
reject(f.game,f.opponent,f.action);
reject(f.game,f.actor,{...f.action,event:`${f.event}:stale`});
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
const view = viewGame(f.game,f.actor);
view.players.find(p => p.id === f.actor)!.bot = profile;
const declaration = botActions(view).find(a => a.type === 'nexusSardaukar');
assert.ok(declaration,`${profile} needs the exact short Cunning action.`);
assert.deepEqual(declaration,f.action);
const pending = act(structuredClone(f.game),f.actor,declaration);
assert.equal(pending.nexusSardaukarHistory![0].receipt.count,2);
const active = resolve(f).game, planning = viewGame(active,f.actor);
planning.players.find(p => p.id === f.actor)!.bot = profile;
const plan = botActions(planning).find(a => a.type === 'battlePlan');
assert.ok(plan,`${profile} needs a legal converted short-group plan.`);
const committed = act(active,f.actor,plan);
assert.ok(committed.battle!.plans[f.actor]);
assert.deepEqual(inventory(committed),f.original);
}
});
+84
View File
@@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { emperorNexusPools, emperorNexusRevivalCount } from '../game/nexus-emperor-secret-ally';
import { nexusEmperorRevivalAction } from '../game/nexus-emperor-secret-ally-options';
import { forceRevivalRemaining, freeRevivalRemaining } from '../game/revival';
import {
createNexusShortRevivalFixture, applyNexusShortRevival as apply,
nexusShortRevivalPlayer as p, nexusShortRevivalInventory as inventory,
type NexusShortRevivalFixture,
} from './fixture-nexus-short-revival';
const sources = new Map<number,NexusShortRevivalFixture>();
function fixture(amount:1|2 = 1) {
let f = sources.get(amount);
if (!f) {f = createNexusShortRevivalFixture({amount}); sources.set(amount,f);}
return f;
}
for (const amount of [1,2] as const)
void test(`earned Emperor Secret Ally returns the actual ${amount}-counter shortage through native revival without ordinary quota or price`, () => {
const f = fixture(amount), before = p(f.game,f.actor);
assert.equal(f.base.lobby.status,'lobby');
assert.ok(f.base.lobby.nexusCards); assert.equal(f.base.setup.status,'setup');
assert.ok(f.game.players.every(p => p.faction !== 'emperor'));
assert.equal(f.shipment.steps[0].actor,'guild');
assert.equal(f.shipment.steps[0].action.type,'ship');
assert.equal(f.shipment.steps[0].action.amount,1);
assert.equal(f.battleResolution.lastBattleContext!.winner,f.actor);
assert.equal(p(f.battleResolution,f.actor).tanks,amount);
assert.equal(f.battleActions.filter(step => step.action.type === 'battlePlan').length,2);
assert.equal(f.alliance.steps.length,2);
assert.equal(p(f.game,'guild').ally,'harkonnen');
assert.equal(p(f.game,f.actor).ally,null);
assert.equal(f.afterDraw.nexusCards!.cards!.hands[f.actor],'emperor');
assert.equal(f.game.phase,4);
if (f.game.revivalOrder) assert.equal(f.game.revivalOrder.remaining[0],f.actor);
assert.equal(before.revived,0);
assert.equal(f.sourceCount,f.sourceActions.length);
const ordinary = forceRevivalRemaining(f.game,before), free = freeRevivalRemaining(f.game,before);
assert.equal(ordinary, amount);
const result = apply(f), after = p(result.game,f.actor);
assert.deepEqual(emperorNexusPools(after),{...emperorNexusPools(before),reserves:before.reserves+amount,tanks:0});
assert.equal(after.spice,before.spice);
assert.equal(forceRevivalRemaining(result.game,after),0, 'The Tanks are now empty; the ordinary usage ledger above remains unchanged.');
assert.equal(freeRevivalRemaining(result.game,after),free);
assert.deepEqual(after.forces,before.forces);
assert.equal(result.game.phase,4);
assert.deepEqual(result.game.revivalOrder,f.game.revivalOrder);
const receipt = result.game.nexusEmperorSecretHistory!.at(-1)!;
assert.ok(receipt.version === 2);
assert.equal(receipt.stage,'returned');
assert.equal(receipt.amount,amount);
assert.equal(emperorNexusRevivalCount(receipt),amount);
assert.equal(receipt.before.tanks,amount);
assert.equal(receipt.after!.tanks,0);
assert.equal(result.game.nexusCards!.cards!.hands[f.actor],null);
assert.equal(result.game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length,1);
assert.equal(result.game.nexusEmperorSecretHistory!.length,1);
assert.deepEqual(inventory(result.game),f.original);
const immutable = structuredClone(result.game);
assert.throws(() => applyAction(result.game,f.actor,f.action));
assert.deepEqual(result.game,immutable);
});
void test('all four minimal policies use the exact earned short-revival adapter and actual native return', () => {
const f = fixture();
assert.deepEqual(nexusEmperorRevivalAction(viewGame(f.game,f.actor),0),f.action);
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
const game = structuredClone(f.game), view = viewGame(game,f.actor);
view.players.find(p => p.id === f.actor)!.bot = profile;
const action = botActions(view).find(action => action.type === 'nexusEmperorRevive');
assert.ok(action,`${profile} needs the real one-counter Secret Ally return.`);
assert.deepEqual(action,f.action);
const returned = apply(f,game).game;
assert.equal(p(returned,f.actor).tanks,0);
const receipt = returned.nexusEmperorSecretHistory!.at(-1)!;
assert.ok(receipt.version === 2);
assert.equal(receipt.amount,1);
assert.equal(returned.nexusCards!.cards!.discard.filter(card => card === 'emperor').length,1);
assert.deepEqual(inventory(returned),f.original);
}
});
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNexusSardaukar, validateNexusSardaukar } from '../game/nexus-sardaukar';
const context = { turn: 2, players: [
{ id: 'e', faction: 'emperor' as const }, { id: 'a', faction: 'atreides' as const },
] };
void test('positive short Cunning groups use every available counter and explicitly record their provisional source', () => {
for (const normal of [1, 2, 3, 4]) {
const receipt = createNexusSardaukar(context, 'e', 'battle', 'arrakeen', normal);
assert.equal(receipt.count, normal);
assert.equal(receipt.shortGroup, true);
validateNexusSardaukar(context, receipt);
assert.throws(() => validateNexusSardaukar(context, { ...receipt, shortGroup: undefined }));
assert.throws(() => validateNexusSardaukar(context, { ...receipt, count: normal - 1 }));
}
for (const normal of [0, -1, 21, 1.5, NaN])
assert.throws(() => createNexusSardaukar(context, 'e', 'battle', 'arrakeen', normal));
});
void test('Cunning retains the original five-role shape and signature when the full group exists', () => {
for (const normal of [5, 7, 20]) {
const receipt = createNexusSardaukar(context, 'e', 'battle', 'arrakeen', normal);
assert.equal(receipt.count, 5);
assert.equal(Object.hasOwn(receipt, 'shortGroup'), false);
assert.equal(receipt.signature, JSON.stringify([
1, receipt.event, 'e', 2, 'battle', 'arrakeen', normal, 0, 5,
[['a', 'atreides'], ['e', 'emperor']],
]));
assert.throws(() => validateNexusSardaukar(context, { ...receipt, shortGroup: true }));
}
});
+4 -1
View File
@@ -124,7 +124,10 @@ for (const advanced of [false, true]) for (const role of ['visitor', 'native'] a
assert.equal(after.tanks, before.tanks + 5, 'Three native fee counters and two real dial casualties, no virtual bonus casualties');
assert.equal(after.elites!.tanks, before.elites!.tanks);
assert.equal(after.battleLosses, before.battleLosses + 5);
assert.equal(after.spice, before.spice - (advanced ? 2 : 0));
if (role === 'visitor') assert.ok(done.log.slice(f.game.log.length).some(entry =>
entry.text === `${before.name} received 2 spice from ${f.location}'s occupied bank award.`),
'Completing the last visitor battle includes its actual occupied-World Collection award.');
assert.equal(after.spice, before.spice - (advanced ? 2 : 0) + (role === 'visitor' ? 2 : 0));
if (role === 'visitor') {
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 1, elite: 4 });
assert.equal(after.reserves, before.reserves - 3);