Repair deterministic Occupy test transitions and card staging

This commit is contained in:
Quintine committed 2026-10-09 02:53:09 +11:00
1 parent 38b1ba3419
commit f2fd274502
5 files changed
+61 -18

No files matched your search

+11 -7
View File
@@ -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 cancellation of an already-canceled compulsory cache lot remains an explicit
open boundary recorded in the decision index. Two intermittently failing open boundary recorded in the decision index. Two intermittently failing
Moritani skill-runtime tests were measured at the same 3/30 rate on both 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 revisions, so they are pre-existing nondeterminism rather than a regression.
same holds for `tests/ecaz-occupy-options.test.ts`, whose Easy Occupy plan The Occupy policy failure (2/20 at `4ed6beb`, 3/20 here) is now explained by
candidate is occasionally absent (2/20 failing runs at `4ed6beb` versus 3/20 an obsolete immediate-plan assertion: seed 8 legally activates Ecaz special
now), for the paired Moritani skill-runtime tests, and for Karama before sealing. All four profiles finish that captured state in two
`tests/ecaz-occupy-cards-runtime.test.ts` (one intermittent Harass/explosion actions; the deterministic regression now checks finite legal progression.
case, passing 5/5 standalone). Those intermittent failures are pre-existing The Harass/explosion failure is now reproduced at deterministic seed 62:
prototype/AI gaps and are not new regressions. 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 **Gated captures resumed under the new prototypes.** The 14 captures that
remained gated after the 7 October rulings were resumed in place against their remained gated after the 7 October rulings were resumed in place against their
+26
View File
@@ -1,5 +1,31 @@
# Dune implementation status # 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 ## 9 October 2026 — provisional Kulon/Ornithopter coexistence
Active Kulon no longer blocks either mode of the Richese movement card. Active Kulon no longer blocks either mode of the Richese movement card.
+3 -1
View File
@@ -5,6 +5,7 @@ import { botActions } from '../game/bots';
import { treacheryDeck } from '../game/cards'; import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards'; import { ecazTreacheryCards } from '../game/ecaz-cards';
import { splitLocation } from '../game/board'; import { splitLocation } from '../game/board';
import { pinDeterministicRandom } from './deterministic-random';
import { import {
createEcazOccupySetup, ecazOccupyTerritoryForces, type EcazOccupyFixtureOptions, createEcazOccupySetup, ecazOccupyTerritoryForces, type EcazOccupyFixtureOptions,
} from './fixture-ecaz-occupy'; } 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 lead of ['ecaz', 'ally'] as const) {
for (const outcome of ['loss', 'soleTraitor', 'opposingTraitor', 'mutualTraitors', 'explosion'] 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 result = ecazOccupyCardsCase({ lead, outcome, slot: 'defense' });
const { fixture, actor, game } = result; const { fixture, actor, game } = result;
const canceledReturn = outcome === 'opposingTraitor' || outcome === 'mutualTraitors'; const canceledReturn = outcome === 'opposingTraitor' || outcome === 'mutualTraitors';
+17 -9
View File
@@ -4,6 +4,7 @@ import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots'; import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles'; import { DIFFICULTIES } from '../game/bot-profiles';
import { controlsLeader } from '../game/leader-control'; import { controlsLeader } from '../game/leader-control';
import { pinDeterministicRandom } from './deterministic-random';
import { import {
ecazOccupyDialChoices, ecazOccupyDialChoices,
ecazOccupyLeadOptions, 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); 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]) { for (const lead of ['ecaz', 'ally'] as const) for (const canceled of [false, true]) {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 }); const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
const game = openEcazOccupyPlans(canceled const game = openEcazOccupyPlans(canceled
? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead)); ? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead));
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally; const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
for (const policy of DIFFICULTIES) { for (const policy of DIFFICULTIES) {
const view = viewGame(game, actor); let state = structuredClone(game);
view.players.find((player) => player.id === actor)!.bot = policy; for (let step = 0; step < 4; step++) {
const plan = botActions(view).find((action) => action.type === 'battlePlan'); const view = viewGame(state, actor);
assert.ok(plan, `${policy}/${lead}/${canceled}: native policy must supply a sealable plan`); view.players.find((player) => player.id === actor)!.bot = policy;
assert.equal(ecazOccupyPlanControl(view, Number(plan.dial), Number(plan.support ?? 0))?.blocked, null); const actions = botActions(view);
const next = applyAction(game, actor, plan); const action = actions[0];
assert.ok(viewGame(next, actor).battle?.submitted.includes(actor), assert.ok(action, `${policy}/${lead}/${canceled}: planning must retain a legal continuation`);
`${policy}/${lead}/${canceled}: the native table must show the selected lead's sealed plan`); 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`);
} }
} }
}); });
+4 -1
View File
@@ -157,7 +157,10 @@ export function stageEcazOccupyCard(game: Game, owner: string,
assert.ok(donor, 'A conserved original physical card must supply this rule case.'); assert.ok(donor, 'A conserved original physical card must supply this rule case.');
const donorIndex = donor.hand.findIndex(predicate); const donorIndex = donor.hand.findIndex(predicate);
card = donor.hand[donorIndex]; 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.'); assert.ok(replacement, 'A conserved physical face must preserve the donor hand.');
donor.hand[donorIndex] = replacement; donor.hand[donorIndex] = replacement;
} }