Retain trained Ixian response inside paused Semuta cost

This commit is contained in:
Quintine committed 2026-10-12 08:51:20 +11:00
1 parent 90c6f0020c
commit 5b3729e2db
7 files changed
+92 -8

No files matched your search

+12
View File
@@ -29,6 +29,18 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented. 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 **Semuta / explicit original Leader Skills — 11 October.** The initializer's
optional second argument and `--profile semuta --leader-skills` now delegate optional second argument and `--profile semuta --leader-skills` now delegate
original all14 native Basic Richese/CHOAM or Advanced E1/E2 training, retaining original all14 native Basic Richese/CHOAM or Advanced E1/E2 training, retaining
+28
View File
@@ -1,5 +1,33 @@
# Dune implementation status # 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 ## 11 October 2026 — explicit Semuta/original Leader Skills composition
`initializeSemutaGameForAudit(state,skills=false)` and the new scoped `initializeSemutaGameForAudit(state,skills=false)` and the new scoped
+12
View File
@@ -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. discs exclude Auditor; human training in all three seats reaches real Traitors.
Observed stale skill guidance was corrected and its actual surface reinspected. Observed stale skill guidance was corrected and its actual surface reinspected.
This is bounded local evidence, not full skill/mode or deployed certification. 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.
+4 -2
View File
@@ -22216,8 +22216,10 @@ function ixSubstitutionIntegrity(g: Game) {
'The saved Ixian substitution changed its original battle losses, cards or physical counters.'); 'The saved Ixian substitution changed its original battle losses, cards or physical counters.');
const decisions = homeworldSavedDecisions(g); const decisions = homeworldSavedDecisions(g);
const selecting = decisions.filter(d => d.kind === 'ixSubstitution'); const selecting = decisions.filter(d => d.kind === 'ixSubstitution');
const exchangeResponse = g.response?.kind === 'ixSubstitution' || g.pendingKarama?.use.kind === 'cancel' && const continuation = g.pendingTreacheryDiscard?.continuation;
g.pendingKarama.use.response.kind === 'ixSubstitution'; 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 && requireRule(selecting.every(d => d.player === pending.player && d.territory === pending.territory &&
JSON.stringify(d.losses) === JSON.stringify(pending.losses)) && JSON.stringify(d.losses) === JSON.stringify(pending.losses)) &&
(exchangeResponse ? !!pending.sources && !!pending.recover : !pending.sources && !pending.recover) && (exchangeResponse ? !!pending.sources && !!pending.recover : !pending.sources && !pending.recover) &&
+4 -4
View File
@@ -26,12 +26,12 @@ export function semutaIxPhysical(game: Game) {
} }
/** Original native lobby, private Ix card choice, Traitors and actual first Storm/HMS placement. */ /** 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']); let game = createGame('SEMUTAIX', newPlayer('r', 'Richese', 'richese'), advanced, ['choam', 'ix']);
joinGame(game, newPlayer('i', 'Ixians', 'ixians')); joinGame(game, newPlayer('i', 'Ixians', 'ixians'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor')); joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const p of game.players) { p.bot = 'Medium'; p.ready = true; } 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++) { for (let step = 0; (game.status === 'setup' || game.phase === 1 || !game.mobileStronghold?.location) && step < 160; step++) {
let next: Game | undefined; let next: Game | undefined;
for (const p of game.players) { 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. */ /** Later rule position uses conserved native pieces, not a synthetic Game or replacement players. */
export function semutaIxBattleFixture(advanced = false): SemutaIxFixture { export function semutaIxBattleFixture(advanced = false, skills = false): SemutaIxFixture {
const game = initializeSemutaIxGame(advanced); const game = initializeSemutaIxGame(advanced, skills);
const cards = semutaIxInventory(game); const cards = semutaIxInventory(game);
assert.equal(new Set(cards).size, cards.length); assert.equal(new Set(cards).size, cards.length);
for (const p of game.players) { for (const p of game.players) {
+6 -2
View File
@@ -24,8 +24,8 @@ export function semutaNativeKaramaAct(game: Game, step: SemutaNativeKaramaStep,
} }
/** Original lobby and conserved later battle; only the existing printed Karama changes hands. */ /** Original lobby and conserved later battle; only the existing printed Karama changes hands. */
export function semutaNativeIxKaramaFixture(advanced = false): SemutaNativeKaramaFixture { export function semutaNativeIxKaramaFixture(advanced = false, skills = false): SemutaNativeKaramaFixture {
const ix = semutaIxBattleFixture(advanced); const ix = semutaIxBattleFixture(advanced, skills);
const richese = semutaIxPlayer(ix.game, ix.holder); const richese = semutaIxPlayer(ix.game, ix.holder);
const index = richese.hand.findIndex(card => card.effect === 'karama'); const index = richese.hand.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0); assert.ok(index >= 0);
@@ -85,6 +85,10 @@ export function semutaNativeKaramaPreCost(f: SemutaNativeKaramaFixture, actions?
step = { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } }; step = { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } };
else if (game.decision?.kind === 'fullPlanOffer') else if (game.decision?.kind === 'fullPlanOffer')
step = { actor: game.decision.player, action: { type: 'decision', decline: true } }; 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; else break;
game = semutaNativeKaramaAct(game, step, actions); game = semutaNativeKaramaAct(game, step, actions);
} }
+26
View File
@@ -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 });
});