Allow Sapho order behind a started turn

This commit is contained in:
Quintine committed 2026-10-25 23:33:04 +11:00
1 parent 3bf7d26e80
commit 8e6602633d
17 files changed
+1121 -62

No files matched your search

+50 -12
View File
@@ -10,6 +10,8 @@ import { CardInspector } from './card-inspector';
function unavailableReason(game: GameView) {
if (game.status !== 'playing')
return 'Order changes are available during play.';
if (game.phase === 5)
return 'Movement order supports clean combined-turn boundaries and queuing behind a started active turn. The active player finishes unchanged; only later unstarted turns can be reordered. Finish pending interactions and preparations first: Sapho never splits an active combined shipment and movement turn. The Advanced Guild retains its next timing choice; an already granted current Guild turn cannot be displaced, and last remains after the Guild. A Karama-locked privilege stays locked. Completed turns and actual shipment and movement counters stay unchanged. Already-held positions and committed cards remain unavailable.';
if (game.truthtrance || game.response || game.phaseOpening ||
(game.decision && !(game.phase === 8 && game.decision.kind === 'moritaniExtortion')))
return 'Finish the current interaction before changing turn order.';
@@ -22,8 +24,6 @@ function unavailableReason(game: GameView) {
return 'No cyclic order change is available for your pending bid. A passed player waits for a new raise; the current high bidder has no pending bid. Already-held positions and committed cards are unavailable.';
return 'No order change is available for you in this lot. You must not have bid or passed; discarding this card can open a hand slot, but a completed opportunity cannot be repeated. A card already committed elsewhere cannot be used.';
}
if (game.phase === 5)
return 'Movement order can change only before the current combined shipment and movement turn begins. First requires all remaining turns to be unstarted. The Advanced Guild retains its next timing choice; an already granted current Guild turn cannot be displaced. A Karama-locked privilege stays locked. Completed turns, already-held positions and committed cards remain unavailable.';
if (game.phase === 6)
return game.battle
? game.battle.saphoTimingVersion === 1
@@ -33,6 +33,37 @@ function unavailableReason(game: GameView) {
return 'These controls support Once Around and cyclic bidding, movement order, remaining battle-choice order and aggressor priority. Other phase modes remain unfinished.';
}
type SaphoOption = NonNullable<GameView['saphoOptions']>[number];
/** Submit only action fields: projected timing guidance is display metadata. */
export function SaphoOrderButton({
card,
option,
act,
busy,
}: {
card: string;
option: SaphoOption;
act: (action: Action) => void;
busy: boolean;
}) {
const bidding = option.scope === 'onceAround' || option.scope === 'cyclicAuction';
return (
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy}
onClick={() => {
if (!busy) act({
type: 'card', card, scope: option.scope, event: option.event, mode: option.mode,
});
}}
>
{option.scope === 'battleAggressor' ? 'Become aggressor and discard Juice of Sapho' :
`${bidding ? 'Bid' : option.scope === 'battleOrder' ? 'Choose battles' : option.scope === 'extortion' ? 'Take your Extortion choice' : 'Take your movement turn'} ${option.mode} and discard Juice of Sapho`}
</Button>
);
}
export function JuiceOfSapho({
game,
act,
@@ -67,11 +98,27 @@ export function JuiceOfSapho({
const battle = option.scope === 'battleOrder';
const aggressor = option.scope === 'battleAggressor';
const extortion = option.scope === 'extortion';
const afterCurrent = option.scope === 'movement' && option.afterCurrent;
const currentPlayer = afterCurrent
? game.players.find(player => player.id === afterCurrent)?.name ?? 'The active player'
: null;
return (
<div
key={`${option.scope}-${option.event}-${option.mode}`}
className="flex min-w-0 flex-col gap-2"
>
{currentPlayer && (
<p>
Provisional queued movement order: {currentPlayer} finishes
the current combined shipment and movement turn unchanged.
Your {option.mode} position applies among later unstarted
combined turns. Completed turns and actual shipment and
movement counters stay unchanged.
{game.advanced && game.players.some(player => player.faction === 'guild' && player.id !== game.me)
? ' The Advanced Guild keeps its native timing choice at the next boundary. A last position still follows the Guild.'
: ''}
</p>
)}
<p>
{aggressor
? 'Become this battle’s aggressor. You win ordinary ties; a Habbanya Stronghold advantage still takes precedence. The participants, committed plans and later battle choices stay unchanged.'
@@ -85,16 +132,7 @@ export function JuiceOfSapho({
? `Choose your remaining battles ${option.mode}. Completed battles stay resolved. This changes who chooses next, not the aggressor or tie advantage of a battle. Other players may still choose battles against you before your turn.`
: `Take the ${option.mode} remaining combined shipment and movement turn. Completed turns stay completed${option.mode === 'last' ? ', and the Guild cannot move behind you' : game.advanced && game.players.some(player => player.faction === 'guild' && player.id !== game.me) ? '. Provisional first priority: the Guild retains its next legal timing choice and may take its turn before yours; a Karama-locked privilege stays locked' : ''}.`}
</p>
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy}
onClick={() => {
if (!busy) act({ type: 'card', card: card.id, ...option });
}}
>
{aggressor ? 'Become aggressor and discard Juice of Sapho' :
`${once || cyclic ? 'Bid' : battle ? 'Choose battles' : extortion ? 'Take your Extortion choice' : 'Take your movement turn'} ${option.mode} and discard Juice of Sapho`}
</Button>
<SaphoOrderButton card={card.id} option={option} act={act} busy={busy} />
</div>
);
})
+37
View File
@@ -16,6 +16,43 @@ open mode gates.
## Current checkpoint and work
**Sapho behind a started combined turn — 25 October. Prototyped / Partial.**
An unstarted holder may now provisionally reorder only later unstarted turns
behind another actor's begun combined turn. That actor finishes unchanged;
shipment/movement counters, payment, completed turns and physical storm order
remain. First means first in the waiting tail; last stays after Guild. Native
Advanced Guild retains its next-boundary choice. Preparation-only windows and
splitting the holder's own begun turn remain blocked. The public queue binds
the queued action event; owner-only `afterCurrent` metadata explains timing,
while human/bot submissions keep card/scope/event/mode only. Original movement
last protection and Semuta's exact disposal continuation retain the active
actor. [Selected policy and source](JUICE_OF_SAPHO_RUNTIME.md#order-after-a-started-combined-turn--25-october-2026).
Three disjoint lanes and root integration pass **11 files, 133/133**, types/
lint/build and **11 engine/GameTable SSR/real button-callback journeys** on
unchanged source. New helper8, controls6 and engine14 cases cover first/last,
stale/no-op/active-holder rejection, all four profiles, actual Guild Take/Wait
and Semuta claim/pass. Seven native journeys earn paid cache1 Sapho and start
the current turn with a real positive shipment: Basic46/Advanced53 source
actions; already-taken Guild55 pays its own one-spice shipment. They complete
all four turns once, using8–13 native continuations and preserving all45 cards.
Four separately labelled Basic/Advanced Homeworld supplements inherit conserved
Sapho/Semuta custody, then actually pay for shipment and use the real discard
claim/pass continuation. These do not claim native card acquisition.
Initial feedback catches the preparation-only guard gap; it is repaired without
opening pending preparations. Old blanket-started assertions now check queued
options plus unchanged current actors and stale original-quote rejection. An
older cyclic case now finishes its real public discard acknowledgement before
the next bid. Test-only type narrowing/card binding, one unused import and a
redundant array copy are corrected; no rule/start guard is weakened. Private
source-bound report/logs: `/home/quintine/.local/share/dune/checkpoints/20261025-sapho-started-lint-fixed/`;
journeys/HTML: adjacent `20261025-sapho-started-runtime/`.
No saved-room/D1/server/reset/restart/deployment or recovery operation occurs.
These are engine/SSR/callback results, not hydration, DOM/browser/mobile or
deployed acceptance. Other ordered phases, split-own-turn policy, wider valid
combinations, complete rules/modes and final assurance remain unfinished.
**Karama acquisitions reach Richese Nexus veto — 25 October. Prototyped / Partial.**
The existing preview now covers immediate ordinary Karama and private Advanced
special purchase. Uniform acknowledgements precede original costs. Under the
+44
View File
@@ -1,5 +1,49 @@
# Dune implementation status
## 25 October 2026 — Sapho reorders the waiting tail behind a started turn
**Prototyped / Partial.** An unstarted different Sapho holder may provisionally
move first/last among later unstarted combined shipment/movement turns. Retain
the active actor's entire turn, original shipment/payment/movement counters,
completed turns and physical storm order. The actual current queue binds the
action event; last protection retains original `movement:turn`. Guild keeps
native next-boundary Take/Wait, including after first; last remains after Guild.
Preparation-only windows, current-holder splitting and pending card/arrival/
payment preparations stay blocked. This selects the source review's retained-
current recommendation under14 October authority, not a publisher clarification.
[Source and boundaries](JUICE_OF_SAPHO_RUNTIME.md#order-after-a-started-combined-turn--25-october-2026).
Owner-only options add `afterCurrent` display metadata; human and minimal bot
actions explicitly submit only card/scope/event/mode. Persistent controls name
the actor who finishes. Semuta's optional continuation field binds that same
active actor through claim/pass; absent-field legacy signatures keep their path.
Three disjoint helper/controls/source lanes freeze before root integration.
Final **11 files,133/133**, types/lint/build and eleven actual engine/GameTable
SSR/button-callback journeys pass unchanged. New helper8/controls6/engine14
cases include both rules bands, actual Guild Take/Wait, stale/no-op/active-holder
rejection, all four profiles and exact Semuta disposal.
Seven native journeys earn cache1 Sapho, then actually ship one counter before
use: Basic46/Advanced53 original actions; granted Guild55 pays one spice. Each
of the four original turns finishes once, in8–13 continuation actions, keeping
all45 cards. Four separately labelled Basic/Advanced Homeworld supplements use
inherited conserved Sapho/Semuta custody, then real paid shipment and claim/pass;
their card acquisition is controlled and not claimed as native.
Initial related125/133 exposes a preparation-only guard gap, stale blanket-
started tests and an old cyclic test skipping its required public discard
acknowledgement. The guard is restored for preparation without actual shipment/
movement. Old cases now test queued options and unchanged active actors while
retaining stale original-action rejection. Types require scoped option/card
narrowing; lint removes one unused import and redundant test copy. No rule or
start guard is weakened to pass. Private source-bound report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261025-sapho-started-lint-fixed/`;
journey data/HTML in adjacent `20261025-sapho-started-runtime/`.
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
Engine/SSR/callback evidence does not prove hydration, DOM/browser/mobile or
deployed acceptance. Other ordered phases, split-own-turn interpretation,
broader combinations, full rules/modes and final assurance remain open.
## 25 October 2026 — Richese Nexus reaches original Karama acquisitions
**Prototyped / Partial.** Immediate ordinary Karama and private Advanced special
+73 -1
View File
@@ -13,6 +13,72 @@ contract supersede the earlier absence of that mode below. The
and a fixed public final opportunity in fresh Richese battles; old battles keep
their early protocol.
## Order after a started combined turn — 25 October 2026
**Prototyped / Partial.** The printed card permits first/last in a turn-ordered
phase/action. The [source review](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast)
finds no entitlement to split an already-started combined shipment/movement
turn. Under the 14 October choose-recommendation authority, adopt its bounded
recommendation: retain that current actor and reorder only later unstarted
turns. The current actor finishes normally; the changed tail then takes effect.
This is visibly provisional online timing, not a new publisher clarification.
An unstarted holder at a clean boundary after another player's actual shipment
or movement may go first among the tail, or last including after Guild. It
cannot split its own begun turn, displace an active Guild, replay a completed
turn, reset shipment/movement counters or intervene during unresolved card,
arrival, payment or movement preparations. Existing last protection and no-op
rules remain. Advanced Guild receives its native next-boundary choice; moving
first in the tail does not consume or suppress that privilege.
The pure [movement-order quote](../game/sapho-movement-order.ts) binds the
original phase event, current actor and whole remaining public order in the
queued action event. Its returned last protection retains the original
`movement:turn` event. The owner-only option adds display metadata `afterCurrent`;
human and minimal bot actions explicitly send only card/scope/event/mode.
Persistent controls name the actor who must finish. Existing clean options and
physical storm order remain. Semuta's new optional continuation field keeps
that same current actor through the original discard claim/pass; absent-field
legacy continuation/signature bytes retain their original path.
This supersedes only the earlier blanket partly-completed-turn exclusion for
an **unstarted different holder** behind the current actor. No split-turn mode
or new ordered phase is invented. Wider combinations, full modes and final
assurance/deployed acceptance remain open.
Frozen **11 files, 133/133**, types/lint/build and eleven actual engine/
GameTable SSR/isolated button-callback journeys pass on unchanged source. New
helper8, controls6 and engine14 cases cover native Basic/Advanced first/last,
actual paid current shipment, unchanged current resources/counters, native
Guild Take/Wait, old-event/no-op/active-holder rejection, exact metadata-free
actions, all four minimal profiles and Semuta claim/pass.
Seven native journeys genuinely earn the cache1 purchase, then ship one actual
counter before intervention. Observed source prefixes are Basic46/Advanced53;
the original granted Guild source55 pays one spice for its native shipment.
All original four turns finish once in8–13 continuation actions and retain45
physical cards. Four separate Basic/Advanced Homeworld supplements inherit
explicitly **controlled conserved Sapho/Semuta custody**, rather than earning
those cards. They then make a real paid shipment, stage the used Sapho and
claim/pass through the exact current-turn continuation. Their source counts
are recorded per journey; no native acquisition is claimed for that supplement.
First related proof125/133 reveals a real preparation-only guard omission and
legacy blanket-started expectations. A Hajr/preparation without actual shipment
or movement remains blocked. Revised old tests retain stale original-quote
rejection, actor/counter/grant identity and cost preservation. A cyclic test
finishes the existing public discard response before its next bid. Test typing,
an unused import and a redundant copy are repaired without relaxing legality.
Private frozen report: `/home/quintine/.local/share/dune/checkpoints/20261025-sapho-started-lint-fixed/report.json`;
journeys/HTML: adjacent `20261025-sapho-started-runtime/`. No saved rooms,
databases, server, resets, restarts, deployment or recovery campaign is touched.
Broader combinations, complete modes, hydration/browser/mobile/deployed and
final assurance remain due. The engine/SSR evidence does not certify them.
Evidence: [native and labelled supplemental sources](../tests/fixture-sapho-started-movement.ts),
[engine cases](../tests/sapho-started-movement-engine.test.ts),
[quote cases](../tests/sapho-movement-order.test.ts),
[controls/callback cases](../tests/sapho-started-movement-controls.test.tsx).
## Extortion payer order — 18 October 2026
First/last now has a provisional adapter for native Moritani Extortion's unanswered
@@ -196,7 +262,13 @@ The [battle-order follow-up](SAPHO_BATTLE_ORDER.md) connects first/last at a cle
boundary between actual battles. It does not implement the separate aggressor
mode discussed below.
Later battle aggressor intervention, other ordered actions and partly completed movement remain unfinished. The current-lot cyclic prototype above supersedes the earlier normal/Black Market absence claim. First/Guild competing priority is guarded. Reacquiring and replaying Sapho while an existing movement protection remains is also guarded. Silent submission is simultaneous and receives no fabricated order or tie change.
Later sections supersede the earlier early-only aggressor, cyclic-auction and
blanket started-movement exclusions. Queued movement now supports an unstarted
different holder behind the active turn under the selected provisional policy.
Other ordered actions, splitting the holder's own started turn and competing
first/Guild precedence beyond the retained native choice remain unfinished.
Reacquiring and replaying Sapho while existing last protection remains is still
guarded. Silent submission stays simultaneous with its original storm tie order.
The battle audit in [the battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) found a real Stone Burner counterexample: a plan admitted with original aggressor tie priority can become allocation-ambiguous after late Sapho. A complete implementation needs the actual allocation continuation (and a usable, possession-independent late intervention boundary), not rejection based on an opponent's hidden plan. No aggressor handler has been enabled around that defect.
+8
View File
@@ -25,6 +25,14 @@ At an actual boundary between combined turns, reorder the remaining **unstarted*
If another faction is halfway through shipment/movement, retaining that active turn and applying the order change at its completion is a defensible bounded implementation, but it must be described as a supported boundary, not a verified entitlement to interrupt that faction. Moving the active faction itself to last after shipping would split a combined turn: sources inspected do not resolve it. The current engine's `movementRemaining` membership alone cannot distinguish unstarted from partly completed. An active seat may have committed movement before shipping was skipped, used a card, or have an outstanding shipment reaction. Both flags and pending declarations matter.
The 25 October prototype selects the retained-current recommendation under
14 October authority: an unstarted different holder may reorder the remaining
tail at a clean boundary, while the started current actor finishes unchanged.
It binds the actual public queue, preserves counters and future Guild timing,
and blocks unresolved preparations and a split of the holder's own turn.
This is provisional implementation timing, not a fresh publisher ruling.
[Selected runtime](JUICE_OF_SAPHO_RUNTIME.md#order-after-a-started-combined-turn--25-october-2026).
Sapho-first versus Guild-first remains a competing-priority question. The card expressly resolves only last. Preserving a pending Guild decision, or deferring first until after Guild's resolved choice, is an implementation restriction; globally suppressing Guild is a substantive interpretation. Do not cancel an already granted Guild turn or consume its power in a silent order rewrite.
### Finite Once Around first/last
+13 -1
View File
@@ -1,5 +1,17 @@
# Rule decision index
## Sapho after a started combined turn — 25 October 2026
Under the 14 October choose-recommendation authority, select the source review's
retained-current interpretation: an unstarted different Sapho holder may change
first/last among later unstarted turns at a clean boundary. The started current
actor finishes normally with original counters/resources; the tail then follows
the changed priority. Guild keeps its native next-boundary choice and last
protection remains after Guild. Split-own-turn, unresolved preparations and
new ordered phases stay unsupported. Queued timing is visibly provisional,
not a publisher clarification or a separately answered numerical user ruling.
[Source, native continuation and limits](JUICE_OF_SAPHO_RUNTIME.md#order-after-a-started-combined-turn--25-october-2026).
## Richese Nexus before Karama acquisition costs — 25 October 2026
Apply the printed purchase veto to native immediate ordinary Karama and private
@@ -1345,7 +1357,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Harass / Stone Burner | The earlier keep-gated versus compare-after-withdrawal question is unanswered. Standing choose-recommended provisionally compares counters left after successful withdrawal. Opposing Traitor cancels the return; original fixed Occupy commitment stays. Public preflight considers possible fixed Ecaz withdrawal without hidden custody. Clean Ix priority follows its separate explicitly provisional contract; new physical commitments do not remove other profile/module limits. This is implementation interpretation, not a new publisher ruling or user answer. | [Withdrawal](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026) · [Clean Ix priority](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) |
| Stone mixed-force allocation timing | The older timing question remains unanswered by a source. Under 14 October choose-recommended, new original outcome-ambiguous Stone/Mirror battles provisionally commit each ambiguous side's undialed physical total after revealed modes/actual withdrawal and decisive Traitor/explosion branches. Storm-order owners choose privately; every original required value stays sealed until all commit. The eventual winner retains all legal typed casualty choices with the count. Ordinary/invariant and unstamped legacy frames keep their existing path. Publisher physical-count text and supplied Advanced physical12/logical10 casualty timing provide the numerical rules, not this online commitment convention. | [Source, versioned procedure and bounded proof](STONE_BURNER_ENGINE_AUDIT.md#outcome-changing-physical-totals--18-october-2026) |
| Recruits late accounting and rates | The earlier one/two-further-free question remains unanswered. The prototype preserves paid spice, deducts recorded free usage from the doubled current rate and all returns from the total cap: two-free/one-paid Atreides can take two more free. Clean classic Homeworlds now provisionally recheck native thresholds on separate accepted requests, retaining completed prices/classifications; this is not a numerical user ruling or newly found timing FAQ. Unknown ledgers, pending repricing, later Fremen grants and same-turn replay remain guarded. | [Source alternatives](RECRUITS_RULES.md), [current native runtime](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026) |
| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns and other ordered phases remain open. Cyclic current-lot duration and the new shared final battle opportunity are explicit provisional protocols, not publisher rulings. Old battles retain early timing. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [current aggressor contract](SAPHO_AGGRESSOR.md), [historical battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) |
| Sapho remaining timing | Selected queued timing retains another actor's started combined turn and reorders only later unstarted turns; Guild retains native next-boundary choice. Splitting the holder's own turn and other ordered phases remain open. Queued timing, cyclic duration and shared final battle opportunity are provisional protocols, not publisher rulings. Old battles retain early timing. | [Queued timing](JUICE_OF_SAPHO_RUNTIME.md#order-after-a-started-combined-turn--25-october-2026), [source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [aggressor contract](SAPHO_AGGRESSOR.md) |
| Semuta Drug | Full-hand exchange is user-approved; neutral all-seat acknowledgements remain the explicit application policy. Completed Ghola may offer its physical card before the original conditional native Tleilaxu one-spice income, including self-income; claim/pass preserves returned pieces and normal quotas, then resumes the same allow/cancel response once. This admits no unrelated response or market parent. Other producers, nested continuation coverage and full-game acceptance remain unfinished. | [Source update and selected exchange](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) |
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifiers, Mentat empty-hand fallback, late/mirror/numeric Diplomat defenses and simultaneous optional timing remain source gaps. Owned multi-pile Smuggler/Sandmaster allocation and printed alternate/hybrid defense roles now have connected, explicitly interpreted paths. The bounded Diplomat retreat prototype chooses printed/copied strength and surviving-loser retreat before casualties; this is not an official priority clarification. | [Material boundaries and retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Defense limits](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
| Smuggler collection strength | The older source check/question produced no numerical answer. Under the later choose-recommended instruction, provisionally cap by copied/printed disc plus declared skill bonuses and KH2, retaining reveal piles and delayed survival settlement. Actual Artillery zeroing voids collection; the base Stunner remains a projectile. Sole own Traitor bypasses weapons, so it retains collection. Printed bounty does not change; this is not a publisher ruling. Other exceptional compositions remain separate. | [Source and explicit policy](LEADER_SKILLS_RULES.md#smuggler-modifier-policy--17-october-2026), [runtime contract](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026) |
+1 -1
View File
@@ -1182,7 +1182,7 @@ function saphoAction(g: GameView): Action | null {
(options[0].scope === 'movement' && earlySpice) || battleFirst ? 'first' : 'last';
const option =
options.find((candidate) => candidate.mode === preferred) ?? options[0];
return { type: 'card', card: card.id, ...option };
return { type: 'card', card: card.id, scope: option.scope, event: option.event, mode: option.mode };
}
function tupileCleanupActions(g: GameView): Action[] {
const me = g.players.find((player) => player.id === g.me);
+48 -37
View File
@@ -381,11 +381,10 @@ import {
import { canceledResponseSource, KaramaContextError } from './karama-context';
import { validateSavedTruthtrance } from './truthtrance';
import {
reorderOrderedOpportunity,
protectOrderedOpportunityLast,
validateOrderedOpportunity,
type OrderedOpportunity,
} from './ordered-opportunity';
import { reorderSaphoMovementOrder, saphoMovementOrderEvent, SaphoMovementOrderError, type SaphoMovementOrderContext } from './sapho-movement-order';
import {
matchesShipment,
parseShipmentClaim,
@@ -1523,6 +1522,8 @@ export type Game = {
event: string;
mode: 'first' | 'last';
previousActive: string;
/** New queued order retains this already-started combined turn. */
afterCurrent?: string;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
@@ -5626,6 +5627,7 @@ function saphoMovementDiscardSignature(g: Game, c: SaphoMovementDiscardContinuat
return JSON.stringify({
player: c.player, card: c.card, event: c.event, mode: c.mode,
previousActive: c.previousActive,
...(c.afterCurrent !== undefined ? { afterCurrent: c.afterCurrent } : {}),
parent: nullentropyParentSignature(g, c.resume, c.event),
turn: g.turn, phase: g.phase, advanced: g.advanced,
active: g.active, order: g.order,
@@ -6658,8 +6660,13 @@ function treacheryDiscardIntegrity(g: Game) {
new Set(g.movementRemaining).size === g.movementRemaining.length &&
g.movementRemaining.every(id => g.order.includes(id)) &&
g.movementRemaining.includes(c.previousActive) &&
(c.afterCurrent === undefined ||
typeof c.afterCurrent === 'string' && c.afterCurrent === c.previousActive &&
c.afterCurrent !== c.player && g.movementRemaining[0] === c.afterCurrent &&
saphoMovementStarted(g, getPlayer(g,c.afterCurrent)) &&
g.movementRemaining.filter(id=>id!==c.afterCurrent).every(id=>!saphoMovementStarted(g,getPlayer(g,id)))) &&
(c.mode === 'first'
? g.movementRemaining[0] === c.player &&
? g.movementRemaining[c.afterCurrent === undefined ? 0 : 1] === c.player &&
c.previousActive !== c.player && !g.saphoMovementLast
: c.mode === 'last' &&
g.movementRemaining.at(-1) === c.player &&
@@ -21811,6 +21818,8 @@ type SaphoOption = {
scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder' | 'extortion';
event: string;
mode: 'first' | 'last';
/** Display only; action submission keeps scope/event/mode. */
afterCurrent?: string;
} | { scope: 'battleAggressor'; event: string; mode: 'aggressor' };
function saphoCyclicRule<T>(quote: () => T): T {
try { return quote(); }
@@ -21953,6 +21962,24 @@ function saphoMovementQueue(g: Game): OrderedOpportunity | null {
validateOrderedOpportunity(state);
return state;
}
function saphoMovementOrderContext(g: Game): SaphoMovementOrderContext | null {
const queue=saphoMovementQueue(g);
if (!queue) return null;
const active=getPlayer(g,queue.current!);
// A prepared extra move alone is not the clean post-shipment/movement
// boundary supported by queued order. Finish that preparation first.
if (queue.currentStarted && !active.shipped && active.moved===0) return null;
const guild=g.advanced ? byFaction(g,'guild') : undefined;
return {queue,started:queue.remaining.filter(id=>saphoMovementStarted(g,getPlayer(g,id))),
guild:guild ? {player:guild.id,granted:!!g.guildTimingGranted,locked:!!g.guildTimingLocked} : null};
}
function saphoMovementRule<T>(calculate: () => T): T {
try { return calculate(); }
catch (error) {
if (error instanceof SaphoMovementOrderError) throw new RuleError(error.message);
throw error;
}
}
/** Own custody and public finite scopes only; no opponent hand scan. */
function saphoOptions(g: Game, p: Player): SaphoOption[] {
if (!saphoCleanWindow(g)) return [];
@@ -22017,24 +22044,15 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
if (remaining.at(-1) !== p.id)
options.push({ scope: 'onceAround', event: lot.event, mode: 'last' });
}
const queue = saphoMovementQueue(g);
if (
queue &&
!queue.currentStarted &&
queue.remaining.includes(p.id) &&
!g.saphoMovementLast
) {
if (
!queue.remaining.some((id) => saphoMovementStarted(g, getPlayer(g, id))) &&
!(
g.advanced && !g.guildTimingLocked && g.guildTimingGranted &&
getPlayer(g, queue.current!).faction === 'guild'
) &&
queue.current !== p.id
)
options.push({ scope: 'movement', event: queue.event, mode: 'first' });
if (queue.remaining.at(-1) !== p.id)
options.push({ scope: 'movement', event: queue.event, mode: 'last' });
const movement=saphoMovementOrderContext(g);
if (movement) for (const mode of ['first','last'] as const) {
try {
const quote=reorderSaphoMovementOrder(movement,{event:saphoMovementOrderEvent(movement),player:p.id,mode});
options.push({scope:'movement',event:quote.event,mode,
...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {})});
} catch(error) {
if (!(error instanceof SaphoMovementOrderError)) throw error;
}
}
return options;
}
@@ -22201,31 +22219,24 @@ function playSapho(g: Game, p: Player, action: Action) {
}
}
} else {
const queue = saphoMovementQueue(g)!;
const next =
option.mode === 'last'
? protectOrderedOpportunityLast(queue, {
event: option.event,
player: p.id,
})
: reorderOrderedOpportunity(queue, {
event: option.event,
player: p.id,
position: 'first',
});
g.movementRemaining = [...next.remaining];
const context=saphoMovementOrderContext(g);
requireRule(context,'The original movement order is no longer available.');
const quote=saphoMovementRule(()=>reorderSaphoMovementOrder(context,
{event:option.event,player:p.id,mode:option.mode}));
g.movementRemaining = [...quote.next.remaining];
if (option.mode === 'last')
g.saphoMovementLast = { event: option.event, turn: g.turn, player: p.id };
g.saphoMovementLast = { event: context.queue.event, turn: g.turn, player: p.id };
const used = discard(g, p, 'richese-juice-of-sapho');
log(
g,
`${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}. Completed turns and physical storm order are unchanged.${option.mode === 'first' && g.advanced && g.movementRemaining.some(id => id !== p.id && getPlayer(g, id).faction === 'guild') ? ' The Guild retains its next legal timing choice and may still take its turn before this reordered turn.' : ''}`,
`${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}.${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the current combined turn unchanged; only later unstarted turns reorder.` : ''} Completed turns and physical storm order are unchanged.${option.mode === 'first' && g.advanced && g.movementRemaining.some(id => id !== p.id && getPlayer(g, id).faction === 'guild') ? ' The Guild retains its next legal timing choice and may still take its turn before this reordered turn.' : ''}`,
{ faction: p.faction, name: 'Juice of Sapho' },
);
if (g.semutaPreview) {
const continuation: SaphoMovementDiscardContinuation = {
kind: 'saphoMovementDiscard', player: p.id, card: used.id,
event: option.event, mode: option.mode, previousActive: g.active!,
event: context.queue.event, mode: option.mode, previousActive: g.active!,
...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {}),
resume: { response: g.response, decision: g.decision,
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
stateSignature: '',
+9 -3
View File
@@ -497,13 +497,14 @@ export const RULE_TOPICS: RuleTopic[] = [
'In ordinary cyclic bidding or native Advanced normal Black Market, provisional first/last changes the current lot only, anchored at the live next bidder. Later circuits use that local cycle. Keep standing bids, allied funding and passes; a passed holder waits for a real raise, and the high bidder has no pending bid to reorder. Every purchase still requires a strict raise. Full effective-cycle no-ops cannot consume the card. The next lot uses its normal physical opener.',
'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. Once Around completed opportunities cannot repeat. A cyclic pass stays spent until a genuine raise reopens it; a finished lot cannot reopen.',
'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, provisional first ordering retains an unspent Guild’s native next-boundary take/wait choice; Guild may still act ahead. An already granted current Guild turn cannot be displaced, and a Karama-locked privilege stays locked. Last stays after the Guild even if the Guild later chooses to wait.',
'At a clean boundary after another player has begun shipment and movement, an unstarted holder may provisionally reorder only later unstarted turns. First means first after that current actor finishes; last remains after Guild. The current actor keeps its entire turn and original counters/resources. This does not split your own started turn or interrupt pending card/arrival/payment preparations. Guild retains its next-boundary timing choice. The panel names the actor who must finish.',
'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.',
'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.',
'During a clean native Extortion payer decision, use Sapho to take your optional choice first/last among unanswered non-Moritani players. Earlier declines, collected bank5 and the one-payment3 limit remain. The next payer event changes even when that player stays next. You may decline instead of paying.',
'Finish existing shipment, movement, card preparation and pending decisions before changing order. The exact Extortion payer window is a supported exception for its own order only. Your hand panel lists available choices. Reserved cards and positions you already hold cannot be selected.',
'Completed bids and turns stay completed. Storm order, committed funding and movement counters do not reset. Refreshing preserves the same remaining opportunities and any declared last position.',
'In the explicitly opted-in Semuta preview, clean Once Around and movement-order discards pause after their bidder/turn 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. Fresh late continuations also bind revealed choices and the public final revision; old continuation bytes remain unchanged. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unresolved battle continues once. Direct combined late claim/capture proof remains open.',
'Both combatants receive the same persistent final battle opportunity after original votes and native late defense, regardless of held cards or result. Finish explicitly; a Sapho priority change clears both finish marks with a fresh event. The second finish closes priority before Stone counts and outcome settlement. This shared online procedure and cyclic lot duration are provisional conventions. Other phase ordering and partly completed combined-turn intervention remain unfinished. Silent bids retain simultaneous timing and storm tie order.',
'Both combatants receive the same persistent final battle opportunity after original votes and native late defense, regardless of held cards or result. Finish explicitly; a Sapho priority change clears both finish marks with a fresh event. The second finish closes priority before Stone counts and outcome settlement. This shared online procedure, cyclic lot duration and queued movement timing are provisional conventions. Other phase ordering and splitting your own started combined turn remain unfinished. Silent bids retain simultaneous timing and storm tie order.',
],
example:
'Atreides bids 2. Before taking its bid, the Emperor uses Sapho to go last. Richese bids 3, then the Emperor must bid at least 4 to win. Atreides does not receive another bid.',
@@ -518,13 +519,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Finite Once Around, provisional current-lot cyclic ordinary/normal Black Market, clean combined-turn and remaining Battle-phase ordering, and clean late aggressor in fresh Richese battles. Public final readiness closes before physical allocation/settlement; plans, votes, original participants and committed cards remain. Local cycles preserve passes/funding and native following openers. Other timing modes remain unfinished.',
'Finite Once Around, current-lot cyclic bidding, clean combined-turn and queued unstarted-tail movement, remaining battle-choice and late aggressor modes are connected. Queued movement retains the started actor, resources/counters and native Guild choice; exact Semuta disposal preserves that same current actor. Tail timing is visibly provisional. Other phases and split-own-turn mode remain unfinished.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Owner-only first/last and clean unresolved-battle aggressor actions, public aggressor/tie priority and remaining battle order. Persistent final controls show both combatants and fresh-event readiness; a public Sapho use clears both finish marks. Cyclic controls retain current-lot duration and pending-bid limits. Older battles keep early timing.',
'Owner-only first/last and unresolved-battle aggressor controls retain public priority. Queued movement names the started actor who finishes first, explains provisional tail ordering and sends only original action fields; afterCurrent is display metadata. Final battle/cyclic controls retain their existing scope. Older battles keep early timing.',
},
{
area: 'AI',
@@ -544,6 +545,11 @@ export const RULE_TOPICS: RuleTopic[] = [
detail:
'18 October Extortion:9-file115/115, types/lint/build and six original-action/rendered-table programmes pass. Public Basic/Advanced cache1, placed/revealed Terror, paid entry1, native Mentat placement-before-bank5 and an earlier decline use120/139 source actions. First/payment3 and last/payment3 or all-decline use2–4 continuations, preserving one payment and native token retirement/rotation. Four profiles make eight legal holder/payer proposals and complete. Pure replay/no-op/recovered reuse cases are separate. One deliberate-invalid-mode test cast was repaired; all checks then passed. Prior Guild, aggressor and cyclic evidence remains in linked feature docs. No browser clicks, visual/deployed, direct combined Semuta claim/capture, complete-mode or final-assurance acceptance',
evidence: [
'game/sapho-movement-order.ts',
'tests/sapho-movement-order.test.ts',
'tests/sapho-started-movement-controls.test.tsx',
'tests/fixture-sapho-started-movement.ts',
'tests/sapho-started-movement-engine.test.ts',
'game/sapho-extortion.ts',
'tests/sapho-extortion.test.ts',
'tests/fixture-sapho-extortion.ts',
+88
View File
@@ -0,0 +1,88 @@
import {
protectOrderedOpportunityLast, reorderOrderedOpportunity, validateOrderedOpportunity,
type OrderedOpportunity,
} from './ordered-opportunity';
export type SaphoMovementOrderContext = {
queue: OrderedOpportunity;
started: readonly string[];
/** Present only for Advanced Guild timing. */
guild: { player: string; granted: boolean; locked: boolean } | null;
};
export class SaphoMovementOrderError extends Error {
constructor(message: string) {
super(message);
this.name = 'SaphoMovementOrderError';
}
}
function requireMovement(value: unknown, message: string): asserts value {
if (!value) throw new SaphoMovementOrderError(message);
}
const identifier = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
function validate(context: SaphoMovementOrderContext): OrderedOpportunity {
requireMovement(context && typeof context === 'object', 'Choose the current movement order.');
const queue = context.queue;
validateOrderedOpportunity(queue);
requireMovement(queue.current !== null && queue.remaining.length > 0,
'The combined movement turns have already finished.');
requireMovement(!queue.protectedLast, 'Juice of Sapho already protects the last turn in this phase.');
requireMovement(Array.isArray(context.started) && context.started.every(identifier) &&
new Set(context.started).size === context.started.length &&
context.started.every(player => queue.eligible.includes(player)),
'Started movement turns must name unique participants in this phase.');
const started = queue.remaining.filter(player => context.started.includes(player));
requireMovement(queue.currentStarted
? started.length === 1 && started[0] === queue.current
: started.length === 0,
'Only the current combined turn may have started among the remaining participants.');
requireMovement(context.guild === null || context.guild && typeof context.guild === 'object' &&
identifier(context.guild.player) && queue.eligible.includes(context.guild.player) &&
typeof context.guild.granted === 'boolean' && typeof context.guild.locked === 'boolean',
'Guild timing must retain its original participant and public grant state.');
return queue;
}
function eventFor(queue: OrderedOpportunity): string {
return queue.currentStarted
? `sapho-after:${encodeURIComponent(JSON.stringify([queue.event, queue.current, queue.remaining]))}`
: queue.event;
}
function wrap<T>(calculate: () => T): T {
try {
return calculate();
} catch (error) {
if (error instanceof SaphoMovementOrderError) throw error;
throw new SaphoMovementOrderError(error instanceof Error ? error.message : 'Invalid Sapho movement order.');
}
}
/** A started turn binds the whole public tail, rather than permitting a stale
* declaration to displace a different actor or newly reordered participants. */
export function saphoMovementOrderEvent(context: SaphoMovementOrderContext): string {
return wrap(() => eventFor(validate(context)));
}
export function reorderSaphoMovementOrder(context: SaphoMovementOrderContext, request: {
event: string;
player: string;
mode: 'first' | 'last';
}): { event: string; afterCurrent: string | null; next: OrderedOpportunity } {
return wrap(() => {
const queue = validate(context), event = eventFor(queue);
requireMovement(request && request.event === event, 'This Sapho movement opportunity has expired.');
requireMovement(request.mode === 'first' || request.mode === 'last', 'Choose first or last.');
requireMovement(identifier(request.player) && queue.remaining.includes(request.player),
'Only a pending participant may change its movement position.');
requireMovement(!context.started.includes(request.player),
'Juice of Sapho cannot split a combined turn that has already started.');
const grantedGuild = context.guild && context.guild.player === queue.current &&
context.guild.granted && !context.guild.locked;
requireMovement(queue.currentStarted || request.mode !== 'first' || !grantedGuild,
'The Guild has already been granted the next combined turn.');
const next = request.mode === 'last'
? protectOrderedOpportunityLast(queue, { event: queue.event, player: request.player })
: reorderOrderedOpportunity(queue, { event: queue.event, player: request.player, position: 'first' });
requireMovement(next.remaining.some((player, index) => player !== queue.remaining[index]),
'This participant is already in that remaining movement position.');
return { event, afterCurrent: queue.currentStarted ? queue.current : null, next };
});
}
+200
View File
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { nativeShipmentSources } from '../game/homeworld-options';
import {
GUILD_FIRST_SAPHO, actSaphoGuildFirst, nextSaphoGuildFirstStep,
type SaphoGuildFirstStep,
} from './fixture-sapho-guild-first';
import { createSemutaHomeworldSaphoFixture } from './fixture-semuta-homeworld-sapho';
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep } from './fixture-semuta-homeworld-choam';
export const STARTED_MOVEMENT_SAPHO = GUILD_FIRST_SAPHO;
export type SaphoStartedMovementStep = SaphoGuildFirstStep;
export const actSaphoStartedMovement = actSaphoGuildFirst;
export const nextSaphoStartedMovementStep = nextSaphoGuildFirstStep;
export const saphoStartedMovementPlayer = (game: Game, actor = 'emperor') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const saphoStartedMovementClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingShipment &&
!game.pendingKarama && !game.currentAuctionSale;
/** Count physical zones once, excluding historical ordinary-auction aliases. */
export function saphoStartedMovementInventory(game: Game) {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length); return ids;
}
export function advanceSaphoStartedMovement(game: Game, until: (state: Game) => boolean,
actions: SaphoStartedMovementStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSaphoStartedMovementStep(game); assert.ok(step, 'Complete the original owned control first');
game = actSaphoStartedMovement(game, step.actor, step.action, actions);
}
throw Error('The native Sapho programme did not reach its finite boundary');
}
/** Original public Basic or Advanced setup, actual cache1 purchase and positive
* shipment. Only unopened original deal/Spice ordering and original scalar
* lotteries are selected; no played custody, resources, phase or queue is set. */
export function createSaphoStartedMovementFixture(options: { advanced?: boolean; guildTake?: boolean } = {}) {
const advanced = options.advanced ?? false, guildTake = options.guildTake ?? false;
assert.ok(!guildTake || advanced, 'Flexible Guild TAKE is an original Advanced rule');
const owner = 'emperor', guild = 'guild', canceler = 'atreides';
let game = createGame('SAPHOSTARTEDMOVEMENT', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
for (const faction of ['atreides', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: SaphoStartedMovementStep[] = [];
const act = (actor: string, action: Action) => { game = actSaphoStartedMovement(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSaphoStartedMovement(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalInventory = saphoStartedMovementInventory(game); assert.equal(originalInventory.length, 45);
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const counter = game.deck[index].id;
[game.deck[1], game.deck[index]] = [game.deck[index], game.deck[1]];
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
assert.ok(saphoStartedMovementPlayer(game, canceler).hand.some(card => card.id === counter));
const spiceInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
for (let count = 0; count < (advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), spiceInventory);
let beforeBid: Game | undefined, bidStep: SaphoStartedMovementStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !saphoStartedMovementPlayer(game, owner).hand.some(card => card.id === STARTED_MOVEMENT_SAPHO); count++) {
const step = nextSaphoStartedMovementStep(game); assert.ok(step);
if (step.actor === owner && step.action.type === 'richeseBid' && step.action.amount === 1) {
beforeBid = structuredClone(game); bidStep = structuredClone(step); bidAt = actions.length;
}
act(step.actor, step.action);
}
assert.ok(beforeBid && bidStep && bidAt >= 0);
assert.ok(saphoStartedMovementPlayer(game, owner).hand.some(card => card.id === STARTED_MOVEMENT_SAPHO));
const acquired = structuredClone(game), acquisition = {
before: beforeBid, bid: bidStep, steps: structuredClone(actions.slice(bidAt)), after: acquired,
};
assert.equal(saphoStartedMovementPlayer(beforeBid, owner).spice - saphoStartedMovementPlayer(game, owner).spice, 1);
assert.equal(saphoStartedMovementPlayer(game, 'richese').spice - saphoStartedMovementPlayer(beforeBid, 'richese').spice, 1);
const timing: { before: Game; step: SaphoStartedMovementStep; declared: Game; after: Game }[] = [];
if (advanced) {
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
const before = structuredClone(game), step: SaphoStartedMovementStep = { actor: guild,
action: { type: 'decision', take: guildTake } };
act(step.actor, step.action); const declared = structuredClone(game); reach(saphoStartedMovementClean);
timing.push({ before, step, declared, after: structuredClone(game) });
} else reach(state => state.phase === 5 && saphoStartedMovementClean(state));
const beforeShipment = structuredClone(game), current = game.active; assert.ok(current && current !== owner);
assert.equal(current, guildTake ? guild : 'richese');
const step: SaphoStartedMovementStep = { actor: current,
action: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } };
const shipmentAt = actions.length;
act(step.actor, step.action); const declared = structuredClone(game); reach(saphoStartedMovementClean);
const shipment = { before: beforeShipment, step, declared,
steps: structuredClone(actions.slice(shipmentAt)), after: structuredClone(game) };
assert.equal(game.active, current); assert.equal(saphoStartedMovementPlayer(game, current).shipped, true);
assert.equal(saphoStartedMovementPlayer(game, current).moved, 0);
assert.equal(saphoStartedMovementPlayer(beforeShipment, current).spice - saphoStartedMovementPlayer(game, current).spice,
current === guild ? 1 : 2);
assert.equal(saphoStartedMovementPlayer(beforeShipment, current).reserves - saphoStartedMovementPlayer(game, current).reserves, 1);
assert.equal(saphoStartedMovementPlayer(game, current).forces['polar_sink:0'], 1);
assert.equal(saphoStartedMovementPlayer(game, owner).shipped, false);
assert.equal(saphoStartedMovementPlayer(game, owner).moved, 0);
assert.ok(saphoStartedMovementPlayer(game, canceler).hand.some(card => card.id === counter));
assert.deepEqual(saphoStartedMovementInventory(game), originalInventory);
const source = structuredClone(game);
return { game, source, advanced, guildTake, owner, guild, canceler, counter, karama: counter,
card: STARTED_MOVEMENT_SAPHO, current, actor: current, beforeShipment, shipment,
lobby, setup, beforeStartingDeal, afterSetup, acquired, acquisition, timing,
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual public ready/start (Advanced uses the original preview acknowledgement), original setup and paid cache1 purchase give Emperor the physical Sapho. Only an unopened original Atreides Karama deal position, one Basic/two Advanced original land Spice positions and scoped scalar lotteries are selected.',
`The original ${advanced ? guildTake ? 'Guild TAKE' : 'Guild WAIT' : 'Basic in-order turn'} is followed by an actual one-force Polar Sink shipment costing ${current === guild ? 1 : 2}. The source stops after all original shipment responses settle, before Sapho or the current actor’s ground move/end. No dealt hand, balance, force, phase, queue, timing grant or pending receipt is installed.`,
] };
}
export type SaphoStartedMovementFixture = ReturnType<typeof createSaphoStartedMovementFixture>;
/** afterCurrent is projected explanation, never an action field. */
export function saphoStartedMovementAction(game: Game, mode: 'first' | 'last', owner = 'emperor'): Action {
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === 'movement' && option.mode === mode);
assert.ok(option, `The actual waiting tail must offer Sapho ${mode}`);
return { type: 'card', card: STARTED_MOVEMENT_SAPHO, scope: option.scope, event: option.event, mode: option.mode };
}
/** LABELLED supplemental source: the borrowed canonical fixture physically
* conserves Sapho/Semuta custody rather than earning their acquisition. Its
* setup, current shipment, subsequent card disposal and continuation are real.
* No further card, resource, profile, phase or queue assignment is made here. */
export function createSaphoStartedSemutaFixture(advanced = false) {
const inherited = createSemutaHomeworldSaphoFixture('movement', 'last', advanced);
let game = structuredClone(inherited.game);
const actions: SaphoStartedMovementStep[] = [];
const beforeShipment = structuredClone(game), current = game.active; assert.ok(current && current !== inherited.owner);
const sources = nativeShipmentSources(viewGame(game, current), 1, 0); assert.ok(sources);
const step: SaphoStartedMovementStep = { actor: current, action: { type: 'ship', territory: 'polar_sink',
sector: 0, amount: 1, elite: 0, homeworldSources: sources, allyPayment: 0 } };
game = applySemutaHomeworldChoamStep(game, step.actor, step.action, actions);
game = advanceSemutaHomeworldChoam(game, saphoStartedMovementClean, actions);
assert.equal(game.active, current); assert.equal(saphoStartedMovementPlayer(game, current).shipped, true);
assert.equal(saphoStartedMovementPlayer(beforeShipment, current).spice - saphoStartedMovementPlayer(game, current).spice, 2);
const shipment = { before: beforeShipment, step, after: structuredClone(game), steps: structuredClone(actions) };
const option = viewGame(game, inherited.owner).saphoOptions.find(option => option.scope === 'movement' && option.mode === 'last');
assert.ok(option, 'The original owner changes last position in this waiting tail');
const action: Action = { type: 'card', card: inherited.card, scope: option.scope, event: option.event, mode: option.mode };
const originalInventory = saphoStartedMovementInventory(inherited.game);
assert.deepEqual(saphoStartedMovementInventory(game), originalInventory);
return { ...inherited, game, source: structuredClone(game), current, actor: current, action, event: option.event,
advanced, beforeShipment, shipment, originalInventory,
sourceActionCount: inherited.actions.length + actions.length, actions: [...inherited.actions, ...actions],
nativeAcquisition: false,
staging: [...inherited.staging,
'Inherited CONTROLLED conserved Sapho/Semuta custody is supplemental, not a native purchase claim. This new branch actually pays two spice to ship one original Homeworld force to Polar Sink before queued LAST, with no added played-state assignments.'] };
}
/** Finish each actual combined turn once. Following Emperor/Guild turns make
* real paid shipments; an already shipped current actor only completes its
* original turn. Native Guild choices/response controls remain real actions. */
export function finishSaphoStartedMovement(state: Game, fixture: Pick<SaphoStartedMovementFixture, 'owner' | 'guild'>,
options: { guildTake?: boolean; shipFollowing?: boolean } = {}, actions: SaphoStartedMovementStep[] = []) {
let game = state;
const turns: { actor: string; before: Game; shipment: SaphoStartedMovementStep | null;
afterShipment: Game; end: SaphoStartedMovementStep; afterEnd: Game }[] = [];
const timing: { before: Game; step: SaphoStartedMovementStep; declared: Game; after: Game }[] = [];
const act = (actor: string, action: Action) => { game = actSaphoStartedMovement(game, actor, action, actions); };
for (let count = 0; game.phase === 5 && count < 160; count++) {
if (!saphoStartedMovementClean(game)) {
if (!game.response && !game.phaseOpening && game.decision?.kind === 'guildTiming') {
const before = structuredClone(game), step: SaphoStartedMovementStep = { actor: fixture.guild,
action: { type: 'decision', take: options.guildTake ?? false } };
act(step.actor, step.action); const declared = structuredClone(game);
game = advanceSaphoStartedMovement(game, saphoStartedMovementClean, actions);
timing.push({ before, step, declared, after: structuredClone(game) });
} else {
const step = nextSaphoStartedMovementStep(game); assert.ok(step); act(step.actor, step.action);
}
continue;
}
const actor = game.active; assert.ok(actor && !turns.some(turn => turn.actor === actor));
const before = structuredClone(game);
const shipment: SaphoStartedMovementStep | null = options.shipFollowing !== false &&
(actor === fixture.owner || actor === fixture.guild) && !saphoStartedMovementPlayer(game, actor).shipped
? { actor, action: { type: 'ship', territory: actor === fixture.owner ? 'imperial_basin' : 'carthag',
sector: actor === fixture.owner ? 9 : 11, amount: 1, elite: 0, allyPayment: 0 } } : null;
if (shipment) {
act(shipment.actor, shipment.action); game = advanceSaphoStartedMovement(game, saphoStartedMovementClean, actions);
}
const afterShipment = structuredClone(game), end: SaphoStartedMovementStep = { actor, action: { type: 'endMovement' } };
act(end.actor, end.action); turns.push({ actor, before, shipment, afterShipment, end, afterEnd: structuredClone(game) });
}
assert.notEqual(game.phase, 5, 'Every original combined turn must finish finitely');
return { game, turns, timing, actions };
}
+42 -4
View File
@@ -394,7 +394,7 @@ void test('All four AI profiles use the private Sapho first option after a compl
}
});
void test('Shipment and movement commitments both prevent splitting the active combined turn', () => {
void test('after shipment or movement another holder may queue last without splitting the active combined turn', () => {
let g = movement();
g = applyAction(g, 'a', {
type: 'ship',
@@ -403,8 +403,18 @@ void test('Shipment and movement commitments both prevent splitting the active c
amount: 1,
});
assert.equal(g.players[0].shipped, true);
assert.deepEqual(viewGame(g, 'e').saphoOptions, []);
reject(g, 'e', action(g, 'last'));
const shipped = reload(g).players[0];
const shipmentOption = viewGame(g, 'e').saphoOptions.find(option => option.scope === 'movement' && option.mode === 'last');
assert.ok(shipmentOption && shipmentOption.scope === 'movement');
assert.equal(shipmentOption.afterCurrent, 'a');
g = applyAction(reload(g), 'e', { type: 'card', card,
scope: shipmentOption.scope, mode: shipmentOption.mode, event: shipmentOption.event });
assert.equal(g.active, 'a');
assert.deepEqual(g.players[0], shipped);
assert.deepEqual(g.movementRemaining, ['a', 'g', 'e']);
assert.equal(g.players[1].hand.some(c => c.id === card), false);
assert.equal(g.discard.filter(c => c.id === card).length, 1);
let moved = movement();
moved.players[0].forces = { 'arrakeen:10': 1 };
moved.players[0].reserves = 19;
@@ -418,6 +428,17 @@ void test('Shipment and movement commitments both prevent splitting the active c
assert.equal(moved.players[0].moved, 1);
reject(moved, 'e', action(moved, 'last'));
reject(moved, 'e', action(moved, 'first'));
const active = reload(moved).players[0];
const movementOption = viewGame(moved, 'e').saphoOptions.find(option => option.scope === 'movement' && option.mode === 'last');
assert.ok(movementOption && movementOption.scope === 'movement');
assert.equal(movementOption.afterCurrent, 'a');
moved = applyAction(reload(moved), 'e', { type: 'card', card,
scope: movementOption.scope, mode: movementOption.mode, event: movementOption.event });
assert.equal(moved.active, 'a');
assert.deepEqual(moved.players[0], active);
assert.deepEqual(moved.movementRemaining, ['a', 'g', 'e']);
assert.equal(moved.players[1].hand.some(c => c.id === card), false);
assert.equal(moved.discard.filter(c => c.id === card).length, 1);
});
void test('Completed holder and already-first/already-last movement no-ops cannot consume Sapho', () => {
@@ -524,7 +545,7 @@ void test('early Guild timing makes the previous penultimate faction actually la
assert.deepEqual(acted, ['g', 'a', 'e']);
});
void test('a granted early Guild turn becomes ineligible for Sapho interruption after shipment starts', () => {
void test('after Guild shipment another holder may queue last while Guild cannot split its own started turn', () => {
for (const holder of ['a', 'g']) {
let g = movement(holder, true);
g = applyAction(g, 'g', { type: 'decision', take: true });
@@ -538,11 +559,28 @@ void test('a granted early Guild turn becomes ineligible for Sapho interruption
g = applyAction(g, 'g', { type: 'decision', allow: true });
assert.equal(g.players[2].shipped, true);
assert.equal(g.active, 'g');
assert.deepEqual(viewGame(g, holder).saphoOptions, []);
reject(g, holder, action(g, 'last'));
assert.ok(
g.players.find((p) => p.id === holder)!.hand.some((c) => c.id === card),
);
const options = viewGame(g, holder).saphoOptions;
if (holder === 'g') {
assert.deepEqual(options, []);
continue;
}
const option = options.find(option => option.scope === 'movement' && option.mode === 'last');
assert.ok(option && option.scope === 'movement');
assert.equal(option.afterCurrent, 'g');
const before = reload(g);
g = applyAction(reload(g), holder, { type: 'card', card,
scope: option.scope, mode: option.mode, event: option.event });
assert.equal(g.active, 'g');
assert.deepEqual(g.players[2], before.players[2]);
assert.equal(g.guildTimingGranted, before.guildTimingGranted);
assert.equal(g.guildTimingLocked, before.guildTimingLocked);
assert.deepEqual(g.movementRemaining, ['g', 'e', 'a']);
assert.equal(g.players[0].hand.some(c => c.id === card), false);
assert.equal(g.discard.filter(c => c.id === card).length, 1);
}
});
+4 -1
View File
@@ -3,7 +3,7 @@ import { test } from 'node:test';
import { viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import {
actSaphoCyclic, completeSaphoCyclicAuction, createSaphoCyclicAuctionFixture, saphoCyclicAction,
actSaphoCyclic, advanceSaphoCyclic, completeSaphoCyclicAuction, createSaphoCyclicAuctionFixture, saphoCyclicAction,
saphoCyclicBid, saphoCyclicLot, saphoCyclicPlayer as player, type SaphoCyclicAuctionFixture,
} from './fixture-sapho-cyclic-auction';
@@ -144,6 +144,9 @@ void test('genuine pending passes survive Sapho and cannot receive a repeat turn
assert.equal(saphoCyclicLot(game).active,f.owner);
const before = saphoCyclicLot(game);
game = actSaphoCyclic(game,f.owner,saphoCyclicAction(game,'last'));
// Fresh Basic Richese also offers its original public used-card response.
// Finish that real boundary before another bidder acts; do not skip it.
game = advanceSaphoCyclic(game,state=>!state.pendingTreacheryDiscard);
assert.deepEqual(saphoCyclicLot(game).passed,before.passed);
const nextPass = saphoCyclicBid(game,null);
game = actSaphoCyclic(game,nextPass.actor,nextPass.action);
+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { protectOrderedOpportunityLast, reorderOrderedOpportunity } from '../game/ordered-opportunity';
import {
reorderSaphoMovementOrder, saphoMovementOrderEvent, SaphoMovementOrderError,
type SaphoMovementOrderContext,
} from '../game/sapho-movement-order';
function context(started = false): SaphoMovementOrderContext {
return {
queue: {
event: 'movement:3', eligible: ['done', 'active', 'later', 'holder', 'guild'],
remaining: ['active', 'later', 'holder', 'guild'], completed: ['done'],
current: 'active', currentStarted: started, protectedLast: null,
},
started: started ? ['done', 'active'] : ['done'],
guild: { player: 'guild', granted: false, locked: false },
};
}
function reorder(input: SaphoMovementOrderContext, player: string, mode: 'first' | 'last') {
return reorderSaphoMovementOrder(input, { event: saphoMovementOrderEvent(input), player, mode });
}
function reject(input: SaphoMovementOrderContext, player: string, mode: 'first' | 'last', event?: string) {
const before = structuredClone(input);
assert.throws(() => reorderSaphoMovementOrder(input, {
event: event ?? saphoMovementOrderEvent(input), player, mode,
}), SaphoMovementOrderError);
assert.deepEqual(input, before);
}
void test('clean first and last retain the existing immutable ordered-opportunity semantics', () => {
for (const mode of ['first', 'last'] as const) {
const input = context(), before = structuredClone(input);
const quote = reorder(input, 'holder', mode);
const expected = mode === 'first'
? reorderOrderedOpportunity(input.queue, { event: input.queue.event, player: 'holder', position: 'first' })
: protectOrderedOpportunityLast(input.queue, { event: input.queue.event, player: 'holder' });
assert.equal(quote.event, input.queue.event);
assert.equal(quote.afterCurrent, null);
assert.deepEqual(quote.next, expected);
assert.deepEqual(input, before);
assert.notEqual(quote.next.remaining, input.queue.remaining);
assert.notEqual(quote.next.eligible, input.queue.eligible);
assert.notEqual(quote.next.completed, input.queue.completed);
}
const ownCleanTurn = reorder(context(), 'active', 'last');
assert.deepEqual(ownCleanTurn.next.remaining, ['later', 'holder', 'guild', 'active']);
assert.equal(ownCleanTurn.next.protectedLast, 'active');
});
void test('started-tail first preserves the active combined turn and all other relative positions', () => {
const input = context(true), before = structuredClone(input);
const quote = reorder(input, 'holder', 'first');
assert.equal(quote.afterCurrent, 'active');
assert.deepEqual(quote.next.remaining, ['active', 'holder', 'later', 'guild']);
assert.equal(quote.next.current, 'active');
assert.equal(quote.next.currentStarted, true);
assert.equal(quote.next.event, 'movement:3');
assert.notEqual(quote.event, quote.next.event);
assert.ok(quote.event.startsWith('sapho-after:'));
assert.deepEqual(input, before);
});
void test('started-tail last includes Guild while retaining the active turn and base protection event', () => {
const input = context(true), before = structuredClone(input);
const quote = reorder(input, 'holder', 'last');
assert.deepEqual(quote.next.remaining, ['active', 'later', 'guild', 'holder']);
assert.equal(quote.next.current, 'active');
assert.equal(quote.afterCurrent, 'active');
assert.equal(quote.next.protectedLast, 'holder');
assert.equal(quote.next.event, input.queue.event);
assert.deepEqual(input, before);
assert.throws(() => saphoMovementOrderEvent({ ...input, queue: quote.next }), SaphoMovementOrderError);
});
void test('an already started actor cannot use either mode to split its combined turn', () => {
reject(context(true), 'active', 'first');
reject(context(true), 'active', 'last');
const anotherStarted = context(true);
anotherStarted.started = ['active', 'holder'];
reject(anotherStarted, 'later', 'last', 'movement:3');
const inconsistentCurrent = context();
inconsistentCurrent.started = ['active'];
reject(inconsistentCurrent, 'holder', 'first', 'movement:3');
const missingCurrent = context(true);
missingCurrent.started = ['done'];
reject(missingCurrent, 'holder', 'first', 'movement:3');
});
void test('granted Guild priority blocks clean displacement but a started Guild turn stays first during tail reorder', () => {
const input = context();
input.queue = { ...input.queue, remaining: ['guild', 'later', 'holder', 'active'], current: 'guild' };
input.guild = { player: 'guild', granted: true, locked: false };
reject(input, 'holder', 'first');
assert.deepEqual(reorder(input, 'holder', 'last').next.remaining, ['guild', 'later', 'active', 'holder']);
input.queue = { ...input.queue, currentStarted: true };
input.started = ['done', 'guild'];
const before = structuredClone(input), quote = reorder(input, 'holder', 'first');
assert.deepEqual(quote.next.remaining, ['guild', 'holder', 'later', 'active']);
assert.equal(quote.next.current, 'guild');
assert.equal(quote.afterCurrent, 'guild');
assert.deepEqual(input, before);
});
void test('ordinary Guild timing flags and Basic null timing retain their own later native opportunities', () => {
const input = context(), before = structuredClone(input);
assert.deepEqual(reorder(input, 'holder', 'first').next.remaining, ['holder', 'active', 'later', 'guild']);
assert.deepEqual(input.guild, { player: 'guild', granted: false, locked: false });
assert.deepEqual(input, before);
const basic = { ...context(), guild: null };
assert.deepEqual(reorder(basic, 'holder', 'last').next.remaining, ['active', 'later', 'guild', 'holder']);
const locked = context();
locked.queue = { ...locked.queue, remaining: ['guild', 'later', 'holder', 'active'], current: 'guild' };
locked.guild = { player: 'guild', granted: true, locked: true };
assert.equal(reorder(locked, 'holder', 'first').next.current, 'holder');
});
void test('started event binds the base phase, active actor and complete public tail with encoded identities', () => {
const input = context(true), event = saphoMovementOrderEvent(input);
assert.equal(saphoMovementOrderEvent(structuredClone(input)), event);
assert.equal(saphoMovementOrderEvent({ ...input, started: ['active'] }), event);
reject(input, 'holder', 'first', input.queue.event);
const reordered = context(true);
reordered.queue = { ...reordered.queue, remaining: ['active', 'later', 'guild', 'holder'] };
reject(reordered, 'holder', 'first', event);
const nextPhase = context(true);
nextPhase.queue = { ...nextPhase.queue, event: 'movement:4' };
reject(nextPhase, 'holder', 'first', event);
const differentCurrent = context(true);
differentCurrent.queue = { ...differentCurrent.queue, remaining: ['later', 'active', 'holder', 'guild'], current: 'later' };
differentCurrent.started = ['later'];
reject(differentCurrent, 'holder', 'last', event);
const encoded = context(true);
const rename = (id: string) => id === 'active' ? 'actor:/"[]' : id;
encoded.queue = { ...encoded.queue, eligible: encoded.queue.eligible.map(rename),
remaining: encoded.queue.remaining.map(rename), current: rename('active') };
encoded.started = [rename('active')];
assert.notEqual(saphoMovementOrderEvent(encoded), event);
assert.ok(saphoMovementOrderEvent(encoded).includes('%22'));
});
void test('no-op, completed, foreign, protected and malformed requests cannot consume Sapho', () => {
reject(context(), 'active', 'first');
reject(context(), 'guild', 'last');
reject(context(true), 'later', 'first');
reject(context(true), 'guild', 'last');
reject(context(), 'done', 'last');
reject(context(), 'foreign', 'first');
const protectedLast = context();
protectedLast.queue = { ...protectedLast.queue, protectedLast: 'guild' };
reject(protectedLast, 'holder', 'first', 'movement:3');
const unknownStarted = context();
unknownStarted.started = ['foreign'];
reject(unknownStarted, 'holder', 'first', 'movement:3');
const duplicateStarted = context(true);
duplicateStarted.started = ['active', 'active'];
reject(duplicateStarted, 'holder', 'last', 'movement:3');
const malformed = context();
malformed.queue = { ...malformed.queue, current: 'holder' };
reject(malformed, 'holder', 'first', 'movement:3');
const finished = context();
finished.queue = { ...finished.queue, remaining: [], completed: finished.queue.eligible, current: null };
reject(finished, 'holder', 'first', 'movement:3');
});
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { richeseCards } from '../game/richese-cards';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { JuiceOfSapho, SaphoOrderButton } = await import('../components/juice-of-sapho');
const foundSapho = richeseCards().find(card => card.effect === 'juiceOfSapho');
assert.ok(foundSapho);
const sapho = foundSapho;
type Option = NonNullable<GameView['saphoOptions']>[number];
/** Controlled owner-view override for component contracts only. The native
* engine suite owns the started-turn producer, queue and physical card path. */
function projected(mode: 'first' | 'last' = 'first', queued = true, advanced = true): GameView {
const lobby = createGame('SAPHOCONTROLS', newPlayer('holder', 'Sapho holder', 'atreides'), advanced);
joinGame(lobby, newPlayer('active', 'Active Emperor', 'emperor'));
joinGame(lobby, newPlayer('guild', 'Guild', 'guild'));
const game = viewGame(lobby, 'holder');
game.status = 'playing';
game.phase = 5;
game.active = 'active';
game.players.find(player => player.id === game.me)!.hand = [{ ...sapho }];
game.saphoOptions = [{ scope: 'movement', event: 'original-movement-event', mode,
...(queued ? { afterCurrent: 'active' } : {}) }];
return game;
}
const panel = (game: GameView, busy = false) =>
renderToStaticMarkup(createElement(JuiceOfSapho, { game, busy, act() {} }));
/** Invoke the real hook-free button used by JuiceOfSapho, without replacing
* React hooks or the component's callback. */
function press(option: Option, busy = false): Action[] {
const actions: Action[] = [];
const button = SaphoOrderButton({ card: sapho!.id, option, busy,
act(action) { actions.push(action); } });
button.props.onClick();
return actions;
}
void test('queued first and last keep their button labels and persistent named-player timing guidance', () => {
for (const mode of ['first', 'last'] as const) {
const game = projected(mode);
for (let render = 0; render < 2; render++) {
const html = panel(game);
assert.match(html, new RegExp(`Take your movement turn ${mode} and discard Juice of Sapho`));
assert.match(html, /Provisional queued movement order: Active Emperor finishes the current combined shipment and movement turn unchanged/);
assert.match(html, new RegExp(`Your ${mode} position applies among later unstarted combined turns`));
assert.match(html, /Completed turns and actual shipment and movement counters stay unchanged/);
}
}
});
void test('actual queued button callbacks submit exactly the existing action fields without display metadata', () => {
for (const mode of ['first', 'last'] as const) {
const option = projected(mode).saphoOptions[0];
const expected: Action = { type: 'card', card: sapho.id, scope: 'movement', event: option.event, mode };
assert.deepEqual(press(option), [expected]);
assert.deepEqual(Object.keys(press(option)[0]).sort(), ['card', 'event', 'mode', 'scope', 'type']);
}
});
void test('busy queued controls retain their explanation, render disabled and cannot dispatch', () => {
const game = projected('last');
const html = panel(game, true);
assert.match(html, /disabled=""[^>]*>Take your movement turn last and discard Juice of Sapho/);
assert.match(html, /Active Emperor finishes the current combined shipment and movement turn unchanged/);
assert.deepEqual(press(game.saphoOptions[0], true), []);
});
void test('Advanced Guild timing remains at the next boundary and last remains after the Guild', () => {
for (const mode of ['first', 'last'] as const) {
assert.match(panel(projected(mode)), /The Advanced Guild keeps its native timing choice at the next boundary/);
assert.match(panel(projected(mode)), /A last position still follows the Guild/);
}
assert.doesNotMatch(panel(projected('first', true, false)), /The Advanced Guild keeps its native timing choice/);
});
void test('clean movement options keep their existing description and exact action payload', () => {
for (const mode of ['first', 'last'] as const) {
const game = projected(mode, false);
const html = panel(game);
assert.match(html, new RegExp(`Take the ${mode} remaining combined shipment and movement turn`));
assert.doesNotMatch(html, /Provisional queued movement order|Active Emperor finishes/);
assert.deepEqual(press(game.saphoOptions[0]), [{ type: 'card', card: sapho.id,
scope: 'movement', event: 'original-movement-event', mode }]);
}
});
void test('unavailable movement guidance covers clean and queued windows without splitting turns or preparations', () => {
const game = projected();
game.saphoOptions = [];
const html = panel(game);
assert.match(html, /clean combined-turn boundaries and queuing behind a started active turn/);
assert.match(html, /Finish pending interactions and preparations first/);
assert.match(html, /Sapho never splits an active combined shipment and movement turn/);
assert.match(html, /last remains after the Guild/);
assert.doesNotMatch(html, /Take your movement turn (first|last) and discard/);
game.players.find(player => player.id === game.me)!.hand = [];
assert.equal(panel(game), '');
});
aliases.deregister();
+211
View File
@@ -0,0 +1,211 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import {
actSaphoStartedMovement as act, advanceSaphoStartedMovement as advance,
createSaphoStartedMovementFixture as fixture, createSaphoStartedSemutaFixture,
finishSaphoStartedMovement as finish, saphoStartedMovementAction as action,
saphoStartedMovementClean as clean, saphoStartedMovementPlayer as player,
saphoStartedMovementInventory as inventory, type SaphoStartedMovementFixture,
} from './fixture-sapho-started-movement';
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
const resources = (game: Game) => ({ players: game.players.map(p => ({ id: p.id, spice: p.spice,
reserves: p.reserves, tanks: p.tanks, forces: p.forces, elites: p.elites,
shipped: p.shipped, moved: p.moved })), homeworlds: game.homeworlds,
hajr: game.hajr, karamaShipping: game.karamaShipping, ornithopter: game.ornithopter });
const effectiveQueue = (game: Game) => [game.active!, ...game.movementRemaining!.filter(id => id !== game.active)];
const queuedOption = (game: Game, owner: string, mode: 'first' | 'last') => {
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === 'movement' && option.mode === mode);
assert.ok(option); return option as typeof option & { afterCurrent?: string };
};
function reject(game: Game, actor: string, proposed: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, proposed)); assert.deepEqual(game, before);
}
function expectedQueue(game: Game, owner: string, mode: 'first' | 'last') {
const current = game.active!, tail = effectiveQueue(game).slice(1).filter(id => id !== owner);
return [current, ...(mode === 'first' ? [owner, ...tail] : [...tail, owner])];
}
function source(f: SaphoStartedMovementFixture) {
assert.equal(f.game.advanced, f.advanced); assert.equal(f.setup.advanced, f.advanced);
assert.ok(f.actions.some(step => step.action.type === 'start' && !!step.action.advancedPreview === f.advanced));
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.equal(f.beforeStartingDeal.deck[1].id, f.counter);
assert.ok(player(f.afterSetup, f.canceler).hand.some(card => card.id === f.counter));
assert.equal(f.acquisition.bid.actor, f.owner); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.owner).spice - player(f.acquired, f.owner).spice, 1);
assert.equal(player(f.acquired, 'richese').spice - player(f.acquisition.before, 'richese').spice, 1);
assert.equal(f.shipment.step.actor, f.current); assert.equal(f.shipment.step.action.amount, 1);
assert.equal(player(f.beforeShipment, f.current).shipped, false);
assert.equal(player(f.game, f.current).shipped, true); assert.equal(player(f.game, f.current).moved, 0);
assert.equal(player(f.game, f.current).forces['polar_sink:0'], 1);
assert.equal(player(f.beforeShipment, f.current).reserves - player(f.game, f.current).reserves, 1);
assert.equal(player(f.beforeShipment, f.current).spice - player(f.game, f.current).spice, f.guildTake ? 1 : 2);
assert.equal(f.game.active, f.current); assert.equal(player(f.game, f.owner).shipped, false);
assert.equal(player(f.game, f.owner).moved, 0);
assert.ok(player(f.game, f.owner).hand.some(card => card.id === f.card));
assert.ok(player(f.game, f.canceler).hand.some(card => card.id === f.counter));
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
}
function ordered(f: SaphoStartedMovementFixture, mode: 'first' | 'last') {
const option = queuedOption(f.game, f.owner, mode), proposed = action(f.game, mode), before = resources(f.game);
assert.equal(option.afterCurrent, f.current); assert.equal('afterCurrent' in proposed, false);
assert.deepEqual(Object.keys(proposed).sort(), ['card', 'event', 'mode', 'scope', 'type']);
assert.notEqual(option.event, `movement:${f.game.turn}`, 'The started-tail quote binds its current and remaining actors');
const game = act(structuredClone(f.game), f.owner, proposed);
assert.deepEqual(game.movementRemaining, expectedQueue(f.game, f.owner, mode));
assert.equal(game.active, f.current); assert.deepEqual(player(game, f.current), player(f.game, f.current));
assert.deepEqual(resources(game), before); assert.deepEqual(game.order, f.game.order);
assert.equal(game.guildTimingGranted, f.game.guildTimingGranted); assert.equal(game.guildTimingLocked, f.game.guildTimingLocked);
assert.equal(game.decision, null); assert.equal(game.response, null);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(game, f.owner).hand.some(card => card.id === f.card), false);
assert.deepEqual(inventory(game), f.originalInventory);
if (mode === 'last') assert.deepEqual(game.saphoMovementLast, { event: `movement:${game.turn}`, turn: game.turn, player: f.owner });
else assert.ok(!game.saphoMovementLast);
return game;
}
function complete(f: SaphoStartedMovementFixture, game: Game, mode: 'first' | 'last', guildTake = false) {
const result = finish(game, f, { guildTake });
assert.notEqual(result.game.phase, 5); assert.deepEqual(result.game.movementRemaining, []);
assert.deepEqual(result.game.order, f.game.order); assert.deepEqual(inventory(result.game), f.originalInventory);
assert.equal(result.turns[0].actor, f.current); assert.equal(result.turns[0].shipment, null);
assert.deepEqual(player(result.turns[0].afterShipment, f.current), player(game, f.current));
assert.equal(result.actions.filter(step => step.action.type === 'endMovement').length, result.turns.length);
assert.equal(new Set(result.turns.map(turn => turn.actor)).size, result.turns.length);
assert.deepEqual(result.turns.map(turn => turn.actor).sort(), [...effectiveQueue(f.game)].sort());
assert.equal(player(result.game, f.current).forces['polar_sink:0'], 1);
assert.equal(player(result.game, f.current).shipped, true); assert.equal(player(result.game, f.current).moved, 0);
assert.equal(result.actions.some(step => step.actor === f.current && step.action.type === 'ship'), false);
assert.equal(result.game.discard.filter(card => card.id === f.card).length, 1);
const own = result.turns.find(turn => turn.actor === f.owner)!; assert.ok(own.shipment);
assert.equal(player(own.before, f.owner).spice - player(own.afterShipment, f.owner).spice, 2);
assert.equal(player(result.game, f.owner).forces['imperial_basin:9'], 1);
if (!guildTake || f.guildTake || mode === 'last')
assert.deepEqual(result.turns.map(turn => turn.actor), expectedQueue(f.game, f.owner, mode));
if (mode === 'last') assert.equal(result.turns.at(-1)!.actor, f.owner);
return result;
}
for (const advanced of [false, true]) for (const mode of ['first', 'last'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} Sapho ${mode} waits behind a genuinely paid current turn and finishes the native queue`, () => {
const f = fixture({ advanced }); source(f);
const game = ordered(f, mode), result = complete(f, game, mode);
if (advanced) {
assert.equal(f.timing.length, 1); assert.equal(f.timing[0].step.action.take, false);
assert.ok(result.timing.every(timing => timing.step.action.take === false));
} else assert.equal(result.timing.length, 0);
});
}
void test('queued Sapho first leaves an unspent Advanced Guild its actual next-boundary TAKE before the waiting holder', () => {
const f = fixture({ advanced: true }); source(f);
const game = ordered(f, 'first'), result = complete(f, game, 'first', true);
assert.equal(result.timing[0].step.action.take, true);
assert.equal(result.timing[0].declared.response?.kind, 'guildTiming');
assert.equal(result.timing[0].after.active, f.guild);
assert.deepEqual(result.turns.map(turn => turn.actor), [f.current, f.guild, f.owner, 'atreides']);
assert.ok(result.actions.some(step => step.action.type === 'passResponse'));
const guild = result.turns.find(turn => turn.actor === f.guild)!; assert.ok(guild.shipment);
assert.equal(player(guild.before, f.guild).spice - player(guild.afterShipment, f.guild).spice, 1);
});
void test('a genuinely taken and paid Advanced Guild turn stays whole while Sapho first changes only its waiting tail', () => {
const f = fixture({ advanced: true, guildTake: true }); source(f);
assert.equal(f.current, f.guild); assert.equal(f.game.guildTimingGranted, true);
assert.equal(f.timing[0].declared.response?.kind, 'guildTiming');
const result = complete(f, ordered(f, 'first'), 'first');
assert.deepEqual(result.turns.map(turn => turn.actor), [f.guild, f.owner, 'richese', 'atreides']);
assert.equal(result.turns[0].shipment, null);
});
for (const advanced of [false, true]) {
const label = advanced ? 'Advanced' : 'Basic';
void test(`${label} queued Sapho event expires when the actual current turn ends with the physical card retained`, () => {
const f = fixture({ advanced }), proposed = action(f.game, 'first');
let game = act(structuredClone(f.game), f.current, { type: 'endMovement' });
game = advance(game, clean);
assert.notEqual(game.active, f.current);
assert.notEqual(queuedOption(game, f.owner, 'first').event, proposed.event);
reject(game, f.owner, proposed);
assert.ok(player(game, f.owner).hand.some(card => card.id === f.card));
assert.deepEqual(inventory(game), f.originalInventory);
});
void test(`${label} an actual active Sapho holder cannot split its own already paid combined turn`, () => {
const f = fixture({ advanced });
let game = advance(structuredClone(f.game), state => clean(state) && state.active === f.owner);
const proposed = action(game, 'last');
game = act(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 });
game = advance(game, clean);
assert.equal(game.active, f.owner); assert.equal(player(game, f.owner).shipped, true);
assert.equal(viewGame(game, f.owner).saphoOptions.some(option => option.scope === 'movement'), false);
reject(game, f.owner, proposed);
assert.ok(player(game, f.owner).hand.some(card => card.id === f.card));
});
}
void test('already first and last waiting positions cannot spend Sapho through no-op or projected-only action fields', () => {
const f = fixture();
let first = act(structuredClone(f.game), f.current, { type: 'endMovement' });
first = advance(first, clean); assert.equal(first.active, 'atreides');
first = act(first, 'atreides', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 });
first = advance(first, clean);
const lastOption = queuedOption(first, f.owner, 'last');
assert.equal(viewGame(first, f.owner).saphoOptions.some(option => option.scope === 'movement' && option.mode === 'first'), false);
reject(first, f.owner, { type: 'card', card: f.card, scope: 'movement', mode: 'first', event: lastOption.event });
const g = fixture({ advanced: true, guildTake: true }), firstOption = queuedOption(g.game, g.owner, 'first');
assert.equal(viewGame(g.game, g.owner).saphoOptions.some(option => option.scope === 'movement' && option.mode === 'last'), false);
reject(g.game, g.owner, { type: 'card', card: g.card, scope: 'movement', mode: 'last', event: firstOption.event });
reject(f.game, f.owner, { ...action(f.game, 'first'), afterCurrent: f.current });
assert.ok(player(f.game, f.owner).hand.some(card => card.id === f.card));
});
void test('all four minimal profiles use actual projected queued Sapho choices without sending afterCurrent', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced });
for (const difficulty of DIFFICULTIES) {
const own = viewGame(f.game, f.owner); own.players.find(p => p.id === f.owner)!.bot = difficulty;
const proposed = botActions(own).find(proposed => proposed.type === 'card' && proposed.card === f.card && proposed.scope === 'movement');
assert.ok(proposed, `${difficulty} answers the real queued opportunity`);
assert.equal('afterCurrent' in proposed, false);
const option = own.saphoOptions.find(option => option.event === proposed.event && option.mode === proposed.mode);
assert.ok(option); assert.equal((option as typeof option & { afterCurrent?: string }).afterCurrent, f.current);
const game = act(structuredClone(f.game), f.owner, proposed);
assert.equal(game.active, f.current); assert.deepEqual(resources(game), resources(f.game));
assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
}
}
});
for (const claim of [false, true]) void test(
`labelled conserved-custody Homeworld supplement: Semuta ${claim ? 'claim' : 'pass'} resumes queued LAST behind an actual paid current shipment`, () => {
const f = createSaphoStartedSemutaFixture(true), before = resources(f.game), queue = expectedQueue(f.game, f.owner, 'last');
assert.equal(f.nativeAcquisition, false); assert.ok(f.staging.some(line => line.includes('CONTROLLED')));
assert.equal(queuedOption(f.game, f.owner, 'last').afterCurrent, f.current);
const offered = act(structuredClone(f.game), f.owner, f.action);
assert.ok(offered.pendingTreacheryDiscard); assert.equal(offered.active, f.current);
assert.deepEqual(offered.movementRemaining, queue); assert.deepEqual(resources(offered), before);
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
reject(offered, f.current, { type: 'endMovement' });
const event = offered.pendingTreacheryDiscard.batch.event;
const resumed = reactSemutaHomeworldChoam(offered, f.holder, claim);
assert.ok(!resumed.pendingTreacheryDiscard); assert.equal(resumed.active, f.current);
assert.deepEqual(resumed.movementRemaining, queue); assert.deepEqual(resources(resumed), before);
assert.deepEqual(inventory(resumed), f.originalInventory);
assert.equal(player(resumed, f.holder).hand.filter(card => card.id === f.card).length, Number(claim));
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.deepEqual(resumed.saphoMovementLast, { event: `movement:${resumed.turn}`, turn: resumed.turn, player: f.owner });
reject(resumed, f.holder, { type: 'semutaCommit', event });
const actions: { actor: string; action: Action }[] = [];
const done = advanceSemutaHomeworldChoam(resumed, state => state.phase > 5 && clean(state), actions);
assert.equal(actions.some(step => step.actor === f.current && step.action.type === 'ship'), false);
assert.equal(actions.filter(step => step.actor === f.current && step.action.type === 'endMovement').length, 1);
assert.equal(player(done, f.current).forces['polar_sink:0'], 1);
assert.deepEqual(inventory(done), f.originalInventory);
},
);
+10 -2
View File
@@ -187,7 +187,7 @@ for (const advanced of [false, true]) for (const scope of scopes) for (const mod
}
for (const advanced of [false, true]) for (const mode of modes) void test(
`${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho ${mode}: actual paid current shipment closes the unstarted Movement window`, () => {
`${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho ${mode}: paid shipment expires the old unstarted quote and new tail options preserve the current turn`, () => {
const f = createSemutaHomeworldSaphoFixture('movement', mode, advanced);
const origin = 'red_chasm:7';
const actor = f.game.active!;
@@ -199,8 +199,16 @@ for (const advanced of [false, true]) for (const mode of modes) void test(
game => !game.response && !game.decision && !game.pendingTreacheryDiscard && !game.phaseOpening);
assert.equal(player(started, actor).shipped, true);
const paid = resources(started), original = stock(started);
assert.ok(!viewGame(started, f.owner).saphoOptions.some(option => option.scope === 'movement'));
const options = viewGame(started, f.owner).saphoOptions.filter(option => option.scope === 'movement');
assert.ok(options.length > 0, 'The unstarted holder can reorder later unstarted combined turns');
assert.ok(options.every(option => option.scope === 'movement' && option.afterCurrent === actor));
assert.ok(options.every(option => option.event !== f.action.event),
'Queued tail options have a new source; the original unstarted quote expires');
const queue = [...started.movementRemaining!];
assert.equal(started.active, actor);
assert.throws(() => applyAction(started, f.owner, f.action));
assert.equal(started.active, actor);
assert.deepEqual(started.movementRemaining, queue);
assert.deepEqual(resources(started), paid);
assert.deepEqual(stock(started), original);
assert.equal(player(started, f.owner).hand.some(card => card.id === f.card), true);