Offer Semuta before private Ixian ally replacement

This commit is contained in:
Quintine committed 2026-09-30 14:08:48 +10:00
1 parent 8f6b1c8463
commit 9bfc3ec037
8 files changed
+246 -34

No files matched your search

+24 -16
View File
@@ -9,14 +9,15 @@ Expansion starts and full rules/publication acceptance remain gated.**
The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime) The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime)
accepts a fresh Richese roster with CHOAM & Richese and optionally accepts a fresh Richese roster with CHOAM & Richese and optionally
the physical Ix deck. Clean public ordinary-card, paid Box, retired the physical Ix deck. Clean public ordinary-card, paid Box, retired
Ornithopter, private Distrans, final Truthtrance, Once Around, Ornithopter, used Distrans, final Truthtrance, four bounded Sapho,
movement-order, between-battles chooser or pre-plan aggressor Sapho, pre-leader Residual Poison, printed Karama canceling clean CHOAM
clean pre-leader Residual Poison, printed Karama canceling clean Charity, Inflation or Bene Gesserit Charity or settling a clean
CHOAM Charity, Inflation or Bene Gesserit Charity or using a clean normal-auction lot, battle-card and Ix-deck Thumper/Amal discards
normal-auction lot for a free purchase or winning-bid payment, open the same neutral offer regardless of hidden Semuta custody.
battle-card and Ix-deck Thumper/Amal discards open the same neutral A clean **private** normal-auction Ixian ally replacement discard
offer regardless of hidden Semuta custody. Karama waits after its also pauses before its draw; no unknown face is shown before a
public cost but before cancellation or the reserved lot settles. holder commits. Karama waits after its public cost but before
cancellation or the reserved lot settles.
Residual Poison commits one random death before the offer; only an Residual Poison commits one random death before the offer; only an
uninspected battle with nobody ready qualifies. Once Around commits uninspected battle with nobody ready qualifies. Once Around commits
bidder order and frees a slot; other Sapho scopes commit chooser or bidder order and frees a slot; other Sapho scopes commit chooser or
@@ -32,19 +33,26 @@ full-hand ordering, normal Richese starts and deployed acceptance
remain unfinished. Neutral passes and free-slot guard are product remain unfinished. Neutral passes and free-slot guard are product
choices. choices.
Clean printed Karama normal-auction purchase and winning-bid payment The private normal-auction Ixian ally reaction passed genuine-setup
passed engine and authenticated SQLite checks. `npm run check` passed engine, authenticated SQLite and direct engine smoke checks. The full
types, lint and 5,837 offline tests; the build and phone-width guide `npm run check` passed types, lint and 5,839 offline tests; build and
inspection passed. A focused built-worker Advanced-lobby HTTP test phone-width guide inspection passed. The isolated built-worker HTTP
timed out at its 15-second request limit; direct QA-room create/join suite passed 49/55 tests: five returned 503 instead of expected
and two authenticated reads passed. The previous purchase build's authorization/validation/success statuses, and one Advanced-lobby
full HTTP suite passed 51/55 cases; four origin/malformed-request request exceeded its 15-second limit. Full HTTP acceptance remains open.
cases returned 503 instead of 403/400. The full HTTP gate remains open.
The previous printed Karama purchase/payment checkpoints passed
engine and authenticated SQLite checks; their local HTTP caveats
are recorded in the implementation log.
The previous purchase revision `eede77a` passed container workflow The previous purchase revision `eede77a` passed container workflow
`36664985601`, including its isolated storage/HTTP gate, and published `36664985601`, including its isolated storage/HTTP gate, and published
an image. This does not deploy it to the live NAS. an image. This does not deploy it to the live NAS.
The following paid Karama revision `8f6b1c8` also passed container
workflow `36665999972` through isolated storage/HTTP verification
and published its image. Neither checkpoint has been deployed to the NAS.
Container workflow `36656043783` built `32d9ef4` but failed Container workflow `36656043783` built `32d9ef4` but failed
isolated storage/HTTP verification; that image was not published and isolated storage/HTTP verification; that image was not published and
the public job exposes no assertion log. The next checkpoint the public job exposes no assertion log. The next checkpoint
+30
View File
@@ -1,5 +1,35 @@
# Dune implementation status # Dune implementation status
## 30 September 2026 — Semuta before private Ixian ally replacement
The explicit Richese preview with the Ix expansion now interrupts a
clean normal-auction Ixian ally card replacement at its original
private discard, before the replacement draw. The buyer paid for the
lot and accepted the alliance; every seat sees the same neutral
Semuta offer without its card face or a named claimant. A committed
holder can take the exact fresh card; claim or all-seat decline
then draws one replacement for the buyer and finishes the saved
sale/Richese cache continuation. The original buyer is the
discarder, not the Ixians. Cache and Black Market sales, nested
interactions and other private discard producers remain guarded.
An engine scenario through genuine three-seat setup checked hidden
Semuta-holder parity in both outsider views, unspent deck order,
physical custody, one replacement and legal AI claim. An authenticated
SQLite scenario checked restart, owner-only controls, stale retry and
the owed Richese cache choice. A separate direct engine smoke
exercised a paid lot, one private reaction and the replacement.
Normal expansion starts and full Semuta acceptance remain gated.
`npm run check` passed types, lint and 5,839 offline tests, and the
production build passed. A phone-width browser view of the built
worker showed the private-discard timing in the rules guide.
Independent review found no scoped defect. The isolated built-worker
HTTP suite passed 49/55; five origin/validation or ordinary-success
requests returned 503 and one Advanced-lobby request timed out at
15 seconds. Full HTTP acceptance remains open. This code was not
deployed to the NAS and no production game was modified.
## 30 September 2026 — Semuta after printed Karama winning-bid payment ## 30 September 2026 — Semuta after printed Karama winning-bid payment
The explicit Richese preview now stages a second normal-auction The explicit Richese preview now stages a second normal-auction
+9
View File
@@ -64,6 +64,15 @@ a neutral claim or decline completes the same free settlement once.
Richese lots, Worthless conversion and nested responses retain their Richese lots, Worthless conversion and nested responses retain their
previous automatic paths. previous automatic paths.
A clean private normal-auction Ixian ally replacement now stages the
existing `ixAllyCard` continuation as a neutral Semuta offer before
`draw(g)`. The original buyer owns the discarded card. Its face stays
unknown to rivals and uncommitted holders; a sole committed claim
moves that exact card before one saved replacement draw and ordinary
sale continuation. Richese cache and Black Market sales retain the
automatic path until their distinct custody and offer windows are
integrated.
Completed Ornithopter movement now offers the same retired public card Completed Ornithopter movement now offers the same retired public card
before its already committed arrival suffix only in a roster without before its already committed arrival suffix only in a roster without
Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response
+11 -2
View File
@@ -67,12 +67,21 @@ opening public expansion starts. It interrupts **clean public
ordinary-card, paid Box, retired Ornithopter, used Distrans, final ordinary-card, paid Box, retired Ornithopter, used Distrans, final
Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison, Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison,
printed Karama canceling clean CHOAM Charity, Inflation or Bene printed Karama canceling clean CHOAM Charity, Inflation or Bene
Gesserit Charity or settling a clean normal-auction purchase or Gesserit Charity or settling a clean normal-auction lot, battle and
winning bid, battle and Ix-deck Thumper/Amal discards**. Other Ix-deck Thumper/Amal discards**, plus a clean **private** normal-auction
Ixian ally replacement discard before its draw. Other
than the one explicitly suspended charity/Inflation response, the than the one explicitly suspended charity/Inflation response, the
parent transaction has no competing response. Every seat sees the parent transaction has no competing response. Every seat sees the
same neutral fresh-discard opportunity same neutral fresh-discard opportunity
regardless of hidden Semuta custody. regardless of hidden Semuta custody.
The Ixian ally discard belongs to the original buyer, not the Ixians.
Every seat sees the neutral offer without its face or a named claimant;
only a committed eligible holder takes the exact fresh card. A claim
removes it before the saved single replacement draw and subsequent
normal-auction settlement. Richese cache and Black Market replacement
discards, competing parents and other private producers remain separate.
An early-ended Ornithopter advances the saved queue; a completed An early-ended Ornithopter advances the saved queue; a completed
flight resumes one committed arrival only without BG, Ecaz or flight resumes one committed arrival only without BG, Ecaz or
Moritani entry reactions. Distrans's gift remains private with its Moritani entry reactions. Distrans's gift remains private with its
+16 -9
View File
@@ -6180,6 +6180,7 @@ function semutaOfferSupported(
continuation.kind !== 'karamaPaymentDiscard' && continuation.kind !== 'karamaPaymentDiscard' &&
continuation.kind !== 'thumperDiscard' && continuation.kind !== 'thumperDiscard' &&
continuation.kind !== 'amalDiscard' && continuation.kind !== 'amalDiscard' &&
continuation.kind !== 'ixAllyCard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') && !(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
continuation.kind !== 'winnerMandatoryDiscard' && continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleResolved' && continuation.kind !== 'battleResolved' &&
@@ -6227,11 +6228,13 @@ function semutaOfferSupported(
return g.semutaPreview === true && g.status === 'playing' && return g.semutaPreview === true && g.status === 'playing' &&
g.players.some(p => p.faction === 'richese') && g.players.some(p => p.faction === 'richese') &&
entries.length > 0 && entries.length > 0 &&
(continuation.kind === 'battleCleanup' || (continuation.kind === 'ixAllyCard'
continuation.kind === 'winnerMandatoryDiscard' || ? entries.length === 1 && !entries[0].publicFace
continuation.kind === 'battleResolved' : continuation.kind === 'battleCleanup' ||
? entries.every(entry => entry.publicFace) continuation.kind === 'winnerMandatoryDiscard' ||
: entries.length === 1 && entries[0].publicFace) && continuation.kind === 'battleResolved'
? entries.every(entry => entry.publicFace)
: entries.length === 1 && entries[0].publicFace) &&
(continuation.kind === 'karamaCharityDiscard' || (continuation.kind === 'karamaCharityDiscard' ||
continuation.kind === 'karamaInflationDiscard' || continuation.kind === 'karamaInflationDiscard' ||
continuation.kind === 'karamaBgCharityDiscard' continuation.kind === 'karamaBgCharityDiscard'
@@ -6243,6 +6246,7 @@ function semutaOfferSupported(
(continuation.kind === 'battleCleanup' || (continuation.kind === 'battleCleanup' ||
continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved' || continuation.kind === 'battleResolved' ||
continuation.kind === 'ixAllyCard' ||
(continuation.kind === 'karamaCharityDiscard' || (continuation.kind === 'karamaCharityDiscard' ||
continuation.kind === 'karamaInflationDiscard' || continuation.kind === 'karamaInflationDiscard' ||
continuation.kind === 'karamaBgCharityDiscard' || continuation.kind === 'karamaBgCharityDiscard' ||
@@ -6252,10 +6256,13 @@ function semutaOfferSupported(
? continuation.resume.phaseOpening?.passed.length === 0 ? continuation.resume.phaseOpening?.passed.length === 0
: !continuation.resume.phaseOpening)))) && : !continuation.resume.phaseOpening)))) &&
!g.karamaShipping && !g.karamaShipping &&
(continuation.kind === 'karamaPurchaseDiscard' || (continuation.kind === 'ixAllyCard'
continuation.kind === 'karamaPaymentDiscard' ? !!g.auction && g.currentAuctionSale?.origin === 'normal' &&
? !!g.auction && !g.currentAuctionSale && !g.richeseAuction continuation.sale.origin === 'normal' && !g.richeseAuction
: !g.auction) && : continuation.kind === 'karamaPurchaseDiscard' ||
continuation.kind === 'karamaPaymentDiscard'
? !!g.auction && !g.currentAuctionSale && !g.richeseAuction
: !g.auction) &&
(continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) && (continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) &&
(continuation.kind === 'saphoAggressorDiscard' || (continuation.kind === 'saphoAggressorDiscard' ||
continuation.kind === 'residualPoisonDiscard' || !g.battle) && continuation.kind === 'residualPoisonDiscard' || !g.battle) &&
+4 -4
View File
@@ -1156,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [
: definition.card.effect === 'portableSnooper' : definition.card.effect === 'portableSnooper'
? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.' ? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.'
: definition.card.effect === 'semutaDrug' : definition.card.effect === 'semutaDrug'
? 'In 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 worm/spice draw; Amal pauses after halving spice before opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, and three battle batches. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other Karama uses, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.' ? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, and three battle batches. A private normal-auction Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.'
: 'Reference component only: this Richese card’s game actions are not enabled.', : 'Reference component only: this Richese card’s game actions are not enabled.',
], ],
...(definition.card.effect === 'semutaDrug' ...(definition.card.effect === 'semutaDrug'
@@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation' as const, area: 'Implementation' as const,
status: 'Partial' as const, status: 'Partial' as const,
detail: detail:
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, Ix-deck Thumper/Amal and three battle discard stages. Karama’s physical cost is discarded before a neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Neutral offers, owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, nested responses, pending rewards, other producers and full-hand exchange remain unfinished.', 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction Ixian ally replacement also pauses before its draw with a neutral, face-hidden offer. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, private discards, nested responses, pending rewards and full-hand exchange remain unfinished.',
}, },
{ {
area: 'Player controls' as const, area: 'Player controls' as const,
@@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Documentation' as const, area: 'Documentation' as const,
status: 'Partial' as const, status: 'Partial' as const,
detail: detail:
'The guide distinguishes clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, Ix-deck Thumper/Amal and three battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.', 'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, Ix-deck Thumper/Amal and three battle batches from a private normal-auction Ixian ally replacement, privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
}, },
{ {
area: 'Verification' as const, area: 'Verification' as const,
status: 'Partial' as const, status: 'Partial' as const,
detail: detail:
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, Ix-deck Thumper/Amal and three battle discard stages. Other Karama uses, pending rewards, full games and live deployment remain unverified.', 'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, pending rewards, full games and live deployment remain unverified.',
evidence: [ evidence: [
'tests/semuta-engine.test.ts', 'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts', 'tests/semuta-recovery.test.ts',
+71 -2
View File
@@ -22,10 +22,11 @@ function inventory(g: Game) {
assert.equal(new Set(ids).size, ids.length); assert.equal(new Set(ids).size, ids.length);
return ids; return ids;
} }
function fixture(ix = false) { function fixture(ix = false, ixianAlly = false) {
let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), false, ix ? ['choam', 'ix'] : ['choam']); let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), false, ix ? ['choam', 'ix'] : ['choam']);
joinGame(g, newPlayer('a', 'Atreides', 'atreides')); joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor')); joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : 'Emperor',
ixianAlly ? 'ixians' : 'emperor'));
for (const p of g.players) { p.bot = 'Medium'; p.ready = true; } for (const p of g.players) { p.bot = 'Medium'; p.ready = true; }
g = initializeSemutaGameForAudit(g); g = initializeSemutaGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 80; step++) { for (let step = 0; g.status === 'setup' && step < 80; step++) {
@@ -48,6 +49,74 @@ function fixture(ix = false) {
return { g, semuta, hajr }; return { g, semuta, hajr };
} }
void test('private Ixian ally discard offers Semuta before replacement without revealing its face', () => {
const { g, semuta, hajr } = fixture(true, true);
player(g, 'a').hand = [];
g.deck.push(hajr);
player(g, 'a').ally = 'e';
player(g, 'e').ally = 'a';
const lot = g.deck.shift()!;
Object.assign(g, { phase: 3, active: 'a', order: ['a', 'e', 'r'],
movementRemaining: [], auction: { cards: [lot], index: 0, bid: 0,
bidder: null, active: 'a', passed: [], opener: 0 } });
g.richeseBidding = { owner: 'r', event: 'richese-normal:2', turn: g.turn,
stage: 'normal', position: 'last', normalCount: 1,
blackMarketSold: false, cacheCanceled: false, opener: 0 };
g.richeseFunding = {};
const absent = reload(g);
player(absent, 'r').hand = [take(absent, 'richese-distrans')];
absent.richeseCache!.push(semuta);
const original = [...inventory(g), lot.id].sort();
const offer = (state: Game) => {
let next = applyAction(state, 'a', { type: 'bid', amount: 2 });
for (const id of ['e', 'r']) next = applyAction(next, id, { type: 'passBid' });
if (next.decision?.kind === 'auctionPayment')
next = applyAction(next, 'a', { type: 'decision', karama: false });
assert.equal(next.decision?.kind, 'ixAllyCard');
next = applyAction(next, 'a', { type: 'decision', accept: true });
for (let i = 0; next.response?.kind === 'ixAllyCard' && i < 3; i++) {
const other = next.players.find(p => !next.response!.passed.includes(p.id))!;
next = applyAction(next, other.id, { type: 'passResponse' });
}
return next;
};
const pending = offer(g), neutral = offer(absent);
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'ixAllyCard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.pendingTreacheryDiscard?.batch.entries[0].publicFace, false);
assert.equal(pending.discard.filter(card => card.id === lot.id).length, 1);
assert.equal(pending.deck[0].id, neutral.deck[0].id);
assert.equal(player(pending, 'a').hand.some(card => card.id === lot.id), false);
assert.deepEqual(viewGame(pending, 'a'), viewGame(neutral, 'a'));
assert.deepEqual(viewGame(pending, 'e'), viewGame(neutral, 'e'));
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
const corrupted = reload(pending);
corrupted.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forged face';
const before = JSON.stringify(corrupted);
assert.throws(() => applyAction(corrupted, 'r', { type: 'semutaCommit', event }));
assert.equal(JSON.stringify(corrupted), before);
let declined = reload(pending);
for (const id of ['a', 'e', 'r'])
declined = applyAction(declined, id, { type: 'semutaPass', event });
const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!);
for (const state of [declined, claimed]) {
assert.equal(state.pendingTreacheryDiscard, null);
assert.equal(state.auction, null);
assert.equal(state.richeseBidding?.stage, 'cacheOffer');
assert.equal(state.decision?.kind, 'richeseCache');
assert.deepEqual(inventory(state), original);
assert.equal(player(state, 'a').spice, player(pending, 'a').spice);
assert.equal(player(state, 'a').hand.length, 1);
assert.throws(() => applyAction(state, 'r', { type: 'semutaCommit', event }));
}
assert.equal(declined.discard.filter(card => card.id === lot.id).length, 1);
assert.equal(player(claimed, 'r').hand.filter(card => card.id === lot.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
});
void test('printed Karama auction payment offers its card before the winning lot settles', () => { void test('printed Karama auction payment offers its card before the winning lot settles', () => {
const { g, semuta, hajr } = fixture(); const { g, semuta, hajr } = fixture();
player(g, 'a').hand = [take(g, 'karama')]; player(g, 'a').hand = [take(g, 'karama')];
+81 -1
View File
@@ -4,7 +4,7 @@ import { startPrototypeRoom } from '../tools/prototype-room';
import { botActions } from '../game/bots'; import { botActions } from '../game/bots';
import { applyAction, createGame, newPlayer, viewGame, type Game, type Action } from '../game/engine'; import { applyAction, createGame, newPlayer, viewGame, type Game, type Action } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { baseDeck, spiceDeck } from '../game/cards'; import { baseDeck, spiceDeck, treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards'; import { richeseCards } from '../game/richese-cards';
import { unitStore } from './fixture-nexus-room-store'; import { unitStore } from './fixture-nexus-room-store';
import { saphoAggressorGame, aggressorAction } from './fixture-sapho-aggressor'; import { saphoAggressorGame, aggressorAction } from './fixture-sapho-aggressor';
@@ -728,6 +728,86 @@ void test('authenticated pre-plan Sapho discard restores one aggressor choice be
} finally { f.sqlite.close(); } } finally { f.sqlite.close(); }
}); });
void test('authenticated private Ixian ally discard waits for Semuta before its replacement', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Ix ally', 'richese', false, ['choam', 'ix']);
const code = made.view.code;
const buyer = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
const ixians = await f.rooms.joinRoom(code, 'Ixians', 'ixians');
const auths = await Promise.all([made.token!, buyer.token!, ixians.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
const g = createGame(code, newPlayer(auths[0].playerId, 'Richese', 'richese'),
false, ['choam', 'ix']);
g.players.push(newPlayer(auths[1].playerId, 'Atreides', 'atreides'),
newPlayer(auths[2].playerId, 'Ixians', 'ixians'));
Object.assign(g, { status: 'playing', phase: 3, active: auths[1].playerId,
order: auths.map(auth => auth.playerId), deck: treacheryDeck(['ix']),
richeseCache: richeseCards(), semutaPreview: true });
for (const p of g.players) {
p.hand = [];
p.spice = 20;
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
p.traitorChoices = [];
}
const semuta = take(g, SEMUTA_DRUG_ID), lot = g.deck.shift()!;
seat(g, auths[0].playerId).hand.push(semuta);
seat(g, auths[1].playerId).ally = auths[2].playerId;
seat(g, auths[2].playerId).ally = auths[1].playerId;
g.auction = { cards: [lot], index: 0, bid: 0, bidder: null,
active: auths[1].playerId, passed: [], opener: 0 };
g.richeseBidding = { owner: auths[0].playerId, event: 'richese-normal:1',
turn: g.turn, stage: 'normal', position: 'last', normalCount: 1,
blackMarketSold: false, cacheCanceled: false, opener: 0 };
g.richeseFunding = {};
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const act = async (index: number, action: Action) => {
const current = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], current.version, action, clock);
return f.restart().readRoom(code);
};
await act(1, { type: 'bid', amount: 2 });
await act(2, { type: 'passBid' });
let next = await act(0, { type: 'passBid' });
if (next.decision?.kind === 'auctionPayment')
next = await act(1, { type: 'decision', karama: false });
assert.equal(next.decision?.kind, 'ixAllyCard');
next = await act(1, { type: 'decision', accept: true });
for (let i = 0; next.response?.kind === 'ixAllyCard' && i < 3; i++) {
const index = auths.findIndex(auth => !next.response!.passed.includes(auth.playerId));
next = await act(index, { type: 'passResponse' });
}
const event = next.pendingTreacheryDiscard!.batch.event;
assert.equal(next.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(next.pendingTreacheryDiscard?.batch.entries[0].publicFace, false);
assert.equal(next.discard.filter(card => card.id === lot.id).length, 1);
const deckTop = next.deck[0]?.id;
const buyerView = await f.restart().readSeatView(code, auths[1]);
assert.equal(buyerView.semutaReaction?.canCommit, false);
assert.deepEqual(buyerView.semutaReaction?.candidates, []);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, next.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const claimed = await act(0, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.auction, null);
assert.equal(claimed.richeseBidding?.stage, 'cacheOffer');
assert.equal(claimed.decision?.kind, 'richeseCache');
assert.equal(seat(claimed, auths[0].playerId).hand.filter(card => card.id === lot.id).length, 1);
assert.equal(seat(claimed, auths[1].playerId).hand.filter(card => card.id === deckTop).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
await assert.rejects(f.restart().act(code, auths[0], next.version,
{ type: 'semutaCommit', event }, clock));
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
} finally { f.sqlite.close(); }
});
void test('authenticated printed Karama auction payment resumes one saved Richese cache offer', async () => { void test('authenticated printed Karama auction payment resumes one saved Richese cache offer', async () => {
const f = unitStore(); const f = unitStore();
try { try {