Offer Semuta after clean Ix-deck Amal discard
This commit is contained in:
1 parent
f0eafc175e
commit
dffbe7b9f7
9 files changed
+249
-26
No files matched your search
+12
-12
@@ -10,19 +10,19 @@ The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-se
|
||||
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
|
||||
Sapho, battle-card and Ix-deck Thumper/Amal 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.
|
||||
before its injected worm or spice draw; Amal pauses after halving
|
||||
personal spice and clearing earlier opening passes, before phase
|
||||
initialization. Claim or decline resumes each saved suffix once.
|
||||
Mixed-owner battle cards require committed, owner-only selection of
|
||||
another player's fresh card. Mandatory and optional winner cleanup
|
||||
remain separate. Minimal legal AI, JSON/SQLite continuation and
|
||||
competing-room CAS are connected. Mixed arrivals, auction/battle
|
||||
Sapho, queued or promise-bound Truthtrance, pending 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,36 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 30 September 2026 — Semuta after Ix-deck Amal
|
||||
|
||||
In the explicit Richese preview with the physical Ix deck, playing
|
||||
Amal at a clean phase opening now publicly discards its one held
|
||||
card, halves every faction's available personal spice and resets
|
||||
earlier opening passes before a neutral all-seat Semuta offer. A
|
||||
different holder with hand space may claim only that used Amal and
|
||||
discard Semuta; all-seat decline leaves Amal in the ordinary pile.
|
||||
The exact phase-opening window then resumes once. No auction card
|
||||
draw, Bidding prelude or other opening effect runs ahead, and the
|
||||
same spice is not halved again.
|
||||
|
||||
Genuine Ix-deck setup covers prior-pass reset, odd/even spice,
|
||||
owner-only legal AI claim, all-seat decline, physical card census,
|
||||
JSON continuation and later Richese Bidding prelude. An isolated
|
||||
migrated SQLite room restores the paused reaction, commits an
|
||||
authenticated Amal claim and completes the opening without changing
|
||||
saved seats or double-paying. Independent rules/continuation and
|
||||
privacy/recovery reviews found no scoped defect. Nested opening
|
||||
interactions, other discard producers, full-hand ordering, normal
|
||||
Richese starts and deployed acceptance remain gated.
|
||||
|
||||
A direct production-engine run completed genuine Ix-deck setup,
|
||||
halved 11/9/7 personal spice to 5/4/3, showed a neutral offer,
|
||||
let the legal bot recover used Amal and restored cleared opening
|
||||
passes. Three real ready actions then opened Richese's Bidding
|
||||
prelude without another halving. The phone-width rules deep link
|
||||
rendered the boundary as Partial. `npm run check` passed typecheck,
|
||||
lint and 5,814/5,814 offline cases; `npm run build` and all 55
|
||||
Vite development HTTP cases passed. No deployed revision was exercised.
|
||||
|
||||
## 30 September 2026 — Semuta after Ix-deck Thumper
|
||||
|
||||
The explicit Richese Semuta development profile now permits a fresh
|
||||
|
||||
@@ -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 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.
|
||||
- Semuta Drug has a bounded preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho and three separate battle discard stages. A fresh Richese roster may include the physical Ix deck: Thumper pauses before worm/spice draw and Amal pauses after halving spice before phase initialization. Multi-owner battle batches reveal candidates only after holder commitment and transfer one other-player card. Private gifts, unresolved promises, reactive arrivals, 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).
|
||||
|
||||
@@ -113,11 +113,11 @@ 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
|
||||
final Truthtrance, movement-order Sapho, Ix-deck Thumper/Amal and
|
||||
three battle discard stages. Thumper pauses before its injected worm
|
||||
or spice draw; Amal resumes its already-halved phase opening once.
|
||||
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 sources stay
|
||||
unconnected. Full-hand ordering, ten-card inventory, cache custody
|
||||
and public expansion start gate are unchanged.
|
||||
@@ -54,6 +54,14 @@ 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.
|
||||
|
||||
The same Ix deck supplies Amal. In a clean phase opening, its
|
||||
public disposal precedes halving every personal spice amount and
|
||||
clearing prior opening passes. `amalDiscard` binds original balances,
|
||||
the halved result and suspended opening; a neutral offer retires
|
||||
before restoring that opening. Bidding pool draws, phase income and
|
||||
other opening effects therefore still occur only after everyone
|
||||
finishes the opening. Nested openings remain unintegrated.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -65,7 +65,7 @@ The explicit `semuta` development profile starts a fresh Richese roster
|
||||
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, battle and Ix-deck Thumper
|
||||
Truthtrance, movement-order Sapho, battle and Ix-deck Thumper/Amal
|
||||
discards**, provided the parent transaction has no competing response.
|
||||
Every seat sees the same neutral fresh-discard opportunity regardless
|
||||
of hidden Semuta custody.
|
||||
@@ -81,6 +81,11 @@ 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.
|
||||
|
||||
An Ix-deck Amal is already publicly discarded after halving every
|
||||
personal spice amount and resetting previous phase-opening passes.
|
||||
Semuta claim or all-seat decline restores that exact opening; no
|
||||
auction pool, phase income or other opening work runs ahead or twice.
|
||||
|
||||
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
|
||||
|
||||
+77
-2
@@ -1160,6 +1160,14 @@ export type Game = {
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'amalDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
before: { player: string; spice: number }[];
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'truthtranceDiscard';
|
||||
consumed: TruthQueueEntry;
|
||||
@@ -4750,6 +4758,24 @@ function thumperDiscardSignature(g: Game, c: ThumperDiscardContinuation) {
|
||||
})),
|
||||
});
|
||||
}
|
||||
type AmalDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'amalDiscard' }
|
||||
>;
|
||||
function amalDiscardSignature(g: Game, c: AmalDiscardContinuation) {
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, before: c.before,
|
||||
parent: nullentropyParentSignature(g, c.resume, `amal:${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,
|
||||
deck: g.deck, richeseCache: g.richeseCache, auction: g.auction,
|
||||
spice: g.spice, spiceDeck: g.spiceDeck,
|
||||
players: g.players.map(p => ({
|
||||
id: p.id, faction: p.faction, spice: p.spice, bribes: p.bribes,
|
||||
forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
})),
|
||||
});
|
||||
}
|
||||
type TruthDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'truthtranceDiscard' }
|
||||
@@ -5207,6 +5233,29 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
c.stateSignature === thumperDiscardSignature(g, c),
|
||||
'The used Thumper no longer matches its undrawn Spice Blow.',
|
||||
);
|
||||
} else if (continuation?.kind === 'amalDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
requireRule(
|
||||
g.expansions.includes('ix') &&
|
||||
g.players.some(p => p.id === c.player) &&
|
||||
batch.cause === 'amal' && batch.entries.length === 1 &&
|
||||
entry.publicFace && entry.discardedBy === c.player &&
|
||||
entry.card.id === c.card && entry.card.name === 'Amal' &&
|
||||
entry.card.kind === 'special' && entry.card.effect === 'amal' &&
|
||||
c.resume && !c.resume.response && !c.resume.decision &&
|
||||
!c.resume.pendingKarama &&
|
||||
c.resume.phaseOpening &&
|
||||
Array.isArray(c.resume.phaseOpening.passed) &&
|
||||
c.resume.phaseOpening.passed.length === 0 &&
|
||||
typeof c.resume.phaseOpening.initialize === 'boolean' &&
|
||||
Array.isArray(c.before) && c.before.length === g.players.length &&
|
||||
c.before.every((row, index) => row.player === g.players[index].id &&
|
||||
Number.isSafeInteger(row.spice) && row.spice >= 0 &&
|
||||
g.players[index].spice === Math.floor(row.spice / 2)) &&
|
||||
c.stateSignature === amalDiscardSignature(g, c),
|
||||
'The used Amal no longer matches its halved spice and saved phase opening.',
|
||||
);
|
||||
suspendedControlsIntegrity(g, c.resume);
|
||||
} else if (continuation?.kind === 'truthtranceDiscard') {
|
||||
const c = continuation,
|
||||
entry = batch.entries[0],
|
||||
@@ -5823,6 +5872,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'distransDiscard' &&
|
||||
continuation.kind !== 'saphoMovementDiscard' &&
|
||||
continuation.kind !== 'thumperDiscard' &&
|
||||
continuation.kind !== 'amalDiscard' &&
|
||||
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
||||
continuation.kind !== 'winnerMandatoryDiscard' &&
|
||||
continuation.kind !== 'battleResolved' &&
|
||||
@@ -5865,7 +5915,10 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'winnerMandatoryDiscard' ||
|
||||
continuation.kind === 'battleResolved' ||
|
||||
(!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening)) &&
|
||||
!continuation.resume.pendingKarama &&
|
||||
(continuation.kind === 'amalDiscard'
|
||||
? continuation.resume.phaseOpening?.passed.length === 0
|
||||
: !continuation.resume.phaseOpening))) &&
|
||||
!g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle &&
|
||||
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
|
||||
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
|
||||
@@ -6034,6 +6087,11 @@ 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 === 'amalDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
} else if (next.kind === 'thumperDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
@@ -24030,7 +24088,10 @@ function applyActionInner(
|
||||
t === 'card' && card && !action.mode,
|
||||
'Play Amal or pass the phase opening before taking other actions.',
|
||||
);
|
||||
discard(g, p, card.id);
|
||||
const before = g.semutaPreview
|
||||
? g.players.map(player => ({ player: player.id, spice: player.spice }))
|
||||
: null;
|
||||
const used = discard(g, p, card.id);
|
||||
for (const player of g.players)
|
||||
player.spice = Math.floor(player.spice / 2);
|
||||
g.phaseOpening.passed = [];
|
||||
@@ -24038,6 +24099,20 @@ function applyActionInner(
|
||||
g,
|
||||
`${p.name} played Amal. Each faction returned half its available spice to the bank, rounded up.`,
|
||||
);
|
||||
if (before) {
|
||||
const continuation: AmalDiscardContinuation = {
|
||||
kind: 'amalDiscard', player: p.id, card: used.id, before,
|
||||
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)) {
|
||||
g.phaseOpening = null;
|
||||
continuation.stateSignature = amalDiscardSignature(g, continuation);
|
||||
stageTreacheryDiscard(g, 'amal', entries, continuation);
|
||||
}
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
+5
-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 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.'
|
||||
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean public discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before its injected worm and spice draw; Amal pauses after halving everyone’s spice but before the opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, movement-order Sapho and three battle batches. Commit held Semuta with a free hand slot; for several eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Nested responses, pending 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 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.',
|
||||
'The explicit Richese profile interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho, Ix-deck Thumper and Amal, and three battle discard stages. Thumper waits before injecting its worm or drawing spice. Amal has already halved each personal spice amount and reset prior opening passes; claim or decline restores the same phase opening once. A neutral offer precedes every eligible batch, owner-only committed candidates remain private, and Semuta transfers one physical card. 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 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.',
|
||||
'The guide distinguishes clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho, Ix-deck Thumper/Amal and three 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, 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.',
|
||||
'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/Amal 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',
|
||||
@@ -1831,6 +1831,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Ix tables open a public confirmation window before each phase. Play Amal or pass. Everyone receives this window regardless of who holds a card. Once everyone passes, the phase begins.',
|
||||
'A pass commits until another opening card is played. Playing Amal resets the confirmations, so everyone can respond to the changed state before continuing.',
|
||||
'Automatic auctions, collection and Mentat payments wait for confirmation. Unpaid bribes are not available spice until paid at Mentat. Income from the previous phase and unused ally credit are settled before the next opening.',
|
||||
'In the explicitly opted-in Semuta preview with a physical Ix deck, a clean Amal discard pauses after the spice reduction and opening-pass reset. Another holder may claim only the used Amal card; all-seat decline leaves it discarded. Either path restores the same phase-opening confirmation window before bidding, draws or other opening effects begin.',
|
||||
'The server, phase controls and AI support this timing. Full expansion starts remain gated. Printed card timing, conflicts among simultaneous beginning-of-phase powers and later expansion economy interactions still need the complete audit.',
|
||||
],
|
||||
related: ['bidding', 'collection', 'mentat', 'card-thumper', 'ix-modules'],
|
||||
|
||||
@@ -813,3 +813,59 @@ void test('a clean Thumper discard pauses before its injected worm and resumes o
|
||||
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
|
||||
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
|
||||
});
|
||||
|
||||
void test('a clean Ix-deck Amal discard pauses after halving spice but before phase initialization', () => {
|
||||
const { g, semuta, hajr } = fixture(true);
|
||||
player(g, 'a').hand = [];
|
||||
g.deck.push(hajr);
|
||||
const amal = take(g, 'amal');
|
||||
player(g, 'a').hand.push(amal);
|
||||
player(g, 'r').spice = 11;
|
||||
player(g, 'a').spice = 9;
|
||||
player(g, 'e').spice = 7;
|
||||
Object.assign(g, { phase: 2, active: null, ready: [],
|
||||
decision: null, response: null, phaseOpening: null, auction: null });
|
||||
const original = inventory(g);
|
||||
let opening = g;
|
||||
for (const id of ['a', 'e', 'r'])
|
||||
opening = applyAction(opening, id, { type: 'ready' });
|
||||
assert.equal(opening.phase, 3);
|
||||
assert.ok(opening.phaseOpening);
|
||||
opening = applyAction(opening, 'e', { type: 'ready' });
|
||||
assert.deepEqual(opening.phaseOpening?.passed, ['e']);
|
||||
const deck = reload(opening).deck;
|
||||
const pending = applyAction(opening, 'a', { type: 'card', card: amal.id });
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(pending.phaseOpening, null);
|
||||
assert.equal(pending.auction, null);
|
||||
assert.deepEqual(pending.deck, deck);
|
||||
assert.deepEqual(pending.players.map(p => p.spice), [5, 4, 3]);
|
||||
assert.equal(pending.discard.filter(card => card.id === amal.id).length, 1);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
|
||||
let declined = reload(pending);
|
||||
for (const id of ['e', 'a', 'r'])
|
||||
declined = applyAction(declined, id, { type: 'semutaPass', event });
|
||||
assert.equal(declined.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(declined.phaseOpening?.passed, []);
|
||||
assert.deepEqual(declined.players.map(p => p.spice), [5, 4, 3]);
|
||||
assert.deepEqual(inventory(declined), original);
|
||||
const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!);
|
||||
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(claimed.phaseOpening?.passed, []);
|
||||
assert.deepEqual(claimed.players.map(p => p.spice), [5, 4, 3]);
|
||||
assert.equal(player(claimed, 'r').hand.filter(card => card.id === amal.id).length, 1);
|
||||
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
|
||||
assert.equal(claimed.discard.some(card => card.id === amal.id), false);
|
||||
for (const p of claimed.players)
|
||||
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
|
||||
let after = reload(claimed);
|
||||
for (const id of ['a', 'e', 'r'])
|
||||
after = applyAction(after, id, { type: 'ready' });
|
||||
assert.equal(after.phaseOpening, null);
|
||||
assert.equal(after.richeseBidding?.turn, after.turn);
|
||||
assert.deepEqual(after.players.map(p => p.spice), [5, 4, 3]);
|
||||
assert.deepEqual(inventory(after), original);
|
||||
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
|
||||
});
|
||||
@@ -618,5 +618,52 @@ void test('an authenticated Ix-deck Thumper pause retains the undrawn blow throu
|
||||
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));
|
||||
|
||||
const bidding = structuredClone(claimed);
|
||||
const again = take(bidding, SEMUTA_DRUG_ID);
|
||||
bidding.deck.push(take(bidding, thumper.id));
|
||||
seat(bidding, ids[0]).hand.push(again);
|
||||
const amal = take(bidding, 'amal');
|
||||
seat(bidding, ids[1]).hand.push(amal);
|
||||
seat(bidding, ids[0]).spice = 11;
|
||||
seat(bidding, ids[1]).spice = 9;
|
||||
seat(bidding, ids[2]).spice = 7;
|
||||
Object.assign(bidding, { turn: 3, phase: 2, active: null, ready: [],
|
||||
decision: null, response: null, phaseOpening: null, auction: null,
|
||||
spiceWindow: null, spiceResolution: null, spiceSequence: null, nexus: false });
|
||||
bidding.version = claimed.version + 1;
|
||||
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
||||
.run(JSON.stringify(bidding), bidding.version, code, claimed.version).changes, 1);
|
||||
for (let index = 0; index < auths.length; index++)
|
||||
await act(index, { type: 'ready' });
|
||||
const opening = await f.restart().readRoom(code);
|
||||
assert.equal(opening.phase, 3);
|
||||
assert.ok(opening.phaseOpening);
|
||||
assert.deepEqual((await act(2, { type: 'ready' })).phaseOpening?.passed, [ids[2]]);
|
||||
const deckBefore = structuredClone(opening.deck);
|
||||
const offeredAmal = await act(1, { type: 'card', card: amal.id });
|
||||
assert.equal(offeredAmal.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(offeredAmal.players.map(p => p.spice), [5, 4, 3]);
|
||||
assert.deepEqual(offeredAmal.deck, deckBefore);
|
||||
assert.equal(offeredAmal.phaseOpening, null);
|
||||
await f.restart().continueRoomAutomatic(code, clock);
|
||||
assert.equal((await f.restart().readRoom(code)).version, offeredAmal.version);
|
||||
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
|
||||
const recoveredAmal = await act(0, { type: 'semutaCommit',
|
||||
event: offeredAmal.pendingTreacheryDiscard!.batch.event });
|
||||
assert.equal(recoveredAmal.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(recoveredAmal.phaseOpening?.passed, []);
|
||||
assert.deepEqual(recoveredAmal.players.map(p => p.spice), [5, 4, 3]);
|
||||
assert.equal(seat(recoveredAmal, ids[0]).hand.filter(card => card.id === amal.id).length, 1);
|
||||
assert.equal(recoveredAmal.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
||||
for (let index = 0; index < auths.length; index++)
|
||||
await act(index, { type: 'ready' });
|
||||
const resumedAmal = await f.restart().readRoom(code);
|
||||
assert.equal(resumedAmal.phaseOpening, null);
|
||||
assert.equal(resumedAmal.richeseBidding?.turn, resumedAmal.turn);
|
||||
assert.deepEqual(resumedAmal.players.map(p => p.spice), [5, 4, 3]);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
|
||||
for (const auth of auths)
|
||||
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(resumedAmal, auth.playerId));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
Reference in new issue
Block a user