Implement High Richese mixed No-Field shipments

This commit is contained in:
Quintine committed 2026-10-25 19:56:38 +11:00
1 parent 052737d0f9
commit 5bcf5bab48
16 files changed
+912 -54

No files matched your search

+17 -17
View File
@@ -59,6 +59,7 @@ import {
paidForceRevivalCost,
} from './revival';
import { controlsLeader, nativeAvailable } from './leader-control';
import { quoteRicheseHomeworldForces } from './richese-homeworld-shipment';
import { CHEAP_HERO_TRAITOR, matchingTraitor } from './traitors';
import {
treacheryDeck,
@@ -3431,23 +3432,22 @@ function policyActions(g: GameView): Action[] {
}
if (selected)
for (const to of markerTargets.slice(0, 24)) {
if (Math.min(selected.value, me.reserves) === 0 && to.enemy > 0)
continue;
const cost = reserveShipmentCost(
{ faction: me.faction, halfRate },
territory(to.t).type,
1,
);
if (cost > shipmentBudget) continue;
actions.push({
type: 'ship',
noField: selected.id,
event: noField.event,
territory: to.t,
sector: to.s,
allyPayment: Math.max(0, cost - (me.spice ?? 0)),
...(quoteSmugglerNoField(g, me.id, to.t) ? { smuggler: true } : {}),
});
const smuggler = !!quoteSmugglerNoField(g, me.id, to.t);
const companions = noField.homeworldForcesEnabled && me.reserves > (smuggler ? 1 : 0) ? [1, 0] : [0];
for (const amount of companions) {
const group = quoteRicheseHomeworldForces({ enabled: !!noField.homeworldForcesEnabled,
reserves: me.reserves, amount, smuggler });
if (Math.min(selected.value, me.reserves) + group.physical === 0 && to.enemy > 0) continue;
const cost = reserveShipmentCost({ faction: me.faction, halfRate }, territory(to.t).type, group.priced);
if (cost > shipmentBudget) continue;
actions.push({
type: 'ship', noField: selected.id, event: noField.event,
territory: to.t, sector: to.s,
allyPayment: Math.max(0, cost - (me.spice ?? 0)),
...(smuggler ? { smuggler: true } : {}),
...(amount ? { noFieldReserves: amount } : {}),
});
}
}
}
if (marker && noField && !g.homeworldMobility?.noFieldMovementBlocked && (me.moved ?? 0) < (me.movesAllowed ?? 1)) {
+58 -24
View File
@@ -570,6 +570,7 @@ import {
import type { MixedShipmentExpression } from './mixed-shipment-question';
import { controlsLeader, nativeAvailable } from './leader-control';
import { ixHomeworldRevivalEnabled, quoteIxSuboidBonus } from './ix-homeworld-revival';
import { richeseHomeworldShipmentEnabled, quoteRicheseHomeworldForces, RicheseHomeworldShipmentError } from './richese-homeworld-shipment';
import type { EcazRevivalCohort } from './ecaz-revival-cohort';
import {
newRevivalRules,
@@ -1210,6 +1211,8 @@ type PendingShipment = {
/** Only new declarations use the bonus; legacy quoted shipments retain their original price. */
smuggler?: SmugglerShipment;
smugglerCompanion?: SmugglerNoFieldCompanion;
/** Actual paid ordinary reserves beside a marker; the marker remains amount1. */
noFieldReserves?: number;
noFieldSkillProof?: string;
guildSecretEvent?: string;
guildRateEvent?: string;
@@ -10632,7 +10635,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
Object.keys(shipment).every(key => ['turn', 'player', 'territory', 'sector', 'amount', 'elite',
'cost', 'allyPayment', 'advisors', 'homeworldSources', 'guildRateEvent',
'smuggler', 'smugglerCompanion', 'guildSecretEvent', 'guildNexusEvent', 'nexusEvent',
'richesePair', 'noField', 'alliedNoField', 'noFieldSkillProof'].includes(key)),
'richesePair', 'noField', 'alliedNoField', 'noFieldSkillProof', 'noFieldReserves'].includes(key)),
'The saved reserve shipment contains foreign source fields.');
requireRule(!shipment.source && !shipment.guildSecretEvent &&
((!shipment.noField && !shipment.alliedNoField && !shipment.richesePair) || nexusCoreProfile(g)) &&
@@ -16547,7 +16550,7 @@ function quoteShipmentArrival(
const p = getPlayer(g, shipment.player);
const amount = shipment.alliedNoField
? Math.max(1, shipment.amount)
: shipment.amount + (shipment.smugglerCompanion ? 1 : 0);
: shipment.amount + (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0);
const guild = byFaction(g, 'guild');
const currentDecision = !!(
g.decision &&
@@ -16602,6 +16605,7 @@ function recoverableUnsupportedShipment(shipment: PendingShipment) {
shipment.source === undefined &&
shipment.ambassadorEvent === undefined &&
shipment.noField === undefined &&
shipment.noFieldReserves === undefined &&
shipment.noFieldSkillProof === undefined &&
shipment.alliedNoField === undefined &&
shipment.smuggler === undefined &&
@@ -30168,12 +30172,22 @@ function finishHomeworldGuildRateResponse(g: Game, response: ResponseWindow, can
function noFieldSkillProof(shipment: PendingShipment): string {
return JSON.stringify({ ...shipment, noFieldSkillProof: undefined });
}
function currentRicheseHomeworldForces(g: Game, p: Player, amount: number, smuggler = false) {
try {
return quoteRicheseHomeworldForces({ enabled: richeseHomeworldShipmentEnabled(g, p.id),
reserves: p.reserves, amount, smuggler });
} catch (error) {
if (error instanceof RicheseHomeworldShipmentError) throw new RuleError(error.message);
throw error;
}
}
function validateLeaderSkillNoFieldShipment(g: Game, shipment: PendingShipment) {
requireRule(g.leaderSkills && smugglerNoFieldModeSupported(g) && shipment.noField &&
const mixed = shipment.noFieldReserves !== undefined;
requireRule((mixed || g.leaderSkills && smugglerNoFieldModeSupported(g)) && shipment.noField &&
!shipment.alliedNoField && !shipment.smuggler && !shipment.source &&
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent &&
shipment.homeworldSources === undefined && shipment.ambassadorEvent === undefined,
'Smuggler No-Field shipping requires an own Richese marker without other shipment routes or modules.');
'A paid Homeworld group or Smuggler shipment requires its own Richese marker without other shipment routes.');
requireRule(typeof shipment.noFieldSkillProof === 'string' &&
shipment.noFieldSkillProof === noFieldSkillProof(shipment),
'The saved No-Field shipment changed its original marker or optional Smuggler companion.');
@@ -30196,10 +30210,16 @@ function validateLeaderSkillNoFieldShipment(g: Game, shipment: PendingShipment)
shipment.smugglerCompanion !== null,
'The saved Smuggler No-Field companion lost its living trainer, reserve force or empty destination.',
);
if (mixed) integer(shipment.noFieldReserves, 1, p.reserves, 'Paid No-Field reserve forces');
const group = currentRicheseHomeworldForces(g, p, shipment.noFieldReserves ?? 0, !!shipment.smugglerCompanion);
if (g.homeworlds?.custody && group.physical) homeworldRule(() =>
quoteNativeReserveWithdrawal(homeworldContext(g), g.homeworlds!.custody!, p.id,
{ normal: group.physical, elite: 0 }));
const cost = reserveShipmentCost({ faction: p.faction, halfRate: shipmentHalfRate(g, p) },
territory(shipment.territory).type, 1);
territory(shipment.territory).type, group.priced);
requireRule(Number.isSafeInteger(shipment.cost) && shipment.cost === cost,
'A No-Field with an optional Smuggler force keeps the price of one marker.');
mixed ? 'The No-Field invoice must price one marker plus its paid reserve forces.'
: 'A No-Field with an optional Smuggler force keeps the price of one marker.');
integer(p.spice, 0, Number.MAX_SAFE_INTEGER, 'Current No-Field shipment spice');
integer(shipment.allyPayment, 0, cost, 'No-Field shipment ally payment');
const credit = aidFor(g, p);
@@ -30241,7 +30261,7 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean {
return true;
}
const pair = !!g.leaderSkills && !!shipment?.richesePair;
if (!(pair || shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined ||
if (!(pair || shipment?.noFieldReserves !== undefined || shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined ||
(g.leaderSkills && shipment?.noField) || responses.some(r => r.noFieldSkillProof !== undefined) ||
decisions.some(d => 'noFieldSkillProof' in d))) return false;
requireRule(shipment, 'The saved No-Field response lost its original shipment.');
@@ -30262,10 +30282,12 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean {
return true;
}
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
requireRule(shipment.noFieldReserves === undefined || shipment.noField && !shipment.richesePair,
'Paid No-Field reserves require a single native concealed marker.');
requireRule(!atomicsShipmentBlocked(g.moritaniAtomics, shipment.territory),
'Atomics Aftermath permanently blocks shipments into this territory.');
// The pair has its original signed two-token validator below; skill proofs bind single markers.
if (!shipment.richesePair && (shipment.smugglerCompanion !== undefined || shipment.noFieldSkillProof !== undefined ||
if (!shipment.richesePair && (shipment.noFieldReserves !== undefined || shipment.smugglerCompanion !== undefined || shipment.noFieldSkillProof !== undefined ||
(g.leaderSkills && shipment.noField))) validateLeaderSkillNoFieldShipment(g, shipment);
if (shipment.smuggler) requireRule(
!shipment.source && !shipment.noField && !shipment.alliedNoField &&
@@ -30396,7 +30418,7 @@ function validateGuildShipmentDecision(
decision.territory === shipment.territory &&
decision.sector === shipment.sector &&
decision.noFieldSkillProof === shipment.noFieldSkillProof &&
decision.amount === (shipment.alliedNoField ? 1 : shipment.amount),
decision.amount === (shipment.alliedNoField ? 1 : shipment.amount + (shipment.noFieldReserves ?? 0)),
'The Guild decision does not match the pending shipment.',
);
validatePhysicalShipment(g, shipment);
@@ -30411,7 +30433,8 @@ const CLASSIC_FACTIONS: Partial<Record<FactionId, true>> = {
function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | null {
const guild = byFaction(g, 'guild');
const p = getPlayer(g, shipment.player);
const pricedAmount = shipment.noField || shipment.alliedNoField || shipment.nexusEvent ? 1
const pricedAmount = shipment.noField ? 1 + (shipment.noFieldReserves ?? 0)
: shipment.alliedNoField || shipment.nexusEvent ? 1
: shipment.amount - (shipment.smuggler ? 1 : 0);
if (!guild || (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) ||
g.karamaShipping?.player === p.id ||
@@ -30837,7 +30860,7 @@ function offerShipment(g: Game, shipment: PendingShipment, matchingApproved = fa
return;
}
const { territory: to, sector: s } = shipment;
const n = shipment.alliedNoField || shipment.ambassadorMarker ? 1 : shipment.amount;
const n = shipment.alliedNoField || shipment.ambassadorMarker ? 1 : shipment.amount + (shipment.noFieldReserves ?? 0);
const guild = byFaction(g, 'guild');
if (
g.advanced &&
@@ -30860,7 +30883,7 @@ function offerShipment(g: Game, shipment: PendingShipment, matchingApproved = fa
};
log(
g,
`${p.name} declared a shipment of ${shipment.noField ? `one concealed No-Field${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''}` : `${n} forces`} to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`,
`${p.name} declared a shipment of ${shipment.noField ? `one concealed No-Field${shipment.noFieldReserves ? ` and ${shipment.noFieldReserves} paid reserve forces` : ''}${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''}` : `${n} forces`} to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`,
);
} else if (!openGuildRate(g, shipment)) commitShipment(g, shipment);
}
@@ -30882,7 +30905,9 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
finishShipmentPromises(
g,
getPlayer(g, shipment.player),
shipment.noField || shipment.alliedNoField ? null : shipment,
shipment.noField ? (shipment.noFieldReserves || shipment.smugglerCompanion
? { territory: shipment.territory, amount: (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0) }
: null) : shipment.alliedNoField ? null : shipment,
);
const {
territory: to,
@@ -30988,9 +31013,10 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
.join('; ');
if (n > 0) place(p, to, s, n, elite);
}
if (shipment.smugglerCompanion) {
withdrawNativeReserves(g, p, 1, 0);
place(p, to, s, 1, 0);
const markerCompanions = (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0);
if (markerCompanions) {
withdrawNativeReserves(g, p, markerCompanions, 0);
place(p, to, s, markerCompanions, 0);
}
if (advisors && n > 0) (p.advisors ??= {})[to] ??= {};
p.shipped = true;
@@ -31021,7 +31047,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
: shipment.noField
? pairRevealed
? `${p.name} shipped two No-Fields with Richese Nexus Cunning to ${territory(to).name}, sector ${s}, for ${cost} spice. The faceup ${pairRevealed.value} token placed ${pairRevealed.forces} physical forces from reserves${pairRevealed.forces < pairRevealed.value ? ' (only the available reserves)' : ''}; the other token remains concealed and counts as one force.`
: `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. ${shipment.smugglerCompanion ? 'One free Smuggler force left reserves and arrived beside the marker; the concealed token remains unchanged until reveal.' : 'It counts as one force; physical reserves remain unchanged until reveal.'}`
: `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. ${shipment.noFieldReserves ? `${shipment.noFieldReserves} paid reserve forces${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''} arrived beside the marker; the concealed token remains unchanged until reveal.` : shipment.smugglerCompanion ? 'One free Smuggler force left reserves and arrived beside the marker; the concealed token remains unchanged until reveal.' : 'It counts as one force; physical reserves remain unchanged until reveal.'}`
: `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.guildSecretEvent ? ` Guild Secret Ally is spent; Guild shipping prices cost ${cost} spice paid to the bank.` : ''}${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`,
shipment.noField || shipment.alliedNoField
? { faction: 'richese', name: 'No-Field shipment' }
@@ -31052,7 +31078,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
p,
to,
s,
shipment.alliedNoField ? Math.max(1, n) : n + (shipment.smugglerCompanion ? 1 : 0) + (pairRevealed?.forces ?? 0),
shipment.alliedNoField ? Math.max(1, n) : n + markerCompanions + (pairRevealed?.forces ?? 0),
elite,
'shipment',
);
@@ -36711,9 +36737,10 @@ function applyActionInner(
'Richese Secret Ally requires an ordinary physical reserve shipment.');
nexusEvent = offer.event;
}
checkShipmentPromises(g, p, {
territory: String(action.territory),
amount: richesePair ? 1 : Number(action.amount),
requireRule(action.noFieldReserves === undefined || action.noField !== undefined && !richesePair,
'Paid No-Field reserves accompany one native concealed marker.');
if (action.noField === undefined) checkShipmentPromises(g, p, {
territory: String(action.territory), amount: Number(action.amount),
});
const to = String(action.territory),
s = integer(action.sector, 0, 18, 'Sector');
@@ -36768,9 +36795,12 @@ function applyActionInner(
? quoteSmugglerNoField(g, id, to) : null;
requireRule(action.smuggler !== true || smuggler || smugglerCompanion,
'Smuggler needs a living face-up native trainer and an empty territory, with two ordinary shipment forces or one reserve companion beside your No-Field.');
const noFieldReserves = action.noFieldReserves === undefined ? 0
: integer(action.noFieldReserves, 1, p.reserves, 'Paid No-Field reserve forces');
const noFieldGroup = noField ? currentRicheseHomeworldForces(g, p, noFieldReserves, !!smugglerCompanion) : null;
const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(to).type,n)).cost : nexusEvent
? nexusRule(() => quoteNexusRicheseShipment(price,territory(to).type,n)).cost
: reserveShipmentCost(price,territory(to).type,n - (smuggler ? 1 : 0));
: reserveShipmentCost(price,territory(to).type,noFieldGroup?.priced ?? n - (smuggler ? 1 : 0));
if (p.faction === 'fremen' && !guildSecretEvent) {
requireRule(
territory('the_great_flat').sectors.some((fs) =>
@@ -36790,6 +36820,7 @@ function applyActionInner(
const shipment: PendingShipment = {
...(smuggler ? { smuggler } : {}),
...(smugglerCompanion ? { smugglerCompanion } : {}),
...(noFieldReserves ? { noFieldReserves } : {}),
...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}),
...(nexusEvent ? {nexusEvent} : {}),
...(guildSecretEvent ? {guildSecretEvent} : {}),
@@ -36811,7 +36842,9 @@ function applyActionInner(
}),
...(noField ? { noField } : {}),
};
if (noField && g.leaderSkills && !richesePair) {
if (noField) checkShipmentPromises(g, p, noFieldGroup!.physical
? { territory: to, amount: noFieldGroup!.physical } : null);
if (noField && (g.leaderSkills || noFieldReserves) && !richesePair) {
shipment.noFieldSkillProof = noFieldSkillProof(shipment);
validatePhysicalShipment(g, shipment);
}
@@ -36838,7 +36871,7 @@ function applyActionInner(
...(shipment.noFieldSkillProof ? { noFieldSkillProof: shipment.noFieldSkillProof } : {}) };
log(
g,
`${p.name} declared a concealed No-Field shipment${richesePair ? ' with Richese Nexus Cunning and one faceup token' : ''}${smugglerCompanion ? ' with one free Smuggler force' : ''} to ${territory(to).name}, sector ${s}.${richesePair ? ' The Nexus card is spent; payment and both token effects await the responses.' : ''}`,
`${p.name} declared a concealed No-Field shipment${richesePair ? ' with Richese Nexus Cunning and one faceup token' : ''}${noFieldReserves ? ` with ${noFieldReserves} paid reserve forces` : ''}${smugglerCompanion ? ' with one free Smuggler force' : ''} to ${territory(to).name}, sector ${s}.${richesePair ? ' The Nexus card is spent; payment and both token effects await the responses.' : ''}`,
);
} else offerShipment(g, shipment);
return g;
@@ -38474,6 +38507,7 @@ export function viewGame(state: Game, id: string) {
? projectRicheseNoField(owner.noField, true).private
: null,
canShip: !shipBlock,
homeworldForcesEnabled: mine && richeseHomeworldShipmentEnabled(g, owner.id),
shipBlock,
canReveal: !revealBlock,
revealBlock,
+9 -1
View File
@@ -4602,6 +4602,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Caladan shares the admitted original Bidding inspection with the occupier, not its ally; native inspection can still be prevented independently. Ix gives the occupier the actual private drawn pool and selection, not unrelated setup or Technology. Richese sets its own method/direction before the occupier chooses only the physical cache card; seller, payment and other decisions stay native. Giedi gives one original purchase bonus to the occupier or its reciprocal ally with real receiver capacity, even when Harkonnen’s purchased card fills its hand.',
'The separately marked fresh Tupile entry grants one hand slot to the proved occupier and reciprocal ally. Every server-derived hand limit includes it, so Harkonnen can hold nine without receiving a tenth bonus. After a proved loss, each old holder privately selects exactly its excess original eligible cards to return to the normal limit; committed cards remain protected. Original phase opening and remaining movement resume without another shipment or payment. Basic unknown lifecycle blocks rather than guesses cleanup. Advanced departure can restore CHOAM’s low intelligence without clearing its previous answers or used factions.',
'The fresh native Ecaz high-card preview shows a separate prospective conjunction: native seven-plus population, at least one actual jointly held stronghold and proved current alliance holdings of two other native factions’ Homeworlds. This does not change the ordinary four-stronghold or three-joint Ecaz targets. Original Mentat decides the win before prediction/fallback; source uncertainty remains blocked. Duke authority and the Ecaz-only revival ruling are separate.',
'At High, Richese may send paid physical reserve forces beside one concealed No-Field. The persistent selector names only actual counters; the marker stays priced as one and its hidden value stays in reserves until reveal. One rounded invoice for marker plus paid group and pre-group High timing are provisional. Actual departure can cross Low; later reveal uses remaining reserves. The optional Smuggler force stays separately free. Original No-Field/Guild responses stay; the two-token Cunning route remains separate.',
'Ix High adds up to two physical Suboids from Tanks per actually paid Cyborg in an ordinary revival. The persistent quote follows selected free types. Its additional allowance, partial shortage and free-event income are explicit provisional compositions; prior La La La suppresses new bonuses, while native cancellation and atomic request-stop outcomes remain. Recruits does not multiply the bonus. Old accepted requests are not retrofitted.',
'The card collection below is available for reference. Homeworld starts are enabled in supported fresh preview envelopes; the remaining occupation variants and combinations stay under verification.',
'Original classic World/Nexus uses base33 or selected independent36 with optional all14, Tech3+ and Advanced Strongholds2+ with real first-Storm token owners and end-Mentat card claims. Guild World shipments and Cunning visitor returns pass native permission/rate before the funded fee; borrowed Caladan fee1/fee5 settles to the distinct holder while typed custody waits. Temporary-Sardaukar own-Kaitain battles keep ordinary-only casualties and true Salusa custody; Giedi native-call cancellation follows original capture/execution/cleanup and one private replacement. Normal Smuggler companions may also supply the fee in core. On an own World, Reinforcements provisionally spends ordinary non-role counters first, reducing temporary Sardaukar roles only when converted counters pay. Kaitain7 paying local3 leaves four temporary roles on four normals; the original five-counter declaration and pre-fee traits remain. Other envelopes and complete-mode acceptance stay separate.',
@@ -4621,7 +4622,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
"All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
"All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
evidence: [
'game/faction-module-profile.ts',
'game/reinforcements-homeworld.ts',
@@ -4629,6 +4630,13 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/nexus-world-independent-cards.test.ts',
'tests/nexus-world-independent-battles.test.ts',
'game/homeworld-cards.ts',
'game/richese-homeworld-shipment.ts',
'components/richese-homeworld-forces.tsx',
'tests/fixture-richese-homeworld-shipment.ts',
'tests/richese-homeworld-shipment-runtime.test.ts',
'tests/richese-homeworld-shipment-price.test.ts',
'tests/richese-homeworld-shipment.test.ts',
'tests/richese-homeworld-forces-controls.test.ts',
'game/ix-homeworld-revival.ts',
'components/ix-homeworld-revival.tsx',
'tests/fixture-ix-homeworld-revival-bonus.ts',
+45
View File
@@ -0,0 +1,45 @@
import type { Game } from './engine';
import { homeworldContext } from './homeworld-game';
import { homeworldPopulations } from './homeworld-population';
type RicheseHomeworldContext = Pick<Game, 'advanced' | 'players' | 'homeworlds'>;
export type RicheseHomeworldForcesInput = {
enabled: boolean;
reserves: number;
amount: number;
smuggler?: boolean;
};
export type RicheseHomeworldForcesQuote = { paid: number; physical: number; priced: number };
export class RicheseHomeworldShipmentError extends Error {
constructor(message: string) {
super(message);
this.name = 'RicheseHomeworldShipmentError';
}
}
/** The printed High Richese modifier applies in either rules band, using only
* native counters currently at Richese rather than foreign visitors. */
export function richeseHomeworldShipmentEnabled(context: RicheseHomeworldContext, playerId: string): boolean {
if (!context.homeworlds?.custody ||
!context.players.some(player => player.id === playerId && player.faction === 'richese')) return false;
return homeworldPopulations(homeworldContext(context), context.homeworlds.custody)
.some(home => home.native === playerId && home.card === 'richese' && home.side === 'high');
}
const count = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= 20;
/** One priced No-Field marker plus the selected paid physical reserve forces.
* The concealed value never supplies counters or changes this invoice. */
export function quoteRicheseHomeworldForces(input: RicheseHomeworldForcesInput): RicheseHomeworldForcesQuote {
const { enabled, reserves, amount, smuggler } = input;
if (typeof enabled !== 'boolean' || !count(reserves) || !count(amount) ||
(smuggler !== undefined && typeof smuggler !== 'boolean'))
throw new RicheseHomeworldShipmentError('Choose zero to twenty physical reserve forces and a valid shipment source.');
if (amount > 0 && !enabled)
throw new RicheseHomeworldShipmentError('Paid forces alongside a No-Field require native Richese at High population.');
const physical = amount + (smuggler ? 1 : 0);
if (physical > reserves)
throw new RicheseHomeworldShipmentError('The paid forces and any Smuggler force must fit the available physical reserves.');
return { paid: amount, physical, priced: 1 + amount };
}