@eigeninteractive/testkit 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract-cli.d.ts +1 -2
- package/dist/contract-cli.js +21 -22
- package/dist/contract-cli.js.map +1 -1
- package/dist/contract-command-aCqO5CyJ.js +542 -0
- package/dist/contract-command-aCqO5CyJ.js.map +1 -0
- package/dist/index.d.ts +159 -8
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -55
- package/dist/index.js.map +1 -1
- package/dist/inspect-cli.d.ts +1 -16
- package/dist/inspect-cli.js +169 -153
- package/dist/inspect-cli.js.map +1 -1
- package/dist/local-state-CnO-Oi-R.js +467 -0
- package/dist/local-state-CnO-Oi-R.js.map +1 -0
- package/dist/local-state.d.ts +188 -224
- package/dist/local-state.d.ts.map +1 -0
- package/dist/local-state.js +2 -11
- package/package.json +10 -9
- package/src/contract-cli.ts +29 -0
- package/src/contract-command.ts +78 -0
- package/src/game-contract.ts +205 -0
- package/src/index.ts +53 -0
- package/src/inspect-cli.ts +231 -0
- package/{dist/kernel-scenarios.d.ts → src/kernel-scenarios.ts} +41 -5
- package/src/local-state.ts +638 -0
- package/src/twin-fixtures.ts +533 -0
- package/dist/chunk-BQIY636F.js +0 -483
- package/dist/chunk-BQIY636F.js.map +0 -1
- package/dist/chunk-YJE4AGHJ.js +0 -391
- package/dist/chunk-YJE4AGHJ.js.map +0 -1
- package/dist/contract-command.d.ts +0 -13
- package/dist/game-contract.d.ts +0 -58
- package/dist/local-state.js.map +0 -1
- package/dist/twin-fixtures.d.ts +0 -145
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emit the language-neutral contract a game's Flutter repository consumes.
|
|
3
|
+
*
|
|
4
|
+
* The authoritative inputs live with the TypeScript rules: the four payload
|
|
5
|
+
* schemas and the shared behavioral fixtures. The output is one deterministic
|
|
6
|
+
* JSON file so it can be copied, attached to a release, or fetched by checksum
|
|
7
|
+
* without assuming any repository layout.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { basename, relative, resolve } from "node:path";
|
|
12
|
+
import { assertPortableSchema, type GameModule, type StandardJSONSchemaV1 } from "@eigeninteractive/rules";
|
|
13
|
+
import { parseTwinFixtureFile } from "./twin-fixtures.js";
|
|
14
|
+
|
|
15
|
+
/** Current format of the language-neutral contract consumed by EigenInteractive's Dart generator. */
|
|
16
|
+
export const GAME_CONTRACT_FORMAT_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
/** One validated twin-fixture document embedded in a {@link GameContract}. */
|
|
19
|
+
export interface GameContractFixture {
|
|
20
|
+
/** POSIX-style path relative to the supplied fixtures root. */
|
|
21
|
+
path: string;
|
|
22
|
+
/** Validated fixture document, retained in its original JSON shape. */
|
|
23
|
+
document: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The four JSON Schemas emitted for one game `schemaVersion`. */
|
|
27
|
+
export interface GameContractVersion {
|
|
28
|
+
schemas: {
|
|
29
|
+
state: Record<string, unknown>;
|
|
30
|
+
observation: Record<string, unknown>;
|
|
31
|
+
action: Record<string, unknown>;
|
|
32
|
+
config: Record<string, unknown>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Language-neutral schemas and fixtures shared by a game's Worker and app. */
|
|
37
|
+
export interface GameContract {
|
|
38
|
+
formatVersion: typeof GAME_CONTRACT_FORMAT_VERSION;
|
|
39
|
+
game: string;
|
|
40
|
+
versions: Record<string, GameContractVersion>;
|
|
41
|
+
fixtures: GameContractFixture[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Inputs for building a {@link GameContract} without writing it. */
|
|
45
|
+
export interface BuildGameContractOptions {
|
|
46
|
+
/** Stable display name used as the generated Dart type prefix. */
|
|
47
|
+
game: string;
|
|
48
|
+
/** Authoritative TypeScript rules registry. */
|
|
49
|
+
gameModule: GameModule;
|
|
50
|
+
/** Root containing `v<N>/*.json` twin fixtures. */
|
|
51
|
+
fixturesRoot?: string | URL;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Inputs for emitting or checking a {@link GameContract} file. */
|
|
55
|
+
export interface EmitGameContractOptions extends BuildGameContractOptions {
|
|
56
|
+
/** Destination `game-contract.json` path. */
|
|
57
|
+
output: string | URL;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sort object keys so the same schemas always emit the same bytes.
|
|
62
|
+
*
|
|
63
|
+
* Ordering is by UTF-16 code unit — plain `Array#sort` — which is what RFC 8785
|
|
64
|
+
* specifies and what `tool/check-contracts.mjs` uses to digest a manifest. It is
|
|
65
|
+
* *not* `localeCompare`: that collates case-insensitively, so a `$defs` entry
|
|
66
|
+
* named `Move` sorts before `additionalProperties` under one rule and after it
|
|
67
|
+
* under the other. Generated JSON Schema is full of capitalized `$defs` names, so
|
|
68
|
+
* the two orders genuinely disagree, and a digest computed over one would never
|
|
69
|
+
* match a document written by the other.
|
|
70
|
+
*/
|
|
71
|
+
function canonical(value: unknown): unknown {
|
|
72
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
73
|
+
if (value !== null && typeof value === "object") {
|
|
74
|
+
return Object.fromEntries(
|
|
75
|
+
Object.entries(value as Record<string, unknown>)
|
|
76
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
77
|
+
.map(([key, item]) => [key, canonical(item)]),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Emit one payload schema as portable draft-2020-12 JSON Schema.
|
|
85
|
+
*
|
|
86
|
+
* Always the **output** direction. Zod's input schema omits
|
|
87
|
+
* `additionalProperties: false` on objects, because on input a permissive object
|
|
88
|
+
* still strips unknown keys rather than rejecting them — but the contract is what
|
|
89
|
+
* the Dart side generates a validator from, and an open object there means the two
|
|
90
|
+
* halves disagree about an unknown key. Schemas are required to be transform-free
|
|
91
|
+
* (see `GameSchemas`), so the two directions describe the same values and the
|
|
92
|
+
* output one is simply the honest document.
|
|
93
|
+
*
|
|
94
|
+
* The result is then checked against the portable profile, because emission is
|
|
95
|
+
* where an unportable schema can still be fixed cheaply. Skipping this check is
|
|
96
|
+
* how the profile came to be violated in the first place.
|
|
97
|
+
*/
|
|
98
|
+
function jsonSchema(schema: StandardJSONSchemaV1, label: string): Record<string, unknown> {
|
|
99
|
+
const options = { target: "draft-2020-12" as const };
|
|
100
|
+
let emitted: unknown;
|
|
101
|
+
try {
|
|
102
|
+
emitted = canonical(schema["~standard"].jsonSchema.output(options));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new Error(`${label} cannot emit its type as draft-2020-12 JSON Schema`, { cause: error });
|
|
105
|
+
}
|
|
106
|
+
assertPortableSchema(emitted, label);
|
|
107
|
+
return emitted as Record<string, unknown>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fixtureFiles(root: string): string[] {
|
|
111
|
+
const found: string[] = [];
|
|
112
|
+
const visit = (directory: string): void => {
|
|
113
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
114
|
+
const path = resolve(directory, entry.name);
|
|
115
|
+
if (entry.isDirectory()) visit(path);
|
|
116
|
+
else if (entry.isFile() && entry.name.endsWith(".json")) found.push(path);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
visit(root);
|
|
120
|
+
return found.sort();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fixtures(fixturesRoot: string | URL | undefined, versions: ReadonlySet<string>): GameContractFixture[] {
|
|
124
|
+
if (fixturesRoot === undefined) return [];
|
|
125
|
+
const root = fixturesRoot instanceof URL ? fixturesRoot.pathname : resolve(fixturesRoot);
|
|
126
|
+
return fixtureFiles(root).map((path) => {
|
|
127
|
+
const document = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
128
|
+
const fixture = parseTwinFixtureFile(path, document);
|
|
129
|
+
const fixturePath = relative(root, path).split("\\").join("/");
|
|
130
|
+
const directory = fixturePath.split("/")[0];
|
|
131
|
+
const match = /^v([1-9]\d*)$/.exec(directory ?? "");
|
|
132
|
+
if (match === null) {
|
|
133
|
+
throw new Error(`${fixturePath}: fixtures must live under a v<N>/ directory`);
|
|
134
|
+
}
|
|
135
|
+
const pathVersion = Number(match[1]);
|
|
136
|
+
if (pathVersion !== fixture.schemaVersion) {
|
|
137
|
+
throw new Error(`${fixturePath}: directory v${pathVersion} disagrees with schemaVersion ${fixture.schemaVersion}`);
|
|
138
|
+
}
|
|
139
|
+
if (!versions.has(String(fixture.schemaVersion))) {
|
|
140
|
+
throw new Error(`${fixturePath}: gameModule ships no rules unit for schemaVersion ${fixture.schemaVersion}`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
path: fixturePath,
|
|
144
|
+
document: canonical(document),
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Build a deterministic in-memory contract without touching the filesystem. */
|
|
150
|
+
export function buildGameContract(options: BuildGameContractOptions): GameContract {
|
|
151
|
+
const game = options.game.trim();
|
|
152
|
+
if (game.length === 0) throw new Error("game contract name must not be empty");
|
|
153
|
+
|
|
154
|
+
const versions = Object.fromEntries(
|
|
155
|
+
Object.entries(options.gameModule.versions)
|
|
156
|
+
.sort(([a], [b]) => Number(a) - Number(b))
|
|
157
|
+
.map(([version, rules]) => [
|
|
158
|
+
version,
|
|
159
|
+
{
|
|
160
|
+
schemas: {
|
|
161
|
+
state: jsonSchema(rules.schemas.state, `v${version} state`),
|
|
162
|
+
observation: jsonSchema(rules.schemas.observation, `v${version} observation`),
|
|
163
|
+
action: jsonSchema(rules.schemas.action, `v${version} action`),
|
|
164
|
+
config: jsonSchema(rules.schemas.config, `v${version} config`),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
]),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (Object.keys(versions).length === 0) {
|
|
171
|
+
throw new Error("game contract must contain at least one schema version");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
formatVersion: GAME_CONTRACT_FORMAT_VERSION,
|
|
176
|
+
game,
|
|
177
|
+
versions,
|
|
178
|
+
fixtures: fixtures(options.fixturesRoot, new Set(Object.keys(versions))),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Render one deterministic, newline-terminated contract document. */
|
|
183
|
+
export function gameContractJson(options: BuildGameContractOptions): string {
|
|
184
|
+
return `${JSON.stringify(buildGameContract(options), null, 2)}\n`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Emit one deterministic, newline-terminated `game-contract.json`. */
|
|
188
|
+
export function emitGameContract(options: EmitGameContractOptions): void {
|
|
189
|
+
const output = options.output instanceof URL ? options.output : resolve(options.output);
|
|
190
|
+
writeFileSync(output, gameContractJson(options));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Fail when an emitted contract is missing or differs from its inputs. */
|
|
194
|
+
export function checkGameContract(options: EmitGameContractOptions): void {
|
|
195
|
+
const output = options.output instanceof URL ? options.output : resolve(options.output);
|
|
196
|
+
if (!existsSync(output) || readFileSync(output, "utf8") !== gameContractJson(options)) {
|
|
197
|
+
throw new Error(`${output.toString()} is stale; run eigen-contract`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A useful default filename for scripts that accept an output directory. */
|
|
202
|
+
export const gameContractFilename = (game: string): string =>
|
|
203
|
+
`${basename(game)
|
|
204
|
+
.toLowerCase()
|
|
205
|
+
.replaceAll(/[^a-z0-9]+/g, "-")}-game-contract.json`;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@eigeninteractive/testkit`: drive a game's rules through the real kernel without a
|
|
3
|
+
* Worker, a database or a network. Build a table, submit actions as seats,
|
|
4
|
+
* assert on the resulting transitions and per-seat observations.
|
|
5
|
+
*
|
|
6
|
+
* @module @eigeninteractive/testkit
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { checkConfiguredGameContract, emitConfiguredGameContract } from "./contract-command.js";
|
|
10
|
+
export {
|
|
11
|
+
type BuildGameContractOptions,
|
|
12
|
+
buildGameContract,
|
|
13
|
+
checkGameContract,
|
|
14
|
+
type EmitGameContractOptions,
|
|
15
|
+
emitGameContract,
|
|
16
|
+
GAME_CONTRACT_FORMAT_VERSION,
|
|
17
|
+
type GameContract,
|
|
18
|
+
type GameContractFixture,
|
|
19
|
+
type GameContractVersion,
|
|
20
|
+
gameContractFilename,
|
|
21
|
+
gameContractJson,
|
|
22
|
+
} from "./game-contract.js";
|
|
23
|
+
export {
|
|
24
|
+
type CommitInput,
|
|
25
|
+
type CommitPlan,
|
|
26
|
+
commit,
|
|
27
|
+
DEADLINE_GRACE_MS,
|
|
28
|
+
deriveRng,
|
|
29
|
+
type Effect,
|
|
30
|
+
type GameRow,
|
|
31
|
+
type Intent,
|
|
32
|
+
isRejected,
|
|
33
|
+
type ObservationFrame,
|
|
34
|
+
projectView,
|
|
35
|
+
type RejectCode,
|
|
36
|
+
type Rejected,
|
|
37
|
+
randomSeed,
|
|
38
|
+
type Seat,
|
|
39
|
+
type SeatView,
|
|
40
|
+
type StateRow,
|
|
41
|
+
} from "./kernel-scenarios.js";
|
|
42
|
+
export {
|
|
43
|
+
type ActionCase,
|
|
44
|
+
type BotSeatableCase,
|
|
45
|
+
deepEquals,
|
|
46
|
+
evaluateTwinCase,
|
|
47
|
+
type PlayerLimitsCase,
|
|
48
|
+
parseTwinFixtureFile,
|
|
49
|
+
type RatingPoolCase,
|
|
50
|
+
type TwinFixtureCase,
|
|
51
|
+
type TwinFixtureFile,
|
|
52
|
+
twinFixtureTests,
|
|
53
|
+
} from "./twin-fixtures.js";
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `eigen-inspect`: read a Worker's local `.wrangler` state from the terminal.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately narrow. Wrangler already ships a first-party browser for local
|
|
6
|
+
* resources (press `e` in `wrangler dev`, or open `/cdn-cgi/explorer`), and
|
|
7
|
+
* generic table browsing is its job, not this tool's. What that view cannot do
|
|
8
|
+
* is answer a question in the engine's own terms, because a game's truth is
|
|
9
|
+
* split across two stores on purpose: the D1 index and the game's own Durable
|
|
10
|
+
* Object. This reads both, decodes the JSON columns, prints the transition log
|
|
11
|
+
* as a timeline, and says what the game is waiting for.
|
|
12
|
+
*
|
|
13
|
+
* It also runs with no dev server, no browser and no account, and emits
|
|
14
|
+
* `--json` for a script or an agent.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { parseArgs } from "node:util";
|
|
18
|
+
import type { LocalGameRow, LocalGameView, LocalSeat } from "./local-state.js";
|
|
19
|
+
import { LocalStore } from "./local-state.js";
|
|
20
|
+
|
|
21
|
+
const HELP = `Usage: eigen-inspect <command> [options]
|
|
22
|
+
|
|
23
|
+
Read the local .wrangler state of an EigenInteractive Worker: the D1 index and
|
|
24
|
+
each game's own Durable Object database.
|
|
25
|
+
|
|
26
|
+
Commands:
|
|
27
|
+
games The D1 index, newest first
|
|
28
|
+
game <id|code|prefix> One game across both stores, with its timeline
|
|
29
|
+
do Every local Durable Object, mapped back to its game
|
|
30
|
+
players Local users and bots, by id
|
|
31
|
+
tables [--game <ref>] Table names and row counts
|
|
32
|
+
sql <query> [--game <ref>]
|
|
33
|
+
Run a read-only statement against D1 or one game's DO
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--dir <path> Start the search for .wrangler/state here (default: cwd)
|
|
37
|
+
--status <s> games: filter by status
|
|
38
|
+
--limit <n> games: row cap (default 20)
|
|
39
|
+
--game <ref> tables/sql: target one game's Durable Object instead of D1
|
|
40
|
+
--json Emit JSON instead of a table
|
|
41
|
+
-h, --help Show this help
|
|
42
|
+
|
|
43
|
+
Every database is opened read-only, so this is safe to run while the Worker is
|
|
44
|
+
running. Wrangler's own browser for the same files is \`e\` in \`wrangler dev\`.`;
|
|
45
|
+
|
|
46
|
+
function relative(ms: number | null): string {
|
|
47
|
+
if (ms === null) return "never";
|
|
48
|
+
const delta = Date.now() - ms;
|
|
49
|
+
const future = delta < 0;
|
|
50
|
+
const seconds = Math.round(Math.abs(delta) / 1000);
|
|
51
|
+
const [value, unit] = seconds < 60 ? [seconds, "s"] : seconds < 3600 ? [Math.round(seconds / 60), "m"] : seconds < 86400 ? [Math.round(seconds / 3600), "h"] : [Math.round(seconds / 86400), "d"];
|
|
52
|
+
return future ? `in ${value}${unit}` : `${value}${unit} ago`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Render rows as an aligned table. Values are stringified defensively because
|
|
56
|
+
* SQLite hands back whatever the column holds, including blobs and nulls. */
|
|
57
|
+
function table(rows: Record<string, unknown>[]): string {
|
|
58
|
+
if (rows.length === 0) return "(no rows)";
|
|
59
|
+
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
60
|
+
const cell = (value: unknown): string => (value === null || value === undefined ? "" : typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
61
|
+
const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => cell(row[column]).length)));
|
|
62
|
+
const line = (values: string[]): string =>
|
|
63
|
+
values
|
|
64
|
+
.map((value, i) => value.padEnd(widths[i]))
|
|
65
|
+
.join(" ")
|
|
66
|
+
.trimEnd();
|
|
67
|
+
return [line(columns), line(widths.map((w) => "-".repeat(w))), ...rows.map((row) => line(columns.map((column, i) => cell(row[column]).padEnd(widths[i]))))].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function short(id: string | null, length = 8): string {
|
|
71
|
+
return id === null ? "" : id.length <= length ? id : `${id.slice(0, length)}…`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function seatLabel(seat: LocalSeat): string {
|
|
75
|
+
const identity = seat.username ?? short(seat.userId ?? seat.botId);
|
|
76
|
+
return `${seat.playerIndex}:${identity || "(empty)"}${seat.type === "bot" ? " (bot)" : ""}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function timingLabel(row: { turnSeconds: number | null; budgetSeconds: number | null; incrementSeconds: number | null }): string {
|
|
80
|
+
if (row.turnSeconds !== null) return `${row.turnSeconds}s per turn`;
|
|
81
|
+
if (row.budgetSeconds !== null) return `${row.budgetSeconds}s budget${row.incrementSeconds === null ? "" : ` +${row.incrementSeconds}s`}`;
|
|
82
|
+
return "untimed";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function gamesTable(rows: LocalGameRow[]): Record<string, unknown>[] {
|
|
86
|
+
return rows.map((row) => ({
|
|
87
|
+
id: short(row.id, 8),
|
|
88
|
+
code: row.shortCode,
|
|
89
|
+
status: row.status,
|
|
90
|
+
access: row.access,
|
|
91
|
+
seats: `${row.minPlayers}-${row.maxPlayers}`,
|
|
92
|
+
rated: row.rated ? "yes" : "no",
|
|
93
|
+
timing: timingLabel(row),
|
|
94
|
+
pending: row.pendingPlayers === null ? "" : JSON.stringify(row.pendingPlayers),
|
|
95
|
+
updated: relative(row.updatedAt),
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The `game` report: the whole session in reading order, index first, then the
|
|
100
|
+
* authoritative session, then the log, then the one sentence that explains it. */
|
|
101
|
+
function renderGame(view: LocalGameView): string {
|
|
102
|
+
const out: string[] = [];
|
|
103
|
+
const index = view.index;
|
|
104
|
+
out.push(`Game ${view.gameId}${index === null ? "" : ` ${index.shortCode}`}`);
|
|
105
|
+
|
|
106
|
+
if (index === null) {
|
|
107
|
+
out.push("", "D1 index (no games row)");
|
|
108
|
+
} else {
|
|
109
|
+
out.push(
|
|
110
|
+
"",
|
|
111
|
+
`D1 index status=${index.status} access=${index.access} rated=${index.rated ? "yes" : "no"} schema=v${index.schemaVersion}`,
|
|
112
|
+
` seats ${index.minPlayers}-${index.maxPlayers} ${timingLabel(index)} created ${relative(index.createdAt)} updated ${relative(index.updatedAt)}`,
|
|
113
|
+
`config ${JSON.stringify(index.config)}`,
|
|
114
|
+
`participants ${view.indexSeats.length === 0 ? "(none)" : view.indexSeats.map(seatLabel).join(" ")}`,
|
|
115
|
+
);
|
|
116
|
+
if (index.finishedAt !== null) out.push(`finished ${relative(index.finishedAt)} outcomes ${JSON.stringify(index.outcomes)}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
out.push("");
|
|
120
|
+
if (view.durableObject === null) {
|
|
121
|
+
out.push("Durable Object (none: no command or socket has touched this game yet)");
|
|
122
|
+
} else {
|
|
123
|
+
out.push(`Durable Object ${short(view.durableObject.id, 12)} ${view.durableObject.file}`);
|
|
124
|
+
const meta = view.meta;
|
|
125
|
+
if (meta === null) {
|
|
126
|
+
out.push("DO meta (no meta row: the object exists but never initialised)");
|
|
127
|
+
} else {
|
|
128
|
+
out.push(`DO meta status=${meta.status} seats ${meta.minPlayers}-${meta.maxPlayers} createdBy=${short(meta.createdBy)} rngSeed=${meta.rngSeed === null ? "(unset, so no start has committed)" : short(meta.rngSeed, 12)}`);
|
|
129
|
+
}
|
|
130
|
+
out.push(`roster ${view.roster.length === 0 ? "(empty)" : view.roster.map(seatLabel).join(" ")}`);
|
|
131
|
+
out.push(`alarm ${view.alarm === null || view.alarm.scheduledTime === null ? "none armed" : `${new Date(view.alarm.scheduledTime).toISOString()} (${relative(view.alarm.scheduledTime)})`}`);
|
|
132
|
+
if (view.outbox.length > 0) out.push(`outbox ${view.outbox.length} UNAPPLIED finish row(s): ${view.outbox.map((row) => short(row.finishId)).join(", ")}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
out.push("", "Timeline");
|
|
136
|
+
if (view.transitions.length === 0) {
|
|
137
|
+
out.push(" (no transitions: the game has not started, so there is no v0)");
|
|
138
|
+
} else {
|
|
139
|
+
for (const transition of view.transitions) {
|
|
140
|
+
const action = transition.action;
|
|
141
|
+
const cause = action === null ? "start" : `${action.kind}/${action.type}${action.playerIndex === null ? "" : ` seat ${action.playerIndex}`}`;
|
|
142
|
+
const payload = action === null || action.data === null || action.data === undefined ? "" : ` ${JSON.stringify(action.data)}`;
|
|
143
|
+
out.push(` v${String(transition.version).padEnd(3)} ${cause}${payload}`);
|
|
144
|
+
out.push(` state ${JSON.stringify(transition.state)}`);
|
|
145
|
+
out.push(` pending ${JSON.stringify(transition.pending)} deadline ${transition.deadline === null ? "none" : relative(transition.deadline)} frames ${transition.frameSeats.length === 0 ? "(compacted)" : JSON.stringify(transition.frameSeats)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
out.push("", `Diagnosis ${view.diagnosis.summary}`);
|
|
150
|
+
for (const drift of view.diagnosis.mirrorDrift) out.push(`Mirror drift ${drift}`);
|
|
151
|
+
return out.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function main(): void {
|
|
155
|
+
const { values, positionals } = parseArgs({
|
|
156
|
+
allowPositionals: true,
|
|
157
|
+
options: {
|
|
158
|
+
dir: { type: "string" },
|
|
159
|
+
status: { type: "string" },
|
|
160
|
+
limit: { type: "string" },
|
|
161
|
+
game: { type: "string" },
|
|
162
|
+
json: { type: "boolean" },
|
|
163
|
+
help: { type: "boolean", short: "h" },
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const [command, ...rest] = positionals;
|
|
168
|
+
if (values.help === true || command === undefined) {
|
|
169
|
+
console.log(HELP);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const emit = (value: unknown, text: () => string): void => {
|
|
174
|
+
console.log(values.json === true ? JSON.stringify(value, null, 2) : text());
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const store = LocalStore.open(values.dir);
|
|
178
|
+
try {
|
|
179
|
+
switch (command) {
|
|
180
|
+
case "games": {
|
|
181
|
+
const rows = store.games({ status: values.status, limit: values.limit === undefined ? 20 : Number(values.limit) });
|
|
182
|
+
emit(rows, () => (rows.length === 0 ? "no games in the local D1 index" : table(gamesTable(rows))));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
case "game": {
|
|
186
|
+
const ref = rest[0];
|
|
187
|
+
if (ref === undefined) throw new Error("game needs an id, short code, or id prefix");
|
|
188
|
+
const view = store.game(ref);
|
|
189
|
+
if (view === null) throw new Error(`no game matching "${ref}" in the local D1 index or in any local Durable Object`);
|
|
190
|
+
emit(view, () => renderGame(view));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
case "do": {
|
|
194
|
+
// Paths print relative to the state root: the absolute prefix is the
|
|
195
|
+
// same on every row and pushes the useful columns off the terminal.
|
|
196
|
+
const objects = store.durableObjects().map((object) => ({ class: object.className, game: object.name ?? "(unnamed)", doId: short(object.id, 12), file: object.file.startsWith(store.root) ? object.file.slice(store.root.length + 1) : object.file }));
|
|
197
|
+
emit(objects, () => (objects.length === 0 ? "no local Durable Objects: nothing has created a game session yet" : table(objects)));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
case "players": {
|
|
201
|
+
const users = store.query("select id, username, display_name, is_anonymous, created_at from users order by created_at desc");
|
|
202
|
+
const bots = store.query("select id, username, display_name, type, schema_version from bots order by username");
|
|
203
|
+
emit({ users, bots }, () => `Users\n${table(users)}\n\nBots\n${table(bots)}`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
case "tables": {
|
|
207
|
+
const rows = store.tables({ game: values.game });
|
|
208
|
+
emit(rows, () => table(rows));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
case "sql": {
|
|
212
|
+
const sql = rest.join(" ");
|
|
213
|
+
if (sql === "") throw new Error("sql needs a statement");
|
|
214
|
+
const rows = store.query(sql, { game: values.game });
|
|
215
|
+
emit(rows, () => table(rows));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
default:
|
|
219
|
+
throw new Error(`unknown command "${command}". Run eigen-inspect --help`);
|
|
220
|
+
}
|
|
221
|
+
} finally {
|
|
222
|
+
store.close();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
main();
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.error(`eigen-inspect: ${error instanceof Error ? error.message : String(error)}`);
|
|
230
|
+
process.exitCode = 1;
|
|
231
|
+
}
|
|
@@ -8,14 +8,37 @@
|
|
|
8
8
|
* game's test suite legitimately needs from the kernel is re-exported here,
|
|
9
9
|
* so the kernel's own surface stays free to churn.
|
|
10
10
|
*/
|
|
11
|
-
|
|
11
|
+
|
|
12
|
+
import { assertHookPayload, type SeatView } from "@eigeninteractive/kernel";
|
|
12
13
|
import type { GameRules, JsonObject, TransitionCause } from "@eigeninteractive/rules";
|
|
13
|
-
|
|
14
|
-
export {
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
CommitInput,
|
|
17
|
+
CommitPlan,
|
|
18
|
+
Effect,
|
|
19
|
+
GameRow,
|
|
20
|
+
Intent,
|
|
21
|
+
ObservationFrame,
|
|
22
|
+
RejectCode,
|
|
23
|
+
Rejected,
|
|
24
|
+
Seat,
|
|
25
|
+
SeatView,
|
|
26
|
+
StateRow,
|
|
27
|
+
} from "@eigeninteractive/kernel";
|
|
28
|
+
export {
|
|
29
|
+
commit,
|
|
30
|
+
DEADLINE_GRACE_MS,
|
|
31
|
+
deriveRng,
|
|
32
|
+
isRejected,
|
|
33
|
+
randomSeed,
|
|
34
|
+
} from "@eigeninteractive/kernel";
|
|
35
|
+
|
|
15
36
|
/** Project one seat's view of a state: the stored-frame shape the same-view
|
|
16
37
|
* rule compares (`commit()`'s `staleViews` input). Convenience for scenario
|
|
17
38
|
* tests that replay a simultaneous-move race. */
|
|
18
|
-
export
|
|
39
|
+
export function projectView(
|
|
40
|
+
rules: GameRules,
|
|
41
|
+
args: {
|
|
19
42
|
state: JsonObject;
|
|
20
43
|
pending: number[];
|
|
21
44
|
/** The seat to project for, or null for a viewer. */
|
|
@@ -24,4 +47,17 @@ export declare function projectView(rules: GameRules, args: {
|
|
|
24
47
|
cause?: TransitionCause;
|
|
25
48
|
participantCount?: number;
|
|
26
49
|
isReplay?: boolean;
|
|
27
|
-
}
|
|
50
|
+
},
|
|
51
|
+
): SeatView {
|
|
52
|
+
const slice = rules.computeObservation({
|
|
53
|
+
state: args.state,
|
|
54
|
+
pending: args.pending,
|
|
55
|
+
playerIndex: args.seat,
|
|
56
|
+
participantCount: args.participantCount ?? 2,
|
|
57
|
+
config: args.config,
|
|
58
|
+
cause: args.cause ?? null,
|
|
59
|
+
isReplay: args.isReplay ?? false,
|
|
60
|
+
});
|
|
61
|
+
assertHookPayload(rules.schemas.observation, slice.data, `computeObservation for ${args.seat === null ? "viewer" : `seat ${args.seat}`}`);
|
|
62
|
+
return { data: slice.data, pendingPlayers: slice.pendingPlayers };
|
|
63
|
+
}
|