Files
Dune/tests/discovery-shrine-recovery.test.ts
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

195 lines
5.8 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import type { DatabaseSync } from 'node:sqlite';
import { baseDeck } from '../game/cards';
import { createDiscoveryState } from '../game/discoveries';
import { createGame, newPlayer, type Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
function savedShrineGame(code: string, ids: [string, string, string]): Game {
const game = createGame(code, newPlayer(ids[0], 'Atreides', 'atreides'));
game.players.push(
newPlayer(ids[1], 'Emperor', 'emperor'),
newPlayer(ids[2], 'Guild', 'guild'),
);
Object.assign(game, {
status: 'playing',
phase: 4,
turn: 2,
active: ids[0],
advanced: true,
discoveryEnabled: true,
discoveries: createDiscoveryState(() => 0),
order: ids,
deck: baseDeck(),
response: {
kind: 'emperorGift',
owner: ids[1],
recipient: ids[2],
amount: 5,
passed: [],
},
decision: null,
phaseOpening: null,
});
for (const player of game.players) {
player.hand = [];
player.forces = {};
player.spice = 10;
player.traitorChoices = [];
}
game.players[1].ally = ids[2];
game.players[2].ally = ids[1];
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--;
const index = game.deck.findIndex((card) => card.effect === 'karama');
assert.ok(index >= 0);
game.players[0].hand.push(game.deck.splice(index, 1)[0]);
return game;
}
void test('SQLite restoration and competing Shrine declarations keep one physical Karama private and complete once', async () => {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
try {
const created = await store.rooms.createRoom(
'Shrine recovery',
'atreides',
false,
[],
);
const joined = await store.rooms.joinRoom(
created.view.code,
'Emperor',
'emperor',
);
const third = await store.rooms.joinRoom(
created.view.code,
'Guild',
'guild',
);
const auths = await Promise.all(
[created.token, joined.token!, third.token!].map((token) =>
store.rooms.authenticate(created.view.code, token),
),
);
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
const stored = await store.rooms.readRoom(created.view.code);
const game = savedShrineGame(created.view.code, ids);
game.version = stored.version;
const karama = game.players[0].hand[0];
sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(game), game.version, game.code);
const seats = sqlite
.prepare('SELECT * FROM seats ORDER BY player_id')
.all();
const action = {
type: 'card' as const,
card: karama.id,
shrineTruthtrance: [karama.id],
};
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
store.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
try {
const results = await Promise.allSettled(
[0, 1].map(() =>
store
.restart()
.act(game.code, auths[0], game.version, action, clock),
),
);
assert.equal(arrivals, 2);
assert.equal(
results.filter((result) => result.status === 'fulfilled').length,
1,
);
} finally {
clearTimeout(timer);
delete store.hooks.beforeWrite;
}
let saved = await store.restart().readRoom(game.code);
assert.equal(saved.version, game.version + 1);
assert.deepEqual(saved.truthtrance!.queue, [
{ player: ids[0], card: karama.id, source: 'shrine' },
]);
assert.equal(saved.players[0].hand[0].effect, 'karama');
const ownerView = await store.restart().readSeatView(game.code, auths[0]);
const otherView = await store.restart().readSeatView(game.code, auths[1]);
assert.equal(ownerView.players[0].hand![0].effect, 'karama');
assert.equal(otherView.players[0].hand, undefined);
for (let i = 1; i < auths.length; i++) {
await store
.restart()
.act(game.code, auths[i], saved.version, { type: 'truthPass' }, clock);
saved = await store.restart().readRoom(game.code);
}
assert.equal(saved.truthtrance!.stage, 'ask');
await store.restart().act(
game.code,
auths[0],
saved.version,
{
type: 'truthAsk',
question: {
kind: 'fact',
target: ids[1],
fact: { kind: 'spice', compare: 'eq', value: 10 },
},
},
clock,
);
saved = await store.restart().readRoom(game.code);
assert.deepEqual(saved.response, game.response);
await store.restart().act(
game.code,
auths[1],
saved.version,
{ type: 'truthAnswer', answer: 'yes' },
clock,
);
const done = await store.restart().readRoom(game.code);
assert.equal(done.truthtrance, null);
assert.equal(done.response, null);
assert.equal(done.players[1].spice, 5);
assert.equal(done.players[2].spice, 15);
assert.equal(
done.players[0].hand.some((card) => card.id === karama.id),
false,
);
assert.deepEqual(
done.discard.filter((card) => card.id === karama.id),
[{ ...karama, effect: 'karama' }],
);
assert.deepEqual(
sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
seats,
);
} finally {
sqlite.close();
}
});