Compose Semuta with completed paid Box discard

This commit is contained in:
Quintine committed 2026-09-30 01:25:22 +10:00
1 parent 9ea50a792e
commit 0e9945e333
9 files changed
+146 -28

No files matched your search

+7 -6
View File
@@ -7,14 +7,15 @@ 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)
now pauses a clean public ordinary-card discard with the same neutral
offer for every seat, independently of hidden Semuta possession. A
holder with a free hand slot can commit to the one fresh other-player
card; its physical claim and Semuta disposal resume the original
effect once. All-seat decline, four minimal legal AI profiles, exact
now pauses a clean public ordinary-card discard or the used Box after
a completed paid search with the same neutral offer for every seat,
independently of hidden Semuta possession. A holder with a free hand
slot can commit to the one fresh other-player card; its physical claim
and Semuta disposal resume the original effect or Box parent once.
All-seat decline, four minimal legal AI profiles, exact
JSON/SQLite continuation and competing-room CAS are connected. Phone
width showed the owned Commit control and the identical rival Continue
event without a private card face. Multi-card and other discard
event without a private card face. Multi-card and most other discard
producers, full-hand ordering, normal Richese starts and deployed
acceptance remain unfinished; the neutral passes and free-slot guard
are product safety choices, not publisher timing rulings.
+30
View File
@@ -1,5 +1,35 @@
# Dune implementation status
## 30 September 2026 — Semuta after a completed paid Box search
The explicit Richese Semuta preview now opens the same neutral
all-seat response when a clean paid Nullentropy Box search finishes
and places its used physical Box on the discard pile. The search
owner has already paid two spice, privately recovered its chosen
card and shuffled the remaining pile once. Another holder may commit
Semuta and take only the freshly discarded Box; declining preserves
the Box on top. Neither path re-pays, reselects, reshuffles or
reopens the search. Older discard cards and the privately recovered
card are not fresh targets.
Genuine-setup engine cases verify both claim and all-pass paths,
exact card census and discard order, every-seat JSON restoration
and stable two-spice payment. An isolated migrated SQLite room
restores the Box reaction after the paid selection, leaves automatic
recovery idle until a real choice and commits the claim through
authenticated seat control without changing saved seat rows.
Independent privacy and continuation reviews found no scoped issue.
This extends the previous phone-width neutral control surface; no
new browser POST was sent. Auction/CHOAM-market overlaps, multiple
fresh cards, other producer families and full-hand ordering remain
gated. This is not a complete Semuta, Richese or deployed claim.
`npm run check` passes typecheck, lint and 5,802/5,802 offline cases;
`npm run build` and the 55/55 local development HTTP cases pass.
The phone-width rules deep link shows the updated Box target boundary
and partial checklist. No authenticated production Box/Semuta play or
deployed revision was exercised.
## 30 September 2026 — neutral Semuta clean-discard prototype
The [bounded runtime](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime)
+4 -3
View File
@@ -112,6 +112,7 @@ 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 now work after clean public
ordinary-card discards. The other semantic discard families and
full-hand ordering are not connected; the ten-card inventory, cache
custody and public expansion start gate remain unchanged.
ordinary-card discards and the used Box after a completed paid search.
Other semantic discard families and full-hand ordering remain
unconnected; the ten-card inventory, cache custody and public expansion
start gate are unchanged.
+11 -8
View File
@@ -63,17 +63,18 @@ Do not ask again about normal targets, forced-discard owner bookkeeping, simulta
The explicit `semuta` development profile starts a fresh Richese roster
without opening ordinary expansion starts. It now interrupts **one public
ordinary-card discard** from a clean transaction with a neutral
fresh-discard opportunity for every seat, regardless of hidden Semuta
ordinary-card discard or the used Box after a completed paid Nullentropy
search**, provided the parent transaction is clean, with a neutral
fresh-discard opportunity for every seat regardless of hidden Semuta
possession. Each seat can Continue; only the canonical holder with a
free hand slot and a card discarded by another player can commit. The
sole eligible fresh card transfers automatically in the same accepted
action, then Semuta goes to the normal discard pile. The saved ordinary
card effect resumes once; passing by every seat preserves the original
discard and effect. No face from an undisclosed event is projected before
commitment. Owner-only multi-candidate selection support exists in the
reaction component and bot adapter, but no multi-discard producer is
activated in this first boundary.
card effect or Box parent resumes once; passing by every seat preserves
the original discard and effect. No face from an undisclosed event is
projected before commitment. Owner-only multi-candidate selection
support exists in the reaction component and bot adapter, but no
multi-discard producer is activated in this bounded runtime.
This neutral all-seat acknowledgement is a conservative **application
policy**, not a newly discovered publisher timing rule. The explicit
@@ -81,7 +82,9 @@ free-slot requirement is a provisional safety guard, not the printed
resolution of full-hand take-before-discard. Auction, CHOAM market,
pending gifts, competing responses and other typed discard producers
retain their existing automatic continuation; Semuta cannot claim those
events yet. No source ruling is inferred from this omission. The
events yet. The completed Box's selected search card stays private and
its paid shuffle is not repeated when another seat takes the used Box.
No source ruling is inferred from this omission. The
separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why
auto-closing only when nobody secretly holds the card would leak
possession. The main game and public expansion gates remain closed.
+2 -2
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. Only clean public ordinary-card discards are connected; other discard events remain unfinished.';
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean public ordinary-card discards and the used Box after a completed paid search are connected; other discard events remain unfinished.';
return definition && definition.card.effect !== 'karama'
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
: null;
@@ -92,7 +92,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
richese.card.effect === 'juiceOfSapho'
? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.'
: richese.card.effect === 'semutaDrug'
? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, with owner-only commitment and a conservative free-slot requirement. Other discard producers, full-hand sequencing and normal expansion starts remain unfinished.'
? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards and completed paid Box disposal, with owner-only commitment and a conservative free-slot requirement. Other discard producers, full-hand sequencing and normal expansion starts remain unfinished.'
: richese.card.effect === 'mirrorWeapon'
? 'Mirror Weapon works in bounded CHOAM/Richese-deck classic-faction battles; copied Tooth and Stone choices resolve separately, and a winning physical Mirror may be kept even after copying Tooth or Artillery. This winner-retention treatment is a user-selected interpretation, not an official clarification. Combined modes and full Richese starts remain gated.'
: richese.card.effect === 'stoneBurner'
+5 -4
View File
@@ -5642,17 +5642,18 @@ function treacheryDiscardIntegrity(g: Game) {
'The committed Kaitain discard no longer matches its payment and closing opportunity.');
} else throw new RuleError('Unknown saved discard continuation.');
}
/** First playable Semuta boundary: one public ordinary-card discard with no
* competing suspended transaction. Other typed frames retain their existing
* automatic continuation until their reaction composition is integrated. */
/** Bounded public single-card reaction after a completed ordinary effect or
* paid Box search, without any competing suspended transaction. Other typed
* frames retain automatic continuation until their composition is integrated. */
function semutaOfferSupported(
g: Game,
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
entries: FreshDiscardBatch['entries'],
) {
if (continuation.kind !== 'ordinaryCardDiscard' &&
continuation.kind !== 'nullentropyDiscard') return false;
return g.semutaPreview === true && g.status === 'playing' &&
g.players.some(p => p.faction === 'richese') &&
continuation.kind === 'ordinaryCardDiscard' &&
entries.length === 1 && entries[0].publicFace &&
!continuation.resume.response && !continuation.resume.decision &&
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening &&
+4 -4
View File
@@ -941,7 +941,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'An active Truthtrance question and exact card or transaction commitments must resolve as required. A full hand is currently guarded: the printed sequence adds the recovered card before discarding the Box, and the temporary capacity question is unresolved. The current implementation requires a pre-existing free hand slot. This is not a verified prohibition on every net-zero exchange.',
'A pending Guild refund claim on a discarded shipping Karama also blocks starting the search while that provisional refund policy is unresolved. The guard protects the competing claim to that card; it is not a general ban on Box during Shipment. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.',
'All four AI profiles start only when their own projected availability and spice reserve allow it. They do not inspect unpaid discard contents. Once paid, they choose from their entitled cards, prefer existing functional effects over unfinished Richese faces and complete a legal selection even if only unfinished effects remain. A paid search takes priority over optional actions.',
'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation, the provisional Guild refund interaction and Semuta response to the used Box remain unresolved or unfinished. Mirror Weapon has a separate bounded CHOAM/Richese-deck classic-faction battle path; Semuta Drug has a separate clean public ordinary-card preview, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.',
'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation and the provisional Guild refund interaction remain unresolved. In the explicit Semuta preview, another holder may recover the used Box after a clean completed search without replaying payment or shuffle; other Semuta discard sources remain unfinished. Mirror Weapon has a separate bounded CHOAM/Richese-deck classic-faction battle path, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.',
],
related: [
'card-richese-nullentropy-box',
@@ -1155,7 +1155,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 a clean public ordinary-card discard. Continue or commit your held Semuta with a free hand slot; the sole eligible card transfers automatically. Other discard producers, full-hand sequencing and normal Richese starts remain unfinished.'
? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard or the used Box following a completed paid search. Continue or commit your held Semuta with a free hand slot; the sole eligible card transfers automatically. Other discard producers, full-hand sequencing and normal Richese starts remain unfinished.'
: 'Reference component only: this Richese card’s game actions are not enabled.',
],
...(definition.card.effect === 'semutaDrug'
@@ -1165,7 +1165,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation' as const,
status: 'Partial' as const,
detail:
'An explicitly opted-in Richese profile now interrupts clean public ordinary-card discards with a neutral all-seat offer. Committing a held Semuta atomically claims the sole eligible other-player card, discards Semuta and resumes the saved ordinary-card suffix once. Other typed discard producers and full-hand exchange remain unfinished.',
'An explicitly opted-in Richese profile interrupts clean public ordinary-card and completed paid Box discards with a neutral all-seat offer. Committing held Semuta atomically claims the sole eligible other-player card, discards Semuta and resumes the saved ordinary-card or Box parent once. Other typed discard producers and full-hand exchange remain unfinished.',
},
{
area: 'Player controls' as const,
@@ -1189,7 +1189,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification' as const,
status: 'Partial' as const,
detail:
'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and all four legal AI profiles cover the bounded ordinary-card reaction. Other discard producers, full games and live deployment remain unverified.',
'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and all four legal AI profiles cover clean ordinary-card and paid Box reactions. Other discard producers, full games and live deployment remain unverified.',
evidence: [
'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts',
+46
View File
@@ -166,3 +166,49 @@ void test('an impossible saved all-passed offer rejects instead of waiting forev
assert.throws(() => normalizeAutomaticGame(corrupt));
assert.equal(JSON.stringify(corrupt), before);
});
void test('a completed paid Box discard can be claimed before resumption without repeating its search', () => {
const { g, semuta, hajr } = fixture();
player(g, 'a').hand = [];
g.deck.push(hajr);
const box = take(g, 'richese-nullentropy-box');
player(g, 'a').hand.push(box);
g.discard.push(g.deck.shift()!, g.deck.shift()!);
g.phase = 4;
g.active = null;
const original = inventory(g);
const spice = player(g, 'a').spice;
const paid = applyAction(g, 'a', { type: 'card', card: box.id });
assert.ok(paid.pendingNullentropy);
assert.equal(player(paid, 'a').spice, spice - 2);
const target = paid.discard[0].id;
const pending = applyAction(paid, 'a', {
type: 'decision', event: paid.pendingNullentropy.event, card: target,
});
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'nullentropyDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const event = pending.pendingTreacheryDiscard!.batch.event;
const shuffled = pending.discard.filter(card => card.id !== box.id).map(card => card.id);
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
assert.equal(viewGame(pending, 'a').semutaReaction?.canCommit, false);
assert.deepEqual(viewGame(pending, 'r').semutaReaction?.candidates, []);
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
let declined = reload(pending);
for (const id of ['r', 'e', 'a']) declined = applyAction(declined, id, { type: 'semutaPass', event });
assert.equal(declined.pendingTreacheryDiscard, null);
assert.deepEqual(declined.discard, pending.discard);
assert.equal(player(declined, 'a').spice, spice - 2);
assert.equal(player(declined, 'a').hand.filter(card => card.id === target).length, 1);
assert.deepEqual(inventory(declined), original);
const claimed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(player(claimed, 'a').spice, spice - 2);
assert.equal(player(claimed, 'a').hand.filter(card => card.id === target).length, 1);
assert.equal(player(claimed, 'r').hand.filter(card => card.id === box.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
assert.deepEqual(claimed.discard.filter(card => card.id !== semuta.id).map(card => card.id), shuffled);
assert.deepEqual(inventory(claimed), original);
for (const p of claimed.players)
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
assert.deepEqual(normalizeAutomaticGame(reload(claimed)), reload(claimed));
});
+37 -1
View File
@@ -16,7 +16,7 @@ function take(g: Game, identity: string) {
throw new Error('The genuine setup lost a physical card.');
}
void test('authenticated fresh discard survives restart and duplicate Semuta commits transfer one physical card', async () => {
void test('authenticated ordinary and paid-Box discards preserve one Semuta claim through restart and room CAS', async () => {
const f = unitStore();
try {
const created = await f.rooms.createRoom('Semuta QA', 'richese', false, ['choam']);
@@ -93,5 +93,41 @@ void test('authenticated fresh discard survives restart and duplicate Semuta com
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths) assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(done, auth.playerId));
const next = structuredClone(done);
const usedSemuta = take(next, SEMUTA_DRUG_ID);
const box = take(next, 'richese-nullentropy-box');
seat(next, ids[0]).hand.push(usedSemuta);
seat(next, ids[1]).hand.push(box);
const older = [next.deck.shift(), next.deck.shift()];
assert.ok(older[0] && older[1]);
next.discard.push(older[0], older[1]);
Object.assign(next, { turn: 3, phase: 4, active: null, ready: [], decision: null,
response: null, phaseOpening: null, stormPending: null });
next.version = done.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(next), next.version, code, done.version).changes, 1);
const beforeSpice = seat(next, ids[1]).spice;
const paid = await act(1, { type: 'card', card: box.id });
assert.ok(paid.pendingNullentropy);
const selected = paid.discard[0].id;
const offered = await act(1, { type: 'decision', event: paid.pendingNullentropy.event, card: selected });
assert.equal(offered.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, offered.version);
const claimedBox = await act(0, { type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event });
assert.equal(claimedBox.pendingTreacheryDiscard, null);
assert.equal(seat(claimedBox, ids[1]).spice, beforeSpice - 2);
assert.equal(seat(claimedBox, ids[1]).hand.filter(card => card.id === selected).length, 1);
assert.equal(seat(claimedBox, ids[0]).hand.filter(card => card.id === box.id).length, 1);
assert.equal(claimedBox.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(claimedBox.discard.some(card => card.id === box.id), false);
const finalCards = [...claimedBox.deck, ...claimedBox.discard, ...claimedBox.richeseCache!,
...claimedBox.players.flatMap(p => p.hand)];
assert.equal(new Set(finalCards.map(card => card.id)).size, finalCards.length);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimedBox, auth.playerId));
} finally { f.sqlite.close(); }
});