Files
Dune/tests/battle-promises.test.ts
Quintine 91dcca46fb Connect ordinary bribe payments to player controls
Players can select a non-allied recipient and explicitly pay a whole-spice bribe
outside Mentat and open decision/response windows. The server derives private
payment limits from uncommitted spice and existing joint Truthtrance completion
checks on isolated copies. Target-specific Bureaucrat guards preserve supported
redirect/allow continuations. Reserved bids, sealed spending and incoming escrow
cannot be spent twice. No strategic AI negotiation or mode/publication changes.

The panel separates deferred incoming bribes from spendable supply, disables
stale/invalid selections and uses existing request/version fencing. The chronicle
now describes actual payment and next-Mentat spendability rather than a promise.
Internal guidance and the implementation checklist record this boundary. The
separate Emperor gift/Bureaucrat source ambiguity remains explicitly pending.

Independent review found no material issue. Focused tests cover out-of-turn
payment, JSON escrow/collection, private budgets, invalid targets/amounts,
phase/response gates, reserved bids, joint promises, supported redirection and
rendered controls. Existing Bureaucrat SQLite/all-four legal response tests pass.
The new HTTP test races duplicate submissions and verifies one debit, refreshed
private balances and stale/over-budget rejection.

Types, lint, all 4968 offline tests, production build and 49 HTTP tests
pass on stable source. Preliminary checks caught a typed fixture opener and
semantic-status/internal-link/template lint requirements and the new SSR test
needed the existing Vinext Link alias. All were corrected before the
final4 run. Browser QA used genuine fresh Basic setup with a human payer and
Easy AI recipient: 11 rejected by controls, 4 paid from10 leaving6, one public
payment entry and unchanged recipient spendable10 with4 in escrow after refresh.
Keyboard selection and amount access passed. At390px the panel fits with16px
text and44px-or-larger controls; linked rules and back navigation work. Screenshot
capture timed out; full visual/assistive and all-configuration acceptance remain
open. Browser transport loss was not injected; HTTP concurrency is separately
covered. This is playability evidence, not difficulty calibration.

All 1418 original rooms, 2991 seats and auxiliary rows are
unchanged. SQLite integrity and foreign keys pass. The healthy server was reused
without restart, migration, reset or recurring maintenance.

Private evidence: 20260921-bribe-controls.
Verified source tree: b9a8594c5dcd85f78974c9647f24f5f6eba60a38c9dcd2badcf4529d1e825ae1
2026-09-21 10:22:18 +10:00

555 lines
20 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
RuleError,
type Game,
type Plan,
} from '../game/engine';
import { treacheryDeck, leaders } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { matchesPlanClaim, type PlanClaim } from '../game/battle-promises';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
function fixture(advanced = false) {
const g = createGame(
'BTRUTH22',
newPlayer('a', 'Atreides', 'atreides'),
advanced,
);
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('b', 'Sisterhood', 'beneGesserit'),
);
g.status = 'playing';
g.turn = 2;
g.phase = 6;
g.order = ['a', 'e', 'b'];
g.active = 'a';
g.storm = 18;
g.deck = treacheryDeck(['ix']);
for (const p of g.players) {
p.hand = [];
p.traitors = [];
p.traitorChoices = [];
p.spice = 6;
}
for (const p of g.players.slice(0, 2)) {
p.forces = { 'arrakeen:10': 6 };
p.reserves = 14;
}
hold(g, 'a', 'Truthtrance');
hold(g, 'a', 'Truthtrance');
hold(g, 'e', 'Shield');
hold(g, 'e', 'Gom Jabbar');
hold(g, 'e', 'Maula Pistol');
g.battle = {
territory: 'arrakeen',
attacker: 'a',
defender: 'e',
prepared: true,
plans: {},
revealed: false,
traitorCalls: {},
};
return g;
}
function hold(g: Game, player: string, name: string) {
const i = g.deck.findIndex((c) => c.name === name);
assert.ok(i >= 0, name);
const c = g.deck.splice(i, 1)[0];
g.players.find((p) => p.id === player)!.hand.push(c);
return c.id;
}
function ask(initial: Game, claim: PlanClaim, target = 'e') {
let g = applyAction(initial, 'a', {
type: 'card',
card: initial.players[0].hand.find((c) => c.effect === 'truthtrance')!.id,
});
while (g.truthtrance!.stage === 'priority')
g = applyAction(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
return applyAction(g, 'a', {
type: 'truthAsk',
question: { kind: 'battlePlan', target, claim, territory: 'forged' },
});
}
function answer(g: Game, value: 'yes' | 'no' | 'unknown' = 'yes') {
return applyAction(g, g.truthtrance!.question!.target, {
type: 'truthAnswer',
answer: value,
});
}
function promise(
g: Game,
claim: PlanClaim,
value: 'yes' | 'no' = 'yes',
target = 'e',
) {
return answer(ask(g, claim, target), value);
}
function plan(g: Game, override: Partial<Plan> = {}, player = 'e') {
return {
type: 'battlePlan',
dial: 0,
support: 0,
leader: g.players.find((p) => p.id === player)!.leaders[0].id,
weapon: null,
defense: null,
...override,
};
}
function allow(state: Game) {
let g = state;
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
const minimum: PlanClaim = { kind: 'dial', compare: 'gte', value: 3 };
void test('a battle answer binds only the stated plan elements and exposes no private completion', () => {
const initial = fixture();
const pending = ask(initial, minimum);
assert.deepEqual(viewGame(pending, 'e').truthBattleAnswers, ['yes', 'no']);
assert.equal(viewGame(pending, 'a').truthBattleAnswers, null);
assert.equal(viewGame(pending, 'b').truthBattleAnswers, null);
assert.equal(
pending.truthtrance!.question!.kind === 'battlePlan' &&
pending.truthtrance!.question!.territory,
'arrakeen',
);
const g = answer(pending);
assert.equal(g.battle!.truthPromises!.length, 1);
assert.equal(g.battle!.truthPromises![0].released, undefined);
assert.ok(viewGame(g, 'e').battle!.compliantPlan!.dial >= 3);
assert.equal(viewGame(g, 'a').battle!.compliantPlan, null);
assert.equal(viewGame(g, 'b').battle!.compliantPlan, null);
assert.throws(() => applyAction(g, 'e', plan(g, { dial: 2 })), /Truthtrance/);
const weapon = g.players[1].hand.find((c) => c.kind === 'poison')!.id;
const sealed = applyAction(
JSON.parse(JSON.stringify(g)),
'e',
plan(g, { dial: 4, leader: g.players[1].leaders[1].id, weapon }),
);
assert.equal(sealed.battle!.plans.e.dial, 4);
assert.equal(sealed.battle!.plans.e.weapon, weapon);
assert.equal(viewGame(sealed, 'a').battle!.plans.e, undefined);
assert.deepEqual(initial.battle!.truthPromises, undefined);
});
void test('overlapping Yes/No answers compose and cannot be contradicted by a later answer', () => {
const initial = promise(fixture(), minimum);
const pending = ask(initial, { kind: 'dial', compare: 'gte', value: 2 });
assert.deepEqual(viewGame(pending, 'e').truthBattleAnswers, ['yes']);
assert.throws(() => answer(pending, 'no'), /previous commitments/);
assert.throws(() => answer(pending, 'unknown'), /previous commitments/);
const g = promise(initial, { kind: 'dial', compare: 'gte', value: 5 }, 'no');
assert.throws(() => applyAction(g, 'e', plan(g, { dial: 5 })), /Truthtrance/);
const sealed = applyAction(g, 'e', plan(g, { dial: 3 }));
assert.equal(sealed.battle!.plans.e.dial, 3);
});
void test('AND/OR promises preserve alternatives instead of locking an arbitrary witness', () => {
const claim: PlanClaim = {
kind: 'or',
terms: [
{ kind: 'weapon', name: 'Gom Jabbar' },
{ kind: 'defense', name: 'Shield' },
],
};
const g = promise(fixture(), claim);
const weapon = g.players[1].hand.find((c) => c.name === 'Gom Jabbar')!.id;
const defense = g.players[1].hand.find((c) => c.name === 'Shield')!.id;
for (const choices of [{ weapon }, { defense }, { weapon, defense }])
assert.ok(applyAction(g, 'e', plan(g, choices)).battle!.plans.e);
assert.throws(() => applyAction(g, 'e', plan(g)), /Truthtrance/);
const and = promise(fixture(), {
kind: 'and',
terms: [
{ kind: 'dial', compare: 'eq', value: 2 },
{ kind: 'defense', name: 'Shield' },
],
});
assert.throws(
() => applyAction(and, 'e', plan(and, { dial: 2 })),
/Truthtrance/,
);
});
void test('plan existence respects dual-role Ix cards and cannot promise an illegal pair', () => {
const initial = fixture();
hold(initial, 'e', 'Chemistry');
const invalid: PlanClaim = {
kind: 'and',
terms: [
{ kind: 'weapon', name: 'Chemistry' },
{ kind: 'defense', name: null },
],
};
const q = ask(initial, invalid);
assert.deepEqual(viewGame(q, 'e').truthBattleAnswers, ['no']);
assert.throws(() => answer(q), /legal plans/);
const g = promise(initial, { kind: 'weapon', name: 'Chemistry' });
const legal = viewGame(g, 'e').battle!.compliantPlan!;
assert.ok(legal.defense);
assert.ok(
applyAction(g, 'e', { type: 'battlePlan', ...legal }).battle!.plans.e,
);
});
void test('a sealed plan gives one truthful answer without exposing unrelated components or adding a new promise', () => {
let g = fixture();
g = applyAction(g, 'e', plan(g, { dial: 4 }));
g = ask(g, minimum);
assert.deepEqual(viewGame(g, 'e').truthBattleAnswers, ['yes']);
assert.throws(() => answer(g, 'no'), /legal plans/);
g = answer(g);
assert.equal(g.battle!.truthPromises, undefined);
assert.equal(viewGame(g, 'a').battle!.plans.e, undefined);
assert.equal(g.battle!.plans.e.dial, 4);
});
void test('prescience searches across permitted dials instead of assuming all unshown elements can be zero', () => {
let g = promise(fixture(), minimum);
g.battle!.preparation = { kind: 'prescience', owner: 'a', beneficiary: 'a' };
g = allow(applyAction(g, 'a', { type: 'prescience', field: 'leader' }));
g = applyAction(g, 'e', {
type: 'prescienceAnswer',
value: g.players[1].leaders[1].id,
});
assert.equal(g.battle!.prescience!.value, 'emperor-1');
const legal = viewGame(g, 'e').battle!.compliantPlan!;
assert.ok(legal.dial >= 3);
assert.equal(legal.leader, 'emperor-1');
assert.ok(
applyAction(g, 'e', { type: 'battlePlan', ...legal }).battle!.plans.e,
);
g = promise(fixture(), minimum);
g.battle!.preparation = { kind: 'prescience', owner: 'a', beneficiary: 'a' };
g = allow(applyAction(g, 'a', { type: 'prescience', field: 'dial' }));
assert.throws(
() => applyAction(g, 'e', { type: 'prescienceAnswer', value: 2 }),
/legal battle plan/,
);
assert.ok(
applyAction(g, 'e', { type: 'prescienceAnswer', value: 3 }).battle!
.prescience,
);
});
void test('a canceled Voice preserves earlier Truthtrance, while a completed incompatible Voice releases it', () => {
let initial = fixture();
initial.players[2].ally = 'a';
initial.players[0].ally = 'b';
hold(initial, 'a', 'Karama');
initial = promise(initial, { kind: 'defense', name: 'Shield' });
initial.battle!.preparation = { kind: 'voice', owner: 'b', beneficiary: 'a' };
const voiced = applyAction(initial, 'b', {
type: 'voice',
kind: 'shield',
must: false,
});
assert.equal(voiced.battle!.truthPromises![0].released, undefined);
const canceled = applyAction(voiced, 'a', {
type: 'card',
card: voiced.players[0].hand.find((c) => c.effect === 'karama')!.id,
mode: 'cancel',
});
assert.equal(canceled.battle!.truthPromises![0].released, undefined);
assert.throws(() => {
const g = structuredClone(canceled);
g.battle!.preparation = undefined;
return applyAction(g, 'e', plan(g));
}, /Truthtrance/);
const allowed = allow(voiced);
assert.equal(allowed.battle!.truthPromises![0].released, true);
assert.match(allowed.log.at(-1)!.text, /no longer binding/);
allowed.battle!.preparation = undefined;
assert.ok(applyAction(allowed, 'e', plan(allowed)).battle!.plans.e);
});
void test('a player cannot give away support spice to escape their own battle promise', () => {
const initial = fixture(true);
initial.players[1].spice = 3;
const g = promise(initial, { kind: 'support', compare: 'gte', value: 3 });
const before = JSON.stringify(g);
assert.equal(viewGame(g, 'e').bribeOptions.targets.find(p => p.id === 'b')!.maximum, 0);
const extra = structuredClone(g);
extra.players[1].spice += 2;
assert.equal(viewGame(extra, 'e').bribeOptions.targets.find(p => p.id === 'b')!.maximum, 2);
assert.ok(applyAction(extra, 'e', { type: 'bribe', target: 'b', amount: 2 }));
assert.throws(() => applyAction(extra, 'e', { type: 'bribe', target: 'b', amount: 3 }));
assert.throws(
() => applyAction(g, 'e', { type: 'bribe', target: 'b', amount: 1 }),
/voluntarily/,
);
assert.equal(JSON.stringify(g), before);
const legal = viewGame(g, 'e').battle!.compliantPlan!;
assert.equal(legal.support, 3);
assert.ok(
applyAction(g, 'e', { type: 'battlePlan', ...legal }).battle!.plans.e,
);
});
void test('a promised Worthless battle card cannot be voluntarily consumed as Karama', () => {
const initial = fixture(true);
initial.battle!.defender = 'b';
initial.players[2].forces = { 'arrakeen:10': 6 };
initial.players[2].reserves = 14;
initial.players[1].forces = {};
initial.players[1].reserves = 20;
const worthless = initial.deck.find((c) => c.kind === 'worthless')!;
hold(initial, 'b', worthless.name);
let g = promise(
initial,
{ kind: 'weapon', name: worthless.name },
'yes',
'b',
);
g.battle!.preparation = { kind: 'prescience', owner: 'a', beneficiary: 'a' };
g = applyAction(g, 'a', { type: 'prescience', field: 'leader' });
assert.throws(
() =>
applyAction(g, 'b', { type: 'card', mode: 'cancel', card: worthless.id }),
/voluntarily/,
);
assert.ok(g.players[2].hand.some((c) => c.id === worthless.id));
});
void test('Kwisatz inclusion and exclusion are enforced even when the plan omits the optional flag', () => {
for (const use of [true, false]) {
const initial = fixture(true);
initial.players[0].battleLosses = 7;
initial.players[0].kwisatz = { dead: false };
// Move a physical Truthtrance to the Emperor, so Atreides can be asked by another player.
initial.players[1].hand.push(initial.players[0].hand.pop()!);
let g = applyAction(initial, 'e', {
type: 'card',
card: initial.players[1].hand.at(-1)!.id,
});
for (const id of ['a', 'b']) g = applyAction(g, id, { type: 'truthPass' });
g = applyAction(g, 'e', {
type: 'truthAsk',
question: {
kind: 'battlePlan',
target: 'a',
claim: { kind: 'kwisatz', use },
},
});
g = answer(g);
const wrong = plan(g, use ? {} : { kwisatz: true }, 'a');
assert.throws(() => applyAction(g, 'a', wrong), /Truthtrance/);
assert.ok(
applyAction(g, 'a', plan(g, { kwisatz: use }, 'a')).battle!.plans.a,
);
}
});
void test('Cheap Hero promises match either physical hero and exact named cards match duplicate copies', () => {
const initial = fixture();
const hero = initial.deck.find((c) => c.kind === 'hero')!;
hold(initial, 'e', hero.name);
const g = promise(initial, { kind: 'leader', leader: CHEAP_HERO_TRAITOR });
assert.ok(applyAction(g, 'e', plan(g, { leader: hero.id })).battle!.plans.e);
assert.throws(() => applyAction(g, 'e', plan(g)), /Truthtrance/);
const copies = fixture();
const second = hold(copies, 'e', 'Shield');
const constrained = promise(copies, { kind: 'defense', name: 'Shield' });
for (const c of constrained.players[1].hand.filter(
(c) => c.name === 'Shield',
))
assert.ok(
applyAction(constrained, 'e', plan(constrained, { defense: c.id }))
.battle!.plans.e,
);
assert.ok(constrained.players[1].hand.some((c) => c.id === second));
});
void test('basic Ix fractional dials and fixed-half suboids participate in legal-plan searches', () => {
const initial = fixture();
const ix = initial.players[1];
ix.faction = 'ixians';
ix.leaders = leaders('ixians');
ix.elites = {
reserves: 6,
tanks: 0,
forces: { 'arrakeen:10': 1 },
revived: 0,
};
const g = promise(initial, { kind: 'dial', compare: 'eq', value: 2.5 });
const legal = viewGame(g, 'e').battle!.compliantPlan!;
assert.equal(legal.dial, 2.5);
assert.equal(legal.support, 0);
let insight = structuredClone(g);
insight.battle!.preparation = {
kind: 'prescience',
owner: 'a',
beneficiary: 'a',
};
insight = allow(
applyAction(insight, 'a', { type: 'prescience', field: 'dial' }),
);
insight = applyAction(insight, 'e', { type: 'prescienceAnswer', value: 2.5 });
assert.equal(insight.battle!.prescience!.value, 2.5);
assert.ok(
applyAction(g, 'e', { type: 'battlePlan', ...legal }).battle!.plans.e,
);
});
void test('malformed claims and noncombatant targets are rejected without consuming the card', () => {
const initial = fixture();
for (const claim of [
{ kind: 'dial', compare: 'eq', value: NaN },
{ kind: 'dial', compare: 'eq', value: 0.3 },
{ kind: 'support', compare: 'eq', value: 0.5 },
{ kind: 'leader', leader: 'fake' },
{ kind: 'weapon', name: 'fake' },
{ kind: 'kwisatz', use: 'yes' },
{ kind: 'and', terms: [] },
])
assert.throws(() => ask(initial, claim as PlanClaim), RuleError);
assert.throws(() => ask(initial, minimum, 'b'), /combatant/);
assert.equal(initial.discard.length, 0);
});
void test('all AI levels can choose battle answers and seal compliant plans even for uncommon exact dials', () => {
for (const level of DIFFICULTIES) {
const initial = fixture();
initial.players[1].bot = level;
const pending = ask(initial, minimum);
const a = botActions(viewGame(pending, 'e'))[0];
assert.equal(a.type, 'truthAnswer');
assert.ok(applyAction(pending, 'e', a).battle!.truthPromises!.length);
const g = promise(initial, { kind: 'dial', compare: 'eq', value: 2 });
let committed: Game | undefined;
for (const action of botActions(viewGame(g, 'e'))) {
try {
committed = applyAction(g, 'e', action);
break;
} catch (error) {
if (!(error instanceof RuleError)) throw error;
}
}
assert.ok(committed, level);
assert.equal(committed.battle!.plans.e.dial, 2);
assert.equal(
matchesPlanClaim(
g.battle!.truthPromises![0].claim,
{ ...committed.battle!.plans.e, kwisatz: false },
g.players[1].hand,
),
true,
);
}
});
void test('verified answer choices match an exhaustive authoritative basic-plan oracle', () => {
const initial = fixture();
initial.players[1].forces = { 'arrakeen:10': 2 };
initial.players[1].reserves = 18;
const legal: Plan[] = [];
const hand = initial.players[1].hand;
for (const dial of [0, 1, 2])
for (const leader of initial.players[1].leaders.map((l) => l.id))
for (const weapon of [null, ...hand.map((c) => c.id)])
for (const defense of [null, ...hand.map((c) => c.id)]) {
try {
const g = applyAction(
initial,
'e',
plan(initial, { dial, leader, weapon, defense }),
);
legal.push({ ...g.battle!.plans.e, kwisatz: false });
} catch (error) {
if (!(error instanceof RuleError)) throw error;
}
}
assert.ok(legal.length > 0);
const claims: PlanClaim[] = [
{ kind: 'dial', compare: 'gte', value: 3 },
{ kind: 'dial', compare: 'eq', value: 1 },
{ kind: 'support', compare: 'gte', value: 1 },
{ kind: 'leader', leader: null },
{ kind: 'kwisatz', use: true },
{ kind: 'weapon', name: 'Gom Jabbar' },
{ kind: 'defense', name: 'Gom Jabbar' },
{
kind: 'or',
terms: [
{ kind: 'dial', compare: 'eq', value: 2 },
{ kind: 'defense', name: 'Shield' },
],
},
{
kind: 'and',
terms: [
{ kind: 'weapon', name: 'Gom Jabbar' },
{ kind: 'defense', name: 'Shield' },
],
},
];
for (const claim of claims) {
const expected = [
...new Set(
legal.map((plan) =>
matchesPlanClaim(claim, plan, hand) ? 'yes' : 'no',
),
),
].sort();
const pending = ask(initial, claim);
assert.deepEqual(
viewGame(pending, 'e').truthBattleAnswers!.sort(),
expected,
JSON.stringify(claim),
);
}
});
void test('canceling Fremen free support releases a dial promise only when no supported completion remains', () => {
for (const spice of [0, 6]) {
const initial = fixture(true);
const fremen = initial.players[1];
fremen.faction = 'fremen';
fremen.leaders = leaders('fremen');
fremen.spice = spice;
const karama = hold(initial, 'a', 'Karama');
const g = promise(initial, { kind: 'dial', compare: 'eq', value: 6 });
g.response = { kind: 'fremenSupport', owner: 'e', passed: [] };
const canceled = applyAction(g, 'a', {
type: 'card',
mode: 'cancel',
card: karama,
});
assert.equal(canceled.battle!.fremenSupportBlocked, true);
assert.equal(!!canceled.battle!.truthPromises![0].released, spice === 0);
if (spice) {
const legal = viewGame(canceled, 'e').battle!.compliantPlan!;
assert.equal(legal.dial, 6);
assert.equal(legal.support, 6);
assert.ok(
applyAction(canceled, 'e', { type: 'battlePlan', ...legal }).battle!
.plans.e,
);
}
}
});
void test('canceling elite strength updates battle-promise feasibility without losing physical forces', () => {
const initial = fixture(true);
const emperor = initial.players[1];
emperor.forces = { 'arrakeen:10': 2 };
emperor.reserves = 18;
emperor.elites = {
reserves: 4,
tanks: 0,
forces: { 'arrakeen:10': 1 },
revived: 0,
};
const karama = hold(initial, 'a', 'Karama');
const g = promise(initial, { kind: 'dial', compare: 'eq', value: 3 });
g.response = { kind: 'eliteStrength', owner: 'e', passed: [] };
const canceled = applyAction(g, 'a', {
type: 'card',
mode: 'cancel',
card: karama,
});
assert.equal(canceled.battle!.truthPromises![0].released, true);
assert.deepEqual(canceled.players[1].forces, emperor.forces);
assert.deepEqual(canceled.players[1].elites, emperor.elites);
});