Connect Tleilaxu revival payments to Bureaucrat

This commit is contained in:
Quintine committed 2026-09-28 18:57:14 +10:00
1 parent 3b118dd845
commit e6bb9809f7
10 files changed
+252 -18

No files matched your search

+5
View File
@@ -39,6 +39,11 @@ export function BureaucratPayment({
It cannot be spent before then.
</p>
)}
{pending.kind === 'revival' && (
<p className="text-base leading-7">
This is the actual revival price. A separate free-revival award, if any, stays with Tleilaxu.
</p>
)}
<p className="fine">
Allowing the full payment preserves Bureaucrat for later use this phase.
</p>
+20 -6
View File
@@ -33,11 +33,12 @@ A verbal promise alone transfers no spice.
## Connected boundary and explicit interpretations
The target paths are ordinary paid auctions, Guild shipment income, direct
Richese auction proceeds and actual bribe transfers in the existing base or
CHOAM-only skill profile. Other optional modules retain their existing gates.
Tleilaxu revival, other expansion payment sources and direct gifts remain outside
this connected batch. Their absence does not establish a rule prohibition.
The connected paths are ordinary paid auctions, Guild shipment income, direct
Richese auction proceeds and actual bribe transfers in the base or CHOAM-only
skill profile. Basic Tleilaxu with Leader Skills and base opponents additionally
supports a single payer's actual paid revival price to Tleilaxu. Other expansion
payment sources and direct gifts remain outside this batch; omission is not a
printed prohibition.
### 21 September gift classification review
@@ -64,7 +65,20 @@ composition inference. The rulebook's skill-before-faction sentence occurs in
its battle paragraph and does not expressly order this economic reaction.
The implementation does not settle Bureaucrat-before-Karama consumption.
Qualifying single-payer payments have a public amount. Qualifying allied funding,
In the Basic Tleilaxu skill profile, a paid revival first commits its force or
leader custody and deducts the actual price. Its native income response still
allows Karama cancellation. Only an allowed, single-payer payment of at least
five spice then offers Bureaucrat before Tleilaxu receives that price. A separate
one-spice free-revival award is not a payment and cannot make a smaller price
qualify; if both occur, the award stays with Tleilaxu after any redirection.
The signed response and saved Bureaucrat choice retain the original payer and
price through JSON/SQLite continuation, while unrelated observers do not see
the private negotiated amount. The trainer's declined opportunity remains
available for a later qualifying payment in that phase.
Existing auction, shipment and bribe paths have public payment amounts; the
negotiated revival price remains limited to the participating seats and trainer.
Qualifying allied funding,
including an aggregate such as three plus three and a payment funded entirely by
the ally, is guarded before costs or commitment. It needs separate payment-unit
and privacy work, as does split-recipient Homeworld income. Independent Karama
+9 -1
View File
@@ -1,6 +1,6 @@
# Current development status
Updated 27 September 2026. **The full twelve-faction goal is unfinished. Basic
Updated 28 September 2026. **The full twelve-faction goal is unfinished. Basic
is playable and the six classic factions have a user-authorized Advanced preview.
Expansion starts and full rules/publication acceptance remain gated.**
@@ -83,6 +83,14 @@ attack under the user's interpretation, not a GF9 clarification. Combined
modes and full Richese starts remain gated. Neither new path is in the
verified live `c9a3625` image.
The bounded [Bureaucrat/Tleilaxu revival payment](BUREAUCRAT_PAYMENTS.md)
path adds the Basic Tleilaxu/Leader Skills profile's actual single-payer
revival prices to the skill's post-Karama redirection. Paid custody, separate
free-revival income, private negotiated amounts, owner controls, four legal
AI policies and concurrent SQLite continuation are connected. Other expansion
payment families, gifts and combined modules remain gated; this is not a
complete Leader Skills or Tleilaxu certification.
The main page
labels its build's Git commit in the lobby and active-table masthead; local
Git HEAD does not represent uncommitted work. The main-branch container
+31
View File
@@ -1,5 +1,36 @@
# Dune implementation status
## 28 September 2026 — Basic Tleilaxu revival/Bureaucrat composition
A genuine Basic Tleilaxu/Leader Skills game now offers a living native
Bureaucrat's once-per-phase redirection on another player's actual paid revival
price of at least five spice. The payer's cost and revived physical leader or
forces are committed once; native Tleilaxu income retains its existing Karama
window, and a cancellation does not offer a player-directed payment. A separate
one-spice free-revival award cannot lift a small price over the threshold and
is not diverted. The decision binds the payer, trainer, payee, amount, turn and
phase to the saved response; the declined opportunity remains available.
Negotiated price is available to its participants and trainer but omitted
from unrelated observers' pending view and the public redirection log.
The owner panel shows the original price, both possible recipient amounts and
the separate free-award explanation. Four legal bot policies use the projected
choice. Genuine setup, Karama allow/cancel, negotiated leader and small force
revival, JSON reload, authenticated SQLite restart and competing-version CAS
have focused regressions. A read-only intercepted local browser room displayed
the actual seven-spice payment and both controls; no room write occurred. This
is one bounded payment family, not complete Tleilaxu, Leader Skills or
combined-module acceptance; the verified live image remains `c9a3625`.
Independent rules and privacy/recovery reviews passed after correcting a public
log that initially disclosed the negotiated price. Scoped lint, the production
build, all 52 local HTTP integration tests and the standalone 716-file,
5,652-case offline suite pass. The required
`npm run check` cannot complete: concurrent independent work in
`game/nexus-fremen-betrayal.ts:38` reads an undeclared
`Player.fremenNexusMovementBlockedTurn` field. This checkpoint does not alter
that draft or claim a green typecheck.
## 28 September 2026 — Ecaz Duke revival and bounded Mirror Weapon
The user chose two explicit product interpretations after bounded publisher
+1 -1
View File
@@ -1,5 +1,5 @@
import { basicExpansionLeaderSkillsProfile, noOtherLeaderSkillModules, type LeaderSkillProfile } from './leader-skill-profile';
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe';
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival';
export type BureaucratPaymentSource = {
event: string;
turn: number;
+18 -8
View File
@@ -6254,7 +6254,8 @@ function bureaucratPaymentFrame(g: Game): string {
}
function validateBureaucratSource(g: Game,source:BureaucratPaymentSource) {
requireRule(source && typeof source === 'object' && typeof source.event === 'string' && source.event.length > 0 &&
source.turn === g.turn && source.phase === g.phase && ['auction','shipment','bribe'].includes(source.kind) &&
source.turn === g.turn && source.phase === g.phase && ['auction','shipment','bribe','revival'].includes(source.kind) &&
(source.kind !== 'revival' || g.phase === 4) &&
g.players.some(p => p.id === source.payer) && g.players.some(p => p.id === source.payee) && source.payer !== source.payee &&
Number.isSafeInteger(source.amount) && source.amount >= 5 && source.signature === bureaucratPaymentSignature(source),
'The saved Bureaucrat payment changed its actual payer, payee, amount or event.');
@@ -6292,7 +6293,10 @@ function bureaucratPaymentIntegrity(g: Game) {
'The Bureaucrat source no longer matches its paid auction sale.');
else requireRule(source.payee === carrier.owner &&
(carrier.kind === 'guildIncome' && source.kind === 'shipment' && source.amount === carrier.amount ||
carrier.kind === 'emperorIncome' && source.kind === 'auction' && g.currentAuctionSale?.bureaucratPaymentEvent === source.event),
carrier.kind === 'emperorIncome' && source.kind === 'auction' && g.currentAuctionSale?.bureaucratPaymentEvent === source.event ||
carrier.kind === 'revivalIncome' && source.kind === 'revival' && byFaction(g,'tleilaxu')?.id === source.payee &&
source.amount <= (carrier.amount ?? -1) && (carrier.amount ?? 0) <= source.amount + 1 &&
source.payer === carrier.recipient),
'The Bureaucrat source no longer matches its original native income.');
}
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'bureaucratPayment');
@@ -6328,9 +6332,11 @@ function offerBureaucratPayment(g: Game,source:BureaucratPaymentSource|undefined
g.decision = {kind:'bureaucratPayment',player:assignment.owner,event:source.event};
return true;
}
function projectedBureaucrat(g: Game): BureaucratPaymentView {
function projectedBureaucrat(g: Game,viewer: string): BureaucratPaymentView {
const pending = g.bureaucratPayments?.pending;
return {pending:pending ? {event:pending.source.event,owner:pending.owner,payer:pending.source.payer,payee:pending.source.payee,
const privateRevival = pending?.source.kind === 'revival' &&
![pending.owner,pending.source.payer,pending.source.payee,pending.resume.response?.recipient].includes(viewer);
return {pending:pending && !privateRevival ? {event:pending.source.event,owner:pending.owner,payer:pending.source.payer,payee:pending.source.payee,
amount:pending.source.amount,kind:pending.source.kind,redirect:2} : null,
usedThisPhase:bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase)};
}
@@ -6343,7 +6349,7 @@ function actBureaucratPayment(g: Game,action:Action) {
const use:BureaucratPaymentUse = {event:pending.source.event,owner:pending.owner,turn:g.turn,phase:g.phase,signature:''};
use.signature = bureaucratPaymentSignature(use);
g.bureaucratPayments!.used.push(use);(g.bureaucratUseEvents ??= []).push(use.event);
log(g,`${getPlayer(g,pending.owner).name} used Bureaucrat to redirect 2 spice of the ${pending.source.amount}-spice payment to the Bank.`);
log(g,`${getPlayer(g,pending.owner).name} used Bureaucrat to redirect 2 spice of ${pending.source.kind === 'revival' ? 'a qualifying revival payment' : `the ${pending.source.amount}-spice payment`} to the Bank.`);
}
delete g.bureaucratPayments!.pending;delete g.bureaucratPaymentEvent;
g.response = pending.resume.response;g.decision = pending.resume.decision;
@@ -18557,7 +18563,11 @@ function finishRevival(
g.revivalFreeIncome ??= {};
g.revivalFreeIncome[quote.freeIncome.player] = quote.freeIncome.turn;
}
if (quote.nextResponse) g.response = quote.nextResponse;
if (quote.nextResponse) {
g.response = quote.nextResponse;
if (quote.nextResponse.kind === 'revivalIncome')
Object.assign(g.response, stampBureaucratPayment(g,'revival',payer.id,quote.nextResponse.owner,revival.cost));
}
}
function beginRevival(g: Game, revival: PendingRevival) {
if (revival.kind === 'leader') requireLeaderSkillRevival(g,revival.player);
@@ -19384,7 +19394,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
g.revivalRules ??= newRevivalRules();
if (g.pendingRevival) finishRevival(g);
} else if (response.kind === 'revivalIncome') {
if (!canceled) getPlayer(g, response.owner).spice += response.amount!;
if (!canceled) getPlayer(g, response.owner).spice += response.amount! - (bureaucratDiversion ?? 0);
if (g.pendingChoamMarketGhola && g.pendingChoamMarketGhola.event === response.intent)
g.pendingChoamMarketGhola.stage = 'complete';
} else if (response.kind === 'faceDancerReplacement') {
@@ -26304,7 +26314,7 @@ export function viewGame(state: Game, id: string) {
setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null,
setupPending: setupPending(g),
leaderSkills: projectedLeaderSkills(g, id),
bureaucrat: projectedBureaucrat(g),
bureaucrat: projectedBureaucrat(g, id),
bribeOptions: projectedBribes(g, me),
mentat: projectedMentat(g, id),
rihani: projectedRihani(g, id),
+1 -1
View File
@@ -4178,7 +4178,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income remains unfinished.',
'Diplomat may use one committed Worthless card as the opponent’s base Shield or Snooper if you played no defense. After both plans reveal, choose the card or decline before traitor decisions. A native Diplomat may protect its own selected trained leader this way; a captive grants no upper effect. The chosen Worthless must be discarded afterward, even if you win. A surviving trained leader that loses a supported battle may retreat selected undialed physical counters up to its printed/copied disc strength into one empty adjacent non-stronghold territory before ordinary loser losses; modified defenses remain unfinished.',
'Mentat has a private developer preview for naming a specific weapon before posture and faction powers. The target privately shows the named held card or chooses another held card. It stays in hand and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.',
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income and Richese sales have connected payment paths; split funding, other payment families and combined modules remain unfinished.',
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income, Richese sales and actual paid Tleilaxu revival income in the Basic Tleilaxu skill profile have connected payment paths. A separate free-revival award is not part of the paid price and stays with Tleilaxu. Split funding, gifts, other payment families and combined modules remain unfinished.',
'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.',
'Other special Sandmaster relocation and Spice Banker normal income remain missing. Diplomat retreat uses a documented conservative outcome-order interpretation in standalone Leader Skills play. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
+4 -1
View File
@@ -27,11 +27,13 @@ export function takeBureaucratCard(g: Game, id: string, name = 'Karama') {
export function bureaucratPaymentGame({
advanced = false,
choam = false,
tleilaxu = false,
phase = 5,
advisor = false,
}: {
advanced?: boolean;
choam?: boolean;
tleilaxu?: boolean;
phase?: number;
advisor?: boolean;
} = {}): Game {
@@ -39,7 +41,7 @@ export function bureaucratPaymentGame({
'BUROTEST',
newPlayer('b', 'Bureaucrat', 'atreides'),
advanced,
choam ? ['choam'] : [],
choam ? ['choam'] : tleilaxu ? ['ix'] : [],
);
for (const [id, name, faction] of [
['p', 'Payer', 'harkonnen'],
@@ -47,6 +49,7 @@ export function bureaucratPaymentGame({
['g', 'Guild', 'guild'],
...(choam ? [['r', 'Richese', 'richese']] : []),
...(advisor ? [['bg', 'Bene Gesserit', 'beneGesserit']] : []),
...(tleilaxu ? [['t', 'Tleilaxu', 'tleilaxu']] : []),
] as [string, string, Parameters<typeof newPlayer>[2]][])
joinGame(g, newPlayer(id, name, faction));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { newRevivalRules } from '../game/revival';
import { unitStore } from './fixture-nexus-room-store';
import { bureaucratPaymentGame, bureaucratPlayer as player, takeBureaucratCard } from './bureaucrat-payment-fixture';
const clock = { now: () => 82_000, sleep: async () => {} };
const hash = (value: string) => createHash('sha256').update(value).digest('hex');
void test('saved Tleilaxu revival payment survives restart and racing choices settle one actual recipient share', async (t) => {
const store = unitStore();
t.after(() => store.sqlite.close());
let game = bureaucratPaymentGame({ tleilaxu: true, phase: 4 });
game.active = null;
game.revivalRules = newRevivalRules();
player(game, 'p').leaders[1].dead = true;
takeBureaucratCard(game, 'e');
game = applyAction(game, 'p', { type: 'requestLeaderRevival', leader: 'harkonnen-1' });
game = applyAction(game, 't', { type: 'quoteLeaderRevival', target: 'p', amount: 7 });
game = applyAction(game, 'p', { type: 'acceptLeaderRevival' });
for (let step = 0; game.response && step < 30; step++) {
const responder = game.players.find((seat) => !game.response!.passed.includes(seat.id));
assert.ok(responder);
game = applyAction(game, responder.id, { type: 'passResponse' });
}
assert.equal(game.decision?.kind, 'bureaucratPayment');
assert.equal(game.bureaucratPayments?.pending?.source.amount, 7);
game.version = 41;
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
.run(game.code, JSON.stringify(game), game.version, 80_000);
const tokens = Object.fromEntries(game.players.map((seat) => [seat.id, hash(`revival-payment:${seat.id}`)]));
for (const seat of game.players)
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)')
.run(hash(tokens[seat.id]), game.code, seat.id);
const auth = await store.rooms.authenticate(game.code, tokens.b);
assert.deepEqual(await store.restart().readSeatView(game.code, auth), viewGame(game, 'b'));
const outsider = await store.rooms.authenticate(game.code, tokens.g);
assert.equal((await store.restart().readSeatView(game.code, outsider)).bureaucrat.pending, null);
const event = game.decision.event!;
const results = await Promise.allSettled([
store.rooms.act(game.code, auth, 41, { type: 'decision', event, redirect: true }, clock),
store.restart().act(game.code, auth, 41, { type: 'decision', event, redirect: false }, clock),
]);
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
const after = await store.restart().readRoom(game.code);
assert.equal(after.version, 42);
assert.equal(player(after, 'p').spice, 23);
assert.ok([35, 37].includes(player(after, 't').spice));
assert.equal(after.bureaucratPayments?.used.length, player(after, 't').spice === 35 ? 1 : 0);
assert.equal(after.bureaucratPayments?.pending, undefined);
assert.equal(after.response, null);
assert.equal(player(after, 'p').leaders[1].dead, false);
assert.deepEqual(after.players.map((seat) => seat.hand), game.players.map((seat) => seat.hand));
await assert.rejects(store.restart().act(game.code, auth, 41,
{ type: 'decision', event, redirect: true }, clock), /table changed/);
assert.deepEqual(await store.restart().readSeatView(game.code, outsider), viewGame(after, 'g'));
});
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
bureaucratPaymentGame,
bureaucratPlayer as player,
bureaucratReload as reload,
takeBureaucratCard,
} from './bureaucrat-payment-fixture';
function paidRevival(kind: 'leader' | 'forces', cancelIncome = false): Game {
let game = bureaucratPaymentGame({ tleilaxu: true, phase: 4 });
game.active = null;
player(game, 'p').leaders[1].dead = true;
player(game, 'p').leaderRevived = false;
player(game, 'p').tanks = 3;
player(game, 'p').reserves = 17;
const karama = takeBureaucratCard(game, 'e');
if (kind === 'leader') {
game = applyAction(game, 'p', { type: 'requestLeaderRevival', leader: 'harkonnen-1' });
game = applyAction(game, 't', { type: 'quoteLeaderRevival', target: 'p', amount: 7 });
game = applyAction(game, 'p', { type: 'acceptLeaderRevival' });
} else game = applyAction(game, 'p', { type: 'revive', amount: 3 });
for (let step = 0; game.response && step < 30; step++) {
if (cancelIncome && game.response.kind === 'revivalIncome') {
game = applyAction(game, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
continue;
}
const responder = game.players.find((seat) => !game.response!.passed.includes(seat.id));
assert.ok(responder);
game = applyAction(game, responder.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
return game;
}
void test('negotiated paid leader revival opens Bureaucrat after Tleilaxu income response, then redirects exactly two', () => {
const before = paidRevival('leader');
assert.equal(before.decision?.kind, 'bureaucratPayment');
assert.equal(before.bureaucratPayments?.pending?.source.kind, 'revival');
assert.equal(before.bureaucratPayments?.pending?.source.amount, 7);
assert.equal(player(before, 'p').spice, 23);
assert.equal(player(before, 't').spice, 30);
assert.equal(player(before, 'p').leaders[1].dead, false);
const event = before.decision!.event!;
assert.throws(() => applyAction(reload(before), 'p', { type: 'decision', event, redirect: true }));
const after = applyAction(reload(before), 'b', { type: 'decision', event, redirect: true });
assert.equal(player(after, 'p').spice, 23);
assert.equal(player(after, 't').spice, 35);
assert.equal(after.bureaucratPayments?.used.length, 1);
assert.equal(after.response, null);
assert.equal(after.decision, null);
const notice = after.log.findLast((entry) => entry.text.includes('used Bureaucrat'));
assert.match(notice?.text ?? '', /qualifying revival payment/);
assert.doesNotMatch(notice!.text, /7.spice/);
assert.deepEqual(after.players.map((seat) => seat.hand), before.players.map((seat) => seat.hand));
assert.throws(() => applyAction(reload(after), 'b', { type: 'decision', event, redirect: true }));
});
void test('Karama-stopped native revival income goes to the bank without a Bureaucrat offer', () => {
const after = paidRevival('leader', true);
assert.equal(after.decision?.kind, undefined);
assert.equal(after.bureaucratPayments?.pending, undefined);
assert.equal(after.bureaucratPayments?.used.length ?? 0, 0);
assert.equal(player(after, 'p').spice, 23);
assert.equal(player(after, 't').spice, 30);
assert.equal(after.discard.filter((card) => card.effect === 'karama').length, 1);
});
void test('owner alone sees negotiated revival price in pending Bureaucrat choice; decline preserves the skill', () => {
const before = paidRevival('leader');
assert.equal(viewGame(before, 'g').bureaucrat.pending, null);
assert.equal(viewGame(before, 'b').bureaucrat.pending?.amount, 7);
const decision = before.decision;
assert.ok(decision?.kind === 'bureaucratPayment');
const after = applyAction(reload(before), 'b', {
type: 'decision', event: decision.event, redirect: false,
});
assert.equal(player(after, 't').spice, 37);
assert.equal(after.bureaucratPayments?.used.length ?? 0, 0);
assert.equal(after.decision, null);
});
void test('a small force-revival payment and separate free-revival reward cannot trigger Bureaucrat', () => {
const game = paidRevival('forces');
assert.equal(game.decision?.kind, undefined);
assert.equal(game.bureaucratPayments?.pending, undefined);
assert.equal(player(game, 'p').spice, 28);
assert.equal(player(game, 't').spice, 33);
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty} resolves paid revival Bureaucrat through the projected legal choice`, () => {
const game = paidRevival('leader');
player(game, 'b').bot = difficulty;
const actions = botActions(viewGame(game, 'b'));
const decision = game.decision;
assert.ok(decision?.kind === 'bureaucratPayment');
assert.ok(actions.some((action) => action.type === 'decision' && action.event === decision.event));
const after = applyAction(reload(game), 'b', actions[0]);
assert.equal(after.bureaucratPayments?.pending, undefined);
assert.equal(after.response, null);
});