@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.
@@ -0,0 +1,163 @@
1
+ // Saved progress for local play, in memory only: achievements, goals and
2
+ // titles the sim reports, the looks and unlocks bought with play money,
3
+ // settings and a display name. Everything is gone when `kit play` stops.
4
+ // Nothing here is a wallet, a balance or a claim.
5
+ import { metaPrice, openKeepers, ownedCosmetics, todaysGoals, utcDay } from "@netforge/engine/game";
6
+ const GUEST_RE = /^[A-Za-z0-9_-]{16,80}$/;
7
+ export function isGuestId(v) {
8
+ return typeof v === "string" && GUEST_RE.test(v);
9
+ }
10
+ export class LocalProfiles {
11
+ game;
12
+ spec;
13
+ now;
14
+ byId = new Map();
15
+ listeners = new Set();
16
+ constructor(game, spec, now = Date.now) {
17
+ this.game = game;
18
+ this.spec = spec;
19
+ this.now = now;
20
+ }
21
+ onChange(fn) {
22
+ this.listeners.add(fn);
23
+ return () => this.listeners.delete(fn);
24
+ }
25
+ saved(id) {
26
+ let s = this.byId.get(id);
27
+ if (!s) {
28
+ s = { achievements: new Map(), goals: new Map(), titles: new Map(), owned: new Set(), meta: {}, settings: {}, displayName: null };
29
+ this.byId.set(id, s);
30
+ }
31
+ return s;
32
+ }
33
+ changed(id) {
34
+ for (const fn of this.listeners)
35
+ fn(id);
36
+ }
37
+ /** Keeper indices this profile may pick (null: the game has no keepers). */
38
+ keepers(id) {
39
+ const spec = this.spec;
40
+ if (!spec || spec.keepers.length === 0)
41
+ return null;
42
+ const s = id ? this.saved(id) : null;
43
+ return openKeepers(spec, { achievements: s ? s.achievements.keys() : [], owned: s ? s.owned : [] });
44
+ }
45
+ ownsCosmetic(id, value) {
46
+ if (value === 0)
47
+ return true;
48
+ if (!this.spec || !id)
49
+ return false;
50
+ return ownedCosmetics(this.spec, this.saved(id).owned).includes(value);
51
+ }
52
+ record(id, kind, item) {
53
+ const s = this.saved(id);
54
+ if (kind === "achievement") {
55
+ if (s.achievements.has(item))
56
+ return;
57
+ s.achievements.set(item, this.now());
58
+ }
59
+ else if (kind === "goal") {
60
+ const day = utcDay(this.now());
61
+ const set = s.goals.get(day) ?? new Set();
62
+ if (set.has(item))
63
+ return;
64
+ set.add(item);
65
+ s.goals.set(day, set);
66
+ }
67
+ else {
68
+ s.titles.set(item, (s.titles.get(item) ?? 0) + 1);
69
+ }
70
+ this.changed(id);
71
+ }
72
+ /** A permanent SKU bought with play money (POST /profile/redeem): the look or unlock is owned from now on. */
73
+ redeem(id, item) {
74
+ const def = this.spec?.meta.get(item);
75
+ if (def) {
76
+ const s = this.saved(id);
77
+ const level = s.meta[item] ?? 0;
78
+ if (level >= def.levels)
79
+ return { ok: false, error: "at the top level" };
80
+ s.meta[item] = level + 1;
81
+ this.changed(id);
82
+ return { ok: true };
83
+ }
84
+ const sku = this.spec?.skus.get(item);
85
+ if (!sku)
86
+ return { ok: false, error: "no such item" };
87
+ const s = this.saved(id);
88
+ if (sku.kind === "consumable")
89
+ return { ok: false, error: "a run upgrade is bought in the run" };
90
+ s.owned.add(item);
91
+ this.changed(id);
92
+ return { ok: true };
93
+ }
94
+ /** Meta upgrade levels owned (bought with play money). */
95
+ metaLevels(id) {
96
+ return id ? { ...this.saved(id).meta } : {};
97
+ }
98
+ settings(id, settings) {
99
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings))
100
+ return;
101
+ if (JSON.stringify(settings).length > 8192)
102
+ return;
103
+ this.saved(id).settings = { ...settings };
104
+ this.changed(id);
105
+ }
106
+ name(id, displayName) {
107
+ if (typeof displayName !== "string")
108
+ return;
109
+ const n = displayName.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 24);
110
+ this.saved(id).displayName = n || null;
111
+ this.changed(id);
112
+ }
113
+ view(id) {
114
+ const spec = this.spec;
115
+ const day = utcDay(this.now());
116
+ const today = spec ? todaysGoals([...spec.missions.keys()], day) : [];
117
+ const s = id ? this.saved(id) : null;
118
+ const owned = s ? [...s.owned] : [];
119
+ const keeperIdx = this.keepers(id) ?? [];
120
+ return {
121
+ game: this.game,
122
+ kind: id ? "guest" : null,
123
+ wallet: null,
124
+ displayName: s?.displayName ?? null,
125
+ achievements: s ? [...s.achievements].map(([a, at]) => ({ id: a, at, source: "sim" })) : [],
126
+ keepers: spec ? keeperIdx.map((i) => spec.keepers[i]?.id ?? "").filter(Boolean) : [],
127
+ owned,
128
+ cosmetics: spec ? ownedCosmetics(spec, owned) : [0],
129
+ meta: s ? { ...s.meta } : {},
130
+ goals: { day, today, done: s ? [...(s.goals.get(day) ?? [])].filter((g) => today.includes(g)) : [] },
131
+ titles: s ? Object.fromEntries(s.titles) : {},
132
+ settings: s ? { ...s.settings } : {},
133
+ store: spec ? this.store(owned, s?.meta ?? {}) : [],
134
+ imported: false,
135
+ credit: 0,
136
+ totals: { achievements: s?.achievements.size ?? 0, titles: s ? [...s.titles.values()].reduce((a, b) => a + b, 0) : 0, games: s ? 1 : 0 },
137
+ };
138
+ }
139
+ store(owned, meta) {
140
+ const spec = this.spec;
141
+ if (!spec)
142
+ return [];
143
+ const have = new Set(owned);
144
+ const out = [];
145
+ for (const sku of spec.skus.values()) {
146
+ if (sku.kind === "consumable" || !sku.grants)
147
+ continue;
148
+ const g = sku.grants;
149
+ const mine = have.has(sku.id);
150
+ if ("cosmetic" in g)
151
+ out.push({ id: sku.id, kind: "cosmetic", coins: mine ? null : sku.coins, owned: mine, cosmetic: g.cosmetic });
152
+ else if ("keeper" in g)
153
+ out.push({ id: sku.id, kind: "unlock", coins: mine ? null : sku.coins, owned: mine, keeper: g.keeper });
154
+ else if ("gear" in g)
155
+ out.push({ id: sku.id, kind: "gear", coins: mine ? null : sku.coins, owned: mine, gear: g.gear, loseBelow: g.loseBelow });
156
+ }
157
+ for (const [id, def] of spec.meta) {
158
+ const level = meta[id] ?? 0;
159
+ out.push({ id, kind: "meta", coins: metaPrice(def, level), owned: level > 0, stat: def.stat, step: def.step, level, levels: def.levels, bonus: level * def.step });
160
+ }
161
+ return out;
162
+ }
163
+ }
@@ -0,0 +1,37 @@
1
+ import { Room, type Client } from "@colyseus/core";
2
+ import { type ProfileSpec } from "@netforge/engine/game";
3
+ import { type CreateOptions, type JoinOptions, type SimAdapter } from "@netforge/engine/net";
4
+ import { type LocalAoi } from "./core.js";
5
+ import { type LocalProfiles } from "./profile.js";
6
+ /** What the host serves: one game, set once before the server listens. */
7
+ export interface LocalGame {
8
+ readonly sim: SimAdapter<unknown, unknown>;
9
+ readonly template: string;
10
+ readonly place: string;
11
+ readonly instanceCap: number;
12
+ readonly aoi: LocalAoi;
13
+ readonly profile: ProfileSpec | null;
14
+ readonly profiles: LocalProfiles;
15
+ /** SKU ids the store sells for coins (play money here): the only commands a client may send. */
16
+ readonly storeCommands: ReadonlySet<string>;
17
+ }
18
+ export declare function setLocalGame(g: LocalGame): void;
19
+ /** Why local play turns these down, in the words the client shows. */
20
+ export declare const LOCAL_ONLY = "Local play uses play money only";
21
+ export declare class LocalRoom extends Room {
22
+ private core;
23
+ private g;
24
+ private readonly seats;
25
+ private readonly clients_;
26
+ private unsubscribe;
27
+ onCreate(options: CreateOptions): void;
28
+ onJoin(client: Client, options?: JoinOptions): void;
29
+ onLeave(client: Client): void;
30
+ onDispose(): void;
31
+ private applyProfile;
32
+ private sendInfo;
33
+ private recordProgress;
34
+ private tickOnce;
35
+ /** The sim ended a run: a fresh world, as the hosted room does between runs. Players stay connected. */
36
+ private newRun;
37
+ }
@@ -0,0 +1,199 @@
1
+ // The local play room: one Colyseus room per place, speaking the engine's
2
+ // room protocol (@netforge/engine/net protocol.ts) so the template
3
+ // clients connect to it exactly as they connect to a hosted room. Play
4
+ // money only: there are no wallets, no sign-in, no stakes, no pots and no
5
+ // claims here, and earn events are shown to the player but pay nothing.
6
+ import { Room } from "@colyseus/core";
7
+ import { packMeta, RESERVED_COMMANDS } from "@netforge/engine/game";
8
+ import { MSG_INPUT, MSG_SNAPSHOT, } from "@netforge/engine/net";
9
+ import { LocalCore, SNAPSHOT_HZ } from "./core.js";
10
+ import { isGuestId } from "./profile.js";
11
+ let current = null;
12
+ export function setLocalGame(g) {
13
+ current = g;
14
+ }
15
+ function game() {
16
+ if (!current)
17
+ throw new Error("local game not set");
18
+ return current;
19
+ }
20
+ /** Why local play turns these down, in the words the client shows. */
21
+ export const LOCAL_ONLY = "Local play uses play money only";
22
+ const INFO_EVERY_TICKS = 15;
23
+ function parseCommand(raw) {
24
+ if (typeof raw !== "object" || raw === null)
25
+ return null;
26
+ const o = raw;
27
+ if (typeof o.name !== "string" || o.name.length === 0 || o.name.length > 32)
28
+ return null;
29
+ const value = typeof o.value === "number" && Number.isFinite(o.value) ? o.value : undefined;
30
+ return { name: o.name, ...(value !== undefined ? { value } : {}) };
31
+ }
32
+ export class LocalRoom extends Room {
33
+ core;
34
+ g;
35
+ seats = new Map();
36
+ clients_ = new Map();
37
+ unsubscribe = null;
38
+ onCreate(options) {
39
+ this.g = game();
40
+ if (options?.template !== this.g.template)
41
+ throw new Error(`this host plays the ${this.g.template} template`);
42
+ const sim = this.g.sim;
43
+ this.maxClients = this.g.instanceCap;
44
+ this.autoDispose = true;
45
+ this.setPatchRate(null);
46
+ void this.setMetadata({ place: this.g.place, template: this.g.template });
47
+ this.core = new LocalCore(sim, this.g.aoi, (Math.random() * 2 ** 31) | 0);
48
+ this.unsubscribe = this.g.profiles.onChange((id) => {
49
+ for (const seat of this.seats.values())
50
+ if (seat.profile === id)
51
+ this.applyProfile(seat);
52
+ });
53
+ this.onMessageBytes(MSG_INPUT, (client, bytes) => {
54
+ const seat = this.seats.get(client.sessionId);
55
+ if (seat)
56
+ this.core.receiveInput(seat.player, bytes);
57
+ });
58
+ this.onMessage("command", (client, raw) => {
59
+ const seat = this.seats.get(client.sessionId);
60
+ const cmd = parseCommand(raw);
61
+ if (!seat || !cmd)
62
+ return;
63
+ const reply = (r) => client.send("command-result", r);
64
+ if (!sim.command)
65
+ return reply({ name: cmd.name, ok: false, reason: "no store in this game" });
66
+ if (RESERVED_COMMANDS.has(cmd.name))
67
+ return reply({ name: cmd.name, ok: false, reason: "not for sale" });
68
+ const sold = cmd.name === "cosmetic" ? this.g.profiles.ownsCosmetic(seat.profile, cmd.value ?? 0) : this.g.storeCommands.has(cmd.name);
69
+ if (!sold)
70
+ return reply({ name: cmd.name, ok: false, reason: "not for sale" });
71
+ const ok = this.core.command(seat.player, { name: cmd.name, ...(cmd.value !== undefined ? { value: cmd.value } : {}) });
72
+ reply(ok ? { name: cmd.name, ok } : { name: cmd.name, ok, reason: "not applicable now" });
73
+ });
74
+ this.onMessage("stake", (client) => client.send("stake", { state: "refused", stake: 0, reason: LOCAL_ONLY }));
75
+ this.onMessage("pot", (client) => client.send("pot", { state: "refused", entry: 0, reason: LOCAL_ONLY }));
76
+ this.onMessage("sign-in", (client) => client.send("sign-in-result", { ok: false, runId: null, earnRefused: "guest" }));
77
+ this.onMessage("claim", (client) => client.send("attest-error", LOCAL_ONLY));
78
+ this.onMessage("ping", (client, t) => client.send("pong", t));
79
+ this.setSimulationInterval(() => this.tickOnce(), 1000 / sim.tickHz);
80
+ }
81
+ onJoin(client, options) {
82
+ const { player, entity } = this.core.addPlayer();
83
+ const seat = { sessionId: client.sessionId, player, entity, profile: isGuestId(options?.guest) ? `g:${options.guest}` : null, metaApplied: 0 };
84
+ this.seats.set(client.sessionId, seat);
85
+ this.clients_.set(player, client);
86
+ const sim = this.g.sim;
87
+ const welcome = {
88
+ player,
89
+ entity,
90
+ tickHz: sim.tickHz,
91
+ snapshotHz: SNAPSHOT_HZ,
92
+ sim: sim.name,
93
+ version: sim.version,
94
+ place: this.g.place,
95
+ template: this.g.template,
96
+ runId: null,
97
+ earnLive: false,
98
+ };
99
+ client.send("welcome", welcome);
100
+ this.applyProfile(seat);
101
+ this.sendInfo(seat, client, true);
102
+ }
103
+ onLeave(client) {
104
+ const seat = this.seats.get(client.sessionId);
105
+ if (!seat)
106
+ return;
107
+ this.seats.delete(client.sessionId);
108
+ this.clients_.delete(seat.player);
109
+ this.core.removePlayer(seat.player);
110
+ }
111
+ onDispose() {
112
+ this.unsubscribe?.();
113
+ this.core?.dispose();
114
+ }
115
+ applyProfile(seat) {
116
+ const keepers = this.g.profiles.keepers(seat.profile);
117
+ this.core.setRights(seat.player, keepers ? { keepers } : null);
118
+ const spec = this.g.profile;
119
+ if (spec && spec.economy.metaUpgrades && spec.meta.size > 0) {
120
+ const value = packMeta(spec, this.g.profiles.metaLevels(seat.profile));
121
+ if (value !== seat.metaApplied) {
122
+ this.core.command(seat.player, { name: "meta", value });
123
+ seat.metaApplied = value;
124
+ }
125
+ }
126
+ this.clients_.get(seat.player)?.send("profile", this.g.profiles.view(seat.profile));
127
+ }
128
+ sendInfo(seat, client, force) {
129
+ const sim = this.g.sim;
130
+ if (sim.infoFor) {
131
+ const v = sim.infoFor(this.core.world, seat.player);
132
+ if (v === null || v === undefined)
133
+ return;
134
+ const json = JSON.stringify(v);
135
+ if (!force && json === seat.lastInfo)
136
+ return;
137
+ seat.lastInfo = json;
138
+ client.send("run", v);
139
+ return;
140
+ }
141
+ const info = sim.info?.(this.core.world);
142
+ if (info)
143
+ client.send("run", info);
144
+ }
145
+ recordProgress(e) {
146
+ if (!this.g.profile)
147
+ return;
148
+ const c = this.clients_.get(e.player);
149
+ const seat = c ? this.seats.get(c.sessionId) : undefined;
150
+ if (seat?.profile)
151
+ this.g.profiles.record(seat.profile, e.kind, e.id);
152
+ }
153
+ tickOnce() {
154
+ const res = this.core.step();
155
+ let reset = false;
156
+ for (const e of res.events) {
157
+ if (e.type === "run.reset")
158
+ reset = true;
159
+ else if (e.type === "progress")
160
+ this.recordProgress(e);
161
+ else
162
+ this.clients_.get(e.player)?.send("earn", { rule: e.rule, units: e.units });
163
+ }
164
+ for (const [player, bytes] of res.snapshots)
165
+ this.clients_.get(player)?.sendBytes(MSG_SNAPSHOT, bytes);
166
+ const sim = this.g.sim;
167
+ if (res.tick % (sim.infoEveryTicks ?? INFO_EVERY_TICKS) === 0) {
168
+ if (sim.infoFor) {
169
+ for (const seat of this.seats.values()) {
170
+ const c = this.clients_.get(seat.player);
171
+ if (c)
172
+ this.sendInfo(seat, c, false);
173
+ }
174
+ }
175
+ else if (sim.info) {
176
+ const info = sim.info(this.core.world);
177
+ if (info)
178
+ this.broadcast("run", info);
179
+ }
180
+ }
181
+ if (reset)
182
+ this.newRun();
183
+ }
184
+ /** The sim ended a run: a fresh world, as the hosted room does between runs. Players stay connected. */
185
+ newRun() {
186
+ const entities = this.core.rebase((Math.random() * 2 ** 31) | 0);
187
+ const sim = this.g.sim;
188
+ for (const seat of this.seats.values()) {
189
+ const entity = entities.get(seat.player);
190
+ if (entity === undefined)
191
+ continue;
192
+ seat.entity = entity;
193
+ seat.lastInfo = undefined;
194
+ seat.metaApplied = 0;
195
+ this.clients_.get(seat.player)?.send("rebase", { entity, sim: sim.name, version: sim.version });
196
+ this.applyProfile(seat);
197
+ }
198
+ }
199
+ }
@@ -0,0 +1,25 @@
1
+ import { type LocalGame } from "./room.js";
2
+ export interface HostOptions {
3
+ readonly port: number;
4
+ /** Interface to bind; 127.0.0.1 keeps the game on this computer. */
5
+ readonly host: string;
6
+ /** The built client (index.html and its assets). */
7
+ readonly clientDir: string;
8
+ /** The built asset library (index.json and the style folders), or null to serve none. */
9
+ readonly libraryDir: string | null;
10
+ }
11
+ export interface RunningHost {
12
+ readonly url: string;
13
+ close(): Promise<void>;
14
+ }
15
+ /** Rooms one local host keeps open at once (each runs its own sim). */
16
+ export declare const MAX_ROOMS = 8;
17
+ /** A file under `root` for a request path, or null (never outside root). */
18
+ export declare function fileUnder(root: string, urlPath: string): string | null;
19
+ /**
20
+ * On a loopback bind, only requests addressed to this computer by name are
21
+ * answered (a page elsewhere that rebinds its own host name to 127.0.0.1
22
+ * gets 403), and only same-origin or non-browser requests may POST.
23
+ */
24
+ export declare function allowedRequest(bindHost: string, port: number, host: string | undefined, origin: string | undefined, method: string | undefined): boolean;
25
+ export declare function startServer(g: LocalGame, o: HostOptions): Promise<RunningHost>;
@@ -0,0 +1,233 @@
1
+ // The local play host's HTTP side, on one port: the built game page, the
2
+ // asset library, the seat request (POST /world/join) and in-memory profiles,
3
+ // with Colyseus behind them for the room itself. It binds to 127.0.0.1
4
+ // unless told otherwise. Sign-in, the referee and every money route answer
5
+ // 403: local play has none of them.
6
+ import { existsSync, readFileSync, statSync } from "node:fs";
7
+ import { createServer } from "node:http";
8
+ import { extname, normalize, resolve, sep } from "node:path";
9
+ import { defineRoom, defineServer, matchMaker } from "@colyseus/core";
10
+ import { WebSocketTransport } from "@colyseus/ws-transport";
11
+ import { ROOM_NAME } from "@netforge/engine/net";
12
+ import { LOCAL_ONLY, LocalRoom, setLocalGame } from "./room.js";
13
+ import { isGuestId } from "./profile.js";
14
+ const TYPES = {
15
+ ".html": "text/html; charset=utf-8",
16
+ ".js": "text/javascript; charset=utf-8",
17
+ ".mjs": "text/javascript; charset=utf-8",
18
+ ".css": "text/css; charset=utf-8",
19
+ ".json": "application/json",
20
+ ".wasm": "application/wasm",
21
+ ".svg": "image/svg+xml",
22
+ ".png": "image/png",
23
+ ".webp": "image/webp",
24
+ ".jpg": "image/jpeg",
25
+ ".glb": "model/gltf-binary",
26
+ ".ktx2": "image/ktx2",
27
+ ".ogg": "audio/ogg",
28
+ ".mp3": "audio/mpeg",
29
+ ".woff2": "font/woff2",
30
+ };
31
+ const MAX_BODY = 64 * 1024;
32
+ /** Rooms one local host keeps open at once (each runs its own sim). */
33
+ export const MAX_ROOMS = 8;
34
+ function send(res, status, body) {
35
+ if (res.headersSent)
36
+ return;
37
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
38
+ res.end(JSON.stringify(body));
39
+ }
40
+ /** A file under `root` for a request path, or null (never outside root). */
41
+ export function fileUnder(root, urlPath) {
42
+ let rel;
43
+ try {
44
+ rel = decodeURIComponent(urlPath);
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ if (rel.includes("\0"))
50
+ return null;
51
+ const base = resolve(root);
52
+ const file = resolve(base, "." + normalize("/" + rel));
53
+ if (file !== base && !file.startsWith(base + sep))
54
+ return null;
55
+ if (!existsSync(file) || !statSync(file).isFile())
56
+ return null;
57
+ return file;
58
+ }
59
+ function serveFile(res, file) {
60
+ res.writeHead(200, { "content-type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream", "cache-control": "no-cache" });
61
+ res.end(readFileSync(file));
62
+ }
63
+ function readBody(req) {
64
+ return new Promise((ok, fail) => {
65
+ const chunks = [];
66
+ let size = 0;
67
+ req.on("data", (c) => {
68
+ size += c.length;
69
+ if (size > MAX_BODY) {
70
+ fail(new Error("body too large"));
71
+ req.destroy();
72
+ return;
73
+ }
74
+ chunks.push(c);
75
+ });
76
+ req.on("end", () => {
77
+ if (chunks.length === 0)
78
+ return ok({});
79
+ try {
80
+ ok(JSON.parse(Buffer.concat(chunks).toString("utf8")));
81
+ }
82
+ catch {
83
+ ok({});
84
+ }
85
+ });
86
+ req.on("error", fail);
87
+ });
88
+ }
89
+ const obj = (v) => (typeof v === "object" && v !== null && !Array.isArray(v) ? v : {});
90
+ async function joinSeat(g, body) {
91
+ if (body.template !== g.template)
92
+ throw new Error(`this host plays the ${g.template} template`);
93
+ const place = typeof body.place === "string" && body.place.length > 0 && body.place.length <= 64 ? body.place : g.place;
94
+ const rooms = await matchMaker.query({ name: ROOM_NAME });
95
+ const open = rooms.find((r) => r.metadata?.place === place && !r.locked && r.clients < r.maxClients);
96
+ if (!open && rooms.length >= MAX_ROOMS)
97
+ throw new Error("too many rooms on this computer");
98
+ const room = open ?? (await matchMaker.createRoom(ROOM_NAME, { place, template: g.template }));
99
+ const reservation = await matchMaker.reserveSeatFor(room, isGuestId(body.guest) ? { guest: body.guest } : {});
100
+ return { roomId: room.roomId, reason: open ? "joined" : "created", reservations: [reservation] };
101
+ }
102
+ async function route(g, o, req, res) {
103
+ const url = new URL(req.url ?? "/", "http://local");
104
+ const path = url.pathname;
105
+ if (path === "/health")
106
+ return send(res, 200, { ok: true, local: true, template: g.template }), true;
107
+ if (path.startsWith("/auth/") || path.startsWith("/referee/") || path.startsWith("/admin/"))
108
+ return send(res, 403, { error: LOCAL_ONLY }), true;
109
+ if (path === "/world/join" && req.method === "POST") {
110
+ try {
111
+ send(res, 200, await joinSeat(g, obj(await readBody(req))));
112
+ }
113
+ catch (e) {
114
+ send(res, 409, { error: e instanceof Error ? e.message : "join failed" });
115
+ }
116
+ return true;
117
+ }
118
+ if (path.startsWith("/profile")) {
119
+ const guest = req.headers["x-guest-id"];
120
+ const id = isGuestId(guest) ? `g:${guest}` : null;
121
+ if (req.method === "GET" && path.startsWith("/profile/game/"))
122
+ return send(res, 200, g.profiles.view(id)), true;
123
+ if (req.method === "GET" && path === "/profile")
124
+ return send(res, 200, { kind: id ? "guest" : null, wallet: null, displayName: null, settings: {}, totals: { achievements: 0, titles: 0, games: 0 }, games: [] }), true;
125
+ if (req.method !== "POST")
126
+ return send(res, 405, { error: "method" }), true;
127
+ let body;
128
+ try {
129
+ body = obj(await readBody(req));
130
+ }
131
+ catch {
132
+ return send(res, 413, { error: "body too large" }), true;
133
+ }
134
+ if (!id)
135
+ return send(res, 400, { error: "a guest id is required" }), true;
136
+ if (path === "/profile/settings")
137
+ g.profiles.settings(id, body.settings);
138
+ else if (path === "/profile/name")
139
+ g.profiles.name(id, body.displayName);
140
+ else if (path === "/profile/redeem") {
141
+ const r = g.profiles.redeem(id, typeof body.item === "string" ? body.item : "");
142
+ if (!r.ok)
143
+ return send(res, 400, { error: r.error ?? "refused" }), true;
144
+ }
145
+ else if (path !== "/profile/import" && path !== "/profile/claim")
146
+ return send(res, 404, { error: "not found" }), true;
147
+ return send(res, 200, g.profiles.view(id)), true;
148
+ }
149
+ if (req.method !== "GET" && req.method !== "HEAD")
150
+ return send(res, 404, { error: "not found" }), true;
151
+ if (path.startsWith("/library/")) {
152
+ const file = o.libraryDir ? fileUnder(o.libraryDir, path.slice("/library/".length)) : null;
153
+ if (!file)
154
+ return send(res, 404, { error: "not in the asset library" }), true;
155
+ return serveFile(res, file), true;
156
+ }
157
+ const file = fileUnder(o.clientDir, path === "/" ? "index.html" : path.slice(1));
158
+ if (!file)
159
+ return send(res, 404, { error: "not found" }), true;
160
+ serveFile(res, file);
161
+ return true;
162
+ }
163
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
164
+ /**
165
+ * On a loopback bind, only requests addressed to this computer by name are
166
+ * answered (a page elsewhere that rebinds its own host name to 127.0.0.1
167
+ * gets 403), and only same-origin or non-browser requests may POST.
168
+ */
169
+ export function allowedRequest(bindHost, port, host, origin, method) {
170
+ if (!LOOPBACK.has(bindHost))
171
+ return true;
172
+ const name = (host ?? "").replace(/:\d+$/, "").toLowerCase();
173
+ if (!LOOPBACK.has(name))
174
+ return false;
175
+ if (method === "GET" || method === "HEAD" || origin === undefined)
176
+ return true;
177
+ try {
178
+ const o = new URL(origin);
179
+ return LOOPBACK.has(o.hostname.toLowerCase()) && Number(o.port || 80) === port;
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ }
185
+ export async function startServer(g, o) {
186
+ setLocalGame(g);
187
+ const http = createServer();
188
+ const emit = http.emit.bind(http);
189
+ // Our routes answer first; Colyseus sees only what is left (the WebSocket upgrades that consume a seat).
190
+ http.emit = ((event, ...args) => {
191
+ if (event !== "request")
192
+ return emit(event, ...args);
193
+ const req = args[0];
194
+ const res = args[1];
195
+ const path = (req.url ?? "/").split("?")[0] ?? "/";
196
+ const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
197
+ if (!allowedRequest(o.host, o.port, req.headers.host, origin, req.method))
198
+ return send(res, 403, { error: "local play answers this computer only" }), true;
199
+ // Seats come only from /world/join; Colyseus' own HTTP matchmaking (which could open rooms without limit) is closed.
200
+ if (path.startsWith("/matchmake"))
201
+ return send(res, 403, { error: "seats come from /world/join" }), true;
202
+ void route(g, o, req, res).catch(() => send(res, 500, { error: "local host error" }));
203
+ return true;
204
+ });
205
+ // WebSocket upgrades pass the same Host and Origin check as requests.
206
+ const upgradeEmit = http.emit;
207
+ http.emit = ((event, ...args) => {
208
+ if (event === "upgrade") {
209
+ const req = args[0];
210
+ const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
211
+ if (!allowedRequest(o.host, o.port, req.headers.host, origin, "POST")) {
212
+ args[1].destroy();
213
+ return true;
214
+ }
215
+ }
216
+ return upgradeEmit.call(http, event, ...args);
217
+ });
218
+ const server = defineServer({
219
+ transport: new WebSocketTransport({ server: http, pingInterval: 5000, pingMaxRetries: 3 }),
220
+ rooms: { [ROOM_NAME]: defineRoom(LocalRoom) },
221
+ greet: false,
222
+ gracefullyShutdown: false,
223
+ });
224
+ await server.listen(o.port, o.host);
225
+ const shown = o.host === "0.0.0.0" || o.host === "::" ? "localhost" : o.host;
226
+ return {
227
+ url: `http://${shown}:${o.port}`,
228
+ close: async () => {
229
+ await server.gracefullyShutdown(false).catch(() => undefined);
230
+ http.close();
231
+ },
232
+ };
233
+ }
@@ -0,0 +1,4 @@
1
+ export { HELP, runPlayCli, type PlayCliIo } from "./cli.js";
2
+ export { ALLOWED_IMPORT, play, PlayError, readGameFolder, type GameFolder, type PlayOptions, type Playing } from "./play.js";
3
+ export { TEMPLATES, templateFor, type TemplateEntry, type TemplateId } from "./templates.js";
4
+ export { namesModule, parseNames, type Names } from "./names.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { HELP, runPlayCli } from "./cli.js";
2
+ export { ALLOWED_IMPORT, play, PlayError, readGameFolder } from "./play.js";
3
+ export { TEMPLATES, templateFor } from "./templates.js";
4
+ export { namesModule, parseNames } from "./names.js";