@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,859 @@
1
+ import { z } from "zod";
2
+ // Asset library: model appearances (see ./appearance.ts).
3
+ import { appearanceSchema } from "./appearance.js";
4
+ /**
5
+ * Zod schemas for everything a game declares ("Everything
6
+ * is typed with Zod schemas, so a bad prefab fails at build with a message
7
+ * the agent can act on"). Messages are written for the agent that reads them.
8
+ */
9
+ /** Templates carry the genre machinery; the model adds content and rules. */
10
+ export const TEMPLATES = ["survivor", "arena", "dungeon", "racer", "platformer", "tower-defense", "party", "idle", "cards", "collector"];
11
+ /** Art styles of the asset library (§10). */
12
+ export const STYLES = ["pixel", "lowpoly", "toon", "voxel", "ps1", "handdrawn"];
13
+ const ID = /^[a-z][a-z0-9_]{0,47}$/;
14
+ /** Library asset ids look like `style.lowpoly.mech_01`. Never a URL (§10). */
15
+ const ASSET_ID = /^style\.[a-z0-9]+\.[a-z0-9_]+$/;
16
+ export const idSchema = z
17
+ .string()
18
+ .regex(ID, "ids are lower_snake_case: start with a letter, then a-z, 0-9 or _, at most 48 characters");
19
+ export const assetIdSchema = z
20
+ .string()
21
+ .refine((s) => !/^[a-z]+:\/\//i.test(s) && !s.startsWith("/") && !s.startsWith("data:"), {
22
+ message: "reference assets by library id (e.g. \"style.lowpoly.bat\"), never by URL or path",
23
+ })
24
+ .refine((s) => ASSET_ID.test(s), {
25
+ message: "asset ids look like \"style.<style>.<name>\", e.g. \"style.lowpoly.mech_01\"",
26
+ });
27
+ export const vec3Schema = z.tuple([z.number().finite(), z.number().finite(), z.number().finite()]);
28
+ /** A colour as "#rrggbb". */
29
+ const colorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'colours are "#rrggbb", e.g. "#ffb347"');
30
+ /** Lighting presets of the engine's render stack (sun, sky, fog and grading together). */
31
+ export const LOOK_PRESETS = ["night", "dusk", "day", "studio"];
32
+ /** GPU particle presets the engine draws (one draw call for all of them). */
33
+ export const PARTICLE_PRESETS = ["sparks", "burst", "embers", "smoke", "hitSpark", "dust", "magic"];
34
+ /** Ground decals left behind (scorch marks, splats, shock rings). */
35
+ export const DECAL_PRESETS = ["none", "scorch", "splat", "ring"];
36
+ /** Procedural sound cues of the feel kit (no audio files needed). */
37
+ export const SOUND_CUES = ["none", "hit", "crunch", "boom", "zap", "chime", "thud"];
38
+ /**
39
+ * How the game looks (the engine's render stack):
40
+ * one preset sets lighting, shadows, sky, fog and colour grading; the
41
+ * other fields fine-tune it. Quality tiers still scale everything down on
42
+ * slow devices, so turning things on here never breaks the frame-rate bar.
43
+ */
44
+ export const lookSchema = z
45
+ .object({
46
+ preset: z.enum(LOOK_PRESETS).default("night"),
47
+ /** Bloom strength on bright and emissive things (0 off, 1 default, 3 max). */
48
+ bloom: z.number().min(0).max(3).optional(),
49
+ /** Sun shadows (on by default above the low quality tier). */
50
+ shadows: z.boolean().optional(),
51
+ /** Toon outline pass around shapes. */
52
+ outline: z.boolean().optional(),
53
+ /** Fog density, 0 (none) to 1 (thick). */
54
+ fog: z.number().min(0).max(1).optional(),
55
+ /** Exposure multiplier (1 is the preset's own). */
56
+ exposure: z.number().min(0.2).max(3).optional(),
57
+ /** Saturation and contrast, 1 neutral. */
58
+ saturation: z.number().min(0).max(2).optional(),
59
+ contrast: z.number().min(0.5).max(1.5).optional(),
60
+ /** Darkened screen edges, 0 to 1. */
61
+ vignette: z.number().min(0).max(1).optional(),
62
+ /** Colour tint over the whole frame (grading), e.g. "#ffd9a0" for warm. */
63
+ tint: colorSchema.optional(),
64
+ })
65
+ .strict();
66
+ /**
67
+ * Game feel for a prefab or a weapon. On a prefab it
68
+ * is what its death and its hits look and sound like; on a weapon, its
69
+ * impacts. Everything is client-side presentation: it never changes the
70
+ * simulation, so it is safe to tune freely.
71
+ */
72
+ export const feelSchema = z
73
+ .object({
74
+ /** Screen shake added (trauma, 0 to 1; 0.2 is a bump, 0.6 a big hit). */
75
+ shake: z.number().min(0).max(1).optional(),
76
+ /** Hit-stop in seconds (freeze the picture briefly on impact; 0.03 to 0.08 feels good, 0.2 max). */
77
+ hitStop: z.number().min(0).max(0.2, "hitStop is in seconds; 0.2 is the cap").optional(),
78
+ /** Particle burst preset. */
79
+ particles: z.enum(PARTICLE_PRESETS).optional(),
80
+ /** Particle and flash colour. */
81
+ color: colorSchema.optional(),
82
+ /** Decal left on the ground. */
83
+ decal: z.enum(DECAL_PRESETS).optional(),
84
+ /** Sound cue. */
85
+ sound: z.enum(SOUND_CUES).optional(),
86
+ /** White hit flash on damage (default true). */
87
+ flash: z.boolean().optional(),
88
+ })
89
+ .strict();
90
+ /** An axis-aligned box on the ground: [x, z, halfX, halfZ] in metres. */
91
+ const boxSchema = z.tuple([z.number().finite(), z.number().finite(), z.number().min(0.25).max(120), z.number().min(0.25).max(120)]);
92
+ export const placeSchema = z
93
+ .object({
94
+ /**
95
+ * Folder of streamed chunks, relative to the game repo (§8). Reserved:
96
+ * no template streams chunks yet, and the survivor template's arena is
97
+ * built in, so this path is validated but not loaded.
98
+ */
99
+ chunks: z.string().min(1).refine((s) => !s.includes(".."), "chunk paths stay inside the game repo"),
100
+ spawn: vec3Schema,
101
+ })
102
+ .strict();
103
+ const xySchema = z.tuple([z.number().min(-100).max(900), z.number().min(-60).max(220)]);
104
+ const positive = (what, max) => z.number().positive(`${what} must be above 0`).max(max, `${what} is at most ${max} m`);
105
+ /**
106
+ * Platformer: one course, side view, in metres. x runs right (start to
107
+ * flag), y runs up. Boxes are [x, y, width, height] with [x, y] the
108
+ * bottom-left corner; ledges are [x, y, width] one-way platforms (stand on
109
+ * top, jump up through, Down + Jump drops through).
110
+ */
111
+ export const platformLevelSchema = z
112
+ .object({
113
+ /** Shown on the HUD when the round starts ("Mossy Steps"). */
114
+ name: z.string().min(1).max(32).optional(),
115
+ /** Where everyone starts (feet). */
116
+ start: xySchema,
117
+ /** The flag pole's foot: touching it finishes. */
118
+ flag: xySchema,
119
+ /** Below this height a body has fallen (default: 8 m below the lowest block). */
120
+ killY: z.number().min(-80).max(200).optional(),
121
+ blocks: z
122
+ .array(z.tuple([z.number().min(-100).max(900), z.number().min(-60).max(220), positive("block width", 200), positive("block height", 120)]))
123
+ .min(1, "a level needs at least one block to stand on")
124
+ .max(400, "at most 400 blocks a level"),
125
+ ledges: z.array(z.tuple([z.number().min(-100).max(900), z.number().min(-60).max(220), positive("ledge width", 60)])).max(200).default([]),
126
+ /** Spikes, saws, lava: touching one is a fall back to the last checkpoint. */
127
+ hazards: z.array(z.tuple([z.number().min(-100).max(900), z.number().min(-60).max(220), positive("hazard width", 60), positive("hazard height", 20)])).max(200).default([]),
128
+ /** [x, y, power]: landing on one launches you up at `power` m/s (a jump is about 18; 24 is a big launch). */
129
+ springs: z.array(z.tuple([z.number().min(-100).max(900), z.number().min(-60).max(220), z.number().min(8).max(40)])).max(64).default([]),
130
+ /** Moving one-way platforms: `at` [x, y, width], swinging by `to` [dx, dy] and back every `seconds`. */
131
+ movers: z
132
+ .array(z
133
+ .object({
134
+ at: z.tuple([z.number().min(-100).max(900), z.number().min(-60).max(220), positive("mover width", 20)]),
135
+ to: z.tuple([z.number().min(-40).max(40), z.number().min(-40).max(40)]),
136
+ seconds: z.number().min(1).max(30),
137
+ /** Where in its swing it starts, 0..1. */
138
+ phase: z.number().min(0).max(1).default(0),
139
+ })
140
+ .strict())
141
+ .max(32)
142
+ .default([]),
143
+ /** Coin centres [x, y]. Each player collects their own. */
144
+ coins: z.array(xySchema).max(62, "at most 62 coins a level").default([]),
145
+ /** Checkpoint flags (feet) in course order: a fall returns you to the last one you touched. */
146
+ checkpoints: z.array(xySchema).max(16).default([]),
147
+ })
148
+ .strict();
149
+ /** Platformer feel numbers (the defaults are tuned; change a few at a time). */
150
+ export const motorSchema = z
151
+ .object({
152
+ runSpeed: z.number().min(3).max(14).optional(),
153
+ jumpHeight: z.number().min(1).max(8).optional(),
154
+ timeToApex: z.number().min(0.2).max(0.7).optional(),
155
+ fallGravity: z.number().min(1).max(3).optional(),
156
+ jumpCut: z.number().min(0.2).max(1).optional(),
157
+ coyoteSeconds: z.number().min(0).max(0.25).optional(),
158
+ bufferSeconds: z.number().min(0).max(0.25).optional(),
159
+ airJumps: z.number().int().min(0).max(2).optional(),
160
+ dashSpeed: z.number().min(6).max(30).optional(),
161
+ airAccel: z.number().min(10).max(120).optional(),
162
+ groundAccel: z.number().min(10).max(160).optional(),
163
+ })
164
+ .strict();
165
+ /**
166
+ * Racer: one circuit, on the ground plane, in metres. points is the closed
167
+ * centreline in driving order (a smooth curve runs through them); boosts and
168
+ * coins are [lap fraction 0..1, lateral -1..1 of the half width].
169
+ */
170
+ export const kartTrackSchema = z
171
+ .object({
172
+ /** Shown on the HUD when the race starts ("Harbour Loop"). */
173
+ name: z.string().min(1).max(32).optional(),
174
+ points: z
175
+ .array(z.tuple([z.number().min(-2000).max(2000), z.number().min(-2000).max(2000)]))
176
+ .min(4, "a track needs at least 4 control points")
177
+ .max(64, "at most 64 control points"),
178
+ /** Asphalt width, m. */
179
+ width: z.number().min(8, "a track is at least 8 m wide").max(30),
180
+ laps: z.number().int().min(1).max(9),
181
+ /** Grass between the asphalt edge and the barrier, m (default 4). */
182
+ verge: z.number().min(1).max(15).optional(),
183
+ boosts: z.array(z.tuple([z.number().min(0).max(1), z.number().min(-1).max(1)])).max(24).default([]),
184
+ coins: z.array(z.tuple([z.number().min(0).max(1), z.number().min(-1).max(1)])).max(62, "at most 62 coins a track").default([]),
185
+ })
186
+ .strict();
187
+ /** Racer handling (the defaults are tuned; change a few at a time). */
188
+ export const kartTuningSchema = z
189
+ .object({
190
+ topSpeed: z.number().min(10).max(45).optional(),
191
+ accel: z.number().min(8).max(80).optional(),
192
+ turn: z.number().min(1).max(4).optional(),
193
+ grip: z.number().min(3).max(20).optional(),
194
+ driftGrip: z.number().min(0.5).max(6).optional(),
195
+ grass: z.number().min(0.2).max(1).optional(),
196
+ boostSpeed: z.number().min(1).max(1.8).optional(),
197
+ })
198
+ .strict();
199
+ export const gameSchema = z
200
+ .object({
201
+ template: z.enum(TEMPLATES),
202
+ style: z.enum(STYLES),
203
+ places: z.record(idSchema, placeSchema).refine((p) => Object.keys(p).length > 0, "declare at least one place"),
204
+ players: z
205
+ .object({
206
+ prefab: assetIdSchema,
207
+ max: z.number().int().min(1).max(100, "an instance holds at most 100 players (spec §7); more players means more instances"),
208
+ /** The weapon every player starts a run with (a declared weapon id). */
209
+ weapon: idSchema.optional(),
210
+ /**
211
+ * XP to go from level L to L+1 is base + growth x (L - 1)^exponent.
212
+ * Default base 5, growth 4, exponent 1. Early levels should come every
213
+ * 6-10 seconds (GAME-DESIGN.md); an exponent of 1.3-1.8 keeps late
214
+ * levels meaningful.
215
+ */
216
+ xp: z
217
+ .object({
218
+ base: z.number().min(1).max(10_000),
219
+ growth: z.number().min(0).max(10_000),
220
+ exponent: z.number().min(0.5, "xp.exponent is between 0.5 and 3").max(3, "xp.exponent is between 0.5 and 3").optional(),
221
+ })
222
+ .strict()
223
+ .optional(),
224
+ /** Card rerolls each player gets per run. Default 1. */
225
+ rerolls: z.number().int().min(0).max(9).optional(),
226
+ /** Pickup (magnet) radius in metres at level 1. Default 3.75. */
227
+ magnet: z.number().min(0.5).max(20, "magnet is in metres; 20 is the cap").optional(),
228
+ /** Seconds a player is immune to contact damage after a contact hit (hit i-frames). Default 0.5; 0 turns them off. */
229
+ hitIframes: z.number().min(0).max(3, "hitIframes is in seconds; 3 is the cap").optional(),
230
+ /**
231
+ * Boss health grows with the players present when it spawns:
232
+ * health x (1 - bossHealthPerPlayer + bossHealthPerPlayer x players). Default 0.55.
233
+ */
234
+ bossHealthPerPlayer: z.number().min(0).max(2).optional(),
235
+ /**
236
+ * Co-op XP: "nearby" (default for survivor games) gives every other
237
+ * living player within 20 m half of each gem; "all" shares it fully;
238
+ * "collector" keeps it with whoever picked it up. The object form sets
239
+ * the fraction and radius.
240
+ */
241
+ share: z
242
+ .union([
243
+ z.enum(["collector", "nearby", "all"], { error: 'share is "collector", "nearby" or "all" (or { mode, fraction, radius })' }),
244
+ z
245
+ .object({
246
+ mode: z.enum(["collector", "nearby", "all"]),
247
+ fraction: z.number().min(0).max(1).default(0.5),
248
+ radius: z.number().min(1).max(100, "radius is in metres; 100 is the cap").default(20),
249
+ })
250
+ .strict(),
251
+ ])
252
+ .optional(),
253
+ /** Critical hits on enemies: chance per hit (0 to 0.5) and damage multiplier. Default none. */
254
+ crit: z
255
+ .object({
256
+ chance: z.number().min(0).max(0.5, "crit.chance is 0 to 0.5"),
257
+ multiplier: z.number().min(1.2).max(5).default(2),
258
+ })
259
+ .strict()
260
+ .optional(),
261
+ /** Co-op: the teammate who revives someone gets a short buff. Default 5 s, damage x1.25, speed x1.15. */
262
+ reviveBuff: z
263
+ .object({
264
+ seconds: z.number().min(0).max(30),
265
+ damage: z.number().min(1).max(3).default(1.25),
266
+ speed: z.number().min(1).max(2).default(1.15),
267
+ })
268
+ .strict()
269
+ .optional(),
270
+ /**
271
+ * Survivor: what going down costs. A downed keeper bleeds for `seconds`
272
+ * and crawls at `crawl` of run speed; teammates revive by standing
273
+ * close. `selfRevives` free get-ups per run (Sprint while downed; an
274
+ * unused one stands you up when the timer runs out). Out of those, a
275
+ * keeper is out for `respawn` seconds, then comes back beside a
276
+ * teammate who is up. Defaults: 10 s, 0.35, 1, 5 s.
277
+ */
278
+ downed: z
279
+ .object({
280
+ seconds: z.number().min(3).max(60).default(10),
281
+ crawl: z.number().min(0).max(1).default(0.35),
282
+ selfRevives: z.number().int().min(0).max(5).default(1),
283
+ respawn: z.number().min(0, "respawn is in seconds; 0 keeps a keeper out until respawnDead()").max(60).default(5),
284
+ })
285
+ .strict()
286
+ .optional(),
287
+ /** Co-op: a player joining a run in progress starts this many levels below the team's average, with that many cards to pick. Default 2. */
288
+ lateJoinBehind: z.number().int().min(0).max(20).optional(),
289
+ /** Arena: the weapons every player carries, slots 1 to 4 (declared weapon ids). */
290
+ loadout: z.array(idSchema).min(1).max(4, "a loadout holds at most 4 weapons").optional(),
291
+ /** Arena: bots fill empty seats up to this many players (0 to 32). They never earn, and killing one pays no earn rule. */
292
+ bots: z.number().int().min(0).max(32).optional(),
293
+ /** Arena: seconds a killed player waits to respawn. Default 3. */
294
+ respawn: z.number().min(0.5).max(30).optional(),
295
+ /** Arena: the pickup prefab a killed player drops; its value is the health it restores. */
296
+ drop: idSchema.optional(),
297
+ // Asset library: how the player's model is dressed (see ./appearance.ts).
298
+ appearance: appearanceSchema.optional(),
299
+ })
300
+ .strict(),
301
+ tickRate: z.union([z.literal(30), z.literal(60)]).optional(),
302
+ /** Arena: the floor, cover and spawn points, in metres. The floor runs from -halfSize to +halfSize on x and z. */
303
+ arena: z
304
+ .object({
305
+ halfSize: z.number().min(10).max(80),
306
+ /** Round pillars of cover: [x, z, radius]. */
307
+ pillars: z.array(z.tuple([z.number().finite(), z.number().finite(), z.number().min(0.3).max(8)])).max(48).default([]),
308
+ /** Respawn points: [x, z]. */
309
+ spawns: z.array(z.tuple([z.number().finite(), z.number().finite()])).min(2, "declare at least 2 spawn points").max(32),
310
+ })
311
+ .strict()
312
+ .optional(),
313
+ /**
314
+ * Survivor: the field, in metres (default 60 with 8 pillars). A bigger
315
+ * field (up to 120) gives moving a value; pillars are [x, z, radius].
316
+ */
317
+ field: z
318
+ .object({
319
+ halfSize: z.number().min(20).max(120),
320
+ pillars: z.array(z.tuple([z.number().finite(), z.number().finite(), z.number().min(0.3).max(8)])).max(64).default([]),
321
+ /**
322
+ * Dungeon walls: boxes [x, z, halfX, halfZ] standing on the floor.
323
+ * Players, enemies and bolts all stop at them. Build them with
324
+ * dungeon(...) rather than by hand.
325
+ */
326
+ walls: z.array(boxSchema).max(800, "800 wall boxes is the cap; merge runs into longer boxes").optional(),
327
+ /**
328
+ * Dungeon floors: rooms and corridors as boxes [x, z, halfX, halfZ].
329
+ * With floors only they are ground: spawns land on them and enemies
330
+ * path between them (round walls, through doors).
331
+ */
332
+ floors: z.array(boxSchema).max(200).optional(),
333
+ })
334
+ .strict()
335
+ .optional(),
336
+ /** Platformer: the courses, raced one a round in this order. */
337
+ levels: z.array(platformLevelSchema).min(1).max(8, "at most 8 levels").optional(),
338
+ /** Platformer: the jump and run feel. */
339
+ motor: motorSchema.optional(),
340
+ /** Racer: the circuits, raced one a round in this order. */
341
+ tracks: z.array(kartTrackSchema).min(1).max(8, "at most 8 tracks").optional(),
342
+ /** Racer: the karts' handling. */
343
+ kart: kartTuningSchema.optional(),
344
+ /** Arena: a round ends after `seconds`, or when a player reaches `frags` kills. Platformer and racer: the round's time limit. */
345
+ round: z
346
+ .object({
347
+ seconds: z.number().min(60, "rounds are at least 60 seconds").max(600, "rounds are at most 10 minutes"),
348
+ frags: z.number().int().min(1).max(500).optional(),
349
+ })
350
+ .strict()
351
+ .optional(),
352
+ /** How the game looks: lighting preset, bloom, shadows, fog, outline, grading. */
353
+ look: lookSchema.optional(),
354
+ })
355
+ .strict();
356
+ /**
357
+ * Enemy behaviours the sim runs (roles). Every big hit is telegraphed with
358
+ * a ground marker that fills before it lands.
359
+ * - chase (default): runs at the nearest player. tank: chase, for slow heavy prefabs (give it mass).
360
+ * - idle / none: stands still.
361
+ * - runner: aims where the player will be in 0.5 s. Fast and fragile.
362
+ * - charger: inside attack.range (6 m) stops, shows a lane for 0.55 s, dashes 3.4x along it for 0.6 s, recovers 1 s.
363
+ * - ranged: keeps ~80% of attack.range (10 m), backs off, shows an aim line 0.3 s, fires a slow shot (attack.speed 7 m/s).
364
+ * - bomber: next to a player stops, arms a 1 s fuse circle (attack.radius 2.5 m), explodes for 1.4x damage; the blast hurts enemies too.
365
+ * - shielder: every 6 s shields the 6 nearest allies for 35% of their health, 8 s.
366
+ * - healer: every 3.5 s heals the 6 nearest allies 7%.
367
+ * - summoner: keeps its distance and calls attack.count (2) attack.summon adds every 7 s, at most 4 alive.
368
+ * - flee: runs from the nearest player. wander: drifts.
369
+ * Anything else is rejected at build time, never silently ignored.
370
+ */
371
+ export const AI_BEHAVIOURS = ["none", "chase", "idle", "tank", "runner", "charger", "ranged", "bomber", "shielder", "healer", "summoner", "flee", "wander"];
372
+ /** Attacks a boss can declare. Each one is telegraphed. */
373
+ export const BOSS_ATTACKS = ["slam", "dash", "leap", "rain", "ring", "fan", "summon"];
374
+ /** Numbers for an enemy's role attack (all optional; the role's defaults fill the rest). Seconds and metres. */
375
+ export const attackSchema = z
376
+ .object({
377
+ damage: z.number().min(0).max(1_000_000).optional(),
378
+ range: z.number().min(0.5).max(40).optional(),
379
+ windup: z.number().min(0.2, "a telegraph shorter than 0.2 s cannot be read; use 0.3 s or more").max(5).optional(),
380
+ cooldown: z.number().min(0.2).max(60).optional(),
381
+ speed: z.number().min(0.5).max(40).optional(),
382
+ radius: z.number().min(0.3).max(12).optional(),
383
+ /** summoner: the enemy prefab it calls. */
384
+ summon: idSchema.optional(),
385
+ /** summoner: adds per call; shielder and healer: allies affected. */
386
+ count: z.number().int().min(1).max(12).optional(),
387
+ })
388
+ .strict();
389
+ /**
390
+ * A boss's patterns: it alternates near and far modes every 4-9 s and picks
391
+ * telegraphed attacks: slam (circle around it, 0.85 s), dash (11 m lane,
392
+ * 0.75 s), leap (lands on a circle at the target, 1.15 s), rain (4 circles
393
+ * near players), ring (14 shots outward), fan (7 shots at the target),
394
+ * summon (adds). Below enrageAt of its health it moves 15% faster, winds up
395
+ * 20% quicker and throws more. A "mini" boss raises "miniboss.killed" when
396
+ * it dies; a "final" boss (default) raises "boss.killed".
397
+ */
398
+ export const bossSchema = z
399
+ .object({
400
+ attacks: z.array(z.enum(BOSS_ATTACKS, { error: `boss attacks are: ${BOSS_ATTACKS.join(", ")}` })).min(1).max(7),
401
+ enrageAt: z.number().min(0).max(1).default(0.5),
402
+ /** The enemy prefab "summon" calls. */
403
+ summon: idSchema.optional(),
404
+ rank: z.enum(["final", "mini"]).default("final"),
405
+ })
406
+ .strict();
407
+ export const prefabSchema = z
408
+ .object({
409
+ model: assetIdSchema,
410
+ health: z.number().int().min(1).max(1_000_000),
411
+ speed: z.number().min(0).max(50, "speed is in metres per second; 50 is the cap"),
412
+ /** Behaviour (role); enemies default to "chase". See AI_BEHAVIOURS. */
413
+ ai: z.enum(AI_BEHAVIOURS, { error: `ai is one of: ${AI_BEHAVIOURS.join(", ")}. Unknown behaviours are rejected, not ignored.` }).optional(),
414
+ /** The role's attack numbers (charger, ranged, bomber, support, summoner). */
415
+ attack: attackSchema.optional(),
416
+ /** On death, `count` (1-4) enemies of prefab `into` burst out where it fell. */
417
+ split: z.object({ into: idSchema, count: z.number().int().min(1).max(4) }).strict().optional(),
418
+ radius: z.number().min(0.05).max(20).optional(),
419
+ damage: z.number().int().min(0).max(1_000_000).optional(),
420
+ /**
421
+ * What it drops: pickup ids (XP gems always drop), or { id, chance }
422
+ * for special pickups (heals, vacuums, bombs, chests) on a 0..1 chance.
423
+ */
424
+ drops: z.array(z.union([idSchema, z.object({ id: idSchema, chance: z.number().min(0).max(1) }).strict()])).max(16).default([]),
425
+ /** Pickups: what it does when collected (default: an XP gem worth `value`). */
426
+ pickup: z
427
+ .object({
428
+ effect: z.enum(["heal", "vacuum", "bomb", "chest"], { error: "pickup effects: heal, vacuum (pulls every gem), bomb (clears the screen), chest (card picks)" }),
429
+ /** heal: fraction of max health (0..1) or hit points; chest: card picks (1-3). */
430
+ amount: z.number().min(0).max(100_000).optional(),
431
+ /** chest: a boss chest. A weapon the player holds with its paired passive jumps to max level, so its evolution is on the cards. Bosses drop it instead of the plain chest. */
432
+ evolve: z.boolean().optional(),
433
+ })
434
+ .strict()
435
+ .optional(),
436
+ /** Enemies: a breakable prop (urn, lantern) that never moves or hurts; scatter it from a system. */
437
+ prop: z.boolean().optional(),
438
+ /** "enemy" (default) or "pickup". A prefab another prefab drops is a pickup unless it says otherwise. */
439
+ kind: z.enum(["enemy", "pickup"]).optional(),
440
+ /** Pickups: the XP the gem is worth (default 1). */
441
+ value: z.number().int().min(0).max(100_000).optional(),
442
+ /**
443
+ * Enemies: a boss (heavy, a HUD bar, a gem burst worth 1-2 levels).
444
+ * true: a plain final boss that chases; or { attacks, enrageAt, summon, rank } for a pattern fight.
445
+ */
446
+ boss: z
447
+ .union([z.boolean(), bossSchema], {
448
+ error: `boss is true, or { attacks: [...], enrageAt, summon, rank } with attacks from: ${BOSS_ATTACKS.join(", ")}`,
449
+ })
450
+ .optional(),
451
+ /** Enemies: knockback resistance (default 1, bosses 20). Knockback divides by it; above about 8 an enemy shrugs off light hits. */
452
+ mass: z.number().min(0.1).max(1000).optional(),
453
+ /** How its hits and its death look and sound (client-side only). */
454
+ feel: feelSchema.optional(),
455
+ // Asset library: how the model is dressed (see ./appearance.ts).
456
+ appearance: appearanceSchema.optional(),
457
+ })
458
+ .strict();
459
+ export const WEAPON_KINDS = ["nova", "bolt", "orbit", "blaster", "beam", "cone", "chain", "aura", "meteor", "boomerang"];
460
+ /** Numbers a weapon level can change. */
461
+ export const LEVEL_FIELDS = ["damage", "cooldown", "count", "pierce", "range", "speed", "knockback", "spread"];
462
+ /**
463
+ * One weapon level: what changes from the level before. A number adds
464
+ * (count: 1, pierce: 1, cooldown: -0.1 seconds), a percent string scales
465
+ * ("+40%" damage, "-20%" cooldown). Cards show these changes.
466
+ */
467
+ const levelChange = z.union([z.number().finite(), z.string().regex(/^[+-]\d+(\.\d+)?%$/, 'percent changes look like "+40%" or "-20%"')]).optional();
468
+ export const weaponLevelSchema = z
469
+ .object({
470
+ damage: levelChange,
471
+ cooldown: levelChange,
472
+ count: levelChange,
473
+ pierce: levelChange,
474
+ range: levelChange,
475
+ speed: levelChange,
476
+ knockback: levelChange,
477
+ spread: levelChange,
478
+ })
479
+ .strict()
480
+ .refine((r) => Object.values(r).some((v) => v !== undefined), `a level changes at least one of: ${LEVEL_FIELDS.join(", ")}`);
481
+ /**
482
+ * A weapon.
483
+ * Survivor template (fires by itself):
484
+ * - nova: hits every enemy within `range` m, every `cooldown` s, when one is in range;
485
+ * - bolt: shoots `count` projectiles at the nearest enemy within `range` m at `speed` m/s, each passing through `pierce` enemies;
486
+ * - orbit: `count` blades circle at `range` m, `speed` rad/s, hitting what they touch;
487
+ * - cone: sweeps `spread` degrees (360: all around) where the player faces, `range` m deep;
488
+ * - chain: strikes the nearest enemy in `range`, then jumps `count` times to the nearest enemy within `speed` m (default 4);
489
+ * - aura: a field of `range` m around the player, damaging everything inside every `cooldown` s;
490
+ * - meteor: `count` circles of `blast` m land on random enemies within `range` m, 0.6 s after they show;
491
+ * - boomerang: `count` blades fly out `range` m at `speed` m/s and come back, hitting everything both ways.
492
+ * levels: [...] declares each level's change (see weaponLevelSchema); without it each level adds 25%
493
+ * damage, cuts cooldowns 6%, adds a projectile or blade every second level and grows novas and auras 8%.
494
+ * evolve: { into, requires }: at max level, with the passive `requires` taken, the evolution card
495
+ * always shows; the weapon `into` (declared like any weapon, never offered on its own) replaces it.
496
+ * Arena template (fires while the player holds fire, along their aim):
497
+ * - blaster: `count` projectiles fanned over `spread` degrees at `speed` m/s for `range` m;
498
+ * - beam: an instant hit to `range` m, stopped by pillars (lag-compensated on the server);
499
+ * - nova: hits every opponent within `range` m.
500
+ */
501
+ export const weaponSchema = z
502
+ .object({
503
+ model: assetIdSchema.optional(),
504
+ kind: z.enum(WEAPON_KINDS),
505
+ damage: z.number().min(0).max(1_000_000),
506
+ cooldown: z.number().min(1 / 30, "cooldown is in seconds; the floor is one tick (1/30 s)").max(8),
507
+ range: z.number().min(0.5).max(40, "range is in metres; 40 is the cap"),
508
+ count: z.number().int().min(1).max(16).default(1),
509
+ speed: z.number().min(0).max(80).optional(),
510
+ pierce: z.number().int().min(0).max(255).default(0),
511
+ /** Metres per second pushed into each enemy it hits (divided by the enemy's mass). Enemies knocked hard stagger, slide, bounce off pillars and shove the enemies behind them. */
512
+ knockback: z.number().min(0).max(50).default(2),
513
+ /** Default 8, or levels.length + 1 when levels are declared. */
514
+ maxLevel: z.number().int().min(1).max(15).optional(),
515
+ /** Arena blaster: the fan of `count` projectiles; cone: the sweep (360: all around). Degrees. */
516
+ spread: z.number().min(0).max(360).optional(),
517
+ /** Meteor: blast radius, m (default 1.6). */
518
+ blast: z.number().min(0.3).max(12).optional(),
519
+ /** What each level changes, from level 2 up: [{ count: 1 }, { damage: "+40%" }, { cooldown: "-20%" }, ...]. */
520
+ levels: z.array(weaponLevelSchema).min(1).max(14).optional(),
521
+ /** Evolution: { into: weapon id, requires: upgrade id }. */
522
+ evolve: z.object({ into: idSchema, requires: idSchema }).strict().optional(),
523
+ /** How its impacts look and sound (client-side only). */
524
+ feel: feelSchema.optional(),
525
+ })
526
+ .strict();
527
+ export const STAT_KINDS = ["speed", "maxHealth", "magnet", "might", "haste", "regen", "armor", "area", "amount"];
528
+ /**
529
+ * A passive upgrade offered at level-up. Per level: speed and might
530
+ * multiply by (1 + step x level); haste cuts cooldowns by step x level;
531
+ * maxHealth and magnet (m) add step x level; regen heals step x level per
532
+ * second; armor removes step x level from each hit (floor 1); area grows
533
+ * novas, auras, cones, meteors and orbits by (1 + step x level); amount adds
534
+ * step x level projectiles, blades, jumps or meteors. Up to 8 passives.
535
+ */
536
+ export const upgradeSchema = z
537
+ .object({
538
+ stat: z.enum(STAT_KINDS),
539
+ step: z.number().min(0).max(1000),
540
+ maxLevel: z.number().int().min(1).max(15).default(5),
541
+ })
542
+ .strict();
543
+ /**
544
+ * A value over run time: a constant, or keyframes [[seconds, value], ...]
545
+ * in increasing time, linear between them and held after the last one.
546
+ * "Make it harder" is a matter of raising the values.
547
+ */
548
+ export const curveSchema = z.union([
549
+ z.number().min(0).max(100_000),
550
+ z
551
+ .array(z.tuple([z.number().min(0, "keyframe times are seconds from the run start"), z.number().min(0).max(100_000)]))
552
+ .min(1, "a curve needs at least one [seconds, value] keyframe")
553
+ .max(32, "at most 32 keyframes")
554
+ .refine((k) => k.every((p, i) => i === 0 || p[0] > (k[i - 1]?.[0] ?? -1)), "keyframe times must increase: [[0, 1], [60, 2], ...]"),
555
+ ]);
556
+ /** One enemy the director can spawn, and when. */
557
+ export const directorEntrySchema = z
558
+ .object({
559
+ /** An enemy prefab id. */
560
+ prefab: idSchema,
561
+ /** Relative pick weight (default 1). */
562
+ weight: z.number().positive().max(1000).default(1),
563
+ /** Seconds into the run it joins the pool (default 0). */
564
+ from: z.number().min(0).default(0),
565
+ /** Seconds into the run it leaves the pool (default never). */
566
+ until: z.number().min(0).optional(),
567
+ /** Spawned together from one direction: a fixed size or [min, max] (default 1). A stream of 8-12 runners reads as one threat. */
568
+ group: z.union([z.number().int().min(1).max(200), z.tuple([z.number().int().min(1).max(200), z.number().int().min(1).max(200)])]).default(1),
569
+ })
570
+ .strict();
571
+ /**
572
+ * The spawn director: one per game. It replaces "top the crowd up to N"
573
+ * loops. Every tick it spawns enemies from `pool` at `rate` per second,
574
+ * up to `alive` living enemies, just outside each player's screen (never
575
+ * on it), with new enemies' health, damage and XP scaled by the curves.
576
+ * More players raise `alive` and `rate` by `perPlayer` each; a living boss
577
+ * multiplies `rate` by `whileBoss`. At the cap, the bodies it could not
578
+ * spawn become `crowded` multipliers on the next spawns (tougher, not
579
+ * more). Stragglers far off screen are moved back to the edge ahead of a
580
+ * moving player (`recycle`).
581
+ */
582
+ export const directorSchema = z
583
+ .object({
584
+ /** Living enemies to aim for (one player). */
585
+ alive: curveSchema,
586
+ /** Spawns per second (one player). */
587
+ rate: curveSchema,
588
+ /** New enemies' health, damage and gem XP multipliers. Default 1. */
589
+ health: curveSchema.default(1),
590
+ damage: curveSchema.default(1),
591
+ xp: curveSchema.default(1),
592
+ /** Every non-boss enemy's speed multiplier over the run (0.25 to 3). Default 1. Late swarms that close in faster make kiting harder. */
593
+ speed: curveSchema.default(1),
594
+ pool: z.array(directorEntrySchema).min(1, "the director needs at least one enemy in its pool").max(32),
595
+ /** Pool entries older than `after` seconds (since their `from`) pick at weight x `weight`, so the mix keeps changing. */
596
+ fade: z.object({ after: z.number().min(1), weight: z.number().min(0).max(1) }).strict().optional(),
597
+ /** Each player past the first adds this fraction to `alive` and `rate`. Default 0.6. */
598
+ perPlayer: z.number().min(0).max(2).default(0.6),
599
+ /** Rate multiplier while any boss is alive. Default 0.5. */
600
+ whileBoss: z.number().min(0).max(1).default(0.5),
601
+ /** Multipliers a spawn gets for each body the cap held back. Default health 1.6, damage 1.25, xp 1.6. */
602
+ crowded: z
603
+ .object({ health: z.number().min(1).max(10).default(1.6), damage: z.number().min(1).max(10).default(1.25), xp: z.number().min(1).max(10).default(1.6) })
604
+ .strict()
605
+ .default({ health: 1.6, damage: 1.25, xp: 1.6 }),
606
+ /** Move stragglers far off screen back to the edge: after `seconds` beyond the view plus `margin` m. Default on, 2.5 s, 8 m. */
607
+ recycle: z
608
+ .union([z.boolean(), z.object({ seconds: z.number().min(0.5).max(30).default(2.5), margin: z.number().min(0).max(40).default(8) }).strict()])
609
+ .default(true),
610
+ /** Elites from the current pool: the first at `from` seconds, then one every `every` [min, max] seconds. */
611
+ elites: z
612
+ .object({ from: z.number().min(0), every: z.tuple([z.number().min(5), z.number().min(5)]) })
613
+ .strict()
614
+ .refine((e) => e.every[0] <= e.every[1], "elites.every is [min, max] seconds")
615
+ .optional(),
616
+ /** Share of spawns placed ahead of a moving player (0 to 1, default 0.35): pressure meets you instead of trailing you. */
617
+ ahead: z.number().min(0).max(1).default(0.35),
618
+ /** Enemies spawned at once when the run starts (default 0). */
619
+ start: z.number().int().min(0).max(100).default(0),
620
+ /** Hard cap on living enemies, whatever the curves say (default 2000). */
621
+ max: z.number().int().min(1).max(2000, "2,000 living enemies is the template's cap").default(2000),
622
+ })
623
+ .strict();
624
+ export const earnRuleSchema = z
625
+ .object({
626
+ /** A game event name, e.g. "wave.cleared". The room server is the referee (§11). */
627
+ when: z.string().regex(/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/, "events are dotted names like \"wave.cleared\""),
628
+ /** Fire when the event's count reaches n (e.g. the 10th wave). */
629
+ n: z.number().int().min(1).default(1),
630
+ /** Coins paid (1 coin = 1 game token, shown under the token's own name and symbol). */
631
+ pay: z.number().positive(),
632
+ oncePer: z.enum(["run", "day", "lifetime"]),
633
+ })
634
+ .strict();
635
+ /**
636
+ * Buy-in runs, the mandatory sink loop (the sink loop is a
637
+ * stake). Earning above free play needs a
638
+ * stake of the game's coins at run start. The referee fixes the stake when
639
+ * the run starts (server state, never the client). A staked run pays its
640
+ * free earn as usual plus a share of `stake x multiple`: the share is the
641
+ * part of the run's free earn the player actually made (skill decides it).
642
+ * What the run does not win back stays in the RewardVault for other
643
+ * players. Every game declares one, on one line in game.ts:
644
+ * `earn.stake({ min: 20, max: 200, multiple: 2 });`
645
+ */
646
+ /** PLACEHOLDER: the most a stake's payout ceiling may be, as a multiple of the stake. */
647
+ export const STAKE_MAX_MULTIPLE = 2;
648
+ /** Highest stake one run may take, in coins (the source reader's bound too). */
649
+ export const STAKE_MAX_COINS = 1_000_000;
650
+ export const earnStakeSchema = z
651
+ .object({
652
+ /** Smallest stake a run takes, in coins. */
653
+ min: z.number().positive().max(STAKE_MAX_COINS),
654
+ /** Largest stake a run takes, in coins. */
655
+ max: z.number().positive().max(STAKE_MAX_COINS),
656
+ /** The stake's payout ceiling is stake x multiple (1 to STAKE_MAX_MULTIPLE). */
657
+ multiple: z.number().min(1).max(STAKE_MAX_MULTIPLE, `the payout ceiling is at most ${STAKE_MAX_MULTIPLE}x the stake`),
658
+ })
659
+ .strict()
660
+ .refine((s) => s.min <= s.max, "stake min must not be above max")
661
+ .refine((s) => [s.min, s.max].every((v) => Math.round(v * 1e6) / 1e6 === v), "stakes have up to 6 decimals")
662
+ .refine((s) => Math.round(s.multiple * 100) / 100 === s.multiple, "multiple has up to 2 decimals");
663
+ /**
664
+ * PvP pots (the sink loop), for player-versus-
665
+ * player games (the arena template). Each player who wants in pays the
666
+ * same `entry` in coins before a round; at the round's end the pot, less
667
+ * the `recycle` share that stays in the reward pool, is split by how much
668
+ * of the round's earn each entrant made (the room's verified units, never
669
+ * a draw). A round where no entrant earned recycles the whole pot.
670
+ */
671
+ export const earnPotSchema = z
672
+ .object({
673
+ /** Coins each entrant pays in. */
674
+ entry: z.number().positive().max(STAKE_MAX_COINS),
675
+ /** Share of every pot that stays in the reward pool (0.01 to 0.5). */
676
+ recycle: z.number().min(0.01).max(0.5),
677
+ /**
678
+ * Pots are OFF by default until a legal review clears
679
+ * them. A declared pot runs only with `enabled: true`; without it the
680
+ * terms are kept for later and no room takes an entry.
681
+ */
682
+ enabled: z.boolean().default(false),
683
+ })
684
+ .strict();
685
+ /**
686
+ * What buying a SKU gives (the recommended coin model). Leave `grants` out for an in-run consumable: a
687
+ * room command applied once ("revive", or a run upgrade named after a
688
+ * declared passive). `{ cosmetic: n }` is a look the player owns for good
689
+ * (cosmetic id n, drawn by the client). `{ keeper: id }` unlocks a keeper
690
+ * for good: a character, a starting loadout or a sidegrade, never more
691
+ * power than the keepers anyone can earn. Owned grants live in the
692
+ * player's profile on the room server, never in the browser.
693
+ */
694
+ export const skuGrantSchema = z.union([
695
+ z.object({ cosmetic: z.number().int().min(1, "cosmetic 0 is the default look; number owned looks from 1").max(255) }).strict(),
696
+ z.object({ keeper: z.string().regex(/^[a-z][a-z0-9_]{0,31}$/, "a keeper id") }).strict(),
697
+ /**
698
+ * Losable gear (the sink loop): owned in the
699
+ * profile, it brings one level of the named run upgrade into every
700
+ * STAKED run, and is lost when that run makes less than `loseBelow` of
701
+ * its most earn (0 to 1). Its coins stayed in the reward pool when it
702
+ * was bought, so a lost piece needs no transfer to recycle.
703
+ */
704
+ z
705
+ .object({
706
+ gear: z.string().regex(/^[a-z][a-z0-9_]{0,47}$/, "an upgrade id"),
707
+ loseBelow: z.number().min(0).max(1),
708
+ })
709
+ .strict(),
710
+ ]);
711
+ /**
712
+ * A store item. Every game has coins (1 coin = 1 game token, shown under
713
+ * the token's own name, symbol and logo): price cosmetics, unlocks and
714
+ * in-run consumables in `coins`. Players spend earned coins first, off
715
+ * chain, then their wallet. `usdg` sells the item for dollars as well.
716
+ * `token` is the old name for `coins` and reads as coins. Permanent stat
717
+ * power is never a SKU: it is a metaUpgrade, opt-in through economy().
718
+ */
719
+ export const skuSchema = z
720
+ .object({
721
+ /** Price in coins (whole coins, 1 coin = 1 game token). */
722
+ coins: z.number().int().min(1).max(1_000_000).optional(),
723
+ /** Price in USDG. */
724
+ usdg: z.number().min(0).max(1000).optional(),
725
+ /** @deprecated Use `coins`. */
726
+ token: z.number().int().min(1).max(1_000_000).optional(),
727
+ /** What it gives for good: { cosmetic: n } or { keeper: id }. Omit for an in-run consumable. */
728
+ grants: skuGrantSchema.optional(),
729
+ })
730
+ .strict()
731
+ .refine((s) => !(s.coins !== undefined && s.token !== undefined), "use coins only (token is the old name for coins)")
732
+ .refine((s) => s.usdg !== undefined || s.coins !== undefined || s.token !== undefined, "a SKU needs a coins price, a usdg price, or both")
733
+ .transform((s) => ({
734
+ ...(s.coins !== undefined || s.token !== undefined ? { coins: (s.coins ?? s.token) } : {}),
735
+ ...(s.usdg !== undefined ? { usdg: s.usdg } : {}),
736
+ ...(s.grants !== undefined ? { grants: s.grants } : {}),
737
+ }));
738
+ /**
739
+ * The game's coin model. The
740
+ * default needs no declaration: coins buy cosmetics, unlocks (keepers,
741
+ * starting loadouts, sidegrades) and in-run consumables (a revive), and
742
+ * never permanent stat power.
743
+ * economy({ store: "cosmetics" }) cosmetics only: no unlocks, no consumables.
744
+ * economy({ metaUpgrades: true }) opt in to metaUpgrade(...): permanent stat
745
+ * upgrades for coins, under the engine's caps.
746
+ */
747
+ export const economySchema = z
748
+ .object({
749
+ /** "default": cosmetics, unlocks and consumables. "cosmetics": cosmetics only. */
750
+ store: z.enum(["default", "cosmetics"]).default("default"),
751
+ /** Permanent stat upgrades priced in coins (metaUpgrade). Off unless declared; never with store "cosmetics". */
752
+ metaUpgrades: z.boolean().default(false),
753
+ })
754
+ .strict()
755
+ .refine((e) => !(e.store === "cosmetics" && e.metaUpgrades), "a cosmetics-only store sells no meta upgrades");
756
+ /** Stats a meta upgrade may raise (each a percentage of the base). */
757
+ export const META_STATS = ["speed", "maxHealth", "magnet", "might", "haste", "area"];
758
+ /** Hard caps (engine-enforced, not a game setting): +15% to any one stat from meta upgrades, +30% across all of them. */
759
+ export const META_MAX_PER_STAT = 0.15;
760
+ export const META_MAX_TOTAL = 0.3;
761
+ /** Most levels of one meta upgrade, most meta upgrades per game, and the slowest cost growth (geometric). */
762
+ export const META_MAX_LEVELS = 5;
763
+ export const META_MAX_UPGRADES = 8;
764
+ export const META_MIN_GROWTH = 1.5;
765
+ /**
766
+ * A meta upgrade (opt-in: economy({ metaUpgrades: true })): a permanent,
767
+ * per-player stat bonus bought with coins, one level at a time. Level k
768
+ * costs round(cost x growth^(k-1)) coins and adds `step` (0.05 = +5%) to
769
+ * `stat`. speed, might and area multiply; haste cuts cooldowns; maxHealth
770
+ * and magnet add that share of the base. The engine caps the sum per stat
771
+ * at +15% and across stats at +30%, and the store shows each level's
772
+ * bonus and price.
773
+ */
774
+ export const metaUpgradeSchema = z
775
+ .object({
776
+ stat: z.enum(META_STATS),
777
+ /** Bonus per level as a fraction: 0.05 is +5%. */
778
+ step: z.number().positive().max(META_MAX_PER_STAT, "one level adds at most +15%"),
779
+ levels: z.number().int().min(1).max(META_MAX_LEVELS, `at most ${META_MAX_LEVELS} levels`),
780
+ /** Coins for the first level. */
781
+ cost: z.number().int().min(1).max(1_000_000),
782
+ /** Each level costs this many times the last (geometric, at least 1.5). */
783
+ growth: z.number().min(META_MIN_GROWTH, `meta costs grow geometrically: growth at least ${META_MIN_GROWTH}`).max(4).default(2),
784
+ })
785
+ .strict()
786
+ .refine((m) => m.step * m.levels <= META_MAX_PER_STAT + 1e-9, "step x levels must stay within +15% (META_MAX_PER_STAT)");
787
+ /**
788
+ * A keeper (a playable character): its start weapon and one signature
789
+ * passive at level 1. `unlock` names an achievement that unlocks it (no
790
+ * unlock and not sold: available from the start). A keeper declared with
791
+ * no unlock can instead be sold for coins by a store SKU with
792
+ * { grants: { keeper } }; it is earned or bought, never both. The first
793
+ * keeper is everyone's default and is always free.
794
+ */
795
+ export const keeperSchema = z
796
+ .object({
797
+ name: z.string().min(1).max(24),
798
+ /** One line under the name on the picker. */
799
+ text: z.string().max(80).optional(),
800
+ /** The keeper's model (defaults to players.prefab) and tint. */
801
+ model: assetIdSchema.optional(),
802
+ tint: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'colours are "#rrggbb", e.g. "#ffd23f"').optional(),
803
+ weapon: idSchema,
804
+ passive: idSchema.optional(),
805
+ unlock: idSchema.optional(),
806
+ })
807
+ .strict();
808
+ const eventName = z.string().regex(/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/, 'events are dotted names like "player.evolved"');
809
+ /**
810
+ * An achievement: a player reaches it when their count of `when` reaches
811
+ * `n` in one run ("evolve a weapon": { when: "player.evolved" }). It pays
812
+ * nothing; keepers can unlock with it.
813
+ */
814
+ export const achievementSchema = z
815
+ .object({
816
+ title: z.string().min(1).max(32),
817
+ text: z.string().max(80).optional(),
818
+ when: eventName,
819
+ n: z.number().int().min(1).max(100_000).default(1),
820
+ })
821
+ .strict();
822
+ /**
823
+ * A daily goal ("kill an elite", "survive a Tide"): the HUD shows a few
824
+ * each day, chosen from those declared, with each player's count of `when`
825
+ * in the run. It pays nothing (coin rewards are earn rules).
826
+ */
827
+ export const missionSchema = z
828
+ .object({
829
+ text: z.string().min(1).max(60),
830
+ when: eventName,
831
+ n: z.number().int().min(1).max(100_000).default(1),
832
+ })
833
+ .strict();
834
+ /** What end-screen titles rank players by. */
835
+ export const TITLE_STATS = ["kills", "damage", "revives", "downs", "level", "pickups"];
836
+ /** An end-screen title: who had the most (or the least) of a stat this run ("The Butcher": { most: "kills" }). */
837
+ export const titleSchema = z
838
+ .object({
839
+ text: z.string().min(1).max(32),
840
+ most: z.enum(TITLE_STATS).optional(),
841
+ least: z.enum(TITLE_STATS).optional(),
842
+ })
843
+ .strict()
844
+ .refine((t) => (t.most === undefined) !== (t.least === undefined), "a title ranks by exactly one of most or least");
845
+ /** Thrown for any invalid declaration; `issues` are agent-readable lines. */
846
+ export class GameDefinitionError extends Error {
847
+ issues;
848
+ constructor(where, issues) {
849
+ super(`${where}:\n - ${issues.join("\n - ")}`);
850
+ this.name = "GameDefinitionError";
851
+ this.issues = issues;
852
+ }
853
+ }
854
+ export function parseOrThrow(schema, value, where) {
855
+ const r = schema.safeParse(value);
856
+ if (r.success)
857
+ return r.data;
858
+ throw new GameDefinitionError(where, r.error.issues.map((i) => `${i.path.length ? i.path.join(".") + ": " : ""}${i.message}`));
859
+ }