@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 +32 -0
- package/dist/ai/director/pressure.d.ts +10 -9
- package/dist/ai/director/pressure.js +9 -8
- package/dist/game/gameSave.d.ts +6 -0
- package/dist/game/gameSave.js +6 -0
- package/dist/game/saveStore.d.ts +7 -0
- package/dist/game/saveStore.js +35 -0
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/item/affix.d.ts +2 -0
- package/dist/loot/drop.d.ts +47 -0
- package/dist/loot/drop.js +57 -0
- package/dist/loot/haul.d.ts +78 -0
- package/dist/loot/haul.js +126 -0
- package/dist/loot/lootGen.d.ts +7 -1
- package/dist/loot/lootGen.js +24 -5
- package/dist/loot/salvage.d.ts +74 -0
- package/dist/loot/salvage.js +68 -0
- package/dist/loot/vendor.d.ts +76 -0
- package/dist/loot/vendor.js +72 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +28 -1
- package/dist/mission/mission.d.ts +24 -0
- package/dist/mission/mission.js +28 -3
- package/package.json +1 -1
- package/skills/jgengine/api.md +3 -3
- package/skills/jgengine-gameplay/SKILL.md +10 -1
- package/skills/jgengine-gameplay/api.md +52 -3
- package/skills/jgengine-gameplay/capabilities.md +40 -0
package/dist/loot/lootGen.js
CHANGED
|
@@ -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) =>
|
|
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
|
+
}
|
package/dist/meta/changelog.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.31.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[];
|
package/dist/meta/changelog.js
CHANGED
|
@@ -1,7 +1,34 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.31.0";
|
|
3
3
|
/** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
|
|
4
4
|
export const CHANGELOG = {
|
|
5
|
+
"0.31.0": {
|
|
6
|
+
migrate: [
|
|
7
|
+
"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`.",
|
|
8
|
+
],
|
|
9
|
+
added: [
|
|
10
|
+
"`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.",
|
|
11
|
+
"`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.",
|
|
12
|
+
"`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.",
|
|
13
|
+
"`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`.",
|
|
14
|
+
"`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.",
|
|
15
|
+
"`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.",
|
|
16
|
+
"`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.",
|
|
17
|
+
"`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.",
|
|
18
|
+
"`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`.",
|
|
19
|
+
"`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.",
|
|
20
|
+
"`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.",
|
|
21
|
+
"`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.",
|
|
22
|
+
],
|
|
23
|
+
changed: [
|
|
24
|
+
"`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`.",
|
|
25
|
+
"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.",
|
|
26
|
+
"`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.",
|
|
27
|
+
"The HUD boundary check returned early whenever nothing overflowed, so a later report in the same pass could never run.",
|
|
28
|
+
"`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.",
|
|
29
|
+
],
|
|
30
|
+
removed: [],
|
|
31
|
+
},
|
|
5
32
|
"0.30.0": {
|
|
6
33
|
migrate: [
|
|
7
34
|
"Bump lockstep SDK packages to `^0.30.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.27.0`; `@jgengine/github` is `0.17.0`.",
|
|
@@ -341,6 +341,8 @@ export interface MissionRuntimeConfig {
|
|
|
341
341
|
readonly missions: readonly MissionDef[];
|
|
342
342
|
/** Marker positions resolved from the authored scene, by marker id. See `missionMarkers`. */
|
|
343
343
|
readonly markers?: Readonly<Record<string, ScenePoint3>>;
|
|
344
|
+
/** How marker proximity is measured for `reach`, `interact` and `left-area`. Default horizontal. */
|
|
345
|
+
readonly markerProximity?: MissionMarkerProximity;
|
|
344
346
|
/** Actor id used for a `pursue` objective that names no `pursuer`. */
|
|
345
347
|
readonly defaultPursuer?: string;
|
|
346
348
|
/** `[0,1)` source for a mission's `rollout`; pass `ctx.rng`. Only read when a mission declares one. */
|
|
@@ -378,8 +380,26 @@ export interface MissionRuntime {
|
|
|
378
380
|
/** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
|
|
379
381
|
dispose(): void;
|
|
380
382
|
}
|
|
383
|
+
/** Which plane a marker proximity test compares in. */
|
|
384
|
+
export type MissionMarkerPlane = "horizontal" | "spatial";
|
|
385
|
+
/**
|
|
386
|
+
* How a positional objective compares an actor to its authored marker.
|
|
387
|
+
*
|
|
388
|
+
* Authored markers sit on a flat plan (`y = 0`) while the terrain around them rises, so a straight
|
|
389
|
+
* 3D test can leave a marker unreachable from the ground below it. `"horizontal"` compares the
|
|
390
|
+
* ground distance and allows the actor up to `tolerance` metres off the marker's height;
|
|
391
|
+
* `"spatial"` is the plain 3D check, for a world whose stacked platforms must stay distinct.
|
|
392
|
+
*/
|
|
393
|
+
export interface MissionMarkerProximity {
|
|
394
|
+
/** `"horizontal"` (default) or `"spatial"`. */
|
|
395
|
+
plane?: MissionMarkerPlane;
|
|
396
|
+
/** Horizontal plane only: how far (m) the actor may sit above or below the marker. Default {@link DEFAULT_MARKER_TOLERANCE}. */
|
|
397
|
+
tolerance?: number;
|
|
398
|
+
}
|
|
381
399
|
/** Default proximity radius (m) for a `reach` / `interact` objective. */
|
|
382
400
|
export declare const DEFAULT_REACH_RADIUS = 2.5;
|
|
401
|
+
/** Default vertical allowance (m) for a horizontal marker proximity test. */
|
|
402
|
+
export declare const DEFAULT_MARKER_TOLERANCE = 8;
|
|
383
403
|
/** Default keep-close distance (m) for a `pursue` objective. */
|
|
384
404
|
export declare const DEFAULT_PURSUE_DISTANCE = 3;
|
|
385
405
|
/** A pursuit escapes at this multiple of its keep-close distance when none is declared. */
|
|
@@ -404,6 +424,10 @@ export declare function applyMissionRewards(rewards: MissionRewards, appliers: M
|
|
|
404
424
|
*
|
|
405
425
|
* Cost per tick is bounded by the active objective's own fail list plus one goal test.
|
|
406
426
|
*
|
|
427
|
+
* Marker proximity runs on the plane `markerProximity` picks: horizontal by default, so a marker
|
|
428
|
+
* authored on a flat `y = 0` plan still triggers from ground that rises under it; `spatial` for a
|
|
429
|
+
* world whose stacked platforms must stay apart.
|
|
430
|
+
*
|
|
407
431
|
* @capability mission-runtime missions as a data-driven objective state machine with fail states, timers, branches and rewards
|
|
408
432
|
*/
|
|
409
433
|
export declare function createMissionRuntime(config: MissionRuntimeConfig): MissionRuntime;
|
package/dist/mission/mission.js
CHANGED
|
@@ -23,6 +23,8 @@ export function isCounterObjective(kind) {
|
|
|
23
23
|
}
|
|
24
24
|
/** Default proximity radius (m) for a `reach` / `interact` objective. */
|
|
25
25
|
export const DEFAULT_REACH_RADIUS = 2.5;
|
|
26
|
+
/** Default vertical allowance (m) for a horizontal marker proximity test. */
|
|
27
|
+
export const DEFAULT_MARKER_TOLERANCE = 8;
|
|
26
28
|
/** Default keep-close distance (m) for a `pursue` objective. */
|
|
27
29
|
export const DEFAULT_PURSUE_DISTANCE = 3;
|
|
28
30
|
/** A pursuit escapes at this multiple of its keep-close distance when none is declared. */
|
|
@@ -30,6 +32,21 @@ export const DEFAULT_ESCAPE_MULTIPLE = 3;
|
|
|
30
32
|
function distance(a, b) {
|
|
31
33
|
return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
|
32
34
|
}
|
|
35
|
+
/** Whether `from` is inside the marker's reach radius under the configured plane — no allocation, no sqrt. */
|
|
36
|
+
function markerWithin(from, to, radius, plane, tolerance) {
|
|
37
|
+
const dy = from.y - to.y;
|
|
38
|
+
const limit = radius * radius;
|
|
39
|
+
if (plane === "spatial") {
|
|
40
|
+
const dx = from.x - to.x;
|
|
41
|
+
const dz = from.z - to.z;
|
|
42
|
+
return dx * dx + dy * dy + dz * dz <= limit;
|
|
43
|
+
}
|
|
44
|
+
if (Math.abs(dy) > tolerance)
|
|
45
|
+
return false;
|
|
46
|
+
const dx = from.x - to.x;
|
|
47
|
+
const dz = from.z - to.z;
|
|
48
|
+
return dx * dx + dz * dz <= limit;
|
|
49
|
+
}
|
|
33
50
|
function cloneState(state) {
|
|
34
51
|
return {
|
|
35
52
|
...state,
|
|
@@ -110,6 +127,10 @@ export function applyMissionRewards(rewards, appliers) {
|
|
|
110
127
|
*
|
|
111
128
|
* Cost per tick is bounded by the active objective's own fail list plus one goal test.
|
|
112
129
|
*
|
|
130
|
+
* Marker proximity runs on the plane `markerProximity` picks: horizontal by default, so a marker
|
|
131
|
+
* authored on a flat `y = 0` plan still triggers from ground that rises under it; `spatial` for a
|
|
132
|
+
* world whose stacked platforms must stay apart.
|
|
133
|
+
*
|
|
113
134
|
* @capability mission-runtime missions as a data-driven objective state machine with fail states, timers, branches and rewards
|
|
114
135
|
*/
|
|
115
136
|
export function createMissionRuntime(config) {
|
|
@@ -117,6 +138,8 @@ export function createMissionRuntime(config) {
|
|
|
117
138
|
for (const def of config.missions)
|
|
118
139
|
defs.set(def.id, def);
|
|
119
140
|
const markers = config.markers ?? {};
|
|
141
|
+
const markerPlane = config.markerProximity?.plane ?? "horizontal";
|
|
142
|
+
const markerTolerance = Math.max(0, config.markerProximity?.tolerance ?? DEFAULT_MARKER_TOLERANCE);
|
|
120
143
|
let rng = config.rng;
|
|
121
144
|
let state = null;
|
|
122
145
|
let ticks = 0;
|
|
@@ -222,7 +245,7 @@ export function createMissionRuntime(config) {
|
|
|
222
245
|
const to = objective.marker === undefined ? undefined : markers[objective.marker];
|
|
223
246
|
if (from === undefined || to === undefined)
|
|
224
247
|
return false;
|
|
225
|
-
return
|
|
248
|
+
return markerWithin(from, to, objective.radius ?? DEFAULT_REACH_RADIUS, markerPlane, markerTolerance);
|
|
226
249
|
}
|
|
227
250
|
case "interact": {
|
|
228
251
|
if (objective.actor === undefined || !(input.interact ?? []).includes(objective.actor))
|
|
@@ -231,7 +254,8 @@ export function createMissionRuntime(config) {
|
|
|
231
254
|
if (to === undefined)
|
|
232
255
|
return true;
|
|
233
256
|
const from = positionOf(objective.actor, input);
|
|
234
|
-
return from !== undefined
|
|
257
|
+
return from !== undefined
|
|
258
|
+
&& markerWithin(from, to, objective.radius ?? DEFAULT_REACH_RADIUS, markerPlane, markerTolerance);
|
|
235
259
|
}
|
|
236
260
|
case "survive":
|
|
237
261
|
return state.objectiveElapsed >= (objective.duration ?? 0);
|
|
@@ -306,7 +330,8 @@ export function createMissionRuntime(config) {
|
|
|
306
330
|
const actor = condition.actor ?? objective.actor;
|
|
307
331
|
const from = positionOf(actor, input);
|
|
308
332
|
const to = condition.marker === undefined ? undefined : markers[condition.marker];
|
|
309
|
-
|
|
333
|
+
const radius = condition.radius ?? DEFAULT_REACH_RADIUS;
|
|
334
|
+
if (from !== undefined && to !== undefined && !markerWithin(from, to, radius, markerPlane, markerTolerance)) {
|
|
310
335
|
return { state: condition.to ?? "failed", reason: condition.message ?? `${actor ?? "actor"} left the area` };
|
|
311
336
|
}
|
|
312
337
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "jgengine core — pure-TypeScript game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. Agent skills ship in the tarball. https://jgengine.com",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jgengine",
|
package/skills/jgengine/api.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
8
8
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
9
|
-
- `VERSION` (const): const VERSION: "0.
|
|
9
|
+
- `VERSION` (const): const VERSION: "0.31.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
10
10
|
|
|
11
11
|
## @jgengine/core/authoring
|
|
12
12
|
|
|
@@ -285,7 +285,7 @@
|
|
|
285
285
|
|
|
286
286
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
287
287
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
288
|
-
- `VERSION` (const): const VERSION: "0.
|
|
288
|
+
- `VERSION` (const): const VERSION: "0.31.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
289
289
|
|
|
290
290
|
## @jgengine/core/runtime/adapter
|
|
291
291
|
|
|
@@ -728,7 +728,7 @@
|
|
|
728
728
|
- `GameLoop` (interface): interface GameLoop<TContext = unknown> — Lifecycle hooks a game implements to drive init, per-tick simulation, and player join/leave.
|
|
729
729
|
- `Hotbar` (function): function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, itemIcon, style, className, }: { inventoryId: string; activeSlot?: number; keys?: readonly string[]; slotSize?: number; /** Caller-supplied item id → icon registry; return null/undefined to fall back to the default glyph. */ itemIcon?: … — A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
|
|
730
730
|
- `HudCanvas` (function): function HudCanvas({ layout, editChord, compactScale, showDuring, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; /** Opt-in play-phase gate: render the HUD only … — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
|
|
731
|
-
- `HudPanel` (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /**
|
|
731
|
+
- `HudPanel` (function): function HudPanel({ id, anchor = "top-left", lane, order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** … — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
|
|
732
732
|
- `PlayableGame` (type): type PlayableGame = EnginePlayableGame< ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps> > & { /** HUD theme the shell applies at the `GameUI` root. Unset uses `defaultHudTheme`; `false` opts out of the default theme and the en… — The concrete React-bound playable a shell `defineGame` returns and `GameHost` mounts — the one PlayableGame games handle.
|
|
733
733
|
- `StatBar` (function): function StatBar({ statId = "health", entityId, tone = "health", label, showValue = true, width = 200, icon, style, className, }: { statId?: string; entityId?: string; tone?: StatTone; label?: string; showValue?: boolean; width?: number; icon?: ReactNode; style?: CSSProperties; className?: string; }… — A polished stat bar (health/mana/stamina/shield/xp) — a rounded, glassy meter with a tone-colored fill and an optional value readout. Reads `statId` off `entityId` (defaults to the local player). Renders nothing until the stat exists.
|
|
734
734
|
- `StoreHandle` (interface): interface StoreHandle<T> — A typed, cast-free handle onto one slot of the reactive game store (`ctx.game.store`). The single type parameter is fixed at definition; `read`/`write`/`update` never widen to `unknown`, so no call site repeats `store.get(key) as T`. Writes flow through the engine store, so they bump `ctx.version()`, serialize into a {@link WorldSnapshot}, and replay under host authority — the same guarantees a hand-rolled `store.get`/`store.set` pair gives up the moment it forks run state into a module-level singleton.
|
|
@@ -44,7 +44,16 @@ tiers, manufacturer stat personalities and a level curve, all caller data.
|
|
|
44
44
|
`validateLootItem` names each defect, `compareLootItems` returns per-stat deltas
|
|
45
45
|
and a weighted score, and `lootWeaponDefinition` (`loot/lootWeapon`) projects a
|
|
46
46
|
rolled item onto a `WeaponDefinition` for `createWeaponSystem`. `createLootRoller`
|
|
47
|
-
counts drops per rarity for a debug probe and snapshots its counters.
|
|
47
|
+
counts drops per rarity for a debug probe and snapshots its counters.
|
|
48
|
+
|
|
49
|
+
Roll a campaign drop with `rollDrop` (`loot/drop`) and show `compareToEquipped`
|
|
50
|
+
against worn gear; carry and bank it with `createRunHaul` (`loot/haul`), which
|
|
51
|
+
weighs the hold through the encumbrance rules and drops the hold on `fail`. Break
|
|
52
|
+
an item down once with `salvageItem` and `createItemLedger` (`loot/salvage`), and
|
|
53
|
+
price a vendor shelf through the wallet with `stockVendor` (`loot/vendor`).
|
|
54
|
+
Bases and affixes take `minLevel`; bases take `mass`, which a roll carries forward.
|
|
55
|
+
|
|
56
|
+
Sample
|
|
48
57
|
catalog, distribution check and generated-weapon proof are in
|
|
49
58
|
[reference-systems.md](reference-systems.md#procedural-gear).
|
|
50
59
|
|