Support Sapho ordering for shared arrivals
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 05:29:51 +11:00
1 parent b18a767470
commit e457478f7d
18 files changed
+808 -23

No files matched your search

+8 -3
View File
@@ -1,4 +1,5 @@
'use client';
import { SaphoArrivalOrder } from './sapho-arrival-order';
import { RicheseBidPromisePanel } from './richese-bid-promise-panel';
import { EarlyRicheseBidPromises } from './early-richese-bid-promises';
import { TravelPromises } from './travel-promises';
@@ -385,8 +386,8 @@ export function GameTable({
const harkonnenBetrayalOwnsControls = !!g.nexusHarkonnenBetrayalReaction &&
!g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
!g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending;
const busy = reactionBusy || !!g.victoryContinuation || !!g.nexusFremenRideBetrayal || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls;
const reactionOwnsControls = !!g.nexusFremenRideBetrayal || !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls;
const busy = reactionBusy || !!g.saphoArrivalOrder || !!g.victoryContinuation || !!g.nexusFremenRideBetrayal || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls;
const reactionOwnsControls = !!g.saphoArrivalOrder || !!g.nexusFremenRideBetrayal || !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls;
const canSpendKarama = (card: Card, purpose: KullPurpose) =>
!kullPurposeBlock(g, purpose) && canUseAsKaramaRole(g, me, card);
const canActivateKarama = (card: Card) => canSpendKarama(card, kullSpecialPurpose(me.faction));
@@ -1514,15 +1515,19 @@ export function GameTable({
<EcazLoyaltyCard loyalty={g.ecazLoyalty} />
<MoritaniTerrorSupply game={g} />
<MoritaniExtortionNotice game={g} />
{g.saphoArrivalOrder && <SaphoArrivalOrder view={g.saphoArrivalOrder} me={g.me}
busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot}
name={id => g.players.find(player => player.id === id)?.name ?? id} act={act} />}
{g.arrivalOverlap && (
<section className="notice" aria-label="Ambassador and Terror response order">
<p>
{g.players.find((player) => player.id === g.arrivalOverlap!.entrant)?.name ?? 'The entrant'}
{' '}entered {territory(g.arrivalOverlap.territory).name}. Ecaz Ambassador
and Moritani Terror resolve in this table’s provisional storm order:{' '}
and Moritani Terror use this table’s provisional reaction order:{' '}
{g.arrivalOverlap.first === 'ambassador' ? 'Ecaz, then Moritani' : 'Moritani, then Ecaz'}.
{g.semutaReaction
? ' The current fresh-discard response must finish before the next arrival decision.'
: g.saphoArrivalOrder ? ' The reaction owners are choosing their order.'
: <> {g.arrivalOverlap.active === 'ambassador' ? 'Ecaz' : 'Moritani'} decides now.</>}
{' '}The original entry is not repeated.
</p>
+75
View File
@@ -0,0 +1,75 @@
'use client';
import type { Action } from '@/game/engine';
import { Button } from './ui/button';
/** Public order and responses; choices contain only this viewer's legal cards. */
export type SaphoArrivalOrderView = {
event: string;
revision: number;
order: string[];
passed: string[];
choices: { card: string; mode: 'first' | 'last' }[];
};
export type SaphoArrivalOrderProps = {
view: SaphoArrivalOrderView;
me: string;
busy: boolean;
name: (player: string) => string;
act: (action: Action) => void;
};
function current(props: SaphoArrivalOrderProps, view: SaphoArrivalOrderView,
event: string, revision: number): boolean {
return !props.busy && props.view === view && view.event === event &&
view.revision === revision && typeof event === 'string' && event.length > 0 &&
Number.isSafeInteger(revision) && revision >= 0 &&
view.order.includes(props.me) && !view.passed.includes(props.me);
}
/** Both original reaction owners can continue without revealing their hands. */
export function SaphoArrivalOrderPass(props: SaphoArrivalOrderProps) {
const { view } = props, event = view.event, revision = view.revision;
const canAct = () => current(props, view, event, revision);
return <Button className="game-action min-h-11 whitespace-normal" data-testid="sapho-arrival-order-pass"
disabled={!canAct()} onClick={() => { if (canAct()) props.act({ type: 'arrivalOrderPass', event, revision }); }}>
Continue without changing reaction order
</Button>;
}
export function SaphoArrivalOrderChoiceButton(props: SaphoArrivalOrderProps & {
choice: SaphoArrivalOrderView['choices'][number];
}) {
const { view, choice } = props, event = view.event, revision = view.revision;
const card = choice.card, mode = choice.mode;
const canAct = () => current(props, view, event, revision) &&
typeof card === 'string' && card.length > 0 && (mode === 'first' || mode === 'last') &&
choice.card === card && choice.mode === mode &&
view.choices.some(item => item.card === card && item.mode === mode);
return <Button className="game-action min-h-11 whitespace-normal" data-testid={`sapho-arrival-order-${mode}`}
disabled={!canAct()} onClick={() => {
if (canAct()) props.act({ type: 'saphoArrivalOrder', event, revision, card, mode });
}}>
Use Juice of Sapho to react {mode}
</Button>;
}
/** Do not infer eligibility from a hand: the server supplies the public window
* and only the current viewer's privately quoted card choices. */
export function SaphoArrivalOrder(props: SaphoArrivalOrderProps) {
const { view, me, name } = props;
const eligible = view.order.includes(me), passed = view.passed.includes(me);
return <section className="notice space-y-3" aria-label="Ambassador and Terror reaction order">
<h3>Ambassador and Terror reaction order</h3>
<p>Both reactions follow the same entry. Before either begins, their owners may continue or use an available order change.</p>
<p>Current order: {view.order.map((player, index) =>
<span key={player}>{index > 0 && ' → '}{name(player)}</span>)}.</p>
<p>{view.passed.length} of {view.order.length} players have finished this choice.</p>
<p className="fine">This ordering procedure is a provisional interpretation.</p>
{eligible && !passed ? <>
<SaphoArrivalOrderPass {...props} />
{view.choices.map(choice => <SaphoArrivalOrderChoiceButton {...props}
key={`${choice.card}:${choice.mode}`} choice={choice} />)}
</> : <p>{eligible ? 'You have finished this choice.' : 'Waiting for the reaction owners.'}</p>}
</section>;
}
+10 -6
View File
@@ -17,6 +17,16 @@ open mode gates.
## Current checkpoint and work
**Sapho at a shared arrival — 29 October. Prototyped / Partial.**
Supported Basic Ecaz/Moritani entries now give both reaction owners a public
ordering opportunity before either acts. An eligible Sapho can reorder its
owner; Semuta claim/pass resumes the same order and original entry once. A
real deferred Guild-income arrival also reaches the window. Seventy related
cases, quick type/lint checks and the production build pass. The ordering is a
provisional interpretation; other arrival modes, full rules and deployed
acceptance remain open.
[Scope](JUICE_OF_SAPHO_RUNTIME.md#shared-ambassador-and-terror-arrival--29-october-2026).
**Intermittent CI fixture — 29 October. Partial.** Run #65 failed when its
scripted Atreides all-reserve shipment was rejected; its old log did not record
the reason. Initial Storm losses followed by optional Revival can increase the
@@ -27,12 +37,6 @@ checks pass. This removes a plausible random failure path, not a proven root
cause or a guarantee of future CI success. The latest completed run #77 passed;
#80 was running and #82 queued when checked. [Fixture](../tests/richese-homeworld-marker-mixed-http.test.ts).
**Next candidate — Sapho and shared arrival.** An Ecaz Ambassador and Moritani
Terror can both react to one entry in a provisional storm order. Sapho cannot
yet reorder that pair. A safe implementation needs a public pre-reaction order
window, preserved old overlap records and a Semuta continuation; no feature
is claimed complete by this scoping note.
**Truthtrance category questions — 29 October. Prototyped / Partial.**
Recognized freeform current-fact questions now include weapon, defense and
Worthless hand categories and selected subtypes. The table sees the primary-role
+23
View File
@@ -1,5 +1,28 @@
# Juice of Sapho: finite ordering implementation
## Shared Ambassador and Terror arrival — 29 October 2026
**Prototyped / Partial.** In a supported fresh Basic Richese game, an ordinary
shipment or movement that triggers both an Ecaz Ambassador and Moritani Terror
opens a public ordering opportunity before either reaction. Each original owner
can continue; an owner holding an eligible physical Juice of Sapho can place
its own reaction first or last. The existing provisional storm order starts
the queue. Both owners respond independently of their hands, preserving hidden
card ownership. Sapho disposal opens the normal neutral Semuta response and
then returns to the same ordering opportunity or opens the selected first
reaction. Entry, payment, forces and both original tokens remain committed once.
This is a provisional composition of the printed Sapho effect with the
existing provisional overlap order, not a publisher ruling. The current
prototype is limited to ordinary Basic movement/shipment, the existing bare
Semuta profile, and clean arrivals without other queued or nested transport.
Earlier saved overlaps without an ordering frame keep their original behavior.
[Native action journeys](../tests/sapho-shared-arrival-runtime.test.ts) exercise
both owners, Semuta claim/pass and the final-response path; physical card
placement is explicitly staged by a conserving exchange. Other modules,
arrivals and full game/deployed acceptance remain open.
## Simultaneous Truthtrance in battle — 29 October 2026
**Prototyped / Partial.** When simultaneous Truthtrances are declared in a
+10
View File
@@ -1,5 +1,15 @@
# Rule decision index
## Sapho at a shared arrival — 29 October 2026
The provisional Basic overlap procedure offers Ecaz and Moritani a public
ordering opportunity before either arrival reaction; an eligible Sapho holder
may place its own reaction first or last. The printed effect and the existing
storm-order convention do not explicitly settle this overlap. This is an online
interpretation, not a publisher ruling.
[Scope](JUICE_OF_SAPHO_RUNTIME.md#shared-ambassador-and-terror-arrival--29-october-2026).
## Advanced native Homeworld No-Field — 29 October 2026
The user-authorized revised rules treat a concealed No-Field as one force
+14 -2
View File
@@ -4,6 +4,7 @@ import { caladanOwnsTerrorEntry, validateCaladanChildArrival, caladanEntryParent
import { currentHomeworldRevivalArrival } from './homeworld-revival-return';
import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntryParent,
revivalEntryParentTuple, type RevivalEntryParent } from './revival-terror-entry';
import { validateSaphoArrivalOrder } from './sapho-arrival-order';
type Overlap = NonNullable<Game['pendingArrivalOverlap']> & { revivalArrival?: RevivalEntryParent };
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']> & { revivalArrival?: RevivalEntryParent };
@@ -28,7 +29,8 @@ export function arrivalOverlapSignature(record: Overlap): string {
record.ambassadorToken, record.terrorTokens,
...(record.revivalArrival === undefined ? [] : [revivalEntryParentTuple(record.revivalArrival)]),
...(record.victoryEvent === undefined ? [] : [['caladanVictory', record.victoryEvent]]),
...(record.caladanArrival === undefined ? [] : [caladanEntryParentTuple(record.caladanArrival)])]);
...(record.caladanArrival === undefined ? [] : [caladanEntryParentTuple(record.caladanArrival)]),
...(record.saphoOrder === undefined ? [] : [['saphoArrivalOrder', record.saphoOrder]])]);
}
/** A fresh discard belongs to the active owner of an ordinary or bound revived entry.
@@ -52,9 +54,19 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove
!['shipment', 'movement'].includes(overlap.cause)) return null;
const ecaz = g.order.indexOf(overlap.ecaz), moritani = g.order.indexOf(overlap.moritani);
if (ecaz < 0 || moritani < 0 || ecaz === moritani ||
overlap.first !== (ecaz < moritani ? 'ambassador' : 'terror') ||
!g.players.some(p => p.id === overlap.ecaz && p.faction === 'ecaz') ||
!g.players.some(p => p.id === overlap.moritani && p.faction === 'moritani')) return null;
if (overlap.saphoOrder === undefined) {
if (overlap.first !== (ecaz < moritani ? 'ambassador' : 'terror')) return null;
} else {
const original: [string, string] = ecaz < moritani
? [overlap.ecaz, overlap.moritani] : [overlap.moritani, overlap.ecaz];
const selectedFirst = overlap.first === 'ambassador' ? overlap.ecaz : overlap.moritani;
try {
validateSaphoArrivalOrder(overlap.saphoOrder, overlap.event, original, selectedFirst);
if (overlap.saphoOrder.frame.passed.length !== original.length) return null;
} catch { return null; }
}
const active = overlap.stage === 'first' ? overlap.first :
overlap.first === 'ambassador' ? 'terror' : 'ambassador';
return active === owner ? overlap : null;
+7
View File
@@ -4358,6 +4358,13 @@ export function botActions(g: GameView): Action[] {
return g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending
? policyActions(g) : [];
if (g.homeworldTupileBlocked) return [];
if (g.saphoArrivalOrder) {
const own = g.players.find(player => player.id === g.me), order = g.saphoArrivalOrder;
return (own?.bot || own?.autopilot) && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending &&
order.order.includes(g.me) && !order.passed.includes(g.me)
? [{type: 'arrivalOrderPass', event: order.event, revision: order.revision}] : [];
}
const dealPayer = g.players.find(player => player.id === g.me);
if ((dealPayer?.bot || dealPayer?.autopilot) && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) {
+197 -2
View File
@@ -20,6 +20,8 @@ import { mobileStrongholdCollectionCapacity } from './mobile-stronghold-collecti
import { basicKullTechRoster, kullDefaultEligible } from './kull-default-profile';
import { advancedKullLobbyEligible } from './kull-advanced-profile';
import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order';
import { createSaphoArrivalOrder, validateSaphoArrivalOrder, quoteSaphoArrivalOrder,
passSaphoArrivalOrder, reorderSaphoArrivalOrder, type SaphoArrivalOrder } from './sapho-arrival-order';
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises,
type RicheseBidClaim, type RicheseBidPromise, type RicheseBidOpportunity } from './richese-bid-promises';
@@ -1722,6 +1724,16 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'saphoArrivalOrderDiscard';
player: string;
card: string;
event: string;
revision: number;
mode: 'first' | 'last';
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'saphoMovementDiscard';
player: string;
@@ -2285,6 +2297,8 @@ export type Game = {
moritani: string;
first: 'ambassador' | 'terror';
stage: 'first' | 'second';
/** Present only for freshly opened public Sapho ordering windows. */
saphoOrder?: SaphoArrivalOrder;
ambassadorToken: string;
terrorTokens: string[];
ambassadorEvent?: string;
@@ -6357,6 +6371,14 @@ function distransDiscardSignature(g: Game, c: DistransDiscardContinuation) {
type SaphoTruthOrderDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'], { kind: 'saphoTruthOrderDiscard' }
>;
type SaphoArrivalOrderDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'], { kind: 'saphoArrivalOrderDiscard' }
>;
function saphoArrivalOrderDiscardSignature(g: Game, c: SaphoArrivalOrderDiscardContinuation) {
return JSON.stringify({ player: c.player, card: c.card, event: c.event, revision: c.revision,
mode: c.mode, overlap: g.pendingArrivalOverlap, turn: g.turn, phase: g.phase,
active: g.active, order: g.order, movementRemaining: g.movementRemaining });
}
function saphoTruthOrderDiscardSignature(g: Game, c: SaphoTruthOrderDiscardContinuation) {
return JSON.stringify({player:c.player,card:c.card,event:c.event,revision:c.revision,mode:c.mode,
truth:c.truth,parent:nullentropyParentSignature(g,c.resume,c.event),turn:g.turn,phase:g.phase,
@@ -8194,6 +8216,16 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === distransDiscardSignature(g, c),
'The completed private Distrans transfer no longer matches its fresh discard.',
);
} else if (continuation?.kind === 'saphoArrivalOrderDiscard') {
const c = continuation, entry = batch.entries[0], overlap = g.pendingArrivalOverlap;
requireRule(batch.cause === 'sapho:arrivalOrder' && batch.entries.length === 1 && entry.publicFace &&
entry.discardedBy === c.player && entry.card.id === c.card &&
richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' &&
arrivalOrderWaiting(g) && overlap?.saphoOrder?.frame.event === c.event &&
overlap.saphoOrder.frame.revision === c.revision + 1 && overlap.saphoOrder.frame.passed.includes(c.player) &&
!c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening &&
c.stateSignature === saphoArrivalOrderDiscardSignature(g, c),
'The used Sapho no longer matches its suspended arrival order.');
} else if (continuation?.kind === 'saphoTruthOrderDiscard') {
const c = continuation, entry = batch.entries[0], truth = c.truth;
requireRule(batch.cause === 'sapho:truthOrder' && batch.entries.length === 1 && entry.publicFace &&
@@ -9481,6 +9513,16 @@ function semutaOfferSupported(
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
entries: FreshDiscardBatch['entries'],
) {
if (continuation.kind === 'saphoArrivalOrderDiscard') {
const order = g.pendingArrivalOverlap?.saphoOrder;
return g.semutaPreview === true && bareBasicSemutaProfile(g) && g.phase === 5 &&
arrivalOrderUnopened(g) && !!order && order.frame.event === continuation.event &&
order.frame.revision === continuation.revision + 1 && order.frame.passed.includes(continuation.player) &&
entries.length === 1 && entries[0].publicFace && entries[0].discardedBy === continuation.player &&
entries[0].card.id === continuation.card && entries[0].card.effect === 'juiceOfSapho' &&
!continuation.resume.response && !continuation.resume.decision &&
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening;
}
if (continuation.kind === 'ordinaryCardDiscard' && continuation.effect === 'recruits')
return g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(g) &&
g.status === 'playing' && g.phase === 4 && g.recruits?.turn === g.turn &&
@@ -10195,6 +10237,10 @@ 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 === 'saphoArrivalOrderDiscard') {
g.response = next.resume.response; g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening;
if (g.pendingArrivalOverlap?.saphoOrder?.frame.passed.length === 2) openFirstOrderedArrival(g);
} else if (next.kind === 'saphoTruthOrderDiscard') {
g.response = next.resume.response; g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening;
@@ -24199,11 +24245,30 @@ function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParen
!g.homeworlds && !g.nexusCards && !g.leaderSkills &&
!g.discoveryEnabled && !g.techTokens && !g.strongholdCards);
}
function arrivalOrderUnopened(g: Game): boolean {
const overlap = g.pendingArrivalOverlap;
return !!overlap?.saphoOrder && overlap.stage === 'first' &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !overlap.ambassadorEvent;
}
function arrivalOrderWaiting(g: Game): boolean {
return arrivalOrderUnopened(g) &&
(g.pendingArrivalOverlap!.saphoOrder!.frame.passed.length < 2 ||
g.pendingTreacheryDiscard?.continuation.kind === 'saphoArrivalOrderDiscard');
}
function arrivalOverlapIntegrity(g: Game) {
const record = g.pendingArrivalOverlap;
if (!record) return;
const ecazIndex = g.order.indexOf(record.ecaz);
const moritaniIndex = g.order.indexOf(record.moritani);
const originalOwners: [string, string] = ecazIndex < moritaniIndex
? [record.ecaz, record.moritani] : [record.moritani, record.ecaz];
const selectedFirst = record.first === 'ambassador' ? record.ecaz : record.moritani;
if (record.saphoOrder) try {
validateSaphoArrivalOrder(record.saphoOrder, record.event, originalOwners, selectedFirst);
} catch (error) {
if (error instanceof SaphoTruthOrderError) throw new RuleError(error.message);
throw error;
}
const continuation = g.pendingTreacheryDiscard?.continuation;
const ambassador = g.pendingAmbassador ??
(continuation?.kind === 'ambassador' ? continuation.entry : null);
@@ -24228,7 +24293,7 @@ function arrivalOverlapIntegrity(g: Game) {
ecazIndex >= 0 && moritaniIndex >= 0 && ecazIndex !== moritaniIndex &&
g.order.lastIndexOf(record.ecaz) === ecazIndex &&
g.order.lastIndexOf(record.moritani) === moritaniIndex &&
record.first === (ecazIndex < moritaniIndex ? 'ambassador' : 'terror') &&
(!!record.saphoOrder || record.first === (ecazIndex < moritaniIndex ? 'ambassador' : 'terror')) &&
(record.stage === 'first' || record.stage === 'second') &&
Number.isSafeInteger(record.sector) &&
TERROR_STRONGHOLDS.includes(record.territory) &&
@@ -24241,7 +24306,14 @@ function arrivalOverlapIntegrity(g: Game) {
Array.isArray(record.terrorTokens) && record.terrorTokens.length > 0 &&
record.terrorTokens.every(id => typeof id === 'string' && id.length > 0) &&
new Set(record.terrorTokens).size === record.terrorTokens.length &&
(active === 'ambassador'
(arrivalOrderWaiting(g) || !record.saphoOrder || record.saphoOrder.frame.passed.length === 2) &&
(arrivalOrderWaiting(g)
? !g.response && !g.decision && !g.phaseOpening && !g.truthtrance &&
!!g.ecazAmbassadors?.tokens.some(token => token.id === record.ambassadorToken && token.zone === 'placed' && token.location === record.territory) &&
record.terrorTokens.every(id => g.moritaniTerror?.tokens.some(token => token.id === id && token.status === 'placed' && token.location === record.territory)) &&
(record.saphoOrder!.frame.passed.length < 2 ||
g.pendingTreacheryDiscard?.continuation.kind === 'saphoArrivalOrderDiscard')
: active === 'ambassador'
? !!ambassador && (!terror || ambassadorTerrorEntryMatches(g, terror)) &&
ambassador.event === record.ambassadorEvent &&
ambassador.owner === record.ecaz &&
@@ -24316,6 +24388,25 @@ function finishArrivalOverlap(g: Game, overlap: NonNullable<Game['pendingArrival
log(g, 'Both optional Ambassador and Terror reactions to the original entry have finished.');
if (overlap.resume === 'wormRide') nextWormRide(g);
}
/** Open the first original reaction once its public ordering window is complete. */
function openFirstOrderedArrival(g: Game) {
const overlap = g.pendingArrivalOverlap;
requireRule(overlap && arrivalOrderUnopened(g) && overlap.saphoOrder?.frame.passed.length === 2 &&
!g.pendingTreacheryDiscard, 'Finish the public arrival order before opening its first reaction.');
const entrant = getPlayer(g, overlap.entrant), owner = getPlayer(g, overlap.ecaz);
const token = g.ecazAmbassadors?.tokens.find(item => item.id === overlap.ambassadorToken &&
item.zone === 'placed' && item.location === overlap.territory);
requireRule(token, 'The original Ambassador must remain at the arrival.');
if (overlap.first === 'terror') {
requireRule(openTerrorEntry(g, entrant, overlap.territory, overlap.sector, overlap.amount,
overlap.elite, overlap.cause, 'none', undefined, overlap.revivalArrival, overlap.caladanArrival),
'The original Terror opportunity must remain available.');
} else {
openAmbassadorEntry(g, owner, token, entrant, overlap.territory, overlap.sector,
'none', overlap.cause, overlap.revivalArrival, overlap.caladanArrival);
overlap.ambassadorEvent = g.pendingAmbassador!.event;
}
}
/** The original entry is committed once; eligible overlapping owners act in storm order. */
function openTerritoryEntry(
g: Game,
@@ -24405,8 +24496,18 @@ function openTerritoryEntry(
...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}),
...(caladanArrival ? {caladanArrival:structuredClone(caladanArrival)} : {}),
};
const saphoArrival = g.advanced === false && g.phase === 5 && g.semutaPreview === true &&
bareBasicSemutaProfile(g) && resume === 'none' && (cause === 'shipment' || cause === 'movement') &&
!g.pendingArrivalReaction?.length && !g.pendingChoamMove && !g.pendingIxMove && !g.pendingFremenMove &&
!g.ornithopter && !g.summonedWorm && g.wormRides.length === 0 && !revivalArrival && !caladanArrival;
if (saphoArrival) overlap.saphoOrder = createSaphoArrivalOrder(overlap.event,
first === 'ambassador' ? owner.id : moritani.id, first === 'ambassador' ? moritani.id : owner.id);
overlap.signature = arrivalOverlapSignature(overlap);
g.pendingArrivalOverlap = overlap;
if (overlap.saphoOrder) {
log(g, `${owner.name} and ${moritani.name} may adjust their provisional reaction order before either acts.`);
return true;
}
log(g, `${owner.name} and ${moritani.name} both have optional reactions to ${entrant.name}'s entry in ${territory(to).name}; ${first === 'ambassador' ? owner.name : moritani.name} acts first in storm order.`);
if (first === 'terror')
return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none', undefined, revivalArrival, caladanArrival);
@@ -28465,6 +28566,81 @@ function saphoBattleCardReserved(g: Game, player: string, card: string): boolean
return !!battle && (!!plan && [plan.weapon, plan.defense, plan.leader].includes(card) ||
committedPlanElements(battle, player).some(element => element.value === card));
}
function saphoArrivalOrderChoices(g: Game, p: Player): {card: string; mode: 'first' | 'last'}[] {
const overlap = g.pendingArrivalOverlap, order = overlap?.saphoOrder;
if (!arrivalOrderWaiting(g) || !order || g.pendingTreacheryDiscard ||
printedRolePlayBlock(g, p.id, 'juiceOfSapho')) return [];
const card = p.hand.find(item => item.id === 'richese-juice-of-sapho' && item.effect === 'juiceOfSapho');
if (!card || !richeseCardDefinition(card) || p.hand.filter(item => item.id === card.id).length !== 1 ||
transferCardBlock(g, p, card) || giftReserved(g, p.id, card.id) || saphoBattleCardReserved(g, p.id, card.id)) return [];
try {
return quoteSaphoArrivalOrder(order, p.id).modes.filter(mode => {
const trial = structuredClone(g), actor = getPlayer(trial, p.id), parent = trial.pendingArrivalOverlap!;
const result = reorderSaphoArrivalOrder(parent.saphoOrder!, {
event: order.frame.event, revision: order.frame.revision, player: p.id, mode,
});
parent.saphoOrder = result.order;
parent.first = result.order.queue[0].player === parent.ecaz ? 'ambassador' : 'terror';
parent.signature = arrivalOverlapSignature(parent);
recordNativePrintedCardPlay(trial, p.id, 'juiceOfSapho');
const used = discard(trial, actor, card.id);
guardTurnActionPrintedCardCost(trial, actor, used);
const continuation: SaphoArrivalOrderDiscardContinuation = {
kind: 'saphoArrivalOrderDiscard', player: p.id, card: used.id, event: order.frame.event,
revision: order.frame.revision, mode,
resume: { response: trial.response, decision: trial.decision, pendingKarama: trial.pendingKarama, phaseOpening: trial.phaseOpening },
stateSignature: '',
};
return semutaOfferSupported(trial, continuation, [{card: used, discardedBy: p.id, publicFace: true}]);
}).map(mode => ({card: card.id, mode}));
} catch (error) {
if (error instanceof RuleError || error instanceof SaphoTruthOrderError) return [];
throw error;
}
}
function decideSaphoArrivalOrder(g: Game, p: Player, action: Action) {
const overlap = g.pendingArrivalOverlap, order = overlap?.saphoOrder;
requireRule(arrivalOrderWaiting(g) && overlap && order && !g.pendingTreacheryDiscard,
'This arrival-order opportunity is no longer open.');
const request = { event: action.event as string, revision: action.revision as number, player: p.id };
let result;
try {
if (action.type === 'arrivalOrderPass') {
requireRule(Object.keys(action).sort().join(',') === 'event,revision,type', 'Choose only the current arrival order.');
result = passSaphoArrivalOrder(order, request);
} else {
requireRule(action.type === 'saphoArrivalOrder' && Object.keys(action).sort().join(',') === 'card,event,mode,revision,type' &&
saphoArrivalOrderChoices(g, p).some(choice => choice.card === action.card && choice.mode === action.mode),
'This Sapho arrival-order choice is unavailable or has expired.');
result = reorderSaphoArrivalOrder(order, {...request, mode: action.mode as 'first' | 'last'});
}
} catch (error) {
if (error instanceof SaphoTruthOrderError) throw new RuleError(error.message);
throw error;
}
overlap.saphoOrder = result.order;
overlap.first = result.order.queue[0].player === overlap.ecaz ? 'ambassador' : 'terror';
overlap.signature = arrivalOverlapSignature(overlap);
if (action.type === 'arrivalOrderPass') {
if (result.done) openFirstOrderedArrival(g);
return;
}
recordNativePrintedCardPlay(g, p.id, 'juiceOfSapho');
const used = discard(g, p, action.card as string);
guardTurnActionPrintedCardCost(g, p, used);
log(g, `${p.name} discarded Juice of Sapho to react ${String(action.mode)} in the shared arrival.`,
{ faction: p.faction, name: 'Juice of Sapho' });
const continuation: SaphoArrivalOrderDiscardContinuation = {
kind: 'saphoArrivalOrderDiscard', player: p.id, card: used.id, event: request.event,
revision: request.revision, mode: action.mode as 'first' | 'last',
resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
stateSignature: '',
};
const entries = [{card: used, discardedBy: p.id, publicFace: true}];
requireRule(semutaOfferSupported(g, continuation, entries), 'This arrival-order discard cannot safely resume its Semuta reaction.');
continuation.stateSignature = saphoArrivalOrderDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'sapho:arrivalOrder', entries, continuation);
}
/** Quote the exact public parent/discard continuation on a detached copy.
* This does not inspect whether anyone holds Semuta; it prevents offering a
* Sapho action whose mandatory supported reaction cannot be staged. */
@@ -41845,6 +42021,15 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
);
return normalizeAutomaticGame(state);
}
if (arrivalOrderWaiting(state)) {
getPlayer(state, id);
if (action?.type === 'advanceBots') return state;
requireRule(action?.type === 'arrivalOrderPass' || action?.type === 'saphoArrivalOrder',
'Finish the shared arrival order before another action.');
const game = structuredClone(state);
decideSaphoArrivalOrder(game, getPlayer(game, id), action);
return game.pendingTreacheryDiscard?.reaction ? game : normalizeAutomaticGame(game);
}
if (action.alliancePromiseEvent !== undefined) {
const guide = projectedAlliancePromiseGuide(state, id);
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
@@ -42113,6 +42298,7 @@ function finishActionContinuations(g: Game) {
finishTreacheryDiscard(g);
if (g.pendingTreacheryDiscard?.reaction) return;
requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.');
if (arrivalOrderWaiting(g)) return;
if (g.pendingNexusEmperorPayment) return;
if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return;
if (pendingNexusTraitors(g)) return;
@@ -42170,6 +42356,7 @@ function finishActionContinuations(g: Game) {
finishMoritaniPlacement(g);
}
function settleAutomaticContinuations(g: Game) {
if (arrivalOrderWaiting(g)) return;
if (g.victoryContinuation) return;
if (g.pendingNexusHarkonnenBetrayal) return;
if (g.pendingNexusIxianBetrayal) return;
@@ -42182,6 +42369,7 @@ function settleAutomaticContinuations(g: Game) {
if (pendingNexusTraitors(g)) return;
if (g.pendingNullentropy) return;
for (let iteration = 0; iteration < 128; iteration++) {
if (arrivalOrderWaiting(g)) return;
if (g.victoryContinuation) return;
if (g.pendingNexusHarkonnenBetrayal) return;
if (g.pendingNexusIxianBetrayal) return;
@@ -47178,6 +47366,13 @@ export function viewGame(state: Game, id: string, now?: number) {
allyAtActivation: g.moritaniAtomics.allyAtActivation }
: null,
arrivalOverlapMode: arrivalOverlapModeSupported(g),
saphoArrivalOrder: arrivalOrderWaiting(g) && !g.pendingTreacheryDiscard
? { event: g.pendingArrivalOverlap!.saphoOrder!.frame.event,
revision: g.pendingArrivalOverlap!.saphoOrder!.frame.revision,
order: g.pendingArrivalOverlap!.saphoOrder!.queue.map(item => item.player),
passed: [...g.pendingArrivalOverlap!.saphoOrder!.frame.passed],
choices: saphoArrivalOrderChoices(g, me) }
: null,
arrivalOverlap: g.pendingArrivalOverlap
? {
first: g.pendingArrivalOverlap.first,
+1
View File
@@ -513,6 +513,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. At a clean boundary, the holder may pause its own claimed turn and resume last without repeating the claim or payment. Completed turns stay closed and physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.',
'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If another player has started, provisional queued timing lets that player finish unchanged. At a clean boundary, you may instead pause your own started turn and resume last, retaining used allowances. Pending requests must finish first. Completed turns and already-held positions cannot be repeated. Basic and older concurrent Revival phases have no ordered scope.',
'When two or more players declare simultaneous Truthtrances in a quiet unrevealed battle, their public question-order window can use Sapho to move the holder’s group first or last. The current battle and committed plans remain in place. In the supported Basic Semuta preview, the discard and any exchange finish before the reordered questions resume.',
'In a supported Basic Richese game, a shared Ecaz Ambassador and Moritani Terror entry opens a public ordering opportunity before either reaction. Each owner may continue or use an eligible held Sapho to put its own reaction first or last. The original entry and tokens remain committed; the ordinary neutral Semuta response finishes before the queue continues. This ordering is provisional and other arrival combinations remain unfinished.',
'Finish pending shipment, movement and card preparations and decisions before changing order. The exact Extortion payer window is a supported exception for its own order only. Your hand panel lists available choices. Reserved cards and positions you already hold cannot be selected.',
'Completed bids and turns stay completed. Storm order, committed funding and movement counters do not reset. Refreshing preserves the same remaining opportunities and any declared last position.',
'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.',
+88
View File
@@ -0,0 +1,88 @@
import {
createSaphoTruthOrder,
passSaphoTruthOrder,
quoteSaphoTruthOrder,
reorderSaphoTruthOrder,
SaphoTruthOrderError,
validateSaphoTruthOrder,
type SaphoTruthOrderFrame,
type SaphoTruthOrderMode,
type SaphoTruthOrderRequest,
type SaphoTruthOrderResult,
} from './sapho-truth-order';
/** Public owner identities only: no card, token face or hidden choice is stored. */
export type SaphoArrivalOrder = {
frame: SaphoTruthOrderFrame;
queue: [{ player: string }, { player: string }];
};
export type SaphoArrivalOrderResult = { order: SaphoArrivalOrder; done: boolean };
type Entry = { player: string };
function check(condition: unknown, message: string): asserts condition {
if (!condition) throw new SaphoTruthOrderError(message);
}
/** The original pair is already in provisional storm order. */
export function createSaphoArrivalOrder(event: string, firstOwner: string, secondOwner: string): SaphoArrivalOrder {
const queue: SaphoArrivalOrder['queue'] = [{ player: firstOwner }, { player: secondOwner }];
const frame = createSaphoTruthOrder(queue, event);
check(frame && frame.holders.length === 2, 'Arrival ordering requires two distinct original owners.');
return { frame, queue };
}
function validateShape(order: SaphoArrivalOrder): void {
check(order && typeof order === 'object' && !Array.isArray(order) &&
Array.isArray(order.queue) && order.queue.length === 2 && order.frame &&
Array.isArray(order.frame.holders) && order.frame.holders.length === 2,
'Retain the two original arrival owners and ordering frame.');
validateSaphoTruthOrder(order.frame, order.queue);
check(order.queue[0].player === order.frame.holders[0] ||
order.frame.protectedFirst === order.queue[0].player ||
order.frame.protectedLast === order.queue[1].player,
'The reordered arrival must retain its original Sapho choice.');
}
/** Match a saved frame to the unchanged public parent, not a caller-supplied
* card or a newly sampled token. The queue may differ from original order only
* through a recorded Sapho choice. */
export function validateSaphoArrivalOrder(
order: SaphoArrivalOrder, event: string, originalOwners: readonly string[], selectedFirst: string,
): void {
validateShape(order);
check(order.frame.event === event && Array.isArray(originalOwners) && originalOwners.length === 2 &&
order.frame.holders[0] === originalOwners[0] && order.frame.holders[1] === originalOwners[1] &&
order.queue[0].player === selectedFirst,
'The arrival ordering no longer matches its original owners, event or selected first reaction.');
}
export function quoteSaphoArrivalOrder(order: SaphoArrivalOrder, player: string) {
validateShape(order);
return quoteSaphoTruthOrder(order.frame, order.queue, player);
}
function detach(result: SaphoTruthOrderResult<Entry>): SaphoArrivalOrderResult {
check(result.queue.length === 2, 'Retain both arrival owners.');
return {
order: {
frame: { ...result.frame, holders: [...result.frame.holders], passed: [...result.frame.passed] },
queue: [{ player: result.queue[0].player }, { player: result.queue[1].player }],
},
done: result.done,
};
}
/** Passing and using Sapho both retain the complete public receipt, even after
* the last owner has responded, so later continuation validates the order. */
export function passSaphoArrivalOrder(order: SaphoArrivalOrder, request: SaphoTruthOrderRequest): SaphoArrivalOrderResult {
validateShape(order);
return detach(passSaphoTruthOrder(order.frame, order.queue, request));
}
export function reorderSaphoArrivalOrder(
order: SaphoArrivalOrder, request: SaphoTruthOrderRequest & { mode: SaphoTruthOrderMode },
): SaphoArrivalOrderResult {
validateShape(order);
return detach(reorderSaphoTruthOrder(order.frame, order.queue, request));
}
+4
View File
@@ -29,6 +29,10 @@ export function createBasicNativeOverlapFixture(native: Native, first: First, en
send('ecaz',{type:'seatPosition',position:first === 'ambassador' ? 4 : 5});
send('choam',{type:'seatPosition',position:1});
send(native,{type:'seatPosition',position:2});
// CHOAM and Ix now enable printed Kull by default. This fixture exercises
// the separately supported no-Kull arrival profile, so the host opts out
// through the normal lobby control before anyone becomes ready.
send('ecaz',{type:'moduleOption',module:'kullPreview',enabled:false});
for (const seat of game.players) send(seat.id,{type:'ready'});
const lobby=structuredClone(game);
send('ecaz',{type:'start'});
+20 -2
View File
@@ -20,6 +20,22 @@ export const actSemutaArrivalOverlap = actBasicSemutaAmbassador;
export const player = basicSemutaAmbassadorPlayer;
export const inventory = basicSemutaAmbassadorInventory;
/** Complete only the original public ordering opportunity. Owners pass even
* when neither holds Sapho; no card custody or arrival outcome is staged. */
export function passArrivalOrder(source: Game): Game {
let game = source;
for (let count = 0; count < 2; count++) {
const overlap = game.pendingArrivalOverlap, order = overlap?.saphoOrder;
if (!order || game.pendingAmbassador || game.pendingTerrorEntry || game.pendingTreacheryDiscard ||
order.frame.passed.length === order.frame.holders.length) break;
const actor = order.frame.holders.find(id => !order.frame.passed.includes(id));
assert.ok(actor);
game = actSemutaArrivalOverlap(game, actor, { type: 'arrivalOrderPass', event: order.frame.event,
revision: order.frame.revision });
}
return game;
}
/** Quiet native choices after the inherited turn-one prefix; token placement,
* arrival and both reactions remain explicit caller actions. */
export function nextSemutaArrivalOverlapStep(game: Game): BasicSemutaAmbassadorStep | null {
@@ -160,6 +176,7 @@ export function claimOverlapSemuta(source: Game, actor: string, card: string): G
export function openOverlapAmbassadorGrant(source: Game, f: SemutaArrivalOverlapFixture): Game {
let game = source;
if (!game.pendingArrivalOverlap) game = actSemutaArrivalOverlap(game, f.arrivalStep.actor, f.arrivalStep.action);
game = passArrivalOrder(game);
assert.ok(game.pendingArrivalOverlap);
if (game.pendingTerrorEntry?.stage === 'offer')
game = actSemutaArrivalOverlap(game, f.moritani, { type: 'decision', decline: true });
@@ -171,8 +188,9 @@ export function openOverlapAmbassadorGrant(source: Game, f: SemutaArrivalOverlap
assert.equal(game.pendingAmbassador?.stage, 'cards'); return game;
}
export function openOverlapTerrorDiscard(source: Game, f: SemutaArrivalOverlapFixture): Game {
assert.equal(source.pendingTerrorEntry?.stage, 'offer');
let game = actSemutaArrivalOverlap(source, f.moritani, { type: 'decision', reveal: true });
let game = passArrivalOrder(source);
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
game = actSemutaArrivalOverlap(game, f.moritani, { type: 'decision', reveal: true });
if (f.terror === 'robbery') {
assert.equal(game.pendingTerrorEntry?.stage, 'robbery');
game = actSemutaArrivalOverlap(game, f.moritani, { type: 'decision', choice: 'card' });
+2 -2
View File
@@ -3,7 +3,7 @@ import type { Action, Game } from '../game/engine';
import { validateAmbassadors } from '../game/ecaz-ambassadors';
import { fremenAmbassadorMovement } from '../game/fremen-ambassador';
import { actSemutaArrivalOverlap as act, createSemutaArrivalOverlapFixture,
inventory, player } from './fixture-semuta-arrival-overlap';
inventory, player, passArrivalOrder } from './fixture-semuta-arrival-overlap';
export { act, inventory, player };
@@ -37,7 +37,7 @@ export type SemutaOverlapChildFixture = ReturnType<typeof createSemutaOverlapChi
/** The real paid Guild arrival opens both original opportunities; the Fremen
* Ambassador then moves one existing Ecaz force into that same stronghold. */
export function openSemutaOverlapChild(f: SemutaOverlapChildFixture): Game {
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
assert.equal(game.pendingArrivalOverlap?.first, 'ambassador');
const entry = game.pendingAmbassador; assert.ok(entry && entry.stage === 'offer');
assert.equal(entry.token, f.fremenToken);
+2 -2
View File
@@ -5,7 +5,7 @@ import { botActions } from '../game/bots';
import {
createSemutaArrivalOverlapFixture as fixture, actSemutaArrivalOverlap as act,
inventory, player, passCurrentSemuta as passSemuta, claimOverlapSemuta as claimSemuta,
openOverlapTerrorDiscard,
openOverlapTerrorDiscard, passArrivalOrder,
} from './fixture-semuta-arrival-overlap';
function reject(game: Game, actor: string, action: Action) {
@@ -19,7 +19,7 @@ function reject(game: Game, actor: string, action: Action) {
* original unopened physical cards move from the deck to the entrant's hand. */
function source() {
const f = fixture({ effect: 'choam', first: 'terror', arrival: 'shipment', terror: 'sabotage' });
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
game = openOverlapTerrorDiscard(game, f);
const originalDiscard = game.pendingTreacheryDiscard!.batch.event;
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { Action } from '../game/engine';
import { SaphoArrivalOrder, SaphoArrivalOrderChoiceButton, SaphoArrivalOrderPass,
type SaphoArrivalOrderProps } from '../components/sapho-arrival-order';
function props(choices: SaphoArrivalOrderProps['view']['choices'] = []): SaphoArrivalOrderProps & { sent: Action[] } {
const sent: Action[] = [];
const names: Record<string, string> = { ecaz: 'Ecaz', moritani: 'Moritani' };
return { view: { event: 'arrival-order-event', revision: 2, order: ['ecaz', 'moritani'], passed: [], choices },
me: 'ecaz', busy: false, name: id => names[id] ?? id,
sent, act(action) { sent.push(action); } };
}
const render = (input: SaphoArrivalOrderProps) => renderToStaticMarkup(createElement(SaphoArrivalOrder, input));
void test('both reaction owners see a public ordering opportunity without needing Sapho', () => {
const input = props(), html = render(input);
assert.match(html, /Ambassador and Terror reaction order/);
assert.match(html, /Ecaz.*→.*Moritani/);
assert.match(html, /provisional interpretation/);
assert.match(html, /Continue without changing reaction order/);
assert.doesNotMatch(html, /Use Juice of Sapho/);
SaphoArrivalOrderPass(input).props.onClick();
assert.deepEqual(input.sent, [{ type: 'arrivalOrderPass', event: 'arrival-order-event', revision: 2 }]);
});
void test('quoted private options dispatch the exact event-bound Sapho actions', () => {
const input = props([{ card: 'private-card-id', mode: 'first' }, { card: 'private-card-id', mode: 'last' }]);
const html = render(input);
assert.match(html, /Use Juice of Sapho to react first/);
assert.match(html, /Use Juice of Sapho to react last/);
assert.doesNotMatch(html, /private-card-id/);
for (const choice of input.view.choices) SaphoArrivalOrderChoiceButton({ ...input, choice }).props.onClick();
assert.deepEqual(input.sent, input.view.choices.map(choice => ({ type: 'saphoArrivalOrder',
event: 'arrival-order-event', revision: 2, ...choice })));
});
void test('busy, already-passed and other viewers cannot issue ordering actions', () => {
for (const variant of ['busy', 'passed', 'other'] as const) {
const input = props([{ card: 'sapho-card', mode: 'first' }]);
if (variant === 'busy') input.busy = true;
if (variant === 'passed') input.view.passed.push(input.me);
if (variant === 'other') input.me = 'other';
SaphoArrivalOrderPass(input).props.onClick();
SaphoArrivalOrderChoiceButton({ ...input, choice: input.view.choices[0] }).props.onClick();
assert.deepEqual(input.sent, []);
if (variant !== 'busy') assert.doesNotMatch(render(input), /data-testid="sapho-arrival-order-pass"/);
else assert.match(render(input), /disabled=""/);
}
});
void test('callbacks captured before an event, revision, pass or quoted choice changes do not send', () => {
for (const change of ['event', 'revision', 'pass', 'choice'] as const) {
const input = props([{ card: 'sapho-card', mode: 'first' }]);
const pass = SaphoArrivalOrderPass(input), choice = SaphoArrivalOrderChoiceButton({ ...input, choice: input.view.choices[0] });
if (change === 'event') input.view.event = 'new-event';
if (change === 'revision') input.view.revision++;
if (change === 'pass') input.view.passed.push(input.me);
if (change === 'choice') input.view.choices.splice(0);
if (change !== 'choice') pass.props.onClick();
choice.props.onClick();
assert.deepEqual(input.sent, []);
}
});
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createSaphoArrivalOrder,
passSaphoArrivalOrder,
quoteSaphoArrivalOrder,
reorderSaphoArrivalOrder,
validateSaphoArrivalOrder,
} from '../game/sapho-arrival-order';
const owners = ['ecaz', 'moritani'] as const;
const initial = () => createSaphoArrivalOrder('arrival:event', owners[0], owners[1]);
const request = (player: string, revision: number) => ({ event: 'arrival:event', revision, player });
void test('both public arrival owners can pass and retain the original storm order', () => {
const first = initial();
assert.deepEqual(first.frame.holders, owners);
validateSaphoArrivalOrder(first, 'arrival:event', owners, 'ecaz');
const a = passSaphoArrivalOrder(first, request('ecaz', 0));
assert.equal(a.done, false);
const b = passSaphoArrivalOrder(a.order, request('moritani', 1));
assert.equal(b.done, true);
assert.deepEqual(b.order.queue.map(row => row.player), owners);
assert.deepEqual(b.order.frame.passed, owners);
validateSaphoArrivalOrder(b.order, 'arrival:event', owners, 'ecaz');
assert.deepEqual(first.frame.passed, [], 'a later response does not mutate the prior frame');
});
for (const [player, mode] of [['moritani', 'first'], ['ecaz', 'last']] as const) {
void test(`${player} can select ${mode} without displacing a prior protected choice`, () => {
const start = initial();
assert.ok(quoteSaphoArrivalOrder(start, player).modes.includes(mode));
const used = reorderSaphoArrivalOrder(start, { ...request(player, 0), mode });
assert.equal(used.done, false);
assert.deepEqual(used.order.queue.map(row => row.player), ['moritani', 'ecaz']);
validateSaphoArrivalOrder(used.order, 'arrival:event', owners, 'moritani');
const other = player === 'ecaz' ? 'moritani' : 'ecaz';
assert.deepEqual(quoteSaphoArrivalOrder(used.order, other).modes, []);
const passed = passSaphoArrivalOrder(used.order, request(other, 1));
assert.equal(passed.done, true);
validateSaphoArrivalOrder(passed.order, 'arrival:event', owners, 'moritani');
assert.deepEqual(start.queue.map(row => row.player), owners);
});
}
void test('an arrival frame rejects stale, foreign, duplicate and no-op actions', () => {
const start = initial();
assert.throws(() => createSaphoArrivalOrder('arrival:event', 'ecaz', 'ecaz'));
assert.throws(() => passSaphoArrivalOrder(start, request('outsider', 0)));
assert.throws(() => passSaphoArrivalOrder(start, request('ecaz', 1)));
assert.throws(() => reorderSaphoArrivalOrder(start, { ...request('ecaz', 0), mode: 'first' }));
const passed = passSaphoArrivalOrder(start, request('ecaz', 0));
assert.throws(() => passSaphoArrivalOrder(passed.order, request('ecaz', 1)));
assert.throws(() => passSaphoArrivalOrder(passed.order, { ...request('moritani', 1), event: 'old' }));
});
void test('an arrival receipt remains bound to the parent owners, event and chosen first reaction', () => {
const used = reorderSaphoArrivalOrder(initial(), { ...request('moritani', 0), mode: 'first' }).order;
const restored = JSON.parse(JSON.stringify(used)) as typeof used;
validateSaphoArrivalOrder(restored, 'arrival:event', owners, 'moritani');
assert.throws(() => validateSaphoArrivalOrder(restored, 'other:event', owners, 'moritani'));
assert.throws(() => validateSaphoArrivalOrder(restored, 'arrival:event', ['moritani', 'ecaz'], 'moritani'));
assert.throws(() => validateSaphoArrivalOrder(restored, 'arrival:event', owners, 'ecaz'));
const tampered = structuredClone(restored);
delete tampered.frame.protectedFirst;
assert.throws(() => validateSaphoArrivalOrder(tampered, 'arrival:event', owners, 'moritani'));
const duplicate = structuredClone(restored);
duplicate.queue[1].player = duplicate.queue[0].player;
assert.throws(() => validateSaphoArrivalOrder(duplicate, 'arrival:event', owners, 'moritani'));
});
+207
View File
@@ -0,0 +1,207 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createSemutaArrivalOverlapFixture, actSemutaArrivalOverlap as act, player,
inventory, passCurrentSemuta, claimOverlapSemuta, advanceSemutaArrivalOverlap } from './fixture-semuta-arrival-overlap';
type Mode = 'first' | 'last';
type OrderView = { event: string; revision: number; order: string[]; passed: string[];
choices: { card: string; mode: Mode }[] };
function orderView(game: Game, actor: string): OrderView {
const value = (viewGame(game, actor) as unknown as { saphoArrivalOrder: OrderView | null }).saphoArrivalOrder;
assert.ok(value, 'The public shared-arrival ordering window is open');
return value;
}
function pass(game: Game, actor: string): Game {
const order = orderView(game, actor);
return act(game, actor, { type: 'arrivalOrderPass', event: order.event, revision: order.revision } as Action);
}
function use(game: Game, actor: string, card: string, mode: Mode): Game {
const order = orderView(game, actor);
assert.ok(order.choices.some(choice => choice.card === card && choice.mode === mode));
return act(game, actor, { type: 'saphoArrivalOrder', event: order.event,
revision: order.revision, card, mode } as Action);
}
/** At the clean owned entry opportunity, exchange original physical cards only.
* This preserves every hand size, card identity and actual resource/force state. */
function stageSapho(game: Game, recipient: string): string {
const zones = [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []),
...(game.richeseRemoved ? [game.richeseRemoved] : []), ...game.players.map(seat => seat.hand)];
const target = player(game, recipient).hand;
const found = zones.find(zone => zone.some(card => card.effect === 'juiceOfSapho'));
assert.ok(found);
const at = found.findIndex(card => card.effect === 'juiceOfSapho');
const card = found[at];
if (found !== target) {
const replacement = target.findIndex(item => item.effect !== 'semutaDrug');
assert.ok(replacement >= 0);
[found[at], target[replacement]] = [target[replacement], found[at]];
}
return card.id;
}
function decline(game: Game, who: 'ambassador' | 'terror'): Game {
if (who === 'ambassador') {
assert.ok(game.pendingAmbassador);
return act(game, 'ecaz', { type: 'decision', event: game.pendingAmbassador.event, decline: true });
}
assert.ok(game.pendingTerrorEntry);
return act(game, 'moritani', { type: 'decision', decline: true });
}
function checkArrival(game: Game, f: ReturnType<typeof createSemutaArrivalOverlapFixture>): void {
assert.equal(player(game, f.entrant).forces[f.location], 1);
assert.equal(player(game, f.entrant).reserves, player(f.game, f.entrant).reserves - 1);
assert.equal(player(game, f.entrant).spice, f.balances[f.entrant] - 1);
assert.equal(player(game, f.entrant).shipped, true);
assert.deepEqual(inventory(game), f.originalInventory);
}
for (const first of ['ambassador', 'terror'] as const) {
void test(`Sapho may move ${first} last before the original shared arrival, then resume after Semuta`, () => {
const f = createSemutaArrivalOverlapFixture({ effect: 'choam', first, terror: 'robbery' });
const holder = first === 'ambassador' ? f.owner : f.moritani;
const other = holder === f.owner ? f.moritani : f.owner;
const card = stageSapho(f.game, holder);
assert.deepEqual(inventory(f.game), f.originalInventory);
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
checkArrival(game, f);
assert.equal(game.pendingAmbassador ?? null, null);
assert.equal(game.pendingTerrorEntry ?? null, null);
assert.deepEqual(orderView(game, holder).order, [holder, other]);
assert.ok(orderView(game, holder).choices.some(choice => choice.card === card && choice.mode === 'last'));
assert.deepEqual(orderView(game, other).choices, []);
const stale = orderView(game, holder);
assert.throws(() => act(game, holder, { type: 'saphoArrivalOrder', event: stale.event,
revision: stale.revision + 1, card, mode: 'last' } as Action));
game = use(game, holder, card, 'last');
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(game.pendingAmbassador ?? null, null);
assert.equal(game.pendingTerrorEntry ?? null, null);
if (first === 'ambassador') {
game = claimOverlapSemuta(game, f.claimant, card);
assert.ok(player(game, f.claimant).hand.some(row => row.id === card));
} else {
game = passCurrentSemuta(game);
assert.ok(game.discard.some(row => row.id === card));
}
assert.deepEqual(orderView(game, other).order, [other, holder]);
game = pass(game, other);
assert.equal(game.pendingArrivalOverlap?.first, first === 'ambassador' ? 'terror' : 'ambassador');
checkArrival(game, f);
const now = first === 'ambassador' ? 'terror' : 'ambassador';
game = decline(game, now);
game = decline(game, first);
assert.equal(game.pendingArrivalOverlap, null);
checkArrival(game, f);
});
}
void test('both eligible owners can pass without Sapho and four bot profiles provide legal replies', () => {
const f = createSemutaArrivalOverlapFixture({ effect: 'choam', first: 'ambassador', terror: 'robbery' });
stageSapho(f.game, 'choam');
const initial = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
const original = orderView(initial, f.owner);
assert.deepEqual(original.order, [f.owner, f.moritani]);
assert.deepEqual(original.choices, []);
assert.deepEqual(orderView(initial, f.moritani).choices, []);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(initial, f.owner);
view.players.find(seat => seat.id === f.owner)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, difficulty);
assert.doesNotThrow(() => act(initial, f.owner, action));
}
let game = pass(initial, f.owner);
game = pass(game, f.moritani);
assert.equal(game.pendingArrivalOverlap?.first, 'ambassador');
game = decline(game, 'ambassador');
game = decline(game, 'terror');
assert.equal(game.pendingArrivalOverlap ?? null, null);
checkArrival(game, f);
});
void test('a final Sapho response resumes directly into the newly first reaction after Semuta passes', () => {
const f = createSemutaArrivalOverlapFixture({ effect: 'choam', first: 'ambassador', terror: 'robbery' });
const card = stageSapho(f.game, f.moritani);
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
game = pass(game, f.owner);
assert.deepEqual(orderView(game, f.moritani).passed, [f.owner]);
game = use(game, f.moritani, card, 'first');
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(game.pendingAmbassador ?? null, null);
assert.equal(game.pendingTerrorEntry ?? null, null);
game = passCurrentSemuta(game);
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.pendingArrivalOverlap?.first, 'terror');
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
checkArrival(game, f);
game = decline(game, 'terror');
game = decline(game, 'ambassador');
assert.equal(game.pendingArrivalOverlap, null);
checkArrival(game, f);
});
void test('a Guild income response finishes before a deferred shared arrival opens its public order', () => {
const f = createSemutaArrivalOverlapFixture({ effect: 'choam', first: 'ambassador', terror: 'robbery', copied: true });
// Replay the original real Mentat placement, then stop on CHOAM's own
// actual next-turn movement opportunity instead of Guild's later turn.
let game = act(f.beforeTerrorPlacement, f.terrorPlacement.actor, f.terrorPlacement.action);
game = advanceSemutaArrivalOverlap(game, state => state.turn === 2 && state.phase === 5 &&
state.active === 'choam' && !state.response && !state.decision && !state.phaseOpening &&
!state.truthtrance && !state.pendingTreacheryDiscard);
// The source's controlled ordinary opening hands have no Karama, so the
// engine automatically resolves Guild income before exposing the queue.
// Swap one existing physical card for a real Karama in Ecaz's hand to keep
// the native cancellation opportunity open; no response is manufactured.
const zones = [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []),
...(game.richeseRemoved ? [game.richeseRemoved] : []), ...game.players.map(seat => seat.hand)];
const ecazHand = player(game, f.owner).hand;
const source = zones.find(zone => zone.some(card => card.effect === 'karama'));
assert.ok(source);
const karamaIndex = source.findIndex(card => card.effect === 'karama');
const karama = source[karamaIndex];
if (source !== ecazHand) {
const replacement = ecazHand.findIndex(card => card.effect !== 'semutaDrug');
assert.ok(replacement >= 0);
[source[karamaIndex], ecazHand[replacement]] = [ecazHand[replacement], source[karamaIndex]];
}
const before = structuredClone(game), originalCards = inventory(game);
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)?.location, f.territory);
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.terrorToken)?.location, f.territory);
game = act(game, 'choam', { type: 'ship', territory: f.territory, sector: f.sector,
amount: 1, elite: 0, allyPayment: 0 });
// A native Guild-rate response can precede actual payment. Pass it without
// staging any response or changing its saved shipment.
for (let count = 0; game.response?.kind === 'guildRate' && count < game.players.length; count++) {
const seat = game.players.find(row => !game.response!.passed.includes(row.id));
assert.ok(seat); game = act(game, seat.id, { type: 'passResponse' });
}
assert.equal(game.response?.kind, 'guildIncome');
assert.ok(viewGame(game, f.owner).responseControls?.cancelCards.includes(karama.id));
assert.equal(game.pendingArrivalReaction?.length, 1);
assert.equal(game.pendingArrivalReaction?.[0].entrant, 'choam');
assert.equal(game.pendingArrivalReaction?.[0].territory, f.territory);
assert.equal(game.pendingArrivalOverlap ?? null, null);
assert.equal(player(game, 'choam').reserves, player(before, 'choam').reserves - 1);
for (let count = 0; game.response?.kind === 'guildIncome' && count < game.players.length; count++) {
const seat = game.players.find(row => !game.response!.passed.includes(row.id));
assert.ok(seat); game = act(game, seat.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
assert.equal(game.pendingArrivalReaction ?? null, null);
assert.equal(game.pendingAmbassador ?? null, null);
assert.equal(game.pendingTerrorEntry ?? null, null);
assert.deepEqual(orderView(game, f.owner).order, [f.owner, f.moritani]);
assert.equal(player(game, 'choam').forces[f.location], 1);
assert.equal(player(game, 'choam').reserves, player(before, 'choam').reserves - 1);
assert.deepEqual(inventory(game), originalCards);
game = pass(game, f.owner);
game = pass(game, f.moritani);
game = decline(game, 'ambassador');
game = decline(game, 'terror');
assert.equal(game.pendingArrivalOverlap, null);
assert.equal(player(game, 'choam').forces[f.location], 1);
assert.deepEqual(inventory(game), originalCards);
});
+4 -4
View File
@@ -5,7 +5,7 @@ import {
createSemutaArrivalOverlapFixture as fixture, actSemutaArrivalOverlap as act,
player, inventory, passCurrentSemuta as pass, claimOverlapSemuta as claim,
openOverlapAmbassadorGrant as grant, openOverlapTerrorDiscard as terrorDiscard,
finishOverlapTerror as finishTerror, type SemutaArrivalOverlapFixture,
finishOverlapTerror as finishTerror, passArrivalOrder, type SemutaArrivalOverlapFixture,
} from './fixture-semuta-arrival-overlap';
function reject(game: Game, actor: string, action: Action) {
@@ -52,7 +52,7 @@ for (const first of ['ambassador', 'terror'] as const)
assert.equal(f.game.moritaniTerror!.placementTurn, 1);
assert.equal(f.game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'placed');
assert.ok(f.game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(f.territory + ':'))));
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
const overlap = game.pendingArrivalOverlap; assert.ok(overlap);
assert.equal(overlap.first, first); assert.equal(overlap.stage, 'first');
assert.equal(overlap.entrant, f.entrant); assert.equal(overlap.cause, arrival);
@@ -106,7 +106,7 @@ void test('CHOAM first recovers one private card before Sabotage, pays once and
void test('Robbery first passes its overflow before a full-hand Ixian claim and one original replacement', () => {
const f = fixture({ effect: 'ixians', first: 'terror', terror: 'robbery', fullHand: true });
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
const top = game.deck[0].id, size = game.deck.length;
game = terrorDiscard(game, f);
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
@@ -134,7 +134,7 @@ void test('Robbery first passes its overflow before a full-hand Ixian claim and
void test('Sabotage first spends Semuta on its victim before copied CHOAM creates a distinct discard', () => {
const f = fixture({ effect: 'choam', first: 'terror', arrival: 'movement', copied: true });
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
game = terrorDiscard(game, f);
const terrorEvent = game.pendingTreacheryDiscard!.batch.event;
assert.equal(game.pendingTreacheryDiscard!.batch.entries[0].card.id, f.entrantCard);