diff --git a/components/battle-preparation.tsx b/components/battle-preparation.tsx
index 283e39d0..2ae92b38 100644
--- a/components/battle-preparation.tsx
+++ b/components/battle-preparation.tsx
@@ -27,15 +27,16 @@ import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode';
export function battlePlanCardOptions(game: GameView, slot: BattleCardSlot, leader: string, weapon: string, defense: string) {
const me = game.players.find(player => player.id === game.me)!;
const fixed = battlePlanCommitments(game)[slot];
+ const assignments = game.leaderSkills?.assignments.filter(a => a.controller === game.me) ?? [];
return (me.hand ?? []).filter(card => {
const w = slot === 'weapon' ? card : me.hand?.find(c => c.id === weapon);
const d = slot === 'defense' ? card : me.hand?.find(c => c.id === defense);
const planetologistWeapon = canUsePlanetologistBattleSpecial({
- assignments: game.leaderSkills?.assignments.filter(a => a.controller === game.me) ?? [],
+ assignments,
selectedLeader: leader, card: w,
});
- return (card.id !== 'ecaz-reinforcements' || (!!game.battle?.reinforcements && !game.battle.reinforcements.blocked)) &&
- (card.id !== 'ecaz-harass-withdraw' || (!!game.battle?.harassWithdraw && !game.battle.harassWithdraw.blocked)) &&
+ return (card.id !== 'ecaz-reinforcements' || (!!game.battle?.reinforcements && !game.battle.reinforcements.blocked) || (slot === 'weapon' && planetologistWeapon)) &&
+ (card.id !== 'ecaz-harass-withdraw' || (!!game.battle?.harassWithdraw && !game.battle.harassWithdraw.blocked) || (slot === 'weapon' && planetologistWeapon)) &&
(!isMirrorWeapon(card) || !mirrorWeaponModeBlock(game)) &&
battleCardSlotEligible(slot, card, { planetologistWeapon }) &&
(!fixed || fixedBattleInspectionMatches(slot, fixed.value, card.id, card)) &&
diff --git a/components/battle-promises.tsx b/components/battle-promises.tsx
index f80612b4..5aa054c3 100644
--- a/components/battle-promises.tsx
+++ b/components/battle-promises.tsx
@@ -233,6 +233,9 @@ export function BattlePromises({
{plan.reinforcementsSources && (
Native reserve fee: {reinforcementsSourceLabel(g, plan.reinforcementsSources)}
)}
+ {plan.planetologistSpecial && (
+ Planetologist alternate Special: +2 if the leader survives; no normal card effect.
+ )}
These choices satisfy your current commitments. Review and change
the other elements before sealing your plan.
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 79a186c3..1b55a19b 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -164,7 +164,7 @@ import {
isStoneBurner,
isMirrorWeapon,
} from '@/game/battle-cards';
-import { validBattleSlotPair } from '@/game/battle-card-slots';
+import { validBattleSlotPair, usesNativeBattleSpecial } from '@/game/battle-card-slots';
import { stoneBurnerPlanBlock, stoneBurnerWithdrawalContext } from '@/game/stone-burner';
import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode';
import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens';
@@ -203,7 +203,7 @@ import { HarassWithdrawChoice } from './harass-withdraw-choice';
import { PrivateBattlePlan } from './private-battle-plan';
import { SpiceBankerControl, spiceBankerControlState } from './spice-banker';
import { SpiceBankerIncome } from './spice-banker-income';
-import { canUsePlanetologistBattleSpecial } from '../game/leader-skill-combat';
+import { canUsePlanetologistBattleSpecial, planetologistSpecialNeedsChoice } from '../game/leader-skill-combat';
import type { Card } from '../game/cards';
import { RevealedBattle } from './revealed-battle';
import { DiplomatDefense } from './diplomat-defense';
@@ -428,6 +428,7 @@ export function GameTable({
const [target, setTarget] = useState('');
const [leader, setLeader] = useState('');
const [weapon, setWeapon] = useState('');
+ const [planetologistDraft, setPlanetologistDraft] = useState<{ key: string; enabled: boolean } | null>(null);
const [defense, setDefense] = useState('');
const [dial, setDial] = useState(0);
const [stormDial, setStormDial] = useState(0);
@@ -442,8 +443,22 @@ export function GameTable({
const selectedDefenseId = battlePlanCardValue(g, 'defense', defense);
const selectedBattleWeapon = me.hand?.find(c => c.id === selectedWeaponId);
const selectedBattleDefense = me.hand?.find(c => c.id === selectedDefenseId);
- const reinforcementsSelected = selectedWeaponId === 'ecaz-reinforcements' ||
+ const committed = battlePlanCommitments(g);
+ const selectedBattleLeader = committed.leader ? String(committed.leader.value ?? '') : leader;
+ const battleSkills = g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [];
+ const planetologistSpecial = (card: Card) => canUsePlanetologistBattleSpecial({
+ assignments: battleSkills, selectedLeader: selectedBattleLeader, card,
+ });
+ const planetologistRoleKey = `${g.battle?.event ?? ''}:${selectedBattleLeader}:${selectedWeaponId}`;
+ const planetologistEligible = !!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon);
+ const planetologistRoleChoice = planetologistEligible && planetologistSpecialNeedsChoice(selectedBattleWeapon);
+ const planetologistAlternate = planetologistRoleChoice &&
+ planetologistDraft?.key === planetologistRoleKey && planetologistDraft.enabled;
+ const planetologistWeapon = planetologistEligible && (!planetologistRoleChoice || planetologistAlternate);
+ const reinforcementsSelected = (selectedWeaponId === 'ecaz-reinforcements' && !planetologistWeapon) ||
selectedDefenseId === 'ecaz-reinforcements';
+ const harassSelected = (selectedWeaponId === 'ecaz-harass-withdraw' && !planetologistWeapon) ||
+ selectedDefenseId === 'ecaz-harass-withdraw';
const [reinforcementsSourceDraft, setReinforcementsSourceDraft] = useState<{
key: string; index: number;
} | null>(null);
@@ -529,10 +544,6 @@ export function GameTable({
);
const [discardCards, setDiscardCards] = useState>({});
const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null;
- const committed = battlePlanCommitments(g);
- const selectedBattleLeader = committed.leader
- ? String(committed.leader.value ?? '')
- : leader;
const paidBattleSupport = Math.max(0, battleSupport - strongholdBankSupport);
const selectedBattleAllyPayment =
allyPayment === ''
@@ -558,11 +569,6 @@ export function GameTable({
0,
Math.min(Math.trunc(bankerSpice), bankerControl.maximum),
);
- const planetologistSpecial = (card: Card) => canUsePlanetologistBattleSpecial({
- assignments: g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [],
- selectedLeader: committed.leader ? String(committed.leader.value ?? '') : leader,
- card,
- });
const reinforcementsPlanReason = !reinforcementsSelected ? null :
!g.battle?.reinforcements ? 'Reinforcements is unavailable in this battle.' :
g.battle.reinforcements.blocked ??
@@ -579,9 +585,9 @@ export function GameTable({
committed.dial ? Number(committed.dial.value) : battleDial,
battleFreeSupport ? 0 : battleSupport);
const battlePairValid = validBattleSlotPair(selectedBattleWeapon, selectedBattleDefense,
- !!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon));
+ planetologistWeapon);
const harassControl = harassWithdrawControlState(g.battle?.harassWithdraw,
- selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw',
+ harassSelected,
committed.dial ? Number(committed.dial.value) : battleDial,
battleFreeSupport ? 0 : battleSupport);
const selectedSpecialWeapon = me.hand?.find(
@@ -590,7 +596,7 @@ export function GameTable({
const mirrorSelected = isMirrorWeapon(selectedSpecialWeapon);
const stoneContext = g.battle?.stoneBurnerContext;
const stoneCandidateContext = selectedSpecialWeapon &&
- (selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw')
+ harassSelected
? stoneBurnerWithdrawalContext(stoneContext?.occupy,
g.battle!.attacker === me.id ? 'attacker' : 'defender', g.battle?.harassWithdraw?.occupy)
: stoneContext?.occupy;
@@ -3105,7 +3111,7 @@ export function GameTable({
forces remaining after a successful withdrawal. An opposing
successful Traitor cancels that withdrawal.
- {g.battle?.cards.some(card => card.id === 'ecaz-reinforcements') && (
+ {Object.values(g.battle?.plans ?? {}).some(plan => plan && usesNativeBattleSpecial(plan, 'ecaz-reinforcements')) && (
Reinforcements still pays its original three-reserve cost.
Its +2 normal-score bonus does not add undialed physical
@@ -5120,6 +5126,10 @@ export function GameTable({
setLeader(plan.leader ?? '');
setWeapon(plan.weapon ?? '');
setDefense(plan.defense ?? '');
+ setPlanetologistDraft({
+ key: `${g.battle?.event ?? ''}:${plan.leader ?? ''}:${plan.weapon ?? ''}`,
+ enabled: plan.planetologistSpecial === true,
+ });
setKwisatz(!!plan.kwisatz);
if (plan.reinforcementsSources) {
const chosen = JSON.stringify(plan.reinforcementsSources);
@@ -5387,14 +5397,24 @@ export function GameTable({
selectedBattleLeader, selectedWeaponId, selectedDefenseId)
.map((c) => (
- {c.name}{name === 'Weapon' && planetologistSpecial(c) ? ' · Planetologist +2, discard after battle' : ''}
+ {c.name}{name === 'Weapon' && planetologistSpecial(c)
+ ? planetologistSpecialNeedsChoice(c) ? ' · normal or Planetologist role' : ' · Planetologist +2, discard after battle'
+ : ''}
))}
))}
-
- {g.homeworlds && g.battle.harassWithdraw && (
+ {planetologistRoleChoice && (
+
+ setPlanetologistDraft({ key: planetologistRoleKey, enabled: event.target.checked })} />
+ Use {selectedBattleWeapon!.name} as Planetologist Special (+2 leader; no normal card effect)
+
+ )}
+ {!(planetologistWeapon && selectedWeaponId === 'ecaz-harass-withdraw') &&
+ }
+ {g.homeworlds && g.battle.harassWithdraw && !(planetologistWeapon && selectedWeaponId === 'ecaz-harass-withdraw') && (
Homeworld timing: actual undialed counters
return to your native reserve Homeworlds, never to
@@ -5405,7 +5425,7 @@ export function GameTable({
thresholds. Harass cannot be played on any own World.
)}
- {g.battle.reinforcements && (
+ {g.battle.reinforcements && !(planetologistWeapon && selectedWeaponId === 'ecaz-reinforcements') && (
Reinforcements may occupy either card slot but is neither a weapon nor a defense.
It adds 2 to battle strength without changing the physical dial or spice support.
@@ -5421,7 +5441,7 @@ export function GameTable({
selected={reinforcementsSourceIndex} busy={busy}
onChange={index => setReinforcementsSourceDraft({ key: reinforcementsSourceKey, index })} />
)}
- {g.battle.reinforcements?.noField &&
+ {g.battle.reinforcements?.noField && !(planetologistWeapon && selectedWeaponId === 'ecaz-reinforcements') &&
Provisional native order: your No-Field first materializes{' '}
{g.battle.reinforcements.noField.materializes} forces, leaving{' '}
{g.battle.reinforcements.noField.reservesAfterReveal} own reserves.
@@ -5429,7 +5449,7 @@ export function GameTable({
a later Harass return cannot fund or change its original cost.
}
{reinforcementsSelected &&
- (selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw') && (
+ harassSelected && (
Provisional combined timing: pay the original three-reserve
Reinforcements cost before Harass & Withdraw returns any
@@ -5439,7 +5459,7 @@ export function GameTable({
)}
{reinforcementsPlanReason &&
{reinforcementsPlanReason}
}
{!battlePairValid && Choose two compatible battle cards. A single physical card cannot fill both slots.
}
- {selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon) && (
+ {selectedBattleWeapon && planetologistWeapon && (
{selectedBattleWeapon.name} adds 2 to the surviving Planetologist leader.
It makes no weapon attack and does not use its normal effect.
@@ -5517,6 +5537,7 @@ export function GameTable({
: 'Seal battle plan',
bindBattlePlanCommitments(g, {
type: 'battlePlan',
+ ...(planetologistAlternate ? { planetologistSpecial: true } : {}),
...(battleBankerSpice
? { bankerSpice: battleBankerSpice }
: {}),
@@ -5549,6 +5570,9 @@ export function GameTable({
spice committed.
)}
+ {g.battle.plans[me.id]?.planetologistSpecial && (
+ Planetologist alternate Special: +2 if the leader survives; no normal card effect.
+ )}
{g.battle.plans[me.id]?.reinforcementsSources && (
Sealed reserve fee: {reinforcementsSourceLabel(g, g.battle.plans[me.id].reinforcementsSources!)}
)}
diff --git a/components/private-battle-plan.tsx b/components/private-battle-plan.tsx
index be604464..297e1816 100644
--- a/components/private-battle-plan.tsx
+++ b/components/private-battle-plan.tsx
@@ -75,6 +75,12 @@ export function PrivateBattlePlan({ game }: { game: GameView }) {
Kwisatz Haderach
{insight.plan.kwisatz ? 'Included' : 'Not included'}
+ {insight.plan.planetologistSpecial && (
+
+
Planetologist alternate Special
+ +2 if the leader survives; the normal card effect is not used.
+
+ )}
{insight.plan.reinforcementsSources && (
Native Reinforcements fee
diff --git a/components/revealed-battle.tsx b/components/revealed-battle.tsx
index 494825dd..d0178a52 100644
--- a/components/revealed-battle.tsx
+++ b/components/revealed-battle.tsx
@@ -2,6 +2,7 @@ import type { GameView } from '@/game/engine';
import { faction } from '@/game/catalog';
import { leaderStrengthLabel } from '@/game/cards';
import { isStoneBurner } from '@/game/battle-cards';
+import { usesNativeBattleSpecial } from '@/game/battle-card-slots';
import { stoneBurnerComparison, stoneBurnerWithdrawalContext } from '@/game/stone-burner';
import { RevealedPlanPieces } from './revealed-plan-pieces';
import { KwisatzInspector } from './kwisatz-inspector';
@@ -13,7 +14,7 @@ export function RevealedBattle({ game }: { game: GameView }) {
let stoneContext = battle.stoneBurnerContext?.occupy;
const withdrawing = [battle.attacker, battle.defender].filter(id => {
const plan = battle.plans[id];
- return plan && [plan.weapon, plan.defense].includes('ecaz-harass-withdraw');
+ return plan && usesNativeBattleSpecial(plan, 'ecaz-harass-withdraw');
});
for (const id of withdrawing) {
const occupy = battle.ecazOccupy?.lead === id ? {
@@ -84,6 +85,9 @@ export function RevealedBattle({ game }: { game: GameView }) {
{!!plan.bankerSpice && (
Spice Banker: {plan.bankerSpice} spice committed
)}
+ {plan.planetologistSpecial && (
+
Planetologist alternate Special · +2 if leader survives · no normal card effect
+ )}
{battle.native === id && !battle.cards.some(isStoneBurner) && (
Native Homeworld bonus: +{battle.nativeBattleStrength} to the
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 83f55618..fe5ed34c 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -29,6 +29,19 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
+**Independent36/all14 Skills composition — 17 October.** Fresh ready classic
+Basic/Advanced all14 now admits the printed independent three-card variant
+(original36, no other modules, no played-game conversion). Real Recruits after a
+prior free return prices4 as free1+paid3=6 to Bank and defers one Banker spice to
+actual Mentat. R+H keep fee-before-return with opposing Warmaster (Worthless
+required) and owner Suk rescue. All three cards print green Special headers:
+Planetologist substitutes Recruits automatically; R/H need explicit
+`planetologistSpecial` and then skip their native fee/return. Zero reserves block
+native R but permit that substitution; a truthful named promise finds it and all
+four policies execute it. Bureaucrat redirection is admitted. Frozen16-file 167/167,
+types/lint and build pass; actual390px zero-reserve journey (5 UI actions) reaches
+Collection with no errors/overflow. Combined-role choice is provisional.
+
**Mandatory normal Banker default — 17 October.** Fresh supported ordinary all14
Skills now creates the existing deferred-income ledger automatically, including
its original module/native overlay envelopes. Separate initializer/CLI profile
diff --git a/game/battle-card-slots.ts b/game/battle-card-slots.ts
index 6db0fd15..202a7aee 100644
--- a/game/battle-card-slots.ts
+++ b/game/battle-card-slots.ts
@@ -59,6 +59,14 @@ export function validBattleSlotPair(
);
}
+/** A Planetologist substitution suppresses only the selected weapon's native effect. */
+export function usesNativeBattleSpecial(
+ plan: Readonly<{ weapon?: unknown; defense?: unknown; planetologistSpecial?: unknown }>,
+ card: 'ecaz-reinforcements' | 'ecaz-harass-withdraw',
+): boolean {
+ return plan.defense === card || (plan.weapon === card && plan.planetologistSpecial !== true);
+}
+
/**
* Prescience asks for the selected category rather than physical slot
* occupancy. Exact Ecaz battle specials answer null without revealing their
diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts
index e8c36fc9..83028510 100644
--- a/game/battle-resolution-quote.ts
+++ b/game/battle-resolution-quote.ts
@@ -46,7 +46,7 @@ import {
} from './homeworld-battle-rules';
import {
PLANETOLOGIST_BASE_SPECIALS,
- canUsePlanetologistBattleSpecial,
+ usesPlanetologistBattleSpecial,
isPlanetologistBattleSpecialCard,
leaderSkillBattleBonus,
bureaucratBattlePenalty,
@@ -65,6 +65,7 @@ export class BattleResolutionQuoteError extends Error {
}
export type ResolutionPlan = {
bankerSpice?: number;
+ planetologistSpecial?: true;
dial: number;
support: number;
allyPayment?: number;
@@ -240,6 +241,10 @@ function validateCombatant(
} catch (error) {
throw new BattleResolutionQuoteError(error instanceof Error ? error.message : 'Invalid Spice Banker commitment.');
}
+ requireQuote(p.planetologistSpecial === undefined || (p.planetologistSpecial === true &&
+ usesPlanetologistBattleSpecial({ assignments: side.leaderSkills ?? [],
+ selectedLeader: p.leader, card: side.hand.find(card => card.id === p.weapon), planetologistSpecial: true })),
+ 'The alternate Special role needs the actual Planetologist weapon substitution.');
const selected = [p.weapon, p.defense, p.leader, side.lateDefense].filter(
(value): value is string => value != null,
);
@@ -277,9 +282,8 @@ function validateCombatant(
].filter(
(selectedId): selectedId is string =>
!!selectedId &&
- PLANETOLOGIST_BASE_SPECIALS.some(
- (card) => card.id === selectedId,
- ),
+ (PLANETOLOGIST_BASE_SPECIALS.some((card) => card.id === selectedId) ||
+ selectedId === 'ecaz-recruits' || (p.planetologistSpecial === true && selectedId === p.weapon)),
);
requireQuote(
selectedPlanetologistCards.every((selectedId) => {
@@ -289,10 +293,11 @@ function validateCombatant(
side.leader?.id === p.leader &&
!side.leader.dead &&
isPlanetologistBattleSpecialCard(selectedCard) &&
- canUsePlanetologistBattleSpecial({
+ usesPlanetologistBattleSpecial({
assignments: side.leaderSkills ?? [],
selectedLeader: p.leader,
card: selectedCard,
+ planetologistSpecial: p.planetologistSpecial,
})
);
}),
@@ -405,29 +410,33 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
const reinforcements: NonNullable = [];
for (const side of [a, d]) {
const slots = [card(side, side.plan.weapon), card(side, side.plan.defense)];
+ const planetologistWeapon = usesPlanetologistBattleSpecial({
+ assignments: side.leaderSkills ?? [], selectedLeader: side.plan.leader,
+ card: slots[0], planetologistSpecial: side.plan.planetologistSpecial,
+ });
const selectedIds = [side.plan.weapon, side.plan.defense, side.plan.leader, side.lateDefense];
requireQuote(selectedIds.every((selectedId) =>
selectedId !== REINFORCEMENTS_CARD || slots.some(c => c?.id === selectedId && isReinforcements(c))),
'Reinforcements must be a canonical physical card in exactly one battle-card slot.');
- const selected = slots.filter(isReinforcements);
+ const selected = slots.filter((selectedCard, slot) =>
+ isReinforcements(selectedCard) && !(slot === 0 && planetologistWeapon));
requireQuote(selected.length <= 1 && (selected.length === 0) === (side.reinforcementsReserves === undefined),
'Reinforcements requires its own reserve counts exactly when selected.');
if (selected.length) {
- requireQuote(validBattleSlotPair(slots[0], slots[1]) &&
+ requireQuote(validBattleSlotPair(slots[0], slots[1], planetologistWeapon) &&
!(side === d && reinforcements.length),
'Reinforcements needs a unique physical card and valid weapon and defense slot pair.');
- requireQuote(!(side.leaderSkills?.length || (side === a ? d : a).leaderSkills?.length),
- 'Reinforcements cannot combine with leader skills.');
const reserves = side.reinforcementsReserves!;
requireQuote(!!reserves && typeof reserves === 'object' && !Array.isArray(reserves),
'Reinforcements needs own typed reserves.');
const cost = quoteReinforcements(reserves.normal, reserves.elite);
reinforcements.push({ player: side.id, card: REINFORCEMENTS_CARD, normal: cost.normal, elite: cost.elite });
}
- const harass = slots.some(isHarassWithdraw);
+ const harass = slots.some((selectedCard, slot) =>
+ isHarassWithdraw(selectedCard) && !(slot === 0 && planetologistWeapon));
requireQuote((!side.harassWithdraw && side.harassSelection === undefined) || harass, 'The withdrawal context requires the physical Harass & Withdraw card.');
if (harass) {
- requireQuote(validBattleSlotPair(slots[0], slots[1]),
+ requireQuote(validBattleSlotPair(slots[0], slots[1], planetologistWeapon),
'The Harass & Withdraw plan has an invalid weapon and defense pair.');
requireQuote(side.harassWithdraw,
'Harass & Withdraw needs its supported physical context.');
@@ -672,6 +681,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
effectiveDefense: effectiveAd,
skilledLeaderSurvives: !deaths.attacker,
bankerSpice: a.plan.bankerSpice,
+ planetologistSpecial: a.plan.planetologistSpecial,
}),
defender: leaderSkillBattleBonus({
assignments: d.leaderSkills ?? [],
@@ -681,6 +691,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
effectiveDefense: effectiveDd,
skilledLeaderSurvives: !deaths.defender,
bankerSpice: d.plan.bankerSpice,
+ planetologistSpecial: d.plan.planetologistSpecial,
}),
};
const attackerPenalty = bureaucratBattlePenalty(d.leaderSkills ?? [], d.leader?.id,
@@ -860,10 +871,11 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
selected: string,
) =>
selected === side.plan.weapon &&
- canUsePlanetologistBattleSpecial({
+ usesPlanetologistBattleSpecial({
assignments: side.leaderSkills ?? [],
selectedLeader: side.plan.leader,
card: card(side, selected),
+ planetologistSpecial: side.plan.planetologistSpecial,
});
// The explicit after-use instruction also applies to a successful own traitor
// call, using the same specificity inference as Planetologist's discard.
diff --git a/game/bots.ts b/game/bots.ts
index ae738ea1..e70c9e87 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -3,7 +3,7 @@ import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions }
import { quoteEcazOccupyDial } from './ecaz-occupy-battle';
import { recruitsPlayAction } from './recruits';
import type { Card } from './cards';
-import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus } from './leader-skill-combat';
+import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, planetologistSpecialNeedsChoice, leaderSkillBattleBonus } from './leader-skill-combat';
import { defaultSmugglerBattleAllocation, smugglerBattlePlanBlock } from './smuggler-battle';
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots';
import { mirrorWeaponModeBlock } from './mirror-weapon-mode';
@@ -732,19 +732,24 @@ function plans(g: GameView): Action[] {
const bankerPrototypeAvailable = spiceBankerModeSupported(g);
const specialForLeader = (card: Card | undefined, leader: string | null) =>
canUsePlanetologistBattleSpecial({assignments:battleSkills, selectedLeader:leader, card});
- const prototypeCardAllowed = (card: Card) =>
- (!isMirrorWeapon(card) || !mirrorWeaponModeBlock(g)) &&
- (card.id !== 'ecaz-reinforcements' || (!!b.reinforcements && !b.reinforcements.blocked)) &&
- (card.id !== 'ecaz-harass-withdraw' || (!!b.harassWithdraw && !b.harassWithdraw.blocked));
+ const prototypeCardAllowed = (card: Card, slot: 'weapon' | 'defense') => {
+ const native = (!isMirrorWeapon(card) || !mirrorWeaponModeBlock(g)) &&
+ (card.id !== 'ecaz-reinforcements' || (!!b.reinforcements && !b.reinforcements.blocked)) &&
+ (card.id !== 'ecaz-harass-withdraw' || (!!b.harassWithdraw && !b.harassWithdraw.blocked));
+ return native || (slot === 'weapon' && planetologistSpecialNeedsChoice(card) &&
+ me.leaders.some(l => !l.dead && controlsLeader(me, l) &&
+ (!l.usedAt || l.usedAt === b.territory) && specialForLeader(card, l.id)));
+ };
const weapons = [
null,
- ...(me.hand ?? []).filter(c => prototypeCardAllowed(c) && battleCardSlotEligible('weapon', c, {
- planetologistWeapon: me.leaders.some(l => !l.dead && controlsLeader(me,l) && (!l.usedAt || l.usedAt === b.territory) && specialForLeader(c,l.id)),
- })).map(c => c.id),
+ ...(me.hand ?? []).filter(c => prototypeCardAllowed(c, 'weapon') &&
+ (battleCardSlotEligible('weapon', c) ||
+ me.leaders.some(l => !l.dead && controlsLeader(me, l) &&
+ (!l.usedAt || l.usedAt === b.territory) && specialForLeader(c, l.id)))).map(c => c.id),
].filter((id) => fixed('weapon', id));
const defenses = [
null,
- ...(me.hand ?? []).filter(c => prototypeCardAllowed(c) && battleCardSlotEligible('defense', c)).map((c) => c.id),
+ ...(me.hand ?? []).filter(c => prototypeCardAllowed(c, 'defense') && battleCardSlotEligible('defense', c)).map((c) => c.id),
].filter((id) => fixed('defense', id));
const availableLeaders = me.leaders.filter(
(l) =>
@@ -829,7 +834,11 @@ function plans(g: GameView): Action[] {
isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon));
if ((!leader && (weapon || defense)) || !validBattleSlotPair(w, d, specialForLeader(w,leader)))
continue;
- const reinforcements = weapon === 'ecaz-reinforcements' || defense === 'ecaz-reinforcements';
+ const planetologistSpecial = planetologistSpecialNeedsChoice(w) && specialForLeader(w, leader);
+ const reinforcements = (weapon === 'ecaz-reinforcements' && !planetologistSpecial) || defense === 'ecaz-reinforcements';
+ const harass = (weapon === 'ecaz-harass-withdraw' && !planetologistSpecial) || defense === 'ecaz-harass-withdraw';
+ if (reinforcements && (!b.reinforcements || b.reinforcements.blocked)) continue;
+ if (harass && (!b.harassWithdraw || b.harassWithdraw.blocked)) continue;
if (voice) {
const used =
playedVoiceMatch(
@@ -878,6 +887,7 @@ function plans(g: GameView): Action[] {
assignments:battleSkills,
selectedLeader:leader ? {id:leader,kind:myLeader?'disc':'hero'} : undefined,
weapon:w,defense:d,skilledLeaderSurvives:true,
+ planetologistSpecial: planetologistSpecial ? true : undefined,
}).bonus;
const enemySurvivingStrength =
effects.stunned || (inspected && effects.defenderDead)
@@ -916,6 +926,7 @@ function plans(g: GameView): Action[] {
])) {
const action: Action = {
type: 'battlePlan',
+ ...(planetologistSpecial ? { planetologistSpecial: true } : {}),
kwisatz,
dial,
leader,
@@ -976,7 +987,6 @@ function plans(g: GameView): Action[] {
const stonePlan = isStoneBurner(me.hand?.find((c) => c.id === action.weapon)) ||
isMirrorWeapon(me.hand?.find((c) => c.id === action.weapon));
if (stonePlan && !action.leader) continue;
- const harass = weapon === 'ecaz-harass-withdraw' || defense === 'ecaz-harass-withdraw';
const stoneContext = stonePlan && harass
? stoneBurnerWithdrawalContext(b.stoneBurnerContext?.occupy,
b.attacker === me.id ? 'attacker' : 'defender', b.harassWithdraw?.occupy)
diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts
index b64160cb..70effc97 100644
--- a/game/bureaucrat-payment.ts
+++ b/game/bureaucrat-payment.ts
@@ -1,4 +1,4 @@
-import { homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile';
+import { homeworldLeaderSkillsProfile, classicIndependentLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile';
import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile';
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival';
export type BureaucratPaymentSource = {
@@ -40,7 +40,7 @@ export function bureaucratPaymentSignature(value: object): string {
return JSON.stringify({ ...value, signature: undefined });
}
export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolean {
- return (!game.homeworlds && game.expansions.length === 1 &&
+ return classicIndependentLeaderSkillsProfile(game) || (!game.homeworlds && game.expansions.length === 1 &&
(game.expansions[0] === 'choam' || game.expansions[0] === 'ix') &&
pairedNexusLeaderSkillsProfile(game)) ||
mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) ||
diff --git a/game/ecaz-cards.ts b/game/ecaz-cards.ts
index 1e9d2033..bec3400b 100644
--- a/game/ecaz-cards.ts
+++ b/game/ecaz-cards.ts
@@ -118,7 +118,7 @@ export const ECAZ_TREACHERY_DEFINITIONS: readonly EcazTreacheryDefinition[] =
verification: {
inventory: 'verified',
sourceRules: 'verified',
- runtime: 'not-implemented',
+ runtime: 'prototype',
combinedInteractions: 'incomplete',
unresolved: [
'Reserve-cost timing when either side calls a traitor and in explosion or instant-outcome battles.',
diff --git a/game/engine.ts b/game/engine.ts
index b9696136..7f8a47cc 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,7 +1,7 @@
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { nativeFactionTechProfile } from './faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
-import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
+import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicIndependentLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
@@ -10,7 +10,7 @@ import { leaderSkillCard } from './leader-skill-cards';
import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment';
import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question';
import {
- canUsePlanetologistBattleSpecial,
+ usesPlanetologistBattleSpecial, planetologistSpecialNeedsChoice,
type BattleLeaderSkill,
} from './leader-skill-combat';
import { quoteSukPhysicalRescue, quoteSukRescue, SukPhysicalRescueError, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukPhysicalRescueQuote, type SukPhysicalRescueReceipt, type SukReserveDestinations, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
@@ -29,7 +29,7 @@ import { BankerIncomeError, createBankerIncomeState, validateBankerIncomeState,
import { quoteDiplomatDefense, diplomatDefenseModeSupported, diplomatDefenseLabel, type DiplomatDefenseQuote } from './diplomat-defense';
import { diplomatRetreatChoices, type DiplomatRetreatSelection, type DiplomatRetreatDestination } from './diplomat-retreat';
import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup';
-import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair } from './battle-card-slots';
+import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, usesNativeBattleSpecial } from './battle-card-slots';
import { HARASS_WITHDRAW_CARD, HarassWithdrawError, isHarassWithdraw, quoteHarassWithdraw, defaultHarassWithdrawAllocation, harassWithdrawNeedsAllocation, type HarassWithdrawSelection, type HarassWithdrawContext, type HarassWithdrawPreview } from './harass-withdraw';
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, discoveryStormDistance, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
@@ -681,6 +681,8 @@ export type Player = {
export type Plan = {
/** Separate own-spice commitment for the selected Spice Banker; absent is zero. */
bankerSpice?: number;
+ /** Explicit alternate weapon use for a green Special with its own battle effect. */
+ planetologistSpecial?: true;
dial: number;
leader: string | null;
weapon: string | null;
@@ -693,7 +695,7 @@ export type Plan = {
};
export type PlanField = Exclude<
keyof Plan,
- 'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice' | 'reinforcementsSources'
+ 'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice' | 'reinforcementsSources' | 'planetologistSpecial'
>;
export type DiplomatDefenseReceipt = DiplomatDefenseQuote & {
event: string;
@@ -8365,7 +8367,7 @@ function planCombatForces(g: Game, p: Player, opponent: Player,
const home = homeworldBattleLocation(g, g.battle!.territory);
if (!home || home.native !== p.id) return forces;
const plan = g.battle!.plans[p.id];
- const cost = reserveCost ?? (plan && (plan.weapon === REINFORCEMENTS_CARD || plan.defense === REINFORCEMENTS_CARD)
+ const cost = reserveCost ?? (plan && usesNativeBattleSpecial(plan, REINFORCEMENTS_CARD)
? currentReinforcementsCost(g, p, plan.reinforcementsSources) : undefined);
return reinforcementsBattleForces(forces, cost?.sources?.[home.id]);
}
@@ -8831,11 +8833,13 @@ function canUsePlanetologistWeapon(
player: Player,
leader: string | null,
card: Card | undefined,
+ planetologistSpecial?: true,
) {
- return canUsePlanetologistBattleSpecial({
+ return usesPlanetologistBattleSpecial({
assignments: battleLeaderSkills(g, player),
selectedLeader: leader,
card,
+ planetologistSpecial,
});
}
function nativeLeaderSkill(g: Game, owner: string) {
@@ -10634,6 +10638,12 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
export function initializeLeaderSkillsGameForAudit(state: Game): Game {
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
const homeworlds = homeworldLeaderSkillsProfile(state);
+ const independent = classicIndependentLeaderSkillsProfile(state);
+ if (state.ecazTreachery) {
+ requireRule(independent, 'Independent Treachery with Leader Skills requires a fresh classic lobby without other modules.');
+ requireFreshBaseRuntime(state);
+ requireFreshFactionInventory(state);
+ }
if (state.homeworlds) {
requireRule(homeworlds && state.homeworlds.custody === null,
'Homeworld Leader Skills require a fresh supported classic or native E1/E2/standalone E3 lobby with original modules and undealt custody.');
@@ -10654,7 +10664,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) ||
mixedE3 || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3 || guildBetrayalNexusLeaderSkillsProfile(g), false,
classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
- g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g));
+ g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g), independent);
if (advancedMoritaniLeaderSkillsProfile(initialized) || ((mixedE3 || pairedE3) && initialized.advanced &&
byFaction(initialized, 'moritani'))) initializeMoritaniAssassinateState(initialized);
if ((mixedE3 || homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized) || pairedE3) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
@@ -13703,29 +13713,29 @@ function ecazTreacheryIntegrity(g: Game) {
'The Ecaz Treachery Cards inventory contains an unknown physical card.',
);
for (const [player, plan] of Object.entries(g.battle?.plans ?? {})) {
- if ([plan.weapon, plan.defense].includes('ecaz-reinforcements')) {
+ if (usesNativeBattleSpecial(plan, REINFORCEMENTS_CARD)) {
const owner = getPlayer(g, player);
requireRule(plan.leader !== 'ecaz-reinforcements' &&
g.battle?.lateDefense?.[player] !== 'ecaz-reinforcements' &&
plan.weapon !== plan.defense &&
owner.hand.some(isReinforcements) &&
[plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) &&
- validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense)),
+ validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense), true),
'The sealed Reinforcements plan lost its physical card or legal slot.');
currentReinforcementsCost(g, owner, plan.reinforcementsSources);
}
requireRule(plan.reinforcementsSources === undefined ||
- (g.homeworlds && (plan.weapon === REINFORCEMENTS_CARD || plan.defense === REINFORCEMENTS_CARD)),
+ (g.homeworlds && usesNativeBattleSpecial(plan, REINFORCEMENTS_CARD)),
'Native reserve fee sources require the physical Reinforcements card in a Homeworld plan.');
requireRule(plan.leader !== HARASS_WITHDRAW_CARD && g.battle?.lateDefense?.[player] !== HARASS_WITHDRAW_CARD &&
!(plan.weapon === HARASS_WITHDRAW_CARD && plan.defense === HARASS_WITHDRAW_CARD),
'Harass & Withdraw occupies exactly one ordinary Battle Plan card slot.');
- if ([plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)) {
+ if (usesNativeBattleSpecial(plan, HARASS_WITHDRAW_CARD)) {
const owner = getPlayer(g, player);
requireRule(owner.hand.some(isHarassWithdraw),
'The sealed Harass & Withdraw card must remain in its owner’s hand.');
requireRule([plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) &&
- validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense)),
+ validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense), true),
'The sealed Harass & Withdraw plan has an invalid weapon and defense pair.');
currentHarassWithdrawQuote(g, owner, plan);
}
@@ -13756,7 +13766,7 @@ function recruitsModeSupported(g: Game) {
return (
(!g.homeworlds || ecazHomeworldProfile(g)) &&
!g.nexusCards &&
- !g.leaderSkills &&
+ (!g.leaderSkills || classicIndependentLeaderSkillsProfile(g)) &&
(!g.techTokens || modules) &&
(!g.strongholdCards || modules) &&
!g.discoveryEnabled
@@ -13864,7 +13874,7 @@ function recruitsPlayBlock(g: Game, owner: Player, card: Card): string | null {
if (!g.ecazTreachery || !ecazTreacheryDefinition(card) || card.id !== RECRUITS_CARD_ID)
return 'Choose the physical Recruits card from the enabled Ecaz Treachery Cards variant.';
if (!recruitsModeSupported(g))
- return 'Recruits requires a supported independent-card profile: faction-only, clean classic Homeworlds, or clean classic/Ix/CHOAM with original Tech and Advanced Strongholds.';
+ return 'Recruits requires a supported independent-card profile: faction-only, classic all14 Skills without other modules, or the existing native Homeworld and phase-module compositions.';
if (g.status !== 'playing' || g.phase !== 4)
return 'Play Recruits during Revival.';
if (g.recruits?.turn === g.turn)
@@ -21139,12 +21149,12 @@ function stoneOccupyContext(g: Game): StoneBurnerContext | undefined {
function stonePlanBlock(
g: Game,
p: Player,
- plan: Pick,
+ plan: Pick,
): string | null {
const b = g.battle!;
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
const baseContext = stoneOccupyContext(g);
- const context = [plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)
+ const context = usesNativeBattleSpecial(plan, HARASS_WITHDRAW_CARD)
? stoneBurnerWithdrawalContext(baseContext, b.attacker === p.id ? 'attacker' : 'defender',
harassWithdrawContext(g, p).occupy)
: baseContext;
@@ -21210,7 +21220,15 @@ function validatePlan(
): Plan {
const b = g.battle!;
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
- const selectedReinforcements = input.weapon === REINFORCEMENTS_CARD || input.defense === REINFORCEMENTS_CARD;
+ requireRule(input.planetologistSpecial === undefined || typeof input.planetologistSpecial === 'boolean',
+ 'Choose whether to use the Planetologist alternate Special role.');
+ const planetologistWeapon = canUsePlanetologistWeapon(g, p,
+ typeof input.leader === 'string' ? input.leader : null,
+ typeof input.weapon === 'string' ? cardOf(p, input.weapon) : undefined,
+ input.planetologistSpecial === true ? true : undefined);
+ requireRule(input.planetologistSpecial !== true || planetologistWeapon,
+ 'The alternate Special role requires the actual Planetologist leader and a green Special in the weapon slot.');
+ const selectedReinforcements = usesNativeBattleSpecial(input, REINFORCEMENTS_CARD);
requireRule(input.reinforcementsSources === undefined || selectedReinforcements,
'Native reserve fee sources require the physical Reinforcements card.');
const reserveCost = selectedReinforcements
@@ -21244,6 +21262,7 @@ function validatePlan(
);
const plan: Plan = {
...(bankerSpice ? { bankerSpice } : {}),
+ ...(input.planetologistSpecial === true ? { planetologistSpecial: true as const } : {}),
...(allyPayment ? { allyPayment } : {}),
...(reserveCost?.sources ? { reinforcementsSources: structuredClone(reserveCost.sources) } : {}),
dial,
@@ -21310,12 +21329,6 @@ function validatePlan(
if (smugglerPlan.assignments.some(a => a.skill === 'smuggler' && a.leader === l?.id))
nexusRule(() => smugglerBattlePile(b.territory, g.spice));
}
- const planetologistWeapon = canUsePlanetologistWeapon(
- g,
- p,
- plan.leader,
- w,
- );
requireRule(
!plan.weapon || (w && battleCardSlotEligible('weapon', w, { planetologistWeapon })),
'Choose a weapon, Worthless card, eligible Planetologist Special, Harass & Withdraw, or Reinforcements.',
@@ -21336,7 +21349,7 @@ function validatePlan(
validBattleSlotPair(w, d, planetologistWeapon),
'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.',
);
- if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan);
+ if (usesNativeBattleSpecial(plan, HARASS_WITHDRAW_CARD)) currentHarassWithdrawQuote(g, p, plan);
if (isMirrorWeapon(w)) {
const blocked = mirrorWeaponModeBlock(g) ?? stonePlanBlock(g, p, plan);
requireRule(!blocked, blocked ?? 'Mirror Weapon cannot resolve this commitment.');
@@ -21858,6 +21871,8 @@ function findLegalBattlePlan(
if (!accepts({ leader })) continue;
for (const weapon of weapons) {
if (!accepts({ leader, weapon })) continue;
+ const alternateSpecial = planetologistSpecialNeedsChoice(cardOf(p, weapon)) &&
+ canUsePlanetologistWeapon(g, p, leader, cardOf(p, weapon), true);
for (const defense of defenses) {
if (
!accepts({ leader, weapon, defense }) ||
@@ -21883,6 +21898,15 @@ function findLegalBattlePlan(
} catch (error) {
if (!(error instanceof RuleError)) throw error;
}
+ if (alternateSpecial) {
+ candidate.planetologistSpecial = true;
+ delete candidate.reinforcementsSources;
+ try {
+ return validatePlan(g, p, candidate, promises);
+ } catch (error) {
+ if (!(error instanceof RuleError)) throw error;
+ }
+ }
}
}
}
@@ -22782,7 +22806,7 @@ function harassAllocationOffer(g: Game) {
if (!b?.revealed || b.harassAllocationVersion !== 1) return null;
for (const id of [b.attacker, b.defender]) {
const p = getPlayer(g, id), plan = b.plans[id];
- if (![plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)) continue;
+ if (!usesNativeBattleSpecial(plan, HARASS_WITHDRAW_CARD)) continue;
const context = harassWithdrawContext(g, p);
try {
if (harassWithdrawNeedsAllocation(context, plan.dial, plan.support))
@@ -22968,11 +22992,11 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
leaderSkills: battleLeaderSkills(g, p),
occupiedStrongholds: leaderSkillStrongholdCount(g, p),
forces: planCombatForces(g, p, opponent),
- ...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isReinforcements)
+ ...(usesNativeBattleSpecial(plan, REINFORCEMENTS_CARD)
? { reinforcementsReserves: {
normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0,
} } : {}),
- ...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isHarassWithdraw)
+ ...(usesNativeBattleSpecial(plan, HARASS_WITHDRAW_CARD)
? { harassWithdraw: harassWithdrawContext(g, p),
...(b.harassAllocation?.player === p.id && b.harassAllocation.stage === 'selected'
? { harassSelection: b.harassAllocation.selection! } : {}) } : {}),
@@ -23337,7 +23361,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
}
if (quote.choamIncome) g.pendingChoamBattleIncome = { ...quote.choamIncome };
for (const [player, plan, canceled] of [[a, ap, dc], [d, dp, ac]] as const)
- if (canceled && [cardOf(player, plan.weapon), cardOf(player, plan.defense)].some(isHarassWithdraw))
+ if (canceled && usesNativeBattleSpecial(plan, HARASS_WITHDRAW_CARD))
log(g, `${player.name}'s Harass & Withdraw was canceled by an opposing traitor. No forces returned through this card.`);
for (const withdrawal of quote.harassWithdraw ?? []) {
const player = getPlayer(g, withdrawal.player);
diff --git a/game/leader-skill-combat.ts b/game/leader-skill-combat.ts
index dfa8d6e5..cbce848e 100644
--- a/game/leader-skill-combat.ts
+++ b/game/leader-skill-combat.ts
@@ -7,6 +7,7 @@ import {
} from './battle-cards';
import type { LeaderSkillId } from './leader-skill-cards';
import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
+import { ECAZ_TREACHERY_DEFINITIONS } from './ecaz-cards';
export type DirectLeaderSkillId =
| 'warmaster'
@@ -80,6 +81,8 @@ for (const definition of RICHESE_CARD_DEFINITIONS) {
)
planetologistSpecialById[definition.card.id] = definition.card;
}
+for (const definition of ECAZ_TREACHERY_DEFINITIONS)
+ planetologistSpecialById[definition.card.id] = definition.card;
/** Shared engine/UI/bot eligibility: exact printed green component, no native use. */
export function isPlanetologistBattleSpecialCard(
@@ -111,6 +114,20 @@ export function canUsePlanetologistBattleSpecial(input: {
);
}
+/** These green Specials also have a native battle-slot use, so the role is explicit. */
+export function planetologistSpecialNeedsChoice(card: Card | undefined): boolean {
+ return isPlanetologistBattleSpecialCard(card) &&
+ (card?.effect === 'reinforcements' || card?.effect === 'harassWithdraw');
+}
+
+/** Base/ordinary green Specials imply substitution; dual-use utility cards do not. */
+export function usesPlanetologistBattleSpecial(input: Parameters[0] & {
+ planetologistSpecial?: true;
+}): boolean {
+ return canUsePlanetologistBattleSpecial(input) &&
+ (!planetologistSpecialNeedsChoice(input.card) || input.planetologistSpecial === true);
+}
+
/** Planetologist fills the slot without becoming an actual Weapon card. */
export function validLeaderSkillBattleCardPair(
weapon: Card | undefined,
@@ -183,6 +200,7 @@ export function leaderSkillBattleBonus(input: {
effectiveDefense?: Card;
skilledLeaderSurvives: boolean;
bankerSpice?: number;
+ planetologistSpecial?: true;
}): LeaderSkillBattleBonus {
if (!input.selectedLeader) return { bonus: 0, applied: [] };
const applied: AppliedLeaderSkill[] = [];
@@ -195,10 +213,11 @@ export function leaderSkillBattleBonus(input: {
if (
input.selectedLeader.kind === 'disc' &&
input.skilledLeaderSurvives &&
- canUsePlanetologistBattleSpecial({
+ usesPlanetologistBattleSpecial({
assignments: input.assignments,
selectedLeader: input.selectedLeader.id,
card: input.weapon,
+ planetologistSpecial: input.planetologistSpecial,
})
)
applied.push({
diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts
index 2bbe5722..18bc8365 100644
--- a/game/leader-skill-profile.ts
+++ b/game/leader-skill-profile.ts
@@ -507,9 +507,31 @@ export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean
return true;
}
+/** Independent utility cards with original classic all14 setup, without other modules. */
+export function classicIndependentLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
+ if (game.ecazTreachery !== true || game.expansions.length ||
+ typeof game.advanced !== 'boolean' || !game.players ||
+ game.players.length < 2 || game.players.length > 6 ||
+ game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled ||
+ game.discoveryStash || game.greatMaker || game.techTokens || game.strongholdCards ||
+ game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview ||
+ game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
+ game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
+ game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
+ const players = game.players;
+ for (let i = 0; i < players.length; i++) {
+ for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
+ let classic = false;
+ for (const faction of FACTIONS)
+ if (faction.id === players[i].faction) { classic = faction.expansion === 'base'; break; }
+ if (!classic) return false;
+ }
+ return true;
+}
+
/** Shared by rule quotes, controls and minimal legal bot participation. */
export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean {
- return (noOtherLeaderSkillModules(game) &&
+ return classicIndependentLeaderSkillsProfile(game) || (noOtherLeaderSkillModules(game) &&
(!game.expansions.length || nativeExpansionLeaderSkillsProfile(game))) ||
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) ||
strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) ||
diff --git a/tests/battle-resolution-quote.test.ts b/tests/battle-resolution-quote.test.ts
index 7286fd3e..7db3c2e0 100644
--- a/tests/battle-resolution-quote.test.ts
+++ b/tests/battle-resolution-quote.test.ts
@@ -15,6 +15,7 @@ import {
} from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
+import { ecazTreacheryCards } from '../game/ecaz-cards';
import {
createRicheseNoField,
deployRicheseNoField,
@@ -1156,3 +1157,75 @@ void test('canceling an allied traitor into a real explosion checks uninvolved f
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
+
+function independentPlanetologistRole(
+ effect: 'reinforcements' | 'harassWithdraw' | 'recruits', alternate = false,
+): BattleResolutionInput {
+ const game = input(), cards = ecazTreacheryCards();
+ const weapon = cards.find(card => card.effect === effect)!;
+ const defense = cards.find(card => card.effect === (effect === 'harassWithdraw' ? 'reinforcements' : 'harassWithdraw'))!;
+ game.physicalCards = [...baseDeck(), ...cards];
+ game.attacker.hand = [weapon, defense];
+ game.attacker.plan.weapon = weapon.id;
+ game.attacker.plan.defense = defense.id;
+ if (alternate) game.attacker.plan.planetologistSpecial = true;
+ game.attacker.leaderSkills = [{
+ skill: 'planetologist', leader: game.attacker.leader!.id, faceUp: false, captured: false,
+ }];
+ if ((effect === 'reinforcements' && !alternate) || defense.effect === 'reinforcements')
+ game.attacker.reinforcementsReserves = { normal: 3, elite: 0 };
+ if ((effect === 'harassWithdraw' && !alternate) || defense.effect === 'harassWithdraw')
+ game.attacker.harassWithdraw = { blocked: null, forces: game.attacker.forces,
+ locations: { 'arrakeen:10': { normal: 5, elite: 0 } } };
+ return game;
+}
+
+void test('Planetologist chooses between native Reinforcements and its green Special role without combining fees or bonuses', () => {
+ const native = quoteBattleResolution(independentPlanetologistRole('reinforcements'));
+ const alternate = quoteBattleResolution(independentPlanetologistRole('reinforcements', true));
+ assert.equal(native.scores!.attacker, 7);
+ assert.equal(alternate.scores!.attacker, 7);
+ assert.deepEqual(native.reinforcements, [{ player: 'a', card: 'ecaz-reinforcements', normal: 3, elite: 0 }]);
+ assert.deepEqual(alternate.reinforcements ?? [], []);
+ assert.equal(native.leaderSkillBonuses?.attacker.bonus ?? 0, 0);
+ assert.equal(alternate.leaderSkillBonuses!.attacker.bonus, 2);
+ for (const quote of [native, alternate]) {
+ assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 3, elite: 0 });
+ assert.equal(quote.payments.find(payment => payment.player === 'a')!.ownPayment, 2);
+ assert.deepEqual(quote.casualties!.options, [{ normal: 2, elite: 0, paidNormal: 2, paidElite: 0 }]);
+ assert.ok(quote.discarded.some(card => card.player === 'a' && card.card === 'ecaz-reinforcements'));
+ }
+});
+
+void test('Planetologist Harass substitution suppresses withdrawal but leaves a distinct defense-slot Reinforcements native', () => {
+ const quote = quoteBattleResolution(independentPlanetologistRole('harassWithdraw', true));
+ assert.equal(quote.scores!.attacker, 9);
+ assert.equal(quote.leaderSkillBonuses!.attacker.bonus, 2);
+ assert.deepEqual(quote.harassWithdraw ?? [], []);
+ assert.deepEqual(quote.reinforcements, [{ player: 'a', card: 'ecaz-reinforcements', normal: 3, elite: 0 }]);
+ assert.deepEqual(quote.casualties!.options, [{ normal: 2, elite: 0, paidNormal: 2, paidElite: 0 }]);
+});
+
+void test('green Recruits uses the ordinary Planetologist substitution and a killed trainer gains no alternate strength', () => {
+ const recruits = quoteBattleResolution(independentPlanetologistRole('recruits'));
+ assert.equal(recruits.leaderSkillBonuses!.attacker.bonus, 2);
+ assert.equal(recruits.scores!.attacker, 7);
+ assert.ok(recruits.discarded.some(card => card.card === 'ecaz-recruits'));
+ const doomed = independentPlanetologistRole('reinforcements', true);
+ play(doomed, 'defender', 'weapon', card => card.kind === 'poison');
+ const death = quoteBattleResolution(doomed);
+ assert.equal(death.leaderDeaths.attacker, true);
+ assert.equal(death.leaderSkillBonuses?.attacker.bonus ?? 0, 0);
+ assert.equal(death.scores!.attacker, 2);
+ assert.deepEqual(death.reinforcements ?? [], []);
+ assert.deepEqual(death.harassWithdraw![0].returned, { normal: 3, elite: 0 });
+});
+
+void test('an explicit Planetologist alternate role cannot be forged without its actual trainer', () => {
+ const game = independentPlanetologistRole('reinforcements', true);
+ game.attacker.leaderSkills = [];
+ reject(game);
+ const malformed = independentPlanetologistRole('reinforcements', true);
+ Object.assign(malformed.attacker.plan, { planetologistSpecial: false });
+ reject(malformed);
+});
diff --git a/tests/fixture-independent-skills-battle.ts b/tests/fixture-independent-skills-battle.ts
new file mode 100644
index 00000000..09838b5e
--- /dev/null
+++ b/tests/fixture-independent-skills-battle.ts
@@ -0,0 +1,477 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
+ viewGame, type Action, type Game, type Player,
+} from '../game/engine';
+import { territory } from '../game/board';
+import { treacheryDeck, type Card } from '../game/cards';
+import type { FactionId } from '../game/catalog';
+import { ecazTreacheryCards } from '../game/ecaz-cards';
+import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw';
+import { REINFORCEMENTS_CARD } from '../game/reinforcements';
+import type { SukRescueOption } from '../game/suk-graduate';
+import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
+import { validateLeaderSkills } from '../game/leader-skills';
+import { traitorDeck } from '../game/traitors';
+import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
+import { usesNativeBattleSpecial } from '../game/battle-card-slots';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+
+export type IndependentSkillsBattleStep = { actor: string; action: Action };
+/** 'same-plan' trains the opposing Warmaster; 'traitor-cancel' adds its real
+ * Traitor call; 'suk' trains the owner's Suk Graduate for a real rescue choice. */
+export type IndependentSkillsBattleCase = 'same-plan' | 'traitor-cancel' | 'suk';
+export type IndependentSkillsBattleSkillSet = {
+ owner: LeaderSkillId;
+ opponent: LeaderSkillId;
+ observer: LeaderSkillId;
+};
+export type IndependentSkillsBattleOptions = {
+ /** Continue only the original fresh classic lobby built by this fixture. */
+ initial?: Game;
+ advanced?: boolean;
+ seatIds?: readonly string[];
+ case?: IndependentSkillsBattleCase;
+ /** Construction hook: request exact original skill cards for one seat. */
+ skills?: Partial;
+};
+export type IndependentSkillsBattleSetup = {
+ initial: Game;
+ offered: Game;
+ afterSetup: Game;
+ owner: string;
+ opponent: string;
+ observer: string;
+ skills: IndependentSkillsBattleSkillSet;
+ trainers: Record<'owner' | 'opponent' | 'observer', string>;
+ actions: IndependentSkillsBattleStep[];
+ staging: string[];
+};
+export type IndependentSkillsBattleFixture = IndependentSkillsBattleSetup & {
+ case: IndependentSkillsBattleCase;
+ advanced: boolean;
+ location: string;
+ first: string;
+ second: string;
+ beforeShipment: Game;
+ shipment: IndependentSkillsBattleStep;
+ afterShipment: Game;
+ move: IndependentSkillsBattleStep;
+ afterMove: Game;
+ opponentMove: IndependentSkillsBattleStep;
+ beforeBattle: Game;
+ battleStep: IndependentSkillsBattleStep;
+ /** Revealed window: both original plans sealed, postures committed. */
+ game: Game;
+ plans: IndependentSkillsBattleStep[];
+ returns: HarassWithdrawSelection;
+ /** Real posture decision per seat id: true hides the skilled leader behind the shield. */
+ hide: Record;
+ /** Attacker leader disc the opposing Traitor matches, for the control case. */
+ traitor: string | null;
+};
+
+export const independentSkillsBattlePlayer = (game: Game, actor: string): Player => {
+ const player = game.players.find((seat) => seat.id === actor);
+ assert.ok(player, `Missing original classic seat ${actor}`);
+ return player;
+};
+export const independentSkillsBattleClean = (game: Game) =>
+ !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
+const sum = (values: Record) => Object.values(values).reduce((a, b) => a + b, 0);
+
+/** Conserved original thirty-six cards, all fourteen skills and ordinary counters. */
+export function assertIndependentSkillsBattleCustody(game: Game): void {
+ validateLeaderSkills(game.leaderSkills!, game.players);
+ for (const player of game.players) {
+ assert.equal(player.reserves + player.tanks + sum(player.forces), 20, player.faction);
+ assert.ok([player.reserves, player.tanks, ...Object.values(player.forces)]
+ .every((count) => Number.isSafeInteger(count) && count >= 0), player.faction);
+ if (player.elites) assert.equal(
+ player.elites.reserves + player.elites.tanks + sum(player.elites.forces),
+ player.faction === 'emperor' ? 5 : 3, player.faction);
+ assert.ok(player.hand.length <= (player.faction === 'harkonnen' ? 8 : 4), player.faction);
+ for (const [key, count] of Object.entries(player.forces)) {
+ assert.ok(!key.includes('undefined') && !key.startsWith('homeworld:'), key);
+ assert.ok(Number.isInteger(count) && count >= 0, key);
+ }
+ }
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand),
+ ...(game.auction?.cards.slice(game.auction.index) ?? [])];
+ assert.equal(cards.length, 36);
+ assert.deepEqual(cards.map((card) => card.id).sort(),
+ [...treacheryDeck([]), ...ecazTreacheryCards()].map((card) => card.id).sort());
+ assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap((p) => p.traitors)].sort(),
+ traitorDeck(game.players).sort());
+}
+
+/** Actual Fisher-Yates draws for one desired order; never a held-card injection. */
+function permutationDraws(source: readonly T[], desired: readonly T[]): number[] {
+ const working = [...source], draws: number[] = [];
+ for (let index = working.length - 1; index > 0; index--) {
+ const from = working.indexOf(desired[index]);
+ assert.ok(from >= 0 && from <= index);
+ draws.push(Math.floor(((from + 0.5) / (index + 1)) * 4294967296));
+ [working[index], working[from]] = [working[from], working[index]];
+ }
+ assert.deepEqual(working, desired);
+ return draws;
+}
+/** Only the original skill lottery is solved; later native shuffles keep real draws. */
+function skillLottery(draws: readonly number[], run: () => T): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ const native = crypto.getRandomValues.bind(crypto);
+ let cursor = 0;
+ crypto.getRandomValues = (array: V): V => {
+ if (array instanceof Uint32Array && array.length === 1)
+ array[0] = cursor < draws.length ? draws[cursor++] : 0xffffffff;
+ else Reflect.apply(native, crypto, [array]);
+ return array;
+ };
+ try { return run(); }
+ finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+function defaultSkills(caseKind: IndependentSkillsBattleCase): IndependentSkillsBattleSkillSet {
+ return caseKind === 'suk'
+ ? { owner: 'suk-graduate', opponent: 'master-of-assassins', observer: 'rihani-decipherer' }
+ : { owner: 'prana-bindu-adept', opponent: 'warmaster', observer: 'rihani-decipherer' };
+}
+/** Private two-card offers are dealt in player order, so the first two positions
+ * of the solved deck decide the requesting seat's real choice. */
+function skillDeckOrder(skills: IndependentSkillsBattleSkillSet): LeaderSkillId[] {
+ const requested = [skills.owner, skills.opponent, skills.observer];
+ const rest = LEADER_SKILL_CARDS.map((card) => card.id).filter((id) => !requested.includes(id));
+ return requested.flatMap((id) => [id, rest.shift()!]).concat(rest);
+}
+
+/** Genuine classic all-fourteen training from a fresh READY lobby whose independent
+ * three-card marker is staged only after every ready action. */
+export function createIndependentSkillsBattleSetup(
+ options: IndependentSkillsBattleOptions = {},
+): IndependentSkillsBattleSetup {
+ const caseKind = options.case ?? 'same-plan';
+ const ids = options.seatIds ?? ['emperor', 'guild', 'harkonnen'];
+ assert.equal(ids.length, 3);
+ const factions: FactionId[] = ['emperor', 'guild', 'harkonnen'];
+ let game = options.initial ? structuredClone(options.initial)
+ : createGame('INDEPENDENTSKILLSBATTLE', newPlayer(ids[0], 'Emperor', factions[0]), options.advanced ?? false, []);
+ if (!options.initial) for (let index = 1; index < ids.length; index++)
+ joinGame(game, newPlayer(ids[index], ids[index], factions[index]));
+ assert.equal(game.advanced, options.advanced ?? game.advanced);
+ assert.deepEqual(game.expansions, []);
+ assert.ok(!game.leaderSkills && !game.homeworlds && !game.nexusCards && !game.discoveryEnabled &&
+ !game.techTokens && !game.strongholdCards, 'Only the original classic composition is accepted.');
+ assert.equal(game.status, 'lobby', 'Continue only an original fresh classic lobby.');
+ const staging: string[] = [];
+ const actions: IndependentSkillsBattleStep[] = [];
+ const owner = game.players[0].id, opponent = game.players[1].id, observer = game.players[2].id;
+ const skills = { ...defaultSkills(caseKind), ...options.skills };
+ const deckOrder = skillDeckOrder(skills);
+ assert.equal(deckOrder.length, LEADER_SKILL_CARDS.length);
+ const initial = structuredClone(game);
+ for (const player of game.players) if (!player.ready) {
+ const action: Action = { type: 'ready' };
+ actions.push({ actor: player.id, action });
+ game = applyAction(game, player.id, action);
+ }
+ game.ecazTreachery = true;
+ staging.push('Ready actions precede the independent three-card marker: the original lobby rejects ecazTreachery before every seat is ready.');
+ const draws = permutationDraws(LEADER_SKILL_CARDS.map((card) => card.id), deckOrder);
+ game = skillLottery(draws, () => initializeLeaderSkillsGameForAudit(game));
+ staging.push('Solved the original fourteen-card skill lottery with real Fisher-Yates draws; every later single-word draw in the same initializer kept a fixed maximum value, and larger native requests were forwarded unchanged.');
+ assert.equal(game.status, 'setup');
+ assert.equal(game.turn, 1);
+ assert.equal(game.phase, 0);
+ const offered = structuredClone(game);
+ const requested: Record = {
+ [owner]: skills.owner, [opponent]: skills.opponent, [observer]: skills.observer,
+ };
+ for (let count = 0; game.status === 'setup' && count < 200; count++) {
+ let step: IndependentSkillsBattleStep;
+ if (game.setupStage === 'leaderSkills') {
+ const actor = Object.keys(game.leaderSkills!.offers)[0];
+ const offer = game.leaderSkills!.offers[actor];
+ const view = viewGame(game, actor).leaderSkills!;
+ const wanted = requested[actor];
+ assert.ok(offer.cards.includes(wanted), 'The requested original skill must be in the real offer');
+ assert.ok(!view.unavailableSkills?.[wanted], 'The requested original skill must be selectable');
+ const leader = independentSkillsBattlePlayer(game, actor).leaders
+ .filter((disc) => view.eligibleLeaders.some((eligible) => eligible.id === disc.id))
+ .sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
+ assert.ok(leader);
+ step = { actor, action: { type: 'leaderSkill', event: offer.event, skill: wanted, leader: leader.id } };
+ } else {
+ const next = nextStrongholdFactionsNativeStep(game);
+ assert.ok(next, 'The original setup continuation stopped before training completed');
+ step = next;
+ }
+ actions.push(structuredClone(step));
+ game = applyAction(game, step.actor, step.action);
+ }
+ assert.equal(game.status, 'playing');
+ assertIndependentSkillsBattleCustody(game);
+ const trained = (id: string) => game.leaderSkills!.assignments.find((a) => a.owner === id)!.leader;
+ for (const [actor, skill] of Object.entries(requested))
+ assert.equal(game.leaderSkills!.assignments.find((a) => a.owner === actor)!.skill, skill);
+ return { initial, offered, afterSetup: structuredClone(game), owner, opponent, observer, skills,
+ trainers: { owner: trained(owner), opponent: trained(opponent), observer: trained(observer) },
+ actions, staging };
+}
+
+export type IndependentSkillsBattlePolicy = {
+ hide?: Record;
+ /** Real Guild choice of the Advanced movement slot: 'last' defers to the end. */
+ guildTiming?: 'first' | 'last';
+};
+
+/** Only genuine native decisions; a human plan, Traitor call, withdrawal allocation
+ * or Suk rescue choice is never invented by this helper. */
+export function nextIndependentSkillsBattleStep(
+ game: Game,
+ policy: IndependentSkillsBattlePolicy = {},
+): IndependentSkillsBattleStep | null {
+ if (!game.response && !game.phaseOpening) {
+ const decision = game.decision;
+ if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player, action: {
+ type: 'leaderSkillVisibility', event: decision.event, hide: policy.hide?.[decision.player] ?? false,
+ } };
+ if (decision?.kind === 'guildTiming')
+ return { actor: decision.player, action: { type: 'decision', take: (policy.guildTiming ?? 'last') === 'first' } };
+ if (decision?.kind === 'mentatQuestion')
+ return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
+ if (decision?.kind === 'sukRescue' || decision?.kind === 'harassWithdraw') return null;
+ if (decision?.kind === 'rihani')
+ return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
+ }
+ return nextStrongholdFactionsNativeStep(game);
+}
+export function advanceIndependentSkillsBattle(game: Game, until: (state: Game) => boolean,
+ actions?: IndependentSkillsBattleStep[], policy: IndependentSkillsBattlePolicy = {}): Game {
+ for (let count = 0; count < 1500; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished');
+ const step = nextIndependentSkillsBattleStep(game, policy);
+ assert.ok(step, 'Supply the genuine human plan, allocation or rescue choice');
+ actions?.push(structuredClone(step));
+ game = applyAction(game, step.actor, step.action);
+ }
+ throw Error('The original classic all-fourteen programme did not reach its boundary');
+}
+/** Conserved physical card custody: remaining deck or discard → a real hand. */
+function hold(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[]): Card {
+ const player = independentSkillsBattlePlayer(game, actor);
+ const held = player.hand.find(matches);
+ if (held) return held;
+ const from = game.deck.findIndex(matches);
+ assert.ok(from >= 0 || game.discard.some(matches), 'The required original card must remain in deck or discard custody');
+ const card = from >= 0 ? game.deck.splice(from, 1)[0] : game.discard.splice(game.discard.findIndex(matches), 1)[0];
+ assert.ok(player.hand.length < (player.faction === 'harkonnen' ? 8 : 4));
+ player.hand.push(card);
+ staging.push(`Conserved original ${card.id}: ${from >= 0 ? 'undealt deck' : 'discard'} → ${actor}'s hand`);
+ return card;
+}
+/** Conserved Traitor identity: only a real reserved or held leader exchanges. */
+function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
+ const player = independentSkillsBattlePlayer(game, actor);
+ if (player.traitors.includes(identity)) return;
+ const returned = player.traitors[0];
+ assert.ok(returned);
+ const reserve = game.traitorReserve?.indexOf(identity) ?? -1;
+ const donor = game.players.find((seat) => seat.id !== actor && seat.traitors.includes(identity));
+ assert.ok(reserve >= 0 || donor, 'The requested Traitor must retain original custody');
+ if (reserve >= 0) game.traitorReserve!.splice(reserve, 1, returned);
+ else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
+ player.traitors[0] = identity;
+ staging.push(`Conserved Traitor ${identity}: original ${donor?.id ?? 'reserve'} custody exchanged with ${actor}'s ${returned}`);
+}
+function strongestOther(game: Game, actor: string, excluded: string): string {
+ const leader = independentSkillsBattlePlayer(game, actor).leaders
+ .filter((disc) => !disc.dead && !disc.usedAt && disc.id !== excluded)
+ .sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
+ assert.ok(leader);
+ return leader.id;
+}
+
+/** Real training, a paid native shipment, real within-territory movement and both
+ * original Battle Plans. Only card and Traitor custody is staged. */
+export function createIndependentSkillsBattleFixture(
+ options: IndependentSkillsBattleOptions = {},
+): IndependentSkillsBattleFixture {
+ const caseKind = options.case ?? 'same-plan';
+ const setup = createIndependentSkillsBattleSetup(options);
+ const advanced = setup.afterSetup.advanced;
+ const staging = [...setup.staging];
+ const actions = [...setup.actions];
+ const { owner, opponent, trainers } = setup;
+ const location = 'pasty_mesa', first = 'pasty_mesa:5', second = 'pasty_mesa:6';
+ assert.deepEqual(territory(location).sectors, [5, 6, 7, 8]);
+ let game = advanceIndependentSkillsBattle(setup.afterSetup,
+ (state) => state.phase === 5 && state.active === owner && independentSkillsBattleClean(state), actions);
+ const beforeShipment = structuredClone(game);
+ assert.equal(independentSkillsBattlePlayer(game, owner).reserves, 20);
+ assert.equal(independentSkillsBattlePlayer(game, owner).spice, 10);
+ const shipment: IndependentSkillsBattleStep = { actor: owner, action: {
+ type: 'ship', territory: location, sector: 5, amount: 4, elite: 0, allyPayment: 0 } };
+ actions.push(structuredClone(shipment));
+ game = applyAction(game, owner, shipment.action);
+ game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
+ !state.pendingShipment && state.phase === 5 && state.active === owner, actions);
+ const afterShipment = structuredClone(game);
+ assert.equal(independentSkillsBattlePlayer(game, owner).spice, 2);
+ assert.equal(independentSkillsBattlePlayer(game, owner).reserves, 16);
+ assert.equal(independentSkillsBattlePlayer(game, owner).forces[first], 4);
+ assert.equal(independentSkillsBattlePlayer(game, opponent).spice, 5 + 8);
+ staging.push('The Emperor paid the original eight-spice desert shipment of four reserves into Pasty Mesa; that payment reached the Guild as printed shipping income.');
+ const move: IndependentSkillsBattleStep = { actor: owner, action: {
+ type: 'move', from: first, amount: 2, territory: location, sector: 6 } };
+ actions.push(structuredClone(move));
+ game = applyAction(game, owner, move.action);
+ game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
+ state.phase === 5 && state.active === owner, actions);
+ assert.deepEqual(independentSkillsBattlePlayer(game, owner).forces, { [first]: 2, [second]: 2 });
+ const afterMove = structuredClone(game);
+ staging.push('The Emperor then moved two of those four counters from sector 5 to sector 6 in the same movement turn, so the Harass & Withdraw return allocation is genuinely ambiguous between two physical sectors.');
+ const endMove: IndependentSkillsBattleStep = { actor: owner, action: { type: 'endMovement' } };
+ game = applyAction(game, owner, endMove.action);
+ game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
+ state.phase === 5 && state.active === opponent, actions);
+ assert.equal(independentSkillsBattlePlayer(game, opponent).forces['tueks_sietch:5'], 5);
+ const opponentMove: IndependentSkillsBattleStep = { actor: opponent, action: {
+ type: 'move', from: 'tueks_sietch:5', amount: 5, territory: location, sector: 6 } };
+ actions.push(structuredClone(opponentMove));
+ game = applyAction(game, opponent, opponentMove.action);
+ game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
+ state.phase === 5 && state.active === opponent, actions);
+ assert.deepEqual(independentSkillsBattlePlayer(game, opponent).forces, { [second]: 5 });
+ staging.push('The Guild declined the first Advanced movement slot (a real legal Guild choice) and moved its whole army from Tuek’s Sietch into Pasty Mesa sector 6 during its own movement turn.');
+ const endOpponent: IndependentSkillsBattleStep = { actor: opponent, action: { type: 'endMovement' } };
+ game = applyAction(game, opponent, endOpponent.action);
+ game = advanceIndependentSkillsBattle(game, (state) => state.phase === 6 && independentSkillsBattleClean(state), actions);
+ for (const player of game.players) game.deck.push(...player.hand.splice(0));
+ staging.push('At the real Battle window every original hand returned to the undealt deck before the physical Reinforcements and Harass & Withdraw cards were conserved to the Emperor.');
+ hold(game, owner, (card) => card.id === REINFORCEMENTS_CARD, staging);
+ hold(game, owner, (card) => card.id === HARASS_WITHDRAW_CARD, staging);
+ const worthless = caseKind === 'suk' ? null : hold(game, opponent, (card) => card.kind === 'worthless', staging);
+ const hide: Record = { [owner]: true, [opponent]: caseKind !== 'suk' };
+ // The trained leader must be committed (so Warmaster/Suk take effect) exactly
+ // when the owner's posture decision hides it behind the shield.
+ const ownerLeader = caseKind === 'suk' ? trainers.owner : strongestOther(game, owner, trainers.owner);
+ const opponentLeader = caseKind === 'suk' ? strongestOther(game, opponent, trainers.opponent) : trainers.opponent;
+ let traitor: string | null = null;
+ if (caseKind === 'traitor-cancel') {
+ // The opposing Traitor must match the attacker's real committed leader disc.
+ conserveTraitor(game, opponent, ownerLeader, staging);
+ traitor = ownerLeader;
+ }
+ const beforeBattle = structuredClone(game);
+ const choice = viewGame(game, game.active!).battleChoices.find((candidate) => candidate.territory === location);
+ assert.ok(choice);
+ assert.equal(choice.attacker, owner);
+ assert.equal(choice.chooser, owner);
+ const battleStep: IndependentSkillsBattleStep = { actor: choice.chooser, action: {
+ type: 'chooseBattle', territory: location, target: choice.defender } };
+ actions.push(structuredClone(battleStep));
+ game = applyAction(game, choice.chooser, battleStep.action);
+ game = advanceIndependentSkillsBattle(game,
+ (state) => !!state.battle && !state.battle.revealed && independentSkillsBattleClean(state),
+ actions, { hide });
+ const support = advanced ? 2 : 0;
+ const plans: IndependentSkillsBattleStep[] = [
+ { actor: owner, action: { type: 'battlePlan', dial: 2, support, leader: ownerLeader,
+ weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } },
+ { actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: opponentLeader,
+ ...(worthless ? { weapon: worthless.id } : {}) } },
+ ];
+ for (const plan of plans) {
+ actions.push(structuredClone(plan));
+ game = applyAction(game, plan.actor, plan.action);
+ }
+ assert.ok(game.battle?.revealed);
+ staging.push(`Both combatants made their real posture decisions before sealing: the Emperor hid its skilled leader behind the shield, the Guild ${caseKind === 'suk' ? 'kept its skilled leader face up and therefore committed another leader' : 'hid its skilled leader so that the committed Warmaster could resolve'}.`);
+ const returns: HarassWithdrawSelection = { [first]: { normal: 1, elite: 0 }, [second]: { normal: 1, elite: 0 } };
+ const fixture: IndependentSkillsBattleFixture = { ...setup, case: caseKind, advanced, location, first, second,
+ beforeShipment, shipment, afterShipment, move, afterMove, opponentMove, beforeBattle, battleStep,
+ game, plans, returns, hide, traitor, actions, staging };
+ assertIndependentSkillsBattleCustody(game);
+ return fixture;
+}
+
+/** Quote the real revealed plans, postures and physical custody; Traitor calls and
+ * the typed withdrawal allocation are the caller's current human choices. */
+export function quoteIndependentSkillsBattle(game: Game,
+ options: { callers?: readonly string[]; returns?: HarassWithdrawSelection } = {}) {
+ const battle = game.battle;
+ assert.ok(battle?.revealed);
+ const side = (actor: string): ResolutionCombatant => {
+ const player = independentSkillsBattlePlayer(game, actor);
+ const view = viewGame(game, actor).battle!;
+ return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand,
+ plan: battle.plans[actor],
+ leader: player.leaders.find((leader) => leader.id === battle.plans[actor].leader),
+ forces: view.ownForces!,
+ leaderSkills: game.leaderSkills!.assignments.filter((assignment) => assignment.owner === actor)
+ .map((assignment) => ({ skill: assignment.skill, leader: assignment.leader,
+ faceUp: battle.leaderSkillHidden?.[actor] !== true,
+ captured: false })),
+ ...(view.reinforcements && usesNativeBattleSpecial(battle.plans[actor], REINFORCEMENTS_CARD)
+ ? { reinforcementsReserves: { normal: player.reserves - (player.elites?.reserves ?? 0),
+ elite: player.elites?.reserves ?? 0 } } : {}),
+ ...(view.harassWithdraw && usesNativeBattleSpecial(battle.plans[actor], HARASS_WITHDRAW_CARD) ? { harassWithdraw: { blocked: null,
+ forces: view.harassWithdraw.forces, locations: view.harassWithdraw.locations,
+ ...(view.harassWithdraw.occupy ? { occupy: view.harassWithdraw.occupy } : {}) },
+ ...(options.returns ? { harassSelection: options.returns } : {}) } : {}) };
+ };
+ return quoteBattleResolution({
+ advanced: game.advanced,
+ turn: game.turn,
+ territory: battle.territory,
+ aggressor: viewGame(game, game.host).battle!.aggressor,
+ attacker: side(battle.attacker),
+ defender: side(battle.defender),
+ voters: viewGame(game, game.host).battle!.traitorVoters.map((id) => ({ id, beneficiary: id,
+ called: options.callers?.includes(id) ?? false,
+ traitors: independentSkillsBattlePlayer(game, id).traitors })),
+ participants: game.players,
+ physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand)],
+ pendingAuditorPresent: false,
+ pendingRetentionPresent: false,
+ });
+}
+
+/** Resolve the real aftermath: Traitor calls, the typed withdrawal allocation, the
+ * Suk rescue choice and mandatory disposal, stopping at the original Collection. */
+export function finishIndependentSkillsBattle(game: Game,
+ options: { callers?: readonly string[]; return?: HarassWithdrawSelection;
+ rescue?: (options: readonly SukRescueOption[]) => number;
+ actions?: IndependentSkillsBattleStep[]; until?: (state: Game) => boolean } = {}): Game {
+ const until = options.until ?? ((state: Game) =>
+ state.phase === 7 && independentSkillsBattleClean(state));
+ for (let count = 0; count < 400; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished');
+ let step: IndependentSkillsBattleStep | null;
+ if (game.decision?.kind === 'harassWithdraw') {
+ assert.ok(options.return, 'Supply the genuine typed undialed return allocation');
+ step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event,
+ returns: options.return } };
+ } else if (game.decision?.kind === 'sukRescue') {
+ const decision = game.decision;
+ const choice = options.rescue ? options.rescue(decision.options)
+ : decision.options.findIndex((option) => option.normal + option.elite === 2 && option.kept);
+ assert.ok(choice >= 0, 'Choose one real Suk Graduate rescue option');
+ step = { actor: decision.player, action: { type: 'decision', event: decision.event, choice } };
+ } else {
+ const next = nextIndependentSkillsBattleStep(game);
+ step = next && next.action.type === 'traitorCall'
+ ? { ...next, action: { type: 'traitorCall', call: options.callers?.includes(next.actor) ?? false } }
+ : next;
+ }
+ assert.ok(step, 'Resolve both original plans before their physical aftermath');
+ options.actions?.push(structuredClone(step));
+ game = applyAction(game, step.actor, step.action);
+ }
+ throw Error('The original classic battle aftermath did not reach Collection');
+}
diff --git a/tests/fixture-independent-skills-revival.ts b/tests/fixture-independent-skills-revival.ts
new file mode 100644
index 00000000..fbbbf674
--- /dev/null
+++ b/tests/fixture-independent-skills-revival.ts
@@ -0,0 +1,521 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
+ type Action, type Game, type Player,
+} from '../game/engine';
+import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
+import { validateLeaderSkills } from '../game/leader-skills';
+import { RECRUITS_CARD_ID } from '../game/recruits';
+import { forceRevivalQuote } from '../game/revival';
+import { ecazTreacheryCards } from '../game/ecaz-cards';
+import { TERRITORIES } from '../game/board';
+import type { Card } from '../game/cards';
+import {
+ nextIndependentCardModulesStep, type IndependentCardModulesStep,
+} from './fixture-independent-card-modules';
+
+export type IndependentSkillsRevivalStep = IndependentCardModulesStep;
+export type IndependentSkillsRevivalOptions = {
+ /** Original Basic or Advanced rules; the independent composition is the same. */
+ advanced?: boolean;
+ seatIds?: readonly string[];
+};
+
+/** Real consumer-visible numbers measured on the actual states, never assigned. */
+export type IndependentSkillsRevivalPricing = {
+ /** Turn-two ordinary cap and remaining free rate before any return this phase. */
+ ordinary: { limit: number; freeRemaining: number; forcesRemaining: number };
+ /** After the payer's real prior free return, still before the Recruits play. */
+ priorReturn: { limit: number; freeRemaining: number; revived: number; freeUsed: number };
+ /** After the real Recruits activation. */
+ doubled: { limit: number; freeRemaining: number };
+ /** Independent original price of the real revival requested from that state. */
+ revival: { amount: number; free: number; cost: number };
+};
+
+export type IndependentSkillsRevivalControl = {
+ label: string;
+ playAction: Action;
+ revivalAction: Action;
+ /** Spice the payer actually spent across both real actions. */
+ paid: number;
+ game: Game;
+};
+
+export type IndependentSkillsRevivalFixture = {
+ /** Fresh ready classic lobby carrying the independent Ecaz marker. */
+ initial: Game;
+ /** Original admitted setup: the physical two-card skill offers are open. */
+ offered: Game;
+ /** Genuine native setup finished: original hands, traitors, forces and all14 skills. */
+ afterSetup: Game;
+ /** After the real first-turn battle sent the payer's counters to the Tanks. */
+ afterBattle: Game;
+ /** Immediately after the real out-of-turn bribe was declared. */
+ afterBribe: Game;
+ /** Turn-one Mentat Pause: the real bribe is spendable and no Bank income is owed. */
+ atMentat1: Game;
+ /** Real turn-two Revival with the physical Recruits card conserved in the payer's hand. */
+ atRevival: Game;
+ /** After the payer's real prior free return, before the Recruits play. */
+ beforeRecruits: Game;
+ /** After the actual Recruits activation, before the priced revival. */
+ beforeMainRevival: Game;
+ /** After the payer's real revival: Bank income is deferred and unpaid. */
+ afterRevival: Game;
+ /** Turn-two Spice Collection settled, immediately before the Mentat settlement. */
+ atMentat2: Game;
+ /** Turn-two Mentat Pause after the deferred income was credited exactly once. */
+ afterMentat: Game;
+ owner: string;
+ payer: string;
+ opponent: string;
+ /** Banker's own trained leader, alive and face up through the programme. */
+ bankerLeader: string;
+ card: typeof RECRUITS_CARD_ID;
+ territory: { id: string; sector: number; type: string };
+ battle: {
+ event: string;
+ attacker: string;
+ defender: string;
+ chooser: string;
+ moveAction: IndependentSkillsRevivalStep;
+ shipAction: IndependentSkillsRevivalStep;
+ battleAction: IndependentSkillsRevivalStep;
+ /** Real battle leaders; neither is a held traitor. */
+ payerLeader: string;
+ opponentLeader: string;
+ visibilityActions: IndependentSkillsRevivalStep[];
+ planActions: IndependentSkillsRevivalStep[];
+ traitorCalls: IndependentSkillsRevivalStep[];
+ };
+ bribeAction: IndependentSkillsRevivalStep;
+ priorReturnAction: IndependentSkillsRevivalStep;
+ playAction: IndependentSkillsRevivalStep;
+ revivalAction: IndependentSkillsRevivalStep;
+ /** Real below-threshold and free-only revivals on clones of atRevival. */
+ controls: IndependentSkillsRevivalControl[];
+ pricing: IndependentSkillsRevivalPricing;
+ /** Physical deltas the real actions produced. */
+ physical: {
+ payerBoardLosses: number;
+ payerTanksAtRevival: number;
+ priorFreeReturn: number;
+ revived: number;
+ freeReturned: number;
+ paidReturned: number;
+ };
+ /** The single deferred Bank income the real revival produced. */
+ grant: { kind: string; payer: string; amount: number; deferred: number };
+ /** Native decision windows acknowledged by real actions, in order. */
+ decisions: string[];
+ /** Every explicit physical staging step, including the conserved Recruits face. */
+ staging: string[];
+ steps: IndependentSkillsRevivalStep[];
+ /** Shared native continuation for continuing this exact programme. */
+ advanceUntil: (state: Game, until: (game: Game) => boolean) => Game;
+};
+
+const BANKER = 'atreides';
+const PAYER = 'guild';
+const OPPONENT = 'emperor';
+const TERRITORY_ID = 'south_mesa';
+const TERRITORY_SECTOR = 4;
+const PRIOR_FREE_RETURN = 1;
+const REVIVED = 4;
+const BRIBE = 4;
+
+const clean = (game: Game): boolean =>
+ !game.phaseOpening && !game.response && !game.decision && !game.pendingRevival;
+
+export const independentSkillsRevivalPlayer = (game: Game, id: string): Player => {
+ const player = game.players.find(candidate => candidate.id === id);
+ assert.ok(player, `Missing original ${id} seat`);
+ return player;
+};
+
+const boardCount = (player: Player): number =>
+ Object.values(player.forces).reduce((sum, count) => sum + count, 0);
+const counterTotal = (player: Player): number => player.reserves + player.tanks + boardCount(player);
+const allCards = (game: Game): Card[] => [
+ ...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+];
+
+/** Physical counters and one copy of every selected card stay conserved. */
+export function assertIndependentSkillsRevivalInventory(game: Game): void {
+ validateLeaderSkills(game.leaderSkills!, game.players);
+ const skills = [
+ ...game.leaderSkills!.deck,
+ ...game.leaderSkills!.assignments.map(assignment => assignment.skill),
+ ...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards),
+ ];
+ assert.equal(skills.length, LEADER_SKILL_CARDS.length);
+ assert.equal(new Set(skills).size, LEADER_SKILL_CARDS.length);
+ for (const expected of ecazTreacheryCards()) {
+ const matching = allCards(game).filter(card => card.id === expected.id);
+ assert.equal(matching.length, 1, `${expected.id} must keep exactly one physical face`);
+ }
+ for (const player of game.players) {
+ assert.equal(counterTotal(player), 20, `${player.id} must conserve twenty normal counters`);
+ if (player.elites)
+ assert.equal(player.elites.reserves + player.elites.tanks +
+ Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0),
+ player.faction === 'ixians' ? 7 : 5, `${player.id} must conserve its special counters`);
+ }
+}
+
+/** Deterministic only for the original admission and setup deal; play is real. */
+function withCanonicalSetupEntropy(deal: () => T): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ crypto.getRandomValues = (array: V): V => {
+ assert.ok(array);
+ new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
+ return array;
+ };
+ try {
+ return deal();
+ } finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+
+/** Physically conserve one existing face into a hand; no minted or replaced copy. */
+function conserveCard(game: Game, actor: string, id: string, staging: string[]): Card {
+ const player = independentSkillsRevivalPlayer(game, actor);
+ const held = player.hand.find(card => card.id === id);
+ if (held) {
+ staging.push(`Physical staging: ${id} already held by ${actor}; no zone changed.`);
+ return held;
+ }
+ const zones: { label: string; cards: Card[] }[] = [
+ { label: 'remaining original deck', cards: game.deck },
+ { label: 'original discard', cards: game.discard },
+ ...game.players.filter(other => other.id !== actor)
+ .map(other => ({ label: `${other.id}'s hand`, cards: other.hand })),
+ ];
+ const source = zones.find(zone => zone.cards.some(card => card.id === id));
+ assert.ok(source, `Original physical ${id} is missing from every retained zone`);
+ const limit = player.faction === 'harkonnen' ? 8 : player.faction === 'choam' ? 5 : 4;
+ if (player.hand.length >= limit) {
+ const index = player.hand.findIndex(card => card.id !== id);
+ assert.ok(index >= 0, `${actor} must have a non-Recruits card to return`);
+ const [returned] = player.hand.splice(index, 1);
+ game.deck.push(returned);
+ staging.push(`Physical staging: ${returned.id}, ${actor}'s hand -> remaining original deck (printed hand limit).`);
+ }
+ const [card] = source.cards.splice(source.cards.findIndex(candidate => candidate.id === id), 1);
+ player.hand.push(card);
+ staging.push(`Physical staging: conserved ${id}, ${source.label} -> ${actor}'s hand; no purchase, deal or replacement face.`);
+ return card;
+}
+
+/**
+ * Genuine classic Basic/Advanced all14 Leader Skills programme with the
+ * independent Ecaz Treachery cards. The real first Storm decides custody; the
+ * original skill deal is admitted by the ordinary initializer. One real
+ * Banker is trained, one different payer loses a native army in an actual
+ * first-turn desert battle, is bribed through the real out-of-turn action and
+ * revives at the real turn-two Revival after the physical Recruits card
+ * doubles the current free rates. No wallet, owner, phase, receipt or price is
+ * assigned; only the Recruits face is conserved into a hand.
+ */
+export function createIndependentSkillsRevivalFixture(
+ options: IndependentSkillsRevivalOptions = {},
+): IndependentSkillsRevivalFixture {
+ const advanced = options.advanced ?? false;
+ const ids = options.seatIds ?? ['p0', 'p1', 'p2'];
+ assert.equal(ids.length, 3, 'The revival programme uses the Banker, payer and battle opponent seats');
+ const [owner, payer, opponent] = ids;
+ const steps: IndependentSkillsRevivalStep[] = [];
+ const staging: string[] = [];
+ const decisions: string[] = [];
+ const record = (game: Game, step: IndependentSkillsRevivalStep): Game => {
+ assert.ok(game.status === 'lobby' || game.status === 'setup' || game.status === 'playing');
+ if (game.decision) decisions.push(game.decision.kind);
+ const next = applyAction(game, step.actor, step.action);
+ steps.push({ actor: step.actor, action: structuredClone(step.action) });
+ return next;
+ };
+
+ let game = createGame('INDEPENDENTSKILLSREVIVAL', newPlayer(owner, 'Banker', BANKER), advanced);
+ joinGame(game, newPlayer(payer, 'Payer', PAYER));
+ joinGame(game, newPlayer(opponent, 'Opponent', OPPONENT));
+ for (const player of game.players) game = record(game, { actor: player.id, action: { type: 'ready' } });
+ // The marker is set after every lobby action, before the ordinary initializer.
+ game.ecazTreachery = true;
+ const initial = structuredClone(game);
+ let offeredSetup: Game | undefined;
+ /** Original admission plus the whole setup deal: this is the only fixed entropy. */
+ const settleSetup = (): Game => {
+ let setup = initializeLeaderSkillsGameForAudit(game);
+ offeredSetup = structuredClone(setup);
+ for (let count = 0; setup.status === 'setup' && count < 400; count++) {
+ if (setup.setupStage === 'leaderSkills') {
+ const actor = Object.keys(setup.leaderSkills!.offers)[0];
+ const offer = setup.leaderSkills!.offers[actor];
+ const view = viewGame(setup, actor).leaderSkills!;
+ const skill = actor === owner && offer.cards.includes('spice-banker')
+ ? 'spice-banker' : offer.cards[0];
+ setup = record(setup, {
+ actor,
+ action: { type: 'leaderSkill', event: offer.event, skill, leader: view.eligibleLeaders[0].id },
+ });
+ } else if (setup.setupStage === 'traitors') {
+ const pending = setup.players.filter(player => player.traitorChoices.length);
+ assert.ok(pending.length, 'The original traitor stage must still be waiting for real choices');
+ for (const player of pending)
+ setup = record(setup, { actor: player.id, action: { type: 'traitor', leader: player.traitorChoices[0] } });
+ } else {
+ const step = nextIndependentCardModulesStep(setup);
+ assert.ok(step, `No native setup continuation at ${setup.setupStage}`);
+ setup = record(setup, step);
+ }
+ }
+ return setup;
+ };
+ game = withCanonicalSetupEntropy(settleSetup);
+ assert.ok(offeredSetup, 'The admitted setup must expose its physical skill offers');
+ const offered = structuredClone(offeredSetup);
+ assert.equal(game.advanced, advanced);
+ assert.deepEqual(game.expansions, []);
+ assert.equal(game.ecazTreachery, true);
+ assert.equal(TERRITORIES.find(entry => entry.id === TERRITORY_ID)?.type, 'sand');
+ assert.ok(TERRITORIES.find(entry => entry.id === TERRITORY_ID)!.sectors.includes(TERRITORY_SECTOR));
+ const assignment = game.leaderSkills!.assignments.find(entry => entry.skill === 'spice-banker');
+ assert.ok(assignment, 'The original skill deal must place the physical Spice Banker card');
+ assert.equal(game.status, 'playing');
+ assert.equal(assignment.owner, owner, 'The Banker must be trained in the Banker faction');
+ const bankerLeader = assignment.leader;
+ const payerTrained = game.leaderSkills!.assignments.find(entry => entry.owner === payer)?.leader;
+ const opponentTrained = game.leaderSkills!.assignments.find(entry => entry.owner === opponent)?.leader;
+ assertIndependentSkillsRevivalInventory(game);
+ staging.push('Initial scalar entropy: only the original admission and setup deal are fixed (a canonical first skill order and its traitor deal). Every play action, battle vote and later shuffle uses real entropy; no counter, wallet, phase, skill receipt or Traitor custody was assigned.');
+ const afterSetup = structuredClone(game);
+
+ const advanceUntil = (state: Game, until: (current: Game) => boolean): Game => {
+ let current = state;
+ for (let count = 0; count < 2400; count++) {
+ if (until(current)) return current;
+ assert.notEqual(current.status, 'finished', 'The original programme ended early');
+ // Advanced only: Guild takes its movement turn as the original window offers.
+ const step: IndependentSkillsRevivalStep | null =
+ current.decision?.kind === 'guildTiming'
+ ? { actor: current.decision.player, action: { type: 'decision', take: true } }
+ : nextIndependentCardModulesStep(current);
+ assert.ok(step, `No native continuation at ${current.turn}/${current.phase}/${current.decision?.kind}`);
+ current = record(current, step);
+ }
+ throw Error('The original programme did not reach its requested boundary');
+ };
+
+ // Real first-turn battle: the payer's native army marches into an empty
+ // desert the opponent pays to enter, and loses it to the Tleilaxu Tanks.
+ game = advanceUntil(game, state => state.turn === 1 && state.phase === 5 && clean(state));
+ game = advanceUntil(game, state => state.phase === 5 && state.active === payer && clean(state));
+ const moveAction: IndependentSkillsRevivalStep = {
+ actor: payer,
+ action: { type: 'move', from: 'tueks_sietch:5', territory: TERRITORY_ID, sector: TERRITORY_SECTOR, amount: 5 },
+ };
+ game = record(game, moveAction);
+ game = advanceUntil(game, state => state.phase === 5 && state.active === opponent && clean(state));
+ const shipAction: IndependentSkillsRevivalStep = {
+ actor: opponent,
+ action: { type: 'ship', territory: TERRITORY_ID, sector: TERRITORY_SECTOR, amount: 2 },
+ };
+ game = record(game, shipAction);
+ game = advanceUntil(game, state => state.phase === 6 && clean(state));
+ const choice = viewGame(game, game.active!).battleChoices
+ .find(candidate => candidate.territory === TERRITORY_ID);
+ assert.ok(choice, 'The real movements must open a battle in that desert');
+ const chooser = choice.chooser;
+ assert.ok(chooser, 'The payer\'s march must leave it the aggressor');
+ assert.equal(chooser, payer);
+ assert.deepEqual([choice.attacker, choice.defender], [payer, opponent]);
+ const battleAction: Action = { type: 'chooseBattle', territory: TERRITORY_ID, target: choice.attacker === chooser ? choice.defender : choice.attacker };
+ game = record(game, { actor: chooser, action: battleAction });
+ const battleEvent = game.battle?.event;
+ assert.ok(battleEvent, 'The real battle choice must open an original battle event');
+ const visibilityActions: IndependentSkillsRevivalStep[] = [];
+ const planActions: IndependentSkillsRevivalStep[] = [];
+ const traitorCalls: IndependentSkillsRevivalStep[] = [];
+ // No real battle leader may be a held traitor, so no private vote can change
+ // the ordinary strength outcome this programme relies on.
+ const heldTraitors = new Set(game.players.flatMap(player => player.traitors));
+ /** A trained leader cannot be in its own plan while it stays face up. */
+ const untrainedLeader = (id: string, trained: string | undefined, prefer: 'weakest' | 'strongest'): string => {
+ const player = independentSkillsRevivalPlayer(game, id);
+ const leaders = player.leaders
+ .filter(candidate => candidate.id !== trained && !candidate.dead && !heldTraitors.has(candidate.id))
+ .sort((a, b) => prefer === 'weakest'
+ ? a.strength - b.strength || a.id.localeCompare(b.id)
+ : b.strength - a.strength || a.id.localeCompare(b.id));
+ assert.ok(leaders.length, `${id} needs an unheld, untrained and available battle leader`);
+ return leaders[0].id;
+ };
+ const payerLeader = untrainedLeader(payer, payerTrained, 'weakest');
+ const opponentLeader = untrainedLeader(opponent, opponentTrained, 'strongest');
+ for (let count = 0; (game.response || game.decision || game.battle?.preLeader?.closed === false) && count < 60; count++) {
+ if (game.decision?.kind === 'leaderSkillVisibility') {
+ // Both trained leaders stay public and therefore outside these plans.
+ const step: IndependentSkillsRevivalStep = {
+ actor: game.decision.player,
+ action: { type: 'leaderSkillVisibility', event: game.decision.event, hide: false },
+ };
+ visibilityActions.push(step);
+ game = record(game, step);
+ } else {
+ const step = nextIndependentCardModulesStep(game);
+ assert.ok(step, `No native pre-battle continuation at ${game.decision?.kind}`);
+ game = record(game, step);
+ }
+ }
+ assert.ok(!heldTraitors.has(payerLeader) && !heldTraitors.has(opponentLeader),
+ 'Neither real battle leader may be a held traitor');
+ const payerPlan: IndependentSkillsRevivalStep = {
+ actor: payer, action: { type: 'battlePlan', dial: 0, support: 0, leader: payerLeader },
+ };
+ const opponentPlan: IndependentSkillsRevivalStep = {
+ actor: opponent, action: { type: 'battlePlan', dial: 1, support: 0, leader: opponentLeader },
+ };
+ planActions.push(payerPlan, opponentPlan);
+ game = record(game, payerPlan);
+ game = record(game, opponentPlan);
+ for (let count = 0; (game.response || game.decision || game.battle) && count < 120; count++) {
+ const step = nextIndependentCardModulesStep(game);
+ assert.ok(step, `No native battle continuation at ${game.decision?.kind}`);
+ if (step.action.type === 'traitorCall') traitorCalls.push(step);
+ game = record(game, step);
+ }
+ assert.ok(!game.battle, 'The battle must have resolved completely');
+ assert.equal(game.phase, 7);
+ const payerBoardLosses = independentSkillsRevivalPlayer(game, payer).tanks;
+ assert.equal(payerBoardLosses, 5,
+ 'The real battle must send the payer\'s five physical counters to the Tanks');
+ assert.equal(boardCount(independentSkillsRevivalPlayer(game, payer)), 0);
+ const afterBattle = structuredClone(game);
+
+ // Real out-of-turn bribe funds the next revival; bribes become spendable at Mentat.
+ const bribeAction: IndependentSkillsRevivalStep = {
+ actor: opponent, action: { type: 'bribe', target: payer, amount: BRIBE },
+ };
+ game = record(game, bribeAction);
+ assert.equal(independentSkillsRevivalPlayer(game, payer).bribes, BRIBE);
+ const afterBribe = structuredClone(game);
+ game = advanceUntil(game, state => state.turn === 1 && state.phase === 8 && clean(state));
+ assert.equal(independentSkillsRevivalPlayer(game, payer).bribes, 0);
+ assert.deepEqual(viewGame(game, owner).spiceBankerIncome?.deferred, [],
+ 'The first-turn bribe is a payment to a player, so no Bank income is owed yet');
+ const atMentat1 = structuredClone(game);
+
+ game = advanceUntil(game, state => state.turn === 2 && state.phase === 4 && clean(state));
+ const payerTanksAtRevival = independentSkillsRevivalPlayer(game, payer).tanks;
+ assert.equal(payerTanksAtRevival, payerBoardLosses,
+ 'No counter may reach or leave the Tanks between the battle and the Revival');
+ conserveCard(game, payer, RECRUITS_CARD_ID, staging);
+ staging.push(`Conserved staging: the ${PAYER} payer holds the physical Recruits face after a real native programme; the Banker remains ${bankerLeader} in ${owner}.`);
+ assertIndependentSkillsRevivalInventory(game);
+ const atRevival = structuredClone(game);
+ const ordinaryView = viewGame(game, payer).revival;
+ const pricing: IndependentSkillsRevivalPricing = {
+ ordinary: { limit: ordinaryView.limit, freeRemaining: ordinaryView.freeRemaining, forcesRemaining: ordinaryView.forcesRemaining },
+ priorReturn: { limit: 0, freeRemaining: 0, revived: 0, freeUsed: 0 },
+ doubled: { limit: 0, freeRemaining: 0 },
+ revival: { amount: REVIVED, free: 0, cost: 0 },
+ };
+ assert.equal(pricing.ordinary.limit, 3, 'The ordinary force revival cap is still three');
+ assert.ok(pricing.ordinary.freeRemaining >= PRIOR_FREE_RETURN);
+
+ const priorReturnAction: IndependentSkillsRevivalStep = {
+ actor: payer, action: { type: 'revive', amount: PRIOR_FREE_RETURN },
+ };
+ game = record(game, priorReturnAction);
+ assert.ok(!game.pendingRevival, 'The real revival must settle without an open request');
+ const afterPrior = independentSkillsRevivalPlayer(game, payer);
+ const priorFree = afterPrior.freeForcesRevived;
+ assert.ok(priorFree !== undefined, 'The ordinary free-return ledger must exist');
+ assert.equal(afterPrior.tanks, payerTanksAtRevival - PRIOR_FREE_RETURN);
+ assert.equal(afterPrior.revived, PRIOR_FREE_RETURN);
+ assert.equal(priorFree, PRIOR_FREE_RETURN);
+ const priorView = viewGame(game, payer).revival;
+ pricing.priorReturn = {
+ limit: priorView.limit, freeRemaining: priorView.freeRemaining,
+ revived: afterPrior.revived, freeUsed: priorFree,
+ };
+ const beforeRecruits = structuredClone(game);
+
+ const playAction: IndependentSkillsRevivalStep = {
+ actor: payer, action: { type: 'card', card: RECRUITS_CARD_ID },
+ };
+ game = record(game, playAction);
+ assert.equal(game.recruits?.player, payer);
+ const doubledView = viewGame(game, payer).revival;
+ pricing.doubled = { limit: doubledView.limit, freeRemaining: doubledView.freeRemaining };
+ assert.equal(pricing.doubled.limit, 7, 'Recruits raises the ordinary cap to seven');
+ const beforeMainRevival = structuredClone(game);
+
+ const quote = forceRevivalQuote(game, independentSkillsRevivalPlayer(game, payer), REVIVED);
+ pricing.revival = { amount: REVIVED, free: quote.free, cost: quote.cost };
+ const revivalAction: IndependentSkillsRevivalStep = {
+ actor: payer, action: { type: 'revive', amount: REVIVED },
+ };
+ game = record(game, revivalAction);
+ assert.ok(!game.pendingRevival, 'The real revival must settle without an open request');
+ const afterMain = independentSkillsRevivalPlayer(game, payer);
+ assert.equal(afterMain.tanks, payerTanksAtRevival - PRIOR_FREE_RETURN - REVIVED);
+ assert.equal(afterMain.revived, PRIOR_FREE_RETURN + REVIVED);
+ assert.equal(afterMain.freeForcesRevived, PRIOR_FREE_RETURN + quote.free);
+ const grant = game.spiceBankerIncome!.sources.find(source => source.grant)!;
+ assert.ok(grant, 'The real revival payment must award the Banker income');
+ assert.equal(grant.source.kind, 'force-revival');
+ assert.deepEqual(grant.source.bankLegs, [{ payer, amount: quote.cost }]);
+ assert.deepEqual(viewGame(game, owner).spiceBankerIncome?.deferred, [{ owner, amount: 1 }],
+ 'The Banker income is placed in front of the shield, not added to the wallet');
+ assert.equal(independentSkillsRevivalPlayer(game, owner).spice,
+ independentSkillsRevivalPlayer(beforeMainRevival, owner).spice,
+ 'The Banker wallet must not advance when the income is deferred');
+ const afterRevival = structuredClone(game);
+
+ // The same real state also feeds two genuine no-grant controls.
+ const control = (label: string, action: Action): IndependentSkillsRevivalControl => {
+ let clone = structuredClone(atRevival) as Game;
+ const before = independentSkillsRevivalPlayer(clone, payer).spice;
+ clone = applyAction(clone, payer, structuredClone(playAction.action));
+ clone = applyAction(clone, payer, action);
+ return {
+ label, playAction: structuredClone(playAction.action), revivalAction: structuredClone(action),
+ paid: before - independentSkillsRevivalPlayer(clone, payer).spice, game: clone,
+ };
+ };
+ const controls = [
+ control('free-only revival stays inside the doubled free rate', { type: 'revive', amount: 2 }),
+ control('below-threshold revival pays less than four to the Bank', { type: 'revive', amount: 3 }),
+ ];
+ for (const entry of controls)
+ assert.deepEqual(viewGame(entry.game, owner).spiceBankerIncome?.deferred, [],
+ `${entry.label} must not earn Bank income`);
+
+ const atMentat2 = advanceUntil(game, state => state.turn === 2 && state.phase === 7 && clean(state));
+ assert.deepEqual(viewGame(atMentat2, owner).spiceBankerIncome?.deferred, [{ owner, amount: 1 }],
+ 'The deferred Bank income survives until the actual Mentat settlement');
+ const afterMentat = advanceUntil(atMentat2, state => state.turn === 2 && state.phase === 8 && clean(state));
+ return {
+ initial, offered, afterSetup, afterBattle, afterBribe, atMentat1, atRevival,
+ beforeRecruits, beforeMainRevival, afterRevival, atMentat2, afterMentat,
+ owner, payer, opponent, bankerLeader, card: RECRUITS_CARD_ID,
+ territory: { id: TERRITORY_ID, sector: TERRITORY_SECTOR, type: 'sand' },
+ battle: {
+ event: battleEvent, attacker: payer, defender: opponent, chooser: payer,
+ moveAction, shipAction, battleAction: { actor: chooser, action: structuredClone(battleAction) },
+ payerLeader, opponentLeader,
+ visibilityActions, planActions, traitorCalls,
+ },
+ bribeAction, priorReturnAction, playAction, revivalAction, controls, pricing,
+ physical: {
+ payerBoardLosses, payerTanksAtRevival, priorFreeReturn: PRIOR_FREE_RETURN,
+ revived: REVIVED, freeReturned: quote.free, paidReturned: REVIVED - quote.free,
+ },
+ grant: { kind: grant.source.kind, payer, amount: quote.cost, deferred: 1 },
+ decisions, staging, steps, advanceUntil,
+ };
+}
diff --git a/tests/fixture-planetologist-independent.ts b/tests/fixture-planetologist-independent.ts
new file mode 100644
index 00000000..6a22af65
--- /dev/null
+++ b/tests/fixture-planetologist-independent.ts
@@ -0,0 +1,128 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import type { Card } from '../game/cards';
+import {
+ advanceIndependentSkillsBattle, assertIndependentSkillsBattleCustody,
+ createIndependentSkillsBattleFixture, createIndependentSkillsBattleSetup, finishIndependentSkillsBattle,
+ independentSkillsBattleClean, independentSkillsBattlePlayer,
+ type IndependentSkillsBattleStep,
+} from './fixture-independent-skills-battle';
+
+export type PlanetologistIndependentOptions = {
+ advanced?: boolean;
+ effect?: 'reinforcements' | 'harassWithdraw' | 'recruits';
+ alternate?: boolean;
+ poison?: boolean;
+};
+
+function conserve(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[]): Card {
+ const player = independentSkillsBattlePlayer(game, actor);
+ const held = player.hand.find(matches);
+ if (held) return held;
+ assert.ok(player.hand.length < handLimit(player));
+ for (const [zone, label] of [[game.deck, 'undealt deck'], [game.discard, 'discard']] as const) {
+ const index = zone.findIndex(matches);
+ if (index < 0) continue;
+ const card = zone.splice(index, 1)[0];
+ player.hand.push(card);
+ staging.push(`Conserved original ${card.id} from ${label} to ${actor}; no purchase is claimed.`);
+ return card;
+ }
+ throw Error('The original selected card must remain in stock or already be held.');
+}
+
+export function createPlanetologistIndependentFixture(options: PlanetologistIndependentOptions = {}) {
+ const base = createIndependentSkillsBattleFixture({ advanced: options.advanced ?? true,
+ skills: { owner: 'planetologist' } });
+ let game = structuredClone(base.beforeBattle);
+ const staging = [...base.staging];
+ const before = base.actions.findIndex(step => step.action.type === 'chooseBattle');
+ assert.ok(before >= 0);
+ const actions = base.actions.slice(0, before);
+ const effect = options.effect ?? 'reinforcements';
+ const weapon = conserve(game, base.owner, card => card.effect === effect, staging);
+ const defense = conserve(game, base.owner, card => card.effect === (effect === 'harassWithdraw' ? 'reinforcements' : 'harassWithdraw'), staging);
+ const opposingWeapon = options.poison ? conserve(game, base.opponent, card => card.kind === 'poison', staging).id
+ : base.plans[1].action.weapon;
+ assertIndependentSkillsBattleCustody(game);
+ const beforeBattle = structuredClone(game);
+ actions.push(base.battleStep);
+ game = applyAction(game, base.battleStep.actor, base.battleStep.action);
+ game = advanceIndependentSkillsBattle(game,
+ state => !!state.battle && !state.battle.revealed && independentSkillsBattleClean(state),
+ actions, { hide: { [base.owner]: true, [base.opponent]: true } });
+ const plans: IndependentSkillsBattleStep[] = [
+ { actor: base.owner, action: { type: 'battlePlan', dial: 2, support: game.advanced ? 2 : 0,
+ leader: base.trainers.owner, weapon: weapon.id, defense: defense.id,
+ ...(effect === 'recruits' ? {} : { planetologistSpecial: options.alternate !== false }) } },
+ { actor: base.opponent, action: { ...base.plans[1].action, weapon: opposingWeapon } },
+ ];
+ staging.push('Branch from the original pre-battle position; make real hidden postures and select the trained Planetologist. No army, wallet, clock or skill assignment changes.');
+ return { initial: base.initial, offered: base.offered, beforeBattle, game,
+ owner: base.owner, opponent: base.opponent, observer: base.observer,
+ trainer: base.trainers.owner, location: base.location, first: base.first, second: base.second,
+ returns: base.returns, plans, actions, staging, effect, alternate: effect === 'recruits' || options.alternate !== false };
+}
+export type PlanetologistIndependentFixture = ReturnType;
+export function sealPlanetologistIndependent(fixture: PlanetologistIndependentFixture): Game {
+ let game = fixture.game;
+ for (const step of fixture.plans) game = applyAction(game, step.actor, step.action);
+ assert.ok(game.battle?.revealed);
+ return game;
+}
+export function finishPlanetologistIndependent(game: Game, fixture: PlanetologistIndependentFixture,
+ actions?: IndependentSkillsBattleStep[]): Game {
+ return finishIndependentSkillsBattle(game, { return: fixture.returns, actions });
+}
+
+/** Actual stronghold deployment empties the reserves before the later utility plan. */
+export function createPlanetologistEmptyReserveFixture(): PlanetologistIndependentFixture {
+ const initial = createGame('PLANETEMPTY', newPlayer('h', 'Harkonnen', 'harkonnen'), true);
+ joinGame(initial, newPlayer('g', 'Guild', 'guild'));
+ joinGame(initial, newPlayer('e', 'Emperor', 'emperor'));
+ const setup = createIndependentSkillsBattleSetup({ initial, advanced: true,
+ skills: { owner: 'planetologist', opponent: 'rihani-decipherer', observer: 'prana-bindu-adept' } });
+ const actions = [...setup.actions], staging = [...setup.staging];
+ let game = setup.afterSetup;
+ const act = (actor: string, action: Action) => {
+ actions.push({ actor, action });
+ game = applyAction(game, actor, action);
+ };
+ game = advanceIndependentSkillsBattle(game,
+ state => state.phase === 5 && state.active === 'h' && independentSkillsBattleClean(state), actions);
+ act('h', { type: 'ship', territory: 'carthag', sector: 11, amount: 10 });
+ game = advanceIndependentSkillsBattle(game, state => state.phase === 5 && state.active === 'h' &&
+ independentSkillsBattleClean(state) && !state.pendingShipment, actions);
+ act('h', { type: 'move', from: 'carthag:11', territory: 'imperial_basin', sector: 10, amount: 5 });
+ act('h', { type: 'endMovement' });
+ game = advanceIndependentSkillsBattle(game,
+ state => state.phase === 5 && state.active === 'e' && independentSkillsBattleClean(state), actions);
+ act('e', { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 2 });
+ game = advanceIndependentSkillsBattle(game,
+ state => state.phase === 6 && independentSkillsBattleClean(state), actions);
+ assert.equal(independentSkillsBattlePlayer(game, 'h').reserves, 0);
+ conserve(game, 'h', card => card.id === 'ecaz-reinforcements', staging);
+ conserve(game, 'h', card => card.id === 'ecaz-harass-withdraw', staging);
+ const beforeBattle = structuredClone(game);
+ const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === 'imperial_basin');
+ assert.ok(choice && choice.attacker === 'h');
+ act(choice.chooser, { type: 'chooseBattle', territory: choice.territory, target: choice.defender });
+ game = advanceIndependentSkillsBattle(game,
+ state => !!state.battle && !state.battle.revealed && independentSkillsBattleClean(state),
+ actions, { hide: { h: true, e: false } });
+ const other = independentSkillsBattlePlayer(game, 'e').leaders
+ .find(leader => !leader.dead && leader.id !== setup.trainers.observer)!;
+ const plans: IndependentSkillsBattleStep[] = [
+ { actor: 'h', action: { type: 'battlePlan', leader: setup.trainers.owner,
+ weapon: 'ecaz-reinforcements', defense: 'ecaz-harass-withdraw',
+ dial: 0.5, support: 0, planetologistSpecial: true } },
+ { actor: 'e', action: { type: 'battlePlan', leader: other.id, dial: 0, support: 0 } },
+ ];
+ staging.push('Harkonnen paid its real ten-spice shipment into its own Carthag, then moved five original counters into Imperial Basin. Zero reserves and a half-strength unpaid dial are native outcomes, not staged resources.');
+ assertIndependentSkillsBattleCustody(game);
+ return { initial: setup.initial, offered: setup.offered, beforeBattle, game,
+ owner: 'h', opponent: 'e', observer: 'g', trainer: setup.trainers.owner,
+ location: 'imperial_basin', first: 'imperial_basin:10', second: 'imperial_basin:10',
+ returns: { 'imperial_basin:10': { normal: 4, elite: 0 } },
+ plans, actions, staging, effect: 'reinforcements', alternate: true };
+}
diff --git a/tests/independent-skills-battle.test.ts b/tests/independent-skills-battle.test.ts
new file mode 100644
index 00000000..7352d0cc
--- /dev/null
+++ b/tests/independent-skills-battle.test.ts
@@ -0,0 +1,158 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
+import { REINFORCEMENTS_CARD } from '../game/reinforcements';
+import {
+ assertIndependentSkillsBattleCustody, createIndependentSkillsBattleFixture,
+ createIndependentSkillsBattleSetup, advanceIndependentSkillsBattle, independentSkillsBattleClean,
+ finishIndependentSkillsBattle, independentSkillsBattlePlayer,
+ quoteIndependentSkillsBattle, type IndependentSkillsBattleFixture,
+} from './fixture-independent-skills-battle';
+
+const owner = (fixture: IndependentSkillsBattleFixture, game: Game) =>
+ independentSkillsBattlePlayer(game, fixture.owner);
+const opponent = (fixture: IndependentSkillsBattleFixture, game: Game) =>
+ independentSkillsBattlePlayer(game, fixture.opponent);
+const held = (game: Game, id: string) => [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand)]
+ .filter((card) => card.id === id);
+
+void test('the same-plan programme pays the three-counter fee, returns the undialed complement and scores the real Warmaster bonus', () => {
+ for (const advanced of [false, true]) {
+ const fixture = createIndependentSkillsBattleFixture({ case: 'same-plan', advanced });
+ assert.equal(fixture.location, 'pasty_mesa');
+ assert.equal(owner(fixture, fixture.beforeShipment).reserves, 20);
+ assert.equal(owner(fixture, fixture.beforeShipment).spice, 10);
+ assert.equal(owner(fixture, fixture.afterShipment).reserves, 16);
+ assert.equal(owner(fixture, fixture.afterShipment).spice, 2);
+ assert.equal(owner(fixture, fixture.afterShipment).forces[fixture.first], 4);
+ assert.equal(opponent(fixture, fixture.afterShipment).spice, 13, 'the paid eight-spice shipment reached the Guild');
+ assert.deepEqual(owner(fixture, fixture.beforeBattle).forces,
+ { [fixture.first]: 2, [fixture.second]: 2 });
+ assert.deepEqual(opponent(fixture, fixture.beforeBattle).forces, { [fixture.second]: 5 });
+ const worthless = fixture.plans[1].action.weapon;
+ const quote = quoteIndependentSkillsBattle(fixture.game, { returns: fixture.returns });
+ assert.deepEqual(quote.reinforcements,
+ [{ player: fixture.owner, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
+ assert.deepEqual(quote.leaderSkillBonuses.defender,
+ { bonus: 3, applied: [{ skill: 'warmaster', amount: 3, mode: 'skilled' }] });
+ assert.deepEqual(quote.leaderSkillBonuses.attacker, { bonus: 0, applied: [] });
+ // Dial 2 + Reinforcements 2 + Captain Aramsham 5 beats Staban Tuek 5 + Warmaster 3.
+ assert.deepEqual(quote.scores, { attacker: 9, defender: 8 });
+ assert.deepEqual(quote.harassWithdraw?.map((entry) => ({
+ player: entry.player, returned: entry.returned,
+ remaining: { normal: entry.remaining.normal, elite: entry.remaining.elite }, locations: entry.locations })),
+ [{ player: fixture.owner, returned: { normal: 2, elite: 0 }, remaining: { normal: 2, elite: 0 },
+ locations: { [fixture.first]: { normal: 1, elite: 0 }, [fixture.second]: { normal: 1, elite: 0 } } }]);
+ assert.equal(fixture.game.decision?.kind, 'harassWithdraw');
+ assert.throws(() => quoteIndependentSkillsBattle(fixture.game,
+ { returns: { [fixture.first]: { normal: 1, elite: 0 } } }),
+ /exact undialed complement of a legal physical commitment/);
+ const done = finishIndependentSkillsBattle(fixture.game, { return: fixture.returns });
+ assert.equal(done.phase, 7);
+ assert.equal(done.lastBattleContext?.winner, fixture.owner);
+ assert.equal(done.lastBattleContext?.result, 'normal');
+ assert.equal(owner(fixture, done).reserves, 15, 'twenty reserves, minus four shipped, minus the fee, plus two returned');
+ assert.equal(owner(fixture, done).tanks, 5, 'the three fee counters plus the two dialed casualties');
+ assert.deepEqual(owner(fixture, done).forces, {});
+ assert.equal(owner(fixture, done).spice, advanced ? 0 : 2);
+ assert.equal(opponent(fixture, done).tanks, 5, 'the defeated Guild army reached the Tanks');
+ assert.deepEqual(opponent(fixture, done).forces, {});
+ for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD, worthless as string]) {
+ assert.equal(done.discard.filter((entry) => entry.id === card).length, 1, card);
+ assert.ok(done.players.every((player) => !player.hand.some((held) => held.id === card)), card);
+ }
+ assert.deepEqual(done.lastBattleContext?.mandatoryDiscard?.entries,
+ [{ card: worthless, owner: fixture.opponent }]);
+ assertIndependentSkillsBattleCustody(done);
+ }
+});
+
+void test('the trained Suk Graduate rescues both committed casualties without refunding the fee or leaving phantom forces', () => {
+ for (const advanced of [false, true]) {
+ const fixture = createIndependentSkillsBattleFixture({ case: 'suk', advanced });
+ const quote = quoteIndependentSkillsBattle(fixture.game, { returns: fixture.returns });
+ // Dial 2 + Reinforcements 2 + Hasimir Fenring 6 against Guild Representative 1 + 2.
+ assert.deepEqual(quote.scores, { attacker: 10, defender: 3 });
+ assert.equal(quote.leaderSkillBonuses.attacker.bonus, 0);
+ const fee = finishIndependentSkillsBattle(fixture.game,
+ { return: fixture.returns, until: (state) => state.decision?.kind === 'sukRescue' });
+ const rescue = fee.decision;
+ assert.equal(rescue?.kind, 'sukRescue');
+ assert.equal(rescue?.mode, 'skilled');
+ assert.deepEqual(owner(fixture, fee).forces,
+ { [fixture.first]: 1, [fixture.second]: 1 }, 'the Harass return left exactly the committed two');
+ assert.equal(owner(fixture, fee).reserves, 15, 'the fee preceded the two returned counters');
+ assert.equal(owner(fixture, fee).tanks, 3, 'only the Reinforcements fee reached the Tanks');
+ assert.equal(owner(fixture, fee).spice, advanced ? 0 : 2);
+ const options = rescue?.kind === 'sukRescue' ? rescue.options : [];
+ const kept = options.find((option) => option.kept?.kind === 'normal' && option.normal + option.elite === 2);
+ assert.ok(kept, 'saving both casualties leaves one counter in its original sector');
+ assert.deepEqual(kept.kept, { key: fixture.first, kind: 'normal' });
+ assert.deepEqual(options.find((option) => option.normal + option.elite === 0),
+ { normal: 0, elite: 0, kept: null });
+ const chosen = options.indexOf(kept);
+ const done = finishIndependentSkillsBattle(fee,
+ { return: fixture.returns, rescue: () => chosen });
+ assert.equal(done.lastBattleContext?.sukRescue?.completed, true);
+ assert.deepEqual(owner(fixture, done).forces, { [fixture.first]: 1 },
+ 'one rescued counter stayed in Pasty Mesa sector 5 and the rest returned to reserves');
+ assert.equal(owner(fixture, done).reserves, 16, 'the fee was not refunded by the rescue');
+ assert.equal(owner(fixture, done).tanks, 3);
+ assert.equal(done.pendingSukRescue, null);
+ assert.equal(opponent(fixture, done).tanks, 5);
+ for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) {
+ assert.equal(held(done, card).length, 1, card);
+ assert.equal(done.discard.filter((entry) => entry.id === card).length, 1, card);
+ }
+ assert.equal(done.discard.some((entry) => entry.kind === 'worthless'), false);
+ assertIndependentSkillsBattleCustody(done);
+ }
+});
+
+void test('an opposing real Traitor cancels the Harass return while the fee still reaches the Tanks', () => {
+ for (const advanced of [false, true]) {
+ const fixture = createIndependentSkillsBattleFixture({ case: 'traitor-cancel', advanced });
+ assert.equal(fixture.traitor, fixture.plans[0].action.leader);
+ assert.ok(opponent(fixture, fixture.game).traitors.includes(fixture.traitor!));
+ const canceled = quoteIndependentSkillsBattle(fixture.game,
+ { callers: [fixture.opponent], returns: fixture.returns });
+ assert.equal(canceled.result, 'traitor');
+ assert.equal(canceled.winner, fixture.opponent);
+ assert.equal(canceled.scores, null, 'a revealed Traitor carries no battle totals');
+ assert.equal(canceled.harassWithdraw, undefined, 'an opposing successful traitor cancels the return');
+ assert.deepEqual(canceled.reinforcements,
+ [{ player: fixture.owner, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
+ const done = finishIndependentSkillsBattle(fixture.game,
+ { callers: [fixture.opponent], return: fixture.returns });
+ assert.equal(done.lastBattleContext?.result, 'traitor');
+ assert.equal(done.lastBattleContext?.winner, fixture.opponent);
+ assert.deepEqual(owner(fixture, done).forces, {}, 'the canceled return left no phantom counters');
+ assert.equal(owner(fixture, done).reserves, 13, 'twenty reserves, minus four shipped and the fee; nothing returned');
+ assert.equal(owner(fixture, done).tanks, 7, 'the fee plus the four counters left in Pasty Mesa');
+ assert.deepEqual(opponent(fixture, done).forces, { [fixture.second]: 5 });
+ assert.equal(opponent(fixture, done).tanks, 0);
+ assert.equal(opponent(fixture, done).spice, 18, 'five spice, the eight-spice shipment and the five-strength bounty');
+ assert.deepEqual(done.lastBattleContext?.mandatoryDiscard?.entries,
+ [{ card: REINFORCEMENTS_CARD, owner: fixture.owner }, { card: HARASS_WITHDRAW_CARD, owner: fixture.owner }]);
+ assertIndependentSkillsBattleCustody(done);
+ }
+});
+
+void test('normal Bureaucrat remains available in independent all14 play and diverts a real bribe without advancing its escrow', () => {
+ const f = createIndependentSkillsBattleSetup({
+ skills: { owner: 'planetologist', opponent: 'warmaster', observer: 'bureaucrat' },
+ });
+ let game = advanceIndependentSkillsBattle(f.afterSetup,
+ state => state.phase === 4 && independentSkillsBattleClean(state));
+ const payer = independentSkillsBattlePlayer(game, f.owner).spice;
+ const payee = independentSkillsBattlePlayer(game, f.opponent).spice;
+ game = applyAction(game, f.owner, { type: 'bribe', target: f.opponent, amount: 5 });
+ const offer = viewGame(game, f.observer).bureaucrat!.pending!;
+ assert.ok(offer);
+ game = applyAction(game, f.observer, { type: 'decision', event: offer.event, redirect: true });
+ assert.equal(independentSkillsBattlePlayer(game, f.owner).spice, payer - 5);
+ assert.equal(independentSkillsBattlePlayer(game, f.opponent).spice, payee);
+ assert.equal(independentSkillsBattlePlayer(game, f.opponent).bribes, 3);
+ assert.equal(viewGame(game, f.observer).bureaucrat!.usedThisPhase, true);
+});
diff --git a/tests/independent-skills-revival.test.ts b/tests/independent-skills-revival.test.ts
new file mode 100644
index 00000000..f46af7da
--- /dev/null
+++ b/tests/independent-skills-revival.test.ts
@@ -0,0 +1,102 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { forceRevivalQuote } from '../game/revival';
+import {
+ assertIndependentSkillsRevivalInventory, createIndependentSkillsRevivalFixture,
+ independentSkillsRevivalPlayer,
+} from './fixture-independent-skills-revival';
+
+const spiceOf = (game: Game, id: string): number =>
+ independentSkillsRevivalPlayer(game, id).spice;
+
+void test('the payer\'s prior free return and the real Recruits activation price the paid revival exactly', () => {
+ const fixture = createIndependentSkillsRevivalFixture();
+ // Ordinary caps and rates on the real turn-two Revival state.
+ assert.deepEqual(fixture.pricing.ordinary, { limit: 3, freeRemaining: 1, forcesRemaining: 3 });
+ // A genuine free return before the card is retained, never washed away.
+ assert.deepEqual(fixture.pricing.priorReturn, { limit: 3, freeRemaining: 0, revived: 1, freeUsed: 1 });
+ const beforeRecruits = independentSkillsRevivalPlayer(fixture.beforeRecruits, fixture.payer);
+ assert.equal(beforeRecruits.reserves, 16);
+ assert.equal(beforeRecruits.tanks, 4);
+ assert.equal(spiceOf(fixture.beforeRecruits, fixture.payer), spiceOf(fixture.atRevival, fixture.payer),
+ 'A free return costs no spice');
+ // The physical card is really played through the ordinary action.
+ assert.equal(fixture.beforeMainRevival.recruits?.player, fixture.payer);
+ assert.deepEqual(fixture.pricing.doubled, { limit: 7, freeRemaining: 1 });
+ // The independent original price of the requested return, from the real state.
+ const payer = independentSkillsRevivalPlayer(fixture.beforeMainRevival, fixture.payer);
+ assert.deepEqual(forceRevivalQuote(fixture.beforeMainRevival, payer, fixture.pricing.revival.amount),
+ { free: 1, normalCost: 6, cost: 6 });
+ assert.deepEqual(fixture.pricing.revival, { amount: 4, free: 1, cost: 6 });
+ // Without the card the ordinary cap refuses the same request on that state.
+ const ordinaryOnly = structuredClone(fixture.atRevival);
+ assert.throws(() => applyAction(ordinaryOnly, fixture.payer, { type: 'revive', amount: 4 }));
+ // The payer's charge is exactly the quote and its counters move as quoted.
+ const after = independentSkillsRevivalPlayer(fixture.afterRevival, fixture.payer);
+ assert.equal(spiceOf(fixture.beforeMainRevival, fixture.payer) - after.spice, fixture.pricing.revival.cost);
+ assert.equal(after.tanks, beforeRecruits.tanks - fixture.pricing.revival.amount);
+ assert.equal(after.reserves, beforeRecruits.reserves + fixture.pricing.revival.amount);
+ assert.equal(after.revived, 1 + fixture.pricing.revival.amount);
+ assert.equal(after.freeForcesRevived, 1 + fixture.pricing.revival.free);
+ assertIndependentSkillsRevivalInventory(fixture.afterRevival);
+});
+
+void test('the real revival leaves one unavailable Banker spice that the Mentat Pause credits exactly once', () => {
+ const fixture = createIndependentSkillsRevivalFixture();
+ const view = viewGame(fixture.afterRevival, fixture.owner).spiceBankerIncome!;
+ assert.deepEqual(view.deferred, [{ owner: fixture.owner, amount: 1 }]);
+ assert.deepEqual(view.usedThisPhase, [fixture.owner]);
+ // Exactly one qualifying Bank payment was recorded, with its real legs.
+ const granted = fixture.afterRevival.spiceBankerIncome!.sources.filter(source => source.grant);
+ assert.equal(granted.length, 1);
+ assert.equal(granted[0].source.kind, 'force-revival');
+ assert.deepEqual(granted[0].source.bankLegs, [{ payer: fixture.payer, amount: fixture.pricing.revival.cost }]);
+ assert.equal(granted[0].grant!.owner, fixture.owner);
+ assert.equal(granted[0].grant!.amount, fixture.grant.deferred);
+ // The spice is in front of the shield: the wallet cannot advance early.
+ assert.equal(spiceOf(fixture.afterRevival, fixture.owner), spiceOf(fixture.beforeMainRevival, fixture.owner));
+ assert.equal(spiceOf(fixture.atMentat2, fixture.owner), spiceOf(fixture.beforeMainRevival, fixture.owner));
+ assert.deepEqual(viewGame(fixture.atMentat2, fixture.owner).spiceBankerIncome?.deferred,
+ [{ owner: fixture.owner, amount: 1 }]);
+ // The actual Mentat Pause pays it once and clears the ledger.
+ assert.deepEqual(viewGame(fixture.afterMentat, fixture.owner).spiceBankerIncome?.deferred, []);
+ assert.deepEqual(viewGame(fixture.afterMentat, fixture.owner).spiceBankerIncome?.usedThisPhase, []);
+ assert.equal(spiceOf(fixture.afterMentat, fixture.owner) - spiceOf(fixture.atMentat2, fixture.owner),
+ fixture.grant.deferred, 'Exactly the deferred spice arrives, once');
+ assertIndependentSkillsRevivalInventory(fixture.afterMentat);
+});
+
+void test('a free-only or below-threshold real revival on the same state earns no Bank income', () => {
+ const fixture = createIndependentSkillsRevivalFixture();
+ for (const control of fixture.controls) {
+ assert.ok(control.paid < 4, `${control.label}: the payer stays below the Bank threshold`);
+ assert.equal(control.game.spiceBankerIncome!.sources.filter(source => source.grant).length, 0,
+ `${control.label}: no Banker income may be earned`);
+ assert.deepEqual(viewGame(control.game, fixture.owner).spiceBankerIncome?.deferred, []);
+ assert.deepEqual(viewGame(control.game, fixture.owner).spiceBankerIncome?.usedThisPhase, []);
+ }
+ const freeOnly = fixture.controls.find(control => control.revivalAction.amount === 2)!;
+ assert.equal(freeOnly.paid, 0);
+ const returned = independentSkillsRevivalPlayer(freeOnly.game, fixture.payer);
+ assert.equal(returned.tanks, 3);
+ assert.equal(returned.freeForcesRevived, 2, 'Both returned forces really came from the doubled free rate');
+ const below = fixture.controls.find(control => control.revivalAction.amount === 3)!;
+ assert.equal(below.paid, 2);
+ assert.equal(independentSkillsRevivalPlayer(below.game, fixture.payer).tanks, 2);
+});
+
+void test('the Advanced programme preserves real paid revival and deferred Banker collection', () => {
+ const basic = createIndependentSkillsRevivalFixture();
+ const advanced = createIndependentSkillsRevivalFixture({ advanced: true });
+ // The covered revival and Banker mechanics are mode-independent.
+ assert.deepEqual(advanced.pricing, basic.pricing);
+ assert.deepEqual(advanced.grant, basic.grant);
+ assert.deepEqual(advanced.physical, basic.physical);
+ assert.equal(spiceOf(advanced.afterRevival, advanced.owner),
+ spiceOf(advanced.beforeMainRevival, advanced.owner));
+ assert.equal(spiceOf(advanced.afterMentat, advanced.owner) -
+ spiceOf(advanced.atMentat2, advanced.owner), advanced.grant.deferred);
+ assert.deepEqual(viewGame(advanced.afterMentat, advanced.owner).spiceBankerIncome?.deferred, []);
+ assertIndependentSkillsRevivalInventory(advanced.afterMentat);
+});
diff --git a/tests/planetologist-independent.test.ts b/tests/planetologist-independent.test.ts
new file mode 100644
index 00000000..35e45ebf
--- /dev/null
+++ b/tests/planetologist-independent.test.ts
@@ -0,0 +1,152 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame } from '../game/engine';
+import {
+ createPlanetologistIndependentFixture, createPlanetologistEmptyReserveFixture, sealPlanetologistIndependent,
+ finishPlanetologistIndependent,
+} from './fixture-planetologist-independent';
+import {
+ assertIndependentSkillsBattleCustody, independentSkillsBattlePlayer,
+ quoteIndependentSkillsBattle,
+} from './fixture-independent-skills-battle';
+
+void test('native Reinforcements and its Planetologist alternative keep the same score but only the native role spends three reserves', () => {
+ for (const alternate of [false, true]) {
+ const f = createPlanetologistIndependentFixture({ alternate });
+ const before = independentSkillsBattlePlayer(f.game, f.owner);
+ const sealed = sealPlanetologistIndependent(f);
+ const quote = quoteIndependentSkillsBattle(sealed, { returns: f.returns });
+ const trainer = before.leaders.find(leader => leader.id === f.trainer)!;
+ assert.equal(quote.scores!.attacker, trainer.strength + 2 + 2);
+ assert.equal(quote.leaderSkillBonuses!.attacker.bonus, alternate ? 2 : 0);
+ assert.equal(quote.reinforcements?.[0]?.normal ?? 0, alternate ? 0 : 3);
+ const done = finishPlanetologistIndependent(sealed, f);
+ const own = independentSkillsBattlePlayer(done, f.owner);
+ assert.equal(done.phase, 7);
+ assert.equal(own.reserves, before.reserves - (alternate ? 0 : 3) + 2);
+ assert.equal(own.tanks, before.tanks + (alternate ? 0 : 3) + 2);
+ assert.deepEqual(own.forces, {});
+ assert.equal(own.spice, before.spice - 2);
+ for (const card of ['ecaz-reinforcements', 'ecaz-harass-withdraw'])
+ assert.equal(done.discard.filter(entry => entry.id === card).length, 1);
+ assertIndependentSkillsBattleCustody(done);
+ }
+});
+
+void test('Harass as the Planetologist Special makes no return while the distinct defense-slot Reinforcements still pays its own fee', () => {
+ const f = createPlanetologistIndependentFixture({ effect: 'harassWithdraw' });
+ const before = independentSkillsBattlePlayer(f.game, f.owner);
+ const sealed = sealPlanetologistIndependent(f);
+ const quote = quoteIndependentSkillsBattle(sealed);
+ assert.deepEqual(quote.harassWithdraw ?? [], []);
+ assert.deepEqual(quote.reinforcements, [{ player: f.owner, card: 'ecaz-reinforcements', normal: 3, elite: 0 }]);
+ assert.equal(quote.leaderSkillBonuses!.attacker.bonus, 2);
+ const done = finishPlanetologistIndependent(sealed, f);
+ const own = independentSkillsBattlePlayer(done, f.owner);
+ assert.equal(own.reserves, before.reserves - 3);
+ assert.equal(own.tanks, before.tanks + 3 + 2);
+ assert.equal(Object.values(own.forces).reduce((sum, count) => sum + count, 0), 2);
+ assertIndependentSkillsBattleCustody(done);
+});
+
+void test('Recruits can be spent as a green Planetologist Special without activating global Revival or spending an unused Reinforcements', () => {
+ const f = createPlanetologistIndependentFixture({ effect: 'recruits' });
+ const before = independentSkillsBattlePlayer(f.game, f.owner);
+ const sealed = sealPlanetologistIndependent(f);
+ const quote = quoteIndependentSkillsBattle(sealed, { returns: f.returns });
+ assert.equal(quote.leaderSkillBonuses!.attacker.bonus, 2);
+ assert.deepEqual(quote.reinforcements ?? [], []);
+ const done = finishPlanetologistIndependent(sealed, f);
+ const own = independentSkillsBattlePlayer(done, f.owner);
+ assert.ok(!done.recruits);
+ assert.equal(own.reserves, before.reserves + 2);
+ assert.equal(own.tanks, before.tanks + 2);
+ assert.equal(done.discard.filter(card => card.id === 'ecaz-recruits').length, 1);
+ assert.ok(own.hand.some(card => card.id === 'ecaz-reinforcements'));
+ assertIndependentSkillsBattleCustody(done);
+});
+
+void test('a poisoned Planetologist loses alternate strength without retroactively activating Reinforcements or suppressing normal Harass', () => {
+ const f = createPlanetologistIndependentFixture({ poison: true });
+ const before = independentSkillsBattlePlayer(f.game, f.owner);
+ const sealed = sealPlanetologistIndependent(f);
+ const quote = quoteIndependentSkillsBattle(sealed, { returns: f.returns });
+ assert.equal(quote.leaderDeaths.attacker, true);
+ assert.equal(quote.leaderSkillBonuses!.attacker.bonus, 0);
+ assert.deepEqual(quote.reinforcements ?? [], []);
+ assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
+ const done = finishPlanetologistIndependent(sealed, f);
+ const own = independentSkillsBattlePlayer(done, f.owner);
+ assert.equal(own.leaders.find(leader => leader.id === f.trainer)!.dead, true);
+ assert.equal(own.reserves, before.reserves + 2);
+ assert.equal(own.tanks, before.tanks + 2);
+ assert.ok(done.leaderSkills!.deck.includes('planetologist'));
+ assertIndependentSkillsBattleCustody(done);
+});
+
+void test('the independent green alternate role also preserves Basic support and cannot be declared using another leader', () => {
+ const f = createPlanetologistIndependentFixture({ advanced: false });
+ const own = independentSkillsBattlePlayer(f.game, f.owner);
+ const other = own.leaders.find(leader => !leader.dead && leader.id !== f.trainer)!;
+ assert.throws(() => applyAction(f.game, f.owner, { ...f.plans[0].action, leader: other.id }));
+ const done = finishPlanetologistIndependent(sealPlanetologistIndependent(f), f);
+ assert.equal(done.phase, 7);
+ assert.equal(independentSkillsBattlePlayer(done, f.owner).spice, own.spice);
+ assert.equal(independentSkillsBattlePlayer(done, f.owner).tanks, own.tanks + 2);
+ assertIndependentSkillsBattleCustody(done);
+});
+
+void test('zero reserves block native Reinforcements before a later Harass return but not its printed Planetologist substitution', () => {
+ const f = createPlanetologistEmptyReserveFixture();
+ const before = independentSkillsBattlePlayer(f.game, f.owner);
+ assert.equal(before.reserves, 0);
+ assert.throws(() => applyAction(f.game, f.owner, { ...f.plans[0].action, planetologistSpecial: false }));
+ const sealed = sealPlanetologistIndependent(f);
+ const quote = quoteIndependentSkillsBattle(sealed);
+ assert.deepEqual(quote.reinforcements ?? [], []);
+ assert.equal(quote.payments.find(payment => payment.player === f.owner)!.cost, 0);
+ assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 4, elite: 0 });
+ const done = finishPlanetologistIndependent(sealed, f);
+ const own = independentSkillsBattlePlayer(done, f.owner);
+ assert.equal(done.phase, 7);
+ assert.equal(own.reserves, 4);
+ assert.equal(own.tanks, 1);
+ assert.equal(Object.values(own.forces).reduce((sum, count) => sum + count, 0), 15);
+ assertIndependentSkillsBattleCustody(done);
+});
+
+void test('a truthful named Reinforcements promise at zero reserves finds and executes the Planetologist alternative', () => {
+ const f = createPlanetologistEmptyReserveFixture();
+ let game = structuredClone(f.game);
+ const index = game.deck.findIndex(card => card.effect === 'truthtrance');
+ assert.ok(index >= 0);
+ const truth = game.deck.splice(index, 1)[0];
+ independentSkillsBattlePlayer(game, f.observer).hand.push(truth);
+ game = applyAction(game, f.observer, { type: 'card', card: truth.id });
+ while (game.truthtrance?.stage === 'priority') {
+ const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id))!;
+ game = applyAction(game, actor.id, { type: 'truthPass' });
+ }
+ game = applyAction(game, f.observer, { type: 'truthAsk', question: {
+ kind: 'battlePlan', target: f.owner, claim: { kind: 'and', terms: [
+ { kind: 'weapon', name: 'Reinforcements' },
+ { kind: 'dial', compare: 'eq', value: 0.5 },
+ { kind: 'support', compare: 'eq', value: 0 },
+ ] },
+ } });
+ assert.ok(viewGame(game, f.owner).truthBattleAnswers!.includes('yes'));
+ game = applyAction(game, f.owner, { type: 'truthAnswer', answer: 'yes' });
+ const witness = viewGame(game, f.owner).battle!.compliantPlan;
+ assert.ok(witness);
+ game = applyAction(game, f.owner, { type: 'battlePlan', ...witness });
+ game = applyAction(game, f.opponent, f.plans[1].action);
+ const done = finishPlanetologistIndependent(game, f);
+ const own = independentSkillsBattlePlayer(done, f.owner);
+ assert.equal(done.phase, 7);
+ assert.equal(own.reserves, 0);
+ assert.equal(own.tanks, 1);
+ assert.equal(Object.values(own.forces).reduce((sum, count) => sum + count, 0), 19);
+ assert.ok(own.hand.some(card => card.id === 'ecaz-harass-withdraw'));
+ assert.equal(done.discard.filter(card => card.id === 'ecaz-reinforcements').length, 1);
+ assertIndependentSkillsBattleCustody(done);
+});
diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts
index c1e509dc..e5ca64f4 100644
--- a/tools/prototype-room.ts
+++ b/tools/prototype-room.ts
@@ -97,8 +97,8 @@ export function startPrototypeRoom(
throw new Error('The room identity is inconsistent.');
if (!isPrototypeProfile(profile))
throw new Error('Unknown prototype profile.');
- if (options.ecazTreachery && profile !== 'ecaz-occupy')
- throw new Error('--ecaz-treachery requires the ecaz-occupy profile.');
+ if (options.ecazTreachery && profile !== 'ecaz-occupy' && profile !== 'leader-skills')
+ throw new Error('--ecaz-treachery requires the ecaz-occupy or leader-skills profile.');
if (options.nexusCards && profile !== 'leader-skills' && profile !== 'semuta')
throw new Error('--nexus-cards requires the leader-skills or semuta profile.');
if (options.discoveries && profile !== 'leader-skills' && profile !== 'nexus' && profile !== 'semuta')
diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts
index b7bac448..a55865b0 100644
--- a/tools/start-prototype.ts
+++ b/tools/start-prototype.ts
@@ -45,6 +45,7 @@ async function main() {
'\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' +
'\nLeader-skills preserves canonical unused Tech Tokens selected in fresh three-through-six-seat classic or supported native Basic/Advanced lobbies. Original selected decks, Richese cache, all fourteen skills, printed native Tech owners, phase-end income and mandatory original-winner reward before Face Dance remain. Mixed E3 families outside the documented Nexus envelopes, other modules and played-game conversion remain excluded.',
'\nAll fresh supported all14 Skills compositions retain mandatory normal Banker income, including original Nexus, Discovery, World and Semuta envelopes. Actual bank payer legs, once-per-phase use and original Mentat collection remain unchanged. No existing game is converted or retrofitted.',
+ '\n--profile leader-skills --ecaz-treachery composes original all14 Skills with the independent three-card variant in fresh classic2–6-seat Basic/Advanced lobbies, without other modules. Original36 Treachery, actual private offers/training and mandatory normal Banker income remain. Recruits uses original current-rate/usage accounting; Reinforcements pays three own reserves before Harass returns undialed counters, adding score2 without physical dial/support/casualties. Printed Warmaster Worthless prerequisites and Suk rescue remain distinct. Expansions, Homeworlds, Nexus, Discovery, Tech, Strongholds, other previews, public starts and played-game conversion remain excluded.',
'\n--profile homeworld-occupation --leader-skills preserves fresh classic Basic/Advanced original Homeworld occupation and all14 Skills without Nexus, Discovery, Tech, Strongholds or expansion/Betrayal overlays. Only actual final Bank portions qualify for normal Banker income, including the same payer’s Bureaucrat diversion. Allied income rounds once for the whole eligible invoice; private rounding/diversion attribution preserves full payer debits and occupied player shares. Ordinary --profile leader-skills --homeworlds retains the original supported World selection without adding occupation preview flags.',
'\nLeader-skills also preserves canonical unused Stronghold Cards in fresh Advanced classic or supported native two-through-six-seat lobbies. Optional Tech Tokens requires three-through-six seats. All14 original skills, decks/cache and actual end-Mentat Stronghold assignment remain. Basic Strongholds, mixed E3 families outside documented Nexus envelopes, other overlays, public activation and save conversion stay excluded.',
'\nThese skill/module entries additionally preserve standalone Ecaz OR Moritani ecaz33/all14 profiles. Tech is Basic/Advanced three-through-six seats; Strongholds is Advanced two-through-six. Native Ecaz five-disc/Duke assignment and Advanced Harkonnen exclusion, plus original Advanced Moritani skill-first assassination/normal-call forfeiture, remain. E3 pairs, mixed families, allied Occupy skills and other overlays are not opened.',
@@ -69,8 +70,8 @@ async function main() {
);
return;
}
- if (values['ecaz-treachery'] && values.profile !== 'ecaz-occupy')
- throw new Error('--ecaz-treachery requires --profile ecaz-occupy.');
+ if (values['ecaz-treachery'] && values.profile !== 'ecaz-occupy' && values.profile !== 'leader-skills')
+ throw new Error('--ecaz-treachery requires --profile ecaz-occupy or leader-skills.');
if (values['nexus-cards'] && values.profile !== 'leader-skills' && values.profile !== 'semuta')
throw new Error('--nexus-cards requires --profile leader-skills or semuta.');
if (values.discoveries && values.profile !== 'leader-skills' && values.profile !== 'nexus' && values.profile !== 'semuta')