Prototype Richese No-Fields on foreign Homeworlds

This commit is contained in:
Quintine committed 2026-10-28 20:04:03 +11:00
1 parent dd3c199e2e
commit 850227c26d
71 files changed
+1806 -187

No files matched your search

+155 -43
View File
@@ -185,7 +185,7 @@ import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusCh
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards';
import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase';
import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history';
import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, homeworldOccupationMarker, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history';
import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence';
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
import { createTupileIntelligenceState, appendTupileIntelligenceObservation, validateTupileIntelligenceState, type TupileIntelligenceState } from './tupile-intelligence-state';
@@ -274,6 +274,7 @@ import {
import {
createHomeworldCustody,
HomeworldCustodyError,
quoteHomeworldCustody,
type HomeworldCustody,
} from './homeworld-custody';
import {
@@ -546,6 +547,7 @@ import {
type RicheseGiftIntent,
} from './richese-gift';
import { richeseCards, richeseCardDefinition } from './richese-cards';
import { quoteRicheseHomeworldMarkerShipment } from './richese-homeworld-marker-shipment';
import {
createRicheseNoField,
deployRicheseNoField,
@@ -556,6 +558,8 @@ import {
recallRicheseNoField,
shipAlliedRicheseNoField,
projectRicheseNoField,
isHomeworldNoFieldLocation,
isBoardNoFieldLocation,
type RicheseNoField,
type NoFieldRevealCause,
} from './richese-no-field';
@@ -1223,7 +1227,7 @@ export type ResponseWindow = {
occupiedPayment?: { event: string; contributions: { payer: string; amount: number }[] };
/** Bank-paid portion of a frozen Homeworld charity claim. */
charityHomeworld?: number;
source?: 'ambassador' | 'ambassadorRevival' | 'ambassadorNoField' | 'ambassadorNoFieldIncome';
source?: 'ambassador' | 'ambassadorRevival' | 'ambassadorNoField' | 'ambassadorNoFieldIncome' | 'homeworldNoField';
intent?: string;
advisors?: boolean;
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
@@ -1325,6 +1329,8 @@ type RichesePreCostDeclaration = { round: string; lot: string } & (
| { kind: 'special'; intent: Extract<SpecialKaramaIntent, { kind: 'richese' }> }
);
type PendingHomeworldShipment = HomeworldShipmentIntent & {
/** The selected physical marker and its original private inventory event. */
noField?: { tokenId: string; event: string };
guildSecretEvent?: string;
guildNexusEvent?: string;
guildRateEvent?: string;
@@ -4715,7 +4721,12 @@ function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) {
p.faction === 'richese' && p.noField?.deployed,
'No concealed No-Field is deployed.',
);
requireRule(validGameLocation(g, p.noField!.deployed!.location.territory, p.noField!.deployed!.location.sector),
const deployedLocation = p.noField.deployed.location;
if (isHomeworldNoFieldLocation(deployedLocation)) {
requireRule(g.homeworlds?.custody &&
homeworldForceGroups(homeworldContext(g), g.homeworlds.custody).some(world => world.id === deployedLocation.homeworld),
'A No-Field can reveal only on an active Homeworld.');
} else requireRule(validGameLocation(g, deployedLocation.territory, deployedLocation.sector),
'A No-Field can reveal only in a current board location.');
const result = noFieldRule(() =>
revealRicheseNoField(p.noField!, {
@@ -4734,19 +4745,35 @@ function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) {
cohort?.noField?.tokenId === p.noField!.deployed!.tokenId &&
cohort.noField.event === p.noFieldEvent
) {
requireRule(isBoardNoFieldLocation(result.location), 'A Homeworld No-Field cannot belong to a board movement cohort.');
const key = location(result.location.territory, result.location.sector);
cohort.forces[key] = (cohort.forces[key] ?? 0) + result.forces;
delete cohort.noField;
}
p.noField = result.state;
p.noFieldEvent = crypto.randomUUID();
p.reserves -= result.forces;
if (result.forces)
place(p, result.location.territory, result.location.sector, result.forces);
if (isHomeworldNoFieldLocation(result.location)) {
const destination = result.location.homeworld;
if (result.forces) {
const transfer = homeworldRule(() => quoteHomeworldCustody(homeworldContext(g), g.homeworlds!.custody!, [
{ homeworld: 'homeworld:richese', player: p.id, withdraw: { normal: result.forces, elite: 0 }, deposit: { normal: 0, elite: 0 } },
{ homeworld: destination, player: p.id, withdraw: { normal: 0, elite: 0 }, deposit: { normal: result.forces, elite: 0 } },
]));
g.homeworlds!.custody = transfer.state;
for (const seat of transfer.players) {
const owner = getPlayer(g, seat.id);
owner.reserves = seat.reserves;
if (owner.elites) owner.elites.reserves = seat.eliteReserves;
}
}
} else {
p.reserves -= result.forces;
if (result.forces) place(p, result.location.territory, result.location.sector, result.forces);
}
observeOccupation(g);
log(
g,
`${p.name} revealed the ${result.value} No-Field in ${territory(result.location.territory).name} (${cause === 'alliedSeparation' ? 'allied separation' : cause === 'discoveryBlow' ? 'Discovery spice blow; provisional destructive reveal' : cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`,
`${p.name} revealed the ${result.value} No-Field in ${isHomeworldNoFieldLocation(result.location) ? combatLocationName(g, result.location.homeworld) : territory(result.location.territory).name} (${cause === 'alliedSeparation' ? 'allied separation' : cause === 'discoveryBlow' ? 'Discovery spice blow; provisional destructive reveal' : cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`,
{ faction: 'richese', name: 'No-Field revealed' },
);
return result;
@@ -5546,10 +5573,10 @@ function currentAtomicsQuote(
requireRule(terrorEntryLocationAllowed(g,entry), 'Atomics lost its original physical placement source.');
// Provisional explosion composition: quote the same capped physical reveal
// that allowance will perform. Forecasts never change or expose a live marker.
const forecast = g.players.some(player => player.noField?.deployed?.location.territory === entry.territory)
const forecast = g.players.some(player => noFieldAtCombatLocation(player,entry.territory))
? structuredClone(g) : g;
if (forecast !== g) for (const player of forecast.players)
if (player.noField?.deployed?.location.territory === entry.territory)
if (noFieldAtCombatLocation(player,entry.territory))
revealPlayerNoField(forecast, player, 'atomics');
return atomicsRule(() => quoteMoritaniAtomics({
token, territory: entry.territory, moritaniId: byFaction(g, 'moritani')!.id,
@@ -5567,7 +5594,7 @@ function resolveAtomics(
quote: AtomicsQuote,
) {
for (const player of g.players)
if (player.noField?.deployed?.location.territory === entry.territory)
if (noFieldAtCombatLocation(player,entry.territory))
revealPlayerNoField(g, player, 'atomics');
g.moritaniAtomics = quote.aftermath;
for (const casualty of quote.casualties) {
@@ -8694,8 +8721,8 @@ function treacheryDiscardIntegrity(g: Game) {
m.total - (m.noField ? 1 : 0) &&
(owner.elites?.forces[location(m.to, m.sector)] ?? 0) >= m.elite &&
(!m.noField ||
(owner.noField?.deployed?.location.territory === m.to &&
owner.noField.deployed.location.sector === m.sector)),
(boardNoFieldLocation(owner)?.territory === m.to &&
boardNoFieldLocation(owner)?.sector === m.sector)),
'The retired Ornithopter arrival differs from its committed movement.',
);
} else
@@ -10302,6 +10329,14 @@ function settleHomeworldExplosion(g: Game, choice: HomeworldForces, automatic: b
g.homeworldBattleLoss = null;
finishBattle(g);
}
function boardNoFieldLocation(p: Player) {
const loc = p.noField?.deployed?.location;
return loc && isBoardNoFieldLocation(loc) ? loc : null;
}
function noFieldAtCombatLocation(p: Player, id: string): boolean {
const loc = p.noField?.deployed?.location;
return !!loc && (isHomeworldNoFieldLocation(loc) ? loc.homeworld === id : loc.territory === id);
}
function killTerritory(
g: Game,
p: Player,
@@ -10312,10 +10347,14 @@ function killTerritory(
) {
if (t.startsWith('homeworld:')) {
requireRule(battle && n === Infinity, 'Homeworld force removal requires an exact combat casualty allocation.');
if (noFieldAtCombatLocation(p,t)) {
requireRule(noFieldCause, 'This removal of a concealed No-Field awaits its specific reveal and casualty rules.');
revealPlayerNoField(g,p,noFieldCause);
}
commitHomeworldLoss(g, p.id, t, combatArmy(g, p.id, t));
return;
}
if (p.noField?.deployed?.location.territory === t) {
if (noFieldAtCombatLocation(p,t)) {
requireRule(
noFieldCause,
'This removal of a concealed No-Field awaits its specific reveal and casualty rules.',
@@ -10333,7 +10372,7 @@ function killTerritory(
* before requesting a pool. Actual reveal remains the native component action. */
function prospectiveNoFieldForces(p: Player, territory: string): number {
const marker = p.noField?.deployed;
return marker?.location.territory === territory
return marker && noFieldAtCombatLocation(p,territory)
? Math.min(p.reserves, p.noField!.tokens.find(token => token.id === marker.tokenId)!.value)
: 0;
}
@@ -10379,7 +10418,7 @@ function occupyArmiesConnected(g: Game, ecaz: Player, ally: Player, t: string):
for (const y in ally.forces)
if (ally.forces[y] > 0 && splitLocation(y).territory === t &&
gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0) return true;
if (marker?.location.territory === t &&
if (marker && isBoardNoFieldLocation(marker.location) && marker.location.territory === t &&
gameDistance(g, x, location(t, marker.location.sector), key => splitLocation(key).sector === g.storm) === 0) return true;
}
return false;
@@ -10395,7 +10434,7 @@ function battleNoFieldPlan(g: Game, id: string): boolean {
}
function battleConcealedForces(g: Game, id: string): boolean {
const b = g.battle;
return !!b && getPlayer(g, battlePhysicalForceOwner(g, id)).noField?.deployed?.location.territory === b.territory;
return !!b && noFieldAtCombatLocation(getPlayer(g, battlePhysicalForceOwner(g, id)),b.territory);
}
function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
const b = g.battle, occupy = b?.ecazOccupy;
@@ -13421,7 +13460,7 @@ function continueStorm(g: Game) {
returnAmbassadorsIn(g, city, 'the storm crossed the exposed city');
for (const p of g.players) {
const marker = p.noField?.deployed?.location;
if (marker && marker.sector === s) {
if (marker && isBoardNoFieldLocation(marker) && marker.sector === s) {
const t = territory(marker.territory);
if (
stormExposesTerritory(t.id, g.shieldWallDestroyed)
@@ -13895,8 +13934,8 @@ function discoveryEntryIntegrity(g: Game) {
(owner.elites?.forces[key] ?? 0) === arrival.destinationEliteBefore + arrival.elite,
'The Discovery arrival has lost its transferred ordinary or elite force custody.');
if (marker) requireRule(owner.faction === 'richese' && owner.noField?.deployed?.tokenId === marker.tokenId &&
owner.noField.deployed.controller === owner.id && owner.noField.deployed.location.territory === arrival.destination &&
owner.noField.deployed.location.sector === 0 && !!owner.noFieldEvent && owner.noFieldEvent !== marker.event,
owner.noField.deployed.controller === owner.id && boardNoFieldLocation(owner)?.territory === arrival.destination &&
boardNoFieldLocation(owner)?.sector === 0 && !!owner.noFieldEvent && owner.noFieldEvent !== marker.event,
'The Discovery arrival has lost its transferred concealed No-Field.');
}
const automatic = g.pendingAutomaticAdvisorRelocation;
@@ -13964,7 +14003,8 @@ function decideDiscoveryEntry(g: Game, p: Player, action: Action) {
return;
}
const marker = action.noField === undefined && action.noFieldEvent === undefined ? undefined : (() => {
requireRule(p.noField?.deployed, 'Choose your currently deployed concealed No-Field.');
requireRule(p.noField?.deployed && isBoardNoFieldLocation(p.noField.deployed.location),
'Choose your currently deployed Arrakis No-Field.');
return {tokenId:stringField(action.noField),event:stringField(action.noFieldEvent),
source:location(p.noField.deployed.location.territory,p.noField.deployed.location.sector)};
})();
@@ -18060,7 +18100,7 @@ function reinforcementsPreview(g: Game, p: Player) {
return { blocked, normal, elite,
...(!blocked && g.homeworlds?.custody ? { homeworldSources: homeworldRule(() =>
reinforcementsHomeworldSources(homeworldContext(g), g.homeworlds!.custody!, p.id)) } : {}),
...(p.noField?.deployed?.location.territory === battle.territory
...(noFieldAtCombatLocation(p,battle.territory)
? { noField: { materializes, reservesAfterReveal } } : {}) };
}
@@ -18338,6 +18378,13 @@ function choamSaleCancellationQuote(g: Game, response: ResponseWindow) {
}
function noFieldCancellationQuote(g: Game, response: ResponseWindow) {
if (response.kind !== 'richeseNoField') return null;
if (response.source === 'homeworldNoField') {
const shipment = g.pendingHomeworldShipment;
requireRule(shipment?.noField && shipment.player === response.owner,
'The Homeworld No-Field response lost its original shipment.');
validateHomeworldShipment(g, shipment);
return {kind:'own' as const,player:shipment.player,blockedTurn:g.turn,pendingShipment:null};
}
if (response.source === 'ambassadorNoField') {
const shipment = validateAmbassadorMarkerResponse(g,response);
return {kind:'own' as const,player:shipment.ambassadorMarker!.owner,blockedTurn:g.turn,pendingShipment:null};
@@ -19606,6 +19653,7 @@ function forceGroup(g: Game, p: Player, action: Action, maxOrigins = 1) {
requireRule(
p.faction === 'richese' &&
marker &&
isBoardNoFieldLocation(marker.location) &&
marker.tokenId === action.noField &&
p.noFieldEvent === action.event,
'This concealed No-Field movement selection is stale.',
@@ -22367,7 +22415,7 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
// the actual declaration still validates exact speed/groups/custody.
const marker = player.faction === 'richese' ? player.noField?.deployed : null;
const sources = [...Object.keys(player.forces).filter(key => player.forces[key] > 0),
...(marker ? [location(marker.location.territory, marker.location.sector)] : [])];
...(marker && isBoardNoFieldLocation(marker.location) ? [location(marker.location.territory, marker.location.sector)] : [])];
return sources.some(key => splitLocation(key).territory === origin &&
gameDistance(state, key, location(destination, sector), point => pathBlocked(state, player, point, isAdvisor(player, origin))) <= Math.max(3, movementRange(state, player, 1)));
},
@@ -22802,7 +22850,7 @@ function commitGroundMovementPieces(g: Game, move: MovementOrder) {
pools.elites,
Object.fromEntries(group),
eliteGroup,
marker && p.noFieldEvent
marker && isBoardNoFieldLocation(marker.location) && p.noFieldEvent
? {
tokenId: marker.tokenId,
event: p.noFieldEvent,
@@ -22955,7 +23003,7 @@ function alliedSeparationView(g: Game, p: Player): {territories: string[]; noFie
const territories = places.map(id => territory(id).name);
const marker = p.noField?.deployed;
return territories.length ? {territories,
...(marker?.controller === p.id && places.includes(marker.location.territory) ? {noField:true as const} : {})} : null;
...(marker?.controller === p.id && isBoardNoFieldLocation(marker.location) && places.includes(marker.location.territory) ? {noField:true as const} : {})} : null;
}
function basicAlliedShipmentView(g: Game, p: Player) {
@@ -28880,7 +28928,7 @@ function observeOccupation(g: Game, cause: 'change' | 'turnStart' | 'turnEnd' =
g.homeworldOccupationHistory!, homeworldContext(g), g.homeworlds!.custody!, g.turn, cause,
cause === 'change'
? `homeworld-change-${g.homeworldOccupationHistory!.sources[0].event}-${g.homeworldOccupationHistory!.sources.length}`
: `homeworld-turn-${g.turn}-${cause}`));
: `homeworld-turn-${g.turn}-${cause}`, homeworldOccupationMarker(g.players)));
syncTupileSlots(g);
}
/** A new Duke grant may precede the first sealed plan, but never changes a
@@ -29661,7 +29709,7 @@ function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
const materializes = prospectiveNoFieldForces(p, b.territory);
if (materializes) {
const marker = p.noField!.deployed!;
const key = location(marker.location.territory, marker.location.sector);
const key = isHomeworldNoFieldLocation(marker.location) ? marker.location.homeworld : location(marker.location.territory, marker.location.sector);
if (locations[key]) locations[key].normal += materializes;
else locations[key] = { normal: materializes, elite: 0 };
}
@@ -29702,7 +29750,7 @@ function harassWithdrawPreview(g: Game, p: Player): HarassWithdrawPreview | null
const materializes = prospectiveNoFieldForces(p, b.territory);
return { ...context, card: HARASS_WITHDRAW_CARD, allowAllocation: b.harassAllocationVersion === 1,
blocked: context.blocked ?? (b.revealed || b.plans[p.id] ? 'Your Battle Plan is already sealed.' : null),
...(p.noField?.deployed?.location.territory === b.territory
...(noFieldAtCombatLocation(p,b.territory)
? { noField: { materializes, reservesAfterReveal: p.reserves - materializes } } : {}) };
}
function validateResidualPoison(
@@ -30003,7 +30051,7 @@ function stonePublicPools(
const b = g.battle!, t = b.territory, occupy = b.ecazOccupy;
const owner = occupy?.lead === p.id && !occupy.canceled ? getPlayer(g, occupy.ally) : p;
const enemy = occupy?.lead === opponent.id && !occupy.canceled ? getPlayer(g, occupy.ally) : opponent;
const hiddenMarker = owner.noField?.deployed?.location.territory === t;
const hiddenMarker = noFieldAtCombatLocation(owner,t);
const pool = combatForces(g, hiddenMarker ? { ...owner, noField: undefined } : owner, t, enemy);
return hiddenMarker
? Array.from({ length: 6 }, (_, n) => ({
@@ -32434,7 +32482,7 @@ function currentBattleResolutionInput(g: Game, canceledVoter?: string): BattleRe
faction: p.faction,
ally: p.ally,
noFieldAtTerritory:
p.noField?.deployed?.location.territory === b.territory,
noFieldAtCombatLocation(p,b.territory),
})),
physicalCards: physicalTreacheryCards(g),
pendingAuditorPresent: !!g.pendingAuditor,
@@ -32529,7 +32577,7 @@ function diplomatRetreatOffer(g: Game, quote: BattleResolutionQuote) {
const leader = controlledLeaders(g, p).find(candidate => candidate.id === plan.leader && !candidate.dead);
if (!leader || (player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender) ||
!battleLeaderSkills(g, p).some(skill => skill.skill === 'diplomat' && skill.leader === leader.id &&
(!skill.faceUp || skill.captured)) || p.noField?.deployed?.location.territory === b.territory) return null;
(!skill.faceUp || skill.captured)) || noFieldAtCombatLocation(p,b.territory)) return null;
const opponent = getPlayer(g, quote.winner);
const forces = combatForces(g, p, b.territory, opponent);
const occupy = ecazBattleProfile(g);
@@ -35819,6 +35867,23 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
g.decision = resume.decision;
g.pendingKarama = resume.pendingKarama;
g.phaseOpening = resume.phaseOpening;
} else if (response.kind === 'richeseNoField' && response.source === 'homeworldNoField') {
const shipment = g.pendingHomeworldShipment;
requireRule(shipment?.noField && shipment.player === response.owner,
'The Homeworld No-Field response lost its original shipment.');
validateHomeworldShipment(g, shipment);
g.pendingHomeworldShipment = null;
if (canceled) {
getPlayer(g,shipment.player).noFieldBlockedTurn = g.turn;
log(g, 'Karama prevented this Homeworld No-Field shipment. No spice, forces or token history changed; an ordinary shipment remains available.');
} else {
const guild = byFaction(g, 'guild');
if (g.advanced && guild && !guild.specialKaramaUsed) {
g.pendingHomeworldShipment = shipment;
g.decision = {kind:'homeworldShipmentGuild',player:guild.id,shipper:shipment.player,
destination:shipment.destination,amount:shipment.amount,event:shipment.event};
} else continueHomeworldShipment(g,shipment);
}
} else if (response.kind === 'richeseNoField' && response.source === 'ambassadorNoField') {
const shipment = validateAmbassadorMarkerResponse(g,response), entry = g.pendingAmbassador!;
g.pendingShipment = null;
@@ -36862,7 +36927,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
}
function homeworldAllianceReason(g: Game, first: string, second: string) {
return g.homeworlds?.custody
? homeworldRule(() => homeworldAllianceBlock(homeworldContext(g), g.homeworlds!.custody!, first, second)) : null;
? homeworldRule(() => homeworldAllianceBlock(homeworldContext(g), g.homeworlds!.custody!, first, second, homeworldOccupationMarker(g.players))) : null;
}
function homeworldShipmentAutomatic(g: Game): boolean {
const decision = g.decision;
@@ -37056,7 +37121,7 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
contribution(g, p, quote.quote.cost, action.allyPayment);
if(!openAdvisorTransportMatching(g,{kind:'junction',declaration}))commitJunctionTransport(g,p,declaration);
}
function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}) {
function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string;noField?:{tokenId:string;event:string}}) {
requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.');
requireRule(!intent.guildSecretEvent || g.karamaShipping?.player !== intent.player,
'Choose the purchased Karama price or Guild Secret Ally, not both.');
@@ -37069,6 +37134,20 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout
purchasedGuildRate: g.karamaShipping?.player === intent.player,
players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})),
};
if (intent.noField) {
const p = getPlayer(g, intent.player);
requireRule(!g.nexusCards && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
!g.techTokens && !g.strongholdCards,
'Homeworld No-Field shipment currently supports native Homeworld play without optional modules.');
requireRule(!intent.route && !intent.guildSecretEvent && intent.sources && typeof intent.sources === 'object' &&
!Array.isArray(intent.sources) && Object.keys(intent.sources).length === 0 &&
p.noField && p.noFieldEvent === intent.noField.event && p.noFieldBlockedTurn !== g.turn,
'The original concealed Homeworld shipment is no longer available.');
const marker = homeworldRule(() => quoteRicheseHomeworldMarkerShipment(context, g.homeworlds!.custody!,
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId},p.noField!));
return {...marker, state:g.homeworlds.custody, players:context.players, sources:[],
boardForces:undefined,boardEliteForces:undefined,sourceNames:[combatLocationName(g,marker.source)]};
}
const order = {player: intent.player, destination: intent.destination, sources: intent.sources};
if (intent.route === 'arrakis') {
const quote = homeworldRule(() => quoteGuildHomeworldShipment({...context, storm: g.storm, discoveries:g.discoveries,
@@ -37108,6 +37187,14 @@ function homeworldShipmentIntegrity(g: Game) {
const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldShipmentGuild');
const shipment = g.pendingHomeworldShipment;
if (!shipment && !decisions.length) return;
const markerResponses = shipment?.noField
? savedNoFieldResponses(g).filter(response => response.kind === 'richeseNoField' && response.source === 'homeworldNoField') : [];
if (shipment?.noField && markerResponses.length) {
requireRule(!decisions.length && markerResponses.length === 1 && markerResponses[0].owner === shipment.player && !g.pendingShipment,
'The saved Homeworld No-Field declaration lost its original response.');
validateHomeworldShipment(g,shipment);
return;
}
if (shipment?.guildTransportEvent) {
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildTransportPermission');
requireRule(!decisions.length && responses.length === 1 && !g.pendingShipment,
@@ -37143,6 +37230,11 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
payWithAlly(g, p, quote.cost, shipment.allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += quote.cost;
g.homeworlds!.custody = quote.state;
if (shipment.noField) {
requireRule('noField' in quote && quote.noField, 'The Homeworld shipment lost its physical No-Field.');
p.noField = quote.noField;
p.noFieldEvent = crypto.randomUUID();
}
if (shipment.route === 'arrakis') {
p.forces = quote.boardForces!;
if (p.elites) p.elites.forces = quote.boardEliteForces!;
@@ -37174,16 +37266,22 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
{ own: false, ally: !purchasedRate && !!guild && p.ally !== guild.id });
finishShipmentPromises(g, p, null);
if (purchasedRate) g.karamaShipping = null;
log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
log(g, `${p.name} shipped ${shipment.noField ? 'one concealed No-Field' : `${quote.amount} physical forces (${quote.elite} special)`} from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
{faction: shipment.guildSecretEvent ? 'guild' : p.faction, name: shipment.guildSecretEvent ? 'Secret Ally Homeworld shipment' : 'Homeworld shipment'});
if (heldConversion) openAutomaticAdvisorFlip(g);
}
function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}, allyPayment?: unknown) {
function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string;noField?:{tokenId:string;event:string}}, allyPayment?: unknown) {
const quote = homeworldShipmentQuote(g, intent);
const shipment: PendingHomeworldShipment = {...intent, ...(currentGuildCunning(g,p.id) ? {guildNexusEvent:currentGuildCunning(g,p.id)!.receipt.event} : {}), sources: structuredClone(intent.sources),
event: homeworldShipmentEvent(g, p, intent.route), turn: g.turn, amount: quote.amount, elite: quote.elite,
cost: quote.cost, allyPayment: contribution(g, p, quote.cost, allyPayment), pools: quote.sources};
bindGuildCunningShipment(g,p,'homeworld',shipment);
if (shipment.noField) {
g.pendingHomeworldShipment = shipment;
g.response = {kind:'richeseNoField',source:'homeworldNoField',owner:p.id,passed:[]};
log(g, `${p.name} declared a concealed No-Field shipment to ${combatLocationName(g,intent.destination)}. Payment and placement await the response.`);
return;
}
const guild = byFaction(g, 'guild');
if (g.advanced && guild && !guild.specialKaramaUsed) {
g.pendingHomeworldShipment = shipment;
@@ -37809,7 +37907,7 @@ function validateGuildTransportWindow(g: Game, response: ResponseWindow) {
shipper.faction === 'richese' && !intent.fromReserves && !intent.advisors &&
typeof marker.tokenId === 'string' && typeof marker.event === 'string' &&
typeof marker.from === 'string' && deployed?.tokenId === marker.tokenId &&
deployed.controller === shipper.id && shipper.noFieldEvent === marker.event &&
deployed.controller === shipper.id && shipper.noFieldEvent === marker.event && isBoardNoFieldLocation(deployed.location) &&
location(deployed.location.territory,deployed.location.sector) === marker.from &&
deployed.location.territory === intent.origin) &&
(intent.fromReserves
@@ -39176,12 +39274,14 @@ function gamontAvailable(
return (
!!marker &&
p.noFieldEvent === noFieldEvent &&
isBoardNoFieldLocation(marker.location) &&
location(marker.location.territory, marker.location.sector) === key &&
elite === 0
);
}
if (
p.noField?.deployed &&
isBoardNoFieldLocation(p.noField.deployed.location) &&
location(
p.noField.deployed.location.territory,
p.noField.deployed.location.sector,
@@ -39297,6 +39397,7 @@ function playChoamWorthless(g: Game, p: Player, action: Action, movementPreparat
const marker = other.noField?.deployed;
const concealed =
!!marker &&
isBoardNoFieldLocation(marker.location) &&
location(marker.location.territory, marker.location.sector) === key;
requireRule(
validGameLocation(g, loc.territory, loc.sector) &&
@@ -44392,11 +44493,22 @@ function applyActionInner(
const route = t === 'guildHomeworldShip' ? 'arrakis' as const : undefined;
const blocked = route ? guildHomeworldShipmentBlock(g, p) : homeworldShipmentBlock(g, p);
requireRule(!blocked, blocked ?? 'Homeworld shipment is unavailable.');
requireRule(Object.keys(action).every((key) => ['type', 'event', 'destination', 'sources', 'allyPayment', ...(guildSecretEvent ? ['nexus'] : [])].includes(key)),
'Choose explicit typed source groups and a Homeworld destination without concealed tokens.');
requireRule(Object.keys(action).every((key) => ['type', 'event', 'destination', 'sources', 'allyPayment',
...(guildSecretEvent ? ['nexus'] : []), ...(action.noField !== undefined ? ['noField','noFieldEvent'] : [])].includes(key)),
'Choose explicit typed source groups or a concealed No-Field and a Homeworld destination.');
requireRule(action.event === homeworldShipmentEvent(g, p, route), 'This Homeworld source selection is stale.');
let noField: {tokenId:string;event:string} | undefined;
if (action.noField !== undefined) {
requireRule(t === 'homeworldShip' && !guildSecretEvent && !currentGuildCunning(g,id),
'This provisional No-Field route uses a native Homeworld shipment.');
const markerBlocked = noFieldShipBlock(g,p);
requireRule(!markerBlocked,markerBlocked ?? 'The No-Field shipment is unavailable.');
requireRule(action.noFieldEvent === p.noFieldEvent, 'This No-Field selection is stale.');
noField = {tokenId:stringField(action.noField),event:stringField(action.noFieldEvent)};
} else requireRule(action.noFieldEvent === undefined, 'Choose a physical No-Field for its inventory event.');
declareHomeworldShipment(g, p, {player: id, destination: stringField(action.destination),
sources: action.sources as HomeworldShipmentIntent['sources'], ...(route ? {route} : {}), ...(guildSecretEvent ? {guildSecretEvent} : {})}, action.allyPayment);
sources: action.sources as HomeworldShipmentIntent['sources'], ...(route ? {route} : {}),
...(guildSecretEvent ? {guildSecretEvent} : {}), ...(noField ? {noField} : {})}, action.allyPayment);
return g;
}
if (t === 'ship') {
@@ -44812,15 +44924,14 @@ function applyActionInner(
ally?.ally === ecaz.id && connected && !isAdvisor(ally, choice.territory) &&
[choice.attacker, choice.defender].some(seat => seat === ecaz.id || seat === ally.id);
const noFieldPlayers = g.players.filter(player =>
player.noField?.deployed?.location.territory === choice.territory &&
noFieldAtCombatLocation(player,choice.territory) &&
(player.id === choice.attacker || player.id === choice.defender ||
(combined && ecaz && player.id === ally?.id &&
(choice.attacker === ecaz.id || choice.defender === ecaz.id)))).map(player => player.id);
let mixedNoFieldPlayers: string[] | undefined;
for (const playerId of noFieldPlayers) {
const mixed = Object.entries(getPlayer(g, playerId).forces).some(
([key, n]) => n > 0 && splitLocation(key).territory === choice.territory,
);
const army = combatArmy(g,playerId,choice.territory);
const mixed = army.normal + army.elite > 0;
if (!mixed) continue;
requireRule(!combined || ecazOccupyCompositionSupported(g),
'Combined mixed No-Field dialing requires an admitted native Occupy composition.');
@@ -45007,7 +45118,7 @@ function applyActionInner(
if (b.plans[b.attacker] && b.plans[b.defender]) {
for (const playerId of b.noFieldPlayers ?? []) {
const owner = getPlayer(g, playerId);
if (owner.noField?.deployed?.location.territory === b.territory)
if (noFieldAtCombatLocation(owner,b.territory))
revealPlayerNoField(g, owner, 'battle');
}
b.revealed = true;
@@ -46177,6 +46288,7 @@ export function viewGame(state: Game, id: string, now?: number) {
const marker = p.noField?.deployed;
const noField =
!!marker &&
isBoardNoFieldLocation(marker.location) &&
location(
marker.location.territory,
marker.location.sector,