@netforge/engine 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.
Files changed (245) hide show
  1. package/GAME-DESIGN.md +195 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +30 -0
  4. package/README.md +100 -2
  5. package/dist/api/appearance.d.ts +37 -0
  6. package/dist/api/appearance.js +41 -0
  7. package/dist/api/cards.d.ts +105 -0
  8. package/dist/api/cards.js +133 -0
  9. package/dist/api/collector.d.ts +114 -0
  10. package/dist/api/collector.js +204 -0
  11. package/dist/api/dungeon.d.ts +58 -0
  12. package/dist/api/dungeon.js +216 -0
  13. package/dist/api/harness.d.ts +149 -0
  14. package/dist/api/harness.js +549 -0
  15. package/dist/api/idle.d.ts +71 -0
  16. package/dist/api/idle.js +104 -0
  17. package/dist/api/race-harness.d.ts +3 -0
  18. package/dist/api/race-harness.js +148 -0
  19. package/dist/api/registry.d.ts +241 -0
  20. package/dist/api/registry.js +394 -0
  21. package/dist/api/scheduler.d.ts +56 -0
  22. package/dist/api/scheduler.js +160 -0
  23. package/dist/api/schemas.d.ts +958 -0
  24. package/dist/api/schemas.js +859 -0
  25. package/dist/api/tower.d.ts +127 -0
  26. package/dist/api/tower.js +183 -0
  27. package/dist/game/adapter.d.ts +69 -0
  28. package/dist/game/adapter.js +341 -0
  29. package/dist/game/arena-bot.d.ts +66 -0
  30. package/dist/game/arena-bot.js +188 -0
  31. package/dist/game/bot.d.ts +95 -0
  32. package/dist/game/bot.js +285 -0
  33. package/dist/game/compile.d.ts +97 -0
  34. package/dist/game/compile.js +476 -0
  35. package/dist/game/director.d.ts +42 -0
  36. package/dist/game/director.js +179 -0
  37. package/dist/game/earn-source.d.ts +122 -0
  38. package/dist/game/earn-source.js +386 -0
  39. package/dist/game/earn.d.ts +49 -0
  40. package/dist/game/earn.js +55 -0
  41. package/dist/game/index.d.ts +37 -0
  42. package/dist/game/index.js +25 -0
  43. package/dist/game/platformer.d.ts +156 -0
  44. package/dist/game/platformer.js +619 -0
  45. package/dist/game/profile.d.ts +97 -0
  46. package/dist/game/profile.js +140 -0
  47. package/dist/game/race.d.ts +177 -0
  48. package/dist/game/race.js +386 -0
  49. package/dist/game/racer.d.ts +98 -0
  50. package/dist/game/racer.js +379 -0
  51. package/dist/game/runner.d.ts +286 -0
  52. package/dist/game/runner.js +824 -0
  53. package/dist/game/threats.d.ts +23 -0
  54. package/dist/game/threats.js +49 -0
  55. package/dist/game/view.d.ts +47 -0
  56. package/dist/game/view.js +123 -0
  57. package/dist/index.d.ts +33 -0
  58. package/dist/index.js +24 -0
  59. package/dist/net/bits.d.ts +33 -0
  60. package/dist/net/bits.js +135 -0
  61. package/dist/net/client.d.ts +75 -0
  62. package/dist/net/client.js +151 -0
  63. package/dist/net/index.d.ts +23 -0
  64. package/dist/net/index.js +23 -0
  65. package/dist/net/input.d.ts +49 -0
  66. package/dist/net/input.js +112 -0
  67. package/dist/net/interpolation.d.ts +37 -0
  68. package/dist/net/interpolation.js +97 -0
  69. package/dist/net/netsim.d.ts +22 -0
  70. package/dist/net/netsim.js +52 -0
  71. package/dist/net/prediction.d.ts +31 -0
  72. package/dist/net/prediction.js +101 -0
  73. package/dist/net/profile-client.d.ts +75 -0
  74. package/dist/net/profile-client.js +173 -0
  75. package/dist/net/protocol.d.ts +215 -0
  76. package/dist/net/protocol.js +10 -0
  77. package/dist/net/room-client.d.ts +65 -0
  78. package/dist/net/room-client.js +156 -0
  79. package/dist/net/schema.d.ts +78 -0
  80. package/dist/net/schema.js +121 -0
  81. package/dist/net/sim-adapter.d.ts +134 -0
  82. package/dist/net/sim-adapter.js +22 -0
  83. package/dist/net/snapshot.d.ts +61 -0
  84. package/dist/net/snapshot.js +301 -0
  85. package/dist/rules/adapter.d.ts +53 -0
  86. package/dist/rules/adapter.js +261 -0
  87. package/dist/rules/cards.d.ts +115 -0
  88. package/dist/rules/cards.js +547 -0
  89. package/dist/rules/collector.d.ts +161 -0
  90. package/dist/rules/collector.js +732 -0
  91. package/dist/rules/genres.d.ts +13 -0
  92. package/dist/rules/genres.js +38 -0
  93. package/dist/rules/harness.d.ts +16 -0
  94. package/dist/rules/harness.js +127 -0
  95. package/dist/rules/idle.d.ts +115 -0
  96. package/dist/rules/idle.js +370 -0
  97. package/dist/rules/index.d.ts +16 -0
  98. package/dist/rules/index.js +14 -0
  99. package/dist/rules/kit.d.ts +171 -0
  100. package/dist/rules/kit.js +88 -0
  101. package/dist/rules/tower.d.ts +214 -0
  102. package/dist/rules/tower.js +655 -0
  103. package/dist/runtime/arena.d.ts +86 -0
  104. package/dist/runtime/arena.js +269 -0
  105. package/dist/runtime/assets.d.ts +26 -0
  106. package/dist/runtime/assets.js +35 -0
  107. package/dist/runtime/camera.d.ts +113 -0
  108. package/dist/runtime/camera.js +247 -0
  109. package/dist/runtime/crowd.d.ts +18 -0
  110. package/dist/runtime/crowd.js +62 -0
  111. package/dist/runtime/feel/audio.d.ts +150 -0
  112. package/dist/runtime/feel/audio.js +327 -0
  113. package/dist/runtime/feel/clock.d.ts +52 -0
  114. package/dist/runtime/feel/clock.js +80 -0
  115. package/dist/runtime/feel/flash.d.ts +28 -0
  116. package/dist/runtime/feel/flash.js +60 -0
  117. package/dist/runtime/feel/index.d.ts +27 -0
  118. package/dist/runtime/feel/index.js +16 -0
  119. package/dist/runtime/feel/kit.d.ts +159 -0
  120. package/dist/runtime/feel/kit.js +226 -0
  121. package/dist/runtime/feel/knockback.d.ts +41 -0
  122. package/dist/runtime/feel/knockback.js +70 -0
  123. package/dist/runtime/feel/noise.d.ts +6 -0
  124. package/dist/runtime/feel/noise.js +30 -0
  125. package/dist/runtime/feel/numbers.d.ts +91 -0
  126. package/dist/runtime/feel/numbers.js +172 -0
  127. package/dist/runtime/feel/rumble.d.ts +40 -0
  128. package/dist/runtime/feel/rumble.js +58 -0
  129. package/dist/runtime/feel/sound.d.ts +96 -0
  130. package/dist/runtime/feel/sound.js +176 -0
  131. package/dist/runtime/feel/spring.d.ts +42 -0
  132. package/dist/runtime/feel/spring.js +95 -0
  133. package/dist/runtime/feel/trauma.d.ts +48 -0
  134. package/dist/runtime/feel/trauma.js +76 -0
  135. package/dist/runtime/feel/tween.d.ts +66 -0
  136. package/dist/runtime/feel/tween.js +180 -0
  137. package/dist/runtime/feel/types.d.ts +30 -0
  138. package/dist/runtime/feel/types.js +5 -0
  139. package/dist/runtime/index.d.ts +28 -0
  140. package/dist/runtime/index.js +26 -0
  141. package/dist/runtime/input.d.ts +38 -0
  142. package/dist/runtime/input.js +159 -0
  143. package/dist/runtime/interpolate.d.ts +6 -0
  144. package/dist/runtime/interpolate.js +13 -0
  145. package/dist/runtime/render/animation.d.ts +63 -0
  146. package/dist/runtime/render/animation.js +128 -0
  147. package/dist/runtime/render/atmosphere.d.ts +32 -0
  148. package/dist/runtime/render/atmosphere.js +59 -0
  149. package/dist/runtime/render/crowd-motion.d.ts +68 -0
  150. package/dist/runtime/render/crowd-motion.js +163 -0
  151. package/dist/runtime/render/decals.d.ts +64 -0
  152. package/dist/runtime/render/decals.js +222 -0
  153. package/dist/runtime/render/highlight.d.ts +29 -0
  154. package/dist/runtime/render/highlight.js +110 -0
  155. package/dist/runtime/render/index.d.ts +35 -0
  156. package/dist/runtime/render/index.js +22 -0
  157. package/dist/runtime/render/lighting.d.ts +50 -0
  158. package/dist/runtime/render/lighting.js +142 -0
  159. package/dist/runtime/render/particles.d.ts +117 -0
  160. package/dist/runtime/render/particles.js +289 -0
  161. package/dist/runtime/render/post.d.ts +92 -0
  162. package/dist/runtime/render/post.js +191 -0
  163. package/dist/runtime/render/presets.d.ts +57 -0
  164. package/dist/runtime/render/presets.js +116 -0
  165. package/dist/runtime/render/quality.d.ts +113 -0
  166. package/dist/runtime/render/quality.js +180 -0
  167. package/dist/runtime/render/rig.d.ts +29 -0
  168. package/dist/runtime/render/rig.js +107 -0
  169. package/dist/runtime/render/sprite.d.ts +34 -0
  170. package/dist/runtime/render/sprite.js +58 -0
  171. package/dist/runtime/render/stack.d.ts +74 -0
  172. package/dist/runtime/render/stack.js +132 -0
  173. package/dist/runtime/render/vat.d.ts +78 -0
  174. package/dist/runtime/render/vat.js +219 -0
  175. package/dist/runtime/renderer.d.ts +19 -0
  176. package/dist/runtime/renderer.js +18 -0
  177. package/dist/runtime/stats.d.ts +11 -0
  178. package/dist/runtime/stats.js +29 -0
  179. package/dist/runtime/styles/handdrawn/ground.d.ts +11 -0
  180. package/dist/runtime/styles/handdrawn/ground.js +282 -0
  181. package/dist/runtime/styles/handdrawn/index.d.ts +4 -0
  182. package/dist/runtime/styles/handdrawn/index.js +94 -0
  183. package/dist/runtime/styles/handdrawn/ink.d.ts +31 -0
  184. package/dist/runtime/styles/handdrawn/ink.js +68 -0
  185. package/dist/runtime/styles/handdrawn/looks.d.ts +18 -0
  186. package/dist/runtime/styles/handdrawn/looks.js +42 -0
  187. package/dist/runtime/styles/handdrawn/paint.d.ts +59 -0
  188. package/dist/runtime/styles/handdrawn/paint.js +129 -0
  189. package/dist/runtime/styles/handdrawn/palette.d.ts +49 -0
  190. package/dist/runtime/styles/handdrawn/palette.js +70 -0
  191. package/dist/runtime/styles/handdrawn/textures.d.ts +27 -0
  192. package/dist/runtime/styles/handdrawn/textures.js +150 -0
  193. package/dist/runtime/styles/handdrawn/vfx.d.ts +14 -0
  194. package/dist/runtime/styles/handdrawn/vfx.js +73 -0
  195. package/dist/runtime/styles/index.d.ts +9 -0
  196. package/dist/runtime/styles/index.js +32 -0
  197. package/dist/runtime/styles/kit.d.ts +193 -0
  198. package/dist/runtime/styles/kit.js +1 -0
  199. package/dist/runtime/styles/pixel/index.d.ts +46 -0
  200. package/dist/runtime/styles/pixel/index.js +167 -0
  201. package/dist/runtime/styles/ps1/index.d.ts +29 -0
  202. package/dist/runtime/styles/ps1/index.js +41 -0
  203. package/dist/runtime/styles/ps1/lights.d.ts +47 -0
  204. package/dist/runtime/styles/ps1/lights.js +146 -0
  205. package/dist/runtime/styles/ps1/materials.d.ts +62 -0
  206. package/dist/runtime/styles/ps1/materials.js +285 -0
  207. package/dist/runtime/styles/ps1/pipeline.d.ts +52 -0
  208. package/dist/runtime/styles/ps1/pipeline.js +111 -0
  209. package/dist/runtime/styles/ps1/surfaces.d.ts +44 -0
  210. package/dist/runtime/styles/ps1/surfaces.js +228 -0
  211. package/dist/runtime/styles/toon/index.d.ts +5 -0
  212. package/dist/runtime/styles/toon/index.js +178 -0
  213. package/dist/runtime/styles/toon/ink.d.ts +39 -0
  214. package/dist/runtime/styles/toon/ink.js +73 -0
  215. package/dist/runtime/styles/toon/look.d.ts +10 -0
  216. package/dist/runtime/styles/toon/look.js +112 -0
  217. package/dist/runtime/styles/toon/material.d.ts +74 -0
  218. package/dist/runtime/styles/toon/material.js +208 -0
  219. package/dist/runtime/styles/toon/normals.d.ts +26 -0
  220. package/dist/runtime/styles/toon/normals.js +127 -0
  221. package/dist/runtime/styles/toon/shadows.d.ts +24 -0
  222. package/dist/runtime/styles/toon/shadows.js +42 -0
  223. package/dist/runtime/styles/toon/vfx.d.ts +15 -0
  224. package/dist/runtime/styles/toon/vfx.js +47 -0
  225. package/dist/runtime/styles/view.d.ts +35 -0
  226. package/dist/runtime/styles/view.js +34 -0
  227. package/dist/runtime/styles/voxel/core.d.ts +114 -0
  228. package/dist/runtime/styles/voxel/core.js +577 -0
  229. package/dist/runtime/styles/voxel/geometry.d.ts +27 -0
  230. package/dist/runtime/styles/voxel/geometry.js +40 -0
  231. package/dist/runtime/styles/voxel/index.d.ts +21 -0
  232. package/dist/runtime/styles/voxel/index.js +23 -0
  233. package/dist/runtime/styles/voxel/kit.d.ts +6 -0
  234. package/dist/runtime/styles/voxel/kit.js +11 -0
  235. package/dist/runtime/styles/voxel/look.d.ts +10 -0
  236. package/dist/runtime/styles/voxel/look.js +16 -0
  237. package/dist/runtime/styles/voxel/props.d.ts +9 -0
  238. package/dist/runtime/styles/voxel/props.js +52 -0
  239. package/dist/runtime/styles/voxel/shapes.d.ts +41 -0
  240. package/dist/runtime/styles/voxel/shapes.js +83 -0
  241. package/dist/runtime/styles/voxel/world.d.ts +49 -0
  242. package/dist/runtime/styles/voxel/world.js +422 -0
  243. package/llms-full.txt +460 -0
  244. package/llms.txt +183 -0
  245. package/package.json +104 -3
@@ -0,0 +1,101 @@
1
+ // Client-side prediction and server reconciliation (Gambetta).
2
+ //
3
+ // The client applies each input to its local world the moment it is sampled
4
+ // and keeps it until the server acknowledges it. When a snapshot arrives, the
5
+ // predicted entity is reset to the server's state and every input the server
6
+ // has not yet applied is replayed on top. Input never waits for the round
7
+ // trip, and the result converges to the server's state.
8
+ //
9
+ // A correction (server disagreed) would snap the character; instead the
10
+ // visual position carries the error as an offset that decays over ~100 ms.
11
+ export class Predictor {
12
+ sim;
13
+ player;
14
+ selfEntity;
15
+ world;
16
+ pending = [];
17
+ nextSeq = 1;
18
+ ready = false;
19
+ offsetX = 0;
20
+ offsetY = 0;
21
+ /** Magnitude of the last correction in world units (telemetry/tests). */
22
+ lastCorrection = 0;
23
+ /** Count of reconciliations that moved the predicted entity. */
24
+ corrections = 0;
25
+ constructor(sim, player, selfEntity, seed = 0, firstSeq = 1) {
26
+ this.sim = sim;
27
+ this.player = player;
28
+ this.selfEntity = selfEntity;
29
+ this.world = sim.createWorld(seed, 'client');
30
+ this.nextSeq = firstSeq;
31
+ }
32
+ /** The seq the next predicted command gets (a rebased predictor continues from here). */
33
+ get upcomingSeq() {
34
+ return this.nextSeq;
35
+ }
36
+ get isReady() {
37
+ return this.ready;
38
+ }
39
+ /** Applies one tick of input locally. Returns the command to send. */
40
+ predict(input, viewTick16) {
41
+ if (!this.ready)
42
+ return null;
43
+ const cmd = { seq: this.nextSeq++, viewTick16, input };
44
+ this.pending.push(cmd);
45
+ // Bound memory if the server stops acking (disconnect): keep ~4 s at 60 Hz.
46
+ if (this.pending.length > 240)
47
+ this.pending.shift();
48
+ this.sim.step(this.world, new Map([[this.player, [input]]]));
49
+ return cmd;
50
+ }
51
+ /** Unacknowledged commands, oldest first. */
52
+ unacked() {
53
+ return this.pending;
54
+ }
55
+ reconcile(ackSeq, serverSelf) {
56
+ const before = this.ready ? this.state() : null;
57
+ this.pending = this.pending.filter((c) => c.seq > ackSeq);
58
+ this.sim.applySnapshot(this.world, [serverSelf]);
59
+ for (const c of this.pending)
60
+ this.sim.step(this.world, new Map([[this.player, [c.input]]]));
61
+ this.ready = true;
62
+ if (!before)
63
+ return;
64
+ const after = this.state();
65
+ if (!after)
66
+ return;
67
+ const xi = this.sim.schema.xField;
68
+ const yi = this.sim.schema.yField;
69
+ const ex = (before.values[xi] ?? 0) - (after.values[xi] ?? 0);
70
+ const ey = (before.values[yi] ?? 0) - (after.values[yi] ?? 0);
71
+ this.lastCorrection = Math.hypot(ex, ey) / this.sim.schema.unitsPerWorld;
72
+ if (ex !== 0 || ey !== 0) {
73
+ this.corrections++;
74
+ this.offsetX += ex;
75
+ this.offsetY += ey;
76
+ }
77
+ }
78
+ /** The predicted entity, exact (what reconciliation converges on). */
79
+ state() {
80
+ return this.sim.snapshot(this.world).find((e) => e.id === this.selfEntity);
81
+ }
82
+ /** The predicted entity with the correction offset applied, for drawing. */
83
+ renderState(dtMs) {
84
+ const s = this.state();
85
+ if (!s)
86
+ return undefined;
87
+ const decay = Math.exp(-dtMs / 50);
88
+ this.offsetX *= decay;
89
+ this.offsetY *= decay;
90
+ if (Math.abs(this.offsetX) < 0.01)
91
+ this.offsetX = 0;
92
+ if (Math.abs(this.offsetY) < 0.01)
93
+ this.offsetY = 0;
94
+ const values = [...s.values];
95
+ const xi = this.sim.schema.xField;
96
+ const yi = this.sim.schema.yField;
97
+ values[xi] = (values[xi] ?? 0) + this.offsetX;
98
+ values[yi] = (values[yi] ?? 0) + this.offsetY;
99
+ return { ...s, values };
100
+ }
101
+ }
@@ -0,0 +1,75 @@
1
+ import { type GameProfile } from './protocol.js';
2
+ /** Storage key for the guest id: shared by every game on one origin, like a Launcher guest. */
3
+ export declare const GUEST_KEY = "netnet:guest:v1";
4
+ /** This browser's guest id (made on first use). Storage blocked: one per page load. */
5
+ export declare function guestId(): string;
6
+ /** A fresh guest id: after a guest profile merges into a wallet, the old id is spent. */
7
+ export declare function rotateGuestId(): string;
8
+ /** The saved session token for a rooms server, if it has not expired. */
9
+ export declare function savedSession(endpoint: string): {
10
+ token: string;
11
+ address: string;
12
+ } | null;
13
+ export declare function saveSession(endpoint: string, s: {
14
+ token: string;
15
+ address: string;
16
+ expiresAt: number;
17
+ } | null): void;
18
+ /** The slice of an EIP-1193 provider sign-in needs. */
19
+ export interface Eip1193 {
20
+ request(args: {
21
+ method: string;
22
+ params?: unknown[];
23
+ }): Promise<unknown>;
24
+ }
25
+ /**
26
+ * Sign in to a rooms server with a wallet: its nonce, an EIP-4361 message
27
+ * for its domain, one personal_sign (no gas), a session token. Saved for
28
+ * this endpoint until it expires.
29
+ */
30
+ export declare function signInWithWallet(endpoint: string, provider: Eip1193, statement?: string): Promise<{
31
+ token: string;
32
+ address: string;
33
+ expiresAt: number;
34
+ }>;
35
+ /** Who is asking: a signed-in session (bearer) or a guest id. */
36
+ export type ProfileAuth = {
37
+ readonly session: string;
38
+ } | {
39
+ readonly guest: string;
40
+ };
41
+ export interface LocalProgress {
42
+ /** Achievement ids this browser reached before profiles. */
43
+ readonly achievements?: readonly string[];
44
+ /** Goals done today (UTC day) in this browser. */
45
+ readonly goals?: {
46
+ readonly day: number;
47
+ readonly done: readonly string[];
48
+ };
49
+ /** Settings to carry over (the keeper pick, sound). */
50
+ readonly settings?: Readonly<Record<string, unknown>>;
51
+ }
52
+ /** The room server's profile routes for one game. */
53
+ export declare class ProfileApi {
54
+ readonly endpoint: string;
55
+ readonly game: string;
56
+ constructor(endpoint: string, game: string);
57
+ private headers;
58
+ private call;
59
+ /** This game's profile for a session or a guest (an empty one before any progress). */
60
+ get(auth: ProfileAuth): Promise<GameProfile>;
61
+ /** One time per guest and game: carry progress this browser kept before profiles existed. */
62
+ importLocal(guest: string, local: LocalProgress): Promise<GameProfile>;
63
+ /** Merge a guest's progress into the signed-in wallet's profile (the guest id is spent: rotate it). */
64
+ claim(session: string, guest: string): Promise<GameProfile>;
65
+ /** Replace this game's settings for the player (a small JSON object). */
66
+ setSettings(auth: ProfileAuth, settings: Readonly<Record<string, unknown>>): Promise<GameProfile>;
67
+ /** The display name the Launcher and end screens show (1-24 characters). */
68
+ setName(auth: ProfileAuth, displayName: string): Promise<GameProfile>;
69
+ /**
70
+ * Turn a committed coin spend (kit.coins.spend(price, id) returned `key`)
71
+ * into ownership: a cosmetic, an unlock, or the next level of a meta
72
+ * upgrade. The server checks the price from the game's manifest.
73
+ */
74
+ redeem(session: string, item: string, key: string): Promise<GameProfile>;
75
+ }
@@ -0,0 +1,173 @@
1
+ // The browser side of universal saves: a guest id,
2
+ // wallet sign-in (SIWE over any EIP-1193 wallet) and the room server's
3
+ // profile routes. Progress lives on the server, keyed by the signed-in
4
+ // wallet or by the guest id; the browser keeps only the guest id and the
5
+ // session token, both conveniences. Plain fetch, no chain calls.
6
+ import { GUEST_ID_RE } from './protocol.js';
7
+ /** Storage key for the guest id: shared by every game on one origin, like a Launcher guest. */
8
+ export const GUEST_KEY = 'netnet:guest:v1';
9
+ const SESSION_KEY = 'netnet:rooms-session:v1';
10
+ function storage() {
11
+ try {
12
+ return typeof localStorage === 'undefined' ? null : localStorage;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ function randomGuest() {
19
+ const bytes = new Uint8Array(16);
20
+ globalThis.crypto.getRandomValues(bytes);
21
+ return `g-${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`;
22
+ }
23
+ let memoGuest = null;
24
+ /** This browser's guest id (made on first use). Storage blocked: one per page load. */
25
+ export function guestId() {
26
+ if (memoGuest)
27
+ return memoGuest;
28
+ const s = storage();
29
+ try {
30
+ const have = s?.getItem(GUEST_KEY);
31
+ if (have && GUEST_ID_RE.test(have))
32
+ return (memoGuest = have);
33
+ }
34
+ catch {
35
+ /* blocked */
36
+ }
37
+ memoGuest = randomGuest();
38
+ try {
39
+ s?.setItem(GUEST_KEY, memoGuest);
40
+ }
41
+ catch {
42
+ /* per page */
43
+ }
44
+ return memoGuest;
45
+ }
46
+ /** A fresh guest id: after a guest profile merges into a wallet, the old id is spent. */
47
+ export function rotateGuestId() {
48
+ memoGuest = randomGuest();
49
+ try {
50
+ storage()?.setItem(GUEST_KEY, memoGuest);
51
+ }
52
+ catch {
53
+ /* per page */
54
+ }
55
+ return memoGuest;
56
+ }
57
+ /** The saved session token for a rooms server, if it has not expired. */
58
+ export function savedSession(endpoint) {
59
+ try {
60
+ const raw = storage()?.getItem(`${SESSION_KEY}:${endpoint}`);
61
+ if (!raw)
62
+ return null;
63
+ const o = JSON.parse(raw);
64
+ if (typeof o.token !== 'string' || typeof o.address !== 'string' || typeof o.expiresAt !== 'number' || o.expiresAt <= Date.now() + 60_000)
65
+ return null;
66
+ return { token: o.token, address: o.address };
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ export function saveSession(endpoint, s) {
73
+ try {
74
+ if (s)
75
+ storage()?.setItem(`${SESSION_KEY}:${endpoint}`, JSON.stringify(s));
76
+ else
77
+ storage()?.removeItem(`${SESSION_KEY}:${endpoint}`);
78
+ }
79
+ catch {
80
+ /* this page only */
81
+ }
82
+ }
83
+ function hexUtf8(text) {
84
+ return `0x${Array.from(new TextEncoder().encode(text), (b) => b.toString(16).padStart(2, '0')).join('')}`;
85
+ }
86
+ /**
87
+ * Sign in to a rooms server with a wallet: its nonce, an EIP-4361 message
88
+ * for its domain, one personal_sign (no gas), a session token. Saved for
89
+ * this endpoint until it expires.
90
+ */
91
+ export async function signInWithWallet(endpoint, provider, statement = 'Sign in to save your progress.') {
92
+ const accounts = (await provider.request({ method: 'eth_requestAccounts' }));
93
+ const address = Array.isArray(accounts) && typeof accounts[0] === 'string' ? accounts[0] : null;
94
+ if (!address || !/^0x[0-9a-fA-F]{40}$/.test(address))
95
+ throw new Error('no wallet account');
96
+ const n = (await (await fetch(`${endpoint}/auth/nonce`)).json());
97
+ if (!n.nonce || !n.domain || !n.uri || !n.chainId)
98
+ throw new Error('the server sent no sign-in nonce');
99
+ const now = new Date();
100
+ const message = [
101
+ `${n.domain} wants you to sign in with your Ethereum account:`,
102
+ address,
103
+ '',
104
+ statement,
105
+ '',
106
+ `URI: ${n.uri}`,
107
+ 'Version: 1',
108
+ `Chain ID: ${n.chainId}`,
109
+ `Nonce: ${n.nonce}`,
110
+ `Issued At: ${now.toISOString()}`,
111
+ `Expiration Time: ${new Date(now.getTime() + 12 * 3600_000).toISOString()}`,
112
+ ].join('\n');
113
+ const signature = (await provider.request({ method: 'personal_sign', params: [hexUtf8(message), address] }));
114
+ const res = await fetch(`${endpoint}/auth/sign-in`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message, signature }) });
115
+ const body = (await res.json());
116
+ if (!res.ok || !body.token || !body.address || !body.expiresAt)
117
+ throw new Error(body.error ?? `sign-in failed (${res.status})`);
118
+ const out = { token: body.token, address: body.address, expiresAt: body.expiresAt };
119
+ saveSession(endpoint, out);
120
+ return out;
121
+ }
122
+ /** The room server's profile routes for one game. */
123
+ export class ProfileApi {
124
+ endpoint;
125
+ game;
126
+ constructor(endpoint, game) {
127
+ this.endpoint = endpoint;
128
+ this.game = game;
129
+ }
130
+ headers(auth) {
131
+ const h = { 'content-type': 'application/json' };
132
+ if (auth && 'session' in auth)
133
+ h.authorization = `Bearer ${auth.session}`;
134
+ else if (auth)
135
+ h['x-guest-id'] = auth.guest;
136
+ return h;
137
+ }
138
+ async call(method, path, auth, body) {
139
+ const res = await fetch(`${this.endpoint}${path}`, { method, headers: this.headers(auth), ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
140
+ const out = (await res.json().catch(() => null));
141
+ if (!res.ok || out === null)
142
+ throw new Error(out?.error ?? `profile ${path} failed (${res.status})`);
143
+ return out;
144
+ }
145
+ /** This game's profile for a session or a guest (an empty one before any progress). */
146
+ get(auth) {
147
+ return this.call('GET', `/profile/game/${encodeURIComponent(this.game)}`, auth);
148
+ }
149
+ /** One time per guest and game: carry progress this browser kept before profiles existed. */
150
+ importLocal(guest, local) {
151
+ return this.call('POST', '/profile/import', { guest }, { game: this.game, ...local });
152
+ }
153
+ /** Merge a guest's progress into the signed-in wallet's profile (the guest id is spent: rotate it). */
154
+ claim(session, guest) {
155
+ return this.call('POST', '/profile/claim', { session }, { guest, game: this.game });
156
+ }
157
+ /** Replace this game's settings for the player (a small JSON object). */
158
+ setSettings(auth, settings) {
159
+ return this.call('POST', '/profile/settings', auth, { game: this.game, settings });
160
+ }
161
+ /** The display name the Launcher and end screens show (1-24 characters). */
162
+ setName(auth, displayName) {
163
+ return this.call('POST', '/profile/name', auth, { game: this.game, displayName });
164
+ }
165
+ /**
166
+ * Turn a committed coin spend (kit.coins.spend(price, id) returned `key`)
167
+ * into ownership: a cosmetic, an unlock, or the next level of a meta
168
+ * upgrade. The server checks the price from the game's manifest.
169
+ */
170
+ redeem(session, item, key) {
171
+ return this.call('POST', '/profile/redeem', { session }, { game: this.game, item, key });
172
+ }
173
+ }
@@ -0,0 +1,215 @@
1
+ export declare const MSG_INPUT = 1;
2
+ export declare const MSG_SNAPSHOT = 2;
3
+ export declare const ROOM_NAME = "instance";
4
+ export interface WelcomeMessage {
5
+ readonly player: number;
6
+ readonly entity: number;
7
+ readonly tickHz: number;
8
+ readonly snapshotHz: number;
9
+ readonly sim: string;
10
+ readonly version: string;
11
+ readonly place: string;
12
+ readonly template: string;
13
+ /** Present when the player joined with a wallet and earn is tracked. */
14
+ readonly runId: string | null;
15
+ /** False when the referee has no signer: earn is tracked but not paid. */
16
+ readonly earnLive: boolean;
17
+ /**
18
+ * Why this seat earns nothing, when it does not: 'guest' (no signed-in
19
+ * session), 'wallet-busy' (the wallet is already earning in another
20
+ * session of this game), 'session' (the session token did not verify).
21
+ */
22
+ readonly earnRefused?: EarnRefusal;
23
+ /** The game's buy-in run terms in whole coins (earn.stake), or absent when it takes no stakes. */
24
+ readonly stake?: StakeTermsView;
25
+ /** The game's PvP pot in whole coins (earn.pot), or absent when it has none. */
26
+ readonly pot?: {
27
+ readonly entry: number;
28
+ readonly recycle: number;
29
+ };
30
+ }
31
+ /**
32
+ * Server -> client ('pot'): 'in' this round's pot holds the player's entry
33
+ * (`pot` is the whole pot so far); 'waiting' the entry is placed for the
34
+ * next round; 'closed' the round split the pot (`paid` is this player's
35
+ * part); 'refused' (`reason`). Whole coins.
36
+ */
37
+ export interface PotMessage {
38
+ readonly state: 'in' | 'waiting' | 'closed' | 'refused';
39
+ readonly entry: number;
40
+ readonly pot?: number;
41
+ readonly paid?: number;
42
+ readonly reason?: string;
43
+ }
44
+ /** A game's buy-in run terms as a client shows them (whole coins). */
45
+ export interface StakeTermsView {
46
+ readonly min: number;
47
+ readonly max: number;
48
+ /** The stake's payout ceiling is stake x multiple. */
49
+ readonly multiple: number;
50
+ }
51
+ /**
52
+ * Client -> server ('stake'): the player spent a stake (the Kit's
53
+ * coins.spend(amount, "stake"), committed). The room fixes the stake from
54
+ * its own ledger when a run starts: always the NEXT run (sign-in or the
55
+ * next round; a run in progress never restarts), and a
56
+ * staked run starts from zero earn progress. `amount` (whole coins) is read only
57
+ * by a room whose store runs in mock mode (local play).
58
+ */
59
+ export interface StakeRequest {
60
+ readonly amount?: number;
61
+ }
62
+ /**
63
+ * Server -> client ('stake'): 'live' the run in progress is staked;
64
+ * 'waiting' a stake is placed for the next run; 'closed' a staked run ended
65
+ * (`paid` is its stake share); 'refused' the stake was not taken (`reason`).
66
+ * Amounts are whole coins.
67
+ */
68
+ export interface StakeMessage {
69
+ readonly state: 'live' | 'waiting' | 'closed' | 'refused';
70
+ readonly stake: number;
71
+ /** Most the run can pay: its free earn plus stake x multiple. */
72
+ readonly ceiling?: number;
73
+ readonly paid?: number;
74
+ readonly reason?: string;
75
+ /** 'live': gear the staked run brought in; 'closed': gear it lost (SKU ids). */
76
+ readonly gear?: readonly string[];
77
+ readonly lost?: readonly string[];
78
+ }
79
+ export type EarnRefusal = 'guest' | 'wallet-busy' | 'session';
80
+ export interface EarnMessage {
81
+ readonly rule: string;
82
+ readonly units: number;
83
+ }
84
+ export interface JoinOptions {
85
+ /**
86
+ * Ignored by the referee. Earning needs `session`: the room takes the
87
+ * wallet from the signed session, never from this field.
88
+ */
89
+ readonly wallet?: string;
90
+ /** Session token from POST /auth/sign-in (SIWE). Without it the seat is a guest. */
91
+ readonly session?: string;
92
+ /**
93
+ * A guest's random profile id (16-80 of A-Z a-z 0-9 _ -), kept by the
94
+ * browser. Progress a guest makes is saved to it on the server and merges
95
+ * into the wallet's profile at sign-in (POST /profile/claim).
96
+ */
97
+ readonly guest?: string;
98
+ }
99
+ /** Guest profile ids: 16-80 characters of A-Z, a-z, 0-9, _ or - (the same rule as Launcher guest drafts). */
100
+ export declare const GUEST_ID_RE: RegExp;
101
+ /** One item of a game's permanent store as a profile sees it (cosmetics, unlocks, meta levels). */
102
+ export interface ProfileStoreItem {
103
+ readonly id: string;
104
+ readonly kind: 'cosmetic' | 'unlock' | 'meta' | 'gear';
105
+ /** Coins for the next purchase (the next level of a meta upgrade); null when owned or at the top. */
106
+ readonly coins: number | null;
107
+ readonly owned: boolean;
108
+ /** Cosmetic id, or the keeper an unlock opens. */
109
+ readonly cosmetic?: number;
110
+ readonly keeper?: string;
111
+ /** Gear: the run upgrade it brings into staked runs, and the share of a run's most earn below which it is lost. */
112
+ readonly gear?: string;
113
+ readonly loseBelow?: number;
114
+ /** Meta upgrades: the stat, bonus per level (0.05 = +5%), the level owned, the top level, the bonus now. */
115
+ readonly stat?: string;
116
+ readonly step?: number;
117
+ readonly level?: number;
118
+ readonly levels?: number;
119
+ readonly bonus?: number;
120
+ }
121
+ /**
122
+ * A player's saved progress in one game, kept by the room server: the same on every device the player signs in on.
123
+ * Achievements, goals and titles come from the authoritative sim only.
124
+ */
125
+ export interface GameProfile {
126
+ readonly game: string;
127
+ /** Whose: a signed-in wallet, a guest id, or null (nothing is saved for this seat). */
128
+ readonly kind: 'wallet' | 'guest' | null;
129
+ readonly wallet: string | null;
130
+ readonly displayName: string | null;
131
+ readonly achievements: readonly {
132
+ readonly id: string;
133
+ readonly at: number;
134
+ readonly source: 'sim' | 'import';
135
+ }[];
136
+ /** Keeper ids this player may pick. */
137
+ readonly keepers: readonly string[];
138
+ /** Permanent SKUs owned (cosmetics and unlocks), by id. */
139
+ readonly owned: readonly string[];
140
+ /** Cosmetic ids owned (0, the default look, always). */
141
+ readonly cosmetics: readonly number[];
142
+ /** Meta upgrade levels owned (opt-in games only). */
143
+ readonly meta: Readonly<Record<string, number>>;
144
+ /** Today's goals (UTC day) and which are done. */
145
+ readonly goals: {
146
+ readonly day: number;
147
+ readonly today: readonly string[];
148
+ readonly done: readonly string[];
149
+ };
150
+ /** Titles taken, by id, with how many times. */
151
+ readonly titles: Readonly<Record<string, number>>;
152
+ /** The game's settings for this player (JSON, set by the client). */
153
+ readonly settings: Readonly<Record<string, unknown>>;
154
+ /** The permanent store: cosmetics, unlocks and meta levels with their next price. */
155
+ readonly store: readonly ProfileStoreItem[];
156
+ /** True once local (pre-profile) progress was imported. */
157
+ readonly imported: boolean;
158
+ /** Store credit in whole coins: a refunded purchase the server could not return as earned coins. The next purchase spends it first. */
159
+ readonly credit: number;
160
+ /** Across every game: for the Launcher's profile page. */
161
+ readonly totals: {
162
+ readonly achievements: number;
163
+ readonly titles: number;
164
+ readonly games: number;
165
+ };
166
+ }
167
+ /** Client -> server: sign in mid-session (a guest who just signed the SIWE message). */
168
+ export interface SignInMessage {
169
+ readonly session: string;
170
+ }
171
+ /** Server -> client: the outcome of a mid-session sign-in. */
172
+ export interface SignInResult {
173
+ readonly ok: boolean;
174
+ readonly runId: string | null;
175
+ readonly earnRefused?: EarnRefusal;
176
+ }
177
+ /**
178
+ * Server -> client: the room rebuilt its world at a safe point (between
179
+ * rounds): a new logic module (hot swap, `version` changed) or a fresh
180
+ * verification segment. The player keeps its id; its entity id may change,
181
+ * baselines are void and the next snapshot is full.
182
+ */
183
+ export interface RebaseMessage {
184
+ readonly entity: number;
185
+ readonly sim: string;
186
+ readonly version: string;
187
+ }
188
+ /** Client -> server: a store hook (paid revive, cosmetic). The room verifies before the sim applies it. */
189
+ export interface CommandMessage {
190
+ readonly name: string;
191
+ readonly value?: number;
192
+ /** Proof of payment from the Kit: a transaction hash, or the key of a coin spend (POST /referee/spend). Verified by the room when the store is live. */
193
+ readonly proof?: string;
194
+ }
195
+ /** Server -> client: the outcome of a command. */
196
+ export interface CommandResult {
197
+ readonly name: string;
198
+ readonly ok: boolean;
199
+ readonly reason?: string;
200
+ }
201
+ /** Server -> client: a fresh referee run started (new runId after a run reset). */
202
+ export interface RunIdMessage {
203
+ readonly runId: string | null;
204
+ }
205
+ export interface CreateOptions {
206
+ readonly place: string;
207
+ readonly template: string;
208
+ /**
209
+ * The game's slug when one room server runs several games of one template
210
+ * (launched Net Forge games, keyed by slug). Absent: the template's own game.
211
+ */
212
+ readonly game?: string;
213
+ }
214
+ /** A game slug as the GameFactory and the room server take it. */
215
+ export declare const GAME_SLUG_RE: RegExp;
@@ -0,0 +1,10 @@
1
+ // Room wire protocol, shared by the server, the demo client and the bots.
2
+ // Hot-path messages are raw bytes under numeric types (smallest header);
3
+ // rare control messages are msgpack objects under string types.
4
+ export const MSG_INPUT = 1; // client -> server, bytes (netcode/input.ts)
5
+ export const MSG_SNAPSHOT = 2; // server -> client, bytes (netcode/snapshot.ts)
6
+ export const ROOM_NAME = 'instance';
7
+ /** Guest profile ids: 16-80 characters of A-Z, a-z, 0-9, _ or - (the same rule as Launcher guest drafts). */
8
+ export const GUEST_ID_RE = /^[A-Za-z0-9_-]{16,80}$/;
9
+ /** A game slug as the GameFactory and the room server take it. */
10
+ export const GAME_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -0,0 +1,65 @@
1
+ import { Client, type Room } from '@colyseus/sdk';
2
+ import { NetClient } from './client.js';
3
+ import type { SimAdapter } from './sim-adapter.js';
4
+ import { type CommandMessage, type CommandResult, type EarnMessage, type GameProfile, type RebaseMessage, type RunIdMessage, type SignInResult, type PotMessage, type StakeMessage, type WelcomeMessage } from './protocol.js';
5
+ export interface JoinParams {
6
+ readonly endpoint: string;
7
+ readonly place: string;
8
+ readonly template: string;
9
+ /**
10
+ * The launched game's slug: one room server runs every launched game of a
11
+ * template side by side. Absent: the page's own ?game= (Net Forge frames a
12
+ * launched game with it), else the template's own game.
13
+ */
14
+ readonly game?: string;
15
+ readonly wallet?: string;
16
+ /** Session token from POST /auth/sign-in. Without it the seat plays as a guest and earns nothing. */
17
+ readonly session?: string;
18
+ /** A guest profile id (see protocol GUEST_ID_RE): a guest's progress is saved under it. */
19
+ readonly guest?: string;
20
+ readonly friendRoomId?: string;
21
+ }
22
+ export interface RoomConnection<W, I> {
23
+ readonly room: Room;
24
+ /** The current predictor. Replaced on a rebase (new entity id), so read it each time. */
25
+ readonly net: NetClient<W, I>;
26
+ readonly welcome: WelcomeMessage;
27
+ readonly roomId: string;
28
+ /** Snapshot payload bytes received (excluding Colyseus/WS framing). */
29
+ bytesIn(): number;
30
+ messagesIn(): number;
31
+ onEarn(cb: (e: EarnMessage) => void): void;
32
+ /** Run state the room broadcasts (game-defined JSON), latest first-come. */
33
+ onRun(cb: (info: unknown) => void): void;
34
+ latestRun(): unknown;
35
+ /** A fresh referee run after the game reset (new runId). */
36
+ onRunId(cb: (m: RunIdMessage) => void): void;
37
+ /** The room rebuilt its world at a safe point (hot swap or a new verification segment). */
38
+ onRebase(cb: (m: RebaseMessage) => void): void;
39
+ /** Sign in mid-session with a token from POST /auth/sign-in. */
40
+ signIn(session: string): Promise<SignInResult>;
41
+ /** The seat's saved progress, pushed by the room on join and whenever it changes (achievements, unlocks, goals). */
42
+ onProfile(cb: (p: GameProfile) => void): void;
43
+ latestProfile(): GameProfile | null;
44
+ /**
45
+ * The buy-in run: tell the room a stake was spent (the Kit's
46
+ * coins.spend(amount, "stake"), committed). The room fixes the stake from
47
+ * its own ledger at run start and answers with a 'stake' message.
48
+ * `amount` (whole coins) counts only on a mock-store room (local play).
49
+ */
50
+ stake(amount?: number): void;
51
+ /** Stake state from the room: live, waiting for the next run, closed (with its share paid), or refused. */
52
+ onStake(cb: (m: StakeMessage) => void): void;
53
+ latestStake(): StakeMessage | null;
54
+ /** PvP pot: tell the room an entry was spent (coins.spend(entry, "pot")); it joins the next round's pot (taken once, at that round's start). */
55
+ pot(): void;
56
+ onPot(cb: (m: PotMessage) => void): void;
57
+ /** A store hook (revive token, cosmetic); resolves with the room's verdict. */
58
+ command(cmd: CommandMessage): Promise<CommandResult>;
59
+ claim(): Promise<unknown>;
60
+ leave(): Promise<void>;
61
+ }
62
+ /** The page's ?game= slug (set by Net Forge on a launched game's frame), or null outside a browser or without one. */
63
+ export declare function pageGame(): string | null;
64
+ export declare function joinRoom<W, I>(sim: SimAdapter<W, I>, p: JoinParams): Promise<RoomConnection<W, I>>;
65
+ export declare function connectReservation<W, I>(sim: SimAdapter<W, I>, endpoint: string, reservation: Parameters<Client['consumeSeatReservation']>[0]): Promise<RoomConnection<W, I>>;