@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,549 @@
1
+ /**
2
+ * Headless test harness: run a game on the server sim
3
+ * with bot players and report what happened.
4
+ *
5
+ * When a game module has been declared (game(), prefabs, weapons, systems,
6
+ * earn rules), it runs that game exactly as the room server does: compiled
7
+ * content in engine-sim, the systems on the GameRunner, earn rules counted
8
+ * per player. With no game declared it runs the week-2 engine-sim arena
9
+ * (the built-in chaser spawner) with scripted bots.
10
+ */
11
+ export async function runHarness(opts) {
12
+ const { collectManifest } = await import("./registry.js");
13
+ let manifest = null;
14
+ try {
15
+ manifest = collectManifest();
16
+ }
17
+ catch {
18
+ manifest = null;
19
+ }
20
+ if (manifest && (manifest.game.template === "platformer" || manifest.game.template === "racer")) {
21
+ const { runRaceHarness } = await import("./race-harness.js");
22
+ return runRaceHarness(manifest, opts);
23
+ }
24
+ if (manifest) {
25
+ // Board genres (tower defense, idle, cards, collector) play on their own rules module.
26
+ const rules = await import("../rules/genres.js");
27
+ if (rules.isRulesTemplate(manifest.game.template)) {
28
+ const { playRules } = await import("../rules/harness.js");
29
+ return playRules(rules.genreFor(manifest), manifest, opts).report;
30
+ }
31
+ }
32
+ if (manifest && manifest.weapons.size > 0)
33
+ return runGame(manifest, opts);
34
+ const sim = await import("@netforge/engine-sim");
35
+ await sim.initSim();
36
+ const tickRate = opts.tickRate ?? sim.DEFAULT_TICK_RATE;
37
+ const world = sim.createWorld({ tickRate, seed: opts.seed ?? 1, enemies: { target: opts.enemies ?? 500 } });
38
+ const bots = Math.max(0, Math.min(100, Math.floor(opts.bots)));
39
+ for (let i = 1; i <= bots; i++)
40
+ sim.addPlayer(world, i, [i * 1.5 - bots * 0.75, 1, 0]);
41
+ let s = (opts.seed ?? 1) >>> 0 || 1;
42
+ const rnd = () => {
43
+ s ^= s << 13;
44
+ s ^= s >>> 17;
45
+ s ^= s << 5;
46
+ return (s >>> 0) / 4294967296;
47
+ };
48
+ const yaw = new Float64Array(bots + 1);
49
+ const times = [];
50
+ let deaths = 0;
51
+ let kills = 0;
52
+ let quiet = 0;
53
+ let softlock = false;
54
+ // performance exists in browsers, Node and Bun, but not in lib ES2022's types.
55
+ const perf = globalThis.performance;
56
+ const now = () => (perf ? perf.now() : Date.now());
57
+ for (let t = 0; t < opts.ticks; t++) {
58
+ const inputs = new Map();
59
+ for (let p = 1; p <= bots; p++) {
60
+ yaw[p] = (yaw[p] ?? 0) + (rnd() - 0.5) * 0.4;
61
+ inputs.set(p, {
62
+ seq: t + 1,
63
+ tick: world.tick,
64
+ moveX: rnd() * 2 - 1,
65
+ moveZ: rnd() * 2 - 0.5,
66
+ yaw: yaw[p] ?? 0,
67
+ buttons: (rnd() < 0.3 ? sim.Button.Fire : 0) | (rnd() < 0.03 ? sim.Button.Jump : 0),
68
+ });
69
+ }
70
+ const a = now();
71
+ const r = sim.step(world, inputs);
72
+ times.push(now() - a);
73
+ let active = false;
74
+ for (const e of r.events) {
75
+ if (e.type === "player.died")
76
+ deaths++;
77
+ if (e.type === "enemy.killed")
78
+ kills++;
79
+ if (e.type === "enemy.killed" || e.type === "player.hit")
80
+ active = true;
81
+ }
82
+ quiet = active || bots > 0 ? 0 : quiet + 1;
83
+ if (quiet >= 30 * tickRate)
84
+ softlock = true;
85
+ }
86
+ sim.destroyWorld(world);
87
+ const sorted = [...times].sort((x, y) => x - y);
88
+ const base = {
89
+ ticks: opts.ticks,
90
+ bots,
91
+ deaths,
92
+ kills,
93
+ msPerTick: times.reduce((x, y) => x + y, 0) / Math.max(1, times.length),
94
+ p99MsPerTick: sorted[Math.floor(0.99 * Math.max(0, sorted.length - 1))] ?? 0,
95
+ earnEmitted: 0,
96
+ softlock,
97
+ wave: 0,
98
+ runs: { won: 0, lost: 0 },
99
+ maxLevel: 0,
100
+ peakEnemies: 0,
101
+ topScore: 0,
102
+ scorers: 0,
103
+ earned: {},
104
+ };
105
+ const failed = (opts.assert ?? []).filter((a) => !a.check({ ...base, failed: [] })).map((a) => a.name);
106
+ return { ...base, failed };
107
+ }
108
+ async function runGame(manifest, opts) {
109
+ const sim = await import("@netforge/engine-sim");
110
+ const { compileGame } = await import("../game/compile.js");
111
+ const { GameRunner } = await import("../game/runner.js");
112
+ const { botInput, HumanBot } = await import("../game/bot.js");
113
+ const { insideView } = await import("../game/view.js");
114
+ const { threatOf } = await import("../game/threats.js");
115
+ await sim.initSim();
116
+ const compiled = compileGame(manifest);
117
+ const world = sim.createWorld({ ...compiled.sim, seed: opts.seed ?? 1 });
118
+ const runner = new GameRunner(compiled, world, opts.enemies ? { holdEnemies: opts.enemies, ...(opts.enemyMix ? { holdMix: true } : {}) } : {});
119
+ const bots = Math.max(0, Math.min(100, Math.floor(opts.bots)));
120
+ const arena = compiled.mode === "arena";
121
+ for (let i = 1; i <= bots; i++) {
122
+ if (arena) {
123
+ const [x, z] = sim.arenaSpawn(world, i);
124
+ sim.addPlayer(world, i, [x, world.config.arena.spawn[1], z]);
125
+ }
126
+ else
127
+ sim.addPlayer(world, i, [i * 1.5 - bots * 0.75, 1, 0]);
128
+ }
129
+ const half = world.config.arena.halfExtent;
130
+ let topScore = 0;
131
+ const scorers = new Set();
132
+ const perf = globalThis.performance;
133
+ const now = () => (perf ? perf.now() : Date.now());
134
+ const times = [];
135
+ let deaths = 0;
136
+ let kills = 0;
137
+ let earnEmitted = 0;
138
+ const earned = {};
139
+ let wave = 0;
140
+ let maxLevel = 0;
141
+ let peakEnemies = 0;
142
+ const runs = { won: 0, lost: 0 };
143
+ let quiet = 0;
144
+ let softlock = false;
145
+ let nearby = [];
146
+ let gems = [];
147
+ let dangers = [];
148
+ let bossesAt = [];
149
+ const enemyTypes = world.config.content.enemyTypes;
150
+ let me = new Map();
151
+ let downedAt = [];
152
+ // How far a player's weapons reach (the human bot orbits about there).
153
+ const weaponDefs = world.config.content.weapons;
154
+ const reachOf = (packed) => {
155
+ let r = 0;
156
+ for (let s = 0; s < 4; s++) {
157
+ const wi = (((packed >>> (s * 8)) & 0x0f) - 1);
158
+ const lvl = (packed >>> (s * 8 + 4)) & 0x0f;
159
+ const d = wi >= 0 ? weaponDefs[wi] : undefined;
160
+ if (!d)
161
+ continue;
162
+ r = Math.max(r, d.kind === "nova" ? d.range * (1 + 0.08 * Math.max(0, lvl - 1)) : d.kind === "orbit" ? d.range + 0.5 : d.range * 0.7);
163
+ }
164
+ return r || 5;
165
+ };
166
+ const human = !arena && opts.bot === "human";
167
+ const humans = new Map();
168
+ if (human) {
169
+ for (let p = 1; p <= bots; p++) {
170
+ const ws = compiled.sim.content?.weapons ?? [];
171
+ const evolved = ws.flatMap((w, i) => (w.evolved ? [i] : []));
172
+ const pairs = ws.flatMap((w, i) => (w.evolve ? [[i, ws.length + w.evolve.requires]] : []));
173
+ humans.set(p, new HumanBot({ picks: opts.picks ?? "random", weaponCount: compiled.weaponIds.length, evolved, pairs, seed: (opts.seed ?? 1) * 97 + p }));
174
+ }
175
+ }
176
+ const pillars = world.config.arena.pillars;
177
+ const rate = world.tickRate;
178
+ const fun = new FunTracker(arena, rate);
179
+ const view = compiled.view;
180
+ const bossSeen = new Map();
181
+ for (let t = 0; t < opts.ticks; t++) {
182
+ if (!arena && t % (human ? 2 : 5) === 0) {
183
+ const st = sim.captureState(world);
184
+ nearby = [];
185
+ gems = [];
186
+ dangers = [];
187
+ bossesAt = [];
188
+ downedAt = [];
189
+ me = new Map();
190
+ for (const e of st.entities) {
191
+ if (human && (e.level !== 0 || e.kind === sim.EntityKind.Projectile)) {
192
+ const th = threatOf(enemyTypes, e.kind, e.flags, e.type, e.level, e.px / 1024, e.pz / 1024, e.vx / 1024, e.vz / 1024);
193
+ if (th && th.kind !== "aura")
194
+ dangers.push(th);
195
+ }
196
+ // Props (urns, braziers) are scenery: never a threat, never something to kite.
197
+ if (e.kind === sim.EntityKind.Enemy && (e.flags & sim.EntityFlag.Dead) === 0 && !enemyTypes[e.type]?.prop) {
198
+ nearby.push([e.px / 1024, e.pz / 1024]);
199
+ if (enemyTypes[e.type]?.boss)
200
+ bossesAt.push([e.px / 1024, e.pz / 1024]);
201
+ }
202
+ else if (e.kind === sim.EntityKind.Pickup)
203
+ gems.push([e.px / 1024, e.pz / 1024]);
204
+ else if (e.kind === sim.EntityKind.Player) {
205
+ const up = (e.flags & (sim.EntityFlag.Dead | sim.EntityFlag.Downed)) === 0;
206
+ me.set(e.owner, { x: e.px / 1024, z: e.pz / 1024, choices: e.choices, weapons: e.weapons, up });
207
+ if ((e.flags & sim.EntityFlag.Downed) !== 0)
208
+ downedAt.push([e.owner, e.px / 1024, e.pz / 1024]);
209
+ }
210
+ }
211
+ if (runner.runNumber === 1) {
212
+ fun.sample(runner.info().seconds, nearby, [...me.values()].filter((m) => m.up), view, insideView);
213
+ const elite = st.entities.some((e) => e.kind === sim.EntityKind.Enemy && (e.flags & (sim.EntityFlag.Elite | sim.EntityFlag.Dead)) === sim.EntityFlag.Elite);
214
+ fun.field(runner.info().seconds, elite, bossesAt.length > 0, [...me.values()].map((m) => m.up));
215
+ }
216
+ }
217
+ const inputs = new Map();
218
+ for (let p = 1; p <= bots; p++) {
219
+ if (arena) {
220
+ // Arena: playtest players play like the seat bots (the runner's autopilot).
221
+ inputs.set(p, runner.autopilot(p));
222
+ continue;
223
+ }
224
+ const m = me.get(p) ?? { x: 0, z: 0, choices: 0, weapons: 0, up: true };
225
+ const hb = humans.get(p);
226
+ if (hb) {
227
+ const onScreen = (q) => insideView(view, q[0] - m.x, q[1] - m.z);
228
+ inputs.set(p, hb.input({ ...m, half, pillars, reach: reachOf(m.weapons), bosses: bossesAt.filter(onScreen), downed: downedAt.filter((d) => d[0] !== p).map((d) => [d[1], d[2]]), enemies: nearby.filter(onScreen), gems: gems.filter(onScreen), dangers: dangers.filter((d) => onScreen([d.x, d.z])) }, t));
229
+ continue;
230
+ }
231
+ const close = nearby.filter(([x, z]) => Math.abs(x - m.x) < 6 && Math.abs(z - m.z) < 6);
232
+ inputs.set(p, botInput({ ...m, half }, close, t, p));
233
+ }
234
+ const a = now();
235
+ runner.beforeStep(inputs);
236
+ const r = sim.step(world, inputs);
237
+ const out = runner.afterStep(r.events);
238
+ times.push(now() - a);
239
+ let active = false;
240
+ const firstRun = runner.runNumber === 1 && !out.reset;
241
+ const secs = runner.info().seconds;
242
+ if (!arena)
243
+ for (const id of runner.bossIds())
244
+ if (!bossSeen.has(id))
245
+ bossSeen.set(id, t);
246
+ for (const e of r.events) {
247
+ if (e.type === "player.downed")
248
+ deaths++;
249
+ if (e.type === "enemy.killed")
250
+ kills++;
251
+ if (arena && e.type === "player.killed") {
252
+ deaths++;
253
+ if (e.by > 0 && e.by !== e.playerId) {
254
+ kills++;
255
+ scorers.add(e.by);
256
+ }
257
+ }
258
+ if (e.type === "enemy.killed" || e.type === "player.hit" || e.type === "entity.spawned")
259
+ active = true;
260
+ if (e.type === "player.levelup")
261
+ maxLevel = Math.max(maxLevel, e.level);
262
+ if (!arena && firstRun && e.type === "enemy.killed" && e.boss) {
263
+ const born = bossSeen.get(e.netId);
264
+ if (born !== undefined)
265
+ fun.bossKilled(compiled.enemyPrefabs[e.enemyType] ?? "", (t - born) / rate);
266
+ }
267
+ if (arena)
268
+ fun.arenaEvent(e, t);
269
+ else if (firstRun) {
270
+ let onScreen = false;
271
+ if (e.type === "entity.spawned" && e.kind === sim.EntityKind.Enemy && !e.from) {
272
+ const pos = sim.entityPosition(world, e.netId);
273
+ // Props (urns, lanterns) are scenery, not threats: they may sit anywhere.
274
+ const t = sim.enemyTypeOf(world, e.netId);
275
+ onScreen = pos !== undefined && !world.config.content.enemyTypes[t]?.prop && sim.isOnScreen(world, pos[0], pos[1]);
276
+ }
277
+ fun.hordeEvent(e, secs, onScreen);
278
+ }
279
+ }
280
+ if (arena)
281
+ fun.leader(runner.info().players.map((p) => [p.id, p.kills]));
282
+ else if (runner.runNumber === 1)
283
+ fun.levels(secs, runner.info().players.map((p) => p.level));
284
+ for (const ev of out.events) {
285
+ if (ev.name === "run.won")
286
+ runs.won++;
287
+ if (ev.name === "run.lost")
288
+ runs.lost++;
289
+ if ((ev.name === "run.won" || ev.name === "run.lost") && runner.runNumber === 1)
290
+ fun.runEnded(secs);
291
+ if (arena && ev.name === "run.won")
292
+ fun.roundEnded();
293
+ }
294
+ for (const aw of out.awards) {
295
+ earnEmitted += aw.units;
296
+ earned[aw.rule] = (earned[aw.rule] ?? 0) + aw.units;
297
+ }
298
+ const info = runner.info();
299
+ wave = Math.max(wave, info.wave);
300
+ peakEnemies = Math.max(peakEnemies, info.enemies);
301
+ for (const p of info.players)
302
+ topScore = Math.max(topScore, p.kills);
303
+ quiet = active ? 0 : quiet + 1;
304
+ if (quiet >= 30 * world.tickRate)
305
+ softlock = true;
306
+ }
307
+ sim.destroyWorld(world);
308
+ const sorted = [...times].sort((x, y) => x - y);
309
+ const base = {
310
+ ticks: opts.ticks,
311
+ bots,
312
+ deaths,
313
+ kills,
314
+ msPerTick: times.reduce((x, y) => x + y, 0) / Math.max(1, times.length),
315
+ p99MsPerTick: sorted[Math.floor(0.99 * Math.max(0, sorted.length - 1))] ?? 0,
316
+ earnEmitted,
317
+ softlock,
318
+ wave,
319
+ runs,
320
+ maxLevel,
321
+ peakEnemies,
322
+ topScore: arena ? topScore : 0,
323
+ scorers: scorers.size,
324
+ earned,
325
+ fun: fun.report(opts.ticks / rate, bots),
326
+ };
327
+ const failed = (opts.assert ?? []).filter((x) => !x.check({ ...base, failed: [] })).map((x) => x.name);
328
+ return { ...base, failed };
329
+ }
330
+ /** Collects FunMetrics while the harness plays. */
331
+ class FunTracker {
332
+ arena;
333
+ rate;
334
+ firstThreat = null;
335
+ firstReward = null;
336
+ levelAt = { "60": null, "180": null, "360": null, "540": null };
337
+ downsEarly = 0;
338
+ onScreen30 = null;
339
+ hitsEarly = 0;
340
+ lastSource = new Map();
341
+ lateDowns = 0;
342
+ lateTelegraphed = 0;
343
+ spawns = 0;
344
+ spawnsSeen = 0;
345
+ survival = null;
346
+ // arena
347
+ spawnTick = new Map();
348
+ firstDamageGaps = [];
349
+ damagedSinceSpawn = new Set();
350
+ arenaDeaths = 0;
351
+ spawnDeaths = 0;
352
+ kills = new Map();
353
+ lead = 0;
354
+ leadChanges = 0;
355
+ rounds = 1;
356
+ bosses = [];
357
+ firstHit = null;
358
+ lowest120 = null;
359
+ firstElite = null;
360
+ firstBoss = null;
361
+ playerSamples = 0;
362
+ downSamples = 0;
363
+ /** One sample of the first run: elites and bosses on the field, and players up or not. */
364
+ field(secs, elite, boss, upFlags) {
365
+ if (elite && this.firstElite === null)
366
+ this.firstElite = Math.round(secs);
367
+ if (boss && this.firstBoss === null)
368
+ this.firstBoss = Math.round(secs);
369
+ for (const up of upFlags) {
370
+ this.playerSamples++;
371
+ if (!up)
372
+ this.downSamples++;
373
+ }
374
+ }
375
+ downSources = {};
376
+ bossKilled(prefab, seconds) {
377
+ this.bosses.push({ prefab, seconds: Math.round(seconds) });
378
+ }
379
+ constructor(arena, rate) {
380
+ this.arena = arena;
381
+ this.rate = rate;
382
+ }
383
+ sample(secs, enemies, players, view, inside) {
384
+ if (this.firstThreat === null) {
385
+ for (const p of players) {
386
+ for (const e of enemies) {
387
+ if ((e[0] - p.x) ** 2 + (e[1] - p.z) ** 2 < 9) {
388
+ this.firstThreat = secs;
389
+ break;
390
+ }
391
+ }
392
+ if (this.firstThreat !== null)
393
+ break;
394
+ }
395
+ }
396
+ if (this.onScreen30 === null && secs >= 30) {
397
+ let most = 0;
398
+ for (const p of players) {
399
+ let n = 0;
400
+ for (const e of enemies)
401
+ if (inside(view, e[0] - p.x, e[1] - p.z))
402
+ n++;
403
+ most = Math.max(most, n);
404
+ }
405
+ this.onScreen30 = most;
406
+ }
407
+ }
408
+ hordeEvent(e, secs, spawnedOnScreen) {
409
+ if (e.type === "player.levelup" && this.firstReward === null)
410
+ this.firstReward = secs;
411
+ else if (e.type === "player.hit") {
412
+ if (this.firstHit === null)
413
+ this.firstHit = Math.round(secs * 10) / 10;
414
+ if (secs < 120)
415
+ this.lowest120 = Math.min(this.lowest120 ?? Infinity, e.health);
416
+ const source = e.source ?? "contact";
417
+ this.lastSource.set(e.playerId, source);
418
+ if (source === "contact" && secs < 60)
419
+ this.hitsEarly++;
420
+ }
421
+ else if (e.type === "player.downed") {
422
+ const src = this.lastSource.get(e.playerId) ?? "contact";
423
+ this.downSources[src] = (this.downSources[src] ?? 0) + 1;
424
+ if (secs < 60)
425
+ this.downsEarly++;
426
+ if (secs >= 180) {
427
+ this.lateDowns++;
428
+ if ((this.lastSource.get(e.playerId) ?? "contact") !== "contact")
429
+ this.lateTelegraphed++;
430
+ }
431
+ }
432
+ else if (e.type === "entity.spawned" && e.kind === 2 && !e.from) {
433
+ this.spawns++;
434
+ if (spawnedOnScreen)
435
+ this.spawnsSeen++;
436
+ }
437
+ }
438
+ levels(secs, levels) {
439
+ const best = levels.reduce((a, b) => Math.max(a, b), 0);
440
+ for (const k of ["60", "180", "360", "540"])
441
+ if (this.levelAt[k] === null && secs >= Number(k))
442
+ this.levelAt[k] = best;
443
+ }
444
+ runEnded(secs) {
445
+ if (this.survival === null)
446
+ this.survival = secs;
447
+ }
448
+ arenaEvent(e, tick) {
449
+ if (e.type === "player.joined" || e.type === "player.respawned") {
450
+ this.spawnTick.set(e.playerId, tick);
451
+ this.damagedSinceSpawn.delete(e.playerId);
452
+ }
453
+ else if (e.type === "player.hit") {
454
+ if (this.firstThreat === null)
455
+ this.firstThreat = tick / this.rate;
456
+ if (!this.damagedSinceSpawn.has(e.playerId)) {
457
+ this.damagedSinceSpawn.add(e.playerId);
458
+ this.firstDamageGaps.push((tick - (this.spawnTick.get(e.playerId) ?? 0)) / this.rate);
459
+ }
460
+ }
461
+ else if (e.type === "player.killed") {
462
+ this.arenaDeaths++;
463
+ if (tick - (this.spawnTick.get(e.playerId) ?? 0) <= 3 * this.rate)
464
+ this.spawnDeaths++;
465
+ if (e.by > 0 && e.by !== e.playerId) {
466
+ if (this.firstReward === null)
467
+ this.firstReward = tick / this.rate;
468
+ this.kills.set(e.by, (this.kills.get(e.by) ?? 0) + 1);
469
+ }
470
+ }
471
+ }
472
+ leader(scores) {
473
+ let best = 0;
474
+ let who = 0;
475
+ let tie = false;
476
+ for (const [id, k] of scores) {
477
+ if (k > best) {
478
+ best = k;
479
+ who = id;
480
+ tie = false;
481
+ }
482
+ else if (k === best && k > 0)
483
+ tie = true;
484
+ }
485
+ if (best > 0 && !tie && who !== this.lead) {
486
+ if (this.lead !== 0)
487
+ this.leadChanges++;
488
+ this.lead = who;
489
+ }
490
+ }
491
+ roundEnded() {
492
+ this.rounds++;
493
+ this.lead = 0;
494
+ }
495
+ report(seconds, players) {
496
+ const r2 = (x) => Math.round(x * 100) / 100;
497
+ if (this.arena) {
498
+ const gaps = [...this.firstDamageGaps].sort((a, b) => a - b);
499
+ const ks = [...this.kills.values()];
500
+ const mean = ks.reduce((a, b) => a + b, 0) / Math.max(1, players);
501
+ return {
502
+ genre: "arena",
503
+ firstThreatSeconds: this.firstThreat,
504
+ firstRewardSeconds: this.firstReward,
505
+ levelAt: { "60": null, "180": null, "360": null, "540": null },
506
+ deathsBeforeMinute: null,
507
+ onScreenAt30: null,
508
+ contactHitsFirstMinute: null,
509
+ telegraphedDeathShare: null,
510
+ spawnsOnScreen: null,
511
+ survivalSeconds: null,
512
+ spawnToDamageSeconds: gaps.length ? r2(gaps[Math.floor(gaps.length / 2)] ?? 0) : null,
513
+ spawnDeathShare: this.arenaDeaths ? r2(this.spawnDeaths / this.arenaDeaths) : null,
514
+ killSpread: mean > 0 ? r2(Math.max(...ks) / mean) : null,
515
+ leadChanges: r2(this.leadChanges / Math.max(1, this.rounds)),
516
+ bossKills: [],
517
+ downsBySource: {},
518
+ firstHitSeconds: null,
519
+ lowestHealthBy120: null,
520
+ firstEliteSeconds: null,
521
+ firstBossSeconds: null,
522
+ downTimeShare: null,
523
+ };
524
+ }
525
+ return {
526
+ genre: "horde",
527
+ firstThreatSeconds: this.firstThreat,
528
+ firstRewardSeconds: this.firstReward,
529
+ levelAt: { ...this.levelAt },
530
+ deathsBeforeMinute: this.downsEarly,
531
+ onScreenAt30: this.onScreen30,
532
+ contactHitsFirstMinute: r2(this.hitsEarly / Math.max(1, players)),
533
+ telegraphedDeathShare: this.lateDowns ? r2(this.lateTelegraphed / this.lateDowns) : null,
534
+ spawnsOnScreen: this.spawns ? r2(this.spawnsSeen / this.spawns) : 0,
535
+ survivalSeconds: this.survival ?? Math.floor(seconds),
536
+ spawnToDamageSeconds: null,
537
+ spawnDeathShare: null,
538
+ killSpread: null,
539
+ leadChanges: null,
540
+ bossKills: [...this.bosses],
541
+ downsBySource: { ...this.downSources },
542
+ firstHitSeconds: this.firstHit,
543
+ lowestHealthBy120: this.lowest120,
544
+ firstEliteSeconds: this.firstElite,
545
+ firstBossSeconds: this.firstBoss,
546
+ downTimeShare: this.playerSamples ? r2(this.downSamples / this.playerSamples) : null,
547
+ };
548
+ }
549
+ }
@@ -0,0 +1,71 @@
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 type { GameManifest } from "./registry.js";
18
+ /** The shortest shift (seconds): earn rules pay once a shift, so this bounds what one seat can earn an hour. */
19
+ export declare const IDLE_MIN_SHIFT = 600;
20
+ /** The longest tending window (seconds): a works nobody touches for this long stops. */
21
+ export declare const IDLE_MAX_TEND = 300;
22
+ export declare const saltFlatSchema: z.ZodObject<{
23
+ shift: z.ZodNumber;
24
+ tend: z.ZodDefault<z.ZodNumber>;
25
+ start: z.ZodDefault<z.ZodNumber>;
26
+ rake: z.ZodDefault<z.ZodObject<{
27
+ salt: z.ZodNumber;
28
+ cooldown: z.ZodNumber;
29
+ }, z.core.$strict>>;
30
+ brinePerSalt: z.ZodDefault<z.ZodNumber>;
31
+ saltPerCrystal: z.ZodDefault<z.ZodNumber>;
32
+ packEvery: z.ZodDefault<z.ZodNumber>;
33
+ contract: z.ZodDefault<z.ZodObject<{
34
+ crystals: z.ZodNumber;
35
+ }, z.core.$strict>>;
36
+ }, z.core.$strict>;
37
+ export declare const PLANT_KINDS: readonly ["pump", "pan", "kiln"];
38
+ /**
39
+ * A plant a player buys and levels with salt. kind "pump" makes `rate`
40
+ * litres of brine a second a level; "pan" boils up to `rate` kg of salt a
41
+ * second a level (from brine); "kiln" cuts up to `rate` crystals a second a
42
+ * level (from salt). The first level costs `cost` kg of salt, each next one
43
+ * `growth` times the last.
44
+ */
45
+ export declare const plantSchema: z.ZodObject<{
46
+ model: z.ZodString;
47
+ kind: z.ZodEnum<{
48
+ pump: "pump";
49
+ pan: "pan";
50
+ kiln: "kiln";
51
+ }>;
52
+ cost: z.ZodNumber;
53
+ growth: z.ZodDefault<z.ZodNumber>;
54
+ rate: z.ZodNumber;
55
+ max: z.ZodDefault<z.ZodNumber>;
56
+ }, z.core.$strict>;
57
+ export type SaltFlatInput = z.input<typeof saltFlatSchema>;
58
+ export type SaltFlatDef = z.output<typeof saltFlatSchema>;
59
+ export type PlantInput = z.input<typeof plantSchema>;
60
+ export type PlantDef = z.output<typeof plantSchema>;
61
+ /** The flat: shift length, tending, raking, the chain's ratios and the shared contract (one per game). */
62
+ export declare function saltFlat(def: SaltFlatInput): SaltFlatDef;
63
+ /** A plant players buy and level (at most 8). */
64
+ export declare function plant(id: string, def: PlantInput): PlantDef;
65
+ /** Store upgrade stats an idle game reads: plant output (might), raking speed (haste), salt a rake (amount). */
66
+ export declare const IDLE_STATS: readonly ["might", "haste", "amount"];
67
+ /** The idle content of a manifest, cross-checked (one line per issue). */
68
+ export declare function idleContent(m: GameManifest): {
69
+ flat: SaltFlatDef;
70
+ plants: [string, PlantDef][];
71
+ };