From 70ef142577958e6070ecadbc953738ed162bc385 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:36:27 +1100
Subject: [PATCH] Support Advanced Sapho Charity suspension
---
components/charity-claim.tsx | 6 +-
components/juice-of-sapho.tsx | 10 +-
components/revival-turn.tsx | 2 +-
docs/ADVANCED_CHARITY_ORDER.md | 43 +++++--
docs/CURRENT_STATUS.md | 13 ++-
docs/IMPLEMENTATION_STATUS.md | 10 ++
docs/JUICE_OF_SAPHO_RUNTIME.md | 23 ++++
docs/RULE_DECISIONS.md | 10 ++
docs/TWO_PLAYER_TWO_FACTIONS.md | 116 +++++++++++++++++++
game/charity-order.ts | 99 +++++++++++++---
game/engine.ts | 11 +-
game/reference.ts | 5 +-
tests/sapho-charity-order-controls.test.tsx | 54 +++++++++
tests/sapho-charity-order-engine.test.ts | 16 ++-
tests/sapho-charity-order.test.ts | 4 +-
tests/sapho-charity-own-semuta.test.ts | 94 +++++++++++++++
tests/sapho-charity-own-suspension.test.ts | 114 ++++++++++++++++++
tests/sapho-charity-suspension-quote.test.ts | 75 ++++++++++++
tests/sapho-revival-order-controls.test.tsx | 38 ++++++
19 files changed, 694 insertions(+), 49 deletions(-)
create mode 100644 docs/TWO_PLAYER_TWO_FACTIONS.md
create mode 100644 tests/sapho-charity-own-semuta.test.ts
create mode 100644 tests/sapho-charity-own-suspension.test.ts
create mode 100644 tests/sapho-charity-suspension-quote.test.ts
diff --git a/components/charity-claim.tsx b/components/charity-claim.tsx
index 09d97d61..f75b7af7 100644
--- a/components/charity-claim.tsx
+++ b/components/charity-claim.tsx
@@ -17,7 +17,7 @@ export function CharityClaim(props: Props) {
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.version === 2) && order.turn === turn && order.remaining[0] === actor;
+ const ownTurn = !order || (order.version === 1 || order.version === 2 || order.version === 3) && 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;
@@ -26,8 +26,8 @@ export function CharityClaim(props: Props) {
return (
<>
{order &&
-
{order.version === 2 ? 'Juice of Sapho changed the remaining Charity order. Completed turns stay finished.' : 'Advanced Charity proceeds from the First Player counterclockwise, one player at a time.'}
-
{(order.version !== 1 && order.version !== 2) || order.turn !== turn
+
{order.version === 2 || order.version === 3 ? 'Juice of Sapho changed the remaining Charity order. Completed turns stay finished.' : 'Advanced Charity proceeds from the First Player counterclockwise, one player at a time.'}
+
{(order.version !== 1 && order.version !== 2 && order.version !== 3) || 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.`
diff --git a/components/juice-of-sapho.tsx b/components/juice-of-sapho.tsx
index 8f83ef3f..9d739186 100644
--- a/components/juice-of-sapho.tsx
+++ b/components/juice-of-sapho.tsx
@@ -18,7 +18,7 @@ function unavailableReason(game: GameView) {
if (game.phase === 2)
return !game.advanced || !game.charityOrder
? 'Charity order changes require an ordered Advanced Charity phase. Basic and an already-opened unstamped phase keep their existing timing.'
- : 'Choose first or last only among remaining unstarted Charity turns. A player who has claimed finishes their Charity turn unchanged. Completed turns stay finished; already-held positions and committed cards are unavailable.';
+ : 'Change the order of remaining Charity turns. Another player finishes any started turn first. At a clean boundary, you may pause your own started turn and resume it last with your claim unchanged. Completed turns stay finished; already-held positions and committed cards are unavailable.';
if (game.phase === 4)
return !game.advanced || !game.revivalOrder
? 'Revival order changes require an ordered Advanced Revival phase. Basic and an already-opened unstamped phase keep their existing timing.'
@@ -67,7 +67,7 @@ export function SaphoOrderButton({
}}
>
{option.scope === 'battleAggressor' ? 'Become aggressor and discard Juice of Sapho' :
- (option.scope === 'movement' || option.scope === 'revival') && option.resumeOwn ? `Resume your ${option.scope} last and discard Juice of Sapho` :
+ (option.scope === 'movement' || option.scope === 'revival' || option.scope === 'charity') && option.resumeOwn ? `Resume your ${option.scope} last and discard Juice of Sapho` :
`${bidding ? 'Bid' : option.scope === 'battleOrder' ? 'Choose battles' : option.scope === 'extortion' ? 'Take your Extortion choice' : option.scope === 'revival' ? 'Take your Revival turn' : option.scope === 'charity' ? 'Take your Charity turn' : 'Take your movement turn'} ${option.mode} and discard Juice of Sapho`}
);
@@ -114,7 +114,7 @@ export function JuiceOfSapho({
const extortion = option.scope === 'extortion';
const revival = option.scope === 'revival';
const charity = option.scope === 'charity';
- const resumeOwn = (option.scope === 'movement' || revival) && option.resumeOwn;
+ const resumeOwn = (option.scope === 'movement' || revival || charity) && option.resumeOwn;
const afterCurrent = (option.scope === 'movement' || revival || charity) && option.afterCurrent;
const currentPlayer = afterCurrent
? game.players.find(player => player.id === afterCurrent)?.name ?? 'The active player'
@@ -154,7 +154,9 @@ export function JuiceOfSapho({
))}
{resumeOwn
- ? revival
+ ? charity
+ ? 'Pause your started Charity turn and resume it last, after the other remaining players. Your completed claim and its payment remain unchanged. This timing is provisional.'
+ : revival
? 'Pause your started Revival turn and resume it last, after the other remaining players. Returned pieces, costs and used revival allowances remain unchanged. This timing is provisional.'
: 'Pause your started turn and resume it last, after the other remaining players. Shipment and movement already used stay spent; no extra action is granted. This timing is provisional.'
: aggressor
diff --git a/components/revival-turn.tsx b/components/revival-turn.tsx
index 53c7dcf5..eeb5fa2a 100644
--- a/components/revival-turn.tsx
+++ b/components/revival-turn.tsx
@@ -10,7 +10,7 @@ type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
export function revivalTurnOrderBlock(game: GameView): string | null {
const order = game.revivalOrder;
if (!game.advanced || !order) return null;
- if ((order.version !== 1 && order.version !== 2) || order.turn !== game.turn || game.status !== 'playing' || game.phase !== 4)
+ if ((order.version !== 1 && order.version !== 2 && order.version !== 3) || order.turn !== game.turn || game.status !== 'playing' || game.phase !== 4)
return 'This Revival turn is no longer current.';
if (!game.players.some(player => player.id === game.me) || !order.original.includes(game.me))
return 'This seat has no Revival turn in the current order.';
diff --git a/docs/ADVANCED_CHARITY_ORDER.md b/docs/ADVANCED_CHARITY_ORDER.md
index b66aae14..34232052 100644
--- a/docs/ADVANCED_CHARITY_ORDER.md
+++ b/docs/ADVANCED_CHARITY_ORDER.md
@@ -53,16 +53,16 @@ once. The original physical First Player order stays unchanged. No extra claim,
repeated completed turn, refund or reset is granted. Basic and already-opened
unstamped concurrent Charity phases have no fabricated ordered scope.
-Only an actual accepted `scope: charity` use creates version2 `charityOrder`
+Only an actual accepted `scope: charity` use creates reordered `charityOrder`
with original order, actual remaining/completed opportunities, current-start
state and public use history. Untouched version1 frames retain their stored
shape; native `charityTurn` receipts establish whether the current turn started.
Once a native claim has been accepted, including a later canceled wealthy BG
-claim, that current player finishes unchanged. Under the standing recommended
-interpretation authority, a different unstarted holder may reorder the later
-tail; first then means first after the started actor. The holder cannot split
-its own started turn. This queued timing is **provisional software composition**,
-not a newly found publisher answer.
+claim, a different unstarted holder may reorder the later tail; first then
+means first after the started actor. The current player finishes unchanged
+unless that same holder voluntarily pauses its own opportunity under the
+separate policy below. This queued timing is **provisional software
+composition**, not a newly found publisher answer.
The persistent panel names the protected actor, explains the later queue and
keeps one claim per player. Its normal card/scope/event/mode packet carries no
@@ -74,6 +74,31 @@ discard reaction and resumes once, retaining the claim receipts and turn.
Broader optional-module interactions, full-mode and final acceptance remain.
+## Own started Sapho suspension — 27 October 2026
+
+At a clean ordered Advanced Charity boundary, the player who has already
+claimed may provisionally discard its own Sapho to pause that same started
+opportunity and resume it last. The accepted native claim, its actual payment,
+the physical First Player order and all completed turns remain unchanged. No
+second Charity claim, payment or extra opportunity is created. Pending
+responses and preparations must finish before this action; another holder
+continues to queue behind the started player rather than displacing them.
+
+The first own-started suspension creates a version3 order frame with the
+paused player and the original public use history. The native claim receipt
+remains authoritative while the player waits and the started marker returns
+on resumption. Untouched version1 and version2 frames retain their stored
+format and history. Existing Basic and unstamped concurrent phases do not gain
+a manufactured order. Controls use the original card, scope, event and mode
+packet; minimal policies receive the same legal option.
+
+The Sapho face permits first/last in a player-ordered phase but does not give
+a partial-turn procedure. The suspension and queue are explicitly provisional
+online timing, not a publisher clarification. They supersede the earlier
+own-started restriction below. Wider interactions, complete-mode and deployed
+acceptance remain open.
+[Sapho scope](JUICE_OF_SAPHO_RUNTIME.md#own-started-advanced-charity-turn--27-october-2026).
+
## Initial ordered-phase evidence
@@ -105,12 +130,12 @@ final rules/recovery/privacy and deployed acceptance remain due.
## Sapho focused evidence
-Sapho now takes its holder first/last among remaining Advanced Charity turns.
+This earlier evidence covered Sapho first/last among remaining Advanced Charity turns.
Actual use creates version2 order history; untouched version1 frames retain
original bytes. An accepted current native claim provisionally protects that
actor before another holder reorders the later tail. Completed turns, one claim
-per player and physical First Player order remain. Own started turns cannot
-split. Basic/old unstamped concurrent phases have no manufactured scope. The
+per player and physical First Player order remain. It predates the separate
+own-started suspension above. Basic/old unstamped concurrent phases have no manufactured scope. The
persistent controls name the protected actor, and native claims/Finish follow
the changed queue. This is provisional retained-current timing, not a new
publisher ruling. [Source and contract](ADVANCED_CHARITY_ORDER.md#juice-of-sapho--27-october-2026).
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 6ef5ae06..d2eaf073 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -20,6 +20,15 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
+**Advanced Sapho Charity suspension — 27 October. Prototyped / Partial.** At a
+clean boundary the claiming player may provisionally pause its own started
+Charity opportunity and resume last, keeping the original claim and payment.
+The related nine-file union, including the repaired v3 Revival controls,
+passes 51 cases and the production build passes; broader combinations and
+deployed acceptance remain open. The optional
+two-human/four-faction setup is separately documented as missing in
+[its implementation contract](TWO_PLAYER_TWO_FACTIONS.md).
+
**Agreed extra turns and Basic simultaneous victory — 27 October. Prototyped /
Partial.** The public continuation now covers Basic qualifiers and tied
Advanced solos, with a saved final-turn marker for agreements at turn ten and
@@ -42,8 +51,8 @@ Partial.** Sapho can provisionally pause a clean started Revival turn and resume
it last with spent allowances and legacy order records preserved. Simultaneous
unallied Advanced qualifiers are ranked by stronghold count before prediction;
ties, Tech counting and other simultaneous victory sources retain the documented
-provisional composition. The 12-file affected union passes 144 cases. The
-unanimous option to play another turn and broader acceptance remain open.
+provisional composition. The earlier 12-file affected union passed 144 cases;
+the follow-ups above connect the unanimous option, while broader acceptance remains open.
[Revival](ADVANCED_REVIVAL_ORDER.md) · [Victory](ADVANCED_PREVIEW.md).
**Advanced Sapho own-turn suspension — 27 October. Prototyped / Partial.** The
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index a40e4fc0..47b69292 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,15 @@
# Dune implementation status
+## 27 October 2026 — Advanced Sapho Charity suspension
+
+A player may provisionally pause its own started Charity turn at a clean
+boundary and resume last while retaining its completed claim and payment. The
+queue retains older records, and Semuta claim/pass restores the same paused
+turn. Related v3 Charity and Revival controls now let the actual actor continue;
+nine files pass 51 cases and the production build passes. Wider combinations and
+deployed acceptance remain open. The separate optional
+[two-person, four-faction setup](TWO_PLAYER_TWO_FACTIONS.md) remains missing.
+
## 27 October 2026 — Basic agreements and extended victory calendar
The publisher FAQ agreement now applies to multiple qualifying Basic sides,
diff --git a/docs/JUICE_OF_SAPHO_RUNTIME.md b/docs/JUICE_OF_SAPHO_RUNTIME.md
index 8d53d837..f5c0aa7c 100644
--- a/docs/JUICE_OF_SAPHO_RUNTIME.md
+++ b/docs/JUICE_OF_SAPHO_RUNTIME.md
@@ -1,5 +1,28 @@
# Juice of Sapho: finite ordering implementation
+## Own started Advanced Charity turn — 27 October 2026
+
+**Prototyped / Partial.** At a clean boundary in ordered Advanced Charity, the
+current holder may use Juice of Sapho to pause its own started opportunity and
+resume it last among the remaining turns. Its accepted claim and actual payment
+remain in place; the same opportunity resumes without a second claim or refund.
+Pending responses and other preparations must finish before the order changes.
+Another holder cannot displace a different player's started turn and may
+reorder only the later unstarted tail.
+
+The first actual own-started suspension upgrades the order frame to version 3,
+recording the paused player alongside the original public Sapho use history.
+The started marker returns when that player becomes current again. Existing
+version 1 and 2 frames retain their prior format until a qualifying suspension;
+Basic and already-opened unstamped concurrent Charity phases stay unchanged.
+
+The Sapho face permits first or last in an ordered phase or action but does not
+specify suspension of a partially completed Charity opportunity. This is a
+provisional online composition, not a publisher clarification. It supersedes
+the earlier own-started Charity exclusion; wider interactions, full rules and
+browser/deployed acceptance remain open.
+[Native Charity order](ADVANCED_CHARITY_ORDER.md#own-started-sapho-suspension--27-october-2026).
+
## Own started Advanced Revival turn — 27 October 2026
**Prototyped / Partial.** At a clean boundary in ordered Advanced Revival, the
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 6686e4c7..3cb35cf4 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,15 @@
# Rule decision index
+## Juice of Sapho and an already-started Advanced Charity turn — 27 October 2026
+
+Extend the provisional own-turn Last composition to a clean ordered Charity
+boundary. The holder's accepted claim and any payment remain unchanged while
+the same opportunity pauses and resumes last. Other holders still cannot
+displace an already-started actor; pending responses must finish first. The
+printed card does not explain splitting a turn, so this is an online timing
+interpretation, not a publisher clarification. Older order records retain
+their original version. [Scope](ADVANCED_CHARITY_ORDER.md).
+
## Advanced simultaneous solo stronghold victories — 27 October 2026
The authorized supplied revision, physical p. 14 (printed p. 12), awards a
diff --git a/docs/TWO_PLAYER_TWO_FACTIONS.md b/docs/TWO_PLAYER_TWO_FACTIONS.md
new file mode 100644
index 00000000..ebc25952
--- /dev/null
+++ b/docs/TWO_PLAYER_TWO_FACTIONS.md
@@ -0,0 +1,116 @@
+# Two players controlling two factions each
+
+## Status and authority
+
+**Missing; design contract only.** The [GF9 classic rulebook, printed p. 6](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=6)
+offers a two-player setup in which each person plays two factions, with the
+two-player four-stronghold victory requirement. The user-authorized revised
+Advanced source, physical p. 4 (printed p. 2), offers the same choice and
+threshold. The supplied PDF stays local; its identity and precedence are in
+[the decision index](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026).
+The similarly titled movie game's double-faction rules are a different game
+and are not authority for this mode.
+
+Neither cited classic passage says whether the two factions controlled by one
+person are automatically allied, how their holdings combine for victory, or
+how controller identity affects every faction-counted ability. Do not silently
+adopt the movie game's automatic alliances or blended inventories. A practical
+first version can retain independent faction records, actual alliance choices,
+and a four-stronghold target per eligible victory side as an explicitly
+provisional policy. Resolve combined holdings and any conflicting interaction
+from authoritative sources or document the chosen interpretation; this design
+does not certify those answers.
+
+## Model and setup
+
+Keep four distinct `Player` faction records and their normal physical forces,
+leaders, decks, private hands, turns and faction identities. Add an explicit,
+lobby-validated association of those four distinct faction seats with exactly
+two controllers, two seats per controller. A controller is not itself a
+faction or a new recipient of faction income. Validate the complete mapping
+before starting and reject ambiguous, duplicate, missing or stale assignments;
+changing an assignment or faction clears readiness. Existing two-seat games,
+already-started games, and saved positions without the new mode retain their
+original interpretation and do not receive invented controller assignments.
+
+Expose this as an explicit two-player setup option. Let each person create or
+join their first seat and securely add/select their second faction before
+readiness, with clear names and a persistent active-faction indicator. Avoid
+claiming support for the mode until setup, both private faction views, legal
+actions, turn continuation and victory all work together. The existing model
+still permits ordinary two-seat play and separate one-faction controllers.
+
+## Separate controllers from faction seats
+
+The `players` array currently represents faction seats, not physical humans.
+For this option `game/victory-progress.ts` must use the validated controller
+count for the printed two-player threshold; `players.length === 2` alone is
+incorrect for four faction seats. Apply any agreed threshold increase through
+the same shared victory projection and quote, and retain explicit ordinary,
+alliance, Ecaz and special victory ordering. Never infer controller count by
+names, cookies, browser tabs, AI labels or faction alliances.
+
+Audit each other count before changing it. Faction turn order, physical pieces,
+dealt cards, faction-specific payments and action ownership can still depend on
+the four faction seats. Optional-module eligibility and phase rules referring
+to players must be checked against their specific source; for example, existing
+Tech Token gates use `players.length >= 3` in `game/engine.ts` and
+`game/faction-module-profile.ts`. A global substitution of controller count
+would silently change unrelated rules. Record uncertain interpretations and
+keep unsupported combinations visibly provisional rather than fabricating a
+new setup or automatic alliance.
+
+## Authentication and switching
+
+Today `db/schema.ts` makes `seats.token_hash` the primary key;
+`db/rooms.ts` authenticates a token to one `player_id` and `act` rechecks that
+exact unrevoked room/player/token tuple before the versioned action. The normal
+`joinRoom` path returns `alreadySeated` for an authenticated caller. Room API
+routes currently use a single room-scoped HttpOnly cookie. Reusing one token
+for two database rows, trusting a caller-supplied seat ID as authorization, or
+projecting both seats' private information into one general view would break
+those existing boundaries.
+
+One workable implementation is an explicit authenticated add-second-seat
+operation that atomically validates the lobby/controller assignment and creates
+the second distinct seat credential. Provision separately random, room-scoped
+HttpOnly credentials for both faction seats. A public active-seat selector may
+choose which credential to present, but every API read, action, message,
+control or recovery request must authenticate that selected credential and
+verify its actual `player_id`; the selector itself grants no access. Apply the
+same room-control, revocation, origin, retry, version and removal checks as
+today. Preserve existing seat cookies and single-seat clients; use additive
+schema or durable mapping changes if required, never reset the room database.
+No secret token belongs in source, client-visible game state or logs.
+
+On a faction switch, `app/page.tsx` should advance its existing request epoch,
+discard stale cross-seat results and fetch the selected authorized view. Fence
+in-flight actions and responses by room, selected seat and version so a delayed
+response from the other faction cannot overwrite or act through the current
+view. Explicitly handle refresh and recovery of both distinct credentials,
+individual revocation, host assignment and relinquishment; a recovered or
+revoked seat must not accidentally grant the other seat or an opponent. Keep
+the UI explicit about which faction owns each pending decision. Existing
+autopilot can operate a faction seat only under its existing authorization;
+until full AI refinement, each offered decision needs a minimal legal bot path.
+
+## Integration and evidence
+
+- Implement one shared controller/seat contract through lobby creation,
+ assignment, setup, the projected active seat, action dispatch and the
+ shared stronghold progress and victory quote. Retain source-specific faction
+ counts and provisional boundaries rather than just adding a selector or
+ changing the victory number.
+- Include focused engine tests for valid/invalid mappings, original and changed
+ thresholds, faction-specific private state, controlled faction decisions,
+ ties and the intended victory-side interpretation. Exercise a real setup and
+ a meaningful sequence using both factions of each controller; include legal
+ minimal bot participation and usable action controls.
+- Test authenticated add/switch/read/action with distinct credentials, denied
+ target seats, stale or concurrent switches and independent private views. Do
+ not expose or reset unrelated rooms; use isolated dedicated test records.
+- Broader recovery, revocation, privacy and custody assurance, valid module
+ combinations, complete human/AI games, browser/mobile acceptance and deployed
+ verification remain required after the rules-first prototype. A passing
+ focused test or this document does not certify a complete mode or change the
+ publication gates.
diff --git a/game/charity-order.ts b/game/charity-order.ts
index 10e9e6fb..e0aebb27 100644
--- a/game/charity-order.ts
+++ b/game/charity-order.ts
@@ -12,10 +12,13 @@ export type SaphoCharityOrderUse = {
mode: 'first' | 'last';
completed: string[];
started: boolean;
+ /** Version 3 only: the current started player voluntarily paused last. */
+ suspended?: true;
};
export type CharityOrder = CharityOrderFields & (
| { version: 1 }
| { version: 2; completed: string[]; started: boolean; uses: SaphoCharityOrderUse[] }
+ | { version: 3; completed: string[]; started: boolean; uses: SaphoCharityOrderUse[]; paused: string[] }
);
export type CharityOrderContext = {
turn: number;
@@ -67,21 +70,23 @@ export function createCharityOrder(context: CharityOrderContext): CharityOrder {
}
function currentStarted(context: CharityOrderContext, frame: CharityOrder): boolean {
- return frame.version === 2 ? frame.started : !!context.claimed?.includes(frame.remaining[0]);
+ return frame.version !== 1 ? frame.started : !!context.claimed?.includes(frame.remaining[0]);
}
function copy(frame: CharityOrder): CharityOrder {
const fields = { turn: frame.turn, order: [...frame.order], remaining: [...frame.remaining] };
- return frame.version === 1 ? { version: 1, ...fields } : {
- version: 2, ...fields, completed: [...frame.completed], started: frame.started,
- uses: frame.uses.map(use => ({ ...use, completed: [...use.completed] })),
- };
+ if (frame.version === 1) return { version: 1, ...fields };
+ const rest = { ...fields, completed: [...frame.completed], started: frame.started,
+ uses: frame.uses.map(use => ({ ...use, completed: [...use.completed] })) };
+ return frame.version === 2 ? { version: 2, ...rest } : { version: 3, ...rest, paused: [...frame.paused] };
}
export function validateCharityOrder(context: CharityOrderContext, frame: CharityOrder): void {
validateContext(context);
- requireOrder(record(frame) && (frame.version === 1 || frame.version === 2) &&
+ requireOrder(record(frame) && (frame.version === 1 || frame.version === 2 || frame.version === 3) &&
Object.keys(frame).sort().join(',') === (frame.version === 1
- ? 'order,remaining,turn,version' : 'completed,order,remaining,started,turn,uses,version') &&
+ ? 'order,remaining,turn,version' : frame.version === 2
+ ? 'completed,order,remaining,started,turn,uses,version'
+ : 'completed,order,paused,remaining,started,turn,uses,version') &&
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), 'Charity must retain its original physical order.');
@@ -91,12 +96,16 @@ export function validateCharityOrder(context: CharityOrderContext, frame: Charit
else {
requireOrder(ids(frame.completed) && same(frame.completed, context.ready) && typeof frame.started === 'boolean',
'Reordered Charity must retain its actual completions and current start.');
- validateSaphoHistory(frame);
+ if (frame.version === 2) validateSaphoHistory(frame);
+ else validateSuspendedSaphoHistory(frame);
requireOrder(frame.remaining.length > 0 || !frame.started,
'Completed Charity cannot retain a started opportunity.');
if (context.claimed !== undefined) requireOrder(frame.started ===
!!context.claimed.includes(frame.remaining[0]),
'The Charity start must match the current native claim receipt.');
+ if (frame.version === 3 && context.claimed !== undefined) requireOrder(
+ frame.paused.every(player => context.claimed!.includes(player)),
+ 'Every paused Charity turn must retain its native claim receipt.');
}
}
@@ -154,27 +163,81 @@ function validateSaphoHistory(frame: Extract): voi
'The Charity frame contradicts its Sapho history or unlocks a started opportunity.');
}
+/** Version 3 preserves public order history and a started faction's native
+ * claim while it waits for its original opportunity to resume. */
+function validateSuspendedSaphoHistory(frame: Extract): void {
+ requireOrder(Array.isArray(frame.uses) && frame.uses.length > 0 && ids(frame.paused),
+ 'A suspended Charity turn needs its actual use history and unique paused factions.');
+ let remaining = [...frame.order], completed: string[] = [], started = false, paused: string[] = [];
+ const advance = (next: readonly string[]) => {
+ const advanced = advanceCompleted(remaining, completed, started, next);
+ const count = next.length - completed.length;
+ paused = paused.filter(player => !next.includes(player));
+ remaining = advanced.remaining;
+ completed = advanced.completed;
+ started = count === 0 ? advanced.started : !!remaining.length && paused.includes(remaining[0]);
+ };
+ for (const [index, use] of frame.uses.entries()) {
+ requireOrder(record(use) &&
+ (Object.keys(use).sort().join(',') === 'completed,event,mode,player,started' ||
+ Object.keys(use).sort().join(',') === 'completed,event,mode,player,started,suspended' && use.suspended === true) &&
+ typeof use.event === 'string' && id(use.player) && (use.mode === 'first' || use.mode === 'last') &&
+ ids(use.completed) && typeof use.started === 'boolean',
+ 'The saved Sapho Charity use is malformed.');
+ advance(use.completed);
+ requireOrder(!started || use.started, 'A later Sapho use cannot unlock the started Charity opportunity.');
+ requireOrder(!use.started || remaining.length > 0, 'Only a current Charity opportunity can have started.');
+ requireOrder(use.event === eventFor(frame.turn, frame.order, remaining, completed, use.started, index),
+ 'The saved Sapho Charity use lost its original public boundary.');
+ if (use.suspended) {
+ requireOrder(use.started && use.player === remaining[0] && use.mode === 'last' && remaining.length > 1,
+ 'Only the current started faction can suspend its own Charity turn last.');
+ if (!paused.includes(use.player)) paused.push(use.player);
+ remaining = [...remaining.slice(1), use.player];
+ started = paused.includes(remaining[0]);
+ } else {
+ remaining = reordered(frame.order, remaining, completed, use.started, use);
+ started = use.started || paused.includes(remaining[0]);
+ }
+ }
+ advance(frame.completed);
+ requireOrder(same(frame.remaining, remaining) && same(frame.paused, paused) && (!started || frame.started),
+ 'The Charity frame contradicts its Sapho history or loses a suspended opportunity.');
+ requireOrder(frame.uses.some(use => use.suspended === true), 'Version 3 requires an actual suspended Charity turn.');
+}
+
export function saphoCharityOrderEvent(context: CharityOrderContext, frame: CharityOrder): string {
validateCharityOrder(context, frame);
return eventFor(frame.turn, frame.order, frame.remaining, context.ready,
- currentStarted(context, frame), frame.version === 2 ? frame.uses.length : 0);
+ currentStarted(context, frame), frame.version === 1 ? 0 : frame.uses.length);
}
export function reorderSaphoCharityOrder(context: CharityOrderContext, frame: CharityOrder, request: {
event: string; player: string; mode: 'first' | 'last';
-}): { order: CharityOrder; afterCurrent?: string } {
+}): { order: CharityOrder; afterCurrent?: string; suspended?: string } {
validateCharityOrder(context, frame);
const event = saphoCharityOrderEvent(context, frame), started = currentStarted(context, frame);
requireOrder(record(request) && Object.keys(request).sort().join(',') === 'event,mode,player' &&
request.event === event && id(request.player) && (request.mode === 'first' || request.mode === 'last'),
'Choose first or last at the current Sapho Charity opportunity.');
- const remaining = reordered(frame.order, frame.remaining, context.ready, started, request);
- const order: CharityOrder = { version: 2, turn: frame.turn, order: [...frame.order], remaining,
- completed: [...context.ready], started,
- uses: [...(frame.version === 2 ? frame.uses.map(use => ({ ...use, completed: [...use.completed] })) : []),
- { event, player: request.player, mode: request.mode, completed: [...context.ready], started }] };
+ const suspended = started && request.player === frame.remaining[0];
+ if (suspended) requireOrder(request.mode === 'last' && frame.remaining.length > 1,
+ 'A started Charity turn can only be suspended last when another opportunity remains.');
+ const remaining = suspended ? [...frame.remaining.slice(1), request.player]
+ : reordered(frame.order, frame.remaining, context.ready, started, request);
+ const paused = frame.version === 3 ? [...frame.paused] : [];
+ if (suspended && !paused.includes(request.player)) paused.push(request.player);
+ const uses = [...(frame.version === 1 ? [] : frame.uses.map(use => ({ ...use, completed: [...use.completed] }))),
+ { event, player: request.player, mode: request.mode, completed: [...context.ready], started,
+ ...(suspended ? { suspended: true as const } : {}) }];
+ const fields = { turn: frame.turn, order: [...frame.order], remaining,
+ completed: [...context.ready], started: suspended ? paused.includes(remaining[0]) : started || paused.includes(remaining[0]) };
+ const order: CharityOrder = frame.version === 3 || suspended
+ ? { version: 3, ...fields, uses, paused }
+ : { version: 2, ...fields, uses };
validateCharityOrder(context, order);
- return { order, ...(started ? { afterCurrent: frame.remaining[0] } : {}) };
+ return { order, ...(suspended ? { suspended: request.player } :
+ started ? { afterCurrent: frame.remaining[0] } : {}) };
}
export function charityOrderBlock(context: CharityOrderContext, frame: CharityOrder, player: string): string | null {
@@ -205,6 +268,10 @@ export function finishCharityOrder(context: CharityOrderContext, frame: CharityO
if (frame.version === 1)
return { version: 1, turn: frame.turn, order: [...frame.order], remaining: frame.remaining.slice(1) };
const next = { ...copy(frame), remaining: frame.remaining.slice(1), completed: [...frame.completed, player], started: false };
+ if (next.version === 3) {
+ next.paused = next.paused.filter(id => id !== player);
+ next.started = next.remaining.length > 0 && next.paused.includes(next.remaining[0]);
+ }
validateCharityOrder({ ...context, ready: [...context.ready, player] }, next);
return next;
}
diff --git a/game/engine.ts b/game/engine.ts
index 41b8660d..5830b9c0 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -7706,8 +7706,8 @@ function treacheryDiscardIntegrity(g: Game) {
catch (error) { if (error instanceof SaphoTruthOrderError) throw new RuleError(error.message); throw error; }
} else if (continuation?.kind === 'saphoCharityDiscard') {
const c=continuation,entry=batch.entries[0],frame=g.charityOrder;
- const latest=frame?.version===2 ? frame.uses.at(-1) : null;
- requireRule(g.advanced && g.phase===2 && frame?.version===2 &&
+ const latest=frame && frame.version!==1 ? frame.uses.at(-1) : null;
+ requireRule(g.advanced && g.phase===2 && frame && frame.version!==1 &&
latest?.event===c.event && latest.player===c.player && latest.mode===c.mode &&
batch.cause==='sapho:charity' && batch.entries.length===1 &&
entry.publicFace && entry.discardedBy===c.player && entry.card.id===c.card &&
@@ -9052,7 +9052,7 @@ function semutaOfferSupported(
if (continuation.kind === 'battleResolved' &&
!g.lastBattleContext?.mandatoryDiscard) return false;
if (continuation.kind === 'saphoCharityDiscard' &&
- (!g.advanced || g.phase!==2 || g.charityOrder?.version!==2 ||
+ (!g.advanced || g.phase!==2 || !g.charityOrder || g.charityOrder.version===1 ||
continuation.resume.response || continuation.resume.decision ||
continuation.resume.pendingKarama || continuation.resume.phaseOpening ||
g.response || g.decision || g.pendingRevival)) return false;
@@ -27176,7 +27176,8 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
for (const mode of ['first','last'] as const) {
try {
const quote=reorderSaphoCharityOrder(context,g.charityOrder,{event,player:p.id,mode});
- options.push({scope:'charity',event,mode,...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {})});
+ options.push({scope:'charity',event,mode,...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {}),
+ ...(quote.suspended ? {resumeOwn:true as const} : {})});
} catch(error) {
if (!(error instanceof CharityOrderError)) throw error;
}
@@ -27380,7 +27381,7 @@ function playSapho(g: Game, p: Player, action: Action) {
const used=discard(g,p,'richese-juice-of-sapho');
g.charityOrder=quote.order;
g.active=quote.order.remaining[0] ?? null;
- log(g,`${p.name} discarded Juice of Sapho to take their remaining Charity turn ${option.mode}.${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the started turn first.` : ''} Completed turns, native claim entitlements and physical First Player order stay unchanged.`,
+ log(g,`${p.name} discarded Juice of Sapho to take their remaining Charity turn ${option.mode}.${quote.suspended ? ' Their started turn pauses provisionally and resumes last with its claim unchanged.' : ''}${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the started turn first.` : ''} Completed turns, native claim entitlements and physical First Player order stay unchanged.`,
{faction:p.faction,name:'Juice of Sapho'});
if (g.semutaPreview) {
const continuation:SaphoCharityDiscardContinuation={kind:'saphoCharityDiscard',player:p.id,card:used.id,
diff --git a/game/reference.ts b/game/reference.ts
index f4105fd3..442c6e15 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -504,7 +504,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.',
'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Basic Semuta claim/pass restores the same question queue. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions, battle and pending-parent combinations remain unfinished.',
'During a clean native Extortion payer decision, use Sapho to take your optional choice first/last among unanswered non-Moritani players. Earlier declines, collected bank5 and the one-payment3 limit remain. The next payer event changes even when that player stays next. You may decline instead of paying.',
- 'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. Each player still claims at most once, completed turns stay closed, and the holder cannot split its own started turn. Physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.',
+ 'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. At a clean boundary, the holder may pause its own claimed turn and resume last without repeating the claim or payment. Completed turns stay closed and physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.',
'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If another player has started, provisional queued timing lets that player finish unchanged. At a clean boundary, you may instead pause your own started turn and resume last, retaining used allowances. Pending requests must finish first. Completed turns and already-held positions cannot be repeated. Basic and older concurrent Revival phases have no ordered scope.',
'Finish pending shipment, movement and card preparations and decisions before changing order. The exact Extortion payer window is a supported exception for its own order only. Your hand panel lists available choices. Reserved cards and positions you already hold cannot be selected.',
'Completed bids and turns stay completed. Storm order, committed funding and movement counters do not reset. Refreshing preserves the same remaining opportunities and any declared last position.',
@@ -524,7 +524,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- 'Finite Once Around, current-lot cyclic bidding, clean/queued movement and ordered Advanced Charity/Revival, remaining battle-choice and late aggressor modes are connected. Queued timing retains another started actor; a clean own started movement turn may provisionally resume last in either ruleset. Charity/Revival keep native claims or requests/allowances and separate physical order through their Semuta disposal. Tail timing is visibly provisional. Other phases remain unfinished.',
+ 'Finite Once Around, current-lot cyclic bidding, clean/queued movement and ordered Advanced Charity/Revival, remaining battle-choice and late aggressor modes are connected. Queued timing retains another started actor; a clean own started movement turn in either ruleset, or Charity/Revival turn in Advanced, may provisionally resume last. Native claims or requests/allowances and physical order remain through their Semuta disposal. Tail timing is visibly provisional. Other phases remain unfinished.',
},
{
area: 'Player controls',
@@ -4439,6 +4439,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Stronghold targets, storm-contested occupation, prediction and final-turn fallback.',
steps: [
'Victory is checked when the Mentat pause begins. A faction needs three strongholds, or four when allied or playing a two-player game. Allies combine distinct qualifying strongholds.',
+ 'The optional two-person setup with two factions per person is not yet available; ordinary two-seat games use the four-stronghold target.',
'Ecaz and its reciprocal ally can also win by jointly occupying three strongholds: both factions need non-advisor presence in each, without opposing fighters. The ordinary four-stronghold alliance route remains available. Occupy cancellation does not remove this victory condition. A concealed No-Field supplies public presence; its secret value is not revealed by this check.',
'The separately marked fresh native Ecaz Homeworld preview has another printed route: native Ecaz must currently have at least seven reserves, the reciprocal alliance must jointly hold one actual uncontested stronghold, and it must occupy worlds of two other native factions. Two Emperor worlds count as one faction. This joins normal winners before a correct BG prediction or final-turn fallback; it adds no ordinary stronghold points.',
'Opposing forces may survive together in a stronghold covered by storm. That contested stronghold does not count for either side. A sole occupant still counts it even under storm.',
diff --git a/tests/sapho-charity-order-controls.test.tsx b/tests/sapho-charity-order-controls.test.tsx
index 7f5d45b9..3ebddd56 100644
--- a/tests/sapho-charity-order-controls.test.tsx
+++ b/tests/sapho-charity-order-controls.test.tsx
@@ -85,6 +85,60 @@ void test('native clean Charity first and last render their scope and submit onl
}
});
+void test('own started Charity pause displays the retained claim and submits its native action', () => {
+ const source = native(true, true), game = source.game;
+ // Controlled custody: transfer the one physical Sapho to the actual claimant.
+ const donor = saphoCharityOrderPlayer(game, source.owner);
+ const claimant = saphoCharityOrderPlayer(game, source.current);
+ const index = donor.hand.findIndex(card => card.id === source.card);
+ assert.ok(index >= 0);
+ claimant.hand.push(donor.hand.splice(index, 1)[0]);
+ const view = viewGame(game, source.current), option = optionFor(view, 'last');
+ assert.equal(option.resumeOwn, true);
+ const html = panel(view);
+ assert.match(html, /Resume your charity last and discard Juice of Sapho/);
+ assert.match(html, /Your completed claim and its payment remain unchanged/);
+ assert.deepEqual(pressSapho(source.card, option), [{ type: 'card', card: source.card,
+ scope: 'charity', event: option.event, mode: 'last' }]);
+ assert.deepEqual(pressSapho(source.card, option, true), []);
+});
+
+void test('actual v3 Charity queue enables the current player and blocks the paused player', () => {
+ const source = native(true, true);
+ let game = source.game;
+ // The already-paid original Sapho changes hands without creating a card.
+ const donor = saphoCharityOrderPlayer(game, source.owner);
+ const claimant = saphoCharityOrderPlayer(game, source.current);
+ const cardIndex = donor.hand.findIndex(card => card.id === source.card);
+ assert.ok(cardIndex >= 0);
+ claimant.hand.push(donor.hand.splice(cardIndex, 1)[0]);
+ const pause = optionFor(viewGame(game, source.current), 'last');
+ game = actSaphoCharityOrder(game, source.current, pressSapho(source.card, pause)[0]!);
+ assert.ok(game.charityOrder && game.charityOrder.version === 3);
+ const actor = game.charityOrder.remaining[0]; assert.notEqual(actor, source.current);
+ const recipient = game.players.find(player => player.id !== actor)!.id;
+ const purse = saphoCharityOrderPlayer(game, actor).spice;
+ if (purse) game = actSaphoCharityOrder(game, actor, { type: 'bribe', target: recipient, amount: purse });
+ const active = viewGame(game, actor), waiting = viewGame(game, source.current);
+ assert.match(charity(active), /It is your Charity turn/);
+ assert.match(charity(waiting), /Your turn comes later/);
+ assert.deepEqual(pressCharity(waiting, 'charity-claim-submit'), { sent: [], disabled: true });
+ assert.deepEqual(pressCharity(waiting, 'charity-turn-finish'), { sent: [], disabled: true });
+ const claim = pressCharity(active, 'charity-claim-submit');
+ assert.deepEqual(claim, { sent: [{ type: 'charity' }], disabled: false });
+ game = actSaphoCharityOrder(game, actor, claim.sent[0]!);
+ const finish = pressCharity(viewGame(game, actor), 'charity-turn-finish');
+ assert.deepEqual(finish, { sent: [{ type: 'ready' }], disabled: false });
+ game = actSaphoCharityOrder(game, actor, finish.sent[0]!);
+ while (game.charityOrder?.remaining[0] !== source.current) {
+ const current = game.charityOrder?.remaining[0]; assert.ok(current);
+ game = actSaphoCharityOrder(game, current, { type: 'ready' });
+ }
+ const resumed = viewGame(game, source.current);
+ assert.deepEqual(pressCharity(resumed, 'charity-claim-submit'), { sent: [], disabled: true });
+ assert.deepEqual(pressCharity(resumed, 'charity-turn-finish'), { sent: [{ type: 'ready' }], disabled: false });
+});
+
void test('native claimed current actor stays first while Sapho first or last queues only its later tail', () => {
for (const mode of ['first', 'last'] as const) {
const source = native(true, true), game = viewGame(source.game, source.owner), option = optionFor(game, mode);
diff --git a/tests/sapho-charity-order-engine.test.ts b/tests/sapho-charity-order-engine.test.ts
index 3cd92b5c..ca103c3f 100644
--- a/tests/sapho-charity-order-engine.test.ts
+++ b/tests/sapho-charity-order-engine.test.ts
@@ -75,18 +75,22 @@ void test('an actual accepted current Charity claim stays ahead of both Sapho ta
}
});
-void test('the real Sapho holder cannot split its own started Charity or choose its existing no-op position', () => {
+void test('the real Sapho holder can pause its started Charity but cannot choose a no-op position', () => {
const f = fixture(); let game = advance(reload(f.game), state => clean(state) && state.charityOrder?.remaining[0] === f.owner);
assert.equal(game.active, f.owner);
assert.equal(viewGame(game, f.owner).saphoOptions.some(option => option.scope === 'charity' && option.mode === 'first'), false);
reject(game, f.owner, { type: 'card', card: f.card, scope: 'charity', event: currentEvent(game), mode: 'first' });
game = claim(game, f.owner).game;
assert.equal(player(game, f.owner).charityTurn, game.turn);
- assert.deepEqual(viewGame(game, f.owner).saphoOptions.filter(option => option.scope === 'charity'), []);
- for (const mode of ['first', 'last'] as const)
- reject(game, f.owner, { type: 'card', card: f.card, scope: 'charity', event: currentEvent(game), mode });
- assert.ok(player(game, f.owner).hand.some(card => card.id === f.card));
- assert.equal(game.discard.some(card => card.id === f.card), false);
+ reject(game, f.owner, { type: 'card', card: f.card, scope: 'charity', event: currentEvent(game), mode: 'first' });
+ const option = viewGame(game, f.owner).saphoOptions.find(option => option.scope === 'charity' && option.mode === 'last');
+ assert.ok(option && option.scope === 'charity' && option.resumeOwn);
+ const before = player(game, f.owner).spice;
+ game = act(game, f.owner, { type: 'card', card: f.card, scope: 'charity', event: option.event, mode: 'last' });
+ assert.equal(game.charityOrder?.remaining.at(-1), f.owner);
+ assert.equal(player(game, f.owner).spice, before);
+ assert.equal(player(game, f.owner).charityTurn, game.turn);
+ assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
});
void test('stale, foreign and finished Charity requests reject before cost while an accepted packet disposes Sapho once', () => {
diff --git a/tests/sapho-charity-order.test.ts b/tests/sapho-charity-order.test.ts
index 45779c69..490e54f5 100644
--- a/tests/sapho-charity-order.test.ts
+++ b/tests/sapho-charity-order.test.ts
@@ -64,7 +64,9 @@ void test('an accepted claim, including canceled BG Charity, locks the current a
const claimed = context([], ['a']);
rejects(claimed, frame, () => reorder(claimed, frame, { event: stale, player: 'd', mode: 'first' }));
rejects(claimed, frame, () => use(claimed, frame, 'a', 'first'));
- rejects(claimed, frame, () => use(claimed, frame, 'a', 'last'));
+ const paused = use(claimed, frame, 'a', 'last');
+ assert.equal(paused.suspended, 'a');
+ assert.deepEqual(paused.order.remaining, ['b', 'c', 'd', 'a']);
const first = use(claimed, frame, 'd', 'first');
assert.equal(first.afterCurrent, 'a');
assert.deepEqual(first.order.remaining, ['a', 'd', 'b', 'c']);
diff --git a/tests/sapho-charity-own-semuta.test.ts b/tests/sapho-charity-own-semuta.test.ts
new file mode 100644
index 00000000..4d387d2e
--- /dev/null
+++ b/tests/sapho-charity-own-semuta.test.ts
@@ -0,0 +1,94 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
+import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
+import {
+ actSaphoCharityOrder as act, advanceSaphoCharityOrder as advance,
+ createSaphoCharitySemutaFixture as fixture, reactSaphoCharitySemuta as react,
+ saphoCharityOrderClean as clean, saphoCharityOrderInventory as inventory,
+ saphoCharityOrderPlayer as player,
+} from './fixture-sapho-charity-order';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before);
+}
+
+/** CONTROLLED supplemental custody: the inherited setup conserves original
+ * Sapho/Semuta cards rather than earning their purchase, then only the one
+ * physical Sapho is transferred to the eventual native Charity claimant. If
+ * that hand is full, one ordinary held card is exchanged to the donor so both
+ * retain legal hand counts. The bribe, Charity claim and all later actions are
+ * executed by the real engine. */
+function claimedSource() {
+ const f = fixture();
+ assert.equal(f.nativeAcquisition, false);
+ assert.ok(f.staging.some(row => row.includes('CONTROLLED')));
+ const originalQueue = f.game.charityOrder!.remaining;
+ const actor = originalQueue.slice(0, -1).find(id => id !== f.holder && id !== f.owner);
+ assert.ok(actor, 'A non-Semuta, non-donor claimant retains a later opportunity');
+ let game = advance(structuredClone(f.game), state => clean(state) &&
+ state.phase === 2 && state.charityOrder?.remaining[0] === actor);
+ const donor = player(game, f.owner), claimant = player(game, actor);
+ const index = donor.hand.findIndex(card => card.id === f.card);
+ assert.ok(index >= 0);
+ const [sapho] = donor.hand.splice(index, 1);
+ if (claimant.hand.length >= handLimit(claimant)) {
+ const swap = claimant.hand.findIndex(card => card.id !== SEMUTA_DRUG_ID);
+ assert.ok(swap >= 0);
+ donor.hand.push(...claimant.hand.splice(swap, 1));
+ }
+ claimant.hand.push(sapho);
+ assert.ok(donor.hand.length <= handLimit(donor));
+ assert.ok(claimant.hand.length <= handLimit(claimant));
+ assert.deepEqual(inventory(game), f.originalInventory);
+ const recipient = game.players.find(seat => seat.id !== actor)!.id;
+ const paid = claimant.spice;
+ assert.ok(paid > 0, 'The claimant makes an actual bribe before Charity');
+ game = act(game, actor, { type: 'bribe', target: recipient, amount: paid });
+ assert.equal(player(game, actor).spice, 0);
+ game = act(game, actor, { type: 'charity' });
+ game = advance(game, clean);
+ assert.equal(player(game, actor).charityTurn, game.turn);
+ const option = viewGame(game, actor).saphoOptions.find(row => row.scope === 'charity' && row.mode === 'last');
+ assert.ok(option && option.scope === 'charity' && option.resumeOwn);
+ return { f, game, actor, action: { type: 'card', card: f.card,
+ scope: 'charity', event: option.event, mode: 'last' } satisfies Action };
+}
+
+for (const claim of [false, true]) void test(
+ `controlled own Charity Sapho suspension survives Semuta ${claim ? 'claim' : 'pass'} without repeating the claim`, () => {
+ const { f, game: original, actor, action } = claimedSource();
+ const before = structuredClone(player(original, actor));
+ const paused = act(original, actor, action);
+ assert.equal(paused.pendingTreacheryDiscard?.continuation?.kind, 'saphoCharityDiscard');
+ assert.equal(paused.pendingTreacheryDiscard?.reaction?.stage, 'offer');
+ assert.ok(paused.charityOrder?.version === 3);
+ assert.deepEqual(paused.charityOrder.paused, [actor]);
+ assert.equal(paused.charityOrder.remaining.at(-1), actor);
+ assert.equal(player(paused, actor).spice, before.spice);
+ assert.equal(player(paused, actor).charityTurn, before.charityTurn);
+ assert.equal(viewGame(paused, f.holder).semutaReaction?.canCommit, true);
+ reject(paused, actor, { type: 'ready' });
+ const frame = structuredClone(paused.charityOrder), ready = [...paused.ready];
+ let game = react(paused, f.holder, claim);
+ game = advance(game, clean);
+ assert.deepEqual(game.charityOrder, frame);
+ assert.deepEqual(game.ready, ready);
+ assert.equal(game.active, frame.remaining[0]);
+ assert.equal(player(game, actor).spice, before.spice);
+ assert.equal(player(game, actor).charityTurn, before.charityTurn);
+ assert.deepEqual(inventory(game), f.originalInventory);
+ assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), claim);
+ assert.equal(game.discard.some(card => card.id === f.card), !claim);
+ if (claim) assert.equal(game.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
+ game = advance(game, state => state.phase === 2 && clean(state) &&
+ state.charityOrder?.remaining[0] === actor);
+ assert.ok(game.charityOrder?.version === 3 && game.charityOrder.started);
+ assert.equal(player(game, actor).spice, before.spice);
+ reject(game, actor, { type: 'charity' });
+ game = act(game, actor, { type: 'ready' });
+ assert.deepEqual(inventory(game), f.originalInventory);
+ },
+);
diff --git a/tests/sapho-charity-own-suspension.test.ts b/tests/sapho-charity-own-suspension.test.ts
new file mode 100644
index 00000000..55ccb00a
--- /dev/null
+++ b/tests/sapho-charity-own-suspension.test.ts
@@ -0,0 +1,114 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import {
+ actSaphoCharityOrder as act, advanceSaphoCharityOrder as advance,
+ createSaphoCharityOrderFixture as fixture, claimSaphoCharityOrder as claim,
+ finishSaphoCharityOrder as finish, saphoCharityOrderClean as clean,
+ saphoCharityOrderInventory as inventory, saphoCharityOrderPlayer as player,
+} from './fixture-sapho-charity-order';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before);
+}
+
+/** The original Advanced setup and paid cache purchase lead to an actual
+ * bribe and Charity claim. Only the already purchased Sapho changes hands:
+ * the conserved physical card is transferred from Emperor to current Richese. */
+function source() {
+ const f = fixture({ started: true });
+ const game = structuredClone(f.game);
+ const donor = player(game, f.owner);
+ const index = donor.hand.findIndex(card => card.id === f.card);
+ assert.ok(index >= 0);
+ player(game, f.current).hand.push(...donor.hand.splice(index, 1));
+ assert.ok(f.claim?.steps.some(step => step.action.type === 'bribe'));
+ assert.ok(f.claim?.steps.some(step => step.action.type === 'charity'));
+ assert.equal(player(game, f.current).charityTurn, game.turn);
+ assert.equal(player(game, f.current).spice, 2);
+ assert.deepEqual(inventory(game), f.originalInventory);
+ return { f, game };
+}
+
+function ownLast(game: Game, actor: string, card: string): Action {
+ const option = viewGame(game, actor).saphoOptions.find(row => row.scope === 'charity' && row.mode === 'last');
+ assert.ok(option && option.scope === 'charity' && option.resumeOwn);
+ return { type: 'card', card, scope: 'charity', mode: 'last', event: option.event };
+}
+
+void test('Advanced own Charity suspension retains the earned claim and resumes once after the remaining players', () => {
+ const { f, game: original } = source();
+ const before = structuredClone(player(original, f.current));
+ const expected = [...original.charityOrder!.remaining.slice(1), f.current];
+ const declaration = ownLast(original, f.current, f.card);
+ assert.deepEqual(Object.keys(declaration).sort(), ['card', 'event', 'mode', 'scope', 'type']);
+ reject(original, f.current, { ...declaration, event: 'stale-charity-event' });
+ reject(original, f.current, { ...declaration, mode: 'first' });
+ let game = act(original, f.current, declaration);
+ assert.equal(game.charityOrder?.version, 3);
+ assert.deepEqual(game.charityOrder?.remaining, expected);
+ assert.ok(game.charityOrder?.version === 3);
+ assert.deepEqual(game.charityOrder.paused, [f.current]);
+ assert.equal(game.charityOrder.started, false);
+ assert.deepEqual(game.order, original.order);
+ assert.equal(player(game, f.current).spice, before.spice);
+ assert.equal(player(game, f.current).charityTurn, before.charityTurn);
+ assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
+ assert.deepEqual(inventory(game), f.originalInventory);
+ reject(game, f.current, declaration);
+ game = advance(game, state => state.phase === 2 && clean(state) && state.charityOrder?.remaining[0] === f.current);
+ assert.ok(game.charityOrder?.version === 3);
+ assert.equal(game.charityOrder.started, true);
+ assert.equal(player(game, f.current).spice, before.spice);
+ assert.equal(player(game, f.current).charityTurn, game.turn);
+ reject(game, f.current, { type: 'charity' });
+ game = act(game, f.current, { type: 'ready' });
+ game = finish(game);
+ assert.equal(game.phase, 3);
+ assert.deepEqual(game.order, original.order);
+ assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
+ assert.deepEqual(inventory(game), f.originalInventory);
+});
+
+void test('the actual Sapho purchaser can pause its own genuinely claimed Charity turn', () => {
+ const f = fixture();
+ let game = advance(structuredClone(f.game), state => state.phase === 2 && clean(state) &&
+ state.charityOrder?.remaining[0] === f.owner);
+ assert.ok(game.charityOrder && game.charityOrder.remaining.length > 1);
+ assert.ok(player(game, f.owner).hand.some(card => card.id === f.card));
+ const result = claim(game, f.owner);
+ assert.ok(result.steps.some(step => step.action.type === 'bribe'));
+ assert.ok(result.steps.some(step => step.action.type === 'charity'));
+ game = result.game;
+ const before = structuredClone(player(game, f.owner));
+ game = act(game, f.owner, ownLast(game, f.owner, f.card));
+ assert.equal(game.charityOrder?.remaining.at(-1), f.owner);
+ assert.equal(player(game, f.owner).spice, before.spice);
+ assert.equal(player(game, f.owner).charityTurn, game.turn);
+ game = advance(game, state => state.phase === 2 && clean(state) && state.charityOrder?.remaining[0] === f.owner);
+ reject(game, f.owner, { type: 'charity' });
+ game = finish(act(game, f.owner, { type: 'ready' }));
+ assert.equal(game.phase, 3);
+ assert.deepEqual(inventory(game), f.originalInventory);
+});
+
+void test('all four minimal bot profiles can use a projected Advanced own Charity suspension', () => {
+ const { f, game } = source();
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(game, f.current);
+ view.players.find(seat => seat.id === f.current)!.bot = difficulty;
+ const proposed = botActions(view).find(row => row.type === 'card' && row.card === f.card && row.scope === 'charity');
+ assert.ok(proposed, `${difficulty} proposes the physical owned Sapho`);
+ assert.equal('afterCurrent' in proposed, false);
+ assert.equal('resumeOwn' in proposed, false);
+ const next = act(game, f.current, proposed);
+ assert.equal(next.charityOrder?.remaining.at(-1), f.current);
+ assert.equal(player(next, f.current).spice, player(game, f.current).spice);
+ assert.equal(player(next, f.current).charityTurn, game.turn);
+ assert.equal(next.discard.filter(card => card.id === f.card).length, 1);
+ }
+});
diff --git a/tests/sapho-charity-suspension-quote.test.ts b/tests/sapho-charity-suspension-quote.test.ts
new file mode 100644
index 00000000..ff792538
--- /dev/null
+++ b/tests/sapho-charity-suspension-quote.test.ts
@@ -0,0 +1,75 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ CharityOrderError, createCharityOrder, finishCharityOrder,
+ reorderSaphoCharityOrder, saphoCharityOrderEvent, startCharityOrder,
+ validateCharityOrder, type CharityOrder, type CharityOrderContext,
+} from '../game/charity-order';
+
+const order = ['a', 'b', 'c', 'd'];
+function context(ready: string[] = [], claimed: string[] = []): CharityOrderContext {
+ return { turn: 5, order, players: order.map(id => ({ id })), ready, claimed };
+}
+function use(ctx: CharityOrderContext, frame: CharityOrder, player: string, mode: 'first' | 'last') {
+ return reorderSaphoCharityOrder(ctx, frame, { event: saphoCharityOrderEvent(ctx, frame), player, mode });
+}
+
+void test('a started Charity claim can pause last and resume the same started opportunity', () => {
+ const initial = context(), frame = createCharityOrder(initial), claimed = context([], ['a']);
+ assert.equal(JSON.stringify(frame), '{"version":1,"turn":5,"order":["a","b","c","d"],"remaining":["a","b","c","d"]}');
+ const result = use(claimed, frame, 'a', 'last');
+ assert.equal(result.suspended, 'a'); assert.equal(result.afterCurrent, undefined);
+ assert.ok(result.order.version === 3);
+ assert.deepEqual(result.order.remaining, ['b', 'c', 'd', 'a']);
+ assert.deepEqual(result.order.paused, ['a']); assert.equal(result.order.started, false);
+ validateCharityOrder(claimed, result.order);
+ let current = result.order as CharityOrder, ready: string[] = [];
+ for (const actor of ['b', 'c', 'd']) {
+ current = finishCharityOrder(context(ready, ['a']), current, actor);
+ ready = [...ready, actor];
+ }
+ assert.ok(current.version === 3); assert.equal(current.started, true);
+ assert.deepEqual(current.paused, ['a']);
+ current = finishCharityOrder(context(ready, ['a']), current, 'a');
+ assert.ok(current.version === 3); assert.deepEqual(current.paused, []);
+ assert.deepEqual(current.remaining, []); assert.equal(current.started, false);
+ validateCharityOrder(context([...ready, 'a'], ['a']), current);
+});
+
+void test('a prior version-two Sapho order retains its history when the current claimant suspends', () => {
+ const ctx = context(), original = createCharityOrder(ctx);
+ const prior = use(ctx, original, 'd', 'first').order;
+ assert.ok(prior.version === 2); const saved = JSON.stringify(prior);
+ const started = startCharityOrder(ctx, prior, 'd');
+ const claimed = context([], ['d']); validateCharityOrder(claimed, started);
+ const result = use(claimed, started, 'd', 'last');
+ assert.ok(result.order.version === 3); assert.equal(result.order.uses.length, 2);
+ assert.deepEqual(result.order.uses[0], prior.uses[0]);
+ assert.deepEqual(result.order.remaining, ['a', 'b', 'c', 'd']);
+ assert.equal(JSON.stringify(prior), saved);
+ validateCharityOrder(claimed, JSON.parse(JSON.stringify(result.order)) as CharityOrder);
+});
+
+void test('a paused claimant moved first by another genuine use resumes with its claim still spent', () => {
+ const claimed = context([], ['a']);
+ const paused = use(claimed, createCharityOrder(context()), 'a', 'last').order;
+ const resumed = use(claimed, paused, 'a', 'first').order;
+ assert.ok(resumed.version === 3); assert.equal(resumed.remaining[0], 'a');
+ assert.equal(resumed.started, true); assert.deepEqual(resumed.paused, ['a']);
+ const complete = finishCharityOrder(claimed, resumed, 'a');
+ assert.ok(complete.version === 3); assert.deepEqual(complete.paused, []);
+ validateCharityOrder(context(['a'], ['a']), complete);
+});
+
+void test('started first, stale requests, altered custody and impossible history reject without mutation', () => {
+ const ctx = context([], ['a']), original = createCharityOrder(context());
+ const old = saphoCharityOrderEvent(context(), original);
+ const before = structuredClone({ ctx, original });
+ assert.throws(() => use(ctx, original, 'a', 'first'), CharityOrderError);
+ assert.throws(() => reorderSaphoCharityOrder(ctx, original, { event: old, player: 'a', mode: 'last' }), CharityOrderError);
+ const paused = use(ctx, original, 'a', 'last').order; assert.ok(paused.version === 3);
+ assert.throws(() => validateCharityOrder(context(), paused), CharityOrderError);
+ assert.throws(() => validateCharityOrder(ctx, { ...paused, paused: [] }), CharityOrderError);
+ assert.throws(() => validateCharityOrder(ctx, { ...paused, remaining: ['c', 'b', 'd', 'a'] }), CharityOrderError);
+ assert.deepEqual({ ctx, original }, before);
+});
diff --git a/tests/sapho-revival-order-controls.test.tsx b/tests/sapho-revival-order-controls.test.tsx
index 9e63f1fb..6bb4a57b 100644
--- a/tests/sapho-revival-order-controls.test.tsx
+++ b/tests/sapho-revival-order-controls.test.tsx
@@ -54,6 +54,12 @@ function press(option: Option, busy = false, card = sapho!.id): Action[] {
SaphoOrderButton({ card, option, busy, act(action) { actions.push(action); } }).props.onClick();
return actions;
}
+function pressFinish(game: GameView, busy = false) {
+ const sent: Action[] = [];
+ const button = FinishRevivalTurn({ game, busy, act(action) { sent.push(action); } });
+ button.props.onClick();
+ return { sent, disabled: button.props.disabled };
+}
void test('clean first and last Revival choices use their own labels and preserve completed-turn guidance', () => {
for (const mode of ['first', 'last'] as const) {
@@ -106,6 +112,38 @@ void test('own started Revival pause explains retained allowances and sends the
assert.deepEqual(press(option, true), []);
});
+void test('actual v3 Revival queue enables the current player and blocks the paused player', () => {
+ const source = createSaphoRevivalOrderFixture({ started: true });
+ let game = structuredClone(source.game);
+ // Transfer the one physical card to the real claimant; no card is created.
+ const donor = game.players.find(player => player.id === source.owner)!;
+ const claimant = game.players.find(player => player.id === source.current)!;
+ const cardIndex = donor.hand.findIndex(card => card.id === source.card);
+ assert.ok(cardIndex >= 0);
+ claimant.hand.push(donor.hand.splice(cardIndex, 1)[0]);
+ const pause = viewGame(game, source.current).saphoOptions.find(option =>
+ option.scope === 'revival' && option.mode === 'last');
+ assert.ok(pause && pause.scope === 'revival' && pause.resumeOwn);
+ game = actSaphoRevivalOrder(game, source.current, press(pause, false, source.card)[0]!);
+ assert.ok(game.revivalOrder && game.revivalOrder.version === 3);
+ let actor = game.revivalOrder.remaining[0];
+ assert.notEqual(actor, source.current);
+ const waiting = viewGame(game, source.current);
+ assert.deepEqual(pressFinish(waiting), { sent: [], disabled: true });
+ while (actor !== source.current) {
+ const active = viewGame(game, actor);
+ assert.match(revival(active), /Current Revival actor:/);
+ const finish = pressFinish(active);
+ assert.deepEqual(finish, { sent: [{ type: 'ready' }], disabled: false });
+ game = actSaphoRevivalOrder(game, actor, finish.sent[0]!);
+ actor = game.revivalOrder?.remaining[0] ?? '';
+ assert.ok(actor);
+ }
+ const resumed = viewGame(game, source.current);
+ assert.deepEqual(pressFinish(resumed), { sent: [{ type: 'ready' }], disabled: false });
+ assert.deepEqual(pressFinish(resumed, true), { sent: [], disabled: true });
+});
+
void test('Basic unstamped and completed controlled views offer no Revival order button even with stale projected options', () => {
const basic = projected(); basic.advanced = false; basic.revivalOrder = null;
const legacy = projected(); legacy.revivalOrder = null;