diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index cdbb4aab..bc1d6ad1 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,19 @@ open mode gates. ## Current checkpoint and work +**Basic Richese No-Field interruptions — 29 October. Prototyped / Partial.** +In supported Basic native Ix/CHOAM games the owner can reveal a deployed +No-Field during an Ix opening and selected economic decisions without replacing +the pending parent. Genuine setup, paid shipment, the next opening, Richese +cache/unbid and CHOAM market/trade choices exercise physical reveal and native +continuation; the owner control and private commitment boundary have focused +checks. Five related files pass 25 cases; type checking, lint and the production +build pass. Current-turn Truthtrance history, +active commitments, older saves without public history, nested responses, +arrival children and optional modules remain outside this slice. An automatic +parent can still finish before a human uses the control; deployed acceptance +remains open. [Scope](RICHESE_NO_FIELD_RULES.md#basic-phase-opening-and-economic-interruptions--29-october-2026). + **Basic Fremen ally revival scheduling — 29 October. Prototyped / Partial.** An automated mutual ally now waits for a useful human Fremen free-revival grant in concurrent Basic Revival, until the grant resolves or the Fremen selects the diff --git a/docs/RICHESE_NO_FIELD_RULES.md b/docs/RICHESE_NO_FIELD_RULES.md index 079e4360..a4c6c622 100644 --- a/docs/RICHESE_NO_FIELD_RULES.md +++ b/docs/RICHESE_NO_FIELD_RULES.md @@ -2,6 +2,27 @@ Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode. +## Basic phase-opening and economic interruptions — 29 October 2026 + +**Prototyped / Partial.** The publisher's CHOAM & Richese rulebook, printed +p. 6, permits Richese to reveal a No-Field before Battle. In supported Basic +native Ix/CHOAM games, the owner can now reveal during an Ix phase opening or +selected ordinary auction, Richese cache and CHOAM market decisions. The +existing private token/event control materializes the physical counters once, +up to the actual reserves, while preserving the original pending choice and +its continuation. Other players cannot issue that reveal. Its ordering within +an online interruption is a provisional implementation, not a publisher +priority ruling. + +Nested responses, Truthtrance and current-turn Truthtrance history, active +commitments, Homeworlds and other optional-module compositions remain outside +this slice. The current-turn history boundary is uniform across private answer +residuals. Older saves without a public Truthtrance history retain the previous +interruption behavior; newly created games initialize an empty history. An +automatically resolved parent can still finish before a human uses this optional +control. These limits do not certify the complete printed timing right, full +factions or deployed acceptance. + ## Fresh marker with native reserves — 28 October 2026 **Prototyped / Partial.** In the existing native Basic and Advanced Homeworld diff --git a/game/engine.ts b/game/engine.ts index a12e1fcc..b8ad7c32 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -664,6 +664,7 @@ import { resolveBattleWeapons } from './effective-weapons'; import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola'; import { quoteAdvisorPreparationWindow, quoteAdvisorPreparation, validateAdvisorPreparation, projectAdvisorPreparationWindow, AdvisorPreparationError, type AdvisorPreparationWindow, type AdvisorPreparationSource } from './advisor-preparation-window'; import { quoteFremenRevivalGrant, validateFremenRevivalGrant, projectFremenRevivalGrant, projectFremenGroupRevivalGrant, FremenRevivalGrantError, type FremenRevivalGrantSource } from './fremen-revival-grant'; +import { richeseRevealInterruptSupported } from './richese-reveal-interruption'; import { splitOrdinaryPayments, type OrdinaryPayment } from './ordinary-payment-plan'; import { charityAmount, @@ -4905,7 +4906,8 @@ function noFieldRevealBlock(g: Game, p: Player): string | null { if (g.status !== 'playing' || g.phase >= 6) return 'Voluntary No-Field reveal is available before the Battle phase.'; if (!p.noField?.deployed) return 'No concealed No-Field is deployed.'; - if (g.truthtrance || g.phaseOpening || g.response || g.decision) + if ((g.truthtrance || g.phaseOpening || g.response || g.decision) && + !richeseRevealInterruptSupported(g)) return 'Resolve the pending interaction before revealing the No-Field.'; return null; } @@ -5813,6 +5815,7 @@ export function createGame( spiceDiscard: [[], []], spice: {}, log: [{ seq: 1, text: `${host.name} opened the table.` }], + truthHistory: [], stormDials: {}, stormPending: null, stormCard: null, @@ -43285,6 +43288,16 @@ function applyActionInner( ); requireRule(g.status !== 'finished', 'This game has ended.'); const t = action.type; + if (t === 'revealNoField' && richeseRevealInterruptSupported(g)) { + requireRule(p.faction === 'richese', 'Only Richese controls the concealed token.'); + const blocked = noFieldRevealBlock(g, p); + requireRule(!blocked, blocked ?? 'Voluntary reveal is unavailable.'); + requireRule(Object.keys(action).sort().join(',') === 'event,token,type' && + action.event === p.noFieldEvent && action.token === p.noField?.deployed?.tokenId, + 'This No-Field reveal selection is stale.'); + revealPlayerNoField(g, p, 'voluntary'); + return g; + } if (g.pendingEmperorAuctionGift && !g.response && !g.pendingKarama) { const pending = g.pendingEmperorAuctionGift; if (t === 'advanceBots') return g; diff --git a/game/reference.ts b/game/reference.ts index 82c36541..ad74fd91 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1127,6 +1127,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Richese has three No-Fields, numbered zero, three and five. A concealed token counts as one force. Only one may be deployed on Arrakis, and consecutive own or allied shipments cannot use the same token.', 'In Basic play, your own single concealed No-Field may provisionally ship into an ally’s territory if it can legally enter; move it and any paid accompanying forces out before your turn ends. Paid companions require the High Richese Homeworld power. If the marker remains, reveal it and send its newly materialized forces to Tanks under the provisional no-departure policy. A zero marker reveals but contributes no casualties. Existing physical forces and the ally are not part of that new-visitor loss. Paired markers keep their separate guards.', '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.', '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.', @@ -1142,7 +1143,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Basic and Advanced ordinary mixed battles have a provisional private pool: physical forces already here plus the token value capped by current reserves. The Battle Wheel labels that interpretation explicitly. Both plans seal before materialization, then native support, casualties and aftermath use actual forces. This first version does not adjudicate the FAQ mixed dial cap.', 'In supported card profiles, provisional materialization-first order forecasts the own marker privately without moving forces early. Both plans seal, the original token reveals and materializes its reserve-limited ordinary counters at its sector, then Harass selects/returns actual undialed counters. Reinforcements must leave three original reserves after that reveal and before any future Harass return; an unfunded card cannot seal a plan. Marker0, last-used identity and original reveal-event history remain native.', 'Concealed collection, occupancy and control use one-force presence. Exposed storm sectors and unprotected worm destruction reveal and remove the resulting actual forces. Storm shelter and other sectors remain separate. Trip to Gamont at a location with a marker reveals it, even if ordinary forces also occupy that sector, then returns one actual force if present. It is still used if zero forces remain. Canceling the CHOAM power leaves the marker concealed.', - 'All four AI profiles choose from their own private token inventory, pay only the one-force shipment price, move the marker explicitly and reveal positive tokens when useful. They retain zero-token presence and use only public information to assess opponents. They also offer safe positive tokens from their own inventory during the allied opportunity, fund them with their own spice and accept or decline private offers using their own force and payment information. A human Richese player’s offer window holds an AI ally’s shipment. Pending responses, decisions and Truthtrance retain priority.', + 'All four AI profiles choose from their own private token inventory, pay only the one-force shipment price, move the marker explicitly and reveal positive tokens when useful. They retain zero-token presence and use only public information to assess opponents. They also offer safe positive tokens from their own inventory during the allied opportunity, fund them with their own spice and accept or decline private offers using their own force and payment information. A human Richese player’s offer window holds an AI ally’s shipment. Unimplemented interruptions, including pending responses and Truthtrance, retain priority.', 'Marker-only and ordinary-plus-marker No-Fields compose with the selected Advanced Occupy profile under the same provisional additive pool. Either selected lead gets its actual variable-owner quote; original fixed Ecaz strength stays separate. Prevention restores only the chosen lead’s own pool. Basic mixed co-side, Homeworld custody, wider Truthtrance and other profile/module boundaries remain. Complete Richese starts and final acceptance stay gated.', ], related: [ @@ -1159,7 +1160,7 @@ export const RULE_TOPICS: RuleTopic[] = [ status: 'Partial', detail: 'Native lifecycle, additive pools, card funding, distinct special inspection and current printed-value Truthtrance remain. Own concealed Planetologist extra-range or two-origin gathering uses current trainer/token/event and exact per-origin route/entry. Provisional Guild cross and actual Secret Ally cross/face-down return keep marker custody/history distinct from physical counters, with one rounded price and native response continuations. Broader special transports remain separate.', - evidence: ['game/shipment-price.ts', 'game/engine.ts', 'game/planetologist-movement.ts', 'game/bot-mobility.ts', 'game/truthtrance.ts', 'game/truthtrance-no-field-value.ts', 'game/guild-no-field-selection.ts', 'game/transport-quote.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-guild-secret-ally.ts'], + evidence: ['game/shipment-price.ts', 'game/engine.ts', 'game/richese-reveal-interruption.ts', 'game/planetologist-movement.ts', 'game/bot-mobility.ts', 'game/truthtrance.ts', 'game/truthtrance-no-field-value.ts', 'game/guild-no-field-selection.ts', 'game/transport-quote.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-guild-secret-ally.ts'], }, { area: 'Player controls', @@ -1201,6 +1202,9 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/planetologist-movement.test.ts', 'tests/planetologist-movement-controls.test.tsx', 'tests/richese-no-field.test.ts', + 'tests/richese-reveal-interruption-runtime.test.ts', + 'tests/richese-reveal-interruption-controls.test.tsx', + 'tests/richese-reveal-interruption-privacy.test.ts', 'tests/richese-no-field-engine.test.ts', 'tests/richese-no-field-review.test.ts', 'tests/richese-no-field-bots.test.ts', diff --git a/game/richese-reveal-interruption.ts b/game/richese-reveal-interruption.ts new file mode 100644 index 00000000..284778e8 --- /dev/null +++ b/game/richese-reveal-interruption.ts @@ -0,0 +1,84 @@ +import type { Game } from './engine'; + +const factions = new Set([ + 'atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen', + 'richese', 'choam', 'ixians', 'tleilaxu', +]); + +/** A deliberately bounded admission for an existing before-Battle parent. + * This does not authorize a seat, marker or event, and does not mutate the + * game. The normal No-Field action and view own those checks. */ +export function richeseRevealInterruptSupported(g: Game): boolean { + if (g.status !== 'playing' || g.advanced || !Number.isSafeInteger(g.phase) || + g.phase < 0 || g.phase >= 6 || !Number.isSafeInteger(g.turn) || g.turn < 1 || + !Array.isArray(g.expansions) || !g.expansions.includes('choam') || + g.expansions.some(deck => deck !== 'choam' && deck !== 'ix') || + new Set(g.expansions).size !== g.expansions.length || + !Array.isArray(g.players) || !g.players.every(player => factions.has(player.faction)) || + g.players.some(player => (player.faction === 'ixians' || player.faction === 'tleilaxu') && !g.expansions.includes('ix')) || + g.coalitionState || g.coalitionPreview || g.techTokens || g.strongholdCards || g.leaderSkills || + g.homeworlds || g.nexusCards || g.discoveryEnabled || g.discoveries || g.discoveryStash || + g.greatMaker || g.sandtrout || g.ecazTreachery || g.truthtrance || g.response || + g.biddingEnd || g.battle || g.stormResolution || g.spiceResolution || g.spiceSequence || + g.victoryContinuation || g.currentAuctionSale || g.richeseAllyOffer || + g.bureaucratPayments?.pending) return false; + + // A mixed Truthtrance answer can create a private residual only for some + // hidden facts. Fence the whole public turn's history uniformly before + // examining those ledgers, so this projected availability cannot reveal + // which private branch was required. + // Historical saves without a complete public history retain their existing + // interruption behavior; fresh games initialize an explicit empty history. + if (!Array.isArray(g.truthHistory) || g.truthHistory.some(entry => entry.turn === g.turn)) return false; + + // Each pending field is an active child/continuation, not a completed + // receipt. Empty pending arrays do not represent a live child. + if (Object.entries(g).some(([key, value]) => key.startsWith('pending') && + (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) => + !!rows?.some(row => row.turn === g.turn && !row.fulfilled && !row.released); + if (live(g.charityPromises) || live(g.travelPromises) || live(g.turnActionPromises) || + live(g.shipmentPromises) || live(g.bidPromises) || live(g.phaseBidPromises) || + live(g.richeseBidPromises) || live(g.earlyRicheseBidPromises) || live(g.revivalPromises) || + live(g.leaderRevivalPromises) || live(g.revivalExpressionPromises) || live(g.bribePromises) || + live(g.pledgePromises) || live(g.bribeExpressionPromises) || live(g.alliancePromises) || + live(g.allianceExpressionPromises) || live(g.movementPromises) || live(g.movementExpressionPromises) || + g.futureBattleLeaderPromises?.some(row => row.turn === g.turn && (row.status === 'waiting' || row.status === 'bound')) || + g.deals?.some(deal => deal.status === 'active')) return false; + + // Mixed-question receipts can own binding state separately from the ordinary + // residual lists. Conservatively retain their whole current-turn boundary. + const mixed = [g.mixedBidQuestions, g.mixedRevivalQuestions, g.mixedBribeQuestions, + g.mixedAllianceQuestions, g.mixedMovementQuestions, g.mixedTraitorQuestions, g.mixedBattleQuestions]; + if (mixed.some(rows => rows?.some(row => row.turn === g.turn && row.binding))) return false; + + if (g.phaseOpening) + return g.expansions.includes('ix') && !!g.amalWindowOpen && !g.decision && !g.choamMarket; + const decision = g.decision; + if (!decision) return false; + if (decision.kind === 'auctionPayment') + return g.phase === 3 && !!g.auction && g.auction.bidder === decision.player && !g.richeseAuction && !g.choamMarket; + if (decision.kind === 'richeseDeclaration' || decision.kind === 'richeseCacheTerms' || + decision.kind === 'richeseCache' || decision.kind === 'richeseUnbid') { + const round = g.richeseBidding; + if (g.phase !== 3 || !round || round.turn !== g.turn || round.owner !== decision.player || g.choamMarket) + return false; + if (decision.kind === 'richeseDeclaration') return round.stage === 'declaration' && !g.richeseAuction; + if (decision.kind === 'richeseUnbid') return round.stage === 'lot' && g.richeseAuction?.source === 'cache' && + g.richeseAuction.outcome?.kind === 'unbid'; + return round.stage === 'cacheOffer' && !g.richeseAuction; + } + if (decision.kind === 'choamMarket' || decision.kind === 'choamTradeReply' || decision.kind === 'choamTradeConfirm') { + const market = g.choamMarket; + if (!market || market.sale) return false; + if (decision.kind === 'choamMarket') return market.owner === decision.player && !market.trade; + if (!market.trade) return false; + return decision.kind === 'choamTradeReply' + ? market.trade.ally === decision.player && !market.trade.returned + : market.owner === decision.player && !!market.trade.returned; + } + return decision.kind === 'richeseAllyOpportunity' && g.phase === 5 && + g.richeseAllyOpportunity?.turn === g.turn && + g.richeseAllyOpportunity.recipient === decision.recipient; +} diff --git a/tests/richese-reveal-interruption-controls.test.tsx b/tests/richese-reveal-interruption-controls.test.tsx new file mode 100644 index 00000000..8ada97bb --- /dev/null +++ b/tests/richese-reveal-interruption-controls.test.tsx @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import * as jsxRuntime from 'react/jsx-runtime'; +import ts from 'typescript'; +import { RicheseNoFieldControls } from '../components/richese-no-field'; +import { applyAction, viewGame, type Action, type GameView } from '../game/engine'; +import * as catalog from '../game/catalog'; +import * as board from '../game/board'; +import * as advisors from '../game/advisors'; +import * as shipmentPrice from '../game/shipment-price'; +import * as noField from '../game/richese-no-field'; +import * as homeworldCards from '../game/homeworld-cards'; +import * as smuggler from '../game/smuggler-no-field'; +import * as homeworldShipment from '../game/richese-homeworld-shipment'; +import { smugglerNoFieldAction, smugglerNoFieldGame } from './smuggler-no-field-fixture'; + +// Stage the admitted quote in a personalized view; engine admission is tested +// independently. The marker itself comes from a real Basic shipment. +function projection(viewer: 'r' | 'h'): GameView { + let game = smugglerNoFieldGame(); + game = applyAction(game, 'r', smugglerNoFieldAction(game, 3, false)); + const view = viewGame(game, viewer); + view.phase = 3; + view.decision = { kind: 'auctionPayment', player: 'h' }; + if (viewer === 'r') { + view.richeseNoField!.canReveal = true; + view.richeseNoField!.revealBlock = null; + } + return view; +} + +function props(game: GameView, busy: boolean, act: (action: Action) => void) { + return { game, busy, act, destination: 'arrakeen', sector: 10, allyPayment: '' }; +} + +type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>; +function elements(node: ReactNode): Element[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return []; + return [node, ...elements(node.props.children)]; +} + +// Invoke the actual component with deterministic hooks to exercise its callback. +function control(game: GameView, busy: boolean, sent: Action[]) { + const exports: { RicheseNoFieldControls?: typeof RicheseNoFieldControls } = {}; + runInNewContext(ts.transpileModule(readFileSync(new URL('../components/richese-no-field.tsx', import.meta.url), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }, + }).outputText, { exports, require(name: string) { + const modules: Record = { + 'react/jsx-runtime': jsxRuntime, + react: { useId: () => 'fixture', useState: (initial: unknown) => [initial, () => {}] }, + '@/game/catalog': catalog, '@/game/board': board, '@/game/advisors': advisors, + '@/game/shipment-price': shipmentPrice, '@/game/richese-no-field': noField, + '@/game/homeworld-cards': homeworldCards, '@/game/smuggler-no-field': smuggler, + '@/game/richese-homeworld-shipment': homeworldShipment, + './richese-homeworld-forces': {}, './shipment-quote': {}, './ui/button': { Button: 'button' }, + }; + if (!(name in modules)) throw Error(`Unexpected import ${name}`); + return modules[name]; + } }); + assert.ok(exports.RicheseNoFieldControls); + return elements(exports.RicheseNoFieldControls(props(game, busy, action => sent.push(action)))); +} + +void test('the owner can reveal the current marker during an admitted Basic auction payment', () => { + const game = projection('r'), sent: Action[] = []; + const html = renderToStaticMarkup(createElement(RicheseNoFieldControls, props(game, false, () => {}))); + assert.match(html, /Reveal your No-Field/); + const button = control(game, false, sent).find(element => element.type === 'button' && element.props.children === 'Reveal your No-Field'); + assert.ok(button); + assert.equal(button.props.disabled, false); + (button.props.onClick as () => void)(); + assert.deepEqual(JSON.parse(JSON.stringify(sent)), [{ type: 'revealNoField', + event: game.richeseNoField!.event, token: game.richeseNoField!.private!.deployed!.tokenId }]); +}); + +void test('the observer has no private inventory or reveal action; busy blocks the owner callback', () => { + const observer = projection('h'), sent: Action[] = []; + assert.equal(observer.richeseNoField!.private, null); + assert.equal(renderToStaticMarkup(createElement(RicheseNoFieldControls, props(observer, false, () => {}))), ''); + assert.deepEqual(control(observer, false, sent), []); + const button = control(projection('r'), true, sent).find(element => element.type === 'button' && element.props.children === 'Reveal your No-Field'); + assert.ok(button); + assert.equal(button.props.disabled, true); + (button.props.onClick as () => void)(); + assert.deepEqual(sent, []); +}); diff --git a/tests/richese-reveal-interruption-privacy.test.ts b/tests/richese-reveal-interruption-privacy.test.ts new file mode 100644 index 00000000..98107542 --- /dev/null +++ b/tests/richese-reveal-interruption-privacy.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { richeseRevealInterruptSupported } from '../game/richese-reveal-interruption'; +import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field'; +import { + bindTruthMixedBribe, + createTruthMixedBribeFixture, + truthMixedBribeFact, + truthMixedBribeGroup, + truthMixedBribePayment, +} from './fixture-truth-mixed-bribe'; + +// The public question and residual are produced by the native mixed-question +// fixture. The pending auction parent, transfer of its records to a Richese +// table, marker, and alternate no-residual ledger are explicitly staged for +// this availability comparison; they are not a replay of the original game. +function stagedPair() { + const fixture = createTruthMixedBribeFixture(); + const mixed = truthMixedBribeGroup('or', truthMixedBribeFact(fixture.hajrFact), + truthMixedBribePayment(fixture.asker)); + const answered = bindTruthMixedBribe(fixture, 'yes', mixed).game; + const record = answered.truthHistory?.at(-1); + const residuals = answered.bribeExpressionPromises; + assert.ok(record && residuals?.length); + assert.equal(record.answer, 'yes'); + assert.ok(residuals.some(row => !row.fulfilled && !row.released)); + + const base = createGame('REVEALPRIVACY', newPlayer('r', 'Richese', 'richese'), false, ['choam']); + base.players.push(...answered.players.map(player => newPlayer(player.id, player.name, player.faction))); + const richese = base.players[0]; + richese.noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']), { + tokenId: 'three', controller: 'r', location: { territory: 'polar_sink', sector: 0 }, + }); + richese.noFieldEvent = 'marker-event'; + base.status = 'playing'; + base.phase = 3; + base.turn = record.turn; + base.response = null; + base.phaseOpening = null; + base.decision = { kind: 'auctionPayment', player: fixture.target }; + base.auction = { cards: [], index: 0, bid: 3, bidder: fixture.target, active: fixture.target, passed: [], opener: 0 }; + base.truthHistory = [structuredClone(record)]; + const withResidual = structuredClone(base); + withResidual.bribeExpressionPromises = structuredClone(residuals); + const withoutResidual = structuredClone(base); + withoutResidual.bribeExpressionPromises = []; + return { withResidual, withoutResidual, record }; +} + +void test('a public current-turn mixed answer uniformly hides private residual-dependent interruption availability', () => { + const { withResidual, withoutResidual, record } = stagedPair(); + const baseline = structuredClone(withoutResidual); + baseline.truthHistory = []; + assert.equal(richeseRevealInterruptSupported(baseline), true, 'the staged economic parent is otherwise admitted'); + for (const game of [withResidual, withoutResidual]) { + const before = structuredClone(game); + assert.deepEqual(viewGame(game, 'r').truthHistory, [record]); + assert.equal(richeseRevealInterruptSupported(game), false); + const info = viewGame(game, 'r').richeseNoField; + assert.ok(info?.private?.deployed); + assert.equal(info.canReveal, false); + assert.throws(() => applyAction(game, 'r', { type: 'revealNoField', + event: info.event, token: info.private!.deployed!.tokenId })); + assert.deepEqual(game, before); + } + assert.equal(viewGame(withResidual, 'r').richeseNoField!.revealBlock, + viewGame(withoutResidual, 'r').richeseNoField!.revealBlock); +}); + +void test('the public history fence applies only to the current turn', () => { + const { withoutResidual } = stagedPair(); + const game: Game = structuredClone(withoutResidual); + game.turn++; + assert.equal(richeseRevealInterruptSupported(game), true); + assert.equal(viewGame(game, 'r').richeseNoField?.canReveal, true); +}); diff --git a/tests/richese-reveal-interruption-quote.test.ts b/tests/richese-reveal-interruption-quote.test.ts new file mode 100644 index 00000000..f2922a74 --- /dev/null +++ b/tests/richese-reveal-interruption-quote.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, newPlayer, type Decision, type Game } from '../game/engine'; +import { richeseRevealInterruptSupported as supported } from '../game/richese-reveal-interruption'; + +/** Staged parent records test only the admission quote; native parent and + * physical token transitions are exercised by the separate runtime suite. */ +function source(): Game { + const g = createGame('REVEALQUOTE', newPlayer('r', 'Richese', 'richese'), false, ['choam', 'ix']); + g.players.push(newPlayer('c', 'CHOAM', 'choam'), newPlayer('e', 'Emperor', 'emperor')); + g.status = 'playing'; g.phase = 3; g.turn = 2; + return g; +} +function parent(kind: Decision['kind']): Game { + const g = source(); + if (kind === 'auctionPayment') { + g.decision = {kind, player:'e'}; + g.auction = {cards:[], index:0, bid:3, bidder:'e', active:'e', passed:[], opener:0}; + } else if (kind === 'richeseDeclaration' || kind === 'richeseCacheTerms' || kind === 'richeseCache' || kind === 'richeseUnbid') { + g.decision = {kind, player:'r'}; + g.richeseBidding = {owner:'r', event:'round', turn:g.turn, + stage:kind === 'richeseDeclaration' ? 'declaration' : kind === 'richeseUnbid' ? 'lot' : 'cacheOffer', + position:'first', normalCount:1, blackMarketSold:false, cacheCanceled:false}; + if (kind === 'richeseUnbid') g.richeseAuction = { + event:'lot', cardId:'card', source:'cache', owner:'r', method:'onceAround', eligible:['r','e'], + order:['e','r'], tieOrder:['r','e'], active:null, bid:0, bidder:null, passed:['r','e'], acted:['r','e'], sealed:{}, outcome:{kind:'unbid'}, + }; + } else if (kind === 'choamMarket' || kind === 'choamTradeReply' || kind === 'choamTradeConfirm') { + g.decision = {kind, player:kind === 'choamTradeReply' ? 'r' : 'c'}; + g.choamMarket = {owner:'c', resume:'phase', blocked:[], + ...(kind === 'choamMarket' ? {} : {trade:{ally:'r', offered:'offered', + ...(kind === 'choamTradeConfirm' ? {returned:'returned'} : {})}})}; + } else if (kind === 'richeseAllyOpportunity') { + g.phase = 5; + g.decision = {kind, player:'r', recipient:'e'}; + g.richeseAllyOpportunity = {turn:g.turn, recipient:'e'}; + } else throw Error(`Unrecognized staged parent ${kind}`); + return g; +} + +void test('each supported economic parent is admitted without modifying its frame or inspecting marker custody', () => { + for (const kind of ['auctionPayment','richeseDeclaration','richeseCacheTerms','richeseCache','richeseUnbid', + 'choamMarket','choamTradeReply','choamTradeConfirm','richeseAllyOpportunity'] as const) { + const game = parent(kind), before = structuredClone(game); + assert.equal(supported(game), true, kind); + assert.deepEqual(game, before); + assert.equal(game.players[0].noField?.deployed ?? null, null, 'Seat/marker authorization belongs to the caller'); + } +}); + +void test('a clean Ix phase opening remains admitted before or after its owner passes', () => { + const game = source(); game.phase = 0; game.phaseOpening = {passed:[], initialize:true}; game.amalWindowOpen = true; + assert.equal(supported(game), true); + game.phaseOpening.passed.push('r'); + assert.equal(supported(game), true); + const before = structuredClone(game); assert.equal(supported(game), true); assert.deepEqual(game, before); + game.decision = {kind:'auctionPayment', player:'e'}; assert.equal(supported(game), false); + game.decision = null; game.amalWindowOpen = false; assert.equal(supported(game), false); +}); + +void test('a legacy table without public Truthtrance history keeps its earlier interruption behavior', () => { + const game = parent('auctionPayment'); + delete game.truthHistory; + assert.equal(supported(game), false); +}); + +void test('profile and nested-interaction boundaries do not admit another parent or module', () => { + const patches: Array> = [ + {advanced:true}, {status:'setup'}, {phase:6}, {phase:-1}, {turn:0}, + {expansions:['choam','ecaz']}, {expansions:['ix']}, + {response:{kind:'richeseAuction', owner:'r', passed:[]}}, + {pendingNullentropy:{} as NonNullable}, + {pendingTreacheryDiscard:{} as NonNullable}, + {pendingShipment:{} as NonNullable}, + {pendingKull:{} as NonNullable}, + {biddingEnd:{} as NonNullable}, + {stormResolution:{} as NonNullable}, + {techTokens:{} as NonNullable}, + {leaderSkills:{} as NonNullable}, + {discoveryEnabled:true}, {ecazTreachery:true}, + ]; + for (const patch of patches) { + const game = Object.assign(parent('auctionPayment'), patch), before = structuredClone(game); + assert.equal(supported(game), false, JSON.stringify(patch)); assert.deepEqual(game, before); + } + const wrong = parent('auctionPayment'); wrong.decision = {kind:'revivalStop', player:'e', recipient:'r', revival:'forces'}; + assert.equal(supported(wrong), false); + const missing = parent('auctionPayment'); missing.auction = null; assert.equal(supported(missing), false); + const wrongSeat = parent('auctionPayment'); wrongSeat.auction!.bidder = 'r'; assert.equal(supported(wrongSeat), false); + const native = parent('auctionPayment'); native.players.push(newPlayer('i','Ixians','ixians')); + native.expansions = ['choam']; assert.equal(supported(native), false); +}); + +void test('live current-turn commitments block the interruption; settled and previous-turn records do not', () => { + const game = parent('auctionPayment'); + game.shipmentPromises = [{turn:game.turn, player:'e', asker:'r', answer:true, territory:'carthag', minimum:3}]; + assert.equal(supported(game), false); + game.shipmentPromises[0].fulfilled = true; assert.equal(supported(game), true); + game.shipmentPromises[0].fulfilled = false; game.shipmentPromises[0].turn--; assert.equal(supported(game), true); + game.shipmentPromises = []; + game.futureBattleLeaderPromises = [{turn:game.turn, asker:'r', player:'e', leader:'leader', answer:true, status:'waiting'}]; + assert.equal(supported(game), false); + game.futureBattleLeaderPromises[0].status = 'settled'; assert.equal(supported(game), true); + game.deals = [{status:'active'}] as unknown as Game['deals']; assert.equal(supported(game), false); +}); diff --git a/tests/richese-reveal-interruption-runtime.test.ts b/tests/richese-reveal-interruption-runtime.test.ts new file mode 100644 index 00000000..52ab8528 --- /dev/null +++ b/tests/richese-reveal-interruption-runtime.test.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; + +const owner = 'richese', observer = 'emperor', choam = 'choam'; +const seat = (game: Game, id: string) => { + const player = game.players.find(row => row.id === id); assert.ok(player); return player; +}; +const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && + !game.truthtrance && !game.pendingTreacheryDiscard; +function next(game: Game) { + // The inherited native helper intentionally leaves Emperor's own move to + // callers. This programme completes the original turn without a shipment. + if (game.phase === 5 && game.active === observer && clean(game)) + return {actor:observer, action:{type:'endMovement'} satisfies Action}; + return nextTruthMovementNativeEarlyStep(game, { + position:'first', method:'onceAround', direction:'clockwise', keepUnbid:false, + }); +} +function reach(game: Game, until: (state: Game) => boolean) { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'Native game ended before the requested window'); + const step = next(game); assert.ok(step, `Supply native action at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = act(game, step.actor, step.action); + } + throw Error('Native No-Field programme did not reach the requested window'); +} + +/** Genuine public Basic Richese/CHOAM/Emperor, both selected native decks. + * Select only original unopened starting-card/Spice positions. Real setup, + * auction passes, one paid concealed shipment and native phase controls lead + * to the next turn. Polar Sink keeps the marker safe from Storm and battles. */ +function nativeSource() { + let game = createGame('RICHESEINTERRUPT', newPlayer(owner, 'Richese', 'richese'), false, ['choam','ix']); + joinGame(game, newPlayer(choam, 'CHOAM', 'choam')); + joinGame(game, newPlayer(observer, 'Emperor', 'emperor')); + for (const p of game.players) game = act(game, p.id, {type:'ready'}); + game = act(game, game.host, {type:'start'}); + assert.equal(game.status, 'setup'); assert.deepEqual(game.expansions, ['choam','ix']); + assert.ok(game.players.every(p => p.hand.length === 0)); + const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 3); + assert.equal(ordinary.length, 3); + for (const [position, card] of ordinary.entries()) { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + } + game = reach(game, state => state.status === 'playing'); + for (const position of [0,1]) { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); + assert.ok(from >= position); + [game.spiceDeck[position], game.spiceDeck[from]] = [game.spiceDeck[from], game.spiceDeck[position]]; + } + game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === owner && clean(state)); + const richese = seat(game, owner), token = richese.noField!.tokens.find(item => item.value === 3); assert.ok(token); + const shipmentEvent = richese.noFieldEvent; assert.ok(shipmentEvent); + const beforeSpice = richese.spice; + game = act(game, owner, {type:'ship', noField:token.id, event:shipmentEvent, + territory:'polar_sink', sector:0, allyPayment:0}); + game = reach(game, clean); + assert.equal(seat(game, owner).spice, beforeSpice - 2); + assert.equal(seat(game, owner).noField?.deployed?.tokenId, token.id); + assert.equal(seat(game, owner).reserves, 20); + assert.equal(seat(game, owner).forces['polar_sink:0'] ?? 0, 0); + game = act(game, owner, {type:'endMovement'}); + game = reach(game, state => state.turn === 2 && state.phase === 0 && !!state.phaseOpening && + !state.response && !state.decision && !state.truthtrance); + assert.equal(seat(game, owner).noField?.deployed?.tokenId, token.id); + const event = seat(game, owner).noFieldEvent; assert.ok(event); + return {game, token:token.id, event}; +} +let saved: ReturnType | undefined; +const source = () => saved ??= nativeSource(); +const reveal = (game: Game, token: string, event: string) => + applyAction(game, owner, {type:'revealNoField', token, event}); +function reject(game: Game, id: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, id, action)); + assert.deepEqual(game, before); +} +function verifyRevealed(before: Game, after: Game, token: string, event: string) { + assert.equal(seat(after, owner).noField?.deployed, null); + assert.notEqual(seat(after, owner).noFieldEvent, event); + assert.equal(seat(after, owner).noField?.lastShipped, token); + assert.equal(seat(after, owner).reserves, seat(before, owner).reserves - 3); + assert.equal(seat(after, owner).forces['polar_sink:0'], (seat(before, owner).forces['polar_sink:0'] ?? 0) + 3); + assert.equal(seat(after, owner).tanks, seat(before, owner).tanks); + assert.equal(seat(after, owner).spice, seat(before, owner).spice); + reject(after, owner, {type:'revealNoField', token, event}); +} + +void test('Basic native Ix opening keeps prior passes and resumes after a voluntary marker reveal', () => { + const {token,event} = source(); + let game = structuredClone(source().game); + game = act(game, owner, {type:'ready'}); + assert.ok(game.phaseOpening!.passed.includes(owner)); + const parent = structuredClone(game.phaseOpening); + assert.equal(viewGame(game, owner).richeseNoField?.canReveal, true); + reject(game, observer, {type:'revealNoField', token, event}); + reject(game, owner, {type:'revealNoField', token, event:'stale'}); + reject(game, owner, {type:'revealNoField', token:'wrong-token', event}); + const before = structuredClone(game), after = reveal(game, token, event); + verifyRevealed(before, after, token, event); + assert.deepEqual(after.phaseOpening, parent); + assert.equal(after.phase, before.phase); assert.equal(after.turn, before.turn); + const continued = reach(after, state => !state.phaseOpening); + assert.equal(seat(continued, owner).forces['polar_sink:0'], 3); +}); + +for (const kind of ['choamMarket','richeseDeclaration','richeseCache','richeseUnbid'] as const) void test( + `Basic native ${kind} remains intact while Richese reveals, then its original decision continues`, () => { + const {token,event} = source(); + const before = reach(structuredClone(source().game), state => + state.turn === 2 && state.decision?.kind === kind && !state.response && !state.phaseOpening); + const parent = structuredClone(before.decision), market = structuredClone(before.choamMarket ?? null), + bidding = structuredClone(before.richeseBidding ?? null), auction = structuredClone(before.auction ?? null); + assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true); + const after = reveal(before, token, event); + verifyRevealed(before, after, token, event); + assert.deepEqual(after.decision, parent); assert.deepEqual(after.choamMarket ?? null, market); + assert.deepEqual(after.richeseBidding ?? null, bidding); assert.deepEqual(after.auction ?? null, auction); + const step = next(after); assert.ok(step); + assert.equal(step.actor, parent!.player); + const continued = act(after, step.actor, step.action); + assert.notDeepEqual(continued.decision, parent); + assert.equal(seat(continued, owner).forces['polar_sink:0'], 3); + }, +); + +/** Reorder only an unopened turn-two Spice card, then form a real CHOAM / + * Emperor alliance at its Nexus and initiate their ordinary market trade. */ +function nativeTradeReply() { + let game = structuredClone(source().game); + const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper); + assert.ok(worm >= 0); + game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]); + game = reach(game, state => state.turn === 2 && state.phase === 1 && !!state.nexus && + !state.spiceWindow && !state.spiceResolution && clean(state)); + game = act(game, choam, {type:'alliance', target:observer}); + game = act(game, observer, {type:'alliance', target:choam}); + assert.equal(seat(game, choam).ally, observer); + assert.equal(seat(game, observer).ally, choam); + game = reach(game, state => state.turn === 2 && state.phase === 1 && + state.decision?.kind === 'choamMarket' && !state.response && !state.phaseOpening); + const offered = seat(game, choam).hand[0]; assert.ok(offered); + const returned = seat(game, observer).hand[0]; assert.ok(returned); + game = act(game, choam, {type:'decision', mode:'trade', card:offered.id}); + assert.equal(game.decision?.kind, 'choamTradeReply'); + assert.equal(game.decision.player, observer); + return {game, offered:offered.id, returned:returned.id}; +} +let tradeFixture: ReturnType | undefined; +const tradeSource = () => tradeFixture ??= nativeTradeReply(); + +void test('Basic CHOAM trade reply retains its genuine alliance, offered card and exact continuation', () => { + const {token,event} = source(), {offered,returned} = tradeSource(); + const before = structuredClone(tradeSource().game); + const parent = structuredClone(before.decision), market = structuredClone(before.choamMarket); + assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true); + const after = reveal(before, token, event); + verifyRevealed(before, after, token, event); + assert.deepEqual(after.decision, parent); assert.deepEqual(after.choamMarket, market); + let continued = act(after, observer, {type:'decision', card:returned}); + assert.equal(continued.decision?.kind, 'choamTradeConfirm'); + assert.equal(continued.decision.player, choam); + continued = act(continued, choam, {type:'decision', accept:true}); + assert.equal(continued.decision?.kind, 'choamMarket'); + assert.ok(seat(continued, choam).hand.some(card => card.id === returned)); + assert.ok(seat(continued, observer).hand.some(card => card.id === offered)); + assert.equal(seat(continued, owner).forces['polar_sink:0'], 3); +}); + +void test('Basic CHOAM trade confirmation remains pending through reveal and exchanges the actual cards', () => { + const {token,event} = source(), f = tradeSource(); + const before = act(structuredClone(f.game), observer, {type:'decision', card:f.returned}); + assert.equal(before.decision?.kind, 'choamTradeConfirm'); + const parent = structuredClone(before.decision), market = structuredClone(before.choamMarket); + assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true); + const after = reveal(before, token, event); + verifyRevealed(before, after, token, event); + assert.deepEqual(after.decision, parent); assert.deepEqual(after.choamMarket, market); + const continued = act(after, choam, {type:'decision', accept:true}); + assert.equal(continued.decision?.kind, 'choamMarket'); + assert.ok(seat(continued, choam).hand.some(card => card.id === f.returned)); + assert.ok(seat(continued, observer).hand.some(card => card.id === f.offered)); + assert.equal(seat(continued, owner).forces['polar_sink:0'], 3); +}); + +void test('all four minimal bot profiles can answer the preserved native Richese economic decision', () => { + const {token,event} = source(); + const before = reach(structuredClone(source().game), state => state.turn === 2 && + state.decision?.kind === 'richeseDeclaration' && !state.response && !state.phaseOpening); + const after = reveal(before, token, event); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(after, owner); + view.players.find(player => player.id === owner)!.bot = difficulty; + const choice = botActions(view).find(action => action.type === 'decision'); + assert.ok(choice, `${difficulty} needs an original legal declaration`); + const continued = applyAction(structuredClone(after), owner, choice); + assert.notDeepEqual(continued.decision, after.decision); + assert.equal(seat(continued, owner).forces['polar_sink:0'], 3); + } +});