Offer Semuta after clean Once Around Sapho discard
This commit is contained in:
1 parent
9872efcc1d
commit
989a986d68
10 files changed
+268
-45
No files matched your search
+12
-10
@@ -9,21 +9,23 @@ Expansion starts and full rules/publication acceptance remain gated.**
|
|||||||
The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime)
|
The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime)
|
||||||
accepts a fresh Richese roster with CHOAM & Richese and optionally
|
accepts a fresh Richese roster with CHOAM & Richese and optionally
|
||||||
the physical Ix deck. Clean public ordinary-card, paid Box, retired
|
the physical Ix deck. Clean public ordinary-card, paid Box, retired
|
||||||
Ornithopter, private Distrans, final Truthtrance, movement-order
|
Ornithopter, private Distrans, final Truthtrance, Once Around,
|
||||||
or pre-plan aggressor Sapho, battle-card and Ix-deck Thumper/Amal
|
movement-order or pre-plan aggressor Sapho, battle-card and Ix-deck
|
||||||
discards open the same neutral offer regardless of hidden Semuta
|
Thumper/Amal discards open the same neutral offer regardless of hidden
|
||||||
custody. Pre-plan Sapho has already committed ordinary tie priority;
|
Semuta custody. Once Around has already committed remaining bidder
|
||||||
claim or decline restores the unsealed shared battle preparation
|
order and a freed hand slot; pre-plan Sapho has committed tie priority.
|
||||||
without another aggressor change. Thumper waits before its injected
|
Claim or decline retains the lot or unsealed shared battle preparation
|
||||||
|
without another order/aggressor change; a Black Market Semuta reserved
|
||||||
|
as the offered card cannot Commit. Thumper waits before its injected
|
||||||
worm/spice draw; Amal waits after halving personal spice and clearing
|
worm/spice draw; Amal waits after halving personal spice and clearing
|
||||||
earlier opening passes. Mixed-owner battle cards require committed
|
earlier opening passes. Mixed-owner battle cards require committed
|
||||||
owner-only selection of another player's fresh card; mandatory and
|
owner-only selection of another player's fresh card; mandatory and
|
||||||
optional winner cleanup remain separate. Minimal legal AI, JSON/
|
optional winner cleanup remain separate. Minimal legal AI, JSON/
|
||||||
SQLite continuation and competing-room CAS are connected. Mixed
|
SQLite continuation and competing-room CAS are connected. Mixed
|
||||||
arrivals, Once Around/remaining battle-choice Sapho, queued or
|
arrivals, remaining battle-choice Sapho, queued or promise-bound
|
||||||
promise-bound Truthtrance, pending rewards, other producers, full-hand
|
Truthtrance, pending rewards, other producers, full-hand ordering,
|
||||||
ordering, normal Richese starts and deployed acceptance remain
|
normal Richese starts and deployed acceptance remain unfinished.
|
||||||
unfinished. Neutral passes and free-slot guard are product choices.
|
Neutral passes and free-slot guard are product choices.
|
||||||
|
|
||||||
One [natural Advanced Moritani assassination](MORITANI_ASSASSINATE_LEADERS.md#29-september-2026--natural-revealed-card-game)
|
One [natural Advanced Moritani assassination](MORITANI_ASSASSINATE_LEADERS.md#29-september-2026--natural-revealed-card-game)
|
||||||
now proceeds from genuine battle loss to a dead-target Traitor reveal,
|
now proceeds from genuine battle loss to a dead-target Traitor reveal,
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 30 September 2026 — Semuta after Once Around Sapho
|
||||||
|
|
||||||
|
The explicit Richese preview now pauses after a clean Once Around
|
||||||
|
Juice of Sapho public discard and finite bidder reorder, before the
|
||||||
|
lot's settlement suffix. Previously acted bids and funding remain
|
||||||
|
committed; discarding Sapho can admit its formerly full-hand holder
|
||||||
|
to the still-open lot. Every seat sees the neutral offer, and only
|
||||||
|
an eligible held Semuta owner can claim the used card. Claim or
|
||||||
|
all-seat decline resumes the same saved lot and active bidder once.
|
||||||
|
The receipt binds the auction event, bidding round, order, funding,
|
||||||
|
hands and spice. A Black Market Semuta reserved as the offered lot
|
||||||
|
remains in its seller's hand: the neutral offer persists, but the
|
||||||
|
owner's Commit is disabled and an attempted commit rejects before
|
||||||
|
any card moves.
|
||||||
|
|
||||||
|
Genuine auction setup, both first/last and full-hand paths, private
|
||||||
|
projection, JSON corruption checks and authenticated SQLite restart
|
||||||
|
were exercised. A direct production-engine smoke run observed
|
||||||
|
`offer`, active reordered bidder and one recovered physical Sapho.
|
||||||
|
The independent read-only rules/privacy reviews found two defects:
|
||||||
|
Black Market reservation and unbound bidding-round state; both were
|
||||||
|
corrected with focused regressions. `npm run check` passed typecheck,
|
||||||
|
lint and 5,819 offline tests; `npm run build` and all 55 local HTTP
|
||||||
|
tests passed. The phone-width local reference rendered the Once
|
||||||
|
Around guide. No deployed revision was exercised. Remaining
|
||||||
|
battle-choice Sapho, competing parents, full-hand take-before-discard
|
||||||
|
and public Richese starts remain gated.
|
||||||
|
|
||||||
## 30 September 2026 — Semuta after pre-plan Sapho aggressor
|
## 30 September 2026 — Semuta after pre-plan Sapho aggressor
|
||||||
|
|
||||||
The explicit Richese preview now stages a clean pre-plan Juice of
|
The explicit Richese preview now stages a clean pre-plan Juice of
|
||||||
|
|||||||
+10
-10
@@ -12,7 +12,7 @@ Audited 6 September 2026. The ten physical identities and their readable face in
|
|||||||
|
|
||||||
- Player controls show both original plans, exact undialed counts or honest possible totals, the mode choice and a complete internal guide. No victim/death confirmation is added. The card stays physically reserved until standard cleanup; stale events, duplicate requests and corrupted source custody fail before resources change. All four AI profiles use public force possibilities, legal low-dial plans and revealed leader/bounty information. They preserve leader value when Artillery suppresses bounty and leave traitor/explosion precedence intact.
|
- Player controls show both original plans, exact undialed counts or honest possible totals, the mode choice and a complete internal guide. No victim/death confirmation is added. The card stays physically reserved until standard cleanup; stale events, duplicate requests and corrupted source custody fail before resources change. All four AI profiles use public force possibilities, legal low-dial plans and revealed leader/bounty information. They preserve leader value when Artillery suppresses bounty and leave traitor/explosion precedence intact.
|
||||||
|
|
||||||
- Semuta Drug has a bounded preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order and pre-plan aggressor Sapho, and three separate battle discard stages. A fresh Richese roster may include the physical Ix deck: Thumper pauses before worm/spice draw and Amal pauses after halving spice before phase initialization. Multi-owner battle batches reveal candidates only after holder commitment and transfer one other-player card. Private gifts, unresolved promises, reactive arrivals, Once Around/remaining battle-choice Sapho and pending battle rewards remain outside scope. Other producers, full-hand ordering and normal Richese starts are unfinished. Mirror Weapon and Juice of Sapho retain separate bounded paths, not combined-mode certification.
|
- Semuta Drug has a bounded preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, Once Around/movement/pre-plan Sapho and three separate battle discard stages. A fresh Richese roster may include the physical Ix deck: Thumper pauses before worm/spice draw and Amal pauses after halving spice before phase initialization. Multi-owner battle batches reveal candidates only after holder commitment and transfer one other-player card. Private gifts, unresolved promises, reactive arrivals, remaining battle-choice Sapho and pending battle rewards remain outside scope. Other producers, full-hand ordering and normal Richese starts are unfinished. Mirror Weapon and Juice of Sapho retain separate bounded paths, not combined-mode certification.
|
||||||
|
|
||||||
The complete integrated validation checkpoint is recorded in [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md).
|
The complete integrated validation checkpoint is recorded in [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md).
|
||||||
|
|
||||||
@@ -113,14 +113,14 @@ opted-in fresh Richese profile, optionally with the physical Ix deck.
|
|||||||
Neutral all-seat responses, physical one-card recovery, provisional
|
Neutral all-seat responses, physical one-card recovery, provisional
|
||||||
free-slot policy, minimal legal AI and saved continuation work after
|
free-slot policy, minimal legal AI and saved continuation work after
|
||||||
clean ordinary-card, paid Box, retired Ornithopter, private Distrans,
|
clean ordinary-card, paid Box, retired Ornithopter, private Distrans,
|
||||||
final Truthtrance, movement-order/pre-plan aggressor Sapho, Ix-deck
|
final Truthtrance, Once Around/movement/pre-plan Sapho, Ix-deck
|
||||||
Thumper/Amal and three battle discard stages. Pre-plan Sapho commits
|
Thumper/Amal and three battle discard stages. Once Around commits
|
||||||
aggressor tie priority before the neutral offer and resumes unsealed
|
the lot order and hand-slot release before a neutral offer;
|
||||||
battle readiness; Thumper pauses before worm/spice draw, and Amal
|
pre-plan Sapho commits aggressor priority but leaves readiness unsealed.
|
||||||
resumes its already-halved opening once. Multi-owner batches require
|
Thumper pauses before worm/spice draw; Amal resumes its already-halved
|
||||||
committed owner-only selection of one other-player card. Queued
|
opening once. Multi-owner batches require committed owner-only selection
|
||||||
questions, promises, reactive arrivals, Once Around/remaining
|
of one other-player card. Queued questions, promises, reactive
|
||||||
battle-choice Sapho, pending battle rewards and other sources stay
|
arrivals, remaining battle-choice Sapho, pending rewards and other
|
||||||
unconnected.
|
sources stay unconnected.
|
||||||
Full-hand ordering, ten-card inventory, cache custody and public
|
Full-hand ordering, ten-card inventory, cache custody and public
|
||||||
expansion start gate are unchanged.
|
expansion start gate are unchanged.
|
||||||
@@ -21,6 +21,10 @@ promise or competing parent qualifies. A claim may move that consumed
|
|||||||
card, never re-answer the question or rewrite its history; other
|
card, never re-answer the question or rewrite its history; other
|
||||||
Truthtrance continuations remain automatic.
|
Truthtrance continuations remain automatic.
|
||||||
|
|
||||||
|
Clean Once Around Sapho stages `saphoAuctionDiscard` after the open
|
||||||
|
lot's finite bidder reorder, hand-slot release and public card disposal,
|
||||||
|
but before settlement. The saved receipt binds the lot, prior funding,
|
||||||
|
active bidder and event; claim or decline restores bidding once.
|
||||||
Clean movement-order Sapho stages `saphoMovementDiscard` after its
|
Clean movement-order Sapho stages `saphoMovementDiscard` after its
|
||||||
finite first/last queue and public card disposal are committed.
|
finite first/last queue and public card disposal are committed.
|
||||||
The reaction retires before `movementTurn` opens the new actor's turn;
|
The reaction retires before `movementTurn` opens the new actor's turn;
|
||||||
@@ -29,8 +33,8 @@ Clean pre-plan aggressor Sapho stages `saphoAggressorDiscard` after
|
|||||||
the aggressor event, ordinary tie priority and card disposal commit.
|
the aggressor event, ordinary tie priority and card disposal commit.
|
||||||
The receipt binds the same unsealed battle, readiness, player hands
|
The receipt binds the same unsealed battle, readiness, player hands
|
||||||
and reserves. Claim or decline restores shared pre-leader preparation
|
and reserves. Claim or decline restores shared pre-leader preparation
|
||||||
without rerunning the aggressor change. Once Around and remaining
|
without rerunning the aggressor change. Remaining battle-choice Sapho
|
||||||
battle-choice Sapho remain outside this preview.
|
stays outside this preview.
|
||||||
|
|
||||||
Completed Ornithopter movement now offers the same retired public card
|
Completed Ornithopter movement now offers the same retired public card
|
||||||
before its already committed arrival suffix only in a roster without
|
before its already committed arrival suffix only in a roster without
|
||||||
|
|||||||
@@ -65,16 +65,21 @@ The explicit `semuta` development profile starts a fresh Richese roster
|
|||||||
with CHOAM & Richese and optionally the physical Ix deck, without
|
with CHOAM & Richese and optionally the physical Ix deck, without
|
||||||
opening public expansion starts. It interrupts **clean public
|
opening public expansion starts. It interrupts **clean public
|
||||||
ordinary-card, paid Box, retired Ornithopter, used Distrans, final
|
ordinary-card, paid Box, retired Ornithopter, used Distrans, final
|
||||||
Truthtrance, movement-order or pre-plan aggressor Sapho, battle
|
Truthtrance, Once Around, movement-order or pre-plan aggressor Sapho,
|
||||||
and Ix-deck Thumper/Amal discards**, provided the parent transaction
|
battle and Ix-deck Thumper/Amal discards**, provided the parent
|
||||||
has no competing response.
|
transaction has no competing response.
|
||||||
Every seat sees the same neutral fresh-discard opportunity regardless
|
Every seat sees the same neutral fresh-discard opportunity regardless
|
||||||
of hidden Semuta custody.
|
of hidden Semuta custody.
|
||||||
An early-ended Ornithopter advances the saved queue; a completed
|
An early-ended Ornithopter advances the saved queue; a completed
|
||||||
flight resumes one committed arrival only without BG, Ecaz or
|
flight resumes one committed arrival only without BG, Ecaz or
|
||||||
Moritani entry reactions. Distrans's gift remains private with its
|
Moritani entry reactions. Distrans's gift remains private with its
|
||||||
recipient; Truthtrance answer/history and Sapho first/last order are
|
recipient; Truthtrance answer/history and Sapho first/last order are
|
||||||
already bound. A clean pre-plan Sapho aggressor choice commits its
|
already bound. A clean Once Around Sapho discard commits its public
|
||||||
|
finite bidder order, retains prior bids/funding and frees the holder's
|
||||||
|
hand slot before the offer; the saved lot resumes without another
|
||||||
|
reorder. If Semuta itself is reserved as an offered Black Market card,
|
||||||
|
its owner cannot Commit, while every seat still sees the neutral offer.
|
||||||
|
A clean pre-plan Sapho aggressor choice commits its
|
||||||
ordinary tie priority and physical card before the Semuta offer; the
|
ordinary tie priority and physical card before the Semuta offer; the
|
||||||
unsealed battle's shared readiness then resumes without another
|
unsealed battle's shared readiness then resumes without another
|
||||||
aggressor change.
|
aggressor change.
|
||||||
@@ -109,11 +114,11 @@ commitment. Other simultaneous discard producers remain unfinished.
|
|||||||
This neutral all-seat acknowledgement is a conservative **application
|
This neutral all-seat acknowledgement is a conservative **application
|
||||||
policy**, not a newly discovered publisher timing rule. The explicit
|
policy**, not a newly discovered publisher timing rule. The explicit
|
||||||
free-slot requirement is a provisional safety guard, not the printed
|
free-slot requirement is a provisional safety guard, not the printed
|
||||||
resolution of full-hand take-before-discard. Auction, CHOAM market,
|
resolution of full-hand take-before-discard. Normal auction/CHOAM
|
||||||
pending gifts, competing responses, completed Ornithopter arrivals
|
market, pending gifts, competing responses, completed Ornithopter
|
||||||
with BG intrusion, Ecaz Ambassador or Moritani Terror, queued or
|
arrivals with BG intrusion, Ecaz Ambassador or Moritani Terror,
|
||||||
promise-bound Truthtrance, Once Around or remaining battle-choice
|
queued or promise-bound Truthtrance, remaining battle-choice Sapho,
|
||||||
Sapho, pending battle rewards and other typed producers retain their
|
pending battle rewards and other typed producers retain their
|
||||||
automatic continuation; Semuta cannot claim those events yet.
|
automatic continuation; Semuta cannot claim those events yet.
|
||||||
The completed Box's selected
|
The completed Box's selected
|
||||||
search card stays private and its paid shuffle is not repeated
|
search card stays private and its paid shuffle is not repeated
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
|
|||||||
if (definition?.card.effect === 'distrans')
|
if (definition?.card.effect === 'distrans')
|
||||||
return 'Choose a recipient and card in the Distrans transfer panel.';
|
return 'Choose a recipient and card in the Distrans transfer panel.';
|
||||||
if (definition?.card.effect === 'semutaDrug')
|
if (definition?.card.effect === 'semutaDrug')
|
||||||
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement/pre-plan Sapho, battle-card and Ix-deck Thumper/Amal discards are connected; pending rewards, other producers and full-hand sequencing remain unfinished.';
|
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, Once Around/movement/pre-plan Sapho, battle-card and Ix-deck Thumper/Amal discards are connected; pending rewards, other producers and full-hand sequencing remain unfinished.';
|
||||||
return definition && definition.card.effect !== 'karama'
|
return definition && definition.card.effect !== 'karama'
|
||||||
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
|
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
+81
-8
@@ -1153,6 +1153,15 @@ export type Game = {
|
|||||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||||
stateSignature: string;
|
stateSignature: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
kind: 'saphoAuctionDiscard';
|
||||||
|
player: string;
|
||||||
|
card: string;
|
||||||
|
event: string;
|
||||||
|
mode: 'first' | 'last';
|
||||||
|
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||||
|
stateSignature: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'saphoAggressorDiscard';
|
kind: 'saphoAggressorDiscard';
|
||||||
player: string;
|
player: string;
|
||||||
@@ -4745,6 +4754,22 @@ function saphoMovementDiscardSignature(g: Game, c: SaphoMovementDiscardContinuat
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
type SaphoAuctionDiscardContinuation = Extract<
|
||||||
|
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||||
|
{ kind: 'saphoAuctionDiscard' }
|
||||||
|
>;
|
||||||
|
function saphoAuctionDiscardSignature(g: Game, c: SaphoAuctionDiscardContinuation) {
|
||||||
|
return JSON.stringify({
|
||||||
|
player: c.player, card: c.card, event: c.event, mode: c.mode,
|
||||||
|
parent: nullentropyParentSignature(g, c.resume, c.event),
|
||||||
|
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
|
||||||
|
lot: g.richeseAuction, bidding: g.richeseBidding, funding: g.richeseFunding,
|
||||||
|
players: g.players.map(p => ({
|
||||||
|
id: p.id, faction: p.faction, ally: p.ally,
|
||||||
|
hand: p.hand.map(card => card.id), spice: p.spice,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
type SaphoAggressorDiscardContinuation = Extract<
|
type SaphoAggressorDiscardContinuation = Extract<
|
||||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||||
{ kind: 'saphoAggressorDiscard' }
|
{ kind: 'saphoAggressorDiscard' }
|
||||||
@@ -5253,6 +5278,25 @@ function treacheryDiscardIntegrity(g: Game) {
|
|||||||
c.stateSignature === saphoMovementDiscardSignature(g, c),
|
c.stateSignature === saphoMovementDiscardSignature(g, c),
|
||||||
'The used Juice of Sapho no longer matches its committed movement order.',
|
'The used Juice of Sapho no longer matches its committed movement order.',
|
||||||
);
|
);
|
||||||
|
} else if (continuation?.kind === 'saphoAuctionDiscard') {
|
||||||
|
const c = continuation, entry = batch.entries[0], lot = g.richeseAuction;
|
||||||
|
const remaining = lot?.order.filter(id => !lot.acted.includes(id));
|
||||||
|
requireRule(
|
||||||
|
g.phase === 3 && lot?.method === 'onceAround' &&
|
||||||
|
lot.event === c.event && !lot.outcome &&
|
||||||
|
lot.eligible.includes(c.player) && !lot.acted.includes(c.player) &&
|
||||||
|
(c.mode === 'first'
|
||||||
|
? remaining?.[0] === c.player && lot.active === c.player
|
||||||
|
: c.mode === 'last' && remaining?.at(-1) === c.player) &&
|
||||||
|
g.active === lot.active && batch.cause === 'sapho:auction' &&
|
||||||
|
batch.entries.length === 1 && entry.publicFace &&
|
||||||
|
entry.discardedBy === c.player && entry.card.id === c.card &&
|
||||||
|
richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' &&
|
||||||
|
!c.resume.response && !c.resume.decision &&
|
||||||
|
!c.resume.pendingKarama && !c.resume.phaseOpening &&
|
||||||
|
c.stateSignature === saphoAuctionDiscardSignature(g, c),
|
||||||
|
'The used Juice of Sapho no longer matches its committed Once Around order.',
|
||||||
|
);
|
||||||
} else if (continuation?.kind === 'saphoAggressorDiscard') {
|
} else if (continuation?.kind === 'saphoAggressorDiscard') {
|
||||||
const c = continuation, entry = batch.entries[0], battle = g.battle;
|
const c = continuation, entry = batch.entries[0], battle = g.battle;
|
||||||
requireRule(
|
requireRule(
|
||||||
@@ -5928,6 +5972,7 @@ function semutaOfferSupported(
|
|||||||
continuation.kind !== 'distransDiscard' &&
|
continuation.kind !== 'distransDiscard' &&
|
||||||
continuation.kind !== 'saphoMovementDiscard' &&
|
continuation.kind !== 'saphoMovementDiscard' &&
|
||||||
continuation.kind !== 'saphoAggressorDiscard' &&
|
continuation.kind !== 'saphoAggressorDiscard' &&
|
||||||
|
continuation.kind !== 'saphoAuctionDiscard' &&
|
||||||
continuation.kind !== 'thumperDiscard' &&
|
continuation.kind !== 'thumperDiscard' &&
|
||||||
continuation.kind !== 'amalDiscard' &&
|
continuation.kind !== 'amalDiscard' &&
|
||||||
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
||||||
@@ -5981,7 +6026,8 @@ function semutaOfferSupported(
|
|||||||
(continuation.kind === 'amalDiscard'
|
(continuation.kind === 'amalDiscard'
|
||||||
? continuation.resume.phaseOpening?.passed.length === 0
|
? continuation.resume.phaseOpening?.passed.length === 0
|
||||||
: !continuation.resume.phaseOpening))) &&
|
: !continuation.resume.phaseOpening))) &&
|
||||||
!g.karamaShipping && !g.auction && !g.richeseAuction &&
|
!g.karamaShipping && !g.auction &&
|
||||||
|
(continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) &&
|
||||||
(continuation.kind === 'saphoAggressorDiscard' || !g.battle) &&
|
(continuation.kind === 'saphoAggressorDiscard' || !g.battle) &&
|
||||||
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
|
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
|
||||||
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
|
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
|
||||||
@@ -6000,6 +6046,10 @@ function semutaContext(g: Game, owner: Player): SemutaContext {
|
|||||||
}
|
}
|
||||||
function semutaCandidates(g: Game, owner: Player) {
|
function semutaCandidates(g: Game, owner: Player) {
|
||||||
const context = semutaContext(g, owner);
|
const context = semutaContext(g, owner);
|
||||||
|
const semuta = owner.hand.find(card => card.id === SEMUTA_DRUG_ID &&
|
||||||
|
richeseCardDefinition(card)?.card.effect === 'semutaDrug');
|
||||||
|
requireRule(semuta && !transferCardBlock(g, owner, semuta),
|
||||||
|
'This Semuta Drug is reserved for an unfinished transaction.');
|
||||||
try {
|
try {
|
||||||
return committedSemutaCandidates(context,
|
return committedSemutaCandidates(context,
|
||||||
{ event: context.event, player: owner.id, semutaId: SEMUTA_DRUG_ID });
|
{ event: context.event, player: owner.id, semutaId: SEMUTA_DRUG_ID });
|
||||||
@@ -6019,14 +6069,16 @@ function projectedSemutaReaction(g: Game, me: Player) {
|
|||||||
candidates: reaction.player === me.id ? semutaCandidates(g, me) : [] as Card[],
|
candidates: reaction.player === me.id ? semutaCandidates(g, me) : [] as Card[],
|
||||||
};
|
};
|
||||||
const passed = reaction.passed.includes(me.id);
|
const passed = reaction.passed.includes(me.id);
|
||||||
const held = me.hand.some(card => card.id === SEMUTA_DRUG_ID &&
|
const semuta = me.hand.find(card => card.id === SEMUTA_DRUG_ID &&
|
||||||
richeseCardDefinition(card)?.card.effect === 'semutaDrug');
|
richeseCardDefinition(card)?.card.effect === 'semutaDrug');
|
||||||
|
const held = !!semuta;
|
||||||
const blocked = !held || passed ? null
|
const blocked = !held || passed ? null
|
||||||
: me.hand.length >= handLimit(me)
|
: transferCardBlock(g, me, semuta!) ??
|
||||||
? 'A free hand slot is required before taking a card with Semuta Drug.'
|
(me.hand.length >= handLimit(me)
|
||||||
: pending.batch.entries.every(entry => entry.discardedBy === me.id)
|
? 'A free hand slot is required before taking a card with Semuta Drug.'
|
||||||
? 'No other player discarded a card in this fresh event.'
|
: pending.batch.entries.every(entry => entry.discardedBy === me.id)
|
||||||
: null;
|
? 'No other player discarded a card in this fresh event.'
|
||||||
|
: null);
|
||||||
return {
|
return {
|
||||||
event: pending.batch.event, stage: 'offer' as const, passed,
|
event: pending.batch.event, stage: 'offer' as const, passed,
|
||||||
canCommit: held && !passed && !blocked, blocked, candidates: [] as Card[],
|
canCommit: held && !passed && !blocked, blocked, candidates: [] as Card[],
|
||||||
@@ -6150,6 +6202,12 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
|||||||
g.pendingKarama = next.resume.pendingKarama;
|
g.pendingKarama = next.resume.pendingKarama;
|
||||||
g.phaseOpening = next.resume.phaseOpening;
|
g.phaseOpening = next.resume.phaseOpening;
|
||||||
if (g.active !== g.movementRemaining?.[0]) movementTurn(g);
|
if (g.active !== g.movementRemaining?.[0]) movementTurn(g);
|
||||||
|
} else if (next.kind === 'saphoAuctionDiscard') {
|
||||||
|
g.response = next.resume.response;
|
||||||
|
g.decision = next.resume.decision;
|
||||||
|
g.pendingKarama = next.resume.pendingKarama;
|
||||||
|
g.phaseOpening = next.resume.phaseOpening;
|
||||||
|
settleRicheseLot(g);
|
||||||
} else if (next.kind === 'saphoAggressorDiscard') {
|
} else if (next.kind === 'saphoAggressorDiscard') {
|
||||||
g.response = next.resume.response;
|
g.response = next.resume.response;
|
||||||
g.decision = next.resume.decision;
|
g.decision = next.resume.decision;
|
||||||
@@ -14495,12 +14553,27 @@ function playSapho(g: Game, p: Player, action: Action) {
|
|||||||
option.mode,
|
option.mode,
|
||||||
);
|
);
|
||||||
g.active = g.richeseAuction.active;
|
g.active = g.richeseAuction.active;
|
||||||
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 bid ${option.mode} in this Once Around auction. Completed bids and their funding remain committed; no bidder receives another bid.`,
|
`${p.name} discarded Juice of Sapho to bid ${option.mode} in this Once Around auction. Completed bids and their funding remain committed; no bidder receives another bid.`,
|
||||||
{ faction: p.faction, name: 'Juice of Sapho' },
|
{ faction: p.faction, name: 'Juice of Sapho' },
|
||||||
);
|
);
|
||||||
|
if (g.semutaPreview) {
|
||||||
|
const continuation: SaphoAuctionDiscardContinuation = {
|
||||||
|
kind: 'saphoAuctionDiscard', player: p.id, card: used.id,
|
||||||
|
event: lot.event, mode: option.mode,
|
||||||
|
resume: { response: g.response, decision: g.decision,
|
||||||
|
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||||
|
stateSignature: '',
|
||||||
|
};
|
||||||
|
const entries = [{ card: used, discardedBy: p.id, publicFace: true }];
|
||||||
|
if (semutaOfferSupported(g, continuation, entries)) {
|
||||||
|
continuation.stateSignature = saphoAuctionDiscardSignature(g, continuation);
|
||||||
|
stageTreacheryDiscard(g, 'sapho:auction', entries, continuation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
settleRicheseLot(g);
|
settleRicheseLot(g);
|
||||||
} else if (option.scope === 'battleOrder') {
|
} else if (option.scope === 'battleOrder') {
|
||||||
const next = battleOrderRule(() => reorderBattleChoosers({state: g.battleOrder, physicalOrder: g.order,
|
const next = battleOrderRule(() => reorderBattleChoosers({state: g.battleOrder, physicalOrder: g.order,
|
||||||
|
|||||||
+5
-5
@@ -488,7 +488,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'During your open pre-plan preparation, use Sapho to become the current battle’s aggressor before declaring ready. You win ordinary ties, while the Habbanya Stronghold advantage still takes precedence. Physical participants, plans and later battle-choice order do not swap. The accepted priority survives refresh.',
|
'During your open pre-plan preparation, use Sapho to become the current battle’s aggressor before declaring ready. You win ordinary ties, while the Habbanya Stronghold advantage still takes precedence. Physical participants, plans and later battle-choice order do not swap. The accepted priority survives refresh.',
|
||||||
'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.',
|
||||||
'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.',
|
'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, a clean movement-order Sapho discard pauses after its queue reorder; a clean pre-plan aggressor discard pauses after tie priority changes but before battle readiness. Every seat receives the same neutral offer. Claim or decline transfers only the used card or leaves it discarded; the original queue or unsealed battle stays intact. Once Around and remaining battle-choice Sapho discards remain automatic without this preview reaction.',
|
'In the explicitly opted-in Semuta preview, a clean Once Around discard pauses after reordering remaining bidders and freeing its hand slot, a clean movement-order discard pauses after its queue reorder, and a clean pre-plan aggressor discard pauses after tie priority changes. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; the committed lot/queue or unsealed battle then continues once. Remaining battle-choice Sapho discards remain automatic without this reaction.',
|
||||||
'These are the supported development controls, not additional printed restrictions. Later aggressor intervention, ordinary cyclic auction scope, other phase ordering and intervention during a partly completed combined turn remain unfinished. Silent bids are simultaneous; their storm-order tie rule is unchanged.',
|
'These are the supported development controls, not additional printed restrictions. Later aggressor intervention, ordinary cyclic auction scope, other phase ordering and intervention during a partly completed combined turn remain unfinished. Silent bids are simultaneous; their storm-order tie rule is unchanged.',
|
||||||
],
|
],
|
||||||
example:
|
example:
|
||||||
@@ -1156,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
: definition.card.effect === 'portableSnooper'
|
: definition.card.effect === 'portableSnooper'
|
||||||
? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.'
|
? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.'
|
||||||
: definition.card.effect === 'semutaDrug'
|
: definition.card.effect === 'semutaDrug'
|
||||||
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean public discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before its worm/spice draw, and Amal pauses after halving spice before its opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, clean movement-order or pre-plan aggressor Sapho and three battle batches. Commit held Semuta with a free hand slot; for several eligible cards, inspect and select exactly one after commitment. Original effects and an unsealed battle resume once. Nested responses, remaining Sapho order scopes, pending rewards, full-hand sequencing and public starts remain unfinished.'
|
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean public discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before its opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, clean Once Around/movement/pre-plan Sapho and three battle batches. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. The committed lot, queue or unsealed battle resumes once. Nested responses, remaining Sapho scope, pending rewards, full-hand sequencing and public starts remain unfinished.'
|
||||||
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
||||||
],
|
],
|
||||||
...(definition.card.effect === 'semutaDrug'
|
...(definition.card.effect === 'semutaDrug'
|
||||||
@@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
area: 'Implementation' as const,
|
area: 'Implementation' as const,
|
||||||
status: 'Partial' as const,
|
status: 'Partial' as const,
|
||||||
detail:
|
detail:
|
||||||
'The explicit Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order and pre-plan aggressor Sapho, Ix-deck Thumper/Amal, and three battle discard stages. Sapho’s aggressor priority is committed before the offer; the pre-leader battle remains unsealed afterward. Thumper waits before worm/spice draw; Amal restores an already-halved phase opening. Neutral offers and committed owner-only candidates remain private, and Semuta transfers one physical card. Competing responses, pending rewards, other producers and full-hand exchange remain unfinished.',
|
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, Once Around/movement/pre-plan Sapho, Ix-deck Thumper/Amal and three battle discard stages. Once Around commits eligible bidders and finite order before the offer; pre-plan Sapho commits aggressor priority while readiness remains unsealed. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Neutral offers, owner-only candidates and one physical Semuta transfer retain privacy. Competing responses, pending rewards, other producers and full-hand exchange remain unfinished.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
area: 'Player controls' as const,
|
area: 'Player controls' as const,
|
||||||
@@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
area: 'Documentation' as const,
|
area: 'Documentation' as const,
|
||||||
status: 'Partial' as const,
|
status: 'Partial' as const,
|
||||||
detail:
|
detail:
|
||||||
'The guide distinguishes clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement/pre-plan Sapho, Ix-deck Thumper/Amal and three battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
|
'The guide distinguishes clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, Once Around/movement/pre-plan Sapho, Ix-deck Thumper/Amal and three battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
area: 'Verification' as const,
|
area: 'Verification' as const,
|
||||||
status: 'Partial' as const,
|
status: 'Partial' as const,
|
||||||
detail:
|
detail:
|
||||||
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement/pre-plan Sapho, Ix-deck Thumper/Amal and three battle discard stages. Remaining Sapho scopes, pending rewards, full games and live deployment remain unverified.',
|
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, Once Around/movement/pre-plan Sapho, Ix-deck Thumper/Amal and three battle discard stages. Remaining Sapho scope, pending rewards, full games and live deployment remain unverified.',
|
||||||
evidence: [
|
evidence: [
|
||||||
'tests/semuta-engine.test.ts',
|
'tests/semuta-engine.test.ts',
|
||||||
'tests/semuta-recovery.test.ts',
|
'tests/semuta-recovery.test.ts',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../game/engine';
|
} from '../game/engine';
|
||||||
import { baseDeck, leaders } from '../game/cards';
|
import { baseDeck, leaders } from '../game/cards';
|
||||||
import { richeseCards } from '../game/richese-cards';
|
import { richeseCards } from '../game/richese-cards';
|
||||||
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||||
import { botActions } from '../game/bots';
|
import { botActions } from '../game/bots';
|
||||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
import { JuiceOfSapho } from '../components/juice-of-sapho';
|
import { JuiceOfSapho } from '../components/juice-of-sapho';
|
||||||
@@ -623,6 +624,79 @@ void test('discarding Sapho creates eligibility for a full-hand holder excluded
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('Semuta pauses a clean Once Around Sapho order and preserves remaining bidders', () => {
|
||||||
|
for (const mode of ['first', 'last'] as const) {
|
||||||
|
let g = auction('e', 'onceAround', true);
|
||||||
|
g.semutaPreview = true;
|
||||||
|
g.expansions = ['choam'];
|
||||||
|
const semuta = g.richeseCache!.findIndex(c => c.id === SEMUTA_DRUG_ID);
|
||||||
|
assert.ok(semuta >= 0);
|
||||||
|
g.players[0].hand.push(g.richeseCache!.splice(semuta, 1)[0]);
|
||||||
|
const original = reload(g), lotEvent = g.richeseAuction!.event;
|
||||||
|
g = applyAction(g, 'e', action(g, mode));
|
||||||
|
const batchEvent = g.pendingTreacheryDiscard!.batch.event;
|
||||||
|
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||||
|
assert.equal(g.richeseAuction?.event, lotEvent);
|
||||||
|
assert.equal(g.richeseAuction?.eligible.includes('e'), true);
|
||||||
|
assert.equal(g.players[1].hand.length, 3);
|
||||||
|
assert.equal(viewGame(g, 'a').semutaReaction?.canCommit, true);
|
||||||
|
assert.equal(viewGame(g, 'g').semutaReaction?.canCommit, false);
|
||||||
|
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
|
||||||
|
for (const [index, change] of [
|
||||||
|
(saved: Game) => { saved.richeseAuction!.order.reverse(); },
|
||||||
|
(saved: Game) => { saved.richeseAuction!.bid = 5; },
|
||||||
|
(saved: Game) => { saved.richeseAuction!.active = mode === 'first' ? 'a' : 'e'; saved.active = saved.richeseAuction!.active; },
|
||||||
|
(saved: Game) => { saved.richeseBidding!.position = 'last'; },
|
||||||
|
(saved: Game) => { saved.players[1].hand.pop(); },
|
||||||
|
].entries()) {
|
||||||
|
const corrupt = reload(g), before = reload(corrupt);
|
||||||
|
change(corrupt);
|
||||||
|
const unchanged = reload(corrupt);
|
||||||
|
assert.throws(() => applyAction(corrupt, 'a', { type: 'semutaCommit', event: batchEvent }), `corruption ${index}`);
|
||||||
|
assert.deepEqual(corrupt, unchanged);
|
||||||
|
assert.notDeepEqual(corrupt, before);
|
||||||
|
}
|
||||||
|
let declined = reload(g);
|
||||||
|
for (const id of ['a', 'e', 'g'])
|
||||||
|
declined = applyAction(declined, id, { type: 'semutaPass', event: batchEvent });
|
||||||
|
assert.equal(declined.pendingTreacheryDiscard, null);
|
||||||
|
assert.equal(declined.discard.filter(c => c.id === card).length, 1);
|
||||||
|
const claimed = applyAction(reload(g), 'a', { type: 'semutaCommit', event: batchEvent });
|
||||||
|
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||||||
|
assert.deepEqual(claimed.richeseAuction, declined.richeseAuction);
|
||||||
|
assert.equal(claimed.players[0].hand.filter(c => c.id === card).length, 1);
|
||||||
|
assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
||||||
|
assert.equal(claimed.richeseAuction?.acted.length, original.richeseAuction?.acted.length);
|
||||||
|
assert.throws(() => applyAction(claimed, 'a', { type: 'semutaCommit', event: batchEvent }));
|
||||||
|
const next = claimed.richeseAuction!.active!;
|
||||||
|
const continued = bid(claimed, next, 1);
|
||||||
|
assert.equal(continued.richeseAuction?.acted.includes(next), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a reserved Black Market Semuta cannot claim an auction Sapho discard', () => {
|
||||||
|
let g = auction('e');
|
||||||
|
g.semutaPreview = true;
|
||||||
|
g.expansions = ['choam'];
|
||||||
|
const semuta = g.richeseCache!.findIndex(c => c.id === SEMUTA_DRUG_ID);
|
||||||
|
assert.ok(semuta >= 0);
|
||||||
|
g.players[2].hand.push(g.richeseCache!.splice(semuta, 1)[0]);
|
||||||
|
g.richeseAuction!.source = 'blackMarket';
|
||||||
|
g.richeseAuction!.cardId = SEMUTA_DRUG_ID;
|
||||||
|
g = applyAction(g, 'e', action(g, 'first'));
|
||||||
|
const event = g.pendingTreacheryDiscard!.batch.event;
|
||||||
|
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||||
|
assert.equal(viewGame(g, 'g').semutaReaction?.canCommit, false);
|
||||||
|
assert.match(viewGame(g, 'g').semutaReaction?.blocked ?? '', /Black Market/);
|
||||||
|
const before = reload(g);
|
||||||
|
assert.throws(() => applyAction(g, 'g', { type: 'semutaCommit', event }));
|
||||||
|
assert.deepEqual(reload(g), before);
|
||||||
|
for (const id of ['a', 'e', 'g'])
|
||||||
|
g = applyAction(g, id, { type: 'semutaPass', event });
|
||||||
|
assert.equal(g.pendingTreacheryDiscard, null);
|
||||||
|
assert.equal(g.players[2].hand.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
void test('Sapho options are owner-only and do not reveal hidden cards through another player view', () => {
|
void test('Sapho options are owner-only and do not reveal hidden cards through another player view', () => {
|
||||||
const withCard = movement();
|
const withCard = movement();
|
||||||
assert.ok(viewGame(withCard, 'e').saphoOptions.length);
|
assert.ok(viewGame(withCard, 'e').saphoOptions.length);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import * as bots from '../game/bots';
|
|||||||
import * as seatAiDelegation from '../lib/seat-ai-delegation';
|
import * as seatAiDelegation from '../lib/seat-ai-delegation';
|
||||||
import { baseDeck } from '../game/cards';
|
import { baseDeck } from '../game/cards';
|
||||||
import { richeseCards } from '../game/richese-cards';
|
import { richeseCards } from '../game/richese-cards';
|
||||||
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||||
import type * as Rooms from '../db/rooms';
|
import type * as Rooms from '../db/rooms';
|
||||||
|
|
||||||
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
|
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
|
||||||
@@ -552,3 +553,39 @@ void test('persisted last protection survives the Guild choosing to wait at a la
|
|||||||
f.sqlite.close();
|
f.sqlite.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('authenticated Once Around Sapho offer survives restart and one committed claim', async () => {
|
||||||
|
const f = await fixture('onceAround');
|
||||||
|
try {
|
||||||
|
let g = await stable(f);
|
||||||
|
g.semutaPreview = true;
|
||||||
|
g.expansions = ['choam'];
|
||||||
|
const semuta = g.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
||||||
|
assert.ok(semuta >= 0);
|
||||||
|
g.players[0].hand.push(g.richeseCache!.splice(semuta, 1)[0]);
|
||||||
|
f.save(g);
|
||||||
|
const cards = physical(g), lotEvent = g.richeseAuction!.event;
|
||||||
|
g = await act(f, 1, action('onceAround', lotEvent, 'first'));
|
||||||
|
const offered = g, event = g.pendingTreacheryDiscard!.batch.event;
|
||||||
|
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||||
|
assert.equal(g.richeseAuction?.active, f.seats[1].playerId);
|
||||||
|
assert.deepEqual(await stable(f), offered);
|
||||||
|
const holder = await f.restart().readSeatView(f.code, f.seats[0]);
|
||||||
|
const outsider = await f.restart().readSeatView(f.code, f.seats[2]);
|
||||||
|
assert.equal(holder.semutaReaction?.canCommit, true);
|
||||||
|
assert.equal(outsider.semutaReaction?.canCommit, false);
|
||||||
|
g = await act(f, 0, { type: 'semutaCommit', event });
|
||||||
|
assert.equal(g.pendingTreacheryDiscard, null);
|
||||||
|
assert.equal(g.richeseAuction?.event, lotEvent);
|
||||||
|
assert.equal(g.richeseAuction?.active, f.seats[1].playerId);
|
||||||
|
assert.equal(g.players[0].hand.filter(card => card.id === SAPHO).length, 1);
|
||||||
|
assert.equal(g.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
||||||
|
assert.deepEqual(physical(g), cards);
|
||||||
|
assert.deepEqual(await stable(f), g);
|
||||||
|
g = await act(f, 1, bid(g, 2));
|
||||||
|
assert.ok(g.richeseAuction?.acted.includes(f.seats[1].playerId));
|
||||||
|
assert.deepEqual(physical(g), cards);
|
||||||
|
} finally {
|
||||||
|
f.sqlite.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user