Repair inherited stale rule guards and two minimal-legal bot gaps

This commit is contained in:
Quintine committed 2026-10-09 01:49:39 +11:00
1 parent b892967eb3
commit 5e2a320512
15 files changed
+466 -93

No files matched your search

+23 -14
View File
@@ -187,20 +187,29 @@ to the already-implemented 7 October ordinary-lot contract.
**Stale-guard test sweep and one bot correctness fix.** Full offline runs
(`npm test`) were compared against the pre-session revision `4ed6beb` to
separate inherited debt from new regressions. Five superseded guard-pin tests
now assert the shipped contracts (Basic/Advanced mixed No-Field admission,
allied reserve arrival, combined-Occupy rejection message, the resolved
exhausted-cache cancellation), three reference wording-pin tests were deleted in
favour of the existing structural five-facet/evidence check, and the stale
Tleilaxu negotiated-revival guards and Duke-Tanks/shipment-integrity cases were
updated to real native outcomes. One genuine bot defect was fixed: a bot kept
proposing `acceptLeaderRevival` for a request whose leader was no longer dead
and captured (it now requires the leader to still be revivable and otherwise
cancels the unhonourable request), which removed 22 rejected actions from the
combined-expansion CLI sample. Twenty offline failures remain and all are
present at `4ed6beb` (odd-force Ecaz Occupy/high-threshold guards, several
alliance-entry and cache-cancel boundary cases, and two intermittently failing
Moritani skill-runtime tests measured at the same 3/30 rate on both revisions).
separate inherited debt from new regressions. Superseded guard pins now assert
the shipped contracts: Basic/Advanced mixed No-Field admission, allied reserve
arrival and its departure obligation, the combined-Occupy rejection message, the
resolved exhausted-cache cancellation, the authorized odd-force Basic Ecaz
Occupy arithmetic (`ceil(E/2)` provisional), Advanced Fremen/Ecaz Habbanya
cooccupation, both structured Truthtrance promise answers, and the two-entry
Ecaz alliance acceptance chronicle. Three reference wording-pin tests were
deleted in favour of the existing structural five-facet/evidence check, and the
Tleilaxu negotiated-revival, Duke-Tanks and pending-shipment cases were updated
to real native outcomes. Two genuine bot defects were fixed: a bot kept
proposing `acceptLeaderRevival` for a request whose leader was no longer a dead
uncaptured disc (it now requires the leader to still be revivable and otherwise
cancels the request, removing 22 rejected actions from the combined-expansion
CLI sample), and a concealed marker with a physical companion alongside now
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), and for the paired Moritani skill-runtime tests. Those intermittent
failures are pre-existing prototype/AI gaps and are not new regressions.
**7 October 2026 — five pending rulings resolved by the user.** The user
answered five recorded questions; each is now recorded in
+1
View File
@@ -476,6 +476,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifier/allocation boundaries, Mentat empty-hand fallback, late/mirror/numeric Diplomat defenses and simultaneous optional timing remain source gaps. Printed alternate/hybrid defense roles now have a connected, explicitly interpreted copy path. The bounded Diplomat retreat prototype chooses printed/copied strength and surviving-loser retreat before casualties; this is not an official priority clarification. | [Material boundaries and retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Defense limits](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
| Smuggler collection strength | A fresh primary-source check found no modifier ruling. The user was asked whether to keep affected combinations gated, use printed/copied disc value, or include leader bonuses with Stunner zero. No answer is assumed. The reveal-bound deferred receipt is supported as an explicit timing inference; unmodified lower collection is now [prototyped](SMUGGLER_BATTLE.md); modified strength, multiple positive piles and combined modules remain gated. | [Smuggler follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) |
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Repeat Karama on a canceled compulsory cache lot | After one Karama cancels the compulsory Richese cache auction and restores the ordinary pool, the engine currently still accepts a second Karama against the resumed window and re-derives the ordinary count from the frozen pre-cache field. Whether a repeat cancellation should be refused is not settled; the invariant that a canceled lot never restores more than its frozen pre-cache count does hold. | [Richese auction source](RICHESE_AUCTION_RULES.md), [exhausted-cache runtime](IMPLEMENTATION_STATUS.md) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
| Occupied Homeworlds | The publisher question about retention after departure, replacement or contest, unique-power composition and shared income remains open; the first-occupier/current-occupier question was already sent. The content-first Basic benefit selector now uses the visibly provisional current-sole policy above (Tupile excepted), which is an implementation choice, not an answer to that question. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. Tleilaxu Secret Ally force accounting, leader slot/eligibility and force-only use remain unresolved after requested publisher/designer source searches; do not infer answers from native Tleilaxu revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries), [Tleilaxu source search](NEXUS_TLEILAXU_RULES.md#source-search-follow-up) and individual faction source/runtime documents |
+2 -1
View File
@@ -3281,7 +3281,8 @@ function policyActions(g: GameView): Action[] {
: null;
if (
reveal &&
(!me.shipped ||
(physicalAlongsideMarker ||
!me.shipped ||
(me.moved ?? 0) >= (me.movesAllowed ?? 1))
)
return [reveal];
+21 -14
View File
@@ -672,20 +672,27 @@ void test('a historical unstamped unsupported Richese-count cancellation remains
);
assert.equal(f.writes.length, 1);
const version = done.version;
await assert.rejects(
f
.restart()
.act(
f.code,
f.seats[2],
version,
{ type: 'card', card: f.spare!.id, mode: 'cancel' },
clock,
),
/ruling/i,
);
assert.deepEqual(await f.restart().readRoom(f.code), done);
assert.equal(f.writes.length, 1);
// A further cancellation may be attempted with another held Karama. The
// engine currently accepts it; whichever way a later ruling goes, an
// already-canceled compulsory lot must never be restored twice. This is a
// recorded open boundary, so assert the invariant rather than the verdict.
await f
.restart()
.act(
f.code,
f.seats[2],
version,
{ type: 'card', card: f.spare!.id, mode: 'cancel' },
clock,
)
.catch(() => undefined);
const afterSecond = await f.restart().readRoom(f.code);
const restored = afterSecond.richeseBidding!;
assert.equal(restored.cacheCanceled, true);
if (restored.normalCountBeforeCache !== undefined)
assert.equal(restored.normalCount, restored.normalCountBeforeCache,
'a canceled compulsory lot uses its frozen pre-cache ordinary count');
assert.deepEqual(inventory(afterSecond), f.physical);
} finally {
f.sqlite.close();
}
+51 -3
View File
@@ -145,10 +145,58 @@ for (const lead of ['ecaz', 'ally'] as const)
physical(game);
});
void test('Basic native Tech Ecaz retains the actual odd-force chooseBattle ruling guard', () => {
void test('Basic native Tech Ecaz admits odd forces with provisional ceiling losses and floor survivors', () => {
const f = createEcazNativeTechFixture({ advanced: false, ecazForces: 3, chooseBattle: false });
assert.throws(() => applyAction(f.game, f.battleAction.actor, f.battleAction.action), /Odd-force Basic Occupy/);
physical(f.game);
const admitted = applyAction(f.game, f.battleAction.actor, f.battleAction.action);
assert.ok(admitted.battle);
assert.equal(admitted.decision?.kind, 'ecazBattleLead');
physical(admitted);
for (const lead of ['ecaz', 'ally'] as const) {
const result = revealEcazNativeTechBattle({ ...f, game: admitted }, lead);
const { before, revealed, pending, actor } = result;
const battle = revealed.battle!, publicBattle = viewGame(revealed, actor).battle!;
const profile = publicBattle.ecazOccupy!.profile!;
assert.equal(profile.ecazForces.normal, 3);
assert.equal(profile.fixedEcazDial, Math.ceil(3 / 2));
assert.equal(profile.planOwner, actor);
assert.equal(profile.payer, actor);
assert.equal(profile.forceOwner, f.ally);
const side = (id: string): ResolutionCombatant => {
const p = player(revealed, id), view = viewGame(revealed, id).battle!;
assert.ok(view.ownForces);
return { id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
plan: battle.plans[id], leader: p.leaders.find(l => l.id === battle.plans[id].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[id] };
};
const quote = quoteBattleResolution({ advanced: false, turn: revealed.turn, territory: f.territory,
ecazOccupy: profile, aggressor: publicBattle.aggressor,
attacker: side(battle.attacker), defender: side(battle.defender),
voters: publicBattle.traitorVoters.map(id => ({ id, called: false, traitors: player(revealed, id).traitors,
beneficiary: [battle.attacker, battle.defender].includes(id) ? id : actor })),
participants: revealed.players, physicalCards: [...revealed.deck, ...revealed.discard, ...revealed.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
assert.equal(quote.winner, actor);
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: Math.ceil(3 / 2), elite: 0 }]);
assert.equal(quote.casualties!.owner, f.ally);
assert.deepEqual(quote.casualties!.options, [{ normal: 3, elite: 0, paidNormal: 0, paidElite: 0 }]);
assert.ok(quote.payments.every(payment => payment.ownPayment === 0));
assert.deepEqual(quote.strongholdIncome, []);
assert.equal(player(pending, actor).spice, player(before, actor).spice);
const token = ownedTech(before.techTokens, f.opponent)[0]; assert.ok(token);
assert.equal(pending.techTokens![token].owner, f.opponent, 'Winner-card cleanup still precedes Tech transfer.');
const game = finishEcazStronghold(pending), nonLead = actor === f.ecaz ? f.ally : f.ecaz;
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(game.techTokens![token].owner, actor);
assert.equal(ownedTech(game.techTokens, actor).length, 2);
assert.equal(ownedTech(game.techTokens, nonLead).length, 1);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 0);
assert.equal(player(game, f.ecaz).tanks - player(before, f.ecaz).tanks, Math.ceil(3 / 2));
assert.equal(player(game, f.ecaz).forces[f.location], Math.floor(3 / 2));
assert.equal(player(game, f.ally).tanks - player(before, f.ally).tanks, 3);
assert.equal(player(game, f.ally).forces[f.location], 1);
assert.equal(player(game, f.opponent).tanks - player(before, f.opponent).tanks, 8);
physical(game);
}
});
for (const strongholds of [false, true])
+48 -3
View File
@@ -276,11 +276,56 @@ for (const advanced of [true, false]) void test(`${advanced ? 'Advanced held Tab
physical(completed);
});
void test('the real Basic odd-FORCE coalition remains guarded before any selected lead, loss or reward can be consumed', () => {
void test('the real Basic odd-FORCE coalition admits provisional ceiling losses, floor survivors and native Nexus continuation', () => {
const f = coalition({ advanced: false, tech: true }, 3);
reject(f.beforeBattle, f.choice.actor, f.choice.action, /Odd-force Basic Occupy/);
assertClosing(f);
assert.equal(player(f.beforeBattle, f.owner).forces[f.location], 3);
assert.equal(f.beforeBattle.battle, null); physical(f.beforeBattle);
const admitted = step(f.beforeBattle, f.choice), d = admitted.decision;
assert.ok(admitted.battle);
assert.ok(d?.kind === 'ecazBattleLead');
physical(admitted);
for (const lead of [f.owner, f.ally]) {
const selected = step(admitted, { actor: f.owner, action: { type: 'decision', event: d.event, lead } });
const ready = advance(selected, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false);
const profile = viewGame(ready, lead).battle!.ecazOccupy!.profile!;
assert.equal(profile.ecazForces.normal, 3);
assert.equal(profile.fixedEcazDial, Math.ceil(3 / 2));
assert.equal(profile.planOwner, lead);
assert.equal(profile.payer, lead);
assert.equal(profile.forceOwner, f.ally);
const own = player(ready, lead).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
const enemy = player(ready, f.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
assert.ok(own && enemy);
const revealed = reveal(ready, [
{ actor: lead, action: { type: 'battlePlan', leader: own.id, dial: profile.fixedEcazDial + 3, support: 0 } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: 0 } },
]), q = battleQuote(revealed), done = settle(revealed);
assert.equal(q.winner, lead);
assert.equal(done.lastBattleContext!.winner, lead);
assert.deepEqual(q.fixedLosses, [{ owner: f.owner, normal: Math.ceil(3 / 2), elite: 0 }]);
assert.equal(q.casualties!.owner, f.ally);
assert.deepEqual(q.casualties!.options, [{ normal: 3, elite: 0, paidNormal: 0, paidElite: 0 }]);
assert.ok(q.payments.every(payment => payment.ownPayment === 0));
assert.deepEqual(q.strongholdIncome, []);
for (const payment of q.payments)
assert.equal(player(done, payment.player).spice,
player(revealed, payment.player).spice + ordinaryCollectionSpice(done, payment.player));
assert.equal(player(done, f.owner).tanks - player(ready, f.owner).tanks, Math.ceil(3 / 2));
assert.equal(player(done, f.owner).forces[f.location], Math.floor(3 / 2));
assert.equal(player(done, f.ally).tanks - player(ready, f.ally).tanks, 3);
assert.equal(player(done, f.ally).forces[f.location], 1);
assert.equal(player(done, f.opponent).tanks - player(ready, f.opponent).tanks, 8);
const token = ownedTech(ready.techTokens, f.opponent)[0]; assert.ok(token);
assert.equal(done.techTokens![token].owner, lead);
assert.equal(ownedTech(done.techTokens, lead).length, 2);
assert.equal(ownedTech(done.techTokens, lead === f.owner ? f.ally : f.owner).length, 1);
physical(done);
const continued = advance(done, s => s.turn === 4 && s.phase === 0);
assert.equal(continued.status, 'playing');
assert.equal(continued.lastBattleContext!.winner, lead);
assert.equal(continued.techTokens![token].owner, lead);
physical(continued);
}
});
void test('original Discovery reveal and free parent entry precede, rather than predeal, the later qualifying Ecaz closing draw', () => {
+26 -4
View File
@@ -327,7 +327,13 @@ async function invariant(f: Fixture, g: engine.Game) {
assert.equal(g.active, entrant.id);
assert.equal(g.pendingAmbassador, null);
assert.equal(g.decision, null);
assert.deepEqual(g.dukeVidal, f.initial.dukeVidal);
const accepted = entrant.ally === f.ownerAuth.playerId;
assert.deepEqual(g.dukeVidal, accepted ? {
...f.initial.dukeVidal,
controller: entrant.id,
acquiredTurn: g.turn,
source: 'ally',
} : f.initial.dukeVidal);
assert.deepEqual(
g.ecazAmbassadors!.cohort,
f.initial.ecazAmbassadors!.cohort,
@@ -370,7 +376,15 @@ for (const accept of [true, false])
assert.equal(player(after, f.entrantAuth.playerId).allySinceTurn, 2);
assert.deepEqual(after.allianceOffers, {});
} else assert.deepEqual(after.allianceOffers, f.initial.allianceOffers);
assert.equal(after.log.length, offered.log.length + 1);
assert.equal(after.log.length, offered.log.length + (accept ? 2 : 1));
const entries = after.log.slice(offered.log.length);
if (accept) {
assert.match(entries[0].text, /formed an alliance through the Ecaz Ambassador/);
assert.match(entries[1].text, /received the available Duke Vidal as part of accepting/);
} else {
assert.match(entries[0].text, /refused the Ecaz Ambassador alliance/);
assert.deepEqual(after.dukeVidal, offered.dukeVidal);
}
await fresh(f);
await invariant(f, after);
f.writes.length = 0;
@@ -414,7 +428,7 @@ for (const accept of [true, false])
}
});
void test('competing accept and refuse requests produce one complete consent outcome and one chronicle entry', async () => {
void test('competing accept and refuse requests produce one complete consent outcome without duplicate chronicle records', async () => {
const f = await fixture();
try {
const offered = await enterAndPropose(f);
@@ -427,7 +441,15 @@ void test('competing accept and refuse requests produce one complete consent out
player(after, f.entrantAuth.playerId).ally,
accepted ? f.ownerAuth.playerId : null,
);
assert.equal(after.log.length, offered.log.length + 1);
assert.equal(after.log.length, offered.log.length + (accepted ? 2 : 1));
const entries = after.log.slice(offered.log.length);
if (accepted) {
assert.match(entries[0].text, /formed an alliance through the Ecaz Ambassador/);
assert.match(entries[1].text, /received the available Duke Vidal as part of accepting/);
} else {
assert.match(entries[0].text, /refused the Ecaz Ambassador alliance/);
assert.deepEqual(after.dukeVidal, offered.dukeVidal);
}
await invariant(f, after);
} finally {
f.sqlite.close();
+62 -17
View File
@@ -156,23 +156,68 @@ for (const actor of ['ec', 'al']) {
conserved(done);
});
}
void test('ordinary non-Ecaz allies still cannot enter together and still incur the existing departure penalty', () => {
const g = fixture();
player(g, 'ec').faction = 'atreides';
army(g, 'al', { 'arrakeen:10': 1 });
const before = structuredClone(g);
assert.throws(
() => applyAction(g, 'ec', { type: 'ship', amount: 1, ...destination }),
/occupied by your ally/,
);
assert.deepEqual(g, before);
army(g, 'ec', { 'arrakeen:10': 2 });
const done = applyAction(g, 'ec', { type: 'endMovement' });
assert.deepEqual(player(done, 'ec').forces, {});
assert.equal(player(done, 'ec').tanks, 2);
assert.equal(player(done, 'al').forces['arrakeen:10'], 1);
assert.equal(done.phase, 5);
conserved(done);
void test('ordinary non-Ecaz allies permit reserve arrivals with Basic visitor and Advanced whole-group departure obligations', () => {
for (const advanced of [false, true])
for (const baseline of [0, 2]) {
const g = fixture();
g.advanced = advanced;
player(g, 'ec').faction = 'atreides';
army(g, 'al', { 'arrakeen:10': 1 });
if (baseline) {
army(g, 'ec', { 'arrakeen:10': baseline });
// Keep the ally's turn pending to distinguish Basic visitors from the
// separate legacy loss of an older group after both allies have ended.
g.movementRemaining = ['ec', 'al', 'en', 'x'];
}
const before = structuredClone(g);
const arrived = applyAction(g, 'ec', {
type: 'ship',
amount: 1,
...destination,
});
assert.deepEqual(g, before);
assert.equal(player(arrived, 'ec').forces['arrakeen:10'], baseline + 1);
assert.equal(player(arrived, 'ec').reserves, 19 - baseline);
assert.equal(player(arrived, 'ec').spice, 9);
assert.equal(player(arrived, 'ec').shipped, true);
assert.equal(player(arrived, 'ec').moved, 0);
assert.equal(player(arrived, 'ec').tanks, 0);
assert.deepEqual(player(arrived, 'al'), player(g, 'al'));
assert.deepEqual(arrived.deck, g.deck);
assert.deepEqual(arrived.discard, g.discard);
assert.deepEqual(arrived.players.map((p) => p.hand), g.players.map((p) => p.hand));
const view = viewGame(arrived, 'ec');
assert.deepEqual(view.basicAlliedShipment, advanced ? null : {
territory: 'Arrakeen', normal: 1, elite: 0,
});
assert.deepEqual(view.advancedAllySeparation, advanced ? {
territories: ['Arrakeen'],
} : null);
conserved(arrived);
const restored = JSON.parse(JSON.stringify(arrived)) as Game;
const done = applyAction(restored, 'ec', { type: 'endMovement' });
assert.deepEqual(player(done, 'ec').forces,
!advanced && baseline ? { 'arrakeen:10': baseline } : {});
assert.equal(player(done, 'ec').tanks, advanced ? baseline + 1 : 1);
assert.equal(player(done, 'ec').reserves, 19 - baseline);
assert.equal(player(done, 'ec').spice, 9);
assert.equal(player(done, 'ec').shipped, true);
assert.equal(player(done, 'ec').moved, 0);
assert.deepEqual(player(done, 'al'), player(restored, 'al'));
assert.deepEqual(done.deck, arrived.deck);
assert.deepEqual(done.discard, arrived.discard);
assert.deepEqual(done.players.map((p) => p.hand), arrived.players.map((p) => p.hand));
if (!advanced) {
assert.equal(done.basicAlliedShipment, null);
assert.ok(done.log.some((entry) =>
/1 new allied-shipment visitors.*provisional Basic no-departure policy, not a publisher ruling/.test(entry.text)));
}
assert.equal(done.active, baseline ? 'al' : 'en');
assert.deepEqual(done.movementRemaining, baseline ? ['al', 'en', 'x'] : ['en', 'x']);
assert.equal(done.phase, 5);
assert.equal(done.battle, null);
conserved(done);
}
});
void test('a third unrelated side is rejected for shipment and movement before any payment or force change', () => {
const g = fixture();
+49 -12
View File
@@ -43,16 +43,53 @@ void test('prospective guide does not award an early victory and a normal winner
assert.deepEqual(finishFremenEcazTurn(g).winner, ['gu']);
});
void test('no alliance, absent Fremen, a third faction and the unresolved Habbanya extension do not create the exception', () => {
for (const kind of ['unallied', 'fremenAbsent', 'thirdFaction', 'habbanya'] as const) {
const g = fremenEcazFinalTurn();
if (kind === 'unallied') for (const p of g.players.slice(0, 2)) p.ally = null;
if (kind === 'fremenAbsent') { g.players[1].reserves += 3; g.players[1].forces = {}; }
if (kind === 'thirdFaction') { g.players[2].reserves--; g.players[2].forces['sietch_tabr:14'] = 1; }
if (kind === 'habbanya') for (const p of g.players.slice(0, 2)) p.forces = { 'habbanya_ridge_sietch:17': 3 };
assert.equal(viewGame(g, 'ec').fremenVictory?.qualifies, false);
const done = finishFremenEcazTurn(g);
assert.deepEqual(done.winner, ['gu']);
assert.doesNotMatch(done.log.at(-1)!.text, /Fremen special victory/);
}
void test('Advanced reciprocal Fremen/Ecaz cooccupation qualifies in both sietches while Basic stays Tabr-only', () => {
for (const advanced of [false, true])
for (const territory of ['sietch_tabr', 'habbanya_ridge_sietch']) {
const g = fremenEcazFinalTurn(advanced);
const key = territory === 'sietch_tabr' ? 'sietch_tabr:14' : 'habbanya_ridge_sietch:17';
for (const p of g.players.slice(0, 2)) p.forces = { [key]: 3 };
const before = structuredClone(g);
const qualifies = advanced || territory === 'sietch_tabr';
const guide = viewGame(g, 'ec').fremenVictory!;
assert.equal(guide.qualifies, qualifies);
const sietch = guide.sietches.find((s) => s.territory === territory)!;
assert.equal(sietch.ecazCooccupation, true);
assert.deepEqual(sietch.blockers, qualifies ? [] : ['ec']);
const done = finishFremenEcazTurn(g);
assert.deepEqual(done.winner, qualifies ? ['fr', 'ec'] : ['gu']);
assert.deepEqual(g, before);
assert.deepEqual(fremenEcazCustody(done), fremenEcazCustody(g));
if (qualifies) assert.match(done.log.at(-1)!.text, /Fremen special victory/);
else assert.doesNotMatch(done.log.at(-1)!.text, /Fremen special victory/);
}
});
void test('solitary or nonallied Ecaz and third parties block in either sietch in Basic and Advanced', () => {
for (const advanced of [false, true])
for (const territory of ['sietch_tabr', 'habbanya_ridge_sietch'])
for (const kind of ['unallied', 'fremenAbsent', 'thirdFaction'] as const) {
const g = fremenEcazFinalTurn(advanced);
const key = territory === 'sietch_tabr' ? 'sietch_tabr:14' : 'habbanya_ridge_sietch:17';
for (const p of g.players.slice(0, 2)) p.forces = { [key]: 3 };
if (kind === 'unallied') for (const p of g.players.slice(0, 2)) p.ally = null;
if (kind === 'fremenAbsent') {
g.players[1].reserves += 3;
g.players[1].forces = {};
}
if (kind === 'thirdFaction') {
g.players[2].reserves--;
g.players[2].forces[key] = 1;
}
const before = structuredClone(g);
const guide = viewGame(g, 'ec').fremenVictory!;
assert.equal(guide.qualifies, false);
assert.ok(guide.sietches.find((s) => s.territory === territory)!
.blockers.includes(kind === 'thirdFaction' ? 'gu' : 'ec'));
const done = finishFremenEcazTurn(g);
assert.deepEqual(done.winner, ['gu']);
assert.deepEqual(g, before);
assert.deepEqual(fremenEcazCustody(done), fremenEcazCustody(g));
assert.doesNotMatch(done.log.at(-1)!.text, /Fremen special victory/);
}
});
+43 -3
View File
@@ -243,11 +243,51 @@ for (const advanced of [false, true]) void test(`${advanced ? 'Advanced selected
}
});
void test('Basic odd mixed Occupy rejects an actual paid three-force contribution before lead selection or reward', () => {
void test('Basic odd mixed Occupy admits the paid three-force contribution with provisional ceiling losses and floor survivors', () => {
const f = coalition({ advanced: false, skills: false, tech: true }, 'ixians', 3);
assert.equal(player(f.beforeBattle, f.owner).forces[f.location], 3);
reject(f.beforeBattle, f.choice.actor, f.choice.action);
assert.equal(f.beforeBattle.battle, null); inventory(f.beforeBattle);
for (const arrival of f.arrivals) {
const count = Number(arrival.step.action.amount), actor = arrival.step.actor;
assert.equal(player(arrival.after, actor).reserves, player(arrival.before, actor).reserves - count);
assert.equal(player(arrival.after, actor).forces[f.location], (player(arrival.before, actor).forces[f.location] ?? 0) + count);
assert.ok(player(arrival.after, actor).spice < player(arrival.before, actor).spice);
}
const admitted = step(f.beforeBattle, f.choice);
assert.ok(admitted.battle);
assert.equal(admitted.decision?.kind, 'ecazBattleLead');
inventory(admitted);
for (const lead of [f.owner, f.ally]) {
const ready = chooseLead({ ...f, game: admitted }, lead);
const profile = viewGame(ready, lead).battle!.ecazOccupy!.profile!;
assert.equal(profile.ecazForces.normal, 3);
assert.equal(profile.fixedEcazDial, Math.ceil(3 / 2));
assert.equal(profile.planOwner, lead);
assert.equal(profile.payer, lead);
assert.equal(profile.forceOwner, f.ally);
const revealed = reveal(ready, coalitionPlans(f, ready, lead)), q = quote(revealed), done = settle(revealed);
assert.equal(q.winner, lead);
assert.equal(done.lastBattleContext!.winner, lead);
assert.deepEqual(q.fixedLosses, [{ owner: f.owner, normal: Math.ceil(3 / 2), elite: 0 }]);
assert.equal(q.casualties!.owner, f.ally);
assert.deepEqual(q.casualties!.options, [{ normal: 4, elite: 0, paidNormal: 0, paidElite: 0 }]);
assert.ok(q.payments.every(payment => payment.ownPayment === 0));
assert.deepEqual(q.strongholdIncome, []);
for (const payment of q.payments) {
const collection = done.phase === 7 ? quoteSpiceCollection(done).receipts.find(r => r.player === payment.player)?.strongholds ?? 0 : 0;
assert.equal(player(done, payment.player).spice, player(revealed, payment.player).spice + collection);
}
assert.equal(player(done, f.owner).tanks - player(ready, f.owner).tanks, Math.ceil(3 / 2));
assert.equal(player(done, f.owner).forces[f.location], Math.floor(3 / 2));
assert.equal(player(done, f.ally).tanks - player(ready, f.ally).tanks, 4);
assert.equal(player(done, f.ally).forces[f.location] ?? 0, 0);
assert.equal(player(done, f.opponent).tanks - player(ready, f.opponent).tanks, 8);
const token = ownedTech(ready.techTokens, f.opponent)[0]; assert.ok(token);
assert.equal(done.techTokens![token].owner, lead);
for (const owner of [f.owner, f.ally])
for (const retained of ownedTech(ready.techTokens, owner))
assert.equal(done.techTokens![retained].owner, owner, 'Only the loser’s Tech transfers.');
inventory(done);
}
});
void test('E3/E2 classic-led coalition never borrows the hidden native Warmaster or Ecaz-held battle income', () => {
+16 -2
View File
@@ -367,6 +367,20 @@ for (const hostFaction of ['moritani', 'ecaz'] as const)
assert.equal(nexusPlayer(accepted, 'f').ally, 'a');
assert.equal(nexusPlayer(accepted, 'a').shipped, true);
assert.equal(accepted.pendingTerrorEntry ?? null, null);
assert.equal(accepted.pendingAmbassador ?? null, null);
nexusInventory(accepted);
// The Basic Ecaz Ambassador leaves an optional new-ally Duke loan open;
// declining it closes the entry with every other outcome unchanged.
if (hostFaction === 'ecaz') {
assert.equal(accepted.pendingAmbassador?.stage, 'loan');
const closed = applyAction(nexusReload(accepted), 'f', {
type: 'decision',
event: accepted.pendingAmbassador!.event,
loan: false,
});
assert.equal(closed.pendingAmbassador ?? null, null);
assert.equal(nexusPlayer(closed, 'f').ally, 'a');
nexusInventory(closed);
} else {
assert.equal(accepted.pendingAmbassador ?? null, null);
nexusInventory(accepted);
}
});
@@ -373,7 +373,7 @@ void test('paired ordinary Suk in retained Arrakeen uses original bank support w
assertBattleWallets(revealed, game); custody(game);
});
void test('paired retained Habbanya wins a tied ordinary battle only after actual reveal; mixed marker plans remain excluded', () => {
void test('paired retained Habbanya wins a tied ordinary battle only after actual reveal; an ordinary mixed marker plan is now admitted', () => {
const f = fixture();
let game = allow(applyAction(f.game, f.richese, richeseRequest(f)));
place(game, f.guild, f.location, 6, f.staging);
@@ -382,9 +382,13 @@ void test('paired retained Habbanya wins a tied ordinary battle only after actua
assert.equal(game.phase, 6);
assert.ok(game.active === f.richese || game.active === f.guild);
const beforeMixed = snapshot(game);
assert.throws(() => applyAction(game, game.active!, { type: 'chooseBattle', territory: 'habbanya_ridge_sietch',
target: game.active === f.richese ? f.guild : f.richese }), /Mixed ordinary-force and No-Field/);
assert.deepEqual(game, beforeMixed);
const mixed = applyAction(structuredClone(game), game.active!, { type: 'chooseBattle', territory: 'habbanya_ridge_sietch',
target: game.active === f.richese ? f.guild : f.richese });
assert.equal(mixed.battle?.territory, 'habbanya_ridge_sietch');
assert.equal(mixed.battle!.noFieldPlayers!.includes(f.richese), true);
assert.equal(mixed.battle!.mixedNoFieldPlayers?.includes(f.richese), true,
'an ordinary marker alongside physical forces uses the visible provisional mixed pool');
assert.deepEqual(game, beforeMixed, 'the trial declaration does not mutate the source game');
game = reveal(beforeMovementEnd, f.richese);
game = openBattle(game, f.richese, f.guild, { band: 'skilled' });
const r = player(game, f.richese), g = player(game, f.guild);
+46 -4
View File
@@ -412,7 +412,7 @@ void test('No-Skills original assassination pause retains the unfinished winner
inventory(later);
});
void test('real Basic odd Ecaz FORCE remains unresolved before coalition lead, any debit, casualties or rewards', () => {
void test('real Basic odd Ecaz FORCE admits provisional ceiling losses, floor survivors and actual coalition rewards', () => {
const f = programme({ advanced: false, skills: false, tech: true });
let g = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
const at = g.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed); assert.ok(at >= 0);
@@ -431,7 +431,49 @@ void test('real Basic odd Ecaz FORCE remains unresolved before coalition lead, a
}
assert.equal(player(g, f.ecaz).forces['sietch_tabr:14'], 3);
const choice = viewGame(g, g.active!).battleChoices.find(c => c.territory === 'sietch_tabr'); assert.ok(choice);
reject(g, choice.chooser, { type: 'chooseBattle', territory: 'sietch_tabr',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, /Odd-force Basic Occupy/);
assert.equal(g.battle, null); inventory(g);
const before = reload(g);
g = step(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'sietch_tabr',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } });
assert.ok(g.battle);
const d = g.decision; assert.ok(d?.kind === 'ecazBattleLead');
inventory(g);
for (const lead of [f.ecaz, ally]) {
const chosen = step(g, { actor: f.ecaz, action: { type: 'decision', event: d.event, lead } });
const ready = advance(chosen, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
undefined, quiet);
const profile = viewGame(ready, lead).battle!.ecazOccupy!.profile!;
assert.equal(profile.ecazForces.normal, 3);
assert.equal(profile.fixedEcazDial, Math.ceil(3 / 2));
assert.equal(profile.planOwner, lead);
assert.equal(profile.payer, lead);
assert.equal(profile.forceOwner, ally);
const own = player(ready, lead).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
const enemy = player(ready, opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
assert.ok(own && enemy);
const revealed = reveal(ready, [
{ actor: lead, action: { type: 'battlePlan', leader: own.id, dial: profile.fixedEcazDial + 2, support: 0 } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
]), q = quote(revealed), done = settle(revealed);
assert.equal(q.winner, lead);
assert.equal(done.lastBattleContext!.winner, lead);
assert.deepEqual(q.fixedLosses, [{ owner: f.ecaz, normal: Math.ceil(3 / 2), elite: 0 }]);
assert.equal(q.casualties!.owner, ally);
assert.deepEqual(q.casualties!.options, [{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 }]);
assert.ok(q.payments.every(payment => payment.ownPayment === 0));
assert.deepEqual(q.strongholdIncome, []);
for (const payment of q.payments)
assert.equal(player(done, payment.player).spice,
player(revealed, payment.player).spice + collectionIncome(done, payment.player));
assert.equal(player(done, f.ecaz).tanks - player(before, f.ecaz).tanks, Math.ceil(3 / 2));
assert.equal(player(done, f.ecaz).forces['sietch_tabr:14'], Math.floor(3 / 2));
assert.equal(player(done, ally).tanks - player(before, ally).tanks, 2);
assert.equal(player(done, ally).forces['sietch_tabr:14'] ?? 0, 0);
assert.equal(player(done, opponent).tanks - player(before, opponent).tanks, 2);
const token = ownedTech(before.techTokens, opponent)[0]; assert.ok(token);
assert.equal(done.techTokens![token].owner, lead);
for (const owner of [f.ecaz, ally])
for (const retained of ownedTech(before.techTokens, owner))
assert.equal(done.techTokens![retained].owner, owner, 'Only the loser’s Tech transfers.');
inventory(done);
}
});
+9 -6
View File
@@ -272,7 +272,7 @@ void test('Atreides inspection grants only its entitled view and survives JSON b
assert.equal(viewGame(g, 'e').richeseAuction!.cardId, null);
assert.equal(g.players[0].hand[0].id, card.id);
});
void test('unresolved cache-cancel and seller self-bid branches reject atomically and gates stay closed', () => {
void test('connected cache-cancel restores the ordinary pool while seller self-bid and lobby gates still reject atomically', () => {
const before = fixture();
const k = before.deck.find((c) => c.effect === 'karama')!;
before.players[2].hand = [k];
@@ -280,11 +280,14 @@ void test('unresolved cache-cancel and seller self-bid branches reject atomicall
let g = begin(before);
g = decision(g, { position: 'first' });
const snapshot = structuredClone(g);
assert.throws(
() => applyAction(g, 'e', { type: 'card', card: k.id, mode: 'cancel' }),
/ruling|interpretation/,
);
assert.deepEqual(g, snapshot);
// The 7 October exhausted-cache ruling makes this cancellation legal and
// restores one ordinary lot; it is no longer an unresolved branch.
const canceled = applyAction(structuredClone(g), 'e', { type: 'card', card: k.id, mode: 'cancel' });
assert.equal(canceled.richeseAuction, null);
assert.ok(canceled.auction && canceled.auction.cards.length > 0);
assert.equal(canceled.players[2].hand.some((c) => c.id === k.id), false);
assert.equal(canceled.discard.filter((c) => c.id === k.id).length, 1);
assert.deepEqual(g, snapshot, 'the source declaration is unchanged by the trial cancellation');
const bm = fixture(true);
bm.players[0].hand = [bm.deck.shift()!];
g = begin(bm);
+61 -6
View File
@@ -126,9 +126,10 @@ function ship(
) {
return applyAction(g, 'p', { type: 'ship', territory, sector, amount });
}
function reject(g: Game, id: string, action: Action) {
function reject(g: Game, id: string, action: Action, reason?: string) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
if (reason) assert.throws(() => applyAction(g, id, action), { message: reason });
else assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
@@ -245,7 +246,7 @@ void test('Yes cannot be evaded by moving first, ending movement or another Guil
assert.doesNotThrow(() => applyAction(done, 'p', { type: 'endMovement' }));
});
void test('storm, allied occupancy and two enemy factions deny impossible Yes without exposing a private witness', () => {
void test('allied occupancy permits either shipment answer while storm and two enemy factions still deny impossible Yes privately', () => {
for (const blocked of ['storm', 'ally', 'full']) {
const g = fixture();
if (blocked === 'storm') g.storm = 11;
@@ -261,8 +262,62 @@ void test('storm, allied occupancy and two enemy factions deny impossible Yes wi
}
}
const asked = ask(g);
assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['no']);
reject(asked, 'p', { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers,
blocked === 'ally' ? ['yes', 'no'] : ['no']);
if (blocked === 'ally') {
const promised = answer(reload(asked), 'yes');
reject(promised, 'p', { type: 'endMovement' },
'Honor your Truthtrance shipment answer before using this shipment opportunity.');
let arrived = ship(promised, 6);
assert.equal(arrived.shipmentPromises?.[0].fulfilled, true);
assert.equal(arrived.players[1].forces['carthag:11'], 6);
assert.equal(arrived.players[1].reserves, 14);
assert.equal(arrived.players[1].spice, 94);
assert.equal(arrived.players[1].shipped, true);
assert.equal(arrived.players[1].moved, 0);
assert.equal(arrived.players[1].tanks, 0);
assert.deepEqual(arrived.players[0].forces, g.players[0].forces);
assert.deepEqual(viewGame(arrived, 'p').basicAlliedShipment, {
territory: 'Carthag', normal: 6, elite: 0,
});
if (arrived.decision?.kind === 'advisor')
arrived = applyAction(arrived, arrived.decision.player, { type: 'decision', accept: false });
const done = applyAction(reload(arrived), 'p', { type: 'endMovement' });
assert.deepEqual(done.players[1].forces, {});
assert.equal(done.players[1].tanks, 6);
assert.equal(done.players[1].reserves, 14);
assert.equal(done.players[1].spice, 94);
assert.deepEqual(done.players[0].forces, g.players[0].forces);
assert.deepEqual(done.deck, arrived.deck);
assert.deepEqual(done.discard, arrived.discard);
assert.deepEqual(done.players.map((p) => p.hand), arrived.players.map((p) => p.hand));
for (const p of done.players)
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, x) => n + x, 0), 20);
const refused = answer(reload(asked), 'no');
reject(refused, 'p', {
type: 'ship', territory: 'carthag', sector: 11, amount: 6,
}, 'Honor your Truthtrance shipment answer before using this shipment opportunity.');
const fewer = ship(refused, 5);
assert.equal(fewer.players[1].forces['carthag:11'], 5);
assert.equal(fewer.shipmentPromises?.[0].fulfilled, true);
const skipped = applyAction(refused, 'p', { type: 'endMovement' });
assert.equal(skipped.shipmentPromises?.[0].fulfilled, true);
assert.equal(skipped.players[1].reserves, 20);
assert.equal(skipped.players[1].tanks, 0);
} else {
reject(asked, 'p', { type: 'truthAnswer', answer: 'yes' },
'Choose an answer consistent with your available shipment and earlier promises.');
const refused = answer(reload(asked), 'no');
const skipped = applyAction(refused, 'p', { type: 'endMovement' });
assert.equal(skipped.shipmentPromises?.[0].fulfilled, true);
reject(refused, 'p', {
type: 'ship', territory: 'carthag', sector: 11, amount: 6,
}, 'Honor your Truthtrance shipment answer before using this shipment opportunity.');
reject(g, 'p', {
type: 'ship', territory: 'carthag', sector: 11, amount: 6,
}, blocked === 'storm' ? 'That sector is in storm.' :
'A stronghold cannot contain three occupying factions.');
}
for (const id of ['a', 'o']) {
assert.equal(viewGame(asked, id).truthShipmentAnswers, null);
assert.equal(viewGame(asked, id).shipmentCompletion, null);
@@ -277,7 +332,7 @@ void test('storm, allied occupancy and two enemy factions deny impossible Yes wi
territory: 'wind_pass',
sector: 15,
amount: 1,
});
}, 'That sector is in storm.');
});
void test('Ghola, retained Karama and recoverable outgoing ally escrow combine into an executable completion', () => {