Prototype CHOAM Nexus Collection trade and inventory Discoveries
An unallied player holding CHOAM Nexus while CHOAM is absent can now discard one Worthless card during Spice Collection for two bank spice. The server spends both physical cards once, exposes only private legal card choices, and persists an already-paid discard receipt. Human controls and all four AI profiles share the action. The after-victory inspection alternative remains missing; normal unfinished modes and publication stay gated. Independent review caught and fixed a queued Grumman Collection lock mismatch. Focused tests cover Basic/Advanced, actual card custody, private controls, all AI profiles, stale and wrong-mode rejection, Truthtrance and Grumman locks, JSON corruption/replay, competing production SQLite writes and private seat restoration. A separate bounded acquisition helper recorded all seven Discovery Spice Cards, eight tokens and exact physical card placements from publisher-authored sources. Discovery runtime remains missing. Validation: types, lint, 4,217 offline tests, production build and 40 HTTP tests passed. Durations: check 139298 ms; build 6617 ms; HTTP 24329 ms. Source fingerprint dfdaaf8bafe69acd31c9d0afc13f7a0d070da43deb544f091e95fe840b399493; private report under ~/.local/share/dune/checkpoints/20260913-choam-nexus-trade/verification/report.json. Browser reference search, Prototyped label and related Collection link checked. All 95 opening saved rooms unchanged; 30 HTTP test rooms added, 125 total. No server restart or database reset. Efficiency counter: 59,982,405 at batch start; 60,581,959 after broad verification, delta 599,554 aggregate tokens before final preservation/Git/usage checks. One newly playable function plus the acquired Discovery inventory; this is not full CHOAM or Nexus compliance.
This commit is contained in:
1 parent
5a76ae0410
commit
4af6e08b3a
18 files changed
+663
-3
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { nexusChoamTradeBotActions } from './nexus-choam-trade-options';
|
||||
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options';
|
||||
import { nexusMoritaniBotActions } from './nexus-moritani-options';
|
||||
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
|
||||
@@ -3368,6 +3369,8 @@ export function botActions(g: GameView): Action[] {
|
||||
: nexusTraitorBotActions(g);
|
||||
if (g.automaticContinuationPending) return [];
|
||||
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
|
||||
const trade = nexusChoamTradeBotActions(g);
|
||||
if (trade.length) return trade;
|
||||
const sardaukar = nexusSardaukarBotActions(g);
|
||||
if (sardaukar.length) return sardaukar;
|
||||
const advisorConversion = nexusAdvisorBotActions(g);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade';
|
||||
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
|
||||
import { shipmentAvailable } from './shipment-opportunity';
|
||||
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
|
||||
@@ -883,6 +884,8 @@ type RicheseAllyOffer = {
|
||||
payer: string;
|
||||
};
|
||||
export type Game = {
|
||||
nexusChoamTrades?: NexusChoamTrade[];
|
||||
nexusChoamTradeLast?: { event: string; stage: NexusChoamTrade['stage'] };
|
||||
treacheryDiscardSequence?: number;
|
||||
resolvedTreacheryDiscardSequence?: number;
|
||||
/** Committed discard receipts are evidence, not additional card custody. */
|
||||
@@ -891,6 +894,7 @@ export type Game = {
|
||||
batch: FreshDiscardBatch;
|
||||
continuation:
|
||||
| { kind: 'ambassador'; entry: NonNullable<Game['pendingAmbassador']> }
|
||||
| { kind: 'nexusChoamTrade'; event: string; owner: string; card: string; spiceAfter: number }
|
||||
| { kind: 'kaitainDiscard'; owner: string; event: string; cost: number; spiceAfter: number }
|
||||
| { kind: 'winnerMandatoryDiscard'; event: string; player: string; territory: string; optional: string[]; commitment: NonNullable<Game['pendingWinnerDiscards']> }
|
||||
| {
|
||||
@@ -1482,6 +1486,61 @@ function nexusCardsIntegrity(g: Game) {
|
||||
!g.truthtrance && !g.pendingKarama && !g.pendingTreacheryDiscard,
|
||||
'Finish the preceding phase interaction before drawing Nexus cards.');
|
||||
}
|
||||
function nexusChoamTradeIntegrity(g: Game) {
|
||||
const history = g.nexusChoamTrades;
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const pending = continuation?.kind === 'nexusChoamTrade' ? continuation : null;
|
||||
if (history === undefined) {
|
||||
requireRule(!g.nexusChoamTradeLast && !pending, 'The CHOAM Nexus trade has lost its saved history.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
|
||||
'The CHOAM Nexus trade requires its physical module and history.');
|
||||
const events = new Set<string>(), turns = new Set<number>();
|
||||
const physical = physicalTreacheryCards(g);
|
||||
for (const [index, record] of history.entries()) {
|
||||
nexusRule(() => validateNexusChoamTrade(g, record, physical));
|
||||
requireRule(!events.has(record.event) && !turns.has(record.turn) &&
|
||||
(record.stage === 'complete' || index === history.length - 1),
|
||||
'The CHOAM Nexus trade has a duplicated or unfinished prior payment.');
|
||||
events.add(record.event); turns.add(record.turn);
|
||||
}
|
||||
const last = history.at(-1)!;
|
||||
requireRule(JSON.stringify(g.nexusChoamTradeLast) === JSON.stringify({event: last.event, stage: last.stage}) &&
|
||||
(last.stage === 'discard') === !!pending,
|
||||
'The CHOAM Nexus trade has lost or reopened its committed discard.');
|
||||
if (last.turn === g.turn)
|
||||
requireRule(g.nexusCards.cards.discard.includes('choam'), 'The CHOAM Nexus trade has lost its spent Nexus card.');
|
||||
if (pending) requireRule(g.status === 'playing' && g.phase === 7 && last.turn === g.turn &&
|
||||
pending.event === last.event && pending.owner === last.owner && pending.card === last.card &&
|
||||
pending.spiceAfter === last.spiceBefore + 2 && getPlayer(g, last.owner).spice === pending.spiceAfter,
|
||||
'The CHOAM Nexus trade no longer matches its committed payment.');
|
||||
}
|
||||
function currentNexusChoamTrade(g: Game, owner: string) {
|
||||
return nexusRule(() => quoteNexusChoamTrade(g, owner,
|
||||
g.phase === 7 && grummanCollectionAutomatic(g)));
|
||||
}
|
||||
function playNexusChoamTrade(g: Game, p: Player, action: Action) {
|
||||
const offer = currentNexusChoamTrade(g, p.id);
|
||||
requireRule(Object.keys(action).every(key => ['type', 'event', 'card'].includes(key)) &&
|
||||
offer && !offer.blocked && action.event === offer.event &&
|
||||
offer.cards.some(card => card.id === action.card),
|
||||
offer?.blocked ?? 'Choose your current CHOAM Nexus trade and one held Worthless card.');
|
||||
requireRule(Number.isSafeInteger(p.spice) && p.spice >= 0 && Number.isSafeInteger(p.spice + 2),
|
||||
'The CHOAM Nexus trade needs a valid spice balance.');
|
||||
const record: NexusChoamTrade = { event: offer.event, turn: g.turn, owner: p.id,
|
||||
card: action.card as string, spiceBefore: p.spice, stage: 'discard', signature: '' };
|
||||
record.signature = nexusChoamTradeSignature(record);
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
const card = discard(g, p, record.card);
|
||||
p.spice += 2;
|
||||
(g.nexusChoamTrades ??= []).push(record);
|
||||
g.nexusChoamTradeLast = { event: record.event, stage: record.stage };
|
||||
stageTreacheryDiscard(g, 'nexus:choamTrade', [{card, discardedBy: p.id, publicFace: true}],
|
||||
{kind: 'nexusChoamTrade', event: record.event, owner: p.id, card: card.id, spiceAfter: p.spice});
|
||||
log(g, `${p.name} spent CHOAM Nexus Secret Ally and discarded ${card.name} during Spice Collection to receive 2 spice from the bank. Both cards are spent.`,
|
||||
{faction: p.faction, name: 'CHOAM Nexus trade'});
|
||||
}
|
||||
function markNexusOccurred(g: Game) {
|
||||
if (!g.nexusCards) return;
|
||||
const phase = g.nexusCards.phase;
|
||||
@@ -4256,6 +4315,13 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
Array.isArray(c.optional) && new Set(c.optional).size === c.optional.length &&
|
||||
c.optional.every((id) => getPlayer(g, c.player).hand.some((card) => card.id === id)),
|
||||
'The mandatory winning card discard no longer matches its resolved battle.');
|
||||
} else if (continuation?.kind === 'nexusChoamTrade') {
|
||||
const c = continuation;
|
||||
nexusChoamTradeIntegrity(g);
|
||||
requireRule(batch.cause === 'nexus:choamTrade' && batch.entries.length === 1 &&
|
||||
batch.entries[0].discardedBy === c.owner && batch.entries[0].publicFace &&
|
||||
batch.entries[0].card.id === c.card && batch.entries[0].card.kind === 'worthless',
|
||||
'The CHOAM Nexus trade has changed its discarded Worthless card.');
|
||||
} else if (continuation?.kind === 'kaitainDiscard') {
|
||||
const c = continuation;
|
||||
requireRule(g.phase === 3 && g.biddingEnd?.event === c.event &&
|
||||
@@ -4304,6 +4370,12 @@ function finishTreacheryDiscard(g: Game) {
|
||||
finishWinner(g, getPlayer(g, next.player), next.territory, next.optional);
|
||||
return;
|
||||
}
|
||||
if (next.kind === 'nexusChoamTrade') {
|
||||
const record = g.nexusChoamTrades!.at(-1)!;
|
||||
record.stage = 'complete'; record.signature = nexusChoamTradeSignature(record);
|
||||
g.nexusChoamTradeLast = { event: record.event, stage: record.stage };
|
||||
return;
|
||||
}
|
||||
if (next.kind === 'kaitainDiscard') return;
|
||||
if (next.kind === 'ambassador') {
|
||||
const p = getPlayer(g, next.entry.beneficiary!);
|
||||
@@ -12157,6 +12229,7 @@ function resumeMarketGhola(g: Game) {
|
||||
if (!pending || pending.stage !== 'complete' || g.pendingTreacheryDiscard || g.pendingNullentropy ||
|
||||
g.response || g.decision || g.pendingKarama || g.truthtrance ||
|
||||
g.phaseOpening || g.pendingRicheseGift || g.pendingExchange) return;
|
||||
nexusChoamTradeIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
g.response = structuredClone(pending.response);
|
||||
g.pendingChoamMarketGhola = null;
|
||||
@@ -17573,6 +17646,7 @@ function normalizeCardNames(g: Game) {
|
||||
]);
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
nexusChoamTradeIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -17723,6 +17797,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
reconcileShipmentPromises(g, { actor: id, action });
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
nexusChoamTradeIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
homeworldBattleLossIntegrity(g);
|
||||
@@ -17817,6 +17892,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
}
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
nexusChoamTradeIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -17844,6 +17920,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileShipmentPromises(g);
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
nexusChoamTradeIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
homeworldBattleLossIntegrity(g);
|
||||
@@ -17934,6 +18011,7 @@ function applyActionInner(
|
||||
requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.');
|
||||
finishNexusTraitorReturn(g, p, action); return g;
|
||||
}
|
||||
if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; }
|
||||
if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; }
|
||||
requireRule(
|
||||
!(
|
||||
@@ -21156,6 +21234,7 @@ function applyActionInner(
|
||||
throw new RuleError('That action is not available.');
|
||||
}
|
||||
export function viewGame(state: Game, id: string) {
|
||||
nexusChoamTradeIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -21447,6 +21526,7 @@ export function viewGame(state: Game, id: string) {
|
||||
techTokens: g.techTokens ?? null,
|
||||
strongholdCards: g.strongholdCards ?? null,
|
||||
nexusCards: projectedNexusCards(g, id),
|
||||
nexusChoamTrade: currentNexusChoamTrade(g, id),
|
||||
nexusMoritani: nexusMoritaniOffer(g,id),
|
||||
nexusRichese: nexusRicheseOffer(g,id),
|
||||
nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** Human and AI use the same private, server-quoted physical card choices. */
|
||||
export function nexusChoamTradeAction(game: GameView, card: string): Action | null {
|
||||
const offer = game.nexusChoamTrade;
|
||||
if (game.status !== 'playing' || game.phase !== 7 ||
|
||||
game.nexusCards?.card !== 'choam' || game.automaticContinuationPending ||
|
||||
!offer || offer.blocked || !offer.cards.some(choice => choice.id === card)) return null;
|
||||
return { type: 'nexusChoamTrade', event: offer.event, card };
|
||||
}
|
||||
export function nexusChoamTradeBotActions(game: GameView): Action[] {
|
||||
const card = game.nexusChoamTrade?.cards[0];
|
||||
const action = card ? nexusChoamTradeAction(game, card.id) : null;
|
||||
return action ? [action] : [];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Card } from './cards';
|
||||
import type { Game } from './engine';
|
||||
import { nexusCardMode } from './nexus-cards';
|
||||
|
||||
export type NexusChoamTrade = {
|
||||
event: string;
|
||||
turn: number;
|
||||
owner: string;
|
||||
card: string;
|
||||
spiceBefore: number;
|
||||
stage: 'discard' | 'complete';
|
||||
signature: string;
|
||||
};
|
||||
export function nexusChoamTradeSignature(record: NexusChoamTrade): string {
|
||||
return JSON.stringify([record.event, record.turn, record.owner, record.card,
|
||||
record.spiceBefore, record.stage]);
|
||||
}
|
||||
export function nexusChoamTradeEvent(turn: number, owner: string): string {
|
||||
return JSON.stringify(['nexusChoamTrade', turn, owner]);
|
||||
}
|
||||
/** Private owner quote. Secret Ally does not change faction identity or invoke
|
||||
* native CHOAM market prices, Cunning conversions, or Auditor rules. */
|
||||
export function quoteNexusChoamTrade(g: Game, owner: string, automaticPending: boolean) {
|
||||
const p = g.players.find(player => player.id === owner);
|
||||
if (!p || g.nexusCards?.cards?.hands[owner] !== 'choam' ||
|
||||
nexusCardMode('choam', p.faction, g.players.map(player => player.faction)) !== 'secretAlly') return null;
|
||||
let blocked: string | null = null;
|
||||
if (p.ally) blocked = 'Use a Nexus card while unallied.';
|
||||
else if (g.status !== 'playing' || g.phase !== 7)
|
||||
blocked = 'Trade during Spice Collection.';
|
||||
else if (automaticPending || g.truthtrance || g.response || g.decision || g.phaseOpening ||
|
||||
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy ||
|
||||
g.pendingExchange || g.pendingAmbassador || g.pendingRicheseGift ||
|
||||
g.pendingRichesePurchaseIncome || g.nexusCards.phase?.stage === 'drawing' ||
|
||||
g.nexusTraitorExchanges?.some(record => record.stage === 'return'))
|
||||
blocked = 'Finish the current interaction before trading.';
|
||||
if (!blocked && (!Number.isSafeInteger(p.spice) || p.spice < 0 || !Number.isSafeInteger(p.spice + 2)))
|
||||
blocked = 'The trade needs a valid spice balance.';
|
||||
const cards = p.hand.filter(card => card.kind === 'worthless').map(card => ({ ...card }));
|
||||
if (!blocked && !cards.length) blocked = 'You need a Worthless card to trade.';
|
||||
return { event: nexusChoamTradeEvent(g.turn, owner), blocked, cards, spice: 2 as const };
|
||||
}
|
||||
export function validateNexusChoamTrade(g: Game, record: NexusChoamTrade, physical: readonly Card[]): void {
|
||||
const p = record && g.players.find(player => player.id === record.owner);
|
||||
if (!record || Object.keys(record).sort().join(',') !== 'card,event,owner,signature,spiceBefore,stage,turn' ||
|
||||
!p || g.players.some(player => player.faction === 'choam') ||
|
||||
!Number.isSafeInteger(record.turn) || record.turn < 1 || record.turn > g.turn ||
|
||||
!Number.isSafeInteger(record.spiceBefore) || record.spiceBefore < 0 ||
|
||||
!Number.isSafeInteger(record.spiceBefore + 2) ||
|
||||
!['discard', 'complete'].includes(record.stage) ||
|
||||
record.event !== nexusChoamTradeEvent(record.turn, record.owner) ||
|
||||
record.signature !== nexusChoamTradeSignature(record) ||
|
||||
!physical.some(card => card.id === record.card && card.kind === 'worthless'))
|
||||
throw new Error('The CHOAM Nexus trade has lost its original owner, card or payment.');
|
||||
}
|
||||
@@ -3406,6 +3406,28 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nexus-choam-trade',
|
||||
title: 'CHOAM Nexus: Collection trade',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'When CHOAM is absent, an unallied holder may spend the CHOAM Nexus card and one Worthless card for two bank spice during Spice Collection.',
|
||||
steps: [
|
||||
'Use the Secret Ally panel only when no player controls CHOAM. This works in Basic and Advanced play.',
|
||||
'During Spice Collection, choose one Worthless card from your own hand. The chosen card and your CHOAM Nexus card are discarded, and you receive two spice from the bank.',
|
||||
'Finish any current choice, Truthtrance or automatic continuation first. Continuing the phase without trading keeps your cards.',
|
||||
'The card also permits a private random unused opposing hand-card inspection after a victory. That alternative is still in development.',
|
||||
],
|
||||
related: ['nexus-cards', 'collection'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Collection trade is playable with physical custody and a saved payment/discard receipt. Battle inspection remains missing.', evidence: ['game/nexus-choam-trade.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private Worthless-card picker and inspector with explicit two-card cost and availability reasons.', evidence: ['components/nexus-choam-trade.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles use the same private legal trade action. Strategy between the two panel uses awaits battle inspection.', evidence: ['game/nexus-choam-trade-options.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Source contract and connected Collection behavior are documented; remaining inspection and combination work is explicit.', evidence: ['docs/NEXUS_CHOAM_SECRET_ALLY.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, private controls, AI, saved continuation and competing SQLite actions. Complete family acceptance remains open.', evidence: ['tests/nexus-choam-trade.test.ts', 'tests/nexus-choam-trade-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nexus-cards',
|
||||
title: 'Nexus Cards',
|
||||
@@ -3436,6 +3458,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.',
|
||||
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
|
||||
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups and other Emperor Nexus modes remain unavailable.',
|
||||
'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. Its after-victory inspection remains unfinished.',
|
||||
'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont are supported; Kull Wahad’s Karama-prevention reaction remains unavailable.',
|
||||
'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.',
|
||||
'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.',
|
||||
|
||||
Reference in new issue
Block a user