@netforge/engine 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/GAME-DESIGN.md +195 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +30 -0
  4. package/README.md +147 -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 +208 -0
  76. package/dist/net/protocol.js +8 -0
  77. package/dist/net/room-client.d.ts +57 -0
  78. package/dist/net/room-client.js +144 -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.txt +183 -0
  244. package/package.json +82 -3
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Idle declarations (game({ template: "idle" })). The template already runs
3
+ * the works, the production chain (pumps make brine, pans boil it into salt,
4
+ * kilns cut crystals), buying and levelling plants, raking, the shared
5
+ * contract, the shift clock and the referee. A game declares its flat (the
6
+ * shift, the tending window, raking, the chain's ratios, the contract) and
7
+ * its plants.
8
+ *
9
+ * saltFlat({ shift: 900, tend: 120, start: 20, rake: { salt: 3, cooldown: 0.4 }, contract: { crystals: 60 } });
10
+ * plant("hand_pump", { model: "style.lowpoly.barrel", kind: "pump", cost: 15, growth: 1.15, rate: 2 });
11
+ *
12
+ * Production runs only on the room's ticks while the player is in the room
13
+ * and has tended the works within `tend` seconds: there is no offline
14
+ * production and nothing a client reports counts.
15
+ */
16
+ import { z } from "zod";
17
+ import { contentOf, declareContent } from "./registry.js";
18
+ import { assetIdSchema, GameDefinitionError, idSchema, parseOrThrow } from "./schemas.js";
19
+ /** The shortest shift (seconds): earn rules pay once a shift, so this bounds what one seat can earn an hour. */
20
+ export const IDLE_MIN_SHIFT = 600;
21
+ /** The longest tending window (seconds): a works nobody touches for this long stops. */
22
+ export const IDLE_MAX_TEND = 300;
23
+ export const saltFlatSchema = z
24
+ .object({
25
+ /** Seconds in a shift. The shift ends for everyone, the end screen shows, a new shift starts from nothing. */
26
+ shift: z.number().min(IDLE_MIN_SHIFT, `a shift is at least ${IDLE_MIN_SHIFT} seconds (earn pays once a shift)`).max(3600),
27
+ /** Seconds the works keep running after the player last tended them (raked, bought, shipped). */
28
+ tend: z.number().min(30).max(IDLE_MAX_TEND, `the works stop at most ${IDLE_MAX_TEND} seconds after the last tend`).default(120),
29
+ /** Salt (kg) each player starts a shift with. */
30
+ start: z.number().min(0).max(10_000).default(20),
31
+ /** Raking: salt (kg) a rake brings in, and seconds between rakes. */
32
+ rake: z.object({ salt: z.number().min(0.1).max(1000), cooldown: z.number().min(0.2).max(10) }).strict().default({ salt: 3, cooldown: 0.4 }),
33
+ /** Litres of brine a pan boils into one kg of salt. */
34
+ brinePerSalt: z.number().min(0.5).max(100).default(3),
35
+ /** Kg of salt a kiln cuts into one crystal. */
36
+ saltPerCrystal: z.number().min(1).max(10_000).default(25),
37
+ /** Every this many kg of salt a player makes raises "salt.packed" (one crate). */
38
+ packEvery: z.number().min(10).max(100_000).default(100),
39
+ /** The shared contract: crystals everyone ships together this shift. */
40
+ contract: z.object({ crystals: z.number().int().min(1).max(100_000) }).strict().default({ crystals: 60 }),
41
+ })
42
+ .strict();
43
+ export const PLANT_KINDS = ["pump", "pan", "kiln"];
44
+ /**
45
+ * A plant a player buys and levels with salt. kind "pump" makes `rate`
46
+ * litres of brine a second a level; "pan" boils up to `rate` kg of salt a
47
+ * second a level (from brine); "kiln" cuts up to `rate` crystals a second a
48
+ * level (from salt). The first level costs `cost` kg of salt, each next one
49
+ * `growth` times the last.
50
+ */
51
+ export const plantSchema = z
52
+ .object({
53
+ model: assetIdSchema,
54
+ kind: z.enum(PLANT_KINDS, { error: `plant kinds are: ${PLANT_KINDS.join(", ")}` }),
55
+ cost: z.number().min(1).max(10_000_000),
56
+ growth: z.number().min(1.05, "growth is at least 1.05 (costs rise every level)").max(3).default(1.15),
57
+ rate: z.number().positive().max(100_000),
58
+ /** Most levels a player can buy in a shift. */
59
+ max: z.number().int().min(1).max(100).default(30),
60
+ })
61
+ .strict();
62
+ /** The flat: shift length, tending, raking, the chain's ratios and the shared contract (one per game). */
63
+ export function saltFlat(def) {
64
+ const parsed = parseOrThrow(saltFlatSchema, def, "saltFlat()");
65
+ declareContent("saltFlat", "flat", parsed);
66
+ return parsed;
67
+ }
68
+ /** A plant players buy and level (at most 8). */
69
+ export function plant(id, def) {
70
+ parseOrThrow(idSchema, id, "plant id");
71
+ const parsed = parseOrThrow(plantSchema, def, `plant("${id}")`);
72
+ declareContent("plant", id, parsed);
73
+ return parsed;
74
+ }
75
+ /** Store upgrade stats an idle game reads: plant output (might), raking speed (haste), salt a rake (amount). */
76
+ export const IDLE_STATS = ["might", "haste", "amount"];
77
+ /** The idle content of a manifest, cross-checked (one line per issue). */
78
+ export function idleContent(m) {
79
+ const issues = [];
80
+ const flat = contentOf(m, "saltFlat")[0]?.[1];
81
+ const plants = contentOf(m, "plant");
82
+ if (!flat)
83
+ issues.push("declare the flat: saltFlat({ shift, tend, start, rake, contract })");
84
+ if (plants.length === 0)
85
+ issues.push('declare at least one plant: plant("hand_pump", { model, kind: "pump", cost, growth, rate })');
86
+ if (plants.length > 8)
87
+ issues.push("at most 8 plants per game");
88
+ for (const k of PLANT_KINDS)
89
+ if (plants.length > 0 && !plants.some(([, p]) => p.kind === k))
90
+ issues.push(`declare a "${k}" plant: the chain is pump (brine), pan (salt), kiln (crystals)`);
91
+ if (m.weapons.size > 0)
92
+ issues.push("weapon(...) is for the survivor and arena templates; an idle game declares plant(...)");
93
+ if (m.prefabs.size > 0)
94
+ issues.push("prefab(...) is not used by the idle template; plants are plant(...)");
95
+ for (const [id, u] of m.upgrades)
96
+ if (!IDLE_STATS.includes(u.stat))
97
+ issues.push(`upgrade("${id}") raises ${u.stat}; the idle template reads ${IDLE_STATS.join(", ")} (plant output, raking speed, salt a rake)`);
98
+ for (const [id, r] of m.earnRules)
99
+ if (r.oncePer !== "run")
100
+ issues.push(`earn.rule("${id}") must be oncePer: "run" (a run is a shift; that is what bounds an idle game's earn)`);
101
+ if (issues.length > 0 || !flat)
102
+ throw new GameDefinitionError("idle", issues);
103
+ return { flat, plants };
104
+ }
@@ -0,0 +1,3 @@
1
+ import type { GameManifest } from "./registry.js";
2
+ import type { HarnessOptions, HarnessReport } from "./harness.js";
3
+ export declare function runRaceHarness(manifest: GameManifest, opts: HarnessOptions): Promise<HarnessReport>;
@@ -0,0 +1,148 @@
1
+ async function setup(manifest) {
2
+ const { compileGame } = await import("../game/compile.js");
3
+ const compiled = compileGame(manifest);
4
+ if (compiled.mode === "platformer" && compiled.platformer) {
5
+ const { PlatformCore, routeFor } = await import("../game/platformer.js");
6
+ const p = compiled.platformer;
7
+ // The start signal comes after the race runner's 3 s countdown.
8
+ const start = Math.round(3 * compiled.tickRate);
9
+ const routes = p.levels.map((level, course) => {
10
+ const tape = routeFor(level, compiled.tickRate, p.tuning, start);
11
+ return { course, found: tape !== null, seconds: tape ? Math.round((tape.length / compiled.tickRate) * 10) / 10 : null };
12
+ });
13
+ return { core: new PlatformCore(p, compiled.tickRate), tickRate: compiled.tickRate, bots: compiled.bots, roundSeconds: compiled.round?.seconds ?? null, courses: p.levels.length, routes, mishap: "player.fell" };
14
+ }
15
+ if (compiled.mode === "racer" && compiled.racer) {
16
+ const { KartCore } = await import("../game/racer.js");
17
+ const r = compiled.racer;
18
+ return { core: new KartCore(r, compiled.tickRate), tickRate: compiled.tickRate, bots: compiled.bots, roundSeconds: compiled.round?.seconds ?? null, courses: r.tracks.length, routes: [], mishap: "kart.bumped" };
19
+ }
20
+ throw new Error(`no race machinery for template "${manifest.game.template}"`);
21
+ }
22
+ export async function runRaceHarness(manifest, opts) {
23
+ const { initSim } = await import("@netforge/engine-sim");
24
+ const { RaceRunner } = await import("../game/race.js");
25
+ await initSim();
26
+ const s = await setup(manifest);
27
+ const runner = new RaceRunner(manifest, s.core, { bots: s.bots, roundSeconds: s.roundSeconds, courses: s.courses, seed: opts.seed ?? 1 });
28
+ const players = Math.max(0, Math.min(100, Math.floor(opts.bots)));
29
+ for (let i = 1; i <= players; i++)
30
+ runner.joined(i);
31
+ const skill = opts.bot === "human" ? 0.75 : 1;
32
+ const perf = globalThis.performance;
33
+ const now = () => (perf ? perf.now() : Date.now());
34
+ const times = [];
35
+ const earned = {};
36
+ let earnEmitted = 0;
37
+ let mishaps = 0;
38
+ const finishes = new Map();
39
+ const finishSeconds = [];
40
+ let firstCoin = null;
41
+ const runs = { won: 0, lost: 0 };
42
+ let rounds = 0;
43
+ let quiet = 0;
44
+ let softlock = false;
45
+ const best = new Map();
46
+ let raceTicks = 0;
47
+ for (let t = 0; t < opts.ticks; t++) {
48
+ const inputs = new Map();
49
+ for (let i = 1; i <= players; i++)
50
+ inputs.set(i, { ...s.core.botInput(i, t, skill), seq: t + 1 });
51
+ const a = now();
52
+ runner.beforeStep(inputs);
53
+ const phase = runner.phaseNow;
54
+ const info = phase === "racing" ? runner.info() : null;
55
+ const r = runner.afterStep(s.core.step(inputs));
56
+ times.push(now() - a);
57
+ for (const aw of r.awards) {
58
+ earned[aw.rule] = (earned[aw.rule] ?? 0) + aw.units;
59
+ earnEmitted += aw.units;
60
+ }
61
+ let moved = false;
62
+ for (const ev of r.events) {
63
+ const mine = ev.players.filter((p) => p >= 1 && p <= players);
64
+ if (mine.length === 0)
65
+ continue;
66
+ if (ev.name === s.mishap)
67
+ mishaps += mine.length;
68
+ if (ev.name === "coin.collected" && firstCoin === null)
69
+ firstCoin = raceTicks / s.tickRate;
70
+ if (ev.name === "player.finished") {
71
+ moved = true;
72
+ for (const p of mine)
73
+ finishes.set(p, (finishes.get(p) ?? 0) + 1);
74
+ finishSeconds.push(info?.seconds ?? 0);
75
+ }
76
+ }
77
+ if (phase === "racing") {
78
+ raceTicks++;
79
+ for (let i = 1; i <= players; i++) {
80
+ const pr = s.core.progress(i);
81
+ if (pr > (best.get(i) ?? -1) + 0.002) {
82
+ best.set(i, pr);
83
+ moved = true;
84
+ }
85
+ if (s.core.finished(i))
86
+ moved = true;
87
+ }
88
+ quiet = moved || players === 0 ? 0 : quiet + 1;
89
+ if (quiet >= 30 * s.tickRate)
90
+ softlock = true;
91
+ }
92
+ if (phase === "racing" && runner.phaseNow === "results") {
93
+ rounds++;
94
+ const done = [...Array(players)].every((_, k) => s.core.finished(k + 1));
95
+ if (done)
96
+ runs.won++;
97
+ else
98
+ runs.lost++;
99
+ best.clear();
100
+ }
101
+ }
102
+ const sorted = [...times].sort((x, y) => x - y);
103
+ const med = (v) => (v.length === 0 ? null : ([...v].sort((x, y) => x - y)[Math.floor(v.length / 2)] ?? null));
104
+ const totalFinishes = [...finishes.values()].reduce((x, y) => x + y, 0);
105
+ const fun = {
106
+ genre: "race",
107
+ firstThreatSeconds: null,
108
+ firstRewardSeconds: firstCoin,
109
+ levelAt: { "60": null, "180": null, "360": null, "540": null },
110
+ deathsBeforeMinute: null,
111
+ onScreenAt30: null,
112
+ contactHitsFirstMinute: null,
113
+ telegraphedDeathShare: null,
114
+ spawnsOnScreen: null,
115
+ survivalSeconds: null,
116
+ spawnToDamageSeconds: null,
117
+ spawnDeathShare: null,
118
+ killSpread: null,
119
+ leadChanges: null,
120
+ bossKills: [],
121
+ downsBySource: {},
122
+ finishSeconds: med(finishSeconds),
123
+ finishRate: rounds > 0 && players > 0 ? Math.round((totalFinishes / (rounds * players)) * 100) / 100 : null,
124
+ mishapsPerRound: rounds > 0 && players > 0 ? Math.round((mishaps / (Math.max(1, rounds) * players)) * 10) / 10 : null,
125
+ firstCoinSeconds: firstCoin,
126
+ routes: s.routes ?? [],
127
+ };
128
+ const base = {
129
+ ticks: opts.ticks,
130
+ bots: players,
131
+ deaths: mishaps,
132
+ kills: 0,
133
+ wave: rounds,
134
+ runs,
135
+ maxLevel: 0,
136
+ peakEnemies: 0,
137
+ topScore: Math.max(0, ...finishes.values()),
138
+ scorers: finishes.size,
139
+ earned,
140
+ msPerTick: times.reduce((x, y) => x + y, 0) / Math.max(1, times.length),
141
+ p99MsPerTick: sorted[Math.floor(0.99 * Math.max(0, sorted.length - 1))] ?? 0,
142
+ earnEmitted,
143
+ softlock,
144
+ fun,
145
+ };
146
+ const failed = (opts.assert ?? []).filter((x) => !x.check({ ...base, failed: [] })).map((x) => x.name);
147
+ return { ...base, failed };
148
+ }
@@ -0,0 +1,241 @@
1
+ import type { AchievementDef, AchievementInput, KeeperDef, KeeperInput, MissionDef, MissionInput, TitleDef, TitleInput, DirectorDef, DirectorInput, EarnRuleDef, EarnRuleInput, EarnStakeDef, EarnStakeInput, EarnPotDef, EarnPotInput, EconomyDef, EconomyInput, MetaUpgradeDef, MetaUpgradeInput, GameDef, GameInput, PrefabDef, PrefabInput, SkuDef, SkuInput, UpgradeDef, UpgradeInput, WeaponDef, WeaponInput } from "./schemas.js";
2
+ /** A read-only view of one player, as game logic sees it. */
3
+ export interface PlayerView {
4
+ readonly id: number;
5
+ readonly position: readonly [number, number, number];
6
+ readonly health: number;
7
+ readonly alive: boolean;
8
+ /** Kills this run: enemies (survivor) or opponents (arena). */
9
+ readonly kills: number;
10
+ /** Times this player died this run. */
11
+ readonly deaths: number;
12
+ /** A server bot filling an empty seat (bots never earn). */
13
+ readonly bot: boolean;
14
+ }
15
+ export interface SpawnRingOptions {
16
+ readonly count: number;
17
+ readonly radius: number;
18
+ /** Centre on this player; default: each spawn picks a living player at random. */
19
+ readonly around?: number;
20
+ }
21
+ /** Where `spawnEdge` places enemies around the target player's screen. */
22
+ export type EdgeSide = "any" | "ahead" | "behind" | "north" | "south" | "east" | "west";
23
+ export interface SpawnEdgeOptions {
24
+ readonly count: number;
25
+ /** The player whose screen edge to use; default: a living player at random for each spawn. */
26
+ readonly around?: number;
27
+ /**
28
+ * Which side: "any" (default), "ahead" or "behind" the player's movement,
29
+ * or a screen side ("north" is the top of the screen). A side spawns the
30
+ * whole count from one direction, like a tide or a stream.
31
+ */
32
+ readonly side?: EdgeSide;
33
+ /** Width of the arc on that side, degrees (default 50; ignored for "any"). */
34
+ readonly spread?: number;
35
+ /** Health, damage and XP multipliers for these spawns (default 1 each). */
36
+ readonly scale?: {
37
+ readonly health?: number;
38
+ readonly damage?: number;
39
+ readonly xp?: number;
40
+ };
41
+ /** Elites: x6 health, x1.5 damage, x5 XP and a gold rim. */
42
+ readonly elite?: boolean;
43
+ }
44
+ /**
45
+ * What a system can do. Deliberately small: no renderer, no network, no
46
+ * physics handles. Systems run only on the authoritative side (the room
47
+ * server and the headless harness); clients receive the results in
48
+ * snapshots. Everything here is still deterministic (tick timers and the
49
+ * sim's own random), because run verification replays it.
50
+ */
51
+ export interface SystemContext {
52
+ /** Fixed ticks since the run started. */
53
+ readonly tick: () => number;
54
+ /** Seconds since the run started (tick / tickRate). */
55
+ readonly time: () => number;
56
+ /** Living and dead players, in id order. */
57
+ readonly players: () => readonly PlayerView[];
58
+ /** Run `fn(n)` every `seconds`, n = 1, 2, 3, ... */
59
+ readonly every: (seconds: number, fn: (n: number) => void) => void;
60
+ /** Run `fn` once after `seconds`. */
61
+ readonly after: (seconds: number, fn: () => void) => void;
62
+ /** Run `fn` every tick. */
63
+ readonly onTick: (fn: () => void) => void;
64
+ /** Spawn at a point. `elite`: x6 health, x1.5 damage, x5 XP, a gold rim (and, with pickups, a chest). Prefer spawnEdge. */
65
+ readonly spawn: (prefab: string, at: readonly [number, number, number], opts?: {
66
+ readonly elite?: boolean;
67
+ }) => void;
68
+ /** Spawn on a ring around players. Points outside the arena or in a pillar retry a new heading (never clamped to a wall). */
69
+ readonly spawnRing: (prefab: string, opts: SpawnRingOptions) => void;
70
+ /**
71
+ * Spawn just outside the screen (never on it): the right way to bring
72
+ * enemies in. Returns how many spawned (fewer when no valid spot).
73
+ */
74
+ readonly spawnEdge: (prefab: string, opts: SpawnEdgeOptions) => number;
75
+ /** Raise a game event (earn rules key on these, e.g. "wave.cleared"). It credits `players` (ids), or everyone present. */
76
+ readonly emit: (event: string, players?: readonly number[]) => void;
77
+ /** Seeded random 0..1 from the sim's PRNG. */
78
+ readonly random: () => number;
79
+ /** Living enemies of a prefab, or of every enemy prefab. */
80
+ readonly alive: (prefab?: string) => number;
81
+ /**
82
+ * Run `fn` when a game event happens: the sim's own ("enemy.killed",
83
+ * "boss.killed", "player.downed", "player.revived", "player.levelup",
84
+ * "player.died"; arena: "opponent.killed" crediting the killer,
85
+ * "round.top", "round.finished"; "run.started" when a player's
86
+ * earning run starts over, so a counter of yours can start over too) and
87
+ * any you `emit`. `players` are the
88
+ * ids it credits. A seat bot's death and a "round.top" with no other
89
+ * person in the round still arrive here, but pay no earn rule, and
90
+ * neither does anything you `emit` while handling them.
91
+ */
92
+ readonly on: (event: string, fn: (players: readonly number[]) => void) => void;
93
+ /** Bring players who bled out back beside a living teammate. */
94
+ readonly respawnDead: () => void;
95
+ /** End the run: "won" or "lost". The room shows the end screen, then starts a fresh run. */
96
+ readonly end: (outcome: "won" | "lost") => void;
97
+ /** Spawn a pickup prefab (a gem, or a special pickup: heal, vacuum, bomb, chest) at a point. */
98
+ readonly spawnPickup: (prefab: string, at: readonly [number, number, number]) => void;
99
+ /**
100
+ * A big centred banner every player sees ("THE TIDE RISES"). tone:
101
+ * "info" (default), "danger" (a threat is coming) or "reward". Keep it a
102
+ * few words; the HUD also points at off-screen bosses.
103
+ */
104
+ readonly announce: (text: string, opts?: {
105
+ readonly tone?: "info" | "danger" | "reward";
106
+ }) => void;
107
+ /** True while any boss is alive (events usually wait for it). */
108
+ readonly bossAlive: () => boolean;
109
+ /** Stop the spawn director for `seconds` (a set piece, a breather). */
110
+ readonly pauseDirector: (seconds: number) => void;
111
+ /** Multiply the director's spawn rate by `multiplier` for `seconds` (a surge). */
112
+ readonly surge: (multiplier: number, seconds: number) => void;
113
+ /**
114
+ * Put a countdown on the HUD: "THE BELL WARDEN" in `seconds`. The latest
115
+ * call replaces the last; it clears itself at zero.
116
+ */
117
+ readonly upcoming: (label: string, seconds: number) => void;
118
+ /** Multiply the XP of every gem dropped for `seconds` (an "ember hour"). */
119
+ readonly xpBoost: (multiplier: number, seconds: number) => void;
120
+ /** Scatter `count` of a prefab (props: urns, lanterns) over the field, off every screen. Returns how many. */
121
+ readonly scatter: (prefab: string, count: number) => number;
122
+ /** Remove every living enemy of a prefab at once (an event ends): no kills, no drops. Returns how many. */
123
+ readonly clear: (prefab: string) => number;
124
+ /**
125
+ * True when the ground point (x, z) is on the screen of any player who is
126
+ * up. Spawn where it is false (a dungeon room's far side), so enemies
127
+ * never pop in where a player is looking.
128
+ */
129
+ readonly onScreen: (x: number, z: number) => boolean;
130
+ }
131
+ export type SystemSetup = (ctx: SystemContext) => void;
132
+ export interface GameManifest {
133
+ readonly game: GameDef;
134
+ readonly prefabs: ReadonlyMap<string, PrefabDef>;
135
+ readonly weapons: ReadonlyMap<string, WeaponDef>;
136
+ readonly upgrades: ReadonlyMap<string, UpgradeDef>;
137
+ readonly systems: ReadonlyMap<string, SystemSetup>;
138
+ readonly earnRules: ReadonlyMap<string, EarnRuleDef>;
139
+ /** The buy-in run terms (earn.stake; every game has one). */
140
+ readonly stake: EarnStakeDef;
141
+ /** A PvP pot (earn.pot), or null: arena games only, optional. */
142
+ readonly pot: EarnPotDef | null;
143
+ readonly skus: ReadonlyMap<string, SkuDef>;
144
+ /** The spawn director, if the game declared one. */
145
+ readonly director: DirectorDef | null;
146
+ /** Keepers in declaration order (the keeper index). */
147
+ readonly keepers: ReadonlyMap<string, KeeperDef>;
148
+ readonly achievements: ReadonlyMap<string, AchievementDef>;
149
+ readonly missions: ReadonlyMap<string, MissionDef>;
150
+ /** End-screen titles in declaration order. */
151
+ readonly titles: ReadonlyMap<string, TitleDef>;
152
+ /** The coin model (economy(...); the recommended default when not declared). */
153
+ readonly economy: EconomyDef;
154
+ /** Meta upgrades in declaration order (empty unless economy({ metaUpgrades: true })). */
155
+ readonly metaUpgrades: ReadonlyMap<string, MetaUpgradeDef>;
156
+ /**
157
+ * Genre content the board-style templates declare (tower defense, idle,
158
+ * cards, collector): their own typed declarations, validated by their own
159
+ * schemas, keyed "<kind>:<id>" in declaration order (see declareContent).
160
+ */
161
+ readonly content: ReadonlyMap<string, unknown>;
162
+ }
163
+ /** Declare the game. Exactly one per game module; it is the module's default export. */
164
+ export declare function game(def: GameInput): GameDef;
165
+ /** Declare a prefab: a spawnable entity template. */
166
+ export declare function prefab(id: string, def: PrefabInput): PrefabDef;
167
+ /** Declare a weapon (survivor template): it fires by itself; players gain and level weapons at level-up. */
168
+ export declare function weapon(id: string, def: WeaponInput): WeaponDef;
169
+ /** Declare a passive upgrade offered at level-up (survivor template). */
170
+ export declare function upgrade(id: string, def: UpgradeInput): UpgradeDef;
171
+ /**
172
+ * Declare the spawn director (at most one per game): who spawns, how many,
173
+ * how fast and how tough, over the run's clock. See directorSchema.
174
+ */
175
+ export declare function director(def: DirectorInput): DirectorDef;
176
+ /** Declare a system. `setup` runs once when a run starts and schedules work with `every`, `after`, `onTick`. */
177
+ export declare function system(name: string, setup: SystemSetup): void;
178
+ /**
179
+ * Declare a keeper (a playable character): a start weapon and a signature
180
+ * passive. Players pick one before their first level; at most 15.
181
+ */
182
+ export declare function keeper(id: string, def: KeeperInput): KeeperDef;
183
+ /** Declare an achievement (counted per player per run; it unlocks keepers, never coins). */
184
+ export declare function achievement(id: string, def: AchievementInput): AchievementDef;
185
+ /** Declare a daily goal (the HUD shows three a day; it pays nothing). */
186
+ export declare function mission(id: string, def: MissionInput): MissionDef;
187
+ /** Declare an end-screen title ("The Butcher": the most kills). */
188
+ export declare function title(id: string, def: TitleInput): TitleDef;
189
+ /**
190
+ * Declare the game's coin model (at most once). Without it a game uses the
191
+ * recommended default: coins buy cosmetics, unlocks and in-run consumables,
192
+ * never permanent stat power. economy({ store: "cosmetics" }) sells looks
193
+ * only; economy({ metaUpgrades: true }) opts in to metaUpgrade(...).
194
+ */
195
+ export declare function economy(def?: EconomyInput): EconomyDef;
196
+ /**
197
+ * Declare a meta upgrade: a permanent stat bonus a player buys with coins,
198
+ * level by level (opt-in: economy({ metaUpgrades: true })). The engine caps
199
+ * meta power at +15% per stat and +30% in total, and costs grow
200
+ * geometrically. The store shows every level's bonus and price.
201
+ */
202
+ export declare function metaUpgrade(id: string, def: MetaUpgradeInput): MetaUpgradeDef;
203
+ /** Earn rules (every game must have at least one). */
204
+ export declare const earn: {
205
+ rule(id: string, def: EarnRuleInput): EarnRuleDef;
206
+ /**
207
+ * The buy-in run, the mandatory sink loop: earning
208
+ * above free play takes a stake of the game's coins at run start. One per
209
+ * game, written on one line so Net Forge's Launch panel can read and edit it.
210
+ */
211
+ stake(def: EarnStakeInput): EarnStakeDef;
212
+ /**
213
+ * A PvP pot, optional, for the arena
214
+ * template: entrants pay `entry` coins before a round, the result splits
215
+ * the pot, and `recycle` of it stays in the reward pool.
216
+ */
217
+ pot(def: EarnPotInput): EarnPotDef;
218
+ };
219
+ /** Store SKUs: upgrades and cosmetics priced in the game's coins (and/or USDG). */
220
+ export declare const store: {
221
+ sku(id: string, def: SkuInput): SkuDef;
222
+ };
223
+ /**
224
+ * Collect and cross-check what the game module declared. Called by the
225
+ * build and the runtime after importing the module, not by game code.
226
+ */
227
+ export declare function collectManifest(): GameManifest;
228
+ /**
229
+ * A genre declaration (rules/genres: tower(...), board(...), card(...)):
230
+ * stored under "<kind>:<id>" for the genre's compile step to read. Ids are
231
+ * unique per kind. Game code calls the genre's own typed function, never this.
232
+ */
233
+ export declare function declareContent(kind: string, id: string, value: unknown): void;
234
+ /** Genre content of one kind, in declaration order (id, value). */
235
+ export declare function contentOf<T>(m: Pick<GameManifest, "content">, kind: string): [string, T][];
236
+ /** The coin spend reason a buy-in run's stake is spent under (the referee reads it at run start). */
237
+ export declare const STAKE_REASON = "stake";
238
+ /** The coin spend reason a PvP pot entry is spent under. */
239
+ export declare const POT_REASON = "pot";
240
+ /** Forget all declarations (the build and tests load several game modules in one process). */
241
+ export declare function resetRegistry(): void;