From 3edebecc8900a39f7dce83e5fb242c90c558005b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:57:50 +1100 Subject: [PATCH] Preserve Hajr after another seat accepts a paid deal --- docs/CURRENT_STATUS.md | 8 +++ docs/IMPLEMENTATION_STATUS.md | 9 ++++ docs/PUBLIC_DEALS.md | 7 +++ docs/RULE_DECISIONS.md | 10 ++++ game/engine.ts | 14 +++-- game/reference.ts | 3 +- tests/public-deals-hajr-runtime.test.ts | 69 +++++++++++++++++++++++++ 7 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 tests/public-deals-hajr-runtime.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 64f4749f..3d1ac0fc 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,14 @@ open mode gates. ## Current checkpoint and work +**Advanced Hajr after a deal payment — 28 October. Prototyped / Partial.** A +previously consented bribe triggered by another seat's final acceptance no +longer closes the payer's immediate Hajr window; the payer's own voluntary +bribe or final acceptance still closes it. Seven related unit files pass 46 +cases, including genuine Advanced movement and the actual Hajr play after the +payment; the payment's funding alone was staged. Broader timing combinations and +deployed acceptance remain open. [Scope](PUBLIC_DEALS.md). + **Immediate deal payments — 28 October. Prototyped / Partial.** A public deal can include a gross spice payment between signatories. Final unanimous acceptance invokes the native bribe action atomically, including its timing, funds, escrow diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 9ec96eab..038536b8 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,14 @@ # Dune implementation status +## 28 October 2026 — Advanced Hajr after deal-triggered payment + +The immediate bribe caused by another player's final acceptance preserves the +payer's current Hajr window, while the payer's own voluntary payment or final +acceptance still closes it. Seven related unit files pass 46 cases, including a +genuine Advanced movement/recovered Hajr fixture with only payment funding +staged. This is a provisional online timing interpretation; broader interactions +and deployed acceptance remain open. [Scope](PUBLIC_DEALS.md). + ## 28 October 2026 — Immediate public-deal payments Two to six human signatories can include an immediate gross spice bribe in a diff --git a/docs/PUBLIC_DEALS.md b/docs/PUBLIC_DEALS.md index b571a71c..71aeed44 100644 --- a/docs/PUBLIC_DEALS.md +++ b/docs/PUBLIC_DEALS.md @@ -32,6 +32,13 @@ reserved during an offer. If timing, available funds, another binding rule or the payer's AI control makes payment unavailable, final acceptance fails without recording that vote or debiting spice. A pending native Bureaucrat decision continues normally; later release of the deal does not reverse a paid bribe. +In Advanced play, the automatic payment triggered by another seat's final +acceptance does not close the payer's immediate-after-movement Hajr opportunity. +If the payer makes the final acceptance, or directly pays a bribe, that payer's +own voluntary action closes the opportunity. This is a provisional online +timing interpretation of the supplied revision, physical p. 39 (printed p. 37). +Seven related unit files pass 46 cases, including genuine Advanced movement and +Hajr play after another seat's deal acceptance; only payment funding was staged. Nine related unit files pass 71 cases, including a native Bureaucrat diversion and unavailable-payment rollback. Three isolated HTTP cases include an earlier consenting payer, final approval by another participant and replay rejection. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 3f7a8a55..edee87e2 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,15 @@ # Rule decision index +## Deal-triggered payment and Advanced Hajr — 28 October 2026 + +The supplied revised rulebook's Hajr errata, physical p. 39 (printed p. 37), +requires Hajr immediately after a Force Movement. The existing online timing +contract closes the window on the owner's next voluntary action. A payment +automatically triggered by another seat's final acceptance is provisionally +treated as that seat's action, so it does not close the payer's window. A +payment initiated by the payer still closes it. This is an online timing +interpretation, not a publisher clarification. [Scope](PUBLIC_DEALS.md). + ## Public deals — 28 October 2026 The publisher's 2019 rulebook permits public nonallied deals, requires their diff --git a/game/engine.ts b/game/engine.ts index 4883374e..92f76a70 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -40012,6 +40012,11 @@ function normalizeCardNames(g: Game) { ]); } export function applyAction(state: Game, id: string, action: Action): Game { + return applyActionWithInitiator(state, id, action, id); +} +/** The initiator can differ from the payer only for a previously consented + * automatic deal payment; it is never supplied by a client action. */ +function applyActionWithInitiator(state: Game, id: string, action: Action, initiator: string): Game { twoPlayerControllersIntegrity(state); factionDraftIntegrity(state); factionSwapIntegrity(state); @@ -40133,9 +40138,9 @@ export function applyAction(state: Game, id: string, action: Action): Game { // on the same cloned state so a failed payment also rolls back consent. try { requireRule(!getPlayer(g, next.payment.payer).autopilot, 'The payer is not controlling this seat.'); - return applyAction(g, next.payment.payer, { + return applyActionWithInitiator(g, next.payment.payer, { type: 'bribe', target: next.payment.recipient, amount: next.payment.amount, - }); + }, id); } catch (error) { if (error instanceof RuleError) throw new RuleError('The agreed payment could not be completed. Check the bribe timing and ask the payer to review it.'); @@ -40307,7 +40312,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { Object.keys(guide.action).every(key => action[key] === guide.action[key]), 'This Richese bid guide is stale or belongs to another lot.'); } - const g = applyActionInner(state, id, action); + const g = applyActionInner(state, id, action, 'live', initiator); if (state.phase === 3 && liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn).some(row => { const stage = row.windowCards?.karamaAuction; return stage && !stage.closed && (windowCardOpportunity(g, 'karamaAuction')?.source !== stage.source || @@ -40840,6 +40845,7 @@ function applyActionInner( id: string, action: Action, execution: 'live' | 'shipmentPreparation' | 'movementPreparation' = 'live', + initiator = id, ): Game { tupileSlotsIntegrity(state); const tupileBlock = tupileSourceBlock(state); @@ -40974,7 +40980,7 @@ function applyActionInner( } // Mandatory movement reactions and other seats' actions do not close the // Advanced owner's immediate Hajr opportunity. Its next voluntary action does. - if (g.advanced && p.hajrWindow && !g.response && !g.decision && !g.truthtrance && + if (g.advanced && initiator === p.id && p.hajrWindow && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening && !g.pendingTreacheryDiscard && t !== 'advanceBots' && !(t === 'card' && p.hand.some(card => card.id === action.card && card.effect === 'hajr'))) delete p.hajrWindow; diff --git a/game/reference.ts b/game/reference.ts index 0e68b720..b7a01346 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2890,6 +2890,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'Make an additional force move during your movement turn.', steps: [ 'In Basic, play during your shipment and movement turn. Advanced requires the immediate opportunity after a completed Force Movement and its required arrival decisions, before your next voluntary game action.', + 'A previously agreed payment triggered by another player accepting a deal does not close your Advanced Hajr opportunity. Your own voluntary bribe or acceptance that triggers your payment closes it.', 'Make one additional legal group move, using the same or another group. Normal source, range and destination restrictions still apply. A completed Ornithopter group provisionally supplies the Advanced timing opportunity.', 'Ornithopter replaces one unused movement entitlement, so one Hajr play and a two-group flight permit three group movements. Groups must differ within that flight, while the later Hajr move may reuse them.', 'The deck contains one physical Hajr. If Nullentropy Box genuinely retrieves it after its discard, you may legally play that same card again for another extra move. This replay is an explicit interpretation because the source establishes no once-per-turn cap. A repeated submission while it is still discarded grants nothing. Advanced still needs a fresh completed-movement opportunity after retrieval; the search supplies no movement.', @@ -2901,7 +2902,7 @@ export const RULE_TOPICS: RuleTopic[] = [ {area:'Player controls',status:'Partial',detail:'The existing card selector uses held-card/current timing authority. Persistent table text shows accepted Hajr plays and explains fresh Advanced timing after recovery.',evidence:['game/card-availability.ts','components/game-table.tsx']}, {area:'AI',status:'Partial',detail:'Four profiles retain a legal third ordinary move after real Basic/Advanced paid Box recovery. Strategic refinement and calibration remain deferred.',evidence:['tests/hajr-replay-engine.test.ts']}, {area:'Documentation',status:'Partial',detail:'Explains the single physical card, original paid retrieval, repeated legal grant, unchanged Basic timing and fresh Advanced opportunity. Replay remains an explicit source interpretation.',evidence:['docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md']}, - {area:'Verification',status:'Partial',detail:'Frozen12-file102/102, types/lint/build and two original-action/rendered-table programmes pass with eight legal policy proposals. Cunning counts have pure/related regressions, not an original paid-Box Nexus programme. No visual/browser/deployed, complete-mode or comprehensive assurance claim.',evidence:['tests/fixture-hajr-replay.ts','tests/hajr-replay-engine.test.ts','tests/nexus-guild-cunning-counted.test.ts','tests/nexus-guild-cunning-engine.test.ts']}, + {area:'Verification',status:'Partial',detail:'Native Hajr replay and deal-triggered payment have focused coverage. Cunning counts have pure/related regressions, not an original paid-Box Nexus programme. No visual/browser/deployed, complete-mode or comprehensive assurance claim.',evidence:['tests/fixture-hajr-replay.ts','tests/hajr-replay-engine.test.ts','tests/public-deals-hajr-runtime.test.ts','tests/nexus-guild-cunning-counted.test.ts','tests/nexus-guild-cunning-engine.test.ts']}, ], }, { diff --git a/tests/public-deals-hajr-runtime.test.ts b/tests/public-deals-hajr-runtime.test.ts new file mode 100644 index 00000000..69481205 --- /dev/null +++ b/tests/public-deals-hajr-runtime.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { + completeHajrReplay, createHajrReplayFixture, hajrReplayPlayer, +} from './fixture-hajr-replay'; + +/** This reaches the native Advanced immediate-after-movement Hajr window via + * setup, auction, shipment, actual Hajr play, Box recovery and movement. The + * genuine fixture has spent its spice on those actions; only the payer's spice + * is explicitly staged so this independent deal payment can be exercised. */ +function windowGame() { + const fixture = createHajrReplayFixture({ advanced: true }); + const game = completeHajrReplay(fixture).beforeSecondHajr; + const payer = game.players.find(player => player.id === fixture.owner)!; + const recipient = game.players.find(player => player.id !== payer.id && !player.bot && + player.id !== payer.ally && player.ally !== payer.id); + assert.ok(recipient); + assert.equal(payer.spice, 0, 'The original purchases, shipment and Box consumed the fixture spice.'); + payer.spice = 4; // Controlled funding only; the native movement/window is not staged. + assert.ok(payer.hajrWindow); + assert.equal(available(game, payer.id), true); + return { fixture, game, payer: payer.id, recipient: recipient.id }; +} + +function available(game: Game, id: string) { + return viewGame(game, id).players.find(player => player.id === id)!.hajrAvailable; +} + +function offer(game: Game, from: string, to: string, payer: string, recipient: string): Game { + return applyAction(game, from, { + type: 'dealOffer', target: to, terms: 'An immediate one-spice bribe.', + payment: { kind: 'immediateBribe', payer, recipient, amount: 1 }, + }); +} + +void test('another seat accepting a prior-consented payment preserves the payer’s native Hajr opportunity', () => { + const { fixture, game: initial, payer, recipient } = windowGame(); + const window = structuredClone(hajrReplayPlayer(initial).hajrWindow); + const beforeSpice = hajrReplayPlayer(initial).spice; + const beforeBribes = initial.players.find(player => player.id === recipient)!.bribes; + const offered = offer(initial, payer, recipient, payer, recipient); + assert.deepEqual(hajrReplayPlayer(offered).hajrWindow, window); + const deal = offered.deals!.at(-1)!; + const paid = applyAction(offered, recipient, { type: 'dealRespond', deal: deal.id, accept: true }); + assert.equal(paid.deals!.at(-1)!.status, 'active'); + assert.equal(hajrReplayPlayer(paid).spice, beforeSpice - 1); + assert.equal(paid.players.find(player => player.id === recipient)!.bribes, beforeBribes + 1); + assert.deepEqual(hajrReplayPlayer(paid).hajrWindow, window); + assert.equal(available(paid, payer), true); + const played = applyAction(paid, payer, { type: 'card', card: fixture.hajr }); + assert.equal(hajrReplayPlayer(played).hajrPlays, 2); + assert.equal(hajrReplayPlayer(played).spice, beforeSpice - 1); + assert.equal(played.players.find(player => player.id === recipient)!.bribes, beforeBribes + 1); +}); + +void test('the payer’s own voluntary bribe or final acceptance closes the immediate Hajr window', () => { + const { game: initial, payer, recipient } = windowGame(); + const direct = applyAction(initial, payer, { type: 'bribe', target: recipient, amount: 1 }); + assert.equal(hajrReplayPlayer(direct).hajrWindow, undefined); + assert.equal(available(direct, payer), false); + + const offered = offer(initial, recipient, payer, payer, recipient); + assert.equal(available(offered, payer), true); + const accepted = applyAction(offered, payer, { type: 'dealRespond', deal: offered.deals!.at(-1)!.id, accept: true }); + assert.equal(hajrReplayPlayer(accepted).hajrWindow, undefined); + assert.equal(available(accepted, payer), false); + assert.equal(hajrReplayPlayer(accepted).spice, 3); +});