From 7fccd62814146b942fd42c43144bc1d6aea10d53 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:07:00 +1100 Subject: [PATCH] Support Truthtrance No-Field shipment promises --- components/shipment-expression-fields.tsx | 25 +- components/truth-no-field-shipment-fields.tsx | 53 ++++ components/truth-shipment-question.tsx | 9 +- docs/CURRENT_STATUS.md | 13 + docs/RULE_DECISIONS.md | 9 + docs/TRUTHTRANCE_SHIPMENT_PROMISES.md | 20 ++ game/bots.ts | 4 +- game/engine.ts | 67 +++-- game/mixed-shipment-question.ts | 2 +- game/reference.ts | 3 +- game/shipment-expression-input.ts | 1 + game/shipment-promises.ts | 54 +++- game/truthtrance.ts | 6 +- tests/shipment-no-field-promises.test.ts | 64 +++++ .../truth-no-field-shipment-controls.test.tsx | 119 +++++++++ ...thtrance-no-field-shipment-runtime.test.ts | 241 ++++++++++++++++++ 16 files changed, 658 insertions(+), 32 deletions(-) create mode 100644 components/truth-no-field-shipment-fields.tsx create mode 100644 tests/shipment-no-field-promises.test.ts create mode 100644 tests/truth-no-field-shipment-controls.test.tsx create mode 100644 tests/truthtrance-no-field-shipment-runtime.test.ts diff --git a/components/shipment-expression-fields.tsx b/components/shipment-expression-fields.tsx index bcfbf505..66e96a66 100644 --- a/components/shipment-expression-fields.tsx +++ b/components/shipment-expression-fields.tsx @@ -8,15 +8,18 @@ import { } from '@/game/shipment-promises'; import { shipmentExpressionInputError, shipmentExpressionInputSize } from '@/game/shipment-expression-input'; import { ShipmentClauseFields } from './shipment-claim-fields'; +import { TruthNoFieldShipmentFields } from './truth-no-field-shipment-fields'; import { Button } from './ui/button'; const newLeaf = (): ShipmentExpression => ({ territory: 'carthag', minimum: 1 }); /** Hook-free controlled tree. Stateful leaf descendants are rendered by React. */ -export function ShipmentExpressionFields({ value, id = 'truth-shipment', disabled, onChange }: { +export function ShipmentExpressionFields({ value, id = 'truth-shipment', disabled, allowNoField = false, onChange }: { value: ShipmentExpression; id?: string; disabled: boolean; + /** Expose the public marker question only for an eligible Richese target. */ + allowNoField?: boolean; onChange: (expression: ShipmentExpression) => void; }) { const size = shipmentExpressionInputSize(value); @@ -30,6 +33,7 @@ export function ShipmentExpressionFields({ value, id = 'truth-shipment', disable remove?: () => ShipmentExpression, ): ReactElement => { const group = 'op' in node; + const marker = !group && 'kind' in node && node.kind === 'noField'; const allowed = (next: ShipmentExpression) => !disabled && !shipmentExpressionInputError(replace(next)); const change = (next: ShipmentExpression) => commit(replace(next)); const wrap = (op: 'and' | 'or'): ShipmentExpression => ({ op, terms: [node, newLeaf()] }); @@ -63,7 +67,24 @@ export function ShipmentExpressionFields({ value, id = 'truth-shipment', disable : 'constant' in node ?

Frozen current facts are unavailable in a pure shipment question. Remove this condition. -

: } +

: <> + {(allowNoField || marker) && <> + + + } + {marker ? <> + {!allowNoField &&

Choose Richese for a No-Field shipment condition, or change this condition to physical forces.

} + + : 'minimum' in node ? : null} + }
diff --git a/components/truth-no-field-shipment-fields.tsx b/components/truth-no-field-shipment-fields.tsx new file mode 100644 index 00000000..7b0ee6b5 --- /dev/null +++ b/components/truth-no-field-shipment-fields.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { TERRITORIES } from '@/game/board'; + +/** This public question never includes a concealed token identity or value. */ +export type NoFieldShipmentInput = { + kind: 'noField'; + territory?: string; + sector?: number; +}; + +/** Author one No-Field leaf inside the ordinary shipment expression. */ +export function TruthNoFieldShipmentFields({ id, value, disabled, onChange }: { + id: string; + value: NoFieldShipmentInput; + disabled: boolean; + onChange: (value: NoFieldShipmentInput) => void; +}) { + const destination = TERRITORIES.find(territory => territory.id === value.territory); + return <> + + + {destination && <> + + + } +

Ask whether the player ships an owned No-Field from reserves this turn. + The question does not identify its token or value. A physical-force minimum is a separate condition.

+ ; +} diff --git a/components/truth-shipment-question.tsx b/components/truth-shipment-question.tsx index 8d1a7326..aa3617f8 100644 --- a/components/truth-shipment-question.tsx +++ b/components/truth-shipment-question.tsx @@ -5,6 +5,7 @@ import { parseShipmentExpression, ShipmentClaimError, shipmentExpressionOf, + shipmentClaimHasNoField, type ShipmentExpression, } from '@/game/shipment-promises'; import { shipmentExpressionInputError } from '@/game/shipment-expression-input'; @@ -23,7 +24,8 @@ export function TruthShipmentQuestion({ game, question, busy, act, onChange }: { const ownsQuestion = () => !!game.me && game.players.some(player => player.id === game.me) && game.truthtrance?.stage === 'ask' && game.truthtrance.queue[0]?.player === game.me; const hasTarget = () => question.target !== game.me && game.players.some(player => player.id === question.target); - const supported = () => hasTarget() && game.truthShipmentTargets.includes(question.target); + const supported = () => hasTarget() && game.truthShipmentTargets.includes(question.target) && + (!shipmentClaimHasNoField(question) || game.players.some(player => player.id === question.target && player.faction === 'richese')); const inputError = () => { try { parseShipmentExpression(shipmentExpressionOf(question), { allowConstants: false }); @@ -37,8 +39,9 @@ export function TruthShipmentQuestion({ game, question, busy, act, onChange }: { const error = inputError(); return <> {hasTarget() ? { - if (!busy && ownsQuestion() && supported() && !shipmentExpressionInputError(next)) onChange(next); + allowNoField={game.players.some(player => player.id === question.target && player.faction === 'richese')} + disabled={busy || !ownsQuestion() || !game.truthShipmentTargets.includes(question.target)} onChange={next => { + if (!busy && ownsQuestion() && hasTarget() && game.truthShipmentTargets.includes(question.target) && !shipmentExpressionInputError(next)) onChange(next); }} /> :

Choose another seated player for this question.

} {!supported() &&

Choose an eligible respondent with an unused shipment this turn and finish pending decisions.

}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 1272f816..a820b947 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,19 @@ open mode gates. ## Current checkpoint and work +**Truthtrance and native No-Field shipment — 29 October. Prototyped / Partial.** +The shipment question builder can ask whether Richese will ship its own +No-Field this turn, optionally to a specific territory or sector, and combine +that with physical shipment conditions. Token identity and denomination remain +private; existing physical minima retain their meaning. Owner-only guidance +can reveal an existing marker before a different eligible marker ships. Real +actions exercise Yes/No, zero marker, prevention/retry, invalid target and all +four minimal bot profiles; some physical cards and an existing marker are +staged with conserved custody. Twelve related files pass 97 cases; types/lint +and production build pass. Allied grants, Homeworlds, physical companions, +other combinations and deployed acceptance remain open. +[Scope](TRUTHTRANCE_SHIPMENT_PROMISES.md#owned-no-field-shipment-question--29-october-2026). + **Basic Ecaz/Moritani Richese interactions — 29 October. Prototyped / Partial.** Previously supported clean No-Field interruptions now work with selected Ecaz/Moritani factions and their native deck. Semuta also receives Sabotage, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 1882f467..e0c165eb 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,14 @@ # Rule decision index +## Truthtrance and a native No-Field shipment — 29 October 2026 + +The structured shipment question can explicitly refer to Richese's owned +No-Field, without disclosing its identity or value. It shares the existing +current-turn feasibility and fulfillment policy with physical shipment claims; +physical minima do not count the marker. Other marker shipment sources and +arbitrary commitments remain outside this first version. +[Scope](TRUTHTRANCE_SHIPMENT_PROMISES.md#owned-no-field-shipment-question--29-october-2026). + ## Basic Ecaz/Moritani Richese interactions — 29 October 2026 Supported clean No-Field reveal interruptions extend to the native Ecaz and diff --git a/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md b/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md index 36870c20..5a8891db 100644 --- a/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md +++ b/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md @@ -2,6 +2,26 @@ Original checkpoint, 7 September 2026; Advanced follow-up, 13 September 2026. The [14 September compound follow-up](COMPOUND_SHIPMENT_PROMISES.md) adds shipment-only AND/OR while retaining the boundaries below. This implements a bounded reserve-shipment future-action predicate. It does not certify complete Truthtrance, Advanced play or expansion interactions, and opens no new mode gate. +## Owned No-Field shipment question — 29 October 2026 + +**Prototyped / Partial.** A shipment expression addressed to Richese may now +ask whether its own concealed No-Field will be shipped from reserves this turn, +optionally to a selected printed territory or sector. The public question and +receipt contain no token identity or denomination. A Yes requires a qualifying +shipment while it remains possible; a No forbids that proposition and can +still allow a different destination. The original physical-force conditions +continue to count actual physical counters, not the concealed marker. AND/OR +conditions refer to one shipment and share the existing feasibility search. + +The owned completion may reveal an already-deployed marker before shipping a +different legal one. A successful declaration does not fulfill the answer until +the real shipment commits; opposing prevention can release an impossible Yes. +This composes the printed No-Field shipment in the CHOAM/Richese rules, p. 6, +with the existing provisional Truthtrance fulfillment and release policy. It +does not cover allied marker grants, Homeworld routes, marker shipments with +physical companions, or arbitrary prose. Those future profiles need their own +completion search before being enabled. + The former Advanced Guild exclusion is superseded by the [18 October native Guild composition](#advanced-guild-interception-and-rates--18-october-2026). The [24 October waiting-turn follow-up](#same-phase-waiting-respondents--24-october-2026) supersedes the active-only timing boundary. The [earlier Basic phase follow-up](#earlier-basic-phases--24-october-2026) extends current-turn timing before Movement; the [26 October Advanced extension](#earlier-classic-advanced-phases--26-october-2026) adds bare classic Advanced while retaining earlier module fences. The [native-faction follow-up](#native-factions-and-owned-choam-opening--26-october-2026) adds native early scope and owned CHOAM income. Earlier scope descriptions and verification totals remain historical. diff --git a/game/bots.ts b/game/bots.ts index 23cdb2b3..12ccd351 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -4642,7 +4642,9 @@ export function botActions(g: GameView): Action[] { sector: splitLocation(String(action.destination)).sector, amount: Object.values(action.sources as Record).reduce((n, source) => n + source.normal + source.elite, 0)} : fromReserves - ? { territory: String(action.territory), sector: Number(action.sector), amount: Number(action.amount) } + ? { territory: String(action.territory), sector: Number(action.sector), + amount: action.noField ? Number(action.noFieldReserves ?? 0) + Number(action.smuggler === true) : Number(action.amount), + ...(action.noField ? {noField: true as const} : {}) } : null; return promises.every( (promise) => matchesShipment(promise, shipment) === promise.answer, diff --git a/game/engine.ts b/game/engine.ts index 3b3f55f4..da28d767 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -506,11 +506,13 @@ import { parseShipmentClaim, validShipmentClaim, shipmentClaimDestinations, + shipmentClaimHasNoField, liveShipmentPromises, shipmentPromiseModeSupported, shipmentPromiseTurnAvailable, type ShipmentClaim, type ShipmentPromise, + type ShipmentReceipt, } from './shipment-promises'; import { portableSnooperPlanBlock } from './portable-snooper'; import { @@ -13095,7 +13097,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation approvedSupplier = {donor:shipment.alliedNoField.owner,amount:quote.ownerPayment}; } if (!approvedSupplier) checkShipmentIncomeFunding(g, p, shipment.cost, shipment.allyPayment); - checkShipmentPromises(g, getPlayer(g, shipment.player), shipment); + checkShipmentPromises(g, getPlayer(g, shipment.player), shipmentPromiseReceipt(g, shipment)); shipper = shipment.player; price = shipment.cost; allyPayment = approvedSupplier?.amount ?? shipment.allyPayment; originalReceiver = guildShipmentIncome({guild: guild.id, shipper, ally: p.ally, @@ -33213,6 +33215,7 @@ function shipmentPromiseIntegrity(g: Game) { (p) => p.id === promise.asker && p.id !== promise.player, ) && validShipmentClaim(promise, {allowConstants: promise.mixed !== undefined}) && + (!shipmentClaimHasNoField(promise) || g.players.some(p => p.id === promise.player && p.faction === 'richese')) && typeof promise.answer === 'boolean' && (promise.released === undefined || typeof promise.released === 'boolean') && @@ -33436,6 +33439,8 @@ function findShipmentCompletion( p.reserves, p.tanks, p.elites, + p.noField, + p.noFieldEvent, g.homeworlds, p.hand.map((c) => c.id), g.aid, @@ -33443,6 +33448,22 @@ function findShipmentCompletion( ]); if (visited.has(signature)) continue; visited.add(signature); + if (p.faction === 'richese' && p.noField && !p.noField.deployed && p.noFieldEvent && !noFieldShipBlock(g, p)) { + for (const destination of destinations) for (const sector of territory(destination).sectors) { + const receipt: ShipmentReceipt = { territory: destination, sector, amount: 0, noField: true }; + if (!promises.every(promise => matchesShipment(promise, receipt) === promise.answer)) continue; + for (const token of p.noField.tokens.filter(token => token.id !== p.noField!.lastShipped)) { + const action: Action = { type: 'ship', noField: token.id, event: p.noFieldEvent, + territory: destination, sector }; + try { + applyActionInner(g, p.id, action, 'movementPreparation'); + return { early, actions: [...actions, action] }; + } catch (error) { + if (!(error instanceof RuleError)) throw error; + } + } + } + } for (const destination of destinations) for (let amount = 1; amount <= p.reserves; amount++) { for (const sector of territory(destination).sectors) { if (!promises.every(p => matchesShipment(p, { territory: destination, amount, sector }) === p.answer)) continue; @@ -33530,6 +33551,8 @@ function findShipmentCompletion( // conversion effects. More reserves/lower cost cannot remove // a previously available choice of physical shipment count. const preparations: Action[] = []; + if (p.faction === 'richese' && p.noField?.deployed && p.noFieldEvent) + preparations.push({ type: 'revealNoField', token: p.noField.deployed.tokenId, event: p.noFieldEvent }); if ((g.aid[p.id]?.amount ?? 0) > outgoingFloor) preparations.push({ type: 'pledgeAid', amount: outgoingFloor }); const karama = p.hand.find((c) => canUseAsKaramaRole(g, p, c)); @@ -33637,7 +33660,7 @@ function bindShipmentTruth( function checkShipmentPromises( g: Game, p: Player, - shipment: { territory: string; amount: number; sector?: number } | null, + shipment: ShipmentReceipt | null, ) { requireRule( liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).every( @@ -33646,24 +33669,36 @@ function checkShipmentPromises( 'Honor your Truthtrance shipment answer before using this shipment opportunity.', ); if (g.phase === 5 && g.active === p.id) guardTurnActionShipmentPriceBoundary(g, p.id, - liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn), undefined, shipment); + liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn), undefined, + shipment?.noField && shipment.amount === 0 ? null : shipment); +} +/** Preserve physical counts while explicitly recording an owned marker. */ +function shipmentPromiseReceipt(g: Game, shipment: PendingShipment): ShipmentReceipt | null { + if (shipment.alliedNoField) return null; + if (!shipment.noField) return { territory: shipment.territory, sector: shipment.sector, amount: shipment.amount }; + return { territory: shipment.territory, sector: shipment.sector, noField: true, + amount: shipment.richesePair + ? currentRichesePairForces(g, getPlayer(g, shipment.player), shipment.noFieldReserves ?? 0, shipment.richesePair.revealedTokenId).physical + : (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0) }; } function finishShipmentPromises( g: Game, p: Player, - shipment: { territory: string; amount: number; sector?: number } | null, + shipment: ShipmentReceipt | null, ) { checkShipmentPromises(g, p, shipment); + // Other promise families retain their original physical-force meaning. + const physical = shipment?.noField && shipment.amount === 0 ? null : shipment; const probe = (g as TurnActionCardProbe)._turnActionShipmentReceipt; if (probe?.player === p.id && probe.event === undefined) - probe.event = shipment ? { territory: shipment.territory, amount: shipment.amount, - ...(shipment.sector === undefined ? {} : { sector: shipment.sector }) } : null; + probe.event = physical ? { territory: physical.territory, amount: physical.amount, + ...(physical.sector === undefined ? {} : { sector: physical.sector }) } : null; if (g.phase === 5 && g.turnActionPromises) g.turnActionPromises = turnActionRule(() => - recordTurnActionShipment(g.turnActionPromises!, p.id, g.turn, shipment ? { territory: shipment.territory, amount: shipment.amount, - ...(shipment.sector === undefined ? {} : { sector: shipment.sector }) } : null)); + recordTurnActionShipment(g.turnActionPromises!, p.id, g.turn, physical ? { territory: physical.territory, amount: physical.amount, + ...(physical.sector === undefined ? {} : { sector: physical.sector }) } : null)); if (g.phase === 5 && g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn, - shipment ? { territory: shipment.territory, amount: shipment.amount, - ...(shipment.sector === undefined ? {} : { sector: shipment.sector }) } : null)); + physical ? { territory: physical.territory, amount: physical.amount, + ...(physical.sector === undefined ? {} : { sector: physical.sector }) } : null)); for (const promise of liveShipmentPromises( g.shipmentPromises ?? [], p.id, @@ -33711,7 +33746,7 @@ function reconcileShipmentPromises( voluntary.action.type === 'richeseBid' && typeof voluntary.action.amount === 'number' && voluntary.action.amount > 0 || voluntary.action.type === 'ready' && [2, 4].includes(voluntary.phase ?? g.phase)); const ownVoluntary = voluntary?.actor === p.id && - (['card', 'bribe', 'charity', 'alliance', 'pledgeAid', 'emperorGift', 'richeseGift'].includes(voluntary.action.type) || + (['card', 'bribe', 'charity', 'alliance', 'pledgeAid', 'emperorGift', 'richeseGift', 'revealNoField'].includes(voluntary.action.type) || earlyVoluntary || future && voluntary.action.type === 'decision'); // A foreign pending parent may spend or refund its approved funding. Wait @@ -40508,11 +40543,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi finishShipmentPromises( g, getPlayer(g, shipment.player), - shipment.noField ? (shipment.richesePair - ? { territory: shipment.territory, sector: shipment.sector, amount: currentRichesePairForces(g, getPlayer(g, shipment.player), shipment.noFieldReserves ?? 0, shipment.richesePair.revealedTokenId).physical } - : shipment.noFieldReserves || shipment.smugglerCompanion - ? { territory: shipment.territory, sector: shipment.sector, amount: (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0) } - : null) : shipment.alliedNoField ? null : shipment, + shipmentPromiseReceipt(g, shipment), ); const { territory: to, @@ -47352,8 +47383,8 @@ function applyActionInner( }), ...(noField ? { noField } : {}), }; - if (noField) checkShipmentPromises(g, p, noFieldGroup!.physical - ? { territory: to, amount: noFieldGroup!.physical, sector: s } : null); + if (noField) checkShipmentPromises(g, p, + { territory: to, amount: noFieldGroup!.physical, sector: s, noField: true }); if (noField && (g.leaderSkills || noFieldReserves) && !richesePair) { shipment.noFieldSkillProof = noFieldSkillProof(shipment); validatePhysicalShipment(g, shipment); diff --git a/game/mixed-shipment-question.ts b/game/mixed-shipment-question.ts index 14e74560..13521d42 100644 --- a/game/mixed-shipment-question.ts +++ b/game/mixed-shipment-question.ts @@ -67,7 +67,7 @@ export function parseMixedShipmentExpression( territory: v.territory, minimum: v.minimum, }); - check('territory' in leaf, 'Choose a reserve-shipment statement.'); + check('minimum' in leaf, 'Choose a reserve-shipment statement.'); hasShipment = true; return { kind: 'shipment', ...leaf }; } diff --git a/game/reference.ts b/game/reference.ts index 0dc27dc4..727b7ae8 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3847,9 +3847,10 @@ export const RULE_TOPICS: RuleTopic[] = [ coverage: 'Partial', developmentStage: 'Prototyped', summary: - 'Bind this turn’s reserve shipment to a destination and physical minimum, including earlier Basic phases and players whose shipment turn is still ahead.', + 'Bind this turn’s reserve shipment to physical-force or owned No-Field conditions, including earlier Basic phases and players whose shipment turn is still ahead.', steps: [ 'Declare Truthtrance, select an eligible player and choose Bind a shipment from reserves. Bare Basic and Advanced rules with selected native faction decks allow the question during Storm through Revival as well as before that player’s shipment. During Shipment and Movement, base Basic and Advanced, including Guild, support an unused active or waiting turn. Finish pending decisions first. Choose a printed destination and minimum of one to twenty forces, or combine conditions using AND or OR; each describes the same shipment. Answering never grants an early shipment. Optional configurations keep their separate boundaries.', + 'When the respondent is Richese, a shipment condition may instead ask whether it will ship its own No-Field from reserves, optionally to a named territory or sector. The question does not reveal the token’s identity or value. A physical-force condition still counts actual counters, not the concealed marker. If an existing marker is deployed, Richese may need to reveal it before shipping a different eligible token.', 'The pure shipment builder supports up to sixteen shipment conditions, nested AND/OR groups and four grouping levels. Add, wrap, change group meaning or remove a condition; a singleton group collapses without losing its remaining statement. These app input limits do not add printed rules. Every condition describes the same eventual reserve shipment; a current fact is unnecessary.', 'Yes requires the complete statement to match while possible: all AND conditions or at least one OR condition. No requires the whole statement to be false: at least one AND condition false, or every OR condition false. A smaller count, different destination, transport of forces already on the board or no shipment may satisfy No. Forces already in the destination and ground movement do not satisfy the question. Fremen reinforcements and Guild transport from southern reserves do count.', 'The target privately sees feasible answers based on the current board, owned resources and all earlier promises. Before Movement, include remaining owned Charity and normal Revival, the current winning invoice and unused outgoing Bidding refunds; incoming Bidding aid expires. Auction payment and shipping discounts need distinct physical Karamas. At shipment time, available Ghola, Karama and current pledged aid retain their normal rules. Unknown purchases, unresolved Storm outcomes, rival gifts and future income are not assumed.', diff --git a/game/shipment-expression-input.ts b/game/shipment-expression-input.ts index 99dcffdd..73497cdd 100644 --- a/game/shipment-expression-input.ts +++ b/game/shipment-expression-input.ts @@ -24,6 +24,7 @@ export function shipmentExpressionInputError(value: ShipmentExpression): string ...('terms' in node && Array.isArray(node.terms) ? { terms: node.terms.map(structure) } : {}), }; if ('constant' in node) return node; + if ('kind' in node && node.kind === 'noField') return node; // Keep the real territory for an exact sector so the shared parser checks // their relationship; territory-only draft editing retains its old rules. return { ...node, territory: Object.hasOwn(node, 'sector') && 'territory' in node diff --git a/game/shipment-promises.ts b/game/shipment-promises.ts index e9950ac6..5500dac9 100644 --- a/game/shipment-promises.ts +++ b/game/shipment-promises.ts @@ -5,8 +5,11 @@ import { nativePromiseScopeSupported } from './native-promise-scope'; /** Every shipment leaf describes the same eventual reserve shipment, or its absence. */ export type ShipmentLeaf = { territory: string; minimum: number; sector?: number }; +/** A public promise about use of a concealed marker, never its identity or value. */ +export type NoFieldShipmentLeaf = { kind: 'noField'; territory?: string; sector?: number }; export type ShipmentExpression = | ShipmentLeaf + | NoFieldShipmentLeaf | { constant: TruthAnswer } | { op: 'and' | 'or'; terms: ShipmentExpression[] }; /** Old flat questions and saved promises remain valid without migration. */ @@ -84,6 +87,25 @@ export function parseShipmentExpression( ); return { constant: value.constant }; } + if (value.kind === 'noField') { + const keys = Object.keys(value).sort().join(','); + check( + (keys === 'kind' || keys === 'kind,territory' || keys === 'kind,sector,territory') && + ++leaves <= SHIPMENT_EXPRESSION_MAX_LEAVES, + 'Choose a No-Field shipment and optionally a printed destination and sector.', + ); + if (keys === 'kind') return { kind: 'noField' }; + check(typeof value.territory === 'string' && + TERRITORIES.some(t => t.id === value.territory), + 'Choose a printed destination territory.'); + if (keys === 'kind,sector,territory') { + check(typeof value.sector === 'number' && Number.isSafeInteger(value.sector) && + TERRITORIES.find(t => t.id === value.territory)!.sectors.includes(value.sector), + 'Choose a printed sector of the selected destination territory.'); + return { kind: 'noField', territory: value.territory, sector: value.sector }; + } + return { kind: 'noField', territory: value.territory }; + } check( (Object.keys(value).sort().join(',') === 'minimum,territory' || Object.keys(value).sort().join(',') === 'minimum,sector,territory') && @@ -134,13 +156,22 @@ export function shipmentExpressionOf(claim: ShipmentClaim): ShipmentExpression { ...(claim.sector === undefined ? {} : { sector: claim.sector }) } ); } +/** Public syntax check; it never reads a player's marker inventory. */ +export function shipmentClaimHasNoField(claim: ShipmentClaim): boolean { + const contains = (expression: ShipmentExpression): boolean => + 'op' in expression ? expression.terms.some(contains) : + 'kind' in expression && expression.kind === 'noField'; + return contains(shipmentExpressionOf(claim)); +} export function shipmentClaimDestinations(claim: ShipmentClaim): string[] { const collect = (expression: ShipmentExpression): string[] => 'op' in expression ? expression.terms.flatMap(collect) : 'constant' in expression ? [] - : [expression.territory]; + : 'kind' in expression && expression.territory === undefined + ? TERRITORIES.map(t => t.id) + : [expression.territory!]; return [...new Set(collect(shipmentExpressionOf(claim)))]; } export function shipmentClaimText( @@ -152,9 +183,11 @@ export function shipmentClaimText( !('constant' in expression), 'Format a compiled mixed claim using its original question, not its private answers.', ); - return 'op' in expression - ? `(${expression.terms.map(text).join(expression.op === 'and' ? ' AND ' : ' OR ')})` - : `ship at least ${expression.minimum} physical forces from your reserves to ${territory(expression.territory).name}${expression.sector === undefined ? '' : `, sector ${expression.sector}`}`; + if ('op' in expression) + return `(${expression.terms.map(text).join(expression.op === 'and' ? ' AND ' : ' OR ')})`; + if ('kind' in expression) + return `ship your No-Field from your reserves${expression.territory === undefined ? '' : ` to ${territory(expression.territory).name}${expression.sector === undefined ? '' : `, sector ${expression.sector}`}`}`; + return `ship at least ${expression.minimum} physical forces from your reserves to ${territory(expression.territory).name}${expression.sector === undefined ? '' : `, sector ${expression.sector}`}`; }; return text(shipmentExpressionOf(claim)); } @@ -221,9 +254,16 @@ export type ShipmentPromise = ShipmentClaim & { released?: boolean; fulfilled?: boolean; }; +/** A completed reserve shipment. Amount counts real physical counters only. */ +export type ShipmentReceipt = { + territory: string; + amount: number; + sector?: number; + noField?: true; +}; export function matchesShipment( claim: ShipmentClaim, - shipment: { territory: string; amount: number; sector?: number } | null, + shipment: ShipmentReceipt | null, ): boolean | null { const matches = (expression: ShipmentExpression): boolean | null => { if ('constant' in expression) @@ -240,6 +280,10 @@ export function matchesShipment( } return unknown ? null : expression.op === 'and'; } + if ('kind' in expression) + return !!shipment && shipment.noField === true && + (expression.territory === undefined || shipment.territory === expression.territory) && + (expression.sector === undefined || shipment.sector === expression.sector); return !!shipment && shipment.territory === expression.territory && (expression.sector === undefined || shipment.sector === expression.sector) && diff --git a/game/truthtrance.ts b/game/truthtrance.ts index a50c9238..0bf7de79 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -32,6 +32,7 @@ import { shipmentPromiseModeSupported, shipmentPromiseTurnAvailable, parseShipmentClaim, + shipmentClaimHasNoField, shipmentClaimText, ShipmentClaimError, } from './shipment-promises'; @@ -606,7 +607,10 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { return {kind:'mixedShipment',target:v.target,mixed:parseMixedShipmentExpression(v.mixed, fact => parseFact(g,fact,respondent))}; } - return { kind: 'shipment', target: v.target, ...parseShipmentClaim(v) }; + const claim = parseShipmentClaim(v); + check(!shipmentClaimHasNoField(claim) || g.players.find(p => p.id === v.target)?.faction === 'richese', + 'A No-Field shipment question needs the Richese player.'); + return { kind: 'shipment', target: v.target, ...claim }; } catch (error) { if (error instanceof ShipmentClaimError) throw new TruthError(error.message); diff --git a/tests/shipment-no-field-promises.test.ts b/tests/shipment-no-field-promises.test.ts new file mode 100644 index 00000000..81a66419 --- /dev/null +++ b/tests/shipment-no-field-promises.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + matchesShipment, + parseShipmentClaim, + parseShipmentExpression, + shipmentClaimDestinations, + shipmentClaimHasNoField, + shipmentClaimText, + ShipmentClaimError, +} from '../game/shipment-promises'; +import { TERRITORIES } from '../game/board'; +import { shipmentExpressionInputError, shipmentExpressionInputSize } from '../game/shipment-expression-input'; + +void test('a No-Field shipment statement keeps identity and denomination private', () => { + const wildcard = parseShipmentClaim({ claim: { kind: 'noField' } }); + assert.deepEqual(wildcard, { claim: { kind: 'noField' } }); + assert.equal(shipmentClaimHasNoField(wildcard), true); + assert.deepEqual(shipmentClaimDestinations(wildcard), TERRITORIES.map(t => t.id)); + assert.equal(shipmentClaimText(wildcard), 'ship your No-Field from your reserves'); + assert.equal(matchesShipment(wildcard, null), false); + assert.equal(matchesShipment(wildcard, { territory: 'carthag', sector: 11, amount: 1 }), false); + assert.equal(matchesShipment(wildcard, { territory: 'carthag', sector: 11, amount: 0, noField: true }), true); + assert.equal(shipmentExpressionInputError({ kind: 'noField' }), null); + assert.deepEqual(shipmentExpressionInputSize({ kind: 'noField' }), { leaves: 1, depth: 0 }); + const target = parseShipmentClaim({ claim: { kind: 'noField', territory: 'carthag', sector: 11 } }); + assert.deepEqual(shipmentClaimDestinations(target), ['carthag']); + assert.match(shipmentClaimText(target), /Carthag, sector 11/); + assert.equal(matchesShipment(target, { territory: 'carthag', sector: 11, amount: 0, noField: true }), true); + assert.equal(matchesShipment(target, { territory: 'carthag', sector: 10, amount: 0, noField: true }), false); + assert.equal(matchesShipment(target, { territory: 'arrakeen', sector: 10, amount: 0, noField: true }), false); +}); + +void test('No-Field and physical clauses constrain the same real shipment', () => { + const claim = parseShipmentClaim({ claim: { op: 'and', terms: [ + { kind: 'noField', territory: 'carthag' }, + { territory: 'carthag', minimum: 2 }, + ] } }); + assert.equal(matchesShipment(claim, { territory: 'carthag', sector: 11, amount: 2, noField: true }), true); + assert.equal(matchesShipment(claim, { territory: 'carthag', sector: 11, amount: 1, noField: true }), false); + assert.equal(matchesShipment(claim, { territory: 'carthag', sector: 11, amount: 2 }), false); + assert.equal(matchesShipment(claim, null), false); + const old = parseShipmentClaim({ territory: 'carthag', minimum: 1 }); + assert.equal(shipmentClaimHasNoField(old), false); + assert.equal(shipmentClaimHasNoField(claim), true); + assert.equal(matchesShipment(old, { territory: 'carthag', amount: 0, noField: true }), false); + assert.equal(matchesShipment(old, { territory: 'carthag', amount: 1 }), true); + const either = parseShipmentClaim({ claim: { op: 'or', terms: [ + { kind: 'noField' }, { territory: 'arrakeen', minimum: 3 }, + ] } }); + assert.equal(matchesShipment(either, { territory: 'carthag', amount: 0, noField: true }), true); + assert.equal(matchesShipment(either, { territory: 'arrakeen', amount: 3 }), true); + assert.equal(matchesShipment(either, null), false); +}); + +void test('No-Field statements reject secret identifiers, invalid destinations and standalone sectors', () => { + for (const bad of [ + { kind: 'noField', token: 'secret' }, { kind: 'noField', value: 3 }, + { kind: 'noField', sector: 11 }, { kind: 'noField', territory: 'not-a-territory' }, + { kind: 'noField', territory: 'carthag', sector: 10 }, + { kind: 'noField', territory: 'carthag', minimum: 1 }, + ]) assert.throws(() => parseShipmentExpression(bad), ShipmentClaimError); + assert.match(shipmentExpressionInputError({ kind: 'noField', sector: 11 })!, /No-Field shipment/); +}); diff --git a/tests/truth-no-field-shipment-controls.test.tsx b/tests/truth-no-field-shipment-controls.test.tsx new file mode 100644 index 00000000..af919ed3 --- /dev/null +++ b/tests/truth-no-field-shipment-controls.test.tsx @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ShipmentExpressionFields } from '../components/shipment-expression-fields'; +import { TruthShipmentQuestion } from '../components/truth-shipment-question'; +import { TruthNoFieldShipmentFields, type NoFieldShipmentInput } from '../components/truth-no-field-shipment-fields'; +import { createGame, joinGame, newPlayer, viewGame, type Action } from '../game/engine'; +import type { ShipmentExpression } from '../game/shipment-promises'; + +type ElementProps = { children?: ReactNode; [key: string]: unknown }; +type Element = ReactElement; +function elements(node: ReactNode): Element[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement(node)) return []; + return [node, ...elements(node.props.children)]; +} +function byId(tree: ReactNode, id: string): Element { + const element = elements(tree).find(item => item.props.id === id); + assert.ok(element, `Expected ${id}`); + return element; +} +function choose(element: Element, value: string) { + (element.props.onChange as (event: {currentTarget: {value: string}}) => void)({currentTarget: {value}}); +} + +void test('No-Field condition authors any destination or one printed territory and sector', () => { + let value: NoFieldShipmentInput = {kind: 'noField'}; + const tree = () => TruthNoFieldShipmentFields({id: 'marker', value, disabled: false, onChange(next) { value = next; }}); + const html = renderToStaticMarkup(createElement(TruthNoFieldShipmentFields, {id: 'marker', value, disabled: false, onChange() {}})); + assert.match(html, /Any destination/); + assert.doesNotMatch(html, /token ID|token value/i); + choose(byId(tree(), 'marker-no-field-territory'), 'arrakeen'); + assert.deepEqual(value, {kind:'noField', territory:'arrakeen'}); + choose(byId(tree(), 'marker-no-field-sector'), '10'); + assert.deepEqual(value, {kind:'noField', territory:'arrakeen', sector:10}); + choose(byId(tree(), 'marker-no-field-territory'), 'carthag'); + assert.deepEqual(value, {kind:'noField', territory:'carthag'}, 'changing destination removes the old sector'); + choose(byId(tree(), 'marker-no-field-sector'), '10'); + assert.deepEqual(value, {kind:'noField', territory:'carthag'}, 'invalid sector is ignored'); + choose(byId(tree(), 'marker-no-field-territory'), ''); + assert.deepEqual(value, {kind:'noField'}); +}); + +void test('disabled marker fields ignore direct callback invocation', () => { + let changes = 0; + const value: NoFieldShipmentInput = {kind:'noField', territory:'arrakeen', sector:10}; + const tree = TruthNoFieldShipmentFields({id:'locked', value, disabled:true, onChange() { changes++; }}); + assert.equal(byId(tree, 'locked-no-field-territory').props.disabled, true); + assert.equal(byId(tree, 'locked-no-field-sector').props.disabled, true); + choose(byId(tree, 'locked-no-field-territory'), 'carthag'); + choose(byId(tree, 'locked-no-field-sector'), ''); + assert.equal(changes, 0); +}); + +void test('mixed shipment expression can change one condition to No-Field without replacing the physical condition', () => { + let value: ShipmentExpression = {op:'or', terms:[{territory:'carthag',minimum:2}, {territory:'arrakeen',minimum:3}]}; + const tree = () => ShipmentExpressionFields({value, id:'question', disabled:false, allowNoField:true, onChange(next) { value = next; }}); + choose(byId(tree(), 'question-1.2-condition-type'), 'noField'); + assert.deepEqual(value, {op:'or', terms:[{territory:'carthag',minimum:2}, {kind:'noField'}]}); + const marker = elements(tree()).find(element => element.type === TruthNoFieldShipmentFields); + assert.ok(marker); + (marker.props.onChange as (claim: NoFieldShipmentInput) => void)({kind:'noField',territory:'arrakeen',sector:10}); + assert.deepEqual(value, {op:'or', terms:[{territory:'carthag',minimum:2}, {kind:'noField',territory:'arrakeen',sector:10}]}); + choose(byId(tree(), 'question-1.2-condition-type'), 'physical'); + assert.deepEqual(value, {op:'or', terms:[{territory:'carthag',minimum:2}, {territory:'carthag',minimum:1}]}); +}); + +void test('a non-Richese target cannot add a No-Field condition and can remove a previously selected one', () => { + let value: ShipmentExpression = {kind:'noField'}; + const tree = () => ShipmentExpressionFields({value, id:'other', disabled:false, allowNoField:false, onChange(next) { value = next; }}); + const html = renderToStaticMarkup(createElement(ShipmentExpressionFields, {value, id:'other', disabled:false, allowNoField:false, onChange() {}})); + assert.match(html, /Choose Richese for a No-Field shipment condition/); + choose(byId(tree(), 'other-1-condition-type'), 'physical'); + assert.deepEqual(value, {territory:'carthag',minimum:1}); + assert.equal(elements(tree()).some(element => element.props.id === 'other-1-condition-type'), false); +}); + +void test('shipment question asks an exact No-Field claim for Richese and blocks it for another faction', () => { + const source = createGame('MARKERQUESTION', newPlayer('asker','Asker','atreides'), false, ['choam']); + joinGame(source, newPlayer('richese','Richese','richese')); + joinGame(source, newPlayer('emperor','Emperor','emperor')); + const game = viewGame(source, 'asker'); + game.truthtrance = {stage:'ask', queue:[{player:'asker',card:'truth'}], passed:[], question:null}; + game.truthShipmentTargets = ['richese','emperor']; + let target = 'richese'; + let claim: ShipmentExpression = {territory:'carthag',minimum:1}; + const actions: Action[] = []; + const question = () => ({kind:'shipment' as const, target, claim}); + const wrapper = () => TruthShipmentQuestion({game, question:question(), busy:false, + act(action) { actions.push(action); }, onChange(next) { claim = next; }}); + const fieldTree = () => { + const field = elements(wrapper()).find(element => element.type === ShipmentExpressionFields); + assert.ok(field); + return ShipmentExpressionFields(field.props as Parameters[0]); + }; + const button = () => { + const found = elements(wrapper()).find(element => element.props['data-testid'] === 'truth-shipment-ask'); + assert.ok(found); + return found; + }; + choose(byId(fieldTree(), 'truth-shipment-1-condition-type'), 'noField'); + assert.deepEqual(claim, {kind:'noField'}); + const marker = elements(fieldTree()).find(element => element.type === TruthNoFieldShipmentFields); + assert.ok(marker); + (marker.props.onChange as (value: NoFieldShipmentInput) => void)({kind:'noField', territory:'arrakeen', sector:10}); + assert.equal(button().props.disabled, false); + (button().props.onClick as () => void)(); + assert.deepEqual(actions, [{type:'truthAsk', question:{kind:'shipment', target:'richese', + claim:{kind:'noField', territory:'arrakeen', sector:10}}}]); + + target = 'emperor'; + assert.equal(button().props.disabled, true); + (button().props.onClick as () => void)(); + assert.equal(actions.length, 1); + choose(byId(fieldTree(), 'truth-shipment-1-condition-type'), 'physical'); + assert.deepEqual(claim, {territory:'carthag',minimum:1}); + assert.equal(button().props.disabled, false); +}); diff --git a/tests/truthtrance-no-field-shipment-runtime.test.ts b/tests/truthtrance-no-field-shipment-runtime.test.ts new file mode 100644 index 00000000..8b47af69 --- /dev/null +++ b/tests/truthtrance-no-field-shipment-runtime.test.ts @@ -0,0 +1,241 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { deployRicheseNoField } from '../game/richese-no-field'; +import type { TruthQuestion } from '../game/truthtrance'; +import { + createTruthtranceRichesePromisesFixture as fixture, + actTruthtranceRichesePromises as act, + askTruthtranceRichesePromises as ask, + advanceTruthtranceRichesePromises as advance, + truthtranceRichesePromisesClean as clean, + truthtranceRichesePromisesPlayer as player, + truthtranceRichesePromisesInventory as inventory, +} from './fixture-truthtrance-richese-promises'; + +const markerQuestion = { + kind: 'shipment', target: 'richese', + claim: {kind: 'noField', territory: 'carthag', sector: 11}, +} as unknown as TruthQuestion; +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); +const seat = (game: Game, id: string) => { + const found = game.players.find(row => row.id === id); assert.ok(found); return found; +}; +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} +function start(answer: 'yes' | 'no') { + const f = fixture(); + const question = ask(structuredClone(f.game), f, markerQuestion); + assert.ok(viewGame(question, f.asker).truthShipmentTargets.includes(f.target)); + assert.deepEqual(viewGame(question, f.target).truthShipmentAnswers, ['yes', 'no']); + let game = act(question, f.target, {type:'truthAnswer', answer}); + game = advance(game, clean); + assert.equal(game.shipmentPromises?.length, 1); + assert.equal(game.shipmentPromises![0].answer, answer === 'yes'); + assert.equal(game.shipmentPromises![0].fulfilled, undefined); + assert.equal(game.shipmentPromises![0].released, undefined); + assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1); + game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state)); + return {f, game}; +} +function markerAction(game: Game, value = 0, territory = 'carthag', sector = 11): Action { + const richese = player(game); + const token = richese.noField!.tokens.find(item => item.value === value); assert.ok(token); + assert.ok(richese.noFieldEvent); + return {type:'ship', noField:token.id, event:richese.noFieldEvent, territory, sector, allyPayment:0}; +} +function finishResponse(game: Game): Game { + return advance(game, clean); +} + +void test('Basic early Yes requires the requested owned No-Field, including its zero token', () => { + const {f, game: before} = start('yes'); + const choice = markerAction(before), target = player(before), marker = String(choice.noField); + const ownView = viewGame(before, f.target), otherView = viewGame(before, f.asker); + assert.ok(ownView.shipmentCompletion?.actions.some(action => action.type === 'ship' && action.noField)); + assert.equal(otherView.shipmentCompletion, null); + assert.equal(otherView.richeseNoField?.private, null); + assert.equal(JSON.stringify(otherView).includes(marker), false); + reject(before, f.target, {type:'endMovement'}); + reject(before, f.target, {type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0}); + reject(before, f.target, markerAction(before, 0, 'arrakeen', 10)); + const declared = act(reload(before), f.target, choice); + const done = finishResponse(declared); + assert.equal(player(done).noField?.deployed?.tokenId, marker); + assert.deepEqual(player(done).noField?.deployed?.location, {territory:'carthag', sector:11}); + assert.equal(player(done).reserves, target.reserves); + assert.equal(player(done).spice, target.spice - 1); + assert.equal(player(done).shipped, true); + assert.equal(done.shipmentPromises![0].fulfilled, true); + assert.equal(done.shipmentPromises![0].released, undefined); + assert.deepEqual(inventory(done), f.originalInventory); + assert.equal(viewGame(done, f.asker).richeseNoField?.private, null); + reject(done, f.target, choice); +}); + +void test('Basic No forbids the selected marker shipment but permits a physical shipment', () => { + const {f, game: before} = start('no'); + reject(before, f.target, markerAction(before)); + const done = finishResponse(act(before, f.target, { + type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0, + })); + assert.equal(player(done).forces['carthag:11'], 1); + assert.equal(player(done).noField?.deployed, null); + assert.equal(done.shipmentPromises![0].fulfilled, true); + assert.deepEqual(inventory(done), f.originalInventory); +}); + +void test('Basic No permits a marker in a different destination', () => { + const {f, game: before} = start('no'); + const action = markerAction(before, 3, 'arrakeen', 10); + const done = finishResponse(act(before, f.target, action)); + assert.deepEqual(player(done).noField?.deployed?.location, {territory:'arrakeen', sector:10}); + assert.equal(done.shipmentPromises![0].fulfilled, true); + assert.deepEqual(inventory(done), f.originalInventory); +}); + +void test('an opposing Karama prevents the marker and releases an impossible Yes without spending shipment', () => { + const {f, game: original} = start('yes'); + const game = structuredClone(original), opponent = seat(game, f.observer); + const from = game.deck.findIndex(card => card.effect === 'karama'); + assert.ok(from >= 0 && opponent.hand.length); + // Move an existing physical Karama into the observer's hand via a conserved + // card swap; the declaration and the resulting counter are actual actions. + [opponent.hand[0], game.deck[from]] = [game.deck[from], opponent.hand[0]]; + const karama = opponent.hand[0].id, before = structuredClone(game); + const declared = act(game, f.target, markerAction(game)); + assert.equal(declared.response?.kind, 'richeseNoField'); + assert.equal(declared.shipmentPromises![0].fulfilled, undefined); + let stopped = act(declared, f.observer, {type:'card', mode:'cancel', card:karama}); + stopped = finishResponse(stopped); + assert.equal(player(stopped).noField?.deployed, null); + assert.equal(player(stopped).noFieldBlockedTurn, stopped.turn); + assert.equal(player(stopped).shipped, false); + assert.equal(player(stopped).spice, player(before).spice); + assert.equal(stopped.shipmentPromises![0].released, true); + assert.equal(stopped.shipmentPromises![0].fulfilled, undefined); + reject(stopped, f.target, markerAction(stopped)); + const normal = finishResponse(act(stopped, f.target, { + type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0, + })); + assert.equal(player(normal).forces['carthag:11'], 1); + assert.deepEqual(inventory(normal), f.originalInventory); +}); + +void test('all existing bot profiles use the same private feasible answers and legal marker completion', () => { + const f = fixture(), asked = ask(structuredClone(f.game), f, markerQuestion); + for (const difficulty of DIFFICULTIES) { + const answering = viewGame(asked, f.target); + answering.players.find(row => row.id === f.target)!.bot = difficulty; + const answer = botActions(answering).find(action => action.type === 'truthAnswer'); + assert.ok(answer && answering.truthShipmentAnswers?.includes(answer.answer as 'yes' | 'no' | 'unknown')); + assert.doesNotThrow(() => act(reload(asked), f.target, answer)); + const {game} = start('yes'); + const turn = viewGame(game, f.target); + turn.players.find(row => row.id === f.target)!.bot = difficulty; + const action = botActions(turn)[0]; assert.ok(action, difficulty); + assert.doesNotThrow(() => act(reload(game), f.target, action)); + } +}); + +void test('a marker-only declaration does not satisfy an existing physical-force promise', () => { + const f = fixture(); + let game = ask(structuredClone(f.game), f); + game = act(game, f.target, {type:'truthAnswer', answer:'yes'}); + game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state)); + reject(game, f.target, markerAction(game)); + const done = finishResponse(act(game, f.target, f.shipmentAction)); + assert.equal(player(done).forces['carthag:11'], 5); + assert.equal(done.shipmentPromises![0].fulfilled, true); +}); + +void test('Basic cannot promise a single shipment is both a marker and physical-only forces', () => { + const f = fixture(); + const question = { + kind:'shipment', target:f.target, claim:{op:'and', terms:[ + {kind:'noField', territory:'carthag'}, {territory:'carthag', minimum:1}, + ]}, + } as unknown as TruthQuestion; + const asked = ask(structuredClone(f.game), f, question); + assert.deepEqual(viewGame(asked, f.target).truthShipmentAnswers, ['no']); + reject(asked, f.target, {type:'truthAnswer', answer:'yes'}); + const done = act(asked, f.target, {type:'truthAnswer', answer:'no'}); + assert.equal(done.shipmentPromises![0].answer, false); +}); + +void test('the marker question cannot target a faction that does not own a No-Field', () => { + const f = fixture(); + let game = act(structuredClone(f.game), f.asker, {type:'card', card:f.truthtrance}); + game = advance(game, state => state.truthtrance?.stage === 'ask'); + const question = {kind:'shipment', target:f.observer, claim:{kind:'noField', territory:'carthag'}}; + reject(game, f.asker, {type:'truthAsk', question}); +}); + +void test('a deployed marker can be revealed before the promised different No-Field is shipped', () => { + const f = fixture(), staged = structuredClone(f.game), richese = player(staged); + const previous = richese.noField!.tokens.find(item => item.value === 3); assert.ok(previous); + // Model an earlier turn's original marker with the same physical token and + // history; staging does not create forces or change reserves. + richese.noField = deployRicheseNoField(richese.noField!, { + tokenId: previous.id, controller: richese.id, + location: {territory:'polar_sink', sector:0}, + }); + let game = ask(staged, f, markerQuestion); + assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes')); + game = act(game, f.target, {type:'truthAnswer', answer:'yes'}); + game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state)); + const first = viewGame(game, f.target).shipmentCompletion?.actions[0]; + assert.equal(first?.type, 'revealNoField'); + assert.equal(first?.token, previous.id); + game = act(game, f.target, first!); + assert.equal(player(game).noField?.deployed, null); + assert.equal(player(game).noField?.lastShipped, previous.id); + assert.equal(player(game).forces['polar_sink:0'], 3); + const next = viewGame(game, f.target).shipmentCompletion?.actions[0]; + assert.equal(next?.type, 'ship'); + assert.ok(next?.noField && next.noField !== previous.id); + game = finishResponse(act(game, f.target, next!)); + assert.equal(game.shipmentPromises![0].fulfilled, true); + assert.equal(player(game).noField?.deployed?.tokenId, next!.noField); + assert.deepEqual(player(game).noField?.deployed?.location, {territory:'carthag', sector:11}); +}); + +function givePhysicalKarama(game: Game, id: string): string { + const target = seat(game, id), held = target.hand.find(card => card.effect === 'karama'); + if (held) return held.id; + assert.ok(target.hand.length); + const inDeck = game.deck.findIndex(card => card.effect === 'karama'); + if (inDeck >= 0) { + [target.hand[0], game.deck[inDeck]] = [game.deck[inDeck], target.hand[0]]; + return target.hand[0].id; + } + for (const other of game.players) { + if (other.id === id) continue; + const index = other.hand.findIndex(card => card.effect === 'karama'); + if (index >= 0) { + [target.hand[0], other.hand[index]] = [other.hand[index], target.hand[0]]; + return target.hand[0].id; + } + } + throw Error('No original physical Karama was available for the fixture'); +} + +void test('Richese cannot cancel its own promised No-Field while another responder holds Karama', () => { + const game = start('yes').game, karama = givePhysicalKarama(game, 'richese'); + const opposing = givePhysicalKarama(game, 'emperor'); + assert.notEqual(karama, opposing); + assert.ok(player(game).hand.some(card => card.id === karama)); + const declared = act(game, 'richese', markerAction(game)); + assert.equal(declared.response?.kind, 'richeseNoField'); + reject(declared, 'richese', {type:'card', mode:'cancel', card:karama}); + assert.equal(declared.pendingKull ?? null, null); + assert.equal(declared.pendingTreacheryDiscard ?? null, null); + assert.ok(player(declared).hand.some(card => card.id === karama)); + assert.equal(declared.shipmentPromises![0].released, undefined); + assert.equal(declared.shipmentPromises![0].fulfilled, undefined); +});