diff --git a/components/game-table.tsx b/components/game-table.tsx
index f6f96474..b4206ab5 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -2217,6 +2217,16 @@ export function GameTable({
When Richese is seated, structured bid, alliance, Revival and bribe commitments are unavailable,
and some Kull reactions may be unavailable in complex response windows.
}
+ {g.lobbyModules?.kullDefault &&
+ {g.lobbyModules.kullPreview
+ ? 'Printed Kull is enabled by default for this supported Basic preview. The host can turn it off to retain the broader non-Kull Truthtrance options.'
+ : 'The host has turned off the printed Kull preview. Broader non-Kull Truthtrance options remain available.'}
+
}
+ {!g.lobbyModules?.kullDefault && !g.lobbyModules?.kullPreview &&
+ g.expansions.includes('choam') && g.expansions.includes('ix') &&
+ g.players.some(player => player.faction === 'choam') &&
+ Printed Kull is inactive in this lobby. Its preview requires the supported Basic configuration and may need different module selections.
+
}
{option.scope === 'battleAggressor' ? 'Become aggressor and discard Juice of Sapho' :
+ option.scope === 'movement' && option.resumeOwn ? 'Resume your movement last and discard Juice of Sapho' :
`${bidding ? 'Bid' : option.scope === 'battleOrder' ? 'Choose battles' : option.scope === 'extortion' ? 'Take your Extortion choice' : option.scope === 'revival' ? 'Take your Revival turn' : option.scope === 'charity' ? 'Take your Charity turn' : 'Take your movement turn'} ${option.mode} and discard Juice of Sapho`}
);
@@ -113,6 +114,7 @@ export function JuiceOfSapho({
const extortion = option.scope === 'extortion';
const revival = option.scope === 'revival';
const charity = option.scope === 'charity';
+ const resumeOwn = option.scope === 'movement' && option.resumeOwn;
const afterCurrent = (option.scope === 'movement' || revival || charity) && option.afterCurrent;
const currentPlayer = afterCurrent
? game.players.find(player => player.id === afterCurrent)?.name ?? 'The active player'
@@ -151,7 +153,9 @@ export function JuiceOfSapho({
))}
- {aggressor
+ {resumeOwn
+ ? 'Pause your started turn and resume it last, after the other remaining players. Shipment and movement already used stay spent; no extra action is granted. This Basic timing is provisional.'
+ : 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.'
: extortion
? `Take your Extortion choice ${option.mode} among unanswered players. Earlier declines and Moritani’s five-spice bank collection remain. You may decline instead of paying; the first actual three-spice payment ends all offers.`
diff --git a/docs/CHOAM_KULL_DESIGN.md b/docs/CHOAM_KULL_DESIGN.md
index 415b240c..9a59e03d 100644
--- a/docs/CHOAM_KULL_DESIGN.md
+++ b/docs/CHOAM_KULL_DESIGN.md
@@ -1,5 +1,19 @@
# Kull Wahad: source limits and Karama interruption design
+## Fresh Basic lobby default — 27 October 2026
+
+**Prototyped / Partial.** New lobbies automatically select the existing
+printed-Kull preview when public configuration meets its supported Basic
+CHOAM/Ix profile. The host can explicitly turn it off and restore the non-Kull
+question options. The visible lobby notice retains the incomplete preview and
+overbid limits; readiness resets when the choice changes. Existing saved games
+and lobbies lack the new default marker and retain their former selection. An
+unsupported configuration is not automatically enabled. The prior descriptions
+below of a default-off fresh lobby are superseded for new eligible lobbies.
+The publisher's printed Kull ability is cited in the sections below; enabling
+its existing provisional reaction does not certify complete CHOAM rules.
+[Genuine-start evidence](../tests/kull-default-start-runtime.test.ts).
+
## Basic Kull and native Truthtrance questions — 27 October 2026
**Prototyped / Partial.** The existing opt-in Basic printed-Kull profile
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 539e4ed2..516168ed 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -20,6 +20,16 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
+**Basic Kull default and Sapho own-turn last — 27 October. Prototyped / Partial.**
+New eligible Basic CHOAM/Ix lobbies select the printed Kull preview by default;
+hosts can opt out, and existing lobbies retain their previous selection. At a
+clean Basic boundary, a Sapho holder may provisionally suspend its own started
+movement turn and resume last without replaying spent shipment or movement.
+Richese/whole-turn Kull limits, Advanced Sapho suspension, pending/shared
+preparations, wider interactions and full rules/deployed acceptance remain open.
+[Kull](CHOAM_KULL_DESIGN.md#fresh-basic-lobby-default--27-october-2026);
+[Sapho](JUICE_OF_SAPHO_SOURCE_UPDATE.md#basic-own-turn-last--27-october-2026).
+
**Basic Kull and Truthtrance bid, Revival and alliance — 27 October. Prototyped / Partial.**
The existing Basic printed-Kull opt-in without Richese admits supported
current-lot bid, Revival and ordinary Nexus alliance questions through their
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index be7e553b..a09fd01b 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,15 @@
# Dune implementation status
+## 27 October 2026 — Basic Kull default and Sapho own-turn last
+
+New eligible Basic CHOAM/Ix lobbies select the existing Kull preview by default;
+hosts can turn it off, and existing saved lobbies keep their previous choice.
+At a clean Basic boundary, a Sapho holder can provisionally pause its own started
+movement turn and resume last without replaying spent actions. Advanced,
+pending/shared preparations, wider combinations and final acceptance remain
+open. [Kull](CHOAM_KULL_DESIGN.md#fresh-basic-lobby-default--27-october-2026);
+[Sapho](JUICE_OF_SAPHO_SOURCE_UPDATE.md#basic-own-turn-last--27-october-2026).
+
## 27 October 2026 — Basic Kull with native Truthtrance questions
The supported non-Richese Basic Kull opt-in admits current-lot bidding,
diff --git a/docs/JUICE_OF_SAPHO_SOURCE_UPDATE.md b/docs/JUICE_OF_SAPHO_SOURCE_UPDATE.md
index 849545d2..82834657 100644
--- a/docs/JUICE_OF_SAPHO_SOURCE_UPDATE.md
+++ b/docs/JUICE_OF_SAPHO_SOURCE_UPDATE.md
@@ -1,5 +1,19 @@
# Juice of Sapho: fresh primary-source and ordering review
+## Basic own-turn last — 27 October 2026
+
+**Prototyped / Partial.** At a clean Basic boundary after the holder's own
+physical shipment or movement has completed, the holder may provisionally
+suspend the same remaining combined turn and resume it last. The actual spent
+shipment, movement allowance, resources and physical pieces remain committed;
+no turn is completed or replayed. Pending payment, arrival, card and shared
+movement preparations stay outside this initial path. Basic Semuta resolves
+the Sapho discard before the next actor begins. Advanced own-turn suspension
+remains unavailable. This supersedes the older Basic prohibition below; the
+printed face allows last, but the sources cited here do not resolve a partially
+started turn, so the suspension protocol is an implementation interpretation.
+[Real-start evidence](../tests/sapho-self-last-runtime.test.ts).
+
Reviewed 7 September 2026. Read-only task; no project runtime changes. This supplements `docs/JUICE_OF_SAPHO_ENGINE_AUDIT.md`; it does not convert its unresolved interpretations into official rulings.
## Evidence checked
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 75037ce4..3f7fb4c4 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,13 @@
# Rule decision index
+## Juice of Sapho and an already-started Basic turn — 27 October 2026
+
+Provisionally permit the holder to pause its own combined shipment and movement
+turn at a clean Basic boundary and resume that same turn last. Committed actions
+and physical state stay spent; pending or shared preparations are not moved.
+The card supports going last, but the cited source does not specify suspension
+of a partly started turn. [Scope](JUICE_OF_SAPHO_SOURCE_UPDATE.md#basic-own-turn-last--27-october-2026).
+
## Truthtrance and ground-movement sector — 27 October 2026
A sector-qualified current-turn question counts a real physical ground group
diff --git a/game/engine.ts b/game/engine.ts
index 9c9a5261..7ba2e477 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,4 +1,5 @@
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
+import { kullDefaultEligible } from './kull-default-profile';
import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order';
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises,
@@ -2685,6 +2686,8 @@ export type Game = {
ecazTreachery?: true;
/** Lobby-only module selections; consumed by the genuine setup at start. */
lobbyOptions?: LobbyOptions;
+ /** New-lobby policy marker; absent on pre-existing lobbies. */
+ kullDefaultPolicy?: true;
/** Public, turn-scoped receipt for the discarded Recruits card. */
recruits?: {
turn: number;
@@ -5604,6 +5607,7 @@ export function createGame(
version: 0,
host: host.id,
status: 'lobby',
+ kullDefaultPolicy: true,
advanced,
expansions: [...new Set(expansions)],
players: [host],
@@ -10085,14 +10089,22 @@ function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) {
return lostCyborgs;
}
/** Lobby-only selections whose saved setup shape is created at start. */
-type LobbyOptions = { leaderSkills?: true; ecazTreachery?: true; kullPreview?: true };
+type LobbyOptions = { leaderSkills?: true; ecazTreachery?: true; kullPreview?: true; kullOptOut?: true };
function lobbyOptionsIntegrity(g: Game) {
+ requireRule(g.kullDefaultPolicy === undefined || g.kullDefaultPolicy === true, 'Invalid Kull lobby policy.');
const options = g.lobbyOptions;
if (options === undefined) return;
requireRule(g.status === 'lobby' && !!options && typeof options === 'object' && !Array.isArray(options) &&
- Object.entries(options).every(([key, value]) => ['leaderSkills', 'ecazTreachery', 'kullPreview'].includes(key) && value === true),
+ Object.entries(options).every(([key, value]) => ['leaderSkills', 'ecazTreachery', 'kullPreview', 'kullOptOut'].includes(key) && value === true) &&
+ (!options.kullOptOut || !!g.kullDefaultPolicy && !options.kullPreview),
'Invalid lobby module options.');
}
+function lobbyKullDefault(g: Game): boolean {
+ return !!g.kullDefaultPolicy && kullDefaultEligible(g);
+}
+function lobbyKullEnabled(g: Game): boolean {
+ return !!g.lobbyOptions?.kullPreview || lobbyKullDefault(g) && !g.lobbyOptions?.kullOptOut;
+}
function lobbyModulesSelected(g: Game): boolean {
return !!g.lobbyOptions?.leaderSkills || !!g.lobbyOptions?.ecazTreachery || !!g.lobbyOptions?.kullPreview ||
!!g.nexusCards || g.discoveryEnabled === true || !!g.homeworlds;
@@ -10102,7 +10114,7 @@ function startModules(state: Game): Game {
const options = state.lobbyOptions ?? {};
const g = structuredClone(state);
delete g.lobbyOptions;
- if (options.kullPreview) {
+ if (lobbyKullEnabled(state)) {
requireRule(g.advanced === false && (g.expansions.length === 2 ||
g.expansions.length === 3 && g.expansions.includes('ecaz')) && g.expansions.includes('choam') &&
g.expansions.includes('ix') && !!byFaction(g,'choam') &&
@@ -10196,7 +10208,7 @@ function requireFreshBaseRuntime(g: Game) {
// unowned optional components are checked separately; gameplay fields stay fresh.
const configuration = new Set(['code', 'version', 'host', 'players', 'playerPositions',
'advanced', 'expansions', 'log', 'botsPending', 'botNextActionAt', 'techTokens', 'strongholdCards',
- 'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery']);
+ 'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery', 'lobbyOptions', 'kullDefaultPolicy']);
const keys = [...new Set([...Object.keys(g), ...Object.keys(fresh)])] as (keyof Game)[];
requireRule(keys.every(key => configuration.has(key) ||
JSON.stringify(g[key]) === JSON.stringify(fresh[key])),
@@ -11079,6 +11091,9 @@ function finishMandatorySkillVisibility(g: Game) {
}
function initializeSetup(g: Game, strongholds = !!g.strongholdCards) {
+ // Lobby defaults are selections for a new setup, never active game state.
+ delete g.lobbyOptions;
+ delete g.kullDefaultPolicy;
const ecaz = byFaction(g, 'ecaz');
if (ecaz && g.advanced) g.ecazLoyalty = { player: ecaz.id, card: null };
if (g.discoveryEnabled) g.discoveries = createDiscoveryState(random);
@@ -26803,6 +26818,7 @@ type SaphoOption = {
mode: 'first' | 'last';
/** Display only; action submission keeps scope/event/mode. */
afterCurrent?: string;
+ resumeOwn?: true;
} | { scope: 'battleAggressor'; event: string; mode: 'aggressor' };
function saphoCyclicRule(quote: () => T): T {
try { return quote(); }
@@ -27020,7 +27036,14 @@ function saphoMovementOrderContext(g: Game): SaphoMovementOrderContext | null {
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};
+ guild:guild ? {player:guild.id,granted:!!g.guildTimingGranted,locked:!!g.guildTimingLocked} : null,
+ // These shared preparations are not yet owned/resumed across another
+ // player's turn. Only a clean Basic physical shipment/move may suspend.
+ allowStartedLast: !g.advanced && !g.karamaShipping && !g.ornithopter && !g.hajr.includes(active.id) &&
+ !(active.faction === 'choam' && g.choamMovement?.turn === g.turn) &&
+ !g.pendingTreacheryDiscard && !g.pendingKull && !g.pendingHomeworldShipment && !g.pendingGuildTransport &&
+ !g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length && !g.pendingAdvisorMatching &&
+ !g.pendingAutomaticAdvisorFlips?.length};
}
function saphoMovementRule(calculate: () => T): T {
try { return calculate(); }
@@ -27123,7 +27146,8 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
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} : {})});
+ ...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {}),
+ ...(quote.suspended ? {resumeOwn:true as const} : {})});
} catch(error) {
if (!(error instanceof SaphoMovementOrderError)) throw error;
}
@@ -27344,7 +27368,7 @@ function playSapho(g: Game, p: Player, action: Action) {
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' : ''}.${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.' : ''}`,
+ `${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.suspended ? ' Their started turn pauses provisionally; spent shipment and movement stay spent until it resumes.' : ''}${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) {
@@ -42699,7 +42723,13 @@ function applyActionInner(
if (action.enabled) g.discoveryEnabled = true; else delete g.discoveryEnabled;
} else {
const options: LobbyOptions = { ...g.lobbyOptions };
- if (action.enabled) options[option as keyof LobbyOptions] = true;
+ if (option === 'kullPreview') {
+ if (action.enabled) {
+ if (lobbyKullDefault(g)) delete options.kullPreview; else options.kullPreview = true;
+ delete options.kullOptOut;
+ }
+ else { delete options.kullPreview; if (g.kullDefaultPolicy) options.kullOptOut = true; }
+ } else if (action.enabled) options[option as keyof LobbyOptions] = true;
else delete options[option as keyof LobbyOptions];
if (Object.keys(options).length) g.lobbyOptions = options; else delete g.lobbyOptions;
}
@@ -45016,7 +45046,7 @@ export function viewGame(state: Game, id: string, now?: number) {
techTokens: g.techTokens ?? null,
lobbyModules: g.status === 'lobby' ? {
leaderSkills: !!g.lobbyOptions?.leaderSkills, ecazTreachery: !!g.lobbyOptions?.ecazTreachery,
- kullPreview: !!g.lobbyOptions?.kullPreview,
+ kullPreview: lobbyKullEnabled(g), kullDefault: lobbyKullDefault(g),
nexusCards: !!g.nexusCards, discoveries: g.discoveryEnabled === true,
} : null,
ecazLoyalty: g.ecazLoyalty?.card ? { player: g.ecazLoyalty.player, card: g.ecazLoyalty.card } : null,
diff --git a/game/kull-default-profile.ts b/game/kull-default-profile.ts
new file mode 100644
index 00000000..eaf7de0d
--- /dev/null
+++ b/game/kull-default-profile.ts
@@ -0,0 +1,61 @@
+/** Public lobby facts only. This controls fresh Basic setup and never examines
+ * a player's cards, spice or any hidden Karama/counter eligibility. */
+export type KullDefaultProfile = {
+ status: string;
+ advanced: boolean;
+ expansions: readonly string[];
+ players: readonly { faction: string }[];
+ lobbyOptions?: { leaderSkills?: unknown; ecazTreachery?: unknown } | null;
+ /** Same public selections on a projected lobby view. */
+ lobbyModules?: { leaderSkills?: unknown; ecazTreachery?: unknown; nexusCards?: unknown; discoveries?: unknown } | null;
+ leaderSkills?: unknown;
+ ecazTreachery?: unknown;
+ nexusCards?: unknown;
+ discoveryEnabled?: unknown;
+ discoveries?: unknown;
+ discoveryStash?: unknown;
+ greatMaker?: unknown;
+ homeworlds?: unknown;
+ techTokens?: unknown;
+ strongholdCards?: unknown;
+};
+export type KullDefaultEligibility = { eligible: boolean; reason: string | null };
+
+const classic = new Set(['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen']);
+const ix = new Set(['ixians', 'tleilaxu']);
+const choam = new Set(['choam', 'richese']);
+const ecaz = new Set(['ecaz', 'moritani']);
+const blocked = (reason: string): KullDefaultEligibility => ({ eligible: false, reason });
+
+/** Public profile eligibility is independent of the host's explicit opt-out.
+ * A lone CHOAM seat can see the choice while waiting for others; the native
+ * start still checks readiness, minimum seats and all fresh private inventory.
+ * This quote cannot authorize a start or inspect pending/private game state. */
+export function kullDefaultProfile(game: KullDefaultProfile): KullDefaultEligibility {
+ if (game.status !== 'lobby') return blocked('Automatic Kull applies only to a fresh lobby.');
+ if (game.advanced !== false) return blocked('Automatic Kull currently supports Basic rules only.');
+ if (!Array.isArray(game.expansions) ||
+ !(game.expansions.length === 2 || game.expansions.length === 3 && game.expansions.includes('ecaz')) ||
+ !game.expansions.includes('choam') || !game.expansions.includes('ix') ||
+ new Set(game.expansions).size !== game.expansions.length)
+ return blocked('Automatic Kull currently needs the CHOAM and Ix decks, with Ecaz optional.');
+ if (!Array.isArray(game.players) || game.players.length < 1 || game.players.length > 6 ||
+ !game.players.some(player => player?.faction === 'choam') ||
+ !game.players.every(player => !!player && typeof player.faction === 'string' &&
+ (classic.has(player.faction) || ix.has(player.faction) && game.expansions.includes('ix') ||
+ choam.has(player.faction) && game.expansions.includes('choam') ||
+ ecaz.has(player.faction) && game.expansions.includes('ecaz'))) ||
+ new Set(game.players.map(player => player.faction)).size !== game.players.length)
+ return blocked('Automatic Kull needs one to six distinct factions, including CHOAM, with their selected decks.');
+ const options = game.lobbyOptions, projected = game.lobbyModules;
+ if (options?.leaderSkills || options?.ecazTreachery || projected?.leaderSkills || projected?.ecazTreachery ||
+ projected?.nexusCards || projected?.discoveries || game.leaderSkills || game.ecazTreachery || game.nexusCards ||
+ game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
+ game.homeworlds || game.techTokens || game.strongholdCards)
+ return blocked('Automatic Kull currently excludes optional modules.');
+ return { eligible: true, reason: null };
+}
+
+export function kullDefaultEligible(game: KullDefaultProfile): boolean {
+ return kullDefaultProfile(game).eligible;
+}
diff --git a/game/reference.ts b/game/reference.ts
index 562de58e..24931713 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -72,7 +72,7 @@ export const HELP = {
summonedWorm:
'Fremen’s special Karama summons a worm in sand once per game during Spice Blow and Nexus. Resolve destruction and protection immediately, then resume the spice blow. The worm causes a Nexus at the end of that blow, followed by Fremen rides. Calling the worm does not draw or discard a spice card.',
choamWorthless:
- 'Kulon adds one territory of movement range on CHOAM’s turn. La La La prevents free force revival. Baliset blocks movement into a CHOAM-occupied territory, not shipment. Trip to Gamont returns another player’s force during Mentat. Jubba Cloak protects one territory from a moving storm. In the opt-in Kull preview, Kull Wahad intercepts a Karama attempt; only successful settlement activates the phase ban. A distinct counter may prevent it. Canceled Worthless costs remain held.',
+ 'Kulon adds one territory of movement range on CHOAM’s turn. La La La prevents free force revival. Baliset blocks movement into a CHOAM-occupied territory, not shipment. Trip to Gamont returns another player’s force during Mentat. Jubba Cloak protects one territory from a moving storm. In the selectable Kull preview, Kull Wahad intercepts a Karama attempt; only successful settlement activates the phase ban. A distinct counter may prevent it. Canceled Worthless costs remain held.',
choamCombat:
'CHOAM may reserve spice for an ally’s advanced battle support. The ally chooses the payment split when sealing. A traitor victory costs the winner no support; any revealed traitor prevents CHOAM force-payment income.',
fullPlan:
@@ -499,17 +499,17 @@ 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.',
+ '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. In Basic, the holder may also provisionally pause its own started turn and resume last, retaining already spent shipment and movement. Pending card, arrival, payment and shared movement preparations must finish first. Advanced Guild retains its next-boundary timing choice.',
'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.',
'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Basic Semuta claim/pass restores the same question queue. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions, battle and pending-parent combinations remain unfinished.',
'During a clean native Extortion payer decision, use Sapho to take your optional choice first/last among unanswered non-Moritani players. Earlier declines, collected bank5 and the one-payment3 limit remain. The next payer event changes even when that player stays next. You may decline instead of paying.',
'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. Each player still claims at most once, completed turns stay closed, and the holder cannot split its own started turn. Physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.',
'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If the current player has already begun a native return or negotiation, provisional queued timing lets that player finish unchanged; first means first among the later unstarted turns. You cannot split your own started turn, repeat a completed turn or consume the card for a position you already hold. Basic and older concurrent Revival phases have no ordered scope.',
- 'Finish existing shipment, movement, card preparation and pending decisions before changing order. The exact Extortion payer window is a supported exception for its own order only. Your hand panel lists available choices. Reserved cards and positions you already hold cannot be selected.',
+ 'Finish pending shipment, movement and card preparations and decisions before changing order. The exact Extortion payer window is a supported exception for its own order only. Your hand panel lists available choices. Reserved cards and positions you already hold cannot be selected.',
'Completed bids and turns stay completed. Storm order, committed funding and movement counters do not reset. Refreshing preserves the same remaining opportunities and any declared last position.',
'In the explicitly opted-in Semuta preview, clean Once Around, movement and ordered Charity/Revival discards pause after their queues reorder; a clean between-battles chooser discard pauses after changing battle priority but before the next chooser acts; an aggressor discard pauses after changing ordinary tie priority. Charity/Revival retain their current participant, claims or requests and allowances through Semuta claim/pass. Fresh late continuations also bind revealed choices and the public final revision; old continuation bytes remain unchanged. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unresolved battle continues once. Direct combined late claim/capture proof remains open.',
- '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.',
+ '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, queued movement and the Basic own-turn pause are provisional conventions. Other phase ordering and Advanced own-turn suspension 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.',
@@ -2998,7 +2998,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Advanced Bene Gesserit may spend Worthless cards as Karama. Each opens a separate native cancellation response and stays spent if the power is denied. In the Semuta preview, its physical cost offers recovery before that response; the card remains Worthless for its new faction. A later printed cancellation cost is a separate fresh event whose recovery does not undo denial. Allowed converted prices retain the original owner/beneficiary and claimed-cost refund boundary. Normal printed Karama has no BG conversion response.',
'A pending Worthless purchase keeps space for its original auction card. Gifts and hand exchanges must leave enough room for every committed incoming card. Refreshing restores the same auction, unused shipment opportunity or original canceled power.',
'Atreides full-plan inspection, Emperor revival, Fremen’s summoned worm, Harkonnen hand exchange the Guild shipment stop and Tleilaxu revival prevention are implemented as once-per-game uses. Other cancellations and special Karamas are still being implemented.',
- 'In the opt-in Kull development preview, another player’s legal ordinary Karama or supported special activation pauses before its costs for CHOAM’s neutral reaction. A declared Kull is pending, not yet a phase ban. Only a different eligible physical Karama can counter it; the original stays reserved. Successful Kull retains the unplayed original, including an advanced Bene Gesserit Worthless card intercepted before conversion, and prevents further Karama activations for that phase in Basic. New Advanced attempts instead restrict that specific effect; phase expiry is provisional. See Kull Wahad for the explicit preview policy and deferred winning-overbid warning.',
+ 'In the selectable Kull development preview, another player’s legal ordinary Karama or supported special activation pauses before its costs for CHOAM’s neutral reaction. A declared Kull is pending, not yet a phase ban. Only a different eligible physical Karama can counter it; the original stays reserved. Successful Kull retains the unplayed original, including an advanced Bene Gesserit Worthless card intercepted before conversion, and prevents further Karama activations for that phase in Basic. New Advanced attempts instead restrict that specific effect; phase expiry is provisional. See Kull Wahad for the preview policy and deferred winning-overbid warning.',
],
related: ['bidding', 'battle', 'advanced-combat', 'special-karama', 'choam-kull', 'nexus-choam-kull'],
},
@@ -4751,7 +4751,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
summary:
- 'Six printed effects cover range, revival, movement, storm protection, force return and Kull Wahad’s opt-in Karama reaction.',
+ 'Six printed effects cover range, revival, movement, storm protection, force return and Kull Wahad’s selectable Karama reaction.',
steps: [
'These CHOAM abilities work in basic and advanced games. Choose a printed Worthless card from the phase controls. Its name and intended effect are announced, then a Karama response occurs before discard. A canceled card remains in hand and cannot be retried for its effect during that phase.',
'On your own Shipment and Movement turn, play Kulon while a movement remains. It adds one territory to normal movement, including ordinary stronghold ornithopter access. Under the provisional Richese card policy, it does not raise the fixed one-group maximum of three, but extends both normal-range groups. It does not add a movement action or bypass storm/alliance/stronghold rules. Applying the bonus to both Hajr movements remains under audit.',
@@ -4759,7 +4759,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Whenever another faction requests a normal revival with a free portion, CHOAM receives a response decision independently of its private hand. Allow the request, or declare La La La for that requester. If the effect succeeds, the whole pending request stops without payment, force movement or quota use; a faction permitted to purchase normal revivals can submit a paid request. If the effect is canceled, the original quote resumes through any Tleilaxu and other applicable responses.',
'La La La does not prevent otherwise legal paid revivals or separate Ghola card effects. With Tleilaxu in the game, Fremen may submit a paid request within their current limit after free revival is prevented. Without Tleilaxu, Fremen’s base purchase restriction leaves no normal force return available. Emperor-funded extras remain separate. The prohibition resets when the next Revival phase begins. Card custody is checked at settlement: if CHOAM cashed in the declared card during the response, no Worthless effect occurs and the interrupted revival resumes.',
'During Shipment and Movement, use Baliset to select another player and a territory CHOAM occupies. The current implementation prevents that player from entering that territory by movement for the phase while CHOAM remains there; shipment is allowed. A declared move into a CHOAM territory pauses for CHOAM regardless of its private hand. Allow it or declare Baliset. A successful effect leaves the moving forces in place and the movement unspent; a canceled or unavailable card resumes the original move after checking its legality again.',
- 'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont have engine, private controls and AI support. Printed Kull Wahad has an opt-in Basic lobby preview for CHOAM, Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with the CHOAM and Ix decks, each seated faction’s deck and no other modules; the explicit Nexus Kull profile adds Cunning fuel choices at that same reaction, not in every Nexus game. See both Kull guides for distinct counters, pre-conversion Bene Gesserit custody and the deferred winning-overbid boundary. Exact cancellation scope, Hajr/ornithopter combinations, Baliset duration and destination scope, simultaneous prevention and later module exceptions remain under audit. Complete expansion play remains unfinished.',
+ 'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont have engine, private controls and AI support. Printed Kull Wahad has a selectable Basic preview, on by default in new eligible lobbies, for CHOAM, Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with the CHOAM and Ix decks, each seated faction’s deck and no other modules; the explicit Nexus Kull profile adds Cunning fuel choices at that same reaction, not in every Nexus game. See both Kull guides for distinct counters, pre-conversion Bene Gesserit custody and the deferred winning-overbid boundary. Exact cancellation scope, Hajr/ornithopter combinations, Baliset duration and destination scope, simultaneous prevention and later module exceptions remain under audit. Complete expansion play remains unfinished.',
],
related: [
'faction-choam',
@@ -4782,7 +4782,7 @@ export const RULE_TOPICS: RuleTopic[] = [
developmentStage: 'Prototyped',
summary: 'React before a Karama is played; a distinct counter can stop Kull before its phase restriction begins.',
steps: [
- 'In the lobby, the host can opt into this Basic development preview with CHOAM, Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions, the CHOAM and Ix decks, each seated faction’s deck, and no other optional modules. It uses printed ordinary Karama. The preview withholds some structured Truthtrance question types and defers unfunded non-CHOAM Karama-dependent bids; leave it off to retain the ordinary current game. A separate Advanced development profile supports Bene Gesserit substitutions and legal special powers. Existing games are not retrofitted, and the warning is not rules-completion approval.',
+ 'In a new supported Basic lobby with CHOAM and the CHOAM and Ix decks, printed Kull is enabled by default as a development preview. The host may turn it off to keep the broader non-Kull Truthtrance options. The supported roster includes Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with each seated faction’s deck and no other optional modules. It uses printed ordinary Karama. The preview withholds some structured questions and defers unfunded non-CHOAM Karama-dependent bids. A separate Advanced development profile supports Bene Gesserit substitutions and legal special powers. Existing lobbies and games are not retrofitted, and this warning does not certify complete rules.',
'In the supported Basic printed Kull preview without Richese, existing current-lot ordinary bid, Revival, alliance and bribe Truthtrance questions are available, including supported grouped and mixed forms. A pending Kull response holds their controls while the original transaction resolves. The existing Richese Semuta preview does not yet admit these structured commitments; phase-wide bid and combined turn-action questions and wider interactions remain incomplete.',
'Another player’s legal Karama cancellation, shipment-rate use, immediate auction purchase, winning-bid payment or supported special activation pauses before its card and other costs. CHOAM sees the attempted player and purpose and may decline or choose a legal held Kull Wahad. Each Use names its printed source explicitly. The separate Nexus Kull guide covers additional Cunning costs in its new profile only. The same public opportunity appears without Kull in hand, so waiting does not disclose hidden ownership. Invalid attempts never open this window; CHOAM does not receive a self-targeted Kull offer.',
'Decline resumes the original attempt once. Declaring Kull opens the ordinary CHOAM-effect response. Kull is still pending: its target may counter with a different eligible physical Karama before any phase ban applies. The interrupted card stays reserved and cannot serve as its own counter. Other eligible responders use their existing response controls.',
@@ -4797,7 +4797,7 @@ export const RULE_TOPICS: RuleTopic[] = [
example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held; in a new Advanced attempt BG cannot repeat that specific Karama effect this phase but may use another legal effect. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.',
related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Opt-in Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Without Richese, supported current-lot bid, Revival, alliance and bribe commitments can coexist with the preview; deferred overbid recovery, phase-wide and whole-turn answers and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Selectable Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Without Richese, supported current-lot bid, Revival, alliance and bribe commitments can coexist with the preview; deferred overbid recovery, phase-wide and whole-turn answers and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only legal held costs and decline use the Kull event; counters reuse ordinary response controls. Public viewers receive purpose/actors, not private intent payloads or hidden choices.', evidence: ['components/choam-kull.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Legal preview participation uses projected reactor choices and ordinary distinct counter controls. Full strategy and difficulty calibration remain deferred.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Pending versus active restrictions, distinct custody, decline/prevention/success, BG-before-conversion and deferred overbid limits have internal explanations. Publisher-source and full-faction acceptance remain separate.', evidence: ['docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/NEXUS_CHOAM_RULES.md'] },
diff --git a/game/sapho-movement-order.ts b/game/sapho-movement-order.ts
index 74545c8e..2761c659 100644
--- a/game/sapho-movement-order.ts
+++ b/game/sapho-movement-order.ts
@@ -8,6 +8,8 @@ export type SaphoMovementOrderContext = {
started: readonly string[];
/** Present only for Advanced Guild timing. */
guild: { player: string; granted: boolean; locked: boolean } | null;
+ /** Explicit Basic clean-boundary prototype; the engine owns all action state. */
+ allowStartedLast?: boolean;
};
export class SaphoMovementOrderError extends Error {
constructor(message: string) {
@@ -39,6 +41,8 @@ function validate(context: SaphoMovementOrderContext): OrderedOpportunity {
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.');
+ requireMovement(context.allowStartedLast === undefined || typeof context.allowStartedLast === 'boolean',
+ 'The started-turn Sapho scope must be an explicit boolean.');
return queue;
}
function eventFor(queue: OrderedOpportunity): string {
@@ -65,13 +69,28 @@ export function reorderSaphoMovementOrder(context: SaphoMovementOrderContext, re
event: string;
player: string;
mode: 'first' | 'last';
-}): { event: string; afterCurrent: string | null; next: OrderedOpportunity } {
+}): { event: string; afterCurrent: string | null; suspended?: string; 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.');
+ if (context.started.includes(request.player) && request.player === queue.current &&
+ context.allowStartedLast === true) {
+ requireMovement(context.guild === null, 'The started-turn Sapho preview supports Basic timing only.');
+ requireMovement(request.mode === 'last', 'An already-started turn can only be moved to last.');
+ requireMovement(queue.remaining.length > 1, 'This participant is already last.');
+ // Suspend this same opportunity; never synthesize a completed turn or a
+ // second opportunity. The engine retains its committed action counters.
+ const remaining = [...queue.remaining.slice(1), request.player];
+ const next: OrderedOpportunity = {
+ ...queue, eligible: [...queue.eligible], completed: [...queue.completed], remaining,
+ current: remaining[0], currentStarted: false, protectedLast: request.player,
+ };
+ validateOrderedOpportunity(next);
+ return { event, afterCurrent: null, suspended: request.player, next };
+ }
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 &&
diff --git a/tests/fixture-default-choam-kull.ts b/tests/fixture-default-choam-kull.ts
new file mode 100644
index 00000000..e0d54b5a
--- /dev/null
+++ b/tests/fixture-default-choam-kull.ts
@@ -0,0 +1,65 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
+import { actKullPurpose, advanceKullPurpose, kullPurposeClean, kullPurposePlayer, type KullPurposeStep } from './fixture-kull-purpose';
+
+export const act = actKullPurpose;
+export const advance = advanceKullPurpose;
+export const clean = kullPurposeClean;
+export const player = kullPurposePlayer;
+
+/** Treachery cards in the physical zones exercised by this native programme. */
+export function defaultKullInventory(g: Game): string[] {
+ const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
+ ...(g.auction?.cards.slice(g.auction.index) ?? []), ...(g.richeseCache ?? [])].map(card => card.id).sort();
+ assert.equal(new Set(cards).size, cards.length, 'Every Treachery card is in one physical zone');
+ return cards;
+}
+
+/** Normal public Basic CHOAM/Emperor/Guild lobby. The host really starts, then
+ * no Kull opt-in is submitted. We select only an unopened original starting deal and Spice prefix.
+ * Everything after that uses native setup, auction and movement actions. */
+export function createDefaultChoamKullFixture() {
+ let game = createGame('PUBLICKULL', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']);
+ for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
+ const actions: KullPurposeStep[] = [];
+ const send = (actor: string, action: Action) => { game = act(game,actor,action,actions); };
+ const reach = (predicate: (g: Game) => boolean) => { game = advance(game,predicate,actions); };
+ for (const p of game.players) send(p.id,{type:'ready'});
+ const lobby = structuredClone(game);
+ send(game.host,{type:'start'});
+ const setup = structuredClone(game);
+ assert.equal(game.status,'setup');
+ assert.equal(game.kullPreview,true,'A supported fresh Basic start enables printed Kull without a separate opt-in');
+ assert.ok(game.players.every(p => p.hand.length === 0));
+ const originalCards = game.deck.map(card => card.id).sort();
+ const selected: Game['deck'] = [];
+ for (const name of ['kull','karama','karama']) {
+ const i = game.deck.findIndex(card => name === 'kull' ? card.id === 'ix-kull-wahad' : card.effect === 'karama');
+ assert.ok(i >= 0); selected.push(game.deck.splice(i,1)[0]);
+ }
+ const unopenedDeal = selected.map((card,index) => ({player:game.players[index].id,card:card.id}));
+ game.deck.unshift(...selected);
+ const beforeDeal = structuredClone(game);
+ reach(g => g.status === 'playing');
+ const afterSetup = structuredClone(game);
+ const [kull,karama,counter] = selected.map(card => card.id);
+ assert.ok(player(game,'choam').hand.some(card => card.id === kull));
+ assert.ok(player(game,'emperor').hand.some(card => card.id === karama));
+ assert.ok(player(game,'guild').hand.some(card => card.id === counter));
+ assert.deepEqual(defaultKullInventory(game),originalCards);
+ const i = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(i >= 0);
+ game.spiceDeck.unshift(game.spiceDeck.splice(i,1)[0]);
+ reach(g => g.phase === 3 && !!g.auction && g.auction.active === 'emperor' && clean(g));
+ const auction = structuredClone(game);
+ assert.deepEqual(defaultKullInventory(auction),originalCards);
+ reach(g => g.phase === 5 && g.active === 'choam' && clean(g));
+ send('choam',{type:'endMovement'});
+ reach(g => g.phase === 5 && g.active === 'emperor' && clean(g));
+ assert.deepEqual(defaultKullInventory(game),originalCards);
+ return {game,lobby,setup,beforeDeal,afterSetup,auction,actions,originalCards,unopenedDeal,
+ kull,karama,counter,actor:'emperor',reactor:'choam',counterOwner:'guild',
+ staging:'No Kull opt-in is submitted. Selected only original unopened starting cards and a safe unopened Spice prefix; all setup, auction passes and turn changes use real native actions.'};
+}
+export type DefaultChoamKullFixture = ReturnType;
+export const defaultKullAttempt = (f: DefaultChoamKullFixture): Action =>
+ ({type:'card',mode:'shipment',card:f.karama,target:f.actor});
diff --git a/tests/kull-default-profile.test.ts b/tests/kull-default-profile.test.ts
new file mode 100644
index 00000000..094b270b
--- /dev/null
+++ b/tests/kull-default-profile.test.ts
@@ -0,0 +1,76 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { kullDefaultEligible, kullDefaultProfile, type KullDefaultProfile } from '../game/kull-default-profile';
+
+const lobby: KullDefaultProfile = {
+ status: 'lobby', advanced: false, expansions: ['choam', 'ix'],
+ players: [{ faction: 'choam' }, { faction: 'atreides' }],
+};
+
+void test('fresh Basic CHOAM with the supported selected decks has native Kull', () => {
+ assert.deepEqual(kullDefaultProfile(lobby), { eligible: true, reason: null });
+ assert.equal(kullDefaultEligible(lobby), true);
+ assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }] }), true,
+ 'show the choice before other seats join; the start enforces its own minimum');
+ for (const faction of ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen',
+ 'ixians', 'tleilaxu', 'richese'])
+ assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }, { faction }] }), true, faction);
+ for (const faction of ['ecaz', 'moritani']) {
+ assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }, { faction }] }), false, faction);
+ assert.equal(kullDefaultEligible({ ...lobby, expansions: ['ix', 'choam', 'ecaz'],
+ players: [{ faction: 'choam' }, { faction }] }), true, faction);
+ }
+});
+
+void test('ongoing games, Advanced rules, absent CHOAM and unsupported deck selections do not qualify', () => {
+ for (const profile of [
+ { ...lobby, status: 'playing' }, { ...lobby, status: 'setup' }, { ...lobby, advanced: true },
+ { ...lobby, players: [{ faction: 'richese' }, { faction: 'atreides' }] },
+ { ...lobby, expansions: ['choam'] }, { ...lobby, expansions: ['ix'] },
+ { ...lobby, expansions: ['choam', 'ix', 'unknown'] },
+ { ...lobby, expansions: ['choam', 'ix', 'ix'] },
+ { ...lobby, players: [{ faction: 'choam' }, { faction: 'unknown' }] },
+ { ...lobby, players: [] },
+ { ...lobby, players: [{ faction: 'choam' }, { faction: 'choam' }] },
+ { ...lobby, players: ['choam', 'atreides', 'fremen', 'guild', 'emperor', 'harkonnen', 'ixians']
+ .map(faction => ({ faction })) },
+ ]) {
+ const result = kullDefaultProfile(profile);
+ assert.equal(result.eligible, false); assert.ok(result.reason);
+ }
+});
+
+void test('each optional module blocks automatic entry from both engine and public lobby shapes', () => {
+ for (const option of [
+ { lobbyOptions: { leaderSkills: true } }, { lobbyOptions: { ecazTreachery: true } },
+ { lobbyModules: { leaderSkills: true } }, { lobbyModules: { ecazTreachery: true } },
+ { lobbyModules: { nexusCards: true } }, { lobbyModules: { discoveries: true } },
+ { leaderSkills: {} }, { ecazTreachery: true }, { nexusCards: {} }, { discoveryEnabled: true },
+ { discoveries: {} }, { discoveryStash: {} }, { greatMaker: {} },
+ { homeworlds: {} }, { techTokens: {} }, { strongholdCards: {} },
+ ]) {
+ assert.equal(kullDefaultEligible({ ...lobby, ...option }), false);
+ }
+ assert.equal(kullDefaultEligible({ ...lobby,
+ lobbyModules: { leaderSkills: false, ecazTreachery: false, nexusCards: false, discoveries: false },
+ lobbyOptions: { leaderSkills: false, ecazTreachery: false } }), true);
+});
+
+void test('public eligibility does not change when the host chooses an opt-out', () => {
+ // The profile quote only needs to understand module prerequisites, not the
+ // key representing the host's choice. The caller interprets that choice.
+ const selection = { leaderSkills: false, kullDisabled: true };
+ const selectedOn = { ...selection, kullDisabled: false };
+ assert.equal(kullDefaultEligible({ ...lobby, lobbyOptions: selection }), true);
+ assert.equal(kullDefaultEligible({ ...lobby, lobbyOptions: selectedOn }), true);
+});
+
+void test('profile reads only public faction and configuration data, never hidden hands or spice', () => {
+ const player = { faction: 'choam', get hand() { throw Error('private hand read'); },
+ get spice() { throw Error('private spice read'); } };
+ const rival = { faction: 'ixians', get hand() { throw Error('private rival hand read'); } };
+ const profile = { ...lobby, players: [player, rival] };
+ assert.equal(kullDefaultEligible(profile), true);
+ assert.deepEqual(profile.expansions, ['choam', 'ix']);
+ assert.equal(profile.status, 'lobby');
+});
diff --git a/tests/kull-default-start-runtime.test.ts b/tests/kull-default-start-runtime.test.ts
new file mode 100644
index 00000000..661274a4
--- /dev/null
+++ b/tests/kull-default-start-runtime.test.ts
@@ -0,0 +1,65 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
+import { act, advance, clean, createDefaultChoamKullFixture, defaultKullAttempt } from './fixture-default-choam-kull';
+
+function lobby(): Game {
+ const game = createGame('KULLDEFAULT', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']);
+ joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
+ return game;
+}
+function start(game: Game): Game {
+ for (const seat of game.players) game = act(game, seat.id, { type: 'ready' });
+ return act(game, game.host, { type: 'start' });
+}
+
+void test('a genuine fresh Basic lobby enables printed Kull and a real interception by default', () => {
+ const f = createDefaultChoamKullFixture();
+ const view = viewGame(f.lobby, f.reactor);
+ assert.equal(view.lobbyModules?.kullDefault, true);
+ assert.equal(view.lobbyModules?.kullPreview, true);
+ assert.equal(f.setup.kullPreview, true);
+ assert.equal(f.setup.kullDefaultPolicy, undefined);
+ assert.equal(f.setup.lobbyOptions, undefined);
+ let game = act(f.game, f.actor, defaultKullAttempt(f));
+ const reaction = viewGame(game, f.reactor).kullReaction; assert.ok(reaction);
+ assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull));
+ game = act(game, f.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
+ game = advance(game, clean);
+ assert.ok(viewGame(game, f.actor).karamaBlocked);
+ assert.equal(game.discard.filter(card => card.id === f.kull).length, 1);
+});
+
+void test('the host can opt out of the new default and then opt back in', () => {
+ let game = lobby();
+ assert.equal(viewGame(game, game.host).lobbyModules?.kullPreview, true);
+ for (const seat of game.players) game = act(game, seat.id, { type: 'ready' });
+ game = act(game, game.host, { type: 'moduleOption', module: 'kullPreview', enabled: false });
+ assert.ok(game.players.every(seat => !seat.ready));
+ assert.equal(viewGame(game, game.host).lobbyModules?.kullPreview, false);
+ assert.equal(game.lobbyOptions?.kullOptOut, true);
+ assert.equal(start(structuredClone(game)).kullPreview ?? false, false);
+ game = act(game, game.host, { type: 'moduleOption', module: 'kullPreview', enabled: true });
+ assert.equal(viewGame(game, game.host).lobbyModules?.kullPreview, true);
+ assert.equal(game.lobbyOptions?.kullOptOut, undefined);
+ assert.equal(game.lobbyOptions?.kullPreview, undefined, 'Re-enabling returns to the automatic policy');
+ const changed = act(structuredClone(game), game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
+ assert.equal(viewGame(changed, changed.host).lobbyModules?.kullDefault, false);
+ assert.equal(viewGame(changed, changed.host).lobbyModules?.kullPreview, false);
+ assert.ok(changed.players.every(seat => !seat.ready));
+ assert.equal(start(game).kullPreview, true);
+});
+
+void test('saved pre-default lobbies retain their original opt-in selection', () => {
+ const old = lobby(); delete old.kullDefaultPolicy;
+ assert.equal(viewGame(old, old.host).lobbyModules?.kullPreview, false);
+ assert.equal(start(old).kullPreview ?? false, false);
+});
+
+void test('an unsupported configuration does not silently enable the preview', () => {
+ const game = createGame('KULLUNSUPPORTED', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam']);
+ joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
+ assert.equal(viewGame(game, game.host).lobbyModules?.kullDefault, false);
+ assert.equal(viewGame(game, game.host).lobbyModules?.kullPreview, false);
+ assert.equal(start(game).kullPreview ?? false, false);
+});
diff --git a/tests/public-choam-kull-controls.test.tsx b/tests/public-choam-kull-controls.test.tsx
index 0434b6a1..6933557f 100644
--- a/tests/public-choam-kull-controls.test.tsx
+++ b/tests/public-choam-kull-controls.test.tsx
@@ -30,6 +30,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its
const f = fixture(), host = viewGame(f.lobby,f.lobby.host), html = table(host);
assert.equal(host.lobbyModules?.kullPreview,true);
assert.match(html,/Kull Wahad · Basic development preview/);
+ assert.match(html,/Printed Kull is enabled by default/);
assert.match(html,/does not support phase-wide bid or combined turn-action Truthtrance questions/);
assert.match(html,/defers unfunded non-CHOAM Karama-dependent bids/);
assert.match(html,/When Richese is seated, structured bid, alliance, Revival and bribe commitments are unavailable/);
@@ -37,6 +38,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its
const unselected = viewGame(off,off.host);
assert.equal(unselected.lobbyModules?.kullPreview,false);
assert.match(table(unselected),/Kull Wahad · Basic development preview/);
+ assert.match(table(unselected),/The host has turned off the printed Kull preview/);
assert.doesNotMatch(table(unselected),/does not support phase-wide bid or combined turn-action Truthtrance questions/);
const nonhost = table(viewGame(f.lobby,f.actor));
assert.match(nonhost,/disabled=""[^>]*type="checkbox"|type="checkbox"[^>]*disabled=""/);
diff --git a/tests/public-choam-kull-runtime.test.ts b/tests/public-choam-kull-runtime.test.ts
index 1ffd14ff..b60128c3 100644
--- a/tests/public-choam-kull-runtime.test.ts
+++ b/tests/public-choam-kull-runtime.test.ts
@@ -26,7 +26,7 @@ function declare(g: Game,kull: string) {
return act(g,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:kull});
}
-void test('Kull is a host-selected lobby preview and default Basic Truth promises retain their existing scope', () => {
+void test('Kull is a host-configurable Basic lobby preview with supported Truth promises', () => {
let lobby = createLobby();
assert.equal(lobby.lobbyOptions?.kullPreview,undefined);
assert.equal(bribePromiseModeSupported(lobby),true);
@@ -34,14 +34,15 @@ void test('Kull is a host-selected lobby preview and default Basic Truth promise
reject(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:'true'});
reject(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:true,other:true});
const baseline = act(ready(lobby),'choam',{type:'start'});
- assert.equal(baseline.kullPreview ?? false,false);
+ assert.equal(baseline.kullPreview,true);
assert.equal(bribePromiseModeSupported(baseline),true);
lobby = ready(lobby);
lobby = act(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
assert.ok(lobby.players.every(seat => !seat.ready));
- assert.equal(lobby.lobbyOptions?.kullPreview,true);
+ assert.equal(lobby.lobbyOptions?.kullPreview,undefined);
lobby = act(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:false});
assert.equal(lobby.lobbyOptions?.kullPreview,undefined);
+ assert.equal(lobby.lobbyOptions?.kullOptOut,true);
const f = fixture();
assert.equal(f.setup.kullPreview,true);
assert.equal(bribePromiseModeSupported(f.setup),true,'Basic printed Kull now supports the structured bribe family');
@@ -55,8 +56,8 @@ void test('incompatible opt-in starts reject without modifying their original lo
{advanced:false,expansions:['choam','ix'],last:'guild' as const,module:true},
]) {
let g = createLobby(config.advanced,config.expansions,config.last);
- g = act(g,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
if (config.module) g = act(g,'choam',{type:'moduleOption',module:'leaderSkills',enabled:true});
+ g = act(g,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
g = ready(g);
reject(g,'choam',config.advanced ? {type:'start',advancedPreview:true} : {type:'start'});
}
diff --git a/tests/sapho-self-last-order.test.ts b/tests/sapho-self-last-order.test.ts
new file mode 100644
index 00000000..458885a6
--- /dev/null
+++ b/tests/sapho-self-last-order.test.ts
@@ -0,0 +1,87 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ reorderSaphoMovementOrder, saphoMovementOrderEvent, SaphoMovementOrderError,
+ type SaphoMovementOrderContext,
+} from '../game/sapho-movement-order';
+
+function context(): SaphoMovementOrderContext {
+ return {
+ queue: {
+ event: 'movement:3', eligible: ['done', 'active', 'later', 'last'],
+ remaining: ['active', 'later', 'last'], completed: ['done'],
+ current: 'active', currentStarted: true, protectedLast: null,
+ },
+ started: ['done', 'active'], guild: null, allowStartedLast: true,
+ };
+}
+function request(input: SaphoMovementOrderContext, player = 'active', mode: 'first' | 'last' = 'last') {
+ return reorderSaphoMovementOrder(input, { event: saphoMovementOrderEvent(input), player, mode });
+}
+function reject(input: SaphoMovementOrderContext, player = 'active', mode: 'first' | 'last' = 'last', event?: string) {
+ const before = structuredClone(input);
+ assert.throws(() => reorderSaphoMovementOrder(input, {
+ event: event ?? saphoMovementOrderEvent(input), player, mode,
+ }), SaphoMovementOrderError);
+ assert.deepEqual(input, before);
+}
+
+void test('a Basic started actor suspends the same opportunity at a protected last position', () => {
+ const input = context(), before = structuredClone(input), event = saphoMovementOrderEvent(input);
+ const result = request(input);
+ assert.equal(result.event, event);
+ assert.equal(result.afterCurrent, null);
+ assert.equal(result.suspended, 'active');
+ assert.deepEqual(result.next, {
+ event: 'movement:3', eligible: ['done', 'active', 'later', 'last'],
+ remaining: ['later', 'last', 'active'], completed: ['done'],
+ current: 'later', currentStarted: false, protectedLast: 'active',
+ });
+ assert.deepEqual(input, before);
+ assert.notEqual(result.next.eligible, input.queue.eligible);
+ assert.notEqual(result.next.completed, input.queue.completed);
+ assert.notEqual(result.next.remaining, input.queue.remaining);
+});
+
+void test('started self-last needs explicit Basic admission and cannot create a first or duplicate turn', () => {
+ const input = context();
+ reject({ ...input, allowStartedLast: undefined });
+ reject({ ...input, allowStartedLast: false });
+ reject(input, 'active', 'first');
+ reject(input, 'done');
+ const alone = context();
+ alone.queue = { ...alone.queue, eligible: ['done', 'active'], remaining: ['active'], completed: ['done'] };
+ reject(alone);
+ const advanced = context();
+ advanced.guild = { player: 'last', granted: false, locked: false };
+ reject(advanced);
+ const proposed = request(input);
+ const already = { ...input, queue: proposed.next, started: ['done', 'active'] };
+ assert.throws(() => saphoMovementOrderEvent(already), SaphoMovementOrderError);
+});
+
+void test('a started actor is bound to its current phase, actor and whole remaining tail', () => {
+ const input = context(), event = saphoMovementOrderEvent(input);
+ assert.notEqual(event, input.queue.event);
+ const changed = context();
+ changed.queue = { ...changed.queue, remaining: ['active', 'last', 'later'] };
+ reject(changed, 'active', 'last', event);
+ const nextPhase = context(); nextPhase.queue = { ...nextPhase.queue, event: 'movement:4' };
+ reject(nextPhase, 'active', 'last', event);
+ const changedActor = context();
+ changedActor.queue = { ...changedActor.queue, remaining: ['later', 'active', 'last'], current: 'later' };
+ changedActor.started = ['done', 'later'];
+ reject(changedActor, 'later', 'last', event);
+ const invalid = context(); invalid.started = ['active', 'later'];
+ assert.throws(() => saphoMovementOrderEvent(invalid), SaphoMovementOrderError);
+});
+
+void test('explicit admission does not change another player’s existing queued-turn reorder', () => {
+ const input = context(), before = structuredClone(input);
+ const result = request(input, 'last', 'first');
+ assert.equal(result.afterCurrent, 'active');
+ assert.equal(result.suspended, undefined);
+ assert.deepEqual(result.next.remaining, ['active', 'last', 'later']);
+ assert.equal(result.next.currentStarted, true);
+ assert.deepEqual(input, before);
+});
diff --git a/tests/sapho-self-last-runtime.test.ts b/tests/sapho-self-last-runtime.test.ts
new file mode 100644
index 00000000..1154685f
--- /dev/null
+++ b/tests/sapho-self-last-runtime.test.ts
@@ -0,0 +1,155 @@
+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 { TERRITORIES } from '../game/board';
+import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
+import { holdSemutaHomeworldChoamCard, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
+import {
+ actSaphoStartedMovement as act,
+ advanceSaphoStartedMovement as advance,
+ createSaphoStartedMovementFixture as fixture,
+ saphoStartedMovementAction as saphoAction,
+ saphoStartedMovementClean as clean,
+ saphoStartedMovementInventory as inventory,
+ saphoStartedMovementPlayer as player,
+} from './fixture-sapho-started-movement';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before);
+}
+
+/** Reach Emperor's actual turn after Richese's original paid shipment and
+ * completed earlier turns. Only the inherited unopened setup/deck choices and
+ * genuine public actions are used; no post-deal player or queue is staged. */
+function source() {
+ const f = fixture();
+ let game = advance(structuredClone(f.game), state =>
+ state.phase === 5 && clean(state) && state.active === f.owner);
+ assert.equal(player(game, f.owner).shipped, false);
+ assert.equal(player(game, f.owner).moved, 0);
+ assert.ok(game.movementRemaining?.some(id => id !== f.owner));
+ const prior = saphoAction(game, 'last', f.owner);
+ const beforeShipment = structuredClone(game);
+ const declared = act(game, f.owner, {
+ type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0,
+ });
+ game = advance(declared, clean);
+ assert.equal(game.active, f.owner);
+ assert.equal(player(game, f.owner).shipped, true);
+ assert.equal(player(game, f.owner).moved, 0);
+ assert.equal(player(beforeShipment, f.owner).reserves - player(game, f.owner).reserves, 1);
+ assert.equal(player(beforeShipment, f.owner).spice - player(game, f.owner).spice, 2);
+ assert.equal(player(game, f.owner).forces['polar_sink:0'], 1);
+ assert.deepEqual(inventory(game), f.originalInventory);
+ return { f, game, prior, beforeShipment, declared };
+}
+
+void test('Basic Sapho last suspends the holder after a real paid shipment and resumes its movement once', () => {
+ const { f, game: original, prior } = source();
+ const option = viewGame(original, f.owner).saphoOptions.find(row => row.scope === 'movement' && row.mode === 'last');
+ assert.ok(option, 'The holder can put its already-started turn last after the shipment finishes');
+ const proposed: Action = { type: 'card', card: f.card, scope: option.scope, mode: option.mode, event: option.event };
+ assert.notEqual(option.event, prior.event);
+ reject(original, f.owner, prior);
+ const before = structuredClone(player(original, f.owner));
+ let game = advance(act(original, f.owner, proposed), clean);
+ assert.equal(game.active, f.guild, 'The original next Guild turn precedes the suspended owner');
+ assert.ok(game.movementRemaining?.includes(f.owner));
+ assert.equal(game.saphoMovementLast?.player, f.owner);
+ assert.equal(player(game, f.owner).spice, before.spice);
+ assert.equal(player(game, f.owner).reserves, before.reserves);
+ assert.deepEqual(player(game, f.owner).forces, before.forces);
+ assert.equal(player(game, f.owner).shipped, true);
+ assert.equal(player(game, f.owner).moved, 0);
+ assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
+ assert.deepEqual(inventory(game), f.originalInventory);
+
+ game = advance(game, state => state.phase === 5 && clean(state) && state.active === f.owner);
+ assert.equal(player(game, f.owner).shipped, true);
+ assert.equal(player(game, f.owner).moved, 0);
+ assert.equal(player(game, f.owner).forces['polar_sink:0'], 1);
+ reject(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 });
+ game = act(game, f.owner, { type: 'endMovement' });
+ assert.notEqual(game.phase, 5);
+ assert.deepEqual(inventory(game), f.originalInventory);
+});
+
+void test('Basic own-last cannot be played while the original shipment is awaiting an owned continuation', () => {
+ const { f, prior, declared } = source();
+ assert.ok(!clean(declared), 'The actual paid shipment opens an original response');
+ assert.equal(viewGame(declared, f.owner).saphoOptions.some(row => row.scope === 'movement'), false);
+ reject(declared, f.owner, prior);
+ assert.ok(player(declared, f.owner).hand.some(card => card.id === f.card));
+ assert.deepEqual(inventory(declared), f.originalInventory);
+});
+
+void test('Basic own-last preserves a completed ground movement without granting another', () => {
+ const { f, game: original } = source();
+ let moved: Game | undefined;
+ let destination: string | undefined;
+ for (const place of TERRITORIES) {
+ for (const sector of place.sectors) {
+ try {
+ moved = act(original, f.owner, { type: 'move', from: 'polar_sink:0', territory: place.id,
+ sector, amount: 1, elite: 0 });
+ destination = `${place.id}:${sector}`;
+ break;
+ } catch { /* Try another physical destination using the native validator. */ }
+ }
+ if (moved) break;
+ }
+ assert.ok(moved && destination, 'The real force has at least one legal movement');
+ let game = advance(moved, clean);
+ assert.equal(player(game, f.owner).moved, 1);
+ const action = saphoAction(game, 'last', f.owner);
+ game = advance(act(game, f.owner, action), clean);
+ game = advance(game, state => state.phase === 5 && clean(state) && state.active === f.owner);
+ assert.equal(player(game, f.owner).moved, 1);
+ assert.equal(player(game, f.owner).forces[destination], 1);
+ reject(game, f.owner, { type: 'move', from: destination, territory: 'polar_sink', sector: 0, amount: 1, elite: 0 });
+ game = act(game, f.owner, { type: 'endMovement' });
+ assert.notEqual(game.phase, 5);
+ assert.deepEqual(inventory(game), f.originalInventory);
+});
+
+void test('the minimal bot can use the actual Basic own-last opportunity', () => {
+ const { f, game } = source();
+ const view = viewGame(game, f.owner);
+ view.players.find(seat => seat.id === f.owner)!.bot = 'Easy';
+ const action = botActions(view).find(candidate => candidate.type === 'card' && candidate.card === f.card &&
+ candidate.scope === 'movement' && candidate.mode === 'last');
+ assert.ok(action);
+ const next = advance(act(structuredClone(game), f.owner, action), clean);
+ assert.notEqual(next.active, f.owner);
+ assert.equal(player(next, f.owner).shipped, true);
+ assert.deepEqual(inventory(next), f.originalInventory);
+});
+
+for (const claim of [false, true]) {
+ void test(`a conserved physical Semuta ${claim ? 'claim' : 'pass'} resumes the same suspended Basic turn`, () => {
+ const { f, game: original } = source();
+ const game = structuredClone(original);
+ // Controlled supplemental custody: move the original drug to Richese;
+ // no physical card is minted, and the paid shipment remains a real action.
+ holdSemutaHomeworldChoamCard(game, 'richese', 'Semuta Drug');
+ assert.deepEqual(inventory(game), f.originalInventory);
+ const action = saphoAction(game, 'last', f.owner);
+ const offered = act(game, f.owner, action);
+ assert.ok(offered.pendingTreacheryDiscard);
+ assert.equal(offered.active, f.owner);
+ assert.equal(viewGame(offered, 'richese').semutaReaction?.canCommit, true);
+ const resumed = reactSemutaHomeworldChoam(offered, 'richese', claim);
+ assert.equal(resumed.pendingTreacheryDiscard, null);
+ assert.equal(resumed.active, f.guild);
+ assert.equal(player(resumed, f.owner).shipped, true);
+ assert.equal(player(resumed, 'richese').hand.some(card => card.id === f.card), claim);
+ assert.equal(resumed.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
+ assert.deepEqual(inventory(resumed), f.originalInventory);
+ const returned = advance(resumed, state => state.phase === 5 && clean(state) && state.active === f.owner);
+ assert.equal(player(returned, f.owner).shipped, true);
+ reject(returned, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 });
+ });
+}
diff --git a/tests/sapho-started-movement-controls.test.tsx b/tests/sapho-started-movement-controls.test.tsx
index 257cc1a8..2843b43a 100644
--- a/tests/sapho-started-movement-controls.test.tsx
+++ b/tests/sapho-started-movement-controls.test.tsx
@@ -103,13 +103,24 @@ void test('clean movement options keep their existing description and exact acti
}
});
+void test('own-started Basic last explains the paused turn and submits only the native action', () => {
+ const game = projected('last', false, false);
+ game.saphoOptions = [{ scope: 'movement', event: 'started-owner-event', mode: 'last', resumeOwn: true }];
+ const html = panel(game);
+ assert.match(html, /Resume your movement last and discard Juice of Sapho/);
+ assert.match(html, /Pause your started turn and resume it last/);
+ assert.match(html, /Shipment and movement already used stay spent/);
+ assert.deepEqual(press(game.saphoOptions[0]), [{ type: 'card', card: sapho.id,
+ scope: 'movement', event: 'started-owner-event', mode: 'last' }]);
+});
+
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, /your own started turn may be paused at a clean boundary and resumed last/);
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 = [];
diff --git a/tests/sapho-started-movement-engine.test.ts b/tests/sapho-started-movement-engine.test.ts
index a0f02078..19d8b41c 100644
--- a/tests/sapho-started-movement-engine.test.ts
+++ b/tests/sapho-started-movement-engine.test.ts
@@ -135,7 +135,7 @@ for (const advanced of [false, true]) {
assert.deepEqual(inventory(game), f.originalInventory);
});
- void test(`${label} an actual active Sapho holder cannot split its own already paid combined turn`, () => {
+ if (advanced) 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');