Offer Semuta after clean Sapho movement reorder
This commit is contained in:
1 parent
836af6fb3a
commit
eb4df0b99c
9 files changed
+281
-40
No files matched your search
+85
-4
@@ -1143,6 +1143,16 @@ export type Game = {
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'saphoMovementDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
event: string;
|
||||
mode: 'first' | 'last';
|
||||
previousActive: string;
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'truthtranceDiscard';
|
||||
consumed: TruthQueueEntry;
|
||||
@@ -4681,6 +4691,25 @@ function distransDiscardSignature(g: Game, c: DistransDiscardContinuation) {
|
||||
recipientHand: recipient.hand.map(card => card.id),
|
||||
});
|
||||
}
|
||||
type SaphoMovementDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'saphoMovementDiscard' }
|
||||
>;
|
||||
function saphoMovementDiscardSignature(g: Game, c: SaphoMovementDiscardContinuation) {
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, event: c.event, mode: c.mode,
|
||||
previousActive: c.previousActive,
|
||||
parent: nullentropyParentSignature(g, c.resume, c.event),
|
||||
turn: g.turn, phase: g.phase, advanced: g.advanced,
|
||||
active: g.active, order: g.order,
|
||||
remaining: g.movementRemaining, protectedLast: g.saphoMovementLast,
|
||||
guildTimingGranted: g.guildTimingGranted, guildTimingLocked: g.guildTimingLocked,
|
||||
hajr: g.hajr, karamaShipping: g.karamaShipping, choamMovement: g.choamMovement,
|
||||
players: g.players.map(p => ({
|
||||
id: p.id, faction: p.faction, moved: p.moved, shipped: p.shipped,
|
||||
})),
|
||||
});
|
||||
}
|
||||
type TruthDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'truthtranceDiscard' }
|
||||
@@ -5095,6 +5124,34 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
c.stateSignature === distransDiscardSignature(g, c),
|
||||
'The completed private Distrans transfer no longer matches its fresh discard.',
|
||||
);
|
||||
} else if (continuation?.kind === 'saphoMovementDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
requireRule(
|
||||
g.phase === 5 && g.active === c.previousActive &&
|
||||
c.event === `movement:${g.turn}` &&
|
||||
g.players.some(p => p.id === c.player) &&
|
||||
Array.isArray(g.movementRemaining) &&
|
||||
g.movementRemaining.length > 1 &&
|
||||
new Set(g.movementRemaining).size === g.movementRemaining.length &&
|
||||
g.movementRemaining.every(id => g.order.includes(id)) &&
|
||||
g.movementRemaining.includes(c.previousActive) &&
|
||||
(c.mode === 'first'
|
||||
? g.movementRemaining[0] === c.player &&
|
||||
c.previousActive !== c.player && !g.saphoMovementLast
|
||||
: c.mode === 'last' &&
|
||||
g.movementRemaining.at(-1) === c.player &&
|
||||
g.saphoMovementLast?.event === c.event &&
|
||||
g.saphoMovementLast.turn === g.turn &&
|
||||
g.saphoMovementLast.player === c.player) &&
|
||||
batch.cause === 'sapho:movement' && 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 === saphoMovementDiscardSignature(g, c),
|
||||
'The used Juice of Sapho no longer matches its committed movement order.',
|
||||
);
|
||||
} else if (continuation?.kind === 'truthtranceDiscard') {
|
||||
const c = continuation,
|
||||
entry = batch.entries[0],
|
||||
@@ -5685,8 +5742,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
} else throw new RuleError('Unknown saved discard continuation.');
|
||||
}
|
||||
/** Bounded public single-card reaction after a completed ordinary effect, paid
|
||||
* Box search, early-ended Ornithopter flight, clean Distrans transfer or final
|
||||
* definite Truthtrance answer without a new promise or parent transaction. */
|
||||
* Box search, early-ended Ornithopter, clean Distrans transfer, final definite
|
||||
* Truthtrance answer or clean Sapho movement reorder. */
|
||||
function semutaOfferSupported(
|
||||
g: Game,
|
||||
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
@@ -5695,11 +5752,14 @@ function semutaOfferSupported(
|
||||
if (continuation.kind !== 'ordinaryCardDiscard' &&
|
||||
continuation.kind !== 'nullentropyDiscard' &&
|
||||
continuation.kind !== 'distransDiscard' &&
|
||||
continuation.kind !== 'saphoMovementDiscard' &&
|
||||
!(continuation.kind === 'ornithopterDiscard' && continuation.source === 'end') &&
|
||||
!(continuation.kind === 'truthtranceDiscard' &&
|
||||
continuation.consumed.source === undefined &&
|
||||
continuation.remaining === null && continuation.promise === null)) return false;
|
||||
if ((continuation.kind === 'distransDiscard' || continuation.kind === 'truthtranceDiscard') &&
|
||||
if ((continuation.kind === 'distransDiscard' ||
|
||||
continuation.kind === 'truthtranceDiscard' ||
|
||||
continuation.kind === 'saphoMovementDiscard') &&
|
||||
(g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove ||
|
||||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false;
|
||||
return g.semutaPreview === true && g.status === 'playing' &&
|
||||
@@ -5869,6 +5929,12 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
g.decision = next.resume.decision;
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
} else if (next.kind === 'saphoMovementDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
if (g.active !== g.movementRemaining?.[0]) movementTurn(g);
|
||||
} else if (next.kind === 'ornithopterDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
@@ -14214,12 +14280,27 @@ function playSapho(g: Game, p: Player, action: Action) {
|
||||
g.movementRemaining = [...next.remaining];
|
||||
if (option.mode === 'last')
|
||||
g.saphoMovementLast = { event: option.event, turn: g.turn, player: p.id };
|
||||
discard(g, p, 'richese-juice-of-sapho');
|
||||
const used = discard(g, p, 'richese-juice-of-sapho');
|
||||
log(
|
||||
g,
|
||||
`${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}. Completed turns and physical storm order are unchanged.`,
|
||||
{ faction: p.faction, name: 'Juice of Sapho' },
|
||||
);
|
||||
if (g.semutaPreview) {
|
||||
const continuation: SaphoMovementDiscardContinuation = {
|
||||
kind: 'saphoMovementDiscard', player: p.id, card: used.id,
|
||||
event: option.event, mode: option.mode, previousActive: g.active!,
|
||||
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 = saphoMovementDiscardSignature(g, continuation);
|
||||
stageTreacheryDiscard(g, 'sapho:movement', entries, continuation);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Preserve the Guild decision already settled for an unchanged current actor.
|
||||
if (g.active !== g.movementRemaining[0]) movementTurn(g);
|
||||
}
|
||||
|
||||
+6
-5
@@ -488,6 +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.',
|
||||
'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.',
|
||||
'In the explicitly opted-in Semuta preview, a clean movement-order Sapho discard pauses after its queue reorder. Every seat receives the same neutral offer before the active turn changes. A claim transfers only the used Sapho card; the saved queue and any protected last position resume once. Auction and battle Sapho discards remain automatic without this preview 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.',
|
||||
],
|
||||
example:
|
||||
@@ -704,7 +705,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Synchronous two-card custody, private transfer, normal Distrans disposal, both-card reservations and prospective transaction/promise checks are integrated. Open-lot timing and self-transfer remain explicit guards; Semuta reaction to Distrans disposal is not yet integrated.',
|
||||
'Synchronous two-card custody, private transfer, normal Distrans disposal, both-card reservations and prospective transaction/promise checks are integrated. The explicit Semuta preview offers a neutral reaction only after a clean completed transfer; open-lot timing, competing transactions and self-transfer remain guarded.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
@@ -1155,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
: 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.'
|
||||
: definition.card.effect === 'semutaDrug'
|
||||
? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard, completed paid Box search, early-ended Ornithopter flight, clean private Distrans transfer or final definite Truthtrance answer. Continue or commit held Semuta with a free hand slot; only the freshly discarded public card transfers. A Distrans gift remains private with its recipient, and the Truthtrance answer/history is already bound. Competing transactions, later queued questions, full-hand sequencing and normal Richese starts remain unfinished.'
|
||||
? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard, paid Box search, early-ended Ornithopter flight, private Distrans transfer, final definite Truthtrance answer or completed movement-order Sapho discard. Continue or commit held Semuta with a free hand slot; only the freshly discarded public card transfers. Private gifts and bound answers stay intact; a Sapho claim resumes its saved movement queue once. Competing transactions, auction and battle Sapho, full-hand sequencing and normal Richese starts remain unfinished.'
|
||||
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
||||
],
|
||||
...(definition.card.effect === 'semutaDrug'
|
||||
@@ -1165,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation' as const,
|
||||
status: 'Partial' as const,
|
||||
detail:
|
||||
'An explicitly opted-in Richese profile interrupts clean public ordinary-card, paid Box, early-ended Ornithopter, private Distrans and final definite Truthtrance discards with a neutral all-seat offer. Committing held Semuta claims only the other-player discarded card and discards Semuta. The private gift stays with its recipient, the answered Truthtrance history remains public, and saved effects or movement resume once. Promise-bound or queued questions, competing transactions, other typed producers and full-hand exchange remain unfinished.',
|
||||
'An explicitly opted-in Richese profile interrupts clean public ordinary-card, paid Box, early-ended Ornithopter, private Distrans, final definite Truthtrance and movement-order Sapho discards with a neutral all-seat offer. Committing held Semuta claims only the other-player discarded card and discards Semuta. Private gifts and answered history remain intact; completed effects, Sapho ordering and movement resume once. Auction/battle Sapho, competing transactions, other typed producers and full-hand exchange remain unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls' as const,
|
||||
@@ -1183,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Documentation' as const,
|
||||
status: 'Partial' as const,
|
||||
detail:
|
||||
'The guide distinguishes bounded public ordinary-card, paid Box, early-ended Ornithopter, clean Distrans and final definite Truthtrance previews, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.',
|
||||
'The guide distinguishes bounded public ordinary-card, paid Box, early-ended Ornithopter, clean Distrans, final definite Truthtrance and movement-order Sapho previews, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.',
|
||||
},
|
||||
{
|
||||
area: 'Verification' as const,
|
||||
status: 'Partial' as const,
|
||||
detail:
|
||||
'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, early-ended Ornithopter, private Distrans and final definite Truthtrance reactions. Other discard producers, full games and live deployment remain unverified.',
|
||||
'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, early-ended Ornithopter, private Distrans, final definite Truthtrance and movement-order Sapho reactions. Other discard producers, full games and live deployment remain unverified.',
|
||||
evidence: [
|
||||
'tests/semuta-engine.test.ts',
|
||||
'tests/semuta-recovery.test.ts',
|
||||
|
||||
Reference in new issue
Block a user