@jgengine/core 0.30.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 CHANGED
@@ -26,6 +26,38 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.31.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.31.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.28.0`; `@jgengine/github` is `0.18.0`.
34
+
35
+ ### Added
36
+
37
+ - `createAgent` (`@jgengine/core/agents/agent`) runs one resident's whole loop on the world clock: needs decay through `sim/needs`, a utility brain picks among the targets the game offers, an authored day plan says where the agent belongs, and the agent falls back to a declared target when its pick disappears. Mood, action and the far flag snapshot and restore with the needs.
38
+ - `createMoodReader` / `readMood` (`@jgengine/core/agents/mood`) derive a 0-100 mood and a caller-labelled tier from weighted need satisfaction, plus named sources a queue, a litter patch or a cold snap raises and clears.
39
+ - `createAgentSchedule` (`@jgengine/core/agents/schedule`) resolves an authored day plan through `sim/routine` and adds a work beat: a stop names the places it patrols, so a janitor is an agent with a route rather than a special case. Off-hours behaviour is a stop without a beat.
40
+ - `createRelationshipBook` (`@jgengine/core/agents/relationships`) keeps undirected familiarity and opinion per pair. A social action moves both and reports the milestones crossed, friends prefer each other's company, and a bonded pair refuses to split. Pair keys come from `relation/keyedValues` and crossings from `relation/thresholds`.
41
+ - `GameSave.deleteSlot(slot)` and `SaveStore.deleteSlot(slot)` remove one slot's payload and drop it from the slot index; they resolve `true` when a document was removed and `false` for an unknown slot. Deleting the active slot resets the live state to the initial document. `load()` no longer lists a slot that holds nothing, so a deleted slot cannot return.
42
+ - `HudPanel` takes `lane` (`@jgengine/react`): blocks naming the same anchor and lane stack without overlap, lanes in one anchor sit side by side, and `resolveHudLanes` resolves the rectangles. A moved panel leaves its lane and keeps its authored placement.
43
+ - `hudBlockOverlaps` and `formatHudOverlapReport` (`@jgengine/react`) name every pair of rendered HUD blocks whose rectangles intersect, with both ids and the viewport; `HudCanvas` warns with that report in a dev build.
44
+ - `rollDrop` (`@jgengine/core/loot/drop`) rolls one deterministic item from a seed and `compareToEquipped` turns it into per-stat deltas against worn gear with a weighted verdict a HUD can render.
45
+ - `createRunHaul` (`@jgengine/core/loot/haul`) gates carried loot by weight through the shared encumbrance rules, banks what the persistent stash fits, and discards the run's haul when the game calls `fail`.
46
+ - `salvageItem` and `createItemLedger` (`@jgengine/core/loot/salvage`) break an item down for the currency its level and rarity say and claim the instance, so a salvaged item cannot be salvaged, equipped or sold again.
47
+ - `stockVendor` and `createVendorRotation` (`@jgengine/core/loot/vendor`) fill a `ShopStock` with rolled items priced through the economy wallet, with a run-count restock rule the game authors.
48
+ - `markerProximity` on `createMissionRuntime` picks the plane a marker proximity test compares in: `"horizontal"` (default) measures ground distance and allows the actor a `tolerance` (default 8 m) above or below the marker, `"spatial"` is the straight 3D check for worlds with stacked platforms. `MissionMarkerPlane`, `MissionMarkerProximity` and `DEFAULT_MARKER_TOLERANCE` are exported with it.
49
+
50
+ ### Changed
51
+
52
+ - `evaluatePressure` (`@jgengine/core/ai/director/pressure`) reads pressure as player control: a healthy player further into a run is leaned on harder, a hurt player is eased off, and every effect stays inside the rule's `maxTier`.
53
+ - Loot bases and affixes take `minLevel`, so `rollLoot` only draws from entries at or below the item level. Loot bases take `mass`; rolled items carry it for carry checks.
54
+
55
+ ### Fixed
56
+
57
+ - `HudCanvas` resolved its host with `canvas.parentElement`, which is `HudThemeRoot`'s `display: contents` element and measures 0x0 — the boundary overlay, the HUD-fit measurement and the new overlap report never ran under the standard HUD root. It now uses the nearest ancestor with a real box.
58
+ - The HUD boundary check returned early whenever nothing overflowed, so a later report in the same pass could never run.
59
+ - `reach`, `interact` and the `left-area` fail condition compared a marker in 3D, so a marker authored at `y=0` under rising ground was unreachable. They now read the same plane.
60
+
29
61
  ## 0.30.0
30
62
 
31
63
  ### Migrate
@@ -5,18 +5,18 @@
5
5
  */
6
6
  /** What the director reads about the player before it chooses a tier. */
7
7
  export interface PressureReading {
8
- /** Player health fraction `0..1`. Lower leans harder. */
8
+ /** Player health fraction `0..1`. A healthier player is leaned on harder. */
9
9
  health: number;
10
- /** Encounter progress `0..1` — waves cleared over waves authored. Lower leans harder. */
10
+ /** Encounter progress `0..1` — waves cleared over waves authored. Further in leans harder. */
11
11
  progress: number;
12
12
  /** Seconds the encounter has run. Longer leans harder, up to the window. */
13
13
  elapsed: number;
14
14
  }
15
15
  /** The weights and window that turn a reading into an index, plus the hard cap. */
16
16
  export interface PressureRule {
17
- /** Weight of low player health. Default `1`. */
17
+ /** Weight of the player staying in control (health). Default `0.5`. */
18
18
  healthWeight?: number;
19
- /** Weight of the encounter not being finished. Default `1`. */
19
+ /** Weight of how far through the encounter the player is. Default `1`. */
20
20
  progressWeight?: number;
21
21
  /** Weight of time spent past `timeFloorSeconds`. Default `0.5`. */
22
22
  timeWeight?: number;
@@ -47,11 +47,12 @@ export interface PressurePlan {
47
47
  /**
48
48
  * Turn one reading into a bounded pressure plan.
49
49
  *
50
- * The index is the weighted mean of three terms — how hurt the player is (`1 - health`), how far
51
- * from finished the encounter is (`1 - progress`), and how long it has run — each clamped to
52
- * `0..1`, then split into `maxTier + 1` bands. Because every term and every derived effect is a
53
- * clamp of an authored cap, the rule cannot climb: a dead player on a stalled timer for an hour
54
- * lands on `tier === maxTier` with `countBonus === maxTier`, never above it.
50
+ * The index is the weighted mean of three terms — the player's health (staying in control), how far
51
+ * through the encounter they are, and how long it has run — each clamped to `0..1`, then split into
52
+ * `maxTier + 1` bands. A hurt player is leaned on *less*, so a director that reads this plan never
53
+ * piles on someone already losing. Because every term and every derived effect is a clamp of an
54
+ * authored cap, the rule cannot climb: a full-health player on a long run lands on
55
+ * `tier === maxTier` with `countBonus === maxTier`, never above it.
55
56
  *
56
57
  * @capability encounter-pressure bounded difficulty tier read from player health, encounter progress and time
57
58
  */
@@ -4,7 +4,7 @@
4
4
  * a callback, so a test can sweep it and a probe can print it.
5
5
  */
6
6
  const RULE_DEFAULTS = {
7
- healthWeight: 1,
7
+ healthWeight: 0.5,
8
8
  progressWeight: 1,
9
9
  timeWeight: 0.5,
10
10
  timeFloorSeconds: 90,
@@ -35,11 +35,12 @@ function timeTerm(elapsed, rule) {
35
35
  /**
36
36
  * Turn one reading into a bounded pressure plan.
37
37
  *
38
- * The index is the weighted mean of three terms — how hurt the player is (`1 - health`), how far
39
- * from finished the encounter is (`1 - progress`), and how long it has run — each clamped to
40
- * `0..1`, then split into `maxTier + 1` bands. Because every term and every derived effect is a
41
- * clamp of an authored cap, the rule cannot climb: a dead player on a stalled timer for an hour
42
- * lands on `tier === maxTier` with `countBonus === maxTier`, never above it.
38
+ * The index is the weighted mean of three terms — the player's health (staying in control), how far
39
+ * through the encounter they are, and how long it has run — each clamped to `0..1`, then split into
40
+ * `maxTier + 1` bands. A hurt player is leaned on *less*, so a director that reads this plan never
41
+ * piles on someone already losing. Because every term and every derived effect is a clamp of an
42
+ * authored cap, the rule cannot climb: a full-health player on a long run lands on
43
+ * `tier === maxTier` with `countBonus === maxTier`, never above it.
43
44
  *
44
45
  * @capability encounter-pressure bounded difficulty tier read from player health, encounter progress and time
45
46
  */
@@ -52,8 +53,8 @@ export function evaluatePressure(reading, rule = {}) {
52
53
  const totalWeight = resolved.healthWeight + resolved.progressWeight + resolved.timeWeight;
53
54
  const index = totalWeight <= 0
54
55
  ? 0
55
- : clamp01((resolved.healthWeight * (1 - health) +
56
- resolved.progressWeight * (1 - progress) +
56
+ : clamp01((resolved.healthWeight * health +
57
+ resolved.progressWeight * progress +
57
58
  resolved.timeWeight * time) /
58
59
  totalWeight);
59
60
  const tier = Math.min(resolved.maxTier, Math.max(0, Math.floor(index * (resolved.maxTier + 1))));
@@ -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(),
@@ -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;