Offer Semuta on clean mandatory battle batch

This commit is contained in:
Quintine committed 2026-09-30 09:41:55 +10:00
1 parent e55d275014
commit dd6d3a89ca
10 files changed
+308 -71

No files matched your search

+15 -16
View File
@@ -9,22 +9,21 @@ 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)
pauses clean public ordinary-card, paid Box, retired Ornithopter, pauses clean public ordinary-card, paid Box, retired Ornithopter,
private Distrans, final definite Truthtrance, movement-order Sapho, private Distrans, final definite Truthtrance, movement-order Sapho,
mandatory winner or later optional winner battle-card discards without initial mandatory battle, mandatory winner and optional winner
pending rewards. The two winner batches are separate: a claim on the discards without competing rewards. A mutual-traitor battle can
mandatory Hero can resume the winner's optional weapon choice once. discard two public cards with different former owners in one batch;
Every seat sees a neutral offer regardless of hidden Semuta possession. a holder must commit before seeing the selectable fresh faces and
A sole eligible card transfers on commitment; a simultaneous winner may take only a card discarded by another player. Mandatory Hero
batch first requires public commitment, then reveals eligible faces cleanup and later optional weapon disposal remain separate events.
only to the holder for one-card selection. Other cards remain in the Every seat sees a neutral offer regardless of hidden Semuta custody.
pile and the resolved battle resumes once. The flight queue, committed Casualties, battle results, flight arrival, private gifts, answer
arrival, private gift, answer history and Sapho order also resume history and Sapho order resume without replay. Minimal legal AI,
without replay. Minimal legal AI, exact JSON/SQLite continuation and exact JSON/SQLite continuation and competing-room CAS are connected.
competing-room CAS are connected. Mixed arrivals, auction/battle Mixed arrivals, auction/battle Sapho, queued or promise-bound
Sapho, queued or promise-bound Truthtrance, loser/mixed-owner battle Truthtrance, pending battle rewards and other discard producers,
batches, pending battle rewards, other discard producers, full-hand full-hand ordering, normal Richese starts and deployed acceptance
ordering, normal Richese starts and deployed acceptance remain remain unfinished. Neutral passes and free-slot guard are product
unfinished. Neutral passes and free-slot guard are product safety safety choices, not publisher rulings.
choices, not publisher rulings.
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,
+40
View File
@@ -1,5 +1,45 @@
# Dune implementation status # Dune implementation status
## 30 September 2026 — Semuta at first mandatory battle discard
The explicit Richese Semuta preview now pauses a clean public
`battleResolved` discard batch after its combat result and physical
used-card disposal but before casualty choices or winner cleanup.
A normal battle's used loser defense is one fresh card; a mutual
Traitor battle can produce one simultaneous pair owned by both
combatants. Every seat sees the same neutral offer. The holder may
commit and recover only a card discarded by another player; two
eligible cards require owner-only inspection and exact one-card
selection. Other cards remain on the pile. The result, losses and
later mandatory/optional winner batches continue once, separately.
Genuine setup exercises a normal battle's loser defense, all-seat
decline into winner choice, and a two-owner mutual-Traitor batch
with committed selection and physical census. An isolated migrated
SQLite room recovers both the initial and owner-committed mixed batch
without auto-advancing; authenticated selection changes one card
without rewriting seats or doubling casualties. Pending battle
rewards, other discard producers, full-hand ordering, normal Richese
starts and deployed acceptance remain gated.
Independent rules review found no scoped issue. Privacy/recovery
review found that the initial `battleResolved` metadata did not bind
the exact original card/owner pair, permitting a malformed saved
batch to reassign a former owner or substitute an old discarded
card. Failing-before/passing-after cases now bind the original
mandatory entries in the resolved battle receipt and reject both
corruptions before projection or claim; frames predating that receipt
continue automatically rather than inventing an offer.
Follow-up privacy/recovery review found no remaining scoped defect.
A direct production-engine run resolved a mutual-Traitor battle,
opened one two-owner public batch, committed Semuta, selected one
opponent card and left the other physically discarded without a
winner. Phone-width rules showed the three separate battle stages.
`npm run check` passed typecheck, lint and 5,811/5,811 offline
cases; `npm run build` and all 55 Vite development HTTP cases
passed. No deployed revision was exercised.
## 30 September 2026 — Semuta between mandatory and optional winner cleanup ## 30 September 2026 — Semuta between mandatory and optional winner cleanup
The explicit Richese Semuta preview now pauses a clean public The explicit Richese Semuta preview now pauses a clean public
+9 -8
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 an explicit bounded development preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final definite Truthtrance, movement-order Sapho and separate mandatory/optional winner battle-card batches. A multi-card winner batch exposes candidates only after holder commitment and transfers one physical card. Private gifts, unresolved promises, reactive arrivals, auction/battle Sapho, loser/mixed-owner battle batches and pending battle rewards remain outside scope. Other producers, full-hand ordering and normal Richese starts remain unfinished. Mirror Weapon and Juice of Sapho have separate bounded paths, not complete combined-mode certification. Other unfinished systems, mobile acceptance, difficulty calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed. - Semuta Drug has an explicit bounded preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final definite Truthtrance, movement-order Sapho and three distinct battle discard stages: initial mandatory combatants, mandatory winner and optional winner. A multi-card batch, including two-owner mutual-Traitor cleanup, exposes candidates only after holder commitment and transfers one other-player physical card. Private gifts, unresolved promises, reactive arrivals, auction/battle 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 have separate bounded paths, not complete combined-mode certification. Other systems, mobile acceptance, difficulty calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed.
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,10 +113,11 @@ explicitly opted-in Richese development profile. Neutral all-seat
responses, physical one-card recovery, a provisional free-slot guard, responses, physical one-card recovery, a provisional free-slot guard,
minimal legal AI and saved continuation work after clean public minimal legal AI and saved continuation work after clean public
ordinary-card, paid Box, retired Ornithopter, private Distrans, final ordinary-card, paid Box, retired Ornithopter, private Distrans, final
definite Truthtrance, movement-order Sapho and separate mandatory definite Truthtrance, movement-order Sapho and three separate battle
and optional winner battle-card batches, including committed card stages: initial mandatory combatants, mandatory winner and
owner-only multi-card selection. Queued questions, promise-bound optional winner. Multi-owner batches require committed owner-only
answers, reactive arrivals, auction/battle Sapho, loser or mixed-owner selection of one other-player card. Queued questions, promises,
battle batches and pending battle rewards remain unconnected. reactive arrivals, auction/battle Sapho, pending battle rewards and
Full-hand ordering, ten-card inventory, cache custody and public other semantic discard families remain unconnected. Full-hand
expansion start gate are unchanged. ordering, ten-card inventory, cache custody and public expansion
start gate are unchanged.
+11 -10
View File
@@ -35,16 +35,17 @@ sources. The final forces and move counter are held in the existing
bound flight frame; claim or decline retires that frame and logs the bound flight frame; claim or decline retires that frame and logs the
arrival once. Mixed entry reactions remain unsupported for Semuta. arrival once. Mixed entry reactions remain unsupported for Semuta.
Mandatory winner cleanup now pauses at `winnerMandatoryDiscard` Clean `battleResolved` now pauses the first public mandatory batch
after any earlier loser/battle batch has retired; a Hero already in after combat result and actual card custody are committed but before
the public pile can be claimed before the exact optional winner casualty choices or winner follow-up. A mutual-Traitor outcome may
choice resumes. If the winner then selects other played cards, place two cards from different former owners in that one batch;
their public disposal is a separate `battleCleanup` batch. Each claimants can choose only a card from another player. Later mandatory
stage has a neutral all-seat offer; only a committed holder inspects winning-card cleanup uses a separate `winnerMandatoryDiscard` batch,
multiple eligible cards, selects one, and leaves the others in the and an optional winner selection uses a third `battleCleanup` batch.
pile. No battle award or card cost repeats. Moritani loser retention Every supported stage has a neutral all-seat offer; committed holders
and pending Auditor, capture, tech, Face Dance or CHOAM rewards select one fresh card, leave the others in the pile and resume exactly
remain automatic without this reaction. once. Pending Auditor, capture, tech, Face Dance or CHOAM rewards
remain automatic without Semuta.
## Main findings ## Main findings
+28 -29
View File
@@ -62,33 +62,32 @@ Do not ask again about normal targets, forced-discard owner bookkeeping, simulta
## 30 September 2026 — bounded clean-discard runtime ## 30 September 2026 — bounded clean-discard runtime
The explicit `semuta` development profile starts a fresh Richese roster The explicit `semuta` development profile starts a fresh Richese roster
without opening ordinary expansion starts. It interrupts **one public without opening ordinary expansion starts. It interrupts **clean public
ordinary-card discard, the used Box after a paid Nullentropy search, ordinary-card, paid Box, retired Ornithopter, used Distrans, final
either retirement of a played Ornithopter in a clean flight, the used Truthtrance, movement-order Sapho and battle discard batches**, provided
Distrans after a private transfer, a final definite Truthtrance, the parent transaction has no competing response. Every seat sees a
movement-order Juice of Sapho, mandatory winning-card cleanup, or a neutral fresh-discard opportunity regardless of hidden Semuta custody.
later winner-selected battle-card batch without pending rewards**, An early-ended Ornithopter advances the saved queue; a completed
provided the parent transaction is clean. Every seat sees a neutral flight resumes one committed arrival only without BG, Ecaz or
fresh-discard opportunity regardless Moritani entry reactions. Distrans's gift remains private with its
of hidden Semuta possession. An early-ended Ornithopter advances its recipient; Truthtrance answer/history and Sapho first/last order are
saved movement queue; a completed flight has committed its forces already bound.
and resumes its one arrival only without BG, Ecaz or Moritani entry
reactions. Distrans's gift stays private with its recipient. The The first battle mandatory batch may contain public cards belonging
Truthtrance answer/history and Sapho first/last queue are bound. to both combatants. Its accepted result and card custody precede
Mandatory winning cards form one public fresh batch after the the neutral offer; casualty allocation and winner cleanup still
previous loser cleanup, and any later optional winner selection is a follow it once. Mandatory winning-card cleanup, if present, is a
separate batch. Neither a Semuta claim nor all-seat decline replays separate later batch; optional winner-selected disposal is later
casualties or re-discards an already claimed card. again. No Semuta claim replays combat or combines these events.
Each seat can Continue; only the canonical holder with a free hand Each seat can Continue; only the canonical holder with a free hand
slot and a card discarded by another player can commit. A sole slot and a card discarded by another player can commit. A sole
eligible fresh card transfers automatically in that same action. eligible fresh card transfers automatically in that same action.
Multiple eligible public winner cards instead open a committed, Multiple eligible public battle cards open a committed, owner-only
owner-only inspection and exact one-card selection; the other fresh inspection and exact one-card selection; the other cards remain in
cards stay in the pile and Semuta is discarded once. The saved the pile and Semuta is discarded once. The saved ordinary effect or
ordinary effect or Box parent resumes once. No undisclosed face is Box parent resumes once. No undisclosed face is projected before
projected before commitment. Other simultaneous discard producers commitment. Other simultaneous discard producers remain unfinished.
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
@@ -96,12 +95,12 @@ 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. Auction, CHOAM market,
pending gifts, competing responses, completed Ornithopter arrivals pending gifts, competing responses, completed Ornithopter arrivals
with BG intrusion, Ecaz Ambassador or Moritani Terror, queued or with BG intrusion, Ecaz Ambassador or Moritani Terror, queued or
promise-bound Truthtrance, auction/battle Sapho, loser or mixed-owner promise-bound Truthtrance, auction/battle Sapho, pending battle
mandatory battle batches and pending battle rewards retain their rewards and other typed producers retain their automatic continuation;
automatic continuation; Semuta cannot claim those events yet. Semuta cannot claim those events yet. The completed Box's selected
The completed Box's selected search card stays private and its paid search card stays private and its paid shuffle is not repeated
shuffle is not repeated when another seat takes the used Box. when another seat takes the used Box. No source ruling is inferred
No source ruling is inferred from this omission. The from this omission. The
separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why
auto-closing only when nobody secretly holds the card would leak auto-closing only when nobody secretly holds the card would leak
possession. The main game and public expansion gates remain closed. possession. The main game and public expansion gates remain closed.
+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 completed ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and winner battle-card discards are connected; other producers and full-hand sequencing remain unfinished.'; 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 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;
+41 -2
View File
@@ -1783,6 +1783,10 @@ export type Game = {
result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion' | 'legacy'; result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion' | 'legacy';
cardRoles?: Record<string, Record<string, Omit<EcazPoisonDiscard, 'card'>>>; cardRoles?: Record<string, Record<string, Omit<EcazPoisonDiscard, 'card'>>>;
cardRolesSignature?: string; cardRolesSignature?: string;
mandatoryDiscard?: {
entries: { card: string; owner: string }[];
signature: string;
};
winnerDiscards?: { cards: string[]; completed: boolean; signature: string }; winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
sukRescue?: { signature: string; completed: boolean }; sukRescue?: { signature: string; completed: boolean };
ixSubstitution?: { signature: string; completed: boolean }; ixSubstitution?: { signature: string; completed: boolean };
@@ -4542,6 +4546,13 @@ function battleCardRolesSignature(context: NonNullable<Game['lastBattleContext']
const { event, turn, territory, combatants, winner, result, cardRoles } = context; const { event, turn, territory, combatants, winner, result, cardRoles } = context;
return JSON.stringify({ event, turn, territory, combatants, winner, result, cardRoles }); return JSON.stringify({ event, turn, territory, combatants, winner, result, cardRoles });
} }
function battleMandatoryDiscardSignature(context: NonNullable<Game['lastBattleContext']>) {
return JSON.stringify({
event: context.event, turn: context.turn, territory: context.territory,
combatants: context.combatants, winner: context.winner, result: context.result,
entries: context.mandatoryDiscard?.entries,
});
}
function battleCardRolesIntegrity(g: Game) { function battleCardRolesIntegrity(g: Game) {
const context = g.lastBattleContext; const context = g.lastBattleContext;
if (!context?.cardRoles && !context?.cardRolesSignature) return; if (!context?.cardRoles && !context?.cardRolesSignature) return;
@@ -5547,6 +5558,20 @@ function treacheryDiscardIntegrity(g: Game) {
), ),
'The saved battle discard does not match its resolved combatants.', 'The saved battle discard does not match its resolved combatants.',
); );
if (c.kind === 'battleResolved' && context.mandatoryDiscard) {
const receipt = context.mandatoryDiscard;
requireRule(
Array.isArray(receipt.entries) &&
receipt.entries.length === batch.entries.length &&
receipt.entries.every(entry =>
typeof entry.card === 'string' && typeof entry.owner === 'string') &&
receipt.signature === battleMandatoryDiscardSignature(context) &&
JSON.stringify(receipt.entries) === JSON.stringify(batch.entries.map(entry => ({
card: entry.card.id, owner: entry.discardedBy,
}))),
'The fresh mandatory battle cards no longer match their former owners.',
);
}
// Projection must validate the private Auditor owner as strictly as actions. // Projection must validate the private Auditor owner as strictly as actions.
auditorIntegrity(g); auditorIntegrity(g);
const loser = c.combatants.find((id) => id !== context.winner); const loser = c.combatants.find((id) => id !== context.winner);
@@ -5755,6 +5780,7 @@ function semutaOfferSupported(
continuation.kind !== 'saphoMovementDiscard' && continuation.kind !== 'saphoMovementDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') && !(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
continuation.kind !== 'winnerMandatoryDiscard' && continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleResolved' &&
!(continuation.kind === 'ornithopterDiscard' && !(continuation.kind === 'ornithopterDiscard' &&
(continuation.source === 'end' || (continuation.source === 'end' ||
(continuation.source === 'move' && (continuation.source === 'move' &&
@@ -5764,8 +5790,11 @@ function semutaOfferSupported(
!(continuation.kind === 'truthtranceDiscard' && !(continuation.kind === 'truthtranceDiscard' &&
continuation.consumed.source === undefined && continuation.consumed.source === undefined &&
continuation.remaining === null && continuation.promise === null)) return false; continuation.remaining === null && continuation.promise === null)) return false;
if (continuation.kind === 'battleResolved' &&
!g.lastBattleContext?.mandatoryDiscard) return false;
if ((continuation.kind === 'battleCleanup' || if ((continuation.kind === 'battleCleanup' ||
continuation.kind === 'winnerMandatoryDiscard') && continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved') &&
(g.pendingAuditor || g.pendingCapture || g.pendingTech || (g.pendingAuditor || g.pendingCapture || g.pendingTech ||
g.pendingFaceDance || g.pendingChoamBattleIncome || g.pendingFaceDance || g.pendingChoamBattleIncome ||
g.pendingWinnerDiscards || g.moritaniRetention || g.pendingWinnerDiscards || g.moritaniRetention ||
@@ -5780,11 +5809,13 @@ function semutaOfferSupported(
g.players.some(p => p.faction === 'richese') && g.players.some(p => p.faction === 'richese') &&
entries.length > 0 && entries.length > 0 &&
(continuation.kind === 'battleCleanup' || (continuation.kind === 'battleCleanup' ||
continuation.kind === 'winnerMandatoryDiscard' continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved'
? entries.every(entry => entry.publicFace) ? entries.every(entry => entry.publicFace)
: entries.length === 1 && entries[0].publicFace) && : entries.length === 1 && entries[0].publicFace) &&
(continuation.kind === 'battleCleanup' || (continuation.kind === 'battleCleanup' ||
continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved' ||
(!continuation.resume.response && !continuation.resume.decision && (!continuation.resume.response && !continuation.resume.decision &&
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening)) && !continuation.resume.pendingKarama && !continuation.resume.phaseOpening)) &&
!g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle && !g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle &&
@@ -18132,6 +18163,14 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
...(casualtyCommitment ? { commitment: { forces: {...casualtyCommitment.forces}, dial: casualtyCommitment.dial, support: casualtyCommitment.support } } : {}), ...(casualtyCommitment ? { commitment: { forces: {...casualtyCommitment.forces}, dial: casualtyCommitment.dial, support: casualtyCommitment.support } } : {}),
}; };
} }
if (discarded.length) {
g.lastBattleContext.mandatoryDiscard = {
entries: discarded.map(entry => ({ card: entry.card.id, owner: entry.discardedBy })),
signature: '',
};
g.lastBattleContext.mandatoryDiscard.signature =
battleMandatoryDiscardSignature(g.lastBattleContext);
}
g.battle = null; g.battle = null;
const cards = [...quote.winnerCards]; const cards = [...quote.winnerCards];
const continuation: Extract< const continuation: Extract<
+5 -5
View File
@@ -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 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 winner battle-card discards without pending rewards. Mandatory winning cards and later optional winner discards are separate fresh batches. Continue or commit held Semuta with a free hand slot; a sole card transfers automatically. For several cards, commit first, then privately select exactly one. The original effect or battle suffix resumes once. BG/Ecaz/Moritani arrivals, loser and mixed-owner mandatory battle batches, competing transactions, full-hand sequencing and normal Richese starts remain unfinished.' ? '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.'
: '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,13 +1166,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation' as const, area: 'Implementation' as const,
status: 'Partial' as const, status: 'Partial' as const,
detail: detail:
'An explicitly opted-in Richese profile interrupts clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho, mandatory winner and optional winner battle-card discards with a neutral all-seat offer. Mandatory and later optional winner cards are separate batches; committed multi-card candidates belong only to the holder. Semuta transfers one other-player card, is discarded once and resumes the original effect once. Mixed arrivals, loser battle batches, pending rewards, other typed producers and full-hand exchange remain unfinished.', '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.',
}, },
{ {
area: 'Player controls' as const, area: 'Player controls' as const,
status: 'Partial' as const, status: 'Partial' as const,
detail: detail:
'All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. A committed winner-battle batch gives that holder the card inspector and exact one-card selector; nonholders receive no candidates. Normal Richese starts remain gated.', 'All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. In a mixed-owner battle batch, a committed holder can inspect and select only cards discarded by another player; nonholders receive no candidates. Normal Richese starts remain gated.',
}, },
{ {
area: 'AI' as const, area: 'AI' 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 bounded public ordinary-card, paid Box, clean Ornithopter, Distrans, final Truthtrance, movement-order Sapho and mandatory/optional winner battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.', '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.',
}, },
{ {
area: 'Verification' as const, area: 'Verification' as const,
status: 'Partial' as const, status: 'Partial' as const,
detail: 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, final Truthtrance, movement-order Sapho and consecutive mandatory/optional winner battle reactions. Loser and mixed-owner batches, full games and live deployment remain unverified.', '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.',
evidence: [ evidence: [
'tests/semuta-engine.test.ts', 'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts', 'tests/semuta-recovery.test.ts',
+81
View File
@@ -534,6 +534,16 @@ void test('clean winner-selected battle cards allow one committed Semuta choice
leader: player(battle, 'e').leaders[0].id, defense: defense.id }); leader: player(battle, 'e').leaders[0].id, defense: defense.id });
battle = applyAction(battle, 'a', { type: 'traitorCall', call: false }); battle = applyAction(battle, 'a', { type: 'traitorCall', call: false });
battle = applyAction(battle, 'e', { type: 'traitorCall', call: false }); battle = applyAction(battle, 'e', { type: 'traitorCall', call: false });
assert.equal(battle.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
assert.equal(battle.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(battle.pendingTreacheryDiscard?.batch.entries.map(entry => [
entry.card.id, entry.discardedBy,
]), [[defense.id, 'e']]);
const mandatoryEvent = battle.pendingTreacheryDiscard!.batch.event;
assert.equal(viewGame(battle, 'r').semutaReaction?.canCommit, true);
assert.deepEqual(normalizeAutomaticGame(reload(battle)), reload(battle));
for (const id of ['e', 'a', 'r'])
battle = applyAction(battle, id, { type: 'semutaPass', event: mandatoryEvent });
assert.equal(battle.decision?.kind, 'battleCards'); assert.equal(battle.decision?.kind, 'battleCards');
const pending = applyAction(reload(battle), 'a', { type: 'decision', discard: [weapon.id] }); const pending = applyAction(reload(battle), 'a', { type: 'decision', discard: [weapon.id] });
assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'battle:winner'); assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'battle:winner');
@@ -666,3 +676,74 @@ void test('a mandatory winner Hero discard can be claimed before optional battle
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id)); assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event })); assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
}); });
void test('a mutual-traitor battle offers one mixed-owner mandatory batch with one committed choice', () => {
const { g, semuta, hajr } = fixture();
player(g, 'a').hand = [];
g.deck.push(hajr);
const [shield] = g.deck.splice(g.deck.findIndex(card => card.kind === 'shield'), 1);
const [snooper] = g.deck.splice(g.deck.findIndex(card => card.kind === 'snooper'), 1);
assert.ok(shield && snooper);
player(g, 'a').hand.push(shield);
player(g, 'e').hand.push(snooper);
player(g, 'a').traitors = [player(g, 'e').leaders[0].id];
player(g, 'e').traitors = [player(g, 'a').leaders[0].id];
for (const id of ['a', 'e']) {
player(g, id).forces = { 'arrakeen:10': 5 };
player(g, id).reserves = 15;
for (const leader of player(g, id).leaders) leader.strength = 0;
}
Object.assign(g, { phase: 6, active: 'a', order: ['a', 'e', 'r'],
response: null, decision: null, phaseOpening: null, ready: [] });
const original = inventory(g);
let battle = applyAction(g, 'a', { type: 'chooseBattle', territory: 'arrakeen', target: 'e' });
for (let step = 0; step < 40; step++) {
if (battle.response) {
const p = battle.players.find(p => !battle.response!.passed.includes(p.id))!;
battle = applyAction(battle, p.id, { type: 'passResponse' });
} else if (battle.battle?.preparation)
battle = applyAction(battle, battle.battle.preparation.owner, { type: 'declineBattlePower' });
else break;
}
if (battle.battle?.preLeader && !battle.battle.preLeader.closed)
for (const id of ['a', 'e'])
battle = applyAction(battle, id, {
type: 'battlePreparationReady', event: battle.battle!.preLeader!.event,
});
battle = applyAction(battle, 'a', { type: 'battlePlan', dial: 2, support: 2,
leader: player(battle, 'a').leaders[0].id, defense: shield.id });
battle = applyAction(battle, 'e', { type: 'battlePlan', dial: 0, support: 0,
leader: player(battle, 'e').leaders[0].id, defense: snooper.id });
battle = applyAction(battle, 'a', { type: 'traitorCall', call: true });
const pending = applyAction(battle, 'e', { type: 'traitorCall', call: true });
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(entry => [
entry.card.id, entry.discardedBy,
]), [[shield.id, 'a'], [snooper.id, 'e']]);
const event = pending.pendingTreacheryDiscard!.batch.event;
const wrongOwner = reload(pending);
wrongOwner.pendingTreacheryDiscard!.batch.entries[0].discardedBy = 'e';
assert.throws(() => viewGame(wrongOwner, 'r'));
const oldCard = reload(pending);
const prior = oldCard.deck.shift()!;
oldCard.discard.push(prior);
oldCard.pendingTreacheryDiscard!.batch.entries[0].card = prior;
assert.throws(() => viewGame(oldCard, 'r'));
const committed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event });
assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
assert.deepEqual(viewGame(committed, 'r').semutaReaction?.candidates.map(card => card.id),
[shield.id, snooper.id]);
assert.deepEqual(viewGame(committed, 'a').semutaReaction?.candidates, []);
assert.deepEqual(normalizeAutomaticGame(reload(committed)), reload(committed));
const selected = applyAction(reload(committed), 'r', { type: 'semutaSelect',
event, card: snooper.id });
assert.equal(selected.pendingTreacheryDiscard, null);
assert.equal(selected.lastBattleContext?.winner, null);
assert.equal(player(selected, 'r').hand.filter(card => card.id === snooper.id).length, 1);
assert.equal(selected.discard.filter(card => card.id === shield.id).length, 1);
assert.equal(selected.discard.filter(card => card.id === semuta.id).length, 1);
assert.deepEqual(inventory(selected), original);
for (const p of selected.players)
assert.deepEqual(viewGame(reload(selected), p.id), viewGame(selected, p.id));
});
+77
View File
@@ -361,6 +361,11 @@ void test('authenticated clean Semuta producers claim once through restart and r
leader: seat(duel, ids[2]).leaders[0].id, defense: defense.id }); leader: seat(duel, ids[2]).leaders[0].id, defense: defense.id });
duel = await act(1, { type: 'traitorCall', call: false }); duel = await act(1, { type: 'traitorCall', call: false });
duel = await act(2, { type: 'traitorCall', call: false }); duel = await act(2, { type: 'traitorCall', call: false });
assert.equal(duel.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
assert.equal(duel.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const mandatoryEvent = duel.pendingTreacheryDiscard!.batch.event;
for (const index of [2, 1, 0])
duel = await act(index, { type: 'semutaPass', event: mandatoryEvent });
assert.equal(duel.decision?.kind, 'battleCards'); assert.equal(duel.decision?.kind, 'battleCards');
const chosen = await act(1, { type: 'decision', discard: [weapon.id, shield.id] }); const chosen = await act(1, { type: 'decision', discard: [weapon.id, shield.id] });
assert.equal(chosen.pendingTreacheryDiscard?.batch.cause, 'battle:winner'); assert.equal(chosen.pendingTreacheryDiscard?.batch.cause, 'battle:winner');
@@ -464,5 +469,77 @@ void test('authenticated clean Semuta producers claim once through restart and r
assert.equal(optional.discard.filter(card => card.id === nextWeapon.id).length, 1); assert.equal(optional.discard.filter(card => card.id === nextWeapon.id).length, 1);
assert.equal(optional.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); assert.equal(optional.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(seat(optional, ids[0]).hand.filter(card => card.id === hero.id).length, 1); assert.equal(seat(optional, ids[0]).hand.filter(card => card.id === hero.id).length, 1);
const mutual = structuredClone(optional);
const mutualSemuta = take(mutual, SEMUTA_DRUG_ID);
mutual.deck.push(take(mutual, hero.id));
seat(mutual, ids[0]).hand.push(mutualSemuta);
const mixedShield = mutual.deck.splice(
mutual.deck.findIndex(card => card.kind === 'shield'), 1)[0];
const mixedSnooper = mutual.deck.splice(
mutual.deck.findIndex(card => card.kind === 'snooper'), 1)[0];
assert.ok(mixedShield && mixedSnooper);
const attacker = seat(mutual, ids[1]), defender = seat(mutual, ids[2]);
attacker.hand.push(mixedShield);
defender.hand.push(mixedSnooper);
for (const p of [attacker, defender]) {
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0) - 5;
p.forces = { 'arrakeen:10': 5 };
for (const leader of p.leaders) leader.strength = 0;
}
const attackerLeader = attacker.leaders.find(leader => !leader.dead)!;
const defenderLeader = defender.leaders.find(leader => !leader.dead)!;
attacker.traitors = [defenderLeader.id];
defender.traitors = [attackerLeader.id];
Object.assign(mutual, { turn: 11, phase: 6, active: ids[1],
order: [ids[1], ids[2], ids[0]], ready: [],
decision: null, response: null, phaseOpening: null, stormPending: null,
lastBattle: [], lastBattleContext: null });
mutual.version = optional.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(mutual), mutual.version, code, optional.version).changes, 1);
let mutualBattle = await act(1, { type: 'chooseBattle', territory: 'arrakeen', target: ids[2] });
for (let step = 0; step < 40; step++) {
if (mutualBattle.response) {
const p = mutualBattle.players.find(p => !mutualBattle.response!.passed.includes(p.id))!;
mutualBattle = await act(ids.indexOf(p.id), { type: 'passResponse' });
} else if (mutualBattle.battle?.preparation)
mutualBattle = await act(ids.indexOf(mutualBattle.battle.preparation.owner),
{ type: 'declineBattlePower' });
else break;
}
if (mutualBattle.battle?.preLeader && !mutualBattle.battle.preLeader.closed)
for (const index of [1, 2])
mutualBattle = await act(index, {
type: 'battlePreparationReady', event: mutualBattle.battle!.preLeader!.event,
});
mutualBattle = await act(1, { type: 'battlePlan', dial: 2, support: 2,
leader: attackerLeader.id, defense: mixedShield.id });
mutualBattle = await act(2, { type: 'battlePlan', dial: 0, support: 0,
leader: defenderLeader.id, defense: mixedSnooper.id });
mutualBattle = await act(1, { type: 'traitorCall', call: true });
const mixedOffer = await act(2, { type: 'traitorCall', call: true });
assert.equal(mixedOffer.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
assert.equal(mixedOffer.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(mixedOffer.pendingTreacheryDiscard?.batch.entries.map(entry => [
entry.card.id, entry.discardedBy,
]), [[mixedShield.id, ids[1]], [mixedSnooper.id, ids[2]]]);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, mixedOffer.version);
const mixedEvent = mixedOffer.pendingTreacheryDiscard!.batch.event;
const mixedCommit = await act(0, { type: 'semutaCommit', event: mixedEvent });
assert.equal(mixedCommit.pendingTreacheryDiscard?.reaction?.stage, 'select');
assert.deepEqual((await f.restart().readSeatView(code, auths[0])).semutaReaction?.candidates.map(card => card.id),
[mixedShield.id, mixedSnooper.id]);
assert.deepEqual((await f.restart().readSeatView(code, auths[1])).semutaReaction?.candidates, []);
const mixedClaim = await act(0, { type: 'semutaSelect', event: mixedEvent, card: mixedSnooper.id });
assert.equal(mixedClaim.pendingTreacheryDiscard, null);
assert.equal(mixedClaim.lastBattleContext?.winner, null);
assert.equal(seat(mixedClaim, ids[0]).hand.filter(card => card.id === mixedSnooper.id).length, 1);
assert.equal(mixedClaim.discard.filter(card => card.id === mixedShield.id).length, 1);
assert.equal(mixedClaim.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(mixedClaim, auth.playerId));
} finally { f.sqlite.close(); } } finally { f.sqlite.close(); }
}); });