@jgengine/core 0.29.0 → 0.31.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 (59) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/agents/agent.d.ts +134 -0
  3. package/dist/agents/agent.js +224 -0
  4. package/dist/agents/mood.d.ts +74 -0
  5. package/dist/agents/mood.js +97 -0
  6. package/dist/agents/relationships.d.ts +92 -0
  7. package/dist/agents/relationships.js +132 -0
  8. package/dist/agents/schedule.d.ts +58 -0
  9. package/dist/agents/schedule.js +72 -0
  10. package/dist/ai/director/boss.d.ts +63 -0
  11. package/dist/ai/director/boss.js +87 -0
  12. package/dist/ai/director/encounter.d.ts +146 -0
  13. package/dist/ai/director/encounter.js +195 -0
  14. package/dist/ai/director/pressure.d.ts +59 -0
  15. package/dist/ai/director/pressure.js +71 -0
  16. package/dist/ai/director/roles.d.ts +61 -0
  17. package/dist/ai/director/roles.js +118 -0
  18. package/dist/ai/director/sound.d.ts +79 -0
  19. package/dist/ai/director/sound.js +113 -0
  20. package/dist/game/gameSave.d.ts +6 -0
  21. package/dist/game/gameSave.js +6 -0
  22. package/dist/game/playableGame.d.ts +2 -2
  23. package/dist/game/saveStore.d.ts +7 -0
  24. package/dist/game/saveStore.js +35 -0
  25. package/dist/gameplay.d.ts +1 -1
  26. package/dist/gameplay.js +1 -1
  27. package/dist/item/affix.d.ts +2 -0
  28. package/dist/loot/drop.d.ts +47 -0
  29. package/dist/loot/drop.js +57 -0
  30. package/dist/loot/haul.d.ts +78 -0
  31. package/dist/loot/haul.js +126 -0
  32. package/dist/loot/lootGen.d.ts +7 -1
  33. package/dist/loot/lootGen.js +24 -5
  34. package/dist/loot/salvage.d.ts +74 -0
  35. package/dist/loot/salvage.js +68 -0
  36. package/dist/loot/vendor.d.ts +76 -0
  37. package/dist/loot/vendor.js +72 -0
  38. package/dist/meta/changelog.d.ts +1 -1
  39. package/dist/meta/changelog.js +47 -1
  40. package/dist/mission/mission.d.ts +24 -0
  41. package/dist/mission/mission.js +28 -3
  42. package/dist/progression/careerBank.d.ts +63 -0
  43. package/dist/progression/careerBank.js +83 -0
  44. package/dist/progression/gates.d.ts +66 -0
  45. package/dist/progression/gates.js +88 -0
  46. package/dist/progression/standing.d.ts +77 -0
  47. package/dist/progression/standing.js +151 -0
  48. package/dist/progression/upgrades.d.ts +70 -0
  49. package/dist/progression/upgrades.js +128 -0
  50. package/dist/ui/accessibility.d.ts +8 -0
  51. package/dist/ui/accessibility.js +14 -0
  52. package/package.json +1 -1
  53. package/skills/jgengine/api.md +3 -3
  54. package/skills/jgengine-gameplay/SKILL.md +12 -1
  55. package/skills/jgengine-gameplay/api.md +141 -3
  56. package/skills/jgengine-gameplay/capabilities.md +74 -0
  57. package/skills/jgengine-gameplay/recipes/agent-lives.md +76 -0
  58. package/skills/jgengine-world/api.md +52 -0
  59. package/skills/jgengine-world/capabilities.md +32 -0
@@ -18,6 +18,10 @@ export function assertLootCatalog(catalog) {
18
18
  baseIds.add(base.id);
19
19
  if (base.weight !== undefined)
20
20
  check(base.weight > 0, `Loot base "${base.id}" weight must be positive`);
21
+ if (base.minLevel !== undefined)
22
+ check(Number.isInteger(base.minLevel) && base.minLevel >= 1, `Loot base "${base.id}" minLevel must be an integer >= 1`);
23
+ if (base.mass !== undefined)
24
+ check(Number.isFinite(base.mass) && base.mass > 0, `Loot base "${base.id}" mass must be positive`);
21
25
  }
22
26
  const poolIds = new Set();
23
27
  const affixIds = new Set();
@@ -29,6 +33,8 @@ export function assertLootCatalog(catalog) {
29
33
  check(!affixIds.has(affix.id), `Loot affix "${affix.id}" is declared twice`);
30
34
  affixIds.add(affix.id);
31
35
  check(affix.weight > 0, `Loot affix "${affix.id}" weight must be positive`);
36
+ if (affix.minLevel !== undefined)
37
+ check(Number.isInteger(affix.minLevel) && affix.minLevel >= 1, `Loot affix "${affix.id}" minLevel must be an integer >= 1`);
32
38
  if (typeof affix.roll === "number") {
33
39
  check(Number.isFinite(affix.roll), `Loot affix "${affix.id}" roll must be finite`);
34
40
  if (affix.op === "mul")
@@ -148,7 +154,7 @@ function weighted(rng, items, weightOf, field) {
148
154
  throw new Error(`Loot catalog has no eligible ${field}`);
149
155
  return picked;
150
156
  }
151
- function candidateAffixes(catalog, base, rarity) {
157
+ function candidateAffixes(catalog, base, rarity, level) {
152
158
  const out = [];
153
159
  for (const poolId of base.pools) {
154
160
  if (rarity.pools !== undefined && !rarity.pools.includes(poolId))
@@ -156,8 +162,11 @@ function candidateAffixes(catalog, base, rarity) {
156
162
  const pool = catalog.pools.find((entry) => entry.id === poolId);
157
163
  if (pool === undefined)
158
164
  continue;
159
- for (const affix of pool.affixes)
165
+ for (const affix of pool.affixes) {
166
+ if (affix.minLevel !== undefined && affix.minLevel > level)
167
+ continue;
160
168
  out.push({ affix, poolId });
169
+ }
161
170
  }
162
171
  return out;
163
172
  }
@@ -192,11 +201,13 @@ export function rollLoot(catalog, rng, options = {}) {
192
201
  const manufacturer = options.manufacturer === undefined
193
202
  ? weighted(rng, catalog.manufacturers.filter((maker) => options.baseId === undefined || maker.bases === undefined || maker.bases.includes(options.baseId)), (maker) => maker.weight ?? 1, "manufacturer")
194
203
  : findOrThrow(catalog.manufacturers, options.manufacturer, "manufacturer");
195
- const basePool = catalog.bases.filter((base) => manufacturer.bases === undefined || manufacturer.bases.includes(base.id));
204
+ const basePool = catalog.bases.filter((base) => (base.minLevel === undefined || base.minLevel <= level)
205
+ && (manufacturer.bases === undefined || manufacturer.bases.includes(base.id)));
196
206
  const base = options.baseId === undefined
197
207
  ? weighted(rng, basePool, (entry) => entry.weight ?? 1, "base")
198
208
  : findOrThrow(catalog.bases, options.baseId, "base");
199
209
  check(manufacturer.bases === undefined || manufacturer.bases.includes(base.id), `Loot manufacturer "${manufacturer.id}" does not produce base "${base.id}"`);
210
+ check(base.minLevel === undefined || base.minLevel <= level, `Loot base "${base.id}" needs item level ${base.minLevel}, got ${level}`);
200
211
  const rarity = options.rarity === undefined
201
212
  ? weighted(rng, catalog.rarities, (tier) => tier.weight, "rarity")
202
213
  : findOrThrow(catalog.rarities, options.rarity, "rarity");
@@ -206,7 +217,7 @@ export function rollLoot(catalog, rng, options = {}) {
206
217
  const part = weighted(rng, eligible, (entry) => entry.weight ?? 1, `part for slot "${slot.id}"`);
207
218
  parts.push({ slotId: slot.id, partId: part.id, category: part.category });
208
219
  }
209
- const candidates = candidateAffixes(catalog, base, rarity);
220
+ const candidates = candidateAffixes(catalog, base, rarity, level);
210
221
  const favors = manufacturer.favoredPools ?? [];
211
222
  const favorWeight = manufacturer.favoredWeight ?? 3;
212
223
  const remaining = candidates.slice();
@@ -254,6 +265,7 @@ export function rollLoot(catalog, rng, options = {}) {
254
265
  }
255
266
  for (const stat of Object.keys(stats))
256
267
  stats[stat] = Math.round(stats[stat] * 1000) / 1000;
268
+ const mass = Math.round((base.mass ?? 1) * (1 + 0.1 * (level - 1)) * 100) / 100;
257
269
  return {
258
270
  baseId: base.id,
259
271
  manufacturer: manufacturer.id,
@@ -263,6 +275,7 @@ export function rollLoot(catalog, rng, options = {}) {
263
275
  parts,
264
276
  affixes,
265
277
  stats,
278
+ mass,
266
279
  };
267
280
  }
268
281
  /**
@@ -280,6 +293,9 @@ export function validateLootItem(item, catalog) {
280
293
  if (base === undefined) {
281
294
  issues.push({ code: "unknown-base", detail: `base "${item.baseId}" is not in the catalog` });
282
295
  }
296
+ else if (base.minLevel !== undefined && base.minLevel > item.level) {
297
+ issues.push({ code: "level-gated-base", detail: `base "${base.id}" needs item level ${base.minLevel}, got ${item.level}` });
298
+ }
283
299
  const manufacturer = catalog.manufacturers.find((entry) => entry.id === item.manufacturer);
284
300
  if (manufacturer === undefined) {
285
301
  issues.push({ code: "unknown-manufacturer", detail: `manufacturer "${item.manufacturer}" is not in the catalog` });
@@ -316,7 +332,7 @@ export function validateLootItem(item, catalog) {
316
332
  if (!seenSlots.has(slot.id))
317
333
  issues.push({ code: "missing-slot-part", detail: `slot "${slot.id}" has no part` });
318
334
  }
319
- const allowed = base === undefined || rarity === undefined ? null : candidateAffixes(catalog, base, rarity);
335
+ const allowed = base === undefined || rarity === undefined ? null : candidateAffixes(catalog, base, rarity, item.level);
320
336
  const seenAffixes = new Set();
321
337
  for (const affix of item.affixes) {
322
338
  if (seenAffixes.has(affix.id)) {
@@ -335,6 +351,9 @@ export function validateLootItem(item, catalog) {
335
351
  if (!Number.isFinite(value))
336
352
  issues.push({ code: "non-finite-stat", detail: `stat "${stat}" is ${String(value)}` });
337
353
  }
354
+ if (item.mass !== undefined && (!Number.isFinite(item.mass) || item.mass <= 0)) {
355
+ issues.push({ code: "invalid-mass", detail: `mass ${String(item.mass)} is not a positive number` });
356
+ }
338
357
  if (item.name.trim() === "")
339
358
  issues.push({ code: "empty-name", detail: "item has no name" });
340
359
  return issues;
@@ -0,0 +1,74 @@
1
+ /** Where an item instance stands: still usable, broken down, or sold. */
2
+ export type ItemLifecycle = "owned" | "salvaged" | "sold";
3
+ /** Items that have left play, keyed by instance id; anything absent is still `owned`. */
4
+ export type ItemLedgerState = Readonly<Record<string, Exclude<ItemLifecycle, "owned">>>;
5
+ /**
6
+ * One record of what happened to rolled item instances. Every path that removes an item - salvage,
7
+ * sale, granting a duplicate - claims it here first, so a second claim returns false and the item
8
+ * cannot be equipped, sold or broken down twice.
9
+ */
10
+ export interface ItemLedger {
11
+ status(id: string): ItemLifecycle;
12
+ /** True only while the instance is `owned`; a salvaged or sold item is no longer usable. */
13
+ canUse(id: string): boolean;
14
+ /** Claim `id` for `salvaged` or `sold`; false when it already left play. */
15
+ spend(id: string, how: Exclude<ItemLifecycle, "owned">): boolean;
16
+ snapshot(): ItemLedgerState;
17
+ /** Replace the ledger; false without mutation when an entry names an unknown outcome. */
18
+ restore(next: ItemLedgerState): boolean;
19
+ reset(): void;
20
+ }
21
+ /**
22
+ * Build an item ledger. Ids are consumed once: the first successful {@link ItemLedger.spend} wins,
23
+ * every later call fails, which is what makes salvage and sale mutually exclusive.
24
+ *
25
+ * @capability item-ledger track which rolled item instances are still owned, salvaged or sold
26
+ */
27
+ export declare function createItemLedger(initial?: ItemLedgerState): ItemLedger;
28
+ /** The currency and amount one salvage yields. `currency` is the game's key, never interpreted. */
29
+ export interface SalvageYield {
30
+ currency: string;
31
+ amount: number;
32
+ }
33
+ /** What a broken-down item is worth: a game-authored currency, rate and per-rarity multiplier. */
34
+ export interface SalvageTable {
35
+ /** Wallet currency key the yield is granted in, e.g. `"cores"`. */
36
+ currency: string;
37
+ /** Units paid per item level at multiplier `1`. */
38
+ perLevel: number;
39
+ /** Multiplier per rarity id; omitted rarities use `1`. */
40
+ rarityMultiplier?: Readonly<Record<string, number>>;
41
+ /** Floor for the yield (default `1`). */
42
+ minimum?: number;
43
+ }
44
+ /**
45
+ * The yield an item of this level and rarity breaks down into. The formula is
46
+ * `round(perLevel x level x rarityMultiplier)`, floored by `minimum`, so the same item always yields
47
+ * the same amount and a higher level never yields less.
48
+ *
49
+ * @capability item-salvage-value compute the currency an item of a level and rarity breaks down into
50
+ */
51
+ export declare function salvageValue(item: Pick<ItemDropLike, "level" | "rarity">, table: SalvageTable): SalvageYield;
52
+ /** The fields salvage reads; an `ItemDrop` from `./drop` satisfies it. */
53
+ export interface ItemDropLike {
54
+ id: string;
55
+ level: number;
56
+ rarity: string;
57
+ }
58
+ /** Salvage outcome: the yield, or the reason the instance could not be broken down. */
59
+ export type SalvageResult = {
60
+ ok: true;
61
+ id: string;
62
+ yield: SalvageYield;
63
+ } | {
64
+ ok: false;
65
+ reason: "already-spent" | "invalid-item";
66
+ };
67
+ /**
68
+ * Break one item down through its ledger. The ledger is claimed before the yield is computed, so a
69
+ * salvaged item cannot be salvaged, equipped or sold again; pass the game's own wallet update from
70
+ * `yield`.
71
+ *
72
+ * @capability item-salvage break an item down for the currency its level and rarity say, once
73
+ */
74
+ export declare function salvageItem(item: ItemDropLike, table: SalvageTable, ledger: ItemLedger): SalvageResult;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Build an item ledger. Ids are consumed once: the first successful {@link ItemLedger.spend} wins,
3
+ * every later call fails, which is what makes salvage and sale mutually exclusive.
4
+ *
5
+ * @capability item-ledger track which rolled item instances are still owned, salvaged or sold
6
+ */
7
+ export function createItemLedger(initial = {}) {
8
+ const spent = new Map();
9
+ for (const [id, how] of Object.entries(initial)) {
10
+ if (how === "salvaged" || how === "sold")
11
+ spent.set(id, how);
12
+ }
13
+ return {
14
+ status: (id) => spent.get(id) ?? "owned",
15
+ canUse: (id) => !spent.has(id),
16
+ spend(id, how) {
17
+ if (spent.has(id))
18
+ return false;
19
+ spent.set(id, how);
20
+ return true;
21
+ },
22
+ snapshot: () => Object.fromEntries(spent),
23
+ restore(next) {
24
+ if (next === null || typeof next !== "object")
25
+ return false;
26
+ const staged = new Map();
27
+ for (const [id, how] of Object.entries(next)) {
28
+ if (how !== "salvaged" && how !== "sold")
29
+ return false;
30
+ if (id.trim() === "")
31
+ return false;
32
+ staged.set(id, how);
33
+ }
34
+ spent.clear();
35
+ for (const [id, how] of staged)
36
+ spent.set(id, how);
37
+ return true;
38
+ },
39
+ reset: () => spent.clear(),
40
+ };
41
+ }
42
+ /**
43
+ * The yield an item of this level and rarity breaks down into. The formula is
44
+ * `round(perLevel x level x rarityMultiplier)`, floored by `minimum`, so the same item always yields
45
+ * the same amount and a higher level never yields less.
46
+ *
47
+ * @capability item-salvage-value compute the currency an item of a level and rarity breaks down into
48
+ */
49
+ export function salvageValue(item, table) {
50
+ const multiplier = table.rarityMultiplier?.[item.rarity] ?? 1;
51
+ const amount = Math.max(table.minimum ?? 1, Math.round(table.perLevel * Math.max(1, item.level) * multiplier));
52
+ return { currency: table.currency, amount };
53
+ }
54
+ /**
55
+ * Break one item down through its ledger. The ledger is claimed before the yield is computed, so a
56
+ * salvaged item cannot be salvaged, equipped or sold again; pass the game's own wallet update from
57
+ * `yield`.
58
+ *
59
+ * @capability item-salvage break an item down for the currency its level and rarity say, once
60
+ */
61
+ export function salvageItem(item, table, ledger) {
62
+ if (!Number.isFinite(item.level) || item.level < 1 || item.id.trim() === "") {
63
+ return { ok: false, reason: "invalid-item" };
64
+ }
65
+ if (!ledger.spend(item.id, "salvaged"))
66
+ return { ok: false, reason: "already-spent" };
67
+ return { ok: true, id: item.id, yield: salvageValue(item, table) };
68
+ }
@@ -0,0 +1,76 @@
1
+ import type { ShopStock, ShopStockEntry } from "../economy/shopStock.js";
2
+ import { type LootCatalog, type LootItem } from "./lootGen.js";
3
+ import type { ItemDrop } from "./drop.js";
4
+ /** How a vendor price is derived from an item: a base, a per-level rate and a rarity multiplier. */
5
+ export interface VendorPriceRule {
6
+ /** Wallet currency key the stock is priced in, e.g. `"credits"`. */
7
+ currency: string;
8
+ /** Price floor at level 1 before the rarity multiplier. */
9
+ base: number;
10
+ /** Added per item level. */
11
+ perLevel: number;
12
+ /** Multiplier per rarity id; omitted rarities use `1`. */
13
+ rarityMultiplier?: Readonly<Record<string, number>>;
14
+ /** Buy-back fraction of the buy price; omitted means the vendor does not buy this stock back. */
15
+ sellFactor?: number;
16
+ }
17
+ /** One stocked item and the shop entry that prices it. */
18
+ export interface VendorOffer {
19
+ item: ItemDrop;
20
+ entry: ShopStockEntry;
21
+ }
22
+ /** What stocking a vendor reads: the roll catalog, a seeded rng and the pricing policy. */
23
+ export interface StockVendorOptions {
24
+ catalog: LootCatalog;
25
+ /** Injected random source; pass a seeded generator so the shelf is reproducible. */
26
+ rng: () => number;
27
+ /** Item level rolled onto the shelf. */
28
+ level: number;
29
+ /** How many items to roll. */
30
+ count: number;
31
+ price: VendorPriceRule;
32
+ /** Stable id prefix for this vendor, e.g. `"harbor.guns"`. */
33
+ idPrefix: string;
34
+ /** Whole units on hand; `null` means unlimited (default `1`). */
35
+ qty?: number | null;
36
+ }
37
+ /**
38
+ * The price of one item under a `VendorPriceRule`: `round(base + perLevel x level)` times the
39
+ * rarity multiplier, never below 1. Sell-back is `floor(price x sellFactor)` when the rule sets one.
40
+ *
41
+ * @capability item-vendor-price price an item from its level and rarity through the economy's currency
42
+ */
43
+ export declare function itemPrice(item: Pick<LootItem, "level" | "rarity">, rule: VendorPriceRule): {
44
+ buy: number;
45
+ sell: number | null;
46
+ };
47
+ /**
48
+ * Roll a vendor's shelf and add it to an existing {@link ShopStock}. Entries added by a previous
49
+ * call with the same `idPrefix` are removed first, so a refresh replaces the shelf instead of
50
+ * throwing on duplicate ids. Buying, selling and wallet charging stay in the economy owner.
51
+ *
52
+ * @capability item-vendor stock a vendor with rolled items priced through the shared economy
53
+ */
54
+ export declare function stockVendor(shop: ShopStock, options: StockVendorOptions): VendorOffer[];
55
+ /** Where a vendor shelf stands: the run number it was last stocked on. */
56
+ export interface VendorRotationState {
57
+ refreshedAt: number;
58
+ }
59
+ /** A run-count refresh rule; the game owns how many runs between restocks. */
60
+ export interface VendorRotation {
61
+ /** True when `run` is at least `everyRuns` past the last refresh. */
62
+ due(run: number): boolean;
63
+ /** Record that the shelf was stocked on `run`. */
64
+ mark(run: number): void;
65
+ state(): VendorRotationState;
66
+ restore(next: VendorRotationState): boolean;
67
+ reset(run?: number): void;
68
+ }
69
+ /**
70
+ * A restock rule the game authors as "every N runs". It only answers when a refresh is due; the
71
+ * game calls {@link stockVendor} and then `mark`. No timer, so a paused or reloaded save keeps the
72
+ * same shelf until the run count moves.
73
+ *
74
+ * @capability item-vendor-rotation decide when a vendor's shelf is due for a restock
75
+ */
76
+ export declare function createVendorRotation(everyRuns: number, start?: number): VendorRotation;
@@ -0,0 +1,72 @@
1
+ import { rollLoot } from "./lootGen.js";
2
+ /**
3
+ * The price of one item under a `VendorPriceRule`: `round(base + perLevel x level)` times the
4
+ * rarity multiplier, never below 1. Sell-back is `floor(price x sellFactor)` when the rule sets one.
5
+ *
6
+ * @capability item-vendor-price price an item from its level and rarity through the economy's currency
7
+ */
8
+ export function itemPrice(item, rule) {
9
+ const multiplier = rule.rarityMultiplier?.[item.rarity] ?? 1;
10
+ const buy = Math.max(1, Math.round((rule.base + rule.perLevel * Math.max(1, item.level)) * multiplier));
11
+ const sell = rule.sellFactor === undefined ? null : Math.max(0, Math.floor(buy * rule.sellFactor));
12
+ return { buy, sell };
13
+ }
14
+ /**
15
+ * Roll a vendor's shelf and add it to an existing {@link ShopStock}. Entries added by a previous
16
+ * call with the same `idPrefix` are removed first, so a refresh replaces the shelf instead of
17
+ * throwing on duplicate ids. Buying, selling and wallet charging stay in the economy owner.
18
+ *
19
+ * @capability item-vendor stock a vendor with rolled items priced through the shared economy
20
+ */
21
+ export function stockVendor(shop, options) {
22
+ for (const entry of shop.list()) {
23
+ if (entry.id.startsWith(`${options.idPrefix}:`))
24
+ shop.remove(entry.id);
25
+ }
26
+ const offers = [];
27
+ for (let i = 0; i < Math.max(0, Math.floor(options.count)); i += 1) {
28
+ const item = {
29
+ ...rollLoot(options.catalog, options.rng, { level: options.level }),
30
+ id: `${options.idPrefix}:${i}`,
31
+ };
32
+ const price = itemPrice(item, options.price);
33
+ const entry = {
34
+ id: item.id,
35
+ kind: item.baseId,
36
+ price: { currency: options.price.currency, amount: price.buy },
37
+ qty: options.qty === undefined ? 1 : options.qty,
38
+ ...(price.sell === null ? {} : { sellPrice: { currency: options.price.currency, amount: price.sell } }),
39
+ };
40
+ shop.add(entry);
41
+ offers.push({ item, entry });
42
+ }
43
+ return offers;
44
+ }
45
+ /**
46
+ * A restock rule the game authors as "every N runs". It only answers when a refresh is due; the
47
+ * game calls {@link stockVendor} and then `mark`. No timer, so a paused or reloaded save keeps the
48
+ * same shelf until the run count moves.
49
+ *
50
+ * @capability item-vendor-rotation decide when a vendor's shelf is due for a restock
51
+ */
52
+ export function createVendorRotation(everyRuns, start = 0) {
53
+ if (!Number.isFinite(everyRuns) || everyRuns < 1)
54
+ throw new RangeError("everyRuns must be >= 1");
55
+ let refreshedAt = start;
56
+ return {
57
+ due: (run) => run - refreshedAt >= everyRuns,
58
+ mark: (run) => {
59
+ refreshedAt = run;
60
+ },
61
+ state: () => ({ refreshedAt }),
62
+ restore(next) {
63
+ if (next === null || typeof next !== "object" || !Number.isFinite(next.refreshedAt))
64
+ return false;
65
+ refreshedAt = next.refreshedAt;
66
+ return true;
67
+ },
68
+ reset(run = 0) {
69
+ refreshedAt = run;
70
+ },
71
+ };
72
+ }
@@ -1,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export declare const VERSION = "0.29.0";
2
+ export declare const VERSION = "0.31.0";
3
3
  /** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */
4
4
  export interface ChangelogEntry {
5
5
  migrate: readonly string[];
@@ -1,7 +1,53 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.29.0";
2
+ export const VERSION = "0.31.0";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
+ "0.31.0": {
6
+ migrate: [
7
+ "Bump lockstep SDK packages to `^0.31.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.28.0`; `@jgengine/github` is `0.18.0`.",
8
+ ],
9
+ added: [
10
+ "`createAgent` (`@jgengine/core/agents/agent`) runs one resident's whole loop on the world clock: needs decay through `sim/needs`, a utility brain picks among the targets the game offers, an authored day plan says where the agent belongs, and the agent falls back to a declared target when its pick disappears. Mood, action and the far flag snapshot and restore with the needs.",
11
+ "`createMoodReader` / `readMood` (`@jgengine/core/agents/mood`) derive a 0-100 mood and a caller-labelled tier from weighted need satisfaction, plus named sources a queue, a litter patch or a cold snap raises and clears.",
12
+ "`createAgentSchedule` (`@jgengine/core/agents/schedule`) resolves an authored day plan through `sim/routine` and adds a work beat: a stop names the places it patrols, so a janitor is an agent with a route rather than a special case. Off-hours behaviour is a stop without a beat.",
13
+ "`createRelationshipBook` (`@jgengine/core/agents/relationships`) keeps undirected familiarity and opinion per pair. A social action moves both and reports the milestones crossed, friends prefer each other's company, and a bonded pair refuses to split. Pair keys come from `relation/keyedValues` and crossings from `relation/thresholds`.",
14
+ "`GameSave.deleteSlot(slot)` and `SaveStore.deleteSlot(slot)` remove one slot's payload and drop it from the slot index; they resolve `true` when a document was removed and `false` for an unknown slot. Deleting the active slot resets the live state to the initial document. `load()` no longer lists a slot that holds nothing, so a deleted slot cannot return.",
15
+ "`HudPanel` takes `lane` (`@jgengine/react`): blocks naming the same anchor and lane stack without overlap, lanes in one anchor sit side by side, and `resolveHudLanes` resolves the rectangles. A moved panel leaves its lane and keeps its authored placement.",
16
+ "`hudBlockOverlaps` and `formatHudOverlapReport` (`@jgengine/react`) name every pair of rendered HUD blocks whose rectangles intersect, with both ids and the viewport; `HudCanvas` warns with that report in a dev build.",
17
+ "`rollDrop` (`@jgengine/core/loot/drop`) rolls one deterministic item from a seed and `compareToEquipped` turns it into per-stat deltas against worn gear with a weighted verdict a HUD can render.",
18
+ "`createRunHaul` (`@jgengine/core/loot/haul`) gates carried loot by weight through the shared encumbrance rules, banks what the persistent stash fits, and discards the run's haul when the game calls `fail`.",
19
+ "`salvageItem` and `createItemLedger` (`@jgengine/core/loot/salvage`) break an item down for the currency its level and rarity say and claim the instance, so a salvaged item cannot be salvaged, equipped or sold again.",
20
+ "`stockVendor` and `createVendorRotation` (`@jgengine/core/loot/vendor`) fill a `ShopStock` with rolled items priced through the economy wallet, with a run-count restock rule the game authors.",
21
+ "`markerProximity` on `createMissionRuntime` picks the plane a marker proximity test compares in: `\"horizontal\"` (default) measures ground distance and allows the actor a `tolerance` (default 8 m) above or below the marker, `\"spatial\"` is the straight 3D check for worlds with stacked platforms. `MissionMarkerPlane`, `MissionMarkerProximity` and `DEFAULT_MARKER_TOLERANCE` are exported with it.",
22
+ ],
23
+ changed: [
24
+ "`evaluatePressure` (`@jgengine/core/ai/director/pressure`) reads pressure as player control: a healthy player further into a run is leaned on harder, a hurt player is eased off, and every effect stays inside the rule's `maxTier`.",
25
+ "Loot bases and affixes take `minLevel`, so `rollLoot` only draws from entries at or below the item level. Loot bases take `mass`; rolled items carry it for carry checks.",
26
+ "`HudCanvas` resolved its host with `canvas.parentElement`, which is `HudThemeRoot`'s `display: contents` element and measures 0x0 — the boundary overlay, the HUD-fit measurement and the new overlap report never ran under the standard HUD root. It now uses the nearest ancestor with a real box.",
27
+ "The HUD boundary check returned early whenever nothing overflowed, so a later report in the same pass could never run.",
28
+ "`reach`, `interact` and the `left-area` fail condition compared a marker in 3D, so a marker authored at `y=0` under rising ground was unreachable. They now read the same plane.",
29
+ ],
30
+ removed: [],
31
+ },
32
+ "0.30.0": {
33
+ migrate: [
34
+ "Bump lockstep SDK packages to `^0.30.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.27.0`; `@jgengine/github` is `0.17.0`.",
35
+ ],
36
+ added: [
37
+ "`ColorblindFrame` (`@jgengine/react/accessibility`) wraps the 3D canvas and applies the player's colour-vision mode to that box alone, so HUD, menu and caption text keep their own contrast. `colorblindFrameFilter` (`@jgengine/core/ui/accessibility`) resolves a mode to the CSS filter, and `useColorblindMode` reads the mode from the settings store the settings rows write; the 3D shell does both for the mounted game.",
38
+ "`SettingsSection` (`@jgengine/react/settingsBlocks`) is one `useMenuNav` menu: a d-pad or left stick moves between rows and left/right steps the focused value — slider by its step, toggle flipped, select to the next option, confirm arms a keybind — through the same `onAdjust` path the arrow keys use. `onAdjust` (`@jgengine/react/menuNav`) is the option that makes left/right adjust instead of move.",
39
+ "`createStanding` (`@jgengine/core/progression/standing`) holds per-faction or per-district standing over authored tracks and moves it from named outcomes, so an outcome that resolves to nothing throws at the call instead of silently moving nothing.",
40
+ "`createGateSet` (`@jgengine/core/progression/gates`) evaluates authored milestones against standing and money, and refuses to load when a gate's unlock id resolves to nothing.",
41
+ "`createCareerBank` (`@jgengine/core/progression/careerBank`) keeps persistent career money apart from a run's haul, so a failed run loses the haul and keeps the bank.",
42
+ "`createUpgradeTree` (`@jgengine/core/progression/upgrades`) prices each rank from the shared curve primitive and gates purchases on prerequisites and standing.",
43
+ ],
44
+ changed: [
45
+ "`AccessibilityProvider` (`@jgengine/react/accessibility`) sets its data attributes and text-scale var without filtering its own subtree. A colour matrix over the HUD and menu root recoloured the text the rest of the UI assumes, so wrap the game canvas in `ColorblindFrame` instead; a custom root can still offset it with its own `ColorblindFilters` and `filter: url(#jg-cb-<mode>)`.",
46
+ "World health bars and nameplates derive their range from the render camera and the target height when a game sets no `maxDistance`: an overhead camera reaches the units it looks down at, and a bar is kept while the target it annotates still covers 18 px, instead of hiding every target past a fixed 60 units.",
47
+ "A configured `worldHealthBars.maxDistance` or `nameplates.maxDistance` that hides every target in view now warns once with the nearest measured distance and the prop to raise, instead of rendering nothing.",
48
+ ],
49
+ removed: [],
50
+ },
5
51
  "0.29.0": {
6
52
  migrate: [
7
53
  "Bump lockstep SDK packages to `^0.29.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.26.0`; `@jgengine/github` is `0.16.0`.",
@@ -341,6 +341,8 @@ export interface MissionRuntimeConfig {
341
341
  readonly missions: readonly MissionDef[];
342
342
  /** Marker positions resolved from the authored scene, by marker id. See `missionMarkers`. */
343
343
  readonly markers?: Readonly<Record<string, ScenePoint3>>;
344
+ /** How marker proximity is measured for `reach`, `interact` and `left-area`. Default horizontal. */
345
+ readonly markerProximity?: MissionMarkerProximity;
344
346
  /** Actor id used for a `pursue` objective that names no `pursuer`. */
345
347
  readonly defaultPursuer?: string;
346
348
  /** `[0,1)` source for a mission's `rollout`; pass `ctx.rng`. Only read when a mission declares one. */
@@ -378,8 +380,26 @@ export interface MissionRuntime {
378
380
  /** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
379
381
  dispose(): void;
380
382
  }
383
+ /** Which plane a marker proximity test compares in. */
384
+ export type MissionMarkerPlane = "horizontal" | "spatial";
385
+ /**
386
+ * How a positional objective compares an actor to its authored marker.
387
+ *
388
+ * Authored markers sit on a flat plan (`y = 0`) while the terrain around them rises, so a straight
389
+ * 3D test can leave a marker unreachable from the ground below it. `"horizontal"` compares the
390
+ * ground distance and allows the actor up to `tolerance` metres off the marker's height;
391
+ * `"spatial"` is the plain 3D check, for a world whose stacked platforms must stay distinct.
392
+ */
393
+ export interface MissionMarkerProximity {
394
+ /** `"horizontal"` (default) or `"spatial"`. */
395
+ plane?: MissionMarkerPlane;
396
+ /** Horizontal plane only: how far (m) the actor may sit above or below the marker. Default {@link DEFAULT_MARKER_TOLERANCE}. */
397
+ tolerance?: number;
398
+ }
381
399
  /** Default proximity radius (m) for a `reach` / `interact` objective. */
382
400
  export declare const DEFAULT_REACH_RADIUS = 2.5;
401
+ /** Default vertical allowance (m) for a horizontal marker proximity test. */
402
+ export declare const DEFAULT_MARKER_TOLERANCE = 8;
383
403
  /** Default keep-close distance (m) for a `pursue` objective. */
384
404
  export declare const DEFAULT_PURSUE_DISTANCE = 3;
385
405
  /** A pursuit escapes at this multiple of its keep-close distance when none is declared. */
@@ -404,6 +424,10 @@ export declare function applyMissionRewards(rewards: MissionRewards, appliers: M
404
424
  *
405
425
  * Cost per tick is bounded by the active objective's own fail list plus one goal test.
406
426
  *
427
+ * Marker proximity runs on the plane `markerProximity` picks: horizontal by default, so a marker
428
+ * authored on a flat `y = 0` plan still triggers from ground that rises under it; `spatial` for a
429
+ * world whose stacked platforms must stay apart.
430
+ *
407
431
  * @capability mission-runtime missions as a data-driven objective state machine with fail states, timers, branches and rewards
408
432
  */
409
433
  export declare function createMissionRuntime(config: MissionRuntimeConfig): MissionRuntime;
@@ -23,6 +23,8 @@ export function isCounterObjective(kind) {
23
23
  }
24
24
  /** Default proximity radius (m) for a `reach` / `interact` objective. */
25
25
  export const DEFAULT_REACH_RADIUS = 2.5;
26
+ /** Default vertical allowance (m) for a horizontal marker proximity test. */
27
+ export const DEFAULT_MARKER_TOLERANCE = 8;
26
28
  /** Default keep-close distance (m) for a `pursue` objective. */
27
29
  export const DEFAULT_PURSUE_DISTANCE = 3;
28
30
  /** A pursuit escapes at this multiple of its keep-close distance when none is declared. */
@@ -30,6 +32,21 @@ export const DEFAULT_ESCAPE_MULTIPLE = 3;
30
32
  function distance(a, b) {
31
33
  return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
32
34
  }
35
+ /** Whether `from` is inside the marker's reach radius under the configured plane — no allocation, no sqrt. */
36
+ function markerWithin(from, to, radius, plane, tolerance) {
37
+ const dy = from.y - to.y;
38
+ const limit = radius * radius;
39
+ if (plane === "spatial") {
40
+ const dx = from.x - to.x;
41
+ const dz = from.z - to.z;
42
+ return dx * dx + dy * dy + dz * dz <= limit;
43
+ }
44
+ if (Math.abs(dy) > tolerance)
45
+ return false;
46
+ const dx = from.x - to.x;
47
+ const dz = from.z - to.z;
48
+ return dx * dx + dz * dz <= limit;
49
+ }
33
50
  function cloneState(state) {
34
51
  return {
35
52
  ...state,
@@ -110,6 +127,10 @@ export function applyMissionRewards(rewards, appliers) {
110
127
  *
111
128
  * Cost per tick is bounded by the active objective's own fail list plus one goal test.
112
129
  *
130
+ * Marker proximity runs on the plane `markerProximity` picks: horizontal by default, so a marker
131
+ * authored on a flat `y = 0` plan still triggers from ground that rises under it; `spatial` for a
132
+ * world whose stacked platforms must stay apart.
133
+ *
113
134
  * @capability mission-runtime missions as a data-driven objective state machine with fail states, timers, branches and rewards
114
135
  */
115
136
  export function createMissionRuntime(config) {
@@ -117,6 +138,8 @@ export function createMissionRuntime(config) {
117
138
  for (const def of config.missions)
118
139
  defs.set(def.id, def);
119
140
  const markers = config.markers ?? {};
141
+ const markerPlane = config.markerProximity?.plane ?? "horizontal";
142
+ const markerTolerance = Math.max(0, config.markerProximity?.tolerance ?? DEFAULT_MARKER_TOLERANCE);
120
143
  let rng = config.rng;
121
144
  let state = null;
122
145
  let ticks = 0;
@@ -222,7 +245,7 @@ export function createMissionRuntime(config) {
222
245
  const to = objective.marker === undefined ? undefined : markers[objective.marker];
223
246
  if (from === undefined || to === undefined)
224
247
  return false;
225
- return distance(from, to) <= (objective.radius ?? DEFAULT_REACH_RADIUS);
248
+ return markerWithin(from, to, objective.radius ?? DEFAULT_REACH_RADIUS, markerPlane, markerTolerance);
226
249
  }
227
250
  case "interact": {
228
251
  if (objective.actor === undefined || !(input.interact ?? []).includes(objective.actor))
@@ -231,7 +254,8 @@ export function createMissionRuntime(config) {
231
254
  if (to === undefined)
232
255
  return true;
233
256
  const from = positionOf(objective.actor, input);
234
- return from !== undefined && distance(from, to) <= (objective.radius ?? DEFAULT_REACH_RADIUS);
257
+ return from !== undefined
258
+ && markerWithin(from, to, objective.radius ?? DEFAULT_REACH_RADIUS, markerPlane, markerTolerance);
235
259
  }
236
260
  case "survive":
237
261
  return state.objectiveElapsed >= (objective.duration ?? 0);
@@ -306,7 +330,8 @@ export function createMissionRuntime(config) {
306
330
  const actor = condition.actor ?? objective.actor;
307
331
  const from = positionOf(actor, input);
308
332
  const to = condition.marker === undefined ? undefined : markers[condition.marker];
309
- if (from !== undefined && to !== undefined && distance(from, to) > (condition.radius ?? DEFAULT_REACH_RADIUS)) {
333
+ const radius = condition.radius ?? DEFAULT_REACH_RADIUS;
334
+ if (from !== undefined && to !== undefined && !markerWithin(from, to, radius, markerPlane, markerTolerance)) {
310
335
  return { state: condition.to ?? "failed", reason: condition.message ?? `${actor ?? "actor"} left the area` };
311
336
  }
312
337
  continue;
@@ -0,0 +1,63 @@
1
+ import { type CurrencyDefinition } from "../economy/currency.js";
2
+ /**
3
+ * Everything a career bank stores, as plain JSON. `banked` is the persistent balance; `haul` is
4
+ * the run in progress and is the part a failed run loses. `deposited`, `lost`, `bestHaul` and
5
+ * `runs` are lifetime counters a results screen reads.
6
+ */
7
+ export interface CareerBankState {
8
+ banked: number;
9
+ haul: number;
10
+ deposited: number;
11
+ lost: number;
12
+ bestHaul: number;
13
+ runs: number;
14
+ }
15
+ /** Configuration for {@link createCareerBank}. */
16
+ export interface CareerBankConfig {
17
+ currency: CurrencyDefinition;
18
+ /** Persistent money before any run (default `0`). */
19
+ start?: number;
20
+ /** Most a single run's haul can carry; omitted means no cap. */
21
+ haulCap?: number;
22
+ /** Rehydrate from a prior {@link CareerBank.snapshot}. */
23
+ state?: CareerBankState;
24
+ }
25
+ /** Persistent career money plus the run in progress, which a failed run loses. */
26
+ export interface CareerBank {
27
+ currency(): CurrencyDefinition;
28
+ /** Money kept between runs. */
29
+ banked(): number;
30
+ /** Money earned this run and not yet banked or lost. */
31
+ haul(): number;
32
+ /** Add to the run's haul (capped by `haulCap`); negative or non-finite is ignored. Returns the haul. */
33
+ earn(amount: number): number;
34
+ /** Bank the haul: it becomes persistent and the run's haul resets. */
35
+ cashOut(): {
36
+ deposited: number;
37
+ banked: number;
38
+ };
39
+ /** The run failed: the haul is gone, persistent money is untouched. */
40
+ loseHaul(): {
41
+ lost: number;
42
+ banked: number;
43
+ };
44
+ /** Pay from persistent money; `false` when it cannot cover the amount. */
45
+ spend(amount: number): boolean;
46
+ afford(amount: number): boolean;
47
+ counters(): {
48
+ deposited: number;
49
+ lost: number;
50
+ bestHaul: number;
51
+ runs: number;
52
+ };
53
+ snapshot(): CareerBankState;
54
+ restore(next: CareerBankState): void;
55
+ }
56
+ /**
57
+ * Money that means something between runs: a persistent balance plus a run's haul that is banked on
58
+ * a cash-out and lost on a failure. Amounts are snapped to the currency's minor units, so a save
59
+ * round-trip and a display never drift. Pair it with `economy/upgrades` for a career upgrade tree.
60
+ *
61
+ * @capability career-bank persistent career money with a run haul that can be banked or lost
62
+ */
63
+ export declare function createCareerBank(config: CareerBankConfig): CareerBank;