Compose classic independent cards with World Nexus rules
Retain independent36, original Homeworlds and Nexus12 through classic fresh setup with optional Skills and phase modules. Resolve own-World Reinforcements fees before computing temporary Sardaukar roles, preserving original Cunning declarations, printed traits and physical counters. Record the provisional ordinary-nonrole-first policy and align human/AI quotes and captions. Validation: types/lint/build, 128 affected tests across 15 files, bounded rule-source review and finite original-action smoke for Recruits, Harass, local-fee Cunning, all four legal policies and rendered source controls. Verified runtime c219ec587a54b44079539168d93835a5af904c8afb4de58bcec0a3d60c809f0c; documentation-only proof update 8ee21598e7be0c89a08fbad50786c346ebc8ae705492d8b31ea00fae0ffefa3c. Full modes, combinations, assurance and deployed acceptance remain open. No saved-room/provider changes.
This commit is contained in:
1 parent
178251ad35
commit
97c2b67650
18 files changed
+737
-30
No files matched your search
@@ -5,12 +5,20 @@ import { combatArmy } from '../game/combat-location';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
|
||||
import { advanceClassicIndependentNexus, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
|
||||
import { classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type WorldNexusBattleStep = ClassicIndependentNexusStep;
|
||||
export type WorldNexusBattleOptions = { advanced?: boolean; skills?: boolean; tech?: boolean; strongholds?: boolean };
|
||||
export type WorldNexusBattleOptions = {
|
||||
advanced?: boolean;
|
||||
skills?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
/** Opt in before the original deal; default fixtures retain base33. */
|
||||
independent?: boolean;
|
||||
};
|
||||
type WorldNexusInvasion = { before: Game; action: Action; cost: number; after: Game };
|
||||
export const worldNexusClean = (game: Game) => classicDiscoveryNexusClean(game) &&
|
||||
!game.pendingGuildBetrayal && !game.pendingNexusHarkonnenBetrayal && !game.pendingNexusIxianBetrayal;
|
||||
@@ -59,7 +67,8 @@ function invade(game: Game, actor: string, location: string, amount: number, act
|
||||
* route exports four actual stars to an empty desert, never converts them. */
|
||||
export function createWorldNexusEmperorBattle(options: WorldNexusBattleOptions & { salusa?: 'high' | 'low' } = {}) {
|
||||
const fixture = drawClassicNexusReaction('emperor', 'emperor', { ...options, advanced: true,
|
||||
independent: false, homeworlds: true, roster: ['atreides', 'guild', 'emperor'],
|
||||
independent: options.independent ?? false, homeworlds: true, roster: ['atreides', 'guild', 'emperor'],
|
||||
startingCards: options.independent ? { emperor: [REINFORCEMENTS_CARD] } : undefined,
|
||||
training: options.skills ? { emperor: 'planetologist', atreides: 'warmaster', guild: 'sandmaster' } : undefined });
|
||||
const actions = fixture.actions, location = 'homeworld:emperor', trained = options.skills ? ['emperor'] : [];
|
||||
let game = advanceWorldNexusBattle(fixture.game, state => state.phase === 5 && worldNexusClean(state), actions);
|
||||
@@ -114,7 +123,7 @@ export function createWorldNexusEmperorBattle(options: WorldNexusBattleOptions &
|
||||
* ordinary call, while the invader owns the independently drawn cancel card. */
|
||||
export function createWorldNexusHarkonnenBattle(options: WorldNexusBattleOptions = {}) {
|
||||
const fixture = drawClassicNexusReaction('harkonnen', 'atreides', { ...options,
|
||||
independent: false, homeworlds: true, roster: ['atreides', 'harkonnen', 'guild'],
|
||||
independent: options.independent ?? false, homeworlds: true, roster: ['atreides', 'harkonnen', 'guild'],
|
||||
training: options.skills ? { harkonnen: 'planetologist', atreides: 'warmaster', guild: 'sandmaster' } : undefined });
|
||||
const actions = fixture.actions, location = 'homeworld:harkonnen';
|
||||
let game = advanceWorldNexusBattle(fixture.game, state => state.phase === 5 && state.active === 'atreides' &&
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { combatArmy } from '../game/combat-location';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import {
|
||||
createClassicIndependentNexusFixture, advanceClassicIndependentNexus,
|
||||
type ClassicIndependentNexusOptions, type ClassicIndependentNexusStep,
|
||||
} from './fixture-classic-independent-nexus';
|
||||
import { applyClassicDiscoveryNexusStep, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type WorldIndependentCardStep = ClassicIndependentNexusStep;
|
||||
export type WorldIndependentCardOptions = Pick<ClassicIndependentNexusOptions, 'advanced' | 'skills' | 'tech' | 'strongholds'>;
|
||||
export const worldIndependentCardPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const worldIndependentCardsClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal &&
|
||||
!game.pendingNexusHarkonnenBetrayal && !game.pendingNexusIxianBetrayal;
|
||||
|
||||
/** Only still-unopened original Spice Cards are ordered. Neither a clock nor
|
||||
* any army, payment, card hand or battle outcome is assigned. */
|
||||
function orderOriginalLandBlows(game: Game, turns: number): void {
|
||||
for (let index = 0; index < turns * (game.advanced ? 2 : 1); index++) {
|
||||
const source = game.spiceDeck.findIndex((card, position) => position >= index && 'territory' in card && !card.discovery);
|
||||
assert.ok(source >= index);
|
||||
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(source, 1)[0]);
|
||||
}
|
||||
}
|
||||
function nextWorldIndependentCardStep(game: Game): WorldIndependentCardStep | null {
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'harassWithdraw') return null;
|
||||
if (decision?.kind === 'homeworldDefense')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, use: false } };
|
||||
if (decision?.kind === 'homeworldExplosion')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, choice: 0 } };
|
||||
if (decision?.kind === 'homeworldRevivalDeployment' || decision?.kind === 'caladanReinforcement')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
|
||||
}
|
||||
return nextClassicDiscoveryNexusStep(game);
|
||||
}
|
||||
export function actWorldIndependentCard(game: Game, actor: string, action: Action,
|
||||
actions?: WorldIndependentCardStep[]): Game {
|
||||
return applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
|
||||
}
|
||||
/** Reuse public native controls and legal Nexus acknowledgement. Sealed plans
|
||||
* and typed Harass allocations stay explicit human-owned actions. */
|
||||
export function advanceWorldIndependentCards(game: Game, until: (state: Game) => boolean,
|
||||
actions: WorldIndependentCardStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original game ended before the requested card action');
|
||||
if (game.pendingGuildBetrayal) {
|
||||
game = advanceClassicIndependentNexus(game, state => !state.pendingGuildBetrayal, actions);
|
||||
continue;
|
||||
}
|
||||
const step = nextWorldIndependentCardStep(game);
|
||||
assert.ok(step, `A real human plan/allocation is required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actWorldIndependentCard(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('Original Nexus/World card programme did not reach its requested boundary');
|
||||
}
|
||||
|
||||
/** Fresh selected classic independent36/Nexus12/Homeworlds only. The physical
|
||||
* card is dealt from the original shuffle, not moved out of a played hand. */
|
||||
export function createWorldIndependentRecruitsFixture(options: WorldIndependentCardOptions = {}) {
|
||||
const source = createClassicIndependentNexusFixture({ ...options, independent: true, homeworlds: true,
|
||||
roster: ['emperor', 'fremen', 'atreides', 'guild'], startingCards: { emperor: [RECRUITS_CARD_ID] } });
|
||||
let game = structuredClone(source.game);
|
||||
const afterSetup = structuredClone(game), actions = source.actions;
|
||||
orderOriginalLandBlows(game, 2);
|
||||
const staging = [...source.staging, 'Order two original non-worm Spice Blows before they are drawn; hold the genuinely dealt Recruits through real native battles.'];
|
||||
const act = (actor: string, action: Action) => { game = actWorldIndependentCard(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceWorldIndependentCards(game, until, actions); };
|
||||
const shipments = new Map<string, { amount: number; elite: number; territory: string; sector: number }>([
|
||||
['emperor', { amount: 3, elite: 0, territory: 'sietch_tabr', sector: 14 }],
|
||||
['fremen', { amount: 8, elite: 3, territory: 'sietch_tabr', sector: 14 }],
|
||||
['guild', { amount: 3, elite: 0, territory: 'arrakeen', sector: 10 }],
|
||||
]);
|
||||
reach(state => state.phase === 5 && worldIndependentCardsClean(state));
|
||||
while (game.phase === 5) {
|
||||
const actor = game.active!, request = worldIndependentCardsClean(game) ? shipments.get(actor) : undefined;
|
||||
if (request) {
|
||||
const sources = nativeShipmentSources(viewGame(game, actor), request.amount, request.elite); assert.ok(sources);
|
||||
act(actor, { type: 'ship', ...request, homeworldSources: sources });
|
||||
shipments.delete(actor);
|
||||
} else {
|
||||
const step = nextWorldIndependentCardStep(game); assert.ok(step); act(step.actor, step.action);
|
||||
}
|
||||
}
|
||||
assert.equal(shipments.size, 0);
|
||||
reach(state => state.phase === 6 && worldIndependentCardsClean(state));
|
||||
while (game.phase === 6) {
|
||||
if (worldIndependentCardsClean(game) && !game.battle && game.active) {
|
||||
const choice = viewGame(game, game.active).battleChoices[0]; assert.ok(choice);
|
||||
act(choice.chooser, { type: 'chooseBattle', territory: choice.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
|
||||
}
|
||||
if (game.battle && !game.battle.revealed) {
|
||||
reach(state => worldIndependentCardsClean(state) && !state.battle?.preparation && state.battle?.preLeader?.closed !== false);
|
||||
const battle = game.battle!;
|
||||
for (const actor of [battle.attacker, battle.defender]) {
|
||||
if (game.battle!.plans[actor]) continue;
|
||||
// Keep optional assigned skill discs out of this focused printed-rate
|
||||
// case; they remain genuinely assigned in their original physical pool.
|
||||
const leaders = worldIndependentCardPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills?.assignments.some(assignment => assignment.owner === actor && assignment.leader === leader.id))
|
||||
.sort((a, b) => a.strength - b.strength);
|
||||
const leader = actor === 'emperor' || actor === 'guild' ? leaders.at(-1) : leaders[0]; assert.ok(leader);
|
||||
act(actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 });
|
||||
reach(state => worldIndependentCardsClean(state) && !state.battle?.preparation && state.battle?.preLeader?.closed !== false);
|
||||
}
|
||||
} else {
|
||||
const step = nextWorldIndependentCardStep(game); assert.ok(step); act(step.actor, step.action);
|
||||
}
|
||||
}
|
||||
reach(state => state.turn === 2 && state.phase === 4 && worldIndependentCardsClean(state));
|
||||
assert.equal(worldIndependentCardPlayer(game, 'fremen').reserves, 2);
|
||||
assert.equal(worldIndependentCardPlayer(game, 'fremen').tanks, 18);
|
||||
assert.ok(worldIndependentCardPlayer(game, 'emperor').hand.some(card => card.id === RECRUITS_CARD_ID));
|
||||
return { ...source, afterSetup, game, actions, staging };
|
||||
}
|
||||
|
||||
/** Original typed invasion of a foreign World, or a native defender's legal
|
||||
* own-World rejection. The Harass card is held from its native starting deal. */
|
||||
export function createWorldIndependentHarassFixture(options: WorldIndependentCardOptions & { native?: boolean } = {}) {
|
||||
const source = createClassicIndependentNexusFixture({ ...options, independent: true, homeworlds: true,
|
||||
roster: ['emperor', 'guild', 'atreides'], startingCards: { emperor: [HARASS_WITHDRAW_CARD] } });
|
||||
let game = structuredClone(source.game);
|
||||
const afterSetup = structuredClone(game), actions = source.actions;
|
||||
orderOriginalLandBlows(game, 1);
|
||||
const staging = [...source.staging, 'Order the unopened original first non-worm Spice Blow; use real typed native invasion and sealed plans.'];
|
||||
const owner = 'emperor', native = options.native ? owner : 'guild', visitor = options.native ? 'guild' : owner;
|
||||
const location = options.native ? 'homeworld:emperor' : 'homeworld:guild';
|
||||
game = advanceWorldIndependentCards(game, state => state.phase === 5 && state.active === visitor && worldIndependentCardsClean(state), actions);
|
||||
const beforeShipment = structuredClone(game);
|
||||
const sources = nativeShipmentSources(viewGame(game, visitor), options.native ? 5 : 7, options.native ? 0 : 4); assert.ok(sources);
|
||||
const quote = homeworldShipmentChoice(viewGame(game, visitor), location,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, force]) => force.normal + force.elite > 0)));
|
||||
assert.ok(quote.action, quote.blocked ?? 'The original native invasion must have a paid typed route');
|
||||
const shipmentStep: WorldIndependentCardStep = { actor: visitor, action: quote.action };
|
||||
game = actWorldIndependentCard(game, visitor, quote.action, actions);
|
||||
game = advanceWorldIndependentCards(game, state => !state.pendingHomeworldShipment && !state.pendingShipment && worldIndependentCardsClean(state), actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
assert.deepEqual(combatArmy(game, visitor, location), options.native ? { normal: 5, elite: 0 } : { normal: 3, elite: 4 });
|
||||
game = advanceWorldIndependentCards(game, state => state.phase === 6 && !!state.active && worldIndependentCardsClean(state), actions);
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(battle => battle.territory === location);
|
||||
assert.ok(choice);
|
||||
const battleStep: WorldIndependentCardStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: location,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
game = actWorldIndependentCard(game, battleStep.actor, battleStep.action, actions);
|
||||
game = advanceWorldIndependentCards(game, state => !!state.battle && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false && worldIndependentCardsClean(state), actions);
|
||||
const leaders = (actor: string) => worldIndependentCardPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills?.assignments.some(assignment => assignment.owner === actor && assignment.leader === leader.id))
|
||||
.sort((a, b) => a.strength - b.strength);
|
||||
const ownLeader = leaders(owner)[0], otherLeader = leaders('guild').at(-1); assert.ok(ownLeader && otherLeader);
|
||||
const plans: WorldIndependentCardStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', dial: options.native ? 0 : 2, support: options.advanced && !options.native ? 1 : 0,
|
||||
leader: ownLeader.id, defense: HARASS_WITHDRAW_CARD } },
|
||||
{ actor: 'guild', action: { type: 'battlePlan', dial: options.native ? 0 : 3, support: options.advanced && !options.native ? 3 : 0,
|
||||
leader: otherLeader.id } },
|
||||
];
|
||||
return { ...source, afterSetup, game, actions, staging, owner, native, visitor, location,
|
||||
beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep, plans,
|
||||
returns: { [location]: { normal: 2, elite: 3 } } };
|
||||
}
|
||||
export function revealWorldIndependentHarass(game: Game, plans: readonly WorldIndependentCardStep[], actions: WorldIndependentCardStep[] = []) {
|
||||
for (const plan of plans) {
|
||||
game = actWorldIndependentCard(game, plan.actor, plan.action, actions);
|
||||
game = advanceWorldIndependentCards(game, state => !!state.battle && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false && (!state.decision || state.decision.kind === 'harassWithdraw') &&
|
||||
!state.phaseOpening && !state.response, actions);
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
@@ -137,7 +137,8 @@ void test('public pending and active notices avoid private hands and identify or
|
||||
const allowed = allowNexusSardaukar(g);
|
||||
const observer = viewGame(allowed, 'q');
|
||||
assert.equal(observer.battle!.opponentForces?.temporaryElite, 5);
|
||||
assert.match(html(observer), /Five ordinary Emperor counters/);
|
||||
assert.match(html(observer), /Cunning declared five ordinary Emperor counters/);
|
||||
assert.match(html(observer), /own-Homeworld reserve fee can reduce that group/);
|
||||
assert.match(html(observer), /ordinary Tanks/);
|
||||
assert.doesNotMatch(html(observer), /<button/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { combatArmy } from '../game/combat-location';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport } from '../game/combat';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldPopulations } from '../game/homeworld-population';
|
||||
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { reinforcementsBattleForces } from '../game/reinforcements-homeworld';
|
||||
import { reactionAction } from './fixture-classic-independent-nexus-reactions';
|
||||
import {
|
||||
advanceWorldNexusBattle, createWorldNexusEmperorBattle, revealWorldNexusPlans,
|
||||
worldNexusClean, worldNexusPlayer, type WorldNexusBattleStep,
|
||||
} from './fixture-nexus-core-world-battles';
|
||||
|
||||
const KAITAIN = 'homeworld:emperor', SALUSA = 'homeworld:emperor:salusa';
|
||||
const localFee: NativeReserveSelections = { [KAITAIN]: { normal: 3, elite: 0 } };
|
||||
const remoteFee: NativeReserveSelections = { [SALUSA]: { normal: 3, elite: 0 } };
|
||||
|
||||
function declareCunning(game: Game): Game {
|
||||
const offer = viewGame(game, 'emperor').nexusSardaukar!.offer!;
|
||||
assert.equal(offer.blocked, null);
|
||||
game = reactionAction(game, 'emperor', { type: 'nexusSardaukar', event: offer.event });
|
||||
game = advanceWorldNexusBattle(game, worldNexusClean);
|
||||
const receipt = game.nexusSardaukarHistory!.at(-1)!.receipt;
|
||||
assert.equal(receipt.normal, 7);
|
||||
assert.equal(receipt.count, 5, 'The original declaration remains five roles, not a rewritten four-counter declaration');
|
||||
return game;
|
||||
}
|
||||
|
||||
function cardPlan(step: WorldNexusBattleStep, slot: 'weapon' | 'defense', sources: NativeReserveSelections): WorldNexusBattleStep {
|
||||
return { actor: step.actor, action: { ...step.action, [slot]: REINFORCEMENTS_CARD,
|
||||
reinforcementsSources: sources } };
|
||||
}
|
||||
|
||||
function finish(game: Game): Game {
|
||||
return advanceWorldNexusBattle(game, state => !state.battle && !state.homeworldBattleLoss && worldNexusClean(state));
|
||||
}
|
||||
|
||||
function checkPhysicalDeal(game: Game): void {
|
||||
assert.equal(game.deck.length + game.discard.length + game.players.reduce((sum, player) => sum + player.hand.length, 0), 36);
|
||||
assert.equal(game.nexusCards!.cards!.deck.length + game.nexusCards!.cards!.discard.length +
|
||||
Object.values(game.nexusCards!.cards!.hands).filter(Boolean).length, 12);
|
||||
}
|
||||
|
||||
for (const salusa of ['high', 'low'] as const) for (const slot of ['weapon', 'defense'] as const) {
|
||||
void test(`original independent ${slot}, own-Kaitain Cunning and ${salusa} Salusa pay the local fee before physical combat`, () => {
|
||||
const f = createWorldNexusEmperorBattle({ independent: true, salusa, skills: true, tech: true, strongholds: true });
|
||||
assert.equal(f.lobby.lobbyOptions?.ecazTreachery, true);
|
||||
assert.ok(f.lobby.players.every(player => !player.hand.length), 'The requested physical card was ordered before any starting deal');
|
||||
assert.equal(worldNexusPlayer(f.game, 'emperor').hand[0].id, REINFORCEMENTS_CARD);
|
||||
assert.ok(f.staging.some(note => note.includes('original unused Treachery shuffle')));
|
||||
checkPhysicalDeal(f.game);
|
||||
let game = declareCunning(f.game);
|
||||
const before = worldNexusPlayer(game, 'emperor'), other = worldNexusPlayer(game, 'guild');
|
||||
const stars = structuredClone(before.elites), salusaBefore = structuredClone(game.homeworlds!.custody!.salusa);
|
||||
const own = viewGame(game, 'emperor').battle!;
|
||||
assert.deepEqual([own.ownForces!.normal, own.ownForces!.elite, own.ownForces!.temporaryElite], [7, 0, 5]);
|
||||
assert.equal(own.nativeBattleStrength, 2);
|
||||
assert.equal(game.battle!.leaderSkillHidden!.emperor, true);
|
||||
assert.deepEqual(own.strongholdEffects, { guild: null, emperor: null });
|
||||
assert.equal(own.reinforcements!.blocked, null);
|
||||
const projected = reinforcementsBattleForces(own.ownForces!, localFee[KAITAIN]);
|
||||
// Provisional composition: the two non-role counters pay first, followed
|
||||
// by one temporary-role counter. The four survivors all retain the role.
|
||||
assert.deepEqual([projected.normal, projected.elite, projected.temporaryElite], [4, 0, 4]);
|
||||
assert.equal(projected.eliteFreeSupport, own.ownForces!.eliteFreeSupport);
|
||||
assert.equal(!!projected.eliteFreeSupport, salusa === 'high');
|
||||
assert.equal(maxCombatDial(projected), 8);
|
||||
assert.equal(maxCombatSupport(projected), salusa === 'high' ? 0 : 4);
|
||||
assert.deepEqual(casualtyOptions(projected, 4, salusa === 'high' ? 0 : 2), [
|
||||
{ normal: 2, elite: 0, paidNormal: salusa === 'high' ? 0 : 2, paidElite: 0 },
|
||||
]);
|
||||
const ownPlan = cardPlan(f.plans[0], slot, localFee);
|
||||
assert.throws(() => applyAction(game, 'emperor', { ...ownPlan.action, dial: 9 }),
|
||||
'The three fee counters cannot also fund the physical dial');
|
||||
if (salusa === 'high') assert.throws(() => applyAction(game, 'emperor', { ...ownPlan.action, support: 1 }),
|
||||
'There are no remaining ordinary non-role counters to pay support');
|
||||
game = revealWorldNexusPlans(game, [ownPlan, f.plans[1]]);
|
||||
assert.equal(viewGame(game, 'emperor').battle!.nativeBattleStrength, 2, 'The printed high face belongs to the original battle');
|
||||
game = finish(game);
|
||||
assert.equal(game.lastBattleContext!.winner, 'emperor');
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.deepEqual(combatArmy(game, 'emperor', KAITAIN), { normal: 2, elite: 0 });
|
||||
assert.deepEqual(combatArmy(game, 'guild', KAITAIN), { normal: 0, elite: 0 });
|
||||
const after = worldNexusPlayer(game, 'emperor');
|
||||
assert.equal(after.reserves, before.reserves - 5);
|
||||
assert.equal(after.tanks, before.tanks + 5, 'Three native fee counters and two real dial casualties, with no virtual bonus casualties');
|
||||
assert.equal(after.battleLosses, before.battleLosses + 5);
|
||||
assert.equal(worldNexusPlayer(game, 'guild').tanks, other.tanks + 3);
|
||||
assert.equal(after.spice, before.spice - (salusa === 'high' ? 0 : 2));
|
||||
assert.deepEqual(after.elites, stars, 'Temporary roles never become or kill actual stars');
|
||||
assert.deepEqual(game.homeworlds!.custody!.salusa, salusaBefore);
|
||||
const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!).find(home => home.location === KAITAIN)!;
|
||||
assert.deepEqual([home.population, home.side], [2, 'low']);
|
||||
const record = game.nexusSardaukarHistory!.at(-1)!;
|
||||
assert.equal(record.receipt.count, 5);
|
||||
assert.equal(record.receipt.normal, 7);
|
||||
assert.deepEqual(record.casualties!.reserveFee, { normal: 3, elite: 0 });
|
||||
assert.deepEqual([record.casualties!.forces.normal, record.casualties!.forces.elite,
|
||||
record.casualties!.forces.temporaryElite], [4, 0, 4]);
|
||||
assert.equal(record.casualties!.outcome, 'complete');
|
||||
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
|
||||
checkPhysicalDeal(game);
|
||||
});
|
||||
}
|
||||
|
||||
void test('an original Salusa-only fee leaves all five Kaitain Cunning roles available', () => {
|
||||
const f = createWorldNexusEmperorBattle({ independent: true, salusa: 'high' });
|
||||
let game = declareCunning(f.game);
|
||||
const before = worldNexusPlayer(game, 'emperor'), stars = structuredClone(before.elites);
|
||||
const salusaBefore = structuredClone(game.homeworlds!.custody!.salusa!);
|
||||
const own = viewGame(game, 'emperor').battle!.ownForces!;
|
||||
const projected = reinforcementsBattleForces(own, remoteFee[KAITAIN]);
|
||||
assert.deepEqual([projected.normal, projected.elite, projected.temporaryElite], [7, 0, 5]);
|
||||
assert.equal(maxCombatDial(projected), 12);
|
||||
assert.equal(maxCombatSupport(projected), 2);
|
||||
game = revealWorldNexusPlans(game, [cardPlan(f.plans[0], 'weapon', remoteFee), f.plans[1]]);
|
||||
game = finish(game);
|
||||
assert.equal(game.lastBattleContext!.winner, 'emperor');
|
||||
assert.deepEqual(combatArmy(game, 'emperor', KAITAIN), { normal: 5, elite: 0 });
|
||||
assert.deepEqual(game.homeworlds!.custody!.salusa, { normal: salusaBefore.normal - 3, elite: salusaBefore.elite });
|
||||
assert.equal(worldNexusPlayer(game, 'emperor').tanks, before.tanks + 5);
|
||||
assert.deepEqual(worldNexusPlayer(game, 'emperor').elites, stars);
|
||||
const record = game.nexusSardaukarHistory!.at(-1)!;
|
||||
assert.equal(record.casualties!.forces.temporaryElite, 5);
|
||||
assert.equal(record.casualties!.reserveFee, undefined, 'An off-World fee never rewrites the original temporary-role army');
|
||||
assert.equal(record.casualties!.outcome, 'complete');
|
||||
checkPhysicalDeal(game);
|
||||
});
|
||||
|
||||
for (const salusa of ['high', 'low'] as const) {
|
||||
void test(`all four minimal legal policies emit positive source-bearing Reinforcements plans with ${salusa} Salusa and original Cunning`, () => {
|
||||
const f = createWorldNexusEmperorBattle({ independent: true, salusa });
|
||||
const game = declareCunning(f.game);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(game, 'emperor');
|
||||
view.players.find(player => player.id === 'emperor')!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'battlePlan' && Number(action.dial) > 0 &&
|
||||
(action.weapon === REINFORCEMENTS_CARD || action.defense === REINFORCEMENTS_CARD));
|
||||
assert.ok(action, `${difficulty} must retain a positive physical card path`);
|
||||
const sources = action.reinforcementsSources as NativeReserveSelections | undefined;
|
||||
assert.ok(sources && Object.keys(sources).length, `${difficulty} must declare actual native sources`);
|
||||
const pool = reinforcementsBattleForces(view.battle!.ownForces!, sources[KAITAIN]);
|
||||
assert.ok(casualtyOptions(pool, Number(action.dial), Number(action.support ?? 0)).length,
|
||||
`${difficulty} must fund the physical dial after its own fee`);
|
||||
const sealed = applyAction(game, 'emperor', action);
|
||||
assert.ok(sealed.battle!.plans.emperor);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||
import { combatArmy } from '../game/combat-location';
|
||||
import { RECRUITS_CARD_ID, recruitsPlayAction } from '../game/recruits';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { createClassicIndependentNexusFixture, type ClassicIndependentNexusOptions } from './fixture-classic-independent-nexus';
|
||||
import {
|
||||
actWorldIndependentCard, advanceWorldIndependentCards, createWorldIndependentHarassFixture,
|
||||
createWorldIndependentRecruitsFixture, revealWorldIndependentHarass,
|
||||
worldIndependentCardPlayer as player, worldIndependentCardsClean,
|
||||
} from './fixture-nexus-world-independent-cards';
|
||||
|
||||
function stock(game: Game): void {
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(card => card.id).sort(),
|
||||
[...treacheryDeck([]), ...ecazTreacheryCards()].map(card => card.id).sort());
|
||||
const nexus = game.nexusCards!.cards!;
|
||||
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter(card => card !== null)].sort(),
|
||||
[...NEXUS_FACTIONS].sort());
|
||||
if (game.leaderSkills) assert.deepEqual([...game.leaderSkills.deck,
|
||||
...game.leaderSkills.assignments.map(assignment => assignment.skill),
|
||||
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards)].sort(),
|
||||
LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||
}
|
||||
function worldPool(game: Game, actor: string, location: string) {
|
||||
const world = viewGame(game, actor).homeworlds!.worlds!.find(world => world.id === location); assert.ok(world);
|
||||
return { ...(world.forces[actor] ?? { normal: 0, elite: 0 }) };
|
||||
}
|
||||
|
||||
const setupSamples: Array<ClassicIndependentNexusOptions & { roster: NonNullable<ClassicIndependentNexusOptions['roster']> }> = [
|
||||
{ roster: ['emperor', 'guild'] },
|
||||
{ roster: ['emperor', 'guild', 'atreides'], tech: true },
|
||||
{ roster: ['emperor', 'fremen', 'atreides', 'guild'], advanced: true, skills: true, tech: true, strongholds: true },
|
||||
{ roster: ['emperor', 'fremen', 'atreides', 'guild', 'harkonnen', 'beneGesserit'], advanced: true, skills: true, tech: true, strongholds: true },
|
||||
];
|
||||
for (const sample of setupSamples) {
|
||||
void test(`${sample.advanced ? 'Advanced all14/phase' : 'Basic'} ${sample.roster.length}-seat native start selects independent36, World custody and Nexus12 without early claims`, () => {
|
||||
const fixture = createClassicIndependentNexusFixture({ ...sample, independent: true, homeworlds: true });
|
||||
const { lobby, setup, game } = fixture;
|
||||
assert.deepEqual(lobby.expansions, []);
|
||||
assert.equal(lobby.lobbyOptions?.ecazTreachery, true);
|
||||
assert.deepEqual(lobby.homeworlds, { custody: null });
|
||||
assert.deepEqual(lobby.nexusCards, { cards: null, phase: null });
|
||||
assert.equal(lobby.leaderSkills?.deck.length ?? 0, 0, 'No skill is manufactured before the selected native setup');
|
||||
if (sample.tech) assert.deepEqual(lobby.techTokens, createTechTokens());
|
||||
if (sample.strongholds) assert.deepEqual(lobby.strongholdCards, createStrongholdCards());
|
||||
assert.equal(setup.status, 'setup');
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.ok(game.homeworlds!.custody);
|
||||
assert.equal(game.nexusCards!.cards!.deck.length, 12);
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
||||
assert.equal(game.nexusCards!.phase, null);
|
||||
if (sample.skills) {
|
||||
assert.equal(game.leaderSkills!.assignments.length, sample.roster.length);
|
||||
assert.deepEqual(game.leaderSkills!.offers, {});
|
||||
} else assert.ok(!game.leaderSkills);
|
||||
if (sample.strongholds) assert.deepEqual(game.strongholdCards, createStrongholdCards());
|
||||
assert.ok(fixture.actions.some(step => step.action.type === 'start'));
|
||||
assert.ok(fixture.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'ecazTreachery'));
|
||||
stock(game);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} genuine Recruits deals recheck the native Fremen population on each return`, () => {
|
||||
const fixture = createWorldIndependentRecruitsFixture({ advanced, skills: advanced, tech: advanced, strongholds: advanced });
|
||||
const initial = fixture.game, spice = player(initial, 'fremen').spice;
|
||||
assert.equal(initial.turn, 2);
|
||||
assert.equal(initial.phase, 4);
|
||||
assert.equal(player(initial, 'fremen').tanks, 18, 'Actual zero-dial first-turn defeat supplied the counters');
|
||||
assert.equal(player(initial, 'fremen').reserves, 2);
|
||||
assert.deepEqual(worldPool(initial, 'fremen', 'homeworld:fremen'), { normal: 2, elite: 0 });
|
||||
const action = recruitsPlayAction(viewGame(initial, 'emperor').recruitsPreview); assert.ok(action);
|
||||
assert.deepEqual(action, { type: 'card', card: RECRUITS_CARD_ID });
|
||||
const played = actWorldIndependentCard(initial, 'emperor', action);
|
||||
const before = viewGame(played, 'fremen');
|
||||
assert.equal(before.revival.homeworldBonus, 1);
|
||||
assert.equal(before.recruitsPreview!.rates.find(rate => rate.player === 'fremen')!.freeRate, 7);
|
||||
assert.throws(() => applyAction(played, 'fremen', { type: 'revive', amount: 8, elite: 0 }));
|
||||
const together = actWorldIndependentCard(played, 'fremen', { type: 'revive', amount: 7, elite: 0 });
|
||||
assert.deepEqual(worldPool(together, 'fremen', 'homeworld:fremen'), { normal: 9, elite: 0 });
|
||||
assert.equal(player(together, 'fremen').freeForcesRevived, 7);
|
||||
assert.equal(player(together, 'fremen').spice, spice);
|
||||
assert.equal(player(together, 'fremen').tanks, 11);
|
||||
|
||||
let separate = actWorldIndependentCard(played, 'fremen', { type: 'revive', amount: 1, elite: 0 });
|
||||
assert.deepEqual(worldPool(separate, 'fremen', 'homeworld:fremen'), { normal: 3, elite: 0 });
|
||||
const changed = viewGame(separate, 'fremen');
|
||||
assert.equal(changed.revival.homeworldBonus, 0);
|
||||
assert.equal(changed.revival.freeRemaining, 5);
|
||||
assert.equal(changed.recruitsPreview!.rates.find(rate => rate.player === 'fremen')!.freeRate, 6);
|
||||
assert.throws(() => applyAction(separate, 'fremen', { type: 'revive', amount: 6, elite: 0 }));
|
||||
separate = actWorldIndependentCard(separate, 'fremen', { type: 'revive', amount: 5, elite: 0 });
|
||||
assert.deepEqual(worldPool(separate, 'fremen', 'homeworld:fremen'), { normal: 8, elite: 0 });
|
||||
assert.equal(player(separate, 'fremen').freeForcesRevived, 6);
|
||||
assert.equal(player(separate, 'fremen').spice, spice);
|
||||
assert.equal(viewGame(separate, 'fremen').revival.forcesRemaining, 0);
|
||||
assert.throws(() => applyAction(separate, 'fremen', { type: 'revive', amount: 1, elite: 0 }));
|
||||
assert.equal(separate.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
|
||||
separate = advanceWorldIndependentCards(separate, state => state.phase === 5 && worldIndependentCardsClean(state));
|
||||
assert.equal(separate.turn, 2);
|
||||
stock(separate);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} genuinely dealt Harass returns typed foreign World visitors through native destinations`, () => {
|
||||
const fixture = createWorldIndependentHarassFixture({ advanced, skills: advanced, tech: advanced, strongholds: advanced });
|
||||
assert.equal(fixture.shipmentStep.action.type, 'homeworldShip');
|
||||
assert.deepEqual(combatArmy(fixture.afterShipment, fixture.visitor, fixture.location), { normal: 3, elite: 4 });
|
||||
let game = revealWorldIndependentHarass(fixture.game, fixture.plans);
|
||||
assert.equal(game.decision?.kind, 'harassWithdraw');
|
||||
const allocation = viewGame(game, fixture.owner).battle!.harassAllocation!;
|
||||
assert.deepEqual(allocation.context.locations, { [fixture.location]: { normal: 3, elite: 4 } });
|
||||
const before = player(game, fixture.owner), kaitain = worldPool(game, fixture.owner, 'homeworld:emperor');
|
||||
const salusa = advanced ? worldPool(game, fixture.owner, 'homeworld:emperor:salusa') : null;
|
||||
assert.ok(game.decision && 'event' in game.decision);
|
||||
game = actWorldIndependentCard(game, fixture.owner, { type: 'decision', event: game.decision.event, returns: fixture.returns });
|
||||
assert.deepEqual(viewGame(game, fixture.owner).battle!.traitorVoters, [fixture.native]);
|
||||
game = advanceWorldIndependentCards(game, state => !state.battle && !state.homeworldBattleLoss && worldIndependentCardsClean(state));
|
||||
const after = player(game, fixture.owner);
|
||||
assert.equal(game.lastBattleContext!.territory, fixture.location);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(game.lastBattleContext!.winner, fixture.native);
|
||||
assert.equal(after.reserves, before.reserves + 5);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + 3);
|
||||
assert.equal(after.tanks, before.tanks + 2);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 1);
|
||||
assert.deepEqual(combatArmy(game, fixture.owner, fixture.location), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(worldPool(game, fixture.owner, 'homeworld:emperor'), {
|
||||
normal: kaitain.normal + 2, elite: kaitain.elite + (advanced ? 0 : 3),
|
||||
});
|
||||
if (salusa) assert.deepEqual(worldPool(game, fixture.owner, 'homeworld:emperor:salusa'), {
|
||||
normal: salusa.normal, elite: salusa.elite + 3,
|
||||
});
|
||||
assert.equal(game.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 1);
|
||||
game = advanceWorldIndependentCards(game, state => state.phase === 7 && worldIndependentCardsClean(state));
|
||||
stock(game);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} native own World still forbids the genuinely dealt Harass`, () => {
|
||||
const fixture = createWorldIndependentHarassFixture({ advanced, native: true, skills: advanced, tech: advanced, strongholds: advanced });
|
||||
assert.equal(fixture.native, fixture.owner);
|
||||
assert.match(viewGame(fixture.game, fixture.owner).battle!.harassWithdraw!.blocked!, /own.*Homeworld/i);
|
||||
assert.throws(() => applyAction(fixture.game, fixture.owner, fixture.plans[0].action), /own.*Homeworld/i);
|
||||
assert.ok(player(fixture.game, fixture.owner).hand.some(card => card.id === HARASS_WITHDRAW_CARD));
|
||||
assert.deepEqual(fixture.game.battle!.plans, {});
|
||||
const plans = fixture.plans.map(plan => plan.actor === fixture.owner
|
||||
? { ...plan, action: { ...plan.action, defense: null } } : plan);
|
||||
let game = revealWorldIndependentHarass(fixture.game, plans);
|
||||
assert.deepEqual(viewGame(game, fixture.owner).battle!.traitorVoters, [fixture.owner]);
|
||||
game = advanceWorldIndependentCards(game, state => !state.battle && !state.homeworldBattleLoss && worldIndependentCardsClean(state));
|
||||
assert.equal(game.lastBattleContext!.territory, fixture.location);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.ok(player(game, fixture.owner).hand.some(card => card.id === HARASS_WITHDRAW_CARD));
|
||||
assert.equal(game.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 0);
|
||||
stock(game);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from '../game/combat';
|
||||
import { reinforcementsBattleForces } from '../game/reinforcements-homeworld';
|
||||
|
||||
function original(normal = 7, freeStars = true): CombatForces {
|
||||
return {normal,elite:0,temporaryElite:5,eliteStrength:2,freeSupport:false,eliteFreeSupport:freeStars};
|
||||
}
|
||||
|
||||
void test('own-World fee spends unconverted normals first, then removes only paid temporary roles', () => {
|
||||
const before = original();
|
||||
assert.deepEqual([1,2,3].map(normal => {
|
||||
const remaining = reinforcementsBattleForces(before,{normal,elite:0});
|
||||
return [remaining.normal,remaining.temporaryElite,maxCombatDial(remaining)];
|
||||
}), [[6,5,11],[5,5,10],[4,4,8]]);
|
||||
const allConverted = reinforcementsBattleForces(original(5),{normal:3,elite:0});
|
||||
assert.deepEqual([allConverted.normal,allConverted.temporaryElite,maxCombatDial(allConverted)],[2,2,4]);
|
||||
assert.deepEqual(before,original(),'The declared five-counter group is unchanged.');
|
||||
});
|
||||
|
||||
void test('reduced temporary force roles retain pre-fee support traits and ordinary physical casualties', () => {
|
||||
for (const freeStars of [false,true]) {
|
||||
const remaining = reinforcementsBattleForces(original(7,freeStars),{normal:3,elite:0});
|
||||
assert.equal(remaining.eliteFreeSupport,freeStars);
|
||||
assert.equal(maxCombatSupport(remaining),freeStars ? 0 : 4);
|
||||
const losses = casualtyOptions(remaining,4,freeStars ? 0 : 2);
|
||||
assert.ok(losses.length);
|
||||
assert.ok(losses.every(loss => loss.normal === 2 && loss.elite === 0));
|
||||
}
|
||||
});
|
||||
|
||||
void test('off-World and zero allocations keep the original five roles; impossible local fees reject', () => {
|
||||
const before = original();
|
||||
assert.equal(reinforcementsBattleForces(before),before);
|
||||
assert.equal(reinforcementsBattleForces(before,{normal:0,elite:0}),before);
|
||||
assert.throws(()=>reinforcementsBattleForces(before,{normal:8,elite:0}));
|
||||
assert.throws(()=>reinforcementsBattleForces(before,{normal:0,elite:1}));
|
||||
});
|
||||
Reference in new issue
Block a user