Offer Semuta before Ix-deck Thumper Spice Blow

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

No files matched your search

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