Files
Dune/tests/automatic-decisions.test.ts
T
Quintine f0d3d9c464 Prototype Jacurutu income, Testing Station and Shrine effects
Connect automatic unambiguous Jacurutu physical-force income, a sole-occupant
Testing Station storm choice and Shrine reciprocal physical card roles to
engine actions, player controls, legal AI and saved continuation. Preserve
Weather Control exclusion, exact card/force custody, private hand boundaries
and committed effects after restart. Shared role checks cover existing ordinary
and special Karama paths; reserved cards cannot enter another pending use.

Keep Orgiz, mixed-force Jacurutu allocation and contested Cistern/Testing
Station benefits explicitly pending. These are bounded prototypes; complete
Discovery/Advanced/expansion and publication gates remain closed.

Validation: types, lint and 4,345 offline tests passed (32 added), production
build passed, and 40 HTTP tests passed. Independent cross-reviews repaired
reserved-card deadlock, physical-card selection, alternate-role permission,
storm source/token binding and public-entitlement bot ordering. The first broad
run found five failures; focused repairs preceded the stable repeated check.

Browser A5EJQ2W4 used genuine Discovery setup/reveal/free entry with targeted QA
positioning. A human increased storm movement from two to three sectors,
moving sector 6 to 9. Refresh restored station forces and the private Snooper.
This was targeted control/recovery evidence, not an unmodified full game.
All 187 opening saved rooms remained unchanged; 31 QA/HTTP rooms were added
(218 total). Reused the running server; no restart or save reset.

Source-bound report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-location-effects/verification-final/report.json
Source fingerprint: beecb85ab7ec39319115389c115434d161591e8549802c542a6343a0fc19bea6
Durations: check 140540ms; build 6741ms; integration 24956ms.
Aggregate goal counter 63,116,444 to 64,295,091 at final verification:
1,178,647 tokens before final Git/push overhead for three bounded functions.
Standing authorization: Push all from now on.
2026-09-13 14:50:09 +10:00

291 lines
9.5 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { placeFixtureHand } from './fixture-hand';
const cards = baseDeck();
const shield = cards.find((c) => c.kind === 'shield')!;
const karama = cards.find((c) => c.effect === 'karama')!;
const worthless = cards.find((c) => c.kind === 'worthless')!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function auction() {
const g = createGame('AUTODECS', newPlayer('w', 'Winner', 'guild'));
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('a', 'Observer', 'atreides'),
);
g.status = 'playing';
g.phase = 3;
g.turn = 2;
g.active = 'a';
g.order = ['w', 'e', 'a'];
g.players.forEach((p) => {
p.hand = [];
p.spice = 10;
});
g.auction = {
cards: [shield],
index: 0,
bid: 5,
bidder: 'w',
active: 'a',
passed: ['e'],
opener: 0,
peekKnown: true,
allyPayment: 0,
};
g.deck = [];
return g;
}
const win = (g: Game) => applyAction(g, 'a', { type: 'passBid' });
function pendingPayment(g: Game) {
g.decision = { kind: 'auctionPayment', player: 'w' };
g.active = 'w';
return g;
}
function battle() {
const g = auction();
g.phase = 6;
g.auction = null;
g.active = 'a';
g.players[0].faction = 'guild';
g.players[1].hand = [shield];
g.players[1].forces = { 'arrakeen:10': 3 };
g.players[1].reserves = 17;
g.players[2].forces = { 'arrakeen:10': 3 };
g.players[2].reserves = 17;
g.battle = {
territory: 'arrakeen',
attacker: 'a',
defender: 'e',
prepared: true,
plans: {},
revealed: false,
traitorCalls: {},
fullPlan: { owner: 'a', target: 'e' },
};
return g;
}
const targetPlan = {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
defense: shield.id,
};
const ownPlan = { type: 'battlePlan', dial: 0, leader: 'atreides-0' };
void test('a funded winning bid with no Karama pays immediately and follows Emperor income once', () => {
const initial = auction();
const before = JSON.stringify(initial);
const g = win(initial);
assert.equal(g.decision, null);
assert.equal(g.response, null);
assert.equal(g.auction, null);
assert.equal(g.players[0].spice, 5);
assert.equal(g.players[1].spice, 15);
assert.deepEqual(g.players[0].hand, [shield]);
assert.deepEqual(
g.log.filter((e) => e.automatic).map((e) => e.automatic!.name),
['Auction payment', 'Auction income'],
);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
assert.equal(JSON.stringify(initial), before);
assert.throws(() => applyAction(g, 'w', { type: 'decision', karama: false }));
});
void test('automatic payment consumes exactly the declared ally split and still pauses for a real income response', () => {
const initial = auction();
initial.players[0].spice = 2;
initial.players[0].ally = 'a';
initial.players[2].ally = 'w';
initial.players[2].hand = [karama];
initial.aid.a = { recipient: 'w', amount: 4 };
initial.auction!.allyPayment = 3;
const g = win(initial);
assert.equal(g.decision, null);
assert.equal(g.response?.kind, 'emperorIncome');
assert.equal(g.players[0].spice, 0);
assert.equal(g.players[2].spice, 10);
assert.equal(g.aid.a.amount, 1);
assert.equal(g.players[1].spice, 10);
assert.deepEqual(viewGame(g, 'a').responseControls?.cancelCards, [karama.id]);
assert.deepEqual(g.players[0].hand, [shield]);
});
void test('a physical Karama preserves spice-versus-card choice without spending either method', () => {
const initial = auction();
initial.players[0].hand = [karama];
const g = win(initial);
assert.equal(g.decision?.kind, 'auctionPayment');
assert.equal(g.players[0].spice, 10);
assert.deepEqual(g.players[0].hand, [karama]);
const paid = applyAction(g, 'w', {
type: 'decision',
karama: true,
card: karama.id,
});
assert.equal(paid.players[0].spice, 10);
assert.equal(paid.players[1].spice, 10);
assert.deepEqual(paid.players[0].hand, [shield]);
assert.equal(paid.discard.filter((c) => c.id === karama.id).length, 1);
});
void test('BG Worthless preserves a payment choice only in Advanced mode and only when physically spendable', () => {
for (const advanced of [false, true]) {
const initial = auction();
initial.advanced = advanced;
initial.players[0].faction = 'beneGesserit';
initial.players[0].hand = [worthless];
const g = win(initial);
assert.equal(g.decision?.kind === 'auctionPayment', advanced);
assert.equal(g.players[0].spice, advanced ? 10 : 5);
}
const reserved = auction();
reserved.advanced = true;
reserved.players[0].faction = 'beneGesserit';
reserved.players[0].hand = [worthless];
reserved.moritaniRetention = {
owner: 'a',
player: 'w',
territory: 'arrakeen',
turn: 2,
played: [worthless.id],
eligible: [worthless.id],
stage: 'choose',
};
const g = normalizeAutomaticGame(pendingPayment(reserved));
assert.equal(g.players[0].spice, 5);
assert.ok(g.players[0].hand.some((c) => c.id === worthless.id));
});
void test('normalization does not invent an ally split or bypass provisional unfunded recovery', () => {
const g = auction();
g.players[0].spice = 1;
g.players[0].ally = 'a';
g.players[2].ally = 'w';
g.aid.a = { recipient: 'w', amount: 5 };
g.auction!.allyPayment = 1;
const waiting = normalizeAutomaticGame(pendingPayment(g));
assert.equal(waiting.decision?.kind, 'auctionPayment');
assert.equal(waiting.players[0].spice, 1);
assert.equal(waiting.aid.a.amount, 5);
assert.throws(
() => applyAction(waiting, 'w', { type: 'decision', karama: false }),
/no longer funded/,
);
const unfunded = auction();
unfunded.players[0].spice = 0;
const recovered = win(unfunded);
assert.equal(recovered.auction!.bidder, null);
assert.equal(recovered.auction!.bid, 0);
assert.equal(recovered.decision, null);
assert.equal(recovered.players[0].hand.length, 0);
});
void test('Truthtrance, phase opening and genuine responses retain priority over a singleton payment', () => {
for (const overlay of ['truth', 'opening', 'response'] as const) {
const g = pendingPayment(auction());
if (overlay === 'truth') {
const truth = cards.find((card) => card.effect === 'truthtrance')!;
placeFixtureHand(g, 2, [truth]);
g.truthtrance = {
stage: 'ask',
queue: [{ player: 'a', card: truth.id }],
passed: [],
question: null,
};
}
if (overlay === 'opening')
g.phaseOpening = { initialize: false, passed: [] };
if (overlay === 'response') {
g.response = { kind: 'guildIncome', owner: 'e', amount: 1, passed: [] };
g.players[2].hand = [karama];
}
const next = normalizeAutomaticGame(g);
assert.equal(next.decision?.kind, 'auctionPayment');
assert.equal(next.players[0].spice, 10);
assert.equal(next.players[0].hand.length, 0);
}
});
void test('target seals first, inspection stays private and persistent, and the second plan reveals without acknowledgement', () => {
const initial = battle();
assert.throws(
() => applyAction(initial, 'a', ownPlan),
/requested by special/,
);
const g = applyAction(initial, 'e', targetPlan);
assert.equal(g.decision, null);
assert.equal(g.battle!.revealed, false);
assert.deepEqual(
viewGame(reload(g), 'a').battle?.fullPlanInsight?.plan,
g.battle!.plans.e,
);
assert.equal(viewGame(g, 'w').battle?.fullPlanInsight, null);
assert.equal(viewGame(g, 'e').battle?.fullPlanInsight, null);
assert.equal(
g.log.filter((e) => e.automatic?.name === 'Full plan inspection').length,
1,
);
assert.ok(
g.log.every(
(e) => !e.text.includes(shield.name) && !e.text.includes('emperor-0'),
),
);
const done = applyAction(reload(g), 'a', ownPlan);
assert.equal(done.battle!.revealed, true);
assert.equal(viewGame(done, 'a').battle?.fullPlanInsight, null);
});
void test('legacy inspection acknowledgement normalizes once while preserving exact commitments and owner entitlement', () => {
const g = battle();
g.battle!.plans.e = {
dial: 0,
support: 0,
leader: 'emperor-0',
weapon: null,
defense: shield.id,
};
g.decision = { kind: 'fullPlanRead', player: 'a', target: 'e' };
const before = JSON.stringify(g);
const next = normalizeAutomaticGame(reload(g));
assert.equal(next.decision, null);
assert.deepEqual(next.battle, g.battle);
assert.deepEqual(next.players, g.players);
assert.equal(JSON.stringify(g), before);
assert.deepEqual(normalizeAutomaticGame(reload(next)), reload(next));
assert.ok(viewGame(next, 'a').battle?.fullPlanInsight);
const controlled = applyAction(g, 'a', {
type: 'setAutopilot',
difficulty: 'Easy',
});
assert.deepEqual(controlled.decision, g.decision);
});
void test('an external Atreides inspection remains private without delaying either combatant after the target seals', () => {
const g = battle();
g.battle!.fullPlan!.owner = 'a';
g.battle!.attacker = 'w';
g.players[0].forces = { 'arrakeen:10': 3 };
g.players[0].reserves = 17;
g.players[2].forces = {};
g.players[2].reserves = 20;
const next = applyAction(g, 'e', targetPlan);
assert.equal(next.decision, null);
assert.ok(viewGame(next, 'a').battle?.fullPlanInsight);
assert.equal(viewGame(next, 'w').battle?.fullPlanInsight, null);
const done = applyAction(next, 'w', {
type: 'battlePlan',
dial: 0,
leader: 'guild-0',
});
assert.equal(done.battle!.revealed, true);
});