Connect Harass & Withdraw to the battle preview

The independent Ecaz Treachery variant now supports Harass in either physical battle-card slot. A category-null Prescience answer keeps its identity private without erasing the selected card. Exact undialed normal/elite counters return before battle losses; opposing successful Traitors cancel withdrawal, ordinary leader deaths still resolve, and the card is discarded exactly once. Controls, all four AI profiles and saved continuation share the same physical quote. Existing Planetologist inspection and literal Truthtrance slot promises retain their meanings.

Unique Basic/Advanced physical allocations are supported. Ambiguous allocations, co-present Ecaz allied armies on either side, Richese card-family configurations and other optional modules remain gated before sealing from public/own context. The pending Stone Burner timing question is not assumed answered. Reveal-before-explosion order and mandatory disposal on an own-Traitor win are explicit source-timing/specificity inferences, consistent with existing Planetologist precedence. Reinforcements and full variant/public mode acceptance remain unfinished.

Independent rules/privacy/persistence review fixed asymmetric selector legality, misleading blocked-card guidance, invalid restored battle pairs, native-only inspection bypass and unsupported allied-army admission. Ixian fixtures preserve all seven physical cyborgs. The first broad run caught a semantic-status lint error in the blocked guidance; replacing the paragraph role with an output element fixed it. A subsequent broad run passed 4,775 of 4,776 tests and exposed an old UI assertion requiring category-null slots to be disabled. The updated regression verifies enabled category-null selectors exclude ordinary held cards, while exact leader/dial locks and submitted values remain bound; focused controls pass 14/14. No game source changed after the eight samples or browser replay. Types, lint, 4776 offline tests, build and 43 HTTP tests pass with unchanged source. Authenticated SQLite cases verify sealed private restoration and competing duplicate traitor actions with one physical resolution.

Eight fixed genuine six-seat games finish: basic/Easy: 1803 actions, 0 Harass plans, 0 withdrawals, 48 periodic restores; basic/Medium: 225 actions, 0 Harass plans, 0 withdrawals, 6 periodic restores; basic/Hard: 2184 actions, 1 Harass plans, 1 withdrawals, 59 periodic restores; basic/Brutal: 2073 actions, 1 Harass plans, 1 withdrawals, 56 periodic restores; advanced/Easy: 2057 actions, 1 Harass plans, 1 withdrawals, 55 periodic restores; advanced/Medium: 532 actions, 0 Harass plans, 0 withdrawals, 14 periodic restores; advanced/Hard: 538 actions, 0 Harass plans, 0 withdrawals, 14 periodic restores; advanced/Brutal: 544 actions, 0 Harass plans, 0 withdrawals, 14 periodic restores. All 218 game-source files match their source-stable runs; exact card/traitor/force custody and periodic/terminal private views are checked. Browser controls verify the full card, category None, Defense-first legality, actual sealing/reveal, four returned forces, ordinary leader death, disposal and refresh. A separate final-source replay verifies the conserved browser fixture and opposing continuation, including Face Dancer replacement. This is bounded playability evidence, not full compliance or AI calibration.

All 936 opening games and their original seat/recovery records remain unchanged (970 after checks); all 937 present immediately before checks are also preserved. Database integrity and foreign keys pass. The healthy server was reused after the power failure without restart, reset or recurring automation.

Verified source tree: be193613c455c5b76b49800fe3a7400504ea879af34c59621ade3d44f0fe019d
This commit is contained in:
Quintine committed 2026-09-14 04:41:30 +10:00
1 parent c04cb93830
commit 04501567d0
26 files changed
+2164 -127

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
import type { Card } from './cards';
import { isDefenseCard, isWeaponCard } from './battle-cards';
import { ecazTreacheryDefinition } from './ecaz-cards';
import {
isPlanetologistBattleSpecialCard,
validLeaderSkillBattleCardPair,
} from './leader-skill-combat';
export type BattleCardSlot = 'weapon' | 'defense';
export type BattlePlanInspectionField = BattleCardSlot | 'leader' | 'dial';
export type BattleCategoryInspectionValue = string | number | null;
function canonicalEcazBattleSpecial(card: Card | undefined) {
if (
!card ||
Object.keys(card).sort().join(',') !== 'effect,id,kind,name'
)
return false;
const definition = ecazTreacheryDefinition(card);
return (
definition?.card.effect === 'reinforcements' ||
definition?.card.effect === 'harassWithdraw'
);
}
/**
* Single-slot role eligibility only. The caller still applies paired-card rules
* such as Chemistry/Weirding Way dependencies and validates the selected
* Planetologist leader before setting planetologistWeapon.
*/
export function battleCardSlotEligible(
slot: BattleCardSlot,
card: Card | undefined,
options: Readonly<{ planetologistWeapon?: boolean }> = {},
): boolean {
if (!card) return true;
if (canonicalEcazBattleSpecial(card)) return true;
if (slot === 'defense') return isDefenseCard(card);
return (
isWeaponCard(card) ||
(!!options.planetologistWeapon && isPlanetologistBattleSpecialCard(card))
);
}
/** Slot-only specials do not supply the opposite category for alternate roles. */
export function validBattleSlotPair(
weapon?: Card,
defense?: Card,
planetologistWeapon = false,
): boolean {
if (!canonicalEcazBattleSpecial(weapon) && !canonicalEcazBattleSpecial(defense))
return validLeaderSkillBattleCardPair(weapon, defense, planetologistWeapon);
return (
battleCardSlotEligible('weapon', weapon, { planetologistWeapon }) &&
battleCardSlotEligible('defense', defense) &&
(!weapon || weapon.id !== defense?.id) &&
(weapon?.kind !== 'chemistry' || (!!defense && isDefenseCard(defense))) &&
(defense?.kind !== 'weirdingWay' || (!!weapon && isWeaponCard(weapon)))
);
}
/**
* Prescience asks for the selected category rather than physical slot
* occupancy. Exact Ecaz battle specials answer null without revealing their
* identity. Existing Planetologist inspection behavior is a separate contract.
*/
export function battleCategoryInspectionValue(
field: BattlePlanInspectionField,
value: BattleCategoryInspectionValue | undefined,
card?: Card,
): BattleCategoryInspectionValue | undefined {
if (value === undefined || field === 'leader' || field === 'dial' || value === null)
return value;
if (card && card.id !== value)
throw new Error('The inspected card does not match the selected battle slot.');
if (canonicalEcazBattleSpecial(card))
return null;
return value;
}
/** Partial plan search leaves an unspecified candidate unconstrained. */
export function fixedBattleInspectionMatches(
field: BattlePlanInspectionField,
fixed: BattleCategoryInspectionValue,
candidate: BattleCategoryInspectionValue | undefined,
card?: Card,
): boolean {
return (
candidate === undefined ||
battleCategoryInspectionValue(field, candidate, card) === fixed
);
}
+2 -2
View File
@@ -66,9 +66,9 @@ export const BATTLE_CARD_HELP: Partial<Record<Card['kind'], string>> = {
shieldSnooper:
'Both projectile and poison defense. This is a shield and triggers a lasgun explosion.',
weirdingWay:
'A projectile weapon by default. With another card in the weapon slot, play it as a projectile defense. It is not a shield and does not trigger a lasgun explosion.',
'A projectile weapon by default. With another weapon or Worthless card in the weapon slot, play it as a projectile defense. It is not a shield and does not trigger a lasgun explosion.',
chemistry:
'A poison defense by default. With another card in the defense slot, play it as a poison weapon. It has only the role selected for this battle.',
'A poison defense by default. With another defense or Worthless card in the defense slot, play it as a poison weapon. It has only the role selected for this battle.',
};
/** Roles refer to the selected slot; alternate roles need the other slot occupied. */
+33 -3
View File
@@ -6,6 +6,7 @@ import {
} from './cards';
import type { FactionId } from './catalog';
import { battleWeaponsExplode, isStoneBurner } from './battle-cards';
import { validBattleSlotPair } from './battle-card-slots';
import {
casualtyOptions,
validCombatForces,
@@ -27,6 +28,7 @@ import {
type StoneBurnerComparison,
} from './stone-burner';
import { matchingTraitor } from './traitors';
import { HARASS_WITHDRAW_CARD, isHarassWithdraw, quoteHarassWithdraw, type HarassWithdrawContext, type HarassWithdrawQuote } from './harass-withdraw';
import { auditCount } from './choam-auditor';
import { sukGraduateSkill, type SukGraduateSkill } from './suk-graduate';
import { validateSpiceBankerSpend } from './spice-banker';
@@ -72,6 +74,7 @@ export type ResolutionParticipant = {
noFieldAtTerritory?: boolean;
};
export type ResolutionCombatant = ResolutionParticipant & {
harassWithdraw?: HarassWithdrawContext;
diplomatDefense?: Pick<DiplomatDefenseQuote, 'leader' | 'source' | 'kind'> & { card: string };
spice: number;
hand: readonly Card[];
@@ -124,6 +127,7 @@ export type BattleSupportPayment = {
freeByTraitor: boolean;
};
export type BattleResolutionQuote = {
harassWithdraw?: (HarassWithdrawQuote & { player: string; card: typeof HARASS_WITHDRAW_CARD })[];
result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion';
winner: string | null;
attackerTraitor: boolean;
@@ -361,6 +365,23 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
return copiedDiplomatDefense(quote, copy.card);
};
const effectiveAd = effectiveDefense(a, d, ad), effectiveDd = effectiveDefense(d, a, dd);
const withdrawals = new Map<string, HarassWithdrawQuote>();
for (const side of [a, d]) {
const slots = [card(side, side.plan.weapon), card(side, side.plan.defense)];
requireQuote(!slots.some(c => c?.id === 'ecaz-reinforcements'),
'Reinforcements battle effects are still being implemented.');
const harass = slots.some(isHarassWithdraw);
requireQuote(!side.harassWithdraw || harass, 'The withdrawal context requires the physical Harass & Withdraw card.');
if (harass) {
requireQuote(validBattleSlotPair(slots[0], slots[1]),
'The Harass & Withdraw plan has an invalid weapon and defense pair.');
requireQuote(side.harassWithdraw && !input.homeworld && !isStoneBurner(aw) && !isStoneBurner(dw),
'Harass & Withdraw needs its supported physical context; Homeworld and Stone Burner combinations remain unfinished.');
requireQuote(JSON.stringify(side.harassWithdraw.forces) === JSON.stringify(side.forces),
'The withdrawal context must match the current battle force roles.');
withdrawals.set(side.id, quoteHarassWithdraw(side.harassWithdraw, side.plan.dial, side.plan.support));
}
}
const tie =
d.stronghold === 'habbanya_ridge_sietch' ? 'defender' : 'attacker';
const stone =
@@ -430,6 +451,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
(v) => v.called && !a.plan.kwisatz && v.beneficiary === d.id,
);
const payments: BattleSupportPayment[] = [];
const harassWithdraw = [a, d].flatMap(side => {
const withdrawal = withdrawals.get(side.id);
// An opposing successful traitor cancels this effect, including mutual calls.
return withdrawal && !(side === a ? dc : ac)
? [{ ...withdrawal, player: side.id, card: HARASS_WITHDRAW_CARD }] : [];
});
const spiceBankerPayments = [a, d].filter(side => (side.plan.bankerSpice ?? 0) > 0).map(side => {
const waivedByTraitor = side === a ? ac && !dc : dc && !ac;
return { player: side.id, amount: waivedByTraitor ? 0 : side.plan.bankerSpice!, waivedByTraitor };
@@ -636,7 +663,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
winner.faction === 'ixians'
) {
const options = casualtyOptions(
winner.forces,
harassWithdraw.find(entry => entry.player === winner.id)?.remaining ?? winner.forces,
winner.plan.dial,
winner.plan.support,
);
@@ -645,7 +672,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
'No valid casualty choice remains for this battle.',
);
casualties = {
forces: { ...winner.forces },
forces: { ...(harassWithdraw.find(entry => entry.player === winner.id)?.remaining ?? winner.forces) },
dial: winner.plan.dial,
support: winner.plan.support,
options,
@@ -714,8 +741,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
selectedLeader: side.plan.leader,
card: card(side, selected),
});
// The explicit after-use instruction also applies to a successful own traitor
// call, using the same specificity inference as Planetologist's discard.
const mandatorySkillDiscard = (side: ResolutionCombatant, selected: string) =>
mandatoryPlanetologistDiscard(side, selected) || side.diplomatDefense?.card === selected;
mandatoryPlanetologistDiscard(side, selected) || side.diplomatDefense?.card === selected || isHarassWithdraw(card(side, selected));
const moritani = input.participants.find((p) => p.faction === 'moritani');
if (winner && moritani) {
const loser = winner === a ? d : a,
@@ -780,6 +809,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
)
: [];
return {
...(harassWithdraw.length ? { harassWithdraw } : {}),
result,
winner: winner?.id ?? null,
attackerTraitor: ac,
+33 -13
View File
@@ -1,7 +1,9 @@
import { discoveryBotActions } from './discovery-options';
import { recruitsPlayAction } from './recruits';
import type { Card } from './cards';
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, validLeaderSkillBattleCardPair } from './leader-skill-combat';
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus } from './leader-skill-combat';
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots';
import { quoteHarassWithdraw } from './harass-withdraw';
import { leaderSkillStrongholdCount } from './leader-skill-battle-board';
import { discoveryFlightBotActions } from './discovery-flight-options';
import { discoveryEntryBotActions } from './discovery-entry-options';
@@ -680,22 +682,26 @@ function plans(g: GameView): Action[] {
const commitments = b.ownCommitments.filter(
(element) => element.target === me.id,
);
const fixed = (field: string, value: string | number | null) =>
const fixed = (field: BattlePlanInspectionField, value: string | number | null) =>
commitments.every(
(element) => element.field !== field || element.value === value,
(element) => element.field !== field || fixedBattleInspectionMatches(field, element.value, value,
me.hand?.find(card => card.id === value)),
);
const battleSkills = g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [];
const bankerPrototypeAvailable = spiceBankerModeSupported(g);
const specialForLeader = (card: Card | undefined, leader: string | null) =>
canUsePlanetologistBattleSpecial({assignments:battleSkills, selectedLeader:leader, card});
const prototypeCardAllowed = (card: Card) => card.id !== 'ecaz-reinforcements' &&
(card.id !== 'ecaz-harass-withdraw' || (!!b.harassWithdraw && !b.harassWithdraw.blocked));
const weapons = [
null,
...(me.hand ?? []).filter(c => isWeaponCard(c) ||
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) && 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),
].filter((id) => fixed('weapon', id));
const defenses = [
null,
...(me.hand ?? []).filter(isDefenseCard).map((c) => c.id),
...(me.hand ?? []).filter(c => prototypeCardAllowed(c) && battleCardSlotEligible('defense', c)).map((c) => c.id),
].filter((id) => fixed('defense', id));
const availableLeaders = me.leaders.filter(
(l) =>
@@ -771,10 +777,10 @@ function plans(g: GameView): Action[] {
) + (kwisatz ? 2 : 0);
const w = me.hand?.find((c) => c.id === weapon);
const d = me.hand?.find((c) => c.id === defense);
if (w && !isWeaponCard(w) && !specialForLeader(w,leader)) continue;
if (!battleCardSlotEligible('weapon', w, {planetologistWeapon: specialForLeader(w, leader)})) continue;
const stoneBattle =
isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon));
if ((!leader && (weapon || defense)) || !validLeaderSkillBattleCardPair(w, d, specialForLeader(w,leader)))
if ((!leader && (weapon || defense)) || !validBattleSlotPair(w, d, specialForLeader(w,leader)))
continue;
if (voice) {
const used =
@@ -924,8 +930,17 @@ function plans(g: GameView): Action[] {
))
)
continue;
const effectiveWeapon = w && w.kind !== 'worthless' && !specialForLeader(w,leader) ? 6 : 0;
const effectiveDefense = d && d.kind !== 'worthless' ? 4 : 0;
let withdrawn = 0;
const harass = weapon === 'ecaz-harass-withdraw' || defense === 'ecaz-harass-withdraw';
if (harass) {
if (!b.harassWithdraw) continue;
try {
const quote = quoteHarassWithdraw(b.harassWithdraw, Number(action.dial), Number(action.support ?? 0));
withdrawn = quote.returned.normal + quote.returned.elite;
} catch { continue; }
}
const effectiveWeapon = w && isWeaponCard(w) && w.kind !== 'worthless' ? 6 : 0;
const effectiveDefense = d && isDefenseCard(d) && d.kind !== 'worthless' ? 4 : 0;
const selfExplosion = battleWeaponsExplode(w, d);
const correctDefense = inspected
? weaponKills(enemyWeapon) && !weaponKills(enemyWeapon, d)
@@ -993,7 +1008,12 @@ function plans(g: GameView): Action[] {
options.push({
action,
score:
score - (commitment?.cost ?? 0) * 0.2 - bankerSpice * 0.2,
score - (commitment?.cost ?? 0) * 0.2 - bankerSpice * 0.2 +
(harass && level > 0 && !exposedLeader
? (ownSurvivingStrength + Number(action.dial) + bankerSpice + ownNativeBonus <
enemySurvivingStrength - opponentPenalty + expectedDial + enemyNativeBonus
? withdrawn * (1 + level * 0.5) : -10)
: 0),
});
}
}
@@ -1009,7 +1029,7 @@ function plans(g: GameView): Action[] {
),
);
if (b.compliantPlan && commitments.every(
(element) => b.compliantPlan![element.field] === element.value,
(element) => fixed(element.field, b.compliantPlan![element.field]),
)) ranked.push({ type: 'battlePlan', ...b.compliantPlan });
return ranked;
}
@@ -3476,7 +3496,7 @@ function policyActions(g: GameView): Action[] {
return plans(g).map((p) => ({
type: nexus ? 'nexusPrescienceAnswer' : 'prescienceAnswer',
...(nexus ? { event: nexus.event } : {}),
value: p[field],
value: battleCategoryInspectionValue(field, candidatePlan(p)[field], me.hand?.find(card => card.id === p[field])),
}));
}
if (b.revealed) {
+4 -2
View File
@@ -111,8 +111,10 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
gameplay: ecaz.gameplay,
availability: ecaz.card.effect === 'recruits'
? 'Recruits has development controls during Revival. Earlier paid revivals, pending transactions, later free-rate changes and repeated use this turn remain guarded pending their rulings.'
: 'This battle effect is still being implemented.',
topics: RULE_TOPICS.filter((topic) => topic.id === 'card-recruits'),
: ecaz.card.effect === 'harassWithdraw'
? 'Harass & Withdraw has development controls in either battle-card slot. Ambiguous physical allocations, Richese card combinations and additional optional modules remain guarded.'
: 'This battle effect is still being implemented.',
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : 'card-recruits')),
};
const topicIds =
card.kind === 'special'
+4 -4
View File
@@ -152,12 +152,12 @@ export const ECAZ_TREACHERY_DEFINITIONS: readonly EcazTreacheryDefinition[] =
verification: {
inventory: 'verified',
sourceRules: 'verified',
runtime: 'not-implemented',
runtime: 'prototype',
combinedInteractions: 'incomplete',
unresolved: [
'Withdrawal ordering with Lasgun/Shield explosions and other instant outcomes.',
'Physical dialed/undialed accounting for advanced half-strength and double-strength units, Ecaz support, zero commitments and multiple sectors.',
'Whether a traitor-canceled special counts as used for mandatory disposal or winner retention.',
'Stone Burner comparison before or after withdrawal; its public card-family configuration remains guarded.',
'Ambiguous physical dialed/undialed allocations among regular/elite forces or multiple sectors, and Ecaz allied support.',
'Additional optional modules, Homeworld battles and complete integrated acceptance.',
],
},
}),
+139 -43
View File
@@ -4,8 +4,6 @@ import { bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratU
import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question';
import {
canUsePlanetologistBattleSpecial,
isPlanetologistBattleSpecialCard,
validLeaderSkillBattleCardPair,
type BattleLeaderSkill,
} from './leader-skill-combat';
import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
@@ -18,6 +16,8 @@ import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMov
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense';
import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup';
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair } from './battle-card-slots';
import { HARASS_WITHDRAW_CARD, HarassWithdrawError, isHarassWithdraw, quoteHarassWithdraw, type HarassWithdrawContext, type HarassWithdrawPreview } from './harass-withdraw';
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
@@ -477,9 +477,7 @@ import {
} from './revival';
import { traitorDeck, matchingTraitor, CHEAP_HERO_TRAITOR } from './traitors';
import {
isWeaponCard,
isStoneBurner,
isDefenseCard,
isPortableSnooper,
VOICE_KINDS,
defaultVoiceMatch,
@@ -2353,6 +2351,14 @@ function battleInspectionContext(g: Game): BattleInspectionContext {
function nexusInspectionIntegrity(g: Game) {
const b = g.battle;
if (!b) return;
// Native Prescience is binding even when this battle used no Nexus card.
// Check all sealed plans so removing a slot-only special cannot evade it.
for (const target of [b.attacker, b.defender]) {
const plan = b.plans[target];
if (plan) requireRule(committedPlanElements(b, target).every(element => plan[element.field] !== undefined &&
inspectedPlanMatches(getPlayer(g, target), element.field, element.value, plan[element.field])),
'The sealed plan contradicts its inspected commitment.');
}
const record = b.nexusInspection;
requireRule(!!record === !!b.nexusInspectionUsed, 'The Nexus inspection presence record is missing.');
if (!record) return;
@@ -2370,11 +2376,6 @@ function nexusInspectionIntegrity(g: Game) {
requireRule((record.stage === 'response') === (responses.length > 0),
'The extra inspection has lost or reopened its original cancellation response.');
for (const response of responses) validateNexusInspectionResponse(g, response!);
for (const target of [b.attacker, b.defender]) {
const plan = b.plans[target];
if (plan) requireRule(committedPlanElements(b, target).every(element => plan[element.field] === element.value),
'The sealed plan contradicts its inspected commitment.');
}
if (record.stage === 'answer' || record.stage === 'response')
requireRule(!b.revealed && (b.preparation?.kind === 'nexusPrescienceAnswer' ||
(b.preparation?.kind === 'prescienceAnswer' && b.prescience && !Object.hasOwn(b.prescience, 'value'))),
@@ -2443,7 +2444,8 @@ function settleNexusInspectionResponse(g: Game, response: ResponseWindow, cancel
finishInspectionAnswers(g);
} else {
b.nexusInspection = nexusRule(() => allowNexusInspection(battleInspectionContext(g), record));
if (b.plans[record.target]) answerCurrentNexusInspection(g, b.plans[record.target][record.field]);
if (b.plans[record.target]) answerCurrentNexusInspection(g,
inspectedPlanValue(getPlayer(g, record.target), record.field, b.plans[record.target][record.field]));
}
}
function playNexusAtreides(g: Game, p: Player, action: Action) {
@@ -2469,16 +2471,20 @@ function playNexusAtreides(g: Game, p: Player, action: Action) {
} else {
b.preparation = { kind: 'nexusPrescienceAnswer', owner: target, beneficiary: p.id };
if (offer.mode === 'cunning') g.response = { kind: 'nexusPrescience', owner: p.id, intent: b.event, passed: [] };
else if (b.plans[target]) answerCurrentNexusInspection(g, b.plans[target][field]);
else if (b.plans[target]) answerCurrentNexusInspection(g,
inspectedPlanValue(getPlayer(g, target), field, b.plans[target][field]));
}
}
function projectedNexusInsights(g: Game, id: string) {
const record = g.battle?.nexusInspection;
if (!record || ![record.owner, record.target].includes(id)) return [];
return record.answers.map((value, index) => ({ field: record.field, value,
return record.answers.map((answer, index) => {
const value = inspectedPlanValue(getPlayer(g, record.target), record.field, answer);
return { field: record.field, value,
label: g.players.flatMap(p => [...p.leaders, ...p.hand]).concat(g.dukeVidal ? [g.dukeVidal.leader] : [])
.find(item => item.id === value)?.name ?? String(value ?? 'None'),
active: record.stage === 'answered' && index === record.answers.length - 1 }));
active: record.stage === 'answered' && index === record.answers.length - 1 };
});
}
function homeworldRule<T>(quote: () => T): T {
try {
@@ -8088,6 +8094,22 @@ function ecazTreacheryIntegrity(g: Game) {
physical.filter((card) => card.id.startsWith('ecaz-')).length === 3,
'The Ecaz Treachery Cards inventory contains an unknown physical card.',
);
for (const [player, plan] of Object.entries(g.battle?.plans ?? {})) {
requireRule(![plan.weapon, plan.defense].includes('ecaz-reinforcements'),
'Reinforcements battle effects are still being implemented.');
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)) {
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)),
'The sealed Harass & Withdraw plan has an invalid weapon and defense pair.');
currentHarassWithdrawQuote(g, owner, plan);
}
}
if (g.recruits) {
requireRule(
Object.keys(g.recruits).sort().join(',') === 'card,player,turn' &&
@@ -13420,6 +13442,59 @@ function victory(g: Game, quote: VictoryQuote = currentVictoryQuote(g)) {
function cardOf(p: Player, id: string | null) {
return p.hand.find((c) => c.id === id);
}
function inspectedPlanValue(p: Player, field: PlanField, value: string | number | null | undefined) {
return battleCategoryInspectionValue(field, value,
(field === 'weapon' || field === 'defense') && typeof value === 'string' ? cardOf(p, value) : undefined);
}
function inspectedPlanMatches(p: Player, field: PlanField, fixed: unknown, candidate: string | number | null | undefined) {
return fixedBattleInspectionMatches(field, fixed as string | number | null, candidate,
(field === 'weapon' || field === 'defense') && typeof candidate === 'string' ? cardOf(p, candidate) : undefined);
}
function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
const b = g.battle!;
const other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
let blocked: string | null = null;
if (!g.ecazTreachery) blocked = 'Enable the independent Ecaz Treachery Cards variant before using Harass & Withdraw.';
else if (g.status !== 'playing' || g.phase !== 6 || ![b.attacker, b.defender].includes(p.id))
blocked = 'Use Harass & Withdraw in your own battle.';
else if (g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.discoveryEnabled)
blocked = 'Harass & Withdraw with other optional modules, including Homeworlds, is still being implemented.';
else if (g.players.some(player => player.faction === 'richese') || g.richeseCache !== undefined || g.richeseRemoved !== undefined)
blocked = 'Harass & Withdraw with the Richese card family awaits the Stone Burner timing ruling.';
else if ([p, other].some(side => {
const ally = g.players.find(player => player.id === side.ally && player.ally === side.id);
return ally && (side.faction === 'ecaz' || ally.faction === 'ecaz') &&
Object.entries(ally.forces).some(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory);
})) blocked = 'Harass & Withdraw with co-present Ecaz allies awaits combined-army battle integration.';
else if (p.noField?.deployed?.location.territory === b.territory)
blocked = 'Reveal your No-Field before using Harass & Withdraw.';
const locations = Object.fromEntries(Object.entries(p.forces)
.filter(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory)
.map(([key, amount]) => [key, { normal: amount - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }]));
return { blocked, forces: combatForces(g, p, b.territory, other), locations };
}
function currentHarassWithdrawQuote(g: Game, p: Player, plan: Pick<Plan, 'dial' | 'support'>) {
try {
const quote = quoteHarassWithdraw(harassWithdrawContext(g, p), plan.dial, plan.support);
validateHarassReturnCounters(p, quote.returned);
return quote;
}
catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; }
}
function validateHarassReturnCounters(p: Player, returned: { normal: number; elite: number }) {
requireRule(Number.isSafeInteger(p.reserves) && p.reserves >= 0 &&
Number.isSafeInteger(p.reserves + returned.normal + returned.elite) &&
(!p.elites || (Number.isSafeInteger(p.elites.reserves) && p.elites.reserves >= 0 &&
p.elites.reserves <= p.reserves && Number.isSafeInteger(p.elites.reserves + returned.elite))),
'Harass & Withdraw needs valid reserve and elite counters before any forces return.');
}
function harassWithdrawPreview(g: Game, p: Player): HarassWithdrawPreview | null {
const b = g.battle;
if (!b || ![b.attacker, b.defender].includes(p.id) || !p.hand.some(isHarassWithdraw)) return null;
const context = harassWithdrawContext(g, p);
return { ...context, card: HARASS_WITHDRAW_CARD,
blocked: context.blocked ?? (b.revealed || b.plans[p.id] ? 'Your Battle Plan is already sealed.' : null) };
}
function validateResidualPoison(
g: Game,
p: Player,
@@ -13834,12 +13909,12 @@ function validatePlan(
w,
);
requireRule(
!plan.weapon || (w && (isWeaponCard(w) || planetologistWeapon)),
'Choose a weapon, worthless card, or eligible Planetologist Special.',
!plan.weapon || (w && battleCardSlotEligible('weapon', w, { planetologistWeapon })),
'Choose a weapon, worthless card, eligible Planetologist Special, or Harass & Withdraw.',
);
requireRule(
!plan.defense || (d && isDefenseCard(d)),
'Choose a defense or worthless card.',
!plan.defense || (d && battleCardSlotEligible('defense', d)),
'Choose a defense, worthless card, or Harass & Withdraw.',
);
requireRule(
!plan.weapon || plan.weapon !== plan.defense,
@@ -13850,9 +13925,12 @@ function validatePlan(
'Without a leader, no battle cards can be played.',
);
requireRule(
validLeaderSkillBattleCardPair(w, d, planetologistWeapon),
validBattleSlotPair(w, d, planetologistWeapon),
'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.',
);
requireRule(![w, d].some(card => card?.id === 'ecaz-reinforcements'),
'Reinforcements battle effects are still being implemented.');
if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan);
if (isStoneBurner(w)) {
const blocked = stonePlanBlock(g, p, dial, support);
requireRule(
@@ -13877,7 +13955,7 @@ function validatePlan(
}
for (const element of committedPlanElements(b, p.id))
requireRule(
plan[element.field] === element.value,
inspectedPlanMatches(p, element.field, element.value, plan[element.field]),
'Every element revealed by a battle inspection must remain unchanged.',
);
requireRule(
@@ -13996,7 +14074,7 @@ function normalizeBattle(g: Game) {
if (b.prescience && !('value' in b.prescience)) {
const target = b.prescience.player === b.attacker ? b.defender : b.attacker;
const priorPlan = b.plans[target];
if (priorPlan) b.prescience.value = priorPlan[b.prescience.field];
if (priorPlan) b.prescience.value = inspectedPlanValue(getPlayer(g, target), b.prescience.field, priorPlan[b.prescience.field]) as string | number | null;
else
b.preparation = {
kind: 'prescienceAnswer',
@@ -14222,11 +14300,9 @@ function findLegalBattlePlan(
const commitments = committedPlanElements(b, p.id);
const accepts = (plan: Partial<Plan>) =>
commitments.every(element =>
plan[element.field] === undefined || plan[element.field] === element.value) &&
inspectedPlanMatches(p, element.field, element.value, plan[element.field])) &&
respectsBattlePromises(promises, p.id, plan, p.hand) &&
(!fixed ||
plan[fixed.field] === undefined ||
plan[fixed.field] === fixed.value);
(!fixed || inspectedPlanMatches(p, fixed.field, fixed.value, plan[fixed.field]));
if (b.plans[p.id]) {
const sealed = { ...b.plans[p.id], kwisatz: !!b.plans[p.id].kwisatz };
return accepts(sealed) ? sealed : null;
@@ -14265,13 +14341,13 @@ function findLegalBattlePlan(
...p.hand
.filter(
(card) =>
isWeaponCard(card) || isPlanetologistBattleSpecialCard(card),
battleCardSlotEligible('weapon', card, { planetologistWeapon: true }),
)
.map((card) => card.id),
];
const defenses = [
null,
...p.hand.filter((c) => isDefenseCard(c)).map((c) => c.id),
...p.hand.filter((c) => battleCardSlotEligible('defense', c)).map((c) => c.id),
];
for (const leader of leaderIds) {
if (!accepts({ leader })) continue;
@@ -14280,7 +14356,7 @@ function findLegalBattlePlan(
for (const defense of defenses) {
if (
!accepts({ leader, weapon, defense }) ||
!validLeaderSkillBattleCardPair(
!validBattleSlotPair(
cardOf(p, weapon),
cardOf(p, defense),
canUsePlanetologistWeapon(g, p, leader, cardOf(p, weapon)),
@@ -14841,17 +14917,17 @@ function feasiblePrescience(
...p.hand
.filter(
(card) =>
isWeaponCard(card) || isPlanetologistBattleSpecialCard(card),
battleCardSlotEligible('weapon', card, { planetologistWeapon: true }),
)
.map((card) => card.id),
];
const defenses: (string | null)[] = [
null,
...p.hand.filter((c) => isDefenseCard(c)).map((c) => c.id),
...p.hand.filter((c) => battleCardSlotEligible('defense', c)).map((c) => c.id),
];
for (const leader of field === 'leader' ? [value] : leaderIds)
for (const weapon of field === 'weapon' ? [value] : weapons)
for (const defense of field === 'defense' ? [value] : defenses) {
for (const weapon of field === 'weapon' ? weapons.filter(candidate => inspectedPlanMatches(p, field, value, candidate)) : weapons)
for (const defense of field === 'defense' ? defenses.filter(candidate => inspectedPlanMatches(p, field, value, candidate)) : defenses) {
const maxSupport =
g.advanced && field === 'dial'
? Math.min(battleSupportBudget(g, p), (combatArmy(g, p.id, b.territory).normal + combatArmy(g, p.id, b.territory).elite))
@@ -14865,7 +14941,7 @@ function feasiblePrescience(
support,
dial: field === 'dial' ? value : 0,
});
if (plan[field] === value) return true;
if (inspectedPlanMatches(p, field, value, plan[field])) return true;
} catch (error) {
if (!(error instanceof RuleError)) throw error;
}
@@ -15196,6 +15272,8 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
leaderSkills: battleLeaderSkills(g, p),
occupiedStrongholds: leaderSkillStrongholdCount(g, p),
forces: combatForces(g, p, b.territory, opponent),
...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isHarassWithdraw)
? { harassWithdraw: harassWithdrawContext(g, p) } : {}),
stronghold: strongholdEffect(g, p.id),
lateDefense: b.lateDefense?.[p.id],
...(b.diplomatDefense?.stage === 'copied' && b.diplomatDefense.player === p.id
@@ -15232,6 +15310,8 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
pendingAuditorPresent: !!g.pendingAuditor,
pendingRetentionPresent: !!g.moritaniRetention,
});
for (const withdrawal of quote.harassWithdraw ?? [])
validateHarassReturnCounters(getPlayer(g, withdrawal.player), withdrawal.returned);
if (quote.sukGraduate) requireRule(
!g.advanced || getPlayer(g, quote.winner!).faction !== 'atreides',
'Suk Graduate rescue for Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.');
@@ -15364,6 +15444,24 @@ function resolveBattle(g: Game) {
{ faction: player.faction, name: 'Spice Banker payment' });
}
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))
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);
for (const [key, group] of Object.entries(withdrawal.locations)) {
player.forces[key] -= group.normal + group.elite;
if (!player.forces[key]) delete player.forces[key];
if (group.elite && player.elites) {
player.elites.forces[key] -= group.elite;
if (!player.elites.forces[key]) delete player.elites.forces[key];
}
}
player.reserves += withdrawal.returned.normal + withdrawal.returned.elite;
if (player.elites) player.elites.reserves += withdrawal.returned.elite;
log(g, `${player.name} used Harass & Withdraw to return ${withdrawal.returned.normal + withdrawal.returned.elite} undialed physical forces (${withdrawal.returned.elite} elite) to reserves before battle losses. Their leader still resolves normally.`);
}
if (quote.harassWithdraw?.length) observeOccupation(g);
const explosion = quote.explosion;
if (ac && dc) {
dead(al);
@@ -24882,6 +24980,7 @@ export function viewGame(state: Game, id: string) {
field: b.nexusInspection.field, stage: b.nexusInspection.stage,
} : null,
nexusInsights: projectedNexusInsights(g, id),
harassWithdraw: harassWithdrawPreview(g, me),
ownCommitments: committedPlanElements(b, id),
prescience: b.prescience
? {
@@ -24894,18 +24993,15 @@ export function viewGame(state: Game, id: string) {
insight:
b.prescience &&
'value' in b.prescience &&
(b.prescience.player === id ||
[b.attacker, b.defender].includes(id))
? {
field: b.prescience.field,
value: b.prescience.value,
label:
g.players
.flatMap((p) => [...p.leaders, ...p.hand])
(b.prescience.player === id || [b.attacker, b.defender].includes(id))
? (() => {
const target = getPlayer(g, b.prescience!.player === b.attacker ? b.defender : b.attacker);
const value = inspectedPlanValue(target, b.prescience!.field, b.prescience!.value);
return { field: b.prescience!.field, value,
label: g.players.flatMap(p => [...p.leaders, ...p.hand])
.concat(g.dukeVidal ? [g.dukeVidal.leader] : [])
.find((item) => item.id === b.prescience!.value)?.name ??
String(b.prescience.value ?? 'None'),
}
.find(item => item.id === value)?.name ?? String(value ?? 'None') };
})()
: null,
}
: null,
+115
View File
@@ -0,0 +1,115 @@
import {
casualtyOptions,
validCombatForces,
type CombatForces,
} from './combat';
import type { Card } from './cards';
import { ecazTreacheryDefinition } from './ecaz-cards';
export const HARASS_WITHDRAW_CARD = 'ecaz-harass-withdraw' as const;
export type HarassWithdrawForces = Readonly<{ normal: number; elite: number }>;
export type HarassWithdrawContext = Readonly<{
blocked: string | null;
forces: CombatForces;
locations: Readonly<Record<string, HarassWithdrawForces>>;
}>;
export type HarassWithdrawPreview = HarassWithdrawContext &
Readonly<{
card: typeof HARASS_WITHDRAW_CARD;
}>;
export type HarassWithdrawQuote = {
returned: { normal: number; elite: number };
remaining: CombatForces;
locations: Record<string, { normal: number; elite: number }>;
};
export class HarassWithdrawError extends Error {
constructor(message: string) {
super(message);
this.name = 'HarassWithdrawError';
}
}
export function isHarassWithdraw(card: Card | undefined): boolean {
return (
!!card &&
card.id === HARASS_WITHDRAW_CARD &&
Object.keys(card).sort().join(',') === 'effect,id,kind,name' &&
ecazTreacheryDefinition(card)?.card.effect === 'harassWithdraw'
);
}
function requireHarass(condition: unknown, message: string): asserts condition {
if (!condition) throw new HarassWithdrawError(message);
}
/** Resolve only the user's physical counters. Neither opposing plans nor outcomes are inputs. */
export function quoteHarassWithdraw(
context: HarassWithdrawContext,
dial: number,
support: number,
): HarassWithdrawQuote {
requireHarass(
!context.blocked,
context.blocked ?? 'Harass & Withdraw is unavailable.',
);
const forces = context.forces;
requireHarass(
validCombatForces(forces) && !forces.temporaryElite,
'Harass & Withdraw needs ordinary physical force roles.',
);
const locations = Object.entries(context.locations);
requireHarass(
locations.every(
([key, group]) =>
key.length > 0 &&
group &&
Object.keys(group).sort().join(',') === 'elite,normal' &&
[group.normal, group.elite].every(
(n) => Number.isSafeInteger(n) && n >= 0,
),
) &&
locations.reduce((n, [, group]) => n + group.normal, 0) ===
forces.normal &&
locations.reduce((n, [, group]) => n + group.elite, 0) === forces.elite,
'Harass & Withdraw must match the user’s physical forces in this battle.',
);
const choices = casualtyOptions(forces, dial, support);
requireHarass(
choices.length > 0,
'Dial and spice must match a legal physical force commitment.',
);
const physical = [
...new Map(
choices.map((choice) => [`${choice.normal}:${choice.elite}`, choice]),
).values(),
];
requireHarass(
physical.length === 1,
'Harass & Withdraw with an ambiguous regular/elite commitment is still being implemented. Choose a uniquely determined physical commitment.',
);
const committed = physical[0];
const returned = {
normal: forces.normal - committed.normal,
elite: forces.elite - committed.elite,
};
const allocation: HarassWithdrawQuote['locations'] = {};
for (const type of ['normal', 'elite'] as const) {
const amount = returned[type],
occupied = locations.filter(([, group]) => group[type] > 0);
requireHarass(
amount === 0 || amount === forces[type] || occupied.length === 1,
'Harass & Withdraw with an ambiguous allocation among sectors is still being implemented. Choose a uniquely determined physical commitment.',
);
for (const [key, group] of occupied) {
const count =
amount === forces[type]
? group[type]
: occupied.length === 1
? amount
: 0;
if (count) (allocation[key] ??= { normal: 0, elite: 0 })[type] = count;
}
}
return {
returned,
remaining: { ...forces, normal: committed.normal, elite: committed.elite },
locations: allocation,
};
}
+28 -4
View File
@@ -3677,21 +3677,45 @@ export const RULE_TOPICS: RuleTopic[] = [
developmentStage: 'Prototyped',
summary: 'During Revival, Recruits doubles every faction’s current free rate and raises the ordinary force limit to seven for this turn.',
steps: [
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; the other two battle effects remain unfinished.',
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits and bounded Harass & Withdraw controls are connected. Reinforcements remains unfinished.',
'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.',
'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.',
'Free-revival prevention still prevents free returns. A Fremen ally grant completed before Recruits doubles from three to six. A later grant, play after a paid ordinary return, unresolved transactions and a second use in the same turn remain guarded pending their rules decisions.',
'The preview supports Basic or Advanced faction games without additional optional modules. Public activation, the other two card effects and complete combined-game acceptance remain unfinished.',
'The preview supports Basic or Advanced faction games without additional optional modules. Public activation, Reinforcements and complete combined-game acceptance remain unfinished.',
],
related: ['revival', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Genuine independent three-card setup, physical Recruits play/discard, public turn effect, shared revival quotes, preserved use and guarded timing are connected. Reinforcements and Harass & Withdraw remain missing.', evidence: ['game/ecaz-cards.ts', 'game/recruits.ts', 'game/revival.ts', 'game/engine.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Genuine independent three-card setup, physical Recruits play/discard, public turn effect, shared revival quotes, preserved use and guarded timing are connected. Harass has its own bounded battle prototype; Reinforcements remains missing.', evidence: ['game/ecaz-cards.ts', 'game/recruits.ts', 'game/revival.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'The owned physical card has full inspection, a Revival action and explicit unavailable reasons. All seats see the effective public rates after play.', evidence: ['components/recruits.tsx', 'components/game-table.tsx', 'game/card-presentation.ts'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use the same legal owned offer for useful own or allied revival, then existing authoritative revival choices. Complete card strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'The card guide records examples, current prototype limits and the independent-cap inference; late-paid and late-rate questions remain explicit.', evidence: ['docs/RECRUITS_RULES.md', 'docs/RECRUITS_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, controls, AI and authenticated recovery checks cover the bounded preview. Sample and browser evidence is recorded separately; full variant acceptance remains open.', evidence: ['tests/recruits.test.ts', 'tests/recruits-engine.test.ts', 'tests/recruits-controls.test.tsx', 'tests/recruits-recovery.test.ts', 'tests/prototype-room.test.ts'] },
],
},
{
id: 'card-harass-withdraw',
title: 'Harass & Withdraw',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Return your undialed forces to reserves when your Battle Plan is revealed; your leader still faces normal battle risks.',
steps: [
'Include the physical card in either battle-card slot. It is neither a weapon nor a defense and cannot supply either category for Chemistry or Weirding Way’s alternate role. A single card cannot occupy both slots.',
'When Atreides asks for that weapon or defense category, answer None without exposing Harass & Withdraw. The category is fixed, while the special card may occupy its physical slot. A literal Truthtrance promise that a slot is empty still means no physical card.',
'Return your undialed ordinary and elite forces to reserves. Your leader can still be killed. An opponent’s successful Traitor call cancels withdrawal, including mutual calls. A Face Dancer cannot replace the forces already returned.',
'Discard Harass after use, including a win by your own Traitor call. Reveal-before-explosion return and card-specific discard precedence are explicit implementation inferences; neither is a dedicated combined FAQ ruling.',
'The battle controls show the exact return. Basic and Advanced commitments need a unique physical allocation. Ambiguous regular/elite or sector choices, Richese card combinations and additional optional modules are still being integrated. The printed card forbids use on your own Homeworld.',
'With Ecaz and an ally together, only the card user’s undialed forces return. Combined army attribution remains unfinished. Public variant starts and complete rules acceptance remain gated.',
],
related: ['battle-cards', 'battle', 'card-recruits', 'ecaz-modules', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical slot commitment, category inspection, unique typed return before losses, opponent-Traitor cancellation, mandatory disposal and saved continuation are connected. Richese and optional-module combinations remain gated.', evidence: ['game/harass-withdraw.ts', 'game/battle-card-slots.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Either slot, compatible pair selection, category-null commitment, exact private return guide and card inspection are usable. Ambiguous allocation controls remain unfinished.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles have a legal shared-quote path and preserve inspection secrecy. Retreat strategy and combined-game strength remain uncalibrated.', evidence: ['game/bots.ts', 'tests/ecaz-battle-bots.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'The card guide distinguishes printed effects, explicit inference and unfinished physical allocation/Stone Burner combinations.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, controls, AI and authenticated recovery checks cover the prototype; final sample and browser evidence remains bounded, with full combination acceptance open.', evidence: ['tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
],
},
{
id: 'expansion-faction-games',
title: 'Expansion faction development games',
@@ -3703,7 +3727,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Development setup supports two through six distinct seats from the base factions and selected expansion sets. Expansion selection and optional modules are separate; public expansion starts remain disabled.',
'Ecaz places six starting forces in Imperial Basin, allocated among sectors 9, 10 and 11, and keeps fourteen in reserve. Finish Fremen placement first; Bene Gesserit then places its Advanced starting advisor. Moritani places its six forces in an unoccupied territory after all other setup is complete.',
'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.',
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has a Recruits development preview; Reinforcements and Harass & Withdraw remain unfinished.',
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has Recruits and bounded Harass & Withdraw development controls; Reinforcements remains unfinished.',
'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.',
'Fresh unsupported movement arrivals reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. Simultaneous Ambassador/Terror order remains unresolved.',
'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent to its shipper when allowance encounters that arrival guard, so the shipper can choose another destination. Older No-Field, Smuggler, Nexus and Ambassador shipments are excluded from this recovery. Simultaneous arrival order remains unfinished.',