Offer Semuta after clean Residual Poison discard

This commit is contained in:
Quintine committed 2026-09-30 11:38:00 +10:00
1 parent ab5c78226a
commit 32d9ef4ec9
10 files changed
+235 -23

No files matched your search

+9 -7
View File
@@ -11,13 +11,15 @@ 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, Once Around, Ornithopter, private Distrans, final Truthtrance, Once Around,
movement-order, between-battles chooser or pre-plan aggressor Sapho, movement-order, between-battles chooser or pre-plan aggressor Sapho,
battle-card and Ix-deck Thumper/Amal discards open the same neutral clean pre-leader Residual Poison, battle-card and Ix-deck Thumper/Amal
offer regardless of hidden Semuta custody. Once Around has committed discards open the same neutral offer regardless of hidden Semuta custody.
remaining bidder order and a freed hand slot; between-battles Sapho Residual Poison commits one random death before the offer; only an
has committed chooser priority, and pre-plan Sapho tie priority. uninspected battle with no preparation ready qualifies. Once Around
Claim or decline retains the lot, chooser queue or unsealed battle commits bidder order and a freed slot; other Sapho scopes commit
without replaying its order/aggressor change; a Black Market Semuta reserved chooser or aggressor priority. Claim or decline resumes the same lot,
as the offered card cannot Commit. Thumper waits before its injected chooser queue or unsealed battle without repeating its prior effect;
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
+25
View File
@@ -1,5 +1,30 @@
# Dune implementation status # Dune implementation status
## 30 September 2026 — Semuta after clean Residual Poison
In the explicit Richese preview, Residual Poison now pauses after
one random opposing leader is sent to the Tanks and its used public
card is discarded, before battle inspection reconciliation and shared
readiness resume. A neutral offer appears only for an uninspected,
unready pre-leader battle without competing rewards or responses.
Claim moves that physical card to another holder; all-seat decline
leaves it in the pile. The saved receipt binds battle event, victim,
leaders, hand/force custody and unsealed preparation. Neither choice
rerolls the victim or repeats death. Accepted plays in inspected or
partly ready battles retain the existing automatic path.
An authenticated SQLite room restored the offer and a private holder
claim without replaying death; focused JSON/custody and automatic
fallback tests passed. A direct production-engine smoke run observed
`offer`, one dead leader, a recovered card and unready preparation
after claim. Independent read-only rules/privacy reviews found no
scoped defect. `npm run check` passed typecheck, lint and 5,824 offline
tests; `npm run build` and 55 development HTTP tests passed. The
phone-width local reference rendered the clean Residual Poison note.
No deployed revision was exercised. Inspected/partly ready pauses,
other discard producers, full-hand timing, normal Richese starts and
publication acceptance remain gated.
## 30 September 2026 — Semuta after between-battles Sapho ## 30 September 2026 — Semuta after between-battles Sapho
The explicit Richese preview now pauses after Juice of Sapho The explicit Richese preview now pauses after Juice of Sapho
+6 -5
View File
@@ -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, Once Around/movement/battle-order/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 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, four Sapho scopes, unready pre-leader Residual Poison 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, inspected/partly ready battles 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,15 @@ 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, Once Around/movement/battle-order/pre-plan Sapho, final Truthtrance, four Sapho scopes, clean pre-leader Residual Poison,
Ix-deck Thumper/Amal and three battle discard stages. Once Around Ix-deck Thumper/Amal and three battle discard stages. Once Around
commits the lot order and hand-slot release before a neutral offer; commits the lot order and hand-slot release before a neutral offer;
between-battles Sapho commits chooser priority before selection, and battle-order/pre-plan Sapho commit chooser/tie priority; Residual
pre-plan Sapho commits aggressor priority but leaves readiness unsealed. Poison commits one random death while readiness remains unsealed.
Thumper pauses before worm/spice draw; Amal resumes its already-halved Thumper pauses before worm/spice draw; Amal resumes its already-halved
opening once. Multi-owner batches require committed owner-only selection opening once. Multi-owner batches require committed owner-only selection
of one other-player card. Queued questions, promises, reactive arrivals, of one other-player card. Queued questions, promises, reactive arrivals,
pending rewards and other sources stay unconnected. inspected/partly ready battles, pending rewards and other 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.
+7
View File
@@ -40,6 +40,13 @@ 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. without rerunning the aggressor change.
Clean Residual Poison stages `residualPoisonDiscard` only after its
single random leader death and public used-card disposal, before
reconciling battle inspections and preparation readiness. The receipt
binds the victim, leader custody and unsealed battle. The neutral
offer is limited to a pre-leader board with no inspection or ready
combatant; other accepted plays retain automatic continuation.
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
Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response
+8 -2
View File
@@ -66,8 +66,9 @@ 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, Once Around, movement-order, between-battles chooser Truthtrance, Once Around, movement-order, between-battles chooser
or pre-plan aggressor Sapho, battle and Ix-deck Thumper/Amal or pre-plan aggressor Sapho, pre-leader Residual Poison, battle and
discards**, provided the parent transaction has no competing response. Ix-deck Thumper/Amal discards**, provided the parent 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
@@ -86,6 +87,11 @@ 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.
A clean uninspected Residual Poison death with neither combatant ready
has already chosen one random physical victim and discarded the used
card before the offer. Claim or decline resumes the same pre-leader
preparation once; an inspected or partly ready battle retains the
automatic death path without this preview interruption.
For a genuine Ix-deck Thumper, its played physical card reaches For a genuine Ix-deck Thumper, its played physical card reaches
the discard before an injected worm or actual spice-card draw. the discard before an injected worm or actual spice-card draw.
+1 -1
View File
@@ -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, Once Around/movement/battle-order/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/battle-order/pre-plan Sapho, unready pre-leader Residual Poison, 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;
+87 -2
View File
@@ -1180,6 +1180,16 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume']; resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string; stateSignature: string;
} }
| {
kind: 'residualPoisonDiscard';
player: string;
target: string;
victim: string;
battleEvent: string;
card: string;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| { | {
kind: 'thumperDiscard'; kind: 'thumperDiscard';
player: string; player: string;
@@ -4822,6 +4832,24 @@ function saphoAggressorDiscardSignature(g: Game, c: SaphoAggressorDiscardContinu
players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally })), players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally })),
}); });
} }
type ResidualPoisonDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'residualPoisonDiscard' }
>;
function residualPoisonDiscardSignature(g: Game, c: ResidualPoisonDiscardContinuation) {
return JSON.stringify({
player: c.player, target: c.target, victim: c.victim,
battleEvent: c.battleEvent, card: c.card,
parent: nullentropyParentSignature(g, c.resume, c.battleEvent),
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
battle: g.battle, duke: g.dukeVidal,
players: g.players.map(p => ({
id: p.id, faction: p.faction, ally: p.ally,
hand: p.hand.map(card => card.id), leaders: p.leaders,
reserves: p.reserves, tanks: p.tanks, forces: p.forces,
})),
});
}
type ThumperDiscardContinuation = Extract< type ThumperDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'], NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'thumperDiscard' } { kind: 'thumperDiscard' }
@@ -5361,6 +5389,28 @@ function treacheryDiscardIntegrity(g: Game) {
'The used Juice of Sapho no longer matches its pre-plan aggressor choice.', 'The used Juice of Sapho no longer matches its pre-plan aggressor choice.',
); );
saphoAggressorIntegrity(g); saphoAggressorIntegrity(g);
} else if (continuation?.kind === 'residualPoisonDiscard') {
const c = continuation, entry = batch.entries[0], battle = g.battle;
const victim = g.players.some(p => p.id === c.target)
? controlledLeaders(g, getPlayer(g, c.target)).find(l => l.id === c.victim)
: null;
requireRule(
g.phase === 6 && battle && battle.event === c.battleEvent &&
!battle.revealed && !Object.keys(battle.plans).length &&
[battle.attacker, battle.defender].includes(c.player) &&
c.target === (battle.attacker === c.player ? battle.defender : battle.attacker) &&
battle.preLeader?.event === c.battleEvent && !battle.preLeader.closed &&
battle.preLeader.ready.length === 0 &&
victim?.dead && !victim.capturedBy &&
batch.cause === 'residualPoison' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card &&
richeseCardDefinition(entry.card)?.card.effect === 'residualPoison' &&
!c.resume.response && !c.resume.decision &&
!c.resume.pendingKarama && !c.resume.phaseOpening &&
c.stateSignature === residualPoisonDiscardSignature(g, c),
'The used Residual Poison no longer matches its committed leader death.',
);
} else if (continuation?.kind === 'thumperDiscard') { } else if (continuation?.kind === 'thumperDiscard') {
const c = continuation, entry = batch.entries[0]; const c = continuation, entry = batch.entries[0];
requireRule( requireRule(
@@ -6017,6 +6067,7 @@ function semutaOfferSupported(
continuation.kind !== 'saphoAggressorDiscard' && continuation.kind !== 'saphoAggressorDiscard' &&
continuation.kind !== 'saphoAuctionDiscard' && continuation.kind !== 'saphoAuctionDiscard' &&
continuation.kind !== 'saphoBattleOrderDiscard' && continuation.kind !== 'saphoBattleOrderDiscard' &&
continuation.kind !== 'residualPoisonDiscard' &&
continuation.kind !== 'thumperDiscard' && continuation.kind !== 'thumperDiscard' &&
continuation.kind !== 'amalDiscard' && continuation.kind !== 'amalDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') && !(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
@@ -6051,6 +6102,15 @@ function semutaOfferSupported(
g.pendingFaceDance || g.pendingChoamBattleIncome || g.pendingFaceDance || g.pendingChoamBattleIncome ||
g.pendingWinnerDiscards || g.moritaniRetention || g.pendingWinnerDiscards || g.moritaniRetention ||
g.pendingIxSubstitution)) return false; g.pendingIxSubstitution)) return false;
if (continuation.kind === 'residualPoisonDiscard' &&
(!g.battle?.preLeader || g.battle.preLeader.closed ||
g.battle.preLeader.ready.length > 0 ||
g.battle.preparation || g.battle.prescience ||
g.battle.nexusInspection || g.battle.truthPromises?.length ||
g.pendingAuditor || g.pendingCapture || g.pendingTech ||
g.pendingFaceDance || g.pendingChoamBattleIncome ||
g.pendingWinnerDiscards || g.moritaniRetention ||
g.pendingIxSubstitution)) return false;
if (continuation.kind === 'thumperDiscard' && if (continuation.kind === 'thumperDiscard' &&
(g.summonedBeforeBlow || g.summonedWorm || g.wormRides.length > 0 || (g.summonedBeforeBlow || g.summonedWorm || g.wormRides.length > 0 ||
g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove)) return false; g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove)) return false;
@@ -6072,7 +6132,8 @@ function semutaOfferSupported(
: !continuation.resume.phaseOpening))) && : !continuation.resume.phaseOpening))) &&
!g.karamaShipping && !g.auction && !g.karamaShipping && !g.auction &&
(continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) && (continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) &&
(continuation.kind === 'saphoAggressorDiscard' || !g.battle) && (continuation.kind === 'saphoAggressorDiscard' ||
continuation.kind === 'residualPoisonDiscard' || !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 &&
!g.pendingHomeworldShipment && !g.pendingRicheseGift && !g.pendingHomeworldShipment && !g.pendingRicheseGift &&
@@ -6262,6 +6323,15 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
g.decision = next.resume.decision; g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama; g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening; g.phaseOpening = next.resume.phaseOpening;
} else if (next.kind === 'residualPoisonDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
reconcileChangedBattleInspections(g, 'the leader death');
const battle = g.battle!;
if (battle.preLeader && !battle.preLeader.closed)
battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player);
} else if (next.kind === 'amalDiscard') { } else if (next.kind === 'amalDiscard') {
g.response = next.resume.response; g.response = next.resume.response;
g.decision = next.resume.decision; g.decision = next.resume.decision;
@@ -16019,12 +16089,27 @@ function playResidualPoison(
g.dukeVidal = consumeDuke(g.dukeVidal); g.dukeVidal = consumeDuke(g.dukeVidal);
const harkonnen = byFaction(g, 'harkonnen'); const harkonnen = byFaction(g, 'harkonnen');
if (harkonnen) returnCaptives(g, harkonnen); if (harkonnen) returnCaptives(g, harkonnen);
discard(g, p, card.id); const used = discard(g, p, card.id);
log( log(
g, g,
`${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`, `${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`,
{ faction: p.faction, name: 'Residual Poison' }, { faction: p.faction, name: 'Residual Poison' },
); );
if (g.semutaPreview) {
const continuation: ResidualPoisonDiscardContinuation = {
kind: 'residualPoisonDiscard', player: p.id, target: target.id,
victim: victim.id, battleEvent: b.event!, 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 = residualPoisonDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'residualPoison', entries, continuation);
return;
}
}
reconcileChangedBattleInspections(g, 'the leader death'); reconcileChangedBattleInspections(g, 'the leader death');
if (b.preLeader && !b.preLeader.closed) if (b.preLeader && !b.preLeader.closed)
b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id); b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id);
+5 -5
View File
@@ -1148,7 +1148,7 @@ export const RULE_TOPICS: RuleTopic[] = [
: definition.card.effect === 'ornithopter' : definition.card.effect === 'ornithopter'
? 'Use the Ornithopter movement controls with a selected group and destination. Hajr/prior-move composition, fixed-range Kulon and advanced advisors remain explicitly unresolved.' ? 'Use the Ornithopter movement controls with a selected group and destination. Hajr/prior-move composition, fixed-range Kulon and advanced advisors remain explicitly unresolved.'
: definition.card.effect === 'residualPoison' : definition.card.effect === 'residualPoison'
? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents; advanced Harkonnen tables remain guarded pending the secret-captive ruling.' ? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents; advanced Harkonnen tables remain guarded pending the secret-captive ruling. In the explicit Semuta preview, a clean death with no readiness or inspection commitments pauses after public disposal for the neutral offer, then resumes the same preparation.'
: definition.card.effect === 'stoneBurner' : definition.card.effect === 'stoneBurner'
? 'Use Stone Burner in the battle weapon selector and review the chosen dial/support guard before sealing. Choose the leader effect after revelation; combined timing remains guarded.' ? 'Use Stone Burner in the battle weapon selector and review the chosen dial/support guard before sealing. Choose the leader effect after revelation; combined timing remains guarded.'
: definition.card.effect === 'mirrorWeapon' : definition.card.effect === 'mirrorWeapon'
@@ -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 worm/spice draw; Amal pauses after halving spice before opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, clean Once Around/movement/battle-order/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 queue or unsealed battle resumes once. Nested responses, 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 opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, clean Once Around/movement/battle-order/pre-plan Sapho, clean pre-leader Residual Poison 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 death or order resumes once. Inspected/partly ready battles, nested responses, 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 Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four bounded Sapho scopes (Once Around, movement, between-battles chooser and pre-plan aggressor), Ix-deck Thumper/Amal and three battle discard stages. Once Around commits bidder order; the chooser commits battle priority; 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.', 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison, Ix-deck Thumper/Amal and three battle discard stages. Residual Poison commits one random leader death before the neutral offer and restores an uninspected, unready battle once; Once Around commits bidder order, the chooser commits battle priority and pre-plan Sapho commits aggressor priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Neutral offers, owner-only candidates and one physical Semuta transfer retain privacy. Inspected/partly ready battles, 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, Once Around/movement/battle-order/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/battle-order/pre-plan Sapho, pre-leader Residual Poison, 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, Once Around/movement/battle-order/pre-plan Sapho, Ix-deck Thumper/Amal and three battle discard stages. 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, four Sapho scopes, pre-leader Residual Poison, Ix-deck Thumper/Amal and three battle discard stages. Inspected/partly ready battles, 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',
@@ -11,6 +11,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 { createDukeVidal, acquireDuke } from '../game/duke-vidal'; import { createDukeVidal, acquireDuke } from '../game/duke-vidal';
function fixture() { function fixture() {
@@ -88,6 +89,52 @@ const inventory = (g: Game) =>
.map((c) => c.id) .map((c) => c.id)
.sort(); .sort();
void test('clean Residual Poison death offers Semuta once before shared battle preparation', () => {
let g = fixture();
g.semutaPreview = true;
hold(g, 'e', 'Semuta Drug');
const original = inventory(g);
g = poison(start(g));
const event = g.pendingTreacheryDiscard!.batch.event;
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(g.players[1].leaders.filter(l => l.dead).length, 1);
assert.equal(g.battle!.preLeader?.closed, false);
assert.equal(viewGame(g, 'e').semutaReaction?.canCommit, true);
assert.equal(viewGame(g, 'g').semutaReaction?.canCommit, false);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
const corrupt = reload(g);
const victim = corrupt.players[1].leaders.find(l => l.dead)!;
victim.dead = false;
const before = reload(corrupt);
assert.throws(() => send(corrupt, 'e', { type: 'semutaCommit', event }));
assert.deepEqual(corrupt, before);
let declined = reload(g);
for (const id of ['r', 'g', 'e'])
declined = send(declined, id, { type: 'semutaPass', event });
assert.equal(declined.pendingTreacheryDiscard, null);
assert.deepEqual(inventory(declined), original);
const claimed = send(reload(g), 'e', { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.players[1].leaders.filter(l => l.dead).length, 1);
assert.equal(claimed.players[2].hand.filter(c => c.id === 'richese-residual-poison').length, 1);
assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
assert.deepEqual(inventory(claimed), original);
assert.deepEqual(claimed.battle!.preLeader?.ready, []);
assert.equal(ready(claimed, 'r').battle!.preLeader?.ready.includes('r'), true);
assert.throws(() => send(claimed, 'e', { type: 'semutaCommit', event }));
});
void test('an already-ready opponent retains the automatic Residual Poison suffix', () => {
let g = fixture();
g.semutaPreview = true;
hold(g, 'e', 'Semuta Drug');
g = poison(ready(start(g), 'g'));
assert.equal(g.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(g.battle?.preLeader?.ready, ['g']);
assert.equal(g.players[1].leaders.filter(l => l.dead).length, 1);
assert.equal(g.discard.filter(c => c.id === 'richese-residual-poison').length, 1);
});
void test('a fast opponent cannot commit before both genuine preparation declarations, independent of card possession', () => { void test('a fast opponent cannot commit before both genuine preparation declarations, independent of card possession', () => {
const withCard = start(fixture()); const withCard = start(fixture());
const noCard = fixture(); const noCard = fixture();
+40 -1
View File
@@ -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. */
@@ -94,7 +95,7 @@ function unitStore(runBots = bots.runBots) {
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} }; const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture() { async function fixture(semutaPreview = false) {
const store = unitStore(); const store = unitStore();
const created = await store.rooms.createRoom('Holder', 'guild', false, []), const created = await store.rooms.createRoom('Holder', 'guild', false, []),
code = created.view.code; code = created.view.code;
@@ -145,6 +146,12 @@ async function fixture() {
target: other.playerId, target: other.playerId,
territory: 'arrakeen', territory: 'arrakeen',
}); });
if (semutaPreview) {
initial.semutaPreview = true;
const semuta = initial.richeseCache!.findIndex(c => c.id === SEMUTA_DRUG_ID);
assert.ok(semuta >= 0);
initial.players[2].hand.push(initial.richeseCache!.splice(semuta, 1)[0]);
}
const save = (g: engine.Game) => const save = (g: engine.Game) =>
store.sqlite store.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?') .prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
@@ -343,3 +350,35 @@ void test('two simultaneous ready declarations require refreshed retry; stale ev
f.sqlite.close(); f.sqlite.close();
} }
}); });
void test('authenticated Residual Poison death pauses for a neutral Semuta offer before shared preparation', async () => {
const f = await fixture(true);
try {
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const before = await f.restart().readRoom(f.code);
await f.restart().act(f.code, f.owner, before.version, f.action, clock);
let g = await f.restart().readRoom(f.code);
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(g.players[1].leaders.filter(l => l.dead).length, 1);
assert.equal(g.battle?.preLeader?.closed, false);
const event = g.pendingTreacheryDiscard!.batch.event;
const offered = g.version;
await f.restart().continueRoomAutomatic(f.code, clock);
assert.equal((await f.restart().readRoom(f.code)).version, offered);
assert.equal((await f.restart().readSeatView(f.code, f.observer)).semutaReaction?.canCommit, true);
assert.equal((await f.restart().readSeatView(f.code, f.other)).semutaReaction?.canCommit, false);
await f.restart().act(f.code, f.observer, g.version, { type: 'semutaCommit', event }, clock);
g = await f.restart().readRoom(f.code);
assert.equal(g.pendingTreacheryDiscard, null);
assert.equal(g.players[1].leaders.filter(l => l.dead).length, 1);
assert.equal(g.players[2].hand.filter(c => c.id === f.action.card).length, 1);
assert.equal(g.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
assert.deepEqual(g.battle?.preLeader?.ready, []);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
await f.restart().act(f.code, f.owner, g.version, f.ready, clock);
const prepared = await f.restart().readRoom(f.code);
assert.deepEqual(prepared.battle?.preLeader?.ready, [f.owner.playerId]);
} finally {
f.sqlite.close();
}
});