@jgengine/core 0.30.0 → 0.32.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.
@@ -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;
@@ -18,6 +18,10 @@ export function assertLootCatalog(catalog) {
18
18
  baseIds.add(base.id);
19
19
  if (base.weight !== undefined)
20
20
  check(base.weight > 0, `Loot base "${base.id}" weight must be positive`);
21
+ if (base.minLevel !== undefined)
22
+ check(Number.isInteger(base.minLevel) && base.minLevel >= 1, `Loot base "${base.id}" minLevel must be an integer >= 1`);
23
+ if (base.mass !== undefined)
24
+ check(Number.isFinite(base.mass) && base.mass > 0, `Loot base "${base.id}" mass must be positive`);
21
25
  }
22
26
  const poolIds = new Set();
23
27
  const affixIds = new Set();
@@ -29,6 +33,8 @@ export function assertLootCatalog(catalog) {
29
33
  check(!affixIds.has(affix.id), `Loot affix "${affix.id}" is declared twice`);
30
34
  affixIds.add(affix.id);
31
35
  check(affix.weight > 0, `Loot affix "${affix.id}" weight must be positive`);
36
+ if (affix.minLevel !== undefined)
37
+ check(Number.isInteger(affix.minLevel) && affix.minLevel >= 1, `Loot affix "${affix.id}" minLevel must be an integer >= 1`);
32
38
  if (typeof affix.roll === "number") {
33
39
  check(Number.isFinite(affix.roll), `Loot affix "${affix.id}" roll must be finite`);
34
40
  if (affix.op === "mul")
@@ -148,7 +154,7 @@ function weighted(rng, items, weightOf, field) {
148
154
  throw new Error(`Loot catalog has no eligible ${field}`);
149
155
  return picked;
150
156
  }
151
- function candidateAffixes(catalog, base, rarity) {
157
+ function candidateAffixes(catalog, base, rarity, level) {
152
158
  const out = [];
153
159
  for (const poolId of base.pools) {
154
160
  if (rarity.pools !== undefined && !rarity.pools.includes(poolId))
@@ -156,8 +162,11 @@ function candidateAffixes(catalog, base, rarity) {
156
162
  const pool = catalog.pools.find((entry) => entry.id === poolId);
157
163
  if (pool === undefined)
158
164
  continue;
159
- for (const affix of pool.affixes)
165
+ for (const affix of pool.affixes) {
166
+ if (affix.minLevel !== undefined && affix.minLevel > level)
167
+ continue;
160
168
  out.push({ affix, poolId });
169
+ }
161
170
  }
162
171
  return out;
163
172
  }
@@ -192,11 +201,13 @@ export function rollLoot(catalog, rng, options = {}) {
192
201
  const manufacturer = options.manufacturer === undefined
193
202
  ? weighted(rng, catalog.manufacturers.filter((maker) => options.baseId === undefined || maker.bases === undefined || maker.bases.includes(options.baseId)), (maker) => maker.weight ?? 1, "manufacturer")
194
203
  : findOrThrow(catalog.manufacturers, options.manufacturer, "manufacturer");
195
- const basePool = catalog.bases.filter((base) => manufacturer.bases === undefined || manufacturer.bases.includes(base.id));
204
+ const basePool = catalog.bases.filter((base) => (base.minLevel === undefined || base.minLevel <= level)
205
+ && (manufacturer.bases === undefined || manufacturer.bases.includes(base.id)));
196
206
  const base = options.baseId === undefined
197
207
  ? weighted(rng, basePool, (entry) => entry.weight ?? 1, "base")
198
208
  : findOrThrow(catalog.bases, options.baseId, "base");
199
209
  check(manufacturer.bases === undefined || manufacturer.bases.includes(base.id), `Loot manufacturer "${manufacturer.id}" does not produce base "${base.id}"`);
210
+ check(base.minLevel === undefined || base.minLevel <= level, `Loot base "${base.id}" needs item level ${base.minLevel}, got ${level}`);
200
211
  const rarity = options.rarity === undefined
201
212
  ? weighted(rng, catalog.rarities, (tier) => tier.weight, "rarity")
202
213
  : findOrThrow(catalog.rarities, options.rarity, "rarity");
@@ -206,7 +217,7 @@ export function rollLoot(catalog, rng, options = {}) {
206
217
  const part = weighted(rng, eligible, (entry) => entry.weight ?? 1, `part for slot "${slot.id}"`);
207
218
  parts.push({ slotId: slot.id, partId: part.id, category: part.category });
208
219
  }
209
- const candidates = candidateAffixes(catalog, base, rarity);
220
+ const candidates = candidateAffixes(catalog, base, rarity, level);
210
221
  const favors = manufacturer.favoredPools ?? [];
211
222
  const favorWeight = manufacturer.favoredWeight ?? 3;
212
223
  const remaining = candidates.slice();
@@ -254,6 +265,7 @@ export function rollLoot(catalog, rng, options = {}) {
254
265
  }
255
266
  for (const stat of Object.keys(stats))
256
267
  stats[stat] = Math.round(stats[stat] * 1000) / 1000;
268
+ const mass = Math.round((base.mass ?? 1) * (1 + 0.1 * (level - 1)) * 100) / 100;
257
269
  return {
258
270
  baseId: base.id,
259
271
  manufacturer: manufacturer.id,
@@ -263,6 +275,7 @@ export function rollLoot(catalog, rng, options = {}) {
263
275
  parts,
264
276
  affixes,
265
277
  stats,
278
+ mass,
266
279
  };
267
280
  }
268
281
  /**
@@ -280,6 +293,9 @@ export function validateLootItem(item, catalog) {
280
293
  if (base === undefined) {
281
294
  issues.push({ code: "unknown-base", detail: `base "${item.baseId}" is not in the catalog` });
282
295
  }
296
+ else if (base.minLevel !== undefined && base.minLevel > item.level) {
297
+ issues.push({ code: "level-gated-base", detail: `base "${base.id}" needs item level ${base.minLevel}, got ${item.level}` });
298
+ }
283
299
  const manufacturer = catalog.manufacturers.find((entry) => entry.id === item.manufacturer);
284
300
  if (manufacturer === undefined) {
285
301
  issues.push({ code: "unknown-manufacturer", detail: `manufacturer "${item.manufacturer}" is not in the catalog` });
@@ -316,7 +332,7 @@ export function validateLootItem(item, catalog) {
316
332
  if (!seenSlots.has(slot.id))
317
333
  issues.push({ code: "missing-slot-part", detail: `slot "${slot.id}" has no part` });
318
334
  }
319
- const allowed = base === undefined || rarity === undefined ? null : candidateAffixes(catalog, base, rarity);
335
+ const allowed = base === undefined || rarity === undefined ? null : candidateAffixes(catalog, base, rarity, item.level);
320
336
  const seenAffixes = new Set();
321
337
  for (const affix of item.affixes) {
322
338
  if (seenAffixes.has(affix.id)) {
@@ -335,6 +351,9 @@ export function validateLootItem(item, catalog) {
335
351
  if (!Number.isFinite(value))
336
352
  issues.push({ code: "non-finite-stat", detail: `stat "${stat}" is ${String(value)}` });
337
353
  }
354
+ if (item.mass !== undefined && (!Number.isFinite(item.mass) || item.mass <= 0)) {
355
+ issues.push({ code: "invalid-mass", detail: `mass ${String(item.mass)} is not a positive number` });
356
+ }
338
357
  if (item.name.trim() === "")
339
358
  issues.push({ code: "empty-name", detail: "item has no name" });
340
359
  return issues;
@@ -0,0 +1,74 @@
1
+ /** Where an item instance stands: still usable, broken down, or sold. */
2
+ export type ItemLifecycle = "owned" | "salvaged" | "sold";
3
+ /** Items that have left play, keyed by instance id; anything absent is still `owned`. */
4
+ export type ItemLedgerState = Readonly<Record<string, Exclude<ItemLifecycle, "owned">>>;
5
+ /**
6
+ * One record of what happened to rolled item instances. Every path that removes an item - salvage,
7
+ * sale, granting a duplicate - claims it here first, so a second claim returns false and the item
8
+ * cannot be equipped, sold or broken down twice.
9
+ */
10
+ export interface ItemLedger {
11
+ status(id: string): ItemLifecycle;
12
+ /** True only while the instance is `owned`; a salvaged or sold item is no longer usable. */
13
+ canUse(id: string): boolean;
14
+ /** Claim `id` for `salvaged` or `sold`; false when it already left play. */
15
+ spend(id: string, how: Exclude<ItemLifecycle, "owned">): boolean;
16
+ snapshot(): ItemLedgerState;
17
+ /** Replace the ledger; false without mutation when an entry names an unknown outcome. */
18
+ restore(next: ItemLedgerState): boolean;
19
+ reset(): void;
20
+ }
21
+ /**
22
+ * Build an item ledger. Ids are consumed once: the first successful {@link ItemLedger.spend} wins,
23
+ * every later call fails, which is what makes salvage and sale mutually exclusive.
24
+ *
25
+ * @capability item-ledger track which rolled item instances are still owned, salvaged or sold
26
+ */
27
+ export declare function createItemLedger(initial?: ItemLedgerState): ItemLedger;
28
+ /** The currency and amount one salvage yields. `currency` is the game's key, never interpreted. */
29
+ export interface SalvageYield {
30
+ currency: string;
31
+ amount: number;
32
+ }
33
+ /** What a broken-down item is worth: a game-authored currency, rate and per-rarity multiplier. */
34
+ export interface SalvageTable {
35
+ /** Wallet currency key the yield is granted in, e.g. `"cores"`. */
36
+ currency: string;
37
+ /** Units paid per item level at multiplier `1`. */
38
+ perLevel: number;
39
+ /** Multiplier per rarity id; omitted rarities use `1`. */
40
+ rarityMultiplier?: Readonly<Record<string, number>>;
41
+ /** Floor for the yield (default `1`). */
42
+ minimum?: number;
43
+ }
44
+ /**
45
+ * The yield an item of this level and rarity breaks down into. The formula is
46
+ * `round(perLevel x level x rarityMultiplier)`, floored by `minimum`, so the same item always yields
47
+ * the same amount and a higher level never yields less.
48
+ *
49
+ * @capability item-salvage-value compute the currency an item of a level and rarity breaks down into
50
+ */
51
+ export declare function salvageValue(item: Pick<ItemDropLike, "level" | "rarity">, table: SalvageTable): SalvageYield;
52
+ /** The fields salvage reads; an `ItemDrop` from `./drop` satisfies it. */
53
+ export interface ItemDropLike {
54
+ id: string;
55
+ level: number;
56
+ rarity: string;
57
+ }
58
+ /** Salvage outcome: the yield, or the reason the instance could not be broken down. */
59
+ export type SalvageResult = {
60
+ ok: true;
61
+ id: string;
62
+ yield: SalvageYield;
63
+ } | {
64
+ ok: false;
65
+ reason: "already-spent" | "invalid-item";
66
+ };
67
+ /**
68
+ * Break one item down through its ledger. The ledger is claimed before the yield is computed, so a
69
+ * salvaged item cannot be salvaged, equipped or sold again; pass the game's own wallet update from
70
+ * `yield`.
71
+ *
72
+ * @capability item-salvage break an item down for the currency its level and rarity say, once
73
+ */
74
+ export declare function salvageItem(item: ItemDropLike, table: SalvageTable, ledger: ItemLedger): SalvageResult;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Build an item ledger. Ids are consumed once: the first successful {@link ItemLedger.spend} wins,
3
+ * every later call fails, which is what makes salvage and sale mutually exclusive.
4
+ *
5
+ * @capability item-ledger track which rolled item instances are still owned, salvaged or sold
6
+ */
7
+ export function createItemLedger(initial = {}) {
8
+ const spent = new Map();
9
+ for (const [id, how] of Object.entries(initial)) {
10
+ if (how === "salvaged" || how === "sold")
11
+ spent.set(id, how);
12
+ }
13
+ return {
14
+ status: (id) => spent.get(id) ?? "owned",
15
+ canUse: (id) => !spent.has(id),
16
+ spend(id, how) {
17
+ if (spent.has(id))
18
+ return false;
19
+ spent.set(id, how);
20
+ return true;
21
+ },
22
+ snapshot: () => Object.fromEntries(spent),
23
+ restore(next) {
24
+ if (next === null || typeof next !== "object")
25
+ return false;
26
+ const staged = new Map();
27
+ for (const [id, how] of Object.entries(next)) {
28
+ if (how !== "salvaged" && how !== "sold")
29
+ return false;
30
+ if (id.trim() === "")
31
+ return false;
32
+ staged.set(id, how);
33
+ }
34
+ spent.clear();
35
+ for (const [id, how] of staged)
36
+ spent.set(id, how);
37
+ return true;
38
+ },
39
+ reset: () => spent.clear(),
40
+ };
41
+ }
42
+ /**
43
+ * The yield an item of this level and rarity breaks down into. The formula is
44
+ * `round(perLevel x level x rarityMultiplier)`, floored by `minimum`, so the same item always yields
45
+ * the same amount and a higher level never yields less.
46
+ *
47
+ * @capability item-salvage-value compute the currency an item of a level and rarity breaks down into
48
+ */
49
+ export function salvageValue(item, table) {
50
+ const multiplier = table.rarityMultiplier?.[item.rarity] ?? 1;
51
+ const amount = Math.max(table.minimum ?? 1, Math.round(table.perLevel * Math.max(1, item.level) * multiplier));
52
+ return { currency: table.currency, amount };
53
+ }
54
+ /**
55
+ * Break one item down through its ledger. The ledger is claimed before the yield is computed, so a
56
+ * salvaged item cannot be salvaged, equipped or sold again; pass the game's own wallet update from
57
+ * `yield`.
58
+ *
59
+ * @capability item-salvage break an item down for the currency its level and rarity say, once
60
+ */
61
+ export function salvageItem(item, table, ledger) {
62
+ if (!Number.isFinite(item.level) || item.level < 1 || item.id.trim() === "") {
63
+ return { ok: false, reason: "invalid-item" };
64
+ }
65
+ if (!ledger.spend(item.id, "salvaged"))
66
+ return { ok: false, reason: "already-spent" };
67
+ return { ok: true, id: item.id, yield: salvageValue(item, table) };
68
+ }
@@ -0,0 +1,76 @@
1
+ import type { ShopStock, ShopStockEntry } from "../economy/shopStock.js";
2
+ import { type LootCatalog, type LootItem } from "./lootGen.js";
3
+ import type { ItemDrop } from "./drop.js";
4
+ /** How a vendor price is derived from an item: a base, a per-level rate and a rarity multiplier. */
5
+ export interface VendorPriceRule {
6
+ /** Wallet currency key the stock is priced in, e.g. `"credits"`. */
7
+ currency: string;
8
+ /** Price floor at level 1 before the rarity multiplier. */
9
+ base: number;
10
+ /** Added per item level. */
11
+ perLevel: number;
12
+ /** Multiplier per rarity id; omitted rarities use `1`. */
13
+ rarityMultiplier?: Readonly<Record<string, number>>;
14
+ /** Buy-back fraction of the buy price; omitted means the vendor does not buy this stock back. */
15
+ sellFactor?: number;
16
+ }
17
+ /** One stocked item and the shop entry that prices it. */
18
+ export interface VendorOffer {
19
+ item: ItemDrop;
20
+ entry: ShopStockEntry;
21
+ }
22
+ /** What stocking a vendor reads: the roll catalog, a seeded rng and the pricing policy. */
23
+ export interface StockVendorOptions {
24
+ catalog: LootCatalog;
25
+ /** Injected random source; pass a seeded generator so the shelf is reproducible. */
26
+ rng: () => number;
27
+ /** Item level rolled onto the shelf. */
28
+ level: number;
29
+ /** How many items to roll. */
30
+ count: number;
31
+ price: VendorPriceRule;
32
+ /** Stable id prefix for this vendor, e.g. `"harbor.guns"`. */
33
+ idPrefix: string;
34
+ /** Whole units on hand; `null` means unlimited (default `1`). */
35
+ qty?: number | null;
36
+ }
37
+ /**
38
+ * The price of one item under a `VendorPriceRule`: `round(base + perLevel x level)` times the
39
+ * rarity multiplier, never below 1. Sell-back is `floor(price x sellFactor)` when the rule sets one.
40
+ *
41
+ * @capability item-vendor-price price an item from its level and rarity through the economy's currency
42
+ */
43
+ export declare function itemPrice(item: Pick<LootItem, "level" | "rarity">, rule: VendorPriceRule): {
44
+ buy: number;
45
+ sell: number | null;
46
+ };
47
+ /**
48
+ * Roll a vendor's shelf and add it to an existing {@link ShopStock}. Entries added by a previous
49
+ * call with the same `idPrefix` are removed first, so a refresh replaces the shelf instead of
50
+ * throwing on duplicate ids. Buying, selling and wallet charging stay in the economy owner.
51
+ *
52
+ * @capability item-vendor stock a vendor with rolled items priced through the shared economy
53
+ */
54
+ export declare function stockVendor(shop: ShopStock, options: StockVendorOptions): VendorOffer[];
55
+ /** Where a vendor shelf stands: the run number it was last stocked on. */
56
+ export interface VendorRotationState {
57
+ refreshedAt: number;
58
+ }
59
+ /** A run-count refresh rule; the game owns how many runs between restocks. */
60
+ export interface VendorRotation {
61
+ /** True when `run` is at least `everyRuns` past the last refresh. */
62
+ due(run: number): boolean;
63
+ /** Record that the shelf was stocked on `run`. */
64
+ mark(run: number): void;
65
+ state(): VendorRotationState;
66
+ restore(next: VendorRotationState): boolean;
67
+ reset(run?: number): void;
68
+ }
69
+ /**
70
+ * A restock rule the game authors as "every N runs". It only answers when a refresh is due; the
71
+ * game calls {@link stockVendor} and then `mark`. No timer, so a paused or reloaded save keeps the
72
+ * same shelf until the run count moves.
73
+ *
74
+ * @capability item-vendor-rotation decide when a vendor's shelf is due for a restock
75
+ */
76
+ export declare function createVendorRotation(everyRuns: number, start?: number): VendorRotation;
@@ -0,0 +1,72 @@
1
+ import { rollLoot } from "./lootGen.js";
2
+ /**
3
+ * The price of one item under a `VendorPriceRule`: `round(base + perLevel x level)` times the
4
+ * rarity multiplier, never below 1. Sell-back is `floor(price x sellFactor)` when the rule sets one.
5
+ *
6
+ * @capability item-vendor-price price an item from its level and rarity through the economy's currency
7
+ */
8
+ export function itemPrice(item, rule) {
9
+ const multiplier = rule.rarityMultiplier?.[item.rarity] ?? 1;
10
+ const buy = Math.max(1, Math.round((rule.base + rule.perLevel * Math.max(1, item.level)) * multiplier));
11
+ const sell = rule.sellFactor === undefined ? null : Math.max(0, Math.floor(buy * rule.sellFactor));
12
+ return { buy, sell };
13
+ }
14
+ /**
15
+ * Roll a vendor's shelf and add it to an existing {@link ShopStock}. Entries added by a previous
16
+ * call with the same `idPrefix` are removed first, so a refresh replaces the shelf instead of
17
+ * throwing on duplicate ids. Buying, selling and wallet charging stay in the economy owner.
18
+ *
19
+ * @capability item-vendor stock a vendor with rolled items priced through the shared economy
20
+ */
21
+ export function stockVendor(shop, options) {
22
+ for (const entry of shop.list()) {
23
+ if (entry.id.startsWith(`${options.idPrefix}:`))
24
+ shop.remove(entry.id);
25
+ }
26
+ const offers = [];
27
+ for (let i = 0; i < Math.max(0, Math.floor(options.count)); i += 1) {
28
+ const item = {
29
+ ...rollLoot(options.catalog, options.rng, { level: options.level }),
30
+ id: `${options.idPrefix}:${i}`,
31
+ };
32
+ const price = itemPrice(item, options.price);
33
+ const entry = {
34
+ id: item.id,
35
+ kind: item.baseId,
36
+ price: { currency: options.price.currency, amount: price.buy },
37
+ qty: options.qty === undefined ? 1 : options.qty,
38
+ ...(price.sell === null ? {} : { sellPrice: { currency: options.price.currency, amount: price.sell } }),
39
+ };
40
+ shop.add(entry);
41
+ offers.push({ item, entry });
42
+ }
43
+ return offers;
44
+ }
45
+ /**
46
+ * A restock rule the game authors as "every N runs". It only answers when a refresh is due; the
47
+ * game calls {@link stockVendor} and then `mark`. No timer, so a paused or reloaded save keeps the
48
+ * same shelf until the run count moves.
49
+ *
50
+ * @capability item-vendor-rotation decide when a vendor's shelf is due for a restock
51
+ */
52
+ export function createVendorRotation(everyRuns, start = 0) {
53
+ if (!Number.isFinite(everyRuns) || everyRuns < 1)
54
+ throw new RangeError("everyRuns must be >= 1");
55
+ let refreshedAt = start;
56
+ return {
57
+ due: (run) => run - refreshedAt >= everyRuns,
58
+ mark: (run) => {
59
+ refreshedAt = run;
60
+ },
61
+ state: () => ({ refreshedAt }),
62
+ restore(next) {
63
+ if (next === null || typeof next !== "object" || !Number.isFinite(next.refreshedAt))
64
+ return false;
65
+ refreshedAt = next.refreshedAt;
66
+ return true;
67
+ },
68
+ reset(run = 0) {
69
+ refreshedAt = run;
70
+ },
71
+ };
72
+ }
@@ -1,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export declare const VERSION = "0.30.0";
2
+ export declare const VERSION = "0.32.0";
3
3
  /** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */
4
4
  export interface ChangelogEntry {
5
5
  migrate: readonly string[];