Files
Dune/tests/automatic-responses.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

444 lines
15 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, type Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { placeFixtureHand } from './fixture-hand';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const cards = baseDeck();
const karamas = cards.filter((c) => c.effect === 'karama');
const worthless = cards.filter((c) => c.kind === 'worthless');
const shield = cards.find((c) => c.kind === 'shield')!;
function fixture(
factions: FactionId[] = ['guild', 'emperor', 'beneGesserit'],
advanced = true,
) {
const g = createGame(
'AUTORESP',
newPlayer('a', 'First', factions[0]),
advanced,
);
factions
.slice(1)
.forEach((f, i) =>
g.players.push(newPlayer(String.fromCharCode(98 + i), f, f)),
);
g.status = 'playing';
g.phase = 5;
g.turn = 2;
g.order = g.players.map((p) => p.id);
g.active = 'a';
for (const p of g.players) {
p.hand = [];
p.spice = 10;
}
g.response = { kind: 'guildIncome', owner: 'a', amount: 3, passed: [] };
return g;
}
function auction() {
const g = fixture(['harkonnen', 'emperor', 'guild']);
g.phase = 3;
g.auction = {
cards: [shield],
index: 0,
bid: 4,
bidder: 'a',
active: 'a',
passed: ['b', 'c'],
opener: 0,
peekKnown: false,
};
g.players[0].hand = [shield];
g.deck = [worthless[0], worthless[1]];
g.response = { kind: 'emperorIncome', owner: 'b', passed: [] };
return g;
}
function battle(g: Game) {
g.phase = 6;
g.players[1].ally = 'a';
g.players[0].ally = 'b';
if (g.players[1].faction === 'emperor')
g.response = {
kind: 'emperorGift',
owner: 'b',
recipient: 'a',
amount: 3,
passed: [],
};
g.battle = {
territory: 'arrakeen',
attacker: 'a',
defender: 'c',
prepared: true,
plans: {},
revealed: false,
traitorCalls: {},
};
for (const p of [g.players[0], g.players[2]]) {
p.forces = { 'arrakeen:10': 6 };
p.reserves = 14;
}
}
function ids(g: Game, who: string) {
return viewGame(g, who).responseControls!.cancelCards;
}
function cancel(g: Game, who: string, card: Card) {
return applyAction(g, who, { type: 'card', mode: 'cancel', card: card.id });
}
void test('uncancelable income and Harkonnen bonus drain once; JSON normalization and projection never replay draw/events', () => {
const initial = auction();
const snapshot = JSON.stringify(initial);
const g = normalizeAutomaticGame(reload(initial));
assert.equal(g.response, null);
assert.equal(g.players[1].spice, 14);
assert.deepEqual(
g.players[0].hand.map((c) => c.id),
[shield.id, worthless[0].id],
);
assert.equal(g.deck.length, 1);
assert.deepEqual(
g.log.filter((e) => e.automatic).map((e) => e.automatic!.name),
['Auction income', 'Bonus treachery card'],
);
assert.equal(JSON.stringify(initial), snapshot);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
for (const p of g.players) viewGame(g, p.id);
assert.deepEqual(normalizeAutomaticGame(g), g);
});
void test('chain stops at a real later choice, including an eligible allied opponent', () => {
const g = auction();
g.players[1].hand = [karamas[0]];
g.players[1].ally = 'a';
g.players[0].ally = 'b';
const waiting = normalizeAutomaticGame(g);
assert.equal(waiting.players[1].spice, 14);
assert.equal(waiting.response?.kind, 'harkonnenBonus');
assert.deepEqual(ids(waiting, 'b'), [karamas[0].id]);
assert.equal(waiting.players[0].hand.length, 1);
const done = applyAction(waiting, 'b', { type: 'passResponse' });
assert.equal(done.response, null);
assert.equal(done.players[0].hand.length, 2);
});
void test('viewer controls and passed IDs disclose only that viewer; prior explicit passer retains late cancellation', () => {
let g = fixture();
g.players[1].hand = [karamas[0]];
g.players[2].hand = [karamas[1]];
g = applyAction(g, 'b', { type: 'passResponse' });
assert.deepEqual(g.response!.passed, ['b']);
for (const p of g.players) {
const view = viewGame(g, p.id);
assert.deepEqual(view.response!.passed, p.id === 'b' ? ['b'] : []);
assert.equal(view.responseControls!.hasPassed, p.id === 'b');
assert.deepEqual(
view.responseControls!.cancelCards,
p.hand.map((c) => c.id),
);
for (const other of view.players.filter((other) => other.id !== p.id))
assert.equal('hand' in other, false);
}
assert.deepEqual(ids(g, 'b'), [karamas[0].id]);
const canceled = cancel(g, 'b', karamas[0]);
assert.equal(canceled.response, null);
assert.equal(canceled.players[0].spice, 10);
assert.equal(
canceled.discard.filter((c) => c.id === karamas[0].id).length,
1,
);
});
void test('Basic BG Worthless auto-allows; Advanced BG retains the actual conversion choice', () => {
for (const advanced of [false, true]) {
const g = fixture(undefined, advanced);
g.players[2].hand = [worthless[0]];
const next = normalizeAutomaticGame(g);
assert.equal(!!next.response, advanced);
assert.equal(next.players[0].spice, advanced ? 10 : 13);
if (advanced) assert.deepEqual(ids(next, 'c'), [worthless[0].id]);
}
});
void test('retention, sealed plans and prescience reserve exact cards while a later physical card stays selectable', () => {
for (const kind of ['retention', 'sealed', 'prescience'] as const) {
const g = fixture();
battle(g);
g.players[2].hand = [worthless[0], worthless[1]];
if (kind === 'retention')
g.moritaniRetention = {
owner: 'b',
player: 'c',
territory: 'arrakeen',
turn: 2,
played: [worthless[0].id],
eligible: [worthless[0].id],
stage: 'choose',
};
if (kind === 'sealed')
g.battle!.plans.c = {
dial: 0,
support: 0,
leader: null,
weapon: worthless[0].id,
defense: null,
};
if (kind === 'prescience')
g.battle!.prescience = {
player: 'a',
field: 'weapon',
value: worthless[0].id,
};
assert.deepEqual(ids(g, 'c'), [worthless[1].id]);
const before = JSON.stringify(g);
assert.throws(
() => cancel(g, 'c', worthless[0]),
/cleanup|sealed|prescience/,
);
assert.equal(JSON.stringify(g), before);
const next = applyAction(g, 'c', { type: 'card', mode: 'cancel' });
assert.equal(next.response, null);
assert.deepEqual(
next.players[2].hand.map((c) => c.id),
[worthless[0].id],
);
assert.equal(next.players[0].spice, 10);
}
});
void test('canceled BG conversion restores passes, recomputes depleted custody and completes outer power once', () => {
let g = fixture();
g.players[2].hand = [worthless[0]];
g.players[1].hand = [karamas[0]];
g = applyAction(g, 'b', { type: 'passResponse' });
g = cancel(g, 'c', worthless[0]);
assert.equal(g.response?.kind, 'worthlessKarama');
assert.deepEqual(
g.pendingKarama?.use.kind === 'cancel'
? g.pendingKarama.use.response.passed
: [],
['b'],
);
g = cancel(reload(g), 'b', karamas[0]);
assert.equal(g.response, null);
assert.equal(g.pendingKarama, null);
assert.equal(g.players[0].spice, 13);
assert.equal(
g.discard.filter((c) => [worthless[0].id, karamas[0].id].includes(c.id))
.length,
2,
);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
});
void test('pending conversion never offers a nested Worthless conversion; real Karama still blocks it', () => {
const g = fixture();
g.players[2].hand = [worthless[0], karamas[0]];
g.response = { kind: 'worthlessKarama', owner: 'b', passed: [] };
g.pendingKarama = {
owner: 'b',
use: {
kind: 'cancel',
response: { kind: 'guildIncome', owner: 'a', amount: 3, passed: [] },
},
};
assert.deepEqual(ids(g, 'c'), [karamas[0].id]);
const before = JSON.stringify(g);
assert.throws(
() => cancel(g, 'c', worthless[0]),
/conversion is already pending/,
);
assert.equal(JSON.stringify(g), before);
const next = cancel(g, 'c', karamas[0]);
assert.equal(next.response?.kind, 'guildIncome');
assert.deepEqual(ids(next, 'c'), [worthless[0].id]);
});
void test('Truthtrance and phase-opening overlays pause normalization without payment or response alteration', () => {
for (const overlay of ['truth', 'phase'] as const) {
const g = fixture();
if (overlay === 'truth') {
const truth = cards.find((card) => card.effect === 'truthtrance')!;
placeFixtureHand(g, 1, [truth]);
g.truthtrance = {
stage: 'ask',
queue: [{ player: 'b', card: truth.id }],
passed: [],
question: null,
};
} else g.phaseOpening = { initialize: false, passed: [] };
const next = normalizeAutomaticGame(g);
assert.deepEqual(next.response, g.response);
assert.equal(next.players[0].spice, 10);
assert.deepEqual(next.log, g.log);
if (overlay === 'phase') {
let done = next;
for (const p of done.players)
done = applyAction(done, p.id, { type: 'ready' });
assert.equal(done.response, null);
assert.equal(done.players[0].spice, 13);
}
}
});
void test('promise-bound Worthless candidate is conservatively retained but an illegal voluntary spend rolls back', () => {
const g = fixture();
battle(g);
g.players[2].hand = [worthless[0]];
g.battle!.truthPromises = [
{
player: 'c',
asker: 'a',
claim: { kind: 'weapon', name: worthless[0].name },
answer: true,
},
];
const next = normalizeAutomaticGame(g);
assert.deepEqual(ids(next, 'c'), [worthless[0].id]);
assert.ok(next.response);
const before = JSON.stringify(next);
assert.throws(() => cancel(next, 'c', worthless[0]), /voluntarily/);
assert.equal(JSON.stringify(next), before);
});
void test('hand-exchange suspension waits; restored response uses newly returned card and original passes', () => {
let g = fixture(['harkonnen', 'emperor', 'guild']);
g.pendingExchange = {
response: { kind: 'guildIncome', owner: 'c', amount: 3, passed: ['a'] },
decision: null,
};
g.response = null;
g.decision = { kind: 'handExchange', player: 'a', target: 'b', count: 1 };
g.players[0].hand = [karamas[0]];
g = normalizeAutomaticGame(g);
assert.equal(g.players[2].spice, 10);
assert.equal(g.decision?.kind, 'handExchange');
g = applyAction(g, 'a', { type: 'decision', returnCards: [karamas[0].id] });
assert.deepEqual(g.response?.passed, ['a']);
assert.deepEqual(ids(g, 'b'), [karamas[0].id]);
g = applyAction(g, 'b', { type: 'passResponse' });
assert.equal(g.players[2].spice, 13);
assert.equal(g.response, null);
});
void test('setAutopilot preserves uncancelable response and exact gameplay state until authoritative normalization', () => {
const initial = auction();
const next = applyAction(initial, 'c', {
type: 'setAutopilot',
difficulty: 'Hard',
});
const comparison = structuredClone(next);
delete comparison.players[2].autopilot;
comparison.log = initial.log;
assert.deepEqual(comparison, initial);
const done = normalizeAutomaticGame(next);
assert.equal(done.response, null);
assert.equal(done.players[0].hand.length, 2);
});
void test('setup completion waits for a real response then resumes automatically after its last choice', () => {
const g = fixture(['guild', 'emperor'], false);
g.status = 'setup';
g.phase = 0;
g.turn = 1;
g.players.forEach((p) => {
p.traitors = [p.leaders[0].id];
});
g.players[1].hand = [karamas[0]];
const waiting = normalizeAutomaticGame(g);
assert.equal(waiting.status, 'setup');
assert.equal(waiting.players[0].spice, 10);
const done = applyAction(waiting, 'b', { type: 'passResponse' });
assert.equal(done.status, 'playing');
assert.equal(done.response, null);
assert.equal(done.players[0].spice, 13);
assert.equal(done.log.filter((e) => /Setup complete/.test(e.text)).length, 1);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
});
void test('summoned worm resolves its own window before restoring and automatically completing the saved response', () => {
// Isolate suspended-response ordering with a correctly seated Guild income owner.
let g = fixture(['fremen', 'guild', 'emperor']);
g.phase = 1;
g.storm = 18;
g.players[0].hand = [karamas[0]];
g.players[2].hand = [karamas[1]];
g.players[0].forces = { 'the_great_flat:15': 4 };
g.players[0].reserves = 16;
g.players[1].forces = { 'the_great_flat:15': 3 };
g.players[1].reserves = 17;
g.response = { kind: 'guildIncome', owner: 'b', amount: 3, passed: ['c'] };
g = applyAction(g, 'a', {
type: 'card',
mode: 'special',
card: karamas[0].id,
territory: 'the_great_flat',
});
assert.equal(g.response?.kind, 'wormSurvival');
assert.equal(g.summonedWorm?.resume.response?.kind, 'guildIncome');
assert.equal(g.players[1].spice, 10);
assert.equal(g.players[1].tanks, 0);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
g = applyAction(g, 'c', { type: 'passResponse' });
assert.equal(g.summonedWorm, null);
assert.equal(g.response, null);
assert.equal(g.players[1].spice, 13);
assert.equal(g.players[1].tanks, 3);
assert.equal(g.players[0].forces['the_great_flat:15'], 4);
assert.equal(g.discard.filter((c) => c.id === karamas[0].id).length, 1);
});
void test('Voice cancellation can preserve a promise; automatic allowance releases an incompatible promise', () => {
const g = fixture(['atreides', 'beneGesserit', 'emperor']);
battle(g);
g.players[2].hand = [shield];
g.players[0].hand = [karamas[0]];
g.battle!.voice = { target: 'c', kind: 'shield', must: false };
g.battle!.truthPromises = [
{
player: 'c',
asker: 'a',
claim: { kind: 'defense', name: shield.name },
answer: true,
},
];
g.response = { kind: 'voice', owner: 'b', passed: [] };
const waiting = normalizeAutomaticGame(g);
assert.equal(waiting.battle!.truthPromises![0].released, undefined);
const canceled = cancel(waiting, 'a', karamas[0]);
assert.equal(canceled.battle!.voice, undefined);
assert.equal(canceled.battle!.truthPromises![0].released, undefined);
const allowed = applyAction(waiting, 'a', { type: 'passResponse' });
assert.equal(allowed.response, null);
assert.equal(allowed.battle!.truthPromises![0].released, true);
});
void test('answering an active Truthtrance restores then drains the original response without another confirmation', () => {
const g = fixture();
const truth = cards.find((c) => c.effect === 'truthtrance')!;
g.players[1].hand = [truth];
g.truthtrance = {
stage: 'answer',
queue: [{ player: 'b', card: truth.id }],
passed: ['a', 'b', 'c'],
question: {
kind: 'fact',
target: 'a',
fact: { kind: 'hand', name: 'Shield' },
},
};
const done = applyAction(g, 'a', { type: 'truthAnswer', answer: 'no' });
assert.equal(done.truthtrance, null);
assert.equal(done.response, null);
assert.equal(done.players[0].spice, 13);
assert.equal(done.truthHistory?.length, 1);
});