Preserve Hajr after another seat accepts a paid deal

This commit is contained in:
Quintine committed 2026-10-28 00:57:50 +11:00
1 parent 6da6ff7f83
commit 3edebecc89
7 files changed
+115 -5

No files matched your search

+8
View File
@@ -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
+9
View File
@@ -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
+7
View File
@@ -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.
+10
View File
@@ -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
+10 -4
View File
@@ -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;
+2 -1
View File
@@ -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']},
],
},
{
+69
View File
@@ -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);
});