Connect bounded Richese Nexus Cunning No-Field pair

This commit is contained in:
Quintine committed 2026-09-28 12:29:59 +10:00
1 parent 0c6d28a706
commit 763749f25e
14 files changed
+758 -31

No files matched your search

+114
View File
@@ -109,6 +109,8 @@ export function RicheseNoFieldControls({
}) {
const id = useId();
const [selection, setSelection] = useState('');
const [pairConcealedSelection, setPairConcealedSelection] = useState('');
const [pairRevealedSelection, setPairRevealedSelection] = useState('');
const [useSmuggler, setUseSmuggler] = useState(false);
const info = game.richeseNoField;
const me = game.players.find((player) => player.id === game.me);
@@ -119,6 +121,16 @@ export function RicheseNoFieldControls({
);
const selected =
available.find((token) => token.id === selection) ?? available[0];
const pairConcealed =
available.find((token) => token.id === pairConcealedSelection) ??
available[0];
const pairRevealed =
available.find(
(token) =>
token.id === pairRevealedSelection &&
token.id !== pairConcealed?.id,
) ?? available.find((token) => token.id !== pairConcealed?.id);
const nexus = game.nexusRicheseCunning;
const deployed = state.deployed;
const selectedTerritory = territory(destination);
const rate = {
@@ -165,6 +177,10 @@ export function RicheseNoFieldControls({
if (cost - share > (me.spice ?? 0))
problems.push('Your spice does not cover the chosen payment.');
}
const pairProblems = [...problems];
if (nexus?.blocked) pairProblems.push(nexus.blocked);
if (!pairConcealed || !pairRevealed)
pairProblems.push('Choose two different available No-Field tokens.');
const showShipment = game.phase === 5 && game.active === me.id && !me.shipped;
return (
<details className="my-4" open={showShipment || !!deployed}>
@@ -304,6 +320,104 @@ export function RicheseNoFieldControls({
Ship concealed No-Field {selected?.value ?? ''} · {cost} spice
{useSmuggler && smuggler ? ' · + 1 real force' : ''}
</Button>
{nexus && (
<div className="rounded border border-[#8e8159] p-3">
<h3 className="font-semibold">Nexus Cunning: ship two No-Fields</h3>
<p className="fine mt-2">
Choose one token to reveal immediately and one to keep
concealed at {selectedTerritory.name}, sector {sector}. The
revealed token brings up to its printed value in forces from
your current reserves; the concealed marker still counts as
one force. Both ship for the price of one marker ({cost} spice).
This is separate from the ordinary shipment and Smuggler.
</p>
<label htmlFor={`${id}-pair-revealed`}>
Private token to reveal immediately
</label>
<select
id={`${id}-pair-revealed`}
className="block min-h-11"
value={pairRevealed?.id ?? ''}
disabled={busy || !!deployed || !!nexus.blocked}
onChange={(event) => {
if (event.target.value === pairConcealed?.id)
setPairConcealedSelection(pairRevealed?.id ?? '');
setPairRevealedSelection(event.target.value);
}}
>
{state.tokens.map((token) => (
<option
key={token.id}
value={token.id}
disabled={token.id === state.lastShipped}
>
No-Field {token.value}
{token.id === state.lastShipped ? ' · cannot repeat' : ''}
</option>
))}
</select>
<label htmlFor={`${id}-pair-concealed`}>
Private token to keep concealed
</label>
<select
id={`${id}-pair-concealed`}
className="block min-h-11"
value={pairConcealed?.id ?? ''}
disabled={busy || !!deployed || !!nexus.blocked}
onChange={(event) => {
if (event.target.value === pairRevealed?.id)
setPairRevealedSelection(pairConcealed?.id ?? '');
setPairConcealedSelection(event.target.value);
}}
>
{state.tokens.map((token) => (
<option
key={token.id}
value={token.id}
disabled={token.id === state.lastShipped}
>
No-Field {token.value}
{token.id === state.lastShipped ? ' · cannot repeat' : ''}
</option>
))}
</select>
{pairProblems.length > 0 && (
<ul className="fine" id={`${id}-pair-reasons`}>
{pairProblems.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
)}
<Button
className="min-h-11 whitespace-normal"
disabled={busy || pairProblems.length > 0}
aria-describedby={
pairProblems.length > 0 ? `${id}-pair-reasons` : undefined
}
onClick={() => {
if (
!busy &&
pairProblems.length === 0 &&
pairConcealed &&
pairRevealed
)
act({
type: 'ship',
noField: pairConcealed.id,
revealedToken: pairRevealed.id,
nexus: nexus.event,
event: info.event,
territory: destination,
sector,
allyPayment: share,
});
}}
>
Reveal No-Field {pairRevealed?.value ?? ''} and ship concealed
No-Field {pairConcealed?.value ?? ''} · {cost} spice
</Button>
</div>
)}
</>
)}
</div>
+3
View File
@@ -67,6 +67,9 @@ access revocation, participant removal, assisted recovery, permanent deletion,
bulk actions and backup/operations tools remain unfinished.
[Participant AI](ADMIN_PARTICIPANT_AI.md) checkpoint `2cddf03` is pushed. Types/lint,
The bounded [native Richese Nexus Cunning shipment](NEXUS_RICHESE_RULES.md#bounded-native-cunning-prototype-27-september-2026) now uses two distinct physical No-Field tokens in one priced shipment in genuine Richese/CHOAM paired Nexus games. One token immediately reveals a reserve-capped public force group; the other remains concealed. Owner-only controls, four legal AI profiles, Karama/Guild responses, signed saved receipts and concurrent in-memory SQLite settlement are connected in Basic/Advanced. A read-only local browser fixture displayed the owner selector and no rival private panel. Same-shipment nonrepeat is a documented product interpretation; faceup-component artwork, combined modules, Betrayal and full Nexus acceptance remain gated. This code is local and not in the last verified production image.
5,408 offline tests, 52 HTTP tests, build, twenty-two administrator HTTP groups
and desktop/mobile browser acceptance passed. Deployment delivery was
unconfirmed at that checkpoint; the current production revision is recorded
+6
View File
@@ -1,5 +1,11 @@
# Dune implementation status
## 27 September 2026 — Bounded native Richese Nexus Cunning pair
The [Richese source contract](NEXUS_RICHESE_RULES.md#bounded-native-cunning-prototype-27-september-2026) now has one usable two-No-Field shipment in genuine Richese/CHOAM paired Nexus games without other optional modules. Two distinct nonrepeating physical tokens ship at the one-marker price; one immediately reveals into reserve-capped public normal forces and the other remains the sole concealed marker. The native Karama cancellation leaves resources, tokens and shipment allowance untouched while spending the card; Advanced Guild special prevention spends the attempted allowance without placing tokens or charging spice. Owner-only controls, four minimal legal bot paths, public value/force log and private concealed custody are connected. A read-only local browser fixture displayed the owner pair selector; the rival view had no private panel. Same-shipment `lastShipped` handling is a product interpretation, not a published FAQ answer.
Pure custody, Basic/Advanced engine, canceled/allowed responses, malformed intent, all AI profiles and concurrent in-memory SQLite CAS/reload/replay cases passed 60 focused tests after independent review fixed orphaned pending continuation and nullable bot-offer filtering. The final frozen full offline suite passed 5,581/5,581 cases across 700 files after the allocation-only validation refactor. Isolated project TypeScript excluding only the unrelated Fremen draft, scoped lint and app build also passed. `npm run check` still stops at the unrelated `Player.fremenNexusMovementBlockedTurn` draft error. Persistent faceup-component artwork, allied and optional-module combinations, Richese Betrayal and complete Nexus/public starts remain gated. This checkpoint is not yet in the live image.
## 27 September 2026 — Bounded Ecaz Nexus Secret Ally traitor inquiry
The [Secret Ally prototype](NEXUS_ECAZ_RULES.md#bounded-secret-ally-traitor-inquiry-runtime) lets an unallied holder spend the physical Ecaz Nexus card at a quiet boundary when Ecaz is absent and ask any seated player, including themself, whether that player holds one of the holder's native leaders as an ordinary Traitor Card. The public action names the target but discloses no answer or matching identity; the holder alone can revisit the signed historical yes/no after the target's hand changes and the Nexus discard recycles. Native printed leader scope and holder-only audience are privacy-preserving product interpretations, not publisher rulings. Basic/Advanced real actions, four AI profiles, owner/rival projections, malformed and interrupted requests, JSON recovery and concurrent in-memory SQLite CAS passed six focused cases. A local read-only browser fixture showed the holder's target control and subsequent private answer, while a rival saw neither; no room write occurred. An independent rules/privacy/persistence review found no actionable defect.
+8
View File
@@ -60,6 +60,14 @@ A reveal is not another arrival; E3 explicitly distinguishes No-Field entry from
Concrete Cunning boundaries requiring design or a selected interpretation are: how two same-shipment uses update the shared last-used history; whether an already deployed marker must reveal before the pair; how the immediate faceup component and the still-concealed one affect the next prohibited denomination; and composition with native high-Richese extra ordinary forces or an allied shipment. The present `RicheseNoField` shape has only `lastShipped` and one `deployed` record, so it cannot simply represent an atomic pair by calling single-deploy twice. These are Cunning custody issues, not reasons to withhold Secret Ally's ordinary actual-force shipment.
## Bounded native Cunning prototype (27 September 2026)
The connected path is deliberately narrower than every possible E2 composition: a genuine Richese/CHOAM paired Nexus game with only Richese, CHOAM and classic factions, Basic or Advanced, no Homeworlds, Leader Skills, Discoveries, Sandtrout or Ecaz module. An unallied native Richese holder with an unused native shipment and no deployed marker chooses two distinct available physical tokens. Neither token may equal the previous shipment's `lastShipped`; the remaining concealed token becomes the next `lastShipped`. A previously deployed marker must be resolved through native reveal/movement first. This same-shipment nonrepeat convention is a product interpretation of the one-record E2 custody model, not a published FAQ ruling.
The card is discarded at declaration. The native No-Field Karama cancellation precedes settlement and preserves the two tokens, reserves, spice and ordinary shipment allowance but not the card. Advanced Guild special prevention also leaves the physical shipment unpaid and unplaced while consuming the attempted shipment allowance and the card. Otherwise the pair is one arrival, costs the ordinary one-marker price, immediately reveals one chosen denomination and places up to that value of actual normal forces from current reserves in the destination. Only the other token remains concealed, publicly counting as one force; it draws from then-current reserves on its later native reveal. The immediate group plus the concealed marker count as actual presence for subsequent territory-entry and board logic, not two independent shipments.
An owner-only control distinguishes ordinary No-Field, Smuggler and Nexus pair choices. All four bot profiles have a minimal legal pair candidate. The pending frame binds both physical identities, owner, token event, destination and one-marker price through JSON and in-memory SQLite CAS recovery. Public logs disclose the immediate faceup value/actual force count after settlement; the rival projection contains no concealed token identity or owner-only offer. Signatures detect partial saved-frame corruption, not arbitrary save forgery. Combined modules, allied No-Fields, high-Richese additional-force effects, Betrayal, later faceup display mechanics and full Nexus acceptance remain open.
## Existing integration points and verification
`game/shipment-price.ts` already separates ordinary reserve prices, Guild cross-shipment prices and contributor-filtered Guild income. The ordinary engine chain is `validatePhysicalShipment` → `offerShipment` → `commitShipment`; its checks use native reserve withdrawal, typed counters, arrival stance, actual geography and shipment promises. Introduce a validated charged-count basis without replacing the physical amount. Keep current low-Junction income and occupied-payment routing after the reduced gross is computed.
+8
View File
@@ -41,3 +41,11 @@ Two fresh three-human-seat rooms used final session IDs before genuine fixture s
In 3C2N43XF, the five-force declaration reached the Guild at version 3. The Guild used its actual special Karama control; version 4 retained all twenty shipper reserves and its one spice, consumed the shipment, and left the Nexus spent. No phantom physical marker appeared.
Desktop 1440-pixel and phone 390-pixel screenshots were captured and visually inspected, including the typed source selection, price breakdown and Guild stop controls. Phone checks found no horizontal overflow. All six private sessions refreshed successfully with no page errors; opponents lacked the shipper's private hand, traitors and spice, and no internal shipment/history record was projected. The read-only restoration script is `/tmp/dune-richese-nexus-restore.cjs`. Staging the first room preserved all 3,548 other room states and versions; staging the second preserved all 3,549 other rooms. Final comparison after both browser rooms and thirty isolated HTTP rooms retained every version/hash of all 3,548 opening rooms; 3,580 rooms now persist. The 02:48 UTC controlled restart used a database backup, preserved all opening games and restored twelve earlier Moritani/CHOAM private sessions. Six new Richese private sessions subsequently restored. No publication or full module gate opened; the maintenance automation remains removed.
## 27 September 2026 — bounded native Cunning shipment
In a genuine paired Richese/CHOAM Nexus game with classic factions and no other optional modules, an unallied Richese holder can spend the native Richese Nexus card on one shipment of two distinct available No-Field tokens. One is immediately revealed into up to its printed value of actual normal forces from current reserves; the other remains the sole concealed marker. The pair costs one ordinary marker shipment. Neither token can match the prior shipped marker, and the concealed token becomes the next `lastShipped`. This same-shipment history choice is a documented product interpretation, not a retrieved publisher answer. A previously deployed marker blocks another shipment until its ordinary native resolution.
The declaration binds both token IDs, the native event, destination, sector and price to a saved receipt. Native Karama cancellation spends the card but leaves tokens, spice, reserves and the ordinary shipment available; Advanced Guild special prevention spends the card and shipping allowance without moving either token or charging spice. Accepted arrival publicly logs the revealed denomination, reserve-capped actual force count and concealed one-force marker without revealing the other denomination. Owner-only selectors and all four minimal bot profiles share the supported route. The owner/rival read-only browser fixture showed the separate two-token selector and no rival private panel; no room was written. A concurrent in-memory SQLite CAS case restored the pending native/Guild continuation, settled one pair and rejected replay. Focused No-Field/Cunning/recovery verification passed 60 cases after the continuation-integrity and allocation-only validation fixes; the final frozen complete suite passed 5,581/5,581 cases across 700 files.
Persistent faceup-component artwork, high-Richese extra ordinary forces, allied No-Field combinations, Homeworld/Leader Skills/Discovery/Sandtrout/Ecaz interactions, Richese Betrayal and complete Nexus acceptance remain separate. The earlier Secret Ally checkpoint above is historical evidence; it is not a claim that the native Cunning or live production image was deployed at that time.
+13
View File
@@ -3140,6 +3140,19 @@ function policyActions(g: GameView): Action[] {
Math.abs(a.value - preferred) - Math.abs(b.value - preferred) ||
b.value - a.value,
)[0];
const cunning = g.nexusRicheseCunning;
if (cunning && !cunning.blocked && tokens.length >= 2) {
const revealed = tokens.reduce((best, token) => token.value > best.value ? token : best);
const concealed = tokens.find(token => token.id !== revealed.id)!;
const target = markerTargets.slice(0, 24).find(to =>
reserveShipmentCost({ faction: me.faction, halfRate: false },
territory(to.t).type, 1) <= (me.spice ?? 0));
if (target) return [{
type: 'ship', noField: concealed.id, revealedToken: revealed.id,
nexus: cunning.event, event: noField.event,
territory: target.t, sector: target.s, allyPayment: 0,
}];
}
if (selected)
for (const to of markerTargets.slice(0, 24)) {
if (Math.min(selected.value, me.reserves) === 0 && to.enemy > 0)
+204 -20
View File
@@ -37,6 +37,7 @@ import { quoteNexusChoamInspection, sampleNexusChoamInspection, nexusChoamInspec
import { quoteNexusMoritaniRetention, nexusMoritaniRetentionSignature, type NexusMoritaniRetentionReceipt } from './nexus-moritani-retention';
import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayalSignature, type NexusChoamBetrayal } from './nexus-choam-betrayal';
import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySignature, validateEcazInquiry, type EcazInquiryReceipt } from './nexus-ecaz-inquiry';
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
@@ -382,6 +383,8 @@ import { richeseCards, richeseCardDefinition } from './richese-cards';
import {
createRicheseNoField,
deployRicheseNoField,
deployRicheseNoFieldPair,
validateRicheseNoFieldPair,
moveRicheseNoField,
revealRicheseNoField,
shipAlliedRicheseNoField,
@@ -1009,6 +1012,7 @@ type PendingShipment = {
guildSecretEvent?: string;
guildNexusEvent?: string;
nexusEvent?: string;
richesePair?: { event: string; revealedTokenId: string };
homeworldSources?: NativeReserveSelections;
source?: 'ambassador';
ambassadorEvent?: string;
@@ -1516,6 +1520,8 @@ export type Game = {
nexusGuildSecretLast?: {event:string};
nexusRicheseHistory?: {receipt:NexusRicheseReceipt;stage:'pending'|'shipped'|'stopped';frame:string;halfRate:boolean;signature:string}[];
nexusRicheseLast?: {event:string;stage:'pending'|'shipped'|'stopped'};
nexusRicheseCunningHistory?: { event: string; owner: string; turn: number; phase: 5; tokenEvent: string; frame: string; stage: 'pending' | 'shipped' | 'stopped'; signature: string }[];
nexusRicheseCunningLast?: { event: string; stage: 'pending' | 'shipped' | 'stopped' };
nexusMoritaniLocations?: Record<string,{event:string;territory:string}>;
nexusMoritaniLast?: {event:string;stage:'pending'|'complete'|'canceled'};
nexusChoamLast?: {event: string; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'};
@@ -15255,6 +15261,7 @@ function marketGholaIntegrity(g: Game) {
nexusMoritaniIntegrity(g);
nexusGuildSecretIntegrity(g);
nexusRicheseIntegrity(g);
richesePairIntegrity(g);
nexusGuildCunningIntegrity(g);
traitorDeclarationIntegrity(g);
nexusInspectionIntegrity(g);
@@ -18675,6 +18682,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
shipment.alliedNoField?.owner === response.owner),
'No current No-Field shipment is awaiting this response.',
);
if (canceled && shipment.richesePair) finishRichesePairShipment(g, shipment, 'stopped');
g.pendingShipment = null;
if (canceled) {
requireRule(quote, 'Missing canceled No-Field declaration.');
@@ -18682,7 +18690,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
else getPlayer(g, quote.player).noFieldBlockedTurn = quote.blockedTurn;
log(
g,
'Karama prevented No-Field use for this shipment opportunity. No spice, reserves or token history changed; normal shipment remains available.',
`Karama prevented No-Field use for this shipment opportunity. No spice, reserves or token history changed; normal shipment remains available.${shipment.richesePair ? ' The Richese Nexus card remains spent.' : ''}`,
);
} else offerShipment(g, shipment);
} else if (response.kind === 'moritaniRetention') {
@@ -19847,16 +19855,20 @@ function validateLeaderSkillNoFieldShipment(g: Game, shipment: PendingShipment)
'The declared No-Field contributor is no longer your mutual ally.');
contribution(g, p, cost, shipment.allyPayment);
}
/** Bind both private stages, also while a card effect has saved their controls. */
function leaderSkillNoFieldIntegrity(g: Game): boolean {
/** Find a No-Field response while another card has temporarily suspended it. */
function savedNoFieldResponses(g: Game): ResponseWindow[] {
const continuation = g.pendingTreacheryDiscard?.continuation;
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
continuation && 'resume' in continuation ? continuation.resume : null];
const responses = contexts.flatMap(context => {
return contexts.flatMap(context => {
const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null];
}).filter((response): response is ResponseWindow => !!response);
}
/** Bind both private stages, also while a card effect has saved their controls. */
function leaderSkillNoFieldIntegrity(g: Game): boolean {
const responses = savedNoFieldResponses(g);
const decisions = homeworldSavedDecisions(g);
const shipment = g.pendingShipment;
if (!(shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined ||
@@ -19892,6 +19904,7 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
}
if (shipment.guildNexusEvent) validateGuildCunningShipment(g,shipment.player,shipment.guildNexusEvent,'reserve',shipment);
if (shipment.nexusEvent !== undefined) currentNexusRicheseShipment(g,shipment);
if (shipment.richesePair) currentRichesePairShipment(g, shipment);
requireRule(
!(shipment.noField || shipment.alliedNoField) ||
shipment.homeworldSources === undefined,
@@ -20050,6 +20063,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
validateShipmentArrival(g, shipment);
if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'reserve',shipment);
finishNexusRicheseShipment(g,shipment,'shipped');
finishRichesePairShipment(g, shipment, 'shipped');
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'reserve',shipment);
finishShipmentPromises(
g,
@@ -20066,6 +20080,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
advisors,
} = shipment;
const p = getPlayer(g, shipment.player);
let pairRevealed: ReturnType<typeof deployRicheseNoFieldPair>['revealed'] | null = null;
if (shipment.noField) {
requireRule(
p.faction === 'richese' &&
@@ -20073,13 +20088,19 @@ function commitShipment(g: Game, shipment: PendingShipment) {
p.noFieldEvent === shipment.noField.event,
'This No-Field shipment is no longer current.',
);
p.noField = noFieldRule(() =>
deployRicheseNoField(p.noField!, {
if (shipment.richesePair) {
const pair = noFieldRule(() => deployRicheseNoFieldPair(p.noField!, {
tokenId: shipment.noField!.tokenId,
controller: p.id,
location: { territory: to, sector: s },
}),
);
}, shipment.richesePair!.revealedTokenId, p.reserves));
p.noField = pair.state;
pairRevealed = pair.revealed;
} else p.noField = noFieldRule(() => deployRicheseNoField(p.noField!, {
tokenId: shipment.noField!.tokenId,
controller: p.id,
location: { territory: to, sector: s },
}));
p.noFieldEvent = crypto.randomUUID();
}
if (shipment.alliedNoField) {
@@ -20121,6 +20142,11 @@ function commitShipment(g: Game, shipment: PendingShipment) {
owner.spice -= quote.ownerPayment;
p.spice -= quote.recipientPayment;
} else payWithAlly(g, p, cost, allyPayment);
if (pairRevealed) {
p.reserves -= pairRevealed.forces;
if (pairRevealed.forces) place(p, to, s, pairRevealed.forces);
observeOccupation(g);
}
let homeworldOrigins = '';
if (!shipment.noField) {
const receipts = withdrawNativeReserves(
@@ -20166,7 +20192,9 @@ function commitShipment(g: Game, shipment: PendingShipment) {
shipment.alliedNoField
? `${getPlayer(g, shipment.alliedNoField.owner).name} shipped ${p.name} with No-Field ${quoteAlliedTokenValue(g, shipment.alliedNoField)}, immediately placing ${n} physical forces (${elite} elite) in ${territory(to).name}, sector ${s}. ${shipment.alliedNoField.payer === 'both' ? 'Each ally paid 1 spice' : `${getPlayer(g, shipment.alliedNoField.payer).name} paid ${cost} spice`}.`
: shipment.noField
? `${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.'}`
? 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 ${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' }
@@ -20197,7 +20225,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
p,
to,
s,
shipment.alliedNoField ? Math.max(1, n) : n + (shipment.smugglerCompanion ? 1 : 0),
shipment.alliedNoField ? Math.max(1, n) : n + (shipment.smugglerCompanion ? 1 : 0) + (pairRevealed?.forces ?? 0),
elite,
'shipment',
);
@@ -20489,6 +20517,142 @@ function nexusRicheseOffer(g: Game, owner: string) {
else if (p.reserves < 1) blocked = 'You need physical forces in reserves.';
return {event:JSON.stringify(['nexusRichese',g.turn,owner]),blocked,maxForces:Math.min(5,p.reserves)};
}
const RICHESE_CUNNING_ROSTER = new Set<FactionId>([
'atreides', 'harkonnen', 'emperor', 'fremen', 'beneGesserit', 'guild', 'richese', 'choam',
]);
function richeseCunningModeSupported(g: Game) {
return !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled &&
!g.ecazTreachery && !g.sandtrout &&
g.players.every(player => RICHESE_CUNNING_ROSTER.has(player.faction));
}
function nexusRicheseCunningOffer(g: Game, owner: string) {
const p = g.players.find(player => player.id === owner);
if (!p || p.faction !== 'richese' || g.nexusCards?.cards?.hands[owner] !== 'richese') return null;
let blocked: string | null = null;
if (!richeseCunningModeSupported(g))
blocked = 'This two-token shipment supports classic factions, Richese and CHOAM without other optional modules.';
else if (p.ally) blocked = 'Nexus Cunning requires an unallied Richese holder.';
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || !shipmentAvailable(g, p))
blocked = 'Use Cunning for your unused shipment before movement.';
else if (nexusCleanPlayBlocked(g) || g.karamaShipping)
blocked = 'Finish the current interaction or Karama shipment before using Cunning.';
else blocked = noFieldShipBlock(g, p);
return {
event: JSON.stringify(['nexusRicheseCunning', g.turn, owner, p.noFieldEvent]),
blocked,
};
}
function richesePairSignature(record: NonNullable<Game['nexusRicheseCunningHistory']>[number]) {
return JSON.stringify([record.event, record.owner, record.turn, record.phase,
record.tokenEvent, record.frame, record.stage]);
}
function currentRichesePairShipment(g: Game, shipment: PendingShipment) {
const pair = shipment.richesePair;
const record = g.nexusRicheseCunningHistory?.at(-1);
requireRule(pair && record?.stage === 'pending' && record.event === pair.event &&
record.frame === JSON.stringify(shipment) && record.owner === shipment.player &&
record.turn === g.turn && record.phase === 5 &&
record.tokenEvent === shipment.noField?.event &&
record.signature === richesePairSignature(record) &&
g.nexusCards?.cards?.discard.includes('richese') &&
!shipment.nexusEvent && !shipment.guildNexusEvent && !shipment.guildSecretEvent &&
!shipment.alliedNoField && !shipment.source && !shipment.smuggler &&
!shipment.smugglerCompanion && !shipment.homeworldSources &&
!shipment.noFieldSkillProof && shipment.amount === 1 && shipment.elite === 0 &&
shipment.advisors === false && shipment.allyPayment === 0 &&
richeseCunningModeSupported(g),
'Richese Cunning lost its original two-token shipment or physical Nexus card.');
const p = getPlayer(g, shipment.player);
requireRule(p.faction === 'richese' && !p.ally && p.noField &&
p.noFieldEvent === record.tokenEvent && !p.noField.deployed &&
g.status === 'playing' && g.phase === 5 && g.active === p.id &&
shipmentAvailable(g, p), 'Richese Cunning lost its unused native shipment.');
const cost = reserveShipmentCost({ faction: p.faction, halfRate: false },
territory(shipment.territory).type, 1);
requireRule(shipment.cost === cost && Number.isSafeInteger(shipment.sector) &&
territory(shipment.territory).sectors.includes(shipment.sector),
'Richese Cunning lost its original one-marker price or destination.');
noFieldRule(() => validateRicheseNoFieldPair(p.noField!, {
tokenId: shipment.noField!.tokenId,
controller: p.id,
location: { territory: shipment.territory, sector: shipment.sector },
}, pair.revealedTokenId, p.reserves));
return record;
}
function recordRichesePairShipment(g: Game, shipment: PendingShipment) {
const p = getPlayer(g, shipment.player);
const offer = nexusRicheseCunningOffer(g, p.id);
requireRule(offer && !offer.blocked && offer.event === shipment.richesePair?.event,
offer?.blocked ?? 'Richese Cunning is unavailable or stale.');
const record: NonNullable<Game['nexusRicheseCunningHistory']>[number] = {
event: offer.event, owner: p.id, turn: g.turn, phase: 5,
tokenEvent: shipment.noField!.event, frame: JSON.stringify(shipment),
stage: 'pending', signature: '',
};
record.signature = richesePairSignature(record);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
(g.nexusRicheseCunningHistory ??= []).push(record);
g.nexusRicheseCunningLast = { event: record.event, stage: record.stage };
}
function finishRichesePairShipment(g: Game, shipment: PendingShipment, stage: 'shipped' | 'stopped') {
if (!shipment.richesePair) return;
const record = currentRichesePairShipment(g, shipment);
record.stage = stage;
record.signature = richesePairSignature(record);
g.nexusRicheseCunningLast = { event: record.event, stage };
}
function richesePairIntegrity(g: Game) {
const history = g.nexusRicheseCunningHistory;
const pending = g.pendingShipment;
if (history === undefined) {
requireRule(!g.nexusRicheseCunningLast && !pending?.richesePair,
'Richese Cunning lost its saved shipment history.');
return;
}
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
'Richese Cunning needs its physical Nexus module and history.');
const events = new Set<string>();
for (const [index, record] of history.entries()) {
requireRule(record && typeof record === 'object' &&
Object.keys(record).sort().join(',') === 'event,frame,owner,phase,signature,stage,tokenEvent,turn' &&
typeof record.owner === 'string' && typeof record.tokenEvent === 'string' &&
Number.isSafeInteger(record.turn) && record.turn > 0 && record.turn <= g.turn &&
record.phase === 5 && ['pending', 'shipped', 'stopped'].includes(record.stage) &&
typeof record.frame === 'string' && record.signature === richesePairSignature(record) &&
!events.has(record.event) && (record.stage !== 'pending' || index === history.length - 1) &&
record.event === JSON.stringify(['nexusRicheseCunning', record.turn, record.owner, record.tokenEvent]),
'Richese Cunning changed its original card, owner or shipment receipt.');
const frame = JSON.parse(record.frame) as PendingShipment;
requireRule(frame && typeof frame === 'object' && !Array.isArray(frame) &&
Object.keys(frame).sort().join(',') === 'advisors,allyPayment,amount,cost,elite,noField,player,richesePair,sector,territory,turn' &&
frame.richesePair?.event === record.event && frame.player === record.owner &&
frame.noField?.event === record.tokenEvent && frame.turn === record.turn &&
typeof frame.noField.tokenId === 'string' && typeof frame.richesePair.revealedTokenId === 'string' &&
g.players.some(player => player.id === record.owner && player.faction === 'richese' &&
player.noField?.tokens.some(token => token.id === frame.noField?.tokenId) &&
player.noField?.tokens.some(token => token.id === frame.richesePair?.revealedTokenId)),
'Richese Cunning changed its two physical token identities.');
events.add(record.event);
}
const last = history.at(-1)!;
requireRule(JSON.stringify(g.nexusRicheseCunningLast) ===
JSON.stringify({ event: last.event, stage: last.stage }) &&
(last.stage === 'pending') === !!pending?.richesePair,
'Richese Cunning lost or reopened its latest shipment.');
if (last.stage === 'pending') {
requireRule(pending && pending.richesePair?.event === last.event,
'Richese Cunning lost its pending shipment.');
currentRichesePairShipment(g, pending);
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'richeseNoField');
const decisions = homeworldSavedDecisions(g).filter(decision => decision.kind === 'guildShipment');
requireRule(responses.length + decisions.length === 1,
'Richese Cunning lost its unique Karama response or Guild interception decision.');
for (const response of responses)
requireRule(response.owner === pending.player && response.noFieldSkillProof === undefined,
'Richese Cunning changed its native No-Field response.');
for (const decision of decisions) validateGuildShipmentDecision(g, decision);
}
}
function shipmentHalfRate(g: Game, p: Player) {
return p.faction === 'guild' || byFaction(g,'guild')?.id === p.ally || g.karamaShipping?.player === p.id;
}
@@ -21513,6 +21677,7 @@ export function executeSpecialKaramaIntent(
}
if (g.pendingShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'reserve',g.pendingShipment,'stopped');
if (g.pendingShipment) finishNexusRicheseShipment(g,g.pendingShipment,'stopped');
if (g.pendingShipment) finishRichesePairShipment(g, g.pendingShipment, 'stopped');
shipper.shipped = true;
// Provisional canceled-shipment settlement pending a primary-source
// clarification: retain the attempted shipment's spice and rate card.
@@ -21682,6 +21847,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
nexusMoritaniRetentionIntegrity(state);
nexusChoamBetrayalIntegrity(state);
nexusEcazInquiryIntegrity(state);
richesePairIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -21844,6 +22010,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
nexusMoritaniRetentionIntegrity(g);
nexusChoamBetrayalIntegrity(g);
nexusEcazInquiryIntegrity(g);
richesePairIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
homeworldBattleLossIntegrity(g);
@@ -21960,6 +22127,7 @@ export function normalizeAutomaticGame(state: Game): Game {
nexusMoritaniRetentionIntegrity(state);
nexusChoamBetrayalIntegrity(state);
nexusEcazInquiryIntegrity(state);
richesePairIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -21994,6 +22162,7 @@ export function normalizeAutomaticGame(state: Game): Game {
nexusMoritaniRetentionIntegrity(g);
nexusChoamBetrayalIntegrity(g);
nexusEcazInquiryIntegrity(g);
richesePairIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
homeworldBattleLossIntegrity(g);
@@ -24799,7 +24968,16 @@ function applyActionInner(
);
let nexusEvent: string | undefined;
let guildSecretEvent: string | undefined;
if (action.nexus !== undefined && action.nexus === nexusGuildSecretAllyOffer(g,id)?.event) {
let richesePair: PendingShipment['richesePair'];
if (action.revealedToken !== undefined) {
const offer = nexusRicheseCunningOffer(g, id);
requireRule(offer && !offer.blocked && action.nexus === offer.event,
offer?.blocked ?? 'Richese Cunning is unavailable or stale.');
requireRule(Object.keys(action).sort().join(',') ===
'allyPayment,event,nexus,noField,revealedToken,sector,territory,type',
'Choose exactly two physical No-Field tokens and a shipment destination.');
richesePair = { event: offer.event, revealedTokenId: stringField(action.revealedToken) };
} else if (action.nexus !== undefined && action.nexus === nexusGuildSecretAllyOffer(g,id)?.event) {
guildSecretEvent = validateGuildSecretDeclaration(g,id,action.nexus);
requireRule(Object.keys(action).every(k => ['type','territory','sector','amount','elite','homeworldSources','allyPayment','nexus'].includes(k)),
'Guild Secret Ally needs an ordinary physical reserve shipment.');
@@ -24813,7 +24991,7 @@ function applyActionInner(
}
checkShipmentPromises(g, p, {
territory: String(action.territory),
amount: Number(action.amount),
amount: richesePair ? 1 : Number(action.amount),
});
const to = String(action.territory),
s = integer(action.sector, 0, 18, 'Sector');
@@ -24836,13 +25014,14 @@ function applyActionInner(
'This No-Field selection is stale.',
);
const tokenId = stringField(action.noField);
noFieldRule(() =>
deployRicheseNoField(p.noField!, {
tokenId,
controller: id,
location: { territory: to, sector: s },
}),
);
if (richesePair)
noFieldRule(() => validateRicheseNoFieldPair(p.noField!, {
tokenId, controller: id, location: { territory: to, sector: s },
}, richesePair.revealedTokenId, p.reserves));
else
noFieldRule(() => deployRicheseNoField(p.noField!, {
tokenId, controller: id, location: { territory: to, sector: s },
}));
requireRule(
action.amount === undefined || action.amount === 1,
'The shipment price is for one No-Field, not a chosen number of physical forces.',
@@ -24853,6 +25032,7 @@ function applyActionInner(
);
noField = { tokenId, event: p.noFieldEvent! };
}
requireRule(!richesePair || noField, 'Richese Cunning needs a concealed native No-Field.');
const n = noField ? 1 : integer(action.amount, 1, p.reserves, 'Forces');
const elite = noField
? 0
@@ -24890,6 +25070,7 @@ function applyActionInner(
...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}),
...(nexusEvent ? {nexusEvent} : {}),
...(guildSecretEvent ? {guildSecretEvent} : {}),
...(richesePair ? { richesePair } : {}),
turn: g.turn,
player: id,
territory: to,
@@ -24916,12 +25097,13 @@ function applyActionInner(
// unsupported arrival combinations before saving its private intent.
validateShipmentArrival(g, shipment);
checkShipmentIncomeRounding(g, p, cost, allyPayment);
if (richesePair) recordRichesePairShipment(g, shipment);
g.pendingShipment = shipment;
g.response = { kind: 'richeseNoField', owner: p.id, passed: [],
...(shipment.noFieldSkillProof ? { noFieldSkillProof: shipment.noFieldSkillProof } : {}) };
log(
g,
`${p.name} declared a concealed No-Field shipment${smugglerCompanion ? ' with one free Smuggler force' : ''} to ${territory(to).name}, sector ${s}.`,
`${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.' : ''}`,
);
} else offerShipment(g, shipment);
return g;
@@ -25766,6 +25948,7 @@ export function viewGame(state: Game, id: string) {
nexusMoritaniRetentionIntegrity(state);
nexusChoamBetrayalIntegrity(state);
nexusEcazInquiryIntegrity(state);
richesePairIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -26097,6 +26280,7 @@ export function viewGame(state: Game, id: string) {
nexusEcazBetrayal: ecazBetrayalOffer(g, id,
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
nexusRichese: nexusRicheseOffer(g,id),
nexusRicheseCunning: nexusRicheseCunningOffer(g, id),
nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id),
nexusGuildCunning: projectedNexusGuildCunning(g,id),
nexusTraitors: projectedNexusTraitors(g, id),
+5
View File
@@ -22,6 +22,11 @@ export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action):
const me = g.players.find(p => p.id === g.me)!;
const to = String(action.territory);
const amount = action.noField ? 1 : action.forces ? Object.values(action.forces as Record<string, number>).reduce((sum, n) => sum + n, 0) : Number(action.amount);
if (action.revealedToken !== undefined) {
const offer = g.nexusRicheseCunning;
return action.noField !== undefined && !g.homeworlds && !!offer && !offer.blocked &&
action.nexus === offer.event;
}
const guildSource = action.nexus !== undefined && action.nexus === g.nexusGuildSecretAlly?.event;
const nexusQuote = action.nexus === undefined ? null : guildSource ? nexusGuildSecretAllyQuote(g, to, amount) : nexusRicheseQuote(g, to, amount);
if (action.nexus !== undefined && (!nexusQuote || (!guildSource && (action.nexus !== g.nexusRichese?.event || action.type !== 'ship')) || action.noField !== undefined)) return false;
+1
View File
@@ -4044,6 +4044,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported; Duke Cunning remains unavailable.',
'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.',
'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.',
'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.',
'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; this does not implement Atomics, Extortion or complete Moritani games.',
+61 -2
View File
@@ -119,10 +119,10 @@ export function createRicheseNoField(ids: readonly string[]): RicheseNoField {
}
/** Engine resolves authorization, shipment legality, payment and Karama first. */
export function deployRicheseNoField(
function validateDeployment(
previous: RicheseNoField,
deployment: NoFieldDeployment,
): RicheseNoField {
): void {
validateRicheseNoField(previous);
requireNoField(
!previous.deployed,
@@ -141,12 +141,71 @@ export function deployRicheseNoField(
'Choose the No-Field force controller.',
);
validateLocation(deployment.location);
}
export function deployRicheseNoField(
previous: RicheseNoField,
deployment: NoFieldDeployment,
): RicheseNoField {
validateDeployment(previous, deployment);
const state = structuredClone(previous);
state.lastShipped = deployment.tokenId;
state.deployed = structuredClone(deployment);
return state;
}
/**
* Validate both identities and current reserves without allocating shipment
* state. The token reference belongs to the caller's validated input state.
*/
export function validateRicheseNoFieldPair(
previous: RicheseNoField,
concealed: NoFieldDeployment,
revealedTokenId: string,
reserves: number,
): NoFieldToken {
validateDeployment(previous, concealed);
requireNoField(
Number.isSafeInteger(reserves) && reserves >= 0,
'Available reserves must be a nonnegative safe integer.',
);
const token = previous.tokens.find((candidate) => candidate.id === revealedTokenId);
requireNoField(token && token.id !== concealed.tokenId,
'Choose a different physical No-Field token to reveal.');
requireNoField(token.id !== previous.lastShipped,
'The same No-Field cannot be shipped twice in a row.');
return token;
}
/**
* Ship two distinct tokens for one shipment. Only the concealed token remains
* in marker custody; the other materializes immediately from available reserves.
* Engine owns payment, board placement and Karama resolution.
*/
export function deployRicheseNoFieldPair(
previous: RicheseNoField,
concealed: NoFieldDeployment,
revealedTokenId: string,
reserves: number,
): { state: RicheseNoField; revealed: NoFieldReveal } {
const token = validateRicheseNoFieldPair(previous, concealed, revealedTokenId, reserves);
const state = structuredClone(previous);
state.lastShipped = concealed.tokenId;
state.deployed = structuredClone(concealed);
return {
state,
revealed: {
state,
tokenId: token.id,
controller: concealed.controller,
location: structuredClone(concealed.location),
value: token.value,
forces: Math.min(token.value, reserves),
cause: 'voluntary',
},
};
}
/** Location/custody only; path, movement allowance and exposure belong to engine. */
export function moveRicheseNoField(
previous: RicheseNoField,
+14 -7
View File
@@ -3,6 +3,7 @@ import {
applyAction,
createGame,
initializeNexusGameForAudit,
initializePairedNexusGameForAudit,
joinGame,
newPlayer,
viewGame,
@@ -24,12 +25,13 @@ import {
export type NexusRicheseOwner =
| 'atreides'
| 'harkonnen'
| 'emperor'
| 'fremen'
| 'guild'
| 'beneGesserit'
| 'ixians'
| 'harkonnen';
| 'richese';
export type NexusRicheseFixtureOptions = {
seatIds?: [string, string, string];
ownerFaction?: NexusRicheseOwner;
@@ -66,14 +68,17 @@ export function nexusRicheseFixture(
? 'guild'
: 'harkonnen');
assert.notEqual(ownerFaction, opponentFaction);
const observerFaction = (
['fremen', 'atreides', 'emperor'] as FactionId[]
).find((f) => f !== ownerFaction && f !== opponentFaction)!;
const observerFaction = ownerFaction === 'richese'
? 'choam'
: (['fremen', 'atreides', 'emperor'] as FactionId[])
.find((f) => f !== ownerFaction && f !== opponentFaction)!;
let g = createGame(
'RICHESENEXUS',
newPlayer(owner, ownerFaction, ownerFaction),
options.advanced ?? false,
ownerFaction === 'ixians' || opponentFaction === 'ixians' ? ['ix'] : [],
ownerFaction === 'ixians' || opponentFaction === 'ixians'
? ['ix']
: ownerFaction === 'richese' ? ['choam'] : [],
);
// The unfinished E3 public deck gate is separate from native Moritani audit
// setup. Fix the final faction before readiness and genuine initialization.
@@ -92,7 +97,9 @@ export function nexusRicheseFixture(
g = applyAction(g, owner, { type: 'homeworlds', enabled: true });
g.nexusCards = { cards: null, phase: null };
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeNexusGameForAudit(g);
g = ownerFaction === 'richese'
? initializePairedNexusGameForAudit(g)
: initializeNexusGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 100; step++) {
let next: Game | undefined;
for (const p of g.players) {
@@ -112,7 +119,7 @@ export function nexusRicheseFixture(
}
assert.equal(g.status, 'playing');
for (const p of g.players) g.deck.push(...p.hand.splice(0));
if (g.expansions.includes('ix')) {
if (g.expansions.includes('ix') || ownerFaction === 'richese') {
for (let step = 0; g.phase === 0 && step < 60; step++) g = fixtureStep(g);
assert.equal(g.phase, 1);
orderNexusSpice(g, ['land', 'land']);
+146
View File
@@ -0,0 +1,146 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { presenceAt } from '../game/force-presence';
import { nexusRicheseAllow, nexusRicheseFixture } from './fixture-nexus-richese';
function pair(g: Game, owner: string, revealedValue: 0 | 3 | 5 = 5): Action {
const tokens = g.players.find(player => player.id === owner)!.noField!.tokens;
const revealed = tokens.find(token => token.value === revealedValue)!;
const concealed = tokens.find(token => token.id !== revealed.id &&
token.id !== g.players.find(player => player.id === owner)!.noField!.lastShipped)!;
return {
type: 'ship', noField: concealed.id, revealedToken: revealed.id,
nexus: viewGame(g, owner).nexusRicheseCunning!.event,
event: g.players.find(player => player.id === owner)!.noFieldEvent,
territory: 'arrakeen', sector: 10, allyPayment: 0,
};
}
void test('Basic and Advanced Richese pair spends one card and price, materializes one public token and retains one concealed', () => {
for (const advanced of [false, true]) {
const { g: before, owner, target } = nexusRicheseFixture({ ownerFaction: 'richese', advanced });
const p = before.players.find(player => player.id === owner)!;
const original = JSON.stringify(before);
const request = pair(before, owner);
const concealed = String(request.noField);
const revealed = String(request.revealedToken);
const after = applyAction(before, owner, request);
const actual = after.players.find(player => player.id === owner)!;
assert.equal(JSON.stringify(before), original);
assert.equal(after.nexusCards!.cards!.hands[owner], null);
assert.equal(actual.spice, p.spice - 1);
assert.equal(actual.reserves, p.reserves - 5);
assert.equal(actual.forces['arrakeen:10'], 5);
assert.equal(actual.noField!.deployed!.tokenId, concealed);
assert.equal(actual.noField!.lastShipped, concealed);
assert.equal(presenceAt(actual, 'arrakeen'), 6);
assert.equal(after.nexusRicheseCunningLast?.stage, 'shipped');
assert.match(after.log.at(-1)!.text, /faceup 5 token placed 5 physical forces/);
const rival = viewGame(after, target);
assert.equal(rival.richeseNoField!.private, null);
assert.equal(rival.nexusRicheseCunning, null);
assert.equal(JSON.stringify(rival).includes(concealed), false);
assert.equal(JSON.stringify(rival).includes(revealed), false);
const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(after)));
assert.equal(restored.players.find(player => player.id === owner)!.reserves, p.reserves - 5);
assert.equal(viewGame(restored, target).richeseNoField!.public.deployed!.effectiveForces, 1);
assert.throws(() => applyAction(restored, owner, request), /ship once|unused shipment|stale/i);
}
});
void test('Karama cancels the two-token attempt without spending spice or tokens, while the Nexus stays spent', () => {
const { g: before, owner, target, karama } = nexusRicheseFixture({ ownerFaction: 'richese', karama: true });
const request = pair(before, owner);
const declared = applyAction(before, owner, request);
assert.equal(declared.response?.kind, 'richeseNoField');
assert.equal(declared.nexusCards!.cards!.hands[owner], null);
assert.equal(declared.players[0].noField!.deployed, null);
const canceled = applyAction(JSON.parse(JSON.stringify(declared)), target,
{ type: 'card', mode: 'cancel', card: karama! });
assert.equal(canceled.nexusRicheseCunningLast?.stage, 'stopped');
assert.equal(canceled.players[0].noField!.lastShipped, null);
assert.equal(canceled.players[0].reserves, before.players[0].reserves);
assert.equal(canceled.players[0].spice, before.players[0].spice);
assert.equal(canceled.players[0].shipped, false);
assert.equal(viewGame(canceled, owner).nexusRicheseCunning, null);
assert.throws(() => applyAction(canceled, owner, request), /unavailable|stale/i);
const ordinary = applyAction(canceled, owner,
{ type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 });
assert.equal(ordinary.players[0].forces['arrakeen:10'], 1);
});
void test('Advanced Guild special prevention spends Cunning without moving a token or charging shipment price', () => {
const { g, owner, target, karama } = nexusRicheseFixture({
ownerFaction: 'richese', opponentFaction: 'guild', advanced: true, karama: true,
});
const request = pair(g, owner);
const pending = nexusRicheseAllow(applyAction(g, owner, request));
assert.equal(pending.decision?.kind, 'guildShipment');
assert.equal(pending.pendingShipment?.richesePair?.revealedTokenId, request.revealedToken);
const stopped = nexusRicheseAllow(applyAction(
JSON.parse(JSON.stringify(pending)), target,
{ type: 'card', mode: 'special', card: karama! },
));
assert.equal(stopped.nexusRicheseCunningLast?.stage, 'stopped');
assert.equal(stopped.players[0].spice, g.players[0].spice);
assert.equal(stopped.players[0].reserves, g.players[0].reserves);
assert.equal(stopped.players[0].noField!.deployed, null);
assert.equal(stopped.players[0].noField!.lastShipped, null);
assert.equal(stopped.nexusCards!.cards!.hands[owner], null);
assert.equal(stopped.players[0].shipped, true);
});
void test('restoration rejects an orphaned pair before it can lose its Karama or Guild continuation', () => {
const basic = nexusRicheseFixture({ ownerFaction: 'richese', karama: true });
const declared = applyAction(basic.g, basic.owner, pair(basic.g, basic.owner));
assert.equal(declared.response?.kind, 'richeseNoField');
const missingResponse = JSON.parse(JSON.stringify(declared)) as Game;
missingResponse.response = null;
assert.throws(() => normalizeAutomaticGame(missingResponse), /Karama response or Guild interception/);
const advanced = nexusRicheseFixture({
ownerFaction: 'richese', opponentFaction: 'guild', advanced: true,
});
const pending = nexusRicheseAllow(applyAction(
advanced.g, advanced.owner, pair(advanced.g, advanced.owner),
));
assert.equal(pending.decision?.kind, 'guildShipment');
const missingDecision = JSON.parse(JSON.stringify(pending)) as Game;
missingDecision.decision = null;
assert.throws(() => viewGame(missingDecision, advanced.owner), /Karama response or Guild interception/);
});
void test('pair rejects duplicate, prior-used and stale tokens without changing the original game', () => {
const { g, owner } = nexusRicheseFixture({ ownerFaction: 'richese' });
const request = pair(g, owner);
const original = JSON.stringify(g);
for (const altered of [
{ ...request, revealedToken: request.noField },
{ ...request, noField: 'foreign-token' },
{ ...request, nexus: 'stale' },
{ ...request, event: 'stale' },
{ ...request, extra: 'unbound' },
]) {
assert.throws(() => applyAction(g, owner, altered));
assert.equal(JSON.stringify(g), original);
}
const old = g.players[0].noField!;
old.lastShipped = old.tokens.find(token => token.value === 5)!.id;
assert.throws(() => applyAction(g, owner, request));
});
void test('all four profiles choose and complete a legal pair using their private own-token view', () => {
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const { g, owner } = nexusRicheseFixture({ ownerFaction: 'richese' });
const view = viewGame(g, owner);
view.players.find(player => player.id === owner)!.bot = difficulty;
const action = botActions(view)[0];
assert.equal(action?.type, 'ship');
assert.equal(action?.nexus, view.nexusRicheseCunning!.event, JSON.stringify(action));
assert.ok(action?.revealedToken && action.noField && action.revealedToken !== action.noField);
const after = applyAction(g, owner, action);
assert.equal(after.nexusRicheseCunningLast?.stage, 'shipped');
}
});
+47 -2
View File
@@ -18,15 +18,16 @@ import {
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture(options: NexusRicheseFixtureOptions = {}) {
const store = unitStore();
const richese = options.ownerFaction === 'richese';
const made = await store.rooms.createRoom(
'Richese SQL',
options.ownerFaction ?? 'atreides',
options.advanced ?? true,
[],
richese ? ['choam'] : [],
);
const code = made.view.code,
tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
for (const faction of ['guild', richese ? 'choam' : 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
@@ -150,6 +151,50 @@ void test('SQLite competing Richese declarations spend one Nexus and restore one
}
});
void test('SQLite competing native Cunning pairs settle one concealed marker and one immediate faceup group after reload', async () => {
const f = await fixture({ ownerFaction: 'richese', opponentFaction: 'guild', spice: 20 });
try {
const tokens = f.g.players[0].noField!.tokens;
const three = tokens.find(token => token.value === 3)!.id;
const five = tokens.find(token => token.value === 5)!.id;
const event = viewGame(f.g, f.owner).nexusRicheseCunning!.event;
const request = (concealed: string, revealed: string): Action => ({
type: 'ship', territory: 'arrakeen', sector: 10,
noField: concealed, revealedToken: revealed, event: f.g.players[0].noFieldEvent,
nexus: event, allyPayment: 0,
});
await compete(f, f.g.version, [request(three, five), request(five, three)]);
let pending = await f.restart().readRoom(f.code);
assert.equal(pending.nexusRicheseCunningLast?.stage, 'pending');
assert.equal(pending.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
await restored(f, pending);
while (pending.response) {
const seat = f.ids.findIndex(id => !pending.response!.passed.includes(id));
pending = await act(f, seat, { type: 'passResponse' });
}
assert.equal(pending.decision?.kind, 'guildShipment');
const revealedId = pending.pendingShipment!.richesePair!.revealedTokenId;
await restored(f, pending);
const done = await act(f, 1, { type: 'decision', allow: true });
const marker = done.players[0].noField!.deployed!;
const revealed = tokens.find(token => token.id === revealedId)!;
assert.equal(done.nexusRicheseCunningLast?.stage, 'shipped');
assert.equal(done.players[0].noField!.lastShipped, marker.tokenId);
assert.equal(done.players[0].forces['arrakeen:10'], revealed.value);
assert.equal(done.players[0].reserves, 20 - revealed.value);
assert.equal(done.players[0].spice, 19);
assert.equal(done.players[1].spice, 21);
await restored(f, done);
const before = row(f);
await assert.rejects(() => f.restart().act(
f.code, f.auths[0], done.version, request(marker.tokenId, revealed.id), clock,
));
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
});
void test('SQLite Guild special stop preserves physical reserves and payment while keeping Richese Nexus spent across reload', async () => {
const f = await fixture({ spice: 1 });
try {
+128
View File
@@ -4,6 +4,7 @@ import {
createRicheseNoField,
validateRicheseNoField,
deployRicheseNoField,
deployRicheseNoFieldPair,
moveRicheseNoField,
revealRicheseNoField,
shipAlliedRicheseNoField,
@@ -284,6 +285,133 @@ void test('allied shipment reveals immediately from the ally reserves and shares
assert.equal(zero.state.lastShipped, ids[0]);
});
void test('paired shipment retains concealed custody while the other token materializes only available forces', () => {
for (const [concealedId, revealedId, reserves, value, forces] of [
[ids[0], ids[2], 2, 5, 2],
[ids[2], ids[1], 0, 3, 0],
[ids[1], ids[0], 20, 0, 0],
[ids[0], ids[1], 3, 3, 3],
] as const) {
const previous = fresh();
const snapshot = structuredClone(previous);
const result = deployRicheseNoFieldPair(
previous,
{ tokenId: concealedId, controller: 'richese', location },
revealedId,
reserves,
);
assert.deepEqual(previous, snapshot);
assert.deepEqual(result.state.deployed, {
tokenId: concealedId,
controller: 'richese',
location,
});
assert.equal(result.state.lastShipped, concealedId);
assert.deepEqual(
{
tokenId: result.revealed.tokenId,
controller: result.revealed.controller,
location: result.revealed.location,
value: result.revealed.value,
forces: result.revealed.forces,
cause: result.revealed.cause,
},
{
tokenId: revealedId,
controller: 'richese',
location,
value,
forces,
cause: 'voluntary',
},
);
assert.deepEqual(projectRicheseNoField(result.state, false), {
deployed: { controller: 'richese', location, effectiveForces: 1 },
lastUsed: null,
});
}
});
void test('paired shipment consumes neither identity nor history on invalid choices', () => {
const previous = reveal(deployed(0)).state;
const snapshot = JSON.stringify(previous);
const concealed = { tokenId: ids[1], controller: 'richese', location };
for (const [deployment, revealedId, message] of [
[concealed, ids[1], /different physical/],
[concealed, ids[0], /twice/],
[{ ...concealed, tokenId: ids[0] }, ids[2], /twice/],
[concealed, 'unknown', /physical/],
[{ ...concealed, tokenId: 'unknown' }, ids[2], /physical/],
] as const)
assert.throws(
() => deployRicheseNoFieldPair(previous, deployment, revealedId, 5),
message,
);
assert.equal(JSON.stringify(previous), snapshot);
const restored = JSON.parse(snapshot) as RicheseNoField;
const { state, revealed } = deployRicheseNoFieldPair(
restored,
concealed,
ids[2],
1,
);
assert.equal(revealed.forces, 1);
assert.equal(state.lastShipped, ids[1]);
assert.equal(state.deployed?.tokenId, ids[1]);
assert.equal(JSON.stringify(restored), snapshot);
const restoredPair = JSON.parse(JSON.stringify(state)) as RicheseNoField;
assert.deepEqual(
projectRicheseNoField(restoredPair, true),
projectRicheseNoField(state, true),
);
const afterReveal = reveal(restoredPair).state;
assert.equal(afterReveal.lastShipped, ids[1]);
assert.equal(afterReveal.deployed, null);
assert.throws(
() =>
deployRicheseNoField(afterReveal, {
tokenId: ids[1],
controller: 'richese',
location,
}),
/twice/,
);
});
void test('paired shipment rejects occupied custody, malformed reserves and placement without mutation', () => {
const prior = deployed(0);
const priorSnapshot = JSON.stringify(prior);
assert.throws(
() =>
deployRicheseNoFieldPair(
prior,
{ tokenId: ids[1], controller: 'richese', location },
ids[2],
5,
),
/existing/,
);
assert.equal(JSON.stringify(prior), priorSnapshot);
const previous = fresh();
const snapshot = JSON.stringify(previous);
const deployment = { tokenId: ids[1], controller: 'richese', location };
for (const reserves of [-1, NaN, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1])
assert.throws(
() => deployRicheseNoFieldPair(previous, deployment, ids[2], reserves),
/reserves/,
);
for (const invalid of [
{ ...deployment, controller: ' ' },
{ ...deployment, location: { territory: 'imperial_basin', sector: 0 } },
])
assert.throws(
() => deployRicheseNoFieldPair(previous, invalid, ids[2], 5),
/controller|territory/,
);
assert.equal(JSON.stringify(previous), snapshot);
});
void test('failed allied materialization does not partially consume the selected token or rewrite history', () => {
const state = reveal(deployed(0)).state;
const exact = structuredClone(state);