@jgengine/core 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/dist/agents/agent.d.ts +134 -0
- package/dist/agents/agent.js +224 -0
- package/dist/agents/mood.d.ts +74 -0
- package/dist/agents/mood.js +97 -0
- package/dist/agents/relationships.d.ts +92 -0
- package/dist/agents/relationships.js +132 -0
- package/dist/agents/schedule.d.ts +58 -0
- package/dist/agents/schedule.js +72 -0
- package/dist/ai/director/boss.d.ts +63 -0
- package/dist/ai/director/boss.js +87 -0
- package/dist/ai/director/encounter.d.ts +146 -0
- package/dist/ai/director/encounter.js +195 -0
- package/dist/ai/director/pressure.d.ts +59 -0
- package/dist/ai/director/pressure.js +71 -0
- package/dist/ai/director/roles.d.ts +61 -0
- package/dist/ai/director/roles.js +118 -0
- package/dist/ai/director/sound.d.ts +79 -0
- package/dist/ai/director/sound.js +113 -0
- package/dist/game/gameSave.d.ts +6 -0
- package/dist/game/gameSave.js +6 -0
- package/dist/game/playableGame.d.ts +2 -2
- package/dist/game/saveStore.d.ts +7 -0
- package/dist/game/saveStore.js +35 -0
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/item/affix.d.ts +2 -0
- package/dist/loot/drop.d.ts +47 -0
- package/dist/loot/drop.js +57 -0
- package/dist/loot/haul.d.ts +78 -0
- package/dist/loot/haul.js +126 -0
- package/dist/loot/lootGen.d.ts +7 -1
- package/dist/loot/lootGen.js +24 -5
- package/dist/loot/salvage.d.ts +74 -0
- package/dist/loot/salvage.js +68 -0
- package/dist/loot/vendor.d.ts +76 -0
- package/dist/loot/vendor.js +72 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +47 -1
- package/dist/mission/mission.d.ts +24 -0
- package/dist/mission/mission.js +28 -3
- package/dist/progression/careerBank.d.ts +63 -0
- package/dist/progression/careerBank.js +83 -0
- package/dist/progression/gates.d.ts +66 -0
- package/dist/progression/gates.js +88 -0
- package/dist/progression/standing.d.ts +77 -0
- package/dist/progression/standing.js +151 -0
- package/dist/progression/upgrades.d.ts +70 -0
- package/dist/progression/upgrades.js +128 -0
- package/dist/ui/accessibility.d.ts +8 -0
- package/dist/ui/accessibility.js +14 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +3 -3
- package/skills/jgengine-gameplay/SKILL.md +12 -1
- package/skills/jgengine-gameplay/api.md +141 -3
- package/skills/jgengine-gameplay/capabilities.md +74 -0
- package/skills/jgengine-gameplay/recipes/agent-lives.md +76 -0
- package/skills/jgengine-world/api.md +52 -0
- package/skills/jgengine-world/capabilities.md +32 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const DEFAULTS = {
|
|
2
|
+
maxInvestigators: 3,
|
|
3
|
+
giveUpConfidence: 0.15,
|
|
4
|
+
maxSeconds: 20,
|
|
5
|
+
maxNoises: 16,
|
|
6
|
+
};
|
|
7
|
+
function flatDistance(a, b) {
|
|
8
|
+
return Math.hypot(a[0] - b[0], a[2] - b[2]);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Make enemies investigate a noise, reusing the hearing owner.
|
|
12
|
+
*
|
|
13
|
+
* The noise goes into `PerceptionService.pushStimulus` as a `sound` stimulus, so the same range,
|
|
14
|
+
* loudness and linear memory decay that every other hearing consumer gets apply here; this layer
|
|
15
|
+
* adds only the two things a squad needs: a *bound* on how many enemies chase one noise, and a
|
|
16
|
+
* *give-up* rule, both read back from that memory instead of a second copy of it. A sighting of the
|
|
17
|
+
* source during the hunt raises the same memory's confidence, so an investigation escalates into a
|
|
18
|
+
* pursuit without any bookkeeping here.
|
|
19
|
+
*
|
|
20
|
+
* @capability sound-hunting send a bounded set of nearby enemies to investigate a noise with decaying memory
|
|
21
|
+
*/
|
|
22
|
+
export function createSoundHunt(config) {
|
|
23
|
+
const maxInvestigators = Math.max(0, Math.floor(config.maxInvestigators ?? DEFAULTS.maxInvestigators));
|
|
24
|
+
const giveUp = Math.max(0, config.giveUpConfidence ?? DEFAULTS.giveUpConfidence);
|
|
25
|
+
const maxSeconds = Math.max(0, config.maxSeconds ?? DEFAULTS.maxSeconds);
|
|
26
|
+
const maxNoises = Math.max(1, Math.floor(config.maxNoises ?? DEFAULTS.maxNoises));
|
|
27
|
+
let entries = [];
|
|
28
|
+
return {
|
|
29
|
+
noise(event) {
|
|
30
|
+
if (!Number.isFinite(event.at))
|
|
31
|
+
throw new Error("noise.at must be finite.");
|
|
32
|
+
const loudness = Math.max(0, event.loudness ?? 1);
|
|
33
|
+
const audible = config.hearingRange * loudness;
|
|
34
|
+
const ranked = [];
|
|
35
|
+
if (audible > 0) {
|
|
36
|
+
for (const hunter of config.hunters(event.position, audible)) {
|
|
37
|
+
if (hunter.id === event.sourceId)
|
|
38
|
+
continue;
|
|
39
|
+
if (flatDistance(hunter.position, event.position) >= audible)
|
|
40
|
+
continue;
|
|
41
|
+
ranked.push(hunter);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
ranked.sort((a, b) => {
|
|
45
|
+
const distance = flatDistance(a.position, event.position) - flatDistance(b.position, event.position);
|
|
46
|
+
if (Math.abs(distance) > 0.0001)
|
|
47
|
+
return distance;
|
|
48
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
49
|
+
});
|
|
50
|
+
const admitted = ranked.slice(0, maxInvestigators).map((hunter) => hunter.id);
|
|
51
|
+
config.perception.pushStimulus({
|
|
52
|
+
kind: "sound",
|
|
53
|
+
sourceId: event.sourceId,
|
|
54
|
+
position: [...event.position],
|
|
55
|
+
loudness,
|
|
56
|
+
at: event.at,
|
|
57
|
+
});
|
|
58
|
+
const admittedSet = new Set(admitted);
|
|
59
|
+
entries = entries
|
|
60
|
+
.filter((entry) => entry.noise.sourceId !== event.sourceId)
|
|
61
|
+
.map((entry) => ({ noise: entry.noise, hunters: entry.hunters.filter((id) => !admittedSet.has(id)) }))
|
|
62
|
+
.filter((entry) => entry.hunters.length > 0);
|
|
63
|
+
entries.push({
|
|
64
|
+
noise: { ...event, position: [...event.position], loudness },
|
|
65
|
+
hunters: admitted,
|
|
66
|
+
});
|
|
67
|
+
while (entries.length > maxNoises)
|
|
68
|
+
entries.shift();
|
|
69
|
+
return admitted;
|
|
70
|
+
},
|
|
71
|
+
investigations(nowMs) {
|
|
72
|
+
const results = [];
|
|
73
|
+
const kept = [];
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const ageSeconds = Math.max(0, nowMs - entry.noise.at) / 1000;
|
|
76
|
+
if (ageSeconds > maxSeconds)
|
|
77
|
+
continue;
|
|
78
|
+
const alive = [];
|
|
79
|
+
for (const hunterId of entry.hunters) {
|
|
80
|
+
const memory = config.perception.memoryOf(hunterId, entry.noise.sourceId);
|
|
81
|
+
if (memory === null || memory.confidence <= giveUp)
|
|
82
|
+
continue;
|
|
83
|
+
alive.push(hunterId);
|
|
84
|
+
results.push({
|
|
85
|
+
hunterId,
|
|
86
|
+
sourceId: entry.noise.sourceId,
|
|
87
|
+
position: [...memory.lastKnownPos],
|
|
88
|
+
confidence: memory.confidence,
|
|
89
|
+
ageSeconds,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (alive.length > 0)
|
|
93
|
+
kept.push({ noise: entry.noise, hunters: alive });
|
|
94
|
+
}
|
|
95
|
+
entries = kept;
|
|
96
|
+
return results;
|
|
97
|
+
},
|
|
98
|
+
snapshot() {
|
|
99
|
+
return {
|
|
100
|
+
entries: entries.map((entry) => ({
|
|
101
|
+
noise: { ...entry.noise, position: [...entry.noise.position] },
|
|
102
|
+
hunters: [...entry.hunters],
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
restore(snapshot) {
|
|
107
|
+
entries = snapshot.entries.map((entry) => ({
|
|
108
|
+
noise: { ...entry.noise, position: [...entry.noise.position] },
|
|
109
|
+
hunters: [...entry.hunters],
|
|
110
|
+
}));
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
package/dist/game/gameSave.d.ts
CHANGED
|
@@ -111,6 +111,12 @@ export interface GameSave {
|
|
|
111
111
|
peek(slot: string): Promise<GameSaveDocument | null>;
|
|
112
112
|
/** Slots this save has written. */
|
|
113
113
|
slots(): Promise<string[]>;
|
|
114
|
+
/**
|
|
115
|
+
* Delete one slot's document and drop it from the slot index, so `slots()` and `peek()` stop
|
|
116
|
+
* seeing it. Resolves `true` when a document was removed and `false` when the slot held nothing.
|
|
117
|
+
* Deleting the active slot resets the registered systems to a fresh document.
|
|
118
|
+
*/
|
|
119
|
+
deleteSlot(slot: string): Promise<boolean>;
|
|
114
120
|
slot(): string;
|
|
115
121
|
status(): SaveStatus;
|
|
116
122
|
/** Monotonic change counter — bumps on any value or status change. */
|
package/dist/game/gameSave.js
CHANGED
|
@@ -150,6 +150,12 @@ export function createGameSave(config) {
|
|
|
150
150
|
return stored.value;
|
|
151
151
|
},
|
|
152
152
|
slots: () => store.slots(),
|
|
153
|
+
async deleteSlot(slot) {
|
|
154
|
+
const deleted = await store.deleteSlot(slot);
|
|
155
|
+
if (deleted && slot === store.slot())
|
|
156
|
+
apply(store.value());
|
|
157
|
+
return deleted;
|
|
158
|
+
},
|
|
153
159
|
slot: () => store.slot(),
|
|
154
160
|
status: () => store.status(),
|
|
155
161
|
revision: () => store.revision(),
|
|
@@ -729,7 +729,7 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
|
|
|
729
729
|
platforms?: readonly HudPlatform[];
|
|
730
730
|
/** HUD design resolution + scale clamps (default 1600×900, scale 0.4–1). `mobile` overrides tune the phone fit separately — the same resolution system drives desktop UI-scale and phone shrink. */
|
|
731
731
|
hudFit?: HudViewportConfig;
|
|
732
|
-
/** Opt in to world-space health bars floating over non-local entities that carry the stat. `roles` restricts bars to entities whose catalog entry declares one of the given roles; `maxDistance` hides bars beyond this many world units from the render camera (
|
|
732
|
+
/** Opt in to world-space health bars floating over non-local entities that carry the stat. `roles` restricts bars to entities whose catalog entry declares one of the given roles; `maxDistance` hides bars beyond this many world units from the render camera, and unset derives the range from the camera and the target height (an overhead camera reaches the units it looks at). */
|
|
733
733
|
worldHealthBars?: boolean | {
|
|
734
734
|
statId?: string;
|
|
735
735
|
roles?: readonly CatalogEntityRole[];
|
|
@@ -745,7 +745,7 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
|
|
|
745
745
|
* Opt in to billboarded nameplates (name + optional HP bar) floating over non-local entities — the
|
|
746
746
|
* MMO "who's this and how hurt are they" readout. `roles` restricts to entities whose catalog entry
|
|
747
747
|
* declares one of the given roles (default: all); `maxDistance` hides nameplates beyond this many
|
|
748
|
-
* world units from the render camera
|
|
748
|
+
* world units from the render camera, and unset derives the range the same way the bars do. Headless: skin every part via `className`/`data-*` hooks
|
|
749
749
|
* on `WorldNameplates` (`@jgengine/shell/world/WorldHud`) — this flag only turns the readout on and
|
|
750
750
|
* scopes which entities it covers. Set `showHealth: false` for a name-only plate when a game already
|
|
751
751
|
* draws the HP bar itself (its own HUD, or `worldHealthBars`) — otherwise the nameplate's built-in bar
|
package/dist/game/saveStore.d.ts
CHANGED
|
@@ -92,6 +92,13 @@ export interface SaveStore<T> {
|
|
|
92
92
|
save(): Promise<void>;
|
|
93
93
|
/** Delete the active slot and reset `value()` to `initial`. */
|
|
94
94
|
clear(): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Delete any slot: remove its stored payload and drop it from the slot index, so `slots()` and
|
|
97
|
+
* `peek()` stop seeing it. Resolves `true` when a payload was removed and `false` when the slot
|
|
98
|
+
* held nothing (an unknown slot is a no-op). Deleting the active slot resets `value()` to
|
|
99
|
+
* `initial`; other slots leave the live value alone.
|
|
100
|
+
*/
|
|
101
|
+
deleteSlot(slot: string): Promise<boolean>;
|
|
95
102
|
/**
|
|
96
103
|
* Read a stored slot without touching the live value, active slot or status — what a save-select
|
|
97
104
|
* menu shows. Returns `null` when the slot holds nothing.
|
package/dist/game/saveStore.js
CHANGED
|
@@ -231,6 +231,10 @@ export function createSaveStore(config) {
|
|
|
231
231
|
firstDirtyAt = null;
|
|
232
232
|
setStatus("idle");
|
|
233
233
|
emit();
|
|
234
|
+
// Only a slot that holds a payload belongs in the index — loading an empty
|
|
235
|
+
// slot must not resurrect an id a deleteSlot removed.
|
|
236
|
+
if (raw === null)
|
|
237
|
+
return current;
|
|
234
238
|
void rememberSlot(currentSlot).catch((error) => {
|
|
235
239
|
setStatus("error");
|
|
236
240
|
reportError(error);
|
|
@@ -279,6 +283,37 @@ export function createSaveStore(config) {
|
|
|
279
283
|
reportError(error);
|
|
280
284
|
}
|
|
281
285
|
},
|
|
286
|
+
async deleteSlot(slot) {
|
|
287
|
+
const wasActive = slot === currentSlot;
|
|
288
|
+
if (wasActive) {
|
|
289
|
+
clearTimer();
|
|
290
|
+
dirty = false;
|
|
291
|
+
firstDirtyAt = null;
|
|
292
|
+
}
|
|
293
|
+
const run = saveChain.then(async () => {
|
|
294
|
+
if (wasActive)
|
|
295
|
+
setStatus("saving");
|
|
296
|
+
try {
|
|
297
|
+
const existing = await backend.read(dataKey(slot));
|
|
298
|
+
if (existing !== null)
|
|
299
|
+
await backend.remove(dataKey(slot));
|
|
300
|
+
await forgetSlot(slot);
|
|
301
|
+
if (existing !== null && wasActive)
|
|
302
|
+
current = initial;
|
|
303
|
+
if (wasActive)
|
|
304
|
+
setStatus("idle");
|
|
305
|
+
emit();
|
|
306
|
+
return existing !== null;
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
setStatus("error");
|
|
310
|
+
reportError(error);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
saveChain = run.then(() => undefined, () => undefined);
|
|
315
|
+
return run;
|
|
316
|
+
},
|
|
282
317
|
async switchSlot(slot) {
|
|
283
318
|
if (dirty)
|
|
284
319
|
await save();
|
package/dist/gameplay.d.ts
CHANGED
|
@@ -88,7 +88,7 @@ export { createWeaponStats } from "./item/weapon.js";
|
|
|
88
88
|
export { MAX_LOOT_AFFIXES, assertLootCatalog, compareLootItems, createLootRoller, lootLevelScale, lootStatContributions, rollLoot, scoreLootStats, validateLootItem, type LootBaseDef, type LootCatalog, type LootComparison, type LootComparisonOptions, type LootInstalledPart, type LootIssueCode, type LootItem, type LootManufacturerDef, type LootPartDef, type LootRollOptions, type LootRoller, type LootRollerOptions, type LootRollerState, type LootStatDelta, type LootValidationIssue, } from "./loot/lootGen.js";
|
|
89
89
|
export { lootWeaponDefinition, type LootWeaponProjection, type LootWeaponStatMap } from "./loot/lootWeapon.js";
|
|
90
90
|
export { chaseMission, defendMission, escortMission, type ChaseMissionOptions, type ComposedMissionOptions, type DefendMissionOptions, type EscortMissionOptions, } from "./mission/compositions.js";
|
|
91
|
-
export { DEFAULT_ESCAPE_MULTIPLE, DEFAULT_PURSUE_DISTANCE, DEFAULT_REACH_RADIUS, MISSION_COUNTER_KINDS, applyMissionRewards, createMissionRuntime, isCounterObjective, validateMissionCatalog, type MissionActorFact, type MissionBranch, type MissionDef, type MissionEvent, type MissionEventFilter, type MissionFactCondition, type MissionFailCondition, type MissionFailDef, type MissionIssue, type MissionObjectiveDef, type MissionObjectiveKind, type MissionRewardAppliers, type MissionRewards, type MissionRuntime, type MissionRuntimeConfig, type MissionRuntimeSnapshot, type MissionState, type MissionStats, type MissionStatus, type MissionTickInput, type MissionValidationOptions, type MissionView, type MissionWorldEvent, type MissionWorldEventKind, } from "./mission/mission.js";
|
|
91
|
+
export { DEFAULT_ESCAPE_MULTIPLE, DEFAULT_MARKER_TOLERANCE, DEFAULT_PURSUE_DISTANCE, DEFAULT_REACH_RADIUS, MISSION_COUNTER_KINDS, applyMissionRewards, createMissionRuntime, isCounterObjective, validateMissionCatalog, type MissionActorFact, type MissionBranch, type MissionDef, type MissionEvent, type MissionEventFilter, type MissionFactCondition, type MissionFailCondition, type MissionFailDef, type MissionIssue, type MissionMarkerPlane, type MissionMarkerProximity, type MissionObjectiveDef, type MissionObjectiveKind, type MissionRewardAppliers, type MissionRewards, type MissionRuntime, type MissionRuntimeConfig, type MissionRuntimeSnapshot, type MissionState, type MissionStats, type MissionStatus, type MissionTickInput, type MissionValidationOptions, type MissionView, type MissionWorldEvent, type MissionWorldEventKind, } from "./mission/mission.js";
|
|
92
92
|
export { MISSION_TRIGGER_ACTIONS, createMissionTriggerBridge, missionMarkers, registerMissionTriggerActions, type MissionTriggerBridge, } from "./mission/triggers.js";
|
|
93
93
|
export { compareGearSets, gearScore, type GearScore, type GearScoreOptions, type GearSlotItem, } from "./progression/gearScore.js";
|
|
94
94
|
export { assertSkillTreeConfig, createSkillTree, type SkillActiveDef, type SkillNodeDef, type SkillRequirement, type SkillResolution, type SkillSpendReason, type SkillSpendResult, type SkillSynergyDef, type SkillTree, type SkillTreeConfig, type SkillTreeState, } from "./progression/skillTree.js";
|
package/dist/gameplay.js
CHANGED
|
@@ -88,7 +88,7 @@ export { createWeaponStats } from "./item/weapon.js";
|
|
|
88
88
|
export { MAX_LOOT_AFFIXES, assertLootCatalog, compareLootItems, createLootRoller, lootLevelScale, lootStatContributions, rollLoot, scoreLootStats, validateLootItem, } from "./loot/lootGen.js";
|
|
89
89
|
export { lootWeaponDefinition } from "./loot/lootWeapon.js";
|
|
90
90
|
export { chaseMission, defendMission, escortMission, } from "./mission/compositions.js";
|
|
91
|
-
export { DEFAULT_ESCAPE_MULTIPLE, DEFAULT_PURSUE_DISTANCE, DEFAULT_REACH_RADIUS, MISSION_COUNTER_KINDS, applyMissionRewards, createMissionRuntime, isCounterObjective, validateMissionCatalog, } from "./mission/mission.js";
|
|
91
|
+
export { DEFAULT_ESCAPE_MULTIPLE, DEFAULT_MARKER_TOLERANCE, DEFAULT_PURSUE_DISTANCE, DEFAULT_REACH_RADIUS, MISSION_COUNTER_KINDS, applyMissionRewards, createMissionRuntime, isCounterObjective, validateMissionCatalog, } from "./mission/mission.js";
|
|
92
92
|
export { MISSION_TRIGGER_ACTIONS, createMissionTriggerBridge, missionMarkers, registerMissionTriggerActions, } from "./mission/triggers.js";
|
|
93
93
|
export { compareGearSets, gearScore, } from "./progression/gearScore.js";
|
|
94
94
|
export { assertSkillTreeConfig, createSkillTree, } from "./progression/skillTree.js";
|
package/dist/item/affix.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface AffixDef {
|
|
|
5
5
|
op?: AffixOp;
|
|
6
6
|
roll: number | [number, number];
|
|
7
7
|
weight: number;
|
|
8
|
+
/** Lowest item level whose roll pool includes this affix; omitted means every level. */
|
|
9
|
+
minLevel?: number;
|
|
8
10
|
namePart?: {
|
|
9
11
|
position: "prefix" | "suffix";
|
|
10
12
|
text: string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type LootCatalog, type LootComparisonOptions, type LootItem, type LootRollOptions, type LootStatDelta } from "./lootGen.js";
|
|
2
|
+
/** A rolled item with a stable instance id, so a stash, ledger and vendor stock can name it. */
|
|
3
|
+
export interface ItemDrop extends LootItem {
|
|
4
|
+
id: string;
|
|
5
|
+
}
|
|
6
|
+
/** {@link rollDrop} options: the loot roll plus the instance id (defaults to `drop:<seed>`). */
|
|
7
|
+
export interface RollDropOptions extends LootRollOptions {
|
|
8
|
+
id?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Roll one item from a seed. Same seed, catalog and options produce the same id, name, affixes,
|
|
12
|
+
* stats and mass on every run; level gates which bases and affixes are in the pool.
|
|
13
|
+
*
|
|
14
|
+
* @capability item-roll roll one deterministic, level-gated item from a seed
|
|
15
|
+
*/
|
|
16
|
+
export declare function rollDrop(catalog: LootCatalog, seed: string | number, options?: RollDropOptions): ItemDrop;
|
|
17
|
+
/** A per-stat delta from the equipped item to the candidate; positive `delta` means the candidate is larger. */
|
|
18
|
+
export interface EquippedStatDelta extends LootStatDelta {
|
|
19
|
+
/** Whether this stat reads better on the candidate, following `lowerIsBetter`. */
|
|
20
|
+
better: "candidate" | "equipped" | "equal";
|
|
21
|
+
}
|
|
22
|
+
/** What a HUD renders when a drop lands: per-stat deltas against the worn item and one verdict. */
|
|
23
|
+
export interface EquippedComparison {
|
|
24
|
+
candidateId: string;
|
|
25
|
+
equippedId: string | null;
|
|
26
|
+
deltas: readonly EquippedStatDelta[];
|
|
27
|
+
scoreCandidate: number;
|
|
28
|
+
scoreEquipped: number;
|
|
29
|
+
/** `scoreCandidate - scoreEquipped`. */
|
|
30
|
+
scoreDelta: number;
|
|
31
|
+
better: "candidate" | "equipped" | "equal";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Compare a dropped item against what is equipped, with one delta per stat and a weighted verdict.
|
|
35
|
+
* The delta is `candidate - equipped`; `better` flips for stats the caller lists in
|
|
36
|
+
* `lowerIsBetter`, so reload time improving does not read as worse. A missing equipped item
|
|
37
|
+
* compares against zeros.
|
|
38
|
+
*
|
|
39
|
+
* @capability item-comparison show per-stat deltas and a weighted verdict for a drop against equipped gear
|
|
40
|
+
*/
|
|
41
|
+
export declare function compareToEquipped(candidate: ItemDrop, equipped: ItemDrop | null, options?: LootComparisonOptions): EquippedComparison;
|
|
42
|
+
/**
|
|
43
|
+
* Carried mass of a rolled item. Older saves may predate `mass`; those default to `1`.
|
|
44
|
+
*
|
|
45
|
+
* @capability item-weight read a rolled item's mass for a carry check
|
|
46
|
+
*/
|
|
47
|
+
export declare function itemMass(item: Pick<LootItem, "mass">): number;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { compareLootItems, rollLoot, } from "./lootGen.js";
|
|
2
|
+
import { seededRng } from "../random/rng.js";
|
|
3
|
+
/**
|
|
4
|
+
* Roll one item from a seed. Same seed, catalog and options produce the same id, name, affixes,
|
|
5
|
+
* stats and mass on every run; level gates which bases and affixes are in the pool.
|
|
6
|
+
*
|
|
7
|
+
* @capability item-roll roll one deterministic, level-gated item from a seed
|
|
8
|
+
*/
|
|
9
|
+
export function rollDrop(catalog, seed, options = {}) {
|
|
10
|
+
const item = rollLoot(catalog, seededRng(seed), options);
|
|
11
|
+
return { ...item, id: options.id ?? `drop:${String(seed)}` };
|
|
12
|
+
}
|
|
13
|
+
const NO_ITEM = {
|
|
14
|
+
baseId: "none",
|
|
15
|
+
manufacturer: "none",
|
|
16
|
+
rarity: "none",
|
|
17
|
+
level: 0,
|
|
18
|
+
name: "",
|
|
19
|
+
parts: [],
|
|
20
|
+
affixes: [],
|
|
21
|
+
stats: {},
|
|
22
|
+
mass: 0,
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Compare a dropped item against what is equipped, with one delta per stat and a weighted verdict.
|
|
26
|
+
* The delta is `candidate - equipped`; `better` flips for stats the caller lists in
|
|
27
|
+
* `lowerIsBetter`, so reload time improving does not read as worse. A missing equipped item
|
|
28
|
+
* compares against zeros.
|
|
29
|
+
*
|
|
30
|
+
* @capability item-comparison show per-stat deltas and a weighted verdict for a drop against equipped gear
|
|
31
|
+
*/
|
|
32
|
+
export function compareToEquipped(candidate, equipped, options = {}) {
|
|
33
|
+
const base = equipped ?? NO_ITEM;
|
|
34
|
+
const comparison = compareLootItems(base, candidate, options);
|
|
35
|
+
const lower = new Set(options.lowerIsBetter ?? []);
|
|
36
|
+
const deltas = comparison.deltas.map((entry) => ({
|
|
37
|
+
...entry,
|
|
38
|
+
better: entry.delta === 0 ? "equal" : (entry.delta > 0) !== lower.has(entry.stat) ? "candidate" : "equipped",
|
|
39
|
+
}));
|
|
40
|
+
return {
|
|
41
|
+
candidateId: candidate.id,
|
|
42
|
+
equippedId: equipped?.id ?? null,
|
|
43
|
+
deltas,
|
|
44
|
+
scoreCandidate: comparison.scoreB,
|
|
45
|
+
scoreEquipped: comparison.scoreA,
|
|
46
|
+
scoreDelta: comparison.scoreDelta,
|
|
47
|
+
better: comparison.better === "b" ? "candidate" : comparison.better === "a" ? "equipped" : "equal",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Carried mass of a rolled item. Older saves may predate `mass`; those default to `1`.
|
|
52
|
+
*
|
|
53
|
+
* @capability item-weight read a rolled item's mass for a carry check
|
|
54
|
+
*/
|
|
55
|
+
export function itemMass(item) {
|
|
56
|
+
return item.mass !== undefined && Number.isFinite(item.mass) && item.mass > 0 ? item.mass : 1;
|
|
57
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type WeightLookup } from "../inventory/carryLoad.js";
|
|
2
|
+
import type { InventorySet, InventoryState } from "../inventory/inventoryModel.js";
|
|
3
|
+
import type { ItemStack } from "../inventory/storageTier.js";
|
|
4
|
+
import type { KeyValueStorage } from "../game/keyValueStore.js";
|
|
5
|
+
/** What a carry attempt returns: the new load, or the rule that refused it. */
|
|
6
|
+
export type CarryOutcome = {
|
|
7
|
+
ok: true;
|
|
8
|
+
mass: number;
|
|
9
|
+
remaining: number;
|
|
10
|
+
} | {
|
|
11
|
+
ok: false;
|
|
12
|
+
reason: "over-capacity" | "no-space" | "invalid-count";
|
|
13
|
+
mass: number;
|
|
14
|
+
remaining: number;
|
|
15
|
+
};
|
|
16
|
+
/** Stacks moved between a run and its stash, or lost when the run fails. */
|
|
17
|
+
export interface HaulTransfer {
|
|
18
|
+
/** Stacks that reached the stash, or were lost, in slot order. */
|
|
19
|
+
stacks: readonly ItemStack[];
|
|
20
|
+
/** Stacks the stash had no room for; they stay in the run inventory. */
|
|
21
|
+
refused: readonly ItemStack[];
|
|
22
|
+
}
|
|
23
|
+
/** Detached run and stash contents, for a save or a debug readout. */
|
|
24
|
+
export interface RunHaulState {
|
|
25
|
+
run: InventoryState;
|
|
26
|
+
stash: InventoryState;
|
|
27
|
+
}
|
|
28
|
+
/** What a run haul needs: two inventories, a weight rule and an optional save cell. */
|
|
29
|
+
export interface RunHaulConfig<TId extends string> {
|
|
30
|
+
/** The keyed inventory set that owns both the run and stash inventories. */
|
|
31
|
+
inventories: InventorySet<TId>;
|
|
32
|
+
/** Inventory carried during the run; its contents are lost on `fail`. */
|
|
33
|
+
runId: TId;
|
|
34
|
+
/** Inventories that survive a failed run; left out of the carry weight. */
|
|
35
|
+
stashId: TId;
|
|
36
|
+
/** Per-instance mass in the caller's weight unit; unknown ids weigh 0 only if the caller says so. */
|
|
37
|
+
weightOf: WeightLookup;
|
|
38
|
+
/** Carrying capacity in the same unit as `weightOf`. */
|
|
39
|
+
capacity: number;
|
|
40
|
+
/** Worn item ids, one unit each; they weigh against capacity. */
|
|
41
|
+
worn?: readonly string[];
|
|
42
|
+
/** Fraction of worn weight that counts (default `1`). */
|
|
43
|
+
wornFactor?: number;
|
|
44
|
+
/** Save backend for the stash; `null` keeps it in memory for the session only. */
|
|
45
|
+
storage?: KeyValueStorage | null;
|
|
46
|
+
/** Storage key; defaults to `"jgengine.items.stash"`. */
|
|
47
|
+
key?: string;
|
|
48
|
+
}
|
|
49
|
+
/** A run's carried loot and the stash that outlives it. */
|
|
50
|
+
export interface RunHaul {
|
|
51
|
+
/** Add `count` of `itemId` to the run if the carrier can take its weight and there is slot room. */
|
|
52
|
+
carry(itemId: string, count?: number): CarryOutcome;
|
|
53
|
+
/** Move everything the stash fits out of the run and save; returns what moved and what did not. */
|
|
54
|
+
bank(): HaulTransfer;
|
|
55
|
+
/** Discard the run's haul and save the untouched stash; returns the lost stacks. */
|
|
56
|
+
fail(): HaulTransfer;
|
|
57
|
+
/** Load the stash from storage; false when absent or malformed. */
|
|
58
|
+
load(): boolean;
|
|
59
|
+
/** Write the stash to storage. */
|
|
60
|
+
save(): void;
|
|
61
|
+
state(): RunHaulState;
|
|
62
|
+
/** Replace run and stash contents; false without mutation when either is malformed. */
|
|
63
|
+
restore(next: RunHaulState): boolean;
|
|
64
|
+
/** Carried and stashed mass plus capacity left, for the HUD. */
|
|
65
|
+
mass(): {
|
|
66
|
+
run: number;
|
|
67
|
+
stash: number;
|
|
68
|
+
remaining: number;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A run's haul beside a persistent stash. Carrying is gated by the shared encumbrance rules;
|
|
73
|
+
* `bank` moves what the stash fits and saves; `fail` throws the run's haul away and leaves the
|
|
74
|
+
* stash as it was. The game decides when a run succeeds or fails and calls one of them.
|
|
75
|
+
*
|
|
76
|
+
* @capability item-run-haul carry loot with a weight gate, bank it into a persistent stash, or lose it when the run fails
|
|
77
|
+
*/
|
|
78
|
+
export declare function createRunHaul<TId extends string>(config: RunHaulConfig<TId>): RunHaul;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { canCarry, characterLoad } from "../inventory/carryLoad.js";
|
|
2
|
+
const DEFAULT_KEY = "jgengine.items.stash";
|
|
3
|
+
function emptyLike(state) {
|
|
4
|
+
return { slots: new Array(state.slots.length).fill(null) };
|
|
5
|
+
}
|
|
6
|
+
function stacksOf(state) {
|
|
7
|
+
const out = [];
|
|
8
|
+
for (const slot of state.slots)
|
|
9
|
+
if (slot !== null)
|
|
10
|
+
out.push({ itemId: slot.itemId, count: slot.count });
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A run's haul beside a persistent stash. Carrying is gated by the shared encumbrance rules;
|
|
15
|
+
* `bank` moves what the stash fits and saves; `fail` throws the run's haul away and leaves the
|
|
16
|
+
* stash as it was. The game decides when a run succeeds or fails and calls one of them.
|
|
17
|
+
*
|
|
18
|
+
* @capability item-run-haul carry loot with a weight gate, bank it into a persistent stash, or lose it when the run fails
|
|
19
|
+
*/
|
|
20
|
+
export function createRunHaul(config) {
|
|
21
|
+
const { inventories, runId, stashId } = config;
|
|
22
|
+
const key = config.key ?? DEFAULT_KEY;
|
|
23
|
+
const storage = config.storage ?? null;
|
|
24
|
+
const runMass = () => characterLoad({
|
|
25
|
+
carried: [inventories.state(runId)],
|
|
26
|
+
worn: config.worn,
|
|
27
|
+
wornFactor: config.wornFactor,
|
|
28
|
+
weightOf: config.weightOf,
|
|
29
|
+
capacity: config.capacity,
|
|
30
|
+
});
|
|
31
|
+
const persist = () => {
|
|
32
|
+
if (storage === null)
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
storage.setItem(key, JSON.stringify({ stash: inventories.snapshot()[stashId] }));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// A full or blocked save cell must not lose the live stash; the next save retries.
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
carry(itemId, count = 1) {
|
|
43
|
+
const load = runMass();
|
|
44
|
+
if (!Number.isFinite(count) || count <= 0) {
|
|
45
|
+
return { ok: false, reason: "invalid-count", mass: load.mass, remaining: load.remaining };
|
|
46
|
+
}
|
|
47
|
+
const added = config.weightOf(itemId) * count;
|
|
48
|
+
if (!canCarry(load, added)) {
|
|
49
|
+
return { ok: false, reason: "over-capacity", mass: load.mass, remaining: load.remaining };
|
|
50
|
+
}
|
|
51
|
+
const put = inventories.put(runId, itemId, count);
|
|
52
|
+
if (put.status !== "ok") {
|
|
53
|
+
return { ok: false, reason: "no-space", mass: load.mass, remaining: load.remaining };
|
|
54
|
+
}
|
|
55
|
+
const after = runMass();
|
|
56
|
+
return { ok: true, mass: after.mass, remaining: after.remaining };
|
|
57
|
+
},
|
|
58
|
+
bank() {
|
|
59
|
+
const moved = [];
|
|
60
|
+
const refused = [];
|
|
61
|
+
const run = inventories.state(runId);
|
|
62
|
+
for (let slot = 0; slot < run.slots.length; slot += 1) {
|
|
63
|
+
const stack = inventories.state(runId).slots[slot];
|
|
64
|
+
if (stack === null)
|
|
65
|
+
continue;
|
|
66
|
+
const put = inventories.put(stashId, stack.itemId, stack.count);
|
|
67
|
+
if (put.status !== "ok") {
|
|
68
|
+
refused.push({ itemId: stack.itemId, count: stack.count });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
inventories.take(runId, stack.itemId, stack.count);
|
|
72
|
+
moved.push({ itemId: stack.itemId, count: stack.count });
|
|
73
|
+
}
|
|
74
|
+
persist();
|
|
75
|
+
return { stacks: moved, refused };
|
|
76
|
+
},
|
|
77
|
+
fail() {
|
|
78
|
+
const lost = stacksOf(inventories.state(runId));
|
|
79
|
+
inventories.replaceState(runId, emptyLike(inventories.state(runId)));
|
|
80
|
+
persist();
|
|
81
|
+
return { stacks: lost, refused: [] };
|
|
82
|
+
},
|
|
83
|
+
load() {
|
|
84
|
+
if (storage === null)
|
|
85
|
+
return false;
|
|
86
|
+
try {
|
|
87
|
+
const raw = storage.getItem(key);
|
|
88
|
+
if (raw === null)
|
|
89
|
+
return false;
|
|
90
|
+
const saved = JSON.parse(raw);
|
|
91
|
+
if (saved === null || typeof saved !== "object" || saved.stash === undefined)
|
|
92
|
+
return false;
|
|
93
|
+
const next = inventories.snapshot();
|
|
94
|
+
next[stashId] = saved.stash;
|
|
95
|
+
return inventories.restore(next);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
save: persist,
|
|
102
|
+
state() {
|
|
103
|
+
const saved = inventories.snapshot();
|
|
104
|
+
return { run: saved[runId], stash: saved[stashId] };
|
|
105
|
+
},
|
|
106
|
+
restore(next) {
|
|
107
|
+
const attempt = inventories.snapshot();
|
|
108
|
+
attempt[runId] = next.run;
|
|
109
|
+
attempt[stashId] = next.stash;
|
|
110
|
+
return inventories.restore(attempt);
|
|
111
|
+
},
|
|
112
|
+
mass() {
|
|
113
|
+
const load = runMass();
|
|
114
|
+
let stash = 0;
|
|
115
|
+
for (const slot of inventories.state(stashId).slots) {
|
|
116
|
+
if (slot !== null)
|
|
117
|
+
stash += config.weightOf(slot.itemId) * slot.count;
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
run: load.carriedMass,
|
|
121
|
+
stash,
|
|
122
|
+
remaining: load.remaining,
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
package/dist/loot/lootGen.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface LootPartDef extends PartDef {
|
|
|
13
13
|
export interface LootBaseDef extends ItemBaseDef {
|
|
14
14
|
/** Relative pick weight among bases a manufacturer can produce (default `1`). */
|
|
15
15
|
weight?: number;
|
|
16
|
+
/** Lowest item level a roll may pick this base at; omitted means every level. */
|
|
17
|
+
minLevel?: number;
|
|
18
|
+
/** Mass at item level 1 in the caller's weight unit (default `1`); grows 10% per level. */
|
|
19
|
+
mass?: number;
|
|
16
20
|
/** Free-form tags for caller filters and UI. */
|
|
17
21
|
tags?: readonly string[];
|
|
18
22
|
/** Mount slots a roll fills; every declared slot receives one part. */
|
|
@@ -69,6 +73,8 @@ export interface LootItem {
|
|
|
69
73
|
parts: readonly LootInstalledPart[];
|
|
70
74
|
affixes: readonly RolledAffix[];
|
|
71
75
|
stats: Readonly<Record<string, number>>;
|
|
76
|
+
/** Carried mass from the base def at this level; absent on saves made before mass existed. */
|
|
77
|
+
mass?: number;
|
|
72
78
|
}
|
|
73
79
|
/** Pin any part of a roll; omitted fields are drawn from the catalog. */
|
|
74
80
|
export interface LootRollOptions {
|
|
@@ -79,7 +85,7 @@ export interface LootRollOptions {
|
|
|
79
85
|
rarity?: string;
|
|
80
86
|
}
|
|
81
87
|
/** 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";
|
|
88
|
+
export type LootIssueCode = "invalid-level" | "unknown-base" | "level-gated-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" | "invalid-mass" | "empty-name";
|
|
83
89
|
/** One reason a rolled item is not legal against its catalog, with the offending value named. */
|
|
84
90
|
export interface LootValidationIssue {
|
|
85
91
|
code: LootIssueCode;
|