@jgengine/core 0.28.0 → 0.30.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 +31 -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 +58 -0
- package/dist/ai/director/pressure.js +70 -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/playableGame.d.ts +2 -2
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +30 -1
- package/dist/mission/mission.d.ts +1 -1
- package/dist/mission/mission.js +4 -1
- 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 +2 -2
- package/skills/jgengine-gameplay/SKILL.md +2 -0
- package/skills/jgengine-gameplay/api.md +89 -0
- package/skills/jgengine-gameplay/capabilities.md +34 -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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const DEFAULTS = {
|
|
2
|
+
maxInvestigators: 3,
|
|
3
|
+
giveUpConfidence: 0.15,
|
|
4
|
+
maxSeconds: 20,
|
|
5
|
+
maxNoises: 16,
|
|
6
|
+
};
|
|
7
|
+
function flatDistance(a, b) {
|
|
8
|
+
return Math.hypot(a[0] - b[0], a[2] - b[2]);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Make enemies investigate a noise, reusing the hearing owner.
|
|
12
|
+
*
|
|
13
|
+
* The noise goes into `PerceptionService.pushStimulus` as a `sound` stimulus, so the same range,
|
|
14
|
+
* loudness and linear memory decay that every other hearing consumer gets apply here; this layer
|
|
15
|
+
* adds only the two things a squad needs: a *bound* on how many enemies chase one noise, and a
|
|
16
|
+
* *give-up* rule, both read back from that memory instead of a second copy of it. A sighting of the
|
|
17
|
+
* source during the hunt raises the same memory's confidence, so an investigation escalates into a
|
|
18
|
+
* pursuit without any bookkeeping here.
|
|
19
|
+
*
|
|
20
|
+
* @capability sound-hunting send a bounded set of nearby enemies to investigate a noise with decaying memory
|
|
21
|
+
*/
|
|
22
|
+
export function createSoundHunt(config) {
|
|
23
|
+
const maxInvestigators = Math.max(0, Math.floor(config.maxInvestigators ?? DEFAULTS.maxInvestigators));
|
|
24
|
+
const giveUp = Math.max(0, config.giveUpConfidence ?? DEFAULTS.giveUpConfidence);
|
|
25
|
+
const maxSeconds = Math.max(0, config.maxSeconds ?? DEFAULTS.maxSeconds);
|
|
26
|
+
const maxNoises = Math.max(1, Math.floor(config.maxNoises ?? DEFAULTS.maxNoises));
|
|
27
|
+
let entries = [];
|
|
28
|
+
return {
|
|
29
|
+
noise(event) {
|
|
30
|
+
if (!Number.isFinite(event.at))
|
|
31
|
+
throw new Error("noise.at must be finite.");
|
|
32
|
+
const loudness = Math.max(0, event.loudness ?? 1);
|
|
33
|
+
const audible = config.hearingRange * loudness;
|
|
34
|
+
const ranked = [];
|
|
35
|
+
if (audible > 0) {
|
|
36
|
+
for (const hunter of config.hunters(event.position, audible)) {
|
|
37
|
+
if (hunter.id === event.sourceId)
|
|
38
|
+
continue;
|
|
39
|
+
if (flatDistance(hunter.position, event.position) >= audible)
|
|
40
|
+
continue;
|
|
41
|
+
ranked.push(hunter);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
ranked.sort((a, b) => {
|
|
45
|
+
const distance = flatDistance(a.position, event.position) - flatDistance(b.position, event.position);
|
|
46
|
+
if (Math.abs(distance) > 0.0001)
|
|
47
|
+
return distance;
|
|
48
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
49
|
+
});
|
|
50
|
+
const admitted = ranked.slice(0, maxInvestigators).map((hunter) => hunter.id);
|
|
51
|
+
config.perception.pushStimulus({
|
|
52
|
+
kind: "sound",
|
|
53
|
+
sourceId: event.sourceId,
|
|
54
|
+
position: [...event.position],
|
|
55
|
+
loudness,
|
|
56
|
+
at: event.at,
|
|
57
|
+
});
|
|
58
|
+
const admittedSet = new Set(admitted);
|
|
59
|
+
entries = entries
|
|
60
|
+
.filter((entry) => entry.noise.sourceId !== event.sourceId)
|
|
61
|
+
.map((entry) => ({ noise: entry.noise, hunters: entry.hunters.filter((id) => !admittedSet.has(id)) }))
|
|
62
|
+
.filter((entry) => entry.hunters.length > 0);
|
|
63
|
+
entries.push({
|
|
64
|
+
noise: { ...event, position: [...event.position], loudness },
|
|
65
|
+
hunters: admitted,
|
|
66
|
+
});
|
|
67
|
+
while (entries.length > maxNoises)
|
|
68
|
+
entries.shift();
|
|
69
|
+
return admitted;
|
|
70
|
+
},
|
|
71
|
+
investigations(nowMs) {
|
|
72
|
+
const results = [];
|
|
73
|
+
const kept = [];
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const ageSeconds = Math.max(0, nowMs - entry.noise.at) / 1000;
|
|
76
|
+
if (ageSeconds > maxSeconds)
|
|
77
|
+
continue;
|
|
78
|
+
const alive = [];
|
|
79
|
+
for (const hunterId of entry.hunters) {
|
|
80
|
+
const memory = config.perception.memoryOf(hunterId, entry.noise.sourceId);
|
|
81
|
+
if (memory === null || memory.confidence <= giveUp)
|
|
82
|
+
continue;
|
|
83
|
+
alive.push(hunterId);
|
|
84
|
+
results.push({
|
|
85
|
+
hunterId,
|
|
86
|
+
sourceId: entry.noise.sourceId,
|
|
87
|
+
position: [...memory.lastKnownPos],
|
|
88
|
+
confidence: memory.confidence,
|
|
89
|
+
ageSeconds,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (alive.length > 0)
|
|
93
|
+
kept.push({ noise: entry.noise, hunters: alive });
|
|
94
|
+
}
|
|
95
|
+
entries = kept;
|
|
96
|
+
return results;
|
|
97
|
+
},
|
|
98
|
+
snapshot() {
|
|
99
|
+
return {
|
|
100
|
+
entries: entries.map((entry) => ({
|
|
101
|
+
noise: { ...entry.noise, position: [...entry.noise.position] },
|
|
102
|
+
hunters: [...entry.hunters],
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
restore(snapshot) {
|
|
107
|
+
entries = snapshot.entries.map((entry) => ({
|
|
108
|
+
noise: { ...entry.noise, position: [...entry.noise.position] },
|
|
109
|
+
hunters: [...entry.hunters],
|
|
110
|
+
}));
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -729,7 +729,7 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
|
|
|
729
729
|
platforms?: readonly HudPlatform[];
|
|
730
730
|
/** HUD design resolution + scale clamps (default 1600×900, scale 0.4–1). `mobile` overrides tune the phone fit separately — the same resolution system drives desktop UI-scale and phone shrink. */
|
|
731
731
|
hudFit?: HudViewportConfig;
|
|
732
|
-
/** Opt in to world-space health bars floating over non-local entities that carry the stat. `roles` restricts bars to entities whose catalog entry declares one of the given roles; `maxDistance` hides bars beyond this many world units from the render camera (
|
|
732
|
+
/** Opt in to world-space health bars floating over non-local entities that carry the stat. `roles` restricts bars to entities whose catalog entry declares one of the given roles; `maxDistance` hides bars beyond this many world units from the render camera, and unset derives the range from the camera and the target height (an overhead camera reaches the units it looks at). */
|
|
733
733
|
worldHealthBars?: boolean | {
|
|
734
734
|
statId?: string;
|
|
735
735
|
roles?: readonly CatalogEntityRole[];
|
|
@@ -745,7 +745,7 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
|
|
|
745
745
|
* Opt in to billboarded nameplates (name + optional HP bar) floating over non-local entities — the
|
|
746
746
|
* MMO "who's this and how hurt are they" readout. `roles` restricts to entities whose catalog entry
|
|
747
747
|
* declares one of the given roles (default: all); `maxDistance` hides nameplates beyond this many
|
|
748
|
-
* world units from the render camera
|
|
748
|
+
* world units from the render camera, and unset derives the range the same way the bars do. Headless: skin every part via `className`/`data-*` hooks
|
|
749
749
|
* on `WorldNameplates` (`@jgengine/shell/world/WorldHud`) — this flag only turns the readout on and
|
|
750
750
|
* scopes which entities it covers. Set `showHealth: false` for a name-only plate when a game already
|
|
751
751
|
* draws the HP bar itself (its own HUD, or `worldHealthBars`) — otherwise the nameplate's built-in bar
|
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.30.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,36 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.30.0";
|
|
3
3
|
/** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
|
|
4
4
|
export const CHANGELOG = {
|
|
5
|
+
"0.30.0": {
|
|
6
|
+
migrate: [
|
|
7
|
+
"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`.",
|
|
8
|
+
],
|
|
9
|
+
added: [
|
|
10
|
+
"`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.",
|
|
11
|
+
"`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.",
|
|
12
|
+
"`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.",
|
|
13
|
+
"`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.",
|
|
14
|
+
"`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.",
|
|
15
|
+
"`createUpgradeTree` (`@jgengine/core/progression/upgrades`) prices each rank from the shared curve primitive and gates purchases on prerequisites and standing.",
|
|
16
|
+
],
|
|
17
|
+
changed: [
|
|
18
|
+
"`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>)`.",
|
|
19
|
+
"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.",
|
|
20
|
+
"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.",
|
|
21
|
+
],
|
|
22
|
+
removed: [],
|
|
23
|
+
},
|
|
24
|
+
"0.29.0": {
|
|
25
|
+
migrate: [
|
|
26
|
+
"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`.",
|
|
27
|
+
],
|
|
28
|
+
added: [],
|
|
29
|
+
changed: [
|
|
30
|
+
"`MissionView.remaining` (`@jgengine/core/gameplay`) now counts down from a `survive` objective's own `duration`, not only from a `timeout`. A defend or hold objective reports the seconds it still has to run, which is the one number its HUD needs; a completed run still reports null.",
|
|
31
|
+
],
|
|
32
|
+
removed: [],
|
|
33
|
+
},
|
|
5
34
|
"0.28.0": {
|
|
6
35
|
migrate: [
|
|
7
36
|
"Bump lockstep SDK packages to `^0.28.0`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.25.0`; `@jgengine/github` is `0.15.0`.",
|
|
@@ -281,7 +281,7 @@ export interface MissionView {
|
|
|
281
281
|
objectiveId: string | null;
|
|
282
282
|
label: string;
|
|
283
283
|
objectiveElapsed: number;
|
|
284
|
-
/** Seconds left on the current objective's timer, or null when it has none. */
|
|
284
|
+
/** Seconds left on the current objective's timer (a `timeout` or a `survive` duration), or null when it has none. */
|
|
285
285
|
remaining: number | null;
|
|
286
286
|
/** Counter fraction 0..1 for the current objective, or 0 when it is not a counter. */
|
|
287
287
|
progress: number;
|
package/dist/mission/mission.js
CHANGED
|
@@ -561,7 +561,10 @@ export function createMissionRuntime(config) {
|
|
|
561
561
|
readout.objectiveId = state.objective;
|
|
562
562
|
readout.label = objective === null ? "" : objectiveLabel(objective);
|
|
563
563
|
readout.objectiveElapsed = state.objectiveElapsed;
|
|
564
|
-
|
|
564
|
+
const timer = objective === null
|
|
565
|
+
? undefined
|
|
566
|
+
: objective.timeout ?? (objective.kind === "survive" ? objective.duration : undefined);
|
|
567
|
+
readout.remaining = timer === undefined ? null : Math.max(0, timer - state.objectiveElapsed);
|
|
565
568
|
const counted = objective === null || !isCounterObjective(objective.kind)
|
|
566
569
|
? 0
|
|
567
570
|
: (state.progress[counterKey(objective)] ?? 0);
|
|
@@ -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;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { fromMinorUnits, toMinorUnits } from "../economy/currency.js";
|
|
2
|
+
import { charge, createEmptyWallet, grant } from "../economy/wallet.js";
|
|
3
|
+
function sanitizeAmount(value) {
|
|
4
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Money that means something between runs: a persistent balance plus a run's haul that is banked on
|
|
8
|
+
* a cash-out and lost on a failure. Amounts are snapped to the currency's minor units, so a save
|
|
9
|
+
* round-trip and a display never drift. Pair it with `economy/upgrades` for a career upgrade tree.
|
|
10
|
+
*
|
|
11
|
+
* @capability career-bank persistent career money with a run haul that can be banked or lost
|
|
12
|
+
*/
|
|
13
|
+
export function createCareerBank(config) {
|
|
14
|
+
const currency = config.currency;
|
|
15
|
+
const snap = (amount) => fromMinorUnits(currency, toMinorUnits(currency, sanitizeAmount(amount)));
|
|
16
|
+
const haulCap = config.haulCap === undefined ? Number.POSITIVE_INFINITY : snap(config.haulCap);
|
|
17
|
+
const walletOf = (bankedAmount) => bankedAmount > 0 ? grant(createEmptyWallet(), currency, bankedAmount) : createEmptyWallet();
|
|
18
|
+
let wallet = walletOf(snap(config.start ?? 0));
|
|
19
|
+
let haul = 0;
|
|
20
|
+
let deposited = 0;
|
|
21
|
+
let lost = 0;
|
|
22
|
+
let bestHaul = 0;
|
|
23
|
+
let runs = 0;
|
|
24
|
+
const banked = () => wallet.balances[currency.id] ?? 0;
|
|
25
|
+
if (config.state !== undefined) {
|
|
26
|
+
wallet = walletOf(snap(config.state.banked));
|
|
27
|
+
haul = snap(config.state.haul);
|
|
28
|
+
deposited = snap(config.state.deposited);
|
|
29
|
+
lost = snap(config.state.lost);
|
|
30
|
+
bestHaul = snap(config.state.bestHaul);
|
|
31
|
+
runs = Number.isInteger(config.state.runs) && config.state.runs > 0 ? config.state.runs : 0;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
currency: () => currency,
|
|
35
|
+
banked,
|
|
36
|
+
haul: () => haul,
|
|
37
|
+
earn(amount) {
|
|
38
|
+
const added = snap(amount);
|
|
39
|
+
if (added > 0)
|
|
40
|
+
haul = snap(Math.min(haul + added, haulCap));
|
|
41
|
+
return haul;
|
|
42
|
+
},
|
|
43
|
+
cashOut() {
|
|
44
|
+
const held = haul;
|
|
45
|
+
if (held > 0) {
|
|
46
|
+
wallet = grant(wallet, currency, held);
|
|
47
|
+
deposited = snap(deposited + held);
|
|
48
|
+
bestHaul = Math.max(bestHaul, held);
|
|
49
|
+
}
|
|
50
|
+
haul = 0;
|
|
51
|
+
runs += 1;
|
|
52
|
+
return { deposited: held, banked: banked() };
|
|
53
|
+
},
|
|
54
|
+
loseHaul() {
|
|
55
|
+
const gone = haul;
|
|
56
|
+
haul = 0;
|
|
57
|
+
lost = snap(lost + gone);
|
|
58
|
+
runs += 1;
|
|
59
|
+
return { lost: gone, banked: banked() };
|
|
60
|
+
},
|
|
61
|
+
spend(amount) {
|
|
62
|
+
const cost = snap(amount);
|
|
63
|
+
if (cost <= 0)
|
|
64
|
+
return false;
|
|
65
|
+
const paid = charge(wallet, currency, cost);
|
|
66
|
+
if (paid.status !== "ok")
|
|
67
|
+
return false;
|
|
68
|
+
wallet = paid.state;
|
|
69
|
+
return true;
|
|
70
|
+
},
|
|
71
|
+
afford: (amount) => banked() >= snap(amount),
|
|
72
|
+
counters: () => ({ deposited, lost, bestHaul, runs }),
|
|
73
|
+
snapshot: () => ({ banked: banked(), haul, deposited, lost, bestHaul, runs }),
|
|
74
|
+
restore(next) {
|
|
75
|
+
wallet = walletOf(snap(next.banked));
|
|
76
|
+
haul = snap(next.haul);
|
|
77
|
+
deposited = snap(next.deposited);
|
|
78
|
+
lost = snap(next.lost);
|
|
79
|
+
bestHaul = snap(next.bestHaul);
|
|
80
|
+
runs = Number.isInteger(next.runs) && next.runs > 0 ? next.runs : 0;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { UnlockCatalog } from "../game/unlocks.js";
|
|
2
|
+
import type { Standing, StandingRequirement } from "./standing.js";
|
|
3
|
+
/** What a gate is evaluated against: standing by track and money by currency. */
|
|
4
|
+
export interface GateFacts {
|
|
5
|
+
standing(trackId: string): number;
|
|
6
|
+
balance(currency: string): number;
|
|
7
|
+
}
|
|
8
|
+
/** Every condition a gate can require; all named conditions must hold. */
|
|
9
|
+
export interface GateRequirement {
|
|
10
|
+
standing?: StandingRequirement;
|
|
11
|
+
/** Smallest balance of `currency` that passes. */
|
|
12
|
+
balance?: {
|
|
13
|
+
currency: string;
|
|
14
|
+
min: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A milestone: content that opens exactly when its requirements hold and names the unlock it
|
|
19
|
+
* grants. `unlock` must resolve in the catalog the set is built with, or the set refuses to load.
|
|
20
|
+
*/
|
|
21
|
+
export interface GateDef {
|
|
22
|
+
id: string;
|
|
23
|
+
unlock: string;
|
|
24
|
+
label?: string;
|
|
25
|
+
category?: string;
|
|
26
|
+
requires?: GateRequirement;
|
|
27
|
+
}
|
|
28
|
+
/** Which named condition a closed gate still needs. */
|
|
29
|
+
export type GateMiss = "standing" | "balance";
|
|
30
|
+
/** One gate against live facts. */
|
|
31
|
+
export interface GateStatus {
|
|
32
|
+
gate: GateDef;
|
|
33
|
+
open: boolean;
|
|
34
|
+
missing: readonly GateMiss[];
|
|
35
|
+
}
|
|
36
|
+
/** Evaluated gate content: the table, its status against facts, and the grants a caller applies. */
|
|
37
|
+
export interface GateSet {
|
|
38
|
+
gates(): readonly GateDef[];
|
|
39
|
+
gate(gateId: string): GateDef | null;
|
|
40
|
+
status(facts: GateFacts): readonly GateStatus[];
|
|
41
|
+
isOpen(gateId: string, facts: GateFacts): boolean;
|
|
42
|
+
open(facts: GateFacts): readonly GateDef[];
|
|
43
|
+
/** Gates met now whose `unlock` is not in `granted` — the unlocks to grant this pass. */
|
|
44
|
+
claim(facts: GateFacts, granted: readonly string[]): readonly GateDef[];
|
|
45
|
+
}
|
|
46
|
+
/** Content and the resolvers a {@link GateSet} is built from. */
|
|
47
|
+
export interface GateSetConfig {
|
|
48
|
+
gates: readonly GateDef[];
|
|
49
|
+
/** The ids an unlock may resolve to; normally the game's content catalog. */
|
|
50
|
+
catalog: Pick<UnlockCatalog, "has">;
|
|
51
|
+
/** The standing a requirement reads; also validates every requirement's track at load. */
|
|
52
|
+
standing?: Pick<Standing, "hasTrack">;
|
|
53
|
+
}
|
|
54
|
+
/** Read a {@link Standing} and a balance record as {@link GateFacts}. */
|
|
55
|
+
export declare function gateFacts(standing: Pick<Standing, "standing">, actorId: string, balances?: Readonly<Record<string, number>>): GateFacts;
|
|
56
|
+
/** Check an authored gate table against the content it grants; throws `RangeError` naming the gate. */
|
|
57
|
+
export declare function assertGates(config: GateSetConfig): void;
|
|
58
|
+
/**
|
|
59
|
+
* Authored unlock gates: each one names the unlock it grants and the standing or balance it reads.
|
|
60
|
+
* Evaluated against caller-supplied facts, so one table serves a single-player save, a server actor
|
|
61
|
+
* or a probe. Load refuses a gate whose unlock id resolves to nothing (the D3.8 class), so a dead
|
|
62
|
+
* milestone fails at startup instead of in play. A gate with no requirements is open from the start.
|
|
63
|
+
*
|
|
64
|
+
* @capability unlock-gates milestones gated by standing or money, validated against the content they grant
|
|
65
|
+
*/
|
|
66
|
+
export declare function createGateSet(config: GateSetConfig): GateSet;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Read a {@link Standing} and a balance record as {@link GateFacts}. */
|
|
2
|
+
export function gateFacts(standing, actorId, balances = {}) {
|
|
3
|
+
return {
|
|
4
|
+
standing: (trackId) => standing.standing(actorId, trackId),
|
|
5
|
+
balance: (currency) => balances[currency] ?? 0,
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
function check(condition, message) {
|
|
9
|
+
if (!condition)
|
|
10
|
+
throw new RangeError(message);
|
|
11
|
+
}
|
|
12
|
+
/** Check an authored gate table against the content it grants; throws `RangeError` naming the gate. */
|
|
13
|
+
export function assertGates(config) {
|
|
14
|
+
const ids = new Set();
|
|
15
|
+
for (const gate of config.gates) {
|
|
16
|
+
const id = gate.id.trim();
|
|
17
|
+
check(id !== "", "Gate id must not be empty");
|
|
18
|
+
check(!ids.has(id), `Gate "${id}" is declared twice`);
|
|
19
|
+
ids.add(id);
|
|
20
|
+
check(gate.unlock.trim() !== "", `Gate "${id}" names an empty unlock`);
|
|
21
|
+
check(config.catalog.has(gate.unlock), `Gate "${id}" unlocks unknown id "${gate.unlock}" - the reward resolves to nothing`);
|
|
22
|
+
const standing = gate.requires?.standing;
|
|
23
|
+
if (standing !== undefined) {
|
|
24
|
+
check(standing.trackId.trim() !== "", `Gate "${id}" names an empty standing track`);
|
|
25
|
+
check(config.standing === undefined || config.standing.hasTrack(standing.trackId), `Gate "${id}" reads unknown standing track "${standing.trackId}"`);
|
|
26
|
+
if (standing.min !== undefined)
|
|
27
|
+
check(Number.isFinite(standing.min), `Gate "${id}" standing min must be finite`);
|
|
28
|
+
if (standing.max !== undefined)
|
|
29
|
+
check(Number.isFinite(standing.max), `Gate "${id}" standing max must be finite`);
|
|
30
|
+
check(standing.min === undefined || standing.max === undefined || standing.min <= standing.max, `Gate "${id}" needs standing min <= max`);
|
|
31
|
+
}
|
|
32
|
+
const balance = gate.requires?.balance;
|
|
33
|
+
if (balance !== undefined) {
|
|
34
|
+
check(balance.currency.trim() !== "", `Gate "${id}" names an empty currency`);
|
|
35
|
+
check(Number.isFinite(balance.min) && balance.min >= 0, `Gate "${id}" balance min must be finite and >= 0`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function missingFor(gate, facts) {
|
|
40
|
+
const missing = [];
|
|
41
|
+
const standing = gate.requires?.standing;
|
|
42
|
+
if (standing !== undefined) {
|
|
43
|
+
const value = facts.standing(standing.trackId);
|
|
44
|
+
if ((standing.min !== undefined && value < standing.min) || (standing.max !== undefined && value > standing.max)) {
|
|
45
|
+
missing.push("standing");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const balance = gate.requires?.balance;
|
|
49
|
+
if (balance !== undefined && facts.balance(balance.currency) < balance.min)
|
|
50
|
+
missing.push("balance");
|
|
51
|
+
return missing;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Authored unlock gates: each one names the unlock it grants and the standing or balance it reads.
|
|
55
|
+
* Evaluated against caller-supplied facts, so one table serves a single-player save, a server actor
|
|
56
|
+
* or a probe. Load refuses a gate whose unlock id resolves to nothing (the D3.8 class), so a dead
|
|
57
|
+
* milestone fails at startup instead of in play. A gate with no requirements is open from the start.
|
|
58
|
+
*
|
|
59
|
+
* @capability unlock-gates milestones gated by standing or money, validated against the content they grant
|
|
60
|
+
*/
|
|
61
|
+
export function createGateSet(config) {
|
|
62
|
+
assertGates(config);
|
|
63
|
+
const definitions = config.gates.slice();
|
|
64
|
+
function statusOf(gate, facts) {
|
|
65
|
+
const missing = missingFor(gate, facts);
|
|
66
|
+
return { gate, open: missing.length === 0, missing };
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
gates: () => definitions.slice(),
|
|
70
|
+
gate: (gateId) => definitions.find((gate) => gate.id === gateId) ?? null,
|
|
71
|
+
status: (facts) => definitions.map((gate) => statusOf(gate, facts)),
|
|
72
|
+
isOpen(gateId, facts) {
|
|
73
|
+
const gate = definitions.find((entry) => entry.id === gateId);
|
|
74
|
+
return gate === undefined ? false : missingFor(gate, facts).length === 0;
|
|
75
|
+
},
|
|
76
|
+
open: (facts) => definitions.filter((gate) => missingFor(gate, facts).length === 0),
|
|
77
|
+
claim(facts, granted) {
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const gate of definitions) {
|
|
80
|
+
if (granted.includes(gate.unlock))
|
|
81
|
+
continue;
|
|
82
|
+
if (missingFor(gate, facts).length === 0)
|
|
83
|
+
out.push(gate);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type ReputationTier } from "../faction/reputation.js";
|
|
2
|
+
/** One standing track: a faction, a district, a licence board — whatever an authored outcome moves. */
|
|
3
|
+
export interface StandingTrackDef {
|
|
4
|
+
id: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
/** Tiers this track crosses, ascending by `min`; defaults to the shared reputation ladder. */
|
|
7
|
+
tiers?: readonly ReputationTier[];
|
|
8
|
+
/** Standing every actor starts at (default `0`). */
|
|
9
|
+
initial?: number;
|
|
10
|
+
/** Floor and ceiling; omitted means unbounded. */
|
|
11
|
+
min?: number;
|
|
12
|
+
max?: number;
|
|
13
|
+
}
|
|
14
|
+
/** One outcome a game can report, and the standing it moves on each track. */
|
|
15
|
+
export interface StandingOutcomeDef {
|
|
16
|
+
id: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
/** Track id → signed standing. A negative number is the fall. */
|
|
19
|
+
impacts: Readonly<Record<string, number>>;
|
|
20
|
+
}
|
|
21
|
+
/** Authored standing content: the tracks an actor stands with, and the outcomes that move them. */
|
|
22
|
+
export interface StandingConfig {
|
|
23
|
+
tracks: readonly StandingTrackDef[];
|
|
24
|
+
outcomes?: readonly StandingOutcomeDef[];
|
|
25
|
+
}
|
|
26
|
+
/** One track's movement, with the tier on both sides so a caller can announce a crossing. */
|
|
27
|
+
export interface StandingChange {
|
|
28
|
+
trackId: string;
|
|
29
|
+
before: number;
|
|
30
|
+
after: number;
|
|
31
|
+
delta: number;
|
|
32
|
+
previousTier: string;
|
|
33
|
+
tier: string;
|
|
34
|
+
}
|
|
35
|
+
/** A threshold on one track, as an unlock or an upgrade reads it. */
|
|
36
|
+
export interface StandingRequirement {
|
|
37
|
+
trackId: string;
|
|
38
|
+
/** Smallest standing that passes; omitted means no floor. */
|
|
39
|
+
min?: number;
|
|
40
|
+
/** Largest standing that passes; omitted means no ceiling. */
|
|
41
|
+
max?: number;
|
|
42
|
+
}
|
|
43
|
+
/** Plain JSON state of a {@link Standing}; tracks left at their authored `initial` are absent. */
|
|
44
|
+
export interface StandingState {
|
|
45
|
+
records: Record<string, Record<string, number>>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Live standing over authored tracks, moved by named outcomes or direct adjustments, with the
|
|
49
|
+
* tier on each track and a serializable snapshot.
|
|
50
|
+
*/
|
|
51
|
+
export interface Standing {
|
|
52
|
+
tracks(): readonly StandingTrackDef[];
|
|
53
|
+
hasTrack(trackId: string): boolean;
|
|
54
|
+
hasOutcome(outcomeId: string): boolean;
|
|
55
|
+
standing(actorId: string, trackId: string): number;
|
|
56
|
+
tier(actorId: string, trackId: string): ReputationTier;
|
|
57
|
+
/** Every track's standing for one actor, authored tracks included at their initial value. */
|
|
58
|
+
standings(actorId: string): Record<string, number>;
|
|
59
|
+
/** Apply an authored outcome; returns each track it moved. Throws on an unknown outcome id. */
|
|
60
|
+
apply(actorId: string, outcomeId: string): readonly StandingChange[];
|
|
61
|
+
/** Move one track directly; returns the change. Throws on an unknown track id. */
|
|
62
|
+
adjust(actorId: string, trackId: string, delta: number): StandingChange;
|
|
63
|
+
meets(actorId: string, requirement: StandingRequirement): boolean;
|
|
64
|
+
snapshot(): StandingState;
|
|
65
|
+
restore(next: StandingState): void;
|
|
66
|
+
}
|
|
67
|
+
/** Check authored standing content before use; throws `RangeError` naming the offending field. */
|
|
68
|
+
export declare function assertStandingConfig(config: StandingConfig): void;
|
|
69
|
+
/**
|
|
70
|
+
* Per-faction or per-district standing that rises and falls from named outcomes, over authored
|
|
71
|
+
* tracks. Numbers live in the shared reputation ledger (`faction/reputation`); this adds the
|
|
72
|
+
* authored outcome table, tier-per-track and the one snapshot a save stores. An outcome id or
|
|
73
|
+
* track id that resolves to nothing throws at the call, so authored content fails loudly.
|
|
74
|
+
*
|
|
75
|
+
* @capability standing per-track standing moved by authored outcomes, with tiers and one snapshot
|
|
76
|
+
*/
|
|
77
|
+
export declare function createStanding(config: StandingConfig): Standing;
|