@jgengine/convex 0.50.0 → 0.52.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.
@@ -0,0 +1,752 @@
1
+ # Cookbook
2
+
3
+ <!-- GENERATED by `bun run gen recipes` from packages/core/src/recipes/*.recipe.test.ts - edit the test, not this file. -->
4
+
5
+ Each common feature built from the parts in a few lines. The code is the test body, so it runs; the lines after the blank line check that it behaves.
6
+
7
+ ## Growth stages
8
+
9
+ Phases the game names, each applying its own Modifiers source to size and strength as it begins.
10
+
11
+ ```ts
12
+ import { createPhaseSchedule, createStatGraph } from "@jgengine/core/parts";
13
+
14
+ const stages = createPhaseSchedule({
15
+ phases: [{ state: "hatchling", durationSeconds: 100 }, { state: "juvenile", durationSeconds: 300 }, { state: "adult" }],
16
+ });
17
+ const body = createStatGraph({ inputs: [{ id: "size", base: 1 }, { id: "strength", base: 2 }] }).create();
18
+ const scale = { hatchling: 0.25, juvenile: 0.6, adult: 1 };
19
+ const grow = (dt: number) => {
20
+ for (const t of stages.tick(dt)) body.addSource("stage", { size: { source: t.to, op: "mul", value: scale[t.to] } });
21
+ };
22
+
23
+ grow(100);
24
+ expect(stages.state()).toBe("juvenile");
25
+ expect(body.get("size")).toBe(0.6);
26
+ grow(300);
27
+ expect(stages.state()).toBe("adult");
28
+ expect(body.get("size")).toBe(1);
29
+ ```
30
+
31
+ ## Ammo and reload
32
+
33
+ The magazine and the reserve are two Meters, and a countdown gates firing while the reload runs.
34
+
35
+ ```ts
36
+ import { changeStatPool, createStatPool } from "@jgengine/core/parts";
37
+ import { tickCooldown } from "@jgengine/core/time/cooldown";
38
+
39
+ let magazine = createStatPool({ max: 30 });
40
+ let reserve = createStatPool({ max: 120, current: 40 });
41
+ let reloading = 0;
42
+ const fire = () => reloading === 0 && magazine.current > 0 && ((magazine = changeStatPool(magazine, -1).pool), true);
43
+ const reload = () => {
44
+ const moved = changeStatPool(reserve, -(magazine.max - magazine.current)).applied;
45
+ reserve = changeStatPool(reserve, moved).pool;
46
+ magazine = changeStatPool(magazine, -moved).pool;
47
+ reloading = 2;
48
+ };
49
+
50
+ for (let n = 0; n < 30; n += 1) fire();
51
+ expect(fire()).toBe(false);
52
+ reload();
53
+ expect(fire()).toBe(false);
54
+ reloading = tickCooldown(reloading, 2);
55
+ expect([fire(), magazine.current, reserve.current]).toEqual([true, 29, 10]);
56
+ ```
57
+
58
+ ## Boss with phases
59
+
60
+ A Meter holds the boss's health, a Ladder of health cut points reports each one crossed, and Phases advance by event, not by time.
61
+
62
+ ```ts
63
+ import { changeStatPool, createPhaseSchedule, createStatPool, crossThresholds } from "@jgengine/core/parts";
64
+
65
+ let health = createStatPool({ max: 1000 });
66
+ const phases = createPhaseSchedule({ phases: [{ state: "ranged" }, { state: "melee" }, { state: "enraged" }] });
67
+ const cuts = [{ id: "melee", at: 600 }, { id: "enraged", at: 300 }];
68
+ const hit = (damage: number) => {
69
+ const hit = changeStatPool(health, -damage);
70
+ for (const _ of crossThresholds(cuts, health.current, hit.pool.current)) phases.advance();
71
+ health = hit.pool;
72
+ };
73
+
74
+ expect(phases.state()).toBe("ranged");
75
+ hit(500);
76
+ expect(phases.state()).toBe("melee");
77
+ hit(450);
78
+ expect(phases.state()).toBe("enraged");
79
+ hit(900);
80
+ expect(health.current).toBe(0);
81
+ ```
82
+
83
+ ## Bounty board
84
+
85
+ Slots hold one bounty each and refill a while after one is done, a Table draws the next target, and a Requirement gates who may take it.
86
+
87
+ ```ts
88
+ import { createRefillSlots, evaluatePredicate, pickWeighted } from "@jgengine/core/parts";
89
+ import { seededRng } from "@jgengine/core/random/rng";
90
+
91
+ const rng = seededRng("bounties");
92
+ const board = createRefillSlots({ slots: ["a", "b", "c"], refillSeconds: [100, 100] }, rng);
93
+ const targets = [{ name: "bandit", weight: 3 }, { name: "wolf king", weight: 1 }];
94
+ const posted: Record<string, string> = {};
95
+ const post = (now: number) => board.ready(now).forEach((slot) => ((posted[slot] = pickWeighted(rng, targets, (t) => t.weight)?.name ?? ""), board.fill(slot)));
96
+ const take = (slot: string, renown: number, now: number) => {
97
+ if (!board.isFilled(slot) || !evaluatePredicate({ gte: ["renown", 5] }, { renown })) return false;
98
+ board.empty(slot, now);
99
+ return true;
100
+ };
101
+
102
+ post(0);
103
+ expect(Object.keys(posted)).toEqual(["a", "b", "c"]);
104
+ expect(take("a", 2, 10)).toBe(false);
105
+ expect(take("a", 8, 10)).toBe(true);
106
+ post(50);
107
+ expect(board.isFilled("a")).toBe(false);
108
+ post(110);
109
+ expect(board.isFilled("a")).toBe(true);
110
+ ```
111
+
112
+ ## Buff stack
113
+
114
+ Modifiers stack named add and multiply sources on a stat, and a plain countdown removes a buff when it runs out.
115
+
116
+ ```ts
117
+ import { createStatGraph } from "@jgengine/core/parts";
118
+ import { tickCooldown } from "@jgengine/core/time/cooldown";
119
+
120
+ const sheet = createStatGraph({ inputs: [{ id: "speed", base: 10 }] }).create();
121
+ const buffs = new Map([["haste", 5], ["boots", 60]]);
122
+ sheet.addSource("boots", { speed: { source: "boots", op: "add", value: 2 } });
123
+ sheet.addSource("haste", { speed: { source: "haste", op: "mul", value: 1.5 } });
124
+ const tick = (dt: number) => {
125
+ for (const [id, left] of buffs) {
126
+ buffs.set(id, tickCooldown(left, dt));
127
+ if (buffs.get(id) === 0) sheet.removeSource(id);
128
+ }
129
+ };
130
+
131
+ expect(sheet.get("speed")).toBe(18);
132
+ tick(5);
133
+ expect(sheet.hasSource("haste")).toBe(false);
134
+ expect(sheet.get("speed")).toBe(12);
135
+ expect(sheet.explain("speed").steps.map((s) => s.source)).toContain("boots");
136
+ ```
137
+
138
+ ## Checkpoint respawn
139
+
140
+ Placement records where you may come back, and a Meter says when you are down; respawning refills the meter and moves you there.
141
+
142
+ ```ts
143
+ import { changeStatPool, createSpawnPoints, createStatPool } from "@jgengine/core/parts";
144
+
145
+ const spawns = createSpawnPoints();
146
+ const poses = new Map<string, { position: { x: number; y: number; z: number } }>();
147
+ let health = createStatPool({ max: 100 });
148
+ spawns.record("camp", { x: 40, y: 0, z: -5 });
149
+ const hurt = (amount: number, checkpoint: string) => {
150
+ health = changeStatPool(health, -amount).pool;
151
+ if (health.current > 0) return false;
152
+ health = createStatPool({ max: health.max });
153
+ return spawns.respawn({ setPose: (id, pose) => !!poses.set(id, pose) }, "player", checkpoint);
154
+ };
155
+
156
+ expect(hurt(60, "camp")).toBe(false);
157
+ expect(hurt(60, "camp")).toBe(true);
158
+ expect(poses.get("player")?.position).toEqual({ x: 40, y: 0, z: -5 });
159
+ expect(health.current).toBe(100);
160
+ expect(hurt(100, "missing")).toBe(false);
161
+ ```
162
+
163
+ ## Combo meter
164
+
165
+ A Meter that fills on every hit and drains once you stop for a moment, with a Ladder naming the multiplier.
166
+
167
+ ```ts
168
+ import { changeMeter, createMeter, tickMeter, tierAt } from "@jgengine/core/parts";
169
+
170
+ let combo = createMeter({ max: 20, current: 0 });
171
+ const multipliers = [{ id: 1, at: 0 }, { id: 2, at: 5 }, { id: 3, at: 10 }];
172
+ const hit = () => (combo = changeMeter(combo, 1).meter);
173
+ const wait = (dt: number) => (combo = tickMeter(combo, { decay: 4, decayDelay: 2 }, dt).meter);
174
+ const multiplier = () => tierAt(multipliers, combo.pool.current)?.id;
175
+
176
+ for (let n = 0; n < 12; n += 1) hit();
177
+ expect(multiplier()).toBe(3);
178
+ wait(1.9);
179
+ expect(multiplier()).toBe(3);
180
+ wait(1.1);
181
+ expect(multiplier()).toBe(2);
182
+ wait(10);
183
+ expect(multiplier()).toBe(1);
184
+ ```
185
+
186
+ ## Cooldown ability
187
+
188
+ A cost drawn from a Meter and a countdown that gates the next cast.
189
+
190
+ ```ts
191
+ import { changeStatPool, createStatPool } from "@jgengine/core/parts";
192
+ import { tickCooldown } from "@jgengine/core/time/cooldown";
193
+
194
+ let mana = createStatPool({ max: 100 });
195
+ let cooldown = 0;
196
+ const cast = () => {
197
+ if (cooldown > 0 || mana.current < 30) return false;
198
+ mana = changeStatPool(mana, -30).pool;
199
+ cooldown = 5;
200
+ return true;
201
+ };
202
+
203
+ expect(cast()).toBe(true);
204
+ expect(cast()).toBe(false);
205
+ cooldown = tickCooldown(cooldown, 4);
206
+ expect(cast()).toBe(false);
207
+ cooldown = tickCooldown(cooldown, 1);
208
+ expect([cast(), mana.current]).toEqual([true, 40]);
209
+ cooldown = tickCooldown(cooldown, 5);
210
+ expect(cast()).toBe(true);
211
+ cooldown = tickCooldown(cooldown, 5);
212
+ expect(cast()).toBe(false);
213
+ ```
214
+
215
+ ## Day and night
216
+
217
+ Phases that loop: each day part holds for its own seconds and the cycle wraps from night back to dawn.
218
+
219
+ ```ts
220
+ import { createPhaseSchedule } from "@jgengine/core/parts";
221
+
222
+ const clock = createPhaseSchedule({
223
+ loop: true,
224
+ phases: [
225
+ { state: "dawn", durationSeconds: 60 },
226
+ { state: "day", durationSeconds: 300 },
227
+ { state: "dusk", durationSeconds: 60 },
228
+ { state: "night", durationSeconds: 180 },
229
+ ],
230
+ });
231
+
232
+ expect(clock.state()).toBe("dawn");
233
+ expect(clock.tick(60).map((t) => t.to)).toEqual(["day"]);
234
+ expect(clock.tick(360).map((t) => t.to)).toEqual(["dusk", "night"]);
235
+ expect(clock.tick(180).map((t) => t.wrapped)).toEqual([true]);
236
+ expect(clock.state()).toBe("dawn");
237
+ ```
238
+
239
+ ## Difficulty scaling
240
+
241
+ A Ladder over a skill score picks the difficulty, and that level's Modifiers source scales enemy health and damage.
242
+
243
+ ```ts
244
+ import { createStatGraph, tierAt } from "@jgengine/core/parts";
245
+
246
+ const enemy = createStatGraph({ inputs: [{ id: "health", base: 100 }, { id: "damage", base: 10 }] }).create();
247
+ const levels = [{ id: "easy", at: 0, health: 0.75, damage: 0.5 }, { id: "normal", at: 40, health: 1, damage: 1 }, { id: "hard", at: 80, health: 1.5, damage: 1.5 }];
248
+ const rescale = (score: number) => {
249
+ const level = tierAt(levels, score)?.id ?? "easy";
250
+ const set = levels.find((l) => l.id === level) ?? levels[0];
251
+ enemy.addSource("difficulty", { health: { source: level, op: "mul", value: set?.health ?? 1 }, damage: { source: level, op: "mul", value: set?.damage ?? 1 } });
252
+ return level;
253
+ };
254
+
255
+ expect(rescale(10)).toBe("easy");
256
+ expect(enemy.get("health")).toBe(75);
257
+ expect(rescale(90)).toBe("hard");
258
+ expect([enemy.get("health"), enemy.get("damage")]).toEqual([150, 15]);
259
+ ```
260
+
261
+ ## Dog breeding
262
+
263
+ Inheritance gives the pup its values from both parents with a rare mutation, and Phases grow it from puppy to adult.
264
+
265
+ ```ts
266
+ import { createPhaseSchedule, inheritValues } from "@jgengine/core/parts";
267
+ import { seededRng } from "@jgengine/core/random/rng";
268
+
269
+ const rng = seededRng("pups");
270
+ const mother = { health: 80, speed: 6, bite: 4 };
271
+ const father = { health: 100, speed: 5, bite: 7 };
272
+ const pup = inheritValues(mother, father, rng, { mutation: { rolls: 2, chance: 0.05, amount: 3, fields: ["health", "speed", "bite"] } });
273
+ const growth = createPhaseSchedule({ phases: [{ state: "puppy", durationSeconds: 600 }, { state: "adult" }] });
274
+
275
+ expect([80, 100]).toContain(pup.values.health - 3 * pup.mutations.filter((f) => f === "health").length);
276
+ expect(growth.state()).toBe("puppy");
277
+ growth.tick(599);
278
+ expect(growth.state()).toBe("puppy");
279
+ growth.tick(1);
280
+ expect(growth.state()).toBe("adult");
281
+ ```
282
+
283
+ ## Crafting recipe with station requirement
284
+
285
+ A Requirement names the station and skill, and the materials are Meters the craft drains.
286
+
287
+ ```ts
288
+ import { changeStatPool, createStatPool, evaluatePredicate, type Predicate } from "@jgengine/core/parts";
289
+
290
+ let iron = createStatPool({ max: 99, current: 5 });
291
+ const sword: Predicate = { all: [{ eq: ["station", "forge"] }, { gte: ["skill.smithing", 2] }, { gte: ["iron", 3] }] };
292
+ const craft = (station: string) => {
293
+ if (!evaluatePredicate(sword, { station, skill: { smithing: 2 }, iron: iron.current })) return false;
294
+ iron = changeStatPool(iron, -3).pool;
295
+ return true;
296
+ };
297
+
298
+ expect(craft("workbench")).toBe(false);
299
+ expect(craft("forge")).toBe(true);
300
+ expect(iron.current).toBe(2);
301
+ expect(craft("forge")).toBe(false);
302
+ ```
303
+
304
+ ## Hunger and thirst
305
+
306
+ Two Meters that decay at their own rates, a Ladder that names how bad it is, and eating is a fill.
307
+
308
+ ```ts
309
+ import { changeMeter, createMeter, tickMeter, tierAt } from "@jgengine/core/parts";
310
+
311
+ let hunger = createMeter({ max: 100 });
312
+ let thirst = createMeter({ max: 100 });
313
+ const bands = [{ id: "starving", at: 0 }, { id: "hungry", at: 25 }, { id: "fed", at: 60 }];
314
+ const live = (dt: number) => {
315
+ hunger = tickMeter(hunger, { decay: 0.5 }, dt).meter;
316
+ thirst = tickMeter(thirst, { decay: 1 }, dt).meter;
317
+ };
318
+
319
+ live(140);
320
+ expect(tierAt(bands, hunger.pool.current)?.id).toBe("hungry");
321
+ expect(thirst.pool.current).toBe(0);
322
+ live(30);
323
+ expect(tierAt(bands, hunger.pool.current)?.id).toBe("starving");
324
+ hunger = changeMeter(hunger, 20).meter;
325
+ expect(tierAt(bands, hunger.pool.current)?.id).toBe("hungry");
326
+ ```
327
+
328
+ ## Patrol route
329
+
330
+ A Motion track per leg between waypoints; when one arrives the next starts, and the last leg returns to the first.
331
+
332
+ ```ts
333
+ import { createMotionTrack } from "@jgengine/core/parts";
334
+ import type { Vec3 } from "@jgengine/core/world/geometry";
335
+
336
+ const stops: Vec3[] = [[0, 0, 0], [10, 0, 0], [10, 0, 10]];
337
+ const leg = (i: number, startAt: number) =>
338
+ createMotionTrack({ points: [stops[i % 3] ?? [0, 0, 0], stops[(i + 1) % 3] ?? [0, 0, 0]], speed: 2, startAt });
339
+ let index = 0;
340
+ let track = leg(0, 0);
341
+ const at = (now: number): Vec3 => {
342
+ if (track.justArrived(now)) track = leg(++index, now);
343
+ const out: [number, number, number] = [0, 0, 0];
344
+ track.sampleInto(now, out);
345
+ return out;
346
+ };
347
+
348
+ expect(at(2.5)).toEqual([5, 0, 0]);
349
+ expect(at(5)).toEqual([10, 0, 0]);
350
+ expect(at(7.5)).toEqual([10, 0, 5]);
351
+ expect(at(10)).toEqual([10, 0, 10]);
352
+ expect(at(13.5)[0]).toBeCloseTo(10 - 7 / Math.SQRT2, 5);
353
+ ```
354
+
355
+ ## Quest gate
356
+
357
+ A Requirement over facts the game names: level, quests done, items held. No code in the saved quest, only data.
358
+
359
+ ```ts
360
+ import { evaluatePredicate, type Predicate } from "@jgengine/core/parts";
361
+
362
+ const dragonQuest: Predicate = {
363
+ all: [{ gte: ["player.level", 10] }, { includes: ["quests.done", "intro"] }, { includes: ["items", "dragonKey"] }],
364
+ };
365
+ const mayStart = (facts: Record<string, unknown>) => evaluatePredicate(dragonQuest, facts);
366
+ const facts = { player: { level: 12 }, quests: { done: ["intro"] }, items: ["torch"] };
367
+
368
+ expect(mayStart(facts)).toBe(false);
369
+ facts.items.push("dragonKey");
370
+ expect(mayStart(facts)).toBe(true);
371
+ facts.player.level = 4;
372
+ expect(mayStart(facts)).toBe(false);
373
+ ```
374
+
375
+ ## Weather event
376
+
377
+ A Schedule decides when weather rolls in, a Table picks which, and Phases hold it for a while before clear skies return.
378
+
379
+ ```ts
380
+ import { createPacedSchedule, createPhaseSchedule, pickWeighted } from "@jgengine/core/parts";
381
+ import { seededRng } from "@jgengine/core/random/rng";
382
+
383
+ const rng = seededRng("weather");
384
+ const rolls = createPacedSchedule({ firstAt: 300, every: 600, jitter: 60 }, rng);
385
+ const sky = createPhaseSchedule({
386
+ phases: [{ state: "clear" }, { state: "rain", durationSeconds: 120, next: "clear" }, { state: "storm", durationSeconds: 60, next: "clear" }],
387
+ });
388
+ const weather = [{ id: "rain", weight: 3 }, { id: "storm", weight: 1 }];
389
+ const update = (now: number, dt: number) => {
390
+ if (rolls.due(now).length > 0) sky.enter(pickWeighted(rng, weather, (w) => w.weight)?.id === "storm" ? "storm" : "rain");
391
+ sky.tick(dt);
392
+ };
393
+
394
+ update(299, 1);
395
+ expect(sky.state()).toBe("clear");
396
+ update(300, 1);
397
+ expect(sky.state()).not.toBe("clear");
398
+ update(301, 130);
399
+ expect(sky.state()).toBe("clear");
400
+ ```
401
+
402
+ ## Rank ladder
403
+
404
+ A Ladder over rating points names the rank, and the crossings between old and new points are the promotions.
405
+
406
+ ```ts
407
+ import { crossThresholds, tierAt } from "@jgengine/core/parts";
408
+
409
+ const ranks = [{ id: "bronze", at: 0 }, { id: "silver", at: 100 }, { id: "gold", at: 300 }];
410
+ let points = 90;
411
+ const win = (gain: number) => {
412
+ const promoted = crossThresholds(ranks, points, points + gain).map((c) => c.id);
413
+ points += gain;
414
+ return promoted;
415
+ };
416
+
417
+ expect(tierAt(ranks, points)?.id).toBe("bronze");
418
+ expect(win(20)).toEqual(["silver"]);
419
+ expect(win(250)).toEqual(["gold"]);
420
+ expect(tierAt(ranks, points)?.id).toBe("gold");
421
+ expect(crossThresholds(ranks, points, 50).map((c) => c.id)).toEqual(["gold", "silver"]);
422
+ ```
423
+
424
+ ## Loot table by rarity
425
+
426
+ A Ladder turns a roll into a rarity, and a Table per rarity picks the item.
427
+
428
+ ```ts
429
+ import { pickWeighted, tierAt } from "@jgengine/core/parts";
430
+ import { seededRng } from "@jgengine/core/random/rng";
431
+
432
+ const rng = seededRng("loot");
433
+ const rarities = [{ id: "common", at: 0 }, { id: "rare", at: 80 }, { id: "epic", at: 97 }];
434
+ const pools: Record<string, string[]> = { common: ["stick", "rag"], rare: ["sword"], epic: ["crown"] };
435
+ const drop = () => {
436
+ const rarity = tierAt(rarities, rng() * 100)?.id ?? "common";
437
+ return { rarity, item: pickWeighted(rng, pools[rarity] ?? [], () => 1) };
438
+ };
439
+
440
+ const drops = Array.from({ length: 2000 }, drop);
441
+ const count = (rarity: string) => drops.filter((d) => d.rarity === rarity).length;
442
+ expect(drops.every((d) => pools[d.rarity]?.includes(d.item ?? ""))).toBe(true);
443
+ expect(count("common")).toBeGreaterThan(count("rare"));
444
+ expect(count("rare")).toBeGreaterThan(count("epic"));
445
+ expect(count("epic")).toBeGreaterThan(0);
446
+ ```
447
+
448
+ ## Reputation tiers
449
+
450
+ A Ladder over a standing number, with a dead band so a value hovering on a line does not flip the tier back and forth.
451
+
452
+ ```ts
453
+ import { crossThresholds, tierAt } from "@jgengine/core/parts";
454
+
455
+ const tiers = [{ id: "hostile", at: -100 }, { id: "neutral", at: -10 }, { id: "friendly", at: 25 }];
456
+ let standing = 20;
457
+ const shift = (by: number) => {
458
+ const changed = crossThresholds(tiers, standing, standing + by, { hysteresis: 5 }).map((c) => `${c.id}:${c.direction}`);
459
+ standing += by;
460
+ return changed;
461
+ };
462
+
463
+ expect(tierAt(tiers, standing)?.id).toBe("neutral");
464
+ expect(shift(6)).toEqual([]);
465
+ expect(shift(5)).toEqual(["friendly:up"]);
466
+ expect(shift(-8)).toEqual([]);
467
+ expect(shift(-10)).toEqual(["friendly:down"]);
468
+ ```
469
+
470
+ ## Respawning resource node
471
+
472
+ Slots hold one node per spot and make it ready again a while after it is mined; a Table picks what it yields.
473
+
474
+ ```ts
475
+ import { createRefillSlots, pickWeighted } from "@jgengine/core/parts";
476
+ import { seededRng } from "@jgengine/core/random/rng";
477
+
478
+ const rng = seededRng("ore");
479
+ const nodes = createRefillSlots({ slots: ["vein-a", "vein-b"], refillSeconds: [60, 120] }, rng);
480
+ const yields = [{ item: "iron", weight: 4 }, { item: "gold", weight: 1 }];
481
+ const mine = (node: string, now: number) => {
482
+ nodes.empty(node, now);
483
+ return pickWeighted(rng, yields, (y) => y.weight)?.item;
484
+ };
485
+
486
+ for (const node of nodes.ready(0)) nodes.fill(node);
487
+ expect(nodes.ready(0)).toEqual([]);
488
+ expect(["iron", "gold"]).toContain(mine("vein-a", 10));
489
+ expect(nodes.isFilled("vein-a")).toBe(false);
490
+ expect(nodes.ready(69)).toEqual([]);
491
+ expect(nodes.ready(131)).toEqual(["vein-a"]);
492
+ ```
493
+
494
+ ## Shop with prices and stock
495
+
496
+ Gold is a Meter, each item's stock is a Meter that regenerates (the restock), and the price is data beside it.
497
+
498
+ ```ts
499
+ import { changeStatPool, createMeter, createStatPool, tickMeter } from "@jgengine/core/parts";
500
+
501
+ let gold = createStatPool({ max: 1000, current: 120 });
502
+ let potion = createMeter({ max: 3 });
503
+ const price = 50;
504
+ const buy = () => {
505
+ if (potion.pool.current < 1 || gold.current < price) return false;
506
+ gold = changeStatPool(gold, -price).pool;
507
+ potion = { ...potion, pool: changeStatPool(potion.pool, -1).pool };
508
+ return true;
509
+ };
510
+
511
+ expect([buy(), buy(), buy()]).toEqual([true, true, false]);
512
+ expect(gold.current).toBe(20);
513
+ gold = changeStatPool(gold, 500).pool;
514
+ expect(buy()).toBe(true);
515
+ expect(potion.pool.current).toBe(0);
516
+ potion = tickMeter(potion, { regen: 1 / 60 }, 120).meter;
517
+ expect(potion.pool.current).toBe(2);
518
+ ```
519
+
520
+ ## Skill tree unlock
521
+
522
+ Skill points are a Meter, each node's prerequisite is a Requirement, and an unlocked node adds a Modifiers source.
523
+
524
+ ```ts
525
+ import { changeStatPool, createStatGraph, createStatPool, evaluatePredicate, type Predicate } from "@jgengine/core/parts";
526
+
527
+ let points = createStatPool({ max: 10, current: 2 });
528
+ const sheet = createStatGraph({ inputs: [{ id: "health", base: 100 }] }).create();
529
+ const nodes: Record<string, { needs: Predicate; health: number }> = {
530
+ tough: { needs: { gte: ["level", 1] }, health: 20 },
531
+ tougher: { needs: { includes: ["unlocked", "tough"] }, health: 30 },
532
+ };
533
+ const unlocked: string[] = [];
534
+ const unlock = (id: string) => {
535
+ const node = nodes[id];
536
+ if (node === undefined || points.current < 1 || !evaluatePredicate(node.needs, { level: 1, unlocked })) return false;
537
+ points = changeStatPool(points, -1).pool;
538
+ sheet.addSource(id, { health: { source: id, op: "add", value: node.health } });
539
+ unlocked.push(id);
540
+ return true;
541
+ };
542
+
543
+ expect(unlock("tougher")).toBe(false);
544
+ expect([unlock("tough"), unlock("tougher")]).toEqual([true, true]);
545
+ expect(sheet.get("health")).toBe(150);
546
+ expect(points.current).toBe(0);
547
+ ```
548
+
549
+ ## Spawn director by pressure
550
+
551
+ A pressure Meter, a Ladder of pressure bands, and a Schedule retuned to each band's pace, with a Table per band.
552
+
553
+ ```ts
554
+ import { changeStatPool, createPacedSchedule, createStatPool, pickWeighted, tierAt } from "@jgengine/core/parts";
555
+ import { seededRng } from "@jgengine/core/random/rng";
556
+
557
+ const rng = seededRng("director");
558
+ let pressure = createStatPool({ max: 100, current: 0 });
559
+ const bands = [{ id: "calm", at: 0, every: 20, mix: ["rat"] }, { id: "tense", at: 40, every: 8, mix: ["rat", "wolf"] }, { id: "frantic", at: 80, every: 3, mix: ["wolf", "ogre"] }];
560
+ const pace = createPacedSchedule({ firstAt: 0, every: 20 }, rng);
561
+ const spawn = (now: number) => {
562
+ const band = tierAt(bands, pressure.current)?.id;
563
+ const chosen = bands.find((b) => b.id === band) ?? bands[0];
564
+ pace.retune({ every: chosen?.every ?? 20 });
565
+ return pace.due(now).map(() => pickWeighted(rng, chosen?.mix ?? [], () => 1));
566
+ };
567
+
568
+ expect(spawn(0)).toEqual(["rat"]);
569
+ pressure = changeStatPool(pressure, 85).pool;
570
+ expect(spawn(20)).toHaveLength(1);
571
+ expect(pace.remaining(20)).toBe(3);
572
+ expect(spawn(26).every((id) => id === "wolf" || id === "ogre")).toBe(true);
573
+ ```
574
+
575
+ ## Stamina sprint
576
+
577
+ A Meter drained while sprinting that only regenerates a moment after the last drain.
578
+
579
+ ```ts
580
+ import { changeMeter, createMeter, tickMeter } from "@jgengine/core/parts";
581
+
582
+ let stamina = createMeter({ max: 100 });
583
+ const rest = (dt: number) => (stamina = tickMeter(stamina, { regen: 15, regenDelay: 1.5 }, dt).meter);
584
+ const sprint = (dt: number) => {
585
+ if (stamina.pool.current <= 0) return false;
586
+ stamina = changeMeter(stamina, -20 * dt).meter;
587
+ return true;
588
+ };
589
+
590
+ for (let second = 0; second < 5; second += 1) sprint(1);
591
+ expect(stamina.pool.current).toBe(0);
592
+ expect(sprint(1)).toBe(false);
593
+ rest(1);
594
+ expect(stamina.pool.current).toBe(0);
595
+ rest(3);
596
+ expect(stamina.pool.current).toBeGreaterThan(0);
597
+ expect(sprint(1)).toBe(true);
598
+ ```
599
+
600
+ ## Supply drop
601
+
602
+ A Schedule says when, Placement where, a Ladder names the rarity, a Table fills the crate, a Motion track is the fall, a Requirement gates opening it, and Slots hold one crate per site.
603
+
604
+ ```ts
605
+ import { createMotionTrack, createPacedSchedule, createRefillSlots, discRegion, evaluatePredicate, pickWeighted, samplePoint, tierAt } from "@jgengine/core/parts";
606
+ import { seededRng } from "@jgengine/core/random/rng";
607
+
608
+ const rng = seededRng("supply-drop");
609
+ const schedule = createPacedSchedule({ firstAt: 60, every: 120 }, rng);
610
+ const sites = createRefillSlots({ slots: ["north", "south"], refillSeconds: [30, 30] }, rng);
611
+ const rarity = tierAt([{ id: "white", at: 0 }, { id: "purple", at: 80 }], rng() * 100)?.id ?? "white";
612
+ const pool = { white: ["bandage", "ammo"], purple: ["rifle"] }[rarity] ?? [];
613
+ const spawnSupplyDrop = (site: string, now: number) => {
614
+ const [x = 0, z = 0] = samplePoint({ region: discRegion([0, 0], 40), rng }).point ?? [];
615
+ sites.fill(site);
616
+ return { site, rarity, loot: pickWeighted(rng, pool, () => 1), fall: createMotionTrack({ points: [[x, 80, z], [x, 0, z]], duration: 8, startAt: now }) };
617
+ };
618
+ const mayOpen = (level: number) => evaluatePredicate({ gte: ["player.level", rarity === "purple" ? 10 : 1] }, { player: { level } });
619
+
620
+ expect(schedule.due(60)).toEqual([0]);
621
+ const drop = spawnSupplyDrop(sites.ready(60)[0] ?? "north", 60);
622
+ expect(drop.fall.arrivesAt()).toBe(68);
623
+ expect(pool).toContain(drop.loot);
624
+ expect(sites.isFilled("north")).toBe(true);
625
+ sites.empty("north", 70);
626
+ expect(sites.ready(100)).toContain("north");
627
+ expect(mayOpen(rarity === "purple" ? 3 : 1)).toBe(rarity !== "purple");
628
+ ```
629
+
630
+ ## Taming by feeding
631
+
632
+ A taming Meter filled by food, worth more when a Requirement says it is the creature's favourite, and draining if you stop.
633
+
634
+ ```ts
635
+ import { changeMeter, createMeter, evaluatePredicate, tickMeter } from "@jgengine/core/parts";
636
+
637
+ let trust = createMeter({ max: 100, current: 0 });
638
+ const favourite = { in: ["food", ["berry", "meat"]] } as const;
639
+ const worth = (food: string) => (evaluatePredicate(favourite, { food }) ? 30 : 10);
640
+ const feed = (food: string) => (trust = changeMeter(trust, worth(food)).meter);
641
+ const wait = (dt: number) => (trust = tickMeter(trust, { decay: 1, decayDelay: 20 }, dt).meter);
642
+
643
+ feed("bread");
644
+ expect(trust.pool.current).toBe(10);
645
+ wait(40);
646
+ expect(trust.pool.current).toBe(0);
647
+ for (let n = 0; n < 3; n += 1) feed("berry");
648
+ expect(trust.pool.current).toBe(90);
649
+ feed("meat");
650
+ expect(changeMeter(trust, 0).change.hitMax).toBe(true);
651
+ ```
652
+
653
+ ## Thrown item arc
654
+
655
+ A Motion track through points sampled along a parabola; the track reports where the item is and when it lands.
656
+
657
+ ```ts
658
+ import { createMotionTrack } from "@jgengine/core/parts";
659
+
660
+ const arc = Array.from({ length: 9 }, (_, i) => {
661
+ const t = i / 8;
662
+ return [12 * t, 1 + 12 * t * (1 - t) - t, 0] satisfies [number, number, number];
663
+ });
664
+ const flight = createMotionTrack({ points: arc, duration: 1.2, startAt: 0 });
665
+ const where: [number, number, number] = [0, 0, 0];
666
+
667
+ flight.sampleInto(0.6, where);
668
+ expect(where[1]).toBeGreaterThan(3);
669
+ expect(flight.justArrived(1.1)).toBe(false);
670
+ flight.sampleInto(1.2, where);
671
+ expect(where).toEqual([12, 0, 0]);
672
+ expect(flight.justArrived(1.2)).toBe(true);
673
+ ```
674
+
675
+ ## Door that opens over time
676
+
677
+ A Requirement decides who may open it, and a Motion track with easing slides it up and, reversed, back down.
678
+
679
+ ```ts
680
+ import { easeInOutCubic } from "@jgengine/core/anim/easing";
681
+ import { createMotionTrack, evaluatePredicate } from "@jgengine/core/parts";
682
+
683
+ const needsKey = { includes: ["keys", "red"] } as const;
684
+ const slide = (up: boolean, startAt: number) =>
685
+ createMotionTrack({ points: up ? [[0, 0, 0], [0, 3, 0]] : [[0, 3, 0], [0, 0, 0]], duration: 1.5, easing: easeInOutCubic, startAt });
686
+ const open = (keys: string[], now: number) => (evaluatePredicate(needsKey, { keys }) ? slide(true, now) : null);
687
+ const height = (door: ReturnType<typeof slide>, now: number) => {
688
+ const out: [number, number, number] = [0, 0, 0];
689
+ door.sampleInto(now, out);
690
+ return out[1];
691
+ };
692
+
693
+ expect(open(["blue"], 0)).toBeNull();
694
+ const door = open(["red"], 10);
695
+ expect(door && height(door, 10.75)).toBeCloseTo(1.5, 5);
696
+ expect(door && height(door, 12)).toBe(3);
697
+ expect(height(slide(false, 20), 20.75)).toBeCloseTo(1.5, 5);
698
+ ```
699
+
700
+ ## Timed event
701
+
702
+ A Schedule opens the event, Phases hold it open for a while, and while it is open a Modifiers source doubles experience.
703
+
704
+ ```ts
705
+ import { createPacedSchedule, createPhaseSchedule, createStatGraph } from "@jgengine/core/parts";
706
+ import { seededRng } from "@jgengine/core/random/rng";
707
+
708
+ const opens = createPacedSchedule({ firstAt: 100, every: 1000 }, seededRng("event"));
709
+ const event = createPhaseSchedule({ phases: [{ state: "closed" }, { state: "open", durationSeconds: 300, next: "closed" }] });
710
+ const xp = createStatGraph({ inputs: [{ id: "gain", base: 10 }] }).create();
711
+ const update = (now: number, dt: number) => {
712
+ if (opens.due(now).length > 0) event.enter("open");
713
+ for (const t of event.tick(dt)) if (t.to === "closed") xp.removeSource("double");
714
+ if (event.state() === "open") xp.addSource("double", { gain: { source: "double", op: "mul", value: 2 } });
715
+ };
716
+
717
+ update(50, 50);
718
+ expect(xp.get("gain")).toBe(10);
719
+ update(100, 50);
720
+ expect(xp.get("gain")).toBe(20);
721
+ update(150, 300);
722
+ expect(event.state()).toBe("closed");
723
+ expect(xp.get("gain")).toBe(10);
724
+ ```
725
+
726
+ ## Wave schedule
727
+
728
+ A Schedule fires each wave on a pace, a Table picks the enemy for every spawn, and the wave index sets how many.
729
+
730
+ ```ts
731
+ import { createPacedSchedule, pickWeighted } from "@jgengine/core/parts";
732
+ import { seededRng } from "@jgengine/core/random/rng";
733
+
734
+ const rng = seededRng("waves");
735
+ const waves = createPacedSchedule({ firstAt: 10, every: 30, count: 3 }, rng);
736
+ const enemies = [{ id: "grunt", weight: 5 }, { id: "brute", weight: 1 }];
737
+ const spawned: string[] = [];
738
+ const tick = (now: number) => {
739
+ for (const wave of waves.due(now)) {
740
+ for (let n = 0; n < 5 + wave * 2; n += 1) spawned.push(pickWeighted(rng, enemies, (e) => e.weight)?.id ?? "grunt");
741
+ }
742
+ };
743
+
744
+ tick(9);
745
+ expect(spawned).toHaveLength(0);
746
+ tick(10);
747
+ expect(spawned).toHaveLength(5);
748
+ tick(100);
749
+ expect(spawned).toHaveLength(5 + 7 + 9);
750
+ expect(waves.done()).toBe(true);
751
+ expect(spawned.filter((id) => id === "grunt").length).toBeGreaterThan(spawned.length / 2);
752
+ ```