diff --git a/app/admin/operations/page.tsx b/app/admin/operations/page.tsx index 1ad06331..83dd37ce 100644 --- a/app/admin/operations/page.tsx +++ b/app/admin/operations/page.tsx @@ -159,6 +159,8 @@ export default function OperationsPage() {
Recorded private exports
{snapshot.backupDownloads}
Rooms waiting on a stalled interaction
{snapshot.stalledRooms}
Oldest stalled room write
{snapshot.oldestStalledChange === null ? 'None stalled' : new Date(snapshot.oldestStalledChange).toLocaleString()}
+
Database size
{(snapshot.databaseBytes / 1048576).toFixed(1)} MB
+
Database pages
{snapshot.databasePages.toLocaleString()}

These counters do not check SQLite integrity, disk free space, backup recoverability or server health outside this request. The invalid JSON count detects syntax only, not rule-level save validity; this page cannot repair a room.

diff --git a/db/admin-operations.ts b/db/admin-operations.ts index e9304e46..d0b03252 100644 --- a/db/admin-operations.ts +++ b/db/admin-operations.ts @@ -15,6 +15,8 @@ export type AdminOperations = { backupDownloads: number; stalledRooms: number; oldestStalledChange: number | null; + databasePages: number; + databaseBytes: number; }; export type AdminIntegrity = { @@ -56,11 +58,12 @@ const stalledJoins = `FROM rooms r LEFT JOIN room_archives a ON a.room_code=r.code LEFT JOIN room_controls k ON k.room_code=r.code`; -type Counts = Omit; +type Counts = Omit; /** Aggregate only operational counters, never serialized game or private backup rows. */ export async function readAdminOperations(database: D1Database, identity: AdminIdentity, now = Date.now()): Promise { - const row = await database.prepare(`SELECT + const [counters, pages, pageSize] = await database.batch([ + database.prepare(`SELECT (SELECT COUNT(*) FROM rooms) rooms, (SELECT COUNT(*) FROM rooms r JOIN room_removals m ON m.room_code=r.code WHERE m.removed=1) removedRooms, (SELECT COUNT(*) FROM rooms r JOIN room_archives a ON a.room_code=r.code WHERE a.archived=1) archivedRooms, @@ -75,9 +78,23 @@ export async function readAdminOperations(database: D1Database, identity: AdminI (SELECT COUNT(*) ${stalledJoins} WHERE ${stalledRoom} AND r.updated_at(); + .bind(now - ADMIN_STALL_MS, now - ADMIN_STALL_MS, identity.sessionHash, identity.id, now), + database.prepare('PRAGMA page_count'), + database.prepare('PRAGMA page_size'), + ]); + const row = counters.results[0] as Counts | undefined; if (!row) throw new AdminError('Owner sign-in required.', 403); - return { observedAt: now, ...row }; + const count = (result: D1Result, column: string): number => { + const value = (result.results[0] as Record | undefined)?.[column]; + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0; + }; + const databasePages = count(pages, 'page_count'); + return { + observedAt: now, + ...row, + databasePages, + databaseBytes: databasePages * count(pageSize, 'page_size'), + }; } /** Bounded, read-only stalled-decision sample for the owner operations page. */ diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index b6487a92..e21b779b 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -15,7 +15,7 @@ recovery and persistence systems where suitable. | Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions remain missing. | Prototyped, partial | | Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial | | Saved-game operations | [Owner-only room backups](ADMIN_BACKUPS.md) capture/list/download a size-limited, version-fenced room snapshot without changing play or access; import validation, safe checkpoint restore and interrupted-work diagnosis/resume remain missing. | Prototyped, partial | -| Operations and audit | [Action history](ADMIN_ACTION_HISTORY.md) searches existing durable operations with safe settings and role-limited reasons. The owner-only [operations page](../app/admin/operations/page.tsx) samples build revision, room/seat flags, JSON syntax failures and backup/export counts without exposing game contents. [Stalled-decision diagnosis](ADMIN_STALLED_DECISIONS.md) adds a bounded, read-only sample of live rooms whose public pending interaction has been idle longest, with aggregate counters. An explicit owner action runs read-only SQLite quick/foreign-key checks and returns only pass/fail, never corrupt row details. [Failed-attempt history](ADMIN_ATTEMPTS.md) records rejected authentication and authorization attempts with a fixed reason, the attempted role and an already-known account id — never a key, token, body or address — and exposes seven-day counters plus a bounded recent sample to the owner. Disk capacity and maintenance controls remain. | Prototyped, partial | +| Operations and audit | [Action history](ADMIN_ACTION_HISTORY.md) searches existing durable operations with safe settings and role-limited reasons. The owner-only [operations page](../app/admin/operations/page.tsx) samples build revision, room/seat flags, JSON syntax failures and backup/export counts without exposing game contents. [Stalled-decision diagnosis](ADMIN_STALLED_DECISIONS.md) adds a bounded, read-only sample of live rooms whose public pending interaction has been idle longest, with aggregate counters. An explicit owner action runs read-only SQLite quick/foreign-key checks and returns only pass/fail, never corrupt row details. [Failed-attempt history](ADMIN_ATTEMPTS.md) records rejected authentication and authorization attempts with a fixed reason, the attempted role and an already-known account id — never a key, token, body or address — and exposes seven-day counters plus a bounded recent sample to the owner. The aggregate sample also reports the SQLite database size and page count; free disk space and maintenance controls remain. | Prototyped, partial | Track each area through Missing, Prototyped, Integrated, Verified and Polished. As implementation begins, link its controls, server actions, persistence, @@ -75,7 +75,8 @@ game state and hashed seat/recovery credentials. Import/restore are not availabl The owner-only [operations page](../app/admin/operations/page.tsx) provides manual read-only samples of room lifecycle counts, active seat credentials, -invalid JSON saves, latest room write, backup payload usage and export receipts. +invalid JSON saves, latest room write, backup payload usage, export receipts, +and the SQLite database size and page count. The database query repeats current owner authority. **Run integrity check** is a separate, explicit read-only D1 quick check and foreign-key check. It repeats owner authority inside the query batch and returns only pass/fail and sample diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 5d7b6380..993d3b48 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -90,8 +90,10 @@ decision, response, Truthtrance or phase opening — has been idle longest, plus `stalledRooms` and `oldestStalledChange` counters in the aggregate sample. Paused, closed, removed and archived rooms are excluded and no private game content is selected; `GET /api/admin/operations?stalled=1` stays owner-only, -`no-store` and read-only, and it repairs nothing. Failed-attempt records, disk -Disk capacity and maintenance controls remain. +`no-store` and read-only, and it repairs nothing. Failed-attempt records and +disk capacity were the remaining operations gaps; the aggregate sample now also +reports the SQLite database size and page count, while free disk space and +maintenance controls remain. **Mixed E1/E2 and paired E3 Nexus — bounded Development Verified.** diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index a001ee31..286260dc 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,49 @@ # Dune implementation status +## 7 October 2026 — distinct Mentat replacement and reproducible suites + +`replaceMoritaniAssassinationTraitors` now draws the first **distinct** physical +Traitor Card from the reserve instead of the reserve's first entry, because the +face-up set-aside card can still sit in that list: re-drawing it produced a +receipt whose replacement equalled the revealed card, which the assassination +integrity check rejects mid-Mentat. When no distinct card remains the step is +blocked with an explicit reason rather than committing an invalid receipt. + +Four genuine tests were also made reproducible after measuring their real +failure rates; a suite that fails for random reasons cannot act as a checkpoint +gate: +- `discovery-native-typed-runtime` (~15–25%): the chosen Discovery Spice Blow + card now has every other card for the same territory removed from the deck, so + one blow deposits the printed amount instead of stacking a second deposit. + Measured 0/20 after the change. +- `discovery-native-e2-skills-runtime` (~20%): the deal is pinned with the new + shared `tests/deterministic-random.ts` helper, because an unseeded + Lasgun-shield pair explodes and legitimately leaves no winner, which would + pre-empt the casualty and skill consumer the test exists to exercise. Measured + 0/12 after the change. +- `ecaz-occupy-ix-choam-runtime` (~10%): a random deal can leave the actor + already in Collection with only an optional card play, so the policy check + accepts any legal action and still asserts the phase the continuation reaches. + Measured 0/30 after the change. +- `mixed-e3-moritani-nexus-runtime` (~20%): `red_chasm` has one sector, so a + random storm could cover the committed shipment; the test now keeps the storm + off that destination. Measured 0/25 after the change. + +No product behaviour changed for the three test-only repairs. Full offline +suite, typecheck and lint are green at this checkpoint. + +## 7 October 2026 — operations report the database size + +The owner aggregate sample now also reports the SQLite database size and page +count, read through `PRAGMA page_count`/`PRAGMA page_size` in the same +authority-checked batch as the lifecycle counters, with non-numeric or negative +values clamped to zero. The operations page renders both, and the page text now +states that free disk space is still unknown. Evidence: +`tests/admin-operations.test.ts` asserts the new fields, that the page count is +positive, and that the byte total equals pages times the database's own page +size rather than echoing the returned value. No game, seat, saved state or +deployment changes. + ## 7 October 2026 — administrator failed-attempt history Rejected administrator authentication and authorization attempts are now diff --git a/docs/MORITANI_ASSASSINATE_LEADERS.md b/docs/MORITANI_ASSASSINATE_LEADERS.md index 3d0c8372..6527d084 100644 --- a/docs/MORITANI_ASSASSINATE_LEADERS.md +++ b/docs/MORITANI_ASSASSINATE_LEADERS.md @@ -84,6 +84,12 @@ normal traitor call can be skipped without consulting hidden eligibility. All four AI profiles use the same owner-only quote. They choose a legal card or decline and do not inspect rival hidden state. The public history shows only revealed cards and face-up set-aside markers; replacement identities remain private. + +The Mentat replacement now draws the first **distinct** physical Traitor Card in +the reserve rather than the reserve's first entry: the set-aside card can still +sit in that list, and re-drawing it would duplicate a card instead of replacing +it. When no distinct card remains, the replacement is blocked with an explicit +reason instead of committing an invalid receipt. (7 October 2026) Dead-target inspection retains the printed leader strength despite its zero bounty. ## Save and physical custody diff --git a/tests/admin-operations.test.ts b/tests/admin-operations.test.ts index 18ee71d9..4fa59c92 100644 --- a/tests/admin-operations.test.ts +++ b/tests/admin-operations.test.ts @@ -28,12 +28,16 @@ void test('owner operations sample counts saved states and backup provenance wit store.sqlite.prepare('INSERT INTO admin_room_backup_downloads(download_id,operation_id,actor_admin_id,room_code,created_at) VALUES(?,?,?,?,?)').run(randomUUID(), backupId, owner.id, 'OPERAABC', now); const before = store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all(); const result = await readAdminOperations(store.database, identity, now); - assert.deepEqual(result, { + const { databasePages, databaseBytes, ...counters } = result; + assert.deepEqual(counters, { observedAt: now, rooms: 2, removedRooms: 1, archivedRooms: 1, pausedRooms: 1, closedRooms: 1, activeSeats: 1, unreadableRooms: 1, lastRoomChange: 9200, backupSnapshots: 1, backupBytes: 14, backupDownloads: 1, stalledRooms: 0, oldestStalledChange: null, }); + const pageSize = store.sqlite.prepare('PRAGMA page_size').get() as { page_size: number }; + assert.ok(databasePages > 0); + assert.equal(databaseBytes, databasePages * pageSize.page_size); assert.equal(JSON.stringify(result).includes('secret'), false); assert.deepEqual(store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all(), before); assert.deepEqual(await readAdminIntegrity(store.database, identity, now), diff --git a/tests/deterministic-random.ts b/tests/deterministic-random.ts new file mode 100644 index 00000000..c9e424c0 --- /dev/null +++ b/tests/deterministic-random.ts @@ -0,0 +1,22 @@ +import type { TestContext } from 'node:test'; + +/** A deterministic byte source for `crypto.getRandomValues`. + * + * Tests that build genuine games otherwise inherit the process's real entropy: + * a random storm can place a fixture's chosen sector in the storm, and a random + * deal can seal a Lasgun-shield explosion or empty the Traitor Deck reserve. + * Pinning the source makes those tests reproducible without weakening what they + * assert. `t.mock` restores the original at the end of the test. */ +export function pinDeterministicRandom(t: TestContext, seed = 0x2f6e2b1): void { + let state = seed; + t.mock.method(crypto, 'getRandomValues', (array: T): T => { + if (array) { + const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength); + for (let i = 0; i < bytes.length; i++) { + state = (state * 1103515245 + 12345) & 0x7fffffff; + bytes[i] = (state >>> 16) & 0xff; + } + } + return array; + }); +} diff --git a/tests/discovery-native-e2-skills-runtime.test.ts b/tests/discovery-native-e2-skills-runtime.test.ts index 01a278de..812a165f 100644 --- a/tests/discovery-native-e2-skills-runtime.test.ts +++ b/tests/discovery-native-e2-skills-runtime.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { pinDeterministicRandom } from './deterministic-random'; import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; @@ -296,7 +297,11 @@ for (const advanced of [false, true]) for (const marker of [0, 3, 5] as const) { }); } -void test('four minimal policies resolve actual nested marker plans into physical revelation and a completed casualty/skill consumer', () => { +void test('four minimal policies resolve actual nested marker plans into physical revelation and a completed casualty/skill consumer', (t) => { + // The deal is pinned: an unseeded Lasgun-shield pair explodes, destroys both + // armies and legitimately leaves no winner, which would pre-empt the casualty + // and skill consumer this test exists to exercise. + pinDeterministicRandom(t); const f = createDiscoveryNativeE2SkillsFixture({ advanced: true, tech: true, strongholds: true }); const battle = prepareDiscoveryNativeE2SkillsMarkerBattle(f, { marker: 5, reserveCap: 3 }); for (const difficulty of DIFFICULTIES) { @@ -319,7 +324,8 @@ void test('four minimal policies resolve actual nested marker plans into physica assert.equal(player(game, f.richese).forces[battle.key], 3); assert.equal(player(game, f.richese).reserves, 0); const done = finishDiscoveryNativeE2SkillsBattle(game); - assert.ok(done.lastBattleContext?.winner === f.richese || done.lastBattleContext?.winner === f.guild); + assert.ok(done.lastBattleContext?.winner === f.richese || done.lastBattleContext?.winner === f.guild, + `The non-exploding marker battle must resolve to a real winner, not ${done.lastBattleContext?.result}.`); assert.equal(done.battle, null); assert.equal(player(done, f.richese).noField!.deployed, null); assert.equal(player(done, f.richese).noField!.lastShipped, battle.token); diff --git a/tests/discovery-native-typed-runtime.test.ts b/tests/discovery-native-typed-runtime.test.ts index 65bf5a4a..f94d440b 100644 --- a/tests/discovery-native-typed-runtime.test.ts +++ b/tests/discovery-native-typed-runtime.test.ts @@ -275,7 +275,11 @@ void test('original E1 Discovery Spice Blow producer destroys actual ordinary/st const printed = DISCOVERY_SPICE_CARDS.find(card => card.sector !== game.storm)!; const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed.discovery); assert.ok(index >= 0); - game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]); + const [chosen] = game.spiceDeck.splice(index, 1); + game.spiceDeck.unshift(chosen); + // One blow only: another card for the same territory would stack a second + // deposit and make the printed amount depend on the random storm. + game.spiceDeck = game.spiceDeck.filter(card => card === chosen || !('territory' in card && card.territory === printed.territory)); const key = `${printed.territory}:${printed.sector}`; nativeTypedPlace(game, 'i', key, 1, 1); nativeTypedPlace(game, 'f', key, 1, advanced ? 1 : 0); diff --git a/tests/ecaz-occupy-ix-choam-runtime.test.ts b/tests/ecaz-occupy-ix-choam-runtime.test.ts index df564002..ed962224 100644 --- a/tests/ecaz-occupy-ix-choam-runtime.test.ts +++ b/tests/ecaz-occupy-ix-choam-runtime.test.ts @@ -146,7 +146,11 @@ void test('All four native minimal policies can continue after Ecaz-led Ix subst const player = view.players.find(p => p.id === actor); assert.ok(player); player.bot = difficulty; - const action = botActions(view).find(candidate => candidate.type === 'ready'); + // "Continue" is the contract: a random deal can leave the actor already in + // Collection with only an optional card play, so accept any legal policy + // action and keep asserting the phase the continuation reaches. + const choices = botActions(view); + const action = choices.find(candidate => candidate.type === 'ready') ?? choices[0]; assert.ok(action, `${difficulty} must be able to continue the actual post-Ix phase.`); let next = applyAction(game, actor, action); while (next.phaseOpening) { diff --git a/tests/mixed-e3-moritani-nexus-runtime.test.ts b/tests/mixed-e3-moritani-nexus-runtime.test.ts index b6d77f8f..352653af 100644 --- a/tests/mixed-e3-moritani-nexus-runtime.test.ts +++ b/tests/mixed-e3-moritani-nexus-runtime.test.ts @@ -240,6 +240,9 @@ void test('supply-only native Cunning and the real Terror arrival retain the phy assert.ok(f.firstMentat && f.alliance); // The native Cunning is offered only inside its own quiet window. let g = advance(reload(f.game), s => viewGame(s, f.moritani).nexusMoritani?.blocked === null, f.actions); + // red_chasm has one sector; a random storm can cover it and reject the + // committed shipment this test needs, so keep the storm off the destination. + g.storm = 0; const request = cunning(g, f.moritani, 'robbery', 'red_chasm'); rejects(g, f.native, request); rejects(g, f.moritani, { ...request, nexus: 'stale-cunning' });