@jgengine/core 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/dist/ai/director/pressure.d.ts +10 -9
- package/dist/ai/director/pressure.js +9 -8
- package/dist/audio/feedbackCues.d.ts +30 -0
- package/dist/audio/feedbackCues.js +119 -0
- package/dist/game/events.d.ts +11 -0
- package/dist/game/gameSave.d.ts +6 -0
- package/dist/game/gameSave.js +6 -0
- package/dist/game/playableGame.d.ts +3 -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 +45 -1
- package/dist/mission/mission.d.ts +24 -0
- package/dist/mission/mission.js +28 -3
- package/dist/runtime/gameContext.js +3 -0
- package/dist/runtime/gameContextTypes.d.ts +16 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +4 -3
- package/skills/jgengine-gameplay/SKILL.md +10 -1
- package/skills/jgengine-gameplay/api.md +53 -3
- package/skills/jgengine-gameplay/capabilities.md +40 -0
- package/skills/jgengine-world/api.md +14 -4
- package/skills/jgengine-world/capabilities.md +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,57 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
26
26
|
`packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
|
|
27
27
|
-->
|
|
28
28
|
|
|
29
|
+
## 0.32.0
|
|
30
|
+
|
|
31
|
+
### Migrate
|
|
32
|
+
|
|
33
|
+
- **Bump lockstep SDK packages to `^0.32.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.29.0`; `@jgengine/github` is `0.19.0`.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- Audio mixing grew a mute that composes with gain: `AudioEngine.setBusMute` / `setMasterMute` keep the player's volume while silencing a bus, `masterLevel()` / `busLevel()` report the composed value, and Settings carries a mute row per bus plus "Mute all" (`sound.muted`, `sound.bus.<id>.muted`). `AudioEngine.play` and `playOneShot` hold cues in a bounded 16-slot queue while the browser keeps the context suspended and play them on the first gesture instead of dropping them; `installGestureResume` wires that gesture for both presentation paths.
|
|
38
|
+
- `AudioEngine.measure()` reads master RMS and peak from a lazily built analyser, and `installAudioProbe` publishes `window.__jgengineAudio` so a headless capture can prove sound with a measured level rather than a screenshot.
|
|
39
|
+
- `@jgengine/core/audio/feedbackCues` ships seven procedural reactions - `hit`, `hurt`, `kill`, `pickup`, `objective`, `wave`, `deny` - on an `sfx` bus, merged into every game's catalog as `feel.<cue>`. A game sound with that id replaces one; `audio.feedbackCues` replaces or silences them.
|
|
40
|
+
- `@jgengine/shell/audio/eventFeedback` maps the events the engine already announces to those reactions: `combat.damage` (local player reads as hurt, anything else as a hit), `entity.died`, `worldItem.picked_up`, `quest.completed`, `stat.levelUp` and `command.refused`. Each row carries a sound, a gamepad pulse, camera trauma and a caption; the mapping and reactions are data (`DEFAULT_FEEDBACK_BINDINGS`, `DEFAULT_FEEDBACK_REACTIONS`) a game can replace, cooldowns fold a burst into one impact, reduced motion keeps sound and rumble but drops the camera kick, and the resolved reaction is pooled so handling an event allocates nothing per frame. `attachWaveFeedback(runner, ctx.game.feedback)` announces each new wave.
|
|
41
|
+
- `ctx.game.feedback.cue(cue, caption?)` announces a moment no engine event covers and emits `feedback.cue`.
|
|
42
|
+
- `@jgengine/shell/audio/captions` and `CaptionOverlay` render a bounded, deduping, self-expiring caption line under the accessibility captions setting; `FeedbackHost` mounts the whole layer - feedback mapping, gesture resume, audio probe and captions - for both the 3D and HUD presentation paths.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- The CI gate runs nine test shards instead of ten, and a `types` job whose stages overlap, so a docs-only pull request fits the account's 20-job runner cap with room for another run beside it. `scripts/test-shard.ts` owns which files each shard runs and what it prepares first; `bun scripts/type-shard.ts` runs the `check-types` plan two stages at a time and then the tsc fan-out alone.
|
|
47
|
+
|
|
48
|
+
## 0.31.0
|
|
49
|
+
|
|
50
|
+
### Migrate
|
|
51
|
+
|
|
52
|
+
- **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`.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- `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.
|
|
57
|
+
- `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.
|
|
58
|
+
- `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.
|
|
59
|
+
- `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`.
|
|
60
|
+
- `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.
|
|
61
|
+
- `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.
|
|
62
|
+
- `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.
|
|
63
|
+
- `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.
|
|
64
|
+
- `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`.
|
|
65
|
+
- `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.
|
|
66
|
+
- `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.
|
|
67
|
+
- `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.
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- `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`.
|
|
72
|
+
- 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.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- `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.
|
|
77
|
+
- The HUD boundary check returned early whenever nothing overflowed, so a later report in the same pass could never run.
|
|
78
|
+
- `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.
|
|
79
|
+
|
|
29
80
|
## 0.30.0
|
|
30
81
|
|
|
31
82
|
### Migrate
|
|
@@ -5,18 +5,18 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/** What the director reads about the player before it chooses a tier. */
|
|
7
7
|
export interface PressureReading {
|
|
8
|
-
/** Player health fraction `0..1`.
|
|
8
|
+
/** Player health fraction `0..1`. A healthier player is leaned on harder. */
|
|
9
9
|
health: number;
|
|
10
|
-
/** Encounter progress `0..1` — waves cleared over waves authored.
|
|
10
|
+
/** Encounter progress `0..1` — waves cleared over waves authored. Further in leans harder. */
|
|
11
11
|
progress: number;
|
|
12
12
|
/** Seconds the encounter has run. Longer leans harder, up to the window. */
|
|
13
13
|
elapsed: number;
|
|
14
14
|
}
|
|
15
15
|
/** The weights and window that turn a reading into an index, plus the hard cap. */
|
|
16
16
|
export interface PressureRule {
|
|
17
|
-
/** Weight of
|
|
17
|
+
/** Weight of the player staying in control (health). Default `0.5`. */
|
|
18
18
|
healthWeight?: number;
|
|
19
|
-
/** Weight of the encounter
|
|
19
|
+
/** Weight of how far through the encounter the player is. Default `1`. */
|
|
20
20
|
progressWeight?: number;
|
|
21
21
|
/** Weight of time spent past `timeFloorSeconds`. Default `0.5`. */
|
|
22
22
|
timeWeight?: number;
|
|
@@ -47,11 +47,12 @@ export interface PressurePlan {
|
|
|
47
47
|
/**
|
|
48
48
|
* Turn one reading into a bounded pressure plan.
|
|
49
49
|
*
|
|
50
|
-
* The index is the weighted mean of three terms —
|
|
51
|
-
*
|
|
52
|
-
* `
|
|
53
|
-
*
|
|
54
|
-
*
|
|
50
|
+
* The index is the weighted mean of three terms — the player's health (staying in control), how far
|
|
51
|
+
* through the encounter they are, and how long it has run — each clamped to `0..1`, then split into
|
|
52
|
+
* `maxTier + 1` bands. A hurt player is leaned on *less*, so a director that reads this plan never
|
|
53
|
+
* piles on someone already losing. Because every term and every derived effect is a clamp of an
|
|
54
|
+
* authored cap, the rule cannot climb: a full-health player on a long run lands on
|
|
55
|
+
* `tier === maxTier` with `countBonus === maxTier`, never above it.
|
|
55
56
|
*
|
|
56
57
|
* @capability encounter-pressure bounded difficulty tier read from player health, encounter progress and time
|
|
57
58
|
*/
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* a callback, so a test can sweep it and a probe can print it.
|
|
5
5
|
*/
|
|
6
6
|
const RULE_DEFAULTS = {
|
|
7
|
-
healthWeight:
|
|
7
|
+
healthWeight: 0.5,
|
|
8
8
|
progressWeight: 1,
|
|
9
9
|
timeWeight: 0.5,
|
|
10
10
|
timeFloorSeconds: 90,
|
|
@@ -35,11 +35,12 @@ function timeTerm(elapsed, rule) {
|
|
|
35
35
|
/**
|
|
36
36
|
* Turn one reading into a bounded pressure plan.
|
|
37
37
|
*
|
|
38
|
-
* The index is the weighted mean of three terms —
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
42
|
-
*
|
|
38
|
+
* The index is the weighted mean of three terms — the player's health (staying in control), how far
|
|
39
|
+
* through the encounter they are, and how long it has run — each clamped to `0..1`, then split into
|
|
40
|
+
* `maxTier + 1` bands. A hurt player is leaned on *less*, so a director that reads this plan never
|
|
41
|
+
* piles on someone already losing. Because every term and every derived effect is a clamp of an
|
|
42
|
+
* authored cap, the rule cannot climb: a full-health player on a long run lands on
|
|
43
|
+
* `tier === maxTier` with `countBonus === maxTier`, never above it.
|
|
43
44
|
*
|
|
44
45
|
* @capability encounter-pressure bounded difficulty tier read from player health, encounter progress and time
|
|
45
46
|
*/
|
|
@@ -52,8 +53,8 @@ export function evaluatePressure(reading, rule = {}) {
|
|
|
52
53
|
const totalWeight = resolved.healthWeight + resolved.progressWeight + resolved.timeWeight;
|
|
53
54
|
const index = totalWeight <= 0
|
|
54
55
|
? 0
|
|
55
|
-
: clamp01((resolved.healthWeight *
|
|
56
|
-
resolved.progressWeight *
|
|
56
|
+
: clamp01((resolved.healthWeight * health +
|
|
57
|
+
resolved.progressWeight * progress +
|
|
57
58
|
resolved.timeWeight * time) /
|
|
58
59
|
totalWeight);
|
|
59
60
|
const tier = Math.min(resolved.maxTier, Math.max(0, Math.floor(index * (resolved.maxTier + 1))));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AudioBusDef, SoundDef } from "./audioFalloff.js";
|
|
2
|
+
/**
|
|
3
|
+
* A gameplay moment the engine reacts to: taking or dealing a hit, a kill, a pickup, an objective,
|
|
4
|
+
* a wave starting, or a refused action. The game names the moment; the engine owns the reaction.
|
|
5
|
+
*/
|
|
6
|
+
export type FeedbackCue = "hit" | "hurt" | "kill" | "pickup" | "objective" | "wave" | "deny";
|
|
7
|
+
/** Every {@link FeedbackCue}, in a stable order. */
|
|
8
|
+
export declare const FEEDBACK_CUES: readonly FeedbackCue[];
|
|
9
|
+
/** Bus the default feedback cues mix through; the settings menu shows its volume and mute. */
|
|
10
|
+
export declare const FEEDBACK_CUE_BUS = "sfx";
|
|
11
|
+
/** Catalog id a cue plays under: `feel.hit`, `feel.kill`, ... A game sound with that id replaces the default. */
|
|
12
|
+
export declare function feedbackCueSoundId(cue: FeedbackCue): string;
|
|
13
|
+
/**
|
|
14
|
+
* Short procedural reactions - no samples to load, so every game has impact feedback without
|
|
15
|
+
* authoring audio. Loud enough to read, quiet enough to sit under a weapon's own sound.
|
|
16
|
+
*/
|
|
17
|
+
export declare const DEFAULT_FEEDBACK_CUE_SOUNDS: Readonly<Record<FeedbackCue, SoundDef>>;
|
|
18
|
+
/** Per-cue override: a replacement sound, or `false` to silence that cue. `false` for the whole map silences all. */
|
|
19
|
+
export type FeedbackCueOverrides = false | Partial<Record<FeedbackCue, SoundDef | false>>;
|
|
20
|
+
/**
|
|
21
|
+
* Merge the default feedback reactions into a game's sound catalog. A game sound already named
|
|
22
|
+
* `feel.<cue>` wins, then `overrides`, then the default. Adds the `sfx` bus (gain 0.9) when the game
|
|
23
|
+
* did not declare one, so the impact layer has its own volume and mute row.
|
|
24
|
+
*
|
|
25
|
+
* @capability feedback-cue-sounds default procedural hit/hurt/kill/pickup/objective/wave/deny reactions on an "sfx" bus, overridable per game
|
|
26
|
+
*/
|
|
27
|
+
export declare function withFeedbackCueSounds(sounds: Readonly<Record<string, SoundDef>> | undefined, buses: Readonly<Record<string, AudioBusDef>> | undefined, overrides?: FeedbackCueOverrides): {
|
|
28
|
+
sounds: Record<string, SoundDef>;
|
|
29
|
+
buses: Record<string, AudioBusDef> | undefined;
|
|
30
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** Every {@link FeedbackCue}, in a stable order. */
|
|
2
|
+
export const FEEDBACK_CUES = ["hit", "hurt", "kill", "pickup", "objective", "wave", "deny"];
|
|
3
|
+
/** Bus the default feedback cues mix through; the settings menu shows its volume and mute. */
|
|
4
|
+
export const FEEDBACK_CUE_BUS = "sfx";
|
|
5
|
+
/** Catalog id a cue plays under: `feel.hit`, `feel.kill`, ... A game sound with that id replaces the default. */
|
|
6
|
+
export function feedbackCueSoundId(cue) {
|
|
7
|
+
return `feel.${cue}`;
|
|
8
|
+
}
|
|
9
|
+
function feelSound(cue, def) {
|
|
10
|
+
return { id: feedbackCueSoundId(cue), bus: FEEDBACK_CUE_BUS, positional: false, priority: 5, ...def };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Short procedural reactions - no samples to load, so every game has impact feedback without
|
|
14
|
+
* authoring audio. Loud enough to read, quiet enough to sit under a weapon's own sound.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_FEEDBACK_CUE_SOUNDS = {
|
|
17
|
+
hit: feelSound("hit", {
|
|
18
|
+
gain: 0.55,
|
|
19
|
+
maxVoices: 4,
|
|
20
|
+
synth: {
|
|
21
|
+
voices: [
|
|
22
|
+
{ kind: "noise", filterType: "bandpass", filterFreq: 1800, duration: 0.05, gain: 0.3 },
|
|
23
|
+
{ kind: "tone", wave: "triangle", freq: 210, slideTo: 150, duration: 0.07, gain: 0.24, attack: 0.002 },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
hurt: feelSound("hurt", {
|
|
28
|
+
gain: 0.65,
|
|
29
|
+
maxVoices: 2,
|
|
30
|
+
synth: {
|
|
31
|
+
voices: [
|
|
32
|
+
{ kind: "tone", wave: "triangle", freq: 165, slideTo: 110, duration: 0.16, gain: 0.32, attack: 0.003 },
|
|
33
|
+
{ kind: "noise", filterType: "lowpass", filterFreq: 900, duration: 0.1, gain: 0.14 },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
kill: feelSound("kill", {
|
|
38
|
+
gain: 0.6,
|
|
39
|
+
maxVoices: 2,
|
|
40
|
+
synth: {
|
|
41
|
+
voices: [
|
|
42
|
+
{ kind: "noise", filterType: "lowpass", filterFreq: 1400, duration: 0.12, gain: 0.24 },
|
|
43
|
+
{ kind: "tone", wave: "sawtooth", freq: 330, slideTo: 220, duration: 0.14, gain: 0.18 },
|
|
44
|
+
{ kind: "tone", wave: "sine", freq: 110, slideTo: 78, duration: 0.2, gain: 0.22, delay: 0.03 },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
pickup: feelSound("pickup", {
|
|
49
|
+
gain: 0.5,
|
|
50
|
+
maxVoices: 3,
|
|
51
|
+
synth: {
|
|
52
|
+
voices: [
|
|
53
|
+
{ kind: "tone", wave: "sine", freq: 880, duration: 0.09, gain: 0.2, attack: 0.003 },
|
|
54
|
+
{ kind: "tone", wave: "sine", freq: 1320, duration: 0.11, gain: 0.16, delay: 0.05, attack: 0.003 },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
objective: feelSound("objective", {
|
|
59
|
+
gain: 0.55,
|
|
60
|
+
maxVoices: 1,
|
|
61
|
+
synth: {
|
|
62
|
+
voices: [
|
|
63
|
+
{ kind: "tone", wave: "triangle", freq: 523, duration: 0.14, gain: 0.22 },
|
|
64
|
+
{ kind: "tone", wave: "triangle", freq: 659, duration: 0.14, gain: 0.2, delay: 0.08 },
|
|
65
|
+
{ kind: "tone", wave: "triangle", freq: 784, duration: 0.26, gain: 0.2, delay: 0.16 },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
wave: feelSound("wave", {
|
|
70
|
+
gain: 0.6,
|
|
71
|
+
maxVoices: 1,
|
|
72
|
+
synth: {
|
|
73
|
+
voices: [
|
|
74
|
+
{ kind: "tone", wave: "sawtooth", freq: 98, duration: 0.5, gain: 0.16, attack: 0.04, sustain: 0.24 },
|
|
75
|
+
{ kind: "tone", wave: "sawtooth", freq: 147, duration: 0.5, gain: 0.12, attack: 0.05, sustain: 0.24 },
|
|
76
|
+
{ kind: "noise", filterType: "lowpass", filterFreq: 600, duration: 0.45, gain: 0.12, decay: 1 },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
deny: feelSound("deny", {
|
|
81
|
+
gain: 0.5,
|
|
82
|
+
maxVoices: 1,
|
|
83
|
+
synth: {
|
|
84
|
+
voices: [
|
|
85
|
+
{ kind: "tone", wave: "square", freq: 196, slideTo: 170, duration: 0.07, gain: 0.16, attack: 0.002 },
|
|
86
|
+
{ kind: "tone", wave: "square", freq: 155, slideTo: 130, duration: 0.1, gain: 0.16, delay: 0.07, attack: 0.002 },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Merge the default feedback reactions into a game's sound catalog. A game sound already named
|
|
93
|
+
* `feel.<cue>` wins, then `overrides`, then the default. Adds the `sfx` bus (gain 0.9) when the game
|
|
94
|
+
* did not declare one, so the impact layer has its own volume and mute row.
|
|
95
|
+
*
|
|
96
|
+
* @capability feedback-cue-sounds default procedural hit/hurt/kill/pickup/objective/wave/deny reactions on an "sfx" bus, overridable per game
|
|
97
|
+
*/
|
|
98
|
+
export function withFeedbackCueSounds(sounds, buses, overrides) {
|
|
99
|
+
if (overrides === false)
|
|
100
|
+
return { sounds: { ...sounds }, buses: buses === undefined ? undefined : { ...buses } };
|
|
101
|
+
const merged = {};
|
|
102
|
+
let usesBus = false;
|
|
103
|
+
for (const cue of FEEDBACK_CUES) {
|
|
104
|
+
const id = feedbackCueSoundId(cue);
|
|
105
|
+
if (sounds?.[id] !== undefined)
|
|
106
|
+
continue;
|
|
107
|
+
const override = overrides?.[cue];
|
|
108
|
+
if (override === false)
|
|
109
|
+
continue;
|
|
110
|
+
const def = override ?? DEFAULT_FEEDBACK_CUE_SOUNDS[cue];
|
|
111
|
+
merged[id] = { ...def, id };
|
|
112
|
+
if (def.bus === FEEDBACK_CUE_BUS)
|
|
113
|
+
usesBus = true;
|
|
114
|
+
}
|
|
115
|
+
const nextBuses = usesBus && buses?.[FEEDBACK_CUE_BUS] === undefined
|
|
116
|
+
? { ...buses, [FEEDBACK_CUE_BUS]: { id: FEEDBACK_CUE_BUS, gain: 0.9 } }
|
|
117
|
+
: buses;
|
|
118
|
+
return { sounds: { ...merged, ...sounds }, buses: nextBuses };
|
|
119
|
+
}
|
package/dist/game/events.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { EffectResult } from "../combat/effects.js";
|
|
|
2
2
|
import type { DeathReason } from "../combat/deathReason.js";
|
|
3
3
|
import type { TelegraphShape } from "../combat/telegraph.js";
|
|
4
4
|
import type { CameraShake } from "../combat/hitReaction.js";
|
|
5
|
+
import type { FeedbackCue } from "../audio/feedbackCues.js";
|
|
5
6
|
import type { CombatVfxInstanceEvent } from "./vfxInstance.js";
|
|
6
7
|
export type { DeathReason };
|
|
7
8
|
export interface EntityDiedEvent {
|
|
@@ -242,6 +243,15 @@ export interface AudioPlayEvent {
|
|
|
242
243
|
}
|
|
243
244
|
/** Request that the shell's audio engine resume its (browser-gesture-suspended) context; carries no payload. */
|
|
244
245
|
export type AudioResumeEvent = Record<string, never>;
|
|
246
|
+
/**
|
|
247
|
+
* A gameplay moment the game announces itself, for the moments no engine event covers (a wave
|
|
248
|
+
* starting, a scripted beat). The shell reacts with the cue's sound, rumble, camera kick and caption.
|
|
249
|
+
*/
|
|
250
|
+
export interface FeedbackCueEvent {
|
|
251
|
+
cue: FeedbackCue;
|
|
252
|
+
/** Caption text that replaces the cue's default label ("WAVE 3"). */
|
|
253
|
+
caption?: string;
|
|
254
|
+
}
|
|
245
255
|
/** Crossfade the procedural soundtrack to `theme` (null fades out), optionally transposing the incoming theme by `transpose` semitones. */
|
|
246
256
|
export interface AudioMusicEvent {
|
|
247
257
|
theme: string | null;
|
|
@@ -325,6 +335,7 @@ export interface GameEventMap {
|
|
|
325
335
|
"audio.loopStop": AudioLoopStopEvent;
|
|
326
336
|
"entity.animation": EntityAnimationEvent;
|
|
327
337
|
"animation.event": AnimationClipEvent;
|
|
338
|
+
"feedback.cue": FeedbackCueEvent;
|
|
328
339
|
"command.refused": CommandRefusedEvent;
|
|
329
340
|
}
|
|
330
341
|
export type GameEventHandler<TPayload> = (payload: TPayload) => void;
|
package/dist/game/gameSave.d.ts
CHANGED
|
@@ -111,6 +111,12 @@ export interface GameSave {
|
|
|
111
111
|
peek(slot: string): Promise<GameSaveDocument | null>;
|
|
112
112
|
/** Slots this save has written. */
|
|
113
113
|
slots(): Promise<string[]>;
|
|
114
|
+
/**
|
|
115
|
+
* Delete one slot's document and drop it from the slot index, so `slots()` and `peek()` stop
|
|
116
|
+
* seeing it. Resolves `true` when a document was removed and `false` when the slot held nothing.
|
|
117
|
+
* Deleting the active slot resets the registered systems to a fresh document.
|
|
118
|
+
*/
|
|
119
|
+
deleteSlot(slot: string): Promise<boolean>;
|
|
114
120
|
slot(): string;
|
|
115
121
|
status(): SaveStatus;
|
|
116
122
|
/** Monotonic change counter — bumps on any value or status change. */
|
package/dist/game/gameSave.js
CHANGED
|
@@ -150,6 +150,12 @@ export function createGameSave(config) {
|
|
|
150
150
|
return stored.value;
|
|
151
151
|
},
|
|
152
152
|
slots: () => store.slots(),
|
|
153
|
+
async deleteSlot(slot) {
|
|
154
|
+
const deleted = await store.deleteSlot(slot);
|
|
155
|
+
if (deleted && slot === store.slot())
|
|
156
|
+
apply(store.value());
|
|
157
|
+
return deleted;
|
|
158
|
+
},
|
|
153
159
|
slot: () => store.slot(),
|
|
154
160
|
status: () => store.status(),
|
|
155
161
|
revision: () => store.revision(),
|
|
@@ -2,6 +2,7 @@ import type { AnimGraph } from "../anim/animGraph.js";
|
|
|
2
2
|
import type { AudioBusDef, SoundDef } from "../audio/audioFalloff.js";
|
|
3
3
|
import type { MusicTheme } from "../audio/music.js";
|
|
4
4
|
import type { UiCueOverrides } from "../audio/uiCues.js";
|
|
5
|
+
import type { FeedbackCueOverrides } from "../audio/feedbackCues.js";
|
|
5
6
|
import type { NoticeFeedConfig } from "./notices.js";
|
|
6
7
|
import type { MaterialAsset, MaterialAssignment, MaterialSurfaceParameters, MaterialTextureRole } from "../material/materialAsset.js";
|
|
7
8
|
import type { EditorCatalogDefinition, EditorDocument } from "../editor/types.js";
|
|
@@ -786,6 +787,8 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
|
|
|
786
787
|
hudCues?: boolean;
|
|
787
788
|
/** Default UI cues (`ui.click`, `ui.deny`, ...) on a "ui" bus. A sound named `ui.<cue>` in `sounds` replaces one; a map entry replaces or (`false`) silences one; `false` silences all. */
|
|
788
789
|
uiCues?: UiCueOverrides;
|
|
790
|
+
/** Default gameplay reactions (`feel.hit`, `feel.kill`, `feel.pickup`, ...) on an "sfx" bus, played by the shell from the events the engine announces. A sound named `feel.<cue>` in `sounds` replaces one; a map entry replaces or (`false`) silences one; `false` silences all. */
|
|
791
|
+
feedbackCues?: FeedbackCueOverrides;
|
|
789
792
|
};
|
|
790
793
|
/** Continuous positional emitter keyed by entity kind name: while a matching entity exists, the shell plays and repositions the mapped `audio.sounds` id (looping engine hum, campfire crackle, footstep loop) with listener-distance falloff. */
|
|
791
794
|
entitySounds?: Record<string, string>;
|
package/dist/game/saveStore.d.ts
CHANGED
|
@@ -92,6 +92,13 @@ export interface SaveStore<T> {
|
|
|
92
92
|
save(): Promise<void>;
|
|
93
93
|
/** Delete the active slot and reset `value()` to `initial`. */
|
|
94
94
|
clear(): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Delete any slot: remove its stored payload and drop it from the slot index, so `slots()` and
|
|
97
|
+
* `peek()` stop seeing it. Resolves `true` when a payload was removed and `false` when the slot
|
|
98
|
+
* held nothing (an unknown slot is a no-op). Deleting the active slot resets `value()` to
|
|
99
|
+
* `initial`; other slots leave the live value alone.
|
|
100
|
+
*/
|
|
101
|
+
deleteSlot(slot: string): Promise<boolean>;
|
|
95
102
|
/**
|
|
96
103
|
* Read a stored slot without touching the live value, active slot or status — what a save-select
|
|
97
104
|
* menu shows. Returns `null` when the slot holds nothing.
|
package/dist/game/saveStore.js
CHANGED
|
@@ -231,6 +231,10 @@ export function createSaveStore(config) {
|
|
|
231
231
|
firstDirtyAt = null;
|
|
232
232
|
setStatus("idle");
|
|
233
233
|
emit();
|
|
234
|
+
// Only a slot that holds a payload belongs in the index — loading an empty
|
|
235
|
+
// slot must not resurrect an id a deleteSlot removed.
|
|
236
|
+
if (raw === null)
|
|
237
|
+
return current;
|
|
234
238
|
void rememberSlot(currentSlot).catch((error) => {
|
|
235
239
|
setStatus("error");
|
|
236
240
|
reportError(error);
|
|
@@ -279,6 +283,37 @@ export function createSaveStore(config) {
|
|
|
279
283
|
reportError(error);
|
|
280
284
|
}
|
|
281
285
|
},
|
|
286
|
+
async deleteSlot(slot) {
|
|
287
|
+
const wasActive = slot === currentSlot;
|
|
288
|
+
if (wasActive) {
|
|
289
|
+
clearTimer();
|
|
290
|
+
dirty = false;
|
|
291
|
+
firstDirtyAt = null;
|
|
292
|
+
}
|
|
293
|
+
const run = saveChain.then(async () => {
|
|
294
|
+
if (wasActive)
|
|
295
|
+
setStatus("saving");
|
|
296
|
+
try {
|
|
297
|
+
const existing = await backend.read(dataKey(slot));
|
|
298
|
+
if (existing !== null)
|
|
299
|
+
await backend.remove(dataKey(slot));
|
|
300
|
+
await forgetSlot(slot);
|
|
301
|
+
if (existing !== null && wasActive)
|
|
302
|
+
current = initial;
|
|
303
|
+
if (wasActive)
|
|
304
|
+
setStatus("idle");
|
|
305
|
+
emit();
|
|
306
|
+
return existing !== null;
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
setStatus("error");
|
|
310
|
+
reportError(error);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
saveChain = run.then(() => undefined, () => undefined);
|
|
315
|
+
return run;
|
|
316
|
+
},
|
|
282
317
|
async switchSlot(slot) {
|
|
283
318
|
if (dirty)
|
|
284
319
|
await save();
|
package/dist/gameplay.d.ts
CHANGED
|
@@ -88,7 +88,7 @@ export { createWeaponStats } from "./item/weapon.js";
|
|
|
88
88
|
export { MAX_LOOT_AFFIXES, assertLootCatalog, compareLootItems, createLootRoller, lootLevelScale, lootStatContributions, rollLoot, scoreLootStats, validateLootItem, type LootBaseDef, type LootCatalog, type LootComparison, type LootComparisonOptions, type LootInstalledPart, type LootIssueCode, type LootItem, type LootManufacturerDef, type LootPartDef, type LootRollOptions, type LootRoller, type LootRollerOptions, type LootRollerState, type LootStatDelta, type LootValidationIssue, } from "./loot/lootGen.js";
|
|
89
89
|
export { lootWeaponDefinition, type LootWeaponProjection, type LootWeaponStatMap } from "./loot/lootWeapon.js";
|
|
90
90
|
export { chaseMission, defendMission, escortMission, type ChaseMissionOptions, type ComposedMissionOptions, type DefendMissionOptions, type EscortMissionOptions, } from "./mission/compositions.js";
|
|
91
|
-
export { DEFAULT_ESCAPE_MULTIPLE, DEFAULT_PURSUE_DISTANCE, DEFAULT_REACH_RADIUS, MISSION_COUNTER_KINDS, applyMissionRewards, createMissionRuntime, isCounterObjective, validateMissionCatalog, type MissionActorFact, type MissionBranch, type MissionDef, type MissionEvent, type MissionEventFilter, type MissionFactCondition, type MissionFailCondition, type MissionFailDef, type MissionIssue, type MissionObjectiveDef, type MissionObjectiveKind, type MissionRewardAppliers, type MissionRewards, type MissionRuntime, type MissionRuntimeConfig, type MissionRuntimeSnapshot, type MissionState, type MissionStats, type MissionStatus, type MissionTickInput, type MissionValidationOptions, type MissionView, type MissionWorldEvent, type MissionWorldEventKind, } from "./mission/mission.js";
|
|
91
|
+
export { DEFAULT_ESCAPE_MULTIPLE, DEFAULT_MARKER_TOLERANCE, DEFAULT_PURSUE_DISTANCE, DEFAULT_REACH_RADIUS, MISSION_COUNTER_KINDS, applyMissionRewards, createMissionRuntime, isCounterObjective, validateMissionCatalog, type MissionActorFact, type MissionBranch, type MissionDef, type MissionEvent, type MissionEventFilter, type MissionFactCondition, type MissionFailCondition, type MissionFailDef, type MissionIssue, type MissionMarkerPlane, type MissionMarkerProximity, type MissionObjectiveDef, type MissionObjectiveKind, type MissionRewardAppliers, type MissionRewards, type MissionRuntime, type MissionRuntimeConfig, type MissionRuntimeSnapshot, type MissionState, type MissionStats, type MissionStatus, type MissionTickInput, type MissionValidationOptions, type MissionView, type MissionWorldEvent, type MissionWorldEventKind, } from "./mission/mission.js";
|
|
92
92
|
export { MISSION_TRIGGER_ACTIONS, createMissionTriggerBridge, missionMarkers, registerMissionTriggerActions, type MissionTriggerBridge, } from "./mission/triggers.js";
|
|
93
93
|
export { compareGearSets, gearScore, type GearScore, type GearScoreOptions, type GearSlotItem, } from "./progression/gearScore.js";
|
|
94
94
|
export { assertSkillTreeConfig, createSkillTree, type SkillActiveDef, type SkillNodeDef, type SkillRequirement, type SkillResolution, type SkillSpendReason, type SkillSpendResult, type SkillSynergyDef, type SkillTree, type SkillTreeConfig, type SkillTreeState, } from "./progression/skillTree.js";
|
package/dist/gameplay.js
CHANGED
|
@@ -88,7 +88,7 @@ export { createWeaponStats } from "./item/weapon.js";
|
|
|
88
88
|
export { MAX_LOOT_AFFIXES, assertLootCatalog, compareLootItems, createLootRoller, lootLevelScale, lootStatContributions, rollLoot, scoreLootStats, validateLootItem, } from "./loot/lootGen.js";
|
|
89
89
|
export { lootWeaponDefinition } from "./loot/lootWeapon.js";
|
|
90
90
|
export { chaseMission, defendMission, escortMission, } from "./mission/compositions.js";
|
|
91
|
-
export { DEFAULT_ESCAPE_MULTIPLE, DEFAULT_PURSUE_DISTANCE, DEFAULT_REACH_RADIUS, MISSION_COUNTER_KINDS, applyMissionRewards, createMissionRuntime, isCounterObjective, validateMissionCatalog, } from "./mission/mission.js";
|
|
91
|
+
export { DEFAULT_ESCAPE_MULTIPLE, DEFAULT_MARKER_TOLERANCE, DEFAULT_PURSUE_DISTANCE, DEFAULT_REACH_RADIUS, MISSION_COUNTER_KINDS, applyMissionRewards, createMissionRuntime, isCounterObjective, validateMissionCatalog, } from "./mission/mission.js";
|
|
92
92
|
export { MISSION_TRIGGER_ACTIONS, createMissionTriggerBridge, missionMarkers, registerMissionTriggerActions, } from "./mission/triggers.js";
|
|
93
93
|
export { compareGearSets, gearScore, } from "./progression/gearScore.js";
|
|
94
94
|
export { assertSkillTreeConfig, createSkillTree, } from "./progression/skillTree.js";
|
package/dist/item/affix.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface AffixDef {
|
|
|
5
5
|
op?: AffixOp;
|
|
6
6
|
roll: number | [number, number];
|
|
7
7
|
weight: number;
|
|
8
|
+
/** Lowest item level whose roll pool includes this affix; omitted means every level. */
|
|
9
|
+
minLevel?: number;
|
|
8
10
|
namePart?: {
|
|
9
11
|
position: "prefix" | "suffix";
|
|
10
12
|
text: string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type LootCatalog, type LootComparisonOptions, type LootItem, type LootRollOptions, type LootStatDelta } from "./lootGen.js";
|
|
2
|
+
/** A rolled item with a stable instance id, so a stash, ledger and vendor stock can name it. */
|
|
3
|
+
export interface ItemDrop extends LootItem {
|
|
4
|
+
id: string;
|
|
5
|
+
}
|
|
6
|
+
/** {@link rollDrop} options: the loot roll plus the instance id (defaults to `drop:<seed>`). */
|
|
7
|
+
export interface RollDropOptions extends LootRollOptions {
|
|
8
|
+
id?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Roll one item from a seed. Same seed, catalog and options produce the same id, name, affixes,
|
|
12
|
+
* stats and mass on every run; level gates which bases and affixes are in the pool.
|
|
13
|
+
*
|
|
14
|
+
* @capability item-roll roll one deterministic, level-gated item from a seed
|
|
15
|
+
*/
|
|
16
|
+
export declare function rollDrop(catalog: LootCatalog, seed: string | number, options?: RollDropOptions): ItemDrop;
|
|
17
|
+
/** A per-stat delta from the equipped item to the candidate; positive `delta` means the candidate is larger. */
|
|
18
|
+
export interface EquippedStatDelta extends LootStatDelta {
|
|
19
|
+
/** Whether this stat reads better on the candidate, following `lowerIsBetter`. */
|
|
20
|
+
better: "candidate" | "equipped" | "equal";
|
|
21
|
+
}
|
|
22
|
+
/** What a HUD renders when a drop lands: per-stat deltas against the worn item and one verdict. */
|
|
23
|
+
export interface EquippedComparison {
|
|
24
|
+
candidateId: string;
|
|
25
|
+
equippedId: string | null;
|
|
26
|
+
deltas: readonly EquippedStatDelta[];
|
|
27
|
+
scoreCandidate: number;
|
|
28
|
+
scoreEquipped: number;
|
|
29
|
+
/** `scoreCandidate - scoreEquipped`. */
|
|
30
|
+
scoreDelta: number;
|
|
31
|
+
better: "candidate" | "equipped" | "equal";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Compare a dropped item against what is equipped, with one delta per stat and a weighted verdict.
|
|
35
|
+
* The delta is `candidate - equipped`; `better` flips for stats the caller lists in
|
|
36
|
+
* `lowerIsBetter`, so reload time improving does not read as worse. A missing equipped item
|
|
37
|
+
* compares against zeros.
|
|
38
|
+
*
|
|
39
|
+
* @capability item-comparison show per-stat deltas and a weighted verdict for a drop against equipped gear
|
|
40
|
+
*/
|
|
41
|
+
export declare function compareToEquipped(candidate: ItemDrop, equipped: ItemDrop | null, options?: LootComparisonOptions): EquippedComparison;
|
|
42
|
+
/**
|
|
43
|
+
* Carried mass of a rolled item. Older saves may predate `mass`; those default to `1`.
|
|
44
|
+
*
|
|
45
|
+
* @capability item-weight read a rolled item's mass for a carry check
|
|
46
|
+
*/
|
|
47
|
+
export declare function itemMass(item: Pick<LootItem, "mass">): number;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { compareLootItems, rollLoot, } from "./lootGen.js";
|
|
2
|
+
import { seededRng } from "../random/rng.js";
|
|
3
|
+
/**
|
|
4
|
+
* Roll one item from a seed. Same seed, catalog and options produce the same id, name, affixes,
|
|
5
|
+
* stats and mass on every run; level gates which bases and affixes are in the pool.
|
|
6
|
+
*
|
|
7
|
+
* @capability item-roll roll one deterministic, level-gated item from a seed
|
|
8
|
+
*/
|
|
9
|
+
export function rollDrop(catalog, seed, options = {}) {
|
|
10
|
+
const item = rollLoot(catalog, seededRng(seed), options);
|
|
11
|
+
return { ...item, id: options.id ?? `drop:${String(seed)}` };
|
|
12
|
+
}
|
|
13
|
+
const NO_ITEM = {
|
|
14
|
+
baseId: "none",
|
|
15
|
+
manufacturer: "none",
|
|
16
|
+
rarity: "none",
|
|
17
|
+
level: 0,
|
|
18
|
+
name: "",
|
|
19
|
+
parts: [],
|
|
20
|
+
affixes: [],
|
|
21
|
+
stats: {},
|
|
22
|
+
mass: 0,
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Compare a dropped item against what is equipped, with one delta per stat and a weighted verdict.
|
|
26
|
+
* The delta is `candidate - equipped`; `better` flips for stats the caller lists in
|
|
27
|
+
* `lowerIsBetter`, so reload time improving does not read as worse. A missing equipped item
|
|
28
|
+
* compares against zeros.
|
|
29
|
+
*
|
|
30
|
+
* @capability item-comparison show per-stat deltas and a weighted verdict for a drop against equipped gear
|
|
31
|
+
*/
|
|
32
|
+
export function compareToEquipped(candidate, equipped, options = {}) {
|
|
33
|
+
const base = equipped ?? NO_ITEM;
|
|
34
|
+
const comparison = compareLootItems(base, candidate, options);
|
|
35
|
+
const lower = new Set(options.lowerIsBetter ?? []);
|
|
36
|
+
const deltas = comparison.deltas.map((entry) => ({
|
|
37
|
+
...entry,
|
|
38
|
+
better: entry.delta === 0 ? "equal" : (entry.delta > 0) !== lower.has(entry.stat) ? "candidate" : "equipped",
|
|
39
|
+
}));
|
|
40
|
+
return {
|
|
41
|
+
candidateId: candidate.id,
|
|
42
|
+
equippedId: equipped?.id ?? null,
|
|
43
|
+
deltas,
|
|
44
|
+
scoreCandidate: comparison.scoreB,
|
|
45
|
+
scoreEquipped: comparison.scoreA,
|
|
46
|
+
scoreDelta: comparison.scoreDelta,
|
|
47
|
+
better: comparison.better === "b" ? "candidate" : comparison.better === "a" ? "equipped" : "equal",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Carried mass of a rolled item. Older saves may predate `mass`; those default to `1`.
|
|
52
|
+
*
|
|
53
|
+
* @capability item-weight read a rolled item's mass for a carry check
|
|
54
|
+
*/
|
|
55
|
+
export function itemMass(item) {
|
|
56
|
+
return item.mass !== undefined && Number.isFinite(item.mass) && item.mass > 0 ? item.mass : 1;
|
|
57
|
+
}
|