Integrate Ecaz Duke revival and bounded Mirror Weapon battles

This commit is contained in:
Quintine committed 2026-09-28 18:02:04 +10:00
1 parent 68d782ca82
commit 3b118dd845
39 files changed
+1377 -533

No files matched your search

+10 -2
View File
@@ -3,6 +3,7 @@ import { richeseCardDefinition } from './richese-cards';
import {
resolveBattleWeapons,
type EffectiveWeapon,
type BattleWeaponResolution,
} from './effective-weapons';
/** Keep persisted physical card shapes stable while validating their printed role. */
@@ -17,8 +18,11 @@ export function isDefenseCard(card: Card): boolean {
export function isStoneBurner(card: Card | undefined): boolean {
return !!card && richeseCardDefinition(card)?.card.effect === 'stoneBurner';
}
export function isMirrorWeapon(card: Card | undefined): boolean {
return !!card && richeseCardDefinition(card)?.card.effect === 'mirrorWeapon';
}
export function isWeaponCard(card: Card): boolean {
return WEAPON_KINDS.includes(card.kind) || isStoneBurner(card);
return WEAPON_KINDS.includes(card.kind) || isStoneBurner(card) || isMirrorWeapon(card);
}
export const WEAPON_KINDS = [
@@ -42,6 +46,7 @@ export const DEFENSE_KINDS = [
];
export const VOICE_KINDS = [
'stoneBurner',
'mirrorWeapon',
'poison',
'projectile',
'lasgun',
@@ -84,6 +89,7 @@ export function validBattleCardPair(weapon?: Card, defense?: Card) {
export function weaponTypes(card?: Card): string[] {
if (!card) return [];
if (isStoneBurner(card)) return ['stoneBurner'];
if (isMirrorWeapon(card)) return ['mirrorWeapon'];
if (card.kind === 'poisonBlade') return ['projectile', 'poison'];
if (card.kind === 'weirdingWay') return ['projectile'];
if (card.kind === 'chemistry' || card.kind === 'poisonTooth')
@@ -164,6 +170,7 @@ export function battleCardLabel(kind: string) {
poisonTooth: 'Poison Tooth',
artillery: 'Artillery Strike',
stoneBurner: 'Stone Burner',
mirrorWeapon: 'Mirror Weapon',
poisonBlade: 'Poison Blade',
shieldSnooper: 'Shield Snooper',
weirdingWay: 'Weirding Way',
@@ -189,8 +196,9 @@ export function battleCardEffects(
toothA = true,
toothD = true,
extraSnoopers: { attacker?: boolean; defender?: boolean } = {},
resolved?: BattleWeaponResolution,
) {
const weapons = resolveBattleWeapons({
const weapons = resolved ?? resolveBattleWeapons({
attacker: { weapon: aw, defense: ad },
defender: { weapon: dw, defense: dd },
});
+24 -18
View File
@@ -5,8 +5,9 @@ import {
type Leader,
} from './cards';
import type { FactionId } from './catalog';
import { battleWeaponsExplode, isStoneBurner } from './battle-cards';
import { battleWeaponsExplode } from './battle-cards';
import { validBattleSlotPair } from './battle-card-slots';
import { resolveBattleWeapons } from './effective-weapons';
import {
casualtyOptions,
validCombatForces,
@@ -376,6 +377,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
return copiedDiplomatDefense(quote, copy.card);
};
const effectiveAd = effectiveDefense(a, d, ad), effectiveDd = effectiveDefense(d, a, dd);
const weapons = resolveBattleWeapons({
attacker: { weapon: aw, defense: effectiveAd },
defender: { weapon: dw, defense: effectiveDd },
});
requireQuote(!weapons.error, weapons.error ?? 'The revealed weapons are invalid.');
const hasStone = weapons.attacker.kind === 'stoneBurner' || weapons.defender.kind === 'stoneBurner';
const withdrawals = new Map<string, HarassWithdrawQuote>();
const reinforcements: NonNullable<BattleResolutionQuote['reinforcements']> = [];
for (const side of [a, d]) {
@@ -393,7 +400,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
'Reinforcements needs a unique physical card and valid weapon and defense slot pair.');
requireQuote(!input.homeworld && !(side.leaderSkills?.length || (side === a ? d : a).leaderSkills?.length) &&
!slots.some(isHarassWithdraw) &&
!isStoneBurner(aw) && !isStoneBurner(dw),
!hasStone,
'Reinforcements cannot combine with Homeworld, leader skills, own Harass & Withdraw or Stone Burner.');
const reserves = side.reinforcementsReserves!;
requireQuote(!!reserves && typeof reserves === 'object' && !Array.isArray(reserves),
@@ -406,7 +413,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
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),
requireQuote(side.harassWithdraw && !input.homeworld && !hasStone,
'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.');
@@ -416,29 +423,28 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
const tie = battleTieOwner(a.id, d.id, input.aggressor ?? a.id,
[a, d].filter(side => side.stronghold === 'habbanya_ridge_sietch').map(side => side.id)) === a.id
? 'attacker' : 'defender';
const stone =
isStoneBurner(aw) || isStoneBurner(dw)
? stoneBurnerComparison(
a.forces,
a.plan.dial,
a.plan.support,
d.forces,
d.plan.dial,
d.plan.support,
tie,
)
: null;
const stone = hasStone
? stoneBurnerComparison(
a.forces,
a.plan.dial,
a.plan.support,
d.forces,
d.plan.dial,
d.plan.support,
tie,
)
: null;
if (stone) {
requireQuote(
!!stone.winner,
'Stone Burner has an unresolved physical casualty allocation. No battle resources were spent.',
);
for (const [side, weapon] of [
[a, aw],
[d, dw],
[a, weapons.attacker],
[d, weapons.defender],
] as const)
requireQuote(
!isStoneBurner(weapon) ||
weapon.kind !== 'stoneBurner' ||
['kill', 'ignore'].includes(side.stoneMode ?? ''),
'Choose Stone Burner’s revealed mode before resolving battle.',
);
+6 -3
View File
@@ -4,6 +4,7 @@ import type { Card } from './cards';
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, usesSurvivingSkilledLeader } from './leader-skill-combat';
import { smugglerBattleModeSupported, smugglerBattlePlanBlock } from './smuggler-battle';
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots';
import { mirrorWeaponModeBlock } from './mirror-weapon-mode';
import { defaultHarassWithdrawAllocation, quoteHarassWithdraw } from './harass-withdraw';
import { leaderSkillStrongholdCount } from './leader-skill-battle-board';
import { discoveryFlightBotActions } from './discovery-flight-options';
@@ -61,6 +62,7 @@ import {
import {
isWeaponCard,
isStoneBurner,
isMirrorWeapon,
isDefenseCard,
defaultVoiceMatch,
playedVoiceMatch,
@@ -701,6 +703,7 @@ function plans(g: GameView): Action[] {
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 weapons = [
@@ -927,9 +930,8 @@ function plans(g: GameView): Action[] {
: 0,
);
if (bankerSpice) action.bankerSpice = bankerSpice;
const stonePlan = isStoneBurner(
me.hand?.find((c) => c.id === action.weapon),
);
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;
if (
stonePlan &&
@@ -2384,6 +2386,7 @@ function policyActions(g: GameView): Action[] {
return [
{
type: 'decision',
...(d.event ? { event: d.event } : {}),
activate:
level === 0
? variation(g, 'tooth') > 0.5
+6 -2
View File
@@ -25,6 +25,8 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
const definition = richeseCardDefinition(card);
if (definition?.card.effect === 'stoneBurner')
return 'Choose Stone Burner in your battle weapon slot, then choose its leader effect after both plans are revealed.';
if (definition?.card.effect === 'mirrorWeapon')
return 'Choose Mirror Weapon in your battle weapon slot. It copies the opposing revealed weapon attack, with your own first post-reveal choice if needed.';
if (definition?.card.effect === 'portableSnooper')
return 'Choose Portable Snooper as your battle defense, or use its late-defense panel after reveal before submitting your traitor decision.';
if (definition?.card.effect === 'residualPoison')
@@ -86,10 +88,12 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
gameplay: richese.gameplay,
availability:
richese.card.effect === 'juiceOfSapho'
? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first before bidding begins, Once Around last before your bid, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.'
? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.'
: richese.card.effect === 'mirrorWeapon'
? 'Mirror Weapon works in bounded CHOAM/Richese-deck classic-faction battles; copied Tooth and Stone choices resolve separately, and a winning physical Mirror may be kept even after copying Tooth or Artillery. This winner-retention treatment is a user-selected interpretation, not an official clarification. Combined modes and full Richese starts remain gated.'
: richese.card.effect === 'stoneBurner'
? 'Stone Burner has development battle controls. Combined allocation timing and Ix timing remain guarded; full expansion starts stay disabled.'
: 'Richese effect integration and verification are incomplete. Karama, Distrans, Nullentropy Box, Ornithopter, Residual Poison, Portable Snooper and Stone Burner have card handlers in development fixtures; full expansion starts remain disabled.',
: 'Richese effect integration and verification are incomplete. Karama, Distrans, Nullentropy Box, Ornithopter, Residual Poison, Portable Snooper, Stone Burner and Mirror Weapon have bounded card handlers; full expansion starts remain disabled.',
topics: RULE_TOPICS.filter(
(topic) =>
topic.id ===
+1 -1
View File
@@ -113,7 +113,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
implementation: Object.freeze({
custody: 'implemented',
capture: 'not-implemented',
revival: 'not-implemented',
revival: 'ecaz-only-set-aside',
ambassadorAcquisition: 'ecaz-self-implemented',
ambassadorAlliance: 'consent-implemented',
ambassadorAllyAcquisition: 'unresolved',
+1 -1
View File
@@ -128,7 +128,7 @@ export function resolveEcazDukeRevival(
whole(duke.acquiredTurn) &&
duke.acquiredTurn > 0 &&
duke.acquiredTurn <= g.turn &&
['ecaz', 'moritani', 'ally'].includes(duke.source!),
['ecaz', 'ecazNexus', 'moritani', 'ally'].includes(duke.source!),
);
return {
source: 'sharedDuke',
+84 -31
View File
@@ -1,5 +1,6 @@
import type { Card } from './cards';
import { richeseCardDefinition } from './richese-cards';
import { canRetainBattleCard } from './moritani-retention';
export type BattleWeaponSide = 'attacker' | 'defender';
export type EffectiveBattleWeapon = {
@@ -11,13 +12,17 @@ export type EffectiveBattleWeapon = {
/** Public name used by effective attack/defense integration. */
export type EffectiveWeapon = EffectiveBattleWeapon;
type SelectedBattleCards = { weapon?: Card; defense?: Card };
export type EffectiveWeaponChoice = {
side: BattleWeaponSide;
kind: 'poisonTooth' | 'stoneBurner';
/** The committed card making this decision, never the copied source card. */
physicalId: string;
copiedFrom: string | null;
};
export type BattleWeaponResolution = {
attacker: EffectiveBattleWeapon;
defender: EffectiveBattleWeapon;
choiceOrder: {
side: BattleWeaponSide;
kind: 'poisonTooth' | 'stoneBurner';
}[];
choiceOrder: EffectiveWeaponChoice[];
error: string | null;
};
const mirrorId = 'richese-mirror-weapon';
@@ -34,6 +39,17 @@ const empty = (card?: Card): EffectiveBattleWeapon => ({
choice: null,
});
function appendChoice(result: BattleWeaponResolution, side: BattleWeaponSide) {
const weapon = result[side];
if (weapon.choice && weapon.physicalId)
result.choiceOrder.push({
side,
kind: weapon.choice,
physicalId: weapon.physicalId,
copiedFrom: weapon.copiedFrom,
});
}
/** The caller validates whole plans. Here Chemistry's selected weapon role must
* have its original second defense; a copied effect does not require that card.
*/
@@ -88,6 +104,7 @@ function originalWeapon({
* This establishes a copy-first dependency, not general battle turn order.
* Without that dependency choiceOrder follows input attacker/defender order;
* callers retain the existing ordinary storm-order choice policy as required.
* Choice entries identify the physical owner of each independent decision.
* This helper decides neither activation answers nor physical-card retention.
*/
export function resolveBattleWeapons(input: {
@@ -100,43 +117,79 @@ export function resolveBattleWeapons(input: {
choiceOrder: [],
error: null,
};
const cards = [
input.attacker.weapon,
input.attacker.defense,
input.defender.weapon,
input.defender.defense,
].filter((card): card is Card => !!card);
if (cards.some((card) => claimsMirror(card) && !isMirror(card))) {
const aw = input.attacker.weapon;
const ad = input.attacker.defense;
const dw = input.defender.weapon;
const dd = input.defender.defense;
const attackerMirror = isMirror(aw);
const attackerDefenseMirror = isMirror(ad);
const defenderMirror = isMirror(dw);
const defenderDefenseMirror = isMirror(dd);
if (
(aw && claimsMirror(aw) && !attackerMirror) ||
(ad && claimsMirror(ad) && !attackerDefenseMirror) ||
(dw && claimsMirror(dw) && !defenderMirror) ||
(dd && claimsMirror(dd) && !defenderDefenseMirror)
) {
result.error = 'Mirror Weapon must match its canonical physical identity.';
return result;
}
if (cards.filter(isMirror).length > 1) {
if (
Number(attackerMirror) +
Number(attackerDefenseMirror) +
Number(defenderMirror) +
Number(defenderDefenseMirror) >
1
) {
result.error =
'Only one physical Mirror Weapon exists; duplicated custody is invalid.';
return result;
}
result.attacker = originalWeapon(input.attacker);
result.defender = originalWeapon(input.defender);
for (const side of ['attacker', 'defender'] as const) {
if (!isMirror(input[side].weapon)) continue;
const other = side === 'attacker' ? 'defender' : 'attacker';
const original = result[other];
if (original.kind !== null) {
result[side] = {
physicalId: input[side].weapon!.id,
copiedFrom: original.physicalId,
kind: original.kind,
choice: original.choice,
};
}
if (attackerMirror && result.defender.kind !== null) {
result.attacker = {
physicalId: aw!.id,
copiedFrom: result.defender.physicalId,
kind: result.defender.kind,
choice: result.defender.choice,
};
} else if (defenderMirror && result.attacker.kind !== null) {
result.defender = {
physicalId: dw!.id,
copiedFrom: result.attacker.physicalId,
kind: result.attacker.kind,
choice: result.attacker.choice,
};
}
const order: BattleWeaponSide[] =
result.defender.copiedFrom && result.defender.choice
? ['defender', 'attacker']
: ['attacker', 'defender'];
for (const side of order) {
const choice = result[side].choice;
if (choice) result.choiceOrder.push({ side, kind: choice });
if (result.defender.copiedFrom && result.defender.choice) {
appendChoice(result, 'defender');
appendChoice(result, 'attacker');
} else {
appendChoice(result, 'attacker');
appendChoice(result, 'defender');
}
return result;
}
/** Ordinary battle cleanup quote for one committed physical card. The copy's
* effective kind is deliberately not an input: mandatory Tooth/Artillery
* disposal applies to those original physical cards, not to Mirror. Moritani's
* separate losing-ally interception is outside ordinary winner/loser cleanup.
*/
export function quoteBattleWeaponRetention(input: {
card: Card;
outcome: 'winner' | 'loser';
traitorDecided: boolean;
toothUsed: boolean;
}): { physicalId: string; mayRetain: boolean } {
const { card, outcome, traitorDecided, toothUsed } = input;
if (claimsMirror(card) && !isMirror(card))
throw new Error('Mirror Weapon must match its canonical physical identity.');
return {
physicalId: card.id,
mayRetain:
outcome === 'winner' &&
canRetainBattleCard(card, traitorDecided, toothUsed),
};
}
+193 -118
View File
@@ -438,7 +438,7 @@ import {
type MoritaniRetention,
} from './moritani-retention';
import { ordinaryCardAvailability } from './card-availability';
import { ecazDukeRevivalBlock } from './ecaz-duke-revival';
import { ecazDukeRevivalBlock, quoteEcazDukeRevival } from './ecaz-duke-revival';
import {
reserveShipmentCost,
guildShipmentCost,
@@ -510,11 +510,13 @@ import { traitorDeck, matchingTraitor, CHEAP_HERO_TRAITOR } from './traitors';
import { chooseEcazLoyalty, withoutEcazLoyalty, validateEcazLoyalty, type EcazLoyalty } from './ecaz-loyalty';
import {
isStoneBurner,
isMirrorWeapon,
isPortableSnooper,
VOICE_KINDS,
defaultVoiceMatch,
playedVoiceMatch,
} from './battle-cards';
import { mirrorWeaponModeBlock } from './mirror-weapon-mode';
import {
TECH_TOKENS,
createTechTokens,
@@ -811,8 +813,8 @@ export type Decision =
territory: string;
}
| { kind: 'techToken'; player: string; loser: string; choices: TechId[] }
| { kind: 'poisonTooth'; player: string }
| { kind: 'stoneBurner'; player: string; event: string }
| { kind: 'poisonTooth'; player: string; event?: string; physicalId?: string; copiedFrom?: string | null }
| { kind: 'stoneBurner'; player: string; event: string; physicalId?: string; copiedFrom?: string | null }
| { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[] }
| { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' }
| { kind: 'fullPlanOffer'; player: string }
@@ -4631,7 +4633,7 @@ function suspendedControlsIntegrity(
saphoMovementIntegrity(context);
ornithopterIntegrity(context);
lateDefenseIntegrity(context);
stoneBurnerIntegrity(context);
battleSpecialWeaponIntegrity(context);
strongholdIntegrity(context);
auditorIntegrity(context);
}
@@ -15176,6 +15178,10 @@ function validatePlan(
currentReinforcementsCost(g, p);
}
if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan);
if (isMirrorWeapon(w)) {
const blocked = mirrorWeaponModeBlock(g) ?? stonePlanBlock(g, p, dial, support);
requireRule(!blocked, blocked ?? 'Mirror Weapon cannot resolve this commitment.');
}
if (isStoneBurner(w)) {
const blocked = stonePlanBlock(g, p, dial, support);
requireRule(
@@ -15332,13 +15338,12 @@ function normalizeBattle(g: Game) {
/** Ghola follows current control for ordinary leaders, but Duke's printed
* revival right belongs exclusively to Ecaz, regardless of temporary custody. */
function gholaLeaders(g: Game, p: Player) {
return controlledLeaders(g, p).filter(
(l) =>
l.dead &&
!l.capturedBy &&
(l.id !== DUKE_VIDAL_ID ||
(p.faction === 'ecaz' && !ecazDukeRevivalBlock(g, p.id))),
const native = controlledLeaders(g, p).filter(
(l) => l.id !== DUKE_VIDAL_ID && l.dead && !l.capturedBy,
);
if (p.faction === 'ecaz' && !ecazDukeRevivalBlock(g, p.id))
native.push(g.dukeVidal!.leader);
return native;
}
function gholaOptions(g: Game, p: Player) {
const cards = p.hand
@@ -15512,16 +15517,16 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
requireRule(l?.dead && !l.capturedBy, 'Choose a dead leader in your pool.');
l.dead = false;
delete l.concealed;
// GF9 November 2020 FAQ p.9 permits a Ghola return in another battle
// this turn. Death history remains; the earlier battle location does not.
// Ghola permits a return in another battle this turn (GF9 FAQ p.9).
delete l.usedAt;
if (l.id === DUKE_VIDAL_ID) g.dukeVidal = consumeDuke(g.dukeVidal!);
if (g.pendingChoamMarketGhola && g.leaderSkills && !isAuditorLeader(l) &&
!l.gholaBy && p.leaders.some(leader => leader.id === l.id))
g.leaderSkills = skillRule(() => offerRevivedLeaderSkill(
g.leaderSkills!, p.id, l.id, choamGholaSkillOfferEvent(g.pendingChoamMarketGhola!, l.id)));
log(
g,
`${p.name} revived ${l.name} with Ghola. The leader may fight again this turn; no spice or normal leader-revival allowance was spent.`,
`${p.name} revived ${l.name} with Ghola.${l.id === DUKE_VIDAL_ID ? ' The living shared disc is set aside, unclaimed.' : ' The leader may fight again this turn;'} No spice or normal leader-revival allowance was spent.`,
);
} else {
const n = integer(
@@ -16329,49 +16334,100 @@ function portableSnooperView(g: Game, owner: Player) {
}
return { card, event: g.battle?.event ?? null, blocked };
}
function stoneBurnerIntegrity(g: Game) {
function revealedWeaponChoices(g: Game) {
const b = g.battle!;
requireRule(b.revealed && !!b.plans[b.attacker] && !!b.plans[b.defender],
'Revealed weapon choices need both exact battle plans.');
const selected = (id: string) => {
const p = getPlayer(g, id), plan = b.plans[id];
return { weapon: cardOf(p, plan.weapon), defense: cardOf(p, b.lateDefense?.[id] ?? plan.defense) };
};
const resolved = resolveBattleWeapons({ attacker: selected(b.attacker), defender: selected(b.defender) });
requireRule(!resolved.error, resolved.error ?? 'The revealed weapon choices are invalid.');
return resolved.choiceOrder.map((choice) => ({
...choice,
player: choice.side === 'attacker' ? b.attacker : b.defender,
}));
}
function orderedRevealedWeaponChoices(
g: Game,
choices: ReturnType<typeof revealedWeaponChoices>,
) {
if (choices.some((choice) => choice.copiedFrom !== null)) return choices;
return [
...choices.filter((choice) => choice.kind === 'stoneBurner'),
...g.order.flatMap((id) => choices.filter((choice) =>
choice.kind === 'poisonTooth' && choice.player === id)),
];
}
function revealedWeaponAnswered(
b: Battle,
choice: ReturnType<typeof revealedWeaponChoices>[number],
) {
return choice.kind === 'stoneBurner'
? b.stoneBurner?.[choice.player] !== undefined
: b.poisonTooth?.[choice.player] !== undefined;
}
function battleSpecialWeaponIntegrity(g: Game) {
const b = g.battle;
if (!b) return;
if (b.stoneBurner !== undefined) {
requireRule(
g.phase === 6 &&
b.revealed &&
!!b.event &&
!!b.stoneBurner &&
typeof b.stoneBurner === 'object' &&
!Array.isArray(b.stoneBurner),
'The saved Stone Burner choice context is invalid.',
);
for (const [id, mode] of Object.entries(b.stoneBurner))
requireRule(
[b.attacker, b.defender].includes(id) &&
['kill', 'ignore'].includes(mode) &&
isStoneBurner(cardOf(getPlayer(g, id), b.plans[id]?.weapon)),
'The saved Stone Burner choice has an invalid owner, mode, or missing canonical weapon.',
);
}
for (const id of [b.attacker, b.defender]) {
if (b.plans[id]?.weapon !== 'richese-stone-burner') continue;
const owner = getPlayer(g, id),
card = cardOf(owner, b.plans[id].weapon);
requireRule(
isStoneBurner(card),
'The committed canonical Stone Burner weapon is missing from its reserved hand.',
);
requireRule(
g.players.reduce(
(n, p) => n + p.hand.filter((c) => c.id === card!.id).length,
0,
) === 1 &&
![
...g.deck,
...g.discard,
...(g.richeseCache ?? []),
...(g.auction?.cards ?? []),
...(g.ixSetupCards ?? []),
].some((c) => c.id === card!.id),
'The committed Stone Burner physical card is duplicated.',
);
const cardId = b.plans[id]?.weapon;
if (cardId !== 'richese-stone-burner' && cardId !== 'richese-mirror-weapon') continue;
const owner = getPlayer(g, id), card = cardOf(owner, cardId);
const name = cardId === 'richese-stone-burner' ? 'Stone Burner' : 'Mirror Weapon';
if (cardId === 'richese-mirror-weapon') {
const blocked = mirrorWeaponModeBlock(g);
requireRule(!blocked, blocked ?? 'The committed Mirror Weapon mode is invalid.');
}
requireRule(cardId === 'richese-stone-burner' ? isStoneBurner(card) : isMirrorWeapon(card),
`The committed canonical ${name} weapon is missing from its reserved hand.`);
requireRule(g.players.reduce((n, p) => n + p.hand.filter((c) => c.id === cardId).length, 0) === 1 &&
![...g.deck, ...g.discard, ...(g.richeseCache ?? []),
...(g.auction?.cards ?? []), ...(g.ixSetupCards ?? [])].some((c) => c.id === cardId),
`The committed ${name} physical card is duplicated.`);
}
const choices = b.revealed ? revealedWeaponChoices(g) : [];
if (b.stoneBurner !== undefined) {
requireRule(g.phase === 6 && b.revealed && !!b.event && !!b.stoneBurner &&
typeof b.stoneBurner === 'object' && !Array.isArray(b.stoneBurner),
'The saved Stone Burner choice context is invalid.');
for (const [id, mode] of Object.entries(b.stoneBurner))
requireRule(['kill', 'ignore'].includes(mode) &&
choices.some((choice) => choice.player === id && choice.kind === 'stoneBurner' &&
choice.physicalId === b.plans[id]?.weapon),
'The saved Stone Burner choice has an invalid owner, mode, or physical weapon.');
}
if (b.poisonTooth !== undefined) {
requireRule(g.phase === 6 && b.revealed && !!b.poisonTooth &&
typeof b.poisonTooth === 'object' && !Array.isArray(b.poisonTooth),
'The saved Poison Tooth choice context is invalid.');
for (const [id, activated] of Object.entries(b.poisonTooth))
requireRule(typeof activated === 'boolean' &&
choices.some((choice) => choice.player === id && choice.kind === 'poisonTooth' &&
choice.physicalId === b.plans[id]?.weapon &&
(choice.copiedFrom === null || !!b.event)),
'The saved Poison Tooth choice has an invalid owner, answer, or physical weapon.');
}
const ordered = orderedRevealedWeaponChoices(g, choices);
let pendingSeen = false;
for (const choice of ordered) {
if (revealedWeaponAnswered(b, choice))
requireRule(!pendingSeen, 'A revealed weapon choice cannot precede its copied source.');
else pendingSeen = true;
}
if (g.decision?.kind === 'stoneBurner' || g.decision?.kind === 'poisonTooth') {
const decision = g.decision;
const choice = ordered.find((candidate) => !revealedWeaponAnswered(b, candidate));
requireRule(!!choice && choice.player === decision.player &&
choice.kind === decision.kind &&
choice.physicalId === b.plans[decision.player]?.weapon &&
(decision.event === undefined || decision.event === b.event) &&
(decision.physicalId === undefined || decision.physicalId === choice.physicalId) &&
(decision.copiedFrom === undefined || decision.copiedFrom === choice.copiedFrom) &&
(choice.copiedFrom === null || (decision.event === b.event &&
decision.physicalId === choice.physicalId && decision.copiedFrom === choice.copiedFrom)),
'The saved revealed weapon choice lost its physical card or battle event.');
}
}
function diplomatDefenseOffer(g: Game): (DiplomatDefenseQuote & { player: string }) | null {
@@ -16531,23 +16587,17 @@ function nextRevealedDecision(g: Game) {
const b = g.battle!;
if (offerHarassAllocation(g)) return;
if (offerDiplomatDefense(g)) return;
const stoneOwner = [b.attacker, b.defender].find(
(id) =>
isStoneBurner(cardOf(getPlayer(g, id), b.plans[id].weapon)) &&
!b.stoneBurner?.[id],
);
if (stoneOwner) {
requireRule(!!b.event, 'The Stone Burner battle event is missing.');
g.decision = { kind: 'stoneBurner', player: stoneOwner, event: b.event };
return;
const ordered = orderedRevealedWeaponChoices(g, revealedWeaponChoices(g));
const choice = ordered.find((candidate) => !revealedWeaponAnswered(b, candidate));
if (choice) {
if (choice.kind === 'stoneBurner' || choice.copiedFrom)
requireRule(!!b.event, 'The revealed weapon battle event is missing.');
g.decision = choice.kind === 'stoneBurner'
? { kind: 'stoneBurner', player: choice.player, event: b.event!,
physicalId: choice.physicalId, copiedFrom: choice.copiedFrom }
: { kind: 'poisonTooth', player: choice.player, event: b.event,
physicalId: choice.physicalId, copiedFrom: choice.copiedFrom };
}
const player = g.order.find(
(id) =>
[b.attacker, b.defender].includes(id) &&
cardOf(getPlayer(g, id), b.plans[id].weapon)?.kind === 'poisonTooth' &&
b.poisonTooth?.[id] === undefined,
);
if (player) g.decision = { kind: 'poisonTooth', player };
}
/** Homeworld invaders have no traitor vote to hold their late-defense window.
* Offer only a real held legal defense; empty windows resolve automatically. */
@@ -18477,15 +18527,16 @@ function finishRevival(
Math.max(p.revivalCycle, p.kwisatz!.revivalCycle ?? 1) + 1;
log(g, `${p.name} revived Kwisatz Haderach.`, { faction: p.faction, name: 'Revival' });
} else {
const leader = g.players
.flatMap((owner) => owner.leaders)
.find((l) => l.id === revival.leader)!;
const sharedDuke = revival.kind === 'leader' && revival.leader === DUKE_VIDAL_ID;
const leader = sharedDuke
? g.dukeVidal!.leader
: g.players.flatMap((owner) => owner.leaders).find((l) => l.id === revival.leader)!;
if (revival.kind === 'foreignGhola') leader.gholaBy = p.id;
else delete leader.gholaBy;
leader.dead = false;
delete leader.concealed;
if (sharedDuke) g.dukeVidal = consumeDuke(g.dukeVidal!);
// Automatic Karama response chains may commit after the action's before/after
// observer. Record the own-leader offer at the successful revival itself.
if (revival.kind === 'leader' && g.leaderSkills && !isAuditorLeader(leader) &&
!leader.capturedBy && p.leaders.some(own => own.id === leader.id)) {
requireLeaderSkillRevival(g, p.id);
@@ -18494,7 +18545,7 @@ function finishRevival(
}
log(
g,
`${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.`,
`${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.${sharedDuke ? ' The living shared disc is set aside, unclaimed.' : ''}`,
{ faction: p.faction, name: 'Revival' },
);
}
@@ -22095,7 +22146,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
karamaConversionIntegrity(g);
ornithopterIntegrity(g);
lateDefenseIntegrity(g);
stoneBurnerIntegrity(g);
battleSpecialWeaponIntegrity(g);
saphoAggressorIntegrity(g);
strongholdIntegrity(g);
auditorIntegrity(g);
@@ -22326,7 +22377,7 @@ export function normalizeAutomaticGame(state: Game): Game {
const g = structuredClone(state);
ornithopterIntegrity(g);
lateDefenseIntegrity(g);
stoneBurnerIntegrity(g);
battleSpecialWeaponIntegrity(g);
strongholdIntegrity(g);
auditorIntegrity(g);
if (g.pendingNullentropy) return g;
@@ -22370,7 +22421,7 @@ function applyActionInner(
g.biddingEnd.ready = [];
ornithopterIntegrity(g);
lateDefenseIntegrity(g);
stoneBurnerIntegrity(g);
battleSpecialWeaponIntegrity(g);
strongholdIntegrity(g);
auditorIntegrity(g);
if (g.pendingNullentropy && !['nexusTraitorDraw', 'nexusTraitorReturn', 'nexusFaceDancers'].includes(action?.type)) {
@@ -23545,43 +23596,41 @@ function applyActionInner(
nextRevealedDecision(g);
} else if (decision.kind === 'stoneBurner') {
requireRule(
Object.keys(action).every((key) =>
['type', 'event', 'mode'].includes(key),
),
'Stone Burner needs only this battle event and a mode.',
);
requireRule(
g.battle?.revealed &&
action.event === decision.event &&
Object.keys(action).every((key) => ['type', 'event', 'mode'].includes(key)) &&
(action.mode === 'kill' || action.mode === 'ignore') &&
g.battle?.revealed && action.event === decision.event &&
action.event === g.battle.event,
'Choose Stone Burner’s mode for this exact revealed battle.',
);
requireRule(
action.mode === 'kill' || action.mode === 'ignore',
'Choose to kill both leaders or ignore surviving leader strength.',
);
requireRule(
isStoneBurner(cardOf(p, g.battle.plans[id]?.weapon)) &&
!g.battle.stoneBurner?.[id],
'Stone Burner’s mode is already chosen or its weapon is missing.',
);
const choice = revealedWeaponChoices(g).find((candidate) =>
candidate.kind === 'stoneBurner' && candidate.player === id);
requireRule(!!choice && choice.physicalId === (decision.physicalId ?? choice.physicalId) &&
choice.copiedFrom === (decision.copiedFrom ?? null) &&
g.battle.stoneBurner?.[id] === undefined,
'Stone Burner’s mode is already chosen or its physical weapon changed.');
(g.battle.stoneBurner ??= {})[id] = action.mode;
log(
g,
`${p.name} chose Stone Burner: ${action.mode === 'kill' ? 'both participating leaders will be killed' : 'surviving leaders add no strength; other weapon attacks still apply'}. Undialed physical force tokens decide the ordinary battle; traitors and explosions retain precedence.`,
);
log(g, `${p.name} chose ${choice.copiedFrom ? 'Mirror Weapon copying ' : ''}Stone Burner: ${
action.mode === 'kill' ? 'both participating leaders will be killed' :
'surviving leaders add no strength; other weapon attacks still apply'
}. Undialed physical force tokens decide the ordinary battle; traitors and explosions retain precedence.`);
nextRevealedDecision(g);
} else if (decision.kind === 'poisonTooth') {
requireRule(
g.battle?.revealed && typeof action.activate === 'boolean',
'Choose whether to activate Poison Tooth.',
);
g.battle.poisonTooth ??= {};
g.battle.poisonTooth[id] = action.activate;
log(
g,
`${p.name} ${action.activate ? 'activated' : 'declined to activate'} Poison Tooth.`,
);
requireRule(Object.keys(action).every((key) => ['type', 'event', 'activate'].includes(key)) &&
g.battle?.revealed && typeof action.activate === 'boolean' &&
(decision.copiedFrom === null || decision.copiedFrom === undefined ||
action.event === decision.event) &&
(action.event === undefined || action.event === decision.event) &&
(decision.event === undefined || decision.event === g.battle.event),
'Choose whether to activate Poison Tooth for this exact revealed battle.');
const choice = revealedWeaponChoices(g).find((candidate) =>
candidate.kind === 'poisonTooth' && candidate.player === id);
requireRule(!!choice && choice.physicalId === (decision.physicalId ?? choice.physicalId) &&
choice.copiedFrom === (decision.copiedFrom ?? null) &&
g.battle.poisonTooth?.[id] === undefined,
'Poison Tooth is already chosen or its physical weapon changed.');
(g.battle.poisonTooth ??= {})[id] = action.activate;
log(g, `${p.name} ${action.activate ? 'activated' : 'declined to activate'} ${
choice.copiedFrom ? 'Mirror Weapon copying ' : ''}Poison Tooth.`);
nextRevealedDecision(g);
} else if (decision.kind === 'techToken') {
requireRule(
@@ -24908,6 +24957,8 @@ function applyActionInner(
g.advanced && g.phase === 4 && p.faction === 'tleilaxu',
'Foreign gholas require advanced Tleilaxu during Revival.',
);
requireRule(action.leader !== DUKE_VIDAL_ID,
'Only Ecaz may revive Duke Vidal, including with Ghola.');
const leader = g.players
.flatMap((owner) => owner.leaders)
.find((l) => l.id === action.leader);
@@ -24997,11 +25048,16 @@ function applyActionInner(
'Tleilaxu prevented this faction’s normal revivals for this turn.',
);
const options = leaderRevivalOptions(g, p);
p.revivalCycle = options.cycle;
const l =
t === 'reviveLeader'
? p.leaders.find((l) => l.id === action.leader)
: undefined;
const duke = t === 'reviveLeader' && action.leader === DUKE_VIDAL_ID;
if (duke) {
requireRule(p.faction === 'ecaz', 'Only Ecaz may revive Duke Vidal.');
const blocked = ecazDukeRevivalBlock(g, p.id);
requireRule(!blocked, blocked ?? 'Duke Vidal is unavailable for revival.');
}
const dukeQuote = duke ? quoteEcazDukeRevival(g, id, !!revivalDiscount(g, p)) : null;
const l = t === 'reviveLeader'
? duke ? g.dukeVidal!.leader : p.leaders.find((l) => l.id === action.leader)
: undefined;
if (t === 'reviveKwisatz')
requireRule(
g.advanced && p.faction === 'atreides' && p.kwisatz?.dead,
@@ -25031,10 +25087,14 @@ function applyActionInner(
t === 'reviveKwisatz' ? !!options.kwisatz : !!option,
'That leader is unavailable for revival this phase.',
);
const normalCost = l?.strength ?? 2;
const cost = revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost;
const normalCost = dukeQuote?.normalCost ?? l?.strength ?? 2;
const cost = dukeQuote?.cost ?? (revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost);
requireRule(p.spice >= cost, 'Not enough spice.');
if (cost < normalCost) checks.push('revivalDiscount');
// Duke may be revived independently even while later six-disc native
// cycles are gated; that request must not erase an opened first cohort.
if (!duke || options.cycle > p.revivalCycle)
p.revivalCycle = options.cycle;
beginRevival(g, {
player: id,
kind: t === 'reviveKwisatz' ? 'kwisatz' : 'leader',
@@ -25857,8 +25917,12 @@ function applyActionInner(
VOICE_KINDS.includes(String(action.kind)),
'Choose a card type.',
);
if (action.kind === 'mirrorWeapon') {
const blocked = mirrorWeaponModeBlock(g);
requireRule(!blocked, blocked ?? 'Mirror Weapon is unavailable in this battle configuration.');
}
requireRule(typeof action.must === 'boolean', 'Choose require or forbid.');
if (action.kind === 'stoneBurner' && action.must) {
if ((action.kind === 'stoneBurner' || action.kind === 'mirrorWeapon') && action.must) {
const target = getPlayer(
g,
b.attacker === b.preparation.beneficiary ? b.defender : b.attacker,
@@ -25866,7 +25930,7 @@ function applyActionInner(
const blocked = stoneCompulsionBlock(g, target);
requireRule(
!blocked,
blocked ?? 'Stone Burner cannot be compelled in this battle.',
blocked ?? `${action.kind === 'mirrorWeapon' ? 'Mirror Weapon' : 'Stone Burner'} cannot be compelled in this battle.`,
);
}
b.voice = {
@@ -26148,6 +26212,7 @@ export function viewGame(state: Game, id: string) {
ambassadorRelocationIntegrity(state);
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
battleSpecialWeaponIntegrity(state);
const g = structuredClone(state);
normalizeCardNames(g);
settleAdvisors(g);
@@ -26165,7 +26230,15 @@ export function viewGame(state: Game, id: string) {
if (!(error instanceof VictoryProgressError)) throw error;
}
}
const leaderRevivals = leaderRevivalOptions(g, me);
// A concealed capture may change the real death count without revealing it
// to the native owner. Derive guidance from the same projected discs that
// the seat can inspect; action validation still uses authoritative custody.
const visibleRevivalGame = me.faction === 'ecaz' && g.dukeVidal
? { ...g, dukeVidal: { ...g.dukeVidal,
leader: projectLeader(g, g.dukeVidal.leader, id) } }
: g;
const leaderRevivals = leaderRevivalOptions(visibleRevivalGame,
{ ...me, leaders: me.leaders.map((leader) => projectLeader(g, leader, id)) });
const b = g.battle;
const completion =
!g.pendingTreacheryDiscard &&
@@ -26767,6 +26840,8 @@ export function viewGame(state: Game, id: string) {
: null,
leaders: leaderRevivals.leaders,
kwisatz: leaderRevivals.kwisatz,
cycleBlock: leaderRevivals.cycleBlock,
dukeBlocked: leaderRevivals.dukeBlocked,
prevented: revivalPrevented(g, me.id),
eliteRemaining: eliteRevivalRemaining(me, g.advanced),
limit: normalForceRevivalLimit(g, me),
+1
View File
@@ -144,6 +144,7 @@ function practiceLeaders(choice: IntroductionResourceChoice): Leader[] {
export function introductionRevival(choice: IntroductionResourceChoice) {
validateChoice(choice);
const context = {
status: 'playing' as const,
advanced: false,
phase: 4,
turn: 1,
+24
View File
@@ -0,0 +1,24 @@
import type { FactionId } from './catalog';
const supported = new Set<FactionId>([
'atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit', 'fremen',
'choam', 'richese',
]);
/** Public configuration only: never inspect an opponent's hand or sealed plan. */
export function mirrorWeaponModeBlock(g: {
expansions: readonly string[];
players: readonly { faction: FactionId }[];
homeworlds?: unknown;
nexusCards?: unknown;
leaderSkills?: unknown;
discoveries?: unknown;
sandtrout?: boolean;
}): string | null {
if (g.expansions.length !== 1 || g.expansions[0] !== 'choam' ||
g.players.some((p) => !supported.has(p.faction)))
return 'Mirror Weapon currently needs the CHOAM/Richese expansion with classic or CHOAM/Richese factions.';
if (g.homeworlds || g.nexusCards || g.leaderSkills || g.discoveries || g.sandtrout)
return 'Mirror Weapon with combined optional modules is still being integrated.';
return null;
}
+7 -3
View File
@@ -413,7 +413,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A player whose hand becomes full is automatically passed, or submits zero in an unfinished Silent lot. A full-hand owner’s unbid cache card is removed automatically. If every hand is full, preparing the normal pool does not draw and lose unseen cards.',
'AI supports cache choices and all three offered bidding methods using its own projected information. It currently skips the optional Black Market prelude. A seller in an already offered Black Market lot passes or submits zero.',
'Some unresolved rules are explicitly blocked: changing the normal card count after cache-auction cancellation, exhausted-cache arithmetic, positive Black Market self-bids, and advanced Ixian Technology substitution on special lots. These are implementation guards while the rules are resolved, not claims of official prohibitions.',
'Juice of Sapho now supports bounded first/last Once Around, movement and remaining battle-choice ordering; see its timing guide for current limits. Richese’s Karama uses the existing generic handler, Distrans supports a separate private hand transfer, Nullentropy Box supports a paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment in development fixtures. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon and Semuta Drug remain unfinished, as do the other Sapho timing modes; this does not certify every Karama interaction. Richese expansion starts remain disabled. Each component guide identifies its currently available action. Independent continuation and persisted-room checks pass. Desktop checks confirmed all ten enlarged guides, direct topic links, Once Around payment and restored private Silent bidding. Mobile and full Richese-game verification remain unfinished.',
'Juice of Sapho now supports bounded first/last Once Around, movement and remaining battle-choice ordering; see its timing guide for current limits. Richese’s Karama uses the existing generic handler, Distrans supports a separate private hand transfer, Nullentropy Box supports a paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment in development fixtures. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon copies opposing revealed attacks, with independent copy-first choices for Tooth and Stone in bounded CHOAM/Richese-deck classic-faction battles. A winning physical Mirror may be kept even after copying activated Tooth or Artillery under the user-selected interpretation, not a publisher clarification. Semuta Drug and other Sapho timing modes remain unfinished; this does not certify every Karama interaction. Richese expansion starts remain disabled. Each component guide identifies its currently available action. Independent continuation and persisted-room checks pass. Desktop checks confirmed all ten enlarged guides, direct topic links, Once Around payment and restored private Silent bidding. Mobile and full Richese-game verification remain unfinished.',
],
related: [
'choam-modules',
@@ -624,7 +624,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The server checks alliance, exact card ownership, capacity and commitments again before transfer. An obsolete pending gift aborts without a partial transfer and restores its interrupted flow. Unrelated players see the gift power, not its private card identity. A pending card cannot also be spent, exchanged or randomly extracted.',
'After cancellation, trying the same card again in the same phase is currently blocked pending a ruling on repeated use. This is an explicit implementation guard, not a printed once-per-phase limit on all gifts. Other legally available cards are assessed separately.',
'All four AI profiles conservatively give only the canonical Richese Karama during Bidding to free a full hand when the ally’s public card count is lower and there is room. They do not inspect ally hand contents or infer an unknown need for a card. Pending responses, decisions and Truthtrance take priority.',
'All ten canonical Richese cards may be transferred when legal, and Karama has its existing card-effect handler. Distrans supports its separate card-transfer effect, Nullentropy Box supports paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Juice of Sapho supports bounded Once Around and movement ordering. Mirror Weapon and Semuta Drug remain unfinished, as do the other Sapho timing modes. Desktop gift transfer and fresh-tab pending recovery have been checked in a synthetic table. Mobile, broader interactions and full Richese games remain pending; Richese starts remain disabled.',
'All ten canonical Richese cards may be transferred when legal, and Karama has its existing card-effect handler. Distrans supports its separate card-transfer effect, Nullentropy Box supports paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon copies an opposing revealed weapon in bounded CHOAM/Richese-deck classic-faction battles, with copy-first Tooth/Stone choices; physical-Mirror winner retention even after copied activated Tooth/Artillery is a user-selected interpretation. Juice of Sapho supports bounded Once Around and movement ordering. Semuta Drug and other Sapho timing modes remain unfinished. Desktop gift transfer and fresh-tab pending recovery have been checked in a synthetic table. Mobile, broader interactions and full Richese games remain pending; Richese starts remain disabled.',
],
related: [
'richese-cards',
@@ -940,7 +940,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'An active Truthtrance question and exact card or transaction commitments must resolve as required. A full hand is currently guarded: the printed sequence adds the recovered card before discarding the Box, and the temporary capacity question is unresolved. The current implementation requires a pre-existing free hand slot. This is not a verified prohibition on every net-zero exchange.',
'A pending Guild refund claim on a discarded shipping Karama also blocks starting the search while that provisional refund policy is unresolved. The guard protects the competing claim to that card; it is not a general ban on Box during Shipment. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.',
'All four AI profiles start only when their own projected availability and spice reserve allow it. They do not inspect unpaid discard contents. Once paid, they choose from their entitled cards, prefer existing functional effects over unfinished Richese faces and complete a legal selection even if only unfinished effects remain. A paid search takes priority over optional actions.',
'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation, the provisional Guild refund interaction and future Semuta discard reactions remain unresolved or unfinished. Mirror Weapon and Semuta Drug remain inactive, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.',
'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation, the provisional Guild refund interaction and future Semuta discard reactions remain unresolved or unfinished. Mirror Weapon has a separate bounded CHOAM/Richese-deck classic-faction battle path; Semuta Drug remains inactive, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.',
],
related: [
'card-richese-nullentropy-box',
@@ -1149,6 +1149,8 @@ export const RULE_TOPICS: RuleTopic[] = [
? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents; advanced Harkonnen tables remain guarded pending the secret-captive ruling.'
: definition.card.effect === 'stoneBurner'
? 'Use Stone Burner in the battle weapon selector and review the chosen dial/support guard before sealing. Choose the leader effect after revelation; combined timing remains guarded.'
: definition.card.effect === 'mirrorWeapon'
? 'Choose Mirror Weapon in the battle weapon slot in a supported CHOAM/Richese-deck table. Its attack copies the opposing revealed weapon; copied Tooth or Stone choices resolve first and independently. Under the user-selected interpretation, a winning physical Mirror may be retained even after a copied Tooth or Artillery attack. Full Richese starts and combined modules remain guarded.'
: definition.card.effect === 'portableSnooper'
? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.'
: 'Reference component only: this Richese card’s game actions are not enabled.',
@@ -1224,6 +1226,8 @@ export const RULE_TOPICS: RuleTopic[] = [
? ['residual-poison']
: definition.card.effect === 'stoneBurner'
? ['stone-burner']
: definition.card.effect === 'mirrorWeapon'
? ['battle', 'stone-burner']
: definition.card.effect === 'portableSnooper'
? ['portable-snooper']
: []),
+27
View File
@@ -1,4 +1,6 @@
import type { Game, ResponseWindow, Player } from './engine';
import { DUKE_VIDAL_ID } from './duke-vidal';
import { resolveEcazDukeRevival } from './ecaz-duke-revival';
import { isAuditorLeader } from './choam-auditor';
import { controlsLeader } from './leader-control';
import {
@@ -28,6 +30,7 @@ export type RevivalCancellationContext = Pick<
| 'players'
| 'revivalRules'
| 'freeRevival'
| 'dukeVidal'
| 'pendingRevival'
| 'emperorExtra'
>;
@@ -118,6 +121,20 @@ export function continuationCustody(
);
return;
}
if (pending.leader === DUKE_VIDAL_ID) {
requireRevival(pending.kind === 'leader' && p.faction === 'ecaz' &&
pending.normalCost === 5 && (pending.cost === 3 || pending.cost === 5) &&
!pending.checks.includes('earlyRevival'),
'The pending shared leader has invalid revival costs or checks.');
try {
resolveEcazDukeRevival(g, p.id);
} catch {
throw new RevivalCancellationError('The pending shared leader is unavailable for revival.');
}
requireRevival(!p.leaderRevived,
'The pending shared leader has already used this phase’s revival slot.');
return;
}
const matches = g.players
.flatMap((owner) => owner.leaders.map((leader) => ({ owner, leader })))
.filter(({ leader }) => leader.id === pending.leader);
@@ -241,6 +258,16 @@ export function quoteRevivalCancellation(
g.advanced && p.faction === 'atreides' && !!p.kwisatz,
'The pending Kwisatz Haderach belongs to no valid revival pool.',
);
} else if (original.leader === DUKE_VIDAL_ID) {
requireRevival(original.kind === 'leader' && p.faction === 'ecaz' &&
original.normalCost === 5 && (original.cost === 3 || original.cost === 5) &&
kind !== 'earlyRevival',
'The pending shared leader has invalid revival costs or checks.');
try {
resolveEcazDukeRevival(g, p.id);
} catch {
throw new RevivalCancellationError('The pending shared leader is unavailable for revival.');
}
} else {
const matches = g.players
.flatMap((seat) => seat.leaders.map((leader) => ({ seat, leader })))
+54 -40
View File
@@ -9,6 +9,8 @@ import { isAuditorLeader } from './choam-auditor';
import { controlsLeader } from './leader-control';
import { TECH_TOKENS, ownedTech } from './tech-tokens';
import { tleilaxuHomeworldFreeIncomeBlocked } from './homeworld-benefits';
import { DUKE_VIDAL_ID } from './duke-vidal';
import { resolveEcazDukeRevival } from './ecaz-duke-revival';
export type RevivalResumeContext = Pick<
Game,
@@ -247,49 +249,61 @@ export function quoteRevivalResume(
throw error;
}
} else {
const matches = g.players
.flatMap((owner) => owner.leaders.map((leader) => ({ owner, leader })))
.filter(({ leader }) => leader.id === pending.leader);
requireResume(
matches.length === 1 &&
matches[0].leader.dead &&
!matches[0].leader.capturedBy,
'The pending revival needs one dead, uncaptured physical leader.',
);
const { owner, leader } = matches[0];
if (pending.kind === 'foreignGhola') {
const living =
g.players
.flatMap((seat) => seat.leaders)
.filter((leader) => controlsLeader(p, leader) && !leader.dead)
.length +
(g.dukeVidal?.controller === p.id &&
!g.dukeVidal.leader.dead &&
!g.dukeVidal.leader.capturedBy &&
!g.dukeVidal.leader.gholaBy &&
!(g.advanced && g.players.some((seat) => seat.faction === 'harkonnen'))
? 1
: 0);
requireResume(
leader.faction !== p.faction && !isAuditorLeader(leader) && living < 5,
'The pending foreign ghola has invalid leader identity or active pool capacity.',
);
if (pending.leader === DUKE_VIDAL_ID) {
requireResume(pending.kind === 'leader' && p.faction === 'ecaz' &&
pending.normalCost === 5 && (pending.cost === 3 || pending.cost === 5) &&
!pending.checks.includes('earlyRevival') && !p.leaderRevived,
'The pending shared leader has invalid ownership, cost, or revival slot.');
try {
resolveEcazDukeRevival(g, p.id);
} catch {
throw new RevivalResumeError('The pending shared leader is unavailable for revival.');
}
} else {
// A negotiated native buyback may still carry the Tleilaxu ghola marker.
// The original approved request survives while benefit checks are answered.
const request = g.revivalRequests?.[p.id];
const buyback =
!!tleilaxu &&
leader.gholaBy === tleilaxu.id &&
request?.leader === leader.id &&
request.price === pending.cost &&
!request.declined;
const matches = g.players
.flatMap((owner) => owner.leaders.map((leader) => ({ owner, leader })))
.filter(({ leader }) => leader.id === pending.leader);
requireResume(
owner.id === p.id &&
(!leader.gholaBy || buyback) &&
(!p.leaderRevived || p.faction === 'tleilaxu'),
'The pending native leader no longer belongs to this revival request.',
matches.length === 1 &&
matches[0].leader.dead &&
!matches[0].leader.capturedBy,
'The pending revival needs one dead, uncaptured physical leader.',
);
const { owner, leader } = matches[0];
if (pending.kind === 'foreignGhola') {
const living =
g.players
.flatMap((seat) => seat.leaders)
.filter((leader) => controlsLeader(p, leader) && !leader.dead)
.length +
(g.dukeVidal?.controller === p.id &&
!g.dukeVidal.leader.dead &&
!g.dukeVidal.leader.capturedBy &&
!g.dukeVidal.leader.gholaBy &&
!(g.advanced && g.players.some((seat) => seat.faction === 'harkonnen'))
? 1
: 0);
requireResume(
leader.faction !== p.faction && !isAuditorLeader(leader) && living < 5,
'The pending foreign ghola has invalid leader identity or active pool capacity.',
);
} else {
// A negotiated native buyback may still carry the Tleilaxu ghola marker.
// The original approved request survives while benefit checks are answered.
const request = g.revivalRequests?.[p.id];
const buyback =
!!tleilaxu &&
leader.gholaBy === tleilaxu.id &&
request?.leader === leader.id &&
request.price === pending.cost &&
!request.declined;
requireResume(
owner.id === p.id &&
(!leader.gholaBy || buyback) &&
(!p.leaderRevived || p.faction === 'tleilaxu'),
'The pending native leader no longer belongs to this revival request.',
);
}
}
}
if (stop)
+35 -6
View File
@@ -3,6 +3,8 @@ import { faction, type FactionId } from './catalog';
import { isAuditorLeader } from './choam-auditor';
import { homeworldLowBonus } from './homeworld-benefits';
import { recruitsRevivalAllowance } from './recruits';
import { DUKE_VIDAL_ID } from './duke-vidal';
import { ecazDukeRevivalBlock, quoteEcazDukeRevival } from './ecaz-duke-revival';
type RevivalRateContext = Pick<
Game,
| 'advanced'
@@ -136,10 +138,9 @@ export function normalRevivalCycle(
: p.revivalCycle;
}
/** Native leader options for the existing ordinary cycle and Tleilaxu self-service.
* Shared discs are deliberately separate: Ecaz's six-disc cycle is not implemented here.
* This is a pure quote; committing an action, not reading a view, advances the cycle.
*/
/** The five-disc Ecaz opening is recorded in revivalCycle on the first
* accepted ordinary return. Later six-disc cohorts are not established by E3:
* keep their native returns gated rather than guessing from death counters. */
export function leaderRevivalOptions(
g: Pick<
Game,
@@ -149,6 +150,8 @@ export function leaderRevivalOptions(
| 'revivalPrevention'
| 'players'
| 'revivalRules'
| 'status'
| 'dukeVidal'
>,
p: Pick<
Player,
@@ -161,7 +164,21 @@ export function leaderRevivalOptions(
| 'kwisatz'
>,
) {
const cycle = normalRevivalCycle(p);
const shared = p.faction === 'ecaz' && g.dukeVidal;
const dukeContext = shared && g.status === 'playing' ? g : null;
const dukeBlocked = dukeContext ? ecazDukeRevivalBlock(dukeContext, p.id) : null;
const dukeAvailable = !!dukeContext && !dukeBlocked;
const repeatHistory = !!shared &&
(g.dukeVidal!.leader.deaths > 1 || p.leaders.some((l) => l.deaths > 1) ||
p.revivalCycle > 1);
const firstOpening = !!shared && !repeatHistory &&
(p.leaders.filter((l) => l.dead && !l.capturedBy && !l.gholaBy && l.deaths === 1).length +
(g.dukeVidal!.leader.dead && dukeAvailable &&
g.dukeVidal!.leader.deaths === 1 ? 1 : 0) >= 5 ||
p.leaders.every((l) => l.dead || l.capturedBy || l.gholaBy));
const cycle = shared
? (!repeatHistory && (p.revivalCycle === 1 || firstOpening) ? 1 : 0)
: normalRevivalCycle(p);
const enabled = g.phase === 4 && !revivalPrevented(g, p.id);
const discount = revivalDiscount(g, p);
const leaders = p.leaders.flatMap((leader) => {
@@ -195,6 +212,17 @@ export function leaderRevivalOptions(
},
];
});
if (dukeAvailable && enabled && !p.leaderRevived) {
const quote = quoteEcazDukeRevival(dukeContext!, p.id, !!discount);
leaders.push({
id: DUKE_VIDAL_ID,
name: quote.duke.leader.name,
normalCost: quote.normalCost,
cost: quote.cost,
early: false,
affordable: p.spice >= quote.cost,
});
}
const kwisatzCost = discount ? 1 : 2;
const kwisatz =
enabled &&
@@ -205,7 +233,8 @@ export function leaderRevivalOptions(
cycle >= (p.kwisatz.revivalCycle ?? 1)
? { cost: kwisatzCost, affordable: p.spice >= kwisatzCost }
: null;
return { cycle, leaders, kwisatz };
return { cycle, leaders, kwisatz, dukeBlocked, cycleBlock: repeatHistory
? 'Ecaz six-disc repeated revival cycles are not yet resolved.' : null };
}
export type PendingRevival = {
player: string;
+2 -3
View File
@@ -194,11 +194,10 @@ export const RICHESE_CARD_DEFINITIONS: readonly RicheseCardDefinition[] =
],
[
'Determine the copied weapon from the revealed opposing plan, not the opponent’s hand.',
'Physical disposal follows the general official winner-retention rule unless a specific exception applies; copied special-weapon disposal remains under audit.',
'The user-selected interpretation keeps a victorious physical Mirror eligible for normal winner retention even if it copied activated Poison Tooth or Artillery; this is not a publisher clarification.',
],
[
'Two Mirror Weapons, no opposing weapon, mode-dependent cards, Poison Tooth, Artillery Strike, Stone Burner and weapon-order effects.',
'Copied card disposal and effects that cancel or replace weapons.',
'Further combined-module card-role changes and effects that cancel or replace weapons.',
],
),
define(
+11 -4
View File
@@ -5,6 +5,7 @@ import {
weaponTypes,
defenseTypes,
} from './battle-cards';
import { resolveBattleWeapons, type EffectiveWeapon } from './effective-weapons';
import type { StrongholdId } from './stronghold-cards';
/** Carthag adds a Snooper property, not a second physical card or Chemistry. */
@@ -12,6 +13,7 @@ export function strongholdSnooper(
effect: StrongholdId | null | undefined,
weapon?: Card,
defense?: Card,
effectiveKind?: EffectiveWeapon['kind'],
) {
return (
effect === 'carthag' &&
@@ -19,7 +21,8 @@ export function strongholdSnooper(
defense.kind !== 'worthless' &&
isDefenseCard(defense) &&
!defenseTypes(defense).includes('snooper') &&
!weaponTypes(weapon).includes('poison')
!(effectiveKind === undefined ? weaponTypes(weapon).includes('poison') :
['poison', 'poisonBlade', 'poisonTooth', 'chemistry'].includes(effectiveKind ?? ''))
);
}
export function strongholdBattleEffects(
@@ -32,8 +35,12 @@ export function strongholdBattleEffects(
aEffect?: StrongholdId | null,
dEffect?: StrongholdId | null,
) {
return battleCardEffects(aw, ad, dw, dd, toothA, toothD, {
attacker: strongholdSnooper(aEffect, aw, ad),
defender: strongholdSnooper(dEffect, dw, dd),
const weapons = resolveBattleWeapons({
attacker: { weapon: aw, defense: ad },
defender: { weapon: dw, defense: dd },
});
return battleCardEffects(aw, ad, dw, dd, toothA, toothD, {
attacker: strongholdSnooper(aEffect, aw, ad, weapons.attacker.kind),
defender: strongholdSnooper(dEffect, dw, dd, weapons.defender.kind),
}, weapons);
}