Offer Semuta before Ix-deck Thumper Spice Blow
This commit is contained in:
1 parent
dd6d3a89ca
commit
f0eafc175e
11 files changed
+313
-48
No files matched your search
+16
-17
@@ -7,23 +7,22 @@ Expansion starts and full rules/publication acceptance remain gated.**
|
||||
## Current checkpoint and work
|
||||
|
||||
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,
|
||||
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.
|
||||
accepts a fresh Richese roster with CHOAM & Richese and optionally
|
||||
the physical Ix deck. Clean public ordinary-card, paid Box, retired
|
||||
Ornithopter, private Distrans, final Truthtrance, movement-order
|
||||
Sapho, battle-card and Ix-deck Thumper discards open the same
|
||||
neutral offer regardless of hidden Semuta custody. Thumper pauses
|
||||
before its injected worm or spice draw; claim or decline resumes
|
||||
one saved Spice Blow. Mixed-owner battle cards require a committed,
|
||||
owner-only selection of another player's freshly discarded card.
|
||||
Mandatory and optional winner cleanup remain separate events.
|
||||
Casualties, 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, 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 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,42 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 30 September 2026 — Semuta after Ix-deck Thumper
|
||||
|
||||
The explicit Richese Semuta development profile now permits a fresh
|
||||
CHOAM & Richese game with the physical Ix deck. Only that deck
|
||||
supplies canonical Thumper. Playing it at a clean Spice Blow opening
|
||||
places its public card on the discard pile, then pauses before the
|
||||
injected worm or next actual spice-card draw. Every seat sees the
|
||||
same neutral offer; another holder may take the used Thumper and
|
||||
discard Semuta, or all seats may decline. Both choices run one
|
||||
saved `blowSpice` suffix: the prior territory is devoured once,
|
||||
the next land card is drawn once, and phase/Nexus consequences
|
||||
retain the existing rules. CHOAM-only Semuta games have no Thumper
|
||||
to offer; public expansion starts remain closed.
|
||||
|
||||
Genuine Ix-deck setup exercises owner-only legal AI claim, all-seat
|
||||
decline, pre-draw deck invariance, one force/spice casualty outcome
|
||||
and physical census across JSON. An isolated migrated SQLite room
|
||||
preserves the undrawn state through automatic recovery and commits
|
||||
one authenticated claim while retaining seat rows. Nested opening
|
||||
responses, other discard producers, full-hand ordering, normal
|
||||
Richese starts and deployed acceptance remain gated.
|
||||
|
||||
Independent privacy/recovery review found no scoped defect. Rules
|
||||
review found the initial saved Thumper receipt omitted Advanced
|
||||
mode, elite/No-Field custody, Ix expansion selection and the
|
||||
additional-worm cancellation turn. Failing-before/passing-after
|
||||
regressions now bind those inputs; follow-up rules review found no
|
||||
remaining scoped defect. A direct production-engine run completed
|
||||
genuine Ix-deck setup, undrawn neutral offer and bot claim, then
|
||||
one worm casualty and one spice draw. No original play was modified.
|
||||
|
||||
The actual phone-width rules page rendered the Ix-deck Thumper
|
||||
boundary and Partial checklist. `npm run check` passed typecheck,
|
||||
lint and 5,813/5,813 offline cases after the receipt corrections;
|
||||
`npm run build` and all 55 Vite development HTTP cases passed.
|
||||
No deployed revision was exercised.
|
||||
|
||||
## 30 September 2026 — Semuta at first mandatory battle discard
|
||||
|
||||
The explicit Richese Semuta preview now pauses a clean public
|
||||
|
||||
+14
-14
@@ -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 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.
|
||||
- Semuta Drug has a bounded preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho and separate initial/mandatory-winner/optional-winner battle batches. A fresh Richese roster may explicitly include the physical Ix deck, whose Thumper discard pauses before its worm and spice draw. Multiple public battle cards reveal candidates only after holder commitment and transfer one other-player 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 retain separate bounded paths, not 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).
|
||||
|
||||
@@ -108,16 +108,16 @@ Six effects now have bounded development handlers: Karama, Distrans, Nullentropy
|
||||
## 30 September 2026 — Semuta clean-discard preview
|
||||
|
||||
The [bounded Semuta runtime](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime)
|
||||
supersedes the older inactive-card observations above for one
|
||||
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 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.
|
||||
supersedes older inactive-card observations for one explicitly
|
||||
opted-in fresh Richese profile, optionally with the physical Ix deck.
|
||||
Neutral all-seat responses, physical one-card recovery, provisional
|
||||
free-slot policy, minimal legal AI and saved continuation work after
|
||||
clean ordinary-card, paid Box, retired Ornithopter, private Distrans,
|
||||
final Truthtrance, movement-order Sapho, Ix-deck Thumper and three
|
||||
battle discard stages. Thumper pauses before injecting its worm or
|
||||
drawing a spice card; claim or decline resumes once. Multi-owner
|
||||
batches require committed owner-only selection of another player's
|
||||
card. Queued questions, promises, reactive arrivals, auction/battle
|
||||
Sapho, pending battle rewards and other discard families remain
|
||||
unconnected. Full-hand ordering, ten-card inventory, cache custody
|
||||
and public expansion start gate are unchanged.
|
||||
@@ -47,6 +47,13 @@ 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.
|
||||
|
||||
The explicit Richese preview also accepts a fresh physical Ix deck.
|
||||
Clean Thumper disposal stages `thumperDiscard` before `blowSpice`
|
||||
injects a worm or draws from either spice pile. The saved frame binds
|
||||
the undrawn decks, previous land/discard, forces and alliance state;
|
||||
Semuta claim or all-seat decline retires it before one ordinary
|
||||
Spice Blow suffix. A CHOAM-only preview has no Thumper identity.
|
||||
|
||||
## Main findings
|
||||
|
||||
A post-`applyAction` pile diff cannot implement immediate Semuta. Ix Ambassador and Ixian ally replacement discard then draw directly. Ordinary Karama cancellation/purchase can reach replacement, Harkonnen bonus or a new auction pool indirectly. A stopped frame must resume at a named stage, never rerun its original public action.
|
||||
|
||||
@@ -62,17 +62,25 @@ 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 **clean public
|
||||
with CHOAM & Richese and optionally the physical Ix deck, without
|
||||
opening public 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.
|
||||
Truthtrance, movement-order Sapho, battle and Ix-deck Thumper
|
||||
discards**, provided the parent transaction has no competing response.
|
||||
Every seat sees the same 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.
|
||||
|
||||
For a genuine Ix-deck Thumper, its played physical card reaches
|
||||
the discard before an injected worm or actual spice-card draw.
|
||||
Semuta claim or all-seat decline resumes the one saved Spice Blow;
|
||||
it neither injects a second worm nor replays any earlier effect.
|
||||
Thumper without the Ix deck is not a physical card in this profile.
|
||||
|
||||
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
|
||||
|
||||
@@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
|
||||
if (definition?.card.effect === 'distrans')
|
||||
return 'Choose a recipient and card in the Distrans transfer panel.';
|
||||
if (definition?.card.effect === 'semutaDrug')
|
||||
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean completed ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and battle-card batches are connected; pending rewards, other producers and full-hand sequencing remain unfinished.';
|
||||
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho, battle-card and Ix-deck Thumper discards are connected; pending rewards, other producers and full-hand sequencing remain unfinished.';
|
||||
return definition && definition.card.effect !== 'karama'
|
||||
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
|
||||
: null;
|
||||
|
||||
+76
-5
@@ -1153,6 +1153,13 @@ export type Game = {
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'thumperDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'truthtranceDiscard';
|
||||
consumed: TruthQueueEntry;
|
||||
@@ -4721,6 +4728,28 @@ function saphoMovementDiscardSignature(g: Game, c: SaphoMovementDiscardContinuat
|
||||
})),
|
||||
});
|
||||
}
|
||||
type ThumperDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'thumperDiscard' }
|
||||
>;
|
||||
function thumperDiscardSignature(g: Game, c: ThumperDiscardContinuation) {
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card,
|
||||
parent: nullentropyParentSignature(g, c.resume, `thumper:${c.card}`),
|
||||
turn: g.turn, phase: g.phase, advanced: g.advanced,
|
||||
expansions: g.expansions, active: g.active, order: g.order,
|
||||
ready: g.ready, storm: g.storm, nexus: g.nexus, sandtrout: g.sandtrout,
|
||||
summonedBeforeBlow: g.summonedBeforeBlow, wormRides: g.wormRides,
|
||||
wormPlacementCanceledTurn: g.wormPlacementCanceledTurn,
|
||||
spiceDeck: g.spiceDeck, spiceDiscard: g.spiceDiscard, spice: g.spice,
|
||||
aid: g.aid, allianceOffers: g.allianceOffers,
|
||||
players: g.players.map(p => ({
|
||||
id: p.id, faction: p.faction, ally: p.ally, forces: p.forces,
|
||||
reserves: p.reserves, tanks: p.tanks, advisors: p.advisors,
|
||||
noField: p.noField, elites: p.elites,
|
||||
})),
|
||||
});
|
||||
}
|
||||
type TruthDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'truthtranceDiscard' }
|
||||
@@ -5163,6 +5192,21 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
c.stateSignature === saphoMovementDiscardSignature(g, c),
|
||||
'The used Juice of Sapho no longer matches its committed movement order.',
|
||||
);
|
||||
} else if (continuation?.kind === 'thumperDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
requireRule(
|
||||
g.players.some(p => p.id === c.player) &&
|
||||
g.phase === 1 && !g.spiceSequence && !g.spiceResolution &&
|
||||
!g.spiceWindow && !g.nexus &&
|
||||
batch.cause === 'thumper' && batch.entries.length === 1 &&
|
||||
entry.publicFace && entry.discardedBy === c.player &&
|
||||
entry.card.id === c.card && entry.card.name === 'Thumper' &&
|
||||
entry.card.kind === 'special' && entry.card.effect === 'thumper' &&
|
||||
!c.resume.response && !c.resume.decision &&
|
||||
!c.resume.pendingKarama && !c.resume.phaseOpening &&
|
||||
c.stateSignature === thumperDiscardSignature(g, c),
|
||||
'The used Thumper no longer matches its undrawn Spice Blow.',
|
||||
);
|
||||
} else if (continuation?.kind === 'truthtranceDiscard') {
|
||||
const c = continuation,
|
||||
entry = batch.entries[0],
|
||||
@@ -5778,6 +5822,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'nullentropyDiscard' &&
|
||||
continuation.kind !== 'distransDiscard' &&
|
||||
continuation.kind !== 'saphoMovementDiscard' &&
|
||||
continuation.kind !== 'thumperDiscard' &&
|
||||
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
||||
continuation.kind !== 'winnerMandatoryDiscard' &&
|
||||
continuation.kind !== 'battleResolved' &&
|
||||
@@ -5805,6 +5850,9 @@ function semutaOfferSupported(
|
||||
(continuation.kind === 'ornithopterDiscard' && continuation.source === 'move')) &&
|
||||
(g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove ||
|
||||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false;
|
||||
if (continuation.kind === 'thumperDiscard' &&
|
||||
(g.summonedBeforeBlow || g.summonedWorm || g.wormRides.length > 0 ||
|
||||
g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove)) return false;
|
||||
return g.semutaPreview === true && g.status === 'playing' &&
|
||||
g.players.some(p => p.faction === 'richese') &&
|
||||
entries.length > 0 &&
|
||||
@@ -5986,6 +6034,12 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
if (g.active !== g.movementRemaining?.[0]) movementTurn(g);
|
||||
} else if (next.kind === 'thumperDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
blowSpice(g, true);
|
||||
} else if (next.kind === 'ornithopterDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
@@ -7339,11 +7393,14 @@ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
|
||||
g.moritaniAssassinateCallEvents = [];
|
||||
return g;
|
||||
}
|
||||
/** Development-only Semuta reaction in a fresh Richese roster; public starts stay gated. */
|
||||
/** Development-only Semuta reaction in fresh Richese games, optionally with
|
||||
* the physical Ix Treachery deck; public expansion starts stay gated. */
|
||||
export function initializeSemutaGameForAudit(state: Game): Game {
|
||||
requireRule(!state.semutaPreview && state.expansions.length === 1 &&
|
||||
state.expansions[0] === 'choam' && state.players.some(p => p.faction === 'richese'),
|
||||
'Semuta needs a fresh Richese faction game without other modules.');
|
||||
requireRule(!state.semutaPreview &&
|
||||
state.expansions.includes('choam') &&
|
||||
state.expansions.every(id => id === 'choam' || id === 'ix') &&
|
||||
state.players.some(p => p.faction === 'richese'),
|
||||
'Semuta needs a fresh Richese faction game with no optional modules.');
|
||||
const g = initializeFactionExpansionsGameForAudit(state);
|
||||
g.semutaPreview = true;
|
||||
return g;
|
||||
@@ -27316,11 +27373,25 @@ function applyActionInner(
|
||||
!g.nexus,
|
||||
'Use Thumper at the beginning of Spice Blow, before the first draw.',
|
||||
);
|
||||
discard(g, p, c.id);
|
||||
const used = discard(g, p, c.id);
|
||||
log(
|
||||
g,
|
||||
`${p.name} played Thumper in place of the next spice-card reveal.`,
|
||||
);
|
||||
if (g.semutaPreview) {
|
||||
const continuation: ThumperDiscardContinuation = {
|
||||
kind: 'thumperDiscard', player: p.id, card: used.id,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||
stateSignature: '',
|
||||
};
|
||||
const entries = [{ card: used, discardedBy: p.id, publicFace: true }];
|
||||
if (semutaOfferSupported(g, continuation, entries)) {
|
||||
continuation.stateSignature = thumperDiscardSignature(g, continuation);
|
||||
stageTreacheryDiscard(g, 'thumper', entries, continuation);
|
||||
return g;
|
||||
}
|
||||
}
|
||||
blowSpice(g, true);
|
||||
return g;
|
||||
}
|
||||
|
||||
+4
-4
@@ -1156,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
: definition.card.effect === 'portableSnooper'
|
||||
? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.'
|
||||
: definition.card.effect === 'semutaDrug'
|
||||
? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho or battle-card discards without pending rewards. The battle’s first mandatory batch can include cards from both combatants; a later mandatory winning-card cleanup and optional winner selection are separate fresh batches. Continue or commit held Semuta with a free hand slot; only another player’s card is eligible. A sole card transfers automatically; for several, commit first and privately select exactly one. Casualties and awards never replay. Mixed arrivals, reward-pending battle effects, competing transactions, full-hand sequencing and normal Richese starts remain unfinished.'
|
||||
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean public discards. A CHOAM & Richese game may also include the physical Ix deck: using Thumper then pauses before its injected worm and Spice Blow draw. Other connected sources include ordinary cards, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho and three separate battle batches. Commit held Semuta with a free hand slot; for several eligible cards, inspect and select exactly one only after commitment. The original effect resumes once. Nested responses, unreconciled battle rewards, other sources, full-hand sequencing and public starts remain unfinished.'
|
||||
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
||||
],
|
||||
...(definition.card.effect === 'semutaDrug'
|
||||
@@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation' as const,
|
||||
status: 'Partial' as const,
|
||||
detail:
|
||||
'The explicit Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and three separate battle discard stages: initial mandatory combatants, mandatory winner and optional winner. A neutral all-seat offer precedes each eligible batch. Only a committed holder sees multi-card candidates from other players; Semuta transfers one physical card, is discarded once and resumes the saved battle/casualty suffix once. Mixed arrivals, pending battle rewards, other producers and full-hand exchange remain unfinished.',
|
||||
'The explicit Richese profile interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho, Ix-deck Thumper and three battle discard stages. Thumper pauses before any injected worm or spice-card draw; claim or decline resumes one normal Spice Blow. A neutral offer precedes each eligible batch. Only a committed holder sees multi-card candidates from other players; Semuta transfers one card, is discarded once and resumes the saved effect once. Competing responses, pending rewards, other producers and full-hand exchange remain unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls' as const,
|
||||
@@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Documentation' as const,
|
||||
status: 'Partial' as const,
|
||||
detail:
|
||||
'The guide distinguishes bounded clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and initial/mandatory-winner/optional-winner battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
|
||||
'The guide distinguishes clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho, Ix-deck Thumper and initial/mandatory-winner/optional-winner battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
|
||||
},
|
||||
{
|
||||
area: 'Verification' as const,
|
||||
status: 'Partial' as const,
|
||||
detail:
|
||||
'Genuine setup, physical custody, hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and separate mandatory/optional battle reactions, including a two-owner simultaneous batch. Pending battle rewards, full games and live deployment remain unverified.',
|
||||
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho, Ix-deck Thumper and three battle discard stages. Pending rewards, full games and live deployment remain unverified.',
|
||||
evidence: [
|
||||
'tests/semuta-engine.test.ts',
|
||||
'tests/semuta-recovery.test.ts',
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { spiceDeck } from '../game/cards';
|
||||
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
@@ -19,8 +20,8 @@ function inventory(g: Game) {
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
return ids;
|
||||
}
|
||||
function fixture() {
|
||||
let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
|
||||
function fixture(ix = false) {
|
||||
let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), false, ix ? ['choam', 'ix'] : ['choam']);
|
||||
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
|
||||
for (const p of g.players) { p.bot = 'Medium'; p.ready = true; }
|
||||
@@ -747,3 +748,68 @@ void test('a mutual-traitor battle offers one mixed-owner mandatory batch with o
|
||||
for (const p of selected.players)
|
||||
assert.deepEqual(viewGame(reload(selected), p.id), viewGame(selected, p.id));
|
||||
});
|
||||
|
||||
void test('a clean Thumper discard pauses before its injected worm and resumes one Spice Blow', () => {
|
||||
const { g, semuta, hajr } = fixture(true);
|
||||
player(g, 'a').hand = [];
|
||||
g.deck.push(hajr);
|
||||
const thumper = take(g, 'thumper');
|
||||
player(g, 'a').hand.push(thumper);
|
||||
const lands = spiceDeck().filter(card => 'territory' in card);
|
||||
const prior = lands[0], next = lands[1];
|
||||
if (!('territory' in prior) || !('territory' in next))
|
||||
throw Error('Missing real spice territory cards');
|
||||
player(g, 'e').forces = { [`${prior.territory}:${prior.sector}`]: 3 };
|
||||
player(g, 'e').reserves = 17;
|
||||
g.spice = { [`${prior.territory}:${prior.sector}`]: 8 };
|
||||
g.spiceDiscard = [[prior], []];
|
||||
g.spiceDeck = [next, ...spiceDeck().filter(card => 'worm' in card)];
|
||||
Object.assign(g, { phase: 1, turn: 2, active: null, ready: [],
|
||||
decision: null, response: null, phaseOpening: null,
|
||||
spiceWindow: null, spiceResolution: null, spiceSequence: null, nexus: false });
|
||||
const original = inventory(g), deck = reload(g).spiceDeck;
|
||||
const pending = applyAction(g, 'a', { type: 'card', card: thumper.id });
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(pending.spiceWindow, null);
|
||||
assert.deepEqual(pending.spiceDeck, deck);
|
||||
assert.equal(player(pending, 'e').tanks, 0);
|
||||
assert.equal(pending.spice[`${prior.territory}:${prior.sector}`], 8);
|
||||
assert.equal(pending.discard.filter(card => card.id === thumper.id).length, 1);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
|
||||
const profile = reload(pending);
|
||||
profile.advanced = !profile.advanced;
|
||||
assert.throws(() => viewGame(profile, 'r'));
|
||||
const custody = reload(pending);
|
||||
player(custody, 'r').noField = undefined;
|
||||
assert.throws(() => normalizeAutomaticGame(custody));
|
||||
const expansion = reload(pending);
|
||||
expansion.expansions = ['choam'];
|
||||
assert.throws(() => viewGame(expansion, 'r'));
|
||||
const placement = reload(pending);
|
||||
placement.wormPlacementCanceledTurn = pending.turn;
|
||||
assert.throws(() => normalizeAutomaticGame(placement));
|
||||
let declined = reload(pending);
|
||||
for (const id of ['e', 'a', 'r'])
|
||||
declined = applyAction(declined, id, { type: 'semutaPass', event });
|
||||
assert.equal(declined.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(declined, 'e').tanks, 3);
|
||||
assert.equal(declined.spice[`${prior.territory}:${prior.sector}`], undefined);
|
||||
assert.equal(declined.spiceWindow?.territory, next.territory);
|
||||
assert.equal(declined.discard.filter(card => card.id === thumper.id).length, 1);
|
||||
assert.deepEqual(inventory(declined), original);
|
||||
const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!);
|
||||
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(claimed, 'e').tanks, 3);
|
||||
assert.equal(claimed.spiceWindow?.territory, next.territory);
|
||||
assert.deepEqual(claimed.spiceDeck, declined.spiceDeck);
|
||||
assert.deepEqual(claimed.spiceDiscard, declined.spiceDiscard);
|
||||
assert.equal(player(claimed, 'r').hand.filter(card => card.id === thumper.id).length, 1);
|
||||
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
|
||||
assert.equal(claimed.discard.some(card => card.id === thumper.id), false);
|
||||
assert.deepEqual(inventory(claimed), original);
|
||||
for (const p of claimed.players)
|
||||
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
|
||||
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { startPrototypeRoom } from '../tools/prototype-room';
|
||||
import { botActions } from '../game/bots';
|
||||
import { viewGame, type Game, type Action } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { spiceDeck } from '../game/cards';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock = { now: () => 10000, sleep: async () => {} };
|
||||
@@ -543,3 +544,79 @@ void test('authenticated clean Semuta producers claim once through restart and r
|
||||
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(mixedClaim, auth.playerId));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('an authenticated Ix-deck Thumper pause retains the undrawn blow through restart', async () => {
|
||||
const f = unitStore();
|
||||
try {
|
||||
const created = await f.rooms.createRoom('Semuta Thumper QA', 'richese', false, ['choam', 'ix']);
|
||||
const code = created.view.code;
|
||||
const second = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
|
||||
const third = await f.rooms.joinRoom(code, 'Emperor', 'emperor');
|
||||
const auths = await Promise.all([created.token!, second.token!, third.token!]
|
||||
.map(token => f.restart().authenticate(code, token)));
|
||||
const ids = auths.map(auth => auth.playerId);
|
||||
const act = async (index: number, action: Action) => {
|
||||
const before = await f.restart().readRoom(code);
|
||||
await f.restart().act(code, auths[index], before.version, action, clock);
|
||||
return f.restart().readRoom(code);
|
||||
};
|
||||
for (let index = 0; index < auths.length; index++) await act(index, { type: 'ready' });
|
||||
let game = await f.restart().readRoom(code);
|
||||
startPrototypeRoom(f.sqlite, code, game.version, 'semuta');
|
||||
game = await f.restart().readRoom(code);
|
||||
for (let step = 0; game.status === 'setup' && step < 80; step++) {
|
||||
let moved = false;
|
||||
for (let index = 0; index < auths.length; index++) {
|
||||
const view = viewGame(game, ids[index]);
|
||||
view.players.find(p => p.id === view.me)!.bot = 'Medium';
|
||||
const choice = botActions(view)[0];
|
||||
if (choice) { game = await act(index, choice); moved = true; break; }
|
||||
}
|
||||
assert.ok(moved, 'genuine Ix-deck setup needs a legal AI choice');
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const originalSeats = f.sqlite.prepare('SELECT * FROM seats').all();
|
||||
const semuta = take(game, SEMUTA_DRUG_ID), thumper = take(game, 'thumper');
|
||||
game.deck.push(...game.players.flatMap(p => p.hand));
|
||||
for (const p of game.players) p.hand = [];
|
||||
seat(game, ids[0]).hand.push(semuta);
|
||||
seat(game, ids[1]).hand.push(thumper);
|
||||
const lands = spiceDeck().filter(card => 'territory' in card);
|
||||
const prior = lands[0], next = lands[1];
|
||||
if (!('territory' in prior) || !('territory' in next))
|
||||
throw Error('Missing real spice territories');
|
||||
seat(game, ids[2]).forces = { [`${prior.territory}:${prior.sector}`]: 3 };
|
||||
seat(game, ids[2]).reserves = 17;
|
||||
Object.assign(game, { turn: 2, phase: 1, active: null, ready: [],
|
||||
decision: null, response: null, phaseOpening: null,
|
||||
spiceWindow: null, spiceResolution: null, spiceSequence: null, nexus: false,
|
||||
spice: { [`${prior.territory}:${prior.sector}`]: 8 },
|
||||
spiceDiscard: [[prior], []], spiceDeck: [next, ...spiceDeck().filter(card => 'worm' in card)] });
|
||||
const beforeDeck = structuredClone(game.spiceDeck);
|
||||
game.version++;
|
||||
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
||||
.run(JSON.stringify(game), game.version, code, game.version - 1).changes, 1);
|
||||
const pending = await act(1, { type: 'card', card: thumper.id });
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.spiceDeck, beforeDeck);
|
||||
assert.equal(seat(pending, ids[2]).tanks, 0);
|
||||
await f.restart().continueRoomAutomatic(code, clock);
|
||||
assert.equal((await f.restart().readRoom(code)).version, pending.version);
|
||||
const holder = await f.restart().readSeatView(code, auths[0]);
|
||||
const rival = await f.restart().readSeatView(code, auths[2]);
|
||||
assert.equal(holder.semutaReaction?.canCommit, true);
|
||||
assert.equal(rival.semutaReaction?.canCommit, false);
|
||||
const claimed = await act(0, { type: 'semutaCommit',
|
||||
event: pending.pendingTreacheryDiscard!.batch.event });
|
||||
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||||
assert.equal(seat(claimed, ids[2]).tanks, 3);
|
||||
assert.equal(claimed.spiceWindow?.territory, next.territory);
|
||||
assert.equal(claimed.spiceDeck.length, beforeDeck.length - 1);
|
||||
assert.equal(seat(claimed, ids[0]).hand.filter(card => card.id === thumper.id).length, 1);
|
||||
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
||||
assert.equal(claimed.discard.some(card => card.id === thumper.id), false);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
|
||||
for (const auth of auths)
|
||||
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -20,7 +20,7 @@ async function main() {
|
||||
});
|
||||
if (values.help) {
|
||||
console.log(
|
||||
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into the limited clean ordinary-discard reaction without opening public starts. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
|
||||
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user