@netforge/engine 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/GAME-DESIGN.md +195 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +30 -0
  4. package/README.md +100 -2
  5. package/dist/api/appearance.d.ts +37 -0
  6. package/dist/api/appearance.js +41 -0
  7. package/dist/api/cards.d.ts +105 -0
  8. package/dist/api/cards.js +133 -0
  9. package/dist/api/collector.d.ts +114 -0
  10. package/dist/api/collector.js +204 -0
  11. package/dist/api/dungeon.d.ts +58 -0
  12. package/dist/api/dungeon.js +216 -0
  13. package/dist/api/harness.d.ts +149 -0
  14. package/dist/api/harness.js +549 -0
  15. package/dist/api/idle.d.ts +71 -0
  16. package/dist/api/idle.js +104 -0
  17. package/dist/api/race-harness.d.ts +3 -0
  18. package/dist/api/race-harness.js +148 -0
  19. package/dist/api/registry.d.ts +241 -0
  20. package/dist/api/registry.js +394 -0
  21. package/dist/api/scheduler.d.ts +56 -0
  22. package/dist/api/scheduler.js +160 -0
  23. package/dist/api/schemas.d.ts +958 -0
  24. package/dist/api/schemas.js +859 -0
  25. package/dist/api/tower.d.ts +127 -0
  26. package/dist/api/tower.js +183 -0
  27. package/dist/game/adapter.d.ts +69 -0
  28. package/dist/game/adapter.js +341 -0
  29. package/dist/game/arena-bot.d.ts +66 -0
  30. package/dist/game/arena-bot.js +188 -0
  31. package/dist/game/bot.d.ts +95 -0
  32. package/dist/game/bot.js +285 -0
  33. package/dist/game/compile.d.ts +97 -0
  34. package/dist/game/compile.js +476 -0
  35. package/dist/game/director.d.ts +42 -0
  36. package/dist/game/director.js +179 -0
  37. package/dist/game/earn-source.d.ts +122 -0
  38. package/dist/game/earn-source.js +386 -0
  39. package/dist/game/earn.d.ts +49 -0
  40. package/dist/game/earn.js +55 -0
  41. package/dist/game/index.d.ts +37 -0
  42. package/dist/game/index.js +25 -0
  43. package/dist/game/platformer.d.ts +156 -0
  44. package/dist/game/platformer.js +619 -0
  45. package/dist/game/profile.d.ts +97 -0
  46. package/dist/game/profile.js +140 -0
  47. package/dist/game/race.d.ts +177 -0
  48. package/dist/game/race.js +386 -0
  49. package/dist/game/racer.d.ts +98 -0
  50. package/dist/game/racer.js +379 -0
  51. package/dist/game/runner.d.ts +286 -0
  52. package/dist/game/runner.js +824 -0
  53. package/dist/game/threats.d.ts +23 -0
  54. package/dist/game/threats.js +49 -0
  55. package/dist/game/view.d.ts +47 -0
  56. package/dist/game/view.js +123 -0
  57. package/dist/index.d.ts +33 -0
  58. package/dist/index.js +24 -0
  59. package/dist/net/bits.d.ts +33 -0
  60. package/dist/net/bits.js +135 -0
  61. package/dist/net/client.d.ts +75 -0
  62. package/dist/net/client.js +151 -0
  63. package/dist/net/index.d.ts +23 -0
  64. package/dist/net/index.js +23 -0
  65. package/dist/net/input.d.ts +49 -0
  66. package/dist/net/input.js +112 -0
  67. package/dist/net/interpolation.d.ts +37 -0
  68. package/dist/net/interpolation.js +97 -0
  69. package/dist/net/netsim.d.ts +22 -0
  70. package/dist/net/netsim.js +52 -0
  71. package/dist/net/prediction.d.ts +31 -0
  72. package/dist/net/prediction.js +101 -0
  73. package/dist/net/profile-client.d.ts +75 -0
  74. package/dist/net/profile-client.js +173 -0
  75. package/dist/net/protocol.d.ts +215 -0
  76. package/dist/net/protocol.js +10 -0
  77. package/dist/net/room-client.d.ts +65 -0
  78. package/dist/net/room-client.js +156 -0
  79. package/dist/net/schema.d.ts +78 -0
  80. package/dist/net/schema.js +121 -0
  81. package/dist/net/sim-adapter.d.ts +134 -0
  82. package/dist/net/sim-adapter.js +22 -0
  83. package/dist/net/snapshot.d.ts +61 -0
  84. package/dist/net/snapshot.js +301 -0
  85. package/dist/rules/adapter.d.ts +53 -0
  86. package/dist/rules/adapter.js +261 -0
  87. package/dist/rules/cards.d.ts +115 -0
  88. package/dist/rules/cards.js +547 -0
  89. package/dist/rules/collector.d.ts +161 -0
  90. package/dist/rules/collector.js +732 -0
  91. package/dist/rules/genres.d.ts +13 -0
  92. package/dist/rules/genres.js +38 -0
  93. package/dist/rules/harness.d.ts +16 -0
  94. package/dist/rules/harness.js +127 -0
  95. package/dist/rules/idle.d.ts +115 -0
  96. package/dist/rules/idle.js +370 -0
  97. package/dist/rules/index.d.ts +16 -0
  98. package/dist/rules/index.js +14 -0
  99. package/dist/rules/kit.d.ts +171 -0
  100. package/dist/rules/kit.js +88 -0
  101. package/dist/rules/tower.d.ts +214 -0
  102. package/dist/rules/tower.js +655 -0
  103. package/dist/runtime/arena.d.ts +86 -0
  104. package/dist/runtime/arena.js +269 -0
  105. package/dist/runtime/assets.d.ts +26 -0
  106. package/dist/runtime/assets.js +35 -0
  107. package/dist/runtime/camera.d.ts +113 -0
  108. package/dist/runtime/camera.js +247 -0
  109. package/dist/runtime/crowd.d.ts +18 -0
  110. package/dist/runtime/crowd.js +62 -0
  111. package/dist/runtime/feel/audio.d.ts +150 -0
  112. package/dist/runtime/feel/audio.js +327 -0
  113. package/dist/runtime/feel/clock.d.ts +52 -0
  114. package/dist/runtime/feel/clock.js +80 -0
  115. package/dist/runtime/feel/flash.d.ts +28 -0
  116. package/dist/runtime/feel/flash.js +60 -0
  117. package/dist/runtime/feel/index.d.ts +27 -0
  118. package/dist/runtime/feel/index.js +16 -0
  119. package/dist/runtime/feel/kit.d.ts +159 -0
  120. package/dist/runtime/feel/kit.js +226 -0
  121. package/dist/runtime/feel/knockback.d.ts +41 -0
  122. package/dist/runtime/feel/knockback.js +70 -0
  123. package/dist/runtime/feel/noise.d.ts +6 -0
  124. package/dist/runtime/feel/noise.js +30 -0
  125. package/dist/runtime/feel/numbers.d.ts +91 -0
  126. package/dist/runtime/feel/numbers.js +172 -0
  127. package/dist/runtime/feel/rumble.d.ts +40 -0
  128. package/dist/runtime/feel/rumble.js +58 -0
  129. package/dist/runtime/feel/sound.d.ts +96 -0
  130. package/dist/runtime/feel/sound.js +176 -0
  131. package/dist/runtime/feel/spring.d.ts +42 -0
  132. package/dist/runtime/feel/spring.js +95 -0
  133. package/dist/runtime/feel/trauma.d.ts +48 -0
  134. package/dist/runtime/feel/trauma.js +76 -0
  135. package/dist/runtime/feel/tween.d.ts +66 -0
  136. package/dist/runtime/feel/tween.js +180 -0
  137. package/dist/runtime/feel/types.d.ts +30 -0
  138. package/dist/runtime/feel/types.js +5 -0
  139. package/dist/runtime/index.d.ts +28 -0
  140. package/dist/runtime/index.js +26 -0
  141. package/dist/runtime/input.d.ts +38 -0
  142. package/dist/runtime/input.js +159 -0
  143. package/dist/runtime/interpolate.d.ts +6 -0
  144. package/dist/runtime/interpolate.js +13 -0
  145. package/dist/runtime/render/animation.d.ts +63 -0
  146. package/dist/runtime/render/animation.js +128 -0
  147. package/dist/runtime/render/atmosphere.d.ts +32 -0
  148. package/dist/runtime/render/atmosphere.js +59 -0
  149. package/dist/runtime/render/crowd-motion.d.ts +68 -0
  150. package/dist/runtime/render/crowd-motion.js +163 -0
  151. package/dist/runtime/render/decals.d.ts +64 -0
  152. package/dist/runtime/render/decals.js +222 -0
  153. package/dist/runtime/render/highlight.d.ts +29 -0
  154. package/dist/runtime/render/highlight.js +110 -0
  155. package/dist/runtime/render/index.d.ts +35 -0
  156. package/dist/runtime/render/index.js +22 -0
  157. package/dist/runtime/render/lighting.d.ts +50 -0
  158. package/dist/runtime/render/lighting.js +142 -0
  159. package/dist/runtime/render/particles.d.ts +117 -0
  160. package/dist/runtime/render/particles.js +289 -0
  161. package/dist/runtime/render/post.d.ts +92 -0
  162. package/dist/runtime/render/post.js +191 -0
  163. package/dist/runtime/render/presets.d.ts +57 -0
  164. package/dist/runtime/render/presets.js +116 -0
  165. package/dist/runtime/render/quality.d.ts +113 -0
  166. package/dist/runtime/render/quality.js +180 -0
  167. package/dist/runtime/render/rig.d.ts +29 -0
  168. package/dist/runtime/render/rig.js +107 -0
  169. package/dist/runtime/render/sprite.d.ts +34 -0
  170. package/dist/runtime/render/sprite.js +58 -0
  171. package/dist/runtime/render/stack.d.ts +74 -0
  172. package/dist/runtime/render/stack.js +132 -0
  173. package/dist/runtime/render/vat.d.ts +78 -0
  174. package/dist/runtime/render/vat.js +219 -0
  175. package/dist/runtime/renderer.d.ts +19 -0
  176. package/dist/runtime/renderer.js +18 -0
  177. package/dist/runtime/stats.d.ts +11 -0
  178. package/dist/runtime/stats.js +29 -0
  179. package/dist/runtime/styles/handdrawn/ground.d.ts +11 -0
  180. package/dist/runtime/styles/handdrawn/ground.js +282 -0
  181. package/dist/runtime/styles/handdrawn/index.d.ts +4 -0
  182. package/dist/runtime/styles/handdrawn/index.js +94 -0
  183. package/dist/runtime/styles/handdrawn/ink.d.ts +31 -0
  184. package/dist/runtime/styles/handdrawn/ink.js +68 -0
  185. package/dist/runtime/styles/handdrawn/looks.d.ts +18 -0
  186. package/dist/runtime/styles/handdrawn/looks.js +42 -0
  187. package/dist/runtime/styles/handdrawn/paint.d.ts +59 -0
  188. package/dist/runtime/styles/handdrawn/paint.js +129 -0
  189. package/dist/runtime/styles/handdrawn/palette.d.ts +49 -0
  190. package/dist/runtime/styles/handdrawn/palette.js +70 -0
  191. package/dist/runtime/styles/handdrawn/textures.d.ts +27 -0
  192. package/dist/runtime/styles/handdrawn/textures.js +150 -0
  193. package/dist/runtime/styles/handdrawn/vfx.d.ts +14 -0
  194. package/dist/runtime/styles/handdrawn/vfx.js +73 -0
  195. package/dist/runtime/styles/index.d.ts +9 -0
  196. package/dist/runtime/styles/index.js +32 -0
  197. package/dist/runtime/styles/kit.d.ts +193 -0
  198. package/dist/runtime/styles/kit.js +1 -0
  199. package/dist/runtime/styles/pixel/index.d.ts +46 -0
  200. package/dist/runtime/styles/pixel/index.js +167 -0
  201. package/dist/runtime/styles/ps1/index.d.ts +29 -0
  202. package/dist/runtime/styles/ps1/index.js +41 -0
  203. package/dist/runtime/styles/ps1/lights.d.ts +47 -0
  204. package/dist/runtime/styles/ps1/lights.js +146 -0
  205. package/dist/runtime/styles/ps1/materials.d.ts +62 -0
  206. package/dist/runtime/styles/ps1/materials.js +285 -0
  207. package/dist/runtime/styles/ps1/pipeline.d.ts +52 -0
  208. package/dist/runtime/styles/ps1/pipeline.js +111 -0
  209. package/dist/runtime/styles/ps1/surfaces.d.ts +44 -0
  210. package/dist/runtime/styles/ps1/surfaces.js +228 -0
  211. package/dist/runtime/styles/toon/index.d.ts +5 -0
  212. package/dist/runtime/styles/toon/index.js +178 -0
  213. package/dist/runtime/styles/toon/ink.d.ts +39 -0
  214. package/dist/runtime/styles/toon/ink.js +73 -0
  215. package/dist/runtime/styles/toon/look.d.ts +10 -0
  216. package/dist/runtime/styles/toon/look.js +112 -0
  217. package/dist/runtime/styles/toon/material.d.ts +74 -0
  218. package/dist/runtime/styles/toon/material.js +208 -0
  219. package/dist/runtime/styles/toon/normals.d.ts +26 -0
  220. package/dist/runtime/styles/toon/normals.js +127 -0
  221. package/dist/runtime/styles/toon/shadows.d.ts +24 -0
  222. package/dist/runtime/styles/toon/shadows.js +42 -0
  223. package/dist/runtime/styles/toon/vfx.d.ts +15 -0
  224. package/dist/runtime/styles/toon/vfx.js +47 -0
  225. package/dist/runtime/styles/view.d.ts +35 -0
  226. package/dist/runtime/styles/view.js +34 -0
  227. package/dist/runtime/styles/voxel/core.d.ts +114 -0
  228. package/dist/runtime/styles/voxel/core.js +577 -0
  229. package/dist/runtime/styles/voxel/geometry.d.ts +27 -0
  230. package/dist/runtime/styles/voxel/geometry.js +40 -0
  231. package/dist/runtime/styles/voxel/index.d.ts +21 -0
  232. package/dist/runtime/styles/voxel/index.js +23 -0
  233. package/dist/runtime/styles/voxel/kit.d.ts +6 -0
  234. package/dist/runtime/styles/voxel/kit.js +11 -0
  235. package/dist/runtime/styles/voxel/look.d.ts +10 -0
  236. package/dist/runtime/styles/voxel/look.js +16 -0
  237. package/dist/runtime/styles/voxel/props.d.ts +9 -0
  238. package/dist/runtime/styles/voxel/props.js +52 -0
  239. package/dist/runtime/styles/voxel/shapes.d.ts +41 -0
  240. package/dist/runtime/styles/voxel/shapes.js +83 -0
  241. package/dist/runtime/styles/voxel/world.d.ts +49 -0
  242. package/dist/runtime/styles/voxel/world.js +422 -0
  243. package/llms-full.txt +460 -0
  244. package/llms.txt +183 -0
  245. package/package.json +104 -3
@@ -0,0 +1,655 @@
1
+ import { towerContent } from "../api/tower.js";
2
+ import { entity, RulesKind, UNITS } from "./kit.js";
3
+ /** Fixed-point units per tile. */
4
+ export const Q = 256;
5
+ /** Metres per tile. */
6
+ export const TILE_M = 2;
7
+ /** Actions (RulesInput.act). */
8
+ export const TowerAct = { Build: 1, Upgrade: 2, Sell: 3, Ready: 4 };
9
+ /** Creep flags on the wire. */
10
+ export const CreepFlag = { Slowed: 1, Boss: 2, Dead: 4, Leaked: 8 };
11
+ /** Tile key. */
12
+ export const tileKey = (x, y) => y * 64 + x;
13
+ /** A tile's centre in metres, board centred on the origin. */
14
+ export function tileCenter(b, tx, ty) {
15
+ return { x: (tx + 0.5 - b.w / 2) * TILE_M, z: (ty + 0.5 - b.h / 2) * TILE_M };
16
+ }
17
+ /** The tile under a point in metres, or null off the board. */
18
+ export function tileAt(b, x, z) {
19
+ const tx = Math.floor(x / TILE_M + b.w / 2);
20
+ const ty = Math.floor(z / TILE_M + b.h / 2);
21
+ return tx >= 0 && ty >= 0 && tx < b.w && ty < b.h ? { tx, ty } : null;
22
+ }
23
+ export function compileBoard(def) {
24
+ const [w, h] = def.size;
25
+ const road = new Set();
26
+ const cum = [0];
27
+ for (let i = 1; i < def.path.length; i++) {
28
+ const a = def.path[i - 1];
29
+ const b = def.path[i];
30
+ const len = Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
31
+ cum.push((cum[i - 1] ?? 0) + len * Q);
32
+ const sx = Math.sign(b[0] - a[0]);
33
+ const sy = Math.sign(b[1] - a[1]);
34
+ for (let k = 0; k <= len; k++)
35
+ road.add(tileKey(a[0] + sx * k, a[1] + sy * k));
36
+ }
37
+ if (def.path.length === 1) {
38
+ const p = def.path[0];
39
+ road.add(tileKey(p[0], p[1]));
40
+ }
41
+ const blocked = new Set(def.blocked.map(([x, y]) => tileKey(x, y)));
42
+ return { w, h, path: def.path, cum, length: cum[cum.length - 1] ?? 0, road, blocked };
43
+ }
44
+ /** Position on the road at progress `d` (Q units), in Q units with the board's corner at 0, and the heading (u16 turn). */
45
+ export function roadPoint(b, d) {
46
+ let i = 1;
47
+ while (i < b.cum.length - 1 && d > (b.cum[i] ?? 0))
48
+ i++;
49
+ const a = b.path[i - 1];
50
+ const c = b.path[i] ?? a;
51
+ const t = Math.max(0, d - (b.cum[i - 1] ?? 0));
52
+ const sx = Math.sign(c[0] - a[0]);
53
+ const sy = Math.sign(c[1] - a[1]);
54
+ const qx = a[0] * Q + Q / 2 + sx * t;
55
+ const qy = a[1] * Q + Q / 2 + sy * t;
56
+ // Facing along the road: yaw 0 looks toward -z (the engine's convention).
57
+ const yaw = sx > 0 ? 49152 : sx < 0 ? 16384 : sy > 0 ? 32768 : 0;
58
+ return { qx, qy, yaw };
59
+ }
60
+ function statsAt(def, level) {
61
+ const s = { damage: def.damage, rate: def.rate, range: def.range, radius: def.radius, slow: def.slow, slowSeconds: def.slowSeconds, chains: def.chains };
62
+ for (let i = 0; i < level && i < def.levels.length; i++) {
63
+ const l = def.levels[i];
64
+ if (!l)
65
+ continue;
66
+ if (l.damage !== undefined)
67
+ s.damage = l.damage;
68
+ if (l.rate !== undefined)
69
+ s.rate = l.rate;
70
+ if (l.range !== undefined)
71
+ s.range = l.range;
72
+ if (l.radius !== undefined)
73
+ s.radius = l.radius;
74
+ if (l.slow !== undefined)
75
+ s.slow = l.slow;
76
+ if (l.chains !== undefined)
77
+ s.chains = l.chains;
78
+ }
79
+ return s;
80
+ }
81
+ export function compileTowerGame(m) {
82
+ const c = towerContent(m);
83
+ const creepIds = [...m.prefabs].filter(([, p]) => p.kind !== "pickup").map(([id]) => id);
84
+ const creeps = creepIds.map((id) => {
85
+ const p = m.prefabs.get(id);
86
+ if (!p)
87
+ throw new Error(id);
88
+ return { id, model: p.model, health: p.health, speed: p.speed, value: p.value ?? 1, damage: p.damage ?? 1, boss: p.boss !== undefined && p.boss !== false };
89
+ });
90
+ return {
91
+ board: compileBoard(c.board),
92
+ towers: c.towers.map(([id, def]) => ({
93
+ id,
94
+ def,
95
+ levels: Array.from({ length: def.levels.length + 1 }, (_, i) => statsAt(def, i)),
96
+ upgradeCost: def.levels.map((l) => l.cost),
97
+ })),
98
+ creeps,
99
+ lives: c.board.lives,
100
+ gold: c.board.gold,
101
+ prep: c.board.prep,
102
+ breather: c.board.breather,
103
+ share: c.board.share,
104
+ waveGold: c.board.waveGold,
105
+ refund: c.board.refund,
106
+ totalWaves: c.waves.total,
107
+ healthGrowth: c.waves.health,
108
+ groups: c.waves.groups.map((g) => ({ type: creepIds.indexOf(g.prefab), from: g.from, until: g.until ?? c.waves.total, every: g.every, count: g.count, grow: g.grow, gap: g.gap, delay: g.delay })),
109
+ };
110
+ }
111
+ /** The spawns of wave `n` (1-based), by tick offset into the wave. */
112
+ export function waveSpawns(g, n, tickRate) {
113
+ const out = [];
114
+ g.groups.forEach((grp, gi) => {
115
+ if (n < grp.from || n > grp.until || (n - grp.from) % grp.every !== 0)
116
+ return;
117
+ const k = (n - grp.from) / grp.every;
118
+ const count = Math.max(1, Math.floor(grp.count + grp.grow * k));
119
+ for (let i = 0; i < count; i++)
120
+ out.push({ tick: Math.round((grp.delay + i * grp.gap) * tickRate), type: grp.type, order: gi * 10000 + i });
121
+ });
122
+ out.sort((a, b) => a.tick - b.tick || a.order - b.order);
123
+ return out.map(({ tick, type }) => ({ tick, type }));
124
+ }
125
+ /** What the next wave brings, by creep type (the HUD's preview). */
126
+ export function wavePreview(g, n) {
127
+ const counts = new Map();
128
+ for (const s of waveSpawns(g, n, 1))
129
+ counts.set(s.type, (counts.get(s.type) ?? 0) + 1);
130
+ return [...counts].map(([type, count]) => ({ type, count }));
131
+ }
132
+ const TICK = 30;
133
+ /** Alive and on the road: not a corpse, not leaked. */
134
+ const alive = (c) => c.gone === 0 && c.hp > 0 && (c.flags & CreepFlag.Leaked) === 0;
135
+ /** Road tiles within reach of each tile, per board and range (the bots' placement score), cached. */
136
+ const coverageCache = new WeakMap();
137
+ export function roadCoverage(b, rangeQ) {
138
+ let byRange = coverageCache.get(b);
139
+ if (!byRange)
140
+ coverageCache.set(b, (byRange = new Map()));
141
+ let c = byRange.get(rangeQ);
142
+ if (c)
143
+ return c;
144
+ c = new Int32Array(64 * 64);
145
+ const r2 = rangeQ * rangeQ;
146
+ const roadTiles = [...b.road].map((k) => [k % 64, Math.floor(k / 64)]);
147
+ for (let y = 0; y < b.h; y++)
148
+ for (let x = 0; x < b.w; x++) {
149
+ let n = 0;
150
+ for (const [rx, ry] of roadTiles) {
151
+ const dx = (rx - x) * Q;
152
+ const dy = (ry - y) * Q;
153
+ if (dx * dx + dy * dy <= r2)
154
+ n++;
155
+ }
156
+ c[tileKey(x, y)] = n;
157
+ }
158
+ byRange.set(rangeQ, c);
159
+ return c;
160
+ }
161
+ /**
162
+ * The best free tile for a tower of `type` (the most road in reach), with a
163
+ * per-player tie-break and `jitter` (0 for the sharpest choice). Null when
164
+ * no free tile reaches the road. Used by the playtest bots and the
165
+ * client's autopilot alike.
166
+ */
167
+ export function bestTowerTile(g, occupied, type, salt, jitter = 0) {
168
+ const b = g.board;
169
+ const tt = g.towers[type];
170
+ if (!tt)
171
+ return null;
172
+ const cov = roadCoverage(b, Math.round((tt.levels[0]?.range ?? 1) * Q));
173
+ let best = -1;
174
+ let bx = 0;
175
+ let by = 0;
176
+ for (let y = 0; y < b.h; y++)
177
+ for (let x = 0; x < b.w; x++) {
178
+ const k = tileKey(x, y);
179
+ if (b.road.has(k) || b.blocked.has(k) || occupied.has(k))
180
+ continue;
181
+ const h = ((x * 73856093) ^ (y * 19349663) ^ (salt * 83492791)) >>> 0;
182
+ const score = (cov[k] ?? 0) * 8 + (h % (1 + jitter * 8));
183
+ if (score > best) {
184
+ best = score;
185
+ bx = x;
186
+ by = y;
187
+ }
188
+ }
189
+ return best > 8 ? { tx: bx, ty: by } : null;
190
+ }
191
+ export function towerGenre(m) {
192
+ const g = compileTowerGame(m);
193
+ const b = g.board;
194
+ const tickRate = TICK;
195
+ const halfW = (b.w * TILE_M) / 2;
196
+ const halfH = (b.h * TILE_M) / 2;
197
+ /** Avatar speed, fixed units a tick (24 m/s). */
198
+ const AVATAR_STEP = Math.round((24 * UNITS) / tickRate);
199
+ const humans = (s) => [...s.players.keys()].filter((p) => !s.ctx.isBot(p));
200
+ const buildable = (s, tx, ty) => tx >= 0 && ty >= 0 && tx < b.w && ty < b.h && !b.road.has(tileKey(tx, ty)) && !b.blocked.has(tileKey(tx, ty)) && !s.towers.some((t) => t.tx === tx && t.ty === ty);
201
+ const effective = (s, t) => {
202
+ const base = g.towers[t.type]?.levels[t.level] ?? g.towers[0].levels[0];
203
+ const might = s.ctx.bonus(t.owner, "might");
204
+ const haste = s.ctx.bonus(t.owner, "haste");
205
+ const area = s.ctx.bonus(t.owner, "area");
206
+ return { ...base, damage: base.damage * (1 + might), rate: base.rate * (1 + haste), range: base.range * (1 + area) };
207
+ };
208
+ const startWave = (s) => {
209
+ s.wave++;
210
+ s.phase = "wave";
211
+ s.queue = waveSpawns(g, s.wave, tickRate);
212
+ s.waveTick = 0;
213
+ for (const p of s.players.values())
214
+ p.ready = false;
215
+ s.activity++;
216
+ };
217
+ const kill = (s, c, owner, emit) => {
218
+ c.hp = 0;
219
+ c.gone = 8;
220
+ c.flags |= CreepFlag.Dead;
221
+ c.by = owner;
222
+ s.kills++;
223
+ s.activity++;
224
+ const pay = c.value;
225
+ const own = s.players.get(owner);
226
+ if (own) {
227
+ own.gold += pay;
228
+ own.kills++;
229
+ emit({ name: "enemy.killed", players: [owner], own: true });
230
+ }
231
+ const share = Math.floor(pay * g.share);
232
+ if (share > 0)
233
+ for (const [p, pl] of s.players)
234
+ if (p !== owner)
235
+ pl.gold += share;
236
+ if (c.boss)
237
+ emit({ name: "boss.killed", players: humans(s) });
238
+ };
239
+ const hit = (s, c, dmg, owner, emit) => {
240
+ if (!alive(c))
241
+ return;
242
+ c.hp -= Math.max(1, Math.round(dmg));
243
+ if (c.hp <= 0)
244
+ kill(s, c, owner, emit);
245
+ };
246
+ const creepQ = (c) => roadPoint(b, c.d);
247
+ const fire = (s, t, emit) => {
248
+ const st = effective(s, t);
249
+ if (t.cd > 0) {
250
+ t.cd--;
251
+ return;
252
+ }
253
+ const cx = t.tx * Q + Q / 2;
254
+ const cy = t.ty * Q + Q / 2;
255
+ const r = Math.round(st.range * Q);
256
+ const r2 = r * r;
257
+ let best = null;
258
+ for (const c of s.creeps) {
259
+ if (!alive(c))
260
+ continue;
261
+ const p = creepQ(c);
262
+ const dx = p.qx - cx;
263
+ const dy = p.qy - cy;
264
+ if (dx * dx + dy * dy > r2)
265
+ continue;
266
+ if (!best || c.d > best.d)
267
+ best = c;
268
+ }
269
+ if (!best)
270
+ return;
271
+ const kind = g.towers[t.type]?.def.kind ?? "bolt";
272
+ const p = creepQ(best);
273
+ t.yaw = Math.round((Math.atan2(-(p.qx - cx), -(p.qy - cy)) / (Math.PI * 2)) * 65536) & 0xffff;
274
+ t.cd = Math.max(1, Math.round(tickRate / Math.max(0.1, st.rate))) - 1;
275
+ t.shots = (t.shots + 1) & 0xffff;
276
+ t.target = best.id;
277
+ s.activity++;
278
+ const before = s.kills;
279
+ if (kind === "splash") {
280
+ const rr = Math.round(st.radius * Q);
281
+ for (const c of s.creeps) {
282
+ if (!alive(c))
283
+ continue;
284
+ const q = creepQ(c);
285
+ const dx = q.qx - p.qx;
286
+ const dy = q.qy - p.qy;
287
+ if (dx * dx + dy * dy <= rr * rr)
288
+ hit(s, c, st.damage, t.owner, emit);
289
+ }
290
+ }
291
+ else if (kind === "chain") {
292
+ let at = best;
293
+ const hitIds = new Set();
294
+ let dmg = st.damage;
295
+ for (let j = 0; j < st.chains && at; j++) {
296
+ hitIds.add(at.id);
297
+ hit(s, at, dmg, t.owner, emit);
298
+ dmg *= 0.7;
299
+ const from = creepQ(at);
300
+ let next = null;
301
+ let nd = (Q * 1.6) ** 2;
302
+ for (const c of s.creeps) {
303
+ if (!alive(c) || hitIds.has(c.id))
304
+ continue;
305
+ const q = creepQ(c);
306
+ const d2 = (q.qx - from.qx) ** 2 + (q.qy - from.qy) ** 2;
307
+ if (d2 < nd) {
308
+ nd = d2;
309
+ next = c;
310
+ }
311
+ }
312
+ at = next;
313
+ }
314
+ }
315
+ else {
316
+ hit(s, best, st.damage, t.owner, emit);
317
+ if (kind === "frost" && alive(best) && st.slow > 0) {
318
+ best.slow = Math.max(best.slow, st.slow);
319
+ best.slowTicks = Math.round(st.slowSeconds * tickRate);
320
+ }
321
+ }
322
+ t.kills += s.kills - before;
323
+ };
324
+ return {
325
+ template: "tower-defense",
326
+ tickRate,
327
+ endSeconds: 8,
328
+ infoEveryTicks: 3,
329
+ game: g,
330
+ create(seed, ctx) {
331
+ return {
332
+ rng: { s: seed | 0 },
333
+ ctx,
334
+ tick: 0,
335
+ wave: 0,
336
+ phase: "build",
337
+ countdown: Math.round(g.prep * tickRate),
338
+ lives: g.lives,
339
+ livesMax: g.lives,
340
+ livesBought: new Map(),
341
+ queue: [],
342
+ waveTick: 0,
343
+ creeps: [],
344
+ towers: [],
345
+ players: new Map(),
346
+ nextId: 1,
347
+ kills: 0,
348
+ leaks: 0,
349
+ peak: 0,
350
+ won: false,
351
+ activity: 0,
352
+ };
353
+ },
354
+ spawn(_s, player) {
355
+ const end = b.path[b.path.length - 1] ?? [0, 0];
356
+ const c = tileCenter(b, end[0], end[1]);
357
+ const k = (player % 8) - 4;
358
+ return { x: Math.round((c.x + k * 0.6) * UNITS), z: Math.round((c.z - 1) * UNITS) };
359
+ },
360
+ moveAvatar(a, i) {
361
+ const tx = Math.max(-halfW - 2, Math.min(halfW + 2, i.cx / 16)) * UNITS;
362
+ const tz = Math.max(-halfH - 2, Math.min(halfH + 2, i.cz / 16)) * UNITS;
363
+ if (i.cx === 0 && i.cz === 0 && i.act === 0)
364
+ return;
365
+ const dx = Math.round(tx) - a.x;
366
+ const dz = Math.round(tz) - a.z;
367
+ const d = Math.sqrt(dx * dx + dz * dz);
368
+ if (d < 1)
369
+ return;
370
+ if (d <= AVATAR_STEP) {
371
+ a.x += dx;
372
+ a.z += dz;
373
+ }
374
+ else {
375
+ a.x += Math.round((dx * AVATAR_STEP) / d);
376
+ a.z += Math.round((dz * AVATAR_STEP) / d);
377
+ }
378
+ if (d > 8)
379
+ a.yaw = Math.round((Math.atan2(-dx, -dz) / (Math.PI * 2)) * 65536) & 0xffff;
380
+ },
381
+ join(s, player) {
382
+ // A late joiner gets the gold the waves so far would have paid a builder.
383
+ const gold = g.gold + Math.max(0, s.wave - 1) * (g.waveGold.base + g.waveGold.perWave * s.wave);
384
+ s.players.set(player, { gold, kills: 0, ready: false, built: 0 });
385
+ s.activity++;
386
+ },
387
+ leave(s, player) {
388
+ s.players.delete(player);
389
+ // Their towers keep standing for the team; their kills credit nobody.
390
+ for (const t of s.towers)
391
+ if (t.owner === player)
392
+ t.owner = 0;
393
+ },
394
+ act(s, player, i, a, emit) {
395
+ const pl = s.players.get(player);
396
+ if (!pl)
397
+ return;
398
+ if (i.act === TowerAct.Ready) {
399
+ if (s.phase === "build")
400
+ pl.ready = i.a !== 0;
401
+ a.flags = pl.ready ? a.flags | 1 : a.flags & ~1;
402
+ s.activity++;
403
+ return;
404
+ }
405
+ const tx = i.b;
406
+ const ty = i.c;
407
+ if (i.act === TowerAct.Build) {
408
+ const tt = g.towers[i.a];
409
+ if (!tt || !buildable(s, tx, ty) || pl.gold < tt.def.cost)
410
+ return;
411
+ pl.gold -= tt.def.cost;
412
+ pl.built++;
413
+ s.towers.push({ id: s.nextId++, type: i.a, owner: player, tx, ty, level: 0, cd: 0, spent: tt.def.cost, shots: 0, target: 0, yaw: 0, kills: 0 });
414
+ s.activity++;
415
+ emit({ name: "tower.built", players: [player], own: true });
416
+ return;
417
+ }
418
+ const t = s.towers.find((x) => x.tx === tx && x.ty === ty);
419
+ if (!t)
420
+ return;
421
+ if (i.act === TowerAct.Upgrade) {
422
+ const cost = g.towers[t.type]?.upgradeCost[t.level];
423
+ if (cost === undefined || pl.gold < cost)
424
+ return;
425
+ pl.gold -= cost;
426
+ t.level++;
427
+ if (t.owner === player)
428
+ t.spent += cost;
429
+ s.activity++;
430
+ emit({ name: "tower.upgraded", players: [player], own: true });
431
+ return;
432
+ }
433
+ if (i.act === TowerAct.Sell) {
434
+ if (t.owner !== player)
435
+ return;
436
+ pl.gold += Math.floor(t.spent * g.refund);
437
+ s.towers = s.towers.filter((x) => x !== t);
438
+ s.activity++;
439
+ }
440
+ },
441
+ step(s, emit) {
442
+ s.tick++;
443
+ // Team lives bought in the store (maxHealth upgrades): each player's bonus counts once.
444
+ for (const p of s.players.keys()) {
445
+ const want = Math.floor(s.ctx.bonus(p, "maxHealth"));
446
+ const had = s.livesBought.get(p) ?? 0;
447
+ if (want > had) {
448
+ s.lives += want - had;
449
+ s.livesMax += want - had;
450
+ s.livesBought.set(p, want);
451
+ s.activity++;
452
+ }
453
+ }
454
+ if (s.phase === "build") {
455
+ const people = humans(s);
456
+ const allReady = people.length > 0 && people.every((p) => s.players.get(p)?.ready);
457
+ if (allReady && s.countdown > 0) {
458
+ // Calling the wave early pays a little gold for the seconds skipped.
459
+ const bonus = Math.floor(s.countdown / tickRate);
460
+ for (const pl of s.players.values())
461
+ pl.gold += bonus;
462
+ s.countdown = 0;
463
+ }
464
+ if (s.countdown > 0)
465
+ s.countdown--;
466
+ else if (s.players.size > 0)
467
+ startWave(s);
468
+ }
469
+ else {
470
+ // Spawns due this tick.
471
+ while (s.queue.length > 0 && (s.queue[0]?.tick ?? 0) <= s.waveTick) {
472
+ const sp = s.queue.shift();
473
+ const ct = g.creeps[sp.type];
474
+ if (!ct)
475
+ continue;
476
+ // Creeps toughen each wave, and with every extra builder on the board (more towers): +30% a player past the first.
477
+ const team = 1 + 0.3 * Math.max(0, humans(s).length - 1);
478
+ const hp = Math.max(1, Math.round(ct.health * (1 + g.healthGrowth * (s.wave - 1)) * team));
479
+ s.creeps.push({
480
+ id: s.nextId++,
481
+ type: sp.type,
482
+ hp,
483
+ maxHp: hp,
484
+ d: 0,
485
+ speed: Math.max(1, Math.round(((ct.speed / TILE_M) * Q) / tickRate)),
486
+ slowTicks: 0,
487
+ slow: 0,
488
+ value: ct.value,
489
+ dmg: ct.damage,
490
+ boss: ct.boss,
491
+ gone: 0,
492
+ flags: ct.boss ? CreepFlag.Boss : 0,
493
+ by: 0,
494
+ });
495
+ s.activity++;
496
+ }
497
+ s.waveTick++;
498
+ }
499
+ // Creeps walk.
500
+ for (const c of s.creeps) {
501
+ if (c.gone > 0) {
502
+ c.gone--;
503
+ continue;
504
+ }
505
+ const slowed = c.slowTicks > 0;
506
+ if (slowed)
507
+ c.slowTicks--;
508
+ c.flags = slowed ? c.flags | CreepFlag.Slowed : c.flags & ~CreepFlag.Slowed;
509
+ c.d += slowed ? Math.max(1, Math.round(c.speed * (1 - c.slow))) : c.speed;
510
+ if (!slowed)
511
+ c.slow = 0;
512
+ if (c.d >= b.length) {
513
+ c.d = b.length;
514
+ c.gone = 3;
515
+ c.flags |= CreepFlag.Leaked;
516
+ s.lives = Math.max(0, s.lives - c.dmg);
517
+ s.leaks += c.dmg;
518
+ s.activity++;
519
+ }
520
+ }
521
+ // Corpses and leaks stay a few ticks (clients see them fall or reach the spire), then go.
522
+ s.creeps = s.creeps.filter((c) => c.gone > 0 || alive(c));
523
+ // Towers fire, in build order.
524
+ for (const t of s.towers)
525
+ fire(s, t, emit);
526
+ const living = s.creeps.filter(alive).length;
527
+ s.peak = Math.max(s.peak, living);
528
+ if (s.lives <= 0)
529
+ return;
530
+ if (s.phase === "wave" && s.queue.length === 0 && living === 0) {
531
+ emit({ name: "wave.cleared", players: humans(s) });
532
+ const pay = g.waveGold.base + g.waveGold.perWave * s.wave;
533
+ for (const pl of s.players.values())
534
+ pl.gold += pay;
535
+ s.activity++;
536
+ if (s.wave >= g.totalWaves) {
537
+ s.won = true;
538
+ emit({ name: "run.won", players: humans(s) });
539
+ return;
540
+ }
541
+ s.phase = "build";
542
+ s.countdown = Math.round(g.breather * tickRate);
543
+ }
544
+ },
545
+ outcome(s) {
546
+ if (s.lives <= 0)
547
+ return "lost";
548
+ return s.won ? "won" : null;
549
+ },
550
+ view(s, player) {
551
+ const boss = s.creeps.find((c) => c.boss && alive(c));
552
+ const me = player !== null ? s.players.get(player) : undefined;
553
+ return {
554
+ genre: "tower",
555
+ wave: s.wave,
556
+ waves: g.totalWaves,
557
+ stage: s.phase,
558
+ countdown: Math.ceil(s.countdown / tickRate),
559
+ lives: s.lives,
560
+ livesMax: s.livesMax,
561
+ left: s.queue.length + s.creeps.filter(alive).length,
562
+ next: wavePreview(g, Math.min(g.totalWaves, s.wave + (s.phase === "build" ? 1 : 0) || 1)),
563
+ boss: boss ? { type: boss.type, hp: boss.hp, maxHp: boss.maxHp } : null,
564
+ players: [...s.players].map(([id, p]) => ({ id, gold: p.gold, kills: p.kills, ready: p.ready, built: p.built })),
565
+ ...(me ? { gold: me.gold, ready: me.ready } : {}),
566
+ };
567
+ },
568
+ entities(s, out) {
569
+ const toUnits = (q) => Math.round((q * TILE_M * UNITS) / Q);
570
+ const ox = (b.w * Q) / 2;
571
+ const oy = (b.h * Q) / 2;
572
+ for (const c of s.creeps) {
573
+ const p = roadPoint(b, c.d);
574
+ out.push(entity(c.id, RulesKind.Unit, c.by, toUnits(p.qx - ox), toUnits(p.qy - oy), p.yaw, c.type, Math.max(0, c.hp), c.flags, Math.min(0xffff, Math.round((c.hp / Math.max(1, c.maxHp)) * 1000)), Math.min(0xffff, c.maxHp)));
575
+ }
576
+ for (const t of s.towers) {
577
+ out.push(entity(t.id, RulesKind.Structure, t.owner, toUnits(t.tx * Q + Q / 2 - ox), toUnits(t.ty * Q + Q / 2 - oy), t.yaw, t.type, t.target, 0, t.level, t.shots));
578
+ }
579
+ },
580
+ bot(s, player, a, tick, skill) {
581
+ const pl = s.players.get(player);
582
+ const idle = { mx: 0, mz: 0, cx: Math.round((a.x / UNITS) * 16), cz: Math.round((a.z / UNITS) * 16), act: 0, a: 0, b: 0, c: 0 };
583
+ if (!pl)
584
+ return idle;
585
+ const period = Math.max(3, Math.round(tickRate * (skill >= 1 ? 0.5 : 1.4)));
586
+ if ((tick + player * 7) % period !== 0)
587
+ return idle;
588
+ const at = (tx, ty, act, aa = 0) => {
589
+ const c = tileCenter(b, tx, ty);
590
+ return { mx: 0, mz: 0, cx: Math.round(c.x * 16), cz: Math.round(c.z * 16), act, a: aa, b: tx, c: ty };
591
+ };
592
+ const mine = s.towers.filter((t) => t.owner === player);
593
+ const order = g.towers.map((t, i) => i).sort((x, y) => (g.towers[x]?.def.cost ?? 0) - (g.towers[y]?.def.cost ?? 0));
594
+ const want = order[(mine.length + player) % order.length] ?? 0;
595
+ const pick = g.towers[want] && pl.gold >= (g.towers[want]?.def.cost ?? Infinity) ? want : order.find((i) => (g.towers[i]?.def.cost ?? Infinity) <= pl.gold);
596
+ // Build while there is a good tile and gold; a weaker player builds less often and less well.
597
+ const upgradeable = mine.filter((t) => (g.towers[t.type]?.upgradeCost[t.level] ?? Infinity) <= pl.gold).sort((x, y) => x.level - y.level || x.id - y.id);
598
+ const preferUpgrade = mine.length >= 6 && upgradeable.length > 0;
599
+ if (pick !== undefined && !preferUpgrade) {
600
+ const occupied = new Set(s.towers.map((t) => tileKey(t.tx, t.ty)));
601
+ const tile = bestTowerTile(g, occupied, pick, player, skill >= 1 ? 0 : 3);
602
+ if (tile)
603
+ return at(tile.tx, tile.ty, 1, pick);
604
+ }
605
+ const up = upgradeable[0];
606
+ if (up)
607
+ return at(up.tx, up.ty, 2);
608
+ if (s.phase === "build" && !pl.ready && skill >= 0.5)
609
+ return { ...idle, act: 4, a: 1 };
610
+ return idle;
611
+ },
612
+ stats(s) {
613
+ let top = 0;
614
+ let scorers = 0;
615
+ for (const p of s.players.values()) {
616
+ top = Math.max(top, p.kills);
617
+ if (p.kills > 0)
618
+ scorers++;
619
+ }
620
+ return {
621
+ kills: s.kills,
622
+ wave: Math.max(1, s.wave),
623
+ level: s.towers.reduce((m, t) => Math.max(m, t.level + 1), 0),
624
+ deaths: s.leaks,
625
+ peakUnits: s.peak,
626
+ scorers,
627
+ topScore: top,
628
+ };
629
+ },
630
+ activity(s) {
631
+ return s.activity;
632
+ },
633
+ checks(run) {
634
+ const min = Math.round(run.seconds / 6) / 10;
635
+ const waveDone = run.stats.wave;
636
+ const out = [];
637
+ out.push({
638
+ ok: run.stats.kills > 0,
639
+ line: `in ${min} minutes of ${run.players}-player play no creep was killed: check that the towers' damage, rate and range reach the road, and that waves() spawns creeps`,
640
+ });
641
+ out.push({
642
+ ok: run.runs.lost === 0 || waveDone >= 4,
643
+ line: `${run.players} playtest players lost the spire before wave 4: the opening is too punishing (fewer or slower first creeps, more starting gold or lives)`,
644
+ });
645
+ out.push({
646
+ ok: waveDone >= 3 || run.seconds < 150,
647
+ line: `only ${waveDone} wave(s) started in ${min} minutes: shorten prep and breather, or the gaps between creeps`,
648
+ });
649
+ return out;
650
+ },
651
+ progress(run) {
652
+ return Math.min(1, run.stats.wave / Math.max(1, Math.min(g.totalWaves, 8))) * 0.6 + (run.stats.level >= 2 ? 0.2 : 0) + Math.min(0.2, run.stats.kills / 500);
653
+ },
654
+ };
655
+ }