Allow Advanced Sapho to pause a started movement turn

This commit is contained in:
Quintine committed 2026-10-27 23:08:17 +11:00
1 parent 533c651b07
commit 812657bb35
13 files changed
+321 -26

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ 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. In Basic, your own started turn may be paused at a clean boundary and resumed last, with spent actions retained. Finish pending interactions and preparations first. 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.';
return 'Movement order supports clean combined-turn boundaries and queuing behind a started active turn. Your own started turn may be paused at a clean boundary and resumed last, with spent actions retained. Finish pending interactions and preparations first. The Advanced Guild retains its next timing choice; another player cannot displace an already granted Guild turn, 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.';
+7
View File
@@ -20,6 +20,13 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Advanced Sapho own-turn suspension — 27 October. Prototyped / Partial.** The
existing clean started Shipment and Movement turn pause now extends to Advanced,
retaining already-paid actions and the Guild's original choice. Seven related
files pass 68 focused cases, including native Advanced action and separately
controlled Guild paths; the production build passes. Wider combinations and
deployed acceptance remain open. [Scope](JUICE_OF_SAPHO_RUNTIME.md#own-started-advanced-movement-turn--27-october-2026).
**Salusa low free-revival prohibition — 27 October. Prototyped / Partial.** Apply
the printed low penalty to ordinary force pricing, Ghola, Emperor special
Karama and the Tleilaxu Ambassador, preserving normal-force/leader choices,
+9
View File
@@ -1,5 +1,14 @@
# Dune implementation status
## 27 October 2026 — Advanced Sapho own-turn suspension
A player can provisionally pause its own already-started Advanced Shipment and
Movement turn at a clean boundary and resume last without repeating paid
actions. The Guild retains its native timing choice and can voluntarily pause
its own turn. The seven-file affected union passes 68 cases and the production
build passes. Wider combinations and deployed acceptance remain open.
[Scope](JUICE_OF_SAPHO_RUNTIME.md#own-started-advanced-movement-turn--27-october-2026).
## 27 October 2026 — Salusa Low free Sardaukar revival
Advanced Homeworld play applies the Low Salusa penalty across normal free
+38 -9
View File
@@ -1,5 +1,32 @@
# Juice of Sapho: finite ordering implementation
## Own started Advanced movement turn — 27 October 2026
**Prototyped / Partial.** The clean-boundary own-turn Last option now extends
the existing Basic policy to Advanced Shipment and Movement. The holder may
voluntarily suspend its own already-started combined turn and resume that same
opportunity after the remaining turns. An already completed shipment or move,
its cost, physical pieces and used movement allowance stay in place; the
suspended turn is neither completed nor replayed. The card is unavailable if
the holder is already last or the action cannot meaningfully change the queue.
Pending responses and preparations still have to finish before this boundary.
The actual Advanced Guild retains its native timing decisions and any existing
Karama lock. Last is protected behind that Guild opportunity. A Guild player
may voluntarily suspend its own started turn; another holder cannot displace an
already granted current Guild turn and may instead reorder the later unstarted
tail. Physical storm order and completed opportunities do not change. This
does not extend the rule to splitting Charity, Revival or unfinished nested
interactions.
The inspected Sapho face permits first or last in a turn-ordered phase or
action, but does not state whether or how an already-started combined turn can
be suspended. This is a provisional online timing composition under the
standing choose-recommended direction, not a publisher clarification. It
supersedes earlier Advanced-only started-turn exclusions below. Broader
combinations, complete modes and browser/deployed acceptance remain open.
[Earlier Basic source boundary](JUICE_OF_SAPHO_SOURCE_UPDATE.md#basic-own-turn-last--27-october-2026).
## Truthtrance order in Richese cache auctions — 27 October 2026
**Prototyped / Partial.** The possession-independent public response following
@@ -150,11 +177,12 @@ 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.
cannot 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 separate later own-started policy above
permits voluntary suspension by the current holder.
The pure [movement-order quote](../game/sapho-movement-order.ts) binds the
original phase event, current actor and whole remaining public order in the
@@ -166,10 +194,11 @@ 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.
This historical scope superseded the earlier blanket partly-completed-turn
exclusion for an **unstarted different holder** behind the current actor. The
later own-started Basic and Advanced policies above add a separate voluntary
clean-boundary suspension; no 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
+18 -3
View File
@@ -1,5 +1,19 @@
# Rule decision index
## Juice of Sapho and an already-started Advanced movement turn — 27 October 2026
Extend the existing provisional clean own-turn Last policy to Advanced
Shipment and Movement. The holder may suspend its own started turn and resume
the same opportunity last without repeating already spent shipment, movement,
costs or pieces. Pending preparations remain excluded. Guild retains its
native timing and locked privilege; the holder's protected last position stays
after Guild. A Guild holder may voluntarily suspend its own turn, while a
different holder cannot displace a granted current Guild turn. The printed
card does not resolve partial-turn suspension, so this is a provisional online
interpretation rather than an official clarification. It supersedes earlier
Advanced own-started restrictions, not the distinct Charity or Revival
policies. [Scope](JUICE_OF_SAPHO_RUNTIME.md#own-started-advanced-movement-turn--27-october-2026).
## Salusa Low and free Sardaukar revival — 27 October 2026
GF9's [Ecaz and Moritani rulebook, pp. 9–10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
@@ -1391,8 +1405,9 @@ 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,
protection remains after Guild. The later own-started decision above permits a
holder's voluntary clean-boundary suspension; unresolved preparations and
other ordered phases stay separate. 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).
@@ -2747,7 +2762,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 | 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) |
| Sapho remaining timing | Queued timing retains another actor's started combined turn and reorders only later unstarted turns; Guild retains native next-boundary choice. The holder's own Basic or Advanced combined turn can be provisionally suspended at a clean boundary and resumed last. Other ordered-phase suspensions remain open. Queued timing, cyclic duration and shared final battle opportunity are provisional protocols, not publisher rulings. Old battles retain early timing. | [Own-turn scope](JUICE_OF_SAPHO_RUNTIME.md#own-started-advanced-movement-turn--27-october-2026), [queued timing](JUICE_OF_SAPHO_RUNTIME.md#order-after-a-started-combined-turn--25-october-2026), [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) |
+2 -2
View File
@@ -27130,8 +27130,8 @@ function saphoMovementOrderContext(g: Game): SaphoMovementOrderContext | null {
return {queue,started:queue.remaining.filter(id=>saphoMovementStarted(g,getPlayer(g,id))),
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) &&
// player's turn. Only a clean physical shipment/move may suspend.
allowStartedLast: !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 &&
+3 -3
View File
@@ -499,7 +499,7 @@ 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. 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.',
'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 or Advanced, 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; Guild may voluntarily pause its own granted turn.',
'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.',
@@ -509,7 +509,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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, 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.',
'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 own-turn pause are provisional conventions. Other phase ordering remains 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.',
@@ -524,7 +524,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Finite Once Around, current-lot cyclic bidding, clean/queued movement and ordered Advanced Charity/Revival, remaining battle-choice and late aggressor modes are connected. Queued timing retains the started actor; Charity/Revival keep native claims or requests/allowances and separate physical order through their Semuta disposal. Tail timing is visibly provisional. Other phases and split-own-turn mode remain unfinished.',
'Finite Once Around, current-lot cyclic bidding, clean/queued movement and ordered Advanced Charity/Revival, remaining battle-choice and late aggressor modes are connected. Queued timing retains another started actor; a clean own started movement turn may provisionally resume last in either ruleset. Charity/Revival keep native claims or requests/allowances and separate physical order through their Semuta disposal. Tail timing is visibly provisional. Other phases remain unfinished.',
},
{
area: 'Player controls',
+1 -2
View File
@@ -8,7 +8,7 @@ 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. */
/** Explicit clean-boundary prototype; the engine owns all action state. */
allowStartedLast?: boolean;
};
export class SaphoMovementOrderError extends Error {
@@ -78,7 +78,6 @@ export function reorderSaphoMovementOrder(context: SaphoMovementOrderContext, re
'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
+25 -2
View File
@@ -545,7 +545,7 @@ void test('early Guild timing makes the previous penultimate faction actually la
assert.deepEqual(acted, ['g', 'a', 'e']);
});
void test('after Guild shipment another holder may queue last while Guild cannot split its own started turn', () => {
void test('after Guild shipment another holder may queue last or Guild may pause its own started turn', () => {
for (const holder of ['a', 'g']) {
let g = movement(holder, true);
g = applyAction(g, 'g', { type: 'decision', take: true });
@@ -565,7 +565,30 @@ void test('after Guild shipment another holder may queue last while Guild cannot
);
const options = viewGame(g, holder).saphoOptions;
if (holder === 'g') {
assert.deepEqual(options, []);
const option = options.find(option => option.scope === 'movement' && option.mode === 'last');
assert.ok(option && option.scope === 'movement' && option.resumeOwn);
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, 'a');
assert.deepEqual(g.movementRemaining, ['a', 'e', 'g']);
assert.equal(g.players[2].shipped, true);
assert.equal(g.players[2].reserves, before.players[2].reserves);
assert.equal(g.players[2].spice, before.players[2].spice);
assert.equal(g.guildTimingGranted, before.guildTimingGranted);
assert.equal(g.discard.filter(c => c.id === card).length, 1);
const resumed: string[] = [];
while (g.phase === 5) {
assert.equal(g.decision, null);
resumed.push(g.active!);
if (g.active === 'g') {
assert.equal(g.players[2].shipped, true);
reject(g, 'g', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1 });
}
g = end(g);
}
assert.deepEqual(resumed, ['a', 'e', 'g']);
assert.equal(g.saphoMovementLast, null);
continue;
}
const option = options.find(option => option.scope === 'movement' && option.mode === 'last');
@@ -0,0 +1,159 @@
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 { TERRITORIES } from '../game/board';
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);
}
/** Original Advanced setup and paid Sapho purchase, followed by actual prior
* turns and an Emperor shipment. Inherited fixture selects only unopened
* starting deal/spice positions and scalar lotteries; no played state is set. */
function source() {
const f = fixture({ advanced: true });
let game = advance(structuredClone(f.game), state =>
state.phase === 5 && clean(state) && state.active === f.owner);
assert.ok(game.movementRemaining?.some(id => id !== f.owner));
assert.equal(player(game, f.owner).shipped, false);
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, declared };
}
void test('Advanced Sapho suspends a genuinely paid shipment and resumes the same owner after Guild', () => {
const { f, game: original, prior } = source();
const option = viewGame(original, f.owner).saphoOptions.find(row => row.scope === 'movement' && row.mode === 'last');
assert.ok(option && option.scope === 'movement' && option.resumeOwn);
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, saphoAction(original, 'last', f.owner)), clean);
assert.equal(game.active, f.guild);
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);
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('Advanced own-last waits for the real shipment response before allowing suspension', () => {
const { f, prior, declared } = source();
assert.ok(!clean(declared));
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('Advanced 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 { /* Find a real legal destination using the native validator. */ }
}
if (moved) break;
}
assert.ok(moved && destination);
let game = advance(moved, clean);
assert.equal(player(game, f.owner).moved, 1);
game = advance(act(game, f.owner, saphoAction(game, 'last', f.owner)), 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('all four minimal profiles can select an actual Advanced own-last opportunity', () => {
const { f, game } = source();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.owner);
view.players.find(seat => seat.id === f.owner)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'card' && candidate.card === f.card &&
candidate.scope === 'movement' && candidate.mode === 'last');
assert.ok(action, `${difficulty} proposes the native suspension`);
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 take of [false, true]) void test(
`Advanced Guild ${take ? 'TAKE' : 'WAIT'} after an early own-last suspension preserves the protected tail`, () => {
const f = fixture({ advanced: true });
const game = structuredClone(f.game);
// Controlled supplemental custody: the original purchased Sapho changes
// hands to the actual paid current actor. No card or resource is minted.
const owner = player(game, f.owner), current = player(game, f.current);
const index = owner.hand.findIndex(card => card.id === f.card);
assert.ok(index >= 0);
current.hand.push(...owner.hand.splice(index, 1));
assert.deepEqual(inventory(game), f.originalInventory);
const before = structuredClone(current);
const option = viewGame(game, f.current).saphoOptions.find(row => row.scope === 'movement' && row.mode === 'last');
assert.ok(option && option.scope === 'movement' && option.resumeOwn);
const suspended = act(game, f.current, { type: 'card', card: f.card, scope: 'movement', mode: 'last', event: option.event });
assert.equal(suspended.saphoMovementLast?.player, f.current);
assert.equal(player(suspended, f.current).shipped, true);
assert.equal(player(suspended, f.current).spice, before.spice);
assert.equal(suspended.decision?.kind, 'guildTiming');
const declared = act(suspended, f.guild, { type: 'decision', take });
const after = advance(declared, clean);
if (take) assert.equal(after.active, f.guild);
else assert.notEqual(after.active, f.guild);
assert.equal(after.movementRemaining?.at(-1), f.current);
const returned = advance(after, state => state.phase === 5 && clean(state) && state.active === f.current);
assert.equal(player(returned, f.current).shipped, true);
assert.equal(player(returned, f.current).spice, before.spice);
reject(returned, f.current, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 });
const done = act(returned, f.current, { type: 'endMovement' });
assert.notEqual(done.phase, 5);
assert.deepEqual(inventory(done), f.originalInventory);
},
);
+47
View File
@@ -115,6 +115,53 @@ void test('ordinary Guild timing flags and Basic null timing retain their own la
assert.equal(reorder(locked, 'holder', 'first').next.current, 'holder');
});
void test('an Advanced started actor can suspend its own turn to last without consuming Guild timing', () => {
for (const guildState of [
{ player: 'guild', granted: false, locked: false },
{ player: 'guild', granted: true, locked: false },
{ player: 'guild', granted: false, locked: true },
]) {
const input = { ...context(true), allowStartedLast: true, guild: guildState };
const before = structuredClone(input);
const quote = reorder(input, 'active', 'last');
assert.equal(quote.suspended, 'active');
assert.equal(quote.afterCurrent, null);
assert.deepEqual(quote.next.remaining, ['later', 'holder', 'guild', 'active']);
assert.deepEqual(quote.next.completed, ['done']);
assert.equal(quote.next.current, 'later');
assert.equal(quote.next.currentStarted, false);
assert.equal(quote.next.protectedLast, 'active');
assert.equal(quote.next.event, input.queue.event);
assert.deepEqual(input, before);
reject(input, 'active', 'first');
reject(input, 'active', 'last', input.queue.event);
}
});
void test('a started Guild can voluntarily move its granted turn last without creating another opportunity', () => {
const input = context(true);
input.queue = { ...input.queue, remaining: ['guild', 'active', 'later', 'holder'], current: 'guild' };
input.started = ['done', 'guild'];
input.guild = { player: 'guild', granted: true, locked: false };
input.allowStartedLast = true;
const before = structuredClone(input);
const quote = reorder(input, 'guild', 'last');
assert.equal(quote.suspended, 'guild');
assert.deepEqual(quote.next.remaining, ['active', 'later', 'holder', 'guild']);
assert.equal(quote.next.protectedLast, 'guild');
assert.deepEqual(input, before);
const alreadyLast: SaphoMovementOrderContext = {
...input,
queue: { ...input.queue, remaining: ['guild'], completed: ['done', 'active', 'later', 'holder'] },
};
reject(alreadyLast, 'guild', 'last');
const protectedLast: SaphoMovementOrderContext = {
...input, queue: { ...input.queue, protectedLast: 'holder' },
};
reject(protectedLast, 'guild', 'last', 'movement:3');
});
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);
@@ -120,7 +120,7 @@ void test('unavailable movement guidance covers clean and queued windows without
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, /your own started turn may be paused at a clean boundary and resumed last/);
assert.match(html, /your own started turn may be paused at a clean boundary and resumed last/i);
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 = [];
+10 -3
View File
@@ -135,16 +135,23 @@ for (const advanced of [false, true]) {
assert.deepEqual(inventory(game), f.originalInventory);
});
if (advanced) 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 may pause its own paid combined turn for last`, () => {
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));
const option = viewGame(game, f.owner).saphoOptions.find(option => option.scope === 'movement' && option.mode === 'last');
assert.ok(option && option.scope === 'movement' && option.resumeOwn);
const before = structuredClone(player(game, f.owner));
game = act(game, f.owner, { type: 'card', card: f.card, scope: 'movement', mode: 'last', event: option.event });
assert.deepEqual(game.movementRemaining?.at(-1), f.owner);
assert.equal(player(game, f.owner).shipped, before.shipped);
assert.equal(player(game, f.owner).reserves, before.reserves);
assert.equal(player(game, f.owner).spice, before.spice);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
});
}