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.
195 lines
5.8 KiB
TypeScript
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();
|
|
}
|
|
});
|