Implement the five 7 October pending rule rulings

The user answered five recorded questions; each ruling is indexed in
RULE_DECISIONS.md and implemented with a focused regression.

- CHOAM opening income counts as a qualifying collection, so low-population
  Tupile adds its bank increment under the Inflation multiplier. The engine
  guard is removed and the projection/notice expose the added bonus.
- An exhausted Richese cache is legal and restores one ordinary lot; the
  canceled/prevented compulsory cache auction branch restores the reduction
  before the ordinary pool runs and the cache offer is skipped when no cache
  lot can run. The pure continuation quote mirrors the count.
- Suk-rescued casualties count toward Advanced Atreides' seven Kwisatz
  Haderach losses: the battle-loss counter gains the full dialed allocation in
  both rescue paths, and the earlier Suk availability restriction is removed.
- The Guild Nexus Secret Ally reserve return overrides the Arrakis-to-native-
  Homeworld restriction for the holder's own reserves and deposits through the
  shared native-reserve pipeline (generalized from the revival deposit).
- Interior passengers count as entering the territories a relocated Ixian HMS
  points into, so Sandmaster collects there before the native collection.

Full offline suite 7804/7804, types and lint pass. Historical evidence and
superseded test expectations are updated to the ruled behaviour; preserved
combined Homeworld/Nexus, occupation and Richese captures are unblocked but
not re-run here. No existing game, seat or saved state was changed.
This commit is contained in:
Quintine committed 2026-10-07 21:56:17 +11:00
1 parent e0101a937b
commit 7e3bec2721
41 files changed
+525 -234

No files matched your search

+9 -6
View File
@@ -435,15 +435,18 @@ function calculate(
if (index === a.cards.length || !able.length) {
const round = input.richeseBidding,
activeRound = round?.turn === input.turn && round.stage === 'normal';
// User ruling 7 October 2026: the cache lot only runs when physical cache
// cards remain and the compulsory auction was not canceled.
const cacheLast =
!!activeRound &&
round.position === 'last' &&
!round.cacheCanceled &&
(input.richeseCacheCount ?? 0) > 0;
return result({
kind: 'normalEnd',
returned: structuredClone(a.cards.slice(index)),
after:
activeRound && round.position === 'last' && !round.cacheCanceled
? 'richeseCache'
: 'phase',
completeRound:
!!activeRound && !(round.position === 'last' && !round.cacheCanceled),
after: cacheLast ? 'richeseCache' : 'phase',
completeRound: !!activeRound && !cacheLast,
});
}
let opener = a.opener;
+71 -57
View File
@@ -6,7 +6,7 @@ import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-optio
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
import { leaderSkillCard, type LeaderSkillId } from './leader-skill-cards';
import { leaderSkillCard } from './leader-skill-cards';
import { bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment';
import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question';
import {
@@ -21,7 +21,7 @@ import { planetologistMovementModeSupported, planetologistLeader, planetologistR
import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, type SmugglerBattleReceipt } from './smuggler-battle';
import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollectible, sandmasterLeader, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { sandmasterWormCollection } from './sandmaster-worm';
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
import { BankerIncomeError, createBankerIncomeState, validateBankerIncomeState, quoteBankerIncome, commitBankerIncome, quoteBankerIncomeCollection, commitBankerIncomeCollection, projectBankerIncome, type BankerIncomeState, type BankerIncomeAuthority, type BankerIncomeContext } from './spice-banker-income';
@@ -164,7 +164,7 @@ import {
} from './homeworld-emperor-move';
import {
quoteNativeReserveWithdrawal,
quoteNativeRevivalDeposit,
quoteNativeReserveDeposit,
NativeReserveError,
type NativeReserveSelections,
} from './homeworld-native-reserves';
@@ -2687,7 +2687,7 @@ function playNexusEmperorRevive(g:Game,p:Player,action:Action) {
offer?.revival.blocked ?? 'Choose the current Emperor Nexus revival and an eligible exact three-force group.');
const elite = action.elite as number, before = emperorNexusPools(p);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
addRevivedReserves(g,p,EMPEROR_NEXUS_REVIVALS,elite); p.tanks -= EMPEROR_NEXUS_REVIVALS;
addNativeReserves(g,p,EMPEROR_NEXUS_REVIVALS,elite); p.tanks -= EMPEROR_NEXUS_REVIVALS;
if (p.elites) {p.elites.tanks -= elite;p.elites.revived += elite;}
techIncome(g, 'axlotl', p);
const record:NexusEmperorRevival = {kind:'revival',event:offer.event,owner:p.id,turn:g.turn,phase:4,
@@ -2862,7 +2862,7 @@ function playNexusFremenRevive(g: Game, p: Player, action: Action) {
offer?.blocked ?? 'Choose a current eligible three-force Fremen Nexus return.');
const elite = action.elite as number, before = fremenNexusRevivalPools(p);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
addRevivedReserves(g, p, FREMEN_NEXUS_FREE_FORCES, elite);
addNativeReserves(g, p, FREMEN_NEXUS_FREE_FORCES, elite);
p.tanks -= FREMEN_NEXUS_FREE_FORCES;
p.revived += FREMEN_NEXUS_FREE_FORCES;
p.freeForcesRevived = (p.freeForcesRevived ?? 0) + FREMEN_NEXUS_FREE_FORCES;
@@ -7105,14 +7105,17 @@ function withdrawNativeReserves(
observeOccupation(g);
return result.receipts;
}
function addRevivedReserves(g: Game, p: Player, amount: number, elite: number) {
/** Adds counters to a faction's reserves, depositing into the native Homeworld
* custody when Homeworlds are enabled. Used by revival and by the authorized
* Nexus Secret Ally reserve return (user ruling 7 October 2026). */
function addNativeReserves(g: Game, p: Player, amount: number, elite: number) {
if (!g.homeworlds?.custody) {
p.reserves += amount;
if (p.elites) p.elites.reserves += elite;
return;
}
const result = homeworldRule(() =>
quoteNativeRevivalDeposit(
quoteNativeReserveDeposit(
homeworldContext(g),
g.homeworlds!.custody!,
p.id,
@@ -7597,19 +7600,6 @@ function skillEligibleLeaders(g: Game, owner: string): string[] {
)
.map((leader) => leader.id);
}
const ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK =
'Suk Graduate is unavailable to Advanced Atreides while the Kwisatz Haderach loss-count ruling is pending.';
function leaderSkillAssignmentUnavailable(
g: Game,
owner: string,
skill: string,
): string | null {
return g.advanced &&
getPlayer(g, owner).faction === 'atreides' &&
skill === 'suk-graduate'
? ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK
: null;
}
/** Public standalone E3 roster; the existing held-card owner remains the plan actor. */
function e3StrongholdFactionProfile(g: Game): boolean {
if (!g.advanced || g.expansions.length !== 1 || g.expansions[0] !== 'ecaz' ||
@@ -8285,12 +8275,6 @@ function projectedLeaderSkills(
const offer = g.leaderSkills.offers[owner] ?? null;
const eligible = skillEligibleLeaders(g, owner);
const assignment = nativeLeaderSkill(g, owner);
const unavailableSkills = Object.fromEntries(
(offer?.cards ?? []).flatMap((skill) => {
const reason = leaderSkillAssignmentUnavailable(g, owner, skill);
return reason ? [[skill, reason]] : [];
}),
) as Partial<Record<LeaderSkillId, string>>;
return {
assignments: g.leaderSkills.assignments.map((a) => {
const controller = leaderSkillController(g, a);
@@ -8303,7 +8287,6 @@ function projectedLeaderSkills(
};
}),
offer,
...(Object.keys(unavailableSkills).length ? { unavailableSkills } : {}),
eligibleLeaders: getPlayer(g, owner)
.leaders.filter((l) => eligible.includes(l.id))
.map((l) => ({ id: l.id, name: l.name })),
@@ -10762,13 +10745,21 @@ function beginRicheseBidding(g: Game) {
richeseDecision(g, 'richeseBlackMarket');
else richeseDeclaration(g);
}
/** User ruling 7 October 2026: a cache lot runs only while physical cache cards
* remain and the compulsory auction was not canceled; otherwise one ordinary
* lot is restored, so the cache's one-lot reduction is not applied. */
function richeseCacheRuns(g: Game) {
return (g.richeseCache?.length ?? 0) > 0 && !g.richeseBidding!.cacheCanceled;
}
function richeseDeclaration(g: Game) {
richeseDeclarationCache(g);
const round = g.richeseBidding!;
round.stage = 'declaration';
round.normalCount = Math.max(
0,
auctionEligible(g).length - 1 - Number(round.blackMarketSold),
auctionEligible(g).length -
Number(richeseCacheRuns(g)) -
Number(round.blackMarketSold),
);
richeseDecision(g, 'richeseDeclaration');
}
@@ -10784,7 +10775,7 @@ function offerRicheseCache(g: Game) {
function finishNormalBidding(g: Game) {
const round = g.richeseBidding;
if (round?.turn === g.turn && round.stage === 'normal') {
if (round.position === 'last' && !round.cacheCanceled) {
if (round.position === 'last' && richeseCacheRuns(g)) {
offerRicheseCache(g);
return;
}
@@ -11276,7 +11267,7 @@ function decideRichese(g: Game, p: Player, decision: Decision, action: Action) {
round.position = action.position as 'first' | 'last';
log(
g,
`${p.name} announced the Richese cache auction ${round.position}, before normal pool preparation. ${round.normalCount} normal lots are scheduled after the cache reduction${round.blackMarketSold ? ' and completed Black Market sale' : ''}.`,
`${p.name} announced the Richese cache auction ${round.position}, before normal pool preparation. ${round.normalCount} normal lots are scheduled ${richeseCacheRuns(g) ? 'after the cache reduction' : 'because no cache lot can run'}${round.blackMarketSold ? ' and completed Black Market sale' : ''}.`,
);
g.response = { kind: 'richeseAuction', owner: p.id, passed: [] };
} else if (decision.kind === 'richeseCacheTerms') {
@@ -20055,7 +20046,7 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
);
const group = { amount: n, elite, free: 0 };
const grant = requireHomeworldRevivalGrant(g, p, 'ghola', group);
addRevivedReserves(g, p, n, elite);
addNativeReserves(g, p, n, elite);
p.tanks -= n;
if (p.elites) {
p.elites.tanks -= elite;
@@ -21260,9 +21251,6 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
});
for (const withdrawal of quote.harassWithdraw ?? [])
validateReserveReturnCounters(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.');
if (quote.sukGraduate) requireRule(
ordinaryLeaderSkillModeSupported(g) &&
(nativeExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
@@ -22154,7 +22142,10 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean, d
} else {
player.reserves += quote!.reserves.normal + quote!.reserves.elite;
player.tanks += quote!.tanks.normal + quote!.tanks.elite;
player.battleLosses += quote!.tanks.normal + quote!.tanks.elite;
// User ruling 7 October 2026: rescued counters still count toward the seven
// battle losses that activate Kwisatz Haderach, so the full dialed casualty
// allocation is added, not only the counters that reach the Tanks.
player.battleLosses += pending.losses.normal + pending.losses.elite;
if (player.elites) {
player.elites.reserves += quote!.reserves.elite;
player.elites.tanks += quote!.tanks.elite;
@@ -22917,7 +22908,7 @@ function currentHomeworldRevivalGrant(
group: HomeworldRevivalDeploymentGroup,
) {
if (!g.homeworlds?.custody || !['fremen', 'tleilaxu'].includes(p.faction)) return null;
const deposit = homeworldRule(() => quoteNativeRevivalDeposit(
const deposit = homeworldRule(() => quoteNativeReserveDeposit(
homeworldContext(g), g.homeworlds!.custody!, p.id,
{ normal: group.amount - group.elite, elite: group.elite },
));
@@ -23157,7 +23148,7 @@ function finishRevival(
elite = revival.elite ?? 0;
const source = revival.emperorExtra ? 'emperorExtra' : 'normal';
const group = { amount: n, elite, free: revival.free };
addRevivedReserves(g, p, n, elite);
addNativeReserves(g, p, n, elite);
p.tanks -= n;
if (revival.emperorExtra)
g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n;
@@ -23955,11 +23946,19 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
if (pending.movement && g.pendingChoamMove) resumeChoamMovement(g);
if (pending.mentat) g.decision = { kind: 'choamMentat', player: choam.id };
} else if (response.kind === 'richeseAuction') {
requireRule(
!canceled,
'The canceled Richese auction count is awaiting an official ruling or an explicit table interpretation.',
);
if (g.richeseBidding!.position === 'first') offerRicheseCache(g);
const round = g.richeseBidding!;
if (canceled) {
// User ruling 7 October 2026: a canceled or prevented compulsory cache
// auction restores one ordinary lot, since no cache lot replaces it.
round.cacheCanceled = true;
round.normalCount = Math.max(0, (round.normalCount ?? 0) + 1);
log(
g,
'The compulsory Richese cache auction was canceled; one ordinary lot is restored.',
);
prepareRicheseNormal(g);
} else if (round.position === 'first' && richeseCacheRuns(g))
offerRicheseCache(g);
else prepareRicheseNormal(g);
} else if (response.kind === 'richeseBlackMarket') {
if (canceled) {
@@ -24226,11 +24225,13 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
: { turn: g.turn, canceled };
if (!canceled) {
const choam = getPlayer(g, response.owner);
requireRule(
homeworldLowBonus(g, choam.id) === 0,
'Low-population CHOAM opening income awaits its Homeworld charity ruling.',
);
const amount = 2 * g.players.length * charityMultiplier(g);
// User ruling 7 October 2026: CHOAM's phase-opening income is a
// qualifying collection, so a low-population native Homeworld adds its
// bank bonus under the same Inflation multiplier as ordinary charity.
const multiplier = charityMultiplier(g);
const amount =
2 * g.players.length * multiplier +
homeworldLowBonus(g, choam.id) * multiplier;
choam.spice += amount;
log(g, `${choam.name} collected ${amount} spice before charity claims.`);
} else
@@ -25337,8 +25338,9 @@ function commitGuildTransport(
if (p.elites) p.elites.reserves -= elite;
} else removeGroup(p, group, eliteGroup);
if (to === 'reserves') {
p.reserves += amount;
if (p.elites) p.elites.reserves += elite;
// User ruling 7 October 2026: the Nexus Secret Ally reserve return is an
// authorized native-reserve return, so it deposits into Homeworld custody.
addNativeReserves(g, p, amount, elite);
} else {
place(p, to, sector, amount, elite);
if (advisors) {
@@ -25643,7 +25645,22 @@ function relocateMobileStronghold(
const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, p.id));
requireRule(!blocked, blocked ?? 'The mobile stronghold cannot move.');
let collected = 0;
if (move.collect)
if (move.collect) {
// User ruling 7 October 2026: passengers remaining inside a relocated HMS
// interior count as entering the outside territories the stronghold points
// into, so the Sandmaster skill collects there before the faction's own
// traversed-sector collection.
if (sandmasterLeader(g, move.player))
for (const pile of sandmasterCollectible(g, { interior: move.route })) {
g.spice[pile]--;
if (!g.spice[pile]) delete g.spice[pile];
p.spice++;
log(
g,
`${p.name}'s Sandmaster collected 1 spice as the stronghold entered ${territory(splitLocation(pile).territory).name}. This territory grants at most one collection during this relocation.`,
{ faction: p.faction, name: 'Sandmaster collection' },
);
}
for (const key of move.route) {
const amount = Math.min(g.spice[key] ?? 0, at(p, MOBILE_STRONGHOLD) * 2);
if (amount) {
@@ -25652,6 +25669,7 @@ function relocateMobileStronghold(
collected += amount;
}
}
}
g.mobileStronghold!.location = move.route.at(-1)!;
const loc = splitLocation(g.mobileStronghold!.location);
log(
@@ -27104,7 +27122,7 @@ export function executeSpecialKaramaIntent(
} else {
const n = intent.amount,
elite = intent.elite;
addRevivedReserves(g, p, n, elite);
addNativeReserves(g, p, n, elite);
p.tanks -= n;
if (p.elites) {
p.elites.tanks -= elite;
@@ -28243,8 +28261,6 @@ function applyActionInner(
else {
requireRule(action.mode === undefined, 'Choose draw, decline or a dealt skill.');
const skill = stringField(action.skill);
const unavailable = leaderSkillAssignmentUnavailable(g, id, skill);
requireRule(!unavailable, unavailable ?? 'This Leader Skill is unavailable.');
g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, stringField(action.leader), skillEligibleLeaders(g,id), random));
log(g, `${p.name} assigned ${leaderSkillCard(action.skill as Parameters<typeof leaderSkillCard>[0]).name} to a newly revived leader.`, {faction:p.faction,name:'Leader Skill'});
}
@@ -29847,8 +29863,6 @@ function applyActionInner(
if (t === 'leaderSkill') {
requireRule(Object.keys(action).every((key) => ['type','event','skill','leader'].includes(key)), 'Choose only your dealt skill and its eligible leader.');
const skill = stringField(action.skill), leader = stringField(action.leader);
const unavailable = leaderSkillAssignmentUnavailable(g, id, skill);
requireRule(!unavailable, unavailable ?? 'This Leader Skill is unavailable.');
g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, leader, skillEligibleLeaders(g, id), random));
log(g, `${p.name} assigned ${leaderSkillCard(skill as Parameters<typeof leaderSkillCard>[0]).name} to ${p.leaders.find((l) => l.id === leader)!.name}.`, { faction: p.faction, name: 'Leader Skill' });
} else if (t === 'advisorSetup') {
@@ -30933,8 +30947,6 @@ function applyActionInner(
to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent,
'Only the Guild may ship forces back to reserves.',
);
requireRule(!guildSecretEvent || to !== 'reserves' || !g.homeworlds?.custody,
'Guild Secret Ally return to Homeworld reserves awaits its route ruling.');
const advisors = to !== 'reserves' && arrivalAsAdvisor(g, p, to);
const sourceLock = p.advisors?.[origin]?.lockedTurn;
requireRule(
@@ -31735,6 +31747,7 @@ export function viewGame(state: Game, id: string) {
settleAdvisors(g);
normalizeBattle(g);
const me = getPlayer(g, id);
const choamPlayer = byFaction(g, 'choam');
let victoryProgress: StrongholdProgress[] = [];
let fremenVictory: FremenVictoryProgress | null = null;
let ecazHomeworldVictory: EcazHomeworldVictoryProgress | null = null;
@@ -32406,6 +32419,7 @@ export function viewGame(state: Game, id: string) {
incomePending: !!byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn,
incomeCanceled:
g.choamCharity?.turn === g.turn && g.choamCharity.canceled,
incomeHomeworld: choamPlayer ? homeworldLowBonus(g, choamPlayer.id) : 0,
},
moritaniAssassinate: projectedMoritaniAssassinate(g,id),
moritaniRetention: g.moritaniRetention ?? null,
+5 -4
View File
@@ -139,10 +139,11 @@ export function quoteNativeReserveWithdrawal(
});
}
/** Revival destination only: E3 p.10 restores Advanced Emperor's normal forces
* to Kaitain and Sardaukar to Salusa. Other native reserve returns must use their
* own route contract; this does not validate Tanks, cost or revival allowances. */
export function quoteNativeRevivalDeposit(
/** Authorized native-reserve deposits: E3 p.10 restores Advanced Emperor's
* normal forces to Kaitain and Sardaukar to Salusa. Used by revival and by the
* Nexus Secret Ally reserve return (user ruling 7 October 2026); it does not
* validate Tanks, cost, revival allowances or route permission. */
export function quoteNativeReserveDeposit(
context: HomeworldCustodyContext,
custody: HomeworldCustody,
actor: string,
+2 -2
View File
@@ -6,7 +6,7 @@ import {
type HomeworldCustodyContext,
} from './homeworld-custody';
import { homeworldContext } from './homeworld-game';
import { quoteNativeRevivalDeposit } from './homeworld-native-reserves';
import { quoteNativeReserveDeposit } from './homeworld-native-reserves';
import { homeworldPopulations } from './homeworld-population';
export type HomeworldRevivalDeploymentContext = Pick<
@@ -86,7 +86,7 @@ export function quoteHomeworldRevivalDeployment(
);
const beforeContext = homeworldContext(before);
const afterContext = homeworldContext(after);
const expected = quoteNativeRevivalDeposit(
const expected = quoteNativeReserveDeposit(
beforeContext,
before.homeworlds.custody,
player,
+2 -1
View File
@@ -46,7 +46,8 @@ export function nexusGuildSecretAllyQuote(
return null;
const me = g.players.find((p) => p.id === g.me)!;
if (destination === 'reserves') {
if (g.homeworlds?.worlds?.length) return null;
// User ruling 7 October 2026: the Secret Ally return grant overrides the
// Arrakis-to-native-Homeworld restriction for the holder's own reserves.
return quoteNexusGuildSecretShipment('reserves', amount);
}
const world = g.homeworlds?.worlds?.find((w) => w.id === destination);
+3 -3
View File
@@ -4461,7 +4461,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Native Richese Cunning uses the original signed two-No-Field shipment in supported single/paired E2 or selected mixed Ix+CHOAM native Nexus tables with classics, optional Skills/Homeworlds/Discovery/Tech/Advanced Strongholds. Select two distinct unused physical tokens: one reveals immediately into reserve-capped forces, one remains concealed; pay one marker invoice and spend the Nexus. The previous shipment token cannot be either choice, and the concealed one becomes last-used. Native/Guild response and actual payer/counter sources remain; a nested destination must be revealed. Prevention keeps physical tokens and unpaid spice, after any printed card cost. Sandtrout, arbitrary companions, shared allocation and other pending combinations retain their guards.',
'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable. The separate guild-betrayal profile admits only original native shipment producers and explicitly blocks this second-shipment offer before Nexus spending; Cunning outside that profile is unchanged.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to the holder’s own reserves, including the native-Homeworld return when Homeworlds are enabled, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Arbitrary foreign-world transport and concealed No-Fields are not granted.',
'Borrowed Guild/Richese tariffs do not change native faction or physical reserve origin. Non-Guild/non-Fremen off-planet arrivals can accrue Heighliners once for collection at the original phase end. A paid borrowed Guild route from Fremen southern reserves is still on-planet and does not trigger it; stopped and native-Guild-only shipments likewise remain excluded. The existing Emperor bank-auction purchase retains the actual buyer wallet and creates no industry activity.',
'Guild Betrayal has a separate fresh classic/native-Guild development preview. Before a fully funded positive shipment settles, an unallied holder may spend the physical Guild Nexus to take the full original fee, including their own pre-funded payment, instead of Guild, bank or occupied Junction income. Original donor escrow is consumed once and original physical delivery is unchanged. Neutral public acknowledgements reveal no fee/source; only private eligibility exposes Use. Zero/stopped shipments create no award or Nexus cost. See the dedicated guide; new verification remains Planned and ordinary Homeworld occupation/rounding questions stay local.',
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.',
@@ -4672,10 +4672,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'The assigned leader keeps its skill through capture and returns with it if it survives its captive battle. The known skill identifies that captured leader; Harkonnen receives only its lower battle effect. Normal effects remain unavailable to both the captor and original faction while it is captured. A killed skilled leader returns its card to the shuffled skill deck.',
'When reviving an own leader without an assigned skill, decide whether to draw before seeing two private cards. Once drawn, keep one and assign it to that revived leader. Replacement while an earlier skill is captured remains a separate unresolved case.',
'Planetologist chooses either one extra movement range (maximum three) or forces from two territories to one destination at normal range. These alternatives do not stack. The actual skilled leader can use an eligible green Special in the weapon slot for +2 if it survives; discard that Special after battle without activating its ordinary effect. Mixed advisor/fighter entry into an enemy-only territory and combined expansion modules remain unfinished.',
'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Advanced Atreides awaits the Kwisatz Haderach loss-count ruling; combined modules remain unfinished.',
'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Rescued counters still count toward Advanced Atreides’ seven battle losses for Kwisatz Haderach; combined modules remain unfinished.',
'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.',
'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.',
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. Choose each collection separately; canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides may collect once at the destination before faction arrival reactions, without a ground route or normal movement use. Multiple piles and other special relocation remain unfinished.',
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. Choose each collection separately; canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides may collect once at the destination before faction arrival reactions, without a ground route or normal movement use. When a native Ixian Hidden Mobile Stronghold relocates, its interior passengers count as entering the territories it points into, so the Sandmaster collects there before the faction’s own traversed-sector collection. Multiple piles and other special relocation remain unfinished.',
'A surviving skilled Sandmaster victory automatically adds three board spice to an existing pile in the battle territory, even with no surviving forces. Multiple piles await a placement ruling.',
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
+9 -6
View File
@@ -145,16 +145,19 @@ export function quoteRicheseSettlement(
}
/**
* The existing declaration boundary rejects an exhausted cache. Use when a
* canceled/unbid Black Market lot will immediately enter richeseDeclaration;
* settlement quoting alone deliberately does not execute that continuation.
* A sold Black Market lot may first pause for replacement/bonus handling.
* Use when a canceled/unbid Black Market lot will immediately enter
* richeseDeclaration; settlement quoting alone deliberately does not execute
* that continuation. A sold Black Market lot may first pause for
* replacement/bonus handling.
*
* User ruling 7 October 2026: an exhausted cache is legal — the ordinary pool
* then runs its full declared count — so only a malformed count is rejected.
*/
export function requireRicheseDeclarationCache(
cacheCount: number | undefined,
): void {
requireSettlement(
Number.isSafeInteger(cacheCount) && cacheCount! > 0,
'An exhausted Richese cache needs an official ruling on the normal auction count.',
Number.isSafeInteger(cacheCount) && cacheCount! >= 0,
'The Richese cache count must be a readable non-negative number.',
);
}
+2 -1
View File
@@ -248,7 +248,8 @@ export function quoteSukPhysicalRescue(
...transaction.players[index],
tanks: player.tanks + (player.id === request.player ? rescue.tanks.normal + rescue.tanks.elite : 0),
eliteTanks: player.eliteTanks + (player.id === request.player ? rescue.tanks.elite : 0),
battleLosses: player.battleLosses + (player.id === request.player ? rescue.tanks.normal + rescue.tanks.elite : 0),
battleLosses: player.battleLosses + (player.id === request.player
? request.losses.normal + request.losses.elite : 0),
boardForces: !home && player.id === request.player
? { normal: player.boardForces.normal - removedTotal.normal, elite: player.boardForces.elite - removedTotal.elite }
: { ...player.boardForces },