Allow Sapho first while preserving unspent Guild timing

At a clean unstarted non-Guild movement boundary, Sapho FIRST now reorders the remaining turn queue and reopens the native Advanced Guild choice. Guild may take its turn ahead or wait while all non-Guild turns retain their relative order. Protect an already granted current Guild turn, keep canceled timing locked and preserve completed turns, payments and last protection. Existing discard continuations and minimal policy options need no new state or signature format. Explain the provisional precedence in the controls and reference.

Validation:7-file97/97, types/lint/build and six original-action/rendered-table programmes. Actual cache1 Sapho, original Guild wait/take, paid completed Richese prefix, Emperor2/Guild1 shipments and native phase completion. An actually dealt Karama cancels TAKE without its privilege being revived; a granted current turn cannot be displaced. Four profiles make eight legal holder/Guild proposals. Corrected an obsolete first exclusion and a fixture that mistook a later response pass for a repeated movement turn; exact failures and then the whole batch pass.

This nonconflicting composition is provisional. Actual competing first, partly completed turns, other ordered actions, direct combined Semuta claim/capture, full modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
This commit is contained in:
Quintine committed 2026-10-18 13:56:29 +11:00
1 parent 84200ebc74
commit 2d89ddb6e1
10 files changed
+469 -16

No files matched your search

+2 -2
View File
@@ -20,7 +20,7 @@ function unavailableReason(game: GameView) {
return 'No order change is available for you in this lot. You must not have bid or passed; discarding this card can open a hand slot, but a completed opportunity cannot be repeated. A card already committed elsewhere cannot be used.'; return 'No order change is available for you in this lot. You must not have bid or passed; discarding this card can open a hand slot, but a completed opportunity cannot be repeated. A card already committed elsewhere cannot be used.';
} }
if (game.phase === 5) if (game.phase === 5)
return 'Movement order can change only before the current combined shipment and movement turn begins. First requires all remaining turns to be unstarted and the Advanced Guild to have finished or be absent. Completed turns stay completed. Already-held positions and committed cards are unavailable.'; return 'Movement order can change only before the current combined shipment and movement turn begins. First requires all remaining turns to be unstarted. The Advanced Guild retains its next timing choice; an already granted current Guild turn cannot be displaced. A Karama-locked privilege stays locked. Completed turns, already-held positions and committed cards remain unavailable.';
if (game.phase === 6) if (game.phase === 6)
return game.battle return game.battle
? game.battle.saphoTimingVersion === 1 ? game.battle.saphoTimingVersion === 1
@@ -77,7 +77,7 @@ export function JuiceOfSapho({
? `Bid ${option.mode} in the current Once Around lot. Each eligible player still has only one bidding opportunity.` ? `Bid ${option.mode} in the current Once Around lot. Each eligible player still has only one bidding opportunity.`
: battle : battle
? `Choose your remaining battles ${option.mode}. Completed battles stay resolved. This changes who chooses next, not the aggressor or tie advantage of a battle. Other players may still choose battles against you before your turn.` ? `Choose your remaining battles ${option.mode}. Completed battles stay resolved. This changes who chooses next, not the aggressor or tie advantage of a battle. Other players may still choose battles against you before your turn.`
: `Take the ${option.mode} remaining combined shipment and movement turn. Completed turns stay completed${option.mode === 'last' ? ', and the Guild cannot move behind you' : ''}.`} : `Take the ${option.mode} remaining combined shipment and movement turn. Completed turns stay completed${option.mode === 'last' ? ', and the Guild cannot move behind you' : game.advanced && game.players.some(player => player.faction === 'guild' && player.id !== game.me) ? '. Provisional first priority: the Guild retains its next legal timing choice and may take its turn before yours; a Karama-locked privilege stays locked' : ''}.`}
</p> </p>
<Button <Button
className="min-h-11 whitespace-normal motion-reduce:transition-none" className="min-h-11 whitespace-normal motion-reduce:transition-none"
+30
View File
@@ -16,6 +16,36 @@ open mode gates.
## Current checkpoint and work ## Current checkpoint and work
**Sapho first with an unspent Advanced Guild — 18 October. Prototyped / Partial.**
At a clean unstarted non-Guild boundary, first now reorders the remaining queue
and retains the Guild's native next-boundary take/wait choice. A granted current
Guild turn stays protected; actual Karama cancellation stays locked. Original
permissions, completed turns, payments and last protection remain. Existing
continuations and minimal policies need no new saved field or signature format.
This nonconflicting composition is provisional, not a publisher precedence FAQ.
One disjoint genuine-fixture lane, bounded source/continuation review and owner
integration pass **7 files97/97**, types/lint/build and six original-action/
rendered-table programmes. Original cache1/native wait uses49 source actions;
an actual Richese1 Polar Sink shipment costing2 and explicit end uses55. Original
grant/cancel branches fork from48 before the first wait. TAKE/WAIT with and
without that prefix use12–14 continuation actions, retain ordinary non-Guild
order and pay Emperor2/Guild1 once. Granted-current and actual dealt-Karama
cancellation use3/2 choice actions plus11/7 continuations. Four profiles make
eight legal holder/Guild proposals. Only unopened original deal/Spice positions
and scalar lotteries are selected; no postdeal pieces, funds, queue or receipt
is installed.
Frozen feedback corrected an obsolete first exclusion and a fixture that counted
an already completed player's later response pass as a repeated movement turn.
The exact two failures and then the whole batch pass. Actual controls render
first/busy, renewed native Guild timing, granted-current and locked states;
no browser click or visual/deployed acceptance. Direct combined Semuta claim/
capture, actual competing first, partly completed turns, wider ordered actions,
full modes and final assurance remain open. No saved-room/database/reset/restart/
deployment operation.
[Canonical source, interpretation and proof](JUICE_OF_SAPHO_RUNTIME.md#first-while-the-advanced-guild-remains--18-october-2026).
**Sapho late aggressor — 18 October. Prototyped / Partial.** Fresh public **Sapho late aggressor — 18 October. Prototyped / Partial.** Fresh public
Richese-roster battles permit clean intervention after commitments/reveal and Richese-roster battles permit clean intervention after commitments/reveal and
retain a fixed final opportunity for both combatants. Both explicitly finish; retain a fixed final opportunity for both combatants. Both explicitly finish;
+27
View File
@@ -1,5 +1,32 @@
# Dune implementation status # Dune implementation status
## 18 October 2026 — Sapho first with an unspent Advanced Guild
A clean unstarted non-Guild boundary now admits provisional first ordering while
retaining the native Guild take/wait choice. A granted current Guild turn stays
protected, canceled timing stays locked, and original permissions, completed
turns, costs and last protection remain. Existing continuation/signature formats
and server-derived minimal options suffice; persistent controls explain Guild
may still act ahead. Actual competing first remains a separate guarded case.
A disjoint genuine-fixture lane, bounded source/continuation review and owner
integration pass7-file97/97, types/lint/build and six original-action/rendered-
table programmes. Original cache1/wait uses49 source actions; a real paid Richese
prefix uses55; native grant/cancel branches fork from48 before the wait.
TAKE/WAIT with/without the prefix use12–14 continuations, pay Emperor2/Guild1 once
and preserve non-Guild order. Real dealt-Karama cancellation remains locked;
granted-current timing cannot be displaced. Four profiles make eight legal
proposals. Only unopened original deal/Spice positions and scalar lotteries
are selected; no dealt hand, force, wallet, phase, queue or receipt is installed.
Frozen feedback corrected an obsolete exclusion and a response-pass/turn-count
fixture assumption; exact failures and the whole batch pass.
[Canonical source, interpretation and exact proof](JUICE_OF_SAPHO_RUNTIME.md#first-while-the-advanced-guild-remains--18-october-2026).
No browser click, visual/deployed, direct combined Semuta claim/capture, actual
competing first, partly completed turn or full-mode acceptance. Final assurance
remains due. No saved-room/database/reset/restart/deployment operation.
## 18 October 2026 — Sapho late aggressor and final battle actions ## 18 October 2026 — Sapho late aggressor and final battle actions
Fresh actual-Richese battles now support clean priority changes after sealed Fresh actual-Richese battles now support clean priority changes after sealed
+72 -5
View File
@@ -9,8 +9,74 @@ it is not a newly located publisher answer. Other timing questions remain open.
The [Battle-phase ordering prototype](SAPHO_BATTLE_ORDER.md) adds first/last The [Battle-phase ordering prototype](SAPHO_BATTLE_ORDER.md) adds first/last
remaining battle choices. Its separate chooser, phase priority and saved-boundary remaining battle choices. Its separate chooser, phase priority and saved-boundary
contract supersede the earlier absence of that mode below. The contract supersede the earlier absence of that mode below. The
[early aggressor prototype](SAPHO_AGGRESSOR.md) now uses the existing shared [aggressor prototype](SAPHO_AGGRESSOR.md) now includes clean late intervention
pre-plan opportunity; later aggressor intervention remains unfinished. and a fixed public final opportunity in fresh Richese battles; old battles keep
their early protocol.
## First while the Advanced Guild remains — 18 October 2026
**Prototyped / Partial.** The physical face changes ordered turns and explicitly
supersedes Guild only for last. The [source review](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast)
and [Guild audit](JUICE_OF_SAPHO_ENGINE_AUDIT.md#movement-and-guild) distinguish
ordinary reordering from a competing first declaration. No publisher answer
settles that conflict. Under the standing recommended-choice instruction, the
new composition provisionally admits a nonconflicting first after Guild waits,
while preserving its native next-boundary timing. This is an implementation
interpretation, not a retrieved official ruling or unconditional first priority.
At a clean boundary before any remaining combined turn starts, the holder may
move first in the native remaining queue even while Guild has an unspent turn.
The existing native timing choice then opens before that reordered turn. Guild
may take its own turn first or wait, retaining the relative order of every
non-Guild participant. Existing `guildTimingGranted` permission stays recorded;
waiting does not consume it or create another Karama response. An ungranted
out-of-order timing attempt retains its original response. A granted current
Guild turn remains unavailable for displacement. A Karama-locked timing privilege
stays locked: Sapho changes order independently without restoring that advantage.
The queue still includes only unfinished turns. Earlier completed turns, paid
shipments, movement allowances and physical storm order do not reset. Existing
pending decisions/responses, partly completed turns, reserved cards, no-op checks
and Sapho-last protection remain. No new game marker or signature format is
introduced. Reorder and discard before entering native timing; the existing
Semuta suffix does the same only after original disposal finishes.
The hand panel explains that Guild can still go ahead, and existing minimal
policies consume the authoritative options. Frozen **7 files97/97**, types/lint/
build and six original-action/rendered-table programmes pass. Actual public
host/setup, original dealt Atreides Karama, cache1 and native wait use49 source
actions. A real paid Richese1 Polar Sink prefix costing2 and explicit end uses55.
TAKE/WAIT with and without that prefix use12–14 continuation actions; Emperor2
and Guild1 shipments pay once, non-Guild order remains and every turn ends.
Granted-current and canceled-TAKE branches fork from48 source actions before
that wait, use3/2 native choice actions and11/7 continuations to finish the phase.
The canceled privilege remains locked with its real Karama discarded once.
Four profiles make eight legal holder/Guild proposals without strategy tuning.
Only unopened original starting-deal/Spice positions and scalar lotteries are
selected. No postdeal hand, force, wallet, queue, phase or timing receipt is
installed. Actual GameTable and isolated Sapho controls render offered/busy,
renewed native Guild choice, granted-current and locked states. Framework aliases
and CSS stubs mean no browser clicks or visual/deployed acceptance.
Frozen feedback repaired two fixture assumptions: an old LAST regression also
expected first to remain excluded, and a completed Richese could still allow
another faction's later response without repeating its movement turn. The
exact two failures pass, then all97/types/lint/build/runtime pass together.
The bounded source/continuation review found no need for another saved field
or continuation. No application rule repair followed frozen feedback.
Private proof: `/home/quintine/.local/share/dune/checkpoints/20261018-sapho-guild-first/frozen-integrated-2/report.json`,
base `84200ebc7448bbefa788158408bb389b722ee8f0`, tree
`0f03e96546e48e9ae3a355b4935e72685c9b891dbde0fcdd15d31a03f63ef4ea`.
The report binds the unchanged implementation/control/test source. Final
checkpoint prose/reference evidence is a separately reviewed overlay; private
`documentation-overlay.json` and `git-result.json` bind it to the pushed revision.
Direct combined Semuta claim/capture, actual competing first, partly completed
movement and other ordered actions remain open. Full-mode, browser/deployed and
final assurance acceptance is separate. No saved-room/database, reset, restart
or deployment operation is part of this checkpoint.
## Cyclic ordinary and Black Market bidding — 18 October 2026 ## Cyclic ordinary and Black Market bidding — 18 October 2026
@@ -107,7 +173,7 @@ tests stay identical to the frozen proof.
`{type:'card',card:'richese-juice-of-sapho',scope,event,mode}` accepts one of these current opportunities and discards the actual held card once: `{type:'card',card:'richese-juice-of-sapho',scope,event,mode}` accepts one of these current opportunities and discards the actual held card once:
- `scope:'onceAround'`: first or last while the holder has not acted and the change reorders a still-unacted bidder. Earlier bids remain committed. The intervention applies to this lot only. Prior bids, funding, completed participants, physical lot, event and storm tie order remain intact. Richese is not restored to last after Sapho. A holder excluded initially by a full hand gains a slot and eligibility by discarding Sapho, provided this lot is still open. Completed passes never reopen. Every purchase still requires a strict outbid. - `scope:'onceAround'`: first or last while the holder has not acted and the change reorders a still-unacted bidder. Earlier bids remain committed. The intervention applies to this lot only. Prior bids, funding, completed participants, physical lot, event and storm tie order remain intact. Richese is not restored to last after Sapho. A holder excluded initially by a full hand gains a slot and eligibility by discarding Sapho, provided this lot is still open. Completed passes never reopen. Every purchase still requires a strict outbid.
- `scope:'movement'`: reorder entire remaining combined shipment/movement turns at a clean boundary. First requires every remaining turn to be unstarted; previously completed turns may retain their spent shipment/movement counters. In Advanced, Guild must be absent or have fully completed its turn and left the remaining queue. Last remains after Guild, including later Guild deferrals. No movement counters, Hajr, transport pricing, concealed tokens, resources or completed turns reset. Existing shipment/movement/preparation, reaction, Truthtrance, paid Box and gift commitments block the intervention. The next actor retains an already settled Guild timing choice when their identity is unchanged. An already granted, unstarted early Guild turn is lifted to the front of the local remaining queue before applying last, without changing physical order; this also permits Guild itself to declare last. A player now actually last cannot spend the card on a no-op. - `scope:'movement'`: reorder entire remaining combined shipment/movement turns at a clean boundary. First requires every remaining turn to be unstarted; previously completed turns may retain their spent shipment/movement counters. In Advanced, Guild retains its native next-boundary timing even while unspent; it may still take its turn before the reordered holder. An already granted current Guild turn is protected. A Karama-locked privilege stays locked. Last remains after Guild, including later Guild deferrals. No movement counters, Hajr, transport pricing, concealed tokens, resources or completed turns reset. Existing shipment/movement/preparation, reaction, Truthtrance, paid Box and gift commitments block the intervention. The next actor retains an already settled Guild timing choice when their identity is unchanged. An already granted, unstarted early Guild turn is lifted to the front of the local remaining queue before applying last, without changing physical order; this also permits Guild itself to declare last. A player now actually last cannot spend the card on a no-op.
The reusable immutable `ordered-opportunity` helper checks unique IDs, exact remaining/completed partition, stable event and locked current/protected-last positions. It never defines card scope or cyclic timing. Movement protection persists as `saphoMovementLast:{event,turn,player}`, checked on action, normalization and projection, and cleared when its owner finishes. Normal storm order is never rewritten. Invalid records are rejected, not repaired by replaying turns. The reusable immutable `ordered-opportunity` helper checks unique IDs, exact remaining/completed partition, stable event and locked current/protected-last positions. It never defines card scope or cyclic timing. Movement protection persists as `saphoMovementLast:{event,turn,player}`, checked on action, normalization and projection, and cleared when its owner finishes. Normal storm order is never rewritten. Invalid records are rejected, not repaired by replaying turns.
@@ -136,8 +202,9 @@ Browser and final regression outcomes from 7 September are recorded in the histo
The [existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast) The [existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast)
already permits reordering the remaining unstarted participants between complete already permits reordering the remaining unstarted participants between complete
combined turns. The old global completed/shipped guard was narrower than that combined turns. The old global completed/shipped guard was narrower than that
contract. The engine now checks only remaining participants and only retains the contract. That checkpoint checked only remaining participants but retained the
Advanced Guild-first guard while Guild is among them. No new timing ruling, Advanced Guild-first guard while Guild was among them; the 18 October section
above supersedes that blanket exclusion. No new timing ruling,
saved field, player action shape or separate AI legality calculation is added. saved field, player action shape or separate AI legality calculation is added.
Four new engine tests cover a real completed shipment before first, a genuine Four new engine tests cover a real completed shipment before first, a genuine
+12 -1
View File
@@ -1,5 +1,16 @@
# Rule decision index # Rule decision index
## Provisional Sapho first with an unspent Guild — 18 October 2026
The face explicitly supersedes Guild for last, without settling competing first
claims. After a native Guild wait, Sapho may provisionally reorder the next clean
unstarted turn while Guild retains its native next-boundary choice to take or
wait. An already granted current Guild turn stays protected; Karama-locked timing
stays locked. No completed turn, permission, shipment or allowance resets.
This extends nonconflicting ordering rather than deciding the actual competing
first case. [Canonical source and contract](JUICE_OF_SAPHO_RUNTIME.md#first-while-the-advanced-guild-remains--18-october-2026).
## Provisional Sapho final battle opportunity — 18 October 2026 ## Provisional Sapho final battle opportunity — 18 October 2026
The printed aggressor effect has no before-plans restriction. Fresh battles The printed aggressor effect has no before-plans restriction. Fresh battles
@@ -849,7 +860,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Ixian Nexus Betrayal: Bidding or Technology | Fresh native-Ixian/classic-or-Tleilaxu Basic/Advanced local `ixian-betrayal` uses Ix47 Treachery and Nexus alone, with no initial Nexus deal or old-save retrofit. Resolve actual native Karama/BG counters first; a native denial opens no Nexus gate or cost. After all native passes, uniform publicly possible unallied non-Ixian held-Nexus seats acknowledge before the native effect; concealed face governs only private Use. These are bounded privacy/ordering inferences, not publisher universal priority. One physical Ixian Nexus denies one current Bidding attempt (normal count, no Ixian inspection/return) or one Advanced Technology declaration (both card locations preserved, attempt remains used); all-pass allows the original effect and suffix once. No whole-phase ban, completed undo, invented direct-Nexus Karama counter or blanket immunity. Richese special-lot exchange, wider priority/modules, complete games, strategy and public/live release remain open; verification is Planned pending frozen direct evidence. | [Photographed face and native source protocol](NEXUS_CARD_RULES.md#bounded-ixian-betrayal-source-protocol--1-october-2026), [fresh local runtime and acceptance](NEXUS_CARD_RUNTIME.md#bounded-ixian-betrayal-local-runtime--1-october-2026), [five-facet internal topic](../game/reference.ts) | | Ixian Nexus Betrayal: Bidding or Technology | Fresh native-Ixian/classic-or-Tleilaxu Basic/Advanced local `ixian-betrayal` uses Ix47 Treachery and Nexus alone, with no initial Nexus deal or old-save retrofit. Resolve actual native Karama/BG counters first; a native denial opens no Nexus gate or cost. After all native passes, uniform publicly possible unallied non-Ixian held-Nexus seats acknowledge before the native effect; concealed face governs only private Use. These are bounded privacy/ordering inferences, not publisher universal priority. One physical Ixian Nexus denies one current Bidding attempt (normal count, no Ixian inspection/return) or one Advanced Technology declaration (both card locations preserved, attempt remains used); all-pass allows the original effect and suffix once. No whole-phase ban, completed undo, invented direct-Nexus Karama counter or blanket immunity. Richese special-lot exchange, wider priority/modules, complete games, strategy and public/live release remain open; verification is Planned pending frozen direct evidence. | [Photographed face and native source protocol](NEXUS_CARD_RULES.md#bounded-ixian-betrayal-source-protocol--1-october-2026), [fresh local runtime and acceptance](NEXUS_CARD_RUNTIME.md#bounded-ixian-betrayal-local-runtime--1-october-2026), [five-facet internal topic](../game/reference.ts) |
| Harkonnen Nexus Betrayal local preview | Fresh classic/base33/Nexus-only `harkonnen-betrayal` admits genuine personal/allied native matching calls. Native allied Karama/BG allowance comes first; a provisional uniform acknowledgement precedes the original outcome. Use returns the exact physical traitor immediately and preserves one automatic private actual Mentat replacement. The local ordering/privacy convention is not publisher/user priority and does not answer the previously asked universal reaction policy; public starts, old-game retrofit, arbitrary stacks and combined modules remain gated. Bounded native/private/recovery/CLI/phone verification is Partial development verified, not complete modes or deployment. | [Bounded source contract](NEXUS_HARKONNEN_RULES.md#bounded-betrayal-preview-contract--1-october-2026), [runtime limits](NEXUS_CARD_RUNTIME.md) | | Harkonnen Nexus Betrayal local preview | Fresh classic/base33/Nexus-only `harkonnen-betrayal` admits genuine personal/allied native matching calls. Native allied Karama/BG allowance comes first; a provisional uniform acknowledgement precedes the original outcome. Use returns the exact physical traitor immediately and preserves one automatic private actual Mentat replacement. The local ordering/privacy convention is not publisher/user priority and does not answer the previously asked universal reaction policy; public starts, old-game retrofit, arbitrary stacks and combined modules remain gated. Bounded native/private/recovery/CLI/phone verification is Partial development verified, not complete modes or deployment. | [Bounded source contract](NEXUS_HARKONNEN_RULES.md#bounded-betrayal-preview-contract--1-october-2026), [runtime limits](NEXUS_CARD_RUNTIME.md) |
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) | | CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
| Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) | | Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. An unspent Advanced Guild retains its native next-boundary take/wait choice after first. An already granted current Guild turn stays protected, and a Karama-locked privilege stays locked. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) |
| Basic CHOAM / Leader Skills | Compose existing ordinary skills with native sales and own-leader Ghola revival. Finish the private optional replacement before the exact suspended sale resumes. The free revival remains completed if the resumed sale is canceled. Basic uses five ordinary leaders and 35 Treachery Cards; Auditor remains Advanced and ineligible for skills. | [Integration and source contracts](CHOAM_LEADER_SKILLS.md) | | Basic CHOAM / Leader Skills | Compose existing ordinary skills with native sales and own-leader Ghola revival. Finish the private optional replacement before the exact suspended sale resumes. The free revival remains completed if the resumed sale is canceled. Basic uses five ordinary leaders and 35 Treachery Cards; Auditor remains Advanced and ineligible for skills. | [Integration and source contracts](CHOAM_LEADER_SKILLS.md) |
| Basic Ixians / Leader Skills | Per-origin cyborg range, room-local HMS force routes, rescue before actual cyborg Tank substitution, Rihani before faction substitution and mandatory cleanup compose existing contracts. Ambiguous rescued cyborg reserve origins are explicit player choices. Exact green Ix Specials are Thumper, Harvester and Amal. | [Integration and physical sources](IX_LEADER_SKILLS.md) | | Basic Ixians / Leader Skills | Per-origin cyborg range, room-local HMS force routes, rescue before actual cyborg Tank substitution, Rihani before faction substitution and mandatory cleanup compose existing contracts. Ambiguous rescued cyborg reserve origins are explicit player choices. Exact green Ix Specials are Thumper, Harvester and Amal. | [Integration and physical sources](IX_LEADER_SKILLS.md) |
| Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value as the Smuggler base; the controller's own modifiers follow the later provisional policy. Advanced foreign gholas and existing skill questions retain separate contracts. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md), [Smuggler policy](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026) | | Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value as the Smuggler base; the controller's own modifiers follow the later provisional policy. Advanced foreign gholas and existing skill questions retain separate contracts. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md), [Smuggler policy](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026) |
+3 -3
View File
@@ -19580,8 +19580,8 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
if ( if (
!queue.remaining.some((id) => saphoMovementStarted(g, getPlayer(g, id))) && !queue.remaining.some((id) => saphoMovementStarted(g, getPlayer(g, id))) &&
!( !(
g.advanced && g.advanced && !g.guildTimingLocked && g.guildTimingGranted &&
queue.remaining.some((id) => getPlayer(g, id).faction === 'guild') getPlayer(g, queue.current!).faction === 'guild'
) && ) &&
queue.current !== p.id queue.current !== p.id
) )
@@ -19749,7 +19749,7 @@ function playSapho(g: Game, p: Player, action: Action) {
const used = discard(g, p, 'richese-juice-of-sapho'); const used = discard(g, p, 'richese-juice-of-sapho');
log( log(
g, g,
`${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}. Completed turns and physical storm order are unchanged.`, `${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}. Completed turns and physical storm order are unchanged.${option.mode === 'first' && g.advanced && g.movementRemaining.some(id => id !== p.id && getPlayer(g, id).faction === 'guild') ? ' The Guild retains its next legal timing choice and may still take its turn before this reordered turn.' : ''}`,
{ faction: p.faction, name: 'Juice of Sapho' }, { faction: p.faction, name: 'Juice of Sapho' },
); );
if (g.semutaPreview) { if (g.semutaPreview) {
+4 -2
View File
@@ -496,7 +496,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In a current Once Around auction, first or last can reorder remaining bidders while you have not yet bid. Earlier bids stay committed; a position you already hold is unavailable. You still get only one bidding opportunity and must outbid the current high bid to win.', 'In a current Once Around auction, first or last can reorder remaining bidders while you have not yet bid. Earlier bids stay committed; a position you already hold is unavailable. You still get only one bidding opportunity and must outbid the current high bid to win.',
'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.', '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.', '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, the Guild must have finished or be absent. Last stays after the Guild even if the Guild later chooses to wait.', '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.',
'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.', '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.', '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.',
'Finish existing shipment, movement, card preparation and pending decisions before changing order. Your hand panel lists only currently available choices. A reserved card or a position you already hold cannot be selected.', 'Finish existing shipment, movement, card preparation and pending decisions before changing order. Your hand panel lists only currently available choices. A reserved card or a position you already hold cannot be selected.',
@@ -541,8 +541,10 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification', area: 'Verification',
status: 'Partial', status: 'Partial',
detail: detail:
'18 October late aggressor:16-file177/177, types/lint/build and eight original-action/rendered-table programmes pass. Actual cache1/paid forces use40 Basic/47 Advanced source actions; native Stone1 turn1/Sapho1 turn2 uses86. Sealed-plan and revised-final-window uses preserve plans/votes; both final acknowledgements precede real count2 and settlement. Sixteen minimal proposals are legal. Controlled reservation probes are separate. Related Homeworld final-vote routing is repaired; no direct combined World/Nexus/Sapho or late Semuta claim/capture proof. Earlier cyclic scope:10-file134/134, types/lint/build and eight original-action/rendered-table continuations pass. Actual cache1, Basic/Advanced ordinary bidding, native Advanced Black Market and paid full hand preserve prior1,2/3/4 raises, one payment4 and original next opener or final-lot Revival. Eight minimal policy proposals are legal; actual passes need a real raise. Controlled pure-cycle/post-sale quotes are separate. Fixture opener/completion and external smoke assumptions were repaired; only runtime was rerun against identical source after types/lint/build. No browser click, visual/deployed, direct combined Semuta claim/capture, full-mode or final-assurance acceptance.', '18 October unspent Guild first:7-file97/97, types/lint/build and six original-action/rendered-table programmes pass. Actual cache1/native wait uses49 source actions; real paid completed Richese prefix uses55. TAKE/WAIT preserves non-Guild order and paid Emperor2/Guild1 shipments. Granted-current and actual originally-dealt Karama cancellation both finish without restoring blocked timing. Four profiles make eight legal holder/Guild proposals. Two fixture assumptions were repaired, then all checks passed together. Prior late-aggressor and cyclic evidence remains in the linked feature docs. No browser clicks, visual/deployed, direct combined Semuta claim/capture, actual competing first, partly completed turn, full-mode or final-assurance acceptance',
evidence: [ evidence: [
'tests/fixture-sapho-guild-first.ts',
'tests/sapho-guild-first-engine.test.ts',
'game/battle-outcome-window.ts', 'game/battle-outcome-window.ts',
'components/battle-outcome-window.tsx', 'components/battle-outcome-window.tsx',
'tests/battle-outcome-window.test.ts', 'tests/battle-outcome-window.test.ts',
+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
const sapho = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(sapho);
export const GUILD_FIRST_SAPHO = sapho.id;
export type SaphoGuildFirstStep = { actor: string; action: Action };
export const saphoGuildFirstPlayer = (game: Game, actor = 'emperor') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const saphoGuildFirstClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function actSaphoGuildFirst(game: Game, actor: string, action: Action, actions: SaphoGuildFirstStep[] = []): Game {
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
export function nextSaphoGuildFirstStep(game: Game): SaphoGuildFirstStep | null {
if (!game.response && !game.phaseOpening && game.decision) {
const decision = game.decision;
if (decision.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
if (decision.kind === 'richeseCache') {
const card = game.richeseCache!.find(card => card.id === GUILD_FIRST_SAPHO) ?? game.richeseCache![0]; assert.ok(card);
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
card: card.id, method: 'onceAround', direction: 'clockwise' } };
}
if (decision.kind === 'richeseUnbid') return { actor: decision.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
}
const lot = game.richeseAuction;
if (saphoGuildFirstClean(game) && lot?.cardId === GUILD_FIRST_SAPHO && !lot.outcome &&
lot.active === 'emperor' && lot.bid === 0)
return { actor: 'emperor', action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } };
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceSaphoGuildFirst(game: Game, until: (state: Game) => boolean,
actions: SaphoGuildFirstStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Sapho/Guild programme ended before its next control');
const step = nextSaphoGuildFirstStep(game); assert.ok(step, 'Finish the original owned control before advancing');
game = actSaphoGuildFirst(game, step.actor, step.action, actions);
}
throw Error('The original Sapho/Guild programme did not reach its finite boundary');
}
/** Genuine public host/setup, paid cache1 Sapho and native Guild WAIT. One
* original Karama is selected while the starting deck is still unopened, then
* actually dealt to Atreides so native TAKE has a real allow/cancel control.
* The optional completed prefix is an original paid shipment and explicit end.
* No dealt hand, force, money, phase, queue or timing receipt is installed. */
export function createSaphoGuildFirstFixture(options: { completedFirst?: boolean } = {}) {
let game = createGame('SAPHOGUILDFIRST', newPlayer('richese', 'Richese', 'richese'), true, ['choam']);
for (const faction of ['atreides', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: SaphoGuildFirstStep[] = [];
const act = (actor: string, action: Action) => { game = actSaphoGuildFirst(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSaphoGuildFirst(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0));
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const counter = game.deck[index].id;
[game.deck[1], game.deck[index]] = [game.deck[index], game.deck[1]];
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
assert.ok(saphoGuildFirstPlayer(game, 'atreides').hand.some(card => card.id === counter));
for (let count = 0; count < 2; count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
reach(state => saphoGuildFirstPlayer(state).hand.some(card => card.id === GUILD_FIRST_SAPHO));
const acquired = structuredClone(game);
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
const beforeGuildWait = structuredClone(game), waits: { before: Game; step: SaphoGuildFirstStep; after: Game }[] = [];
const wait = () => {
assert.equal(game.decision?.kind, 'guildTiming');
const before = structuredClone(game), step: SaphoGuildFirstStep = { actor: 'guild', action: { type: 'decision', take: false } };
act(step.actor, step.action); reach(saphoGuildFirstClean);
waits.push({ before, step, after: structuredClone(game) });
};
wait();
let prefix: { before: Game; shipment: SaphoGuildFirstStep; afterShipment: Game; end: SaphoGuildFirstStep; afterEnd: Game } | null = null;
if (options.completedFirst) {
assert.equal(game.active, 'richese');
const before = structuredClone(game), shipment: SaphoGuildFirstStep = { actor: 'richese', action: {
type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0,
} };
act(shipment.actor, shipment.action); reach(saphoGuildFirstClean);
const afterShipment = structuredClone(game), end: SaphoGuildFirstStep = { actor: 'richese', action: { type: 'endMovement' } };
act(end.actor, end.action);
const afterEnd = structuredClone(game);
prefix = { before, shipment, afterShipment, end, afterEnd };
wait();
}
return { game, lobby, setup, beforeStartingDeal, afterSetup, acquired, beforeGuildWait, waits, prefix, counter,
card: GUILD_FIRST_SAPHO, owner: 'emperor', guild: 'guild', canceler: 'atreides',
completedFirst: options.completedFirst ?? false, sourceActionCount: actions.length, actions,
staging: ['Only the unopened original Atreides Karama starting-deal position, unopened first-turn land Spice positions and scalar lotteries are selected. Public host/setup, paid cache1, native Guild wait(s) and optional paid Richese prefix provide the original source. No postdeal custody, resources, clocks, queues or timing receipts are assigned.'] };
}
export type SaphoGuildFirstFixture = ReturnType<typeof createSaphoGuildFirstFixture>;
export function saphoGuildFirstAction(game: Game): Action {
const option = viewGame(game, 'emperor').saphoOptions.find(option => option.scope === 'movement' && option.mode === 'first');
assert.ok(option, 'The real unstarted non-Guild boundary must offer Sapho first');
return { type: 'card', card: GUILD_FIRST_SAPHO, ...option };
}
/** Branch from the original first native decision, not a synthesized timing
* state. A real distinct Atreides Karama may cancel TAKE; ordinary allowance
* uses its actual pass response instead. */
export function prepareSaphoGuildTake(fixture: SaphoGuildFirstFixture, cancel = false) {
const actions: SaphoGuildFirstStep[] = [];
let game = actSaphoGuildFirst(structuredClone(fixture.beforeGuildWait), fixture.guild,
{ type: 'decision', take: true }, actions);
const declared = structuredClone(game);
assert.equal(game.response?.kind, 'guildTiming');
if (cancel) game = actSaphoGuildFirst(game, fixture.canceler,
{ type: 'card', mode: 'cancel', card: fixture.counter }, actions);
game = advanceSaphoGuildFirst(game, saphoGuildFirstClean, actions);
return { game, declared, actions, cancel };
}
/** Sapho takes first among unstarted non-Guild turns; the real Guild timing
* decision is reopened. TAKE is paid and finished before the holder, while WAIT
* retains the original relative non-Guild suffix. Every turn explicitly ends. */
export function completeSaphoGuildFirst(fixture: SaphoGuildFirstFixture, take: boolean) {
let game = structuredClone(fixture.game);
const actions: SaphoGuildFirstStep[] = [], turns: {
actor: string; before: Game; shipment: SaphoGuildFirstStep | null; afterShipment: Game;
end: SaphoGuildFirstStep; afterEnd: Game;
}[] = [];
const act = (actor: string, action: Action) => { game = actSaphoGuildFirst(game, actor, action, actions); };
const settle = () => { game = advanceSaphoGuildFirst(game, saphoGuildFirstClean, actions); };
const first = saphoGuildFirstAction(game);
act(fixture.owner, first);
const reoffered = structuredClone(game);
assert.equal(game.decision?.kind, 'guildTiming');
act(fixture.guild, { type: 'decision', take });
const declaredGuild = structuredClone(game);
settle();
const afterGuildChoice = structuredClone(game), seen = new Set<string>();
for (let count = 0; game.phase === 5 && count < 80; count++) {
if (!saphoGuildFirstClean(game)) { settle(); continue; }
const actor = game.active; assert.ok(actor && !seen.has(actor), 'A completed native movement turn cannot recur');
seen.add(actor);
const before = structuredClone(game);
const shipment: SaphoGuildFirstStep | null = actor === fixture.owner || actor === fixture.guild ? {
actor, action: actor === fixture.owner
? { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0 }
: { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 },
} : null;
if (shipment) { act(shipment.actor, shipment.action); settle(); }
const afterShipment = structuredClone(game), end: SaphoGuildFirstStep = { actor, action: { type: 'endMovement' } };
act(end.actor, end.action);
turns.push({ actor, before, shipment, afterShipment, end, afterEnd: structuredClone(game) });
}
assert.notEqual(game.phase, 5, 'The native combined-turn queue finishes finitely');
return { game, first, reoffered, declaredGuild, afterGuildChoice, turns, actions, take };
}
+8 -3
View File
@@ -314,7 +314,7 @@ void test('Sapho first reorders only the unstarted movement suffix after a real
reject(startedNext, 'g', request); reject(startedNext, 'g', request);
}); });
void test('Genuine Advanced setup permits Sapho first after Guild finishes, retaining unspent Guild priority beforehand', () => { void test('Genuine Advanced setup permits Sapho first after Guild waits or finishes, retaining a granted current Guild turn', () => {
let g = movement('e', true); let g = movement('e', true);
assert.equal(g.players[1].elites!.reserves, 5); assert.equal(g.players[1].elites!.reserves, 5);
assert.equal(g.decision?.kind, 'guildTiming'); assert.equal(g.decision?.kind, 'guildTiming');
@@ -324,7 +324,12 @@ void test('Genuine Advanced setup permits Sapho first after Guild finishes, reta
take: false, take: false,
}); });
assert.equal(deferred.active, 'a'); assert.equal(deferred.active, 'a');
reject(deferred, 'e', action(deferred, 'first')); const first = applyAction(deferred, 'e', action(deferred, 'first'));
assert.deepEqual(first.movementRemaining, ['e', 'a', 'g']);
assert.equal(first.active, null);
assert.equal(first.decision?.kind, 'guildTiming');
assert.equal(first.guildTimingGranted, deferred.guildTimingGranted);
assert.equal(first.guildTimingLocked, deferred.guildTimingLocked);
g = applyAction(g, 'g', { type: 'decision', take: true }); g = applyAction(g, 'g', { type: 'decision', take: true });
assert.equal(g.active, 'g'); assert.equal(g.active, 'g');
@@ -431,7 +436,7 @@ void test('Advanced Guild remains before Sapho last across repeated deferrals an
reject(g, 'e', action(g, 'last')); reject(g, 'e', action(g, 'last'));
g = applyAction(g, 'g', { type: 'decision', take: false }); g = applyAction(g, 'g', { type: 'decision', take: false });
assert.equal(g.active, 'a'); assert.equal(g.active, 'a');
reject(g, 'e', action(g, 'first')); assert.ok(viewGame(g, 'e').saphoOptions.some(option => option.scope === 'movement' && option.mode === 'first'));
g = applyAction(g, 'e', action(g, 'last')); g = applyAction(g, 'e', action(g, 'last'));
assert.deepEqual(g.movementRemaining, ['a', 'g', 'e']); assert.deepEqual(g.movementRemaining, ['a', 'g', 'e']);
assert.equal( assert.equal(
+147
View File
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import {
actSaphoGuildFirst, advanceSaphoGuildFirst, completeSaphoGuildFirst, createSaphoGuildFirstFixture,
prepareSaphoGuildTake, saphoGuildFirstAction, saphoGuildFirstClean,
saphoGuildFirstPlayer as player, type SaphoGuildFirstFixture,
} from './fixture-sapho-guild-first';
function source(f: SaphoGuildFirstFixture) {
assert.equal(f.game.advanced, true);
assert.equal(f.beforeStartingDeal.deck[1].id, f.counter);
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.ok(player(f.afterSetup, f.canceler).hand.some(card => card.id === f.counter));
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.ok(player(f.game).hand.some(card => card.id === f.card));
assert.equal(f.beforeGuildWait.decision?.kind, 'guildTiming');
assert.ok(f.waits.every(wait => wait.step.actor === f.guild && wait.step.action.take === false));
assert.equal(f.game.decision, null);
assert.notEqual(f.game.active, f.guild);
assert.notEqual(f.game.active, f.owner);
assert.equal(f.game.guildTimingLocked, false);
assert.ok(f.game.movementRemaining!.includes(f.guild));
for (const actor of f.game.movementRemaining!) {
assert.equal(player(f.game, actor).moved, 0);
assert.equal(player(f.game, actor).shipped, false);
}
assert.equal(f.sourceActionCount, f.actions.length);
}
function settled(f: SaphoGuildFirstFixture, result: ReturnType<typeof completeSaphoGuildFirst>) {
assert.equal(result.first.scope, 'movement');
assert.equal(result.first.mode, 'first');
assert.equal(result.reoffered.decision?.kind, 'guildTiming');
if (result.reoffered.decision?.kind === 'guildTiming') assert.equal(result.reoffered.decision.next, f.owner);
assert.deepEqual(result.reoffered.movementRemaining,
[f.owner, ...f.game.movementRemaining!.filter(actor => actor !== f.owner)]);
assert.deepEqual(result.reoffered.order, f.game.order);
assert.equal(result.reoffered.saphoMovementLast ?? null, null);
assert.equal(result.game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(result.game.order, f.game.order);
assert.deepEqual(result.game.movementRemaining, []);
const owner = result.turns.find(turn => turn.actor === f.owner)!, guild = result.turns.find(turn => turn.actor === f.guild)!;
assert.ok(owner.shipment && guild.shipment);
assert.equal(player(owner.before).spice - player(owner.afterShipment).spice, 2);
assert.equal(player(owner.before).reserves - player(owner.afterShipment).reserves, 1);
assert.equal(player(guild.before, f.guild).spice - player(guild.afterShipment, f.guild).spice, 1);
assert.equal(player(guild.before, f.guild).reserves - player(guild.afterShipment, f.guild).reserves, 1);
assert.equal(player(result.game).forces['imperial_basin:9'], 1);
assert.equal(player(result.game, f.guild).forces['carthag:11'], 1);
assert.equal(result.actions.filter(step => step.actor === f.owner && step.action.type === 'ship').length, 1);
assert.equal(result.actions.filter(step => step.actor === f.guild && step.action.type === 'ship').length, 1);
assert.equal(result.actions.filter(step => step.action.type === 'endMovement').length, result.turns.length);
}
void test('genuine paid Sapho first reopens Guild TAKE before its holder without changing the original non-Guild suffix', () => {
const f = createSaphoGuildFirstFixture();
source(f);
const result = completeSaphoGuildFirst(f, true);
settled(f, result);
assert.equal(result.declaredGuild.response?.kind, 'guildTiming');
assert.ok(viewGame(result.declaredGuild, f.canceler).responseControls!.cancelCards.includes(f.counter));
assert.ok(result.actions.some(step => step.actor === f.canceler && step.action.type === 'passResponse'));
assert.equal(result.afterGuildChoice.active, f.guild);
assert.equal(result.afterGuildChoice.guildTimingGranted, true);
assert.deepEqual(result.turns.map(turn => turn.actor), ['guild', 'emperor', 'richese', 'atreides']);
});
void test('genuine Guild WAIT after Sapho first lets the holder complete its paid turn before the original following actors', () => {
const f = createSaphoGuildFirstFixture();
source(f);
const result = completeSaphoGuildFirst(f, false);
settled(f, result);
assert.equal(result.afterGuildChoice.active, f.owner);
assert.deepEqual(result.turns.map(turn => turn.actor), ['emperor', 'richese', 'atreides', 'guild']);
const ownTurn = result.turns[0];
assert.equal(player(ownTurn.afterShipment, f.guild).spice - player(ownTurn.before, f.guild).spice, 2,
'The originally unspent Guild receives the actual ordinary opposing shipment fee once');
});
void test('a genuinely completed paid native prefix remains finished when Sapho first reorders the later unstarted suffix', () => {
const f = createSaphoGuildFirstFixture({ completedFirst: true });
source(f);
assert.ok(f.prefix);
assert.equal(player(f.prefix!.before, 'richese').spice - player(f.prefix!.afterShipment, 'richese').spice, 2);
assert.equal(player(f.game, 'richese').shipped, true);
assert.equal(player(f.game, 'richese').forces['polar_sink:0'], 1);
assert.equal(f.game.movementRemaining!.includes('richese'), false);
const result = completeSaphoGuildFirst(f, false);
settled(f, result);
assert.deepEqual(result.turns.map(turn => turn.actor), ['emperor', 'atreides', 'guild']);
assert.equal(result.actions.some(step => step.actor === 'richese' &&
['ship', 'move', 'endMovement'].includes(step.action.type)), false,
'A completed player may still allow another faction response, without another movement turn');
assert.equal(player(result.game, 'richese').forces['polar_sink:0'], 1);
assert.equal(player(result.game, 'richese').shipped, true);
});
void test('real granted Guild TAKE protects its current turn while an actually canceled TAKE stays locked under Sapho first', () => {
const f = createSaphoGuildFirstFixture();
const granted = prepareSaphoGuildTake(f);
assert.equal(granted.game.active, f.guild);
assert.equal(granted.game.guildTimingGranted, true);
assert.equal(viewGame(granted.game, f.owner).saphoOptions.some(option => option.scope === 'movement' && option.mode === 'first'), false);
assert.throws(() => actSaphoGuildFirst(granted.game, f.owner, saphoGuildFirstAction(f.game)), /Sapho|opportunity|Guild|expired/i);
assert.ok(player(granted.game).hand.some(card => card.id === f.card));
let finished = actSaphoGuildFirst(granted.game, f.guild,
{ type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 });
finished = advanceSaphoGuildFirst(finished, saphoGuildFirstClean);
finished = actSaphoGuildFirst(finished, f.guild, { type: 'endMovement' });
finished = advanceSaphoGuildFirst(finished, saphoGuildFirstClean);
finished = actSaphoGuildFirst(finished, f.owner, saphoGuildFirstAction(finished));
assert.equal(finished.active, f.owner);
assert.equal(finished.decision, null);
assert.equal(finished.movementRemaining!.includes(f.guild), false);
assert.equal(player(finished, f.guild).forces['carthag:11'], 1);
const canceled = prepareSaphoGuildTake(f, true);
assert.equal(canceled.game.guildTimingLocked, true);
assert.equal(canceled.game.discard.filter(card => card.id === f.counter).length, 1);
const first = actSaphoGuildFirst(canceled.game, f.owner, saphoGuildFirstAction(canceled.game));
assert.equal(first.active, f.owner);
assert.equal(first.decision, null);
assert.equal(first.guildTimingLocked, true);
assert.ok(first.movementRemaining!.includes(f.guild), 'Cancellation keeps the native in-order turn without reviving flexible timing');
});
void test('all four minimal policies propose legal Sapho-first and renewed native Guild timing decisions from original views', () => {
const f = createSaphoGuildFirstFixture();
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const holder = viewGame(f.game, f.owner);
holder.players.find(player => player.id === f.owner)!.bot = difficulty;
const first = botActions(holder).find(action => action.type === 'card' && action.card === f.card && action.scope === 'movement' && action.mode === 'first');
assert.ok(first, `${difficulty} retains the actual meaningful first choice`);
const renewed = actSaphoGuildFirst(structuredClone(f.game), f.owner, first);
assert.equal(renewed.decision?.kind, 'guildTiming');
const guild = viewGame(renewed, f.guild);
guild.players.find(player => player.id === f.guild)!.bot = difficulty;
const choice = botActions(guild).find(action => action.type === 'decision' && typeof action.take === 'boolean');
assert.ok(choice, `${difficulty} answers the actual renewed native timing control`);
const declared = actSaphoGuildFirst(renewed, f.guild, choice);
const continued = advanceSaphoGuildFirst(declared, saphoGuildFirstClean);
assert.equal(continued.active, choice.take ? f.guild : f.owner);
assert.deepEqual(continued.order, f.game.order);
assert.equal(player(continued).shipped, false);
assert.equal(player(continued).moved, 0);
}
});