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.
180 lines
12 KiB
TypeScript
180 lines
12 KiB
TypeScript
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;
|
|
}
|