@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.
- package/CHANGELOG.md +44 -0
- package/dist/game/playableGame.d.ts +19 -2
- package/dist/gameplay.d.ts +5 -0
- package/dist/gameplay.js +5 -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 +37 -1
- package/dist/nav/laneGraph.d.ts +178 -0
- package/dist/nav/laneGraph.js +671 -0
- package/dist/nav/traffic.d.ts +223 -0
- package/dist/nav/traffic.js +919 -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/scene/builtinSceneKinds.js +2 -0
- package/dist/world/decalKind.d.ts +48 -0
- package/dist/world/decalKind.js +86 -0
- package/dist/world/features.d.ts +3 -1
- 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.d.ts +5 -1
- package/dist/world.js +4 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-combat/SKILL.md +3 -1
- package/skills/jgengine-gameplay/SKILL.md +31 -0
- package/skills/jgengine-gameplay/api.md +150 -6
- package/skills/jgengine-gameplay/capabilities.md +48 -3
- package/skills/jgengine-gameplay/reference-systems.md +118 -0
- package/skills/jgengine-world/SKILL.md +4 -0
- package/skills/jgengine-world/api.md +115 -5
- package/skills/jgengine-world/capabilities.md +20 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
|
@@ -147,6 +147,53 @@ export function placeShaped(grid, item, origin, rotation = 0) {
|
|
|
147
147
|
};
|
|
148
148
|
return { status: "ok", grid: { ...grid, placements: [...grid.placements, placement] } };
|
|
149
149
|
}
|
|
150
|
+
function footprintBounds(footprint) {
|
|
151
|
+
let width = 0;
|
|
152
|
+
let height = 0;
|
|
153
|
+
for (const [c, r] of footprint) {
|
|
154
|
+
width = Math.max(width, c + 1);
|
|
155
|
+
height = Math.max(height, r + 1);
|
|
156
|
+
}
|
|
157
|
+
return { width, height };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Place a shaped item at the first legal spot, or say why it does not fit. Distinguishes an item that
|
|
161
|
+
* can never fit the board (`too-large`, with its footprint and the board size in `detail`) from a full
|
|
162
|
+
* board (`no-space`) and an exhausted search (`budget-exceeded`, not a full bag).
|
|
163
|
+
*
|
|
164
|
+
* @capability shaped-placement place a shaped item at its first fit and name why it does not fit
|
|
165
|
+
*/
|
|
166
|
+
export function fitShapedItem(grid, item, options = {}) {
|
|
167
|
+
if (grid.placements.some((placement) => placement.id === item.id)) {
|
|
168
|
+
return { status: "rejected", reason: "duplicate-id", detail: `"${item.id}" is already placed`, checks: 0 };
|
|
169
|
+
}
|
|
170
|
+
if (!validFootprint(item.footprint)) {
|
|
171
|
+
return { status: "rejected", reason: "invalid-footprint", detail: `footprint has ${item.footprint.length} cell(s)`, checks: 0 };
|
|
172
|
+
}
|
|
173
|
+
const rotations = options.rotations ?? [0, 1, 2, 3];
|
|
174
|
+
const candidates = rotations.map((rotation) => ({ rotation, bounds: footprintBounds(rotateFootprint(item.footprint, rotation)) }));
|
|
175
|
+
const fitsEmptyBoard = candidates.filter(({ bounds }) => bounds.width <= grid.width && bounds.height <= grid.height);
|
|
176
|
+
if (fitsEmptyBoard.length === 0) {
|
|
177
|
+
const bounds = footprintBounds(normalizeFootprint(item.footprint));
|
|
178
|
+
return {
|
|
179
|
+
status: "rejected",
|
|
180
|
+
reason: "too-large",
|
|
181
|
+
detail: `a ${bounds.width}×${bounds.height} item does not fit a ${grid.width}×${grid.height} board`,
|
|
182
|
+
checks: 0,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const fit = findShapedPlacement(grid, item.footprint, { ...options, rotations: fitsEmptyBoard.map((entry) => entry.rotation) });
|
|
186
|
+
if (fit.status !== "found") {
|
|
187
|
+
return fit.status === "budget-exceeded"
|
|
188
|
+
? { status: "rejected", reason: "budget-exceeded", detail: `search stopped after ${fit.checks} placements`, checks: fit.checks }
|
|
189
|
+
: { status: "rejected", reason: "no-space", detail: `every position is covered after ${fit.checks} placements`, checks: fit.checks };
|
|
190
|
+
}
|
|
191
|
+
const placed = placeShaped(grid, item, fit.origin, fit.rotation);
|
|
192
|
+
if (placed.status === "rejected") {
|
|
193
|
+
return { status: "rejected", reason: "no-space", detail: placed.reason, checks: fit.checks };
|
|
194
|
+
}
|
|
195
|
+
return { status: "ok", grid: placed.grid, origin: fit.origin, rotation: fit.rotation, checks: fit.checks };
|
|
196
|
+
}
|
|
150
197
|
/**
|
|
151
198
|
* Remove one placement without mutating the source board.
|
|
152
199
|
* @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { AffixPool, ItemBaseDef, RarityTier, RolledAffix } from "../item/affix.js";
|
|
2
|
+
import { type MountSlotDef, type PartDef } from "../item/modularItem.js";
|
|
3
|
+
/** Cap on affixes one item may roll, so a bad catalog cannot make a drop cost unbounded work. */
|
|
4
|
+
export declare const MAX_LOOT_AFFIXES = 8;
|
|
5
|
+
/** A part a base can mount: its stats, a pick weight, and an optional word for the item name. */
|
|
6
|
+
export interface LootPartDef extends PartDef {
|
|
7
|
+
/** Relative pick weight among parts eligible for the same slot (default `1`). */
|
|
8
|
+
weight?: number;
|
|
9
|
+
/** Word this part adds to the generated name, e.g. `"Riveted"`. */
|
|
10
|
+
namePart?: string;
|
|
11
|
+
}
|
|
12
|
+
/** A base family (frame, housing, chassis — caller nouns) with its slots, stats and affix pools. */
|
|
13
|
+
export interface LootBaseDef extends ItemBaseDef {
|
|
14
|
+
/** Relative pick weight among bases a manufacturer can produce (default `1`). */
|
|
15
|
+
weight?: number;
|
|
16
|
+
/** Free-form tags for caller filters and UI. */
|
|
17
|
+
tags?: readonly string[];
|
|
18
|
+
/** Mount slots a roll fills; every declared slot receives one part. */
|
|
19
|
+
slots?: readonly MountSlotDef[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A maker with a stat personality: its mark goes in the name, `bias` shifts stats additively,
|
|
23
|
+
* `scale` multiplies them, and favored affix pools weigh more when this maker rolls the item.
|
|
24
|
+
*/
|
|
25
|
+
export interface LootManufacturerDef {
|
|
26
|
+
id: string;
|
|
27
|
+
/** The maker's mark as it reads in the item name. */
|
|
28
|
+
name: string;
|
|
29
|
+
/** Relative pick weight among manufacturers (default `1`). */
|
|
30
|
+
weight?: number;
|
|
31
|
+
/** Base ids this maker produces; omitted means every base. */
|
|
32
|
+
bases?: readonly string[];
|
|
33
|
+
/** Flat stat shifts applied after level scaling and rarity scaling. */
|
|
34
|
+
bias?: Readonly<Record<string, number>>;
|
|
35
|
+
/** Per-stat multipliers applied after `bias`. */
|
|
36
|
+
scale?: Readonly<Record<string, number>>;
|
|
37
|
+
/** Affix pool ids this maker favors. */
|
|
38
|
+
favoredPools?: readonly string[];
|
|
39
|
+
/** Weight multiplier for an affix from a favored pool (default `3`). */
|
|
40
|
+
favoredWeight?: number;
|
|
41
|
+
}
|
|
42
|
+
/** Everything a roll reads: bases, parts, affixes, rarity odds, makers and the level curve. */
|
|
43
|
+
export interface LootCatalog {
|
|
44
|
+
bases: readonly LootBaseDef[];
|
|
45
|
+
parts: readonly LootPartDef[];
|
|
46
|
+
pools: readonly AffixPool[];
|
|
47
|
+
rarities: readonly RarityTier[];
|
|
48
|
+
manufacturers: readonly LootManufacturerDef[];
|
|
49
|
+
/** `[level, multiplier]` points, linearly interpolated and clamped; omitted means no level scaling. */
|
|
50
|
+
levelScale?: readonly (readonly [number, number])[];
|
|
51
|
+
}
|
|
52
|
+
/** One part occupying one slot of a rolled item. */
|
|
53
|
+
export interface LootInstalledPart {
|
|
54
|
+
slotId: string;
|
|
55
|
+
partId: string;
|
|
56
|
+
category: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One rolled item: the base, maker, rarity, level, composed name, chosen parts, rolled affixes and
|
|
60
|
+
* resolved stats. Plain JSON, so a save stores it and the instance registry can key it.
|
|
61
|
+
*/
|
|
62
|
+
export interface LootItem {
|
|
63
|
+
/** Base definition id; also the registry `baseId` a generated drop is stored under. */
|
|
64
|
+
baseId: string;
|
|
65
|
+
manufacturer: string;
|
|
66
|
+
rarity: string;
|
|
67
|
+
level: number;
|
|
68
|
+
name: string;
|
|
69
|
+
parts: readonly LootInstalledPart[];
|
|
70
|
+
affixes: readonly RolledAffix[];
|
|
71
|
+
stats: Readonly<Record<string, number>>;
|
|
72
|
+
}
|
|
73
|
+
/** Pin any part of a roll; omitted fields are drawn from the catalog. */
|
|
74
|
+
export interface LootRollOptions {
|
|
75
|
+
/** Item level, integer `>= 1` (default `1`). */
|
|
76
|
+
level?: number;
|
|
77
|
+
baseId?: string;
|
|
78
|
+
manufacturer?: string;
|
|
79
|
+
rarity?: string;
|
|
80
|
+
}
|
|
81
|
+
/** A defect found by {@link validateLootItem}. */
|
|
82
|
+
export type LootIssueCode = "invalid-level" | "unknown-base" | "unknown-manufacturer" | "manufacturer-base" | "unknown-rarity" | "duplicate-slot" | "unknown-slot" | "missing-slot-part" | "unknown-part" | "part-category" | "unknown-affix" | "duplicate-affix" | "invalid-affix" | "non-finite-stat" | "empty-name";
|
|
83
|
+
/** One reason a rolled item is not legal against its catalog, with the offending value named. */
|
|
84
|
+
export interface LootValidationIssue {
|
|
85
|
+
code: LootIssueCode;
|
|
86
|
+
detail: string;
|
|
87
|
+
}
|
|
88
|
+
/** A per-stat comparison entry; `delta` is `b - a`, so positive means the second item is larger. */
|
|
89
|
+
export interface LootStatDelta {
|
|
90
|
+
stat: string;
|
|
91
|
+
a: number;
|
|
92
|
+
b: number;
|
|
93
|
+
delta: number;
|
|
94
|
+
}
|
|
95
|
+
/** How two items compare: every stat delta plus one weighted score for each. */
|
|
96
|
+
export interface LootComparison {
|
|
97
|
+
/** Union of both items' stats, sorted by stat id. Missing stats count as `0`. */
|
|
98
|
+
deltas: readonly LootStatDelta[];
|
|
99
|
+
scoreA: number;
|
|
100
|
+
scoreB: number;
|
|
101
|
+
/** `scoreB - scoreA`. */
|
|
102
|
+
scoreDelta: number;
|
|
103
|
+
better: "a" | "b" | "equal";
|
|
104
|
+
}
|
|
105
|
+
/** Caller policy for the comparison score. */
|
|
106
|
+
export interface LootComparisonOptions {
|
|
107
|
+
/** Weight per stat in the score (default `1` each). */
|
|
108
|
+
weights?: Readonly<Record<string, number>>;
|
|
109
|
+
/** Stats where a smaller number is better, e.g. reload time; flips their score sign. */
|
|
110
|
+
lowerIsBetter?: readonly string[];
|
|
111
|
+
/** Score difference under which the items count as equal (default `1e-9`). */
|
|
112
|
+
tolerance?: number;
|
|
113
|
+
}
|
|
114
|
+
/** Saved counters of a {@link LootRoller}. */
|
|
115
|
+
export interface LootRollerState {
|
|
116
|
+
rolls: number;
|
|
117
|
+
byRarity: Readonly<Record<string, number>>;
|
|
118
|
+
}
|
|
119
|
+
/** Options for {@link createLootRoller}. */
|
|
120
|
+
export interface LootRollerOptions {
|
|
121
|
+
/** Register a devtools probe under this name reporting roll counts per rarity. */
|
|
122
|
+
devtoolsName?: string;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A roll counter over one catalog: every roll goes through {@link rollLoot}, and the handle keeps
|
|
126
|
+
* counters (a distribution readout in the debug overlay) that snapshot and restore as plain data.
|
|
127
|
+
*/
|
|
128
|
+
export interface LootRoller {
|
|
129
|
+
roll(rng: () => number, options?: LootRollOptions): LootItem;
|
|
130
|
+
stats(): LootRollerState;
|
|
131
|
+
snapshot(): LootRollerState;
|
|
132
|
+
/** Replace counters; returns false without mutation when the state is malformed. */
|
|
133
|
+
restore(next: LootRollerState): boolean;
|
|
134
|
+
reset(): void;
|
|
135
|
+
/** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
|
|
136
|
+
dispose(): void;
|
|
137
|
+
}
|
|
138
|
+
/** Check a catalog before rolling; throws `RangeError` naming the offending entry. */
|
|
139
|
+
export declare function assertLootCatalog(catalog: LootCatalog): void;
|
|
140
|
+
/** Stat multiplier at `level` from the catalog's level curve; `1` when the catalog declares none. */
|
|
141
|
+
export declare function lootLevelScale(catalog: LootCatalog, level: number): number;
|
|
142
|
+
/**
|
|
143
|
+
* Roll one item from a catalog: a base and maker, a weighted rarity, one part per slot, rarity-count
|
|
144
|
+
* affixes, level-scaled stats and a name composed from maker, rarity, part, affix words and base.
|
|
145
|
+
* Deterministic for a given `rng`; every field it reads is caller data. Validate a catalog built
|
|
146
|
+
* outside {@link createLootRoller} once with {@link assertLootCatalog} before rolling from it.
|
|
147
|
+
*
|
|
148
|
+
* @capability loot-generation roll a named item from bases, parts, affixes, rarity weights, manufacturer personalities and level scaling
|
|
149
|
+
*/
|
|
150
|
+
export declare function rollLoot(catalog: LootCatalog, rng: () => number, options?: LootRollOptions): LootItem;
|
|
151
|
+
/**
|
|
152
|
+
* Check one rolled item against its catalog: base, maker and rarity exist, every slot holds an
|
|
153
|
+
* eligible part, affixes belong to the base's pools, stats are finite and the item is named.
|
|
154
|
+
*
|
|
155
|
+
* @capability loot-validation check a rolled item against its catalog and report each defect with its code
|
|
156
|
+
*/
|
|
157
|
+
export declare function validateLootItem(item: LootItem, catalog: LootCatalog): LootValidationIssue[];
|
|
158
|
+
/**
|
|
159
|
+
* Per-stat weighted contribution: `value × weight`, negated for stats listed in `lowerIsBetter`.
|
|
160
|
+
* This is the single weight policy behind {@link scoreLootStats}, {@link compareLootItems} and a
|
|
161
|
+
* gear score, so an item and a whole loadout are never ranked by different arithmetic.
|
|
162
|
+
*
|
|
163
|
+
* @capability loot-score split one stat block into per-stat weighted contributions
|
|
164
|
+
*/
|
|
165
|
+
export declare function lootStatContributions(stats: Readonly<Record<string, number>>, options?: Pick<LootComparisonOptions, "weights" | "lowerIsBetter">): Record<string, number>;
|
|
166
|
+
/**
|
|
167
|
+
* One stat block's weighted score: the sum of `value × weight`, negated for stats listed in
|
|
168
|
+
* `lowerIsBetter`. Pair it with {@link lootStatContributions} when a caller also needs the split.
|
|
169
|
+
*
|
|
170
|
+
* @capability loot-score score one stat block with caller weights and lower-is-better stats
|
|
171
|
+
*/
|
|
172
|
+
export declare function scoreLootStats(stats: Readonly<Record<string, number>>, options?: Pick<LootComparisonOptions, "weights" | "lowerIsBetter">): number;
|
|
173
|
+
/**
|
|
174
|
+
* Compare two rolled items: every stat delta plus one weighted score for each, with caller-named
|
|
175
|
+
* weights and which stats read better when small.
|
|
176
|
+
*
|
|
177
|
+
* @capability loot-comparison compare two rolled items with per-stat deltas and a weighted score
|
|
178
|
+
*/
|
|
179
|
+
export declare function compareLootItems(a: LootItem, b: LootItem, options?: LootComparisonOptions): LootComparison;
|
|
180
|
+
/**
|
|
181
|
+
* Wrap a catalog in a roll counter: same results as {@link rollLoot}, plus counts by rarity for a
|
|
182
|
+
* debug overlay, snapshot/restore of those counts, and an optional devtools probe.
|
|
183
|
+
*
|
|
184
|
+
* @capability loot-roller roll items from one validated catalog while counting drops per rarity for a debug probe
|
|
185
|
+
*/
|
|
186
|
+
export declare function createLootRoller(catalog: LootCatalog, options?: LootRollerOptions): LootRoller;
|
|
@@ -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
|
+
}
|