Offer Semuta on clean mandatory battle batch
This commit is contained in:
1 parent
e55d275014
commit
dd6d3a89ca
10 files changed
+308
-71
No files matched your search
+15
-16
@@ -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)
|
||||
pauses clean public ordinary-card, paid Box, retired Ornithopter,
|
||||
private Distrans, final definite Truthtrance, movement-order Sapho,
|
||||
mandatory winner or later optional winner battle-card discards without
|
||||
pending rewards. The two winner batches are separate: a claim on the
|
||||
mandatory Hero can resume the winner's optional weapon choice once.
|
||||
Every seat sees a neutral offer regardless of hidden Semuta possession.
|
||||
A sole eligible card transfers on commitment; a simultaneous winner
|
||||
batch first requires public commitment, then reveals eligible faces
|
||||
only to the holder for one-card selection. Other cards remain in the
|
||||
pile and the resolved battle resumes once. The flight queue, committed
|
||||
arrival, private gift, answer history and Sapho order also resume
|
||||
without replay. Minimal legal AI, exact JSON/SQLite continuation and
|
||||
competing-room CAS are connected. Mixed arrivals, auction/battle
|
||||
Sapho, queued or promise-bound Truthtrance, loser/mixed-owner battle
|
||||
batches, pending battle rewards, other discard producers, full-hand
|
||||
ordering, normal Richese starts and deployed acceptance remain
|
||||
unfinished. Neutral passes and free-slot guard are product safety
|
||||
choices, not publisher rulings.
|
||||
initial mandatory battle, mandatory winner and optional winner
|
||||
discards without competing rewards. A mutual-traitor battle can
|
||||
discard two public cards with different former owners in one batch;
|
||||
a holder must commit before seeing the selectable fresh faces and
|
||||
may take only a card discarded by another player. Mandatory Hero
|
||||
cleanup and later optional weapon disposal remain separate events.
|
||||
Every seat sees a neutral offer regardless of hidden Semuta custody.
|
||||
Casualties, battle results, flight arrival, private gifts, answer
|
||||
history and Sapho order resume without replay. Minimal legal AI,
|
||||
exact JSON/SQLite continuation and competing-room CAS are connected.
|
||||
Mixed arrivals, auction/battle Sapho, queued or promise-bound
|
||||
Truthtrance, pending battle rewards and other discard producers,
|
||||
full-hand ordering, normal Richese starts and deployed acceptance
|
||||
remain unfinished. Neutral passes and free-slot guard are product
|
||||
safety choices, not publisher rulings.
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
# 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
|
||||
|
||||
The explicit Richese Semuta preview now pauses a clean public
|
||||
|
||||
@@ -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.
|
||||
|
||||
- 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).
|
||||
|
||||
@@ -113,10 +113,11 @@ explicitly opted-in Richese development profile. Neutral all-seat
|
||||
responses, physical one-card recovery, a provisional free-slot guard,
|
||||
minimal legal AI and saved continuation work after clean public
|
||||
ordinary-card, paid Box, retired Ornithopter, private Distrans, final
|
||||
definite Truthtrance, movement-order Sapho and separate mandatory
|
||||
and optional winner battle-card batches, including committed
|
||||
owner-only multi-card selection. Queued questions, promise-bound
|
||||
answers, reactive arrivals, auction/battle Sapho, loser or mixed-owner
|
||||
battle batches and pending battle rewards remain unconnected.
|
||||
Full-hand ordering, ten-card inventory, cache custody and public
|
||||
expansion start gate are unchanged.
|
||||
definite Truthtrance, movement-order Sapho and three separate battle
|
||||
card stages: initial mandatory combatants, mandatory winner and
|
||||
optional winner. Multi-owner batches require committed owner-only
|
||||
selection of one other-player card. Queued questions, promises,
|
||||
reactive arrivals, auction/battle Sapho, pending battle rewards and
|
||||
other semantic discard families remain unconnected. Full-hand
|
||||
ordering, ten-card inventory, cache custody and public expansion
|
||||
start gate are unchanged.
|
||||
@@ -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
|
||||
arrival once. Mixed entry reactions remain unsupported for Semuta.
|
||||
|
||||
Mandatory winner cleanup now pauses at `winnerMandatoryDiscard`
|
||||
after any earlier loser/battle batch has retired; a Hero already in
|
||||
the public pile can be claimed before the exact optional winner
|
||||
choice resumes. If the winner then selects other played cards,
|
||||
their public disposal is a separate `battleCleanup` batch. Each
|
||||
stage has a neutral all-seat offer; only a committed holder inspects
|
||||
multiple eligible cards, selects one, and leaves the others in the
|
||||
pile. No battle award or card cost repeats. Moritani loser retention
|
||||
and pending Auditor, capture, tech, Face Dance or CHOAM rewards
|
||||
remain automatic without this reaction.
|
||||
Clean `battleResolved` now pauses the first public mandatory batch
|
||||
after combat result and actual card custody are committed but before
|
||||
casualty choices or winner follow-up. A mutual-Traitor outcome may
|
||||
place two cards from different former owners in that one batch;
|
||||
claimants can choose only a card from another player. Later mandatory
|
||||
winning-card cleanup uses a separate `winnerMandatoryDiscard` batch,
|
||||
and an optional winner selection uses a third `battleCleanup` batch.
|
||||
Every supported stage has a neutral all-seat offer; committed holders
|
||||
select one fresh card, leave the others in the pile and resume exactly
|
||||
once. Pending Auditor, capture, tech, Face Dance or CHOAM rewards
|
||||
remain automatic without Semuta.
|
||||
|
||||
## Main findings
|
||||
|
||||
|
||||
@@ -62,33 +62,32 @@ Do not ask again about normal targets, forced-discard owner bookkeeping, simulta
|
||||
## 30 September 2026 — bounded clean-discard runtime
|
||||
|
||||
The explicit `semuta` development profile starts a fresh Richese roster
|
||||
without opening ordinary expansion starts. It interrupts **one public
|
||||
ordinary-card discard, the used Box after a paid Nullentropy search,
|
||||
either retirement of a played Ornithopter in a clean flight, the used
|
||||
Distrans after a private transfer, a final definite Truthtrance,
|
||||
movement-order Juice of Sapho, mandatory winning-card cleanup, or a
|
||||
later winner-selected battle-card batch without pending rewards**,
|
||||
provided the parent transaction is clean. Every seat sees a neutral
|
||||
fresh-discard opportunity regardless
|
||||
of hidden Semuta possession. An early-ended Ornithopter advances its
|
||||
saved movement queue; a completed flight has committed its forces
|
||||
and resumes its one arrival only without BG, Ecaz or Moritani entry
|
||||
reactions. Distrans's gift stays private with its recipient. The
|
||||
Truthtrance answer/history and Sapho first/last queue are bound.
|
||||
Mandatory winning cards form one public fresh batch after the
|
||||
previous loser cleanup, and any later optional winner selection is a
|
||||
separate batch. Neither a Semuta claim nor all-seat decline replays
|
||||
casualties or re-discards an already claimed card.
|
||||
without opening ordinary expansion starts. It interrupts **clean public
|
||||
ordinary-card, paid Box, retired Ornithopter, used Distrans, final
|
||||
Truthtrance, movement-order Sapho and battle discard batches**, provided
|
||||
the parent transaction has no competing response. Every seat sees a
|
||||
neutral fresh-discard opportunity regardless of hidden Semuta custody.
|
||||
An early-ended Ornithopter advances the saved queue; a completed
|
||||
flight resumes one committed arrival only without BG, Ecaz or
|
||||
Moritani entry reactions. Distrans's gift remains private with its
|
||||
recipient; Truthtrance answer/history and Sapho first/last order are
|
||||
already bound.
|
||||
|
||||
The first battle mandatory batch may contain public cards belonging
|
||||
to both combatants. Its accepted result and card custody precede
|
||||
the neutral offer; casualty allocation and winner cleanup still
|
||||
follow it once. Mandatory winning-card cleanup, if present, is a
|
||||
separate later batch; optional winner-selected disposal is later
|
||||
again. No Semuta claim replays combat or combines these events.
|
||||
|
||||
Each seat can Continue; only the canonical holder with a free hand
|
||||
slot and a card discarded by another player can commit. A sole
|
||||
eligible fresh card transfers automatically in that same action.
|
||||
Multiple eligible public winner cards instead open a committed,
|
||||
owner-only inspection and exact one-card selection; the other fresh
|
||||
cards stay in the pile and Semuta is discarded once. The saved
|
||||
ordinary effect or Box parent resumes once. No undisclosed face is
|
||||
projected before commitment. Other simultaneous discard producers
|
||||
remain unfinished.
|
||||
Multiple eligible public battle cards open a committed, owner-only
|
||||
inspection and exact one-card selection; the other cards remain in
|
||||
the pile and Semuta is discarded once. The saved ordinary effect or
|
||||
Box parent resumes once. No undisclosed face is projected before
|
||||
commitment. Other simultaneous discard producers remain unfinished.
|
||||
|
||||
This neutral all-seat acknowledgement is a conservative **application
|
||||
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,
|
||||
pending gifts, competing responses, completed Ornithopter arrivals
|
||||
with BG intrusion, Ecaz Ambassador or Moritani Terror, queued or
|
||||
promise-bound Truthtrance, auction/battle Sapho, loser or mixed-owner
|
||||
mandatory battle batches and pending battle rewards retain their
|
||||
automatic continuation; Semuta cannot claim those events yet.
|
||||
The completed Box's selected search card stays private and its paid
|
||||
shuffle is not repeated when another seat takes the used Box.
|
||||
No source ruling is inferred from this omission. The
|
||||
promise-bound Truthtrance, auction/battle Sapho, pending battle
|
||||
rewards and other typed producers retain their automatic continuation;
|
||||
Semuta cannot claim those events yet. The completed Box's selected
|
||||
search card stays private and its paid shuffle is not repeated
|
||||
when another seat takes the used Box. No source ruling is inferred
|
||||
from this omission. The
|
||||
separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why
|
||||
auto-closing only when nobody secretly holds the card would leak
|
||||
possession. The main game and public expansion gates remain closed.
|
||||
@@ -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 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'
|
||||
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
|
||||
: null;
|
||||
|
||||
+41
-2
@@ -1783,6 +1783,10 @@ export type Game = {
|
||||
result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion' | 'legacy';
|
||||
cardRoles?: Record<string, Record<string, Omit<EcazPoisonDiscard, 'card'>>>;
|
||||
cardRolesSignature?: string;
|
||||
mandatoryDiscard?: {
|
||||
entries: { card: string; owner: string }[];
|
||||
signature: string;
|
||||
};
|
||||
winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
|
||||
sukRescue?: { 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;
|
||||
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) {
|
||||
const context = g.lastBattleContext;
|
||||
if (!context?.cardRoles && !context?.cardRolesSignature) return;
|
||||
@@ -5547,6 +5558,20 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
),
|
||||
'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.
|
||||
auditorIntegrity(g);
|
||||
const loser = c.combatants.find((id) => id !== context.winner);
|
||||
@@ -5755,6 +5780,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'saphoMovementDiscard' &&
|
||||
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
||||
continuation.kind !== 'winnerMandatoryDiscard' &&
|
||||
continuation.kind !== 'battleResolved' &&
|
||||
!(continuation.kind === 'ornithopterDiscard' &&
|
||||
(continuation.source === 'end' ||
|
||||
(continuation.source === 'move' &&
|
||||
@@ -5764,8 +5790,11 @@ function semutaOfferSupported(
|
||||
!(continuation.kind === 'truthtranceDiscard' &&
|
||||
continuation.consumed.source === undefined &&
|
||||
continuation.remaining === null && continuation.promise === null)) return false;
|
||||
if (continuation.kind === 'battleResolved' &&
|
||||
!g.lastBattleContext?.mandatoryDiscard) return false;
|
||||
if ((continuation.kind === 'battleCleanup' ||
|
||||
continuation.kind === 'winnerMandatoryDiscard') &&
|
||||
continuation.kind === 'winnerMandatoryDiscard' ||
|
||||
continuation.kind === 'battleResolved') &&
|
||||
(g.pendingAuditor || g.pendingCapture || g.pendingTech ||
|
||||
g.pendingFaceDance || g.pendingChoamBattleIncome ||
|
||||
g.pendingWinnerDiscards || g.moritaniRetention ||
|
||||
@@ -5780,11 +5809,13 @@ function semutaOfferSupported(
|
||||
g.players.some(p => p.faction === 'richese') &&
|
||||
entries.length > 0 &&
|
||||
(continuation.kind === 'battleCleanup' ||
|
||||
continuation.kind === 'winnerMandatoryDiscard'
|
||||
continuation.kind === 'winnerMandatoryDiscard' ||
|
||||
continuation.kind === 'battleResolved'
|
||||
? entries.every(entry => entry.publicFace)
|
||||
: entries.length === 1 && entries[0].publicFace) &&
|
||||
(continuation.kind === 'battleCleanup' ||
|
||||
continuation.kind === 'winnerMandatoryDiscard' ||
|
||||
continuation.kind === 'battleResolved' ||
|
||||
(!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening)) &&
|
||||
!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 } } : {}),
|
||||
};
|
||||
}
|
||||
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;
|
||||
const cards = [...quote.winnerCards];
|
||||
const continuation: Extract<
|
||||
|
||||
+5
-5
@@ -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 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.',
|
||||
],
|
||||
...(definition.card.effect === 'semutaDrug'
|
||||
@@ -1166,13 +1166,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation' as const,
|
||||
status: 'Partial' as const,
|
||||
detail:
|
||||
'An explicitly opted-in Richese profile interrupts clean public ordinary-card, paid Box, 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,
|
||||
status: 'Partial' as const,
|
||||
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,
|
||||
@@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Documentation' as const,
|
||||
status: 'Partial' as const,
|
||||
detail:
|
||||
'The guide distinguishes bounded public ordinary-card, paid Box, 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,
|
||||
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, 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: [
|
||||
'tests/semuta-engine.test.ts',
|
||||
'tests/semuta-recovery.test.ts',
|
||||
|
||||
@@ -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 });
|
||||
battle = applyAction(battle, 'a', { 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');
|
||||
const pending = applyAction(reload(battle), 'a', { type: 'decision', discard: [weapon.id] });
|
||||
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.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));
|
||||
});
|
||||
@@ -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 });
|
||||
duel = await act(1, { 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');
|
||||
const chosen = await act(1, { type: 'decision', discard: [weapon.id, shield.id] });
|
||||
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 === SEMUTA_DRUG_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(); }
|
||||
});
|
||||
Reference in new issue
Block a user