Files
Dune/tests/discovery-shrine-engine.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

443 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { createDiscoveryState } from '../game/discoveries';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { truthFactAnswer } from '../game/truthtrance';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/link'
? 'vinext/shims/link'
: specifier === 'next/image'
? 'vinext/shims/image'
: specifier,
context,
);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries(
[...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((match) => [
match[1],
match[1],
]),
);
return {
format: 'module',
source: `export default ${JSON.stringify(classes)}`,
shortCircuit: true,
};
},
});
const { Truthtrance } = await import('../components/truthtrance');
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value));
function fixture(advanced = true) {
const game = createGame('SHRINE01', newPlayer('a', 'Atreides', 'atreides'));
game.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('g', 'Guild', 'guild'),
);
Object.assign(game, {
status: 'playing',
phase: 4,
turn: 2,
active: 'a',
advanced,
discoveryEnabled: true,
discoveries: createDiscoveryState(() => 0),
order: ['a', 'e', 'g'],
deck: baseDeck(),
response: null,
decision: null,
phaseOpening: null,
});
for (const player of game.players) {
player.hand = [];
player.forces = {};
player.traitorChoices = [];
}
const shrine = game.discoveries!.tokens.find(
(token) => token.face === 'shrine',
)!;
Object.assign(shrine, {
status: 'placed',
territory: 'gara_kulon',
sector: 8,
revealedTurn: 1,
});
game.players[0].forces['shrine:0'] = 1;
game.players[0].reserves--;
return game;
}
function hold(game: Game, player: string, effect: 'karama' | 'truthtrance') {
const index = game.deck.findIndex((card) => card.effect === effect);
assert.ok(index >= 0, effect);
const card = game.deck.splice(index, 1)[0];
game.players.find((candidate) => candidate.id === player)!.hand.push(card);
return card;
}
function rejectUnchanged(game: Game, player: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, player, action));
assert.deepEqual(game, before);
}
function finishPriority(state: Game) {
let game = state;
while (game.truthtrance?.stage === 'priority') {
const player = game.players.find(
(candidate) => !game.truthtrance!.passed.includes(candidate.id),
)!;
game = applyAction(game, player.id, { type: 'truthPass' });
}
return game;
}
void test('a current Shrine occupant spends physical Truthtrance through ordinary Karama cancellation, shipment and purchase paths', () => {
for (const advanced of [false, true]) {
const cancellation = fixture(advanced);
const card = hold(cancellation, 'a', 'truthtrance');
cancellation.players[1].ally = 'g';
cancellation.players[2].ally = 'e';
cancellation.response = {
kind: 'emperorGift',
owner: 'e',
recipient: 'g',
amount: 5,
passed: [],
};
const beforeView = viewGame(cancellation, 'a');
assert.equal(beforeView.players[0].hand![0].effect, 'truthtrance');
assert.ok(beforeView.responseControls!.cancelCards.includes(card.id));
assert.equal(
truthFactAnswer(cancellation.players[0], {
kind: 'handInventory',
category: 'special',
compare: 'eq',
value: 1,
}),
'yes',
);
const canceled = applyAction(reload(cancellation), 'a', {
type: 'card',
card: card.id,
mode: 'cancel',
});
assert.equal(canceled.response, null);
assert.equal(canceled.pendingKarama ?? null, null);
assert.equal(canceled.discard.at(-1)?.id, card.id);
assert.equal(canceled.discard.at(-1)?.effect, 'truthtrance');
assert.ok(
canceled.log.some((entry) =>
entry.text.includes('used Truthtrance as Karama'),
),
);
const shipment = fixture(advanced);
const shippingCard = hold(shipment, 'a', 'truthtrance');
shipment.phase = 5;
const shipped = applyAction(shipment, 'a', {
type: 'card',
card: shippingCard.id,
mode: 'shipment',
target: 'a',
});
assert.deepEqual(shipped.karamaShipping, {
player: 'a',
owner: 'a',
card: shippingCard.id,
});
assert.equal(shipped.discard.at(-1)?.effect, 'truthtrance');
const purchase = fixture(advanced);
const purchaseCard = hold(purchase, 'a', 'truthtrance');
purchase.phase = 3;
const lot = purchase.deck.shift()!;
purchase.auction = {
cards: [lot],
index: 0,
bid: 0,
bidder: null,
active: 'a',
passed: [],
opener: 0,
};
const bought = applyAction(purchase, 'a', {
type: 'card',
card: purchaseCard.id,
mode: 'purchase',
});
assert.ok(bought.players[0].hand.some((card) => card.id === lot.id));
assert.equal(bought.discard.at(-1)?.id, purchaseCard.id);
assert.equal(bought.discard.at(-1)?.effect, 'truthtrance');
}
});
void test('hidden Shrine, nonoccupants, advisors, and departed occupants cannot start a converted use', () => {
const cases: [string, (game: Game) => void][] = [
['hidden', (game: Game) => {
game.discoveries!.tokens.find((token) => token.face === 'shrine')!.revealedTurn =
null;
}],
['departed', (game: Game) => {
game.players[0].forces = {};
}],
['advisor', (game: Game) => {
game.players[0].faction = 'beneGesserit';
game.players[0].advisors = { shrine: {} };
game.players[1].forces['shrine:0'] = 1;
game.players[1].reserves--;
}],
];
for (const [label, alter] of cases) {
const game = fixture();
const card = hold(game, 'a', 'truthtrance');
alter(game);
game.players[1].ally = 'g';
game.players[2].ally = 'e';
game.response = {
kind: 'emperorGift',
owner: 'e',
recipient: 'g',
amount: 5,
passed: [],
};
assert.ok(
!viewGame(game, 'a').responseControls!.cancelCards.includes(card.id),
label,
);
rejectUnchanged(game, 'a', {
type: 'card',
card: card.id,
mode: 'cancel',
});
}
});
void test('a physical card reserved for a pending Richese gift cannot also enter the Truthtrance queue', () => {
let game = fixture();
const owner = game.players[0];
owner.faction = 'richese';
owner.ally = 'e';
game.players[1].ally = owner.id;
const karama = richeseCards().find((card) => card.effect === 'karama')!;
owner.hand = [karama];
hold(game, 'g', 'karama');
game = applyAction(game, owner.id, { type: 'richeseGift', card: karama.id });
assert.equal(game.pendingRicheseGift?.intent.cardId, karama.id);
rejectUnchanged(game, owner.id, {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
});
});
void test('a Shrine occupant can spend physical Truthtrance on an implemented special Karama power', () => {
const game = fixture();
const emperor = game.players[0];
emperor.faction = 'emperor';
emperor.hand = [];
emperor.tanks = 3;
emperor.reserves = 17;
const truthtrance = hold(game, emperor.id, 'truthtrance');
const markup = renderToStaticMarkup(
createElement(GameTable, {
game: viewGame(game, emperor.id),
send: async () => {},
onExit() {},
busy: false,
}),
);
assert.match(markup, /Use special Karama · forces/);
const done = applyAction(game, emperor.id, {
type: 'card',
mode: 'special',
card: truthtrance.id,
amount: 3,
elite: 0,
});
assert.equal(done.players[0].specialKaramaUsed, true);
assert.equal(done.players[0].tanks, 0);
assert.equal(done.players[0].reserves, 20);
assert.equal(done.discard.at(-1)?.id, truthtrance.id);
assert.equal(done.discard.at(-1)?.effect, 'truthtrance');
assert.ok(
done.log.some((entry) =>
entry.text.includes('physical Truthtrance as Karama'),
),
);
});
void test('Karama committed as Truthtrance keeps its Shrine receipt through JSON, departure, answer, and discard continuation', () => {
let game = fixture();
const karama = hold(game, 'a', 'karama');
hold(game, 'e', 'truthtrance');
game.players[1].ally = 'g';
game.players[2].ally = 'e';
const suspended = {
kind: 'emperorGift' as const,
owner: 'e',
recipient: 'g',
amount: 5,
passed: [] as string[],
};
game.response = suspended;
game = applyAction(game, 'a', {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
});
assert.deepEqual(game.truthtrance!.queue[0], {
player: 'a',
card: karama.id,
source: 'shrine',
});
const pending = reload(game);
assert.deepEqual(pending.response, suspended);
assert.deepEqual(normalizeAutomaticGame(reload(pending)), pending);
pending.players[0].forces = { 'arrakeen:10': 1 };
game = finishPriority(pending);
game = applyAction(game, 'a', {
type: 'truthAsk',
question: {
kind: 'fact',
target: 'e',
fact: { kind: 'hand', name: 'Truthtrance' },
},
});
assert.deepEqual(game.response, suspended);
assert.equal(viewGame(reload(game), 'e').truthAnswer, 'yes');
game = applyAction(reload(game), 'e', {
type: 'truthAnswer',
answer: 'yes',
});
assert.equal(game.truthtrance, null);
assert.equal(game.truthHistory!.at(-1)?.answer, 'yes');
assert.equal(game.discard.at(-1)?.id, karama.id);
assert.equal(game.discard.at(-1)?.effect, 'karama');
const forged = reload(pending);
delete forged.truthtrance!.queue[0].source;
assert.throws(() => viewGame(forged, 'a'), /committed physical card/);
});
void test('an unknown converted question may save the same Karama, but departure revokes a fresh conversion', () => {
let game = fixture();
const karama = hold(game, 'a', 'karama');
game = finishPriority(
applyAction(game, 'a', {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
}),
);
game = applyAction(game, 'a', {
type: 'truthAsk',
question: {
kind: 'freeform',
target: 'e',
text: 'Will the next battle be won by a traitor?',
scope: 'currentTurn',
},
});
game = applyAction(game, 'e', { type: 'truthAnswer', answer: 'unknown' });
game = applyAction(reload(game), 'a', { type: 'truthSave' });
assert.equal(game.truthtrance, null);
assert.equal(game.players[0].hand[0].id, karama.id);
game.players[0].forces = {};
rejectUnchanged(game, 'a', {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
});
});
void test('all four AI profiles use the converted Truthtrance control and complete the existing question path', () => {
for (const difficulty of DIFFICULTIES) {
let game = fixture();
const karama = hold(game, 'a', 'karama');
game.phase = 6;
game.battle = {
territory: 'arrakeen',
attacker: 'a',
defender: 'e',
prepared: true,
plans: {},
revealed: false,
traitorCalls: {},
};
for (const player of game.players) player.bot = difficulty;
const action = botActions(viewGame(game, 'a'))[0];
assert.deepEqual(action, {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
});
game = applyAction(game, 'a', action);
while (game.truthtrance?.stage === 'priority') {
const next = game.players.find(
(player) => !game.truthtrance!.passed.includes(player.id),
)!;
const pass = botActions(viewGame(game, next.id))[0];
assert.deepEqual(pass, { type: 'truthPass' });
game = applyAction(game, next.id, pass);
}
const question = botActions(viewGame(game, 'a'))[0];
assert.equal(question.type, 'truthAsk');
game = applyAction(game, 'a', question);
const target = game.truthtrance!.question!.target;
game.players.find((player) => player.id === target)!.bot = difficulty;
const answer = botActions(viewGame(game, target))[0];
assert.equal(answer.type, 'truthAnswer');
game = applyAction(game, target, answer);
assert.equal(game.truthtrance, null);
assert.equal(game.discard.at(-1)?.id, karama.id);
}
});
void test('Truthtrance priority renders the owner conversion without exposing another hand', () => {
let game = fixture();
const initiator = hold(game, 'a', 'truthtrance');
const karama = hold(game, 'e', 'karama');
const truthtrance = hold(game, 'e', 'truthtrance');
game.players[1].forces['shrine:0'] = 1;
game.players[1].reserves--;
game = applyAction(game, 'a', { type: 'card', card: initiator.id });
const view = viewGame(game, 'e');
const markup = renderToStaticMarkup(
createElement(Truthtrance, { game: view, busy: false, act() {} }),
);
assert.match(markup, /Use my Karama as Truthtrance/);
assert.match(markup, /Declare my Truthtrance/);
assert.match(markup, /Declare all 2 as Truthtrance/);
assert.deepEqual(
view.players[1].hand!.map((card) => card.id),
[karama.id, truthtrance.id],
);
assert.equal(view.players[0].hand, undefined);
});