Offer Semuta before Ix-deck Thumper Spice Blow

This commit is contained in:
Quintine committed 2026-09-30 10:04:48 +10:00
1 parent dd6d3a89ca
commit f0eafc175e
11 files changed
+313 -48

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 completed ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and battle-card batches 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, movement-order Sapho, battle-card and Ix-deck Thumper 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;
+76 -5
View File
@@ -1153,6 +1153,13 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'thumperDiscard';
player: string;
card: string;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'truthtranceDiscard';
consumed: TruthQueueEntry;
@@ -4721,6 +4728,28 @@ function saphoMovementDiscardSignature(g: Game, c: SaphoMovementDiscardContinuat
})),
});
}
type ThumperDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'thumperDiscard' }
>;
function thumperDiscardSignature(g: Game, c: ThumperDiscardContinuation) {
return JSON.stringify({
player: c.player, card: c.card,
parent: nullentropyParentSignature(g, c.resume, `thumper:${c.card}`),
turn: g.turn, phase: g.phase, advanced: g.advanced,
expansions: g.expansions, active: g.active, order: g.order,
ready: g.ready, storm: g.storm, nexus: g.nexus, sandtrout: g.sandtrout,
summonedBeforeBlow: g.summonedBeforeBlow, wormRides: g.wormRides,
wormPlacementCanceledTurn: g.wormPlacementCanceledTurn,
spiceDeck: g.spiceDeck, spiceDiscard: g.spiceDiscard, spice: g.spice,
aid: g.aid, allianceOffers: g.allianceOffers,
players: g.players.map(p => ({
id: p.id, faction: p.faction, ally: p.ally, forces: p.forces,
reserves: p.reserves, tanks: p.tanks, advisors: p.advisors,
noField: p.noField, elites: p.elites,
})),
});
}
type TruthDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'truthtranceDiscard' }
@@ -5163,6 +5192,21 @@ 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 === 'thumperDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
g.players.some(p => p.id === c.player) &&
g.phase === 1 && !g.spiceSequence && !g.spiceResolution &&
!g.spiceWindow && !g.nexus &&
batch.cause === 'thumper' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card && entry.card.name === 'Thumper' &&
entry.card.kind === 'special' && entry.card.effect === 'thumper' &&
!c.resume.response && !c.resume.decision &&
!c.resume.pendingKarama && !c.resume.phaseOpening &&
c.stateSignature === thumperDiscardSignature(g, c),
'The used Thumper no longer matches its undrawn Spice Blow.',
);
} else if (continuation?.kind === 'truthtranceDiscard') {
const c = continuation,
entry = batch.entries[0],
@@ -5778,6 +5822,7 @@ function semutaOfferSupported(
continuation.kind !== 'nullentropyDiscard' &&
continuation.kind !== 'distransDiscard' &&
continuation.kind !== 'saphoMovementDiscard' &&
continuation.kind !== 'thumperDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleResolved' &&
@@ -5805,6 +5850,9 @@ function semutaOfferSupported(
(continuation.kind === 'ornithopterDiscard' && continuation.source === 'move')) &&
(g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove ||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false;
if (continuation.kind === 'thumperDiscard' &&
(g.summonedBeforeBlow || g.summonedWorm || g.wormRides.length > 0 ||
g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove)) return false;
return g.semutaPreview === true && g.status === 'playing' &&
g.players.some(p => p.faction === 'richese') &&
entries.length > 0 &&
@@ -5986,6 +6034,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 === 'thumperDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
blowSpice(g, true);
} else if (next.kind === 'ornithopterDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -7339,11 +7393,14 @@ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
g.moritaniAssassinateCallEvents = [];
return g;
}
/** Development-only Semuta reaction in a fresh Richese roster; public starts stay gated. */
/** Development-only Semuta reaction in fresh Richese games, optionally with
* the physical Ix Treachery deck; public expansion starts stay gated. */
export function initializeSemutaGameForAudit(state: Game): Game {
requireRule(!state.semutaPreview && state.expansions.length === 1 &&
state.expansions[0] === 'choam' && state.players.some(p => p.faction === 'richese'),
'Semuta needs a fresh Richese faction game without other modules.');
requireRule(!state.semutaPreview &&
state.expansions.includes('choam') &&
state.expansions.every(id => id === 'choam' || id === 'ix') &&
state.players.some(p => p.faction === 'richese'),
'Semuta needs a fresh Richese faction game with no optional modules.');
const g = initializeFactionExpansionsGameForAudit(state);
g.semutaPreview = true;
return g;
@@ -27316,11 +27373,25 @@ function applyActionInner(
!g.nexus,
'Use Thumper at the beginning of Spice Blow, before the first draw.',
);
discard(g, p, c.id);
const used = discard(g, p, c.id);
log(
g,
`${p.name} played Thumper in place of the next spice-card reveal.`,
);
if (g.semutaPreview) {
const continuation: ThumperDiscardContinuation = {
kind: 'thumperDiscard', player: p.id, card: used.id,
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 = thumperDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'thumper', entries, continuation);
return g;
}
}
blowSpice(g, true);
return g;
}
+4 -4
View File
@@ -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 an explicitly opted-in Richese development game, every seat sees a neutral response after clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho or battle-card discards without pending rewards. The battle’s first mandatory batch can include cards from both combatants; a later mandatory winning-card cleanup and optional winner selection are separate fresh batches. Continue or commit held Semuta with a free hand slot; only another player’s card is eligible. A sole card transfers automatically; for several, commit first and privately select exactly one. Casualties and awards never replay. Mixed arrivals, reward-pending battle effects, competing transactions, full-hand sequencing and normal Richese 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 also include the physical Ix deck: using Thumper then pauses before its injected worm and Spice Blow draw. Other connected sources include ordinary cards, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho and three separate battle batches. Commit held Semuta with a free hand slot; for several eligible cards, inspect and select exactly one only after commitment. The original effect resumes once. Nested responses, unreconciled battle rewards, other sources, 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 Sapho and three separate battle discard stages: initial mandatory combatants, mandatory winner and optional winner. A neutral all-seat offer precedes each eligible batch. Only a committed holder sees multi-card candidates from other players; Semuta transfers one physical card, is discarded once and resumes the saved battle/casualty suffix once. Mixed arrivals, pending battle rewards, other producers and full-hand exchange remain unfinished.',
'The explicit Richese profile interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho, Ix-deck Thumper and three battle discard stages. Thumper pauses before any injected worm or spice-card draw; claim or decline resumes one normal Spice Blow. A neutral offer precedes each eligible batch. Only a committed holder sees multi-card candidates from other players; Semuta transfers one card, is discarded once and resumes the saved effect once. 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 bounded clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and initial/mandatory-winner/optional-winner 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, movement-order Sapho, Ix-deck Thumper and initial/mandatory-winner/optional-winner 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, physical custody, hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and separate mandatory/optional battle reactions, including a two-owner simultaneous batch. Pending battle 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, movement-order Sapho, Ix-deck Thumper and three battle discard stages. Pending rewards, full games and live deployment remain unverified.',
evidence: [
'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts',