@netforge/kit-play 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NetNet Capital Management
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,13 @@
1
+ @netforge/kit-play
2
+ Copyright (c) 2026 NetNet Capital Management
3
+
4
+ Dependencies (unmodified, via npm)
5
+ - @colyseus/core 0.18.18 and @colyseus/ws-transport 0.18.4 (https://github.com/colyseus/colyseus), MIT, Endel Dreyer.
6
+ - vite 6.4.3 (https://github.com/vitejs/vite), MIT.
7
+ - esbuild 0.25.12 (https://github.com/evanw/esbuild), MIT.
8
+ - The Net Forge engine packages (@netforge/engine, -sim, asset-library,
9
+ asset-library-files, tabletop, template-*); see each package's NOTICE.
10
+
11
+ The local host (src/host) is written for this package on the engine's open
12
+ netcode (@netforge/engine/net). It holds no code from the hosted room
13
+ server or the referee.
package/README.md CHANGED
@@ -1,3 +1,86 @@
1
- # Temporary Holding Version
1
+ # @netforge/kit-play
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Part of Net Forge, the open-source multiplayer browser game engine built for AI coding agents (`@netforge` on npm, https://github.com/netnetdev/net-forge and https://forge.netnet.capital/docs). Not affiliated with other projects named NetForge.
4
+
5
+ `kit play` runs a Net Forge game on your own computer with play money: it builds the template's browser client with your `src/game.ts` and starts a local room host for the same game module, with no wallet, no chain and no Net Forge servers.
6
+ It also starts new games from a template and tests them with playtest bots.
7
+
8
+ | Command | What it does |
9
+ |---|---|
10
+ | `kit-play play [dir]` | Build the game in `dir` (default: here) and play it. `kit-play [dir]` does the same. |
11
+ | `kit-play test [dir]` | Compile `src/game.ts` as the play build does and run the playtest bots. No browser. Exits 1 on a failure; `--json` prints the report. |
12
+ | `kit-play check [dir]` | `kit-play test` with the report as JSON on stdout. |
13
+ | `kit-play new [folder] --template <id>` | Start a new game. The same as `npm create netforge@latest`; see [create-netforge](../create-netforge). |
14
+ | `kit-play templates [--json]` | List the ten templates. |
15
+
16
+ `kit-play test` takes `--minutes <n>` (minutes of game time at the
17
+ template's own tick rate, default 1), `--bots <n>` and `--seed <n>`.
18
+
19
+ With `--host 0.0.0.0`, `kit-play play` also prints a link for each network
20
+ address of this computer. Open that link on a phone or another computer:
21
+ it carries `?server=`, which tells the page which room to join.
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ npm install @netforge/kit-play
27
+ ```
28
+
29
+ Node 20 or later. The package brings the engine, the asset library files and every template with it. The bin is `kit-play`; `kit play` through `@netforge/kit` runs the same command.
30
+
31
+ ## Example
32
+
33
+ A game folder needs `netnet.json` (which template) and `src/game.ts` (the game). `src/names.json` (the title and labels) is optional. To start from a template:
34
+
35
+ ```sh
36
+ mkdir -p my-game/src && cd my-game
37
+ npm init -y && npm install @netforge/kit-play @netforge/engine
38
+ cp node_modules/@netforge/template-survivor/src/game.ts src/
39
+ echo '{"template":"survivor"}' > netnet.json
40
+ npx kit-play # opens http://127.0.0.1:4663
41
+ npx kit-play --players 2 # two tabs, two local players
42
+ ```
43
+
44
+ Edit `src/game.ts` and run it again to try a change. Open the link in more tabs or windows for more players. The build lands in `.netnet/play/` inside the game folder.
45
+
46
+ Template ids for `netnet.json`: `survivor`, `arena`, `tower-defense`, `dungeon`, `idle`, `platformer`, `cards`, `collector`, `racer`, `party`.
47
+
48
+ ## Flags
49
+
50
+ | Flag | Default | |
51
+ |---|---|---|
52
+ | `--port <n>` | 4663 | the page and the room, one port |
53
+ | `--host <addr>` | 127.0.0.1 | `0.0.0.0` lets devices on your network join |
54
+ | `--players <n>` | 1 | browser tabs to open (1 to 16) |
55
+ | `--no-open` | | print the link only |
56
+ | `--library-url <url>` | the installed library | load models and sounds from a mirror instead |
57
+
58
+ From code: `import { play } from "@netforge/kit-play"` takes `{ dir, port, host, open, players, libraryUrl, log }` and resolves to `{ url, close() }`. `readGameFolder(dir)` checks a folder without running it, and `TEMPLATES` lists the templates it can run.
59
+
60
+ ## What it does not do
61
+
62
+ - No wallet, no chain, no real tokens. Coins are play money in the page.
63
+ - No calls to Net Forge servers. The page is built with no `VITE_*` environment and talks only to the local host; sign-in, the referee and every money route answer 403.
64
+ - No stakes, pots or claims. Earn rules still fire, so you can see them, and pay nothing.
65
+ - No saved progress. Achievements and looks bought with play money live in memory and are gone when `kit play` stops.
66
+
67
+ The local host is built from the engine's open simulation (`@netforge/engine`, `@netforge/engine-sim`). The hosted room server and the referee that decides real payouts are not part of it.
68
+
69
+ kit play is not a sandbox: it runs the game's code (the room host in Node, the client in your browser) with your permissions. Play only games whose code you trust, as with any program you run.
70
+
71
+ `src/game.ts` may import only `@netforge/engine`, as in the Net Forge studio, so a game that plays here plays there.
72
+
73
+ ## For AI agents
74
+
75
+ - Read `llms.txt` in this package, and the template's own `AGENTS.md` and `llms.txt`.
76
+ - The Net Forge MCP server runs local play for an agent with the `netforge_play_locally` tool, next to `netforge_validate_game` and `netforge_run_playtest`: `npx -y @netforge/mcp` (package `@netforge/mcp`, bin `netforge-mcp`).
77
+ - `npx netforge setup agent` writes AGENTS.md, CLAUDE.md, Cursor rules and `.mcp.json` into a game project. In Claude Code: `claude mcp add netforge -- npx -y @netforge/mcp`.
78
+
79
+ ## Docs
80
+
81
+ - https://forge.netnet.capital/docs/packages/kit-play
82
+ - `llms.txt` in this package
83
+
84
+ ## License
85
+
86
+ MIT. See `LICENSE` and `NOTICE`.
@@ -0,0 +1,43 @@
1
+ export interface CheckOptions {
2
+ readonly dir: string;
3
+ /** Minutes of game time to play, at the template's own tick rate. */
4
+ readonly minutes: number;
5
+ /** Bots playing; null for the template's default. */
6
+ readonly bots: number | null;
7
+ readonly seed: number;
8
+ readonly log: (s: string) => void;
9
+ }
10
+ /** What the engine's playtest reports (the fields this command reads; the rest pass through to --json). */
11
+ export interface CheckReport {
12
+ readonly ticks: number;
13
+ readonly bots: number;
14
+ readonly kills: number;
15
+ readonly deaths: number;
16
+ readonly wave: number;
17
+ readonly maxLevel: number;
18
+ readonly earnEmitted: number;
19
+ readonly earned: Readonly<Record<string, number>>;
20
+ readonly softlock: boolean;
21
+ readonly msPerTick: number;
22
+ readonly rules?: {
23
+ readonly template: string;
24
+ readonly checks: readonly {
25
+ readonly ok: boolean;
26
+ readonly line: string;
27
+ }[];
28
+ readonly progress: number;
29
+ };
30
+ readonly failed: readonly string[];
31
+ }
32
+ export interface CheckResult {
33
+ readonly ok: boolean;
34
+ readonly template: string;
35
+ /** Why it failed, one line each (empty when ok). */
36
+ readonly problems: readonly string[];
37
+ /** Simulation ticks per second of game time (the template's own rate). */
38
+ readonly tickRate?: number;
39
+ readonly report: CheckReport | null;
40
+ }
41
+ export declare function check(o: CheckOptions): Promise<CheckResult>;
42
+ /** The report in a few lines. */
43
+ export declare function summary(r: CheckResult): string[];
package/dist/check.js ADDED
@@ -0,0 +1,106 @@
1
+ // `kit-play test`: compile the game folder's src/game.ts the way the play
2
+ // build does and run the engine's headless playtest (engine.test.run) on
3
+ // it with bots. No browser, no server, no network: a quick check after
4
+ // every change, made for coding agents as much as for people.
5
+ import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { gameSwap, PlayError, readGameFolder, templateDir } from "./play.js";
9
+ /** Bundles the template's manifest with the folder's game module, plus the engine's playtest. Returns the bundle path. */
10
+ async function bundleCheck(gameFile, tplDir, outFile) {
11
+ const esbuild = await import("esbuild");
12
+ const manifest = realpathSync(join(tplDir, "src", "manifest.ts"));
13
+ const contents = [`export * as template from ${JSON.stringify(manifest)};`, `export { engine } from "@netforge/engine";`, `export { initSim } from "@netforge/engine-sim";`].join("\n");
14
+ const out = await esbuild.build({
15
+ stdin: { contents, resolveDir: join(tplDir, "src"), sourcefile: "kit-play-test.js", loader: "js" },
16
+ bundle: true,
17
+ platform: "node",
18
+ format: "esm",
19
+ target: "node20",
20
+ write: false,
21
+ logLevel: "silent",
22
+ plugins: [gameSwap(manifest, realpathSync(gameFile), tplDir)],
23
+ banner: { js: "import { createRequire as __kpRequire } from 'node:module'; const require = __kpRequire(import.meta.url);" },
24
+ });
25
+ const file = out.outputFiles[0];
26
+ if (!file)
27
+ throw new PlayError("the test bundle produced no output");
28
+ mkdirSync(dirname(outFile), { recursive: true });
29
+ writeFileSync(outFile, file.text);
30
+ return outFile;
31
+ }
32
+ /** esbuild's and the engine's errors as the lines a person (or an agent) needs. */
33
+ function problemLines(e) {
34
+ const errors = e.errors;
35
+ if (Array.isArray(errors) && errors.length > 0)
36
+ return errors.map((x) => (x.location ? `${x.location.file}:${x.location.line}:${x.location.column}: ${x.text}` : x.text));
37
+ return [e instanceof Error ? e.message : String(e)];
38
+ }
39
+ export async function check(o) {
40
+ const dir = resolve(o.dir);
41
+ const g = readGameFolder(dir);
42
+ const tplDir = templateDir(g.template, dir);
43
+ const t = g.template;
44
+ o.log(`Compiling src/game.ts (${t.label})...`);
45
+ let mod;
46
+ try {
47
+ // A fresh file name per run: a module imported once stays cached for the process.
48
+ const bundle = await bundleCheck(g.gameFile, tplDir, join(dir, ".netnet", "play", `test-${process.pid}-${Date.now()}.mjs`));
49
+ try {
50
+ mod = (await import(pathToFileURL(bundle).href));
51
+ }
52
+ finally {
53
+ rmSync(bundle, { force: true });
54
+ }
55
+ }
56
+ catch (e) {
57
+ return { ok: false, template: t.id, problems: problemLines(e), report: null };
58
+ }
59
+ let tickRate = 30;
60
+ try {
61
+ await mod.initSim();
62
+ const get = mod.template[t.manifest.fn];
63
+ const make = mod.template[t.adapter];
64
+ if (typeof get !== "function" || typeof make !== "function")
65
+ throw new PlayError(`the template does not export ${t.manifest.fn} and ${t.adapter}`);
66
+ const got = get();
67
+ tickRate = (t.manifest.field ? got.manifest?.game?.tickRate : got.game?.tickRate) ?? 30;
68
+ make();
69
+ }
70
+ catch (e) {
71
+ return { ok: false, template: t.id, problems: problemLines(e), report: null };
72
+ }
73
+ const bots = o.bots ?? Math.min(t.seats, 2);
74
+ // The template's own tick rate (the racer and the arena shooter run at 60 Hz), so a minute is a minute of game time.
75
+ const ticks = Math.round(o.minutes * 60 * tickRate);
76
+ o.log(`Playtest: ${bots} bot${bots === 1 ? "" : "s"} for ${o.minutes} minute${o.minutes === 1 ? "" : "s"} of game time...`);
77
+ let report;
78
+ try {
79
+ report = await mod.engine.test.run({ ticks, bots, seed: o.seed, tickRate });
80
+ }
81
+ catch (e) {
82
+ return { ok: false, template: t.id, problems: problemLines(e), report: null };
83
+ }
84
+ const problems = [...report.failed.map((f) => `check failed: ${f}`), ...(report.rules?.checks ?? []).filter((c) => !c.ok).map((c) => `check failed: ${c.line}`)];
85
+ if (report.softlock)
86
+ problems.push("softlock: nothing changed for 30 seconds of game time (no kills, no damage, no movement)");
87
+ return { ok: problems.length === 0, template: t.id, problems: [...new Set(problems)], tickRate, report };
88
+ }
89
+ /** The report in a few lines. */
90
+ export function summary(r) {
91
+ if (!r.report)
92
+ return [];
93
+ const x = r.report;
94
+ const earned = Object.entries(x.earned)
95
+ .map(([k, v]) => `${k} ${v}`)
96
+ .join(", ");
97
+ return [
98
+ ` kills ${x.kills}, downs ${x.deaths}, wave ${x.wave}, top level ${x.maxLevel}`,
99
+ ` earn rules fired ${x.earnEmitted} time${x.earnEmitted === 1 ? "" : "s"}${earned ? ` (${earned})` : ""}`,
100
+ // A check's line says what went wrong, so only failing ones are shown.
101
+ ...(x.rules ? [` ${x.rules.checks.filter((c) => c.ok).length} of ${x.rules.checks.length} genre checks passed`] : []),
102
+ ...(x.rules ? x.rules.checks.filter((c) => !c.ok).map((c) => ` FAIL ${c.line}`) : []),
103
+ ...(x.rules ? [` progress ${Math.round(x.rules.progress * 100)}%`] : []),
104
+ ` ${x.msPerTick.toFixed(2)} ms per tick on this computer`,
105
+ ];
106
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ export declare const HELP = "kit play: run a Net Forge game on this computer with play money.\n\nUsage: kit play [dir] [flags] (inside the folder you downloaded from Net Forge)\n\n --port <n> Port for the page and the local room (default 4663).\n --host <addr> Interface to listen on (default 127.0.0.1, this computer only).\n Use 0.0.0.0 to let devices on your network join.\n --players <n> Browser tabs to open, one per local player (default 1).\n --no-open Print the link instead of opening a browser.\n --library-url <u> Load the asset library from this URL instead of the installed copy.\n\nNo wallet, no chain, no Net Forge servers: coins are play money and nothing pays out.\nkit play runs the game's own code on this computer with your permissions (it is not a\nsandbox): play only games whose code you trust.";
3
+ export declare const COMMANDS_HELP = "kit-play: make, test and play Net Forge games on this computer with play money.\n\nUsage:\n kit-play play [dir] [flags] Build the game in dir (default: here) and play it. kit-play play --help for flags.\n kit-play test [dir] [flags] Compile src/game.ts and run the playtest bots, no browser.\n kit-play check [dir] [flags] The same, with the report as JSON on stdout.\n kit-play new [folder] [flags] Start a new game from a template (the same as npm create netforge).\n kit-play templates [--json] List the templates.\n\nkit-play [dir] without a command plays, as before.";
4
+ export declare const TEST_HELP = "kit-play test: compile src/game.ts as the play build does and run the playtest bots.\n\nUsage: kit-play test [dir] [flags]\n\n --minutes <n> Minutes of game time the bots play, at the template's own tick rate (default 1, up to 30).\n --bots <n> Bots playing (default: 2, or fewer when the template seats fewer).\n --seed <n> Random seed (default 1).\n --json Print the result as JSON on stdout.\n\nExits 0 when the game compiles and every playtest check passes, 1 otherwise.";
5
+ export interface PlayCliIo {
6
+ out(s: string): void;
7
+ err(s: string): void;
8
+ cwd: string;
9
+ }
10
+ export declare function runPlayCli(argv: readonly string[], io: PlayCliIo): Promise<number>;
11
+ export declare function runTestCli(argv: readonly string[], io: PlayCliIo): Promise<number>;
12
+ /** The kit-play command: play (the default), test, new and templates. */
13
+ export declare function runKitPlayCli(argv: readonly string[], io: PlayCliIo): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ // kit-play [play] [dir] [--port 4663] [--host 127.0.0.1] [--players 1] [--no-open] [--library-url <url>]
3
+ // kit-play test [dir] [--minutes 1] [--bots n] [--seed 1] [--json]
4
+ // kit-play check [dir] ... (test --json)
5
+ // kit-play new [folder] [--template <id>] ... (create-netforge)
6
+ // kit-play templates [--json]
7
+ // `kit play` through @netforge/kit reaches runPlayCli directly.
8
+ import { realpathSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import { listTemplates, nodeIo, runCreate } from "create-netforge";
11
+ import { check, summary } from "./check.js";
12
+ import { play, PlayError } from "./play.js";
13
+ export const HELP = `kit play: run a Net Forge game on this computer with play money.
14
+
15
+ Usage: kit play [dir] [flags] (inside the folder you downloaded from Net Forge)
16
+
17
+ --port <n> Port for the page and the local room (default 4663).
18
+ --host <addr> Interface to listen on (default 127.0.0.1, this computer only).
19
+ Use 0.0.0.0 to let devices on your network join.
20
+ --players <n> Browser tabs to open, one per local player (default 1).
21
+ --no-open Print the link instead of opening a browser.
22
+ --library-url <u> Load the asset library from this URL instead of the installed copy.
23
+
24
+ No wallet, no chain, no Net Forge servers: coins are play money and nothing pays out.
25
+ kit play runs the game's own code on this computer with your permissions (it is not a
26
+ sandbox): play only games whose code you trust.`;
27
+ export const COMMANDS_HELP = `kit-play: make, test and play Net Forge games on this computer with play money.
28
+
29
+ Usage:
30
+ kit-play play [dir] [flags] Build the game in dir (default: here) and play it. kit-play play --help for flags.
31
+ kit-play test [dir] [flags] Compile src/game.ts and run the playtest bots, no browser.
32
+ kit-play check [dir] [flags] The same, with the report as JSON on stdout.
33
+ kit-play new [folder] [flags] Start a new game from a template (the same as npm create netforge).
34
+ kit-play templates [--json] List the templates.
35
+
36
+ kit-play [dir] without a command plays, as before.`;
37
+ export const TEST_HELP = `kit-play test: compile src/game.ts as the play build does and run the playtest bots.
38
+
39
+ Usage: kit-play test [dir] [flags]
40
+
41
+ --minutes <n> Minutes of game time the bots play, at the template's own tick rate (default 1, up to 30).
42
+ --bots <n> Bots playing (default: 2, or fewer when the template seats fewer).
43
+ --seed <n> Random seed (default 1).
44
+ --json Print the result as JSON on stdout.
45
+
46
+ Exits 0 when the game compiles and every playtest check passes, 1 otherwise.`;
47
+ export async function runPlayCli(argv, io) {
48
+ const flags = new Map();
49
+ const positional = [];
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const a = argv[i] ?? "";
52
+ if (a.startsWith("--")) {
53
+ const [k, v] = a.slice(2).split("=", 2);
54
+ const next = argv[i + 1];
55
+ if (v !== undefined)
56
+ flags.set(k, v);
57
+ else if (k !== "no-open" && k !== "help" && next !== undefined && !next.startsWith("--"))
58
+ flags.set(k, argv[++i] ?? "");
59
+ else
60
+ flags.set(k, true);
61
+ }
62
+ else if (a === "-h")
63
+ flags.set("help", true);
64
+ else
65
+ positional.push(a);
66
+ }
67
+ if (flags.has("help")) {
68
+ io.out(HELP);
69
+ return 0;
70
+ }
71
+ const num = (k, d, min, max) => {
72
+ const v = flags.get(k);
73
+ if (v === undefined)
74
+ return d;
75
+ const n = Number(v);
76
+ if (!Number.isInteger(n) || n < min || n > max)
77
+ throw new PlayError(`--${k} must be a whole number from ${min} to ${max}`);
78
+ return n;
79
+ };
80
+ try {
81
+ const lib = flags.get("library-url");
82
+ if (lib !== undefined && (typeof lib !== "string" || !/^https?:\/\/[^\s]+$/.test(lib)))
83
+ throw new PlayError("--library-url must be an http(s) URL");
84
+ const host = flags.get("host");
85
+ const playing = await play({
86
+ dir: positional[0] ?? io.cwd,
87
+ port: num("port", 4663, 1, 65535),
88
+ host: typeof host === "string" && host.length > 0 ? host : "127.0.0.1",
89
+ open: !flags.has("no-open"),
90
+ players: num("players", 1, 1, 16),
91
+ libraryUrl: typeof lib === "string" ? lib.replace(/\/$/, "") : null,
92
+ log: io.out,
93
+ });
94
+ await new Promise((done) => {
95
+ const stop = () => {
96
+ void playing.close().finally(done);
97
+ };
98
+ process.once("SIGINT", stop);
99
+ process.once("SIGTERM", stop);
100
+ });
101
+ return 0;
102
+ }
103
+ catch (e) {
104
+ io.err(e instanceof PlayError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));
105
+ return 1;
106
+ }
107
+ }
108
+ /** Splits flags (--k v, --k=v, bare --k when listed in `bare`) from positional arguments. */
109
+ function split(argv, bare) {
110
+ const flags = new Map();
111
+ const positional = [];
112
+ for (let i = 0; i < argv.length; i++) {
113
+ const a = argv[i] ?? "";
114
+ if (a.startsWith("--")) {
115
+ const [k, v] = a.slice(2).split("=", 2);
116
+ const next = argv[i + 1];
117
+ if (v !== undefined)
118
+ flags.set(k, v);
119
+ else if (!bare.has(k) && next !== undefined && !next.startsWith("--"))
120
+ flags.set(k, argv[++i] ?? "");
121
+ else
122
+ flags.set(k, true);
123
+ }
124
+ else if (a === "-h")
125
+ flags.set("help", true);
126
+ else
127
+ positional.push(a);
128
+ }
129
+ return { flags, positional };
130
+ }
131
+ export async function runTestCli(argv, io) {
132
+ const { flags, positional } = split(argv, new Set(["json", "help"]));
133
+ if (flags.has("help")) {
134
+ io.out(TEST_HELP);
135
+ return 0;
136
+ }
137
+ const json = flags.has("json");
138
+ const num = (k, d, min, max, whole) => {
139
+ const v = flags.get(k);
140
+ if (v === undefined)
141
+ return d;
142
+ const n = Number(v);
143
+ if (!Number.isFinite(n) || (whole && !Number.isInteger(n)) || n < min || n > max)
144
+ throw new PlayError(`--${k} must be a ${whole ? "whole " : ""}number from ${min} to ${max}`);
145
+ return n;
146
+ };
147
+ const known = new Set(["json", "help", "minutes", "bots", "seed"]);
148
+ try {
149
+ for (const k of flags.keys())
150
+ if (!known.has(k))
151
+ throw new PlayError(`unknown flag --${k} (kit-play test --help lists the flags)`);
152
+ const bots = flags.has("bots") ? num("bots", 2, 1, 16, true) : null;
153
+ const r = await check({
154
+ dir: positional[0] ?? io.cwd,
155
+ minutes: num("minutes", 1, 0.1, 30, false),
156
+ bots,
157
+ seed: num("seed", 1, 0, 2 ** 31 - 1, true),
158
+ log: json ? io.err : io.out,
159
+ });
160
+ if (json)
161
+ io.out(JSON.stringify(r, null, 2));
162
+ else {
163
+ for (const l of summary(r))
164
+ io.out(l);
165
+ if (r.ok)
166
+ io.out("PASS: the game compiles and the playtest checks pass.");
167
+ else {
168
+ io.err("FAIL:");
169
+ for (const p of r.problems)
170
+ io.err(` ${p}`);
171
+ }
172
+ }
173
+ return r.ok ? 0 : 1;
174
+ }
175
+ catch (e) {
176
+ const msg = e instanceof PlayError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e);
177
+ if (json)
178
+ io.out(JSON.stringify({ ok: false, problems: [msg], report: null }, null, 2));
179
+ else
180
+ io.err(msg);
181
+ return 1;
182
+ }
183
+ }
184
+ /** The kit-play command: play (the default), test, new and templates. */
185
+ export async function runKitPlayCli(argv, io) {
186
+ const [first, ...rest] = argv;
187
+ switch (first) {
188
+ case "play":
189
+ return runPlayCli(rest, io);
190
+ case "test":
191
+ return runTestCli(rest, io);
192
+ case "check":
193
+ // test with the report as JSON on stdout: what scripts and coding agents read.
194
+ return runTestCli(rest.includes("--json") ? rest : [...rest, "--json"], io);
195
+ case "new":
196
+ case "create":
197
+ return runCreate(rest, { ...nodeIo("kit-play new"), out: io.out, err: io.err, cwd: io.cwd });
198
+ case "templates":
199
+ if (rest.includes("--help") || rest.includes("-h")) {
200
+ io.out("kit-play templates [--json]: list the templates a new game can start from.");
201
+ return 0;
202
+ }
203
+ io.out(listTemplates(rest.includes("--json")));
204
+ return 0;
205
+ case "help":
206
+ io.out(COMMANDS_HELP);
207
+ return 0;
208
+ case undefined:
209
+ return runPlayCli([], io);
210
+ default:
211
+ if (first === "--help" || first === "-h") {
212
+ io.out(`${COMMANDS_HELP}\n\n${HELP}`);
213
+ return 0;
214
+ }
215
+ return runPlayCli(argv, io);
216
+ }
217
+ }
218
+ const isMain = (() => {
219
+ try {
220
+ return process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ })();
226
+ if (isMain) {
227
+ const code = await runKitPlayCli(process.argv.slice(2), { out: (s) => console.log(s), err: (s) => console.error(s), cwd: process.cwd() });
228
+ process.exit(code);
229
+ }
@@ -0,0 +1,50 @@
1
+ import { type Hitbox, type HitscanRequest, type PlayerId, type PlayerRights, type SimAdapter, type SimCommand, type SimEvent } from "@netforge/engine/net";
2
+ /** What each player is sent: everything within `radius` of them (world units), nearest first, up to the caps. */
3
+ export interface LocalAoi {
4
+ readonly radius: number;
5
+ readonly maxEntities: number;
6
+ /** Most entities of one kind (EntityKind number) a snapshot carries. */
7
+ readonly kindCaps?: Readonly<Record<number, number>>;
8
+ /** Kinds every player always receives (players, so the HUD sees teammates). */
9
+ readonly alwaysKinds?: readonly number[];
10
+ }
11
+ export declare const SNAPSHOT_HZ = 20;
12
+ export interface LocalTick {
13
+ readonly tick: number;
14
+ readonly events: readonly SimEvent[];
15
+ readonly snapshots: ReadonlyMap<PlayerId, Uint8Array>;
16
+ }
17
+ /** Distance along a unit ray to a circle, or null. */
18
+ export declare function rayCircle(ox: number, oy: number, ux: number, uy: number, cx: number, cy: number, r: number): number | null;
19
+ /** The nearest hitbox a hitscan meets that the shooter does not own, or null. */
20
+ export declare function resolveHitscan(boxes: readonly Hitbox[], req: HitscanRequest): Hitbox | null;
21
+ export declare class LocalCore<W, I> {
22
+ private readonly sim;
23
+ private readonly aoi;
24
+ private worldRef;
25
+ private tickN;
26
+ private snapAccum;
27
+ private readonly conns;
28
+ private readonly rights;
29
+ private nextPlayer;
30
+ private readonly freeIds;
31
+ private boxes;
32
+ constructor(sim: SimAdapter<W, I>, aoi: LocalAoi, seed: number);
33
+ get world(): W;
34
+ get tick(): number;
35
+ get players(): PlayerId[];
36
+ addPlayer(): {
37
+ player: PlayerId;
38
+ entity: number;
39
+ };
40
+ removePlayer(player: PlayerId): void;
41
+ setRights(player: PlayerId, rights: PlayerRights | null): void;
42
+ command(player: PlayerId, cmd: SimCommand): boolean;
43
+ startRun(player: PlayerId): void;
44
+ receiveInput(player: PlayerId, bytes: Uint8Array): void;
45
+ step(): LocalTick;
46
+ /** A fresh world at a run boundary; every player is re-added under the same id. Returns the new entity ids. */
47
+ rebase(seed: number): Map<PlayerId, number>;
48
+ dispose(): void;
49
+ private buildSnapshots;
50
+ }