diff --git a/components/alliance-expression-fields.tsx b/components/alliance-expression-fields.tsx new file mode 100644 index 00000000..3ea16325 --- /dev/null +++ b/components/alliance-expression-fields.tsx @@ -0,0 +1,116 @@ +'use client'; + +import type { ReactElement } from 'react'; +import { + parseAllianceExpression, ALLIANCE_EXPRESSION_MAX_DEPTH, ALLIANCE_EXPRESSION_MAX_LEAVES, + type AllianceExpression, +} from '@/game/alliance-expression-promises'; +import { Button } from './ui/button'; + +type Partner = { id: string; name: string }; +const newLeaf = (): AllianceExpression => ({ kind: 'partner', partner: '' }); +export function allianceExpressionInputError(value: unknown, partners: readonly Partner[]): string | null { + try { + const expression = parseAllianceExpression(value); + const visit = (node: AllianceExpression): void => { + if (node.kind === 'partner') { + if (!partners.some(partner => partner.id === node.partner)) throw new Error('Choose a current named other alliance partner.'); + } else node.terms.forEach(visit); + }; + visit(expression); + return null; + } catch (error) { + return error instanceof Error ? error.message : 'Choose a valid final-alliance expression.'; + } +} + +// Detached placeholders validate only structure, leaving incomplete public +// partner choices visible and repairable in the original controlled tree. +const structuralDraft = (node: AllianceExpression): AllianceExpression => node.kind === 'partner' + ? { ...node, partner: 'partner' } + : { ...node, terms: node.terms.map(structuralDraft) }; +const inputSize = (node: AllianceExpression): { leaves: number; depth: number } => { + if (node.kind === 'partner') return { leaves: 1, depth: 0 }; + const sizes = node.terms.map(inputSize); + return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) }; +}; + +/** Public partner choices never predict another player's consent. The server + * owns feasibility and final actual membership at the original Nexus close. */ +export function AllianceExpressionFields({ value, partners, disabled, onChange }: { + value: AllianceExpression; + partners: readonly Partner[]; + disabled: boolean; + onChange: (expression: AllianceExpression) => void; +}) { + const size = inputSize(value), error = allianceExpressionInputError(value, partners); + const structuralError = (next: AllianceExpression) => { + try { parseAllianceExpression(structuralDraft(next)); return false; } catch { return true; } + }; + const commit = (next: AllianceExpression) => { + if (!disabled && !structuralError(next)) onChange(next); + }; + const render = (node: AllianceExpression, path: string, replace: (next: AllianceExpression) => AllianceExpression, + remove?: () => AllianceExpression): ReactElement => { + const group = node.kind !== 'partner', id = `truth-alliance-expression-${path}`; + const allowed = (next: AllianceExpression) => !disabled && !structuralError(replace(next)); + const change = (next: AllianceExpression) => commit(replace(next)); + const wrap = (kind: 'and' | 'or'): AllianceExpression => ({ kind, terms: [node, newLeaf()] }); + return
+ {group ? `Alliance group ${path} · ${node.kind.toUpperCase()}` : `Alliance condition ${path}`} + {group ? <> + + + {node.terms.map((term, index) => render(term, `${path}.${index + 1}`, + next => replace({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }), + () => { + const terms = node.terms.filter((_, i) => i !== index); + return replace(terms.length === 1 ? terms[0] : { ...node, terms }); + }))} +
+ + {(['and', 'or'] as const).map(kind => { + const next: AllianceExpression = { ...node, terms: [...node.terms, { kind, terms: [newLeaf(), newLeaf()] }] }; + return ; + })} +
+ : <> + + + } +
+ {(['and', 'or'] as const).map(kind => )} + {remove && } +
+
; + }; + return
+ Final alliance expression · Current Nexus +

Each condition concerns actual membership when this Nexus closes. Offers alone are not alliances. + AND requires every condition; OR requires at least one. No makes the whole expression false, not necessarily every condition. + Legal alternatives remain available; the question does not pin a fixed branch.

+

Yes and No settle only at the actual Nexus close. Forming an alliance does not fulfill the answer early. + You cannot promise the other player’s consent. If future consent is needed, unknown may apply; ask again or save the card.

+

App input limits: {ALLIANCE_EXPRESSION_MAX_LEAVES} conditions and {ALLIANCE_EXPRESSION_MAX_DEPTH} levels of grouping. + Groups do not count as conditions. These are input limits, not additional printed rules.

+

{size.leaves} / {ALLIANCE_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {ALLIANCE_EXPRESSION_MAX_DEPTH} levels of grouping

+ {error &&

{error}

} + {render(value, '1', next => next)} +
; +} diff --git a/components/alliance-expression-promises.tsx b/components/alliance-expression-promises.tsx new file mode 100644 index 00000000..e4b6de67 --- /dev/null +++ b/components/alliance-expression-promises.tsx @@ -0,0 +1,33 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { allianceExpressionText } from '@/game/alliance-expression-promises'; +import { AlliancePromiseGuideButton } from './alliance-promises'; + +export function AllianceExpressionPromises(props: { game: GameView; busy: boolean; send: (action: Action) => void }) { + const game = props.game, own = game.allianceExpressionPromises.filter(promise => promise.player === game.me); + if (!own.length) return null; + const name = (id: string) => game.players.find(player => player.id === id)?.name ?? 'the named partner'; + const live = own.some(promise => promise.turn === game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released); + const scalarOwnsGuide = game.alliancePromises.some(promise => promise.player === game.me && + promise.turn === game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released); + return
+

Your alliance expression promises

+ +

Each condition concerns final actual alliance membership when this Nexus closes. + AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately. + Legal alternatives remain available; no fixed branch is selected for you. + Yes and No settle only at the actual Nexus close. Forming an alliance does not fulfill the answer early. + Offers alone are not alliances.

+

An owned Yes route needs an existing alliance or an actual reciprocal invitation. + Neither answer promises another player’s consent or prevents their original actions. + If future consent is needed, unknown may apply; the holder can ask again or save the card. + Honor any earlier promises first; their own controls handle required payments. + {' '}Alliance expression rules.

+ {live && !scalarOwnsGuide && } +
; +} diff --git a/components/alliance-promises.tsx b/components/alliance-promises.tsx index 17126234..c65719a8 100644 --- a/components/alliance-promises.tsx +++ b/components/alliance-promises.tsx @@ -2,6 +2,7 @@ import type { Action, GameView } from '@/game/engine'; import { allianceClaimText, alliancePromiseModeSupported } from '@/game/alliance-promises'; +import { evaluateAllianceExpression } from '@/game/alliance-expression-promises'; import { quoteNexusAlliance } from '@/game/nexus-alliance'; import { Button } from './ui/button'; @@ -22,13 +23,21 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) { const current = props.game, me = current.players.find(player => player.id === current.me); const live = current.alliancePromises.filter(promise => promise.player === actor && promise.turn === turn && !promise.fulfilled && !promise.released); - let ownedAction = action.type === 'ready' && !!me && live.every(promise => (me.ally === promise.partner) === promise.answer); + const pure = current.allianceExpressionPromises.filter(promise => promise.player === actor && promise.turn === turn && + !promise.fulfilled && !promise.released); + const mixed = current.mixedAlliancePromises.filter(promise => promise.player === actor && promise.turn === turn && + !promise.fulfilled && !promise.released); + // Pure membership is visible. The server alone knows a mixed question's + // frozen facts and reduced future obligation; never reconstruct it here. + const matches = (ally: string | null) => live.every(promise => (ally === promise.partner) === promise.answer) && + pure.every(promise => evaluateAllianceExpression(promise.claim, ally) === promise.answer); + let ownedAction = action.type === 'ready' && !!me && matches(me.ally); if (action.type === 'alliance') { if (action.target === null) ownedAction = true; else if (typeof action.target === 'string' && action.target !== actor && current.players.some(player => player.id === action.target)) { try { const quote = quoteNexusAlliance({ ...publicContext(current), actor, target: action.target }); - ownedAction = !!quote.formed && live.every(promise => (quote.allies[actor] === promise.partner) === promise.answer); + ownedAction = !!quote.formed && matches(quote.allies[actor]); } catch { ownedAction = false; } } } @@ -37,7 +46,7 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) { current.me === actor && current.turn === turn && current.alliancePromiseNexus?.turn === turn && current.alliancePromiseGuide === guide && current.alliancePromiseGuide.action === action && JSON.stringify(action) === signature && JSON.stringify(publicContext(current)) === contextSignature && typeof event === 'string' && - live.some(promise => promise.event === event) && ownedAction && !!me && !me.autopilot && + [...live, ...pure, ...mixed].some(promise => promise.event === event) && ownedAction && !!me && !me.autopilot && !current.truthtrance && !current.response && !current.decision && !current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; }; @@ -55,6 +64,7 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) { export function AlliancePromises(props: AlliancePromiseProps) { const own = props.game.alliancePromises.filter(promise => promise.player === props.game.me); if (!own.length) return null; + const live = own.some(promise => promise.turn === props.game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released); const name = (id: string) => props.game.players.find(player => player.id === id)?.name ?? 'the named partner'; return

Your alliance promises

@@ -76,6 +86,6 @@ export function AlliancePromises(props: AlliancePromiseProps) { Honor any earlier promises first; their own controls handle required payments. {' '}Alliance promise rules.

- + {live && }
; } diff --git a/components/game-table.tsx b/components/game-table.tsx index 1f04467b..f2e1b46f 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -27,6 +27,8 @@ import { } from '@/game/shipment-promises'; import { ShipmentPromises } from './shipment-promises'; import { AlliancePromises } from './alliance-promises'; +import { AllianceExpressionPromises } from './alliance-expression-promises'; +import { MixedAlliancePromises } from './mixed-alliance-promises'; import { BribePromises } from './bribe-promises'; import { BribeExpressionPromises } from './bribe-expression-promises'; import { MixedBribePromises } from './mixed-bribe-promises'; @@ -2048,6 +2050,8 @@ export function GameTable({ {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } + {g.status === 'playing' && } + {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } diff --git a/components/mixed-alliance-fields.tsx b/components/mixed-alliance-fields.tsx new file mode 100644 index 00000000..3f076164 --- /dev/null +++ b/components/mixed-alliance-fields.tsx @@ -0,0 +1,58 @@ +'use client'; + +import type { GameView } from '@/game/engine'; +import type { MixedActionExpression } from '@/game/mixed-action-question'; +import { ALLIANCE_EXPRESSION_MAX_DEPTH, ALLIANCE_EXPRESSION_MAX_LEAVES } from '@/game/alliance-expression-promises'; +import { parseAllianceActionClaim, type AllianceActionClaim } from '@/game/mixed-alliance-question'; +import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields'; + +const partners = (game: GameView, target: string) => game.players.some(player => player.id === target) + ? game.players.filter(player => player.id !== target) : []; +const config: MixedActionConfig = { + prefix: 'mixed-alliance', + legend: 'Mixed current-fact / final alliance claim · Current Nexus', + actionLabel: 'final alliance condition', + actionTiming: 'Final alliance · this Nexus close', + actionHelp: <>The future condition concerns actual membership when this Nexus closes. Offers alone are not alliances. + Any remaining alliance obligation settles at the actual Nexus close; forming an alliance does not fulfill it early. + A constant whole answer can already be complete without an alliance obligation. + You cannot promise the other player’s consent. If no owned route exists, unknown may apply; ask again or save the card. + Legal alternatives remain available; the question does not pin a fixed branch. Original alliance and payment rules still apply., + fallbackError: 'Choose a current fact and a final alliance condition.', + maxLeaves: ALLIANCE_EXPRESSION_MAX_LEAVES, + maxDepth: ALLIANCE_EXPRESSION_MAX_DEPTH, + newAction: () => ({ partner: '' }), + parseAction: (value, game, target) => { + const action = parseAllianceActionClaim(value); + if (!partners(game, target).some(partner => partner.id === action.partner)) + throw new Error('Choose a current named other alliance partner.'); + return action; + }, + renderAction: ({ value, id, game, target, disabled, onChange }) => { + const choices = partners(game, target), valid = choices.some(partner => partner.id === value.partner); + return <> + + + ; + }, +}; + +export function mixedAllianceInputError(value: unknown, game: GameView, target: string): string | null { + return mixedActionInputError(value, game, target, config); +} +export function MixedAllianceFields(props: { + value: MixedActionExpression; + game: GameView; + target: string; + disabled: boolean; + onChange: (expression: MixedActionExpression) => void; +}) { + return MixedActionFields({ ...props, config }); +} diff --git a/components/mixed-alliance-promises.tsx b/components/mixed-alliance-promises.tsx new file mode 100644 index 00000000..ffd13d69 --- /dev/null +++ b/components/mixed-alliance-promises.tsx @@ -0,0 +1,41 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { truthQuestionText } from '@/game/truthtrance'; +import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; +import { AlliancePromiseGuideButton } from './alliance-promises'; + +/** Show only the own original question and aggregate result. Raw current facts + * cannot reveal which private future obligation the server compiled. */ +export function MixedAlliancePromises(props: { game: GameView; busy: boolean; send: (action: Action) => void }) { + const game = props.game, own = game.mixedAlliancePromises.filter(promise => promise.player === game.me); + if (!own.length) return null; + const name = (id: string) => game.players.find(player => player.id === id)?.name ?? 'the named partner'; + const live = own.some(promise => promise.turn === game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released); + const ownsLive = (promise: { player: string; turn: number; fulfilled?: boolean; released?: boolean }) => + promise.player === game.me && promise.turn === game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released; + const sharedGuideElsewhere = game.alliancePromises.some(ownsLive) || game.allianceExpressionPromises.some(ownsLive); + return
+

Your mixed alliance promises

+
    {own.map(promise =>
  • + Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedAlliance', target: promise.player, + turn: promise.turn, mixed: promise.mixed }, id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' + : game.allLeaders.find(leader => leader.id === id)?.name ?? id, + id => game.combatLocations.find(location => location.id === id)?.name ?? id, name)}. + {promise.released ? ' Released: this whole commitment is no longer possible.' + : promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer stays binding until this Nexus closes.'} +
  • )}
+

Current facts are evaluated at the answer. They create no promise to keep cards, spice or forces afterward. + Only the aggregate answer is shared. Individual fact answers and any reduced future branch remain private. + AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately. + Legal alternatives remain available; no fixed branch is selected for you.

+

The future condition concerns final actual alliance membership when this Nexus closes. + A remaining alliance obligation settles only at the actual Nexus close; forming an alliance does not fulfill it early. + Offers alone are not alliances. A constant whole answer can already be complete without an alliance obligation. + Neither answer promises another player’s consent or prevents their original actions. + If future consent is needed, unknown may apply; the holder can ask again or save the card. + Honor any earlier promises first; their own controls handle required payments. + {' '}Mixed alliance rules.

+ {live && !sharedGuideElsewhere && } +
; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 066a8baa..2b2ebb4d 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -19,6 +19,10 @@ import { LeaderRevivalClaimFields, leaderRevivalClaimInputError } from './leader import type { LeaderRevivalClaim } from '@/game/leader-revival-promises'; import { MovementClaimFields } from './movement-claim-fields'; import { AllianceClaimFields, allianceClaimInputError } from './alliance-claim-fields'; +import { AllianceExpressionFields, allianceExpressionInputError } from './alliance-expression-fields'; +import { MixedAllianceFields, mixedAllianceInputError } from './mixed-alliance-fields'; +import type { AllianceExpression } from '@/game/alliance-expression-promises'; +import type { AllianceActionClaim } from '@/game/mixed-alliance-question'; import type { AllianceClaim } from '@/game/alliance-promises'; import { BribeClaimFields, bribeClaimInputError } from './bribe-claim-fields'; import type { BribeClaim } from '@/game/bribe-promises'; @@ -79,7 +83,7 @@ export function Truthtrance({ g.players.find((p) => p.id !== me.id)!.id, ); const [kind, setKind] = useState< - 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' >('fact'); const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? ''); @@ -97,6 +101,15 @@ export function Truthtrance({ const [alliancePartner, setAlliancePartner] = useState(g.me); const allianceClaim: AllianceClaim = { turn: g.turn, partner: alliancePartner }; const alliancePartners = g.players.filter(player => player.id !== target); + const [allianceExpression, setAllianceExpression] = useState({ kind: 'or', terms: [ + { kind: 'partner', partner: g.me }, { kind: 'partner', partner: '' }, + ] }); + const allianceExpressionError = allianceExpressionInputError(allianceExpression, alliancePartners); + const [mixedAlliance, setMixedAlliance] = useState>({ kind: 'and', terms: [ + { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 10 } }, + { kind: 'action', action: { partner: g.me } }, + ] }); + const mixedAllianceError = mixedAllianceInputError(mixedAlliance, g, target); const [bribeSelection, setBribeSelection] = useState>({ recipient: g.me, minimum: 3 }); const bribeClaim: BribeClaim = { turn: g.turn, ...bribeSelection }; const bribeRespondent = g.players.find(player => player.id === target); @@ -179,6 +192,8 @@ export function Truthtrance({ : kind === 'mixedRevival' ? { kind, target, turn: g.turn, mixed: mixedRevival } : kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression } : kind === 'leaderRevival' ? { kind, target, ...leaderRevivalClaim } + : kind === 'allianceExpression' ? { kind, target, turn: g.turn, claim: allianceExpression } + : kind === 'mixedAlliance' ? { kind, target, turn: g.turn, mixed: mixedAlliance } : kind === 'alliance' ? { kind, target, ...allianceClaim } : kind === 'mixedBribe' ? { kind, target, turn: g.turn, mixed: mixedBribe } : kind === 'bribeExpression' ? { kind, target, turn: g.turn, claim: bribeExpression } @@ -321,6 +336,13 @@ export function Truthtrance({ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'unknown'}`, { type: 'truthAnswer', answer })} )} + ) : w.question!.kind === 'allianceExpression' || w.question!.kind === 'mixedAlliance' ? ( + <> +

Answer the whole expression about actual final membership when this Nexus closes. Offers and temporary formation do not settle a binding answer. OR preserves each consent-supported partner choice; No negates the complete expression. Other participants retain their own consent and withdrawal rights. Current facts in mixed questions freeze now; individual factual answers remain private.

+ {((w.question!.kind === 'allianceExpression' ? g.truthAllianceExpressionAnswers : g.truthMixedAllianceAnswers) ?? []).map(answer =>
+ {button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })} +
)} + ) : w.question!.kind === 'alliance' ? ( <>

This answer concerns actual membership when this Nexus closes. An offer alone does not form an alliance. Yes remains binding after formation; No requires ending without this named partner. Consent that is not yet available may be unknown, letting the holder ask again or save the card.

@@ -497,6 +519,8 @@ export function Truthtrance({ + + @@ -521,7 +545,21 @@ export function Truthtrance({ )} - {kind === 'mixedBid' ? ( + {kind === 'allianceExpression' ? ( + <> + + {!g.truthAllianceExpressionTargets.includes(target) &&

Choose a supported participant in a quiet ordinary Nexus.

} +

{allianceExpressionError ?? truthQuestionText(question, leaderName, locationName, name)}

+ + ) : kind === 'mixedAlliance' ? ( + <> + + {!g.truthMixedAllianceTargets.includes(target) &&

Choose a supported participant in a quiet ordinary Nexus.

} +

{mixedAllianceError ?? truthQuestionText(question, leaderName, locationName, name)}

+ + ) : kind === 'mixedBid' ? ( <> @@ -723,6 +761,8 @@ export function Truthtrance({ (kind === 'mixedRevival' && (!!mixedRevivalError || !g.truthMixedRevivalTargets.includes(target))) || (kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) || (kind === 'leaderRevival' && (!!leaderRevivalClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthLeaderRevivalTargets.includes(target))) || + (kind === 'allianceExpression' && (!!allianceExpressionError || !g.truthAllianceExpressionTargets.includes(target))) || + (kind === 'mixedAlliance' && (!!mixedAllianceError || !g.truthMixedAllianceTargets.includes(target))) || (kind === 'alliance' && (!!allianceClaimInputError(allianceClaim, alliancePartners) || !g.truthAllianceTargets.includes(target))) || (kind === 'mixedBribe' && (!!mixedBribeError || !g.truthMixedBribeTargets.includes(target))) || (kind === 'bribeExpression' && (!!bribeExpressionError || !validBribeExpressionRecipients || !g.truthBribeExpressionTargets.includes(target))) || diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d3c56c70..c8161dca 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,42 @@ open mode gates. ## Current checkpoint and work +**Truthtrance / whole and mixed final Nexus alliances — 26 October. Prototyped / Partial.** +Full AND/OR partner trees and mixed current facts now retain every compatible +native consent route. Finite final outcomes reuse the original reciprocal +quote and financial preparations; OR fixes no partner. Whole No and Yes stay +binding through actual Nexus close, preserving temporary formation, opposing +withdrawal/departure and accepted-order release. Scalar/whole/mixed share one +sequence; earlier scalar behavior stays. Mixed facts freeze before payment, +constants need no alliance, and all definite answers uniformly reopen only own +readiness. Full editors/raw Ask/private answers/own aggregates and one exact +native guide connect to four minimal profiles without projecting residuals. + +Root integration with disjoint rules/controls/native lanes passes **24 files, +226/226**, types/lint/build and **twelve native paths**,42 GameTable SSR renders/ +17 guide callbacks plus one editor callback followed by native Ask. Original +H/A/E base33 Basic48/Advanced49 dual-invitation prefixes add9 for real OR +acceptance/Ready/close, or10 with actual bribe3 E10→7 and frozen mixed fact. +Manual other-partner acceptance stays legal. Existing-pair48 adds9 for whole +OR No/break/close. Edited constant48 adds11 and reopens only respondent Ready; +false AND47 adds9 with later real acceptance. Unknown47 adds9/10 for actual Yes +partnership or temporary No pair/break. Dual withdrawals48 add11: one survives, +last releases, reoffer does not rebind. Earlier owed bribe48 adds12 with actual +3 payment/manual alternate/final E7. All33 original cards remain physical. + +Sixteen focused helper,28 native/funding and15 controlled cases supplement +existing scalar/Nexus/funding proof. Two original Truths retain scalar No or +Yes, owed payments and whole-first contradiction in accepted order. A bounded +read-only planner/release review finds no concrete issue. V1 catches two native +profile test fields needing explicit TruthAnswer narrowing; complete v2 passes. +Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-alliance-expression-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-alliance-expression-runtime/`. +No saved-game/database/server/reset/restart/deployment operation is performed. +Earlier-phase diplomacy, other families/correlated unknowns, further preparations, +optional/wider compositions and final rules/browser/deployed/assurance remain. +[Authority, consent/financial model and evidence](TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md). + **Truthtrance / current facts and ordinary bids — 26 October. Prototyped / Partial.** Full mixed AND/OR questions freeze facts before actual current-lot declarations and winning invoices. Remaining same-interval minima reduce exactly (AND max, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d7e2c8a8..3e75d699 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,44 @@ # Dune implementation status +## 26 October 2026 — Whole and mixed final Nexus alliance partners + +**Prototyped / Partial.** +Full AND/OR partner trees and mixed current facts now retain every compatible +native consent route. Finite final outcomes reuse the original reciprocal +quote and financial preparations; OR fixes no partner. Whole No and Yes stay +binding through actual Nexus close, preserving temporary formation, opposing +withdrawal/departure and accepted-order release. Scalar/whole/mixed share one +sequence; earlier scalar behavior stays. Mixed facts freeze before payment, +constants need no alliance, and all definite answers uniformly reopen only own +readiness. Full editors/raw Ask/private answers/own aggregates and one exact +native guide connect to four minimal profiles without projecting residuals. + +Root integration with disjoint rules/controls/native lanes passes **24 files, +226/226**, types/lint/build and **twelve native paths**,42 GameTable SSR renders/ +17 guide callbacks plus one editor callback followed by native Ask. Original +H/A/E base33 Basic48/Advanced49 dual-invitation prefixes add9 for real OR +acceptance/Ready/close, or10 with actual bribe3 E10→7 and frozen mixed fact. +Manual other-partner acceptance stays legal. Existing-pair48 adds9 for whole +OR No/break/close. Edited constant48 adds11 and reopens only respondent Ready; +false AND47 adds9 with later real acceptance. Unknown47 adds9/10 for actual Yes +partnership or temporary No pair/break. Dual withdrawals48 add11: one survives, +last releases, reoffer does not rebind. Earlier owed bribe48 adds12 with actual +3 payment/manual alternate/final E7. All33 original cards remain physical. + +Sixteen focused helper,28 native/funding and15 controlled cases supplement +existing scalar/Nexus/funding proof. Two original Truths retain scalar No or +Yes, owed payments and whole-first contradiction in accepted order. A bounded +read-only planner/release review finds no concrete issue. V1 catches two native +profile test fields needing explicit TruthAnswer narrowing; complete v2 passes. +Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-alliance-expression-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-alliance-expression-runtime/`. +No saved-game/database/server/reset/restart/deployment operation is performed. +Earlier-phase diplomacy, other families/correlated unknowns, further preparations, +optional/wider compositions and final rules/browser/deployed/assurance remain. +[Authority, consent/financial model and evidence](TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md). + + ## 26 October 2026 — Current facts with ordinary bid declarations **Prototyped / Partial.** diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 6eb77a68..2f5c9cfc 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,22 @@ # Rule decision index +## Whole and mixed final Nexus alliances — 26 October 2026 + +Compose recorded publisher FAQp8 AND/OR/current-turn authority and supplied +Advanced physical39 compliance/release/unknown with the existing final Nexus +membership interval. Supplied physical15 retains reciprocal pairing, at most +one alliance and own departure rights. Evaluate whole partner trees against +one final partner or None; No negates the complete tree. Enumerate every truth- +compatible outcome, then reuse original native consent quotes and financial +preparations without fixing an OR route. Do not invent foreign consent. +Facts in mixed questions freeze at the answer; unknown is false for definite +Yes and true for No. Only a needed residual binds the native whole ledger. +Scalar/whole/mixed answers share accepted order and actual close; decisive +mixed constants need no alliance, with uniform own readiness reopening. +This online interval/witness/release model remains provisional; no dedicated +publisher solver, correlated unknown inference, mode or publication change. +[Contract and evidence](TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md). + ## Current facts combined with ordinary bids — 26 October 2026 Reuse recorded publisher FAQp8 current-turn/AND/OR and supplied Advanced diff --git a/docs/TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md b/docs/TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md new file mode 100644 index 00000000..ad11d0ea --- /dev/null +++ b/docs/TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md @@ -0,0 +1,151 @@ +# Truthtrance whole and mixed final-alliance questions + +## Current-Nexus prototype — 26 October 2026 + +**Prototyped / Partial.** Pure questions use +`{kind:'allianceExpression',target,turn,claim}` with partner leaves +`{kind:'partner',partner}` and full AND/OR groups. Mixed questions use +`{kind:'mixedAlliance',target,turn,mixed}` with generic fact/action/AND/OR +clauses and action leaves `{partner}`. Both concern final actual membership +when this current ordinary Nexus closes, within existing bare native Basic/ +Advanced scope. Start/mode gates stay unchanged. + +## Authority and meaning + +Reuse the [scalar alliance contract](TRUTHTRANCE_ALLIANCE_PROMISES.md), recorded +publisher November2020 FAQp8 AND/OR/current-turn authority and supplied Advanced +physical39/printed37 compliance/release/unknown. Supplied physical15 ALLIANCES +retains reciprocal formation, at most one alliance and each participant's right +to leave. The online current-Nexus interval, owned witness and accepted-order +release remain provisional under standing14October priority; no dedicated +publisher solver or complete diplomacy certification is claimed. + +A pure expression evaluates one final native partner or None. OR retains any +compatible partner; AND requires every condition. Two distinct final partners +cannot both be true in the native pair model. No negates the whole tree. A +proposal alone is not membership. Neither formation nor one false leaf settles +a binding whole answer early; Yes and No settle at the actual Nexus close. +A temporary pair may be ended before readiness if whole No remains reachable. + +Mixed facts all freeze at the actual answer. A current-spice fact at10 stays +that answer-time fact after an actual native bribe3 leaves7. Individual frozen +values and compiled branches remain private. Unknown takes false for definite +Yes and true for No, then the remaining owned obligation must guarantee the +aggregate. Correlated unknowns are not inferred. True fact OR partnership can +settle Yes immediately; false fact AND partnership can settle No immediately. +Constants need no future alliance binding. Every definite answer uniformly +reopens only its respondent's readiness, including constants. + +## One native consent and financial system + +`game/alliance-expression-promises.ts` strictly validates full trees under the +existing16-leaf/depth4 software budget. Every raw partner must be another current +participant, including beneath decisive mixed facts. Public frozen constants, +private source or binding metadata are not accepted. Mixed action parsing reuses +the scalar partner decoder and shared generic fact grammar. + +`allianceExpressionOutcomes` enumerates final partner/None states matching all +live scalar and whole answers. `findAllianceExpressionPlans` reuses the original +`findAlliancePromisePlan` for each exact outcome; local synthetic requirements +are never persisted or projected. No second reciprocal-consent implementation +exists. Already matching membership/no-op stays preferred; every owned route is +retained. Another participant's future acceptance, departure or renewed offer is +not invented. Missing foreign consent can leave No or unknown/save/reask available. +Known financial impossibility is not relabelled unknown consent. + +The engine tries each native route against original financial/preparation +quotes. A blocked first route cannot pin OR or hide another legal route. +Earlier owed bribes retain native payment controls before accepting their +recipient when needed. Shipment, movement, Revival, payments and owned funds +remain constrained by original rules. Keeping an already matching membership +adds no prospective foreign cost, preserving the scalar contract. + +One `alliancePromiseSequence` spans scalar and whole records; unstamped older +scalar records retain original priority. Mixed residuals enter the same whole +ledger. Reconciliation keeps earlier possible commitments, rejects voluntary +evasion, and preserves opposing withdrawal/departure rights. Removing one OR +invitation keeps another owned route; removing the last can release Yes. A +reoffer never reactivates history. Native Ready checks both ledgers and actual +Nexus closure settles them together. No prior question is rebound to a new Nexus. + +## Controls and projection + +Pure and mixed editors support full add/wrap/nest/switch/remove/collapse, +repairable partner drafts, unique IDs, nested facts and the complete raw Ask +preview. Choices depend only on the public current roster, not private hands, +funding or future consent. Current consent affects server completion, not which +questions the author can write. + +Pure own rows omit physical Nexus source. Own mixed rows retain original tree, +aggregate answer and terminal flags while hiding binding metadata, frozen facts +and the residual; pure projection excludes mixed events. No progress count is +needed: actual membership is visible, and binding outcomes settle at close. +A shared exact event/turn/context native guide serves scalar, pure or mixed +records once. Historical rows stay passive. The client checks visible scalar/ +pure membership and canonical own quotes; it never reconstructs a mixed branch. +Existing bribe guides handle prior payments. Four minimal profiles answer and +execute legal acceptance/break/Ready; full strategy/calibration remain deferred. +New question discard signatures include native/mixed metadata without changing +older signatures. No saved game or prior answer is rewritten. + +## Evidence and remaining gates + +Pure: `tests/alliance-expression-promises.test.ts`, +`tests/mixed-alliance-question.test.ts`. Original native source/journeys: +`tests/fixture-truth-alliance-expression.ts`, +`tests/truth-alliance-expression-runtime.test.ts`, +`tests/truth-mixed-alliance-runtime.test.ts`, +`tests/truth-alliance-expression-funding.test.ts`. +Controlled: `tests/truth-alliance-expression-controls.test.tsx`, +`tests/truth-mixed-alliance-controls.test.tsx`. +Twelve pure expression and four mixed helper cases,14 pure native,10 mixed native, +four original accepted-order/funding and15 controlled cases supplement the +existing scalar/Nexus/financial coverage. The frozen **24-file,226/226** union, +types/lint, production build and12 native paths pass on the same unchanged +repository source. Actual GameTable SSR renders42 times;17 exact native guide +callbacks and one editor callback submit real packets. Four minimal profiles +actually answer, accept and Ready through the original closure. A bounded +read-only review of the new planner/accepted-order/release integration finds no +concrete issue; comprehensive review remains deferred. + +| Original source | Prefix actions | Further actions | Actual result | +| --- | ---: | ---: | --- | +| Basic whole OR | 48 | 9 | Two actual invitations, canonical acceptance/Ready, settlement only at close | +| Advanced whole OR | 49 | 9 | Same native final interval and consent rights | +| Basic manual OR alternative | 48 | 9 | Other real invitation accepted without following the suggested partner | +| Basic whole OR No | 48 | 9 | Actual existing pair breaks; No stays pending until close | +| Basic mixed frozen spice | 48 | 10 | Actual bribe3 leaves E7; original E10 fact binds through acceptance/close | +| Advanced mixed frozen spice | 49 | 10 | Same snapshot and real native payment/membership | +| Basic edited constant OR | 48 | 11 | Actual AND→OR editor/Ask; only respondent Ready reopens; later pair legal | +| Basic false AND | 47 | 9 | Immediate No, no binding, real later acceptance remains legal | +| Basic unknown OR Yes | 47 | 9 | Unknown forecast requires actual partnership and close | +| Basic unknown AND No | 47 | 10 | Temporary pair allowed, actual break/Ready required, no early settlement | +| Basic two withdrawals | 48 | 11 | First leaves OR possible; last releases; reoffer does not reactivate | +| Basic earlier owed bribe | 48 | 12 | Actual promised3 payment, manual other OR partner, whole close at E7 | + +Other native cases preserve unknown/save/reask after real consent, exact +owner/turn/context/event/JSON packets, private source/raw aggregate boundaries, +No to distinct-partner AND and actual opposing departure. Two original Truths +keep prior scalar No with the other whole-OR route, share one scalar/mixed Yes +at close, retain the earlier bribe snapshot, and reject a later scalar No +contradicting an earlier whole Yes. All33 original cards remain physical. + +V1 typecheck identifies two test action fields needing explicit TruthAnswer +narrowing. No rule expectations change; the complete v2 batch passes. Verified +source tree: `1c2a3a627ebf95e40f7b603b112416cb74a0cf89d0aa338aeddd0e05cb640009` +(3194 files). Final evidence prose is a separate three-document overlay. +Proof/report/manifest: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-alliance-expression-proof-v2/`; +journeys/HTML: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-alliance-expression-runtime/`. +SSR/editor/guide callbacks are local engine/control evidence, not DOM, +hydration, browser/mobile or deployed acceptance. + +The inherited H/A/E base33 source earns its original Worm Nexus and consent; +one optional second invitation is an actual H action. Only raw questions change; +no played purse/hand/clock/offer/pair/answer/promise is installed. Controlled +views retain their labels rather than claiming acquisition histories. + +Earlier-phase/whole-turn diplomacy, other action families, correlated unknowns, +future preparations, optional modules and broader faction/card/funding +composition remain required, followed by final rules/recovery/privacy/custody/ +browser/mobile/deployed acceptance. No complete mode is certified. No saved-game/ +database/server/reset/restart/deployment operation is performed by this batch. diff --git a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md index 302ab34e..335092d3 100644 --- a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md +++ b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md @@ -1,5 +1,10 @@ # Truthtrance nonbattle readiness and first implementation slice +The [whole/mixed final-alliance follow-up](TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md) +adds full AND/OR partner conditions and answer-time facts at the current ordinary +Nexus close. Actual native consent, final membership and financial preparation +remain; broader diplomacy and cross-action combinations remain unfinished. + The [mixed current-lot bid follow-up](TRUTHTRANCE_MIXED_BIDS.md) composes answer-time facts with future accepted ordinary declarations. Native scalar thresholds exactly represent remaining same-lot AND/OR minima; phase-wide and diff --git a/game/alliance-expression-promises.ts b/game/alliance-expression-promises.ts new file mode 100644 index 00000000..2494538e --- /dev/null +++ b/game/alliance-expression-promises.ts @@ -0,0 +1,165 @@ +import { + allianceClaimText, alliancePromisesMatch, findAlliancePromisePlan, + type AllianceOpportunity, type AlliancePromise, +} from './alliance-promises'; + +export const ALLIANCE_EXPRESSION_MAX_LEAVES = 16; +export const ALLIANCE_EXPRESSION_MAX_DEPTH = 4; + +export type AllianceExpression = + | { kind: 'partner'; partner: string } + | { kind: 'and' | 'or'; terms: AllianceExpression[] }; +export type AllianceExpressionClaim = { turn: number; claim: AllianceExpression }; +export type AllianceExpressionPromise = AllianceExpressionClaim & { + event: string; + /** Original private ordinary-Nexus interval; never public question text. */ + source: string; + player: string; + asker: string; + answer: boolean; + fulfilled?: boolean; + released?: boolean; +}; +export type AllianceExpressionContext = { + players: readonly { id: string; ally: string | null }[]; + offers: Readonly>; +}; +export type AllianceAction = { type: 'alliance'; target: string | null }; + +export class AllianceExpressionPromiseError extends Error { + constructor(message: string) { + super(message); + this.name = 'AllianceExpressionPromiseError'; + } +} + +function check(value: unknown, message: string): asserts value { + if (!value) throw new AllianceExpressionPromiseError(message); +} +function object(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} +const identifier = (value: unknown): value is string => + typeof value === 'string' && value.length > 0 && value === value.trim(); +const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean'; +const keys = (value: Record, expected: string) => Object.keys(value).sort().join(',') === expected; + +/** These input limits match the other full expression families. Parse every + * branch before evaluation, including clauses behind a decisive AND or OR. */ +export function parseAllianceExpression(value: unknown): AllianceExpression { + let leaves = 0; + const parse = (input: unknown, depth: number): AllianceExpression => { + check(object(input) && depth <= ALLIANCE_EXPRESSION_MAX_DEPTH, + 'Use an alliance expression within four nested groups.'); + if (input.kind === 'and' || input.kind === 'or') { + check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 && + input.terms.length <= ALLIANCE_EXPRESSION_MAX_LEAVES, + 'Join two or more final alliance statements with AND or OR.'); + return { kind: input.kind, terms: input.terms.map(term => parse(term, depth + 1)) }; + } + check(++leaves <= ALLIANCE_EXPRESSION_MAX_LEAVES && input.kind === 'partner' && + keys(input, 'kind,partner') && identifier(input.partner), + 'Choose at most sixteen exact final alliance partners.'); + return { kind: 'partner', partner: input.partner }; + }; + return parse(value, 0); +} + +export function parseAllianceExpressionClaim(value: unknown): AllianceExpressionClaim { + check(object(value) && keys(value, 'claim,turn') && typeof value.turn === 'number' && + Number.isSafeInteger(value.turn) && value.turn >= 1, + 'Choose the current turn and a final alliance expression.'); + return { turn: value.turn, claim: parseAllianceExpression(value.claim) }; +} + +function evaluate(expression: AllianceExpression, ally: string | null): boolean { + if (expression.kind === 'partner') return ally === expression.partner; + return expression.kind === 'and' ? expression.terms.every(term => evaluate(term, ally)) : + expression.terms.some(term => evaluate(term, ally)); +} + +/** Positive truth at one final native pair, independent of the whole answer. */ +export function evaluateAllianceExpression(expression: AllianceExpression, ally: string | null): boolean { + return evaluate(parseAllianceExpression(expression), ally); +} + +export function allianceExpressionText(expression: AllianceExpression, + playerName: (id: string) => string = id => id): string { + const text = (node: AllianceExpression): string => node.kind === 'partner' ? + allianceClaimText({ turn: 1, partner: node.partner }, playerName) : + `(${node.terms.map(text).join(node.kind === 'and' ? ' AND ' : ' OR ')})`; + return text(parseAllianceExpression(expression)); +} + +export function liveAllianceExpressionPromises(promises: readonly AllianceExpressionPromise[], player: string, + opportunity: AllianceOpportunity): AllianceExpressionPromise[] { + return promises.filter(promise => promise.player === player && promise.turn === opportunity.turn && + promise.source === opportunity.source && !promise.fulfilled && !promise.released); +} + +/** A whole No negates the complete expression. No interim membership settles + * an answer; the engine owns the original ordinary Nexus closing event. */ +export function allianceExpressionPromisesMatch(promises: readonly AllianceExpressionPromise[], player: string, + opportunity: AllianceOpportunity, ally: string | null): boolean { + return liveAllianceExpressionPromises(promises, player, opportunity) + .map(promise => evaluateAllianceExpression(promise.claim, ally) === promise.answer).every(Boolean); +} + +/** Enumerate truth-compatible final identities, not promises of foreign + * consent. Keep current membership first so a legal no-op stays preferred. */ +export function allianceExpressionOutcomes(scalars: readonly AlliancePromise[], + expressions: readonly AllianceExpressionPromise[], player: string, opportunity: AllianceOpportunity, + context: AllianceExpressionContext): (string | null)[] { + const owner = context.players.find(seat => seat.id === player); + if (!owner) return []; + const candidates = new Set([owner.ally, null, + ...context.players.filter(seat => seat.id !== player).map(seat => seat.id)]); + return [...candidates].filter(ally => alliancePromisesMatch(scalars, player, opportunity, ally) && + allianceExpressionPromisesMatch(expressions, player, opportunity, ally)); +} + +/** Reuse the native scalar witness for each compatible outcome. Local exact + * outcome clauses are never persisted/projected and cannot invent a foreign + * acceptance, break or invitation. Return every owned route so the engine can + * try its original financial/preparation constraints without pinning an OR. */ +export function findAllianceExpressionPlans(scalars: readonly AlliancePromise[], + expressions: readonly AllianceExpressionPromise[], player: string, opportunity: AllianceOpportunity, + context: AllianceExpressionContext): AllianceAction[][] { + if (!liveAllianceExpressionPromises(expressions, player, opportunity).length) { + const plan = findAlliancePromisePlan(scalars, player, opportunity, context); + return plan === null ? [] : [plan]; + } + const plans: AllianceAction[][] = []; + const seen = new Set(); + for (const ally of allianceExpressionOutcomes(scalars, expressions, player, opportunity, context)) { + const exact: AlliancePromise[] = (ally === null ? context.players.filter(seat => seat.id !== player) + .map(seat => ({ partner: seat.id, answer: false })) : [{ partner: ally, answer: true }]) + .map((claim, index) => ({ ...claim, turn: opportunity.turn, source: opportunity.source, + event: `alliance-expression-outcome-${index}`, player, + asker: context.players.find(seat => seat.id !== player)?.id ?? player })); + const plan = findAlliancePromisePlan([...scalars, ...exact], player, opportunity, context); + if (plan === null) continue; + const key = JSON.stringify(plan); + if (!seen.has(key)) { seen.add(key); plans.push(plan); } + } + return plans; +} + +export function validateAllianceExpressionPromises(promises: readonly AllianceExpressionPromise[], + playerIds: readonly string[]): void { + check(Array.isArray(promises), 'The alliance expression commitment record is invalid.'); + const events = new Set(); + for (const promise of promises) { + check(object(promise), 'The alliance expression commitment record is invalid.'); + const { claim } = parseAllianceExpressionClaim({ turn: promise.turn, claim: promise.claim }); + const validPartners = (node: AllianceExpression): boolean => node.kind === 'partner' ? + playerIds.includes(node.partner) && node.partner !== promise.player : node.terms.every(validPartners); + check(identifier(promise.event) && !events.has(promise.event) && identifier(promise.source) && + identifier(promise.player) && identifier(promise.asker) && playerIds.includes(promise.player) && + playerIds.includes(promise.asker) && promise.player !== promise.asker && validPartners(claim) && + typeof promise.answer === 'boolean' && optionalBoolean(promise.fulfilled) && optionalBoolean(promise.released) && + !(promise.fulfilled && promise.released), + 'The alliance expression lost its Nexus source, event or participants.'); + events.add(promise.event); + } +} diff --git a/game/bots.ts b/game/bots.ts index a176eadb..ea8a1899 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1336,6 +1336,11 @@ function policyActions(g: GameView): Action[] { const answer = g.truthLeaderRevivalAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } + if (w.question.kind === 'allianceExpression' || w.question.kind === 'mixedAlliance') { + const choices = w.question.kind === 'allianceExpression' ? g.truthAllianceExpressionAnswers : g.truthMixedAllianceAnswers; + const answer = choices?.find(answer => answer !== 'unknown') ?? choices?.[0]; + return answer ? [{ type: 'truthAnswer', answer }] : []; + } if (w.question.kind === 'alliance') { const answer = g.truthAllianceAnswers?.find(answer => answer !== 'unknown') ?? g.truthAllianceAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; diff --git a/game/engine.ts b/game/engine.ts index bbb57608..4f3fdbff 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,7 @@ +import { AllianceExpressionPromiseError, allianceExpressionOutcomes, findAllianceExpressionPlans, + allianceExpressionPromisesMatch, evaluateAllianceExpression, validateAllianceExpressionPromises, + type AllianceExpression, type AllianceExpressionClaim, type AllianceExpressionPromise } from './alliance-expression-promises'; +import type { AllianceActionClaim, MixedAllianceClaim } from './mixed-alliance-question'; import { mixedBidMinimum, type MixedBidClaim } from './mixed-bid-question'; import type { RevivalActionClaim, MixedRevivalClaim } from './mixed-revival-question'; import type { BribePaymentClaim, MixedBribeClaim } from './mixed-bribe-question'; @@ -13,8 +17,8 @@ import { RevivalExpressionPromiseError, liveRevivalExpressionPromises, revivalEx import { LeaderRevivalPromiseError, liveLeaderRevivalPromises, leaderRevivalRequirements, leaderRevivalPromisesSatisfied, recordPromisedLeaderRevival, validateLeaderRevivalPromises, type LeaderRevivalClaim, type LeaderRevivalPromise } from './leader-revival-promises'; -import { AlliancePromiseError, alliancePromiseModeSupported, liveAlliancePromises, alliancePromiseRequirements, - alliancePromisesMatch, findAlliancePromisePlan, validateAlliancePromises, type AllianceClaim, type AllianceOpportunity, type AlliancePromise } from './alliance-promises'; +import { AlliancePromiseError, alliancePromiseModeSupported, + alliancePromisesMatch, validateAlliancePromises, type AllianceClaim, type AllianceOpportunity, type AlliancePromise } from './alliance-promises'; import { BribePromiseError, bribePromiseModeSupported, liveBribePromises, recordPromisedBribe, validateBribePromises, type BribeClaim, type BribePromise } from './bribe-promises'; import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income'; @@ -2179,6 +2183,11 @@ export type Game = { })[]; bribePromiseSequence?: string[]; alliancePromises?: AlliancePromise[]; + allianceExpressionPromises?: AllianceExpressionPromise[]; + alliancePromiseSequence?: string[]; + mixedAllianceQuestions?: (MixedAllianceClaim & { + event: string; player: string; asker: string; answer: boolean; binding: boolean; + })[]; movementPromises?: MovementPromise[]; movementExpressionPromises?: MovementExpressionPromise[]; /** Original mixed questions only; actual obligations remain in the native ledger. */ @@ -6923,6 +6932,10 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) { battle: g.battle, shipmentPromises: g.shipmentPromises, movementPromises: g.movementPromises, + ...(['allianceExpression', 'mixedAlliance'].includes(next.record.question.kind) ? { + allianceExpressionPromises: g.allianceExpressionPromises, alliancePromiseSequence: g.alliancePromiseSequence, + mixedAllianceQuestions: g.mixedAllianceQuestions, + } : {}), ...(next.record.question.kind === 'mixedBid' ? { mixedBidQuestions: g.mixedBidQuestions, bidPromises: g.bidPromises, } : {}), @@ -14952,10 +14965,27 @@ function pledgeAid(g: Game, p: Player, requested: unknown) { } function alliancePromiseRule(calculate: () => T): T { try { return calculate(); } - catch (error) { if (error instanceof AlliancePromiseError) throw new RuleError(error.message); throw error; } + catch (error) { if (error instanceof AlliancePromiseError || error instanceof AllianceExpressionPromiseError) throw new RuleError(error.message); throw error; } } function alliancePromiseIntegrity(g: Game) { - alliancePromiseRule(() => validateAlliancePromises(g.alliancePromises ?? [], g.players.map(player => player.id))); + alliancePromiseRule(() => { + const players = g.players.map(player => player.id); + validateAlliancePromises(g.alliancePromises ?? [], players); + validateAllianceExpressionPromises(g.allianceExpressionPromises ?? [], players); + }); + const events = [...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? [])].map(promise => promise.event); + requireRule(new Set(events).size === events.length, 'Keep each alliance answer bound to one original event.'); +} +type AllianceCommitment = AlliancePromise | AllianceExpressionPromise; +function orderedAllianceCommitments(g: Game): AllianceCommitment[] { + const order = new Map((g.alliancePromiseSequence ?? []).map((event, index) => [event, index])); + return [...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? [])] + .sort((a, b) => (order.get(a.event) ?? -1) - (order.get(b.event) ?? -1)); +} +function reopenAllianceTruthReady(g: Game, p: Player) { + if (!g.ready.includes(p.id)) return; + g.ready = g.ready.filter(player => player !== p.id); + log(g, `${p.name} has a new Truthtrance alliance commitment and must confirm Nexus readiness again.`); } function allianceOpportunity(g: Game): AllianceOpportunity | null { if (g.status !== 'playing' || g.phase !== 1 || !g.nexus || !alliancePromiseModeSupported(g)) return null; @@ -14986,91 +15016,185 @@ function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) { return nexusRule(() => quoteNexusAlliance({ players: g.players.map(player => ({ id: player.id, ally: player.ally })), offers: g.allianceOffers, actor: p.id, target })); } -function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ?? []) { +function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ?? [], + expressions = g.allianceExpressionPromises ?? []) { const opportunity = allianceOpportunity(g); if (!opportunity) return null; - const actions = findAlliancePromisePlan(promises, p.id, opportunity, { players: g.players, offers: g.allianceOffers }); - if (!actions) return null; - // Keeping an already matching membership spends nothing and changes no - // alliance. An unrelated foreign loss is not a prospective cost of Ready. - if (!actions.length) return { actions, bribePreparation: false }; - const trial = structuredClone(g), actor = getPlayer(trial, p.id); - trial.truthtrance = null; - let bribePreparation = false; - try { - // An already owed, currently payable bribe may precede accepting its named - // recipient. Keep that payment on its existing guide and native action. - const accepts = actions.find(action => action.target !== null)?.target; - const future = structuredClone(trial); - if (accepts) getPlayer(future, p.id).ally = accepts; - if (accepts && hasBribePromises(trial, p.id, true) && !bribePromiseCompletion(future, getPlayer(future, p.id))) { - const payment = bribePromiseCompletion(trial, actor)?.find(action => action.target === accepts); - if (!payment) return null; - const amount = payment.amount as number; - actor.spice -= amount; - getPlayer(trial, accepts).bribes += amount; - recordBribePayment(trial, p.id, accepts, amount); - bribePreparation = true; - } - for (const action of actions) { - const quote = normalNexusAllianceQuote(trial, actor, action.target); - for (const player of trial.players) player.ally = quote.allies[player.id]; - trial.allianceOffers = quote.offers; - if (quote.formed) for (const id of quote.formed) getPlayer(trial, id).allySinceTurn = g.turn; - trial.ready = []; - } - const voluntary = { actor: p.id, action: { type: 'alliance' } }; - reconcileShipmentPromises(trial, voluntary); - reconcileGroundMovementPromises(trial, p.id, voluntary.action, g.phase); - reconcileRevivalPromises(trial, voluntary); - reconcileBribePromises(trial, voluntary); - return { actions, bribePreparation }; - } catch (error) { if (error instanceof RuleError) return null; throw error; } + const plans = findAllianceExpressionPlans(promises, expressions, p.id, opportunity, + { players: g.players, offers: g.allianceOffers }); + // Try every consent-supported final outcome. A financial restriction on one + // partner never fixes an OR answer to that route or hides another legal one. + for (const actions of plans) { + if (!actions.length) return { actions, bribePreparation: false }; + const trial = structuredClone(g), actor = getPlayer(trial, p.id); + trial.truthtrance = null; + let bribePreparation = false; + try { + const accepts = actions.find(action => action.target !== null)?.target; + const future = structuredClone(trial); + if (accepts) getPlayer(future, p.id).ally = accepts; + if (accepts && hasBribePromises(trial, p.id, true) && !bribePromiseCompletion(future, getPlayer(future, p.id))) { + const payment = bribePromiseCompletion(trial, actor)?.find(action => action.target === accepts); + if (!payment) continue; + const amount = payment.amount as number; + actor.spice -= amount; + getPlayer(trial, accepts).bribes += amount; + recordBribePayment(trial, p.id, accepts, amount); + bribePreparation = true; + } + for (const action of actions) { + const quote = normalNexusAllianceQuote(trial, actor, action.target); + for (const player of trial.players) player.ally = quote.allies[player.id]; + trial.allianceOffers = quote.offers; + if (quote.formed) for (const id of quote.formed) getPlayer(trial, id).allySinceTurn = g.turn; + trial.ready = []; + } + const voluntary = { actor: p.id, action: { type: 'alliance' } }; + reconcileShipmentPromises(trial, voluntary); + reconcileGroundMovementPromises(trial, p.id, voluntary.action, g.phase); + reconcileRevivalPromises(trial, voluntary); + reconcileBribePromises(trial, voluntary); + return { actions, bribePreparation }; + } catch (error) { if (!(error instanceof RuleError)) throw error; } + } + return null; +} +function allianceUnknownConsent(g: Game, p: Player, promises = g.alliancePromises ?? [], + expressions = g.allianceExpressionPromises ?? []) { + const opportunity = allianceOpportunity(g); + return !!opportunity && allianceExpressionOutcomes(promises, expressions, p.id, opportunity, + { players: g.players, offers: g.allianceOffers }).some(partner => partner !== null && p.ally !== partner && + (getPlayer(g, partner).ally !== null || g.allianceOffers[partner] !== p.id)); } function allianceTruthAnswers(g: Game, p: Player, claim: AllianceClaim): ('yes' | 'no' | 'unknown')[] { const opportunity = allianceOpportunity(g); if (!opportunity || !allianceQuestionWindow(g) || claim.turn !== g.turn || claim.partner === p.id || !g.players.some(player => player.id === claim.partner)) return []; - const prior = alliancePromiseRequirements(g.alliancePromises ?? [], p.id, opportunity); + const candidate = (answer: boolean): AlliancePromise => ({ ...claim, event: 'candidate', source: opportunity.source, + player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer }); const answers: ('yes' | 'no' | 'unknown')[] = (['yes', 'no'] as const).filter(answer => - !!alliancePromisePlan(g, p, [...(g.alliancePromises ?? []), { ...claim, event: 'candidate', source: opportunity.source, - player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes' }])); - const foreignConsent = p.ally !== claim.partner && - (getPlayer(g, claim.partner).ally !== null || g.allianceOffers[claim.partner] !== p.id); - if (!answers.includes('yes') && foreignConsent && prior?.partner === null && - !prior.forbidden.includes(claim.partner)) answers.push('unknown'); + !!alliancePromisePlan(g, p, [...(g.alliancePromises ?? []), candidate(answer === 'yes')])); + if (!answers.includes('yes') && allianceUnknownConsent(g, p, [...(g.alliancePromises ?? []), candidate(true)])) + answers.push('unknown'); return answers; } function bindAllianceTruth(g: Game, p: Player, claim: AllianceClaim, answer: boolean, asker: string) { const opportunity = allianceOpportunity(g); requireRule(opportunity && allianceTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), 'Choose an alliance answer compatible with actual consent and earlier answers.'); - (g.alliancePromises ??= []).push({ ...claim, event: crypto.randomUUID(), source: opportunity.source, - player: p.id, asker, answer }); - if (g.ready.includes(p.id)) { - g.ready = g.ready.filter(player => player !== p.id); - log(g, `${p.name} has a new Truthtrance alliance commitment and must confirm Nexus readiness again.`); + const promise = { ...claim, event: crypto.randomUUID(), source: opportunity.source, player: p.id, asker, answer }; + (g.alliancePromises ??= []).push(promise); + (g.alliancePromiseSequence ??= []).push(promise.event); + reopenAllianceTruthReady(g, p); +} +function allianceExpressionIdentities(g: Game, p: Player, claim: AllianceExpression): boolean { + return claim.kind === 'partner' ? claim.partner !== p.id && g.players.some(player => player.id === claim.partner) + : claim.terms.every(term => allianceExpressionIdentities(g, p, term)); +} +function allianceExpressionTruthAnswers(g: Game, p: Player, claim: AllianceExpressionClaim): TruthAnswer[] { + const opportunity = allianceOpportunity(g); + if (!opportunity || !allianceQuestionWindow(g) || claim.turn !== g.turn || + !allianceExpressionIdentities(g, p, claim.claim)) return []; + const candidate = (answer: boolean): AllianceExpressionPromise => ({ ...claim, event: 'candidate', source: opportunity.source, + player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer }); + const answers: TruthAnswer[] = (['yes', 'no'] as const).filter(answer => !!alliancePromisePlan(g, p, + g.alliancePromises ?? [], [...(g.allianceExpressionPromises ?? []), candidate(answer === 'yes')])); + if (!answers.includes('yes') && allianceUnknownConsent(g, p, g.alliancePromises ?? [], + [...(g.allianceExpressionPromises ?? []), candidate(true)])) answers.push('unknown'); + return answers; +} +function bindAllianceExpressionTruth(g: Game, p: Player, claim: AllianceExpressionClaim, answer: boolean, asker: string) { + const opportunity = allianceOpportunity(g); + requireRule(opportunity && allianceExpressionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), + 'Choose a whole alliance answer compatible with original consent and earlier commitments.'); + const promise: AllianceExpressionPromise = { ...claim, event: crypto.randomUUID(), source: opportunity.source, + player: p.id, asker, answer }; + (g.allianceExpressionPromises ??= []).push(promise); + (g.alliancePromiseSequence ??= []).push(promise.event); + reopenAllianceTruthReady(g, p); +} +function mixedAllianceIdentities(g: Game, p: Player, mixed: MixedAllianceClaim['mixed']): boolean { + if (mixed.kind === 'fact') return true; + if (mixed.kind === 'action') return mixed.action.partner !== p.id && g.players.some(player => player.id === mixed.action.partner); + return mixed.terms.every(term => mixedAllianceIdentities(g, p, term)); +} +function compileMixedAlliance(g: Game, p: Player, claim: MixedAllianceClaim, answer: boolean) { + const compiled = compileMixedActionExpression(claim.mixed, + fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer); + if (compiled.kind === 'constant') return { constant: compiled.value }; + const partners = (node: CompiledMixedAction): AllianceExpression => { + if (node.kind === 'action') return { kind: 'partner', partner: node.action.partner }; + requireRule(node.kind !== 'constant', 'Retain the privately compiled whole mixed alliance condition.'); + return { kind: node.kind, terms: node.terms.map(partners) }; + }; + return { residual: { turn: claim.turn, claim: partners(compiled) } }; +} +function mixedAllianceTruthAnswers(g: Game, p: Player, claim: MixedAllianceClaim): TruthAnswer[] { + if (!allianceOpportunity(g) || !allianceQuestionWindow(g) || claim.turn !== g.turn || + !mixedAllianceIdentities(g, p, claim.mixed)) return []; + const answers: TruthAnswer[] = []; + let unknown = false; + for (const answer of [true, false]) { + const compiled = compileMixedAlliance(g, p, claim, answer); + if (compiled.constant !== undefined) { + if (compiled.constant === answer) answers.push(answer ? 'yes' : 'no'); + } else { + const choices = allianceExpressionTruthAnswers(g, p, compiled.residual!); + if (choices.includes(answer ? 'yes' : 'no')) answers.push(answer ? 'yes' : 'no'); + if (answer && choices.includes('unknown')) unknown = true; + } } + if (unknown || !answers.length) answers.push('unknown'); + return answers; +} +function bindMixedAllianceTruth(g: Game, p: Player, claim: MixedAllianceClaim, answer: boolean, asker: string) { + requireRule(mixedAllianceTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), + 'Choose a mixed alliance answer compatible with the complete fact and final-partner expression.'); + const compiled = compileMixedAlliance(g, p, claim, answer); + let event: string; + if (compiled.residual) { + bindAllianceExpressionTruth(g, p, compiled.residual, answer, asker); + event = g.allianceExpressionPromises!.at(-1)!.event; + } else { + requireRule(compiled.constant === answer, 'Retain the truthful whole mixed-alliance answer.'); + event = crypto.randomUUID(); + } + (g.mixedAllianceQuestions ??= []).push({ turn: claim.turn, mixed: structuredClone(claim.mixed), + event, player: p.id, asker, answer, binding: !!compiled.residual }); + reopenAllianceTruthReady(g, p); +} +function isMixedAllianceEvent(g: Game, event: string): boolean { + return !!g.mixedAllianceQuestions?.some(question => question.event === event); +} +function projectedMixedAlliancePromises(g: Game, player: string) { + return (g.mixedAllianceQuestions ?? []).filter(question => question.player === player).map(question => { + const bound = g.allianceExpressionPromises?.find(promise => promise.event === question.event); + return { event: question.event, player: question.player, asker: question.asker, turn: question.turn, + mixed: structuredClone(question.mixed), answer: question.answer, + fulfilled: !question.binding || bound?.fulfilled === true, released: bound?.released === true }; + }); } function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action: Action }) { - if (!g.alliancePromises?.some(promise => !promise.fulfilled && !promise.released)) return; + const ordered = orderedAllianceCommitments(g); + if (!ordered.some(promise => !promise.fulfilled && !promise.released)) return; alliancePromiseIntegrity(g); if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.spiceWindow || g.spiceResolution || g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return; - const opportunity = allianceOpportunity(g), kept: AlliancePromise[] = []; - for (const promise of g.alliancePromises) { + const opportunity = allianceOpportunity(g), kept: AlliancePromise[] = [], expressions: AllianceExpressionPromise[] = []; + for (const promise of ordered) { if (promise.fulfilled || promise.released) continue; const same = opportunity && promise.turn === opportunity.turn && promise.source === opportunity.source; - const player = getPlayer(g, promise.player), matches = (player.ally === promise.partner) === promise.answer; - const possible = same && alliancePromisePlan(g, player, [...kept, promise]); + const player = getPlayer(g, promise.player), whole = 'claim' in promise; + const matches = (whole ? evaluateAllianceExpression(promise.claim, player.ally) : player.ally === promise.partner) === promise.answer; + const scalars = whole ? kept : [...kept, promise], groups = whole ? [...expressions, promise] : expressions; + const possible = same && alliancePromisePlan(g, player, scalars, groups); if (!same && matches) { promise.fulfilled = true; continue; } if (!possible) { - requireRule(voluntary?.actor !== promise.player, - 'You cannot voluntarily evade your Truthtrance alliance answer.'); + requireRule(voluntary?.actor !== promise.player, 'You cannot voluntarily evade your Truthtrance alliance answer.'); promise.released = true; log(g, `${player.name} can no longer fulfill the Truthtrance alliance answer; that answer is no longer binding.`); } else { - kept.push(promise); + if (whole) expressions.push(promise); else kept.push(promise); if (!matches && g.ready.includes(player.id)) g.ready = g.ready.filter(id => id !== player.id); } } @@ -15078,17 +15202,25 @@ function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action: function guardAlliancePromiseReady(g: Game, p: Player) { const opportunity = allianceOpportunity(g); if (!opportunity) return; - requireRule(alliancePromisesMatch(g.alliancePromises ?? [], p.id, opportunity, p.ally), - 'Match your Truthtrance alliance answers before finishing this Nexus.'); + requireRule(alliancePromisesMatch(g.alliancePromises ?? [], p.id, opportunity, p.ally) && + allianceExpressionPromisesMatch(g.allianceExpressionPromises ?? [], p.id, opportunity, p.ally), + 'Match your Truthtrance alliance answers before finishing this Nexus.'); } function settleAlliancePromises(g: Game) { const opportunity = allianceOpportunity(g); if (!opportunity) return; for (const player of g.players) guardAlliancePromiseReady(g, player); - for (const promise of g.alliancePromises ?? []) + for (const promise of orderedAllianceCommitments(g)) if (promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.fulfilled && !promise.released) promise.fulfilled = true; } +function projectedAllianceExpressionPromises(g: Game, player: string) { + return (g.allianceExpressionPromises ?? []).filter(promise => promise.player === player && !isMixedAllianceEvent(g, promise.event)) + .map(promise => ({ event: promise.event, player: promise.player, asker: promise.asker, turn: promise.turn, + claim: structuredClone(promise.claim), answer: promise.answer, + ...(promise.fulfilled === undefined ? {} : { fulfilled: promise.fulfilled }), + ...(promise.released === undefined ? {} : { released: promise.released }) })); +} function projectedAlliancePromises(g: Game, player: string) { return (g.alliancePromises ?? []).filter(promise => promise.player === player).map(promise => ({ event: promise.event, player: promise.player, asker: promise.asker, turn: promise.turn, @@ -15100,7 +15232,8 @@ function projectedAlliancePromises(g: Game, player: string) { function projectedAlliancePromiseGuide(g: Game, player: string): { action: Action } | null { const opportunity = allianceOpportunity(g); if (!opportunity || !allianceQuestionWindow(g) || g.truthtrance || g.ready.includes(player)) return null; - const promise = liveAlliancePromises(g.alliancePromises ?? [], player, opportunity)[0]; + const promise = orderedAllianceCommitments(g).find(record => record.player === player && record.turn === opportunity.turn && + record.source === opportunity.source && !record.fulfilled && !record.released); if (!promise) return null; const plan = alliancePromisePlan(g, getPlayer(g, player)); if (!plan || plan.bribePreparation) return null; @@ -36619,6 +36752,10 @@ function applyActionInner( bindMovementExpression: bindMovementExpressionTruth, mixedMovementAnswers: mixedMovementTruthAnswers, bindMixedMovement: bindMixedMovementTruth, + allianceExpressionAnswers: allianceExpressionTruthAnswers, + bindAllianceExpression: bindAllianceExpressionTruth, + mixedAllianceAnswers: mixedAllianceTruthAnswers, + bindMixedAlliance: bindMixedAllianceTruth, allianceAnswers: allianceTruthAnswers, bindAlliance: bindAllianceTruth, bribeAnswers: bribeTruthAnswers, @@ -36648,7 +36785,7 @@ function applyActionInner( ) return g; } catch (error) { - if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof MixedActionQuestionError || error instanceof TraitorDecisionError) + if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof AllianceExpressionPromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof MixedActionQuestionError || error instanceof TraitorDecisionError) throw new RuleError(error.message); throw error; } @@ -40485,6 +40622,14 @@ export function viewGame(state: Game, id: string) { truthAllianceTargets: allianceQuestionWindow(g) ? g.players.map(player => player.id) : [], truthAllianceAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'alliance' ? allianceTruthAnswers(g, me, g.truthtrance.question) : null, + truthAllianceExpressionTargets: allianceQuestionWindow(g) ? g.players.map(player => player.id) : [], + truthAllianceExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && + g.truthtrance.question.kind === 'allianceExpression' ? allianceExpressionTruthAnswers(g, me, g.truthtrance.question) : null, + allianceExpressionPromises: projectedAllianceExpressionPromises(g, id), + truthMixedAllianceTargets: allianceQuestionWindow(g) ? g.players.map(player => player.id) : [], + truthMixedAllianceAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && + g.truthtrance.question.kind === 'mixedAlliance' ? mixedAllianceTruthAnswers(g, me, g.truthtrance.question) : null, + mixedAlliancePromises: projectedMixedAlliancePromises(g, id), alliancePromises: projectedAlliancePromises(g, id), alliancePromiseGuide: projectedAlliancePromiseGuide(g, id), truthBribeTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [], diff --git a/game/mixed-alliance-question.ts b/game/mixed-alliance-question.ts new file mode 100644 index 00000000..11ef6f71 --- /dev/null +++ b/game/mixed-alliance-question.ts @@ -0,0 +1,14 @@ +import { AlliancePromiseError, parseAllianceClaim } from './alliance-promises'; +import type { MixedActionExpression } from './mixed-action-question'; + +export type AllianceActionClaim = { partner: string }; +export type MixedAllianceClaim = { turn: number; mixed: MixedActionExpression }; + +/** One exact final partner only. Turn/source/compiled metadata belong to the + * outer current-Nexus question or its private answer-time binding. */ +export function parseAllianceActionClaim(value: unknown): AllianceActionClaim { + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).join(',') !== 'partner') + throw new AlliancePromiseError('Supply one exact final alliance partner without turn or Nexus metadata.'); + const { partner } = parseAllianceClaim({ turn: 1, partner: (value as Record).partner }); + return { partner }; +} diff --git a/game/reference.ts b/game/reference.ts index 0809d275..a425571d 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3208,6 +3208,56 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: '13 files101/101, types/lint/build and six native engine/answer-callback/GameTable SSR journeys pass. Genuine26/72/77-action Basic/allied/Cunning sources earn original cards, alliances, payments, reveal and draw/return. Four profiles answer/vote legally. Unmatched-card staging is labelled; submitted-false and response/typing fixture feedback is repaired. Full future promises, combinations and final assurance remain open.', evidence: ['tests/traitor-decision-promises.test.ts', 'tests/fixture-truth-traitor-decision.ts', 'tests/fixture-truth-traitor-harkonnen.ts', 'tests/truth-traitor-decision-engine.test.ts', 'tests/truth-traitor-harkonnen-engine.test.ts', 'tests/truth-traitor-decision-controls.test.tsx', 'docs/TRUTHTRANCE_TRAITOR_DECISIONS.md'] }, ], }, + { + id: "truthtrance-alliance-expression", + title: "Truthtrance: combined final alliance partners", + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: "Combine final partner conditions with AND/OR, retaining every owned consent route and whole No until actual Nexus close.", + steps: [ + "Choose Combine final alliance partners with AND/OR. Build full groups using current participants other than the respondent. Every condition concerns actual membership when this ordinary Nexus closes.", + "OR keeps every compatible consent-supported partner available. AND requires every child; in the native pair system two different final partners cannot both be true. No negates the complete expression, rather than demanding each child separately be false.", + "A proposal alone is not membership. Yes stays binding after formation; No can permit temporary membership if it ends before the close. Both settle only at the actual Nexus close.", + "A known owned completion uses an existing partnership or a real incoming invitation. No future consent or withdrawal is invented. Missing consent can leave No or I don’t know available; the holder may save the card or ask again after a real offer.", + "Every compatible native consent plan is checked against earlier financial and other commitments. An OR answer selects no fixed route; another manual legal partner remains available. Existing bribes use their original payment controls before accepting a recipient when required.", + "Other participants keep their original withdrawal and departure rights. One surviving OR route preserves Yes; removing the last owned route can release an impossible answer in accepted order. A later reoffer never reactivates a released record.", + "Full editing, raw preview/Ask, private answers, own aggregate status and one exact shared acceptance/break/Ready guide connect to four minimal policies. A definite answer reopens only respondent readiness. Earlier-phase diplomacy, optional combinations, broader action families and final deployed acceptance remain unfinished.", + ], + related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-mixed-alliance', 'truthtrance-bribe', 'implementation-checklist'], + checklist: [ + { area: "Implementation", status: "Partial", detail: "Finite final outcomes reuse original canonical consent plans, native financial preparation and one scalar/whole/mixed accepted sequence. Raw projection omits private source and mixed residuals.", evidence: ["game/alliance-expression-promises.ts", "game/mixed-alliance-question.ts", "game/engine.ts", "game/truthtrance.ts"] }, + { area: "Player controls", status: "Partial", detail: "Full partner/mixed editors, raw preview/Ask, private answers and one exact live native guide retain public context and original consent.", evidence: ["components/alliance-expression-fields.tsx", "components/mixed-alliance-fields.tsx", "components/alliance-expression-promises.tsx", "components/mixed-alliance-promises.tsx", "components/alliance-promises.tsx", "components/truthtrance.tsx", "components/game-table.tsx"] }, + { area: "AI", status: "Partial", detail: "Four minimal profiles answer and perform canonical native acceptance and Ready. Strategy/calibration remain deferred.", evidence: ["game/bots.ts", "tests/truth-alliance-expression-runtime.test.ts", "tests/truth-mixed-alliance-runtime.test.ts"] }, + { area: "Documentation", status: "Implemented", detail: "Current-Nexus interval, whole No, snapshot/unknown, owned consent, native financial preparation and remaining gates are explicit.", evidence: ["docs/TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md", "docs/RULE_DECISIONS.md"] }, + { area: "Verification", status: "Partial", detail: "Focused pure, original native consent/close/funding and controlled editing/guide cases. Wider compositions and final deployed acceptance remain open.", evidence: ["tests/alliance-expression-promises.test.ts", "tests/mixed-alliance-question.test.ts", "tests/fixture-truth-alliance-expression.ts", "tests/truth-alliance-expression-runtime.test.ts", "tests/truth-mixed-alliance-runtime.test.ts", "tests/truth-alliance-expression-funding.test.ts", "tests/truth-alliance-expression-controls.test.tsx", "tests/truth-mixed-alliance-controls.test.tsx"] }, + ], + }, + { + id: "truthtrance-mixed-alliance", + title: "Truthtrance: current facts and final alliance partners", + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: "Combine answer-time facts with final Nexus partner conditions while preserving real consent, alternatives and original commitments.", + steps: [ + "Choose Combine current facts and final alliance partners. Build full AND/OR groups with at least one current fact and one partner condition. Nested facts share the sixteen-condition, four-level input limit.", + "Current facts freeze at the actual answer. A fact that you hold at least10 spice stays that answer-time fact after a real payment leaves7. Only the aggregate answer is shared; frozen clause answers and reduced branches remain private.", + "A fact may settle the whole answer immediately, requiring no later alliance. Otherwise the remaining final-partner conditions use the same native system as whole alliance questions. Unknown facts cannot be relied on being true for Yes or false for No.", + "A known owned completion uses an existing partnership or a real incoming invitation. No future consent or withdrawal is invented. Missing consent can leave No or I don’t know available; the holder may save the card or ask again after a real offer.", + "Every compatible native consent plan is checked against earlier financial and other commitments. An OR answer selects no fixed route; another manual legal partner remains available. Existing bribes use their original payment controls before accepting a recipient when required.", + "Other participants keep their original withdrawal and departure rights. One surviving OR route preserves Yes; removing the last owned route can release an impossible answer in accepted order. A later reoffer never reactivates a released record.", + "Full editing, raw preview/Ask, private answers, own aggregate status and one exact shared acceptance/break/Ready guide connect to four minimal policies. A definite answer reopens only respondent readiness. Earlier-phase diplomacy, optional combinations, broader action families and final deployed acceptance remain unfinished.", + ], + related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-alliance-expression', 'truthtrance-bribe', 'implementation-checklist'], + checklist: [ + { area: "Implementation", status: "Partial", detail: "Finite final outcomes reuse original canonical consent plans, native financial preparation and one scalar/whole/mixed accepted sequence. Raw projection omits private source and mixed residuals.", evidence: ["game/alliance-expression-promises.ts", "game/mixed-alliance-question.ts", "game/engine.ts", "game/truthtrance.ts"] }, + { area: "Player controls", status: "Partial", detail: "Full partner/mixed editors, raw preview/Ask, private answers and one exact live native guide retain public context and original consent.", evidence: ["components/alliance-expression-fields.tsx", "components/mixed-alliance-fields.tsx", "components/alliance-expression-promises.tsx", "components/mixed-alliance-promises.tsx", "components/alliance-promises.tsx", "components/truthtrance.tsx", "components/game-table.tsx"] }, + { area: "AI", status: "Partial", detail: "Four minimal profiles answer and perform canonical native acceptance and Ready. Strategy/calibration remain deferred.", evidence: ["game/bots.ts", "tests/truth-alliance-expression-runtime.test.ts", "tests/truth-mixed-alliance-runtime.test.ts"] }, + { area: "Documentation", status: "Implemented", detail: "Current-Nexus interval, whole No, snapshot/unknown, owned consent, native financial preparation and remaining gates are explicit.", evidence: ["docs/TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md", "docs/RULE_DECISIONS.md"] }, + { area: "Verification", status: "Partial", detail: "Focused pure, original native consent/close/funding and controlled editing/guide cases. Wider compositions and final deployed acceptance remain open.", evidence: ["tests/alliance-expression-promises.test.ts", "tests/mixed-alliance-question.test.ts", "tests/fixture-truth-alliance-expression.ts", "tests/truth-alliance-expression-runtime.test.ts", "tests/truth-mixed-alliance-runtime.test.ts", "tests/truth-alliance-expression-funding.test.ts", "tests/truth-alliance-expression-controls.test.tsx", "tests/truth-mixed-alliance-controls.test.tsx"] }, + ], + }, { id: 'truthtrance-alliance', title: 'Truthtrance: alliance membership at this Nexus close', diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 5b28741d..06cbf803 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,3 +1,5 @@ +import { parseAllianceExpressionClaim, allianceExpressionText, type AllianceExpressionClaim } from './alliance-expression-promises'; +import { parseAllianceActionClaim, type MixedAllianceClaim } from './mixed-alliance-question'; import { parseBidActionClaim, MIXED_BID_MAX_DEPTH, MIXED_BID_MAX_LEAVES, type MixedBidClaim } from './mixed-bid-question'; import { parseRevivalActionClaim, type MixedRevivalClaim } from './mixed-revival-question'; import { parseBribePaymentClaim, type MixedBribeClaim } from './mixed-bribe-question'; @@ -73,6 +75,8 @@ export type TruthFact = | { kind: 'and' | 'or'; terms: TruthFact[] }; export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression }; export type TruthQuestion = + | ({ kind: 'allianceExpression'; target: string } & AllianceExpressionClaim) + | ({ kind: 'mixedAlliance'; target: string } & MixedAllianceClaim) | ({ kind: 'mixedBid'; target: string } & MixedBidClaim) | ({ kind: 'mixedRevival'; target: string } & MixedRevivalClaim) | ({ kind: 'mixedBribe'; target: string } & MixedBribeClaim) @@ -264,6 +268,19 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { 'Choose a supported current-turn ordinary leader-revival question in a quiet window.'); return { kind: 'leaderRevival', target: v.target, ...claim }; } + if (v.kind === 'allianceExpression' || v.kind === 'mixedAlliance') { + check(Object.keys(v).sort().join(',') === (v.kind === 'allianceExpression' ? 'claim,kind,target,turn' : 'kind,mixed,target,turn'), + 'Supply the current Nexus turn, complete alliance expression and respondent.'); + check(alliancePromiseModeSupported(g) && g.status === 'playing' && g.phase === 1 && g.nexus && + !g.spiceWindow && !g.spiceResolution && !g.response && !g.decision && !g.phaseOpening && + !g.pendingTreacheryDiscard && !g.pendingKarama && v.turn === g.turn, + 'Choose the current ordinary Nexus in a quiet supported window.'); + if (v.kind === 'allianceExpression') return { kind: 'allianceExpression', target: v.target as string, + ...parseAllianceExpressionClaim({ turn: v.turn, claim: v.claim }) }; + const respondent = g.players.find(player => player.id === v.target)!; + return { kind: 'mixedAlliance', target: v.target as string, turn: g.turn, + mixed: parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), parseAllianceActionClaim) }; + } if (v.kind === 'alliance') { check(Object.keys(v).sort().join(',') === 'kind,partner,target,turn', 'Supply the current Nexus turn, named alliance partner and respondent.'); @@ -595,6 +612,7 @@ export function truthQuestionText( if (q.kind === 'freeform') return q.text; if (q.kind === 'revivalExpression') return `Will you ${revivalExpressionText(q.claim, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} from now until your Revival opportunity ends this turn?`; if (q.kind === 'leaderRevival') return `Will you ${leaderRevivalClaimText(q, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))}?`; + if (q.kind === 'allianceExpression') return `Is it true that you ${allianceExpressionText(q.claim, playerName)}?`; if (q.kind === 'alliance') return `Will you ${allianceClaimText(q, playerName)}?`; if (q.kind === 'bribeExpression') return `Will you ${bribeExpressionText(q.claim, playerName)} from now until this turn ends?`; if (q.kind === 'bribe') return `Will you ${bribeClaimText(q, playerName)}?`; @@ -620,6 +638,8 @@ export function truthQuestionText( return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`; return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`; }; + if (q.kind === 'mixedAlliance') return `Is it true that ${mixedActionExpressionText(q.mixed, clause, + action => `you ${allianceClaimText({ turn: q.turn, partner: action.partner }, playerName)}`)}?`; if (q.kind === 'mixedBid') return `Is it true that ${mixedActionExpressionText(q.mixed, clause, action => `you ${bidClaimText({ turn: q.turn, lot: q.lot, minimum: action.minimum })}`)}?`; if (q.kind === 'mixedRevival') return `Is it true that ${mixedActionExpressionText(q.mixed, clause, @@ -658,6 +678,10 @@ export function resolveTruthAction( bindMovementExpression?: (g: Game, p: Player, claim: MovementExpressionClaim, answer: boolean, asker: string) => void; movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[]; bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void; + allianceExpressionAnswers?: (g: Game, p: Player, claim: AllianceExpressionClaim) => TruthAnswer[]; + bindAllianceExpression?: (g: Game, p: Player, claim: AllianceExpressionClaim, answer: boolean, asker: string) => void; + mixedAllianceAnswers?: (g: Game, p: Player, claim: MixedAllianceClaim) => TruthAnswer[]; + bindMixedAlliance?: (g: Game, p: Player, claim: MixedAllianceClaim, answer: boolean, asker: string) => void; allianceAnswers?: (g: Game, p: Player, claim: AllianceClaim) => TruthAnswer[]; bindAlliance?: (g: Game, p: Player, claim: AllianceClaim, answer: boolean, asker: string) => void; mixedBribeAnswers?: (g: Game, p: Player, claim: MixedBribeClaim) => TruthAnswer[]; @@ -805,6 +829,12 @@ export function resolveTruthAction( if (w.question.kind === 'leaderRevival') check(effects.leaderRevivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a named leader question with a compatible native completion.'); + if (w.question.kind === 'allianceExpression') + check(effects.allianceExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, + 'Choose a whole final-alliance question compatible with the current Nexus.'); + if (w.question.kind === 'mixedAlliance') + check(effects.mixedAllianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, + 'Choose a mixed fact/final-alliance question compatible with the current Nexus.'); if (w.question.kind === 'alliance') check(effects.allianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a current Nexus question with a compatible answer.'); @@ -908,6 +938,16 @@ export function resolveTruthAction( 'Choose a named leader-revival answer compatible with native eligibility and earlier promises.'); if (a.answer !== 'unknown') effects.bindLeaderRevival(g, p, q, a.answer === 'yes', current.player); } + if (q.kind === 'allianceExpression') { + check(effects.allianceExpressionAnswers && effects.bindAllianceExpression && effects.allianceExpressionAnswers(g, p, q).includes(a.answer), + 'Choose a whole alliance answer compatible with original consent and earlier answers.'); + if (a.answer !== 'unknown') effects.bindAllianceExpression(g, p, q, a.answer === 'yes', current.player); + } + if (q.kind === 'mixedAlliance') { + check(effects.mixedAllianceAnswers && effects.bindMixedAlliance && effects.mixedAllianceAnswers(g, p, q).includes(a.answer), + 'Choose a mixed alliance answer compatible with the whole question and original consent.'); + if (a.answer !== 'unknown') effects.bindMixedAlliance(g, p, q, a.answer === 'yes', current.player); + } if (q.kind === 'alliance') { check(effects.allianceAnswers && effects.bindAlliance && effects.allianceAnswers(g, p, q).includes(a.answer), 'Choose an answer consistent with actual alliance consent and earlier commitments.'); diff --git a/tests/alliance-expression-promises.test.ts b/tests/alliance-expression-promises.test.ts new file mode 100644 index 00000000..0aa8841d --- /dev/null +++ b/tests/alliance-expression-promises.test.ts @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + ALLIANCE_EXPRESSION_MAX_DEPTH, ALLIANCE_EXPRESSION_MAX_LEAVES, + AllianceExpressionPromiseError, allianceExpressionOutcomes, allianceExpressionPromisesMatch, + allianceExpressionText, evaluateAllianceExpression, findAllianceExpressionPlans, + liveAllianceExpressionPromises, parseAllianceExpression, parseAllianceExpressionClaim, + validateAllianceExpressionPromises, type AllianceAction, type AllianceExpression, + type AllianceExpressionContext, type AllianceExpressionPromise, +} from '../game/alliance-expression-promises'; +import { findAlliancePromisePlan, type AllianceOpportunity, type AlliancePromise } from '../game/alliance-promises'; +import { quoteNexusAlliance } from '../game/nexus-alliance'; + +const seats = ['respondent', 'partner-a', 'partner-b', 'partner-c', 'asker']; +const opportunity = (): AllianceOpportunity => ({ turn: 2, source: 'original-worm-nexus' }); +const leaf = (partner: string): AllianceExpression => ({ kind: 'partner', partner }); +const group = (kind: 'and' | 'or', ...terms: AllianceExpression[]): AllianceExpression => ({ kind, terms }); +const either = () => group('or', leaf('partner-a'), leaf('partner-b')); +function promise(event: string, claim = either(), extra: Partial = {}): AllianceExpressionPromise { + return { event, turn: 2, source: 'original-worm-nexus', player: 'respondent', asker: 'asker', claim, answer: true, ...extra }; +} +function scalar(event: string, partner: string, answer: boolean): AlliancePromise { + return { event, turn: 2, source: 'original-worm-nexus', player: 'respondent', asker: 'asker', partner, answer }; +} +function context(): AllianceExpressionContext { + return { players: seats.map(id => ({ id, ally: null })), offers: {} }; +} +function action(state: AllianceExpressionContext, actor: string, target: string | null): AllianceExpressionContext { + const quoted = quoteNexusAlliance({ ...state, actor, target }); + return { players: state.players.map(seat => ({ ...seat, ally: quoted.allies[seat.id] })), offers: quoted.offers }; +} +function invitations(): AllianceExpressionContext { + return action(action(context(), 'partner-a', 'respondent'), 'partner-b', 'respondent'); +} +function execute(state: AllianceExpressionContext, plan: AllianceAction[]): AllianceExpressionContext { + return plan.reduce((current, step) => action(current, 'respondent', step.target), state); +} +const finalAlly = (state: AllianceExpressionContext) => state.players.find(seat => seat.id === 'respondent')!.ally; +function freeze(value: unknown): void { + if (!value || typeof value !== 'object') return; + for (const child of Object.values(value as Record)) freeze(child); + Object.freeze(value); +} + +void test('alliance expression claims retain full nesting and exact public partner text without private metadata', () => { + const claim = { turn: 2, claim: group('and', either(), leaf('partner-a')) }; + const parsed = parseAllianceExpressionClaim(claim); + assert.deepEqual(parsed, claim); assert.notEqual(parsed.claim, claim.claim); + assert.equal(allianceExpressionText(parsed.claim, id => id === 'partner-a' ? 'Atreides' : 'Emperor'), + '((be allied with Atreides when this Nexus ends OR be allied with Emperor when this Nexus ends) AND be allied with Atreides when this Nexus ends)'); + for (const turn of [0, -1, 1.5, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1, '2', null]) + assert.throws(() => parseAllianceExpressionClaim({ ...claim, turn }), AllianceExpressionPromiseError); + for (const extra of [{ source: 'private-source' }, { event: 'private-event' }, { answer: true }]) + assert.throws(() => parseAllianceExpressionClaim({ ...claim, ...extra }), AllianceExpressionPromiseError); +}); + +void test('full alliance parsing enforces all branches, two-child groups, sixteen leaves and four grouping levels', () => { + assert.equal(ALLIANCE_EXPRESSION_MAX_LEAVES, 16); assert.equal(ALLIANCE_EXPRESSION_MAX_DEPTH, 4); + const nested = (depth: number): AllianceExpression => depth === 0 ? leaf('partner-a') : + group('and', nested(depth - 1), leaf('partner-b')); + assert.deepEqual(parseAllianceExpression(nested(4)), nested(4)); + assert.deepEqual(parseAllianceExpression(group('or', ...Array.from({ length: 16 }, () => leaf('partner-a')))), + group('or', ...Array.from({ length: 16 }, () => leaf('partner-a')))); + const invalid: unknown[] = [null, [], {}, { kind: 'partner', partner: '' }, { kind: 'partner', partner: ' partner-a' }, + { kind: 'partner', partner: 'partner-a ' }, { kind: 'partner', partner: null }, + { kind: 'partner', partner: 'partner-a', source: 'private' }, { kind: 'and', terms: [] }, + { kind: 'or', terms: [leaf('partner-a')] }, { kind: 'not', terms: [leaf('partner-a'), leaf('partner-b')] }, + { kind: 'or', terms: [leaf('partner-a'), { kind: 'partner', partner: 2 }] }, + group('or', ...Array.from({ length: 17 }, () => leaf('partner-a'))), nested(5), + group('and', ...Array.from({ length: 9 }, () => either()))]; + for (const value of invalid) assert.throws(() => parseAllianceExpression(value), AllianceExpressionPromiseError); + const hiddenInvalid = { kind: 'or', terms: [leaf('partner-a'), { kind: 'partner', partner: '' }] } as AllianceExpression; + assert.throws(() => evaluateAllianceExpression(hiddenInvalid, 'partner-a'), AllianceExpressionPromiseError); +}); + +void test('positive final truth uses one native partner and a whole No negates the whole nested question', () => { + const both = group('and', leaf('partner-a'), leaf('partner-b')); + for (const ally of [null, 'partner-a', 'partner-b', 'partner-c']) { + assert.equal(evaluateAllianceExpression(both, ally), false); + assert.equal(allianceExpressionPromisesMatch([promise('no-both', both, { answer: false })], 'respondent', opportunity(), ally), true); + assert.equal(evaluateAllianceExpression(either(), ally), ally === 'partner-a' || ally === 'partner-b'); + } + const nested = group('and', either(), group('or', leaf('partner-b'), leaf('partner-c'))); + assert.equal(evaluateAllianceExpression(nested, 'partner-a'), false); + assert.equal(evaluateAllianceExpression(nested, 'partner-b'), true); + assert.equal(evaluateAllianceExpression(group('and', leaf('partner-a'), leaf('partner-a')), 'partner-a'), true); +}); + +void test('truth-compatible outcomes keep current membership first and intersect scalar and whole answers', () => { + const state = action(invitations(), 'respondent', 'partner-b'); + const records = [promise('either')]; + assert.deepEqual(allianceExpressionOutcomes([], records, 'respondent', opportunity(), state), ['partner-b', 'partner-a']); + assert.deepEqual(allianceExpressionOutcomes([scalar('no-b', 'partner-b', false)], records, 'respondent', opportunity(), state), ['partner-a']); + assert.deepEqual(allianceExpressionOutcomes([], [promise('whole-no', either(), { answer: false })], + 'respondent', opportunity(), context()), [null, 'partner-c', 'asker']); + assert.deepEqual(allianceExpressionOutcomes([], records, 'absent', opportunity(), state), []); +}); + +void test('two actual incoming invitations produce both canonical owned OR routes and leave manual alternatives available', () => { + const state = invitations(); const records = [promise('either')]; + const plans = findAllianceExpressionPlans([], records, 'respondent', opportunity(), state); + assert.deepEqual(plans, [[{ type: 'alliance', target: 'partner-a' }], [{ type: 'alliance', target: 'partner-b' }]]); + for (const plan of plans) { + const completed = execute(state, plan); + assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(completed)), true); + const selected = finalAlly(completed)!; + assert.equal(completed.players.find(seat => seat.id === selected)!.ally, 'respondent'); + } + const manual = action(state, 'respondent', 'partner-b'); + assert.equal(finalAlly(manual), 'partner-b'); + assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(manual)), true); + assert.equal(records[0].fulfilled, undefined, 'No branch is stored or fulfilled before actual Nexus close'); +}); + +void test('missing, own, withdrawn or occupied-partner offers cannot invent foreign consent', () => { + const records = [promise('either')]; + assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), context()), []); + const ownOffer = action(context(), 'respondent', 'partner-a'); + assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), ownOffer), []); + const oneWithdrawn = action(invitations(), 'partner-a', null); + assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), oneWithdrawn), + [[{ type: 'alliance', target: 'partner-b' }]]); + const bothWithdrawn = action(oneWithdrawn, 'partner-b', null); + assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), bothWithdrawn), []); + const occupied = action(action(action(context(), 'partner-a', 'respondent'), 'partner-c', 'partner-a'), 'partner-a', 'partner-c'); + assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), occupied), []); +}); + +void test('whole OR No can require exactly None while allowing an interim pair until the owned break', () => { + const anyPartner = group('or', ...seats.filter(id => id !== 'respondent').map(leaf)); + const records = [promise('no-one', anyPartner, { answer: false })]; + const paired = action(invitations(), 'respondent', 'partner-a'); + assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(paired)), false); + assert.deepEqual(allianceExpressionOutcomes([], records, 'respondent', opportunity(), paired), [null]); + const plans = findAllianceExpressionPlans([], records, 'respondent', opportunity(), paired); + assert.deepEqual(plans, [[{ type: 'alliance', target: null }]]); + assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(execute(paired, plans[0]))), true); + assert.equal(records[0].fulfilled, undefined); assert.equal(records[0].released, undefined); +}); + +void test('whole AND No retains a matching held pair and all other currently owned final routes', () => { + const state = action(invitations(), 'respondent', 'partner-a'); + const records = [promise('not-both', group('and', leaf('partner-a'), leaf('partner-b')), { answer: false })]; + const plans = findAllianceExpressionPlans([], records, 'respondent', opportunity(), state); + assert.deepEqual(plans, [[], [{ type: 'alliance', target: null }], + [{ type: 'alliance', target: null }, { type: 'alliance', target: 'partner-b' }]]); + assert.deepEqual(allianceExpressionOutcomes([], records, 'respondent', opportunity(), state), + ['partner-a', null, 'partner-b', 'partner-c', 'asker']); + for (const plan of plans) assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(execute(state, plan))), true); +}); + +void test('joint scalar and whole-expression contradictions have no outcome or invented branch', () => { + const state = invitations(); + const cases = [ + { scalars: [scalar('yes-a', 'partner-a', true)], expressions: [promise('no-either', either(), { answer: false })] }, + { scalars: [scalar('no-a', 'partner-a', false)], expressions: [promise('yes-a-twice', group('and', leaf('partner-a'), leaf('partner-a')))] }, + { scalars: [], expressions: [promise('yes-both', group('and', leaf('partner-a'), leaf('partner-b')))] }, + { scalars: [], expressions: [promise('yes-either'), promise('no-either', either(), { answer: false })] }, + ]; + for (const selected of cases) { + assert.deepEqual(allianceExpressionOutcomes(selected.scalars, selected.expressions, 'respondent', opportunity(), state), []); + assert.deepEqual(findAllianceExpressionPlans(selected.scalars, selected.expressions, 'respondent', opportunity(), state), []); + } +}); + +void test('no live expression preserves the original scalar planner exactly, including no-op and absent-owner behavior', () => { + const old = [promise('old', either(), { source: 'older-nexus' }), promise('released', either(), { released: true })]; + for (const state of [context(), invitations(), action(invitations(), 'respondent', 'partner-a')]) + for (const records of [[], [scalar('yes-a', 'partner-a', true)], [scalar('no-a', 'partner-a', false)]]) { + const expected = findAlliancePromisePlan(records, 'respondent', opportunity(), state); + assert.deepEqual(findAllianceExpressionPlans(records, old, 'respondent', opportunity(), state), expected === null ? [] : [expected]); + } + assert.deepEqual(findAllianceExpressionPlans([], [], 'absent', opportunity(), context()), []); +}); + +void test('original turn, Nexus source, owner and terminal state isolate the live whole-expression records', () => { + const live = promise('live'); + const records = [live, promise('old-turn', leaf('partner-c'), { turn: 1 }), + promise('other-source', leaf('partner-c'), { source: 'other-worm-same-turn' }), + promise('other-owner', leaf('partner-c'), { player: 'partner-a' }), + promise('fulfilled', leaf('partner-c'), { fulfilled: true }), promise('released', leaf('partner-c'), { released: true })]; + assert.deepEqual(liveAllianceExpressionPromises(records, 'respondent', opportunity()), [live]); + assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), 'partner-a'), true); + assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), 'partner-c'), false); + assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), invitations()), + [[{ type: 'alliance', target: 'partner-a' }], [{ type: 'alliance', target: 'partner-b' }]]); +}); + +void test('expression records validate source, unique event, every personal partner and terminal flags without mutating inputs', () => { + const records = [promise('live'), promise('done', leaf('partner-a'), { fulfilled: true }), + promise('released-no', leaf('asker'), { answer: false, released: true })]; + const state = invitations(), scalars = [scalar('no-c', 'partner-c', false)]; + const before = structuredClone({ records, state, scalars }); + freeze(records); freeze(state); freeze(scalars); + assert.doesNotThrow(() => validateAllianceExpressionPromises(records, seats)); + assert.doesNotThrow(() => validateAllianceExpressionPromises(JSON.parse(JSON.stringify(records)) as AllianceExpressionPromise[], seats)); + parseAllianceExpressionClaim({ turn: 2, claim: records[0].claim }); + allianceExpressionText(records[0].claim); evaluateAllianceExpression(records[0].claim, 'partner-a'); + allianceExpressionOutcomes(scalars, records, 'respondent', opportunity(), state); + findAllianceExpressionPlans(scalars, records, 'respondent', opportunity(), state); + assert.deepEqual({ records, state, scalars }, before); + const base = promise('event'); + const invalid: unknown[] = [null, {}, [null], [base, { ...base }], + ...[{ event: '' }, { event: ' event' }, { source: '' }, { source: ' source' }, + { player: 'missing' }, { asker: 'missing' }, { asker: 'respondent' }, { answer: 'yes' }, + { turn: 0 }, { fulfilled: 1 }, { released: 'true' }, { fulfilled: true, released: true }, + { claim: group('or', leaf('partner-a'), leaf('missing')) }, + { claim: group('or', leaf('partner-a'), leaf('respondent')) }].map(extra => [{ ...base, ...extra }])]; + for (const value of invalid) + assert.throws(() => validateAllianceExpressionPromises(value as AllianceExpressionPromise[], seats), AllianceExpressionPromiseError); + assert.deepEqual({ records, state, scalars }, before); +}); diff --git a/tests/fixture-truth-alliance-expression.ts b/tests/fixture-truth-alliance-expression.ts new file mode 100644 index 00000000..c7d75b5f --- /dev/null +++ b/tests/fixture-truth-alliance-expression.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import type { AllianceExpression } from '../game/alliance-expression-promises'; +import type { AllianceActionClaim } from '../game/mixed-alliance-question'; +import type { MixedActionExpression } from '../game/mixed-action-question'; +import type { TruthFact } from '../game/truthtrance'; +import { + actTruthAlliance, advanceTruthAlliance, answerTruthAlliance, closeTruthAllianceNexus, + createTruthAllianceFixture, declareTruthAlliance, finishTruthAlliance, nextTruthAlliance, + settleTruthAlliance, truthAllianceClean, truthAllianceInventory, truthAllianceNexus, + truthAlliancePlayer, type TruthAllianceStep, +} from './fixture-truth-alliance'; + +export type TruthAllianceExpressionStep = TruthAllianceStep; +export type TruthMixedAllianceExpression = MixedActionExpression; +export const actTruthAllianceExpression = actTruthAlliance; +export const advanceTruthAllianceExpression = advanceTruthAlliance; +export const answerTruthAllianceExpression = answerTruthAlliance; +export const closeTruthAllianceExpressionNexus = closeTruthAllianceNexus; +export const finishTruthAllianceExpression = finishTruthAlliance; +export const nextTruthAllianceExpression = nextTruthAlliance; +export const settleTruthAllianceExpression = settleTruthAlliance; +export const truthAllianceExpressionClean = truthAllianceClean; +export const truthAllianceExpressionInventory = truthAllianceInventory; +export const truthAllianceExpressionNexus = truthAllianceNexus; +export const truthAllianceExpressionPlayer = truthAlliancePlayer; +export const actTruthMixedAlliance = actTruthAlliance; +export const advanceTruthMixedAlliance = advanceTruthAlliance; +export const answerTruthMixedAlliance = answerTruthAlliance; +export const closeTruthMixedAllianceNexus = closeTruthAllianceNexus; +export const finishTruthMixedAlliance = finishTruthAlliance; +export const nextTruthMixedAlliance = nextTruthAlliance; +export const settleTruthMixedAlliance = settleTruthAlliance; +export const truthMixedAllianceClean = truthAllianceClean; +export const truthMixedAllianceInventory = truthAllianceInventory; +export const truthMixedAllianceNexus = truthAllianceNexus; +export const truthMixedAlliancePlayer = truthAlliancePlayer; + +export const truthAllianceExpressionPartner = (partner: string): AllianceExpression => ({ kind: 'partner', partner }); +export const truthAllianceExpressionGroup = (kind: 'and' | 'or', ...terms: AllianceExpression[]): AllianceExpression => + ({ kind, terms }); +export const truthMixedAllianceFact = (fact: TruthFact): TruthMixedAllianceExpression => ({ kind: 'fact', fact }); +export const truthMixedAlliancePartner = (partner: string): TruthMixedAllianceExpression => + ({ kind: 'action', action: { partner } }); +export const truthMixedAllianceGroup = (kind: 'and' | 'or', ...terms: TruthMixedAllianceExpression[]): TruthMixedAllianceExpression => + ({ kind, terms }); + +type SourceOptions = Parameters[0] & { secondOffer?: boolean }; + +/** Thin original H/A/E base33 adapter. The optional second invitation is an + * actual Harkonnen action after the original Worm and native Atreides offer. + * No played hand, purse, offer, alliance, promise or answer-time fact is set. */ +export function createTruthAllianceExpressionFixture(options: SourceOptions & { claim?: AllianceExpression } = {}) { + const { claim: suppliedClaim, secondOffer = false, ...sourceOptions } = options; + const base = createTruthAllianceFixture(sourceOptions); + const actions = structuredClone(base.sourceActions); + let game = base.game; + let secondInvitation: { before: Game; steps: TruthAllianceStep[]; after: Game } | null = null; + if (secondOffer) { + const before = structuredClone(game), from = actions.length; + game = actTruthAlliance(game, base.asker, { type: 'alliance', target: base.target }, actions); + assert.equal(game.allianceOffers[base.asker], base.target); + secondInvitation = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; + } + const claim = structuredClone(suppliedClaim ?? truthAllianceExpressionGroup('or', + truthAllianceExpressionPartner(base.asker), truthAllianceExpressionPartner(base.opponent))); + const question = { kind: 'allianceExpression' as const, target: base.target, turn: game.turn, claim }; + assert.deepEqual(truthAllianceInventory(game), base.originalCards); + return { ...base, game, sourceGame: structuredClone(game), allianceSource: base, claim, question, + questionAction: { type: 'truthAsk', question } satisfies Action, + secondOffer, secondInvitation, actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length, + sourceCounts: { inherited: base.sourceActionCount, additionalInvitations: Number(secondOffer) }, + sourceDisclosure: `${base.sourceDisclosure} This adapter changes only the raw final-partner expression and, if requested, adds one real Harkonnen invitation. It installs no aggregate or compiled branch.` }; +} +export type TruthAllianceExpressionFixture = ReturnType; + +export function declareTruthAllianceExpression(f: TruthAllianceExpressionFixture, source = f.game, + options: { copies?: 1 | 2 } = {}) { + return declareTruthAlliance(f.allianceSource, source, options); +} +export function askTruthAllianceExpression(game: Game, asker: string, target: string, claim: AllianceExpression, + actions: TruthAllianceStep[] = []): Game { + return actTruthAlliance(game, asker, { type: 'truthAsk', question: { + kind: 'allianceExpression', target, turn: game.turn, claim: structuredClone(claim), + } }, actions); +} +export function bindTruthAllianceExpression(f: TruthAllianceExpressionFixture, answer: 'yes' | 'no' = 'yes', + claim = f.claim, source = f.game) { + const declared = declareTruthAllianceExpression(f, source), actions = [...declared.actions]; + const asked = askTruthAllianceExpression(declared.game, f.asker, f.target, claim, actions); + const game = answerTruthAlliance(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length }; +} +export function truthAllianceExpressionPromise(game: Game, + target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) { + const row = viewGame(game, target).allianceExpressionPromises.find(row => row.player === target); + assert.ok(row, 'The respondent retains the original whole final-partner question and aggregate'); + return row; +} + +export function createTruthMixedAllianceFixture(options: SourceOptions & { mixed?: TruthMixedAllianceExpression } = {}) { + const { mixed: suppliedMixed, ...sourceOptions } = options; + const base = createTruthAllianceExpressionFixture(sourceOptions); + const shield = base.setup.deck.find(card => card.kind === 'shield'); + const hajr = base.setup.deck.find(card => card.effect === 'hajr'); + assert.ok(shield && hajr, 'Original unopened base33 supplies printed current-fact identities'); + const shieldFact: TruthFact = { kind: 'hand', name: shield.name }; + const hajrFact: TruthFact = { kind: 'hand', name: hajr.name }; + const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 10 }; + const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 }; + const mixed = structuredClone(suppliedMixed ?? truthMixedAllianceGroup('and', truthMixedAllianceFact(spiceFact), + truthMixedAllianceGroup('or', truthMixedAlliancePartner(base.asker), truthMixedAlliancePartner(base.opponent)))); + const question = { kind: 'mixedAlliance' as const, target: base.target, turn: base.game.turn, mixed }; + return { ...base, expressionSource: base, mixed, question, shieldFact, hajrFact, spiceFact, forecastFact, + questionAction: { type: 'truthAsk', question } satisfies Action, + sourceDisclosure: `${base.sourceDisclosure} Current facts and final actual partnership share one raw AND/OR question. Facts and residual clauses are never staged.` }; +} +export type TruthMixedAllianceFixture = ReturnType; + +export function declareTruthMixedAlliance(f: TruthMixedAllianceFixture, source = f.game, + options: { copies?: 1 | 2 } = {}) { + return declareTruthAllianceExpression(f.expressionSource, source, options); +} +export function askTruthMixedAlliance(game: Game, asker: string, target: string, mixed: TruthMixedAllianceExpression, + actions: TruthAllianceStep[] = []): Game { + return actTruthAlliance(game, asker, { type: 'truthAsk', question: { + kind: 'mixedAlliance', target, turn: game.turn, mixed: structuredClone(mixed), + } }, actions); +} +export function bindTruthMixedAlliance(f: TruthMixedAllianceFixture, answer: 'yes' | 'no' = 'yes', + mixed = f.mixed, source = f.game) { + const declared = declareTruthMixedAlliance(f, source), actions = [...declared.actions]; + const asked = askTruthMixedAlliance(declared.game, f.asker, f.target, mixed, actions); + const game = answerTruthAlliance(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length }; +} +export function truthMixedAlliancePromise(game: Game, + target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) { + const row = viewGame(game, target).mixedAlliancePromises.find(row => row.player === target); + assert.ok(row, 'The respondent retains the original mixed question and whole-answer aggregate'); + return row; +} diff --git a/tests/mixed-alliance-question.test.ts b/tests/mixed-alliance-question.test.ts new file mode 100644 index 00000000..f53dfbb0 --- /dev/null +++ b/tests/mixed-alliance-question.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { AlliancePromiseError } from '../game/alliance-promises'; +import { parseAllianceActionClaim, type AllianceActionClaim, type MixedAllianceClaim } from '../game/mixed-alliance-question'; +import { compileMixedActionExpression, parseMixedActionExpression } from '../game/mixed-action-question'; +import type { TruthFact } from '../game/truthtrance'; + +void test('mixed alliance action leaves retain one exact partner without adding turn or private Nexus bindings', () => { + const input = Object.freeze({ partner: 'exact-partner-id' }); + const parsed = parseAllianceActionClaim(input); + assert.deepEqual(parsed, input); assert.notEqual(parsed, input); + assert.deepEqual(parseAllianceActionClaim({ partner: 'asker-can-be-the-partner' }), { partner: 'asker-can-be-the-partner' }); +}); + +void test('mixed alliance action leaves reject metadata, frozen values, groups and missing or inexact identities', () => { + const invalid: unknown[] = [null, undefined, [], 'partner-a', {}, { partner: '' }, { partner: ' ' }, + { partner: ' partner-a' }, { partner: 'partner-a ' }, { partner: 2 }, { partner: null }, + { partner: 'partner-a', turn: 1 }, { partner: 'partner-a', source: 'private' }, + { partner: 'partner-a', event: 'private' }, { partner: 'partner-a', answer: true }, + { partner: 'partner-a', fulfilled: false }, { kind: 'partner', partner: 'partner-a' }, + { kind: 'or', terms: [{ partner: 'partner-a' }, { partner: 'partner-b' }] }]; + for (const value of invalid) assert.throws(() => parseAllianceActionClaim(value), AlliancePromiseError); +}); + +void test('shared mixed parsing rejects a malformed late alliance leaf before a decisive fact can hide it', () => { + const fact: TruthFact = { kind: 'spice', compare: 'gte', value: 10 }; + const parseFact = (): TruthFact => fact; + const input = { kind: 'or', terms: [{ kind: 'fact', fact }, + { kind: 'action', action: { partner: 'partner-a' } }, { kind: 'action', action: { partner: 'partner-b', turn: 1 } }] }; + assert.throws(() => parseMixedActionExpression(input, parseFact, parseAllianceActionClaim), AlliancePromiseError); + const valid = { kind: 'and', terms: [{ kind: 'fact', fact }, { kind: 'or', terms: [ + { kind: 'action', action: { partner: 'partner-a' } }, { kind: 'action', action: { partner: 'partner-b' } }] }] }; + const parsed = parseMixedActionExpression(valid, parseFact, parseAllianceActionClaim); + assert.deepEqual(parsed, valid); + assert.deepEqual(compileMixedActionExpression(parsed, () => 'yes', true), valid.terms[1]); +}); + +void test('frozen current facts reduce only the residual final-partner expression and preserve raw mixed text', () => { + const question: MixedAllianceClaim = { turn: 2, mixed: { kind: 'or', terms: [ + { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 10 } }, + { kind: 'and', terms: [{ kind: 'action', action: { partner: 'partner-a' } }, + { kind: 'action', action: { partner: 'partner-b' } }] }, + ] } }; + const before = structuredClone(question); + assert.deepEqual(compileMixedActionExpression(question.mixed, () => 'yes', true), + { kind: 'constant', value: true }); + assert.deepEqual(compileMixedActionExpression(question.mixed, () => 'no', false), + { kind: 'and', terms: [{ kind: 'action', action: { partner: 'partner-a' } }, + { kind: 'action', action: { partner: 'partner-b' } }] }); + assert.deepEqual(question, before); +}); diff --git a/tests/truth-alliance-expression-controls.test.tsx b/tests/truth-alliance-expression-controls.test.tsx new file mode 100644 index 00000000..9b989f62 --- /dev/null +++ b/tests/truth-alliance-expression-controls.test.tsx @@ -0,0 +1,229 @@ +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 { AllianceExpressionFields, allianceExpressionInputError } from '../components/alliance-expression-fields'; +import { AllianceExpressionPromises } from '../components/alliance-expression-promises'; +import { AlliancePromises, AlliancePromiseGuideButton } from '../components/alliance-promises'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import { parseAllianceExpression, type AllianceExpression } from '../game/alliance-expression-promises'; +import { createTechTokens } from '../game/tech-tokens'; + +type ElementProps = { children?: ReactNode; [key: string]: unknown }; +type Element = ReactElement; +const partners = [{ id: 'r', name: 'Partner' }, { id: 'a', name: 'Asker' }]; +const partner = (id = 'r'): AllianceExpression => ({ kind: 'partner', partner: id }); +function elements(node: ReactNode): Element[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement(node)) return []; + return [node, ...elements(node.props.children)]; +} +function find(tree: ReactNode, key: string, value: string): Element { + const found = elements(tree).find(element => element.props[key] === value); assert.ok(found, `${key}=${value}`); return found; +} +const control = (tree: ReactNode, key: string) => find(tree, 'data-alliance-expression-control', key); +const click = (button: Element) => (button.props.onClick as () => void)(); +function select(tree: ReactNode, path: string, field: string, value: string) { + const input = find(tree, 'id', `truth-alliance-expression-${path}-${field}`); + (input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } }); +} +function editor(initial: AllianceExpression, disabled = false, choices = partners) { + let value = initial, changes = 0; + return { + get value() { return value; }, get changes() { return changes; }, + tree: () => AllianceExpressionFields({ value, partners: choices, disabled, onChange(next) { value = next; changes++; } }), + html: () => renderToStaticMarkup(createElement(AllianceExpressionFields, { value, partners: choices, disabled, onChange() {} })), + }; +} +function nested(depth: number): AllianceExpression { + let value = partner(); + for (let i = 0; i < depth; i++) value = { kind: 'and', terms: [value, partner('a')] }; + return value; +} +function guide(props: Parameters[0]) { + const button = AlliancePromiseGuideButton(props); assert.ok(button); return button; +} +const press = (button: ReturnType) => button.props.onClick(); +const markup = (game: GameView) => renderToStaticMarkup(createElement(AllianceExpressionPromises, { game, busy: false, send() {} })); + +/** Controlled own projections prove UI behavior. Native consent, answer + * feasibility and the actual Nexus closing event have separate engine proof. */ +function projected(kind: 'accept' | 'break' | 'ready' = 'accept', advanced = false): GameView { + const source = createGame('TRUTHALLIANCEEXPRESSIONCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced); + joinGame(source, newPlayer('a', 'Asker', 'harkonnen')); + joinGame(source, newPlayer('r', 'Partner', 'atreides')); + const game = viewGame(source, 'p'); + game.status = 'playing'; game.turn = 2; game.phase = 1; game.nexus = true; game.spiceWindow = null; + game.active = 'a'; game.ready = []; game.alliancePromiseNexus = { turn: 2 }; + game.alliancePromises = []; game.mixedAlliancePromises = []; + game.allianceExpressionPromises = [{ event: 'controlled-pure-alliance', player: 'p', asker: 'a', turn: 2, + claim: kind === 'break' ? partner() : { kind: 'or', terms: [partner(), partner('a')] }, answer: kind !== 'break' }]; + game.allianceOffers = kind === 'accept' ? { r: 'p' } : {}; + if (kind !== 'accept') { + game.players.find(player => player.id === 'p')!.ally = 'r'; + game.players.find(player => player.id === 'r')!.ally = 'p'; + } + game.alliancePromiseGuide = { action: { type: kind === 'ready' ? 'ready' : 'alliance', + ...(kind === 'ready' ? {} : { target: kind === 'accept' ? 'r' : null }), alliancePromiseEvent: 'controlled-pure-alliance' } }; + return game; +} + +void test('all sixteen final partner clauses remain controlled with complete AND/OR branches and singleton collapse', () => { + const e = editor(partner()); click(control(e.tree(), 'or-1')); + assert.deepEqual(e.value, { kind: 'or', terms: [partner(), partner('')] }); + assert.ok(allianceExpressionInputError(e.value, partners)); select(e.tree(), '1.2', 'partner', 'a'); + for (let count = 3; count <= 16; count++) { + click(control(e.tree(), 'add-1')); select(e.tree(), `1.${count}`, 'partner', count % 2 ? 'r' : 'a'); + } + assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16); + assert.deepEqual(parseAllianceExpression(e.value), e.value); assert.match(e.html(), /16 \/ 16 conditions/); + assert.equal((e.html().match(/Named alliance partner/g) ?? []).length, 16); + const ids = [...e.html().matchAll(/ id="([^"]+)"/g)].map(match => match[1]); assert.equal(new Set(ids).size, ids.length); + const before = structuredClone(e.value), changes = e.changes; + for (const key of ['add-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) { + assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key)); + } + assert.equal(e.changes, changes); assert.deepEqual(e.value, before); + const grouped = editor({ kind: 'and', terms: [partner(), partner('a')] }); + click(control(grouped.tree(), 'or-1.2')); select(grouped.tree(), '1.2', 'meaning', 'and'); + click(control(grouped.tree(), 'nested-or-1')); click(control(grouped.tree(), 'nested-and-1')); + assert.ok(grouped.value.kind === 'and'); assert.equal(grouped.value.terms.length, 4); + click(control(grouped.tree(), 'remove-1.2.2')); assert.deepEqual(grouped.value.terms[1], partner('a')); + click(control(grouped.tree(), 'remove-1.4')); click(control(grouped.tree(), 'remove-1.3')); + click(control(grouped.tree(), 'remove-1.1')); assert.deepEqual(grouped.value, partner('a')); +}); + +void test('four grouping levels and strict envelopes coexist with repairable unnamed or removed partner drafts', () => { + const deep = editor(nested(4)); assert.equal(allianceExpressionInputError(deep.value, partners), null); + assert.match(deep.html(), /4 \/ 4 levels of grouping/); + for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-and-1.1.1.1']) { + assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key)); + } + assert.equal(deep.changes, 0); select(deep.tree(), '1.1.1.1.1', 'partner', 'a'); assert.equal(deep.changes, 1); + const draft = editor(partner('removed')); assert.match(draft.html(), /role="alert"/); + click(control(draft.tree(), 'or-1')); assert.ok(draft.value.kind === 'or'); assert.deepEqual(draft.value.terms[0], partner('removed')); + select(draft.tree(), '1.1', 'partner', 'a'); select(draft.tree(), '1.2', 'partner', 'r'); + assert.equal(allianceExpressionInputError(draft.value, partners), null); + for (const invalid of [nested(5), { kind: 'and', terms: [partner()] }, { kind: 'or', terms: Array.from({ length: 17 }, () => partner()) }, + { kind: 'partner', partner: '' }, { kind: 'partner', partner: ' r ' }, { kind: 'partner', partner: 'r', source: 'hidden' }, + { kind: 'or', terms: [partner(), partner('a')], binding: true }, { kind: 'alliance', partner: 'r' }]) + assert.ok(allianceExpressionInputError(invalid, partners)); + assert.ok(allianceExpressionInputError(partner(), [])); +}); + +void test('disabled callbacks and unlisted partner selections preserve the complete controlled expression', () => { + const frozen = editor({ kind: 'and', terms: [partner(), partner('a')] }, true), before = structuredClone(frozen.value); + for (const key of ['add-1', 'nested-and-1', 'or-1', 'remove-1.2']) click(control(frozen.tree(), key)); + select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'partner', 'r'); + assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before); + const e = editor(partner()); + for (const id of ['unknown', '', ' r ', 'p']) select(e.tree(), '1', 'partner', id); + assert.equal(e.changes, 0); select(e.tree(), '1', 'partner', 'a'); assert.deepEqual(e.value, partner('a')); + const empty = editor(partner(), false, []); select(empty.tree(), '1', 'partner', 'r'); assert.equal(empty.changes, 0); +}); + +void test('own panel retains raw alternatives, whole No and passive settled history without exposing private sources', () => { + const game = projected(); game.alliancePromiseGuide = null; + game.allianceExpressionPromises.push( + { ...game.allianceExpressionPromises[0], event: 'no-event', answer: false, claim: { kind: 'and', terms: [partner(), partner('a')] } }, + { ...game.allianceExpressionPromises[0], event: 'done-event', fulfilled: true }, + { ...game.allianceExpressionPromises[0], event: 'released-event', released: true }, + { ...game.allianceExpressionPromises[0], event: 'foreign-event', player: 'a', claim: partner('foreign-partner') }, + ); + for (const promise of game.allianceExpressionPromises) Object.defineProperty(promise, 'source', { + get() { throw Error('No private Nexus source'); }, configurable: true, + }); + const html = markup(game); + assert.match(html, /You answered Yes/); assert.match(html, /You answered No/); assert.match(html, / OR /); assert.match(html, / AND /); + assert.match(html, /No negates the whole expression, not each condition separately/); + assert.match(html, /Legal alternatives remain available; no fixed branch is selected for you/); + assert.match(html, /Yes and No settle only at the actual Nexus close/); + assert.match(html, /Forming an alliance does not fulfill the answer early/); assert.match(html, /Offers alone are not alliances/); + assert.match(html, /Fulfilled at the Nexus close/); assert.match(html, /Released: this commitment is no longer possible/); + assert.match(html, /href="\/rules#truthtrance-alliance-expression"/); + assert.doesNotMatch(html, /foreign-partner|controlled-pure-alliance|foreign-event|data-testid="alliance-promise-guide"/); + assert.equal(AllianceExpressionPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null); +}); + +void test('exact canonical guides accept either OR alternative, break as preparation and require visible scalar and pure outcomes', () => { + for (const advanced of [false, true]) for (const kind of ['accept', 'break', 'ready'] as const) { + const game = projected(kind, advanced), sent: Action[] = []; + const button = guide({ game, busy: false, send(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.alliancePromiseGuide!.action); + assert.notEqual(game.active, game.me); + } + const alternative = projected(); alternative.allianceOffers = { a: 'p' }; alternative.alliancePromiseGuide!.action.target = 'a'; + const sent: Action[] = []; press(guide({ game: alternative, busy: false, send(action) { sent.push(action); } })); + assert.equal(sent[0], alternative.alliancePromiseGuide!.action); + for (const game of [projected(), projected('ready')]) { + game.allianceExpressionPromises[0].claim = partner('a'); + assert.equal(guide({ game, busy: false, send() { assert.fail('Visible pure outcome must match'); } }).props.disabled, true); + } + const scalar = projected(); scalar.alliancePromises.push({ event: 'earlier-scalar', player: 'p', asker: 'a', turn: 2, + partner: 'r', answer: false }); + assert.equal(guide({ game: scalar, busy: false, send() {} }).props.disabled, true); + const preparation = projected('ready'); preparation.alliancePromiseGuide!.action = { type: 'alliance', target: null, + alliancePromiseEvent: 'controlled-pure-alliance' }; + assert.equal(guide({ game: preparation, busy: false, send() {} }).props.disabled, false); + const noReady = projected('ready'); noReady.allianceExpressionPromises[0].answer = false; + assert.equal(guide({ game: noReady, busy: false, send() {} }).props.disabled, true); +}); + +void test('stale pure callbacks retain exact event, action, public consent context, room and Nexus guards', () => { + const changes: ((game: GameView) => void)[] = [ + game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 2; }, game => { game.status = 'setup'; }, + game => { game.nexus = false; }, game => { game.ready = ['p']; }, game => { game.alliancePromiseNexus = null; }, + game => { game.alliancePromiseNexus!.turn = 1; }, game => { game.allianceOffers = {}; }, + game => { game.allianceOffers.a = 'p'; }, game => { game.players.find(player => player.id === 'a')!.ally = 'r'; }, + game => { game.alliancePromiseGuide = { action: { ...game.alliancePromiseGuide!.action } }; }, + game => { game.alliancePromiseGuide = null; }, game => { game.alliancePromiseGuide!.action.target = null; }, + game => { game.allianceExpressionPromises[0].event = 'expired'; }, game => { game.allianceExpressionPromises[0].player = 'a'; }, + game => { game.allianceExpressionPromises[0].turn = 1; }, game => { game.allianceExpressionPromises[0].fulfilled = true; }, + game => { game.allianceExpressionPromises[0].released = true; }, game => { game.allianceExpressionPromises = []; }, + game => { game.players = game.players.filter(player => player.id !== 'r'); }, + game => { game.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; }, + game => { game.phaseOpening = { passed: [] }; }, + game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; }, + game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, + game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; }, + game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; }, + game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; }, + game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; }, + game => { game.techTokens = createTechTokens(); }, + ]; + for (const change of changes) { + const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []); + } + for (const target of ['p', 'missing']) { + const game = projected(); game.alliancePromiseGuide!.action.target = target; + assert.equal(guide({ game, busy: false, send() {} }).props.disabled, true); + } + for (const event of ['', 'unrelated']) { + const game = projected(); game.alliancePromiseGuide!.action.alliancePromiseEvent = event; + const button = guide({ game, busy: false, send() { assert.fail('Unknown event'); } }); assert.equal(button.props.disabled, true); press(button); + } + const busy = { game: projected(), busy: false, send() { assert.fail('Busy guide'); } }; + const oldBusy = guide(busy); busy.busy = true; press(oldBusy); + const replaced = { game: projected(), busy: false, send() { assert.fail('Replaced projection'); } }; + const oldReplaced = guide(replaced); replaced.game = { ...replaced.game }; press(oldReplaced); +}); + +void test('one live scalar or pure panel owns the shared guide while private card, spice and Nexus sources are untouched', () => { + const game = projected(), sent: Action[] = []; + const both = () => renderToStaticMarkup(createElement('div', null, + createElement(AlliancePromises, { game, busy: false, send() {} }), + createElement(AllianceExpressionPromises, { game, busy: false, send() {} }))); + const one = () => assert.equal((both().match(/data-testid="alliance-promise-guide"/g) ?? []).length, 1); + game.alliancePromises.push({ event: 'scalar-history', player: 'p', asker: 'a', turn: 1, partner: 'r', answer: true, fulfilled: true }); + one(); assert.match(markup(game), /data-testid="alliance-promise-guide"/); + game.alliancePromises.push({ event: 'live-scalar', player: 'p', asker: 'a', turn: 2, partner: 'r', answer: true }); + one(); assert.doesNotMatch(markup(game), /data-testid="alliance-promise-guide"/); + game.alliancePromises[1].fulfilled = true; one(); + for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'bribes', 'reserves', 'tanks']) + Object.defineProperty(seat, key, { get() { throw Error(`No private ${key}`); }, configurable: true }); + for (const promise of game.allianceExpressionPromises) Object.defineProperty(promise, 'source', { get() { throw Error('No Nexus source'); } }); + assert.match(markup(game), /Accept Partner’s alliance invitation/); + press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.equal(sent[0], game.alliancePromiseGuide!.action); + game.allianceExpressionPromises[0].fulfilled = true; assert.doesNotMatch(both(), /data-testid="alliance-promise-guide"/); +}); diff --git a/tests/truth-alliance-expression-funding.test.ts b/tests/truth-alliance-expression-funding.test.ts new file mode 100644 index 00000000..9832b672 --- /dev/null +++ b/tests/truth-alliance-expression-funding.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { truthFactAnswer } from '../game/truthtrance'; +import { + actTruthAllianceExpression as act, answerTruthAllianceExpression as answer, + askTruthAllianceExpression as ask, askTruthMixedAlliance as askMixed, + closeTruthAllianceExpressionNexus as close, createTruthAllianceExpressionFixture as fixture, + createTruthMixedAllianceFixture as mixedFixture, declareTruthAllianceExpression as declare, + declareTruthMixedAlliance as declareMixed, truthAllianceExpressionInventory as inventory, + truthAllianceExpressionPlayer as player, truthAllianceExpressionPromise as promise, + truthMixedAlliancePromise as mixedPromise, +} from './fixture-truth-alliance-expression'; + +void test('two original Truths preserve prior scalar alliance No while a later whole OR Yes takes the other real partner', () => { + const f = fixture({ secondOffer: true }); let game = declare(f, f.game, { copies: 2 }).game; + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'alliance', target: f.target, turn: game.turn, partner: f.partner, + } }); + game = answer(game, f.target, 'no'); game = ask(game, f.asker, f.target, f.claim); + assert.deepEqual(viewGame(game, f.target).truthAllianceExpressionAnswers, ['yes', 'no']); + game = answer(game, f.target, 'yes'); + assert.equal(viewGame(game, f.target).alliancePromises.length, 1); + assert.equal(viewGame(game, f.target).allianceExpressionPromises.length, 1); + assert.notEqual(viewGame(game, f.target).alliancePromises[0].event, promise(game).event); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.asker); + game = act(game, f.target, guide.action); assert.equal(player(game, f.target).ally, f.asker); + assert.ok(!game.alliancePromises![0].fulfilled && !promise(game).fulfilled); + game = close(game); assert.equal(game.alliancePromises![0].fulfilled, true); assert.equal(promise(game).fulfilled, true); + for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two original Truths share one real final partner between earlier scalar Yes and later mixed OR Yes at actual close', () => { + const f = mixedFixture({ secondOffer: true }); let game = declareMixed(f, f.game, { copies: 2 }).game; + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'alliance', target: f.target, turn: game.turn, partner: f.partner, + } }); + game = answer(game, f.target, 'yes'); game = askMixed(game, f.asker, f.target, f.mixed); + assert.deepEqual(viewGame(game, f.target).truthMixedAllianceAnswers, ['yes']); game = answer(game, f.target, 'yes'); + assert.equal(viewGame(game, f.target).alliancePromises.length, 1); + assert.deepEqual(viewGame(game, f.target).allianceExpressionPromises, []); + assert.notEqual(viewGame(game, f.target).alliancePromises[0].event, mixedPromise(game).event); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.partner); + game = act(game, f.target, guide.action); assert.equal(player(game, f.target).ally, f.partner); + assert.ok(!game.alliancePromises![0].fulfilled); assert.equal(mixedPromise(game).fulfilled, false); + game = close(game); assert.equal(game.alliancePromises![0].fulfilled, true); assert.equal(mixedPromise(game).fulfilled, true); + for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two original Truths pay the earlier native bribe before a later mixed OR accepts its manual alternative without thawing the spice fact', () => { + const f = mixedFixture({ secondOffer: true }); let game = declareMixed(f, f.game, { copies: 2 }).game; + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'bribe', target: f.target, turn: game.turn, recipient: f.partner, minimum: 3, + } }); + assert.ok(viewGame(game, f.target).truthBribeAnswers?.includes('yes')); game = answer(game, f.target, 'yes'); + game = askMixed(game, f.asker, f.target, f.mixed); + assert.ok(viewGame(game, f.target).truthMixedAllianceAnswers?.includes('yes')); game = answer(game, f.target, 'yes'); + assert.equal(player(game, f.target).spice, 10); assert.equal(mixedPromise(game).fulfilled, false); + const payment = viewGame(game, f.target).bribePromiseGuide; assert.ok(payment); + const recipientSpice = player(game, f.partner).spice; + game = act(game, f.target, payment.action); assert.equal(player(game, f.target).spice, 7); + assert.equal(player(game, f.partner).spice, recipientSpice); assert.equal(player(game, f.partner).bribes, 3); + assert.equal(game.bribePromises![0].fulfilled, true); + assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(mixedPromise(game).fulfilled, false); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'alliance'); + const alternative = guide.action.target === f.asker ? f.partner : f.asker; + game = act(game, f.target, { type: 'alliance', target: alternative }); assert.equal(player(game, f.target).ally, alternative); + assert.equal(mixedPromise(game).fulfilled, false); game = close(game); assert.equal(mixedPromise(game).fulfilled, true); + assert.equal(game.bribePromises![0].fulfilled, true); assert.equal(player(game, f.partner).bribes, 3); + for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two original Truths preserve an earlier whole Yes against a later contradictory scalar alliance No', () => { + const f = fixture({ secondOffer: true }); let game = declare(f, f.game, { copies: 2 }).game; + game = ask(game, f.asker, f.target, { kind: 'partner', partner: f.partner }); + game = answer(game, f.target, 'yes'); + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'alliance', target: f.target, turn: game.turn, partner: f.partner, + } }); + assert.deepEqual(viewGame(game, f.target).truthAllianceAnswers, ['yes']); + const before = structuredClone(game); + assert.throws(() => answer(game, f.target, 'no')); assert.deepEqual(game, before); + game = answer(game, f.target, 'yes'); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); + assert.equal(guide.action.target, f.partner); game = act(game, f.target, guide.action); + assert.ok(!promise(game).fulfilled && !game.alliancePromises![0].fulfilled); + game = close(game); assert.equal(promise(game).fulfilled, true); assert.equal(game.alliancePromises![0].fulfilled, true); + for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1); + assert.deepEqual(inventory(game), f.originalCards); +}); diff --git a/tests/truth-alliance-expression-runtime.test.ts b/tests/truth-alliance-expression-runtime.test.ts new file mode 100644 index 00000000..4bcca517 --- /dev/null +++ b/tests/truth-alliance-expression-runtime.test.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { + actTruthAllianceExpression as act, answerTruthAllianceExpression as answer, + askTruthAllianceExpression as ask, bindTruthAllianceExpression as bind, + closeTruthAllianceExpressionNexus as close, createTruthAllianceExpressionFixture as fixture, + declareTruthAllianceExpression as declare, finishTruthAllianceExpression as finish, + truthAllianceExpressionGroup as group, truthAllianceExpressionInventory as inventory, + truthAllianceExpressionNexus as nexus, truthAllianceExpressionPartner as partner, + truthAllianceExpressionPlayer as player, truthAllianceExpressionPromise as promise, +} from './fixture-truth-alliance-expression'; + +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); +} +function truthOnce(game: Game, truth: string) { + assert.equal(game.discard.filter(card => card.id === truth).length, 1); +} + +for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} whole alliance OR uses two real invitations and settles only at actual Nexus close`, () => { + const f = fixture({ advanced, secondOffer: true }); + assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); assert.ok(nexus(f.game)); + assert.equal(f.game.turn, 2); assert.equal(f.originalCards.length, 33); assert.deepEqual(f.staging, []); + assert.equal(f.sourceActionCount, f.sourceActions.length); + assert.equal(f.sourceActionCount, f.sourceCounts.inherited + 1); + assert.equal(f.offer!.steps[0].actor, f.partner); assert.equal(f.secondInvitation!.steps[0].actor, f.asker); + assert.equal(f.game.allianceOffers[f.partner], f.target); assert.equal(f.game.allianceOffers[f.asker], f.target); + assert.equal(player(f.game, f.target).ally, null); + const before = structuredClone(f.game); let game = bind(f).game; assert.deepEqual(f.game, before); + assert.deepEqual(promise(game).claim, f.claim); assert.equal(promise(game).answer, true); + assert.ok(!promise(game).fulfilled); rejected(game, f.target, { type: 'ready' }); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'alliance'); + assert.ok(guide.action.target === f.asker || guide.action.target === f.partner); + assert.equal(guide.action.alliancePromiseEvent, promise(game).event); + game = act(game, f.target, guide.action); assert.ok(!promise(game).fulfilled); + const chosen = player(game, f.target).ally; assert.ok(chosen === f.asker || chosen === f.partner); + game = close(game); assert.equal(game.nexus, false); assert.equal(promise(game).fulfilled, true); + assert.equal(player(game, f.target).ally, chosen); assert.equal(promise(game).released ?? false, false); + truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards); +}); + +void test('native whole OR accepts the other real invitation manually without pinning its guide route', () => { + const f = fixture({ secondOffer: true }); let game = bind(f).game; + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'alliance'); + const alternative = guide.action.target === f.asker ? f.partner : f.asker; + assert.equal(game.allianceOffers[alternative], f.target); + game = act(game, f.target, { type: 'alliance', target: alternative }); + assert.equal(player(game, f.target).ally, alternative); assert.ok(!promise(game).fulfilled); + game = close(game); assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native distinct-partner whole AND has only No and may retain a real single partnership until close', () => { + const f = fixture({ secondOffer: true }), claim = group('and', partner(f.asker), partner(f.partner)); + const asked = ask(declare(f).game, f.asker, f.target, claim); + assert.deepEqual(viewGame(asked, f.target).truthAllianceExpressionAnswers, ['no']); + rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); let game = answer(asked, f.target, 'no'); + assert.ok(!promise(game).fulfilled); game = act(game, f.target, { type: 'alliance', target: f.partner }); + assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game).fulfilled); + game = close(game); assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).ally, f.partner); + truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native whole OR No breaks an existing named pair and then readies without treating one false leaf as the whole answer', () => { + const f = fixture({ consent: 'allied' }); let game = bind(f, 'no').game; + assert.equal(player(game, f.target).ally, f.partner); rejected(game, f.target, { type: 'ready' }); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, null); + game = act(game, f.target, guide.action); assert.equal(player(game, f.target).ally, null); + assert.ok(!promise(game).fulfilled); const ready = viewGame(game, f.target).alliancePromiseGuide; + assert.ok(ready); assert.equal(ready.action.type, 'ready'); game = close(game); + assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).ally, null); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native whole OR No permits temporary real membership but does not settle until its own break and actual close', () => { + const f = fixture(); let game = bind(f, 'no').game; + game = act(game, f.target, { type: 'alliance', target: f.partner }); + assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game).fulfilled); + rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, { type: 'alliance', target: null }); + assert.ok(!promise(game).fulfilled); game = close(game); assert.equal(promise(game).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native whole OR without foreign consent preserves unknown save and reasks the same physical Truth after a real invitation', () => { + const f = fixture({ consent: 'none' }); let game = ask(declare(f).game, f.asker, f.target, f.claim); + assert.deepEqual(viewGame(game, f.target).truthAllianceExpressionAnswers, ['no', 'unknown']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'unknown'); + assert.equal(game.truthtrance!.stage, 'unknown'); assert.equal(game.discard.filter(card => card.id === f.truth).length, 0); + game = act(game, f.asker, { type: 'truthSave' }); assert.equal(game.truthtrance, null); + assert.equal(player(game, f.asker).hand.filter(card => card.effect === 'truthtrance').length, 2); + game = act(game, f.partner, { type: 'alliance', target: f.target }); game = bind(f, 'yes', f.claim, game).game; + game = close(game); assert.equal(player(game, f.target).ally, f.partner); assert.equal(promise(game).fulfilled, true); + truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native definite whole alliance answer reopens only the respondent existing Ready', () => { + const f = fixture(); let game = act(f.game, f.target, { type: 'ready' }); + game = act(game, f.partner, { type: 'ready' }); assert.ok(game.ready.includes(f.target) && game.ready.includes(f.partner)); + game = bind(f, 'yes', f.claim, game).game; + assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.partner)); + rejected(game, f.target, { type: 'ready' }); game = close(game); assert.equal(promise(game).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native whole alliance question and callback retain current context owner turn event and JSON semantics', () => { + const f = fixture(), declared = declare(f); + assert.ok(viewGame(declared.game, f.asker).truthAllianceExpressionTargets.includes(f.target)); + rejected(declared.game, f.partner, f.questionAction); + rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } }); + rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, claim: partner(f.target) } }); + let game = ask(declared.game, f.asker, f.target, f.claim); + assert.deepEqual(viewGame(game, f.target).truthAllianceExpressionAnswers, ['yes', 'no']); + assert.equal(viewGame(game, f.asker).truthAllianceExpressionAnswers, null); + assert.equal(viewGame(game, f.partner).truthAllianceExpressionAnswers, null); + rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes'); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); + rejected(game, f.asker, guide.action); + rejected(game, f.target, { ...guide.action, alliancePromiseEvent: 'stale-whole-alliance-event' }); + const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire); + rejected(game, f.target, wire); game = close(game); rejected(game, f.target, wire); + game = act(game, f.asker, { type: 'card', card: f.truths[1] }); + for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) { + const next = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(next); + game = act(game, next.id, { type: 'truthPass' }); + } + assert.equal(game.truthtrance?.stage, 'ask'); rejected(game, f.asker, f.questionAction); + truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native whole alliance projection exposes only own original expression and aggregate status', () => { + const f = fixture(), game = bind(f).game; + for (const actor of [f.target, f.asker, f.partner]) { + const view = viewGame(game, actor); assert.equal(view.truthAllianceExpressionAnswers, null); + assert.deepEqual(view.truthHistory.at(-1)?.question, f.question); assert.equal(view.truthHistory.at(-1)?.answer, 'yes'); + if (actor === f.target) { + const row = promise(game); assert.equal(row.player, f.target); assert.equal(row.asker, f.asker); + assert.equal(row.turn, f.game.turn); assert.deepEqual(row.claim, f.claim); assert.equal(row.answer, true); + assert.ok(Object.keys(row).every(key => ['event', 'player', 'asker', 'turn', 'claim', 'answer', 'fulfilled', 'released'].includes(key))); + assert.equal(view.alliancePromiseGuide?.action.alliancePromiseEvent, row.event); + } else { + assert.deepEqual(view.allianceExpressionPromises, []); assert.equal(view.alliancePromiseGuide, null); + } + } + assert.equal(game.allianceExpressionPromises!.length, 1); assert.ok(game.allianceExpressionPromises![0].source); +}); + +void test('native whole OR survives one opposing invitation withdrawal, releases after the last, and never reactivates on reoffer', () => { + const f = fixture({ secondOffer: true }); let game = bind(f).game; + game = act(game, f.partner, { type: 'alliance', target: null }); assert.ok(!promise(game).released); + const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.asker); + game = act(game, f.asker, { type: 'alliance', target: null }); + assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled); + game = act(game, f.asker, { type: 'alliance', target: f.target }); rejected(game, f.target, guide.action); + assert.equal(viewGame(game, f.target).alliancePromiseGuide, null); + game = act(game, f.target, { type: 'alliance', target: f.asker }); game = close(game); + assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native whole OR retains opposing actual departure rights and does not bind a later renewed consent', () => { + const f = fixture({ consent: 'allied' }); let game = bind(f).game; + rejected(game, f.target, { type: 'alliance', target: null }); + game = act(game, f.partner, { type: 'alliance', target: null }); + assert.equal(player(game, f.target).ally, null); assert.equal(promise(game).released, true); + game = act(game, f.partner, { type: 'alliance', target: f.target }); game = close(game); + assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled); + assert.equal(player(game, f.target).ally, null); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native nested whole OR and AND permits each complete matching final-partner outcome', () => { + const f = fixture({ secondOffer: true }); + const claim = group('or', group('and', partner(f.asker), partner(f.asker)), partner(f.partner)); + for (const selected of [f.asker, f.partner]) { + let game = bind(f, 'yes', claim).game; game = act(game, f.target, { type: 'alliance', target: selected }); + assert.ok(!promise(game).fulfilled); game = close(game); assert.equal(player(game, f.target).ally, selected); + assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards); + } +}); + +void test('all four minimal policies answer whole alliance questions and use their real acceptance and Ready controls legally', () => { + const f = fixture({ secondOffer: true }), asked = ask(declare(f).game, f.asker, f.target, f.claim); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(asked, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty; + const action = botActions(view).find(action => action.type === 'truthAnswer'); assert.ok(action, difficulty); + assert.ok(action.answer === 'yes' || action.answer === 'no' || action.answer === 'unknown'); + assert.ok(view.truthAllianceExpressionAnswers!.includes(action.answer)); + const game = act(structuredClone(asked), f.target, action); truthOnce(game, f.truth); + assert.deepEqual(inventory(game), f.originalCards); + } + for (const difficulty of DIFFICULTIES) { + let game = bind(f).game; const view = viewGame(game, f.target); + view.players.find(seat => seat.id === f.target)!.bot = difficulty; + const action = botActions(view).find(action => action.type === 'alliance'); assert.ok(action, difficulty); + game = act(game, f.target, action); assert.ok(player(game, f.target).ally); assert.ok(!promise(game).fulfilled); + const readyView = viewGame(game, f.target); readyView.players.find(seat => seat.id === f.target)!.bot = difficulty; + const ready = botActions(readyView).find(action => action.type === 'ready'); assert.ok(ready, difficulty); + game = act(game, f.target, ready); game = close(game); assert.equal(promise(game).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); + } +}); diff --git a/tests/truth-mixed-alliance-controls.test.tsx b/tests/truth-mixed-alliance-controls.test.tsx new file mode 100644 index 00000000..c5f766ed --- /dev/null +++ b/tests/truth-mixed-alliance-controls.test.tsx @@ -0,0 +1,263 @@ +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 { MixedAllianceFields, mixedAllianceInputError } from '../components/mixed-alliance-fields'; +import { MixedAlliancePromises } from '../components/mixed-alliance-promises'; +import { TruthFactFields } from '../components/mixed-shipment-fields'; +import { AlliancePromises, AlliancePromiseGuideButton } from '../components/alliance-promises'; +import { AllianceExpressionPromises } from '../components/alliance-expression-promises'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import type { MixedActionExpression } from '../game/mixed-action-question'; +import type { AllianceActionClaim } from '../game/mixed-alliance-question'; +import type { TruthFact } from '../game/truthtrance'; +import { createTechTokens } from '../game/tech-tokens'; + +type Expression = MixedActionExpression; +type ElementProps = { children?: ReactNode; [key: string]: unknown }; +type Element = ReactElement; +const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value }); +const partner = (id = 'r'): Expression => ({ kind: 'action', action: { partner: id } }); +const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [partner(), partner('a')] }] }); +function elements(node: ReactNode): Element[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement(node)) return []; + return [node, ...elements(node.props.children)]; +} +function find(tree: ReactNode, key: string, value: string): Element { + const found = elements(tree).find(element => element.props[key] === value); assert.ok(found, `${key}=${value}`); return found; +} +const control = (tree: ReactNode, key: string) => find(tree, 'data-mixed-alliance-control', key); +const click = (button: Element) => (button.props.onClick as () => void)(); +function select(tree: ReactNode, path: string, field: string, value: string) { + const input = find(tree, 'id', `truth-mixed-alliance-${path}-${field}`); + (input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } }); +} +function editFact(tree: ReactNode, path: string, next: TruthFact) { + const scope = find(tree, 'data-mixed-alliance-fact-path', path); + const fields = elements(scope).find(element => element.type === TruthFactFields); assert.ok(fields, `Current fact fields ${path}`); + (fields.props.onChange as (fact: TruthFact) => void)(next); +} + +/** These personalized projections exercise controlled UI and canonical guide + * ownership, without claiming native answer-time snapshots or consent. */ +function projected(kind: 'accept' | 'break' | 'ready' = 'accept', advanced = false): GameView { + const source = createGame('TRUTHMIXEDALLIANCECONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced); + joinGame(source, newPlayer('a', 'Asker', 'harkonnen')); + joinGame(source, newPlayer('r', 'Partner', 'atreides')); + const game = viewGame(source, 'p'); + game.status = 'playing'; game.turn = 2; game.phase = 1; game.nexus = true; game.spiceWindow = null; + game.active = 'a'; game.ready = []; game.alliancePromiseNexus = { turn: 2 }; + game.alliancePromises = []; game.allianceExpressionPromises = []; + game.mixedAlliancePromises = [{ event: 'controlled-mixed-alliance', player: 'p', asker: 'a', turn: 2, + mixed: claim(), answer: kind !== 'break', fulfilled: false, released: false }]; + game.allianceOffers = kind === 'accept' ? { r: 'p' } : {}; + if (kind !== 'accept') { + game.players.find(player => player.id === 'p')!.ally = 'r'; + game.players.find(player => player.id === 'r')!.ally = 'p'; + } + game.alliancePromiseGuide = { action: { type: kind === 'ready' ? 'ready' : 'alliance', + ...(kind === 'ready' ? {} : { target: kind === 'accept' ? 'r' : null }), alliancePromiseEvent: 'controlled-mixed-alliance' } }; + return game; +} +function editor(initial: Expression, disabled = false, game = projected()) { + let value = initial, changes = 0; + return { + get value() { return value; }, get changes() { return changes; }, + tree: () => MixedAllianceFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }), + html: () => renderToStaticMarkup(createElement(MixedAllianceFields, { value, game, target: 'p', disabled, onChange() {} })), + error: () => mixedAllianceInputError(value, game, 'p'), + }; +} +function guide(props: Parameters[0]) { + const button = AlliancePromiseGuideButton(props); assert.ok(button); return button; +} +const press = (button: ReturnType) => button.props.onClick(); +const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedAlliancePromises, { game, busy: false, send() {} })); + +void test('shared mixed editor preserves sixteen clauses and offers every public other partner without predicting consent', () => { + const e = editor(fact()); click(control(e.tree(), 'or-1')); select(e.tree(), '1.2', 'partner', 'r'); + for (let count = 3; count <= 16; count++) { + click(control(e.tree(), 'add-action-1')); select(e.tree(), `1.${count}`, 'partner', count % 2 ? 'r' : 'a'); + } + assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16); assert.equal(e.error(), null); + const html = e.html(), ids = [...html.matchAll(/ id="([^"]+)"/g)].map(match => match[1]); + assert.equal(new Set(ids).size, ids.length); assert.match(html, /16 \/ 16 conditions/); + assert.equal((html.match(/Named alliance partner/g) ?? []).length, 15); + const before = structuredClone(e.value), changes = e.changes; + for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'fact-or-1.1', 'or-1']) { + assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key)); + } + assert.equal(e.changes, changes); assert.deepEqual(e.value, before); + const game = projected(); game.allianceOffers = {}; + game.players.find(player => player.id === 'a')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'a'; + const allied = editor({ kind: 'and', terms: [fact(), partner()] }, false, game); + assert.equal(allied.error(), null); assert.match(allied.html(), /