diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 34cd8218..fd43dce5 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index a680d6e0..402deb63 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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 | diff --git a/game/bots.ts b/game/bots.ts index 403174d2..80c08adb 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -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]; diff --git a/tests/bg-karama-cancel-recovery.test.ts b/tests/bg-karama-cancel-recovery.test.ts index d6008fb3..6555f199 100644 --- a/tests/bg-karama-cancel-recovery.test.ts +++ b/tests/bg-karama-cancel-recovery.test.ts @@ -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(); } diff --git a/tests/e3-native-tech-runtime.test.ts b/tests/e3-native-tech-runtime.test.ts index 9a1da143..3b138477 100644 --- a/tests/e3-native-tech-runtime.test.ts +++ b/tests/e3-native-tech-runtime.test.ts @@ -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]) diff --git a/tests/e3-nexus-ecaz-runtime.test.ts b/tests/e3-nexus-ecaz-runtime.test.ts index e8d17186..7da77da5 100644 --- a/tests/e3-nexus-ecaz-runtime.test.ts +++ b/tests/e3-nexus-ecaz-runtime.test.ts @@ -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', () => { diff --git a/tests/ecaz-alliance-recovery.test.ts b/tests/ecaz-alliance-recovery.test.ts index 66673569..47fb2c4c 100644 --- a/tests/ecaz-alliance-recovery.test.ts +++ b/tests/ecaz-alliance-recovery.test.ts @@ -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(); diff --git a/tests/ecaz-occupy-movement.test.ts b/tests/ecaz-occupy-movement.test.ts index 6efbedde..c62593a1 100644 --- a/tests/ecaz-occupy-movement.test.ts +++ b/tests/ecaz-occupy-movement.test.ts @@ -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(); diff --git a/tests/fremen-ecaz-victory-engine.test.ts b/tests/fremen-ecaz-victory-engine.test.ts index 07890d70..bb004fd0 100644 --- a/tests/fremen-ecaz-victory-engine.test.ts +++ b/tests/fremen-ecaz-victory-engine.test.ts @@ -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/); + } }); diff --git a/tests/mixed-e3-ecaz-nexus-runtime.test.ts b/tests/mixed-e3-ecaz-nexus-runtime.test.ts index adbb6a2d..deb4c9e4 100644 --- a/tests/mixed-e3-ecaz-nexus-runtime.test.ts +++ b/tests/mixed-e3-ecaz-nexus-runtime.test.ts @@ -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', () => { diff --git a/tests/nexus-card-engine.test.ts b/tests/nexus-card-engine.test.ts index 9ce2e9dc..87d66851 100644 --- a/tests/nexus-card-engine.test.ts +++ b/tests/nexus-card-engine.test.ts @@ -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); + } }); diff --git a/tests/paired-choam-nexus-skills-modules-runtime.test.ts b/tests/paired-choam-nexus-skills-modules-runtime.test.ts index e4fded3a..c7508081 100644 --- a/tests/paired-choam-nexus-skills-modules-runtime.test.ts +++ b/tests/paired-choam-nexus-skills-modules-runtime.test.ts @@ -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); diff --git a/tests/paired-e3-nexus-modules-runtime.test.ts b/tests/paired-e3-nexus-modules-runtime.test.ts index 8ce3852f..052a8e4d 100644 --- a/tests/paired-e3-nexus-modules-runtime.test.ts +++ b/tests/paired-e3-nexus-modules-runtime.test.ts @@ -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); + } }); diff --git a/tests/richese-engine.test.ts b/tests/richese-engine.test.ts index 787ac4e4..109344e3 100644 --- a/tests/richese-engine.test.ts +++ b/tests/richese-engine.test.ts @@ -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); diff --git a/tests/shipment-promises.test.ts b/tests/shipment-promises.test.ts index 03b7534a..7d10b41e 100644 --- a/tests/shipment-promises.test.ts +++ b/tests/shipment-promises.test.ts @@ -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', () => {