@jgengine/core 0.26.0 → 0.28.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.
- package/CHANGELOG.md +174 -0
- package/dist/ai/policeResponse.d.ts +253 -0
- package/dist/ai/policeResponse.js +421 -0
- package/dist/anim/actionLayer.d.ts +47 -0
- package/dist/anim/actionLayer.js +63 -0
- package/dist/anim/ikSolver.d.ts +14 -0
- package/dist/anim/ikSolver.js +37 -0
- package/dist/anim/locomotionGraph.d.ts +58 -3
- package/dist/anim/locomotionGraph.js +142 -15
- package/dist/anim/ragdollRig.d.ts +53 -0
- package/dist/anim/ragdollRig.js +132 -0
- package/dist/anim/skeletonStandard.d.ts +140 -0
- package/dist/anim/skeletonStandard.js +326 -0
- package/dist/combat/damageRuntime.d.ts +14 -1
- package/dist/combat/damageRuntime.js +62 -4
- package/dist/combat/death.js +7 -3
- package/dist/combat/deathReason.d.ts +7 -1
- package/dist/combat/effects.d.ts +25 -0
- package/dist/combat/effects.js +16 -0
- package/dist/combat/statusRuntime.d.ts +2 -0
- package/dist/combat/statusRuntime.js +1 -0
- package/dist/economy/loanBook.d.ts +182 -0
- package/dist/economy/loanBook.js +303 -0
- package/dist/economy/market.d.ts +161 -0
- package/dist/economy/market.js +248 -0
- package/dist/economy/transactionLedger.d.ts +162 -0
- package/dist/economy/transactionLedger.js +301 -0
- package/dist/editor/creatorStorage.js +3 -1
- package/dist/editor/simulation.d.ts +5 -2
- package/dist/editor/simulation.js +29 -19
- package/dist/game/cameraConfig.d.ts +14 -0
- package/dist/game/clipRoles.d.ts +5 -4
- package/dist/game/clipRoles.js +34 -4
- package/dist/game/events.d.ts +27 -0
- package/dist/game/gameSave.d.ts +142 -0
- package/dist/game/gameSave.js +174 -0
- package/dist/game/playableGame.d.ts +74 -6
- package/dist/game/saveSlotIndex.d.ts +54 -0
- package/dist/game/saveSlotIndex.js +71 -0
- package/dist/game/saveSlots.d.ts +2 -0
- package/dist/game/saveSlots.js +1 -1
- package/dist/game/saveStore.d.ts +8 -0
- package/dist/game/saveStore.js +16 -0
- package/dist/gameplay.d.ts +17 -2
- package/dist/gameplay.js +15 -0
- package/dist/inventory/carryLoad.d.ts +48 -0
- package/dist/inventory/carryLoad.js +44 -0
- package/dist/inventory/equipment.d.ts +95 -0
- package/dist/inventory/equipment.js +169 -0
- package/dist/inventory/inventoryModel.d.ts +15 -6
- package/dist/inventory/inventoryModel.js +54 -5
- package/dist/inventory/shapedGrid.d.ts +26 -0
- package/dist/inventory/shapedGrid.js +47 -0
- package/dist/loot/lootGen.d.ts +186 -0
- package/dist/loot/lootGen.js +437 -0
- package/dist/loot/lootWeapon.d.ts +35 -0
- package/dist/loot/lootWeapon.js +70 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +156 -1
- package/dist/mission/compositions.d.ts +85 -0
- package/dist/mission/compositions.js +130 -0
- package/dist/mission/mission.d.ts +435 -0
- package/dist/mission/mission.js +770 -0
- package/dist/mission/triggers.d.ts +61 -0
- package/dist/mission/triggers.js +108 -0
- package/dist/nav/laneGraph.d.ts +178 -0
- package/dist/nav/laneGraph.js +671 -0
- package/dist/nav/traffic.d.ts +222 -0
- package/dist/nav/traffic.js +918 -0
- package/dist/physics/kinematicVehicle.d.ts +36 -0
- package/dist/physics/kinematicVehicle.js +15 -1
- package/dist/physics/vehicleDamage.d.ts +136 -0
- package/dist/physics/vehicleDamage.js +214 -0
- package/dist/physics/vehicleParts.d.ts +62 -0
- package/dist/physics/vehicleParts.js +65 -0
- package/dist/progression/gearScore.d.ts +56 -0
- package/dist/progression/gearScore.js +59 -0
- package/dist/progression/skillTree.d.ts +129 -0
- package/dist/progression/skillTree.js +265 -0
- package/dist/progression/xpCurve.d.ts +60 -0
- package/dist/progression/xpCurve.js +141 -0
- package/dist/runtime/context/combat.js +11 -0
- package/dist/scene/builtinSceneKinds.js +2 -0
- package/dist/settings/accessibilitySettings.d.ts +81 -0
- package/dist/settings/accessibilitySettings.js +152 -0
- package/dist/settings/graphicsProfile.d.ts +3 -0
- package/dist/settings/graphicsProfile.js +4 -0
- package/dist/settings/settingsModel.d.ts +1 -1
- package/dist/settings/settingsModel.js +1 -0
- package/dist/sim/nearAgents.d.ts +8 -1
- package/dist/sim/nearAgents.js +34 -17
- package/dist/sim/offScreen.d.ts +2 -1
- package/dist/sim/offScreen.js +14 -3
- package/dist/sim/population.d.ts +147 -0
- package/dist/sim/population.js +182 -0
- package/dist/sim/queue.d.ts +90 -0
- package/dist/sim/queue.js +171 -0
- package/dist/sim/rides.d.ts +287 -0
- package/dist/sim/rides.js +471 -0
- package/dist/sim/routine.js +19 -11
- package/dist/ui/loadingTips.d.ts +41 -0
- package/dist/ui/loadingTips.js +64 -0
- package/dist/ui/menuNav.d.ts +79 -0
- package/dist/ui/menuNav.js +147 -0
- package/dist/ui/menuTransition.d.ts +50 -0
- package/dist/ui/menuTransition.js +67 -0
- package/dist/ui.d.ts +2 -0
- package/dist/ui.js +2 -0
- package/dist/vfx/effectLibrary.d.ts +98 -0
- package/dist/vfx/effectLibrary.js +181 -0
- package/dist/vfx/particleBudget.d.ts +40 -0
- package/dist/vfx/particleBudget.js +39 -0
- package/dist/vfx/particleDirector.d.ts +16 -0
- package/dist/vfx/particleDirector.js +18 -0
- package/dist/world/authoredSimulation.js +7 -1
- package/dist/world/captureCamera.d.ts +30 -0
- package/dist/world/captureCamera.js +19 -0
- package/dist/world/decalKind.d.ts +48 -0
- package/dist/world/decalKind.js +86 -0
- package/dist/world/features.d.ts +18 -5
- package/dist/world/features.js +1 -0
- package/dist/world/grassKind.d.ts +5 -0
- package/dist/world/grassKind.js +3 -0
- package/dist/world/terraform.d.ts +8 -0
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world.d.ts +7 -2
- package/dist/world.js +5 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-combat/SKILL.md +9 -1
- package/skills/jgengine-combat/api.md +5 -4
- package/skills/jgengine-combat/reference.md +4 -0
- package/skills/jgengine-gameplay/SKILL.md +86 -0
- package/skills/jgengine-gameplay/api.md +485 -16
- package/skills/jgengine-gameplay/capabilities.md +131 -4
- package/skills/jgengine-gameplay/reference-systems.md +118 -0
- package/skills/jgengine-world/SKILL.md +8 -0
- package/skills/jgengine-world/api.md +243 -11
- package/skills/jgengine-world/capabilities.md +61 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { devtools } from "../devtools/devtools.js";
|
|
2
|
+
import { slotAccepts } from "../item/modularItem.js";
|
|
3
|
+
import { pickWeighted } from "../random/pick.js";
|
|
4
|
+
/** Cap on affixes one item may roll, so a bad catalog cannot make a drop cost unbounded work. */
|
|
5
|
+
export const MAX_LOOT_AFFIXES = 8;
|
|
6
|
+
function check(condition, message) {
|
|
7
|
+
if (!condition)
|
|
8
|
+
throw new RangeError(message);
|
|
9
|
+
}
|
|
10
|
+
/** Check a catalog before rolling; throws `RangeError` naming the offending entry. */
|
|
11
|
+
export function assertLootCatalog(catalog) {
|
|
12
|
+
check(catalog.bases.length > 0, 'Loot catalog field "bases" must not be empty');
|
|
13
|
+
check(catalog.rarities.length > 0, 'Loot catalog field "rarities" must not be empty');
|
|
14
|
+
const baseIds = new Set();
|
|
15
|
+
for (const base of catalog.bases) {
|
|
16
|
+
check(base.id.trim() !== "", 'Loot base field "id" must not be empty');
|
|
17
|
+
check(!baseIds.has(base.id), `Loot base "${base.id}" is declared twice`);
|
|
18
|
+
baseIds.add(base.id);
|
|
19
|
+
if (base.weight !== undefined)
|
|
20
|
+
check(base.weight > 0, `Loot base "${base.id}" weight must be positive`);
|
|
21
|
+
}
|
|
22
|
+
const poolIds = new Set();
|
|
23
|
+
const affixIds = new Set();
|
|
24
|
+
for (const pool of catalog.pools) {
|
|
25
|
+
check(pool.id.trim() !== "", 'Loot affix pool field "id" must not be empty');
|
|
26
|
+
check(!poolIds.has(pool.id), `Loot affix pool "${pool.id}" is declared twice`);
|
|
27
|
+
poolIds.add(pool.id);
|
|
28
|
+
for (const affix of pool.affixes) {
|
|
29
|
+
check(!affixIds.has(affix.id), `Loot affix "${affix.id}" is declared twice`);
|
|
30
|
+
affixIds.add(affix.id);
|
|
31
|
+
check(affix.weight > 0, `Loot affix "${affix.id}" weight must be positive`);
|
|
32
|
+
if (typeof affix.roll === "number") {
|
|
33
|
+
check(Number.isFinite(affix.roll), `Loot affix "${affix.id}" roll must be finite`);
|
|
34
|
+
if (affix.op === "mul")
|
|
35
|
+
check(affix.roll > 0, `Loot affix "${affix.id}" multiplicative roll must be positive`);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
const [min, max] = affix.roll;
|
|
39
|
+
check(Number.isFinite(min) && Number.isFinite(max), `Loot affix "${affix.id}" roll range must be finite`);
|
|
40
|
+
check(min <= max, `Loot affix "${affix.id}" roll range must ascend`);
|
|
41
|
+
if (affix.op === "mul")
|
|
42
|
+
check(min > 0, `Loot affix "${affix.id}" multiplicative roll must be positive`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const partIds = new Set();
|
|
47
|
+
for (const part of catalog.parts) {
|
|
48
|
+
check(part.id.trim() !== "", 'Loot part field "id" must not be empty');
|
|
49
|
+
check(!partIds.has(part.id), `Loot part "${part.id}" is declared twice`);
|
|
50
|
+
partIds.add(part.id);
|
|
51
|
+
if (part.weight !== undefined)
|
|
52
|
+
check(part.weight > 0, `Loot part "${part.id}" weight must be positive`);
|
|
53
|
+
}
|
|
54
|
+
const rarityIds = new Set();
|
|
55
|
+
for (const rarity of catalog.rarities) {
|
|
56
|
+
check(rarity.id.trim() !== "", 'Loot rarity field "id" must not be empty');
|
|
57
|
+
check(!rarityIds.has(rarity.id), `Loot rarity "${rarity.id}" is declared twice`);
|
|
58
|
+
rarityIds.add(rarity.id);
|
|
59
|
+
check(rarity.weight > 0, `Loot rarity "${rarity.id}" weight must be positive`);
|
|
60
|
+
const [min, max] = typeof rarity.affixCount === "number"
|
|
61
|
+
? [rarity.affixCount, rarity.affixCount]
|
|
62
|
+
: rarity.affixCount;
|
|
63
|
+
check(Number.isInteger(min) && Number.isInteger(max) && min >= 0 && max >= min, `Loot rarity "${rarity.id}" affixCount must be an ascending integer range`);
|
|
64
|
+
if (rarity.pools !== undefined) {
|
|
65
|
+
for (const id of rarity.pools)
|
|
66
|
+
check(poolIds.has(id), `Loot rarity "${rarity.id}" names unknown pool "${id}"`);
|
|
67
|
+
}
|
|
68
|
+
if (rarity.statScale !== undefined)
|
|
69
|
+
check(rarity.statScale > 0, `Loot rarity "${rarity.id}" statScale must be positive`);
|
|
70
|
+
}
|
|
71
|
+
for (const base of catalog.bases) {
|
|
72
|
+
for (const poolId of base.pools) {
|
|
73
|
+
check(poolIds.has(poolId), `Loot base "${base.id}" names unknown affix pool "${poolId}"`);
|
|
74
|
+
}
|
|
75
|
+
for (const slot of base.slots ?? []) {
|
|
76
|
+
const eligible = catalog.parts.some((part) => slotAccepts(slot, part.category));
|
|
77
|
+
check(eligible, `Loot base "${base.id}" slot "${slot.id}" accepts no part category (${String(slot.accepts)})`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const makerIds = new Set();
|
|
81
|
+
for (const manufacturer of catalog.manufacturers) {
|
|
82
|
+
check(manufacturer.id.trim() !== "", 'Loot manufacturer field "id" must not be empty');
|
|
83
|
+
check(manufacturer.name.trim() !== "", `Loot manufacturer "${manufacturer.id}" must have a name`);
|
|
84
|
+
check(!makerIds.has(manufacturer.id), `Loot manufacturer "${manufacturer.id}" is declared twice`);
|
|
85
|
+
makerIds.add(manufacturer.id);
|
|
86
|
+
if (manufacturer.weight !== undefined)
|
|
87
|
+
check(manufacturer.weight > 0, `Loot manufacturer "${manufacturer.id}" weight must be positive`);
|
|
88
|
+
for (const baseId of manufacturer.bases ?? []) {
|
|
89
|
+
check(baseIds.has(baseId), `Loot manufacturer "${manufacturer.id}" names unknown base "${baseId}"`);
|
|
90
|
+
}
|
|
91
|
+
for (const poolId of manufacturer.favoredPools ?? []) {
|
|
92
|
+
check(poolIds.has(poolId), `Loot manufacturer "${manufacturer.id}" names unknown pool "${poolId}"`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const points = catalog.levelScale ?? [];
|
|
96
|
+
for (let i = 0; i < points.length; i += 1) {
|
|
97
|
+
const [level, multiplier] = points[i];
|
|
98
|
+
check(Number.isInteger(level) && level >= 1, 'Loot catalog levelScale levels must be integers >= 1');
|
|
99
|
+
check(Number.isFinite(multiplier) && multiplier > 0, 'Loot catalog levelScale multipliers must be positive');
|
|
100
|
+
if (i > 0)
|
|
101
|
+
check(level > points[i - 1][0], "Loot catalog levelScale levels must ascend");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Stat multiplier at `level` from the catalog's level curve; `1` when the catalog declares none. */
|
|
105
|
+
export function lootLevelScale(catalog, level) {
|
|
106
|
+
const points = catalog.levelScale ?? [];
|
|
107
|
+
if (points.length === 0)
|
|
108
|
+
return 1;
|
|
109
|
+
if (level <= points[0][0])
|
|
110
|
+
return points[0][1];
|
|
111
|
+
const last = points[points.length - 1];
|
|
112
|
+
if (level >= last[0])
|
|
113
|
+
return last[1];
|
|
114
|
+
for (let i = 1; i < points.length; i += 1) {
|
|
115
|
+
const [hiLevel, hiScale] = points[i];
|
|
116
|
+
if (level > hiLevel)
|
|
117
|
+
continue;
|
|
118
|
+
const [loLevel, loScale] = points[i - 1];
|
|
119
|
+
const t = (level - loLevel) / (hiLevel - loLevel);
|
|
120
|
+
return loScale + (hiScale - loScale) * t;
|
|
121
|
+
}
|
|
122
|
+
return last[1];
|
|
123
|
+
}
|
|
124
|
+
function resolveStatScale(rarity) {
|
|
125
|
+
return rarity.statScale ?? 1;
|
|
126
|
+
}
|
|
127
|
+
function rollAffixValue(roll, op, scale, rng) {
|
|
128
|
+
const raw = typeof roll === "number" ? roll : roll[0] + rng() * (roll[1] - roll[0]);
|
|
129
|
+
return op === "mul" ? raw : raw * scale;
|
|
130
|
+
}
|
|
131
|
+
function affixCountOf(rarity, rng) {
|
|
132
|
+
const [min, max] = typeof rarity.affixCount === "number"
|
|
133
|
+
? [rarity.affixCount, rarity.affixCount]
|
|
134
|
+
: rarity.affixCount;
|
|
135
|
+
const span = max - min;
|
|
136
|
+
const count = min + (span === 0 ? 0 : Math.floor(rng() * (span + 1)));
|
|
137
|
+
return Math.min(MAX_LOOT_AFFIXES, Math.max(0, count));
|
|
138
|
+
}
|
|
139
|
+
function findOrThrow(items, id, field) {
|
|
140
|
+
const found = items.find((item) => item.id === id);
|
|
141
|
+
if (found === undefined)
|
|
142
|
+
throw new RangeError(`Loot ${field} "${id}" is not in the catalog`);
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
function weighted(rng, items, weightOf, field) {
|
|
146
|
+
const picked = pickWeighted(rng, items, weightOf);
|
|
147
|
+
if (picked === undefined)
|
|
148
|
+
throw new Error(`Loot catalog has no eligible ${field}`);
|
|
149
|
+
return picked;
|
|
150
|
+
}
|
|
151
|
+
function candidateAffixes(catalog, base, rarity) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const poolId of base.pools) {
|
|
154
|
+
if (rarity.pools !== undefined && !rarity.pools.includes(poolId))
|
|
155
|
+
continue;
|
|
156
|
+
const pool = catalog.pools.find((entry) => entry.id === poolId);
|
|
157
|
+
if (pool === undefined)
|
|
158
|
+
continue;
|
|
159
|
+
for (const affix of pool.affixes)
|
|
160
|
+
out.push({ affix, poolId });
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function composeLootName(base, rarity, manufacturer, parts, affixes, catalog) {
|
|
165
|
+
const namePartById = new Map();
|
|
166
|
+
for (const pool of catalog.pools) {
|
|
167
|
+
for (const affix of pool.affixes)
|
|
168
|
+
if (affix.namePart !== undefined)
|
|
169
|
+
namePartById.set(affix.id, affix.namePart);
|
|
170
|
+
}
|
|
171
|
+
const partWord = parts
|
|
172
|
+
.map((installed) => catalog.parts.find((part) => part.id === installed.partId)?.namePart)
|
|
173
|
+
.find((word) => word !== undefined);
|
|
174
|
+
const prefixWord = affixes.map((affix) => namePartById.get(affix.id)).find((part) => part?.position === "prefix")?.text;
|
|
175
|
+
const suffixWord = affixes.map((affix) => namePartById.get(affix.id)).find((part) => part?.position === "suffix")?.text;
|
|
176
|
+
return [manufacturer.name, rarity.namePart, partWord, prefixWord, base.name, suffixWord]
|
|
177
|
+
.filter((word) => word !== undefined && word.trim() !== "")
|
|
178
|
+
.join(" ");
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Roll one item from a catalog: a base and maker, a weighted rarity, one part per slot, rarity-count
|
|
182
|
+
* affixes, level-scaled stats and a name composed from maker, rarity, part, affix words and base.
|
|
183
|
+
* Deterministic for a given `rng`; every field it reads is caller data. Validate a catalog built
|
|
184
|
+
* outside {@link createLootRoller} once with {@link assertLootCatalog} before rolling from it.
|
|
185
|
+
*
|
|
186
|
+
* @capability loot-generation roll a named item from bases, parts, affixes, rarity weights, manufacturer personalities and level scaling
|
|
187
|
+
*/
|
|
188
|
+
export function rollLoot(catalog, rng, options = {}) {
|
|
189
|
+
const level = options.level ?? 1;
|
|
190
|
+
check(Number.isInteger(level) && level >= 1, `Loot item level must be an integer >= 1; got ${String(level)}`);
|
|
191
|
+
const scale = lootLevelScale(catalog, level);
|
|
192
|
+
const manufacturer = options.manufacturer === undefined
|
|
193
|
+
? weighted(rng, catalog.manufacturers.filter((maker) => options.baseId === undefined || maker.bases === undefined || maker.bases.includes(options.baseId)), (maker) => maker.weight ?? 1, "manufacturer")
|
|
194
|
+
: findOrThrow(catalog.manufacturers, options.manufacturer, "manufacturer");
|
|
195
|
+
const basePool = catalog.bases.filter((base) => manufacturer.bases === undefined || manufacturer.bases.includes(base.id));
|
|
196
|
+
const base = options.baseId === undefined
|
|
197
|
+
? weighted(rng, basePool, (entry) => entry.weight ?? 1, "base")
|
|
198
|
+
: findOrThrow(catalog.bases, options.baseId, "base");
|
|
199
|
+
check(manufacturer.bases === undefined || manufacturer.bases.includes(base.id), `Loot manufacturer "${manufacturer.id}" does not produce base "${base.id}"`);
|
|
200
|
+
const rarity = options.rarity === undefined
|
|
201
|
+
? weighted(rng, catalog.rarities, (tier) => tier.weight, "rarity")
|
|
202
|
+
: findOrThrow(catalog.rarities, options.rarity, "rarity");
|
|
203
|
+
const parts = [];
|
|
204
|
+
for (const slot of base.slots ?? []) {
|
|
205
|
+
const eligible = catalog.parts.filter((part) => slotAccepts(slot, part.category));
|
|
206
|
+
const part = weighted(rng, eligible, (entry) => entry.weight ?? 1, `part for slot "${slot.id}"`);
|
|
207
|
+
parts.push({ slotId: slot.id, partId: part.id, category: part.category });
|
|
208
|
+
}
|
|
209
|
+
const candidates = candidateAffixes(catalog, base, rarity);
|
|
210
|
+
const favors = manufacturer.favoredPools ?? [];
|
|
211
|
+
const favorWeight = manufacturer.favoredWeight ?? 3;
|
|
212
|
+
const remaining = candidates.slice();
|
|
213
|
+
const affixes = [];
|
|
214
|
+
const target = affixCountOf(rarity, rng);
|
|
215
|
+
for (let i = 0; i < target && remaining.length > 0; i += 1) {
|
|
216
|
+
const picked = weighted(rng, remaining, (entry) => entry.affix.weight * (favors.includes(entry.poolId) ? favorWeight : 1), "affix");
|
|
217
|
+
remaining.splice(remaining.indexOf(picked), 1);
|
|
218
|
+
const op = picked.affix.op ?? "add";
|
|
219
|
+
affixes.push({
|
|
220
|
+
id: picked.affix.id,
|
|
221
|
+
stat: picked.affix.stat,
|
|
222
|
+
op,
|
|
223
|
+
value: rollAffixValue(picked.affix.roll, op, scale, rng),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const stats = {};
|
|
227
|
+
const add = (stat, amount) => {
|
|
228
|
+
stats[stat] = (stats[stat] ?? 0) + amount;
|
|
229
|
+
};
|
|
230
|
+
const mul = (stat, factor) => {
|
|
231
|
+
stats[stat] = (stats[stat] ?? 0) * factor;
|
|
232
|
+
};
|
|
233
|
+
const rarityScale = resolveStatScale(rarity);
|
|
234
|
+
for (const [stat, value] of Object.entries(base.baseStats))
|
|
235
|
+
add(stat, value * rarityScale * scale);
|
|
236
|
+
for (const installed of parts) {
|
|
237
|
+
const part = catalog.parts.find((entry) => entry.id === installed.partId);
|
|
238
|
+
if (part === undefined)
|
|
239
|
+
continue;
|
|
240
|
+
for (const [stat, value] of Object.entries(part.stats ?? {}))
|
|
241
|
+
add(stat, value * scale);
|
|
242
|
+
for (const [stat, factor] of Object.entries(part.multipliers ?? {}))
|
|
243
|
+
mul(stat, factor);
|
|
244
|
+
}
|
|
245
|
+
for (const [stat, value] of Object.entries(manufacturer.bias ?? {}))
|
|
246
|
+
add(stat, value * scale);
|
|
247
|
+
for (const [stat, factor] of Object.entries(manufacturer.scale ?? {}))
|
|
248
|
+
mul(stat, factor);
|
|
249
|
+
for (const affix of affixes) {
|
|
250
|
+
if (affix.op === "mul")
|
|
251
|
+
mul(affix.stat, affix.value);
|
|
252
|
+
else
|
|
253
|
+
add(affix.stat, affix.value);
|
|
254
|
+
}
|
|
255
|
+
for (const stat of Object.keys(stats))
|
|
256
|
+
stats[stat] = Math.round(stats[stat] * 1000) / 1000;
|
|
257
|
+
return {
|
|
258
|
+
baseId: base.id,
|
|
259
|
+
manufacturer: manufacturer.id,
|
|
260
|
+
rarity: rarity.id,
|
|
261
|
+
level,
|
|
262
|
+
name: composeLootName(base, rarity, manufacturer, parts, affixes, catalog),
|
|
263
|
+
parts,
|
|
264
|
+
affixes,
|
|
265
|
+
stats,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Check one rolled item against its catalog: base, maker and rarity exist, every slot holds an
|
|
270
|
+
* eligible part, affixes belong to the base's pools, stats are finite and the item is named.
|
|
271
|
+
*
|
|
272
|
+
* @capability loot-validation check a rolled item against its catalog and report each defect with its code
|
|
273
|
+
*/
|
|
274
|
+
export function validateLootItem(item, catalog) {
|
|
275
|
+
const issues = [];
|
|
276
|
+
if (!Number.isInteger(item.level) || item.level < 1) {
|
|
277
|
+
issues.push({ code: "invalid-level", detail: `level ${String(item.level)} is not an integer >= 1` });
|
|
278
|
+
}
|
|
279
|
+
const base = catalog.bases.find((entry) => entry.id === item.baseId);
|
|
280
|
+
if (base === undefined) {
|
|
281
|
+
issues.push({ code: "unknown-base", detail: `base "${item.baseId}" is not in the catalog` });
|
|
282
|
+
}
|
|
283
|
+
const manufacturer = catalog.manufacturers.find((entry) => entry.id === item.manufacturer);
|
|
284
|
+
if (manufacturer === undefined) {
|
|
285
|
+
issues.push({ code: "unknown-manufacturer", detail: `manufacturer "${item.manufacturer}" is not in the catalog` });
|
|
286
|
+
}
|
|
287
|
+
else if (base !== undefined && manufacturer.bases !== undefined && !manufacturer.bases.includes(base.id)) {
|
|
288
|
+
issues.push({ code: "manufacturer-base", detail: `manufacturer "${manufacturer.id}" does not produce "${base.id}"` });
|
|
289
|
+
}
|
|
290
|
+
const rarity = catalog.rarities.find((entry) => entry.id === item.rarity);
|
|
291
|
+
if (rarity === undefined) {
|
|
292
|
+
issues.push({ code: "unknown-rarity", detail: `rarity "${item.rarity}" is not in the catalog` });
|
|
293
|
+
}
|
|
294
|
+
const seenSlots = new Set();
|
|
295
|
+
for (const installed of item.parts) {
|
|
296
|
+
if (seenSlots.has(installed.slotId)) {
|
|
297
|
+
issues.push({ code: "duplicate-slot", detail: `slot "${installed.slotId}" holds more than one part` });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
seenSlots.add(installed.slotId);
|
|
301
|
+
const slot = base?.slots?.find((entry) => entry.id === installed.slotId);
|
|
302
|
+
if (base !== undefined && slot === undefined) {
|
|
303
|
+
issues.push({ code: "unknown-slot", detail: `base "${base.id}" has no slot "${installed.slotId}"` });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const part = catalog.parts.find((entry) => entry.id === installed.partId);
|
|
307
|
+
if (part === undefined) {
|
|
308
|
+
issues.push({ code: "unknown-part", detail: `part "${installed.partId}" is not in the catalog` });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (slot !== undefined && !slotAccepts(slot, part.category)) {
|
|
312
|
+
issues.push({ code: "part-category", detail: `part "${part.id}" (${part.category}) does not fit slot "${slot.id}"` });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (const slot of base?.slots ?? []) {
|
|
316
|
+
if (!seenSlots.has(slot.id))
|
|
317
|
+
issues.push({ code: "missing-slot-part", detail: `slot "${slot.id}" has no part` });
|
|
318
|
+
}
|
|
319
|
+
const allowed = base === undefined || rarity === undefined ? null : candidateAffixes(catalog, base, rarity);
|
|
320
|
+
const seenAffixes = new Set();
|
|
321
|
+
for (const affix of item.affixes) {
|
|
322
|
+
if (seenAffixes.has(affix.id)) {
|
|
323
|
+
issues.push({ code: "duplicate-affix", detail: `affix "${affix.id}" is rolled twice` });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
seenAffixes.add(affix.id);
|
|
327
|
+
if (allowed !== null && !allowed.some((entry) => entry.affix.id === affix.id)) {
|
|
328
|
+
issues.push({ code: "unknown-affix", detail: `affix "${affix.id}" is not in the pools this base and rarity offer` });
|
|
329
|
+
}
|
|
330
|
+
if (!Number.isFinite(affix.value) || (affix.op === "mul" && affix.value <= 0)) {
|
|
331
|
+
issues.push({ code: "invalid-affix", detail: `affix "${affix.id}" value ${String(affix.value)} is not usable` });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
for (const [stat, value] of Object.entries(item.stats)) {
|
|
335
|
+
if (!Number.isFinite(value))
|
|
336
|
+
issues.push({ code: "non-finite-stat", detail: `stat "${stat}" is ${String(value)}` });
|
|
337
|
+
}
|
|
338
|
+
if (item.name.trim() === "")
|
|
339
|
+
issues.push({ code: "empty-name", detail: "item has no name" });
|
|
340
|
+
return issues;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Per-stat weighted contribution: `value × weight`, negated for stats listed in `lowerIsBetter`.
|
|
344
|
+
* This is the single weight policy behind {@link scoreLootStats}, {@link compareLootItems} and a
|
|
345
|
+
* gear score, so an item and a whole loadout are never ranked by different arithmetic.
|
|
346
|
+
*
|
|
347
|
+
* @capability loot-score split one stat block into per-stat weighted contributions
|
|
348
|
+
*/
|
|
349
|
+
export function lootStatContributions(stats, options = {}) {
|
|
350
|
+
const weights = options.weights ?? {};
|
|
351
|
+
const lower = new Set(options.lowerIsBetter ?? []);
|
|
352
|
+
const out = {};
|
|
353
|
+
for (const stat of Object.keys(stats)) {
|
|
354
|
+
out[stat] = (stats[stat] ?? 0) * (weights[stat] ?? 1) * (lower.has(stat) ? -1 : 1);
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* One stat block's weighted score: the sum of `value × weight`, negated for stats listed in
|
|
360
|
+
* `lowerIsBetter`. Pair it with {@link lootStatContributions} when a caller also needs the split.
|
|
361
|
+
*
|
|
362
|
+
* @capability loot-score score one stat block with caller weights and lower-is-better stats
|
|
363
|
+
*/
|
|
364
|
+
export function scoreLootStats(stats, options = {}) {
|
|
365
|
+
let score = 0;
|
|
366
|
+
for (const value of Object.values(lootStatContributions(stats, options)))
|
|
367
|
+
score += value;
|
|
368
|
+
return score;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Compare two rolled items: every stat delta plus one weighted score for each, with caller-named
|
|
372
|
+
* weights and which stats read better when small.
|
|
373
|
+
*
|
|
374
|
+
* @capability loot-comparison compare two rolled items with per-stat deltas and a weighted score
|
|
375
|
+
*/
|
|
376
|
+
export function compareLootItems(a, b, options = {}) {
|
|
377
|
+
const stats = [...new Set([...Object.keys(a.stats), ...Object.keys(b.stats)])].sort();
|
|
378
|
+
const deltas = [];
|
|
379
|
+
for (const stat of stats) {
|
|
380
|
+
const valueA = a.stats[stat] ?? 0;
|
|
381
|
+
const valueB = b.stats[stat] ?? 0;
|
|
382
|
+
deltas.push({ stat, a: valueA, b: valueB, delta: valueB - valueA });
|
|
383
|
+
}
|
|
384
|
+
const scoreA = scoreLootStats(a.stats, options);
|
|
385
|
+
const scoreB = scoreLootStats(b.stats, options);
|
|
386
|
+
const scoreDelta = scoreB - scoreA;
|
|
387
|
+
const tolerance = options.tolerance ?? 1e-9;
|
|
388
|
+
const better = Math.abs(scoreDelta) <= tolerance ? "equal" : scoreDelta > 0 ? "b" : "a";
|
|
389
|
+
return { deltas, scoreA, scoreB, scoreDelta, better };
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Wrap a catalog in a roll counter: same results as {@link rollLoot}, plus counts by rarity for a
|
|
393
|
+
* debug overlay, snapshot/restore of those counts, and an optional devtools probe.
|
|
394
|
+
*
|
|
395
|
+
* @capability loot-roller roll items from one validated catalog while counting drops per rarity for a debug probe
|
|
396
|
+
*/
|
|
397
|
+
export function createLootRoller(catalog, options = {}) {
|
|
398
|
+
assertLootCatalog(catalog);
|
|
399
|
+
let rolls = 0;
|
|
400
|
+
const byRarity = new Map();
|
|
401
|
+
const readState = () => ({ rolls, byRarity: Object.fromEntries(byRarity) });
|
|
402
|
+
const probe = options.devtoolsName === undefined
|
|
403
|
+
? null
|
|
404
|
+
: devtools.probes.register(options.devtoolsName, () => readState());
|
|
405
|
+
return {
|
|
406
|
+
roll(rng, rollOptions) {
|
|
407
|
+
const item = rollLoot(catalog, rng, rollOptions);
|
|
408
|
+
rolls += 1;
|
|
409
|
+
byRarity.set(item.rarity, (byRarity.get(item.rarity) ?? 0) + 1);
|
|
410
|
+
return item;
|
|
411
|
+
},
|
|
412
|
+
stats: readState,
|
|
413
|
+
snapshot: readState,
|
|
414
|
+
restore(next) {
|
|
415
|
+
if (!Number.isInteger(next.rolls) || next.rolls < 0)
|
|
416
|
+
return false;
|
|
417
|
+
const counts = new Map();
|
|
418
|
+
for (const [rarity, count] of Object.entries(next.byRarity)) {
|
|
419
|
+
if (!Number.isInteger(count) || count < 0)
|
|
420
|
+
return false;
|
|
421
|
+
counts.set(rarity, count);
|
|
422
|
+
}
|
|
423
|
+
rolls = next.rolls;
|
|
424
|
+
byRarity.clear();
|
|
425
|
+
for (const [rarity, count] of counts)
|
|
426
|
+
byRarity.set(rarity, count);
|
|
427
|
+
return true;
|
|
428
|
+
},
|
|
429
|
+
reset() {
|
|
430
|
+
rolls = 0;
|
|
431
|
+
byRarity.clear();
|
|
432
|
+
},
|
|
433
|
+
dispose() {
|
|
434
|
+
probe?.();
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type WeaponDefinition } from "../combat/weaponDefinition.js";
|
|
2
|
+
import type { LootItem } from "./lootGen.js";
|
|
3
|
+
/**
|
|
4
|
+
* Which rolled stat feeds which part of a {@link WeaponDefinition}. Every field is a stat id from the
|
|
5
|
+
* item; an omitted field keeps the template's value, so a catalog only maps what it rolls.
|
|
6
|
+
*/
|
|
7
|
+
export interface LootWeaponStatMap {
|
|
8
|
+
roundsPerMinute?: string;
|
|
9
|
+
damage?: string;
|
|
10
|
+
pellets?: string;
|
|
11
|
+
/** Requires the template to declare a magazine. */
|
|
12
|
+
magazineCapacity?: string;
|
|
13
|
+
reloadMs?: string;
|
|
14
|
+
/** Spread half-angle in radians, clamped to `0..PI/2`. */
|
|
15
|
+
spreadBase?: string;
|
|
16
|
+
/** Hitscan range, or a projectile's hard distance cap. */
|
|
17
|
+
maxRange?: string;
|
|
18
|
+
/** Projectile launch speed. */
|
|
19
|
+
projectileSpeed?: string;
|
|
20
|
+
}
|
|
21
|
+
/** A base weapon definition plus the stat map a rolled item overrides. */
|
|
22
|
+
export interface LootWeaponProjection {
|
|
23
|
+
template: WeaponDefinition;
|
|
24
|
+
stats: LootWeaponStatMap;
|
|
25
|
+
/** Definition id override; defaults to `loot.<baseId>.<rarity>`. */
|
|
26
|
+
id?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Project a rolled item onto a plain weapon definition: copy the template, then write the mapped
|
|
30
|
+
* stats into rate, damage, magazine, spread, range and projectile speed. The result is a
|
|
31
|
+
* {@link WeaponDefinition}, so `assertWeaponDefinition` and `createWeaponSystem` accept it unchanged.
|
|
32
|
+
*
|
|
33
|
+
* @capability loot-weapon feed a rolled item into a weapon definition so generated gear fires through the weapon system
|
|
34
|
+
*/
|
|
35
|
+
export declare function lootWeaponDefinition(item: LootItem, projection: LootWeaponProjection): WeaponDefinition;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { MAX_WEAPON_PELLETS_PER_SHOT, } from "../combat/weaponDefinition.js";
|
|
2
|
+
function statOf(item, statId) {
|
|
3
|
+
if (statId === undefined)
|
|
4
|
+
return undefined;
|
|
5
|
+
const value = item.stats[statId];
|
|
6
|
+
if (value === undefined)
|
|
7
|
+
throw new RangeError(`Loot weapon projection names stat "${statId}" but the item does not roll it`);
|
|
8
|
+
if (!Number.isFinite(value))
|
|
9
|
+
throw new RangeError(`Loot weapon projection stat "${statId}" is ${String(value)}`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function clamp(value, min, max) {
|
|
13
|
+
return Math.min(max, Math.max(min, value));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Project a rolled item onto a plain weapon definition: copy the template, then write the mapped
|
|
17
|
+
* stats into rate, damage, magazine, spread, range and projectile speed. The result is a
|
|
18
|
+
* {@link WeaponDefinition}, so `assertWeaponDefinition` and `createWeaponSystem` accept it unchanged.
|
|
19
|
+
*
|
|
20
|
+
* @capability loot-weapon feed a rolled item into a weapon definition so generated gear fires through the weapon system
|
|
21
|
+
*/
|
|
22
|
+
export function lootWeaponDefinition(item, projection) {
|
|
23
|
+
const template = projection.template;
|
|
24
|
+
const map = projection.stats;
|
|
25
|
+
const definition = {
|
|
26
|
+
...template,
|
|
27
|
+
id: projection.id ?? `loot.${item.baseId}.${item.rarity}`,
|
|
28
|
+
delivery: { ...template.delivery },
|
|
29
|
+
handling: { ...template.handling, spread: { ...template.handling.spread } },
|
|
30
|
+
damage: { ...template.damage },
|
|
31
|
+
...(template.magazine === undefined ? {} : { magazine: { ...template.magazine } }),
|
|
32
|
+
...(template.burst === undefined ? {} : { burst: { ...template.burst } }),
|
|
33
|
+
...(template.falloff === undefined ? {} : { falloff: { ...template.falloff } }),
|
|
34
|
+
};
|
|
35
|
+
const rate = statOf(item, map.roundsPerMinute);
|
|
36
|
+
if (rate !== undefined)
|
|
37
|
+
definition.roundsPerMinute = Math.max(Number.MIN_VALUE, rate);
|
|
38
|
+
const damage = statOf(item, map.damage);
|
|
39
|
+
if (damage !== undefined)
|
|
40
|
+
definition.damage.base = Math.max(Number.MIN_VALUE, damage);
|
|
41
|
+
const pellets = statOf(item, map.pellets);
|
|
42
|
+
if (pellets !== undefined) {
|
|
43
|
+
definition.pellets = clamp(Math.round(pellets), 1, MAX_WEAPON_PELLETS_PER_SHOT);
|
|
44
|
+
}
|
|
45
|
+
const capacity = statOf(item, map.magazineCapacity);
|
|
46
|
+
const reloadMs = statOf(item, map.reloadMs);
|
|
47
|
+
if (capacity !== undefined || reloadMs !== undefined) {
|
|
48
|
+
if (definition.magazine === undefined) {
|
|
49
|
+
throw new RangeError('Loot weapon projection maps magazineCapacity or reloadMs but the template has no magazine');
|
|
50
|
+
}
|
|
51
|
+
if (capacity !== undefined)
|
|
52
|
+
definition.magazine.capacity = Math.max(1, Math.round(capacity));
|
|
53
|
+
if (reloadMs !== undefined)
|
|
54
|
+
definition.magazine.reloadMs = Math.max(0, reloadMs);
|
|
55
|
+
}
|
|
56
|
+
const spread = statOf(item, map.spreadBase);
|
|
57
|
+
if (spread !== undefined)
|
|
58
|
+
definition.handling.spread.base = clamp(spread, 0, Math.PI / 2);
|
|
59
|
+
const range = statOf(item, map.maxRange);
|
|
60
|
+
if (range !== undefined)
|
|
61
|
+
definition.delivery.maxRange = Math.max(Number.MIN_VALUE, range);
|
|
62
|
+
const speed = statOf(item, map.projectileSpeed);
|
|
63
|
+
if (speed !== undefined) {
|
|
64
|
+
if (definition.delivery.kind !== "projectile") {
|
|
65
|
+
throw new RangeError('Loot weapon projection maps projectileSpeed but the template is hitscan');
|
|
66
|
+
}
|
|
67
|
+
definition.delivery.speed = Math.max(Number.MIN_VALUE, speed);
|
|
68
|
+
}
|
|
69
|
+
return definition;
|
|
70
|
+
}
|
package/dist/meta/changelog.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.28.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[];
|