diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 179913fe..b464a722 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -215,13 +215,17 @@ reveals before that companion moves, in both modes. A repeated-Karama cancellation of an already-canceled compulsory cache lot remains an explicit open boundary recorded in the decision index. Two intermittently failing Moritani skill-runtime tests were measured at the same 3/30 rate on both -revisions, so they are pre-existing nondeterminism rather than a regression; the -same holds for `tests/ecaz-occupy-options.test.ts`, whose Easy Occupy plan -candidate is occasionally absent (2/20 failing runs at `4ed6beb` versus 3/20 -now), for the paired Moritani skill-runtime tests, and for -`tests/ecaz-occupy-cards-runtime.test.ts` (one intermittent Harass/explosion -case, passing 5/5 standalone). Those intermittent failures are pre-existing -prototype/AI gaps and are not new regressions. +revisions, so they are pre-existing nondeterminism rather than a regression. +The Occupy policy failure (2/20 at `4ed6beb`, 3/20 here) is now explained by +an obsolete immediate-plan assertion: seed 8 legally activates Ecaz special +Karama before sealing. All four profiles finish that captured state in two +actions; the deterministic regression now checks finite legal progression. +The Harass/explosion failure is now reproduced at deterministic seed 62: +the fixture's donor exchange removed its already-selected Harass card. +Staging now uses a conserved deck replacement while hand capacity remains. +The repaired physical scenario returns two forces before the explosion, +discards Harass once and reaches Collection. Neither finding changes rules +or bot behavior; the earlier attribution to prototype/AI gaps was incorrect. **Gated captures resumed under the new prototypes.** The 14 captures that remained gated after the 7 October rulings were resumed in place against their diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 19e2b88b..277c0480 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,31 @@ # Dune implementation status +## 9 October 2026 — two Occupy failures isolated to test contracts and staging + +Deterministic seed 8 reproduces the recorded missing-immediate-plan failure. +The bot actually offers a legal Ecaz special Karama action; Easy, Medium, +Hard and Brutal each then seal the selected lead's plan on their next action. +The actual captured engine smoke consumes Karama once and submits the plan. +No bot or rule behavior changed. + +The regression now pins entropy and follows actual first-policy actions to +a sealed plan for both leads and active/canceled Occupy instead of requiring +the first action list to contain a plan. That case passes. + +Deterministic seed 62 also reproduces the Harass ally/explosion failure: +when Lasgun must come from another hand, fixture staging gives that donor +the actor's sole held Harass, then tries to seal the now-unavailable defense. +The engine correctly rejects it. The staging helper now funds a donor +replacement from the original deck when the recipient has room, retaining +its earlier selected physical cards. Full hands keep the existing exchange. +The real repaired scenario returns two forces before the explosion, +discards Harass once and reaches Collection. Its deterministic regression +passes. Neither repair changes production engine or bot behavior. + +Frozen verification: `check:quick` passes and all 270 cases in the nine +affected Occupy, Stronghold and native-tech files pass. No full offline +suite, HTTP campaign, database audit or deployment was run. + ## 9 October 2026 — provisional Kulon/Ornithopter coexistence Active Kulon no longer blocks either mode of the Richese movement card. diff --git a/tests/ecaz-occupy-cards-runtime.test.ts b/tests/ecaz-occupy-cards-runtime.test.ts index 5ec4d177..e3c08bdf 100644 --- a/tests/ecaz-occupy-cards-runtime.test.ts +++ b/tests/ecaz-occupy-cards-runtime.test.ts @@ -5,6 +5,7 @@ import { botActions } from '../game/bots'; import { treacheryDeck } from '../game/cards'; import { ecazTreacheryCards } from '../game/ecaz-cards'; import { splitLocation } from '../game/board'; +import { pinDeterministicRandom } from './deterministic-random'; import { createEcazOccupySetup, ecazOccupyTerritoryForces, type EcazOccupyFixtureOptions, } from './fixture-ecaz-occupy'; @@ -121,7 +122,8 @@ for (const lead of ['ecaz', 'ally'] as const) for (const slot of ['weapon', 'def for (const lead of ['ecaz', 'ally'] as const) { for (const outcome of ['loss', 'soleTraitor', 'opposingTraitor', 'mutualTraitors', 'explosion'] as const) { - void test(`Harass ${lead} lead, actual ${outcome}: returns precede losses except an opposing successful traitor`, () => { + void test(`Harass ${lead} lead, actual ${outcome}: returns precede losses except an opposing successful traitor`, (t) => { + pinDeterministicRandom(t, 62); const result = ecazOccupyCardsCase({ lead, outcome, slot: 'defense' }); const { fixture, actor, game } = result; const canceledReturn = outcome === 'opposingTraitor' || outcome === 'mutualTraitors'; diff --git a/tests/ecaz-occupy-options.test.ts b/tests/ecaz-occupy-options.test.ts index c659f25d..5318677d 100644 --- a/tests/ecaz-occupy-options.test.ts +++ b/tests/ecaz-occupy-options.test.ts @@ -4,6 +4,7 @@ import { applyAction, viewGame } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { controlsLeader } from '../game/leader-control'; +import { pinDeterministicRandom } from './deterministic-random'; import { ecazOccupyDialChoices, ecazOccupyLeadOptions, @@ -107,21 +108,28 @@ void test('typed Fremen and Emperor variable armies retain native legal incremen assert.equal(ecazOccupyPlanControl(emperorView, 8, 4)?.blocked, null); }); -void test('all four native policies can seal a legal total plan for either lead, active or canceled', () => { +void test('all four native policies finish Occupy planning through optional powers for either lead, active or canceled', (t) => { + pinDeterministicRandom(t, 8); for (const lead of ['ecaz', 'ally'] as const) for (const canceled of [false, true]) { const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 }); const game = openEcazOccupyPlans(canceled ? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead)); const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally; for (const policy of DIFFICULTIES) { - const view = viewGame(game, actor); - view.players.find((player) => player.id === actor)!.bot = policy; - const plan = botActions(view).find((action) => action.type === 'battlePlan'); - assert.ok(plan, `${policy}/${lead}/${canceled}: native policy must supply a sealable plan`); - assert.equal(ecazOccupyPlanControl(view, Number(plan.dial), Number(plan.support ?? 0))?.blocked, null); - const next = applyAction(game, actor, plan); - assert.ok(viewGame(next, actor).battle?.submitted.includes(actor), - `${policy}/${lead}/${canceled}: the native table must show the selected lead's sealed plan`); + let state = structuredClone(game); + for (let step = 0; step < 4; step++) { + const view = viewGame(state, actor); + view.players.find((player) => player.id === actor)!.bot = policy; + const actions = botActions(view); + const action = actions[0]; + assert.ok(action, `${policy}/${lead}/${canceled}: planning must retain a legal continuation`); + if (action.type === 'battlePlan') + assert.equal(ecazOccupyPlanControl(view, Number(action.dial), Number(action.support ?? 0))?.blocked, null); + state = applyAction(state, actor, action); + if (viewGame(state, actor).battle?.submitted.includes(actor)) break; + } + assert.ok(viewGame(state, actor).battle?.submitted.includes(actor), + `${policy}/${lead}/${canceled}: optional powers must finish and the selected lead must seal a plan`); } } }); diff --git a/tests/fixture-ecaz-occupy.ts b/tests/fixture-ecaz-occupy.ts index a9577953..5a8ea69b 100644 --- a/tests/fixture-ecaz-occupy.ts +++ b/tests/fixture-ecaz-occupy.ts @@ -157,7 +157,10 @@ export function stageEcazOccupyCard(game: Game, owner: string, assert.ok(donor, 'A conserved original physical card must supply this rule case.'); const donorIndex = donor.hand.findIndex(predicate); card = donor.hand[donorIndex]; - const replacement = player.hand.shift() ?? game.deck.shift(); + // Keep previously staged battle cards when there is still hand capacity. + // A donor's replacement comes from the original deck, just as a direct + // staging draw does; only a full hand must fund it with a held card. + const replacement = player.hand.length >= 4 ? player.hand.shift() : game.deck.shift(); assert.ok(replacement, 'A conserved physical face must preserve the donor hand.'); donor.hand[donorIndex] = replacement; }