Offer Semuta on clean early-ended Ornithopter discard

This commit is contained in:
Quintine committed 2026-09-30 07:38:45 +10:00
1 parent 0e9945e333
commit 2ec0422b79
8 files changed
+170 -38

No files matched your search

+13 -12
View File
@@ -7,18 +7,19 @@ 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 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 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.
now pauses a clean public ordinary-card discard, the used Box after
a completed paid search, or an Ornithopter retired on early movement
ending 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, Box parent or movement queue 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. Completed-flight arrival, multi-card
and most other discard producers, full-hand ordering, normal Richese
starts and deployed acceptance remain unfinished; neutral passes and
the free-slot guard are product safety choices, not publisher 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,
+30
View File
@@ -1,5 +1,35 @@
# Dune implementation status
## 30 September 2026 — Semuta after an early-ended Ornithopter flight
The explicit Richese Semuta preview now interrupts the one public
Ornithopter discarded when its pilot ends movement before using every
available flight group. The first group has already moved its exact
forces and consumed one move; the card then leaves escrow exactly once.
Every seat sees the same neutral event before the saved movement queue
advances. Another holder with hand space can claim that retired physical
card and discard Semuta; all-seat decline leaves it on the pile. Both
paths advance the original turn once without moving forces again.
Genuine setup exercises a completed first group, early retirement,
JSON restoration, owner-only commitment by the legal AI, all-seat
decline, exact force/card census and stale-action rejection. An
isolated migrated SQLite room recovers the paused flight without
auto-advancing, projects only the owner's Commit control and commits
the claim through authenticated room CAS, preserving seat rows.
Completed flights with arrival/intrusion follow-up, competing suspended
transactions, multiple fresh cards and unresolved full-hand ordering
remain gated. This is not a complete Semuta or deployed Richese path.
Independent rule/continuation and privacy/recovery reviews found no
scoped defect. A throwaway direct production-engine run completed
genuine AI setup, moved one group, showed a neutral offer with owner-only
Commit, recovered the played card and advanced to the next seat with
one move and one arrived force. A phone-width local rules page rendered
the new boundary and Partial checklist. `npm run check` passed typecheck,
lint and 5,803/5,803 offline cases; `npm run build` and 55/55 Vite
development HTTP cases passed. No production deployment was verified.
## 30 September 2026 — Semuta after a completed paid Box search
The explicit Richese Semuta preview now opens the same neutral
+1 -1
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, Mirror Weapon and Juice of Sapho remain inactive. Source audits now identify Semuta's fresh-discard continuation requirements and Sapho's scoped ordering requirements; these are not runtime completion claims. Other unfinished faction/module systems, mobile acceptance, difficulty-strength calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed.
- Semuta Drug now has an explicit bounded development preview for clean public ordinary-card, completed paid Box and early-ended Ornithopter discards; it does not implement other producers, full-hand ordering or normal Richese starts. Mirror Weapon and Juice of Sapho have separate bounded paths, not complete combined-mode certification. Other unfinished faction/module systems, mobile acceptance, difficulty-strength 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).
+21 -17
View File
@@ -63,28 +63,32 @@ 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 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 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.
ordinary-card discard, the used Box after a completed paid Nullentropy
search, or an Ornithopter retired by ending movement early**, provided
the parent transaction is clean, with a neutral fresh-discard opportunity
for every seat regardless of hidden Semuta possession. The flight's
already committed movement and physical escrow remain intact; a claim
or all-seat decline advances the saved movement queue exactly once.
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 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
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. 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
pending gifts, competing responses, completed Ornithopter flights with
arrival follow-up and other typed discard producers retain their existing
automatic continuation; Semuta cannot claim those 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.
+5 -4
View File
@@ -5642,16 +5642,17 @@ function treacheryDiscardIntegrity(g: Game) {
'The committed Kaitain discard no longer matches its payment and closing opportunity.');
} else throw new RuleError('Unknown saved discard continuation.');
}
/** 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. */
/** Bounded public single-card reaction after a completed ordinary effect, paid
* Box search, or early-ended Ornithopter flight. A completed flight's arrival
* may open further entry windows, so that continuation remains automatic. */
function semutaOfferSupported(
g: Game,
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
entries: FreshDiscardBatch['entries'],
) {
if (continuation.kind !== 'ordinaryCardDiscard' &&
continuation.kind !== 'nullentropyDiscard') return false;
continuation.kind !== 'nullentropyDiscard' &&
!(continuation.kind === 'ornithopterDiscard' && continuation.source === 'end')) return false;
return g.semutaPreview === true && g.status === 'playing' &&
g.players.some(p => p.faction === 'richese') &&
entries.length === 1 && entries[0].publicFace &&
+4 -4
View File
@@ -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 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.'
? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard, a completed paid Box search, or early ending an Ornithopter flight. Continue or commit your held Semuta with a free hand slot; the sole eligible card transfers automatically before the saved turn advances. A completed flight with arrival follow-up, 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 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.',
'An explicitly opted-in Richese profile interrupts clean public ordinary-card, completed paid Box and early-ended Ornithopter 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, Box or movement-turn parent once. Completed-flight arrival and other typed discard producers and full-hand exchange remain unfinished.',
},
{
area: 'Player controls' as const,
@@ -1183,13 +1183,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Documentation' as const,
status: 'Partial' as const,
detail:
'The guide distinguishes the bounded public ordinary-card prototype, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.',
'The guide distinguishes bounded public ordinary-card, paid Box and early-ended Ornithopter previews, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.',
},
{
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 clean ordinary-card and paid Box reactions. 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, paid Box and early-ended Ornithopter reactions. Other discard producers, full games and live deployment remain unverified.',
evidence: [
'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts',
+51
View File
@@ -212,3 +212,54 @@ void test('a completed paid Box discard can be claimed before resumption without
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
assert.deepEqual(normalizeAutomaticGame(reload(claimed)), reload(claimed));
});
void test('a retired Ornithopter can be claimed after one flight group without replaying movement', () => {
const { g, semuta, hajr } = fixture();
player(g, 'a').hand = [];
g.deck.push(hajr);
const ornithopter = take(g, 'richese-ornithopter');
player(g, 'a').hand.push(ornithopter);
player(g, 'a').forces = { 'imperial_basin:10': 3 };
player(g, 'a').reserves = 17;
player(g, 'a').shipped = true;
const original = inventory(g);
const first = applyAction(g, 'a', {
type: 'move', movementCard: ornithopter.id, ornithopter: 'twoGroups',
forces: { 'imperial_basin:10': 1 }, territory: 'arrakeen', sector: 10,
});
assert.equal(first.ornithopter?.completed, 1);
assert.equal(first.discard.some(card => card.id === ornithopter.id), false);
const pending = applyAction(first, 'a', { type: 'endMovement' });
assert.equal(pending.ornithopter, null);
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'ornithopterDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.discard.filter(card => card.id === ornithopter.id).length, 1);
assert.equal(pending.active, 'a');
assert.deepEqual(pending.movementRemaining, ['a', 'r', 'e']);
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
assert.equal(viewGame(pending, 'a').semutaReaction?.canCommit, false);
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.equal(declined.active, 'r');
assert.deepEqual(declined.movementRemaining, ['r', 'e']);
assert.equal(declined.discard.filter(card => card.id === ornithopter.id).length, 1);
const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!);
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.active, 'r');
assert.deepEqual(claimed.movementRemaining, ['r', 'e']);
assert.equal(player(claimed, 'a').moved, 1);
assert.equal(player(claimed, 'a').forces['arrakeen:10'], 1);
assert.equal(player(claimed, 'a').forces['imperial_basin:10'], 2);
assert.equal(player(claimed, 'r').hand.filter(card => card.id === ornithopter.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
assert.equal(claimed.discard.some(card => card.id === ornithopter.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.deepEqual(normalizeAutomaticGame(reload(claimed)), reload(claimed));
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
});
+45
View File
@@ -129,5 +129,50 @@ void test('authenticated ordinary and paid-Box discards preserve one Semuta clai
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));
const flight = structuredClone(claimedBox);
const reusableSemuta = take(flight, SEMUTA_DRUG_ID);
const ornithopter = take(flight, 'richese-ornithopter');
seat(flight, ids[0]).hand.push(reusableSemuta);
seat(flight, ids[1]).hand.push(ornithopter);
const pilot = seat(flight, ids[1]);
pilot.reserves += Object.values(pilot.forces).reduce((sum, n) => sum + n, 0) - 3;
pilot.forces = { 'imperial_basin:10': 3 };
pilot.shipped = true;
pilot.moved = 0;
flight.hajr = [];
Object.assign(flight, { turn: 4, phase: 5, active: ids[1], storm: 18,
ready: [], decision: null, response: null, phaseOpening: null,
stormPending: null, movementRemaining: [ids[1], ids[0], ids[2]] });
flight.version = claimedBox.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(flight), flight.version, code, claimedBox.version).changes, 1);
const moved = await act(1, { type: 'move', movementCard: ornithopter.id,
ornithopter: 'twoGroups', forces: { 'imperial_basin:10': 1 },
territory: 'arrakeen', sector: 10 });
assert.equal(moved.ornithopter?.completed, 1);
const retired = await act(1, { type: 'endMovement' });
const flightEvent = retired.pendingTreacheryDiscard?.batch.event;
assert.equal(retired.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.ok(flightEvent);
assert.equal(retired.active, ids[1]);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, retired.version);
const flightOwner = await f.restart().readSeatView(code, auths[0]);
const flightObserver = await f.restart().readSeatView(code, auths[2]);
assert.equal(flightOwner.semutaReaction?.canCommit, true);
assert.equal(flightObserver.semutaReaction?.canCommit, false);
const flown = await act(0, { type: 'semutaCommit', event: flightEvent });
assert.equal(flown.pendingTreacheryDiscard, null);
assert.equal(flown.active, ids[0]);
assert.deepEqual(flown.movementRemaining, [ids[0], ids[2]]);
assert.equal(seat(flown, ids[1]).moved, 1);
assert.equal(seat(flown, ids[1]).forces['arrakeen:10'], 1);
assert.equal(seat(flown, ids[0]).hand.filter(card => card.id === ornithopter.id).length, 1);
assert.equal(flown.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(flown.discard.some(card => card.id === ornithopter.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(flown, auth.playerId));
} finally { f.sqlite.close(); }
});