Support Sapho ordering for shared arrivals
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
b18a767470
commit
e457478f7d
18 files changed
+808
-23
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, []);
|
||||
}
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user