@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,13 @@
1
+ /**
2
+ * Board genres by engine template (game({ template })). The room server,
3
+ * the headless harness, the gate and the template clients all build a
4
+ * genre from a game's manifest here. Each template adds one line.
5
+ */
6
+ import type { GameManifest } from "../api/registry.js";
7
+ import type { RulesGenre } from "./kit.js";
8
+ /** Ids of the content players read labels for (towers, cards...), in declaration order. */
9
+ export declare function labelledContent(m: GameManifest): string[];
10
+ /** True when a template plays on a board genre (not engine-sim's action world). */
11
+ export declare function isRulesTemplate(template: string): boolean;
12
+ /** The genre for a game manifest; throws GameDefinitionError lines through the genre's compile. */
13
+ export declare function genreFor(m: GameManifest): RulesGenre<unknown>;
@@ -0,0 +1,38 @@
1
+ import { idleGenre } from "./idle.js";
2
+ import { collectorGenre } from "./collector.js";
3
+ import { towerGenre } from "./tower.js";
4
+ import { cardsGenre } from "./cards.js";
5
+ const GENRES = {
6
+ "tower-defense": (m) => towerGenre(m),
7
+ idle: (m) => idleGenre(m),
8
+ cards: (m) => cardsGenre(m),
9
+ collector: (m) => collectorGenre(m),
10
+ };
11
+ /**
12
+ * The content kind each genre labels in names.json "weapons" (the model's
13
+ * labels file keeps one table for a game's tools: towers, cards, moves).
14
+ */
15
+ const LABELLED = {
16
+ "tower-defense": "tower",
17
+ idle: "plant",
18
+ cards: "card",
19
+ collector: "creature",
20
+ };
21
+ /** Ids of the content players read labels for (towers, cards...), in declaration order. */
22
+ export function labelledContent(m) {
23
+ const kind = LABELLED[m.game.template];
24
+ if (!kind)
25
+ return [];
26
+ return [...m.content.keys()].filter((k) => k.startsWith(`${kind}:`)).map((k) => k.slice(kind.length + 1));
27
+ }
28
+ /** True when a template plays on a board genre (not engine-sim's action world). */
29
+ export function isRulesTemplate(template) {
30
+ return Object.prototype.hasOwnProperty.call(GENRES, template);
31
+ }
32
+ /** The genre for a game manifest; throws GameDefinitionError lines through the genre's compile. */
33
+ export function genreFor(m) {
34
+ const f = GENRES[m.game.template];
35
+ if (!f)
36
+ throw new Error(`template "${m.game.template}" is not a board genre`);
37
+ return f(m);
38
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The headless playtest for board genres: the game
3
+ * runs on the same adapter the room server steps, with playtest players
4
+ * driven by the genre's own bot, and reports in HarnessReport terms. A run
5
+ * that ends is rebuilt the way the room does it (a fresh world, every
6
+ * player re-added, every referee run restarted).
7
+ */
8
+ import type { GameManifest } from "../api/registry.js";
9
+ import type { HarnessOptions, HarnessReport } from "../api/harness.js";
10
+ import type { RulesGenre, RulesHarnessRun } from "./kit.js";
11
+ /** One run of the playtest, with the genre's numbers kept for its checks. */
12
+ export interface RulesPlaytest {
13
+ readonly report: HarnessReport;
14
+ readonly run: RulesHarnessRun;
15
+ }
16
+ export declare function playRules<S>(genre: RulesGenre<S>, manifest: GameManifest, opts: HarnessOptions): RulesPlaytest;
@@ -0,0 +1,127 @@
1
+ import { rulesAdapter } from "./adapter.js";
2
+ // performance exists in browsers, Node and Bun, but not in lib ES2022's types.
3
+ const perf = globalThis.performance;
4
+ const now = () => (perf ? perf.now() : Date.now());
5
+ export function playRules(genre, manifest, opts) {
6
+ const adapter = rulesAdapter(genre, manifest, { name: "playtest", version: "playtest" });
7
+ const seed = opts.seed ?? 1;
8
+ const players = Math.max(1, Math.min(manifest.game.players.max, opts.bots));
9
+ const skill = opts.bot === "human" ? 0.6 : 1;
10
+ let world = adapter.createWorld(seed, "server");
11
+ const join = (w) => {
12
+ for (let p = 1; p <= players; p++) {
13
+ adapter.addPlayer(w, p);
14
+ adapter.startRun?.(w, p);
15
+ }
16
+ };
17
+ join(world);
18
+ const earned = {};
19
+ let earnEmitted = 0;
20
+ let won = 0;
21
+ let lost = 0;
22
+ let firstEarn = null;
23
+ let firstRun = null;
24
+ const times = [];
25
+ let lastActivity = -1;
26
+ let quietTicks = 0;
27
+ let softlock = false;
28
+ const quietLimit = 30 * genre.tickRate;
29
+ // Totals across runs (each world's stats restart with it).
30
+ let kills = 0;
31
+ let deaths = 0;
32
+ let wave = 0;
33
+ let level = 0;
34
+ let peak = 0;
35
+ let topScore = 0;
36
+ let scorers = 0;
37
+ const fold = (st) => {
38
+ kills += st.kills;
39
+ deaths += st.deaths;
40
+ wave = Math.max(wave, st.wave);
41
+ level = Math.max(level, st.level);
42
+ peak = Math.max(peak, st.peakUnits);
43
+ topScore = Math.max(topScore, st.topScore);
44
+ scorers = Math.max(scorers, st.scorers);
45
+ };
46
+ let tick = 0;
47
+ for (; tick < opts.ticks; tick++) {
48
+ const s = world.state;
49
+ if (!s)
50
+ break;
51
+ const inputs = new Map();
52
+ for (const [p, a] of world.avatars)
53
+ if (p <= players)
54
+ inputs.set(p, [genre.bot(s, p, a, tick, skill)]);
55
+ const t0 = now();
56
+ const events = adapter.step(world, inputs);
57
+ times.push(now() - t0);
58
+ let reset = false;
59
+ for (const e of events) {
60
+ if (e.type === "earn") {
61
+ earned[e.rule] = (earned[e.rule] ?? 0) + e.units;
62
+ earnEmitted += e.units;
63
+ firstEarn ??= tick / genre.tickRate;
64
+ }
65
+ else if (e.type === "run.reset")
66
+ reset = true;
67
+ }
68
+ const act = genre.activity(s);
69
+ if (act === lastActivity) {
70
+ if (++quietTicks >= quietLimit && world.phase === "playing")
71
+ softlock = true;
72
+ }
73
+ else {
74
+ quietTicks = 0;
75
+ lastActivity = act;
76
+ }
77
+ if (reset) {
78
+ if (world.outcome === "won")
79
+ won++;
80
+ else
81
+ lost++;
82
+ firstRun ??= tick / genre.tickRate;
83
+ fold(genre.stats(s));
84
+ world = adapter.createWorld(seed + won + lost, "server");
85
+ join(world);
86
+ lastActivity = -1;
87
+ quietTicks = 0;
88
+ }
89
+ }
90
+ if (world.state) {
91
+ fold(genre.stats(world.state));
92
+ // A run that ended inside the last few seconds of the playtest still counts.
93
+ if (world.phase === "ended") {
94
+ if (world.outcome === "won")
95
+ won++;
96
+ else
97
+ lost++;
98
+ firstRun ??= tick / genre.tickRate;
99
+ }
100
+ }
101
+ const sorted = [...times].sort((a, b) => a - b);
102
+ const mean = times.length ? times.reduce((a, b) => a + b, 0) / times.length : 0;
103
+ const p99 = sorted.length ? (sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.99))] ?? 0) : 0;
104
+ const stats = { kills, wave, level, deaths, peakUnits: peak, scorers, topScore };
105
+ const report = {
106
+ ticks: tick,
107
+ bots: players,
108
+ deaths,
109
+ kills,
110
+ wave,
111
+ runs: { won, lost },
112
+ maxLevel: level,
113
+ peakEnemies: peak,
114
+ topScore,
115
+ scorers,
116
+ earned,
117
+ msPerTick: mean,
118
+ p99MsPerTick: p99,
119
+ earnEmitted,
120
+ softlock,
121
+ failed: [],
122
+ };
123
+ const run = { seconds: tick / genre.tickRate, players, stats, runs: { won, lost }, earned, firstEarnSeconds: firstEarn, firstRunSeconds: firstRun };
124
+ const full = { ...report, rules: { template: genre.template, checks: genre.checks(run), progress: Math.max(0, Math.min(1, genre.progress(run))) } };
125
+ const failed = (opts.assert ?? []).filter((a) => !a.check(full)).map((a) => a.name);
126
+ return { report: { ...full, failed }, run };
127
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The idle genre (template "idle"): a shared salt flat where up to eight
3
+ * players each run a works. Pumps make brine, pans boil it into salt, kilns
4
+ * cut salt into crystals; salt buys and levels plants; crystals go to a
5
+ * contract everyone fills together. A shift has a fixed length; then the
6
+ * room shows the end screen and a new shift starts from nothing.
7
+ *
8
+ * Why an idle game here cannot be farmed (the server is the referee):
9
+ * - Production happens only in `step`, on the room's ticks, for players in
10
+ * the room. There is no wall clock and no offline accrual: a player who
11
+ * leaves takes nothing with them, and a returning player starts a fresh
12
+ * works (and a fresh referee run) from the shift's starting salt.
13
+ * - A works runs only while it is tended: an action (rake, buy, ship)
14
+ * within `tend` seconds. An AFK tab produces nothing after that.
15
+ * - Raking has a cooldown, so clicking faster than it adds nothing.
16
+ * - Earn rules pay once a run (a shift, or a seat's time in it), and the
17
+ * genre's own checks measure the earliest any rule can pay with perfect
18
+ * play from a fresh works (the gate refuses a draft whose first payout
19
+ * comes too soon), which bounds what one seat can earn an hour even by
20
+ * leaving and rejoining.
21
+ * - Everything is integer milli-units and the room replays a run from its
22
+ * inputs exactly (referee/verify.ts).
23
+ */
24
+ import type { GameManifest } from "../api/registry.js";
25
+ import { type PlantDef } from "../api/idle.js";
26
+ import type { Avatar, RulesContext, RulesGenre, RulesInput } from "./kit.js";
27
+ /** Actions (RulesInput.act). */
28
+ export declare const IdleAct: {
29
+ readonly Rake: 1;
30
+ readonly Buy: 2;
31
+ readonly Ship: 3;
32
+ };
33
+ /** Milli-units per unit (litres, kg, crystals). */
34
+ export declare const MILLI = 1000;
35
+ /** The gate's floor: no earn rule may pay sooner than this after a fresh works starts, with perfect play (seconds). */
36
+ export declare const IDLE_MIN_FIRST_EARN = 180;
37
+ /** Plot layout: four across, two deep, each 12 x 10 m. */
38
+ export declare const PLOT: {
39
+ readonly w: 12;
40
+ readonly d: 10;
41
+ readonly cols: 4;
42
+ readonly rows: 2;
43
+ readonly gapX: 1.5;
44
+ readonly gapZ: 5;
45
+ };
46
+ export declare const IDLE_SEATS: number;
47
+ /** A plot's centre in metres. Rows sit either side of the canal the contract barge waits on. */
48
+ export declare function plotCenter(slot: number): {
49
+ x: number;
50
+ z: number;
51
+ };
52
+ /** Where plant `i` stands on a plot (two rows of four), metres from the plot centre. */
53
+ export declare function plantSpot(i: number): {
54
+ x: number;
55
+ z: number;
56
+ };
57
+ export interface Works {
58
+ slot: number;
59
+ brine: number;
60
+ salt: number;
61
+ crystals: number;
62
+ /** Salt made this shift (milli), raked and boiled. */
63
+ made: number;
64
+ packs: number;
65
+ /** Crystal progress (milli). */
66
+ cutAcc: number;
67
+ cut: number;
68
+ levels: number[];
69
+ lastTend: number;
70
+ rakeAt: number;
71
+ shipped: number;
72
+ rakes: number;
73
+ }
74
+ export interface IdleState {
75
+ rng: {
76
+ s: number;
77
+ };
78
+ ctx: RulesContext;
79
+ tick: number;
80
+ players: Map<number, Works>;
81
+ contract: number;
82
+ filled: boolean;
83
+ done: boolean;
84
+ activity: number;
85
+ }
86
+ export interface CompiledIdleGame {
87
+ readonly shiftTicks: number;
88
+ readonly tendTicks: number;
89
+ readonly start: number;
90
+ readonly rakeSalt: number;
91
+ readonly rakeTicks: number;
92
+ readonly brinePerSalt: number;
93
+ readonly saltPerCrystal: number;
94
+ readonly packEvery: number;
95
+ readonly contract: number;
96
+ readonly plants: readonly {
97
+ readonly id: string;
98
+ readonly def: PlantDef;
99
+ }[];
100
+ }
101
+ export declare function compileIdleGame(m: GameManifest): CompiledIdleGame;
102
+ /** Salt (milli) the next level of plant `i` costs at `level`. */
103
+ export declare function plantCost(g: CompiledIdleGame, i: number, level: number): number;
104
+ /** Per-second rates a works runs at (litres, kg, crystals), before tending. */
105
+ export declare function works(g: CompiledIdleGame, w: Pick<Works, "levels">, might: number): {
106
+ pump: number;
107
+ pan: number;
108
+ kiln: number;
109
+ };
110
+ /** Bot policies: "best" plays the chain as well as the template knows, "human" slower, "clicker" only rakes, "idle" does nothing. */
111
+ export type IdlePolicy = "best" | "human" | "clicker" | "idle";
112
+ export declare function idleGenre(m: GameManifest): RulesGenre<IdleState> & {
113
+ readonly game: CompiledIdleGame;
114
+ policyInput(s: IdleState, player: number, a: Avatar, tick: number, policy: IdlePolicy): RulesInput;
115
+ };
@@ -0,0 +1,370 @@
1
+ import { idleContent } from "../api/idle.js";
2
+ import { entity, RulesKind, UNITS } from "./kit.js";
3
+ /** Actions (RulesInput.act). */
4
+ export const IdleAct = { Rake: 1, Buy: 2, Ship: 3 };
5
+ /** Milli-units per unit (litres, kg, crystals). */
6
+ export const MILLI = 1000;
7
+ /** The gate's floor: no earn rule may pay sooner than this after a fresh works starts, with perfect play (seconds). */
8
+ export const IDLE_MIN_FIRST_EARN = 180;
9
+ /** Plot layout: four across, two deep, each 12 x 10 m. */
10
+ export const PLOT = { w: 12, d: 10, cols: 4, rows: 2, gapX: 1.5, gapZ: 5 };
11
+ export const IDLE_SEATS = PLOT.cols * PLOT.rows;
12
+ /** A plot's centre in metres. Rows sit either side of the canal the contract barge waits on. */
13
+ export function plotCenter(slot) {
14
+ const col = slot % PLOT.cols;
15
+ const row = Math.floor(slot / PLOT.cols) % PLOT.rows;
16
+ return { x: (col - (PLOT.cols - 1) / 2) * (PLOT.w + PLOT.gapX), z: (row === 0 ? -1 : 1) * (PLOT.d / 2 + PLOT.gapZ / 2) };
17
+ }
18
+ /** Where plant `i` stands on a plot (two rows of four), metres from the plot centre. */
19
+ export function plantSpot(i) {
20
+ return { x: ((i % 4) - 1.5) * 2.7, z: (Math.floor(i / 4) === 0 ? -1.8 : 1.2) };
21
+ }
22
+ const TICK = 30;
23
+ export function compileIdleGame(m) {
24
+ const c = idleContent(m);
25
+ return {
26
+ shiftTicks: Math.round(c.flat.shift * TICK),
27
+ tendTicks: Math.round(c.flat.tend * TICK),
28
+ start: Math.round(c.flat.start * MILLI),
29
+ rakeSalt: Math.round(c.flat.rake.salt * MILLI),
30
+ rakeTicks: Math.max(1, Math.round(c.flat.rake.cooldown * TICK)),
31
+ brinePerSalt: c.flat.brinePerSalt,
32
+ saltPerCrystal: c.flat.saltPerCrystal,
33
+ packEvery: Math.round(c.flat.packEvery * MILLI),
34
+ contract: c.flat.contract.crystals,
35
+ plants: c.plants.map(([id, def]) => ({ id, def })),
36
+ };
37
+ }
38
+ /** Salt (milli) the next level of plant `i` costs at `level`. */
39
+ export function plantCost(g, i, level) {
40
+ const p = g.plants[i]?.def;
41
+ if (!p)
42
+ return Infinity;
43
+ return Math.round(p.cost * Math.pow(p.growth, level) * MILLI);
44
+ }
45
+ /** Per-second rates a works runs at (litres, kg, crystals), before tending. */
46
+ export function works(g, w, might) {
47
+ let pump = 0;
48
+ let pan = 0;
49
+ let kiln = 0;
50
+ g.plants.forEach((p, i) => {
51
+ const r = p.def.rate * (w.levels[i] ?? 0) * (1 + might);
52
+ if (p.def.kind === "pump")
53
+ pump += r;
54
+ else if (p.def.kind === "pan")
55
+ pan += r;
56
+ else
57
+ kiln += r;
58
+ });
59
+ return { pump, pan, kiln };
60
+ }
61
+ export function idleGenre(m) {
62
+ const g = compileIdleGame(m);
63
+ const tickRate = TICK;
64
+ const AVATAR_STEP = Math.round((8 * UNITS) / tickRate);
65
+ const half = { x: (PLOT.cols * (PLOT.w + PLOT.gapX)) / 2 + 2, z: PLOT.d + PLOT.gapZ / 2 + 2 };
66
+ const humans = (s) => [...s.players.keys()].filter((p) => !s.ctx.isBot(p));
67
+ const add = (s, p, w, salt, emit) => {
68
+ w.salt += salt;
69
+ w.made += salt;
70
+ while (w.made >= (w.packs + 1) * g.packEvery) {
71
+ w.packs++;
72
+ emit({ name: "salt.packed", players: [p], own: true });
73
+ }
74
+ };
75
+ const tend = (s, w) => {
76
+ w.lastTend = s.tick;
77
+ };
78
+ const policyInput = (s, player, a, tick, policy) => {
79
+ const w = s.players.get(player);
80
+ const c = plotCenter(w?.slot ?? 0);
81
+ const idle = { mx: 0, mz: 0, cx: Math.round(c.x * 16), cz: Math.round(c.z * 16), act: 0, a: 0, b: 0, c: 0 };
82
+ if (!w || policy === "idle")
83
+ return idle;
84
+ const act = (k, aa = 0) => ({ ...idle, act: k, a: aa });
85
+ const rakeReady = s.tick >= w.rakeAt;
86
+ if (policy === "clicker")
87
+ return rakeReady ? act(IdleAct.Rake) : idle;
88
+ // A person reacts in about a second and does one thing at a time.
89
+ if (policy === "human" && (tick + player * 11) % 20 !== 0)
90
+ return idle;
91
+ const r = works(g, w, s.ctx.bonus(player, "might"));
92
+ // Buy what the chain is short of: pumps until the pans are fed, pans until the brine is boiled, kilns once salt flows.
93
+ const saltRate = Math.min(r.pan, r.pump / g.brinePerSalt);
94
+ const kind = r.pump / g.brinePerSalt <= r.pan ? "pump" : "pan";
95
+ const late = s.tick > g.shiftTicks * 0.35;
96
+ const wantKind = late && saltRate > r.kiln * g.saltPerCrystal * 2 ? "kiln" : kind;
97
+ let best = -1;
98
+ let bestScore = Infinity;
99
+ g.plants.forEach((p, i) => {
100
+ if (p.def.kind !== wantKind || (w.levels[i] ?? 0) >= p.def.max)
101
+ return;
102
+ const score = plantCost(g, i, w.levels[i] ?? 0) / p.def.rate;
103
+ if (score < bestScore) {
104
+ bestScore = score;
105
+ best = i;
106
+ }
107
+ });
108
+ if (best >= 0 && w.salt >= plantCost(g, best, w.levels[best] ?? 0))
109
+ return act(IdleAct.Buy, best);
110
+ if (rakeReady)
111
+ return act(IdleAct.Rake);
112
+ if (w.crystals >= MILLI && !s.filled)
113
+ return act(IdleAct.Ship, Math.floor(w.crystals / MILLI));
114
+ return idle;
115
+ };
116
+ /** One fresh works on its own for a whole shift under a policy: the tick each rule's event count first reaches its n. */
117
+ const solo = (policy) => {
118
+ const isBot = () => false;
119
+ const ctx = { bonus: () => 0, isBot };
120
+ const s = genre.create(7, ctx);
121
+ genre.join(s, 1);
122
+ const a = { x: 0, z: 0, yaw: 0, cosmetic: 0, flags: 0 };
123
+ const counts = new Map();
124
+ const first = new Map();
125
+ const emit = (e) => {
126
+ if (!e.players.includes(1))
127
+ return;
128
+ const n = (counts.get(e.name) ?? 0) + 1;
129
+ counts.set(e.name, n);
130
+ for (const [id, r] of m.earnRules)
131
+ if (r.when === e.name && n >= r.n && !first.has(id))
132
+ first.set(id, s.tick);
133
+ };
134
+ for (let t = 0; t < g.shiftTicks && !s.done; t++) {
135
+ const i = policyInput(s, 1, a, t, policy);
136
+ if (i.act !== 0)
137
+ genre.act(s, 1, i, a, emit);
138
+ genre.step(s, emit);
139
+ }
140
+ return { first, packs: s.players.get(1)?.packs ?? 0 };
141
+ };
142
+ const genre = {
143
+ template: "idle",
144
+ tickRate,
145
+ endSeconds: 10,
146
+ infoEveryTicks: 6,
147
+ game: g,
148
+ policyInput,
149
+ create(seed, ctx) {
150
+ return { rng: { s: seed | 0 }, ctx, tick: 0, players: new Map(), contract: 0, filled: false, done: false, activity: 0 };
151
+ },
152
+ spawn(s, player) {
153
+ const slot = s?.players.get(player)?.slot ?? 0;
154
+ const c = plotCenter(slot);
155
+ return { x: Math.round(c.x * UNITS), z: Math.round((c.z + 3.6) * UNITS) };
156
+ },
157
+ moveAvatar(a, i) {
158
+ if (i.cx === 0 && i.cz === 0 && i.act === 0)
159
+ return;
160
+ const tx = Math.round(Math.max(-half.x, Math.min(half.x, i.cx / 16)) * UNITS);
161
+ const tz = Math.round(Math.max(-half.z, Math.min(half.z, i.cz / 16)) * UNITS);
162
+ const dx = tx - a.x;
163
+ const dz = tz - a.z;
164
+ const d = Math.sqrt(dx * dx + dz * dz);
165
+ if (d < 1)
166
+ return;
167
+ if (d <= AVATAR_STEP) {
168
+ a.x += dx;
169
+ a.z += dz;
170
+ }
171
+ else {
172
+ a.x += Math.round((dx * AVATAR_STEP) / d);
173
+ a.z += Math.round((dz * AVATAR_STEP) / d);
174
+ }
175
+ if (d > 8)
176
+ a.yaw = Math.round((Math.atan2(-dx, -dz) / (Math.PI * 2)) * 65536) & 0xffff;
177
+ },
178
+ join(s, player) {
179
+ const used = new Set([...s.players.values()].map((w) => w.slot));
180
+ let slot = 0;
181
+ while (used.has(slot) && slot < IDLE_SEATS - 1)
182
+ slot++;
183
+ // A fresh works every time: nothing carries over from an earlier seat, a closed tab or another shift.
184
+ s.players.set(player, { slot, brine: 0, salt: g.start, crystals: 0, made: 0, packs: 0, cutAcc: 0, cut: 0, levels: g.plants.map(() => 0), lastTend: s.tick, rakeAt: s.tick, shipped: 0, rakes: 0 });
185
+ s.activity++;
186
+ },
187
+ leave(s, player) {
188
+ s.players.delete(player);
189
+ },
190
+ act(s, player, i, _a, emit) {
191
+ const w = s.players.get(player);
192
+ if (!w || s.done)
193
+ return;
194
+ if (i.act === IdleAct.Rake) {
195
+ if (s.tick < w.rakeAt)
196
+ return;
197
+ const haste = s.ctx.bonus(player, "haste");
198
+ w.rakeAt = s.tick + Math.max(1, Math.round(g.rakeTicks / (1 + haste)));
199
+ w.rakes++;
200
+ tend(s, w);
201
+ add(s, player, w, Math.round(g.rakeSalt * (1 + s.ctx.bonus(player, "amount"))), emit);
202
+ s.activity++;
203
+ return;
204
+ }
205
+ if (i.act === IdleAct.Buy) {
206
+ const p = g.plants[i.a];
207
+ if (!p)
208
+ return;
209
+ const lvl = w.levels[i.a] ?? 0;
210
+ const cost = plantCost(g, i.a, lvl);
211
+ if (lvl >= p.def.max || w.salt < cost)
212
+ return;
213
+ w.salt -= cost;
214
+ w.levels[i.a] = lvl + 1;
215
+ tend(s, w);
216
+ s.activity++;
217
+ emit({ name: lvl === 0 ? "plant.built" : "plant.upgraded", players: [player], own: true });
218
+ return;
219
+ }
220
+ if (i.act === IdleAct.Ship) {
221
+ const n = Math.min(Math.floor(w.crystals / MILLI), Math.max(1, i.a));
222
+ if (n <= 0 || s.filled)
223
+ return;
224
+ w.crystals -= n * MILLI;
225
+ w.shipped += n;
226
+ s.contract += n;
227
+ tend(s, w);
228
+ s.activity++;
229
+ for (let k = 0; k < n; k++)
230
+ emit({ name: "crystal.shipped", players: [player], own: true });
231
+ if (s.contract >= g.contract && !s.filled) {
232
+ s.filled = true;
233
+ // The whole flat's work: it pays the players who shipped to it (world progress, so never a run that started after the shift began).
234
+ emit({ name: "contract.filled", players: [...s.players].filter(([id, x]) => x.shipped > 0 && !s.ctx.isBot(id)).map(([id]) => id) });
235
+ }
236
+ }
237
+ },
238
+ step(s, emit) {
239
+ // The shift's clock runs only while someone is on the flat: an empty room is not a shift.
240
+ if (humans(s).length === 0 || s.done)
241
+ return;
242
+ s.tick++;
243
+ for (const [p, w] of s.players) {
244
+ // An untended works stops: an AFK seat makes nothing.
245
+ if (s.tick - w.lastTend > g.tendTicks)
246
+ continue;
247
+ const r = works(g, w, s.ctx.bonus(p, "might"));
248
+ w.brine += Math.round((r.pump * MILLI) / tickRate);
249
+ const boil = Math.min(Math.round((r.pan * MILLI) / tickRate), Math.floor(w.brine / g.brinePerSalt));
250
+ if (boil > 0) {
251
+ w.brine -= Math.round(boil * g.brinePerSalt);
252
+ add(s, p, w, boil, emit);
253
+ }
254
+ const cutSalt = Math.min(Math.round((r.kiln * g.saltPerCrystal * MILLI) / tickRate), w.salt);
255
+ if (cutSalt > 0) {
256
+ w.salt -= cutSalt;
257
+ w.cutAcc += Math.round(cutSalt / g.saltPerCrystal);
258
+ while (w.cutAcc >= MILLI) {
259
+ w.cutAcc -= MILLI;
260
+ w.crystals += MILLI;
261
+ w.cut++;
262
+ emit({ name: "crystal.cut", players: [p], own: true });
263
+ }
264
+ }
265
+ }
266
+ if (s.tick % tickRate === 0)
267
+ s.activity++;
268
+ if (s.tick >= g.shiftTicks) {
269
+ s.done = true;
270
+ emit({ name: "shift.ended", players: humans(s) });
271
+ }
272
+ },
273
+ outcome(s) {
274
+ return s.done ? (s.filled ? "won" : "lost") : null;
275
+ },
276
+ view(s, player) {
277
+ const me = player !== null ? s.players.get(player) : undefined;
278
+ const left = Math.max(0, Math.ceil((g.shiftTicks - s.tick) / tickRate));
279
+ return {
280
+ genre: "idle",
281
+ left,
282
+ shift: Math.round(g.shiftTicks / tickRate),
283
+ contract: s.contract,
284
+ contractTarget: g.contract,
285
+ filled: s.filled,
286
+ players: [...s.players].map(([id, w]) => ({ id, slot: w.slot, made: Math.floor(w.made / MILLI), shipped: w.shipped, tended: s.tick - w.lastTend <= g.tendTicks })),
287
+ ...(me && player !== null
288
+ ? {
289
+ slot: me.slot,
290
+ brine: Math.floor(me.brine / MILLI),
291
+ salt: Math.floor(me.salt / MILLI),
292
+ crystals: Math.floor(me.crystals / MILLI),
293
+ made: Math.floor(me.made / MILLI),
294
+ nextPack: Math.round(((me.packs + 1) * g.packEvery) / MILLI),
295
+ levels: me.levels,
296
+ costs: g.plants.map((_, i) => Math.ceil(plantCost(g, i, me.levels[i] ?? 0) / MILLI)),
297
+ stall: Math.max(0, Math.ceil((g.tendTicks - (s.tick - me.lastTend)) / tickRate)),
298
+ rake: Math.max(0, me.rakeAt - s.tick),
299
+ rates: works(g, me, s.ctx.bonus(player, "might")),
300
+ }
301
+ : {}),
302
+ };
303
+ },
304
+ entities(s, out) {
305
+ const u = (m) => Math.round(m * UNITS);
306
+ out.push(entity(1, RulesKind.Unit, 0, 0, 0, 0, 0, 0, s.filled ? 1 : 0, Math.min(0xffff, s.contract), Math.min(0xffff, g.contract)));
307
+ for (const [p, w] of s.players) {
308
+ const c = plotCenter(w.slot);
309
+ const tended = s.tick - w.lastTend <= g.tendTicks ? 1 : 0;
310
+ w.levels.forEach((lvl, i) => {
311
+ if (lvl <= 0)
312
+ return;
313
+ const at = plantSpot(i);
314
+ out.push(entity(0x100000 + p * 16 + i, RulesKind.Structure, p, u(c.x + at.x), u(c.z + at.z), 0, i, 0, tended, lvl, 0));
315
+ });
316
+ out.push(entity(0x200000 + p, RulesKind.Pickup, p, u(c.x + 4.6), u(c.z + 3.4), 0, 0, Math.floor(w.brine / MILLI), tended, Math.min(0xffff, Math.floor(w.salt / MILLI)), Math.min(0xffff, Math.floor(w.crystals / MILLI))));
317
+ }
318
+ },
319
+ bot(s, player, a, tick, skill) {
320
+ return policyInput(s, player, a, tick, skill >= 1 ? "best" : "human");
321
+ },
322
+ stats(s) {
323
+ let top = 0;
324
+ let scorers = 0;
325
+ let made = 0;
326
+ let level = 0;
327
+ for (const w of s.players.values()) {
328
+ top = Math.max(top, w.packs);
329
+ if (w.packs > 0)
330
+ scorers++;
331
+ made += w.packs;
332
+ for (const l of w.levels)
333
+ level = Math.max(level, l);
334
+ }
335
+ return { kills: made, wave: 1 + Math.floor((s.tick / Math.max(1, g.shiftTicks)) * 10), level, deaths: 0, peakUnits: s.players.size, scorers, topScore: top };
336
+ },
337
+ activity(s) {
338
+ return s.activity;
339
+ },
340
+ checks(run) {
341
+ const best = solo("best");
342
+ const clicker = solo("clicker");
343
+ const idle = solo("idle");
344
+ const firstBest = Math.min(...[...best.first.values()].map((t) => t / tickRate), Infinity);
345
+ const firstClick = Math.min(...[...clicker.first.values()].map((t) => t / tickRate), Infinity);
346
+ // The most one seat can earn an hour: every rule paid as often as a fresh works can reach it (leaving and rejoining included).
347
+ let perHour = 0;
348
+ for (const [id, r] of m.earnRules) {
349
+ const t = Math.min(best.first.get(id) ?? Infinity, clicker.first.get(id) ?? Infinity) / tickRate;
350
+ if (Number.isFinite(t))
351
+ perHour += (r.pay * 3600) / Math.max(t, 1);
352
+ }
353
+ const min = Math.round(run.seconds / 6) / 10;
354
+ return [
355
+ { ok: idle.first.size === 0 && idle.packs === 0, line: "a seat that never acts must make nothing: the works must stall when untended (keep tend at 300 seconds or less and start salt below one crate)" },
356
+ {
357
+ ok: firstBest >= IDLE_MIN_FIRST_EARN && firstClick >= IDLE_MIN_FIRST_EARN,
358
+ line: `the first earn rule pays ${Math.round(Math.min(firstBest, firstClick))} s into a fresh works with perfect play; it must take at least ${IDLE_MIN_FIRST_EARN} s (raise packEvery or the first rule's n, or slow raking), or a seat could farm it by rejoining`,
359
+ },
360
+ { ok: Object.keys(run.earned).length > 0 || run.seconds < 200, line: `no earn rule fired in ${min} minutes of ${run.players}-player play: make one reachable within about 4 minutes` },
361
+ { ok: true, line: `bound: first payout after ${Math.round(Math.min(firstBest, firstClick))} s, at most ${Math.round(perHour)} coins an hour a seat` },
362
+ ];
363
+ },
364
+ progress(run) {
365
+ const shift = g.shiftTicks / tickRate;
366
+ return Math.min(1, run.stats.topScore / 30) * 0.6 + (run.stats.level >= 5 ? 0.2 : 0) + (run.seconds >= shift || run.runs.won + run.runs.lost > 0 ? 0.2 : 0.1);
367
+ },
368
+ };
369
+ return genre;
370
+ }