Resume native Intrusion after final-flight Semuta response

This commit is contained in:
Quintine committed 2026-10-11 17:25:26 +11:00
1 parent 9efa17b2b7
commit 18693b3207
8 files changed
+137 -15

No files matched your search

+14
View File
@@ -29,6 +29,20 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Semuta / native Intrusion — 11 October.** Final Ornithopter movement now
offers the retired card before the original Bene Gesserit Intrusion choice.
Forces and move counters stay committed; claim or decline cannot move twice or
skip the original arrival. Basic keeps no Advanced Intrusion; Ecaz/Moritani and
overlap guards remain. Types/lint pass and 113 affected cases pass after replacing
a mistaken advisor-storage assertion with actual fighter-count behavior.
Ten isolated runtime scenarios cover both modes, claim/pass, fighter retention,
two-group completion and four legal bots. Real GameTable at 390px proves Commit,
Become advisors and Finish shipment & movement advancing to the next seat with
unchanged force counts and no horizontal overflow. The observed stale Semuta
tooltip now names Black Market replacement, native Intrusion and the approved
full-hand exchange. No saved store or deployed game was targeted. See
[the movement continuation](ORNITHOPTER_DISCARD_CONTINUATIONS.md#11-october-2026--semuta-before-native-intrusion).
**Semuta / Black Market replacement — 11 October.** A clean native Ixian
allied replacement now offers Semuta before drawing from a Black Market
purchase, as already supported for normal auctions. The discarded card belongs
+32
View File
@@ -1,5 +1,37 @@
# Dune implementation status
## 11 October 2026 — Semuta before original Bene Gesserit Intrusion
Final Ornithopter movement already commits forces, counters and a typed
discard-before-arrival frame. Its Semuta admission still excluded every roster
with Bene Gesserit. That software limit is removed without changing the arrival
suffix: claim or all-seat decline precedes the original native Intrusion once.
Basic retains no Advanced Intrusion; Ecaz/Moritani and overlap guards remain.
This is the existing ordering composition, not a publisher priority ruling.
The regression fails before the admission change. The repaired test checks
fighters remain fighters during the Semuta pause, then become zero fighters
after the original optional flip while the two physical counters remain.
An initial assertion incorrectly expected the advisor marker to be a boolean;
it was deleted rather than re-pinning its object representation. Final 113/113
affected cases pass. TypeScript and lint pass.
Ten isolated CLI scenarios cover Basic/Advanced claim/pass, Intrusion decline,
final two-group completion and four legal bots. Real GameTable at 390px, using
the installed production Vinext image adapter, proves Commit recovering the
physical Ornithopter, the original Become advisors control and Finish shipment
& movement advancing Atreides→Richese. Atreides retains Imperial Basin2/Arrakeen1
and BG retains its original two counters as advisors. No horizontal overflow.
The observed stale Semuta availability tooltip is corrected to describe the
connected private Black Market replacement, native Intrusion and approved
full-hand exchange.
The fixture uses real setup/engine/table with conserved staged forces and no
saved store. Temporary scripts/services/tabs were removed or closed. This is
local changed-path proof, not deployed, full-module, asset or recovery acceptance.
Contracts: [Semuta](SEMUTA_SOURCE_UPDATE_20260907.md),
[Ornithopter](ORNITHOPTER_DISCARD_CONTINUATIONS.md).
## 11 October 2026 — Semuta before Black Market allied replacement draw
The native Black Market purchase already offers Ixian allied replacement and
+18
View File
@@ -2,6 +2,24 @@
7 September 2026. Retiring the played Ornithopter now preserves the exact remaining arrival or turn-ending step. This continues the shared discard infrastructure without enabling Semuta or changing any unresolved movement rule or expansion start gate.
## 11 October 2026 — Semuta before native Intrusion
The explicit Semuta development envelope now pauses the final public Ornithopter
discard even when Bene Gesserit is seated. Its forces and movement counters are
already committed. Claim or all-seat decline retires the same saved frame before
the original native Intrusion choice; it cannot move the group again or skip that
choice. Basic has no Advanced Intrusion. Ecaz, Moritani and overlapping entry
reactions remain outside this Semuta boundary. This is the existing typed
discard-before-arrival ordering, not a new publisher priority ruling.
Ten isolated engine scenarios cover Basic/Advanced claim/pass, retaining fighters,
final two-group completion and four legal bots. In real GameTable at 390px,
Commit recovers the physical Ornithopter; Become advisors flips the original
two BG counters; Finish shipment & movement advances from Atreides to Richese.
Atreides retains two counters in Imperial Basin and one in Arrakeen. No saved
store was targeted. [Current Semuta scope](SEMUTA_SOURCE_UPDATE_20260907.md).
The dated 7 September infrastructure and assurance evidence below is historical.
## Two endings, one physical card
A completed final group has already moved its forces and concealed marker, consumed its original-group quota and incremented movement counters. The retired card is moved from played escrow to the discard pile once, and the retirement log is committed. Its `ornithopterDiscard` frame with source `move` preserves only the final movement facts needed to log and open arrival decisions. Recovery never repeats force removal, placement, marker event generation, shipment completion or movement counters.
+8 -6
View File
@@ -121,12 +121,14 @@ claim or decline marks the one protected territory and resumes
the same storm exactly once. Canceled cards, Nexus conversion
and competing parents retain their direct paths.
Completed Ornithopter movement now offers the same retired public card
before its already committed arrival suffix only in a roster without
Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response
sources. The final forces and move counter are held in the existing
bound flight frame; claim or decline retires that frame and logs the
arrival once. Mixed entry reactions remain unsupported for Semuta.
Completed Ornithopter movement now offers its retired public card before
the original arrival suffix, including native Bene Gesserit Intrusion.
The final forces and move counter stay committed in the bound flight frame.
Claim or decline retires that frame before logging arrival and opening the
original optional fighter-to-advisor choice once. This order is the existing
discard-before-arrival composition, not a new publisher priority ruling.
Ecaz Ambassador, Moritani Terror and overlapping entry reactions remain outside
this Semuta boundary. (11 October 2026)
Clean `battleResolved` now pauses the first public mandatory batch
after combat result and actual card custody are committed but before
+15 -2
View File
@@ -233,8 +233,7 @@ This neutral all-seat acknowledgement is a conservative **application
policy**, not a newly discovered publisher timing rule. Full-hand
Semuta uses the explicit user-approved atomic exchange; its completed
hand count is unchanged. Pending gifts, competing responses,
completed Ornithopter
arrivals with BG intrusion, Ecaz Ambassador or Moritani Terror,
completed Ornithopter arrivals with Ecaz Ambassador or Moritani Terror,
queued or promise-bound Truthtrance, pending battle rewards and other
typed producers retain their automatic continuation; Semuta cannot
claim those events yet.
@@ -245,3 +244,17 @@ 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.
**11 October 2026 — native Intrusion suffix.** Final Ornithopter movement now
offers the retired public card to Semuta before native Bene Gesserit Intrusion.
Physical movement and counters have already committed; claim or decline resumes
the same arrival and optional fighter-to-advisor choice once. Basic still has
no Advanced Intrusion. Range3 and final two-group completion work; other entry
families and overlap guards are unchanged. This is the existing typed
discard-before-arrival composition, not a new publisher priority ruling.
Ten isolated engine scenarios prove claim/pass in both modes, Intrusion decline,
two-group completion and four legal bots. Real GameTable at 390px proves Commit,
the original Become advisors control and Finish shipment & movement advancing
to the next seat with unchanged force counts. Public/full-mode gates stay closed.
TypeScript/lint pass; the final affected union passes 113 cases. The regression
checks actual fighter counts rather than the incidental advisor marker shape.
+1 -1
View File
@@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
if (definition?.card.effect === 'distrans')
return 'Choose a recipient and card in the Distrans transfer panel.';
if (definition?.card.effect === 'semutaDrug')
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected; pending rewards, other producers and full-hand sequencing remain unfinished.';
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. The approved full-hand exchange spends Semuta without increasing the completed hand. Pending rewards, other producers and nested responses remain unfinished.';
return definition && definition.card.effect !== 'karama'
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
: null;
+2 -3
View File
@@ -6640,7 +6640,7 @@ function treacheryDiscardIntegrity(g: Game) {
}
/** Bounded public single-card reaction after clean completed effects. Battle
* winner cleanup may resume only without other pending battle rewards; final
* flight arrival excludes factions that open intrusion or entry responses. */
* flight arrival may resume native Intrusion, but other entry responses remain gated. */
function semutaOfferSupported(
g: Game,
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
@@ -6674,8 +6674,7 @@ function semutaOfferSupported(
!(continuation.kind === 'ornithopterDiscard' &&
(continuation.source === 'end' ||
(continuation.source === 'move' &&
!g.players.some(p => p.faction === 'beneGesserit' ||
p.faction === 'ecaz' || p.faction === 'moritani') &&
!g.players.some(p => p.faction === 'ecaz' || p.faction === 'moritani') &&
!g.pendingArrivalOverlap))) &&
!(continuation.kind === 'truthtranceDiscard' &&
continuation.consumed.source === undefined &&
+47 -3
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { fighterCount } from '../game/advisors';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { spiceDeck } from '../game/cards';
import { saphoAggressorGame, aggressorAction, prepareAggressorPlans } from './fixture-sapho-aggressor';
@@ -22,11 +23,11 @@ function inventory(g: Game) {
assert.equal(new Set(ids).size, ids.length);
return ids;
}
function fixture(ix = false, ixianAlly = false, advanced = false) {
function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emperor' | 'beneGesserit' = 'emperor') {
let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), advanced, ix ? ['choam', 'ix'] : ['choam']);
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : 'Emperor',
ixianAlly ? 'ixians' : 'emperor'));
joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : opponent === 'beneGesserit' ? 'Bene Gesserit' : 'Emperor',
ixianAlly ? 'ixians' : opponent));
for (const p of g.players) { p.bot = 'Medium'; p.ready = true; }
g = initializeSemutaGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 80; step++) {
@@ -538,6 +539,49 @@ void test('a retired Ornithopter can be claimed after one flight group without r
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
});
void test('final Ornithopter discard resolves Semuta before the original Bene Gesserit intrusion without moving twice', () => {
const { g, semuta, hajr } = fixture(false, false, true, 'beneGesserit');
player(g, 'a').hand = [];
g.deck.push(hajr);
const ornithopter = take(g, 'richese-ornithopter');
player(g, 'a').hand.push(ornithopter);
Object.assign(player(g, 'a'), { forces: { 'imperial_basin:10': 3 }, reserves: 17, shipped: true });
Object.assign(player(g, 'e'), { forces: { 'arrakeen:10': 2 }, reserves: 18, advisors: {} });
const original = inventory(g);
const pending = applyAction(g, 'a', {
type: 'move', movementCard: ornithopter.id, ornithopter: 'range3',
forces: { 'imperial_basin:10': 1 }, territory: 'arrakeen', sector: 10,
});
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.decision, null);
assert.equal(fighterCount(player(pending, 'e'), 'arrakeen'), 2);
assert.equal(player(pending, 'a').moved, 1);
assert.deepEqual(player(pending, 'a').forces, { 'imperial_basin:10': 2, 'arrakeen:10': 1 });
const event = pending.pendingTreacheryDiscard!.batch.event;
let declined = reload(pending);
for (const id of ['a', 'e', 'r']) declined = applyAction(declined, id, { type: 'semutaPass', event });
const claimed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event });
for (const resumed of [declined, claimed]) {
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(resumed.decision?.kind, 'intrusion');
assert.equal(resumed.decision?.player, 'e');
let finished = applyAction(resumed, 'e', { type: 'decision', accept: true });
while (finished.response?.kind === 'advisorFlip') {
const actor = finished.players.find(p => !finished.response!.passed.includes(p.id))!;
finished = applyAction(finished, actor.id, { type: 'passResponse' });
}
assert.equal(finished.decision, null);
assert.equal(fighterCount(player(finished, 'e'), 'arrakeen'), 0);
assert.deepEqual(player(finished, 'e').forces, { 'arrakeen:10': 2 });
assert.equal(player(finished, 'a').moved, 1);
assert.deepEqual(player(finished, 'a').forces, { 'imperial_basin:10': 2, 'arrakeen:10': 1 });
assert.deepEqual(inventory(finished), original);
assert.throws(() => applyAction(finished, 'r', { type: 'semutaCommit', event }));
}
assert.equal(player(claimed, 'r').hand.some(card => card.id === ornithopter.id), true);
assert.equal(claimed.discard.some(card => card.id === semuta.id), true);
});
void test('a completed private Distrans transfer offers only its public used card', () => {
const { g, semuta, hajr } = fixture();
const distrans = take(g, 'richese-distrans');