@netforge/kit-play 0.1.0 → 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/README.md +69 -31
- package/dist/check.d.ts +43 -0
- package/dist/check.js +106 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +139 -3
- package/dist/host/server.d.ts +4 -1
- package/dist/host/server.js +28 -6
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/play.d.ts +13 -0
- package/dist/play.js +42 -14
- package/llms.txt +41 -0
- package/package.json +42 -30
package/README.md
CHANGED
|
@@ -1,48 +1,86 @@
|
|
|
1
1
|
# @netforge/kit-play
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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:
|
|
5
34
|
|
|
6
35
|
```sh
|
|
7
|
-
|
|
8
|
-
npm
|
|
9
|
-
|
|
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
|
|
10
42
|
```
|
|
11
43
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
16
49
|
|
|
17
50
|
| Flag | Default | |
|
|
18
51
|
|---|---|---|
|
|
19
52
|
| `--port <n>` | 4663 | the page and the room, one port |
|
|
20
53
|
| `--host <addr>` | 127.0.0.1 | `0.0.0.0` lets devices on your network join |
|
|
21
|
-
| `--players <n>` | 1 | browser tabs to open |
|
|
54
|
+
| `--players <n>` | 1 | browser tabs to open (1 to 16) |
|
|
22
55
|
| `--no-open` | | print the link only |
|
|
23
56
|
| `--library-url <url>` | the installed library | load models and sounds from a mirror instead |
|
|
24
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
|
+
|
|
25
60
|
## What it does not do
|
|
26
61
|
|
|
27
62
|
- No wallet, no chain, no real tokens. Coins are play money in the page.
|
|
28
|
-
- No calls to Net Forge servers. The page is built with no `VITE_*`
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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`.
|
package/dist/check.d.ts
ADDED
|
@@ -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
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
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.";
|
|
3
5
|
export interface PlayCliIo {
|
|
4
6
|
out(s: string): void;
|
|
5
7
|
err(s: string): void;
|
|
6
8
|
cwd: string;
|
|
7
9
|
}
|
|
8
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
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// kit-play [dir] [--port 4663] [--host 127.0.0.1] [--players 1] [--no-open] [--library-url <url>]
|
|
3
|
-
//
|
|
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.
|
|
4
8
|
import { realpathSync } from "node:fs";
|
|
5
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { listTemplates, nodeIo, runCreate } from "create-netforge";
|
|
11
|
+
import { check, summary } from "./check.js";
|
|
6
12
|
import { play, PlayError } from "./play.js";
|
|
7
13
|
export const HELP = `kit play: run a Net Forge game on this computer with play money.
|
|
8
14
|
|
|
@@ -18,6 +24,26 @@ Usage: kit play [dir] [flags] (inside the folder you downloaded from Net For
|
|
|
18
24
|
No wallet, no chain, no Net Forge servers: coins are play money and nothing pays out.
|
|
19
25
|
kit play runs the game's own code on this computer with your permissions (it is not a
|
|
20
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.`;
|
|
21
47
|
export async function runPlayCli(argv, io) {
|
|
22
48
|
const flags = new Map();
|
|
23
49
|
const positional = [];
|
|
@@ -79,6 +105,116 @@ export async function runPlayCli(argv, io) {
|
|
|
79
105
|
return 1;
|
|
80
106
|
}
|
|
81
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
|
+
}
|
|
82
218
|
const isMain = (() => {
|
|
83
219
|
try {
|
|
84
220
|
return process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
@@ -88,6 +224,6 @@ const isMain = (() => {
|
|
|
88
224
|
}
|
|
89
225
|
})();
|
|
90
226
|
if (isMain) {
|
|
91
|
-
const code = await
|
|
227
|
+
const code = await runKitPlayCli(process.argv.slice(2), { out: (s) => console.log(s), err: (s) => console.error(s), cwd: process.cwd() });
|
|
92
228
|
process.exit(code);
|
|
93
229
|
}
|
package/dist/host/server.d.ts
CHANGED
|
@@ -19,7 +19,10 @@ export declare function fileUnder(root: string, urlPath: string): string | null;
|
|
|
19
19
|
/**
|
|
20
20
|
* On a loopback bind, only requests addressed to this computer by name are
|
|
21
21
|
* answered (a page elsewhere that rebinds its own host name to 127.0.0.1
|
|
22
|
-
* gets 403)
|
|
22
|
+
* gets 403). On any other bind (--host 0.0.0.0 for phones on the network)
|
|
23
|
+
* the Host must still be an address, not a DNS name, so the same rebinding
|
|
24
|
+
* trick fails there too. Either way only same-origin or non-browser
|
|
25
|
+
* requests may POST or open a WebSocket.
|
|
23
26
|
*/
|
|
24
27
|
export declare function allowedRequest(bindHost: string, port: number, host: string | undefined, origin: string | undefined, method: string | undefined): boolean;
|
|
25
28
|
export declare function startServer(g: LocalGame, o: HostOptions): Promise<RunningHost>;
|
package/dist/host/server.js
CHANGED
|
@@ -161,22 +161,44 @@ async function route(g, o, req, res) {
|
|
|
161
161
|
return true;
|
|
162
162
|
}
|
|
163
163
|
const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
164
|
+
/** The host name of a Host header value ("192.168.1.5:4663", "[fe80::1]:4663"), lower case. */
|
|
165
|
+
function hostName(host) {
|
|
166
|
+
const h = host.trim().toLowerCase();
|
|
167
|
+
if (h.startsWith("[")) {
|
|
168
|
+
const end = h.indexOf("]");
|
|
169
|
+
return end > 0 ? h.slice(0, end + 1) : h;
|
|
170
|
+
}
|
|
171
|
+
return h.replace(/:\d+$/, "");
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A name a DNS rebinding page cannot put in the Host header: an IP literal,
|
|
175
|
+
* localhost, or an mDNS .local name (resolved on the network itself, never
|
|
176
|
+
* by a public DNS server an attacker controls).
|
|
177
|
+
*/
|
|
178
|
+
function directName(name) {
|
|
179
|
+
return LOOPBACK.has(name) || /^\d{1,3}(\.\d{1,3}){3}$/.test(name) || /^\[[0-9a-f:.]+\]$/.test(name) || /^[a-z0-9-]+(\.[a-z0-9-]+)*\.local$/.test(name);
|
|
180
|
+
}
|
|
164
181
|
/**
|
|
165
182
|
* On a loopback bind, only requests addressed to this computer by name are
|
|
166
183
|
* answered (a page elsewhere that rebinds its own host name to 127.0.0.1
|
|
167
|
-
* gets 403)
|
|
184
|
+
* gets 403). On any other bind (--host 0.0.0.0 for phones on the network)
|
|
185
|
+
* the Host must still be an address, not a DNS name, so the same rebinding
|
|
186
|
+
* trick fails there too. Either way only same-origin or non-browser
|
|
187
|
+
* requests may POST or open a WebSocket.
|
|
168
188
|
*/
|
|
169
189
|
export function allowedRequest(bindHost, port, host, origin, method) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (!LOOPBACK.has(name))
|
|
190
|
+
const loopback = LOOPBACK.has(bindHost);
|
|
191
|
+
const name = hostName(host ?? "");
|
|
192
|
+
if (loopback ? !LOOPBACK.has(name) : !directName(name))
|
|
174
193
|
return false;
|
|
175
194
|
if (method === "GET" || method === "HEAD" || origin === undefined)
|
|
176
195
|
return true;
|
|
177
196
|
try {
|
|
178
197
|
const o = new URL(origin);
|
|
179
|
-
|
|
198
|
+
const oName = o.hostname.toLowerCase();
|
|
199
|
+
if (o.protocol !== "http:" || Number(o.port || 80) !== port)
|
|
200
|
+
return false;
|
|
201
|
+
return loopback ? LOOPBACK.has(oName) : oName === name || (LOOPBACK.has(oName) && LOOPBACK.has(name));
|
|
180
202
|
}
|
|
181
203
|
catch {
|
|
182
204
|
return false;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { HELP, runPlayCli, type PlayCliIo } from "./cli.js";
|
|
1
|
+
export { COMMANDS_HELP, HELP, runKitPlayCli, runPlayCli, runTestCli, TEST_HELP, type PlayCliIo } from "./cli.js";
|
|
2
|
+
export { check, summary, type CheckOptions, type CheckReport, type CheckResult } from "./check.js";
|
|
2
3
|
export { ALLOWED_IMPORT, play, PlayError, readGameFolder, type GameFolder, type PlayOptions, type Playing } from "./play.js";
|
|
3
4
|
export { TEMPLATES, templateFor, type TemplateEntry, type TemplateId } from "./templates.js";
|
|
4
5
|
export { namesModule, parseNames, type Names } from "./names.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { HELP, runPlayCli } from "./cli.js";
|
|
1
|
+
export { COMMANDS_HELP, HELP, runKitPlayCli, runPlayCli, runTestCli, TEST_HELP } from "./cli.js";
|
|
2
|
+
export { check, summary } from "./check.js";
|
|
2
3
|
export { ALLOWED_IMPORT, play, PlayError, readGameFolder } from "./play.js";
|
|
3
4
|
export { TEMPLATES, templateFor } from "./templates.js";
|
|
4
5
|
export { namesModule, parseNames } from "./names.js";
|
package/dist/play.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { networkInterfaces } from "node:os";
|
|
1
2
|
import { type Names } from "./names.js";
|
|
2
3
|
import { type TemplateEntry } from "./templates.js";
|
|
3
4
|
/** The only import a game module may make (the studio's rule, kept here so a game that plays locally plays in the studio). */
|
|
@@ -27,12 +28,24 @@ export declare function readGameFolder(dir: string): GameFolder;
|
|
|
27
28
|
export declare function templateDir(t: TemplateEntry, from: string): string;
|
|
28
29
|
/** The built asset library shipped in @netforge/asset-library-files, or null when it is not installed. */
|
|
29
30
|
export declare function libraryDir(from: string): string | null;
|
|
31
|
+
/**
|
|
32
|
+
* The page link for a host base URL. The template clients connect to the
|
|
33
|
+
* room named by ?server= (without it they fall back to their dev port), so
|
|
34
|
+
* every link carries the server it was served from.
|
|
35
|
+
*/
|
|
36
|
+
export declare function playLink(base: string, libraryUrl: string | null): string;
|
|
37
|
+
/** Links other devices can open when the host listens on every interface: one per IPv4 network address. */
|
|
38
|
+
export declare function lanLinks(host: string, port: number, interfaces: ReturnType<typeof networkInterfaces>, libraryUrl: string | null): string[];
|
|
30
39
|
/** Builds the template client with the game module and labels swapped in. */
|
|
31
40
|
export declare function buildClient(g: GameFolder, tplDir: string, outDir: string, cacheDir: string): Promise<void>;
|
|
41
|
+
/** The esbuild plugin that puts the game folder's src/game.ts in place of the template's and holds it to ALLOWED_IMPORT. */
|
|
42
|
+
export declare function gameSwap(manifest: string, gameFile: string, tplDir: string): import("esbuild").Plugin;
|
|
32
43
|
/** Bundles the local host with the game module (one copy of the engine and the sim). Returns the bundle path. */
|
|
33
44
|
export declare function bundleHost(g: GameFolder, tplDir: string, outFile: string): Promise<string>;
|
|
34
45
|
export interface Playing {
|
|
35
46
|
readonly url: string;
|
|
47
|
+
/** Links for other devices on the network (with --host 0.0.0.0), each carrying its own ?server=. */
|
|
48
|
+
readonly lan: readonly string[];
|
|
36
49
|
close(): Promise<void>;
|
|
37
50
|
}
|
|
38
51
|
export declare function play(o: PlayOptions): Promise<Playing>;
|
package/dist/play.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createRequire } from "node:module";
|
|
|
14
14
|
import { dirname, join, resolve } from "node:path";
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
16
|
import { spawn } from "node:child_process";
|
|
17
|
+
import { networkInterfaces } from "node:os";
|
|
17
18
|
import { namesModule, parseNames } from "./names.js";
|
|
18
19
|
import { templateFor } from "./templates.js";
|
|
19
20
|
/** The only import a game module may make (the studio's rule, kept here so a game that plays locally plays in the studio). */
|
|
@@ -78,6 +79,27 @@ export function libraryDir(from) {
|
|
|
78
79
|
return null;
|
|
79
80
|
}
|
|
80
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* The page link for a host base URL. The template clients connect to the
|
|
84
|
+
* room named by ?server= (without it they fall back to their dev port), so
|
|
85
|
+
* every link carries the server it was served from.
|
|
86
|
+
*/
|
|
87
|
+
export function playLink(base, libraryUrl) {
|
|
88
|
+
const params = new URLSearchParams({ server: base });
|
|
89
|
+
if (libraryUrl)
|
|
90
|
+
params.set("library", libraryUrl);
|
|
91
|
+
return `${base}/?${params.toString()}`;
|
|
92
|
+
}
|
|
93
|
+
/** Links other devices can open when the host listens on every interface: one per IPv4 network address. */
|
|
94
|
+
export function lanLinks(host, port, interfaces, libraryUrl) {
|
|
95
|
+
const wildcard = host === "0.0.0.0" || host === "::";
|
|
96
|
+
const addrs = wildcard
|
|
97
|
+
? Object.values(interfaces).flatMap((list) => (list ?? []).flatMap(({ family, internal, address }) => (family === "IPv4" && !internal ? [address] : [])))
|
|
98
|
+
: host !== "127.0.0.1" && host !== "localhost" && host !== "::1" && /^\d+\.\d+\.\d+\.\d+$/.test(host)
|
|
99
|
+
? [host]
|
|
100
|
+
: [];
|
|
101
|
+
return [...new Set(addrs)].map((a) => playLink(`http://${a}:${port}`, libraryUrl));
|
|
102
|
+
}
|
|
81
103
|
/** Builds the template client with the game module and labels swapped in. */
|
|
82
104
|
export async function buildClient(g, tplDir, outDir, cacheDir) {
|
|
83
105
|
const vite = await import("vite");
|
|
@@ -123,14 +145,9 @@ export async function buildClient(g, tplDir, outDir, cacheDir) {
|
|
|
123
145
|
build: { outDir, emptyOutDir: true, target: "es2022", chunkSizeWarningLimit: 16384, reportCompressedSize: false },
|
|
124
146
|
});
|
|
125
147
|
}
|
|
126
|
-
/**
|
|
127
|
-
export
|
|
128
|
-
|
|
129
|
-
const manifest = realpathSync(join(tplDir, "src", "manifest.ts"));
|
|
130
|
-
const gameFile = realpathSync(g.gameFile);
|
|
131
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
132
|
-
const entryFile = existsSync(join(here, "host", "entry.js")) ? join(here, "host", "entry.js") : join(here, "host", "entry.ts");
|
|
133
|
-
const swap = {
|
|
148
|
+
/** The esbuild plugin that puts the game folder's src/game.ts in place of the template's and holds it to ALLOWED_IMPORT. */
|
|
149
|
+
export function gameSwap(manifest, gameFile, tplDir) {
|
|
150
|
+
return {
|
|
134
151
|
name: "kit-play-swap",
|
|
135
152
|
setup(b) {
|
|
136
153
|
b.onResolve({ filter: /^\.\/game\.(js|ts)$/ }, (args) => (args.importer === manifest ? { path: gameFile } : undefined));
|
|
@@ -139,10 +156,19 @@ export async function bundleHost(g, tplDir, outFile) {
|
|
|
139
156
|
return undefined;
|
|
140
157
|
if (args.path === ALLOWED_IMPORT)
|
|
141
158
|
return b.resolve(args.path, { resolveDir: join(tplDir, "src"), kind: args.kind });
|
|
142
|
-
return { errors: [{ text: `src/game.ts may import only "${ALLOWED_IMPORT}"` }] };
|
|
159
|
+
return { errors: [{ text: `src/game.ts may import only "${ALLOWED_IMPORT}", not "${args.path}"` }] };
|
|
143
160
|
});
|
|
144
161
|
},
|
|
145
162
|
};
|
|
163
|
+
}
|
|
164
|
+
/** Bundles the local host with the game module (one copy of the engine and the sim). Returns the bundle path. */
|
|
165
|
+
export async function bundleHost(g, tplDir, outFile) {
|
|
166
|
+
const esbuild = await import("esbuild");
|
|
167
|
+
const manifest = realpathSync(join(tplDir, "src", "manifest.ts"));
|
|
168
|
+
const gameFile = realpathSync(g.gameFile);
|
|
169
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
170
|
+
const entryFile = existsSync(join(here, "host", "entry.js")) ? join(here, "host", "entry.js") : join(here, "host", "entry.ts");
|
|
171
|
+
const swap = gameSwap(manifest, gameFile, tplDir);
|
|
146
172
|
const contents = [`export { startHost } from ${JSON.stringify(entryFile)};`, `export * as template from ${JSON.stringify(manifest)};`].join("\n");
|
|
147
173
|
const out = await esbuild.build({
|
|
148
174
|
stdin: { contents, resolveDir: here, sourcefile: "kit-play-host.js", loader: "js" },
|
|
@@ -195,15 +221,17 @@ export async function play(o) {
|
|
|
195
221
|
clientDir: clientOut,
|
|
196
222
|
libraryDir: lib,
|
|
197
223
|
});
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
params.set("library", o.libraryUrl);
|
|
201
|
-
const url = `${running.url}/?${params.toString()}`;
|
|
224
|
+
const url = playLink(running.url, o.libraryUrl);
|
|
225
|
+
const lan = lanLinks(o.host, o.port, networkInterfaces(), o.libraryUrl);
|
|
202
226
|
o.log(`Playing locally with play money at ${url}`);
|
|
227
|
+
for (const l of lan)
|
|
228
|
+
o.log(`On your network (phones, other computers): ${l}`);
|
|
229
|
+
if (lan.length === 0 && o.host !== "127.0.0.1" && o.host !== "localhost" && o.host !== "::1")
|
|
230
|
+
o.log(`Listening on ${o.host}: share a link of the form http://<this computer's address>:${o.port}/?server=http://<this computer's address>:${o.port}`);
|
|
203
231
|
o.log("Open the link in more tabs or windows for more players. Ctrl+C stops.");
|
|
204
232
|
o.log("kit play runs the game's code on this computer with your permissions: play only games whose code you trust.");
|
|
205
233
|
if (o.open)
|
|
206
234
|
for (let i = 0; i < Math.max(1, o.players); i++)
|
|
207
235
|
openBrowser(url);
|
|
208
|
-
return { url, close: running.close };
|
|
236
|
+
return { url, lan, close: running.close };
|
|
209
237
|
}
|
package/llms.txt
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @netforge/kit-play
|
|
2
|
+
|
|
3
|
+
> kit play runs a Net Forge game on your own computer with play money. Inside a game folder (netnet.json and src/game.ts) it builds the template's browser client with that game module, starts a local room host for the same module, and opens http://127.0.0.1:4663. No wallet, no chain, no Net Forge servers; earn rules fire and pay nothing.
|
|
4
|
+
|
|
5
|
+
## Run a game
|
|
6
|
+
|
|
7
|
+
- Install: `npm install @netforge/kit-play @netforge/engine` in the game folder (Node 20 or later).
|
|
8
|
+
- Run: `npx kit-play [dir]`, or `kit play` through @netforge/kit. It exits on Ctrl+C.
|
|
9
|
+
- The folder needs netnet.json with `{"template":"<id>"}` and src/game.ts. src/names.json (title and labels) is optional.
|
|
10
|
+
- Template ids: survivor, arena, tower-defense, dungeon, idle, platformer, cards, collector, racer, party.
|
|
11
|
+
- Start from a template: copy node_modules/@netforge/template-<name>/src/game.ts to src/game.ts (the package names are template-survivor, template-arena, template-tower, template-dungeon, template-idle, template-platformer, template-cards, template-collector, template-racer, template-party).
|
|
12
|
+
- The build lands in .netnet/play/ inside the game folder.
|
|
13
|
+
|
|
14
|
+
## Flags
|
|
15
|
+
|
|
16
|
+
- `--port <n>`: the page and the room on one port (default 4663).
|
|
17
|
+
- `--host <addr>`: interface to listen on (default 127.0.0.1; 0.0.0.0 lets devices on your network join).
|
|
18
|
+
- `--players <n>`: browser tabs to open, one per local player (1 to 16, default 1).
|
|
19
|
+
- `--no-open`: print the link instead of opening a browser.
|
|
20
|
+
- `--library-url <url>`: load the asset library from an http(s) mirror instead of the installed @netforge/asset-library-files.
|
|
21
|
+
|
|
22
|
+
## Programmatic API
|
|
23
|
+
|
|
24
|
+
- `play({ dir, port, host, open, players, libraryUrl, log })` resolves to `{ url, close() }`.
|
|
25
|
+
- `readGameFolder(dir)` checks a folder and throws `PlayError` with a readable message (no netnet.json, unknown template, missing src/game.ts, bad names.json).
|
|
26
|
+
- `runPlayCli(argv, io)` is the CLI; `HELP` is its help text.
|
|
27
|
+
- `TEMPLATES` and `templateFor(id)` describe the runnable templates.
|
|
28
|
+
- `ALLOWED_IMPORT` is "@netforge/engine", the only import a game module may have.
|
|
29
|
+
|
|
30
|
+
## Rules for agents
|
|
31
|
+
|
|
32
|
+
- src/game.ts imports only @netforge/engine, the same rule the Net Forge studio enforces.
|
|
33
|
+
- Do not add renderers, netcode, physics or asset URLs to a game; the template draws and runs everything.
|
|
34
|
+
- kit play runs the game's code with the user's permissions. It is not a sandbox.
|
|
35
|
+
- Validate and playtest before playing: the MCP tools netforge_validate_game and netforge_run_playtest, or engine.test.run from @netforge/engine. netforge_play_locally runs kit play for an agent.
|
|
36
|
+
|
|
37
|
+
## Docs
|
|
38
|
+
|
|
39
|
+
- https://forge.netnet.capital/docs/packages/kit-play
|
|
40
|
+
- MCP server: `npx -y @netforge/mcp` (package @netforge/mcp, bin netforge-mcp). Tools: netforge_list_templates, netforge_create_game, netforge_search_docs, netforge_explain_api, netforge_validate_game, netforge_run_playtest, netforge_play_locally.
|
|
41
|
+
- Agent setup in a project: `npx netforge setup agent`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netforge/kit-play",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Make, test and run Net Forge games on your own computer with play money: start a game from a template, run playtest bots, and play it in the browser with a local room host, with no wallet, chain or servers.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"game-engine",
|
|
7
|
+
"multiplayer",
|
|
8
|
+
"browser-game",
|
|
9
|
+
"game-server",
|
|
10
|
+
"websocket",
|
|
11
|
+
"game-template",
|
|
12
|
+
"threejs",
|
|
13
|
+
"typescript",
|
|
14
|
+
"gamedev",
|
|
15
|
+
"ai-agents",
|
|
16
|
+
"mcp",
|
|
17
|
+
"vibe-coding",
|
|
18
|
+
"net-forge"
|
|
19
|
+
],
|
|
5
20
|
"license": "MIT",
|
|
6
21
|
"type": "module",
|
|
7
22
|
"main": "./dist/index.js",
|
|
@@ -11,7 +26,8 @@
|
|
|
11
26
|
"types": "./dist/index.d.ts",
|
|
12
27
|
"default": "./dist/index.js"
|
|
13
28
|
},
|
|
14
|
-
"./package.json": "./package.json"
|
|
29
|
+
"./package.json": "./package.json",
|
|
30
|
+
"./llms.txt": "./llms.txt"
|
|
15
31
|
},
|
|
16
32
|
"bin": {
|
|
17
33
|
"kit-play": "./dist/cli.js"
|
|
@@ -20,24 +36,22 @@
|
|
|
20
36
|
"dist",
|
|
21
37
|
"README.md",
|
|
22
38
|
"LICENSE",
|
|
23
|
-
"NOTICE"
|
|
39
|
+
"NOTICE",
|
|
40
|
+
"llms.txt"
|
|
24
41
|
],
|
|
42
|
+
"sideEffects": false,
|
|
25
43
|
"publishConfig": {
|
|
26
44
|
"access": "public"
|
|
27
45
|
},
|
|
46
|
+
"homepage": "https://forge.netnet.capital/docs/packages/kit-play",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/netnetdev/net-forge/issues"
|
|
49
|
+
},
|
|
28
50
|
"repository": {
|
|
29
51
|
"type": "git",
|
|
30
52
|
"url": "git+https://github.com/netnetdev/net-forge.git",
|
|
31
53
|
"directory": "packages/kit-play"
|
|
32
54
|
},
|
|
33
|
-
"homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/kit-play#readme",
|
|
34
|
-
"keywords": [
|
|
35
|
-
"netnet",
|
|
36
|
-
"net-forge",
|
|
37
|
-
"game",
|
|
38
|
-
"local",
|
|
39
|
-
"three.js"
|
|
40
|
-
],
|
|
41
55
|
"engines": {
|
|
42
56
|
"node": ">=20"
|
|
43
57
|
},
|
|
@@ -46,30 +60,28 @@
|
|
|
46
60
|
"@colyseus/ws-transport": "0.18.4",
|
|
47
61
|
"esbuild": "0.25.12",
|
|
48
62
|
"vite": "6.4.3",
|
|
49
|
-
"@netforge/
|
|
50
|
-
"@netforge/
|
|
51
|
-
"@netforge/
|
|
52
|
-
"@netforge/asset-library-files": "0.
|
|
53
|
-
"@netforge/template-arena": "0.
|
|
54
|
-
"@netforge/
|
|
55
|
-
"@netforge/template-cards": "0.
|
|
56
|
-
"@netforge/template-
|
|
57
|
-
"@netforge/template-idle": "0.
|
|
58
|
-
"@netforge/template-
|
|
59
|
-
"@netforge/template-party": "0.
|
|
60
|
-
"@netforge/template-platformer": "0.
|
|
61
|
-
"@netforge/template-
|
|
62
|
-
"@netforge/template-tower": "0.
|
|
63
|
-
"
|
|
63
|
+
"@netforge/engine-sim": "0.2.0",
|
|
64
|
+
"@netforge/asset-library": "0.2.0",
|
|
65
|
+
"@netforge/tabletop": "0.2.0",
|
|
66
|
+
"@netforge/asset-library-files": "0.2.0",
|
|
67
|
+
"@netforge/template-arena": "0.2.0",
|
|
68
|
+
"@netforge/engine": "0.2.0",
|
|
69
|
+
"@netforge/template-cards": "0.2.0",
|
|
70
|
+
"@netforge/template-dungeon": "0.2.0",
|
|
71
|
+
"@netforge/template-idle": "0.2.0",
|
|
72
|
+
"@netforge/template-collector": "0.2.0",
|
|
73
|
+
"@netforge/template-party": "0.2.0",
|
|
74
|
+
"@netforge/template-platformer": "0.2.0",
|
|
75
|
+
"@netforge/template-survivor": "0.2.0",
|
|
76
|
+
"@netforge/template-tower": "0.2.0",
|
|
77
|
+
"create-netforge": "0.2.0",
|
|
78
|
+
"@netforge/template-racer": "0.2.0"
|
|
64
79
|
},
|
|
65
80
|
"devDependencies": {
|
|
66
81
|
"@types/node": "22.20.1",
|
|
67
82
|
"typescript": "5.8.3",
|
|
68
83
|
"vitest": "3.2.7"
|
|
69
84
|
},
|
|
70
|
-
"bugs": {
|
|
71
|
-
"url": "https://github.com/netnetdev/net-forge/issues"
|
|
72
|
-
},
|
|
73
85
|
"scripts": {
|
|
74
86
|
"build": "node ../engine/scripts/fresh-dist.mjs && tsc -p tsconfig.json",
|
|
75
87
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
|