@jgengine/core 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/dist/agents/agent.d.ts +134 -0
- package/dist/agents/agent.js +224 -0
- package/dist/agents/mood.d.ts +74 -0
- package/dist/agents/mood.js +97 -0
- package/dist/agents/relationships.d.ts +92 -0
- package/dist/agents/relationships.js +132 -0
- package/dist/agents/schedule.d.ts +58 -0
- package/dist/agents/schedule.js +72 -0
- package/dist/ai/director/boss.d.ts +63 -0
- package/dist/ai/director/boss.js +87 -0
- package/dist/ai/director/encounter.d.ts +146 -0
- package/dist/ai/director/encounter.js +195 -0
- package/dist/ai/director/pressure.d.ts +59 -0
- package/dist/ai/director/pressure.js +71 -0
- package/dist/ai/director/roles.d.ts +61 -0
- package/dist/ai/director/roles.js +118 -0
- package/dist/ai/director/sound.d.ts +79 -0
- package/dist/ai/director/sound.js +113 -0
- package/dist/game/gameSave.d.ts +6 -0
- package/dist/game/gameSave.js +6 -0
- package/dist/game/playableGame.d.ts +2 -2
- package/dist/game/saveStore.d.ts +7 -0
- package/dist/game/saveStore.js +35 -0
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/item/affix.d.ts +2 -0
- package/dist/loot/drop.d.ts +47 -0
- package/dist/loot/drop.js +57 -0
- package/dist/loot/haul.d.ts +78 -0
- package/dist/loot/haul.js +126 -0
- package/dist/loot/lootGen.d.ts +7 -1
- package/dist/loot/lootGen.js +24 -5
- package/dist/loot/salvage.d.ts +74 -0
- package/dist/loot/salvage.js +68 -0
- package/dist/loot/vendor.d.ts +76 -0
- package/dist/loot/vendor.js +72 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +47 -1
- package/dist/mission/mission.d.ts +24 -0
- package/dist/mission/mission.js +28 -3
- package/dist/progression/careerBank.d.ts +63 -0
- package/dist/progression/careerBank.js +83 -0
- package/dist/progression/gates.d.ts +66 -0
- package/dist/progression/gates.js +88 -0
- package/dist/progression/standing.d.ts +77 -0
- package/dist/progression/standing.js +151 -0
- package/dist/progression/upgrades.d.ts +70 -0
- package/dist/progression/upgrades.js +128 -0
- package/dist/ui/accessibility.d.ts +8 -0
- package/dist/ui/accessibility.js +14 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +3 -3
- package/skills/jgengine-gameplay/SKILL.md +12 -1
- package/skills/jgengine-gameplay/api.md +141 -3
- package/skills/jgengine-gameplay/capabilities.md +74 -0
- package/skills/jgengine-gameplay/recipes/agent-lives.md +76 -0
- package/skills/jgengine-world/api.md +52 -0
- package/skills/jgengine-world/capabilities.md +32 -0
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,53 @@
|
|
|
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
|
+
},
|
|
32
|
+
"0.30.0": {
|
|
33
|
+
migrate: [
|
|
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`.",
|
|
35
|
+
],
|
|
36
|
+
added: [
|
|
37
|
+
"`ColorblindFrame` (`@jgengine/react/accessibility`) wraps the 3D canvas and applies the player's colour-vision mode to that box alone, so HUD, menu and caption text keep their own contrast. `colorblindFrameFilter` (`@jgengine/core/ui/accessibility`) resolves a mode to the CSS filter, and `useColorblindMode` reads the mode from the settings store the settings rows write; the 3D shell does both for the mounted game.",
|
|
38
|
+
"`SettingsSection` (`@jgengine/react/settingsBlocks`) is one `useMenuNav` menu: a d-pad or left stick moves between rows and left/right steps the focused value — slider by its step, toggle flipped, select to the next option, confirm arms a keybind — through the same `onAdjust` path the arrow keys use. `onAdjust` (`@jgengine/react/menuNav`) is the option that makes left/right adjust instead of move.",
|
|
39
|
+
"`createStanding` (`@jgengine/core/progression/standing`) holds per-faction or per-district standing over authored tracks and moves it from named outcomes, so an outcome that resolves to nothing throws at the call instead of silently moving nothing.",
|
|
40
|
+
"`createGateSet` (`@jgengine/core/progression/gates`) evaluates authored milestones against standing and money, and refuses to load when a gate's unlock id resolves to nothing.",
|
|
41
|
+
"`createCareerBank` (`@jgengine/core/progression/careerBank`) keeps persistent career money apart from a run's haul, so a failed run loses the haul and keeps the bank.",
|
|
42
|
+
"`createUpgradeTree` (`@jgengine/core/progression/upgrades`) prices each rank from the shared curve primitive and gates purchases on prerequisites and standing.",
|
|
43
|
+
],
|
|
44
|
+
changed: [
|
|
45
|
+
"`AccessibilityProvider` (`@jgengine/react/accessibility`) sets its data attributes and text-scale var without filtering its own subtree. A colour matrix over the HUD and menu root recoloured the text the rest of the UI assumes, so wrap the game canvas in `ColorblindFrame` instead; a custom root can still offset it with its own `ColorblindFilters` and `filter: url(#jg-cb-<mode>)`.",
|
|
46
|
+
"World health bars and nameplates derive their range from the render camera and the target height when a game sets no `maxDistance`: an overhead camera reaches the units it looks down at, and a bar is kept while the target it annotates still covers 18 px, instead of hiding every target past a fixed 60 units.",
|
|
47
|
+
"A configured `worldHealthBars.maxDistance` or `nameplates.maxDistance` that hides every target in view now warns once with the nearest measured distance and the prop to raise, instead of rendering nothing.",
|
|
48
|
+
],
|
|
49
|
+
removed: [],
|
|
50
|
+
},
|
|
5
51
|
"0.29.0": {
|
|
6
52
|
migrate: [
|
|
7
53
|
"Bump lockstep SDK packages to `^0.29.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.26.0`; `@jgengine/github` is `0.16.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;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type CurrencyDefinition } from "../economy/currency.js";
|
|
2
|
+
/**
|
|
3
|
+
* Everything a career bank stores, as plain JSON. `banked` is the persistent balance; `haul` is
|
|
4
|
+
* the run in progress and is the part a failed run loses. `deposited`, `lost`, `bestHaul` and
|
|
5
|
+
* `runs` are lifetime counters a results screen reads.
|
|
6
|
+
*/
|
|
7
|
+
export interface CareerBankState {
|
|
8
|
+
banked: number;
|
|
9
|
+
haul: number;
|
|
10
|
+
deposited: number;
|
|
11
|
+
lost: number;
|
|
12
|
+
bestHaul: number;
|
|
13
|
+
runs: number;
|
|
14
|
+
}
|
|
15
|
+
/** Configuration for {@link createCareerBank}. */
|
|
16
|
+
export interface CareerBankConfig {
|
|
17
|
+
currency: CurrencyDefinition;
|
|
18
|
+
/** Persistent money before any run (default `0`). */
|
|
19
|
+
start?: number;
|
|
20
|
+
/** Most a single run's haul can carry; omitted means no cap. */
|
|
21
|
+
haulCap?: number;
|
|
22
|
+
/** Rehydrate from a prior {@link CareerBank.snapshot}. */
|
|
23
|
+
state?: CareerBankState;
|
|
24
|
+
}
|
|
25
|
+
/** Persistent career money plus the run in progress, which a failed run loses. */
|
|
26
|
+
export interface CareerBank {
|
|
27
|
+
currency(): CurrencyDefinition;
|
|
28
|
+
/** Money kept between runs. */
|
|
29
|
+
banked(): number;
|
|
30
|
+
/** Money earned this run and not yet banked or lost. */
|
|
31
|
+
haul(): number;
|
|
32
|
+
/** Add to the run's haul (capped by `haulCap`); negative or non-finite is ignored. Returns the haul. */
|
|
33
|
+
earn(amount: number): number;
|
|
34
|
+
/** Bank the haul: it becomes persistent and the run's haul resets. */
|
|
35
|
+
cashOut(): {
|
|
36
|
+
deposited: number;
|
|
37
|
+
banked: number;
|
|
38
|
+
};
|
|
39
|
+
/** The run failed: the haul is gone, persistent money is untouched. */
|
|
40
|
+
loseHaul(): {
|
|
41
|
+
lost: number;
|
|
42
|
+
banked: number;
|
|
43
|
+
};
|
|
44
|
+
/** Pay from persistent money; `false` when it cannot cover the amount. */
|
|
45
|
+
spend(amount: number): boolean;
|
|
46
|
+
afford(amount: number): boolean;
|
|
47
|
+
counters(): {
|
|
48
|
+
deposited: number;
|
|
49
|
+
lost: number;
|
|
50
|
+
bestHaul: number;
|
|
51
|
+
runs: number;
|
|
52
|
+
};
|
|
53
|
+
snapshot(): CareerBankState;
|
|
54
|
+
restore(next: CareerBankState): void;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Money that means something between runs: a persistent balance plus a run's haul that is banked on
|
|
58
|
+
* a cash-out and lost on a failure. Amounts are snapped to the currency's minor units, so a save
|
|
59
|
+
* round-trip and a display never drift. Pair it with `economy/upgrades` for a career upgrade tree.
|
|
60
|
+
*
|
|
61
|
+
* @capability career-bank persistent career money with a run haul that can be banked or lost
|
|
62
|
+
*/
|
|
63
|
+
export declare function createCareerBank(config: CareerBankConfig): CareerBank;
|