@netforge/kit-play 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +13 -0
- package/README.md +85 -2
- package/dist/check.d.ts +43 -0
- package/dist/check.js +106 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +229 -0
- package/dist/host/core.d.ts +50 -0
- package/dist/host/core.js +247 -0
- package/dist/host/entry.d.ts +20 -0
- package/dist/host/entry.js +37 -0
- package/dist/host/profile.d.ts +29 -0
- package/dist/host/profile.js +163 -0
- package/dist/host/room.d.ts +37 -0
- package/dist/host/room.js +199 -0
- package/dist/host/server.d.ts +28 -0
- package/dist/host/server.js +255 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/names.d.ts +18 -0
- package/dist/names.js +64 -0
- package/dist/play.d.ts +51 -0
- package/dist/play.js +237 -0
- package/dist/templates.d.ts +21 -0
- package/dist/templates.js +34 -0
- package/llms.txt +41 -0
- package/package.json +87 -3
|
@@ -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,28 @@
|
|
|
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). 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.
|
|
26
|
+
*/
|
|
27
|
+
export declare function allowedRequest(bindHost: string, port: number, host: string | undefined, origin: string | undefined, method: string | undefined): boolean;
|
|
28
|
+
export declare function startServer(g: LocalGame, o: HostOptions): Promise<RunningHost>;
|
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
/** 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
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* On a loopback bind, only requests addressed to this computer by name are
|
|
183
|
+
* answered (a page elsewhere that rebinds its own host name to 127.0.0.1
|
|
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.
|
|
188
|
+
*/
|
|
189
|
+
export function allowedRequest(bindHost, port, host, origin, method) {
|
|
190
|
+
const loopback = LOOPBACK.has(bindHost);
|
|
191
|
+
const name = hostName(host ?? "");
|
|
192
|
+
if (loopback ? !LOOPBACK.has(name) : !directName(name))
|
|
193
|
+
return false;
|
|
194
|
+
if (method === "GET" || method === "HEAD" || origin === undefined)
|
|
195
|
+
return true;
|
|
196
|
+
try {
|
|
197
|
+
const o = new URL(origin);
|
|
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));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export async function startServer(g, o) {
|
|
208
|
+
setLocalGame(g);
|
|
209
|
+
const http = createServer();
|
|
210
|
+
const emit = http.emit.bind(http);
|
|
211
|
+
// Our routes answer first; Colyseus sees only what is left (the WebSocket upgrades that consume a seat).
|
|
212
|
+
http.emit = ((event, ...args) => {
|
|
213
|
+
if (event !== "request")
|
|
214
|
+
return emit(event, ...args);
|
|
215
|
+
const req = args[0];
|
|
216
|
+
const res = args[1];
|
|
217
|
+
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
218
|
+
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
|
|
219
|
+
if (!allowedRequest(o.host, o.port, req.headers.host, origin, req.method))
|
|
220
|
+
return send(res, 403, { error: "local play answers this computer only" }), true;
|
|
221
|
+
// Seats come only from /world/join; Colyseus' own HTTP matchmaking (which could open rooms without limit) is closed.
|
|
222
|
+
if (path.startsWith("/matchmake"))
|
|
223
|
+
return send(res, 403, { error: "seats come from /world/join" }), true;
|
|
224
|
+
void route(g, o, req, res).catch(() => send(res, 500, { error: "local host error" }));
|
|
225
|
+
return true;
|
|
226
|
+
});
|
|
227
|
+
// WebSocket upgrades pass the same Host and Origin check as requests.
|
|
228
|
+
const upgradeEmit = http.emit;
|
|
229
|
+
http.emit = ((event, ...args) => {
|
|
230
|
+
if (event === "upgrade") {
|
|
231
|
+
const req = args[0];
|
|
232
|
+
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
|
|
233
|
+
if (!allowedRequest(o.host, o.port, req.headers.host, origin, "POST")) {
|
|
234
|
+
args[1].destroy();
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return upgradeEmit.call(http, event, ...args);
|
|
239
|
+
});
|
|
240
|
+
const server = defineServer({
|
|
241
|
+
transport: new WebSocketTransport({ server: http, pingInterval: 5000, pingMaxRetries: 3 }),
|
|
242
|
+
rooms: { [ROOM_NAME]: defineRoom(LocalRoom) },
|
|
243
|
+
greet: false,
|
|
244
|
+
gracefullyShutdown: false,
|
|
245
|
+
});
|
|
246
|
+
await server.listen(o.port, o.host);
|
|
247
|
+
const shown = o.host === "0.0.0.0" || o.host === "::" ? "localhost" : o.host;
|
|
248
|
+
return {
|
|
249
|
+
url: `http://${shown}:${o.port}`,
|
|
250
|
+
close: async () => {
|
|
251
|
+
await server.gracefullyShutdown(false).catch(() => undefined);
|
|
252
|
+
http.close();
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
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";
|
|
3
|
+
export { ALLOWED_IMPORT, play, PlayError, readGameFolder, type GameFolder, type PlayOptions, type Playing } from "./play.js";
|
|
4
|
+
export { TEMPLATES, templateFor, type TemplateEntry, type TemplateId } from "./templates.js";
|
|
5
|
+
export { namesModule, parseNames, type Names } from "./names.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { COMMANDS_HELP, HELP, runKitPlayCli, runPlayCli, runTestCli, TEST_HELP } from "./cli.js";
|
|
2
|
+
export { check, summary } from "./check.js";
|
|
3
|
+
export { ALLOWED_IMPORT, play, PlayError, readGameFolder } from "./play.js";
|
|
4
|
+
export { TEMPLATES, templateFor } from "./templates.js";
|
|
5
|
+
export { namesModule, parseNames } from "./names.js";
|
package/dist/names.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface Names {
|
|
2
|
+
readonly title: string;
|
|
3
|
+
readonly weapons: Readonly<Record<string, {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly line: string;
|
|
6
|
+
}>>;
|
|
7
|
+
readonly upgrades: Readonly<Record<string, {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly line: string;
|
|
10
|
+
}>>;
|
|
11
|
+
readonly enemies: Readonly<Record<string, string>>;
|
|
12
|
+
readonly rules: Readonly<Record<string, string>>;
|
|
13
|
+
readonly skus: Readonly<Record<string, string>>;
|
|
14
|
+
}
|
|
15
|
+
/** Parses src/names.json; throws with the first problem. */
|
|
16
|
+
export declare function parseNames(text: string): Names;
|
|
17
|
+
/** The names module: everything the template's names.ts exports, with these labels in place of its tables. */
|
|
18
|
+
export declare function namesModule(n: Names, templateNamesPath: string): string;
|
package/dist/names.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// src/names.json, the labels file next to game.ts, as the template client's
|
|
2
|
+
// names module: the template's own names.ts with the creator's labels in
|
|
3
|
+
// place of its tables. The labels are data written as JSON literals, so
|
|
4
|
+
// nothing in the file can execute.
|
|
5
|
+
const ID = /^[a-z][a-z0-9_]{0,47}$/;
|
|
6
|
+
const label = (v) => typeof v === "string" && v.trim().length > 0 && v.length <= 40;
|
|
7
|
+
function table(v, ok, what) {
|
|
8
|
+
if (v === undefined)
|
|
9
|
+
return {};
|
|
10
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
11
|
+
throw new Error(`src/names.json ${what} must be an object`);
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const [k, x] of Object.entries(v)) {
|
|
14
|
+
if (!ID.test(k))
|
|
15
|
+
throw new Error(`src/names.json ${what}.${k}: ids are lower case letters, digits and _`);
|
|
16
|
+
if (!ok(x))
|
|
17
|
+
throw new Error(`src/names.json ${what}.${k}: not a label (1 to 40 characters)`);
|
|
18
|
+
out[k] = x;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
const named = (x) => {
|
|
23
|
+
if (typeof x !== "object" || x === null)
|
|
24
|
+
return false;
|
|
25
|
+
const o = x;
|
|
26
|
+
return label(o.name) && label(o.line) && Object.keys(o).length === 2;
|
|
27
|
+
};
|
|
28
|
+
/** Parses src/names.json; throws with the first problem. */
|
|
29
|
+
export function parseNames(text) {
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
throw new Error(`src/names.json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
36
|
+
}
|
|
37
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
38
|
+
throw new Error("src/names.json must be an object");
|
|
39
|
+
const o = raw;
|
|
40
|
+
if (!label(o.title))
|
|
41
|
+
throw new Error("src/names.json title: 1 to 40 characters");
|
|
42
|
+
return {
|
|
43
|
+
title: o.title.trim(),
|
|
44
|
+
weapons: table(o.weapons, named, "weapons"),
|
|
45
|
+
upgrades: table(o.upgrades, named, "upgrades"),
|
|
46
|
+
enemies: table(o.enemies, label, "enemies"),
|
|
47
|
+
rules: table(o.rules, label, "rules"),
|
|
48
|
+
skus: table(o.skus, label, "skus"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** The names module: everything the template's names.ts exports, with these labels in place of its tables. */
|
|
52
|
+
export function namesModule(n, templateNamesPath) {
|
|
53
|
+
const j = (v) => JSON.stringify(v);
|
|
54
|
+
return [
|
|
55
|
+
`export * from ${j(templateNamesPath)};`,
|
|
56
|
+
`export const TITLE = ${j(n.title)};`,
|
|
57
|
+
`export const WEAPON_NAMES = ${j(n.weapons)};`,
|
|
58
|
+
`export const UPGRADE_NAMES = ${j(n.upgrades)};`,
|
|
59
|
+
`export const ENEMY_NAMES = ${j(n.enemies)};`,
|
|
60
|
+
`export const RULE_NAMES = ${j(n.rules)};`,
|
|
61
|
+
`export const SKU_NAMES = ${j(n.skus)};`,
|
|
62
|
+
"",
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
package/dist/play.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { networkInterfaces } from "node:os";
|
|
2
|
+
import { type Names } from "./names.js";
|
|
3
|
+
import { type TemplateEntry } from "./templates.js";
|
|
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). */
|
|
5
|
+
export declare const ALLOWED_IMPORT = "@netforge/engine";
|
|
6
|
+
export interface PlayOptions {
|
|
7
|
+
readonly dir: string;
|
|
8
|
+
readonly port: number;
|
|
9
|
+
readonly host: string;
|
|
10
|
+
readonly open: boolean;
|
|
11
|
+
/** Browser tabs to open, one per local player. */
|
|
12
|
+
readonly players: number;
|
|
13
|
+
/** A base URL the page loads the asset library from instead of this host (any mirror of the published library). */
|
|
14
|
+
readonly libraryUrl: string | null;
|
|
15
|
+
readonly log: (s: string) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface GameFolder {
|
|
18
|
+
readonly dir: string;
|
|
19
|
+
readonly template: TemplateEntry;
|
|
20
|
+
readonly gameFile: string;
|
|
21
|
+
readonly names: Names | null;
|
|
22
|
+
}
|
|
23
|
+
export declare class PlayError extends Error {
|
|
24
|
+
}
|
|
25
|
+
/** Reads and checks the game folder. */
|
|
26
|
+
export declare function readGameFolder(dir: string): GameFolder;
|
|
27
|
+
/** The template package's root (its "." export is src/manifest.ts). */
|
|
28
|
+
export declare function templateDir(t: TemplateEntry, from: string): string;
|
|
29
|
+
/** The built asset library shipped in @netforge/asset-library-files, or null when it is not installed. */
|
|
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[];
|
|
39
|
+
/** Builds the template client with the game module and labels swapped in. */
|
|
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;
|
|
43
|
+
/** Bundles the local host with the game module (one copy of the engine and the sim). Returns the bundle path. */
|
|
44
|
+
export declare function bundleHost(g: GameFolder, tplDir: string, outFile: string): Promise<string>;
|
|
45
|
+
export interface Playing {
|
|
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[];
|
|
49
|
+
close(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export declare function play(o: PlayOptions): Promise<Playing>;
|