diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 047048b0..dfd56c82 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,18 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Kull and future Karama payment forecasts — 27 October. Prototyped / Partial.** +The shared early Revival/Shipment preparation now checks a prospective Karama +payment against the original live Bidding source, including its Kull phase +restriction and the native spending guard. A detached later-phase +forecast cannot offer a blocked card as an auction payment. This is a bounded +forecast correction, not a new Kull/Revival admission or complete rules mode. +Existing early shipment promises also survive the Ix Movement opening while +retaining voluntary-action feasibility and opposing-effect release. +The frozen checkpoint passed 90 focused tests across 12 files, type/lint and +the production build; broader rules and deployed acceptance remain open. +[Scope and source](TRUTHTRANCE_SHIPMENT_PROMISES.md#source-bound-karama-payment-forecast--27-october-2026). + **Basic Kull and Truthtrance bribes — 27 October. Prototyped / Partial.** The printed Kull preview admits structured ordinary, grouped and mixed bribe questions. A pending Kull response suppresses bribe controls and quotes any diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 88ea36ae..ec3abf86 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,23 @@ # Dune implementation status +## 27 October 2026 — Kull and future Karama payment forecasts + +The shared early Revival/Shipment preparation now tests an owned physical +Karama against the actual current Bidding source and the existing +`auction-free-purchase` spending guard before including it as a future invoice +payment. A provisional later-phase witness must not borrow a card blocked by +Kull for the real phase or blocked by that guard. +Existing native cash alternatives remain. This corrects a forecast; it neither +admits Revival questions to the Kull profile nor certifies wider interactions, +full modes, browser or deployed acceptance. +Early shipment promises also remain live during the Ix Movement opening, with +post-action feasibility checked against the upcoming initialized turn. +Frozen changed-path verification: 90/90 tests across 12 files, type/lint and +production build passed. The native Kull auction regression uses an actual +funded bid; the opening tests include a real preceding-turn shipment and +controlled Amal card transfers. +[Contract](TRUTHTRANCE_SHIPMENT_PROMISES.md#source-bound-karama-payment-forecast--27-october-2026). + ## 27 October 2026 — Basic Kull and Truthtrance bribes Admit ordinary, grouped and mixed structured bribe-payment questions in the diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index c0131dbb..c94900f0 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,14 @@ # Rule decision index +## Source-bound Karama payment forecast — 27 October 2026 + +For the provisional future-action completion forecast, assess an owned Karama +as an auction payment at the original live Bidding source. Apply the existing +phase restriction and native spending guard there rather than checking a +synthetic later-phase state. This does not alter Kull's selected interruption +policy or grant Kull/Revival admission. Broader interactions remain open. +[Contract](TRUTHTRANCE_SHIPMENT_PROMISES.md#source-bound-karama-payment-forecast--27-october-2026). + ## Basic Kull and Truthtrance bribes — 27 October 2026 Provisionally combine the printed Basic Kull preview with existing ordinary, diff --git a/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md b/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md index 834d4848..36870c20 100644 --- a/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md +++ b/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md @@ -6,6 +6,34 @@ The former Advanced Guild exclusion is superseded by the [18 October native Guild composition](#advanced-guild-interception-and-rates--18-october-2026). The [24 October waiting-turn follow-up](#same-phase-waiting-respondents--24-october-2026) supersedes the active-only timing boundary. The [earlier Basic phase follow-up](#earlier-basic-phases--24-october-2026) extends current-turn timing before Movement; the [26 October Advanced extension](#earlier-classic-advanced-phases--26-october-2026) adds bare classic Advanced while retaining earlier module fences. The [native-faction follow-up](#native-factions-and-owned-choam-opening--26-october-2026) adds native early scope and owned CHOAM income. Earlier scope descriptions and verification totals remain historical. +## Source-bound Karama payment forecast — 27 October 2026 + +**Prototyped / Partial.** When the shared early Revival/Shipment preparation +considers paying an already won ordinary auction with a held Karama, it now +checks the original current Bidding source for the `auction-free-purchase` +spending restriction. A Kull phase ban or another native spending restriction +can make that Karama unavailable even if a detached Charity or Revival seed is +in a different phase. The forecast retains the actual cash-payment alternative +and does not discard, pay, or settle the real auction itself. + +An existing early shipment promise also remains valid through the Ix Movement +opening, before that phase initializes its movement queue and resets prior-turn +flags. A detached upcoming turn checks the owner's post-action resources, so +playing Amal cannot voluntarily make a positive shipment promise impossible; +an opposing Amal may instead cause release after its effect settles. The +opening still does not allow a shipment to be performed early. +The future-shipment scope test also reflects the previously admitted native +Richese respondent; its former Semuta exclusion assertion was stale. +Frozen changed-path proof passed 90/90 tests in 12 files, type/lint and the +production build. The [Kull regression](../tests/kull-truth-karama-forecast.test.ts) +uses a real funded auction, and the [opening regressions](../tests/truth-shipment-movement-opening.test.ts) +include a real prior-turn shipment and explicitly controlled Amal transfers. + +This is a conservative correction to the existing provisional owned-stage +witness, not a publisher clarification or a new action. It does not enable +Revival promises in the Kull profile; broader preparation paths, interactions, +complete modes, browser and deployed acceptance remain open. + ## Native factions and owned CHOAM opening — 26 October 2026 **Prototyped / Partial.** Both shipment and diff --git a/game/engine.ts b/game/engine.ts index 0aaf142b..6d968b2e 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -30097,6 +30097,13 @@ function findReachableBattlePlan( } return null; } +/** The Ix opening precedes the native Movement initialization, including its + * reset of last turn's shipped flag and creation of the movement queue. */ +function pendingShipmentMovementOpening(g: Game): boolean { + return g.status === 'playing' && g.phase === 5 && g.phaseOpening?.initialize === true && + g.active === null && (g.movementRemaining == null || + Array.isArray(g.movementRemaining) && g.movementRemaining.length === 0); +} function shipmentPromiseIntegrity(g: Game) { try { validateSavedTruthtrance(g); @@ -30141,7 +30148,8 @@ function shipmentPromiseIntegrity(g: Game) { if (promise.turn === g.turn && !promise.released && !promise.fulfilled) requireRule( shipmentPromiseModeSupported(g, promise.player) && - shipmentPromiseTurnAvailable(g, promise.player, shipmentAvailable(g,getPlayer(g,promise.player))), + (pendingShipmentMovementOpening(g) || + shipmentPromiseTurnAvailable(g, promise.player, shipmentAvailable(g,getPlayer(g,promise.player)))), 'The saved shipment promise does not belong to a supported unused shipment opportunity this turn.', ); } @@ -30187,8 +30195,14 @@ function earlyRevivalCompletionSeeds(initial: Game, source: Game, owner: string) native: source.richeseAuction, funding: source.richeseFunding, pledge: source.aid[owner]?.amount ?? 0 }); const cash = funding.normalCash; + // The payment belongs to the real Bidding lot, not the detached + // Charity/Revival seed. Apply the live Kull restriction and native + // spending guard before offering this card as a future payment. + const originalOwner = getPlayer(source, owner); const alternatives: (string | null)[] = [null, ...(ownBid ? p.hand - .filter(card => canUseAsKaramaRole(seed.game, p, card)).map(card => card.id) : [])]; + .filter(card => canUseAsKaramaRole(seed.game, p, card) && + !karamaSpendingBlock(source, originalOwner, card, 'auction-free-purchase')) + .map(card => card.id) : [])]; for (const card of alternatives) { const g = structuredClone(seed.game), beneficiary = getPlayer(g, owner); if (beneficiary.spice < funding.nativeCash + (card ? 0 : cash)) continue; @@ -30295,6 +30309,17 @@ function findShipmentCompletion( state.turn, ), ): ShipmentCompletion | null { + if (pendingShipmentMovementOpening(state)) { + // Quote the upcoming owned turn after its normal reset. This is detached: + // the live opening and its opponents' remaining choices are untouched. + const upcoming = structuredClone(state); + const player = getPlayer(upcoming, owner.id); + resetPlayerMovement(player); + upcoming.phaseOpening = null; + upcoming.active = owner.id; + upcoming.movementRemaining = [owner.id]; + return findShipmentCompletion(upcoming, player, promises); + } if (liveTravelPromises(state.travelPromises ?? [], owner.id, state.turn).length) return travelCompletion(state, owner.id, undefined, undefined, promises); if ( diff --git a/game/reference.ts b/game/reference.ts index cbf16266..b899063a 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3787,7 +3787,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Bare registered native Basic/Advanced phases0–4 with selected faction decks use explicit provisional current-board/owned-stage feasibility, auction liabilities, distinct card costs, expiring aid and readiness reopening. Wealthy Advanced BG Charity and owned CHOAM opening income retain their native counters; the detached witness uses their allowed fixed payment. A current-turn CHOAM receipt prevents repeated credit. Native Richese guidance passes or submits Silent zero with its exact event. Typed revival keeps the current one-elite limit. Voluntary bids/payments/closure preserve a route; foreign invoices await their actual outcome. Existing phase5 waiting/native Guild interception, permission and rates remain. Earlier modules and wider future actions remain unfinished.', + 'Bare registered native Basic/Advanced phases0–4 with selected faction decks use explicit provisional current-board/owned-stage feasibility, auction liabilities, distinct card costs, expiring aid and readiness reopening. A prospective Karama auction payment must pass the native spending guard at the live Bidding source, including any Kull phase restriction. Wealthy Advanced BG Charity and owned CHOAM opening income retain their native counters; the detached witness uses their allowed fixed payment. A current-turn CHOAM receipt prevents repeated credit. Native Richese guidance passes or submits Silent zero with its exact event. Typed revival keeps the current one-elite limit. Voluntary bids/payments/closure preserve a route; foreign invoices await their actual outcome. Existing phase5 waiting/native Guild interception, permission and rates remain. Earlier modules and wider future actions remain unfinished.', }, { area: 'Player controls', diff --git a/tests/kull-truth-karama-forecast.test.ts b/tests/kull-truth-karama-forecast.test.ts new file mode 100644 index 00000000..a3d3b15c --- /dev/null +++ b/tests/kull-truth-karama-forecast.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { act, advance, clean, createKullBribeFixture, inventory, player } from './fixture-kull-bribe'; + +void test('a Basic Kull phase ban cannot fund a later shipment with the blocked auction Karama', () => { + // The shared fixture selects the original unopened deal and Spice position; + // it uses public Basic opt-in and real setup. No wallet or force is staged. + const f = createKullBribeFixture(); + let game = structuredClone(f.charity); + game = advance(game, g => g.phase === 3 && clean(g) && g.auction?.active === f.actor); + assert.equal(player(game, f.actor).spice, 10); + const lot = game.auction!.cards[game.auction!.index].id; + + // The holder actually attempts an ordinary free purchase. CHOAM interrupts + // with its original Kull; everyone with a counter opportunity passes. + game = act(game, f.actor, { type: 'card', mode: 'purchase', card: f.karama }); + const reaction = viewGame(game, f.reactor).kullReaction; + assert.ok(reaction && reaction.plays.some(play => play.card.id === f.kull && !play.blocked)); + game = act(game, f.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull }); + game = advance(game, clean); + assert.equal(game.phase, 3); + assert.ok(game.kullRestrictions?.some(row => row.player === f.actor && row.turn === game.turn && row.phase === 3)); + assert.ok(player(game, f.actor).hand.some(card => card.id === f.karama)); + assert.ok(viewGame(game, f.actor).karamaBlocked); + const blockedPurchase = structuredClone(game); + assert.throws(() => act(game, f.actor, { type: 'card', mode: 'purchase', card: f.karama }), /Kull Wahad/); + assert.deepEqual(game, blockedPurchase); + + // Seven of ten spice is an actually funded bid; the real invoice leaves + // three. Eight to Carthag costs eight normally or four at the Karama rate. + // The one retained Karama cannot both evade this Bidding invoice (Kull) and + // legitimately fund that shipment after cash payment. + assert.equal(game.auction?.active, f.actor); + game = act(game, f.actor, { type: 'bid', amount: 7, allyPayment: 0 }); + assert.equal(game.auction?.bidder, f.actor); + assert.equal(game.auction?.bid, 7); + assert.equal(player(game, f.actor).spice, 10); + + game = act(game, f.asker, { type: 'card', card: f.truth }); + while (game.truthtrance?.stage === 'priority') { + const passer = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); + assert.ok(passer); + game = act(game, passer.id, { type: 'truthPass' }); + } + assert.equal(game.truthtrance?.stage, 'ask'); + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'shipment', target: f.actor, territory: 'carthag', minimum: 8, + } }); + const respondent = viewGame(game, f.actor); + assert.deepEqual(respondent.truthShipmentAnswers, ['no']); + assert.ok(respondent.shipmentPromisePolicy == null); + assert.equal(respondent.shipmentPreparation, null); + const beforeRejected = structuredClone(game); + assert.throws(() => act(game, f.actor, { type: 'truthAnswer', answer: 'yes' })); + assert.deepEqual(game, beforeRejected); + // The other seat's private counter remains absent from an observer's view. + assert.ok(!JSON.stringify(viewGame(game, f.asker)).includes(f.counter)); + game = act(game, f.actor, { type: 'truthAnswer', answer: 'no' }); + + // Complete the actual auction; the cash invoice settles automatically when + // the blocked Karama leaves no legal alternative payment choice. + game = advance(game, g => player(g, f.actor).hand.some(card => card.id === lot)); + assert.equal(player(game, f.actor).spice, 3); + assert.ok(player(game, f.actor).hand.some(card => card.id === lot)); + assert.ok(player(game, f.actor).hand.some(card => card.id === f.karama)); + assert.deepEqual(inventory(game), f.originalCards); +}); diff --git a/tests/truth-shipment-movement-opening.test.ts b/tests/truth-shipment-movement-opening.test.ts new file mode 100644 index 00000000..302e2c74 --- /dev/null +++ b/tests/truth-shipment-movement-opening.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { act, advance, clean, createKullBribeFixture, player } from './fixture-kull-bribe'; + +function shipmentOpening(answer: 'yes' | 'no', amalHolder?: 'emperor' | 'choam') { + const f = createKullBribeFixture(); + let game = structuredClone(f.charity); + // Controlled physical-card supplements: return Emperor’s original Karama + // to the unopened deck and move an original Amal to the named hand. No + // force, spice, phase or promise is staged. + let amal: string | undefined; + if (amalHolder) { + const own = player(game, f.actor); + const karamaIndex = own.hand.findIndex(card => card.id === f.karama); + assert.ok(karamaIndex >= 0); + game.deck.push(own.hand.splice(karamaIndex, 1)[0]); + const index = game.deck.findIndex(card => card.effect === 'amal'); + assert.ok(index >= 0); + const card = game.deck.splice(index, 1)[0]; + player(game, amalHolder).hand.push(card); + amal = card.id; + } + game = act(game, f.asker, { type: 'card', card: f.truth }); + while (game.truthtrance?.stage === 'priority') { + const passer = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); + assert.ok(passer); + game = act(game, passer.id, { type: 'truthPass' }); + } + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'shipment', target: f.actor, territory: 'carthag', minimum: 8, + } }); + assert.ok(viewGame(game, f.actor).truthShipmentAnswers?.includes(answer)); + game = act(game, f.actor, { type: 'truthAnswer', answer }); + game = advance(game, g => g.phase === 5 && g.phaseOpening?.initialize === true); + assert.equal(game.active, null); + assert.equal(game.movementRemaining, null); + return { f, game, amal }; +} + +function completeOpening(game: Game) { + while (game.phaseOpening) { + const passer = game.players.find(p => !game.phaseOpening!.passed.includes(p.id)); + assert.ok(passer); + game = act(game, passer.id, { type: 'ready' }); + } + return game; +} + +void test('an early shipment No survives each real Ix Movement opening pass', () => { + const { f, game: opening } = shipmentOpening('no'); + const game = completeOpening(opening); + assert.equal(player(game, f.actor).shipped, false); + assert.equal(game.phase, 5); + assert.ok(game.movementRemaining?.includes(f.actor)); + assert.equal(game.shipmentPromises?.[0].answer, false); + assert.equal(game.shipmentPromises?.[0].released, undefined); +}); + +void test('an owner cannot play Amal to defeat a positive shipment promise at the Movement opening', () => { + const { f, game, amal } = shipmentOpening('yes', 'emperor'); + assert.ok(amal); + assert.equal(player(game, f.actor).spice, 10); + const before = structuredClone(game); + assert.throws(() => act(game, f.actor, { type: 'card', card: amal }), /Truthtrance shipment/); + assert.deepEqual(game, before); +}); + +void test('another player’s Amal releases an impossible shipment only after the opening settles', () => { + const { f, game: opening, amal } = shipmentOpening('yes', 'choam'); + assert.ok(amal); + let game = act(opening, f.reactor, { type: 'card', card: amal }); + assert.equal(player(game, f.actor).spice, 5); + assert.equal(game.shipmentPromises?.[0].released, undefined); + game = completeOpening(game); + game = advance(game, clean); + assert.equal(game.shipmentPromises?.[0].released, true); +}); + +void test('a second-turn shipment promise survives the opening after a real previous shipment', () => { + const f = createKullBribeFixture(); + let game = structuredClone(f.game); + assert.equal(game.turn, 1); + assert.equal(game.phase, 5); + assert.equal(game.active, f.actor); + game = act(game, f.actor, { type: 'ship', territory: 'arrakeen', sector: 10, + amount: 1, elite: 0, allyPayment: 0 }); + game = advance(game, clean); + assert.equal(player(game, f.actor).shipped, true); + game = act(game, f.actor, { type: 'endMovement' }); + game = advance(game, g => g.turn === 2 && g.phase === 2 && clean(g)); + assert.deepEqual(game.movementRemaining, []); + assert.equal(player(game, f.actor).shipped, true); + game = act(game, f.asker, { type: 'card', card: f.truth }); + while (game.truthtrance?.stage === 'priority') { + const passer = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); + assert.ok(passer); + game = act(game, passer.id, { type: 'truthPass' }); + } + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'shipment', target: f.actor, territory: 'carthag', minimum: 8, + } }); + assert.ok(viewGame(game, f.actor).truthShipmentAnswers?.includes('no')); + game = act(game, f.actor, { type: 'truthAnswer', answer: 'no' }); + game = advance(game, g => g.turn === 2 && g.phase === 5 && g.phaseOpening?.initialize === true); + assert.deepEqual(game.movementRemaining, []); + assert.equal(player(game, f.actor).shipped, true); + game = completeOpening(game); + assert.equal(player(game, f.actor).shipped, false); + assert.ok(game.movementRemaining?.includes(f.actor)); + assert.ok(game.shipmentPromises?.some(p => p.turn === 2 && p.answer === false && !p.released)); +}); diff --git a/tests/truthtrance-future-shipment.test.ts b/tests/truthtrance-future-shipment.test.ts index b95dc58f..f59799d5 100644 --- a/tests/truthtrance-future-shipment.test.ts +++ b/tests/truthtrance-future-shipment.test.ts @@ -66,7 +66,7 @@ function reject(g: Game, actor: string, action: Action) { const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before); } -void test('current-turn target timing distinguishes earlier phases from unused movement turns and evaluates the actual Semuta respondent', () => { +void test('current-turn target timing distinguishes earlier phases from unused movement turns and admits native Richese', () => { const g = fixture(); assert.equal(shipmentPromiseTurnAvailable(g, 'a', true), true); assert.equal(shipmentPromiseTurnAvailable(g, 'p', true), true); @@ -82,7 +82,7 @@ void test('current-turn target timing distinguishes earlier phases from unused m const mode = { ...g, advanced: true, active: 'r', expansions: ['choam'], semutaPreview: true, players: [{ id: 'r', faction: 'richese' }, { id: 'p', faction: 'emperor' }] }; assert.equal(shipmentPromiseModeSupported(mode, 'p'), true); - assert.equal(shipmentPromiseModeSupported(mode, 'r'), false); + assert.equal(shipmentPromiseModeSupported(mode, 'r'), true); }); void test('a waiting promise supplies no early executable action, survives the intervening turn and fulfills only actual later delivery', () => {