@jgengine/core 0.26.0 → 0.27.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 (55) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/game/playableGame.d.ts +19 -2
  3. package/dist/gameplay.d.ts +5 -0
  4. package/dist/gameplay.js +5 -0
  5. package/dist/inventory/carryLoad.d.ts +48 -0
  6. package/dist/inventory/carryLoad.js +44 -0
  7. package/dist/inventory/equipment.d.ts +95 -0
  8. package/dist/inventory/equipment.js +169 -0
  9. package/dist/inventory/inventoryModel.d.ts +15 -6
  10. package/dist/inventory/inventoryModel.js +54 -5
  11. package/dist/inventory/shapedGrid.d.ts +26 -0
  12. package/dist/inventory/shapedGrid.js +47 -0
  13. package/dist/loot/lootGen.d.ts +186 -0
  14. package/dist/loot/lootGen.js +437 -0
  15. package/dist/loot/lootWeapon.d.ts +35 -0
  16. package/dist/loot/lootWeapon.js +70 -0
  17. package/dist/meta/changelog.d.ts +1 -1
  18. package/dist/meta/changelog.js +37 -1
  19. package/dist/nav/laneGraph.d.ts +178 -0
  20. package/dist/nav/laneGraph.js +671 -0
  21. package/dist/nav/traffic.d.ts +223 -0
  22. package/dist/nav/traffic.js +919 -0
  23. package/dist/physics/kinematicVehicle.d.ts +36 -0
  24. package/dist/physics/kinematicVehicle.js +15 -1
  25. package/dist/physics/vehicleDamage.d.ts +136 -0
  26. package/dist/physics/vehicleDamage.js +214 -0
  27. package/dist/physics/vehicleParts.d.ts +62 -0
  28. package/dist/physics/vehicleParts.js +65 -0
  29. package/dist/progression/gearScore.d.ts +56 -0
  30. package/dist/progression/gearScore.js +59 -0
  31. package/dist/progression/skillTree.d.ts +129 -0
  32. package/dist/progression/skillTree.js +265 -0
  33. package/dist/progression/xpCurve.d.ts +60 -0
  34. package/dist/progression/xpCurve.js +141 -0
  35. package/dist/scene/builtinSceneKinds.js +2 -0
  36. package/dist/world/decalKind.d.ts +48 -0
  37. package/dist/world/decalKind.js +86 -0
  38. package/dist/world/features.d.ts +3 -1
  39. package/dist/world/features.js +1 -0
  40. package/dist/world/grassKind.d.ts +5 -0
  41. package/dist/world/grassKind.js +3 -0
  42. package/dist/world/terraform.d.ts +8 -0
  43. package/dist/world.d.ts +5 -1
  44. package/dist/world.js +4 -0
  45. package/package.json +1 -1
  46. package/skills/jgengine/api.md +2 -2
  47. package/skills/jgengine-combat/SKILL.md +3 -1
  48. package/skills/jgengine-gameplay/SKILL.md +31 -0
  49. package/skills/jgengine-gameplay/api.md +150 -6
  50. package/skills/jgengine-gameplay/capabilities.md +48 -3
  51. package/skills/jgengine-gameplay/reference-systems.md +118 -0
  52. package/skills/jgengine-world/SKILL.md +4 -0
  53. package/skills/jgengine-world/api.md +115 -5
  54. package/skills/jgengine-world/capabilities.md +20 -0
  55. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
@@ -0,0 +1,59 @@
1
+ import { lootStatContributions } from "../loot/lootGen.js";
2
+ /**
3
+ * Score a whole loadout with one documented formula:
4
+ *
5
+ * `slotScore = Σ_stat value × weight × (lowerIsBetter ? -1 : 1) + levelWeight × level + rarityScore[rarity]`
6
+ *
7
+ * `score = Σ slotScore - emptySlotPenalty × emptySlots`, and `byStat` sums the stat term alone. The
8
+ * stat term is `scoreLootStats`, the same weight policy `compareLootItems` uses, so an item swap and
9
+ * a loadout total agree. Ties are exact; nothing here reads a clock or RNG.
10
+ *
11
+ * @capability gear-score score a loadout from stat weights, level, rarity and slot coverage
12
+ */
13
+ export function gearScore(set, options = {}) {
14
+ const bySlot = {};
15
+ const byStat = {};
16
+ const levelWeight = options.levelWeight ?? 0;
17
+ const rarityScores = options.rarityScore ?? {};
18
+ const emptySlotPenalty = options.emptySlotPenalty ?? 0;
19
+ let total = 0;
20
+ let items = 0;
21
+ for (const slot of Object.keys(set).sort()) {
22
+ const item = set[slot];
23
+ if (item === null || item === undefined)
24
+ continue;
25
+ let slotScore = 0;
26
+ const contributions = lootStatContributions(item.stats, options);
27
+ for (const stat of Object.keys(contributions)) {
28
+ const contribution = contributions[stat];
29
+ slotScore += contribution;
30
+ byStat[stat] = (byStat[stat] ?? 0) + contribution;
31
+ }
32
+ if (levelWeight !== 0 && item.level !== undefined)
33
+ slotScore += levelWeight * item.level;
34
+ if (item.rarity !== undefined)
35
+ slotScore += rarityScores[item.rarity] ?? 0;
36
+ bySlot[slot] = slotScore;
37
+ total += slotScore;
38
+ items += 1;
39
+ }
40
+ const expected = options.expectedSlots ?? [];
41
+ const emptySlots = expected.filter((slot) => set[slot] === null || set[slot] === undefined);
42
+ total -= emptySlotPenalty * emptySlots.length;
43
+ return {
44
+ score: total,
45
+ bySlot,
46
+ byStat,
47
+ items,
48
+ average: items === 0 ? 0 : total / items,
49
+ coverage: expected.length === 0 ? 1 : (expected.length - emptySlots.length) / expected.length,
50
+ emptySlots,
51
+ };
52
+ }
53
+ /** Compare two loadouts; negative means `a` scores lower. */
54
+ export function compareGearSets(a, b, options = {}) {
55
+ const scoreA = gearScore(a, options);
56
+ const scoreB = gearScore(b, options);
57
+ const delta = scoreB.score - scoreA.score;
58
+ return { a: scoreA, b: scoreB, delta, better: delta === 0 ? "equal" : delta > 0 ? "b" : "a" };
59
+ }
@@ -0,0 +1,129 @@
1
+ import type { AbilityCastType } from "../combat/abilityKit.js";
2
+ import type { StatModifierSet } from "../stats/statModifiers.js";
3
+ /** One node that must be at or above `rank` for a node or synergy to open; `rank` defaults to `1`. */
4
+ export interface SkillRequirement {
5
+ nodeId: string;
6
+ rank?: number;
7
+ }
8
+ /** An action a node or synergy unlocks, shaped to bind straight into `createAbilityKit`'s slot config. */
9
+ export interface SkillActiveDef {
10
+ id: string;
11
+ cooldownMs?: number;
12
+ resourceCost?: number;
13
+ castType?: AbilityCastType;
14
+ /** Slot ids whose shared cooldown this action joins. */
15
+ groups?: readonly string[];
16
+ }
17
+ /**
18
+ * One authored tree node. Every field except `id` is optional data, so a game states its own tree:
19
+ * what a rank costs, what must come first, the passive it folds in and the actions it opens.
20
+ */
21
+ export interface SkillNodeDef<TStat extends string = string> {
22
+ id: string;
23
+ /** Grouping for balance and UI; nodes with no branch share `""`. */
24
+ branch?: string;
25
+ /** Points one rank of this node costs (default `1`). */
26
+ cost?: number;
27
+ /** Ranks this node holds (default `1`). */
28
+ maxRank?: number;
29
+ /** Nodes and minimum ranks that must be spent before this node opens at all. */
30
+ requires?: readonly SkillRequirement[];
31
+ /** Points that must already sit in this node's branch, excluding this node's own ranks. */
32
+ requiresPointsInBranch?: number;
33
+ /** Nodes that cannot be ranked alongside this one — the exclusivity that makes builds differ. */
34
+ excludes?: readonly string[];
35
+ /** Passive stats one rank adds; multiplicative entries compound per rank. */
36
+ passives?: StatModifierSet<TStat>;
37
+ /** Actions unlocked by one or more ranks. */
38
+ actives?: readonly SkillActiveDef[];
39
+ /** Free-form tags for caller filters and UI. */
40
+ tags?: readonly string[];
41
+ }
42
+ /**
43
+ * A payoff that exists only when two or more nodes are ranked together. This is what rewards
44
+ * breadth over dumping every point into one branch.
45
+ */
46
+ export interface SkillSynergyDef<TStat extends string = string> {
47
+ id: string;
48
+ /** At least two distinct nodes, each at or above its required rank. */
49
+ requires: readonly SkillRequirement[];
50
+ /** Passive stats added once while the synergy is live. */
51
+ passives?: StatModifierSet<TStat>;
52
+ /** Actions unlocked while the synergy is live. */
53
+ actives?: readonly SkillActiveDef[];
54
+ tags?: readonly string[];
55
+ }
56
+ /** Everything {@link createSkillTree} reads: nodes, optional synergies, and starting points. */
57
+ export interface SkillTreeConfig<TStat extends string = string> {
58
+ nodes: readonly SkillNodeDef<TStat>[];
59
+ synergies?: readonly SkillSynergyDef<TStat>[];
60
+ /** Points the character starts with (default `0`). */
61
+ points?: number;
62
+ }
63
+ /** Why a spend was refused; each reason is reachable from authored data, so a UI can say which rule bit. */
64
+ export type SkillSpendReason = "unknown-node" | "max-rank" | "no-points" | "requires" | "branch-points" | "excluded";
65
+ /** The outcome of one spend or legality check: what it costs and the balance after it, or the rule that stopped it. */
66
+ export type SkillSpendResult = {
67
+ ok: true;
68
+ spent: number;
69
+ pointsAvailable: number;
70
+ } | {
71
+ ok: false;
72
+ reason: SkillSpendReason;
73
+ detail: string;
74
+ };
75
+ /** The whole outcome of a build: merged passives, unlocked actions, live synergies and point accounting. */
76
+ export interface SkillResolution<TStat extends string = string> {
77
+ /** Merged passives: node passives per rank, then synergy passives, in config order. */
78
+ stats: StatModifierSet<TStat>;
79
+ /** Every unlocked action in config order, deduplicated by id. */
80
+ actives: readonly SkillActiveDef[];
81
+ activeIds: readonly string[];
82
+ /** Ids of the synergies currently satisfied, in config order. */
83
+ synergies: readonly string[];
84
+ /** Rank held per node, keyed by node id and sorted for a stable readout. */
85
+ ranks: Readonly<Record<string, number>>;
86
+ pointsSpent: number;
87
+ pointsAvailable: number;
88
+ }
89
+ /** Saved tree state: unspent points and the rank of every ranked node. */
90
+ export interface SkillTreeState {
91
+ points: number;
92
+ ranks: Record<string, number>;
93
+ }
94
+ /** A live skill tree over one authored config, with respec, snapshot/restore and an optional devtools probe. */
95
+ export interface SkillTree<TStat extends string = string> {
96
+ rank(nodeId: string): number;
97
+ pointsAvailable(): number;
98
+ /** Points currently committed, counting each node's `cost` per rank. */
99
+ pointsSpent(): number;
100
+ pointsInBranch(branch: string): number;
101
+ grantPoints(amount: number): void;
102
+ canSpend(nodeId: string): SkillSpendResult;
103
+ /** Spend one rank of `nodeId`, charging its `cost`; refused without mutation when a rule blocks it. */
104
+ spend(nodeId: string): SkillSpendResult;
105
+ /** Refund every spent point and clear every rank. Returns the exact number refunded. */
106
+ respec(): {
107
+ refunded: number;
108
+ pointsAvailable: number;
109
+ };
110
+ resolved(): SkillResolution<TStat>;
111
+ snapshot(): SkillTreeState;
112
+ /** Replace state from a save; refuses without mutation when it names unknown nodes or malformed counts. */
113
+ restore(next: SkillTreeState): boolean;
114
+ /** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
115
+ dispose(): void;
116
+ }
117
+ /** Check tree data before play; throws `RangeError` naming the offending node, requirement or synergy. */
118
+ export declare function assertSkillTreeConfig<TStat extends string>(config: SkillTreeConfig<TStat>): void;
119
+ /**
120
+ * A data-authored skill tree: nodes carry a point cost, prerequisites, passives and unlocked actions,
121
+ * synergies pay off two or more nodes together, and `respec` refunds exactly the points spent. Spend
122
+ * against `canSpend` for a reason a UI can show; read `resolved()` for the stat modifiers and action
123
+ * list to feed `createStats` / `createAbilityKit`.
124
+ *
125
+ * @capability skill-tree spend, refuse and refund points over an authored tree with passives, actives and synergies
126
+ */
127
+ export declare function createSkillTree<TStat extends string = string>(config: SkillTreeConfig<TStat>, options?: {
128
+ devtoolsName?: string;
129
+ }): SkillTree<TStat>;
@@ -0,0 +1,265 @@
1
+ import { devtools } from "../devtools/devtools.js";
2
+ const DEFAULT_BRANCH = "";
3
+ function check(condition, message) {
4
+ if (!condition)
5
+ throw new RangeError(message);
6
+ }
7
+ function requirementRank(requirement) {
8
+ return requirement.rank ?? 1;
9
+ }
10
+ function mergePassives(target, source, ranks) {
11
+ if (source === undefined || ranks <= 0)
12
+ return;
13
+ for (const stat of Object.keys(source)) {
14
+ const modifier = source[stat];
15
+ if (modifier === undefined)
16
+ continue;
17
+ const entry = target[stat] ?? {};
18
+ if (modifier.add !== undefined)
19
+ entry.add = (entry.add ?? 0) + modifier.add * ranks;
20
+ if (modifier.multiply !== undefined)
21
+ entry.multiply = (entry.multiply ?? 1) * modifier.multiply ** ranks;
22
+ target[stat] = entry;
23
+ }
24
+ }
25
+ /** Check tree data before play; throws `RangeError` naming the offending node, requirement or synergy. */
26
+ export function assertSkillTreeConfig(config) {
27
+ check(config.nodes.length > 0, "Skill tree needs at least one node");
28
+ const nodes = new Map();
29
+ for (const node of config.nodes) {
30
+ check(node.id.trim() !== "", "Skill node id must not be empty");
31
+ check(!nodes.has(node.id), `Skill node "${node.id}" is declared twice`);
32
+ nodes.set(node.id, node);
33
+ }
34
+ for (const node of config.nodes) {
35
+ const cost = node.cost ?? 1;
36
+ const maxRank = node.maxRank ?? 1;
37
+ check(Number.isInteger(cost) && cost >= 1, `Skill node "${node.id}" cost must be an integer >= 1`);
38
+ check(Number.isInteger(maxRank) && maxRank >= 1, `Skill node "${node.id}" maxRank must be an integer >= 1`);
39
+ const branchPoints = node.requiresPointsInBranch ?? 0;
40
+ check(Number.isInteger(branchPoints) && branchPoints >= 0, `Skill node "${node.id}" requiresPointsInBranch must be an integer >= 0`);
41
+ for (const requirement of node.requires ?? [])
42
+ requireNode(nodes, node.id, requirement);
43
+ for (const excluded of node.excludes ?? []) {
44
+ check(excluded !== node.id, `Skill node "${node.id}" cannot exclude itself`);
45
+ check(nodes.has(excluded), `Skill node "${node.id}" excludes unknown node "${excluded}"`);
46
+ }
47
+ }
48
+ const synergyIds = new Set();
49
+ for (const synergy of config.synergies ?? []) {
50
+ check(synergy.id.trim() !== "", "Skill synergy id must not be empty");
51
+ check(!synergyIds.has(synergy.id), `Skill synergy "${synergy.id}" is declared twice`);
52
+ synergyIds.add(synergy.id);
53
+ const distinct = new Set(synergy.requires.map((requirement) => requirement.nodeId));
54
+ check(distinct.size >= 2, `Skill synergy "${synergy.id}" must require at least two different nodes`);
55
+ for (const requirement of synergy.requires)
56
+ requireNode(nodes, synergy.id, requirement);
57
+ }
58
+ check(Number.isInteger(config.points ?? 0) && (config.points ?? 0) >= 0, "Skill tree points must be an integer >= 0");
59
+ }
60
+ function requireNode(nodes, owner, requirement) {
61
+ const target = nodes.get(requirement.nodeId);
62
+ if (target === undefined)
63
+ throw new RangeError(`"${owner}" requires unknown node "${requirement.nodeId}"`);
64
+ const rank = requirementRank(requirement);
65
+ check(Number.isInteger(rank) && rank >= 1, `"${owner}" requires an integer rank >= 1 of "${requirement.nodeId}"`);
66
+ const maxRank = target.maxRank ?? 1;
67
+ check(rank <= maxRank, `"${owner}" requires rank ${rank} of "${requirement.nodeId}", which holds ${maxRank}`);
68
+ }
69
+ /**
70
+ * A data-authored skill tree: nodes carry a point cost, prerequisites, passives and unlocked actions,
71
+ * synergies pay off two or more nodes together, and `respec` refunds exactly the points spent. Spend
72
+ * against `canSpend` for a reason a UI can show; read `resolved()` for the stat modifiers and action
73
+ * list to feed `createStats` / `createAbilityKit`.
74
+ *
75
+ * @capability skill-tree spend, refuse and refund points over an authored tree with passives, actives and synergies
76
+ */
77
+ export function createSkillTree(config, options = {}) {
78
+ assertSkillTreeConfig(config);
79
+ const nodes = new Map();
80
+ for (const node of config.nodes)
81
+ nodes.set(node.id, node);
82
+ const blockedBy = new Map();
83
+ const block = (from, target) => {
84
+ const list = blockedBy.get(from);
85
+ if (list === undefined)
86
+ blockedBy.set(from, [target]);
87
+ else if (!list.includes(target))
88
+ list.push(target);
89
+ };
90
+ for (const node of config.nodes) {
91
+ for (const excluded of node.excludes ?? []) {
92
+ block(node.id, excluded);
93
+ block(excluded, node.id);
94
+ }
95
+ }
96
+ const synergies = config.synergies ?? [];
97
+ const branchOf = (node) => node.branch ?? DEFAULT_BRANCH;
98
+ const ranks = new Map();
99
+ let points = config.points ?? 0;
100
+ let cache = null;
101
+ const rank = (nodeId) => ranks.get(nodeId) ?? 0;
102
+ const pointsSpent = () => {
103
+ let total = 0;
104
+ for (const [nodeId, count] of ranks)
105
+ total += (nodes.get(nodeId)?.cost ?? 1) * count;
106
+ return total;
107
+ };
108
+ const pointsInBranch = (branch) => {
109
+ let total = 0;
110
+ for (const node of nodes.values()) {
111
+ if (branchOf(node) === branch)
112
+ total += rank(node.id);
113
+ }
114
+ return total;
115
+ };
116
+ const readSnapshot = () => {
117
+ const out = {};
118
+ for (const [nodeId, count] of ranks)
119
+ if (count > 0)
120
+ out[nodeId] = count;
121
+ return { points, ranks: out };
122
+ };
123
+ const canSpend = (nodeId) => {
124
+ const node = nodes.get(nodeId);
125
+ if (node === undefined)
126
+ return { ok: false, reason: "unknown-node", detail: `no node "${nodeId}"` };
127
+ const cost = node.cost ?? 1;
128
+ const maxRank = node.maxRank ?? 1;
129
+ if (rank(nodeId) >= maxRank)
130
+ return { ok: false, reason: "max-rank", detail: `"${nodeId}" is already at rank ${maxRank}` };
131
+ if (points < cost)
132
+ return { ok: false, reason: "no-points", detail: `"${nodeId}" costs ${cost}, ${points} available` };
133
+ for (const requirement of node.requires ?? []) {
134
+ const needed = requirementRank(requirement);
135
+ if (rank(requirement.nodeId) < needed) {
136
+ return { ok: false, reason: "requires", detail: `"${nodeId}" needs rank ${needed} of "${requirement.nodeId}"` };
137
+ }
138
+ }
139
+ const branchPoints = node.requiresPointsInBranch ?? 0;
140
+ if (branchPoints > 0) {
141
+ const invested = pointsInBranch(branchOf(node)) - rank(nodeId);
142
+ if (invested < branchPoints) {
143
+ return { ok: false, reason: "branch-points", detail: `"${nodeId}" needs ${branchPoints} points in branch "${branchOf(node)}"` };
144
+ }
145
+ }
146
+ for (const excluded of blockedBy.get(nodeId) ?? []) {
147
+ if (rank(excluded) > 0)
148
+ return { ok: false, reason: "excluded", detail: `"${nodeId}" cannot be ranked beside "${excluded}"` };
149
+ }
150
+ return { ok: true, spent: cost, pointsAvailable: points - cost };
151
+ };
152
+ const resolve = () => {
153
+ const stats = {};
154
+ const actives = [];
155
+ const seenActives = new Set();
156
+ const addActives = (list) => {
157
+ for (const active of list ?? []) {
158
+ if (seenActives.has(active.id))
159
+ continue;
160
+ seenActives.add(active.id);
161
+ actives.push({ ...active });
162
+ }
163
+ };
164
+ for (const node of nodes.values()) {
165
+ const count = rank(node.id);
166
+ if (count <= 0)
167
+ continue;
168
+ mergePassives(stats, node.passives, count);
169
+ addActives(node.actives);
170
+ }
171
+ const live = [];
172
+ for (const synergy of synergies) {
173
+ const satisfied = synergy.requires.every((requirement) => rank(requirement.nodeId) >= requirementRank(requirement));
174
+ if (!satisfied)
175
+ continue;
176
+ live.push(synergy.id);
177
+ mergePassives(stats, synergy.passives, 1);
178
+ addActives(synergy.actives);
179
+ }
180
+ const ranksOut = {};
181
+ for (const nodeId of [...ranks.keys()].sort())
182
+ ranksOut[nodeId] = rank(nodeId);
183
+ return {
184
+ stats,
185
+ actives,
186
+ activeIds: actives.map((active) => active.id),
187
+ synergies: live,
188
+ ranks: ranksOut,
189
+ pointsSpent: pointsSpent(),
190
+ pointsAvailable: points,
191
+ };
192
+ };
193
+ const probe = options.devtoolsName === undefined
194
+ ? null
195
+ : devtools.probes.register(options.devtoolsName, () => {
196
+ const resolved = resolve();
197
+ return {
198
+ pointsAvailable: resolved.pointsAvailable,
199
+ pointsSpent: resolved.pointsSpent,
200
+ ranks: resolved.ranks,
201
+ synergies: resolved.synergies,
202
+ actives: resolved.activeIds,
203
+ };
204
+ });
205
+ const tree = {
206
+ rank,
207
+ pointsAvailable: () => points,
208
+ pointsSpent,
209
+ pointsInBranch,
210
+ grantPoints(amount) {
211
+ if (!Number.isInteger(amount) || amount <= 0)
212
+ return;
213
+ points += amount;
214
+ cache = null;
215
+ },
216
+ canSpend,
217
+ spend(nodeId) {
218
+ const result = canSpend(nodeId);
219
+ if (!result.ok)
220
+ return result;
221
+ points -= result.spent;
222
+ ranks.set(nodeId, rank(nodeId) + 1);
223
+ cache = null;
224
+ return { ok: true, spent: result.spent, pointsAvailable: points };
225
+ },
226
+ respec() {
227
+ const refunded = pointsSpent();
228
+ points += refunded;
229
+ ranks.clear();
230
+ cache = null;
231
+ return { refunded, pointsAvailable: points };
232
+ },
233
+ resolved() {
234
+ cache ??= resolve();
235
+ return cache;
236
+ },
237
+ snapshot: readSnapshot,
238
+ restore(next) {
239
+ if (!Number.isInteger(next.points) || next.points < 0)
240
+ return false;
241
+ if (next.ranks === null || typeof next.ranks !== "object")
242
+ return false;
243
+ const restored = new Map();
244
+ for (const [nodeId, count] of Object.entries(next.ranks)) {
245
+ const node = nodes.get(nodeId);
246
+ if (node === undefined)
247
+ return false;
248
+ if (!Number.isInteger(count) || count < 0 || count > (node.maxRank ?? 1))
249
+ return false;
250
+ if (count > 0)
251
+ restored.set(nodeId, count);
252
+ }
253
+ points = next.points;
254
+ ranks.clear();
255
+ for (const [nodeId, count] of restored)
256
+ ranks.set(nodeId, count);
257
+ cache = null;
258
+ return true;
259
+ },
260
+ dispose() {
261
+ probe?.();
262
+ },
263
+ };
264
+ return tree;
265
+ }
@@ -0,0 +1,60 @@
1
+ import { type LevelProgress, type LevelingTrack } from "../game/progression.js";
2
+ /** Named rule a level threshold grows by. `flat` repeats `step`; `linear` adds `growthRate` per level; `power` uses `level ^ growthRate`; `geometric` multiplies by `growthRate`; `table` reads `table`. */
3
+ export type XpGrowthRule = "flat" | "linear" | "power" | "geometric" | "table";
4
+ /**
5
+ * Authorable XP curve data. Thresholds are generated from one growth rule, so a game states its
6
+ * curve and its cap instead of hand-writing a table it cannot rebalance, and every consumer of the
7
+ * same data agrees on the numbers.
8
+ */
9
+ export interface XpCurveDef {
10
+ growth: XpGrowthRule;
11
+ /** XP needed for the first level-up (from `startLevel` to `startLevel + 1`). */
12
+ step: number;
13
+ /** Growth parameter: added XP per level (`linear`), the exponent (`power`) or the ratio (`geometric`). Ignored by `flat` and `table`. */
14
+ growthRate?: number;
15
+ /** Explicit thresholds for `growth: "table"`, index `0` being the first level-up. */
16
+ table?: readonly number[];
17
+ /** Highest reachable level. */
18
+ maxLevel: number;
19
+ /** First level of the curve (default `1`). */
20
+ startLevel?: number;
21
+ /** Rounding of generated thresholds (default `ceil`, so no level costs a fraction of XP). */
22
+ round?: "floor" | "ceil" | "round";
23
+ }
24
+ /** One row of a resolved curve: the threshold at `level`, and the lifetime XP it sits at. */
25
+ export interface XpLevelStep {
26
+ level: number;
27
+ /** XP that leaves this level; `0` at the cap. */
28
+ xpForLevel: number;
29
+ /** XP accumulated from `startLevel` to reach `level`; `0` at the first level. */
30
+ totalXp: number;
31
+ }
32
+ /** A resolved, validated {@link XpCurveDef}: thresholds, lifetime totals, a stated rule, and a leveling track bound to the same numbers. */
33
+ export interface XpCurve {
34
+ /** Detached copy of the curve data, safe to save or send to a client. */
35
+ readonly def: XpCurveDef;
36
+ readonly maxLevel: number;
37
+ readonly startLevel: number;
38
+ /** The growth rule written out, e.g. `"power: 80 x level^1.45, ceil (levels 1-40)"`. */
39
+ readonly rule: string;
40
+ /** XP the given level requires to advance; `0` at or past `maxLevel`. */
41
+ xpForLevel(level: number): number;
42
+ /** Lifetime XP needed to reach `level` from `startLevel`. */
43
+ totalXpFor(level: number): number;
44
+ /** Resolve accumulated XP into a settled level, surplus and next threshold. */
45
+ levelForXp(totalXp: number): LevelProgress;
46
+ /** Every level of the curve with its threshold and lifetime XP. */
47
+ table(): readonly XpLevelStep[];
48
+ /** A `leveling` track configured with these exact thresholds and cap, for stat-adapter grants. */
49
+ track(): LevelingTrack;
50
+ }
51
+ /** Check curve data before use; throws `RangeError` naming the offending field. */
52
+ export declare function assertXpCurve(def: XpCurveDef): void;
53
+ /**
54
+ * Resolve authored XP curve data: the threshold per level, the lifetime XP it sits at, a plain-words
55
+ * growth rule, and the level an XP total reaches under the cap. One data record with one growth rule
56
+ * keeps a rebalance in the save-visible definition rather than in formulas scattered across a game.
57
+ *
58
+ * @capability xp-curve resolve an authored xp growth rule into per-level thresholds, totals and a level cap
59
+ */
60
+ export declare function defineXpCurve(def: XpCurveDef): XpCurve;
@@ -0,0 +1,141 @@
1
+ import { evalCurve, leveling } from "../game/progression.js";
2
+ function check(condition, message) {
3
+ if (!condition)
4
+ throw new RangeError(message);
5
+ }
6
+ function curveDefOf(def) {
7
+ switch (def.growth) {
8
+ case "flat":
9
+ return { kind: "const", value: def.step };
10
+ case "linear":
11
+ return { kind: "linear", base: def.step, per: def.growthRate ?? 0 };
12
+ case "power":
13
+ return { kind: "power", base: def.step, exponent: def.growthRate ?? 1 };
14
+ case "geometric":
15
+ return { kind: "geometric", base: def.step, ratio: def.growthRate ?? 1 };
16
+ case "table":
17
+ return null;
18
+ }
19
+ }
20
+ function describeRule(def, startLevel) {
21
+ const round = def.round ?? "ceil";
22
+ const span = `levels ${startLevel}-${def.maxLevel}`;
23
+ switch (def.growth) {
24
+ case "flat":
25
+ return `flat: ${def.step} xp per level, ${round} (${span})`;
26
+ case "linear":
27
+ return `linear: ${def.step} + ${def.growthRate ?? 0} x (level - ${startLevel}), ${round} (${span})`;
28
+ case "power":
29
+ return `power: ${def.step} x level^${def.growthRate ?? 1}, ${round} (${span})`;
30
+ case "geometric":
31
+ return `geometric: ${def.step} x ${def.growthRate ?? 1}^(level - ${startLevel}), ${round} (${span})`;
32
+ case "table":
33
+ return `table: ${def.table?.length ?? 0} thresholds (${span})`;
34
+ }
35
+ }
36
+ /** Check curve data before use; throws `RangeError` naming the offending field. */
37
+ export function assertXpCurve(def) {
38
+ const startLevel = def.startLevel ?? 1;
39
+ check(Number.isInteger(startLevel) && startLevel >= 1, `XP curve startLevel must be an integer >= 1; got ${String(def.startLevel)}`);
40
+ check(Number.isInteger(def.maxLevel) && def.maxLevel > startLevel, `XP curve maxLevel must be an integer above startLevel ${startLevel}; got ${String(def.maxLevel)}`);
41
+ check(Number.isFinite(def.step) && def.step > 0, `XP curve step must be positive; got ${String(def.step)}`);
42
+ if (def.growth === "linear") {
43
+ check(Number.isFinite(def.growthRate ?? 0) && (def.growthRate ?? 0) >= 0, "XP curve linear growthRate must be finite and >= 0");
44
+ }
45
+ if (def.growth === "power") {
46
+ check(Number.isFinite(def.growthRate ?? 1) && (def.growthRate ?? 1) > 0, "XP curve power growthRate must be positive");
47
+ }
48
+ if (def.growth === "geometric") {
49
+ check(Number.isFinite(def.growthRate ?? 1) && (def.growthRate ?? 1) > 0, "XP curve geometric growthRate must be positive");
50
+ }
51
+ if (def.growth === "table") {
52
+ const needed = def.maxLevel - startLevel;
53
+ check((def.table?.length ?? 0) >= needed, `XP curve table needs ${needed} thresholds for ${needed} level-ups`);
54
+ for (const value of def.table ?? [])
55
+ check(Number.isFinite(value) && value > 0, `XP curve table values must be positive; got ${String(value)}`);
56
+ }
57
+ }
58
+ /**
59
+ * Resolve authored XP curve data: the threshold per level, the lifetime XP it sits at, a plain-words
60
+ * growth rule, and the level an XP total reaches under the cap. One data record with one growth rule
61
+ * keeps a rebalance in the save-visible definition rather than in formulas scattered across a game.
62
+ *
63
+ * @capability xp-curve resolve an authored xp growth rule into per-level thresholds, totals and a level cap
64
+ */
65
+ export function defineXpCurve(def) {
66
+ assertXpCurve(def);
67
+ const startLevel = def.startLevel ?? 1;
68
+ const maxLevel = def.maxLevel;
69
+ const round = def.round ?? "ceil";
70
+ const growthCurve = curveDefOf(def);
71
+ const rawThreshold = (level) => {
72
+ if (growthCurve === null) {
73
+ const index = level - startLevel;
74
+ const table = def.table ?? [];
75
+ return table[Math.min(index, table.length - 1)] ?? 0;
76
+ }
77
+ const relative = level - startLevel + 1;
78
+ const x = growthCurve.kind === "linear" || growthCurve.kind === "geometric" ? relative - 1 : relative;
79
+ return evalCurve({ ...growthCurve, round }, x);
80
+ };
81
+ const xpForLevel = (level) => {
82
+ if (level >= maxLevel)
83
+ return 0;
84
+ const clamped = Math.max(startLevel, Math.min(level, maxLevel - 1));
85
+ return Math.max(1, rawThreshold(clamped));
86
+ };
87
+ const totalXpFor = (level) => {
88
+ const clamped = Math.max(startLevel, Math.min(level, maxLevel));
89
+ let total = 0;
90
+ for (let current = startLevel; current < clamped; current += 1)
91
+ total += xpForLevel(current);
92
+ return total;
93
+ };
94
+ const readTable = () => {
95
+ const rows = [];
96
+ let total = 0;
97
+ for (let level = startLevel; level <= maxLevel; level += 1) {
98
+ const threshold = xpForLevel(level);
99
+ rows.push({ level, xpForLevel: threshold, totalXp: total });
100
+ total += threshold;
101
+ }
102
+ return rows;
103
+ };
104
+ const copy = {
105
+ growth: def.growth,
106
+ step: def.step,
107
+ maxLevel,
108
+ startLevel,
109
+ round,
110
+ ...(def.growthRate === undefined ? {} : { growthRate: def.growthRate }),
111
+ ...(def.table === undefined ? {} : { table: [...def.table] }),
112
+ };
113
+ let trackCache = null;
114
+ const buildTrack = () => leveling({
115
+ // `steps` indexes by level number, so the array is level-addressed rather than offset.
116
+ xpForLevel: {
117
+ kind: "steps",
118
+ values: Array.from({ length: maxLevel + 1 }, (_, level) => xpForLevel(Math.max(startLevel, level))),
119
+ },
120
+ maxLevel,
121
+ startLevel,
122
+ thresholdMode: "perLevel",
123
+ });
124
+ return {
125
+ def: copy,
126
+ maxLevel,
127
+ startLevel,
128
+ rule: describeRule(copy, startLevel),
129
+ xpForLevel,
130
+ totalXpFor,
131
+ levelForXp(totalXp) {
132
+ trackCache ??= buildTrack();
133
+ return trackCache.resolve(startLevel, totalXp);
134
+ },
135
+ table: readTable,
136
+ track() {
137
+ trackCache ??= buildTrack();
138
+ return trackCache;
139
+ },
140
+ };
141
+ }
@@ -13,6 +13,7 @@ import { registerWaterKind } from "../world/waterKind.js";
13
13
  import { registerGrassKind } from "../world/grassKind.js";
14
14
  import { registerSoilKind } from "../world/soilKind.js";
15
15
  import { registerPoleLineKind } from "../world/poleLineKind.js";
16
+ import { registerDecalKind } from "../world/decalKind.js";
16
17
  import { registerBuildingGenerator } from "../world/buildingGenerator.js";
17
18
  import { registerCityKind } from "../world/cityKind.js";
18
19
  import { describeScatterCoverage, densityCoverage } from "../world/scatterCoverage.js";
@@ -80,5 +81,6 @@ export function registerBuiltinSceneKinds() {
80
81
  registerSoilKind();
81
82
  registerCityKind();
82
83
  registerPoleLineKind();
84
+ registerDecalKind();
83
85
  registerBuildingGenerator();
84
86
  }