Connect Sapho to ordered Advanced Charity

This commit is contained in:
Quintine committed 2026-10-27 11:42:00 +11:00
1 parent e1596dbcee
commit 28df687bf9
15 files changed
+1080 -38

No files matched your search

+4 -2
View File
@@ -35,8 +35,10 @@ 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).
acts. An owned Juice of Sapho offers first/last among remaining Charity turns;
a player who already claimed finishes before a reordered later queue. This
queued timing is provisional. 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
+3 -3
View File
@@ -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.turn === turn && order.remaining[0] === actor;
const ownTurn = !order || (order.version === 1 || order.version === 2) && 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 && <div className="fine" data-testid="charity-turn-status">
<p>Advanced Charity proceeds from the First Player counterclockwise, one player at a time.</p>
<p>{order.version !== 1 || order.turn !== turn
<p>{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.'}</p>
<p>{(order.version !== 1 && order.version !== 2) || 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.`
+24 -6
View File
@@ -15,6 +15,10 @@ function unavailableReason(game: GameView) {
if (game.truthtrance || game.response || game.phaseOpening ||
(game.decision && !(game.phase === 8 && game.decision.kind === 'moritaniExtortion')))
return 'Finish the current interaction before changing turn order.';
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.';
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.'
@@ -63,7 +67,7 @@ export function SaphoOrderButton({
}}
>
{option.scope === 'battleAggressor' ? 'Become aggressor 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' : 'Take your movement turn'} ${option.mode} 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`}
</Button>
);
}
@@ -83,9 +87,12 @@ export function JuiceOfSapho({
(c) => richeseCardDefinition(c)?.card.effect === 'juiceOfSapho',
);
if (!card) return null;
const options = (game.saphoOptions ?? []).filter(option => option.scope !== 'revival' ||
(game.advanced && game.phase === 4 && game.revivalOrder?.remaining.includes(game.me) &&
!game.revivalOrder.completed.includes(game.me)));
const options = (game.saphoOptions ?? []).filter(option =>
option.scope === 'charity'
? game.advanced && game.phase === 2 && !!game.charityOrder?.remaining.includes(game.me)
: option.scope !== 'revival' ||
(game.advanced && game.phase === 4 && game.revivalOrder?.remaining.includes(game.me) &&
!game.revivalOrder.completed.includes(game.me)));
return (
<details className="my-4 min-w-0">
<summary className="min-h-11 cursor-pointer py-3">
@@ -105,7 +112,8 @@ export function JuiceOfSapho({
const aggressor = option.scope === 'battleAggressor';
const extortion = option.scope === 'extortion';
const revival = option.scope === 'revival';
const afterCurrent = (option.scope === 'movement' || revival) && option.afterCurrent;
const charity = option.scope === 'charity';
const afterCurrent = (option.scope === 'movement' || revival || charity) && option.afterCurrent;
const currentPlayer = afterCurrent
? game.players.find(player => player.id === afterCurrent)?.name ?? 'The active player'
: null;
@@ -114,7 +122,15 @@ export function JuiceOfSapho({
key={`${option.scope}-${option.event}-${option.mode}`}
className="flex min-w-0 flex-col gap-2"
>
{currentPlayer && (revival ? (
{currentPlayer && (charity ? (
<p aria-label="Provisional queued Charity order">
Provisional queued Charity order: {currentPlayer} finishes
the current Charity turn unchanged. Your {option.mode}
{' '}position applies among later unstarted Charity turns.
Completed turns and claims stay finished; each player
still has only one Charity claim.
</p>
) : revival ? (
<p aria-label="Provisional queued Revival order">
Provisional queued Revival order: {currentPlayer} finishes
the current Revival turn unchanged. Your {option.mode}
@@ -145,6 +161,8 @@ export function JuiceOfSapho({
? `Bid ${option.mode} in the current Once Around lot. Each eligible player still has only one bidding opportunity.`
: battle
? `Choose your remaining battles ${option.mode}. Completed battles stay resolved. This changes who chooses next, not the aggressor or tie advantage of a battle. Other players may still choose battles against you before your turn.`
: charity
? `Take the ${option.mode} remaining Charity turn. Completed Charity turns stay finished. Each player still has only one Charity claim; changing order does not grant another claim or reopen completed turns.`
: revival
? `Take the ${option.mode} remaining Revival turn. Completed Revival turns stay finished. This changes the remaining order without granting another revival, resetting allowances or returning completed players to the queue.`
: `Take the ${option.mode} remaining combined shipment and movement turn. Completed turns stay completed${option.mode === 'last' ? ', and the Guild cannot move behind you' : game.advanced && game.players.some(player => player.faction === 'guild' && player.id !== game.me) ? '. Provisional first priority: the Guild retains its next legal timing choice and may take its turn before yours; a Karama-locked privilege stays locked' : ''}.`}
+70 -5
View File
@@ -44,11 +44,38 @@ 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.
## Juice of Sapho — 27 October 2026
## Focused evidence
The printed [Sapho first/last alternative](JUICE_OF_SAPHO_SOURCE_UPDATE.md)
applies to a player-ordered phase. An unreserved owned physical card now takes
its holder first or last among the remaining Charity turns and is discarded
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`
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.
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
display-only `afterCurrent` metadata. Current/wait/completed claim and Finish
controls follow both frame versions. Existing minimal legal policies use the
same projected options; no strategic AI tuning is added. If the optional Semuta
preview offers the real used card, the queue is committed before that separate
discard reaction and resumes once, retaining the claim receipts and turn.
Broader optional-module interactions, full-mode and final acceptance remain.
## Initial ordered-phase evidence
Frozen **15 files, 147/147**, types/lint/build and four native start programmes
pass. Three explicit Advanced previews exercise allowed income, original Karama
@@ -72,5 +99,43 @@ Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-advanced-charity-o
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,
This is the earlier local native/SSR/callback evidence. The Sapho follow-up above
adds its own bounded proof; full modes, wider interactions,
final rules/recovery/privacy and deployed acceptance remain due.
## Sapho focused evidence
Sapho now takes its holder 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
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).
Frozen **12-file union, 108/108**, types/lint/build and four original-game
Advanced preview programmes pass. Actual original cache auction bid1 buys
Sapho; the native first turn continues through Storm2 to Charity2. Clean and
actually claimed current turns exercise both first/last. Runtime records
12 GameTable SSR, 4 Sapho panels, 8 Charity panels, 4 actual SaphoOrderButton,
2 claim and 4 Finish callbacks. Source/further action arrays total320/24.
Actual bribes create poverty; no played hand/purse/phase/queue/claim is installed.
Other native controls are applied directly, without a clicked-control claim.
Browser hydration/navigation and deployed acceptance were not operated.
Eighteen new helper/native/control cases pass, including all four minimal
profiles and Semuta claim/pass. The two Semuta cases inherit an explicitly
controlled conserved physical-card acquisition, advance forward from actual
Revival2 to Charity3, then exercise the real disposal continuation. They are
supplemental composition evidence, not original acquisition proof. The committed
queue resumes once without another claim or order application. AI strategy and
calibration stay deferred.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-sapho-charity-order-proof-v1/`;
journeys/HTML: adjacent `20261027-sapho-charity-order-runtime/run-v1/`.
All checks use the same frozen source; the final evidence prose is a separately
recorded three-document overlay. The existing server returned HTTP200 without
restart. No saved-game/database operation. Wider interactions, complete modes
and final rules/recovery/privacy/browser/deployed acceptance remain.
+38 -1
View File
@@ -20,6 +20,42 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Sapho in ordered Advanced Charity — 27 October. Prototyped / Partial.**
Sapho now takes its holder 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
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).
Frozen **12-file union, 108/108**, types/lint/build and four original-game
Advanced preview programmes pass. Actual original cache auction bid1 buys
Sapho; the native first turn continues through Storm2 to Charity2. Clean and
actually claimed current turns exercise both first/last. Runtime records
12 GameTable SSR, 4 Sapho panels, 8 Charity panels, 4 actual SaphoOrderButton,
2 claim and 4 Finish callbacks. Source/further action arrays total320/24.
Actual bribes create poverty; no played hand/purse/phase/queue/claim is installed.
Other native controls are applied directly, without a clicked-control claim.
Browser hydration/navigation and deployed acceptance were not operated.
Eighteen new helper/native/control cases pass, including all four minimal
profiles and Semuta claim/pass. The two Semuta cases inherit an explicitly
controlled conserved physical-card acquisition, advance forward from actual
Revival2 to Charity3, then exercise the real disposal continuation. They are
supplemental composition evidence, not original acquisition proof. The committed
queue resumes once without another claim or order application. AI strategy and
calibration stay deferred.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-sapho-charity-order-proof-v1/`;
journeys/HTML: adjacent `20261027-sapho-charity-order-runtime/run-v1/`.
All checks use the same frozen source; the final evidence prose is a separately
recorded three-document overlay. The existing server returned HTTP200 without
restart. No saved-game/database operation. Wider interactions, complete modes
and final rules/recovery/privacy/browser/deployed acceptance remain.
**Advanced Family Atomics — 27 October. Prototyped / Partial.**
Play after turn1 and calculated distance, before movement, from physical
fighters on/adjacent to the Wall with no strictly intervening storm. Endpoints
@@ -88,7 +124,8 @@ callbacks and 9 Finish callbacks. Recorded source/further arrays total
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.
sources now finish actual preceding turns. Sapho composition was still missing
at this earlier checkpoint; the follow-up above adds its bounded scope.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-advanced-charity-order-proof-v4/`;
journeys/HTML: adjacent `20261027-advanced-charity-order-runtime/run-v2/`.
+37
View File
@@ -1,5 +1,42 @@
# Dune implementation status
## 27 October 2026 — Sapho in ordered Advanced Charity
Prototyped / Partial. Sapho now takes its holder 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
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).
Frozen **12-file union, 108/108**, types/lint/build and four original-game
Advanced preview programmes pass. Actual original cache auction bid1 buys
Sapho; the native first turn continues through Storm2 to Charity2. Clean and
actually claimed current turns exercise both first/last. Runtime records
12 GameTable SSR, 4 Sapho panels, 8 Charity panels, 4 actual SaphoOrderButton,
2 claim and 4 Finish callbacks. Source/further action arrays total320/24.
Actual bribes create poverty; no played hand/purse/phase/queue/claim is installed.
Other native controls are applied directly, without a clicked-control claim.
Browser hydration/navigation and deployed acceptance were not operated.
Eighteen new helper/native/control cases pass, including all four minimal
profiles and Semuta claim/pass. The two Semuta cases inherit an explicitly
controlled conserved physical-card acquisition, advance forward from actual
Revival2 to Charity3, then exercise the real disposal continuation. They are
supplemental composition evidence, not original acquisition proof. The committed
queue resumes once without another claim or order application. AI strategy and
calibration stay deferred.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-sapho-charity-order-proof-v1/`;
journeys/HTML: adjacent `20261027-sapho-charity-order-runtime/run-v1/`.
All checks use the same frozen source; the final evidence prose is a separately
recorded three-document overlay. The existing server returned HTTP200 without
restart. No saved-game/database operation. Wider interactions, complete modes
and final rules/recovery/privacy/browser/deployed acceptance remain.
## 27 October 2026 — Advanced Family Atomics timing and removal
Prototyped / Partial. Play after turn1 and calculated distance, before movement, from physical
+15 -1
View File
@@ -1,5 +1,18 @@
# Rule decision index
## Sapho in ordered Advanced Charity — 27 October 2026
The printed first/last alternative applies to the supplied rulebook's newly
ordered Advanced Charity phase. Actual use retains physical First Player
order, completed turns and native once-per-turn claims; Basic/old unstamped
concurrent phases have no manufactured scope. Use creates version2 history;
untouched version1 frames retain their bytes. Under the standing recommended
interpretation authority, an accepted native claim protects the current turn:
a different holder reorders only later unstarted turns. The holder cannot split
its own started turn. This retained-current timing is provisional software
composition, not a new publisher ruling. Existing response/disposal timing
precedes the next action. [Source and scope](ADVANCED_CHARITY_ORDER.md#juice-of-sapho--27-october-2026).
## Advanced Family Atomics — 27 October 2026
Supplied physical39/printed37 requires play after turn1, after distance
@@ -30,7 +43,8 @@ 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.
claim and printed order. The later Sapho adapter above supplies first/last;
broader interactions remain.
[Source, lifecycle and boundaries](ADVANCED_CHARITY_ORDER.md).
## Ixian starting draft with Harkonnen — 27 October 2026
+142 -10
View File
@@ -1,15 +1,28 @@
import { reorderOrderedOpportunity, OrderedOpportunityError } from './ordered-opportunity';
/** Native Charity follows the actual First Player order, one opportunity per seat. */
export type CharityOrder = {
version: 1;
type CharityOrderFields = {
turn: number;
order: string[];
remaining: string[];
};
export type SaphoCharityOrderUse = {
event: string;
player: string;
mode: 'first' | 'last';
completed: string[];
started: boolean;
};
export type CharityOrder = CharityOrderFields & (
| { version: 1 }
| { version: 2; completed: string[]; started: boolean; uses: SaphoCharityOrderUse[] }
);
export type CharityOrderContext = {
turn: number;
order: readonly string[];
players: readonly { id: string }[];
ready: readonly string[];
claimed?: readonly string[];
};
export class CharityOrderError extends Error {
constructor(message: string) {
@@ -38,25 +51,130 @@ function validateContext(context: CharityOrderContext): void {
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.');
requireOrder(ids(context.ready) && context.ready.every(player => seats.includes(player)),
'Completed Charity opportunities must be distinct actual seats.');
requireOrder(context.claimed === undefined ||
(ids(context.claimed) && context.claimed.every(player => seats.includes(player))),
'Charity claim receipts must name distinct actual seats.');
}
export function createCharityOrder(context: CharityOrderContext): CharityOrder {
validateContext(context);
requireOrder(same(context.ready, context.order.slice(0, context.ready.length)),
'Completed Charity opportunities must be the exact public-order prefix.');
return { version: 1, turn: context.turn, order: [...context.order],
remaining: context.order.slice(context.ready.length) };
}
function currentStarted(context: CharityOrderContext, frame: CharityOrder): boolean {
return frame.version === 2 ? 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] })),
};
}
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),
requireOrder(record(frame) && (frame.version === 1 || frame.version === 2) &&
Object.keys(frame).sort().join(',') === (frame.version === 1
? 'order,remaining,turn,version' : 'completed,order,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) &&
same(frame.remaining, context.order.slice(context.ready.length)),
requireOrder(same(frame.order, context.order), 'Charity must retain its original physical order.');
if (frame.version === 1) requireOrder(same(context.ready, frame.order.slice(0, context.ready.length)) &&
same(frame.remaining, frame.order.slice(context.ready.length)),
'Charity must retain its original public order and exact remaining suffix.');
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);
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.');
}
}
function eventFor(turn: number, order: readonly string[], remaining: readonly string[],
completed: readonly string[], started: boolean, revision: number): string {
return `saphoCharity:${JSON.stringify([turn, order, remaining, completed, started, revision])}`;
}
function advanceCompleted(remaining: readonly string[], completed: readonly string[], started: boolean,
next: readonly string[]) {
requireOrder(next.length >= completed.length && same(next.slice(0, completed.length), completed),
'Sapho cannot reopen or replace completed Charity opportunities.');
const count = next.length - completed.length;
requireOrder(count <= remaining.length && same(next.slice(completed.length), remaining.slice(0, count)),
'Charity completions must follow the current remaining opportunity order.');
return { remaining: remaining.slice(count), completed: [...next], started: started && count === 0 };
}
function reordered(order: readonly string[], remaining: readonly string[], completed: readonly string[],
started: boolean, use: Pick<SaphoCharityOrderUse, 'event' | 'player' | 'mode'>) {
requireOrder(remaining.includes(use.player), 'Only a pending faction may change its Charity position.');
requireOrder(!started || use.player !== remaining[0],
'Juice of Sapho cannot split a Charity opportunity that has already started.');
try {
const next = reorderOrderedOpportunity({ event: use.event, eligible: order, remaining, completed,
current: remaining[0] ?? null, currentStarted: started },
{ event: use.event, player: use.player, position: use.mode });
requireOrder(!same(next.remaining, remaining), 'This faction is already in that remaining Charity position.');
return [...next.remaining];
} catch (error) {
if (error instanceof OrderedOpportunityError) throw new CharityOrderError(error.message);
throw error;
}
}
function validateSaphoHistory(frame: Extract<CharityOrder, { version: 2 }>): void {
requireOrder(Array.isArray(frame.uses) && frame.uses.length > 0,
'Reordered Charity needs its actual Juice of Sapho use history.');
let remaining = [...frame.order], completed: string[] = [], started = false;
for (const [index, use] of frame.uses.entries()) {
requireOrder(record(use) && Object.keys(use).sort().join(',') === 'completed,event,mode,player,started' &&
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.');
const advanced = advanceCompleted(remaining, completed, started, use.completed);
requireOrder(!advanced.started || use.started,
'A later Sapho use cannot unlock the current started Charity opportunity.');
requireOrder(!use.started || advanced.remaining.length > 0,
'Only a current Charity opportunity can have started.');
requireOrder(use.event === eventFor(frame.turn, frame.order, advanced.remaining,
advanced.completed, use.started, index), 'The saved Sapho Charity use lost its original public boundary.');
remaining = reordered(frame.order, advanced.remaining, advanced.completed, use.started, use);
completed = advanced.completed;
started = use.started;
}
const advanced = advanceCompleted(remaining, completed, started, frame.completed);
requireOrder(same(frame.remaining, advanced.remaining) && (!advanced.started || frame.started),
'The Charity frame contradicts its Sapho history or unlocks a started opportunity.');
}
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);
}
export function reorderSaphoCharityOrder(context: CharityOrderContext, frame: CharityOrder, request: {
event: string; player: string; mode: 'first' | 'last';
}): { order: CharityOrder; afterCurrent?: 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 }] };
validateCharityOrder(context, order);
return { order, ...(started ? { afterCurrent: frame.remaining[0] } : {}) };
}
export function charityOrderBlock(context: CharityOrderContext, frame: CharityOrder, player: string): string | null {
@@ -68,11 +186,25 @@ export function charityOrderBlock(context: CharityOrderContext, frame: CharityOr
return frame.remaining[0] === player ? null : 'Wait for your Charity opportunity.';
}
/** Pre-claim quote: the caller records the accepted native claim in the same
* transaction. Version one derives that start from context, retaining its bytes.
*/
export function startCharityOrder(context: CharityOrderContext, frame: CharityOrder, player: string): CharityOrder {
const blocked = charityOrderBlock(context, frame, player);
requireOrder(blocked === null, blocked ?? 'This Charity opportunity is unavailable.');
const next = copy(frame);
return next.version === 1 ? next : { ...next, started: true };
}
/** 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) };
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 };
validateCharityOrder({ ...context, ready: [...context.ready, player] }, next);
return next;
}
+84 -3
View File
@@ -44,7 +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,
import { createCharityOrder, validateCharityOrder, charityOrderBlock, startCharityOrder,
saphoCharityOrderEvent, reorderSaphoCharityOrder,
finishCharityOrder, CharityOrderError, type CharityOrder } from './charity-order';
import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival';
import { semutaDisasterArrivalSupported } from './semuta-disaster-arrival';
@@ -1608,6 +1609,15 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'saphoCharityDiscard';
player: string;
card: string;
event: string;
mode: 'first' | 'last';
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'saphoRevivalDiscard';
player: string;
@@ -5852,6 +5862,17 @@ type SaphoExtortionDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'saphoExtortionDiscard' }
>;
type SaphoCharityDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'saphoCharityDiscard' }
>;
function saphoCharityDiscardSignature(g: Game, c: SaphoCharityDiscardContinuation) {
return JSON.stringify({player:c.player,card:c.card,event:c.event,mode:c.mode,
parent:nullentropyParentSignature(g,c.resume,c.event),turn:g.turn,phase:g.phase,
advanced:g.advanced,order:g.order,charityOrder:g.charityOrder,active:g.active,ready:g.ready,
inflation:g.inflation,choamCharity:g.choamCharity,
players:g.players.map(p=>({id:p.id,faction:p.faction,spice:p.spice,bribes:p.bribes,charityTurn:p.charityTurn}))});
}
type SaphoRevivalDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'saphoRevivalDiscard' }
@@ -7484,6 +7505,18 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === distransDiscardSignature(g, c),
'The completed private Distrans transfer no longer matches its fresh discard.',
);
} 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 &&
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 &&
richeseCardDefinition(entry.card)?.card.effect==='juiceOfSapho' &&
!c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening &&
c.stateSignature===saphoCharityDiscardSignature(g,c),
'The used Juice of Sapho no longer matches its committed Charity order.');
charityOrderIntegrity(g);
} else if (continuation?.kind === 'saphoRevivalDiscard') {
const c=continuation,entry=batch.entries[0],frame=g.revivalOrder;
const latest=frame?.version===2 ? frame.uses.at(-1) : null;
@@ -8668,6 +8701,7 @@ function semutaOfferSupported(
continuation.kind !== 'nexusChoamBetrayal' &&
continuation.kind !== 'saphoMovementDiscard' &&
continuation.kind !== 'saphoRevivalDiscard' &&
continuation.kind !== 'saphoCharityDiscard' &&
continuation.kind !== 'saphoExtortionDiscard' &&
continuation.kind !== 'saphoAggressorDiscard' &&
continuation.kind !== 'saphoAuctionDiscard' &&
@@ -8738,6 +8772,7 @@ function semutaOfferSupported(
continuation.kind !== 'saphoAuctionDiscard' &&
continuation.kind !== 'saphoMovementDiscard' &&
continuation.kind !== 'saphoRevivalDiscard' &&
continuation.kind !== 'saphoCharityDiscard' &&
continuation.kind !== 'saphoExtortionDiscard' &&
continuation.kind !== 'battleResolved' &&
continuation.kind !== 'winnerMandatoryDiscard' &&
@@ -8754,6 +8789,11 @@ function semutaOfferSupported(
continuation.kind !== 'karamaBattleDiscard') return false;
if (continuation.kind === 'battleResolved' &&
!g.lastBattleContext?.mandatoryDiscard) return false;
if (continuation.kind === 'saphoCharityDiscard' &&
(!g.advanced || g.phase!==2 || g.charityOrder?.version!==2 ||
continuation.resume.response || continuation.resume.decision ||
continuation.resume.pendingKarama || continuation.resume.phaseOpening ||
g.response || g.decision || g.pendingRevival)) return false;
if (continuation.kind === 'saphoRevivalDiscard' &&
(!g.advanced || g.phase!==4 || g.revivalOrder?.version!==2 ||
continuation.resume.response || continuation.resume.decision ||
@@ -9214,6 +9254,12 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
} else if (next.kind === 'saphoCharityDiscard') {
g.response=next.resume.response;
g.decision=next.resume.decision;
g.pendingKarama=next.resume.pendingKarama;
g.phaseOpening=next.resume.phaseOpening;
charityOrderIntegrity(g);
} else if (next.kind === 'saphoRevivalDiscard') {
g.response=next.resume.response;
g.decision=next.resume.decision;
@@ -25737,7 +25783,7 @@ function offerMoritaniDuke(g: Game) {
return true;
}
type SaphoOption = {
scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder' | 'extortion' | 'revival';
scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder' | 'extortion' | 'revival' | 'charity';
event: string;
mode: 'first' | 'last';
/** Display only; action submission keeps scope/event/mode. */
@@ -25919,6 +25965,18 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
)
return [];
const options: SaphoOption[] = [];
if (g.phase===2 && g.advanced && g.charityOrder) {
const context=charityOrderContext(g);
const event=charityOrderRule(()=>saphoCharityOrderEvent(context,g.charityOrder!));
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} : {})});
} catch(error) {
if (!(error instanceof CharityOrderError)) throw error;
}
}
}
if (g.phase===4 && g.advanced && g.revivalOrder) {
const context=revivalOrderContext(g);
const event=revivalOrderRule(()=>saphoRevivalOrderEvent(context,g.revivalOrder!));
@@ -26107,6 +26165,26 @@ function playSapho(g: Game, p: Player, action: Action) {
}
}
settleRicheseLot(g);
} else if (option.scope === 'charity') {
requireRule(g.charityOrder,'The original ordered Charity is no longer available.');
const quote=charityOrderRule(()=>reorderSaphoCharityOrder(charityOrderContext(g),g.charityOrder!,
{event:option.event,player:p.id,mode:option.mode}));
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.`,
{faction:p.faction,name:'Juice of Sapho'});
if (g.semutaPreview) {
const continuation:SaphoCharityDiscardContinuation={kind:'saphoCharityDiscard',player:p.id,card:used.id,
event:option.event,mode:option.mode,
resume:{response:g.response,decision:g.decision,pendingKarama:g.pendingKarama,phaseOpening:g.phaseOpening},
stateSignature:''};
const entries=[{card:used,discardedBy:p.id,publicFace:true}];
if(semutaOfferSupported(g,continuation,entries)) {
continuation.stateSignature=saphoCharityDiscardSignature(g,continuation);
stageTreacheryDiscard(g,'sapho:charity',entries,continuation);
}
}
} else if (option.scope === 'revival') {
requireRule(g.revivalOrder,'The original ordered Revival is no longer available.');
const quote=revivalOrderRule(()=>reorderSaphoRevivalOrder(revivalOrderContext(g),g.revivalOrder!,
@@ -26734,7 +26812,8 @@ 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};
return {turn:g.turn,order:g.order,players:g.players,ready:g.ready,
claimed:g.players.filter(p=>p.charityTurn===g.turn).map(p=>p.id)};
}
function charityOrderRule<T>(calculate: () => T): T {
try { return calculate(); }
@@ -41607,6 +41686,8 @@ function applyActionInner(
p.charityTurn !== g.turn,
'Charity may be claimed only once per turn.',
);
if (g.charityOrder)
g.charityOrder = charityOrderRule(() => startCharityOrder(charityOrderContext(g),g.charityOrder!,id));
p.charityTurn = g.turn;
if (advancedBG && p.spice >= 2)
g.response = {
+13 -7
View File
@@ -122,7 +122,7 @@ 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.',
'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.',
'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. Owned Sapho changes only remaining Charity order; an accepted current claim provisionally protects that turn before the reordered tail.',
],
[
'Before each auction, resolve the Atreides foresight response. The card stays hidden until that response is allowed. Cancellation affects this card only.',
@@ -503,10 +503,11 @@ export const RULE_TOPICS: RuleTopic[] = [
'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.',
'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.',
'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 Revival, take your unfinished opportunity first or last, then discard Sapho. If the current player has already begun a native return or negotiation, provisional queued timing lets that player finish unchanged; first means first among the later unstarted turns. You cannot split your own started turn, repeat a completed turn or consume the card for a position you already hold. Basic and older concurrent Revival phases have no ordered scope.',
'Finish existing shipment, movement, card preparation and pending 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.',
'In the explicitly opted-in Semuta preview, clean Once Around, movement and ordered-Revival discards pause after their queues reorder; a clean between-battles chooser discard pauses after changing battle priority but before the next chooser acts; an aggressor discard pauses after changing ordinary tie priority. Revival retains its current participant, requests and allowances through claim/pass. Fresh late continuations also bind revealed choices and the public final revision; old continuation bytes remain unchanged. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unresolved battle continues once. Direct combined late claim/capture proof remains open.',
'In the explicitly opted-in Semuta preview, clean Once Around, movement and ordered Charity/Revival discards pause after their queues reorder; a clean between-battles chooser discard pauses after changing battle priority but before the next chooser acts; an aggressor discard pauses after changing ordinary tie priority. Charity/Revival retain their current participant, claims or requests and allowances through Semuta claim/pass. Fresh late continuations also bind revealed choices and the public final revision; old continuation bytes remain unchanged. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unresolved battle continues once. Direct combined late claim/capture proof remains open.',
'Both combatants receive the same persistent final battle opportunity after original votes and native late defense, regardless of held cards or result. Finish explicitly; a Sapho priority change clears both finish marks with a fresh event. The second finish closes priority before Stone counts and outcome settlement. This shared online procedure, cyclic lot duration and queued movement timing are provisional conventions. Other phase ordering and splitting your own started combined turn remain unfinished. Silent bids retain simultaneous timing and storm tie order.',
],
example:
@@ -522,13 +523,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Finite Once Around, current-lot cyclic bidding, clean/queued movement and ordered Advanced Revival, remaining battle-choice and late aggressor modes are connected. Queued timing retains the started actor; Revival keeps its requests/allowances and separate physical order through exact Semuta disposal. Tail timing is visibly provisional. Other phases and split-own-turn mode 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 the started actor; Charity/Revival keep native claims or requests/allowances and separate physical order through their Semuta disposal. Tail timing is visibly provisional. Other phases and split-own-turn mode remain unfinished.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Owner-only first/last and unresolved-battle aggressor controls retain public priority. Queued movement names the started actor who finishes first, explains provisional tail ordering and sends only original action fields; afterCurrent is display metadata. Final battle/cyclic controls retain their existing scope. Older battles keep early timing.',
'Owner-only first/last and unresolved-battle aggressor controls retain public priority. Queued movement and Charity/Revival name the started actor who finishes first, explain provisional tail ordering and sends only original action fields; afterCurrent is display metadata. Final battle/cyclic controls retain their existing scope. Older battles keep early timing.',
},
{
area: 'AI',
@@ -549,6 +550,11 @@ export const RULE_TOPICS: RuleTopic[] = [
'18 October Extortion:9-file115/115, types/lint/build and six original-action/rendered-table programmes pass. Public Basic/Advanced cache1, placed/revealed Terror, paid entry1, native Mentat placement-before-bank5 and an earlier decline use120/139 source actions. First/payment3 and last/payment3 or all-decline use2–4 continuations, preserving one payment and native token retirement/rotation. Four profiles make eight legal holder/payer proposals and complete. Pure replay/no-op/recovered reuse cases are separate. One deliberate-invalid-mode test cast was repaired; all checks then passed. Prior Guild, aggressor and cyclic evidence remains in linked feature docs. No browser clicks, visual/deployed, direct combined Semuta claim/capture, complete-mode or final-assurance acceptance',
evidence: [
'game/sapho-movement-order.ts',
'game/charity-order.ts',
'tests/sapho-charity-order.test.ts',
'tests/fixture-sapho-charity-order.ts',
'tests/sapho-charity-order-engine.test.ts',
'tests/sapho-charity-order-controls.test.tsx',
'game/revival-order.ts',
'tests/sapho-revival-order.test.ts',
'tests/fixture-sapho-revival-order.ts',
@@ -2596,15 +2602,15 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'Owned Juice of Sapho changes the remaining turn order first/last. A current accepted claim provisionally protects that turn before the reordered later queue. Completed turns stay closed and no additional claim is granted. 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: 'Implementation', status: 'Partial', detail: 'Fresh native order, current-only claim/finish and original response/closing lifecycle are connected. Sapho reorders remaining opportunities with provisional started-current protection and original claim limits.', 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'] },
{ 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', 'tests/sapho-charity-order.test.ts', 'tests/sapho-charity-order-engine.test.ts', 'tests/sapho-charity-order-controls.test.tsx'] },
],
},
{
+6
View File
@@ -5,6 +5,7 @@ import {
charityOrderBlock as block,
createCharityOrder as create,
finishCharityOrder as finish,
startCharityOrder as start,
validateCharityOrder as validate,
type CharityOrder,
type CharityOrderContext,
@@ -23,6 +24,9 @@ function rejects(current: CharityOrderContext, frame: CharityOrder, action: () =
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'] });
assert.equal(JSON.stringify(frame), '{"version":1,"turn":3,"order":["c","a","b"],"remaining":["c","a","b"]}');
assert.deepEqual(start(current, frame, 'c'), frame);
assert.equal(Object.hasOwn(start(current, frame, 'c'), 'started'), false);
validate(current, frame);
assert.equal(block(current, frame, 'c'), null);
assert.match(block(current, frame, 'a')!, /Wait/);
@@ -36,6 +40,7 @@ void test('Charity preserves the actual rotated First Player order rather than r
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, () => start(current, frame, 'a'));
rejects(current, frame, () => finish(current, frame, 'foreign'));
const next = finish(current, frame, 'c');
assert.deepEqual(next.remaining, ['a', 'b']);
@@ -72,6 +77,7 @@ void test('the native context requires actual unique seats, their permutation an
{ ...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']),
{ ...base, claimed: ['foreign'] }, { ...base, claimed: ['c', 'c'] },
]) 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 }) },
+150
View File
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { saphoCharityOrderEvent } from '../game/charity-order';
import { createSaphoRevivalOrderFixture, nextSaphoRevivalOrder, actSaphoRevivalOrder,
saphoRevivalOrderInventory, saphoRevivalOrderStocks, saphoRevivalOrderPlayer,
saphoRevivalOrderClean, createSaphoRevivalSemutaFixture, reactSaphoRevivalSemuta,
REVIVAL_SAPHO, type SaphoRevivalOrderStep } from './fixture-sapho-revival-order';
import { createSaphoStartedMovementFixture } from './fixture-sapho-started-movement';
export type SaphoCharityOrderStep = SaphoRevivalOrderStep;
export const CHARITY_SAPHO = REVIVAL_SAPHO;
export const actSaphoCharityOrder = actSaphoRevivalOrder;
export const saphoCharityOrderInventory = saphoRevivalOrderInventory;
export const saphoCharityOrderStocks = saphoRevivalOrderStocks;
export const saphoCharityOrderPlayer = saphoRevivalOrderPlayer;
export const saphoCharityOrderClean = saphoRevivalOrderClean;
export const saphoCharityOrderReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
export const nextSaphoCharityOrder = nextSaphoRevivalOrder;
export function advanceSaphoCharityOrder(game: Game, until: (state: Game) => boolean,
actions: SaphoCharityOrderStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Sapho/Charity chronology ended before its requested boundary');
const step = nextSaphoCharityOrder(game); assert.ok(step, 'Complete the current native control first');
game = actSaphoCharityOrder(game, step.actor, step.action, actions);
}
throw Error('The original Sapho/Charity chronology did not reach its finite boundary');
}
export function finishSaphoCharityOrder(game: Game, actions: SaphoCharityOrderStep[] = []): Game {
return advanceSaphoCharityOrder(game, state => state.phase !== 2, actions);
}
export function finishSaphoCharityTurn(game: Game, actions: SaphoCharityOrderStep[] = []): Game {
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
return actSaphoCharityOrder(game, actor, { type: 'ready' }, actions);
}
export function saphoCharityOrderAction(game: Game, mode: 'first' | 'last', owner = 'emperor'): Action {
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === 'charity' && option.mode === mode);
assert.ok(option, `The actual unfinished Charity queue offers Sapho ${mode}`);
return { type: 'card', card: CHARITY_SAPHO, scope: option.scope, event: option.event, mode: option.mode };
}
export function currentSaphoCharityEvent(game: Game): string {
assert.ok(game.charityOrder);
return saphoCharityOrderEvent({ turn: game.turn, order: game.order, players: game.players, ready: game.ready,
claimed: game.players.filter(player => player.charityTurn === game.turn).map(player => player.id) }, game.charityOrder);
}
/** Actual ordinary prepayment empties the current purse, followed by its one
* native claim. Neither a low purse nor a started marker is installed. */
export function claimSaphoCharityOrder(game: Game, actor = game.charityOrder?.remaining[0] ?? '',
actions: SaphoCharityOrderStep[] = []) {
const before = structuredClone(game), from = actions.length;
const recipient = game.players.find(player => player.id !== actor && player.id === 'atreides')?.id ??
game.players.find(player => player.id !== actor)?.id;
assert.ok(recipient); const paid = saphoCharityOrderPlayer(game, actor).spice;
if (paid) game = actSaphoCharityOrder(game, actor, { type: 'bribe', target: recipient, amount: paid }, actions);
assert.equal(saphoCharityOrderPlayer(game, actor).spice, 0);
game = actSaphoCharityOrder(game, actor, { type: 'charity' }, actions);
assert.equal(saphoCharityOrderPlayer(game, actor).charityTurn, game.turn);
assert.equal(saphoCharityOrderPlayer(game, actor).spice, 2);
return { game, before, recipient, paid, steps: structuredClone(actions.slice(from)), actions };
}
/** Actual original cache1 acquisition is inherited unchanged. The Advanced
* source continues its real first Revival and Movement without shipping, then
* native Storm2 and dual Spice to current Charity. Basic uses the original
* native started-Movement source, including its already paid Polar1 shipment.
*/
export function createSaphoCharityOrderFixture(options: { advanced?: boolean; started?: boolean } = {}) {
const advanced = options.advanced ?? true, started = options.started ?? false;
assert.ok(advanced || !started, 'The started ordered source belongs to Advanced Charity');
const inherited = advanced ? createSaphoRevivalOrderFixture()
: createSaphoStartedMovementFixture({ advanced: false });
const inheritedActions = structuredClone(inherited.actions), actions = [...inheritedActions];
let game = structuredClone(inherited.game);
const continuationSpiceOrder = advanced ? [] : [0].map(position => {
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
game = advanceSaphoCharityOrder(game,
state => state.turn === 2 && state.phase === 2 && saphoCharityOrderClean(state), actions);
const beforeClaim = structuredClone(game), owner = inherited.owner;
const current = game.charityOrder?.remaining[0] ?? game.order[0]; assert.ok(current);
if (advanced) {
assert.equal(current, 'richese'); assert.equal(game.active, current);
assert.equal(game.charityOrder?.version, 1);
assert.deepEqual(game.charityOrder?.remaining, game.order);
} else assert.equal(game.charityOrder ?? null, null);
const claim = started ? claimSaphoCharityOrder(game, current, actions) : null;
if (claim) game = claim.game;
assert.ok(saphoCharityOrderPlayer(game, owner).hand.some(card => card.id === CHARITY_SAPHO));
assert.deepEqual(saphoCharityOrderInventory(game), inherited.originalInventory);
if (advanced) {
assert.ok(viewGame(game, owner).saphoOptions.some(option => option.scope === 'charity' && option.mode === 'first'));
assert.ok(viewGame(game, owner).saphoOptions.some(option => option.scope === 'charity' && option.mode === 'last'));
}
return { game, sourceGame: structuredClone(game), advanced, started, owner, current, card: CHARITY_SAPHO,
inherited, beforeClaim, claim, acquisition: inherited.acquisition, continuationSpiceOrder,
lobby: inherited.lobby, setup: inherited.setup, beforeStartingDeal: inherited.beforeStartingDeal,
afterSetup: inherited.afterSetup, originalInventory: inherited.originalInventory,
inheritedActions, continuation: structuredClone(actions.slice(inheritedActions.length)), actions,
sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited: inheritedActions.length, acquisition: inherited.acquisition.steps.length,
continuation: actions.length - inheritedActions.length, claim: claim?.steps.length ?? 0, source: actions.length },
sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor/Guild setup with the native CHOAM/Richese deck. ` +
'Emperor acquires the original physical Sapho through an actual cache auction bid1 and bank payment; no held-card exchange is staged. Only original unopened deal/land-Spice positions and scalar lotteries are selected. ' +
(advanced ? 'Append original quiet Revival/Guild WAIT/Movement/Collection/Mentat and actual card Storm2 controls to the first original turn2 Charity frame. '
: 'Retain the original paid Polar1 shipment, select one original unopened next land-Spice position, and append actual remaining turns, second dials and Charity controls. ') +
(started ? 'Richese then really bribes away its actual full purse and claims ordinary Charity2; the accepted claim alone establishes its started current opportunity. ' : '') +
'Counts derive from actual inherited and continuation action arrays. No purse, phase, queue, claim marker, hand, army or discard is installed.' };
}
export type SaphoCharityOrderFixture = ReturnType<typeof createSaphoCharityOrderFixture>;
/** Conserved frame-only UNIT supplement, not historical saved-game evidence. */
export function unstampedSaphoCharitySupplement(game: Game): Game {
const old = saphoCharityOrderReload(game); delete old.charityOrder; return old;
}
/** CONTROLLED physical acquisition supplement inherited unchanged: Sapho and
* Semuta were conserved into their original holders rather than bought. Start
* at its actual turn2 Revival, then advance FORWARD through turn3 Charity.
* No custody is reassigned here; only the next two original unopened land
* Spice positions are selected before their actual later draws.
*/
export function createSaphoCharitySemutaFixture() {
const inherited = createSaphoRevivalSemutaFixture();
const inheritedActions = structuredClone(inherited.actions), actions = [...inheritedActions];
let game = structuredClone(inherited.game);
assert.equal(game.turn, 2); assert.equal(game.phase, 4);
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
game = advanceSaphoCharityOrder(game,
state => state.turn === 3 && state.phase === 2 && saphoCharityOrderClean(state), actions);
const option = viewGame(game, inherited.owner).saphoOptions.find(option => option.scope === 'charity'); assert.ok(option);
assert.ok(option.mode === 'first' || option.mode === 'last');
const action = saphoCharityOrderAction(game, option.mode, inherited.owner);
assert.deepEqual(saphoCharityOrderInventory(game), inherited.originalInventory);
return { game, sourceGame: structuredClone(game), owner: inherited.owner, holder: inherited.holder,
current: game.charityOrder!.remaining[0], card: CHARITY_SAPHO, mode: option.mode, action,
nativeAcquisition: false, inherited, spiceOrder, originalInventory: inherited.originalInventory,
inheritedActions, continuation: structuredClone(actions.slice(inheritedActions.length)), actions,
sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited: inheritedActions.length, continuation: actions.length - inheritedActions.length,
source: actions.length },
staging: [...inherited.staging,
'CONTROLLED acquisition remains inherited. Actual native turn2 Revival-to-turn3 Charity progression supplies the new scope; no rewind, hand, purse, queue, claim or live discard event is installed.'] };
}
export const reactSaphoCharitySemuta = reactSaphoRevivalSemuta;
+162
View File
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { Children, createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { CharityClaim } from '../components/charity-claim';
import { viewGame, type Action, type GameView } from '../game/engine';
import {
createSaphoCharityOrderFixture, actSaphoCharityOrder, saphoCharityOrderAction,
saphoCharityOrderPlayer, type SaphoCharityOrderFixture,
} from './fixture-sapho-charity-order';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { JuiceOfSapho, SaphoOrderButton } = await import('../components/juice-of-sapho');
aliases.deregister();
const sources = new Map<string, SaphoCharityOrderFixture>();
function native(advanced = true, started = false) {
const key = `${advanced}:${started}`;
let source = sources.get(key);
if (!source) { source = createSaphoCharityOrderFixture({ advanced, started }); sources.set(key, source); }
return { ...source, game: structuredClone(source.game) };
}
function panel(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(JuiceOfSapho, { game, busy, act() {} }));
}
function charity(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(CharityClaim, { game, busy, act() {} }));
}
function optionFor(game: GameView, mode: 'first' | 'last') {
const option = game.saphoOptions.find(candidate => candidate.scope === 'charity' && candidate.mode === mode);
assert.ok(option?.scope === 'charity');
return option;
}
function pressSapho(card: string, option: ReturnType<typeof optionFor>, busy = false) {
const sent: Action[] = [];
SaphoOrderButton({ card, option, busy, act(action) { sent.push(action); } }).props.onClick();
return sent;
}
type ClickProps = { children?: ReactNode; disabled?: boolean; onClick?: () => void; 'data-testid'?: string };
function findButton(node: ReactNode, id: string): ReactElement<ClickProps> | null {
for (const child of Children.toArray(node)) {
if (!isValidElement<ClickProps>(child)) continue;
if (child.props['data-testid'] === id) return child;
const nested = findButton(child.props.children, id);
if (nested) return nested;
}
return null;
}
function pressCharity(game: GameView, id: 'charity-claim-submit' | 'charity-turn-finish', busy = false) {
const sent: Action[] = [];
const tree = CharityClaim({ game, busy, act(action) { sent.push(action); } });
const button = findButton(tree, id); assert.ok(button);
button.props.onClick?.();
return { sent, disabled: button.props.disabled };
}
void test('native clean Charity first and last render their scope and submit only five fields; busy cannot play', () => {
for (const mode of ['first', 'last'] as const) {
const source = native(), game = viewGame(source.game, source.owner), option = optionFor(game, mode);
const html = panel(game);
assert.match(html, new RegExp(`Take your Charity turn ${mode} and discard Juice of Sapho`));
assert.match(html, /Completed Charity turns stay finished/);
assert.match(html, /changing order does not grant another claim or reopen completed turns/);
assert.doesNotMatch(html, /Provisional queued Charity order/);
assert.equal(option.afterCurrent, undefined);
const sent = pressSapho(source.card, option), action = sent[0]; assert.ok(action);
assert.deepEqual(sent, [saphoCharityOrderAction(source.game, mode, source.owner)]);
assert.deepEqual(Object.keys(action).sort(), ['card', 'event', 'mode', 'scope', 'type']);
assert.deepEqual(pressSapho(source.card, option, true), []);
const after = actSaphoCharityOrder(source.game, source.owner, action);
assert.ok(after.charityOrder?.version === 2);
assert.equal(mode === 'first' ? after.charityOrder.remaining[0] : after.charityOrder.remaining.at(-1), source.owner);
}
});
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);
const currentName = game.players.find(player => player.id === source.current)!.name;
assert.equal(option.afterCurrent, source.current);
for (let render = 0; render < 2; render++) {
const html = panel(game);
assert.match(html, /aria-label="Provisional queued Charity order"/);
assert.ok(html.includes(`${currentName} finishes the current Charity turn unchanged`));
assert.match(html, new RegExp(`Your ${mode} position applies among later unstarted Charity turns`));
assert.match(html, /Completed turns and claims stay finished; each player still has only one Charity claim/);
}
const sent = pressSapho(source.card, option), action = sent[0]; assert.ok(action);
assert.deepEqual(Object.keys(action).sort(), ['card', 'event', 'mode', 'scope', 'type']);
const purse = saphoCharityOrderPlayer(source.game, source.current).spice;
const after = actSaphoCharityOrder(source.game, source.owner, action);
assert.ok(after.charityOrder?.version === 2);
assert.equal(after.charityOrder.remaining[0], source.current);
assert.equal(after.charityOrder.remaining[mode === 'first' ? 1 : after.charityOrder.remaining.length - 1], source.owner);
assert.equal(saphoCharityOrderPlayer(after, source.current).spice, purse);
assert.equal(saphoCharityOrderPlayer(after, source.current).charityTurn, after.turn);
}
});
void test('actual v2 Charity callbacks wait, claim once and Finish without reopening a completed turn', () => {
const source = native();
let game = actSaphoCharityOrder(source.game, source.owner, saphoCharityOrderAction(source.game, 'first', source.owner));
const actor = game.charityOrder?.remaining[0]; assert.equal(actor, source.owner); assert.ok(actor);
const recipient = game.players.find(player => player.id !== actor && player.id === 'atreides')?.id; assert.ok(recipient);
const purse = saphoCharityOrderPlayer(game, actor).spice; assert.ok(purse > 0);
game = actSaphoCharityOrder(game, actor, { type: 'bribe', target: recipient, amount: purse });
const current = viewGame(game, actor), waiting = viewGame(game, source.current);
assert.match(charity(current), /Juice of Sapho changed the remaining Charity order/);
assert.match(charity(current), /It is your Charity turn/);
assert.match(charity(waiting), /Waiting for .* to finish Charity/);
assert.deepEqual(pressCharity(waiting, 'charity-claim-submit'), { sent: [], disabled: true });
assert.deepEqual(pressCharity(waiting, 'charity-turn-finish'), { sent: [], disabled: true });
assert.deepEqual(pressCharity(current, 'charity-claim-submit', true), { sent: [], disabled: true });
const claim = pressCharity(current, 'charity-claim-submit');
assert.deepEqual(claim, { sent: [{ type: 'charity' }], disabled: false });
game = actSaphoCharityOrder(game, actor, claim.sent[0]!);
assert.equal(saphoCharityOrderPlayer(game, actor).spice, 2);
const claimed = viewGame(game, actor);
assert.deepEqual(pressCharity(claimed, 'charity-claim-submit'), { sent: [], disabled: true });
assert.deepEqual(pressCharity(claimed, 'charity-turn-finish', true), { sent: [], disabled: true });
const finish = pressCharity(claimed, 'charity-turn-finish');
assert.deepEqual(finish, { sent: [{ type: 'ready' }], disabled: false });
game = actSaphoCharityOrder(game, actor, finish.sent[0]!);
const completed = viewGame(game, actor);
assert.match(charity(completed), /Your Charity turn is complete/);
assert.deepEqual(pressCharity(completed, 'charity-turn-finish'), { sent: [], disabled: true });
});
void test('Basic unstamped and genuinely completed views filter stale Charity options without adding Finish', () => {
const source = native(), original = viewGame(source.game, source.owner), stale = optionFor(original, 'first');
const basicSource = native(false), basic = viewGame(basicSource.game, basicSource.owner);
const unstamped = viewGame(source.game, source.owner);
unstamped.charityOrder = null;
let completedGame = source.game;
for (let count = 0; count < completedGame.players.length && completedGame.charityOrder?.remaining.includes(source.owner); count++) {
const actor = completedGame.charityOrder.remaining[0]; assert.ok(actor);
completedGame = actSaphoCharityOrder(completedGame, actor, { type: 'ready' });
}
const completed = viewGame(completedGame, source.owner);
assert.ok(completed.charityOrder && !completed.charityOrder.remaining.includes(source.owner));
// Stale public projection supplements only: the native Basic/completed
// sources keep their actual custody, while the unstamped display is synthetic.
for (const game of [basic, unstamped, completed]) {
game.saphoOptions = [stale];
assert.doesNotMatch(panel(game), /Take your Charity turn (first|last) and discard/);
assert.match(panel(game), /existing timing|Completed turns stay finished/);
}
assert.doesNotMatch(charity(basic), /Finish Charity turn/);
assert.doesNotMatch(charity(unstamped), /Finish Charity turn/);
});
+192
View File
@@ -0,0 +1,192 @@
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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actSaphoCharityOrder as act, advanceSaphoCharityOrder as advance,
createSaphoCharityOrderFixture as fixture, claimSaphoCharityOrder as claim,
createSaphoCharitySemutaFixture, reactSaphoCharitySemuta,
currentSaphoCharityEvent as currentEvent, finishSaphoCharityOrder as finish,
finishSaphoCharityTurn as finishTurn, nextSaphoCharityOrder as next,
saphoCharityOrderAction as action, saphoCharityOrderClean as clean,
saphoCharityOrderInventory as inventory, saphoCharityOrderStocks as stocks,
saphoCharityOrderPlayer as player, saphoCharityOrderReload as reload,
unstampedSaphoCharitySupplement as unstamped, type SaphoCharityOrderStep } from './fixture-sapho-charity-order';
function reject(game: Game, actor: string, proposed: Action) {
const before = reload(game); assert.throws(() => applyAction(game, actor, proposed)); assert.deepEqual(game, before);
}
function expected(game: Game, owner: string, mode: 'first' | 'last') {
const frame = game.charityOrder; assert.ok(frame);
const current = frame.remaining[0];
const locked = current && player(game, current).charityTurn === game.turn ? [current] : [];
const others = frame.remaining.filter(actor => actor !== owner && !locked.includes(actor));
return [...locked, ...(mode === 'first' ? [owner, ...others] : [...others, owner])];
}
void test('native cache1 Sapho changes fresh Advanced Charity first or last while retaining physical First Player order', () => {
const f = fixture();
assert.equal(f.lobby.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.ok(f.beforeStartingDeal.players.every(seat => !seat.hand.length));
assert.equal(f.acquisition.bid.actor, f.owner); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.owner).spice - player(f.acquisition.after, f.owner).spice, 1);
assert.equal(f.sourceCounts.inherited, f.inheritedActions.length);
assert.equal(f.sourceCounts.acquisition, f.acquisition.steps.length);
assert.equal(f.sourceCounts.continuation, f.continuation.length);
assert.equal(f.sourceCounts.source, f.sourceActions.length);
assert.deepEqual(f.sourceActions, [...f.inheritedActions, ...f.continuation]);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 2); assert.equal(f.game.charityOrder?.version, 1);
for (const mode of ['first', 'last'] as const) {
const wanted = expected(f.game, f.owner, mode), physicalOrder = [...f.game.order], before = stocks(f.game);
const proposed = action(f.game, mode);
assert.deepEqual(Object.keys(proposed).sort(), ['card', 'event', 'mode', 'scope', 'type']);
assert.equal(proposed.event, currentEvent(f.game));
const game = act(reload(f.game), f.owner, proposed);
assert.equal(game.charityOrder?.version, 2); assert.deepEqual(game.charityOrder?.remaining, wanted);
assert.deepEqual(game.order, physicalOrder); assert.equal(game.active, wanted[0]);
assert.deepEqual(game.ready, []); assert.deepEqual(stocks(game), before);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(game, f.owner).hand.some(card => card.id === f.card), false);
assert.deepEqual(inventory(game), f.originalInventory);
}
});
void test('an actual accepted current Charity claim stays ahead of both Sapho tails and cannot be claimed twice', () => {
const f = fixture({ started: true }); assert.ok(f.claim);
assert.equal(f.claim.paid, player(f.beforeClaim, f.current).spice);
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(f.game, f.current).charityTurn, f.game.turn);
assert.equal(player(f.game, f.current).spice, 2);
assert.equal(f.game.charityOrder?.version, 1, 'A claim preserves original v1 bytes; accepted Sapho creates v2');
assert.deepEqual(f.game.charityOrder, f.beforeClaim.charityOrder);
for (const mode of ['first', 'last'] as const) {
const option = viewGame(f.game, f.owner).saphoOptions.find(option => option.scope === 'charity' && option.mode === mode);
assert.ok(option); assert.equal((option as typeof option & { afterCurrent?: string }).afterCurrent, f.current);
const wanted = expected(f.game, f.owner, mode), before = stocks(f.game);
let game = act(reload(f.game), f.owner, action(f.game, mode));
assert.equal(game.active, f.current); assert.deepEqual(game.charityOrder?.remaining, wanted);
assert.equal(game.charityOrder?.remaining[0], f.current); assert.deepEqual(stocks(game), before);
reject(game, f.current, { type: 'charity' });
game = finishTurn(game);
assert.equal(game.active, wanted[1]); assert.ok(game.ready.includes(f.current));
assert.equal(player(game, f.current).spice, 2); assert.equal(player(game, f.current).charityTurn, game.turn);
}
});
void test('the real Sapho holder cannot split its own started Charity or choose its existing 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);
});
void test('stale, foreign and finished Charity requests reject before cost while an accepted packet disposes Sapho once', () => {
const f = fixture(), old = action(f.game, 'last');
reject(f.game, f.owner, { ...old, event: 'old-charity-boundary' });
reject(f.game, 'atreides', old); reject(f.game, f.owner, { ...old, afterCurrent: f.current });
let game = finishTurn(reload(f.game));
assert.notEqual(currentEvent(game), old.event); reject(game, f.owner, old);
game = act(game, f.owner, action(game, 'first'));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); reject(game, f.owner, old);
assert.deepEqual(inventory(game), f.originalInventory);
let completed = advance(reload(f.game), state => clean(state) && state.charityOrder?.remaining[0] === f.owner);
const beforeFinish = action(completed, 'last'); completed = finishTurn(completed);
assert.ok(completed.ready.includes(f.owner));
assert.deepEqual(viewGame(completed, f.owner).saphoOptions.filter(option => option.scope === 'charity'), []);
reject(completed, f.owner, beforeFinish);
assert.ok(player(completed, f.owner).hand.some(card => card.id === f.card));
});
void test('genuine Basic and explicitly frame-only unstamped Advanced controls have no Sapho Charity scope', () => {
const basic = fixture({ advanced: false });
assert.equal(basic.game.advanced, false); assert.equal(basic.game.charityOrder ?? null, null);
assert.deepEqual(viewGame(basic.game, basic.owner).saphoOptions.filter(option => option.scope === 'charity'), []);
reject(basic.game, basic.owner, { type: 'card', card: basic.card, scope: 'charity', event: 'no-native-charity-frame', mode: 'first' });
assert.deepEqual(inventory(basic.game), basic.originalInventory);
const f = fixture(), game = unstamped(f.game), { charityOrder: removed, ...rest } = f.game;
assert.ok(removed); assert.deepEqual(game, rest, 'This labelled unit supplement removes only the frame');
assert.deepEqual(viewGame(game, f.owner).saphoOptions.filter(option => option.scope === 'charity'), []);
reject(game, f.owner, action(f.game, 'first'));
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('all four minimal profiles use native Sapho and finish each remaining current Charity opportunity once', () => {
const f = fixture({ started: true }), physicalOrder = [...f.game.order];
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
const view = viewGame(f.game, f.owner); view.players.find(seat => seat.id === f.owner)!.bot = difficulty;
const proposed = botActions(view).find(action => action.type === 'card' && action.scope === 'charity');
assert.ok(proposed, `${difficulty} proposes the owned physical Sapho's legal native scope`);
const actions: SaphoCharityOrderStep[] = [];
let game = act(reload(f.game), f.owner, proposed, actions);
const wanted = [...game.charityOrder!.remaining], completions: string[] = [];
for (let count = 0; game.phase === 2 && count < 50; 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 waiting = viewGame(game, other.id); waiting.players.find(seat => seat.id === other.id)!.bot = difficulty;
assert.equal(botActions(waiting).some(action => action.type === 'charity' || action.type === 'ready'), false);
}
const own = viewGame(game, actor); own.players.find(seat => seat.id === actor)!.bot = difficulty;
const choice = botActions(own).find(action => action.type === 'charity' || action.type === 'ready');
assert.ok(choice); if (choice.type === 'ready') completions.push(actor);
game = act(game, actor, choice, actions);
}
game = finish(game, actions);
assert.equal(game.phase, 3); assert.deepEqual(completions, wanted);
assert.equal(new Set(completions).size, wanted.length); assert.deepEqual(game.order, physicalOrder);
assert.equal(player(game, f.current).spice, 2); assert.equal(player(game, f.current).charityTurn, 2);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalInventory);
}
});
for (const claimDiscard of [false, true]) {
void test(`controlled Semuta acquisition supplement ${claimDiscard ? 'claims' : 'passes'} actual Sapho Charity disposal and preserves the reordered opportunity`, () => {
const f = createSaphoCharitySemutaFixture();
assert.equal(f.nativeAcquisition, false); assert.ok(f.staging.length > 0);
assert.equal(f.inherited.game.turn, 2); assert.equal(f.inherited.game.phase, 4);
assert.equal(f.game.turn, 3); assert.equal(f.game.phase, 2);
assert.equal(f.sourceCounts.inherited, f.inheritedActions.length);
assert.equal(f.sourceCounts.continuation, f.continuation.length);
assert.equal(f.sourceCounts.source, f.sourceActions.length);
assert.deepEqual(f.sourceActions, [...f.inheritedActions, ...f.continuation]);
const before = stocks(f.game), physicalOrder = [...f.game.order];
const paused = act(reload(f.game), f.owner, f.action);
assert.equal(paused.pendingTreacheryDiscard?.continuation?.kind, 'saphoCharityDiscard');
assert.equal(paused.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(paused.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(paused, f.owner).hand.some(card => card.id === f.card), false);
const frame = structuredClone(paused.charityOrder), completed = [...paused.ready], current = paused.active;
assert.ok(frame); assert.equal(frame.version, 2);
assert.equal(current, frame.remaining[0]); assert.deepEqual(stocks(paused), before);
for (const seat of paused.players)
assert.deepEqual(viewGame(paused, seat.id).saphoOptions.filter(option => option.scope === 'charity'), []);
let game = reactSaphoCharitySemuta(reload(paused), f.holder, claimDiscard);
game = advance(game, clean);
assert.deepEqual(game.charityOrder, frame); assert.deepEqual(game.ready, completed);
assert.equal(game.active, current); assert.deepEqual(game.order, physicalOrder);
assert.deepEqual(stocks(game), before); assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(player(game, f.owner).hand.some(card => card.id === f.card), false);
if (claimDiscard) {
assert.ok(player(game, f.holder).hand.some(card => card.id === f.card));
assert.equal(player(game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
assert.equal(game.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(game.discard.some(card => card.id === f.card), false);
} else assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
game = finish(game);
assert.equal(game.phase, 3); assert.deepEqual(game.order, physicalOrder);
assert.deepEqual(stocks(game), before); assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(game.discard.filter(card => card.id === (claimDiscard ? SEMUTA_DRUG_ID : f.card)).length, 1);
});
}
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
CharityOrderError,
charityOrderBlock as block,
createCharityOrder as create,
finishCharityOrder as finish,
reorderSaphoCharityOrder as reorder,
saphoCharityOrderEvent as event,
startCharityOrder as start,
validateCharityOrder as validate,
type CharityOrder,
type CharityOrderContext,
} from '../game/charity-order';
function context(ready: readonly string[] = [], claimed?: readonly string[]): CharityOrderContext {
return { turn: 5, order: ['a', 'b', 'c', 'd'],
players: ['a', 'b', 'c', 'd'].map(id => ({ id })), ready,
...(claimed === undefined ? {} : { claimed }) };
}
function use(ctx: CharityOrderContext, frame: CharityOrder, player: string, mode: 'first' | 'last') {
return reorder(ctx, frame, { event: event(ctx, frame), player, mode });
}
function rejects(ctx: CharityOrderContext, frame: CharityOrder, action: () => unknown): void {
const before = structuredClone({ ctx, frame });
assert.throws(action, CharityOrderError);
assert.deepEqual({ ctx, frame }, before);
}
void test('native claim receipts start version-one Charity without adding or changing stored fields', () => {
const ctx = context([], []), frame = create(ctx), beforeEvent = event(ctx, frame);
const quoted = start(ctx, frame, 'a');
assert.equal(JSON.stringify(quoted), '{"version":1,"turn":5,"order":["a","b","c","d"],"remaining":["a","b","c","d"]}');
assert.notEqual(quoted, frame);
const claimed = context([], ['a']);
validate(claimed, quoted);
assert.notEqual(event(claimed, quoted), beforeEvent);
const done = finish(claimed, quoted, 'a');
assert.equal(JSON.stringify(done), '{"version":1,"turn":5,"order":["a","b","c","d"],"remaining":["b","c","d"]}');
validate(context(['a'], ['a']), done);
assert.equal(event(context(['a'], ['a']), done), event(context(['a'], []), done),
'a completed seat claim does not start the next opportunity');
});
void test('unstarted first and last upgrade only actual changed opportunities and retain physical order', () => {
const ctx = context([], []), frame = create(ctx), originalEvent = event(ctx, frame);
const first = use(ctx, frame, 'd', 'first');
assert.equal(first.order.version, 2);
assert.equal(first.afterCurrent, undefined);
assert.deepEqual(first.order.remaining, ['d', 'a', 'b', 'c']);
assert.deepEqual(first.order.order, ctx.order);
assert.ok(first.order.version === 2);
assert.deepEqual(first.order.uses, [{ event: originalEvent, player: 'd', mode: 'first', completed: [], started: false }]);
const last = use(ctx, first.order, 'd', 'last');
assert.deepEqual(last.order.remaining, ['a', 'b', 'c', 'd']);
assert.notEqual(event(ctx, last.order), originalEvent, 'independent uses retain a new revision even when queue returns');
validate(ctx, last.order);
assert.equal(frame.version, 1);
});
void test('an accepted claim, including canceled BG Charity, locks the current actor ahead of a reordered tail', () => {
const fresh = context([], []), frame = create(fresh), stale = event(fresh, frame);
// claimed records native acceptance, not the eventual payout or cancellation outcome.
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 first = use(claimed, frame, 'd', 'first');
assert.equal(first.afterCurrent, 'a');
assert.deepEqual(first.order.remaining, ['a', 'd', 'b', 'c']);
assert.ok(first.order.version === 2 && first.order.started);
assert.equal(block(claimed, first.order, 'a'), null);
assert.match(block(claimed, first.order, 'd')!, /Wait/);
const last = use(claimed, first.order, 'b', 'last');
assert.equal(last.afterCurrent, 'a');
assert.deepEqual(last.order.remaining, ['a', 'd', 'c', 'b']);
rejects(claimed, first.order, () => use(claimed, first.order, 'd', 'first'));
validate(claimed, last.order);
});
void test('version-two native claims and finishes advance actual reordered completions without changing physical order', () => {
const initial = context([], []), first = use(initial, create(initial), 'd', 'first').order;
const pendingClaim = start(initial, first, 'd');
assert.ok(pendingClaim.version === 2 && pendingClaim.started);
const accepted = context([], ['d']);
validate(accepted, pendingClaim);
const afterD = finish(accepted, pendingClaim, 'd'), later = context(['d'], ['d']);
validate(later, afterD);
assert.ok(afterD.version === 2);
assert.deepEqual(afterD.completed, ['d']);
assert.equal(afterD.started, false);
const changed = use(later, afterD, 'a', 'last').order;
assert.deepEqual(changed.remaining, ['b', 'c', 'a']);
const afterB = finish(later, changed, 'b'), completed = context(['d', 'b'], ['d']);
validate(completed, afterB);
assert.deepEqual(afterB.order, ['a', 'b', 'c', 'd']);
assert.deepEqual(afterB.remaining, ['c', 'a']);
assert.match(block(completed, afterB, 'd')!, /finished/);
rejects(completed, afterB, () => finish(completed, afterB, 'd'));
});
void test('no-op, completed, foreign or stale Sapho choices cannot spend a scheduling opportunity', () => {
const ctx = context([], []), frame = create(ctx);
rejects(ctx, frame, () => use(ctx, frame, 'a', 'first'));
rejects(ctx, frame, () => use(ctx, frame, 'd', 'last'));
rejects(ctx, frame, () => use(ctx, frame, 'foreign', 'first'));
const changed = use(ctx, frame, 'd', 'first').order;
rejects(ctx, changed, () => reorder(ctx, changed, { event: event(ctx, frame), player: 'b', mode: 'last' }));
const afterD = finish(ctx, changed, 'd'), later = context(['d'], []);
rejects(later, afterD, () => use(later, afterD, 'd', 'first'));
rejects(later, afterD, () => validate({ ...later, turn: 6 }, afterD));
assert.ok(afterD.version === 2);
rejects(later, afterD, () => validate(later, { ...afterD, started: true }));
rejects(later, afterD, () => validate(context(['d'], ['a']), afterD));
});
void test('independent repeated uses and native transitions retain detached history and survive JSON', () => {
const ctx = context([], []);
let frame = create(ctx);
for (let index = 0; index < 4; index++) frame = use(ctx, frame, 'b', index % 2 ? 'last' : 'first').order;
assert.ok(frame.version === 2);
assert.equal(frame.uses.length, 4);
assert.equal(new Set(frame.uses.map(use => use.event)).size, 4);
const restored = JSON.parse(JSON.stringify(frame)) as CharityOrder;
validate(ctx, restored);
assert.equal(event(ctx, restored), event(ctx, frame));
const changed = use(ctx, restored, 'd', 'first').order;
assert.ok(changed.version === 2 && restored.version === 2);
assert.notEqual(changed.order, restored.order);
assert.notEqual(changed.remaining, restored.remaining);
assert.notEqual(changed.completed, restored.completed);
assert.notEqual(changed.uses[0].completed, restored.uses[0].completed);
changed.order.pop();
changed.remaining.pop();
changed.completed.push('output-only');
changed.uses[0].completed.push('output-only');
validate(ctx, restored);
assert.deepEqual(ctx.ready, []);
assert.deepEqual(ctx.claimed, []);
});