diff --git a/README.md b/README.md index ae4c2fa6..7bf8c5cc 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,12 @@ next player acts; support responses keep their own authorized actors. Basic and already-opened older Revival phases retain their current readiness timing. See [Revival source and preview scope](docs/ADVANCED_REVIVAL_ORDER.md). +New Advanced Charity phases also follow First Player order counterclockwise. +Wait for your turn, claim if eligible, then choose **Finish Charity turn**. +CHOAM opening income and any cancellation response finish before the next seat +acts. Basic and already-opened older phases retain concurrent readiness. +See [Charity order and remaining scope](docs/ADVANCED_CHARITY_ORDER.md). + Choose expansion families when creating the room. Before starting, the host can select **Leader Skills**, **Ecaz Treachery Cards** (the independent three-card variant), **Nexus Cards**, **Discoveries**, **Homeworlds**, **Tech Tokens** or diff --git a/components/charity-claim.tsx b/components/charity-claim.tsx index 99f23b85..9c249935 100644 --- a/components/charity-claim.tsx +++ b/components/charity-claim.tsx @@ -4,21 +4,37 @@ import { ArrowRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; import type { Action, GameView } from '@/game/engine'; -export function CharityClaim({ - game, - act, - busy, -}: { +type Props = { game: GameView; act: (action: Action) => void; busy: boolean; -}) { +}; + +export function CharityClaim(props: Props) { + const { game, act, busy } = props; const me = game.players.find((player) => player.id === game.me); if (game.status !== 'playing' || game.phase !== 2 || !me) return null; - const disabled = busy || !!game.truthtrance || !!game.nexusTraitors?.pending || - !!me.charityClaimed || game.charity.incomePending || game.charity.amount === 0; + const actor = game.me, turn = game.turn, order = game.charityOrder; + const currentActor = order?.remaining[0]; + const currentName = game.players.find(player => player.id === currentActor)?.name ?? 'the current player'; + const ownTurn = !order || order.version === 1 && order.turn === turn && order.remaining[0] === actor; + const blocked = busy || !!game.truthtrance || !!game.nexusTraitors?.pending || game.charity.incomePending || + !!game.response || !!game.decision || !!game.phaseOpening || !!game.automaticContinuationPending || + !ownTurn || !!game.charity.orderBlocked; + const canClaim = !blocked && !me.charityClaimed && game.charity.amount > 0; + const canFinish = !!order && !blocked; return ( <> + {order &&
+

Advanced Charity proceeds from the First Player counterclockwise, one player at a time.

+

{order.version !== 1 || order.turn !== turn + ? 'This Charity turn order is no longer current.' + : !currentActor ? 'All Charity turns are complete.' + : !order.remaining.includes(actor) ? `Your Charity turn is complete. Waiting for ${currentName} to finish Charity.` + : currentActor === actor ? 'It is your Charity turn. Claim if eligible, then finish your turn to continue.' + : `Waiting for ${currentName} to finish Charity. Your turn comes later in the public order.`}

+ {game.charity.orderBlocked &&

{game.charity.orderBlocked}

} +
}

{game.charity.multiplier === 0 ? 'Inflation cancels charity for every faction this turn.' @@ -38,9 +54,9 @@ export function CharityClaim({ + {order && } ); } diff --git a/components/game-table.tsx b/components/game-table.tsx index 7e80a9de..788e45d9 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -5824,7 +5824,7 @@ export function GameTable({ )} )} - {[1, 2, 4, 7, 8].includes(g.phase) && !(g.phase === 4 && g.revivalOrder) && + {[1, 2, 4, 7, 8].includes(g.phase) && !(g.phase === 4 && g.revivalOrder) && !(g.phase === 2 && g.charityOrder) && actionButton( g.ready.includes(me.id) ? 'Waiting for the table' diff --git a/docs/ADVANCED_CHARITY_ORDER.md b/docs/ADVANCED_CHARITY_ORDER.md new file mode 100644 index 00000000..5b32f0db --- /dev/null +++ b/docs/ADVANCED_CHARITY_ORDER.md @@ -0,0 +1,76 @@ +# Advanced Charity player order + +27 October 2026. **Prototyped / Partial.** This is a core source correction; +complete Advanced, optional combinations and final acceptance remain open. + +## Source and cutover + +The user-authorized `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`, +physical8/printed6, starts Charity with the First Player and proceeds +counterclockwise. This is the supplied Advanced authority bound by the +[source amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026), +not a newly located publisher ruling. The bounded source read is: + +```sh +pdftotext -f 8 -l 8 -layout UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf - +``` + +New Advanced Charity phases retain the actual First Player order established +by Storm. Basic and already-opened unstamped Advanced phases keep concurrent +readiness; viewing or claiming does not manufacture a new order midway. + +## Connected behavior + +- A version1 public `charityOrder` retains the original turn, full order and + remaining suffix. Only its current seat may make an ordinary Charity claim + or use **Finish Charity turn**. Claiming is optional; finishing without a + claim passes that opportunity. Wealth and entitlement remain private. +- CHOAM opening income resolves before any claim or finish. A wealthy Advanced + Bene Gesserit claim retains its native Karama response. Neither the claim nor + a response advances the player order; required interactions finish first. +- Finish consumes one opportunity and records its completed prefix. Closing + market/Nexus decisions retain the frame until the phase actually ends. + No second claim, replayed Finish or new Truthtrance answer reopens a completed + ordered turn. Existing Basic/unstamped readiness reopening remains. +- Detached Truthtrance witnesses may quote one remaining owned future Charity + preparation. They neither claim early at the real table nor replay a closed + opportunity. Guidance and minimal AI wait for the actual current seat. +- Inflation, Homeworld additions, Production and the existing provisional + [CHOAM shortfall funding](CHOAM_CHARITY_FUNDING.md) retain their calculations. + Other cards and faction responses keep their own timing and authorized actor. + +`game/charity-order.ts` owns the immutable order calculation. The engine owns +phase lifecycle, claims and projection; `components/charity-claim.tsx` supplies +persistent current/wait/completed guidance and the existing `charity`/`ready` +packets. No database migration or saved-game conversion is added. + +Juice of Sapho first/last composition for this newly ordered phase remains +missing and is recorded for the later interaction pass. This version does not +pretend an unavailable card interaction is complete. + +## Focused evidence + +Frozen **15 files, 147/147**, types/lint/build and four native start programmes +pass. Three explicit Advanced previews exercise allowed income, original Karama +cancellation of income or wealthy BG; Basic uses two concurrent low claimants. +Actual pre-Charity bribes create poverty and defer incoming money normally. +Runtime records 11 GameTable SSR, 26 claim-panel renders, 8 actual claim +callbacks and 9 actual Finish callbacks, with source/further action arrays +101/30 across programmes. Native response/cancellation/closing actions are +applied directly; browser callbacks for those separate controls were not operated. + +Nineteen new pure/native/control/Truth cases cover order, optional claims, +responses, Basic/labelled unstamped compatibility, legal policies and future or +completed Charity entitlement. A genuine final-owner case caught commitment +evasion when Finish immediately opened a later phase; preflight now checks the +closed opportunity before its voluntary actor can disappear behind that opening. +Older payment, Production and Inflation sources finish preceding native turns. +The cancelled-income smoke correctly accepts automatic wealthy BG settlement +once the sole original Karama is spent. No game money, phase or queue is installed. + +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-advanced-charity-order-proof-v4/`; +HTML/journeys: adjacent `20261027-advanced-charity-order-runtime/run-v2/`. +Tests/types/lint carry from v3 under exactly the same source snapshot; only the +private smoke harness changed. The existing server returned HTTP200 unchanged. +This is local native/SSR/callback evidence. Sapho, full modes, wider interactions, +final rules/recovery/privacy and deployed acceptance remain due. diff --git a/docs/CHOAM_CHARITY_FUNDING.md b/docs/CHOAM_CHARITY_FUNDING.md index 480acfcc..f23d750a 100644 --- a/docs/CHOAM_CHARITY_FUNDING.md +++ b/docs/CHOAM_CHARITY_FUNDING.md @@ -1,5 +1,10 @@ # CHOAM Charity funding +The [27 October Advanced player-order cutover](ADVANCED_CHARITY_ORDER.md) +now sequences fresh Advanced claims and Finish. Basic/old unstamped phases +retain concurrent timing. The funding calculations and provisional shortfall +policy below are unchanged; earlier evidence does not certify the new order. + ## Source and selected prototype policy — 24 October 2026 Publisher E2 physical p7 says CHOAM collects two spice per faction before diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 99a86071..81311a49 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,32 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Advanced Charity player order — 27 October. Prototyped / Partial.** +New Advanced phases proceed from the First Player counterclockwise. Only the +current seat may claim or Finish; CHOAM income and wealthy BG responses retain +their original timing. Basic/old unstamped phases stay concurrent. A completed +turn cannot reopen through Truthtrance; remaining future entitlement remains +quotable, with executable guidance waiting for the actual owner. A native case +found and repaired last-Finish evasion before a phase-opening interruption. + +Frozen **15 affected files, 147/147**, types/lint/build and 4 fresh native +start programmes pass: three explicit Advanced previews (allowed income, +canceled income, canceled wealthy BG) and a Basic concurrent low-claim control. +Runtime records 11 GameTable SSR, 26 claim-panel renders, 8 actual claim +callbacks and 9 Finish callbacks. Recorded source/further arrays total +101/30 across programmes. Original unopened deal/Spice positions and scalar +lotteries are selected; actual bribes create poverty. Responses/card cancellation +and closing packets use native actions directly, without a clicked-control claim. +Nineteen new helper/native/control/Truth cases pass; old payment and Inflation +sources now finish actual preceding turns. Sapho composition remains missing. + +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-advanced-charity-order-proof-v4/`; +journeys/HTML: adjacent `20261027-advanced-charity-order-runtime/run-v2/`. +Passed tests/types/lint carry from v3 under an identical source hash; v4 repairs +only a private smoke assumption about automatic uncontested BG resolution. +The existing server returned HTTP200 without restart. Browser/deployed, +complete modes and final assurance remain. [Contract](ADVANCED_CHARITY_ORDER.md). + **Ixian Advanced starting draft — 27 October. Prototyped / Partial.** Fresh Advanced setup with Harkonnen now includes its extra starting card in the original Ixian pool; both Harkonnen cards come from the shuffled remainder. @@ -46,14 +72,18 @@ Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-ix-starting-pool-p journeys/HTML: adjacent `20261027-ix-starting-pool-runtime/run/`. [Source contract](IX_PROTOTYPE.md#advanced-starting-draft-source-cutover--27-october-2026). -**Next source-identified functions:** Advanced Charity's First Player claim -order (supplied physical8), CHOAM's total-support rounding (physical29; final -eligibility composition still needs its source check), and Family Atomics' -full timing/sector/removal contract (physical39; the revision's stricter -requirements remain Advanced-only after the bounded Basic source comparison). -These are concrete remaining code/source divergences, not a complete rules -audit. The bounded six-classic-faction pass found connected independent -functions; optional modules, interactions and final acceptance remain open. +**Next source-identified functions:** CHOAM's total-support rounding +(supplied physical29 F) and Family Atomics' full timing/sector/removal contract +(physical39; the revision's stricter requirements remain Advanced-only after +bounded Basic comparison). The bounded CHOAM source comparison supports a +rounding-only correction: retain eligible `support - allyPayment`, including +Arrakeen's expressly sourced Bank contribution; preserve CHOAM-own/CHOAM-aid +exclusions and free-force support. Aggregate before flooring; unrelated +Banker/Bureaucrat per-invoice rulings stay distinct. This is next implementation, +not a passed new effect. Charity Sapho composition waits for the interaction pass. +These findings are concrete divergences, not a complete rules audit. The bounded +six-classic-faction pass found connected independent functions; optional modules, +interactions and final acceptance remain open. **Native E3 trained capture — 27 October. Prototyped / Partial.** Fresh bare Advanced E3/all14 setups now connect Harkonnen with actual Ecaz diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index c3bfd177..dd52fe04 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,32 @@ # Dune implementation status +## 27 October 2026 — Ordered Advanced Charity + +Prototyped / Partial. New Advanced phases proceed from the First Player counterclockwise. Only the +current seat may claim or Finish; CHOAM income and wealthy BG responses retain +their original timing. Basic/old unstamped phases stay concurrent. A completed +turn cannot reopen through Truthtrance; remaining future entitlement remains +quotable, with executable guidance waiting for the actual owner. A native case +found and repaired last-Finish evasion before a phase-opening interruption. + +Frozen **15 affected files, 147/147**, types/lint/build and 4 fresh native +start programmes pass: three explicit Advanced previews (allowed income, +canceled income, canceled wealthy BG) and a Basic concurrent low-claim control. +Runtime records 11 GameTable SSR, 26 claim-panel renders, 8 actual claim +callbacks and 9 Finish callbacks. Recorded source/further arrays total +101/30 across programmes. Original unopened deal/Spice positions and scalar +lotteries are selected; actual bribes create poverty. Responses/card cancellation +and closing packets use native actions directly, without a clicked-control claim. +Nineteen new helper/native/control/Truth cases pass; old payment and Inflation +sources now finish actual preceding turns. Sapho composition remains missing. + +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-advanced-charity-order-proof-v4/`; +journeys/HTML: adjacent `20261027-advanced-charity-order-runtime/run-v2/`. +Passed tests/types/lint carry from v3 under an identical source hash; v4 repairs +only a private smoke assumption about automatic uncontested BG resolution. +The existing server returned HTTP200 without restart. Browser/deployed, +complete modes and final assurance remain. [Contract](ADVANCED_CHARITY_ORDER.md). + ## 27 October 2026 — Source-aligned Advanced Ixian starting draft Prototyped / Partial. Fresh Advanced setup with Harkonnen now includes its extra starting card in the diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index d9e3b6c3..6e4b173c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,17 @@ # Rule decision index +## Advanced Charity player order — 27 October 2026 + +Supplied physical8/printed6 expressly begins Charity with the First Player and +proceeds counterclockwise. New Advanced phases stamp that native order; only +the current seat claims or finishes. CHOAM opening income and wealthy Bene +Gesserit responses precede completion. Basic/old unstamped phases retain their +procedure. Completed ordered turns cannot reopen through Truthtrance, while +remaining owned future entitlements can still supply detached witnesses. +The claim-then-Finish control is explicit software composition of the optional +claim and printed order. Sapho composition and broader interactions remain. +[Source, lifecycle and boundaries](ADVANCED_CHARITY_ORDER.md). + ## Ixian starting draft with Harkonnen — 27 October 2026 Supplied Advanced physical34/printed32 A explicitly draws N+1 when Harkonnen diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md index 5c98332f..ffb5936c 100644 --- a/docs/TRUTHTRANCE_TURN_ACTIONS.md +++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md @@ -262,8 +262,12 @@ agreement must fit before the future phase and retain recipient restrictions. No chosen branch, timeline or prospective income is installed in a real game. Bare, grouped, mixed and combined payment questions use guarded current-phase -payment/Charity packets. Positive acceptance reopens owned Charity readiness; -own Ready cannot forfeit the necessary claim. After an actual claim or phase +payment/Charity packets. Positive acceptance reopens Basic/unstamped Charity +readiness. The [new ordered Advanced phase](ADVANCED_CHARITY_ORDER.md) instead +retains a remaining owned entitlement while guides wait for its real turn; +completed turns cannot reopen. Own Finish checks commitments before advancing, +including the last player whose Finish immediately opens another phase. +Own Ready cannot forfeit the necessary claim. After an actual claim or phase expiry it cannot be reserved again. Cumulative No caps and original history persist. Four profiles consume legal guides; strategy and calibration remain deferred. Native phase controls handle waiting before the actual Charity phase. diff --git a/game/bots.ts b/game/bots.ts index 0ccc27ec..3ad22bcd 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -3213,6 +3213,7 @@ function policyActions(g: GameView): Action[] { .every(p => fighterCount(p, t) < countAt(me, t))); if (territory) return [{ type: 'prepareAdvisors', territory }]; } + if (g.phase === 2 && g.charity.orderBlocked) return []; if ( g.phase === 2 && !me.charityClaimed && diff --git a/game/charity-order.ts b/game/charity-order.ts new file mode 100644 index 00000000..f73534b6 --- /dev/null +++ b/game/charity-order.ts @@ -0,0 +1,78 @@ +/** Native Charity follows the actual First Player order, one opportunity per seat. */ +export type CharityOrder = { + version: 1; + turn: number; + order: string[]; + remaining: string[]; +}; +export type CharityOrderContext = { + turn: number; + order: readonly string[]; + players: readonly { id: string }[]; + ready: readonly string[]; +}; +export class CharityOrderError extends Error { + constructor(message: string) { + super(message); + this.name = 'CharityOrderError'; + } +} + +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); +const id = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; +const ids = (value: unknown): value is readonly string[] => Array.isArray(value) && + [...value].every(id) && new Set(value).size === value.length; +const same = (left: readonly string[], right: readonly string[]) => + left.length === right.length && left.every((value, index) => value === right[index]); +function requireOrder(condition: unknown, message: string): asserts condition { + if (!condition) throw new CharityOrderError(message); +} +function validateContext(context: CharityOrderContext): void { + requireOrder(record(context) && Number.isSafeInteger(context.turn) && context.turn > 0 && + Array.isArray(context.players) && context.players.length >= 2 && context.players.length <= 6 && + [...context.players].every(player => record(player) && id(player.id)) && ids(context.order), + 'Charity needs a current turn and two through six actual seats in public order.'); + const seats = context.players.map(player => player.id); + requireOrder(new Set(seats).size === seats.length && context.order.length === seats.length && + context.order.every(player => seats.includes(player)), + 'Charity order must contain every actual seat exactly once.'); + requireOrder(ids(context.ready) && context.ready.length <= context.order.length && + same(context.ready, context.order.slice(0, context.ready.length)), + 'Completed Charity opportunities must be the exact public-order prefix.'); +} + +export function createCharityOrder(context: CharityOrderContext): CharityOrder { + validateContext(context); + return { version: 1, turn: context.turn, order: [...context.order], + remaining: context.order.slice(context.ready.length) }; +} + +export function validateCharityOrder(context: CharityOrderContext, frame: CharityOrder): void { + validateContext(context); + requireOrder(record(frame) && Object.keys(frame).sort().join(',') === 'order,remaining,turn,version' && + frame.version === 1 && frame.turn === context.turn && ids(frame.order) && ids(frame.remaining), + 'The current Charity order is malformed or belongs to another turn.'); + requireOrder(same(frame.order, context.order) && + same(frame.remaining, context.order.slice(context.ready.length)), + 'Charity must retain its original public order and exact remaining suffix.'); +} + +export function charityOrderBlock(context: CharityOrderContext, frame: CharityOrder, player: string): string | null { + validateCharityOrder(context, frame); + requireOrder(id(player), 'Choose a valid Charity participant.'); + if (!frame.order.includes(player)) return 'Only seated factions have a Charity opportunity.'; + if (!frame.remaining.length) return 'Charity opportunities are complete.'; + if (context.ready.includes(player)) return 'Your Charity opportunity has finished.'; + return frame.remaining[0] === player ? null : 'Wait for your Charity opportunity.'; +} + +/** Consume the pre-finish ready prefix. The caller appends this actor to ready + * with the native claim/pass in the same commit; no wallet or card changes occur here. + */ +export function finishCharityOrder(context: CharityOrderContext, frame: CharityOrder, player: string): CharityOrder { + const blocked = charityOrderBlock(context, frame, player); + requireOrder(blocked === null, blocked ?? 'This Charity opportunity is unavailable.'); + return { version: 1, turn: frame.turn, order: [...frame.order], remaining: frame.remaining.slice(1) }; +} diff --git a/game/engine.ts b/game/engine.ts index c924f34a..859c6eac 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -44,6 +44,8 @@ import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income'; import { createRevivalOrder, validateRevivalOrder, revivalOrderBlock, startRevivalOrder, saphoRevivalOrderEvent, reorderSaphoRevivalOrder, finishRevivalOrder, RevivalOrderError, type RevivalOrder } from './revival-order'; +import { createCharityOrder, validateCharityOrder, charityOrderBlock, + finishCharityOrder, CharityOrderError, type CharityOrder } from './charity-order'; import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival'; import { semutaDisasterArrivalSupported } from './semuta-disaster-arrival'; import { semutaAdvisorArrivalSupported } from './semuta-advisor-arrival'; @@ -1372,6 +1374,7 @@ type RicheseAllyOffer = { }; export type Game = { revivalOrder?: RevivalOrder; + charityOrder?: CharityOrder; pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[]; pendingAutomaticAdvisorTransport?: AutomaticAdvisorTransport; pendingAutomaticAdvisorGamont?: { receipt: AutomaticAdvisorGamont; card: string; nexusEvent?: string }; @@ -15422,6 +15425,9 @@ function bribeQuestionWindow(g: Game): boolean { * completing foreign choices at the real table. */ function charityForecastContext(source: Game): Game { const g = structuredClone(source); g.phase = 2; g.ready = []; + // A detached witness quotes a remaining owned opportunity, never an early + // claim at the real table or a replay of a completed ordered turn. + delete g.charityOrder; g.truthtrance = null; g.response = null; g.decision = null; g.phaseOpening = null; g.pendingKarama = null; g.pendingTreacheryDiscard = null; g.spiceWindow = null; g.spiceResolution = null; g.spiceSequence = null; g.nexus = false; @@ -15445,7 +15451,8 @@ function forecastChoamOpeningIncome(source: Game, owner: string): Game | null { function forecastOrdinaryCharity(source: Game, owner: string): Game | null { const before = getPlayer(source, owner); if (source.phase > 2 || before.charityTurn === source.turn || - source.phase === 2 && source.ready.includes(owner)) return null; + source.phase === 2 && (source.ready.includes(owner) || + source.charityOrder && !source.charityOrder.remaining.includes(owner))) return null; const g = charityForecastContext(source); const player = getPlayer(g, owner); if (charityQuote(g, player).total <= 0) return null; @@ -15533,7 +15540,7 @@ function bribeCompatibleLaterPromises(g: Game, p: Player, function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' | 'unknown')[] { if (!bribeQuestionWindow(g) || claim.turn !== g.turn || claim.recipient === p.id || claim.recipient === p.ally || !g.players.some(player => player.id === claim.recipient)) return []; - const source = g.phase === 2 ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; + const source = g.phase === 2 && !g.charityOrder ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(source, p, [...(g.bribePromises ?? []), { ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: 0 }])); @@ -15541,13 +15548,13 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] { if (!bribeQuestionWindow(g) || claim.turn !== g.turn || bribeExpressionRecipients(claim.claim) .some(recipient => recipient === p.id || recipient === p.ally || !g.players.some(player => player.id === recipient))) return []; - const source = g.phase === 2 ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; + const source = g.phase === 2 && !g.charityOrder ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(source, p, g.bribePromises ?? [], [...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }])); } function reopenBribePayer(g: Game, p: Player, answer: boolean) { - if (answer && (g.phase === 2 || g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) && g.ready.includes(p.id)) { + if (answer && (g.phase === 2 && !g.charityOrder || g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) && g.ready.includes(p.id)) { g.ready = g.ready.filter(player => player !== p.id); log(g, `${p.name} has a new Truthtrance bribe commitment and must finish ${g.phase === 2 ? 'Charity' : g.phase === 8 ? 'Mentat Pause' : 'Collection'} again after its required preparation.`); } @@ -15658,6 +15665,7 @@ function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Ac } function ordinaryPaymentActionAvailable(g: Game, player: Player, action: Action): boolean { if (action.type === 'charity') return g.phase === 2 && !g.ready.includes(player.id) && + !nativeCharityOrderBlock(g, player.id) && player.charityTurn !== g.turn && charityQuote(g, player).total > 0 && (!byFaction(g, 'choam') || g.choamCharity?.turn === g.turn) && !g.truthtrance && !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard; @@ -19493,6 +19501,7 @@ function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) { * normal hidden draw. It grants no spice, cards or foreign decisions. */ function projectAfterKnownThumper(g: Game) { g.phase = 2; g.active = null; g.ready = []; + delete g.charityOrder; g.spiceSequence = null; g.spiceResolution = null; g.spiceWindow = null; g.nexus = false; } @@ -20200,7 +20209,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b } function turnActionAnswerSource(g: Game, player: string, alliance = false, window = false, fresh?: { claim: TurnActionClaim; answer: boolean }): Game { - const source = g.phase === 0 && window || g.phase === 1 && (alliance || window) || g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 || + const source = g.phase === 0 && window || g.phase === 1 && (alliance || window) || g.phase === 2 && !g.charityOrder || g.phase === 4 && !g.revivalOrder || g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g) ? { ...g, ready: g.ready.filter(id => id !== player) } : g; const opened = source.phaseOpening ? { ...source, phaseOpening: { ...source.phaseOpening, @@ -20392,7 +20401,8 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act ...liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player.id, g.turn), ].some(row => row.answer); if (!action && completion.revival && !ownedBox && - !(g.phase === 4 && nativeRevivalOrderBlock(g, player.id))) + !(g.phase === 4 && nativeRevivalOrderBlock(g, player.id)) && + !(g.phase === 2 && nativeCharityOrderBlock(g, player.id))) action = g.phase === 4 ? completion.revival.action : g.phase === 2 || g.phase === 3 && ownedInvoice ? completion.revival.early.find(step => step.phase === g.phase)?.action : undefined; @@ -20404,7 +20414,8 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act if (ownedInvoice) action = current?.type === 'decision' ? current : { type: 'decision', karama: false }; else if (g.auction?.active === player.id) action = { type: 'passBid' }; } else if ([2, 4].includes(g.phase) && !g.ready.includes(player.id) && - !(g.phase === 4 && nativeRevivalOrderBlock(g, player.id))) action = current ?? { type: 'ready' }; + !(g.phase === 4 && nativeRevivalOrderBlock(g, player.id)) && + !(g.phase === 2 && nativeCharityOrderBlock(g, player.id))) action = current ?? { type: 'ready' }; } else if (!action && g.phase === 5 && g.active === player.id && !promise.transportClosed) action = completion.actions[0] ?? { type: 'endMovement' }; } @@ -20526,7 +20537,7 @@ function travelTruthAnswers(g: Game, p: Player, claim: TravelClaim): TruthAnswer asker: g.players.find(player => player.id !== p.id)!.id, answer, compiled, shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false, fulfilled: compiled.kind === 'constant', released: false }; - const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) + const answerSource = (g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; // Candidate witnesses must include the new row, which has no accepted-order // slot until the real answer binds. The authoritative order stays untouched. @@ -20877,6 +20888,7 @@ function earlyGroundMovementPolicy(g: Game, player: Player): Action[] | null { if (g.decision?.kind === 'auctionPayment') return [current?.type === 'decision' ? current : { type: 'decision', karama: false }]; if (g.phase === 4 && nativeRevivalOrderBlock(g,player.id)) return []; + if (g.phase === 2 && nativeCharityOrderBlock(g,player.id)) return []; if (g.phase === 2 || g.phase === 4) return current ? [current] : g.ready.includes(player.id) ? [] : [{ type: 'ready' }]; return null; } @@ -20885,7 +20897,7 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn const answers: TruthAnswer[] = []; for (const answer of [true, false]) { const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer }; - const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; + const answerSource = (g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; if (groundMovementCompletion(answerSource, p.id, [...live, promise]) && revivalCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] }) && bribeCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] })) @@ -20894,7 +20906,7 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn return answers.length ? answers : ['unknown']; } function reopenMovementPreparation(g: Game, p: Player) { - if ((g.phase === 2 || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) { + if (((g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) { g.ready = g.ready.filter(id => id !== p.id); log(g, `${p.name} has a new Truthtrance movement commitment and must confirm readiness again after any needed preparation.`); } @@ -20908,7 +20920,7 @@ function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boo } function movementExpressionTruthAnswers(g: Game, p: Player, claim: MovementExpressionClaim): TruthAnswer[] { if (claim.turn !== g.turn || !movementPromiseTargetAvailable(g, p.id)) return []; - const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; + const answerSource = (g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; const answers: TruthAnswer[] = []; for (const answer of [true, false]) { const promise: MovementExpressionPromise = { ...structuredClone(claim), event: 'candidate', player: p.id, @@ -26618,7 +26630,7 @@ function advancePhase(g: Game) { requireRule(!g.moritaniExtortion, 'Settle Extortion before leaving Mentat Pause.'); if (g.phase === 6) boardResolution(() => quoteBattlePhaseAdvance(g)); commitPhaseResources(g, currentPhaseResources(g)); - if (!(g.phase === 4 && g.revivalOrder)) g.ready = []; + if (!(g.phase === 4 && g.revivalOrder) && !(g.phase === 2 && g.charityOrder)) g.ready = []; g.active = null; const choam = byFaction(g, 'choam'); if (g.phase === 8 && choam) { @@ -26657,6 +26669,10 @@ function finishEcazPlacement(g: Game) { } function completePhase(g: Game) { if (closeNexusCards(g)) return; + if (g.phase === 2 && g.charityOrder) { + delete g.charityOrder; + g.ready = []; + } if (g.phase === 4 && g.revivalOrder) { delete g.revivalOrder; g.ready = []; @@ -26703,6 +26719,43 @@ function openPhase(g: Game, initialize = true) { function revivalOrderContext(g: Game) { return {turn:g.turn,order:g.order,ready:g.ready}; } +function charityOrderContext(g: Game) { + return {turn:g.turn,order:g.order,players:g.players,ready:g.ready}; +} +function charityOrderRule(calculate: () => T): T { + try { return calculate(); } + catch (error) { + if (error instanceof CharityOrderError) throw new RuleError(error.message); + throw error; + } +} +function charityOrderIntegrity(g: Game) { + if (!g.charityOrder) return; + requireRule(g.advanced && g.status === 'playing' && g.phase === 2, + 'The ordered Charity belongs to its original Advanced phase.'); + charityOrderRule(() => validateCharityOrder(charityOrderContext(g),g.charityOrder!)); + requireRule(g.active === (g.charityOrder.remaining[0] ?? null), + 'Charity must retain its current player through the complete opportunity.'); +} +function nativeCharityOrderBlock(g: Game, player: string): string | null { + return g.charityOrder + ? charityOrderRule(() => charityOrderBlock(charityOrderContext(g),g.charityOrder!,player)) + : null; +} +function guardCharityOrderFinish(g: Game, player: string, frame: CharityOrder, action: Action) { + // Check the closed owned opportunity before the last Finish can open a + // different phase/response and defer reconciliation past its voluntary actor. + const closed = structuredClone(g); + closed.charityOrder = frame; + closed.ready.push(player); + closed.active = frame.remaining[0] ?? null; + const voluntary = { actor: player, action }; + reconcileTravelPromises(closed, player, action, g.phase); + reconcileRevivalPromises(closed, voluntary); + reconcileBribePromises(closed, voluntary); + reconcileShipmentPromises(closed, voluntary); + reconcileGroundMovementPromises(closed, player, action, g.phase); +} function revivalOrderRule(calculate: () => T): T { try { return calculate(); } catch (error) { @@ -26744,7 +26797,14 @@ function beginPhase(g: Game) { g.auditorInsight = null; retireNexusChoamInsight(g); } - if (g.phase === 2) openAdvisorPreparation(g); + if (g.phase === 2) { + if (g.advanced && !g.charityOrder) { + g.charityOrder = charityOrderRule(() => createCharityOrder(charityOrderContext(g))); + g.active = g.charityOrder.remaining[0] ?? null; + log(g, 'Advanced Charity proceeds from the First Player counterclockwise. Claim if eligible, then finish your Charity turn; CHOAM opening income and authorized responses resolve first.'); + } + openAdvisorPreparation(g); + } if (g.phase === 2 && g.choamCharity?.turn !== g.turn) { const choam = byFaction(g, 'choam'); if (choam && charityMultiplier(g) === 0) { @@ -29235,6 +29295,7 @@ function earlyRevivalCompletionSeeds(initial: Game, source: Game, owner: string) } return seeds.map(seed => { const g = structuredClone(seed.game); g.phase = 4; + delete g.charityOrder; if (source.phase < 4) { g.revivalRules = newRevivalRules(); g.revivalRequests = {}; g.freeRevival = []; g.emperorExtra = {}; g.ready = []; g.active = owner; delete g.revivalOrder; @@ -29510,6 +29571,7 @@ function earlyShipmentGuidance(g: Game, p: Player): { preparation: { actions: Ac return { preparation: null, policy: g.auction?.active === p.id ? [{ type: 'passBid' }] : [] }; } if (g.phase === 4 && nativeRevivalOrderBlock(g,p.id)) return {preparation:null,policy:[]}; + if (g.phase === 2 && nativeCharityOrderBlock(g,p.id)) return {preparation:null,policy:[]}; if (g.phase === 2 || g.phase === 4) return { preparation: current ? { actions: [current] } : null, policy: current ? [current] : g.ready.includes(p.id) ? [] : [{ type: 'ready' }] }; @@ -29528,7 +29590,7 @@ function shipmentTruthAnswers( { ...claim, turn: g.turn, player: p.id, asker: '', answer }, ]; // Concurrent legacy readiness can reopen; a completed ordered turn cannot. - const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) + const answerSource = (g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; if (findShipmentCompletion(answerSource, p, promises) && revivalCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] }) && @@ -29545,7 +29607,7 @@ function bindShipmentTruth( asker: string, mixed?: MixedShipmentExpression, ) { - if ((g.phase === 2 || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) { + if (((g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) { g.ready = g.ready.filter(id => id !== p.id); log(g, `${p.name} has a new Truthtrance shipment commitment and must confirm readiness again after completing any needed preparation.`); } @@ -33352,7 +33414,8 @@ function projectedTypedRevivalPromiseGuide(g: Game, player: string, kind: 'force const ownPayment = g.phase === 3 && !g.response && !g.phaseOpening && g.decision?.kind === 'auctionPayment' && g.decision.player === player; if ((!revivalQuestionWindow(g) && !ownPayment) || g.truthtrance || !revivalOpportunity(g, player) || g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard || - g.phase === 4 && nativeRevivalOrderBlock(g, player)) return null; + g.phase === 4 && nativeRevivalOrderBlock(g, player) || + g.phase === 2 && nativeCharityOrderBlock(g, player)) return null; // The separate bid guide owns a jointly binding Yes declaration. Passing // cannot evade it merely because Revival comes later in this turn. const lot = g.phase === 3 && !ownPayment && !g.richeseAuction && !g.ixAuction ? bidOpportunity(g) : null; @@ -38228,6 +38291,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { bribePromiseIntegrity(state); alliancePromiseIntegrity(state); revivalOrderIntegrity(state); + charityOrderIntegrity(state); state = migrateAtomicsTargets(state); state = migrateFremenNexusRevival(state); if (state.spiceBankerIncomePreview) { @@ -38558,6 +38622,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { guildBetrayalIntegrity(g); harkonnenNexusBetrayalIntegrity(g); revivalOrderIntegrity(g); + charityOrderIntegrity(g); return g; } function finishActionContinuations(g: Game) { @@ -41510,6 +41575,8 @@ function applyActionInner( return g; } if (t === 'charity') { + const orderBlock = nativeCharityOrderBlock(g, id); + requireRule(!orderBlock, orderBlock ?? 'Wait for your Charity turn.'); const advancedBG = g.advanced && p.faction === 'beneGesserit'; const quote = charityQuote(g, p); const amount = quote.total; @@ -42799,6 +42866,10 @@ function applyActionInner( return g; } if (t === 'ready') { + if (g.phase === 2) { + const orderBlock = nativeCharityOrderBlock(g, id); + requireRule(!orderBlock, orderBlock ?? 'Wait for your Charity turn.'); + } if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p); if (g.turnActionPromises && (g.phase === 0 || g.phase === 1 && (g.spiceWindow || thumperReadyStartsDraw(g)))) { const closed = ownTurnActionWindowClosure(g, p.id); @@ -42837,6 +42908,13 @@ function applyActionInner( 'Complete the current phase action first.', ); requireRule(!g.ready.includes(id), 'You are already ready.'); + if (g.phase === 2 && g.charityOrder) { + const finished = charityOrderRule(() => finishCharityOrder(charityOrderContext(g),g.charityOrder!,id)); + guardCharityOrderFinish(g, id, finished, action); + g.charityOrder = finished; + g.active = g.charityOrder.remaining[0] ?? null; + log(g, `${p.name} finished their Charity turn.`,{faction:p.faction,name:'Charity turn complete'}); + } if (g.phase === 4 && g.revivalOrder) { g.revivalOrder = revivalOrderRule(() => finishRevivalOrder(revivalOrderContext(g),g.revivalOrder!,id)); g.active = g.revivalOrder.remaining[0] ?? null; @@ -42861,6 +42939,7 @@ function applyActionInner( } export function viewGame(state: Game, id: string, now?: number) { revivalOrderIntegrity(state); + charityOrderIntegrity(state); state = migrateAtomicsTargets(state); state = migrateFremenNexusRevival(state); harkonnenNexusBetrayalIntegrity(state); @@ -43751,7 +43830,9 @@ export function viewGame(state: Game, id: string, now?: number) { incomeCanceled: g.choamCharity?.turn === g.turn && g.choamCharity.canceled, incomeHomeworld: choamPlayer ? homeworldLowBonus(g, choamPlayer.id) : 0, + orderBlocked: nativeCharityOrderBlock(g, id), }, + charityOrder: g.charityOrder ? structuredClone(g.charityOrder) : null, moritaniAssassinate: projectedMoritaniAssassinate(g,id), moritaniRetention: g.moritaniRetention ?? null, nexusMoritaniRetention: quoteNexusMoritaniRetention(g, id), diff --git a/game/reference.ts b/game/reference.ts index 9af94b96..bd3b0c37 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -44,7 +44,7 @@ export type RuleTopic = { export const PHASE_HELP = [ 'Complete the table’s storm dials or card reveal. After the distance is revealed, play any storm cards and confirm movement.', 'Reveal a spice blow, resolve worms and card responses, then finish any Nexus and Fremen rides.', - 'Claim charity once if below two spice. Advanced Bene Gesserit receives two spice regardless of wealth. Then mark yourself ready.', + 'Claim charity once if below two spice. Advanced Bene Gesserit receives two regardless of wealth. New Advanced phases follow the First Player counterclockwise: claim if eligible, then finish your Charity turn. Basic and older unstamped phases retain concurrent readiness.', 'Bid or pass in turn. New ordinary lots allow ten seconds per bidder while native decisions hold the clock. A winning bidder chooses how to pay before income and bonus-card responses resolve.', 'Return eligible forces and leaders from the tanks to your reserves. New Advanced Revival phases begin from the First Player counterclockwise; Sapho may change unfinished positions. Finish your own force and leader choices before the next player acts. Basic and already-opened older phases retain their current readiness timing.', 'Ship before moving. Select a destination sector and, for a move, the forces leaving one territory.', @@ -121,7 +121,8 @@ const phaseDetails = [ ], [ 'If CHOAM is present, first resolve its income response. CHOAM then pays other factions’ charity; if its income was canceled, charity comes from the bank. Use Claim charity when eligible to bring ordinary available spice to two.', - 'Mark yourself ready after making your choice. Other players complete their choices independently.', + 'New Advanced Charity follows the First Player counterclockwise. Only the current player may claim or finish; a claim and any cancellation response finish before the player chooses Finish Charity turn. You may finish without claiming. Waiting and completed seats cannot take another Charity turn.', + 'Basic and already-opened older unstamped Advanced Charity keep concurrent readiness. Mark yourself ready after making your choice. New Truthtrance answers do not reopen completed ordered turns. Sapho first/last composition for the newly ordered phase remains unfinished.', ], [ 'Before each auction, resolve the Atreides foresight response. The card stays hidden until that response is allowed. Cancellation affects this card only.', @@ -2576,6 +2577,8 @@ export const RULE_TOPICS: RuleTopic[] = [ ? ['ecaz-ambassadors', 'tleilaxu-revival'] : phase === 3 ? ['ordinary-auction-clock', 'ix-technology', 'nexus-ixian-betrayal', 'nexus-ixian-replacement', 'richese-cards'] + : phase === 2 + ? ['advanced-charity-order', 'choam-economy', 'bg-charity-karama'] : phase === 1 ? ['card-harvester', 'faction-fremen'] : phase === 0 @@ -2583,6 +2586,27 @@ export const RULE_TOPICS: RuleTopic[] = [ : undefined, }), ), + { + id: 'advanced-charity-order', + title: 'Advanced Charity player order', + category: 'Turn phases', coverage: 'Partial', developmentStage: 'Prototyped', + summary: 'New Advanced Charity phases give each faction one opportunity, beginning with the First Player counterclockwise.', + steps: [ + 'Resolve CHOAM opening income before any ordinary claim. When it is your turn, claim if eligible and then finish your Charity turn. Finishing without a claim passes your opportunity.', + 'A claim and its native responses retain the current player. Completing a Karama response does not finish or consume the responder’s own Charity turn.', + 'Waiting and completed seats cannot claim or finish. A new Truthtrance answer cannot reopen a completed ordered turn, while a remaining owned future claim may still support a binding answer.', + 'Basic and already-opened unstamped Advanced phases keep concurrent readiness. Inflation, Homeworld bonuses and CHOAM funding retain their existing calculation and limitations.', + 'Sapho first/last composition, broader interactions and full Advanced acceptance remain unfinished.', + ], + related: ['charity', 'choam-economy', 'bg-charity-karama', 'truthtrance-turn-actions'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Fresh native order, current-only claim/finish and original response/closing lifecycle are connected; Sapho composition remains.', evidence: ['game/charity-order.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Persistent current/wait/completed status and native claim/finish controls retain funding explanations.', evidence: ['components/charity-claim.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Existing policies wait for their actual opportunity and use the native claim and finish actions; strategy and calibration remain deferred.', evidence: ['game/bots.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Source cutover, Basic/legacy behavior, remaining entitlements and interaction boundaries are explicit.', evidence: ['docs/ADVANCED_CHARITY_ORDER.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused helper, native and control sources; frozen runtime evidence and complete-mode acceptance are separate.', evidence: ['tests/charity-order.test.ts', 'tests/advanced-charity-order-engine.test.ts', 'tests/charity-claim-controls.test.tsx'] }, + ], + }, { id: 'ordinary-auction-clock', title: 'Ordinary auction: ten seconds to bid', diff --git a/tests/advanced-charity-order-engine.test.ts b/tests/advanced-charity-order-engine.test.ts new file mode 100644 index 00000000..bc1f5fc4 --- /dev/null +++ b/tests/advanced-charity-order-engine.test.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import type { Difficulty } from '../game/bot-profiles'; +import { + actAdvancedCharityOrder as act, advancedCharityOrderClean as clean, + advancedCharityOrderInventory as inventory, advancedCharityOrderPlayer as p, + advancedCharityOrderReload as reload, allowAdvancedCharityOpening as allow, + createAdvancedCharityOrderFixture as fixture, finishAdvancedCharityPhase as finishPhase, + finishAdvancedCharityTurn as finishTurn, nextAdvancedCharityOrderStep as next, + passAdvancedCharityResponses as pass, unstampedAdvancedCharitySupplement as unstamped, + type AdvancedCharityOrderStep, +} from './fixture-advanced-charity-order'; + +function reject(game: Game, actor: string, action: Action) { + const before = reload(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +void test('genuine Advanced Charity source uses native First Player order and a claim retains its owned turn', () => { + const f = fixture(); let game = allow(reload(f.game)); + assert.equal(f.sourceCounts.setup, f.setupActions.length); + assert.equal(f.sourceCounts.bribes, f.bribeActions.length); + assert.equal(f.sourceCounts.source, f.actions.length); + assert.ok(f.bribeActions.some(step => step.actor === 'atreides' && step.action.type === 'bribe')); + assert.equal(p(f.beforeIncome, 'choam').spice, 2); assert.equal(p(game, 'choam').spice, 8); + assert.equal(p(game, 'choam').bribes, p(f.beforeIncome, 'choam').bribes); + assert.deepEqual(game.charityOrder, { version: 1, turn: game.turn, order: game.order, remaining: game.order }); + assert.equal(game.active, 'atreides'); + assert.deepEqual(viewGame(game, 'atreides').charityOrder, game.charityOrder); + const queue = structuredClone(game.charityOrder); + game = act(game, 'atreides', { type: 'charity' }); + assert.equal(p(game, 'atreides').spice, 2); assert.equal(p(game, 'atreides').charityTurn, game.turn); + assert.equal(p(game, 'choam').spice, 6); + assert.deepEqual(game.charityOrder, queue); assert.equal(game.active, 'atreides'); + game = finishTurn(game); + assert.equal(game.active, 'beneGesserit'); + assert.deepEqual(game.charityOrder?.remaining, ['beneGesserit', 'choam']); + assert.deepEqual(inventory(game), f.original); +}); + +void test('later and completed Advanced Charity actors cannot claim or finish out of order', () => { + const f = fixture(); let game = allow(reload(f.game)); + for (const actor of ['beneGesserit', 'choam']) { + assert.ok(viewGame(game, actor).charity.orderBlocked); + reject(game, actor, { type: 'charity' }); reject(game, actor, { type: 'ready' }); + } + game = finishTurn(game); + reject(game, 'atreides', { type: 'charity' }); reject(game, 'atreides', { type: 'ready' }); + assert.ok(viewGame(game, 'atreides').charity.orderBlocked); + assert.equal(game.active, 'beneGesserit'); + assert.deepEqual(game.charityOrder?.remaining, ['beneGesserit', 'choam']); +}); + +void test('original CHOAM opening response holds the whole Charity queue before any claim or Finish', () => { + const f = fixture(), before = reload(f.game), queue = structuredClone(f.game.charityOrder); + assert.equal(before.response?.kind, 'choamCharity'); + assert.ok(queue); + reject(before, 'atreides', { type: 'charity' }); reject(before, 'atreides', { type: 'ready' }); + let game = before; + for (let count = 0; game.response && count < 10; count++) { + assert.deepEqual(game.charityOrder, queue); + const step = next(game); assert.ok(step); assert.equal(step.action.type, 'passResponse'); + game = act(game, step.actor, step.action); + } + assert.equal(game.response ?? null, null); + assert.deepEqual(game.charityOrder, queue); + assert.equal(game.active, queue.remaining[0]); + assert.equal(p(game, 'choam').spice, 8); + assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); + assert.notEqual(p(game, 'atreides').charityTurn, game.turn); + assert.deepEqual(inventory(game), f.original); +}); + +void test('wealthy Bene Gesserit response retains the current Charity actor through native payment then real finishes close phase', () => { + const f = fixture(); let game = allow(reload(f.game)); + game = act(game, 'atreides', { type: 'charity' }); game = finishTurn(game); + const bg = p(game, 'beneGesserit').spice, provider = p(game, 'choam').spice; + const queue = structuredClone(game.charityOrder); + assert.ok(bg >= 2); + game = act(game, 'beneGesserit', { type: 'charity' }); + assert.equal(game.response?.kind, 'bgCharity'); + assert.equal(game.active, 'beneGesserit'); assert.deepEqual(game.charityOrder, queue); + reject(game, 'beneGesserit', { type: 'ready' }); reject(game, 'choam', { type: 'ready' }); + game = pass(game); + assert.equal(p(game, 'beneGesserit').spice, bg + 2); + assert.equal(p(game, 'choam').spice, provider - 2); + assert.equal(game.active, 'beneGesserit'); assert.deepEqual(game.charityOrder, queue); + game = finishTurn(game); assert.equal(game.active, 'choam'); + game = finishTurn(game); + if (game.phase === 2) assert.ok(game.charityOrder, 'The original closing phase controls still own the frame'); + game = finishPhase(game); + assert.equal(game.phase, 3); assert.equal(game.charityOrder ?? null, null); + assert.deepEqual(inventory(game), f.original); +}); + +void test('Basic low claimants retain concurrent Charity and no ordered frame', () => { + const f = fixture({ advanced: false, lowBG: true }); let game = allow(reload(f.game)); + assert.equal(game.charityOrder ?? null, null); + assert.equal(viewGame(game, 'beneGesserit').charity.orderBlocked, null); + assert.equal(p(game, 'atreides').spice, 0); assert.equal(p(game, 'beneGesserit').spice, 0); + game = act(game, 'beneGesserit', { type: 'charity' }); + assert.equal(p(game, 'beneGesserit').spice, 2); + game = act(game, 'beneGesserit', { type: 'ready' }); + game = act(game, 'atreides', { type: 'charity' }); + assert.equal(p(game, 'atreides').spice, 2); + assert.equal(p(game, 'choam').spice, 4); + assert.equal(game.charityOrder ?? null, null); + assert.deepEqual(inventory(game), f.original); +}); + +void test('explicit conserved unstamped Advanced unit boundary retains the old concurrent claim contract', () => { + const f = fixture(), ordered = allow(reload(f.game)); + let game = unstamped(ordered); + const { charityOrder: ignored, ...rest } = ordered; + assert.ok(ignored); assert.deepEqual(game, rest, 'Only the frame is removed in this labelled unit supplement'); + game = act(game, 'beneGesserit', { type: 'charity' }); + assert.equal(game.response?.kind, 'bgCharity'); game = pass(game); + game = act(game, 'atreides', { type: 'charity' }); + assert.equal(p(game, 'atreides').spice, 2); + assert.equal(game.charityOrder ?? null, null); + assert.deepEqual(inventory(game), f.original); +}); + +void test('all four minimal profiles claim or Finish only the current actual Advanced Charity turn', () => { + const f = fixture(), opened = allow(reload(f.game)); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) { + let game = reload(opened); + const actions: AdvancedCharityOrderStep[] = []; + for (let count = 0; game.phase === 2 && count < 40; count++) { + if (!clean(game)) { + const step = next(game); assert.ok(step); + game = act(game, step.actor, step.action, actions); continue; + } + const actor = game.charityOrder?.remaining[0]; assert.ok(actor); + for (const other of game.players.filter(player => player.id !== actor)) { + const view = viewGame(game, other.id); view.players.find(player => player.id === other.id)!.bot = difficulty; + assert.equal(botActions(view).some(action => ['charity', 'ready'].includes(action.type)), false); + } + const view = viewGame(game, actor); view.players.find(player => player.id === actor)!.bot = difficulty; + const action = botActions(view).find(action => ['charity', 'ready'].includes(action.type)); + assert.ok(action, `${difficulty} supplies the current actor’s native claim or Finish`); + game = act(game, actor, action, actions); + } + assert.ok(actions.length > 0); assert.equal(game.phase, 3); + assert.equal(game.charityOrder ?? null, null); + assert.deepEqual(inventory(game), f.original); + } +}); diff --git a/tests/charity-claim-controls.test.tsx b/tests/charity-claim-controls.test.tsx index a3ee5afb..8159acfb 100644 --- a/tests/charity-claim-controls.test.tsx +++ b/tests/charity-claim-controls.test.tsx @@ -30,25 +30,35 @@ function projected({ choamCharity: { turn: 3, canceled: false }, }); game.players[0].spice = spice; - return viewGame(game, 'p'); + const view = viewGame(game, 'p'); + view.charityOrder = null; + view.charity.orderBlocked = null; + return view; +} + +function ordered(remaining = ['p', 'other'], order = ['p', 'other']): GameView { + const game = projected({ advanced: true, spice: 1 }); + game.charityOrder = { version: 1, turn: game.turn, order, remaining }; + game.charity.orderBlocked = remaining[0] === 'p' ? null : 'Wait for the current Charity turn.'; + return game; } function markup(game: GameView, busy = false) { return renderToStaticMarkup(createElement(CharityClaim, { game, busy, act() {} })); } -function click(node: ReactNode): boolean { +function click(node: ReactNode, selector = 'charity-claim-submit'): boolean { for (const child of Children.toArray(node)) { if (!isValidElement<{ children?: ReactNode; onClick?: () => void; 'data-testid'?: string; }>(child)) continue; - if (child.props['data-testid'] === 'charity-claim-submit' && child.props.onClick) { + if (child.props['data-testid'] === selector && child.props.onClick) { child.props.onClick(); return true; } - if (click(child.props.children)) return true; + if (click(child.props.children, selector)) return true; } return false; } @@ -59,6 +69,12 @@ function claim(game: GameView, busy = false): Action[] { return actions; } +function finish(game: GameView, busy = false): Action[] { + const actions: Action[] = []; + click(CharityClaim({ game, busy, act(action) { actions.push(action); } }), 'charity-turn-finish'); + return actions; +} + void test('the actual charity control submits one ordinary claim action from its current entitlement', () => { const game = projected({ spice: 1 }); assert.match(markup(game), /If you have fewer than two spice, claim charity once to reach two/); @@ -66,6 +82,55 @@ void test('the actual charity control submits one ordinary claim action from its assert.deepEqual(claim(game), [{ type: 'charity' }]); }); +void test('stamped current Charity controls submit exact claim and finish packets without changing funding guidance', () => { + const game = ordered(), html = markup(game); + assert.match(html, /First Player counterclockwise, one player at a time/); + assert.match(html, /It is your Charity turn/); assert.match(html, /Finish Charity turn/); + assert.match(html, /CHOAM pays as much of the ordinary claim as it can/); + assert.deepEqual(claim(game), [{ type: 'charity' }]); + assert.deepEqual(finish(game), [{ type: 'ready' }]); + game.players[0].charityClaimed = true; game.charity.amount = 0; + assert.deepEqual(claim(game), []); assert.deepEqual(finish(game), [{ type: 'ready' }]); +}); + +void test('waiting and completed Charity turns show public names and cannot claim or finish', () => { + const waiting = ordered(['other', 'p'], ['other', 'p']); + const complete = ordered(['other']); + for (const game of [waiting, complete]) { + Object.defineProperty(game.players[1], 'spice', { get() { throw Error('Turn status cannot inspect a rival wallet'); } }); + assert.match(markup(game), /Waiting for Other to finish Charity/); + assert.deepEqual(claim(game), []); assert.deepEqual(finish(game), []); + } + assert.match(markup(waiting), /Your turn comes later in the public order/); + assert.match(markup(complete), /Your Charity turn is complete/); + const ended = ordered([]); assert.match(markup(ended), /All Charity turns are complete/); + assert.deepEqual(finish(ended), []); +}); + +void test('stamped Charity claim and finish retain native pending-income and modal priority', () => { + for (const change of [ + (game: GameView) => { game.charity.incomePending = true; }, + (game: GameView) => { game.response = { kind: 'bgCharity', owner: 'other', passed: [], amount: 2 }; }, + (game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, + (game: GameView) => { game.phaseOpening = { passed: [] }; }, + (game: GameView) => { game.truthtrance = {} as NonNullable; }, + (game: GameView) => { game.nexusTraitors = { offer: null, pending: {} as NonNullable['pending']> }; }, + (game: GameView) => { game.automaticContinuationPending = true; }, + ]) { + const game = ordered(); change(game); assert.deepEqual(claim(game), []); assert.deepEqual(finish(game), []); + } + const game = ordered(); assert.deepEqual(claim(game, true), []); assert.deepEqual(finish(game, true), []); + assert.match(markup(game, true), /data-testid="charity-turn-finish"[^>]*disabled/); +}); + +void test('Basic and unstamped Advanced Charity keep their existing claim control without a new finish action', () => { + for (const advanced of [false, true]) { + const game = projected({ advanced }); + assert.deepEqual(claim(game), [{ type: 'charity' }]); assert.deepEqual(finish(game), []); + assert.doesNotMatch(markup(game), /charity-turn-finish|charity-turn-status|First Player counterclockwise/); + } +}); + void test('busy, pending income, a completed claim and zero entitlement block the actual callback', () => { const game = projected(); assert.deepEqual(claim(game, true), []); diff --git a/tests/charity-order.test.ts b/tests/charity-order.test.ts new file mode 100644 index 00000000..a739d390 --- /dev/null +++ b/tests/charity-order.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + CharityOrderError, + charityOrderBlock as block, + createCharityOrder as create, + finishCharityOrder as finish, + validateCharityOrder as validate, + type CharityOrder, + type CharityOrderContext, +} from '../game/charity-order'; + +function context(ready: readonly string[] = []): CharityOrderContext { + return { turn: 3, order: ['c', 'a', 'b'], players: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], ready }; +} +function rejects(current: CharityOrderContext, frame: CharityOrder, action: () => unknown): void { + const beforeContext = structuredClone(current), beforeFrame = structuredClone(frame); + assert.throws(action, CharityOrderError); + assert.deepEqual(current, beforeContext); + assert.deepEqual(frame, beforeFrame); +} + +void test('Charity preserves the actual rotated First Player order rather than roster order', () => { + const current = context(), frame = create(current); + assert.deepEqual(frame, { version: 1, turn: 3, order: ['c', 'a', 'b'], remaining: ['c', 'a', 'b'] }); + validate(current, frame); + assert.equal(block(current, frame, 'c'), null); + assert.match(block(current, frame, 'a')!, /Wait/); + assert.match(block(current, frame, 'foreign')!, /seated/); + const resumed = context(['c']); + assert.deepEqual(create(resumed), { ...frame, remaining: ['a', 'b'] }); + assert.match(block(resumed, create(resumed), 'c')!, /finished/); + assert.equal(block(resumed, create(resumed), 'a'), null); +}); + +void test('only the current seat finishes, consuming the pre-finish prefix before the caller appends ready', () => { + const current = context(), frame = create(current); + rejects(current, frame, () => finish(current, frame, 'a')); + rejects(current, frame, () => finish(current, frame, 'foreign')); + const next = finish(current, frame, 'c'); + assert.deepEqual(next.remaining, ['a', 'b']); + assert.deepEqual(current.ready, []); + validate(context(['c']), next); + rejects(current, frame, () => validate(current, next)); + rejects(current, frame, () => finish(context(['c']), frame, 'c')); + const afterSecond = finish(context(['c']), next, 'a'); + validate(context(['c', 'a']), afterSecond); + assert.deepEqual(afterSecond.remaining, ['b']); + rejects(context(['c']), next, () => finish(context(['c']), next, 'c')); +}); + +void test('the final completed Charity frame stays valid and never reopens a seat', () => { + const current = context(['c', 'a']), frame = create(current); + const done = finish(current, frame, 'b'), completed = context(['c', 'a', 'b']); + validate(completed, done); + assert.deepEqual(done.remaining, []); + assert.deepEqual(create(completed), done); + for (const player of completed.order) { + assert.match(block(completed, done, player)!, /complete/); + rejects(completed, done, () => finish(completed, done, player)); + } +}); + +void test('the native context requires actual unique seats, their permutation and an exact completed prefix', () => { + const base = context(); + for (const current of [ + { ...base, turn: 0 }, { ...base, turn: 1.5 }, { ...base, turn: Number.NaN }, + { ...base, players: [{ id: 'a' }] }, + { ...base, players: ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map(id => ({ id })) }, + { ...base, players: [{ id: 'a' }, { id: 'a' }, { id: 'c' }] }, + { ...base, players: [{ id: 'a' }, { id: 'b' }, { id: ' ' }] }, + { ...base, order: ['c', 'a'] }, { ...base, order: ['c', 'a', 'a'] }, + { ...base, order: ['c', 'a', 'foreign'] }, context(['a']), context(['c', 'b']), + context(['c', 'c']), context(['c', 'a', 'b', 'foreign']), + ]) assert.throws(() => create(current), CharityOrderError); + for (const current of [null, [], { ...base, players: undefined }, { ...base, order: 'c,a,b' }, + { ...base, ready: undefined }, { ...base, order: Array.from({ length: 3 }) }, + { ...base, players: Array.from({ length: 3 }) }, { ...base, ready: Array.from({ length: 1 }) }]) + assert.throws(() => create(current as unknown as CharityOrderContext), CharityOrderError); + for (const order of [['b', 'a'], ['f', 'a', 'b', 'c', 'd', 'e']]) { + const current = { turn: 1, order, players: [...order].reverse().map(id => ({ id })), ready: [] }; + validate(current, create(current)); + } +}); + +void test('stale, rearranged or extended frames cannot replace the original remaining suffix', () => { + const current = context(['c']), frame = create(current); + const missing = { ...frame } as Partial; + delete missing.remaining; + for (const value of [ + null, [], missing, { ...frame, extra: true }, { ...frame, started: false }, + { ...frame, version: 2 }, { ...frame, turn: 2 }, + { ...frame, order: ['a', 'c', 'b'] }, { ...frame, order: ['c', 'a', 'a'] }, + { ...frame, remaining: ['b', 'a'] }, { ...frame, remaining: ['a'] }, + { ...frame, remaining: ['c', 'a', 'b'] }, { ...frame, remaining: ['a', 'foreign'] }, + ]) rejects(current, frame, () => validate(current, value as unknown as CharityOrder)); + rejects(current, frame, () => validate({ ...current, turn: 4 }, frame)); + rejects(current, frame, () => validate({ ...current, order: ['a', 'c', 'b'], ready: [] }, frame)); + rejects(current, frame, () => finish(current, frame, '')); +}); + +void test('creation and finish detach both public arrays without changing the original context or frame', () => { + const current = context(['c']), before = structuredClone(current); + Object.freeze(current.order); + Object.freeze(current.ready); + Object.freeze(current.players); + Object.freeze(current); + const frame = create(current), original = structuredClone(frame); + Object.freeze(frame.order); + Object.freeze(frame.remaining); + Object.freeze(frame); + const next = finish(current, frame, 'a'); + assert.notEqual(frame.order, current.order); + assert.notEqual(frame.remaining, current.order); + assert.notEqual(frame.order, frame.remaining); + assert.notEqual(next.order, frame.order); + assert.notEqual(next.remaining, frame.remaining); + next.order.pop(); + next.remaining.pop(); + assert.deepEqual(current, before); + assert.deepEqual(frame, original); +}); diff --git a/tests/choam-charity-funding-engine.test.ts b/tests/choam-charity-funding-engine.test.ts index edf1d131..b6984a8d 100644 --- a/tests/choam-charity-funding-engine.test.ts +++ b/tests/choam-charity-funding-engine.test.ts @@ -26,7 +26,10 @@ for (const advanced of [false, true]) { void test(`${advanced ? 'Advanced' : 'Basic'} native other-faction full claim uses provisional shortfall funding`, () => { for (const choamSpice of [0, 1] as const) { const f = fixture({ advanced, choamSpice }), before = structuredClone(f.game); - const g = act(f.game, f.guild, f.otherClaim); + let g = structuredClone(f.game); + while (g.charityOrder && g.charityOrder.remaining[0] !== f.guild) + g = act(g, g.charityOrder.remaining[0], { type: 'ready' }); + g = act(g, f.guild, f.otherClaim); assert.equal(player(g, f.choam).spice, 0); assert.equal(player(g, f.guild).spice, 2); assert.equal(player(g, f.recipient).bribes, 13 - choamSpice); @@ -37,17 +40,21 @@ for (const advanced of [false, true]) { assert.throws(() => act(g, f.guild, f.otherClaim), /once per turn|fewer than/); } }); - void test(`${advanced ? 'Advanced' : 'Basic'} each low faction keeps exactly one independent claim in either order`, () => { + void test(`${advanced ? 'Advanced' : 'Basic'} each low faction keeps exactly one claim ${advanced ? 'in native player order' : 'in either order'}`, () => { const f = fixture({ advanced, choamSpice: 1, guildSpice: 1 }); - for (const order of [[f.choam, f.guild], [f.guild, f.choam]]) { + for (const order of advanced ? [[f.choam, f.guild]] : [[f.choam, f.guild], [f.guild, f.choam]]) { let g = structuredClone(f.game); - for (const actor of order) g = act(g, actor, { type: 'charity' }); + for (const actor of order) { + while (g.charityOrder && g.charityOrder.remaining[0] !== actor) + g = act(g, g.charityOrder.remaining[0], { type: 'ready' }); + g = act(g, actor, { type: 'charity' }); + } assert.equal(player(g, f.guild).spice, 2); assert.equal(player(g, f.choam).spice, order[0] === f.choam ? 1 : 2); assert.equal(player(g, f.recipient).bribes, 11); for (const actor of order) { assert.equal(viewGame(g, actor).players.find(p => p.id === actor)!.charityClaimed, true); - assert.throws(() => act(g, actor, { type: 'charity' }), /once per turn|fewer than/); + assert.throws(() => act(g, actor, { type: 'charity' }), /once per turn|fewer than|Charity opportunity/); } assert.deepEqual(inventory(g), f.originalInventory); } diff --git a/tests/choam-charity.test.ts b/tests/choam-charity.test.ts index 1242fbc0..8cc3ce2f 100644 --- a/tests/choam-charity.test.ts +++ b/tests/choam-charity.test.ts @@ -40,11 +40,21 @@ function fixture(advanced = false, ix = false) { } function ready(state: Game) { let g = state; - for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + const players = g.phase === 2 && g.charityOrder ? [...g.charityOrder.remaining] : g.players.map(p => p.id); + for (const player of players) g = applyAction(g, player, { type: 'ready' }); while (g.decision?.kind === 'choamMarket') g = applyAction(g, g.decision.player, { type: 'decision', done: true }); return g; } +function charityTurn(state: Game, player: string) { + let g = state; + while (g.phase === 2 && g.charityOrder && g.charityOrder.remaining[0] !== player) { + const current = g.charityOrder.remaining[0]; + assert.ok(current, 'the requested Charity opportunity must still be pending'); + g = applyAction(g, current, { type: 'ready' }); + } + return g; +} function allow(state: Game) { let g = state; while (g.response) { @@ -162,6 +172,7 @@ void test('clean BG charity Karama cancellation offers Semuta without awarding e g = allow(ready(g)); contest(g); g.players[2].spice = 12; + g = charityTurn(g, 'b'); g = applyAction(g, 'b', { type: 'charity' }); assert.equal(g.response?.kind, 'bgCharity'); assert.equal(viewGame(g, 'b').players[2].charityClaimed, true); @@ -210,9 +221,11 @@ void test('CHOAM pays ordinary charity by actual need and advanced Bene Gesserit let g = allow(ready(fixture(advanced))); g.players[1].spice = starting; g.players[2].spice = starting; + g = charityTurn(g, 'e'); g = applyAction(g, 'e', { type: 'charity' }); assert.equal(g.players[1].spice, 2); assert.equal(g.players[0].spice, 6 - (2 - starting)); + g = charityTurn(g, 'b'); g = applyAction(g, 'b', { type: 'charity' }); assert.equal(g.players[2].spice, advanced ? starting + 2 : 2); assert.equal( @@ -230,6 +243,7 @@ void test('wealthy Bene Gesserit payment waits for its response; cancellation ne let g = allow(ready(fixture(true))); contest(g); g.players[2].spice = 12; + g = charityTurn(g, 'b'); g = applyAction(g, 'b', { type: 'charity' }); assert.equal(g.response?.kind, 'bgCharity'); assert.equal(g.players[0].spice, 6); @@ -245,6 +259,7 @@ void test('wealthy BG response preserves cancellation before provisional shortfa contest(g); g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 }); g.players[2].spice = 12; + g = charityTurn(g, 'b'); g = applyAction(g, 'b', { type: 'charity' }); assert.equal(g.response?.kind, 'bgCharity'); assert.equal(g.players[0].spice, 0); @@ -260,7 +275,10 @@ void test('Karama cancels CHOAM income, restores bank payments and permits CHOAM let g = cancel(ready(contest(fixture(true)))); assert.equal(g.players[0].spice, 0); assert.deepEqual(g.choamCharity, { turn: 1, canceled: true }); - for (const id of ['c', 'e', 'b']) g = applyAction(g, id, { type: 'charity' }); + for (const id of g.order) { + g = charityTurn(g, id); + g = applyAction(g, id, { type: 'charity' }); + } assert.deepEqual( g.players.map((p) => p.spice), [2, 2, 2], @@ -336,6 +354,7 @@ void test('charity projections expose the payer and own entitlement without leak incomePending: false, incomeCanceled: false, incomeHomeworld: 0, + orderBlocked: null, }); assert.equal(v.players[0].spice, undefined); assert.equal(v.players[0].handLimit, 5); @@ -346,10 +365,13 @@ void test('ordinary funded claims still accrue and settle Spice Production once' g.techTokens = createTechTokens(); g.techTokens.production.owner = 'e'; g = allow(ready(g)); - g = applyAction(g, 'b', { type: 'charity' }); + g = charityTurn(g, 'e'); assert.equal(g.techTokens!.production.spice, 0); g = applyAction(g, 'e', { type: 'charity' }); assert.equal(g.techTokens!.production.spice, 1); + g = charityTurn(g, 'b'); + g = applyAction(g, 'b', { type: 'charity' }); + assert.equal(g.techTokens!.production.spice, 1); g = ready(g); assert.equal(g.players[1].spice, 3); assert.equal(g.techTokens!.production.spice, 0); @@ -419,10 +441,20 @@ void test('all AI levels proceed after automatic CHOAM income and claim funded c assert.ok(actions.length); g = applyAction(g, p.id, actions[0]); } + assert.equal(g.charityOrder?.remaining[0], 'c'); + const choam = botActions(viewGame(g, g.charityOrder!.remaining[0])); + assert.equal(choam[0]?.type, 'ready'); + g = applyAction(g, 'c', choam[0]); for (const id of ['e', 'b']) { - const actions = botActions(viewGame(g, id)); - assert.equal(actions[0].type, 'charity'); + assert.equal(g.charityOrder?.remaining[0], id); + const actions = botActions(viewGame(g, g.charityOrder!.remaining[0])); + assert.equal(actions[0]?.type, 'charity'); g = applyAction(g, id, actions[0]); + if (id === 'e') { + const finish = botActions(viewGame(g, g.charityOrder!.remaining[0])); + assert.equal(finish[0]?.type, 'ready'); + g = applyAction(g, id, finish[0]); + } } assert.deepEqual( g.players.map((p) => p.spice), diff --git a/tests/choam-inflation.test.ts b/tests/choam-inflation.test.ts index 9ae989c8..946f5f5a 100644 --- a/tests/choam-inflation.test.ts +++ b/tests/choam-inflation.test.ts @@ -35,11 +35,21 @@ function fixture(advanced = false) { } function ready(state: Game) { let g = state; - for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + const players = g.phase === 2 && g.charityOrder ? [...g.charityOrder.remaining] : g.players.map(p => p.id); + for (const player of players) g = applyAction(g, player, { type: 'ready' }); while (g.decision?.kind === 'choamMarket') g = applyAction(g, g.decision.player, { type: 'decision', done: true }); return g; } +function charityTurn(state: Game, player: string) { + let g = state; + while (g.phase === 2 && g.charityOrder && g.charityOrder.remaining[0] !== player) { + const current = g.charityOrder.remaining[0]; + assert.ok(current, 'the requested Charity opportunity must still be pending'); + g = applyAction(g, current, { type: 'ready' }); + } + return g; +} function allow(state: Game) { let g = state; while (g.response) { @@ -79,7 +89,7 @@ function nextCharity(state: Game) { return ready(g); } function mentat(state: Game) { - const g = structuredClone(state); + const g = structuredClone(state.phase === 2 && state.charityOrder ? ready(state) : state); g.phase = 7; g.ready = []; return ready(g); @@ -186,8 +196,10 @@ void test('Double changes ordinary payout, preserves ordinary eligibility and do assert.equal(charityAmount(g, g.players[1]), ordinary); assert.equal(charityAmount(g, g.players[2]), bg); const before = g.players[0].spice; + g = charityTurn(g, 'e'); if (ordinary) g = applyAction(g, 'e', { type: 'charity' }); else assert.throws(() => applyAction(g, 'e', { type: 'charity' }), /fewer/); + g = charityTurn(g, 'b'); g = allow(applyAction(g, 'b', { type: 'charity' })); assert.equal(g.players[1].spice, starting + ordinary); assert.equal(g.players[2].spice, starting + bg); @@ -198,10 +210,12 @@ void test('Cancel suppresses all claims and CHOAM income, including wealthy adva let g = nextCharity(allow(place(ready(fixture(true)), 'cancel'))); assert.equal(g.response, null); assert.equal(g.players[0].spice, 20); - for (const p of g.players) { + for (const id of g.order) { + g = charityTurn(g, id); + const p = g.players.find(player => player.id === id)!; assert.equal(charityAmount(g, p), 0); - assert.throws(() => applyAction(g, p.id, { type: 'charity' }), /fewer/); - assert.equal(viewGame(g, p.id).charity.incomePending, false); + assert.throws(() => applyAction(g, id, { type: 'charity' }), /fewer/); + assert.equal(viewGame(g, id).charity.incomePending, false); } g = ready(g); assert.equal(g.phase, 3); @@ -211,7 +225,9 @@ void test('canceling doubled CHOAM income preserves doubled charity from the ban g.players[0].spice = 0; g.players[1].spice = 1; g = applyAction(g, 'c', { type: 'charity' }); + g = charityTurn(g, 'e'); g = applyAction(g, 'e', { type: 'charity' }); + g = charityTurn(g, 'b'); g = allow(applyAction(g, 'b', { type: 'charity' })); assert.deepEqual( g.players.map((p) => p.spice), @@ -222,6 +238,7 @@ void test('canceling doubled CHOAM income preserves doubled charity from the ban void test('canceling doubled wealthy Bene Gesserit charity does not charge CHOAM', () => { let g = allow(nextCharity(allow(place(ready(fixture(true)))))); const before = g.players[0].spice; + g = charityTurn(g, 'b'); g = cancel(applyAction(contest(g), 'b', { type: 'charity' })); assert.equal(g.players[0].spice, before); assert.equal(g.players[2].spice, 20); @@ -327,7 +344,11 @@ void test('all AI levels place Inflation legally, finish its response and do not p.bot = difficulty; p.spice = 0; }); - for (const p of g.players) - assert.equal(botActions(viewGame(g, p.id))[0].type, 'ready'); + for (const id of g.order) { + assert.equal(g.charityOrder?.remaining[0], id); + const actions = botActions(viewGame(g, g.charityOrder!.remaining[0])); + assert.equal(actions[0]?.type, 'ready'); + g = applyAction(g, id, actions[0]); + } } }); diff --git a/tests/fixture-advanced-charity-order.ts b/tests/fixture-advanced-charity-order.ts new file mode 100644 index 00000000..6338a13b --- /dev/null +++ b/tests/fixture-advanced-charity-order.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { treacheryDeck } from '../game/cards'; +import { traitorDeck } from '../game/traitors'; +import { actChoamCharityFunding, nextChoamCharityFundingStep, + type ChoamCharityFundingStep } from './fixture-choam-charity-funding'; + +export type AdvancedCharityOrderStep = ChoamCharityFundingStep; +export const actAdvancedCharityOrder = actChoamCharityFunding; +export const advancedCharityOrderReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game; +export const advancedCharityOrderPlayer = (game: Game, actor: string) => { + const player = game.players.find(player => player.id === actor); assert.ok(player); return player; +}; +export const advancedCharityOrderClean = (game: Game) => !game.response && !game.phaseOpening && + !game.decision && !game.pendingTreacheryDiscard && !game.truthtrance; + +export function advancedCharityOrderInventory(game: Game) { + const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand), + ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])] + .map(card => card.id).sort(); + assert.deepEqual(cards, treacheryDeck(game.expansions).map(card => card.id).sort()); + const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(player => + [...player.traitors, ...player.traitorChoices])].sort(); + if (traitors.length) assert.deepEqual(traitors, traitorDeck(game.players, false).sort()); + const spice = [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(); + for (const player of game.players) + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20); + return { cards, traitors, spice }; +} + +/** Setup, original counters and current Charity Finish only; no automatic claim. */ +export function nextAdvancedCharityOrderStep(game: Game): AdvancedCharityOrderStep | null { + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + if (game.status === 'setup' && game.setupStage === 'prediction') + return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } }; + if (game.status === 'setup' && game.setupStage === 'forces' && game.advanced && + !advancedCharityOrderPlayer(game, 'beneGesserit').advisorSetup) + return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'polar_sink', sector: 0 } }; + if (game.decision?.kind === 'advisorBattle') + return { actor: game.decision.player, action: { type: 'decision', accept: false } }; + if (game.phase === 2 && !game.decision && game.charityOrder?.remaining.length) + return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } }; + } + return nextChoamCharityFundingStep(game); +} +export function advanceAdvancedCharityOrder(game: Game, until: (state: Game) => boolean, + actions: AdvancedCharityOrderStep[] = []): Game { + for (let count = 0; count < 600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Charity chronology ended before its requested boundary'); + const next = nextAdvancedCharityOrderStep(game); assert.ok(next); + game = actAdvancedCharityOrder(game, next.actor, next.action, actions); + } + throw Error('The original Charity programme did not reach its finite boundary'); +} +export function passAdvancedCharityResponses(game: Game, actions: AdvancedCharityOrderStep[] = []): Game { + for (let count = 0; game.response && count < 40; count++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); + game = actAdvancedCharityOrder(game, actor.id, { type: 'passResponse' }, actions); + } + assert.equal(game.response ?? null, null); return game; +} +export function allowAdvancedCharityOpening(game: Game, actions: AdvancedCharityOrderStep[] = []): Game { + assert.equal(game.response?.kind, 'choamCharity'); + game = passAdvancedCharityResponses(game, actions); + return advanceAdvancedCharityOrder(game, state => state.phase === 2 && advancedCharityOrderClean(state), actions); +} +export function finishAdvancedCharityTurn(game: Game, actions: AdvancedCharityOrderStep[] = []): Game { + const actor = game.charityOrder?.remaining[0]; assert.ok(actor); + return actAdvancedCharityOrder(game, actor, { type: 'ready' }, actions); +} +export function finishAdvancedCharityPhase(game: Game, actions: AdvancedCharityOrderStep[] = []): Game { + return advanceAdvancedCharityOrder(game, state => state.phase !== 2, actions); +} + +/** Actual public CHOAM/BG/classic setup. Only original unopened starting-card + * and land-Spice positions plus scalar lotteries are selected. A real phase1 + * bribe empties Atreides' purse; its recipient retains deferred incoming spice. + * No purse, phase, order, readiness, claim, force or receipt is installed. + */ +export function createAdvancedCharityOrderFixture(options: { advanced?: boolean; lowBG?: boolean } = {}) { + const advanced = options.advanced ?? true; + let game = createGame('ADVANCEDCHARITYORDER', newPlayer('atreides', 'Atreides', 'atreides'), advanced, ['choam']); + joinGame(game, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit')); + joinGame(game, newPlayer('choam', 'CHOAM', 'choam')); + const initial = structuredClone(game), actions: AdvancedCharityOrderStep[] = []; + const act = (actor: string, action: Action) => { game = actAdvancedCharityOrder(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceAdvancedCharityOrder(game, until, actions); }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length)); + const karama = game.deck.find(card => card.effect === 'karama'); + const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2); + assert.ok(karama); assert.equal(ordinary.length, 2); + const prefix = [karama, ...ordinary]; + const dealOrder = prefix.map((card, position) => { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: card.id }; + }); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game), setupActions = structuredClone(actions); + assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), prefix.map(card => card.id)); + const original = advancedCharityOrderInventory(game); + const spiceOrder = [0, 1].map(position => { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card); assert.ok(from >= position); + const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card); + return { position, from, card: structuredClone(card) }; + }); + reach(state => state.phase === 1 && advancedCharityOrderClean(state)); + const beforeBribes = structuredClone(game), bribeFrom = actions.length; + const low = ['atreides', ...(options.lowBG ? ['beneGesserit'] : [])]; + for (const actor of low) { + const amount = advancedCharityOrderPlayer(game, actor).spice; assert.ok(amount > 0); + act(actor, { type: 'bribe', target: 'choam', amount }); + assert.equal(advancedCharityOrderPlayer(game, actor).spice, 0); + } + const bribeActions = structuredClone(actions.slice(bribeFrom)); + reach(state => state.phase === 2 && state.response?.kind === 'choamCharity'); + assert.equal(advancedCharityOrderPlayer(game, 'choam').spice, 2); + assert.equal(advancedCharityOrderPlayer(game, 'atreides').spice, 0); + assert.ok(advancedCharityOrderPlayer(game, 'choam').bribes > 0); + assert.deepEqual(advancedCharityOrderInventory(game), original); + return { initial, lobby, setup, afterSetup, beforeBribes, beforeIncome: structuredClone(game), game, + advanced, lowBG: options.lowBG ?? false, karama: karama.id, original, + claimant: 'atreides', bg: 'beneGesserit', provider: 'choam', dealOrder, spiceOrder, + actions, setupActions, bribeActions, + sourceCounts: { setup: setupActions.length, bribes: bribeActions.length, source: actions.length }, + sourceDisclosure: 'Original public CHOAM/BeneGesserit/Atreides setup and native first Storm/Spice/closing controls. Advanced uses the explicit original preview start. Only original unopened starting-deal and land-Spice positions plus scalar lotteries selected. Real pre-Charity bribes create actual low claimants and deferred CHOAM incoming receipts; no money, order, phase, readiness or claim marker installed. Source stops at the original first CHOAM opening income response.' }; +} + +/** Explicit unstamped UNIT supplement; not claimed historical saved-game proof. */ +export function unstampedAdvancedCharitySupplement(game: Game): Game { + const old = advancedCharityOrderReload(game); + delete old.charityOrder; + return old; +} diff --git a/tests/fixture-stronghold-factions.ts b/tests/fixture-stronghold-factions.ts index 93e9b516..9dc52ab2 100644 --- a/tests/fixture-stronghold-factions.ts +++ b/tests/fixture-stronghold-factions.ts @@ -144,6 +144,10 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions } if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } }; if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } }; + if (game.phase === 2 && game.charityOrder) { + const actor = game.charityOrder.remaining[0]; + return actor ? {actor,action:{type:'ready'}} : null; + } if (game.phase === 4 && game.revivalOrder) { const actor = game.revivalOrder.remaining[0]; return actor ? {actor,action:{type:'ready'}} : null; diff --git a/tests/truth-charity-payments-runtime.test.ts b/tests/truth-charity-payments-runtime.test.ts index a5ba0cbd..001fc66c 100644 --- a/tests/truth-charity-payments-runtime.test.ts +++ b/tests/truth-charity-payments-runtime.test.ts @@ -149,7 +149,7 @@ void test('already claimed or genuinely expired Charity cannot be reserved again game = answer(asked, expired.target, 'no'); physical(game, expired); }); -void test('a phase2 positive answer reopens real own Ready and cannot forfeit its necessary Charity claim', () => { +void test('a Basic phase2 positive answer reopens real own Ready and cannot forfeit its necessary Charity claim', () => { for (const kind of ['turnActions', 'bribe'] as const) { const f = fixture({ source: 'phase2' }); let source = pay(f.game, f, 5); source = act(source, f.target, { type: 'ready' }); assert.ok(source.ready.includes(f.target)); @@ -162,6 +162,47 @@ void test('a phase2 positive answer reopens real own Ready and cannot forfeit it } }); +void test('Advanced future Charity can fund an answer but its real claim guide waits for the original owned turn', () => { + const f = fixture({ source: 'phase2', advanced: true }); + assert.notEqual(f.game.charityOrder!.remaining[0], f.target); + let game = bind(f, 'yes').game; + game = pay(game, f, 5); + assert.equal(player(game, f.target).spice, 0); + assert.notEqual(viewGame(game, f.target).turnActionGuide?.action.type, 'charity'); + rejected(game, f.target, f.charity); + while (game.charityOrder!.remaining[0] !== f.target) + game = act(game, game.charityOrder!.remaining[0], { type: 'ready' }); + const guide = viewGame(game, f.target).turnActionGuide; + assert.equal(guide?.action.type, 'charity'); + rejected(game, f.target, { type: 'ready' }); + game = act(game, f.target, guide!.action); + game = pay(game, f, 1); + assert.equal(promise(game).fulfilled, true); + assert.equal(player(game, f.target).spice, 1); + physical(game, f); +}); + +void test('new Advanced answers cannot reopen an actually completed Charity opportunity for another claim', () => { + for (const kind of ['turnActions', 'bribe'] as const) { + const f = fixture({ source: 'phase2', advanced: true }), respondent = 'atreides'; + let game = act(f.game, respondent, { type: 'bribe', target: f.asker, amount: player(f.game, respondent).spice }); + while (!game.ready.includes(respondent)) + game = act(game, game.charityOrder!.remaining[0], { type: 'ready' }); + assert.equal(game.phase, 2); assert.equal(game.active, f.target); + const queue = structuredClone(game.charityOrder), ready = [...game.ready]; + const raw: TruthQuestion = { ...question(f, kind, 1), target: respondent }; + const asked = ask(declare(f, game).game, f.asker, raw); + const view = viewGame(asked, respondent); + assert.deepEqual(kind === 'turnActions' ? view.truthTurnActionAnswers : view.truthBribeAnswers, ['no']); + rejected(asked, respondent, { type: 'truthAnswer', answer: 'yes' }); + game = answer(asked, respondent, 'no'); + assert.deepEqual(game.charityOrder, queue); assert.deepEqual(game.ready, ready); + rejected(game, respondent, f.charity); + assert.notEqual(player(game, respondent).charityTurn, game.turn); + physical(game, f); + } +}); + void test('one native Charity is shared with shipment and cannot be claimed early to strand remaining payments', () => { const f = fixture({ source: 'afterAmal' }); const impossible: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn, diff --git a/tests/truth-choam-income-runtime.test.ts b/tests/truth-choam-income-runtime.test.ts index b6452d4b..6b3143fa 100644 --- a/tests/truth-choam-income-runtime.test.ts +++ b/tests/truth-choam-income-runtime.test.ts @@ -172,9 +172,13 @@ void test('Advanced BG forced settlement after CHOAM passes last releases the fi const f = fixture({ advanced: true, bg: true }); assert.ok(f.bg); assert.ok(f.providerCard); assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.providerCard]); for (const kind of ['turnActions', 'bribe'] as const) { - let game = bind(f, 'yes', question(f, kind, f.maximum), structuredClone(f.game)).game; + let game = bind(f, 'yes', question(f, kind, f.minimum), structuredClone(f.game)).game; game = pay(game, f, f.startCash); game = allow(reachIncome(game)); assert.equal(player(game, f.target).spice, f.incomeAmount); assert.equal(player(game, f.bg).spice, 5); + // CHOAM's current cash meets this fixed-income answer. Finish its actual + // turn before BG acts; the closed ordinary claim cannot repair a later debit. + while (game.charityOrder!.remaining[0] !== f.bg) + game = act(game, game.charityOrder!.remaining[0], { type: 'ready' }); game = act(game, f.bg, f.charity); assert.equal(game.response?.kind, 'bgCharity'); assert.equal(player(game, f.target).spice, f.incomeAmount); assert.ok(!promise(game, kind).released); while (game.response?.kind === 'bgCharity') {