From 5b3729e2db8c167bf6266a89300722aa4f6e2b92 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:51:20 +1100 Subject: [PATCH] Retain trained Ixian response inside paused Semuta cost --- docs/CURRENT_STATUS.md | 12 +++++++++++ docs/IMPLEMENTATION_STATUS.md | 28 +++++++++++++++++++++++++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 12 +++++++++++ game/engine.ts | 6 ++++-- tests/fixture-semuta-ix-substitution.ts | 8 +++---- tests/fixture-semuta-native-karama.ts | 8 +++++-- tests/semuta-native-karama.test.ts | 26 +++++++++++++++++++++++ 7 files changed, 92 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 3dde981d..db2c61bd 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,18 @@ 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 / trained Ixian response ownership — 11 October.** Newly admitted +native Ixian skills exposed a real projection failure: during a printed +substitution-cancellation cost, the validator ignored the response saved in +`karamaBattleDiscard` and reported a lost original choice. It now recognizes +that exact saved Ixian response while preserving quote/signature/force checks. +Both claim/pass regressions fail before and pass after. Types/lint and70 +affected unit cases across9 files pass; six runtime cases cover claim/pass/four +legal profiles and all three views. Styled390px Cancel→Commit→later Tooth +acknowledgements→original Shield choice reaches phase7; actual counters remain +board2Suboids/Tanks1Cyborg/reserves17, cost held by Richese, with no overflow. +No skill/native effect, module gate, existing game/store or deployment changed. + **Semuta / explicit original Leader Skills — 11 October.** The initializer's optional second argument and `--profile semuta --leader-skills` now delegate original all14 native Basic Richese/CHOAM or Advanced E1/E2 training, retaining diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 019781f8..b134c90f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,33 @@ # Dune implementation status +## 11 October 2026 — trained Ixian response retained across Semuta cost + +The explicit skill entry makes the formerly unadmitted receipt path reachable. +Actual Advanced native all14 setup, original posture/preparation, first loser +acknowledgement and declared1/1 exchange precede printed rival Karama. Cost +staging succeeded but every player projection failed: the skill-trained +substitution validator recognized only live response/Worthless conversion, +not the response held by `karamaBattleDiscard`. + +The validator now recognizes that exact saved Ixian cancellation response. +No quote/signature/counter check is weakened, and native cancellation still +finishes once before the held used Tooth and optional Shield cleanup. +Existing fixture entrypoints accept an optional skills argument with default +unchanged; their original preparation consumes actual posture/Mentat controls. + +Both consumer-visible claim/pass regressions fail before and pass after. +Types/lint and70 unit cases across9 files pass. Six actual runtime cases +cover claim/pass/four legal profiles, all three pause views, retired/repeated +actions, physical inventory and one native canceled exchange. Styled real +GameTable at390px completes Cancel with Karama→Commit→Tooth acknowledgements +→original Shield choice. Force inspector shows board2Suboids, Tanks1Cyborg, +reserves17; Richese retains acquired Karama at phase7 opening, no overflow. + +This was a previously static warning made reproducible by the new skill +composition, not a Basic/Advanced force-rule difference. Existing native +handlers, gates and unrelated saves remain unchanged. Temporary probes, +service and tab removed; existing games/storage/user PDF/deployment untouched. + ## 11 October 2026 — explicit Semuta/original Leader Skills composition `initializeSemutaGameForAudit(state,skills=false)` and the new scoped diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index e2418d0f..76fe54b6 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -770,3 +770,15 @@ acknowledgements→Shield cleanup→Collection. Native CHOAM's five eligible nor discs exclude Auditor; human training in all three seats reaches real Traitors. Observed stale skill guidance was corrected and its actual surface reinspected. This is bounded local evidence, not full skill/mode or deployed certification. + +**11 October 2026 — trained Ixian saved response.** Actual skill-trained +substitution cost views previously threw a lost-choice error because +`ixSubstitutionIntegrity` ignored the detached response in `karamaBattleDiscard`. +Recognizing that exact saved Ixian response retains its declared sources and +recovery while Semuta owns controls. Original quote/signature/physical checks +and one cancellation stay authoritative. Claim/pass do not resurrect the +exchange; used Tooth and optional Shield cleanup still follow. +Both regressions fail before and pass after; six runtime cases cover all +views, claim/pass/four legal profiles and native remainder. Styled390px +controls finish with board2Suboids/Tanks1Cyborg/reserves17 and Richese's acquired +Karama, with no overflow. No additional skill/module or mode gate opens. diff --git a/game/engine.ts b/game/engine.ts index 5adb0207..59d6a4ba 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -22216,8 +22216,10 @@ function ixSubstitutionIntegrity(g: Game) { 'The saved Ixian substitution changed its original battle losses, cards or physical counters.'); const decisions = homeworldSavedDecisions(g); const selecting = decisions.filter(d => d.kind === 'ixSubstitution'); - const exchangeResponse = g.response?.kind === 'ixSubstitution' || g.pendingKarama?.use.kind === 'cancel' && - g.pendingKarama.use.response.kind === 'ixSubstitution'; + const continuation = g.pendingTreacheryDiscard?.continuation; + const exchangeResponse = g.response?.kind === 'ixSubstitution' || + (g.pendingKarama?.use.kind === 'cancel' && g.pendingKarama.use.response.kind === 'ixSubstitution') || + (continuation?.kind === 'karamaBattleDiscard' && continuation.use.response.kind === 'ixSubstitution'); requireRule(selecting.every(d => d.player === pending.player && d.territory === pending.territory && JSON.stringify(d.losses) === JSON.stringify(pending.losses)) && (exchangeResponse ? !!pending.sources && !!pending.recover : !pending.sources && !pending.recover) && diff --git a/tests/fixture-semuta-ix-substitution.ts b/tests/fixture-semuta-ix-substitution.ts index 7d94827a..cb20731d 100644 --- a/tests/fixture-semuta-ix-substitution.ts +++ b/tests/fixture-semuta-ix-substitution.ts @@ -26,12 +26,12 @@ export function semutaIxPhysical(game: Game) { } /** Original native lobby, private Ix card choice, Traitors and actual first Storm/HMS placement. */ -export function initializeSemutaIxGame(advanced = false): Game { +export function initializeSemutaIxGame(advanced = false, skills = false): Game { let game = createGame('SEMUTAIX', newPlayer('r', 'Richese', 'richese'), advanced, ['choam', 'ix']); joinGame(game, newPlayer('i', 'Ixians', 'ixians')); joinGame(game, newPlayer('e', 'Emperor', 'emperor')); for (const p of game.players) { p.bot = 'Medium'; p.ready = true; } - game = initializeSemutaGameForAudit(game); + game = initializeSemutaGameForAudit(game, skills); for (let step = 0; (game.status === 'setup' || game.phase === 1 || !game.mobileStronghold?.location) && step < 160; step++) { let next: Game | undefined; for (const p of game.players) { @@ -49,8 +49,8 @@ export function initializeSemutaIxGame(advanced = false): Game { } /** Later rule position uses conserved native pieces, not a synthetic Game or replacement players. */ -export function semutaIxBattleFixture(advanced = false): SemutaIxFixture { - const game = initializeSemutaIxGame(advanced); +export function semutaIxBattleFixture(advanced = false, skills = false): SemutaIxFixture { + const game = initializeSemutaIxGame(advanced, skills); const cards = semutaIxInventory(game); assert.equal(new Set(cards).size, cards.length); for (const p of game.players) { diff --git a/tests/fixture-semuta-native-karama.ts b/tests/fixture-semuta-native-karama.ts index 1d037726..cec140bb 100644 --- a/tests/fixture-semuta-native-karama.ts +++ b/tests/fixture-semuta-native-karama.ts @@ -24,8 +24,8 @@ export function semutaNativeKaramaAct(game: Game, step: SemutaNativeKaramaStep, } /** Original lobby and conserved later battle; only the existing printed Karama changes hands. */ -export function semutaNativeIxKaramaFixture(advanced = false): SemutaNativeKaramaFixture { - const ix = semutaIxBattleFixture(advanced); +export function semutaNativeIxKaramaFixture(advanced = false, skills = false): SemutaNativeKaramaFixture { + const ix = semutaIxBattleFixture(advanced, skills); const richese = semutaIxPlayer(ix.game, ix.holder); const index = richese.hand.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); @@ -85,6 +85,10 @@ export function semutaNativeKaramaPreCost(f: SemutaNativeKaramaFixture, actions? step = { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } }; else if (game.decision?.kind === 'fullPlanOffer') step = { actor: game.decision.player, action: { type: 'decision', decline: true } }; + else if (game.decision?.kind === 'leaderSkillVisibility') + step = { actor: game.decision.player, action: { type: 'leaderSkillVisibility', event: game.decision.event, hide: true } }; + else if (game.decision?.kind === 'mentatQuestion') + step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, decline: true } }; else break; game = semutaNativeKaramaAct(game, step, actions); } diff --git a/tests/semuta-native-karama.test.ts b/tests/semuta-native-karama.test.ts index 881a12d5..d6e45c41 100644 --- a/tests/semuta-native-karama.test.ts +++ b/tests/semuta-native-karama.test.ts @@ -203,3 +203,29 @@ void test('All four profiles legally retire native printed costs and their origi } } }); + +for (const claim of [false, true]) void test(`Skill-trained Ix cancellation cost ${claim ? 'claim' : 'pass'} preserves the declared response while Semuta owns controls`, () => { + const f = semutaNativeIxKaramaFixture(true, true); assert.ok(f.ix); + const before = semutaNativeKaramaPreCost(f); + assert.ok(before.pendingIxSubstitution?.receipt); + const pending = semutaNativeKaramaCost(before, f); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.deepEqual(pending.pendingIxSubstitution!.sources, { [f.ix.location]: 1 }); + assert.deepEqual(pending.pendingIxSubstitution!.recover, { [f.ix.location]: 1 }); + for (const player of pending.players) + assert.equal(viewGame(reload(pending), player.id).semutaReaction!.event, event); + let game = semutaNativeKaramaReact(reload(pending), f, claim); + assert.equal(game.pendingIxSubstitution, null); + assert.equal(game.lastBattleContext!.ixSubstitution!.completed, true); + assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'winnerMandatoryDiscard'); + game = semutaNativeKaramaRemainder(game, f); + assert.equal(game.phase, 7); + assert.equal(semutaIxPlayer(game, f.owner).forces[f.ix.location], 2); + assert.equal(semutaIxPlayer(game, f.owner).elites!.forces[f.ix.location] ?? 0, 0); + assert.equal(semutaIxPlayer(game, f.owner).elites!.tanks, 1); + assert.equal(semutaIxPlayer(game, f.holder).hand.some(card => card.id === f.karama), claim); + assert.deepEqual(semutaIxInventory(game), f.cards); + for (const player of game.players) viewGame(reload(game), player.id); + const cost = semutaNativeKaramaCostStep(f); rejected(game, cost.actor, cost.action); + rejected(game, f.holder, { type: 'semutaCommit', event }); +});