Report database size and make four genuine suites reproducible
Operations now report the SQLite database size and page count, read through PRAGMA page_count/page_size in the same authority-checked batch as the lifecycle counters with non-numeric values clamped to zero; the page renders both and states that free disk space is still unknown. replaceMoritaniAssassinationTraitors 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. With no distinct card left the step is blocked with an explicit reason instead of committing an invalid receipt. Four tests were made reproducible after measuring their real failure rates, because a suite that fails for random reasons cannot act as a checkpoint gate: the Discovery typed Spice Blow test now removes other cards for the same territory so one blow deposits the printed amount (0/20 after, ~20% before); the Discovery E2 skills deal is pinned with the new shared tests/deterministic-random.ts helper so a Lasgun-shield explosion cannot pre-empt its skill consumer (0/12 after, ~20% before); the Ecaz Occupy Ix/CHOAM policy check accepts any legal continuation and still asserts the phase it reaches (0/30 after, ~10% before); and the mixed-E3 Moritani Cunning test keeps the random storm off red_chasm's single sector (0/25 after, ~20% before). Offline suite 7,801/7,801 with typecheck and lint clean. No game, seat, saved state or deployment changes.
This commit is contained in:
1 parent
8bea4c1a0c
commit
e493bcf58e
12 files changed
+128
-13
No files matched your search
@@ -159,6 +159,8 @@ export default function OperationsPage() {
|
|||||||
<div><dt>Recorded private exports</dt><dd>{snapshot.backupDownloads}</dd></div>
|
<div><dt>Recorded private exports</dt><dd>{snapshot.backupDownloads}</dd></div>
|
||||||
<div><dt>Rooms waiting on a stalled interaction</dt><dd>{snapshot.stalledRooms}</dd></div>
|
<div><dt>Rooms waiting on a stalled interaction</dt><dd>{snapshot.stalledRooms}</dd></div>
|
||||||
<div><dt>Oldest stalled room write</dt><dd>{snapshot.oldestStalledChange === null ? 'None stalled' : new Date(snapshot.oldestStalledChange).toLocaleString()}</dd></div>
|
<div><dt>Oldest stalled room write</dt><dd>{snapshot.oldestStalledChange === null ? 'None stalled' : new Date(snapshot.oldestStalledChange).toLocaleString()}</dd></div>
|
||||||
|
<div><dt>Database size</dt><dd>{(snapshot.databaseBytes / 1048576).toFixed(1)} MB</dd></div>
|
||||||
|
<div><dt>Database pages</dt><dd>{snapshot.databasePages.toLocaleString()}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
<Button variant="outline" disabled={loadingStalled} onClick={() => void checkStalled()}>List stalled decisions</Button>
|
<Button variant="outline" disabled={loadingStalled} onClick={() => void checkStalled()}>List stalled decisions</Button>
|
||||||
|
|||||||
+21
-4
@@ -15,6 +15,8 @@ export type AdminOperations = {
|
|||||||
backupDownloads: number;
|
backupDownloads: number;
|
||||||
stalledRooms: number;
|
stalledRooms: number;
|
||||||
oldestStalledChange: number | null;
|
oldestStalledChange: number | null;
|
||||||
|
databasePages: number;
|
||||||
|
databaseBytes: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminIntegrity = {
|
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_archives a ON a.room_code=r.code
|
||||||
LEFT JOIN room_controls k ON k.room_code=r.code`;
|
LEFT JOIN room_controls k ON k.room_code=r.code`;
|
||||||
|
|
||||||
type Counts = Omit<AdminOperations, 'observedAt'>;
|
type Counts = Omit<AdminOperations, 'observedAt' | 'databasePages' | 'databaseBytes'>;
|
||||||
|
|
||||||
/** Aggregate only operational counters, never serialized game or private backup rows. */
|
/** Aggregate only operational counters, never serialized game or private backup rows. */
|
||||||
export async function readAdminOperations(database: D1Database, identity: AdminIdentity, now = Date.now()): Promise<AdminOperations> {
|
export async function readAdminOperations(database: D1Database, identity: AdminIdentity, now = Date.now()): Promise<AdminOperations> {
|
||||||
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) 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_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,
|
(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<?) stalledRooms,
|
(SELECT COUNT(*) ${stalledJoins} WHERE ${stalledRoom} AND r.updated_at<?) stalledRooms,
|
||||||
(SELECT MIN(r.updated_at) ${stalledJoins} WHERE ${stalledRoom} AND r.updated_at<?) oldestStalledChange
|
(SELECT MIN(r.updated_at) ${stalledJoins} WHERE ${stalledRoom} AND r.updated_at<?) oldestStalledChange
|
||||||
WHERE EXISTS (${ownerAuthority})`)
|
WHERE EXISTS (${ownerAuthority})`)
|
||||||
.bind(now - ADMIN_STALL_MS, now - ADMIN_STALL_MS, identity.sessionHash, identity.id, now).first<Counts>();
|
.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);
|
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<string, unknown> | 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. */
|
/** Bounded, read-only stalled-decision sample for the owner operations page. */
|
||||||
|
|||||||
+3
-2
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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.
|
Track each area through Missing, Prototyped, Integrated, Verified and Polished.
|
||||||
As implementation begins, link its controls, server actions, persistence,
|
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
|
The owner-only [operations page](../app/admin/operations/page.tsx) provides
|
||||||
manual read-only samples of room lifecycle counts, active seat credentials,
|
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
|
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
|
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
|
owner authority inside the query batch and returns only pass/fail and sample
|
||||||
|
|||||||
@@ -90,8 +90,10 @@ decision, response, Truthtrance or phase opening — has been idle longest, plus
|
|||||||
`stalledRooms` and `oldestStalledChange` counters in the aggregate sample.
|
`stalledRooms` and `oldestStalledChange` counters in the aggregate sample.
|
||||||
Paused, closed, removed and archived rooms are excluded and no private game
|
Paused, closed, removed and archived rooms are excluded and no private game
|
||||||
content is selected; `GET /api/admin/operations?stalled=1` stays owner-only,
|
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
|
`no-store` and read-only, and it repairs nothing. Failed-attempt records and
|
||||||
Disk capacity and maintenance controls remain.
|
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.**
|
**Mixed E1/E2 and paired E3 Nexus — bounded Development Verified.**
|
||||||
|
|||||||
@@ -1,5 +1,49 @@
|
|||||||
# Dune implementation status
|
# 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
|
## 7 October 2026 — administrator failed-attempt history
|
||||||
|
|
||||||
Rejected administrator authentication and authorization attempts are now
|
Rejected administrator authentication and authorization attempts are now
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
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.
|
Dead-target inspection retains the printed leader strength despite its zero bounty.
|
||||||
|
|
||||||
## Save and physical custody
|
## Save and physical custody
|
||||||
|
|||||||
@@ -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);
|
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 before = store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all();
|
||||||
const result = await readAdminOperations(store.database, identity, now);
|
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,
|
observedAt: now, rooms: 2, removedRooms: 1, archivedRooms: 1,
|
||||||
pausedRooms: 1, closedRooms: 1, activeSeats: 1, unreadableRooms: 1,
|
pausedRooms: 1, closedRooms: 1, activeSeats: 1, unreadableRooms: 1,
|
||||||
lastRoomChange: 9200, backupSnapshots: 1, backupBytes: 14, backupDownloads: 1,
|
lastRoomChange: 9200, backupSnapshots: 1, backupBytes: 14, backupDownloads: 1,
|
||||||
stalledRooms: 0, oldestStalledChange: null,
|
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.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(store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all(), before);
|
||||||
assert.deepEqual(await readAdminIntegrity(store.database, identity, now),
|
assert.deepEqual(await readAdminIntegrity(store.database, identity, now),
|
||||||
|
|||||||
@@ -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', <T extends ArrayBufferView | null>(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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { pinDeterministicRandom } from './deterministic-random';
|
||||||
import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||||
import { botActions } from '../game/bots';
|
import { botActions } from '../game/bots';
|
||||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
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 f = createDiscoveryNativeE2SkillsFixture({ advanced: true, tech: true, strongholds: true });
|
||||||
const battle = prepareDiscoveryNativeE2SkillsMarkerBattle(f, { marker: 5, reserveCap: 3 });
|
const battle = prepareDiscoveryNativeE2SkillsMarkerBattle(f, { marker: 5, reserveCap: 3 });
|
||||||
for (const difficulty of DIFFICULTIES) {
|
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).forces[battle.key], 3);
|
||||||
assert.equal(player(game, f.richese).reserves, 0);
|
assert.equal(player(game, f.richese).reserves, 0);
|
||||||
const done = finishDiscoveryNativeE2SkillsBattle(game);
|
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(done.battle, null);
|
||||||
assert.equal(player(done, f.richese).noField!.deployed, null);
|
assert.equal(player(done, f.richese).noField!.deployed, null);
|
||||||
assert.equal(player(done, f.richese).noField!.lastShipped, battle.token);
|
assert.equal(player(done, f.richese).noField!.lastShipped, battle.token);
|
||||||
|
|||||||
@@ -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 printed = DISCOVERY_SPICE_CARDS.find(card => card.sector !== game.storm)!;
|
||||||
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed.discovery);
|
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed.discovery);
|
||||||
assert.ok(index >= 0);
|
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}`;
|
const key = `${printed.territory}:${printed.sector}`;
|
||||||
nativeTypedPlace(game, 'i', key, 1, 1);
|
nativeTypedPlace(game, 'i', key, 1, 1);
|
||||||
nativeTypedPlace(game, 'f', key, 1, advanced ? 1 : 0);
|
nativeTypedPlace(game, 'f', key, 1, advanced ? 1 : 0);
|
||||||
|
|||||||
@@ -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);
|
const player = view.players.find(p => p.id === actor);
|
||||||
assert.ok(player);
|
assert.ok(player);
|
||||||
player.bot = difficulty;
|
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.`);
|
assert.ok(action, `${difficulty} must be able to continue the actual post-Ix phase.`);
|
||||||
let next = applyAction(game, actor, action);
|
let next = applyAction(game, actor, action);
|
||||||
while (next.phaseOpening) {
|
while (next.phaseOpening) {
|
||||||
|
|||||||
@@ -240,6 +240,9 @@ void test('supply-only native Cunning and the real Terror arrival retain the phy
|
|||||||
assert.ok(f.firstMentat && f.alliance);
|
assert.ok(f.firstMentat && f.alliance);
|
||||||
// The native Cunning is offered only inside its own quiet window.
|
// 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);
|
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');
|
const request = cunning(g, f.moritani, 'robbery', 'red_chasm');
|
||||||
rejects(g, f.native, request);
|
rejects(g, f.native, request);
|
||||||
rejects(g, f.moritani, { ...request, nexus: 'stale-cunning' });
|
rejects(g, f.moritani, { ...request, nexus: 'stale-cunning' });
|
||||||
|
|||||||
Reference in new issue
Block a user