@jgengine/core 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/agents/agent.d.ts +134 -0
  3. package/dist/agents/agent.js +224 -0
  4. package/dist/agents/mood.d.ts +74 -0
  5. package/dist/agents/mood.js +97 -0
  6. package/dist/agents/relationships.d.ts +92 -0
  7. package/dist/agents/relationships.js +132 -0
  8. package/dist/agents/schedule.d.ts +58 -0
  9. package/dist/agents/schedule.js +72 -0
  10. package/dist/ai/director/boss.d.ts +63 -0
  11. package/dist/ai/director/boss.js +87 -0
  12. package/dist/ai/director/encounter.d.ts +146 -0
  13. package/dist/ai/director/encounter.js +195 -0
  14. package/dist/ai/director/pressure.d.ts +59 -0
  15. package/dist/ai/director/pressure.js +71 -0
  16. package/dist/ai/director/roles.d.ts +61 -0
  17. package/dist/ai/director/roles.js +118 -0
  18. package/dist/ai/director/sound.d.ts +79 -0
  19. package/dist/ai/director/sound.js +113 -0
  20. package/dist/game/gameSave.d.ts +6 -0
  21. package/dist/game/gameSave.js +6 -0
  22. package/dist/game/playableGame.d.ts +2 -2
  23. package/dist/game/saveStore.d.ts +7 -0
  24. package/dist/game/saveStore.js +35 -0
  25. package/dist/gameplay.d.ts +1 -1
  26. package/dist/gameplay.js +1 -1
  27. package/dist/item/affix.d.ts +2 -0
  28. package/dist/loot/drop.d.ts +47 -0
  29. package/dist/loot/drop.js +57 -0
  30. package/dist/loot/haul.d.ts +78 -0
  31. package/dist/loot/haul.js +126 -0
  32. package/dist/loot/lootGen.d.ts +7 -1
  33. package/dist/loot/lootGen.js +24 -5
  34. package/dist/loot/salvage.d.ts +74 -0
  35. package/dist/loot/salvage.js +68 -0
  36. package/dist/loot/vendor.d.ts +76 -0
  37. package/dist/loot/vendor.js +72 -0
  38. package/dist/meta/changelog.d.ts +1 -1
  39. package/dist/meta/changelog.js +47 -1
  40. package/dist/mission/mission.d.ts +24 -0
  41. package/dist/mission/mission.js +28 -3
  42. package/dist/progression/careerBank.d.ts +63 -0
  43. package/dist/progression/careerBank.js +83 -0
  44. package/dist/progression/gates.d.ts +66 -0
  45. package/dist/progression/gates.js +88 -0
  46. package/dist/progression/standing.d.ts +77 -0
  47. package/dist/progression/standing.js +151 -0
  48. package/dist/progression/upgrades.d.ts +70 -0
  49. package/dist/progression/upgrades.js +128 -0
  50. package/dist/ui/accessibility.d.ts +8 -0
  51. package/dist/ui/accessibility.js +14 -0
  52. package/package.json +1 -1
  53. package/skills/jgengine/api.md +3 -3
  54. package/skills/jgengine-gameplay/SKILL.md +12 -1
  55. package/skills/jgengine-gameplay/api.md +141 -3
  56. package/skills/jgengine-gameplay/capabilities.md +74 -0
  57. package/skills/jgengine-gameplay/recipes/agent-lives.md +76 -0
  58. package/skills/jgengine-world/api.md +52 -0
  59. package/skills/jgengine-world/capabilities.md +32 -0
@@ -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
+ }
@@ -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. */
@@ -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 (default 60). */
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 (default 40). Headless: skin every part via `className`/`data-*` hooks
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
@@ -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.
@@ -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();
@@ -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";
@@ -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
+ }
@@ -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;