Offer Semuta after clean Once Around Sapho discard

This commit is contained in:
Quintine committed 2026-09-30 11:07:09 +10:00
1 parent 9872efcc1d
commit 989a986d68
10 files changed
+268 -45

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
if (definition?.card.effect === 'distrans')
return 'Choose a recipient and card in the Distrans transfer panel.';
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'
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
: null;
+81 -8
View File
@@ -1153,6 +1153,15 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'saphoAuctionDiscard';
player: string;
card: string;
event: string;
mode: 'first' | 'last';
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'saphoAggressorDiscard';
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<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'saphoAggressorDiscard' }
@@ -5253,6 +5278,25 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === saphoMovementDiscardSignature(g, c),
'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') {
const c = continuation, entry = batch.entries[0], battle = g.battle;
requireRule(
@@ -5928,6 +5972,7 @@ function semutaOfferSupported(
continuation.kind !== 'distransDiscard' &&
continuation.kind !== 'saphoMovementDiscard' &&
continuation.kind !== 'saphoAggressorDiscard' &&
continuation.kind !== 'saphoAuctionDiscard' &&
continuation.kind !== 'thumperDiscard' &&
continuation.kind !== 'amalDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
@@ -5981,7 +6026,8 @@ function semutaOfferSupported(
(continuation.kind === 'amalDiscard'
? continuation.resume.phaseOpening?.passed.length === 0
: !continuation.resume.phaseOpening))) &&
!g.karamaShipping && !g.auction && !g.richeseAuction &&
!g.karamaShipping && !g.auction &&
(continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) &&
(continuation.kind === 'saphoAggressorDiscard' || !g.battle) &&
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
@@ -6000,6 +6046,10 @@ function semutaContext(g: Game, owner: Player): SemutaContext {
}
function semutaCandidates(g: Game, owner: Player) {
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 {
return committedSemutaCandidates(context,
{ 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[],
};
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');
const held = !!semuta;
const blocked = !held || passed ? null
: me.hand.length >= handLimit(me)
? 'A free hand slot is required before taking a card with Semuta Drug.'
: pending.batch.entries.every(entry => entry.discardedBy === me.id)
? 'No other player discarded a card in this fresh event.'
: null;
: transferCardBlock(g, me, semuta!) ??
(me.hand.length >= handLimit(me)
? 'A free hand slot is required before taking a card with Semuta Drug.'
: pending.batch.entries.every(entry => entry.discardedBy === me.id)
? 'No other player discarded a card in this fresh event.'
: null);
return {
event: pending.batch.event, stage: 'offer' as const, passed,
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.phaseOpening = next.resume.phaseOpening;
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') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -14495,12 +14553,27 @@ function playSapho(g: Game, p: Player, action: Action) {
option.mode,
);
g.active = g.richeseAuction.active;
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 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' },
);
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);
} else if (option.scope === 'battleOrder') {
const next = battleOrderRule(() => reorderBattleChoosers({state: g.battleOrder, physicalOrder: g.order,
+5 -5
View File
@@ -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.',
'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; 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.',
],
example:
@@ -1156,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 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.',
],
...(definition.card.effect === 'semutaDrug'
@@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation' as const,
status: 'Partial' as const,
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,
@@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Documentation' as const,
status: 'Partial' as const,
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,
status: 'Partial' as const,
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: [
'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts',