@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,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
|
+
}
|
|
@@ -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
|
+
}
|