Expand Basic Richese movement and worm interactions
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 09:54:24 +11:00
1 parent 3dbd326891
commit 605cb2a32f
12 files changed
+637 -8

No files matched your search

+18 -5
View File
@@ -1696,7 +1696,7 @@ export type Game = {
| { stage: 'offer'; passed: string[] }
| { stage: 'select'; player: string; candidates: string[] };
continuation:
| { kind: 'ambassador'; entry: NonNullable<Game['pendingAmbassador']> }
| { kind: 'ambassador'; entry: NonNullable<Game['pendingAmbassador']>; wormCursor?: string }
| { kind: 'discoveryStash'; event: string; owner: string; card: string }
| { kind: 'nexusChoamTrade'; event: string; owner: string; card: string; spiceAfter: number }
| { kind: 'nexusChoamBetrayal'; event: string; owner: string; target: string; card: string; handBefore: number }
@@ -9626,6 +9626,11 @@ function supportedRicheseTruthLot(g: Game): boolean {
(lot.method === 'onceAround' || lot.method === 'silent') && !lot.outcome &&
!g.currentAuctionSale && !g.auction && !g.ixAuction;
}
/** Bind a parked native ride to its original remaining Spice Blow sequence. */
function ambassadorWormDiscardCursor(g: Game): string {
return JSON.stringify([g.turn, g.phase, g.wormRides, g.spiceSequence,
g.spiceResolution, g.nexus, g.summonedNexusBeforeRides, g.greatMaker]);
}
function semutaOfferSupported(
g: Game,
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
@@ -9727,10 +9732,16 @@ function semutaOfferSupported(
!!g.pendingArrivalOverlap && bareBasicSemutaProfile(g) &&
g.pendingArrivalOverlap.ambassadorEvent === continuation.entry.ambassadorEvent &&
ambassadorTerrorEntryMatches(g, continuation.entry);
const ambassadorWormDiscard = ambassadorDiscard && continuation.entry.resume === 'wormRide' &&
continuation.entry.arrivalCause === 'wormRide' && g.phase === 1 &&
!!continuation.wormCursor && continuation.wormCursor === ambassadorWormDiscardCursor(g) &&
validAmbassadorResume(g, continuation.entry) && !g.pendingArrivalOverlap &&
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction);
if (ambassadorDiscard &&
(!(bareBasicSemutaProfile(g) || basicIndependentRecruitsSemutaProfile(g) && !g.pendingArrivalOverlap) || g.phase !== 5 ||
continuation.entry.stage !== 'cards' || continuation.entry.resume !== 'none' ||
!['shipment', 'movement'].includes(continuation.entry.arrivalCause ?? '') ||
(!(bareBasicSemutaProfile(g) || basicIndependentRecruitsSemutaProfile(g) && !g.pendingArrivalOverlap) ||
!(ambassadorWormDiscard || g.phase === 5 && continuation.entry.resume === 'none' &&
['shipment', 'movement'].includes(continuation.entry.arrivalCause ?? '')) ||
continuation.entry.stage !== 'cards' ||
!['choam', 'ixians'].includes(continuation.entry.effect ?? '') ||
continuation.entry.guildAdvisorOrigin || continuation.entry.revivalEvent ||
continuation.entry.victoryEvent || continuation.entry.relocation ||
@@ -9739,7 +9750,7 @@ function semutaOfferSupported(
(g.pendingArrivalOverlap && !overlapOwnsAmbassadorDiscard(g, continuation.entry)) ||
g.pendingArrivalReaction?.length ||
g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove ||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
g.ornithopter || g.summonedWorm || !ambassadorWormDiscard && g.wormRides.length > 0 ||
g.pendingRevival)) return false;
if (continuation.kind === 'karamaBgConversionCancelDiscard' &&
!semutaBgConversionCancelResponse(g, continuation.use.response, continuation.resume.pendingKarama)) return false;
@@ -27427,6 +27438,8 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
stageTreacheryDiscard(g, `ambassador:${entry.effect}`, entries, {
kind: 'ambassador',
entry,
...(entry.resume === 'wormRide' && entry.arrivalCause === 'wormRide'
? { wormCursor: ambassadorWormDiscardCursor(g) } : {}),
});
}
}
+2
View File
@@ -1131,6 +1131,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
'In supported Basic native games, voluntary revelation is also available during an Ix phase opening or selected auction and market decisions without replacing that decision. Other interruption windows remain unfinished.',
'In supported Basic games, your voluntary reveal can also interrupt a clean Emperor allied spice gift, Tleilaxu revival-income response, Atreides Movement-phase Spice forecast without Bene Gesserit, or Guild’s own ordinary reserve-shipment rate response when the shipment enters a different territory from the marker. The original response and payment continue once; other interruptions remain guarded.',
'During supported Basic games you can also reveal while another faction’s ordinary movement waits for a Fremen or Ixian response or a CHOAM decision. The original move and its response remain pending; a zero No-Field disappears without creating physical forces.',
'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return. Select the marker separately from physical counters; ordinary movement remains available.',
'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld, alone or with physical forces from those reserves. On a later turn, you may ship that same concealed marker between distinct eligible Homeworlds, alone or with selected physical Richese counters from the marker’s current World; its own Homeworld is an eligible endpoint only in Advanced. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal; available reserves are placed there unless it is already on its own World, where the physical pool stays unchanged. Only one marker may be deployed globally. Fresh placement from the screen to your own Homeworld and optional-module combinations remain unavailable; an Advanced native return uses the provisional physical-reserve interpretation described below. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.',
@@ -1407,6 +1408,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Reactive Baliset and La La La also offer their used card after the accepted cost. The original uncommitted move or free-revival request is already stopped: claim or Continue installs the original restriction without moving forces, consuming movement, reviving or charging the denied request. Another legal move or paid revival remains available. Movement-card, Ambassador, Nexus and other competing parents remain separate.',
'A clean proactive Trip to Gamont also pauses for a selected elite or concealed No-Field. Claim or Continue returns the exact selected physical counter, or performs the native No-Field reveal and returns at most one actual force after reserve-capped materialization. A zero token reveals without inventing a force; taking the used card does not undo that effect or spend revival usage.',
'In supported bare Basic tables with Richese, one entry may earn both an Ambassador and Terror opportunity. Their existing provisional storm order stays fixed while CHOAM/Ix or Sabotage/Robbery discards offer their separate Semuta responses. Complete each fresh group before its original effect continues; the other earned opportunity waits. The table shows when the discard response is pending. Credit, replacement, gift and physical entry are never repeated.',
'In supported Basic games, CHOAM and Ixian Ambassador discards from an ordinary Fremen worm ride, including Bene Gesserit copies, also offer Semuta before the original ride continues. CHOAM’s credit and the Ixian replacement are applied once; overlapping Terror and summoned-worm continuations remain guarded.',
'Trip to Gamont in CHOAM’s final Mentat opportunity also offers the used card. Claim or Continue completes the selected return and restores that same CHOAM-owned opportunity. It does not check victory or advance the turn: CHOAM must still choose Finish and check victory, using the changed board.',
'Fresh bare public Basic Richese previews now initialize Semuta before genuine setup with selected native E1/E2/E3 families. Older games are unchanged; Advanced and optional modules retain their existing separate entries. Clean ordinary shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: CHOAM keeps its already-earned credit and Ixians draw one replacement after the reaction. A multiple-card private group requires commitment before exactly one selection. Original entrant actions then resume once. Existing Basic Sabotage/Robbery overflow keeps the original victim/draw, removed token, payment, forces and optional gift. Nested arrivals, completed movement-card provenance, wider module coverage and complete modes remain unfinished. The table labels the preview and its limits.',
] : []),
+85
View File
@@ -0,0 +1,85 @@
import { location, splitLocation, validLocation } from './board';
import type { Game } from './engine';
import { presenceAt } from './force-presence';
type MovementPending = 'pendingFremenMove' | 'pendingIxMove' | 'pendingChoamMove';
type MovementOrder = NonNullable<Game['pendingIxMove']>;
/**
* Identify a single ordinary foreign movement awaiting its native prevention
* response. Revealing Richese's already-present marker cannot add an occupying
* faction, so another faction's declared route and destination remain legal.
* The caller retains its own common profile and Truthtrance/commitment guards.
*/
export function richeseRevealForeignMovementPending(g: Game): MovementPending | null {
if (g.status !== 'playing' || g.advanced || g.phase !== 5 ||
!Number.isSafeInteger(g.turn) || g.turn < 1 || g.phaseOpening || g.truthtrance ||
g.ornithopter || g.choamMarket || !Array.isArray(g.players)) return null;
const richese = g.players.find(player => player.faction === 'richese');
const deployed = richese?.noField?.deployed;
if (!richese || !deployed || deployed.controller !== richese.id ||
!deployed.location || !('territory' in deployed.location)) return null;
let key: MovementPending;
let move: MovementOrder;
const response = g.response;
if (response?.kind === 'fremenMovement' && g.pendingFremenMove && !g.decision) {
key = 'pendingFremenMove';
move = g.pendingFremenMove.order;
if (g.pendingFremenMove.turn !== g.turn) return null;
} else if (response?.kind === 'ixMovement' && g.pendingIxMove && !g.decision) {
key = 'pendingIxMove';
move = g.pendingIxMove;
} else if (!response && g.decision?.kind === 'choamMovement' && g.pendingChoamMove) {
key = 'pendingChoamMove';
move = g.pendingChoamMove;
} else return null;
if (Object.entries(g).some(([name, value]) => name.startsWith('pending') && name !== key &&
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return null;
if (!move || move.player === richese.id || move.player !== g.active ||
move.source || move.ambassadorEvent || move.noField || move.planetologist || move.sandmaster ||
move.ambassadorSandmaster || move.ornithopterEvent || move.ornithopterRange || move.discoveryFlight ||
!Array.isArray(move.group) || move.group.length === 0 || !move.eliteGroup ||
typeof move.origin !== 'string' || typeof move.to !== 'string' ||
!Number.isSafeInteger(move.sector) || !validLocation(move.to, move.sector) ||
!Number.isSafeInteger(move.total) || move.total <= 0 ||
!Number.isSafeInteger(move.elite) || move.elite < 0 || move.elite > move.total ||
(move.origins && (move.origins.length !== 1 || move.origins[0] !== move.origin))) return null;
const mover = g.players.find(player => player.id === move.player);
if (!mover || !Number.isSafeInteger(mover.moved) || mover.moved < 0) return null;
const seen = new Set<string>();
let total = 0, elite = 0;
for (const [source, amount] of move.group) {
if (typeof source !== 'string' || seen.has(source) || splitLocation(source).territory !== move.origin ||
!validLocation(splitLocation(source).territory, splitLocation(source).sector) ||
!Number.isSafeInteger(amount) || amount <= 0 || amount > (mover.forces[source] ?? 0)) return null;
seen.add(source);
const selected = move.eliteGroup[source];
if (!Number.isSafeInteger(selected) || selected < 0 || selected > amount ||
selected > (mover.elites?.forces[source] ?? 0) ||
amount - selected > (mover.forces[source] ?? 0) - (mover.elites?.forces[source] ?? 0)) return null;
total += amount;
elite += selected;
}
if (Object.keys(move.eliteGroup).some(source => !seen.has(source)) ||
total !== move.total || elite !== move.elite) return null;
if (key === 'pendingChoamMove') {
const decision = g.decision;
const choam = g.players.find(player => player.faction === 'choam');
return decision?.kind === 'choamMovement' && choam && presenceAt(choam, move.to) > 0 && decision.player === choam.id &&
decision.mover === move.player && decision.territory === move.to &&
decision.sector === move.sector && decision.amount === move.total ? key : null;
}
if (!response || response.owner !== move.player ||
response.location !== location(move.to, move.sector) || response.amount !== move.total ||
!Array.isArray(response.passed) || new Set(response.passed).size !== response.passed.length ||
response.passed.some(id => !g.players.some(player => player.id === id))) return null;
const keys = Object.keys(response).sort().join(',');
if (key === 'pendingIxMove')
return mover.faction === 'ixians' && keys === 'amount,elite,kind,location,owner,passed' &&
response.elite === move.elite && move.elite > 0 ? key : null;
return mover.faction === 'fremen' && keys === 'amount,kind,location,owner,passed' &&
g.pendingFremenMove?.move === mover.moved ? key : null;
}
+4 -1
View File
@@ -1,5 +1,6 @@
import type { Game } from './engine';
import { validateFremenRevivalGrant } from './fremen-revival-grant';
import { richeseRevealForeignMovementPending } from './richese-reveal-foreign-movement';
const factions = new Set([
'atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen',
@@ -51,8 +52,9 @@ export function richeseRevealInterruptSupported(g: Game): boolean {
// Each other pending field is an active child/continuation, not a completed
// receipt. Empty pending arrays do not represent a live child.
const foreignMovement = richeseRevealForeignMovementPending(g);
if (Object.entries(g).some(([key, value]) => key.startsWith('pending') &&
!(key === 'pendingShipment' && guildRate) &&
!(key === 'pendingShipment' && guildRate) && key !== foreignMovement &&
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return false;
const live = (rows: readonly {turn: number; fulfilled?: boolean; released?: boolean}[] | undefined) =>
@@ -72,6 +74,7 @@ export function richeseRevealInterruptSupported(g: Game): boolean {
g.mixedAllianceQuestions, g.mixedMovementQuestions, g.mixedTraitorQuestions, g.mixedBattleQuestions];
if (mixed.some(rows => rows?.some(row => row.turn === g.turn && row.binding))) return false;
if (foreignMovement) return true;
if (g.response) {
// Only independent, un-nested native responses with a stable original
// source are admitted. In particular, shipment and revival transactions