@netforge/kit-play 0.0.0-stage → 0.1.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,48 @@
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
+ `kit play` runs a game you downloaded from Net Forge on your own computer,
4
+ with play money.
5
+
6
+ ```sh
7
+ cd my-game # the folder from the Source download (netnet.json, src/game.ts)
8
+ npm install # its package.json lists the engine and kit-play
9
+ npx kit play # or: npm run play
10
+ ```
11
+
12
+ It builds the template's browser client with your `src/game.ts` and
13
+ `src/names.json` in place, starts a local room host for the same game
14
+ module, and opens `http://127.0.0.1:4663`. Open the link in more tabs or
15
+ windows for more players.
16
+
17
+ | Flag | Default | |
18
+ |---|---|---|
19
+ | `--port <n>` | 4663 | the page and the room, one port |
20
+ | `--host <addr>` | 127.0.0.1 | `0.0.0.0` lets devices on your network join |
21
+ | `--players <n>` | 1 | browser tabs to open |
22
+ | `--no-open` | | print the link only |
23
+ | `--library-url <url>` | the installed library | load models and sounds from a mirror instead |
24
+
25
+ ## What it does not do
26
+
27
+ - 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
+ environment and talks only to the local host; sign-in, the referee and
30
+ every money route answer 403.
31
+ - No stakes, pots or claims. Earn rules still fire, so you can see them,
32
+ and pay nothing.
33
+
34
+ The local host is built from the engine's open simulation
35
+ (`@netforge/engine`, `@netforge/engine-sim`). The hosted room
36
+ server and the referee that decides real payouts are not part of it.
37
+ Progress (achievements, looks bought with play money) lives in memory and
38
+ is gone when `kit play` stops.
39
+
40
+ kit play is not a sandbox: it runs the game's code (the room host in Node,
41
+ the client in your browser) with your permissions. Play only games whose
42
+ code you trust, as with any program you run.
43
+
44
+ `src/game.ts` may import only `@netforge/engine`, as in the studio, so
45
+ a game that plays here plays there. Send changes back with `/agent` in the
46
+ studio (see your game's README).
47
+
48
+ The build lands in `.netnet/play/` inside the game folder.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,8 @@
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 interface PlayCliIo {
4
+ out(s: string): void;
5
+ err(s: string): void;
6
+ cwd: string;
7
+ }
8
+ export declare function runPlayCli(argv: readonly string[], io: PlayCliIo): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ // kit-play [dir] [--port 4663] [--host 127.0.0.1] [--players 1] [--no-open] [--library-url <url>]
3
+ // Also reached as `kit play` through @netforge/kit.
4
+ import { realpathSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { play, PlayError } from "./play.js";
7
+ export const HELP = `kit play: run a Net Forge game on this computer with play money.
8
+
9
+ Usage: kit play [dir] [flags] (inside the folder you downloaded from Net Forge)
10
+
11
+ --port <n> Port for the page and the local room (default 4663).
12
+ --host <addr> Interface to listen on (default 127.0.0.1, this computer only).
13
+ Use 0.0.0.0 to let devices on your network join.
14
+ --players <n> Browser tabs to open, one per local player (default 1).
15
+ --no-open Print the link instead of opening a browser.
16
+ --library-url <u> Load the asset library from this URL instead of the installed copy.
17
+
18
+ No wallet, no chain, no Net Forge servers: coins are play money and nothing pays out.
19
+ kit play runs the game's own code on this computer with your permissions (it is not a
20
+ sandbox): play only games whose code you trust.`;
21
+ export async function runPlayCli(argv, io) {
22
+ const flags = new Map();
23
+ const positional = [];
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i] ?? "";
26
+ if (a.startsWith("--")) {
27
+ const [k, v] = a.slice(2).split("=", 2);
28
+ const next = argv[i + 1];
29
+ if (v !== undefined)
30
+ flags.set(k, v);
31
+ else if (k !== "no-open" && k !== "help" && next !== undefined && !next.startsWith("--"))
32
+ flags.set(k, argv[++i] ?? "");
33
+ else
34
+ flags.set(k, true);
35
+ }
36
+ else if (a === "-h")
37
+ flags.set("help", true);
38
+ else
39
+ positional.push(a);
40
+ }
41
+ if (flags.has("help")) {
42
+ io.out(HELP);
43
+ return 0;
44
+ }
45
+ const num = (k, d, min, max) => {
46
+ const v = flags.get(k);
47
+ if (v === undefined)
48
+ return d;
49
+ const n = Number(v);
50
+ if (!Number.isInteger(n) || n < min || n > max)
51
+ throw new PlayError(`--${k} must be a whole number from ${min} to ${max}`);
52
+ return n;
53
+ };
54
+ try {
55
+ const lib = flags.get("library-url");
56
+ if (lib !== undefined && (typeof lib !== "string" || !/^https?:\/\/[^\s]+$/.test(lib)))
57
+ throw new PlayError("--library-url must be an http(s) URL");
58
+ const host = flags.get("host");
59
+ const playing = await play({
60
+ dir: positional[0] ?? io.cwd,
61
+ port: num("port", 4663, 1, 65535),
62
+ host: typeof host === "string" && host.length > 0 ? host : "127.0.0.1",
63
+ open: !flags.has("no-open"),
64
+ players: num("players", 1, 1, 16),
65
+ libraryUrl: typeof lib === "string" ? lib.replace(/\/$/, "") : null,
66
+ log: io.out,
67
+ });
68
+ await new Promise((done) => {
69
+ const stop = () => {
70
+ void playing.close().finally(done);
71
+ };
72
+ process.once("SIGINT", stop);
73
+ process.once("SIGTERM", stop);
74
+ });
75
+ return 0;
76
+ }
77
+ catch (e) {
78
+ io.err(e instanceof PlayError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));
79
+ return 1;
80
+ }
81
+ }
82
+ const isMain = (() => {
83
+ try {
84
+ return process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ })();
90
+ if (isMain) {
91
+ const code = await runPlayCli(process.argv.slice(2), { out: (s) => console.log(s), err: (s) => console.error(s), cwd: process.cwd() });
92
+ process.exit(code);
93
+ }
@@ -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
+ }
@@ -0,0 +1,247 @@
1
+ // The local play host's authoritative loop: one world, a fixed tick, one
2
+ // input queue per player and per-player delta snapshots, built only from
3
+ // the engine's open netcode (@netforge/engine/net). It is written for
4
+ // one computer: no lag compensation history (hitscans resolve against the
5
+ // current hitboxes, which is exact at local latency), no run log, no
6
+ // replay, and nothing that pays anyone.
7
+ import { decodeInputPacket, DEFAULT_INPUT_QUEUE, encodeSnapshot, InputQueue, kindFieldIndices, quantizeEntity, SnapshotHistory, } from "@netforge/engine/net";
8
+ export const SNAPSHOT_HZ = 20;
9
+ /** Jitter-buffer depth for one-input-per-tick sims (the room server's default). */
10
+ const ONE_PER_TICK_DEPTH = 2;
11
+ /** Distance along a unit ray to a circle, or null. */
12
+ export function rayCircle(ox, oy, ux, uy, cx, cy, r) {
13
+ const mx = ox - cx;
14
+ const my = oy - cy;
15
+ const b = mx * ux + my * uy;
16
+ const c = mx * mx + my * my - r * r;
17
+ if (c > 0 && b > 0)
18
+ return null;
19
+ const disc = b * b - c;
20
+ if (disc < 0)
21
+ return null;
22
+ const t = -b - Math.sqrt(disc);
23
+ return t < 0 ? 0 : t;
24
+ }
25
+ /** The nearest hitbox a hitscan meets that the shooter does not own, or null. */
26
+ export function resolveHitscan(boxes, req) {
27
+ const len = Math.hypot(req.dx, req.dy);
28
+ if (len === 0)
29
+ return null;
30
+ const ux = req.dx / len;
31
+ const uy = req.dy / len;
32
+ let best = null;
33
+ let bestT = Infinity;
34
+ for (const h of boxes) {
35
+ if (h.owner === req.shooter)
36
+ continue;
37
+ const t = rayCircle(req.ox, req.oy, ux, uy, h.x, h.y, h.r);
38
+ if (t !== null && t <= req.range && t < bestT) {
39
+ bestT = t;
40
+ best = h;
41
+ }
42
+ }
43
+ return best;
44
+ }
45
+ export class LocalCore {
46
+ sim;
47
+ aoi;
48
+ worldRef;
49
+ tickN = 0;
50
+ snapAccum = 0;
51
+ conns = new Map();
52
+ rights = new Map();
53
+ nextPlayer = 1;
54
+ freeIds = [];
55
+ boxes = [];
56
+ constructor(sim, aoi, seed) {
57
+ this.sim = sim;
58
+ this.aoi = aoi;
59
+ this.worldRef = sim.createWorld(seed, "server");
60
+ }
61
+ get world() {
62
+ return this.worldRef;
63
+ }
64
+ get tick() {
65
+ return this.tickN;
66
+ }
67
+ get players() {
68
+ return [...this.conns.keys()];
69
+ }
70
+ addPlayer() {
71
+ const player = this.freeIds.shift() ?? this.nextPlayer++;
72
+ if (player > 0xffff)
73
+ throw new Error("player ids exhausted");
74
+ const entity = this.sim.addPlayer(this.worldRef, player);
75
+ this.conns.set(player, {
76
+ player,
77
+ entity,
78
+ queue: new InputQueue({
79
+ ...DEFAULT_INPUT_QUEUE,
80
+ maxCredits: Math.min(DEFAULT_INPUT_QUEUE.maxCredits, this.sim.maxInputsPerTick ?? Infinity),
81
+ ...(this.sim.maxInputsPerTick === 1 ? { targetDepth: ONE_PER_TICK_DEPTH } : {}),
82
+ }),
83
+ history: new SnapshotHistory(64),
84
+ ackTick: 0,
85
+ });
86
+ return { player, entity };
87
+ }
88
+ removePlayer(player) {
89
+ if (!this.conns.delete(player))
90
+ return;
91
+ this.rights.delete(player);
92
+ this.sim.removePlayer(this.worldRef, player);
93
+ this.freeIds.push(player);
94
+ }
95
+ setRights(player, rights) {
96
+ if (!this.conns.has(player))
97
+ return;
98
+ if (rights)
99
+ this.rights.set(player, rights);
100
+ else
101
+ this.rights.delete(player);
102
+ }
103
+ command(player, cmd) {
104
+ if (!this.sim.command || !this.conns.has(player))
105
+ return false;
106
+ return this.sim.command(this.worldRef, player, cmd);
107
+ }
108
+ startRun(player) {
109
+ if (this.conns.has(player))
110
+ this.sim.startRun?.(this.worldRef, player);
111
+ }
112
+ receiveInput(player, bytes) {
113
+ const c = this.conns.get(player);
114
+ if (!c)
115
+ return;
116
+ let pkt;
117
+ try {
118
+ pkt = decodeInputPacket(this.sim.input, bytes);
119
+ }
120
+ catch {
121
+ return;
122
+ }
123
+ if (pkt.ackTick > c.ackTick && pkt.ackTick <= this.tickN)
124
+ c.ackTick = pkt.ackTick;
125
+ else if (pkt.ackTick === 0)
126
+ c.ackTick = 0;
127
+ const rights = this.rights.get(player);
128
+ const restrict = this.sim.restrictInput;
129
+ c.queue.receive(rights && restrict ? pkt.commands.map((cmd) => ({ ...cmd, input: restrict.call(this.sim, cmd.input, rights) })) : pkt.commands);
130
+ }
131
+ step() {
132
+ this.tickN++;
133
+ const events = [];
134
+ const inputs = new Map();
135
+ for (const c of this.conns.values()) {
136
+ const list = [];
137
+ for (const cmd of c.queue.consume()) {
138
+ const req = this.sim.hitscans?.(this.worldRef, c.player, cmd.input) ?? null;
139
+ if (req && this.sim.applyHit) {
140
+ const hit = resolveHitscan(this.boxes, req);
141
+ if (hit)
142
+ events.push(...this.sim.applyHit(this.worldRef, c.player, hit));
143
+ }
144
+ list.push(cmd.input);
145
+ }
146
+ inputs.set(c.player, list);
147
+ }
148
+ events.push(...this.sim.step(this.worldRef, inputs));
149
+ if (this.sim.hitboxes)
150
+ this.boxes = this.sim.hitboxes(this.worldRef);
151
+ this.snapAccum += SNAPSHOT_HZ / this.sim.tickHz;
152
+ let snapshots = new Map();
153
+ if (this.snapAccum >= 1) {
154
+ this.snapAccum -= 1;
155
+ snapshots = this.buildSnapshots();
156
+ }
157
+ return { tick: this.tickN, events, snapshots };
158
+ }
159
+ /** A fresh world at a run boundary; every player is re-added under the same id. Returns the new entity ids. */
160
+ rebase(seed) {
161
+ this.sim.disposeWorld?.(this.worldRef);
162
+ this.worldRef = this.sim.createWorld(seed, "server");
163
+ this.boxes = [];
164
+ const out = new Map();
165
+ for (const c of this.conns.values()) {
166
+ c.entity = this.sim.addPlayer(this.worldRef, c.player);
167
+ c.history = new SnapshotHistory(64);
168
+ c.ackTick = 0;
169
+ out.set(c.player, c.entity);
170
+ }
171
+ return out;
172
+ }
173
+ dispose() {
174
+ this.sim.disposeWorld?.(this.worldRef);
175
+ }
176
+ buildSnapshots() {
177
+ const schema = this.sim.schema;
178
+ const upw = schema.unitsPerWorld;
179
+ const states = this.sim.snapshot(this.worldRef);
180
+ const byId = new Map();
181
+ for (const s of states)
182
+ byId.set(s.id, s);
183
+ const packed = new Map();
184
+ const qOf = (s) => {
185
+ let q = packed.get(s.id);
186
+ if (!q) {
187
+ q = { id: s.id, kind: s.kind, owner: s.owner, q: quantizeEntity(schema, s, kindFieldIndices(schema, s.kind)) };
188
+ packed.set(s.id, q);
189
+ }
190
+ return q;
191
+ };
192
+ const { radius, maxEntities, kindCaps, alwaysKinds } = this.aoi;
193
+ const out = new Map();
194
+ for (const c of this.conns.values()) {
195
+ const self = byId.get(c.entity);
196
+ const visible = [];
197
+ if (self) {
198
+ const x = (self.values[schema.xField] ?? 0) / upw;
199
+ const y = (self.values[schema.yField] ?? 0) / upw;
200
+ const seen = new Set([self.id]);
201
+ visible.push(qOf(self));
202
+ if (alwaysKinds) {
203
+ for (const e of states) {
204
+ if (!seen.has(e.id) && alwaysKinds.includes(e.kind)) {
205
+ seen.add(e.id);
206
+ visible.push(qOf(e));
207
+ }
208
+ }
209
+ }
210
+ const near = [];
211
+ for (const e of states) {
212
+ if (seen.has(e.id))
213
+ continue;
214
+ const dx = (e.values[schema.xField] ?? 0) / upw - x;
215
+ const dy = (e.values[schema.yField] ?? 0) / upw - y;
216
+ const d = dx * dx + dy * dy;
217
+ if (d <= radius * radius)
218
+ near.push({ e, d });
219
+ }
220
+ near.sort((a, b) => a.d - b.d);
221
+ const used = new Map();
222
+ for (const { e } of near) {
223
+ if (visible.length >= maxEntities)
224
+ break;
225
+ const cap = kindCaps?.[e.kind];
226
+ const n = used.get(e.kind) ?? 0;
227
+ if (cap !== undefined && n >= cap)
228
+ continue;
229
+ used.set(e.kind, n + 1);
230
+ visible.push(qOf(e));
231
+ }
232
+ }
233
+ const baseline = c.ackTick > 0 ? (c.history.get(c.ackTick) ?? null) : null;
234
+ const bytes = encodeSnapshot(schema, {
235
+ tick: this.tickN,
236
+ baselineTick: baseline ? c.ackTick : 0,
237
+ ackSeq: c.queue.lastAppliedSeq,
238
+ self: c.entity,
239
+ ...(this.sim.maxInputsPerTick === 1 ? { inputBuffer: c.queue.pending } : {}),
240
+ }, visible, baseline);
241
+ c.history.put(this.tickN, visible);
242
+ c.history.pruneBefore(c.ackTick);
243
+ out.set(c.player, bytes);
244
+ }
245
+ return out;
246
+ }
247
+ }
@@ -0,0 +1,20 @@
1
+ import type { LocalAoi } from "./core.js";
2
+ import { type RunningHost } from "./server.js";
3
+ export interface EntryOptions {
4
+ readonly adapter: string;
5
+ readonly manifest: {
6
+ readonly fn: string;
7
+ readonly field?: "manifest";
8
+ };
9
+ readonly seats: number;
10
+ readonly aoi: LocalAoi;
11
+ readonly port: number;
12
+ readonly host: string;
13
+ readonly clientDir: string;
14
+ readonly libraryDir: string | null;
15
+ }
16
+ export declare function startHost(tpl: Readonly<Record<string, unknown>>, o: EntryOptions): Promise<RunningHost & {
17
+ readonly template: string;
18
+ readonly place: string;
19
+ readonly title: string;
20
+ }>;
@@ -0,0 +1,37 @@
1
+ // Starts the local host for one game module. `kit play` bundles this file
2
+ // together with the template's src/manifest.ts (the creator's src/game.ts in
3
+ // place of the template's), so the sim, the engine and the host share one
4
+ // copy of every module, and imports the bundle.
5
+ import { initSim } from "@netforge/engine-sim";
6
+ import { profileSpec } from "@netforge/engine/game";
7
+ import { LocalProfiles } from "./profile.js";
8
+ import { startServer } from "./server.js";
9
+ export async function startHost(tpl, o) {
10
+ await initSim();
11
+ const make = tpl[o.adapter];
12
+ const get = tpl[o.manifest.fn];
13
+ if (typeof make !== "function" || typeof get !== "function")
14
+ throw new Error(`the template does not export ${o.adapter} and ${o.manifest.fn}`);
15
+ const sim = make();
16
+ const got = get();
17
+ const manifest = (o.manifest.field ? got[o.manifest.field] : got);
18
+ const template = typeof tpl.TEMPLATE === "string" ? tpl.TEMPLATE : "";
19
+ const place = typeof tpl.PLACE === "string" ? tpl.PLACE : "local";
20
+ const spec = profileSpec(manifest);
21
+ const storeCommands = new Set();
22
+ for (const [id, s] of manifest.skus)
23
+ if (!s.grants && s.coins !== undefined)
24
+ storeCommands.add(id);
25
+ const gameName = typeof tpl.GAME_NAME === "string" ? tpl.GAME_NAME : template;
26
+ const running = await startServer({
27
+ sim,
28
+ template,
29
+ place,
30
+ instanceCap: o.seats,
31
+ aoi: o.aoi,
32
+ profile: spec,
33
+ profiles: new LocalProfiles(gameName, spec),
34
+ storeCommands,
35
+ }, { port: o.port, host: o.host, clientDir: o.clientDir, libraryDir: o.libraryDir });
36
+ return { ...running, template, place, title: gameName };
37
+ }
@@ -0,0 +1,29 @@
1
+ import { type ProfileSpec } from "@netforge/engine/game";
2
+ import type { GameProfile } from "@netforge/engine/net";
3
+ export declare function isGuestId(v: unknown): v is string;
4
+ export declare class LocalProfiles {
5
+ private readonly game;
6
+ private readonly spec;
7
+ private readonly now;
8
+ private readonly byId;
9
+ private readonly listeners;
10
+ constructor(game: string, spec: ProfileSpec | null, now?: () => number);
11
+ onChange(fn: (id: string) => void): () => void;
12
+ private saved;
13
+ private changed;
14
+ /** Keeper indices this profile may pick (null: the game has no keepers). */
15
+ keepers(id: string | null): number[] | null;
16
+ ownsCosmetic(id: string | null, value: number): boolean;
17
+ record(id: string, kind: "achievement" | "goal" | "title", item: string): void;
18
+ /** A permanent SKU bought with play money (POST /profile/redeem): the look or unlock is owned from now on. */
19
+ redeem(id: string, item: string): {
20
+ ok: boolean;
21
+ error?: string;
22
+ };
23
+ /** Meta upgrade levels owned (bought with play money). */
24
+ metaLevels(id: string | null): Readonly<Record<string, number>>;
25
+ settings(id: string, settings: unknown): void;
26
+ name(id: string, displayName: unknown): void;
27
+ view(id: string | null): GameProfile;
28
+ private store;
29
+ }