@eigeninteractive/testkit 0.5.2 → 0.6.1

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.
@@ -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
- import { type SeatView } from "@eigeninteractive/kernel";
11
+
12
+ import { assertHookPayload, type SeatView } from "@eigeninteractive/kernel";
12
13
  import type { GameRules, JsonObject, TransitionCause } from "@eigeninteractive/rules";
13
- export type { CommitInput, CommitPlan, Effect, GameRow, Intent, ObservationFrame, RejectCode, Rejected, Seat, SeatView, StateRow, } from "@eigeninteractive/kernel";
14
- export { commit, DEADLINE_GRACE_MS, deriveRng, isRejected, randomSeed, } from "@eigeninteractive/kernel";
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 declare function projectView(rules: GameRules, args: {
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
- }): SeatView;
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
+ }