@jgengine/core 0.4.0 → 0.6.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 +66 -0
- package/dist/game/defineGame.d.ts +3 -0
- package/dist/game/feed.d.ts +2 -0
- package/dist/game/feed.js +10 -8
- package/dist/game/playableGame.d.ts +10 -1
- package/dist/game/progression.d.ts +61 -0
- package/dist/game/progression.js +96 -0
- package/dist/game/quest.d.ts +39 -0
- package/dist/game/quest.js +195 -0
- package/dist/game/trade.d.ts +23 -0
- package/dist/game/trade.js +50 -8
- package/dist/game/unlocks.d.ts +9 -0
- package/dist/game/unlocks.js +19 -1
- package/dist/inventory/slotModel.d.ts +19 -0
- package/dist/inventory/slotModel.js +76 -0
- package/dist/meta/changelog.d.ts +8 -0
- package/dist/meta/changelog.js +43 -0
- package/dist/physics/physicsWorld.d.ts +136 -0
- package/dist/physics/physicsWorld.js +489 -0
- package/dist/runtime/gameContext.d.ts +2 -0
- package/dist/runtime/gameContext.js +3 -0
- package/dist/scene/assetCatalog.d.ts +13 -0
- package/dist/scene/entityStore.d.ts +6 -0
- package/dist/scene/entityStore.js +15 -1
- package/dist/time/simClock.d.ts +62 -0
- package/dist/time/simClock.js +132 -0
- package/dist/world/buildingIndex.d.ts +16 -0
- package/dist/world/buildingIndex.js +63 -0
- package/dist/world/buildings.d.ts +98 -0
- package/dist/world/buildings.js +320 -0
- package/dist/world/features.d.ts +105 -1
- package/dist/world/features.js +95 -0
- package/dist/world/geometry.d.ts +23 -0
- package/dist/world/geometry.js +75 -0
- package/dist/world/interiors.d.ts +35 -0
- package/dist/world/interiors.js +66 -0
- package/dist/world/placement.d.ts +30 -0
- package/dist/world/placement.js +20 -0
- package/dist/world/regions.d.ts +66 -0
- package/dist/world/regions.js +158 -0
- package/dist/world/scatter.d.ts +24 -0
- package/dist/world/scatter.js +126 -0
- package/dist/world/scatterItems.d.ts +38 -0
- package/dist/world/scatterItems.js +85 -0
- package/dist/world/terrain.d.ts +54 -0
- package/dist/world/terrain.js +181 -0
- package/dist/world/water.d.ts +27 -0
- package/dist/world/water.js +125 -0
- package/dist/world/wind.d.ts +17 -0
- package/dist/world/wind.js +40 -0
- package/package.json +5 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All eight `@jgengine/*` packages are versioned in lockstep, so this one file
|
|
4
|
+
covers every release. Format follows [Keep a Changelog](https://keepachangelog.com);
|
|
5
|
+
each release **leads with a Migrate block** — the concrete steps to move a game
|
|
6
|
+
from the previous version onto the new APIs — because the point of a bump is to
|
|
7
|
+
let consumers pick up the better stuff, not just to list what moved.
|
|
8
|
+
|
|
9
|
+
Agents building on the published SDK can also read this programmatically:
|
|
10
|
+
`import { VERSION, CHANGELOG } from "@jgengine/core/meta/changelog"` gives the
|
|
11
|
+
same data as typed values, so an updater can diff its installed version against
|
|
12
|
+
the latest and surface the migration steps.
|
|
13
|
+
|
|
14
|
+
## Unreleased
|
|
15
|
+
|
|
16
|
+
_Nothing yet._
|
|
17
|
+
|
|
18
|
+
## 0.6.0
|
|
19
|
+
|
|
20
|
+
An additive release: **every 0.5.0 API is unchanged**, so upgrading is only a
|
|
21
|
+
version bump. The headline is a whole outdoor-world layer — composable
|
|
22
|
+
`environment(...)` descriptors, renderer-free query primitives so gameplay reads
|
|
23
|
+
the same world the shell renders, a standalone rigid-body physics sim, and the
|
|
24
|
+
`@jgengine/shell` renderers that draw it all.
|
|
25
|
+
|
|
26
|
+
### Migrate
|
|
27
|
+
|
|
28
|
+
- Bump every `@jgengine/*` dependency to `^0.6.0` (the eight packages version in lockstep).
|
|
29
|
+
- No code change is required — 0.6.0 adds surface, it doesn't move or remove any.
|
|
30
|
+
- Optional: describe an outdoor scene once with `environment({ terrain, weather, vegetation, water, structures })` from `@jgengine/core/world/features`. `@jgengine/shell`'s `EnvironmentScene` renders it (terrain, rain/snow, grass, ocean, buildings), and gameplay reads the same world through the renderer-free primitives — `resolveTerrainField(...)` for ground-snap/collision, `windField(...)` for sway/sailing, `waterSurface(...)` for buoyancy, `scatter(...)` for prop/spawn placement, `buildingIndex(...)` for placement avoidance — no three.js needed.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- `@jgengine/core/world` query primitives — pure, renderer-free world sampling so gameplay and rendering read one source of truth. `world/terrain` (`TerrainField`, `noiseField`, `resolveTerrainField`, `resolveGroundStep` for slope-limited movement), `world/wind` (`windField` — one wind source for weather sway, grass, sailing, fire spread), `world/water` (`waterSurface` / `waterSurfaceFromDescriptor` — CPU Gerstner matching the ocean shader, for buoyancy and shorelines), `world/scatter` (`scatter` — seeded, overlap-aware point distribution for vegetation/props/spawns), `world/buildings` (`generateBuilding`, `generateBuildingDistrict`) and `world/buildingIndex` (`buildingIndex` — `at`/`within`/`nearest`/`isInside`/`blockers` over a district).
|
|
35
|
+
- `@jgengine/core/world/features` composable outdoor descriptors — `environment(...)` plus `terrain()`, `rain()`, `snow()`, `grass()`, `ocean()`, `building()`.
|
|
36
|
+
- `@jgengine/core/world/regions` (`createRegionField` — blend content-agnostic biomes by nearest selector, adding `tint`/`water`/`fog`/`speedMultiplier`/opaque `data`; extends `TerrainField` so it ground-snaps too) and `@jgengine/core/world/scatterItems` (`scatterItems` — region-driven content scatter: density per region, grounded, above-water/slope-aware, with `pickWeighted` for weighted rolls).
|
|
37
|
+
- `@jgengine/core/physics/physicsWorld` — standalone fixed-capacity rigid-body sim (SoA buffers, spatial-hash broadphase, sleeping) for many colliding dynamic bodies (piles, debris, stress scenes). Distinct from the `defineGame` `physics: { gravity }` character-controller config.
|
|
38
|
+
- `ctx.time` simulation clock (`@jgengine/core/time/simClock`) — `onTick`'s `dt` is now **game time**, so `rate * dt` decay/regen/AI obeys pause and fast-forward for free without per-system wiring. Configure with `defineGame({ time: { scale?, speeds?, dayLength?, start?, startPaused? } })` (all optional; default is real-time 1:1). `ctx.time` exposes `pause`/`play`/`toggle`/`setSpeed`/`cycleSpeed` + `snapshot()`/`calendar()`; `useGameClock()` binds it in React.
|
|
39
|
+
- `@jgengine/shell` environment/terrain/water/weather/structures renderers — `EnvironmentScene` mounts an `environment()` descriptor as R3F renderers; `GrassField`, `ProceduralGround`, `Ocean`, `RainField`, `SnowField`, `LightningStrike`, `GeneratedBuilding`, and `world/InstancedBodies` (renders `PhysicsWorld` bodies).
|
|
40
|
+
- `@jgengine/react/liveBind` — `useFrameBind` drives a DOM/SVG element from a per-frame value without re-rendering React (HUDs bound to live engine state never re-render or lag).
|
|
41
|
+
|
|
42
|
+
## 0.5.0
|
|
43
|
+
|
|
44
|
+
An additive release: **every 0.4.0 API is unchanged**, so upgrading is only a
|
|
45
|
+
version bump. New pure primitives across progression, inventory slots, world
|
|
46
|
+
geometry, and React store bindings.
|
|
47
|
+
|
|
48
|
+
### Migrate
|
|
49
|
+
|
|
50
|
+
- Bump every `@jgengine/*` dependency to `^0.5.0` (the eight packages version in lockstep).
|
|
51
|
+
- No code change is required — 0.5.0 adds surface, it doesn't move or remove any.
|
|
52
|
+
- Optional: replace a game's hand-rolled `progression/curves.ts` with the new `leveling(...)` track. `leveling({ xpForLevel: { kind: "power", base, exponent, round: "floor" }, maxLevel })` returns `xpForLevel`, `resolve`, and `grantXp(ctx.scene.entity.stats, userId, amount, onLevelUp?)` — a drop-in for the old `xpRequiredForLevel` / `resolveLevelProgress` / `grantXp` exports. `ctx.scene.entity.stats` satisfies the primitive's `LevelingStatAccess` structurally, so no adapter is needed.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- `@jgengine/core/game/progression` — genre-agnostic progression primitive. `curve(spec)` / `evalCurve(spec, x)` evaluate declarative scalar curves (`const`, `linear`, `power`, `geometric`, `steps`, `piecewise`, each with optional `round`/`min`/`max`) for speed-by-level, difficulty-by-wave, loot drop-rate ramps, and similar scaling. `leveling(config)` builds the stateful XP→level track (threshold accumulation, multi-level grants, cap handling, `stat.levelUp` emit) on top of an `xpForLevel` curve.
|
|
57
|
+
- `@jgengine/core/inventory/slotModel` — pure slot-grid primitives (`createSlots`, `placeAt`, `removeAt`, `moveSlot`).
|
|
58
|
+
- `@jgengine/core/world/geometry`, `/world/interiors`, `/world/placement` — pure world primitives: grid snapping, footprint AABBs and overlap, interior/exterior spaces, and placement validation.
|
|
59
|
+
- `@jgengine/react/engineStore` — raw-store React bindings (`useEngineState`, `useEngineStore`, `useEngineEvent`).
|
|
60
|
+
- Pure/functional tiers for the `trade`, `unlocks`, `quest`, and `feed` verbs in `@jgengine/core/game`.
|
|
61
|
+
|
|
62
|
+
## 0.4.0
|
|
63
|
+
|
|
64
|
+
Baseline release: the eight `@jgengine/*` packages (core, ws, sql, react,
|
|
65
|
+
convex, node, shell, assets) as the first tracked version. No migration —
|
|
66
|
+
this is the floor changelog entries are measured against.
|
|
@@ -3,6 +3,7 @@ import type { ItemTraits } from "../inventory/inventoryModel.js";
|
|
|
3
3
|
import type { SaveConfig } from "../runtime/save.js";
|
|
4
4
|
import type { AssetCatalog, ModelAssetRef } from "../scene/assetCatalog.js";
|
|
5
5
|
import { type EntityStore } from "../scene/entityStore.js";
|
|
6
|
+
import type { TimeConfig } from "../time/simClock.js";
|
|
6
7
|
import type { WorldFeature } from "../world/features.js";
|
|
7
8
|
export interface PhysicsConfig {
|
|
8
9
|
gravity?: number;
|
|
@@ -30,6 +31,8 @@ export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef,
|
|
|
30
31
|
scene: EntityStore;
|
|
31
32
|
world?: WorldFeature;
|
|
32
33
|
physics?: PhysicsConfig;
|
|
34
|
+
/** Simulation clock: real→game time scale, selectable speeds, calendar. Exposed as `ctx.time`; the shell feeds its scaled dt to `loop.onTick`. */
|
|
35
|
+
time?: TimeConfig;
|
|
33
36
|
inventories?: Record<string, InventoryDeclaration>;
|
|
34
37
|
input?: ActionCodesMap;
|
|
35
38
|
server?: GameServerConfig;
|
package/dist/game/feed.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface FeedEntry<T = unknown> {
|
|
|
6
6
|
export interface GameFeedOptions {
|
|
7
7
|
limit?: number;
|
|
8
8
|
}
|
|
9
|
+
export declare function appendFeedEntry<T>(buffer: readonly FeedEntry<T>[], entry: FeedEntry<T>, limit: number): FeedEntry<T>[];
|
|
10
|
+
export declare function recentFeedEntries<T>(buffer: readonly FeedEntry<T>[], limit?: number): FeedEntry<T>[];
|
|
9
11
|
export interface GameFeed {
|
|
10
12
|
bind<TName extends keyof GameEventMap>(action: TName, events: GameEvents): () => void;
|
|
11
13
|
push(action: string, entry: unknown): void;
|
package/dist/game/feed.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
export function appendFeedEntry(buffer, entry, limit) {
|
|
2
|
+
const next = [...buffer, entry];
|
|
3
|
+
return next.length > limit ? next.slice(next.length - limit) : next;
|
|
4
|
+
}
|
|
5
|
+
export function recentFeedEntries(buffer, limit) {
|
|
6
|
+
const count = limit ?? buffer.length;
|
|
7
|
+
return buffer.slice(Math.max(0, buffer.length - count));
|
|
8
|
+
}
|
|
1
9
|
export function createGameFeed(options) {
|
|
2
10
|
const limit = options?.limit ?? 20;
|
|
3
11
|
const buffers = new Map();
|
|
4
12
|
const listeners = new Map();
|
|
5
13
|
function append(action, entry) {
|
|
6
|
-
|
|
7
|
-
buffer.push(entry);
|
|
8
|
-
if (buffer.length > limit)
|
|
9
|
-
buffer.splice(0, buffer.length - limit);
|
|
10
|
-
buffers.set(action, buffer);
|
|
14
|
+
buffers.set(action, appendFeedEntry(buffers.get(action) ?? [], entry, limit));
|
|
11
15
|
for (const listener of listeners.get(action) ?? [])
|
|
12
16
|
listener(entry);
|
|
13
17
|
}
|
|
@@ -19,9 +23,7 @@ export function createGameFeed(options) {
|
|
|
19
23
|
append(action, { at: Date.now(), data });
|
|
20
24
|
},
|
|
21
25
|
recent(action, opts) {
|
|
22
|
-
|
|
23
|
-
const count = opts?.limit ?? buffer.length;
|
|
24
|
-
return buffer.slice(Math.max(0, buffer.length - count));
|
|
26
|
+
return recentFeedEntries(buffers.get(action) ?? [], opts?.limit);
|
|
25
27
|
},
|
|
26
28
|
subscribe(action, listener) {
|
|
27
29
|
let set = listeners.get(action);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PositionedPrompt } from "../interaction/proximityPrompt.js";
|
|
2
2
|
import type { GameContext, GameContextContent } from "../runtime/gameContext.js";
|
|
3
|
+
import type { ModelDims } from "../scene/assetCatalog.js";
|
|
3
4
|
import type { GameDefinition, GameLoop } from "./defineGame.js";
|
|
4
5
|
export interface CameraFollowState {
|
|
5
6
|
entityId: string;
|
|
@@ -71,14 +72,22 @@ export interface ModelConfig {
|
|
|
71
72
|
url: string;
|
|
72
73
|
scale?: number;
|
|
73
74
|
y?: number;
|
|
75
|
+
/** How the model registers on its placement point. `"center"` (default) horizontally centers the measured footprint on the point and ground-snaps its lowest vertex to the point's Y — the correct behavior for corner-pivot modular kits. `"origin"` renders at the raw GLB origin (legacy). */
|
|
76
|
+
anchor?: "center" | "origin";
|
|
77
|
+
/** Measured footprint/center/minY; supplied automatically when the model resolves through an `@jgengine/assets` catalog. Required for `anchor: "center"` to take effect. */
|
|
78
|
+
dims?: ModelDims;
|
|
74
79
|
}
|
|
75
|
-
export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown> {
|
|
80
|
+
export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEntity = never> {
|
|
76
81
|
game: GameDefinition;
|
|
77
82
|
content: GameContextContent;
|
|
78
83
|
loop: Required<GameLoop<GameContext>>;
|
|
79
84
|
GameUI: TUi;
|
|
80
85
|
/** Optional canvas-layer VFX component (e.g. traveling projectiles). */
|
|
81
86
|
WorldOverlay?: TWorldOverlay;
|
|
87
|
+
/** Replaces the default demo backdrop (ground + grid + rocks) with the game's own scene — ground, sky, structures. Camera, input, HUD, entity rendering, and the loop stay shell-provided; supply your world without forking the shell. */
|
|
88
|
+
environment?: TWorldOverlay;
|
|
89
|
+
/** Per-entity visual override: return your own mesh for an entity and the shell still positions it and drives selection/targeting. Return null/undefined to fall back to model → sprite → primitive. */
|
|
90
|
+
renderEntity?: TRenderEntity;
|
|
82
91
|
/** Billboard sprites keyed by entity kind name; unmatched entities get primitive markers. */
|
|
83
92
|
entitySprites?: Record<string, EntitySpriteConfig>;
|
|
84
93
|
/** GLB models keyed by entity kind name; a string resolves as an asset-catalog key via game.assets, a ModelConfig renders directly. Takes priority over sprites, then primitives. */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type CurveDef = {
|
|
2
|
+
kind: "const";
|
|
3
|
+
value: number;
|
|
4
|
+
} | {
|
|
5
|
+
kind: "linear";
|
|
6
|
+
base: number;
|
|
7
|
+
per: number;
|
|
8
|
+
} | {
|
|
9
|
+
kind: "power";
|
|
10
|
+
base: number;
|
|
11
|
+
exponent: number;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "geometric";
|
|
14
|
+
base: number;
|
|
15
|
+
ratio: number;
|
|
16
|
+
} | {
|
|
17
|
+
kind: "steps";
|
|
18
|
+
values: number[];
|
|
19
|
+
} | {
|
|
20
|
+
kind: "piecewise";
|
|
21
|
+
points: [number, number][];
|
|
22
|
+
};
|
|
23
|
+
export interface CurveShape {
|
|
24
|
+
round?: "floor" | "ceil" | "round";
|
|
25
|
+
min?: number;
|
|
26
|
+
max?: number;
|
|
27
|
+
}
|
|
28
|
+
export type Curve = CurveDef & CurveShape;
|
|
29
|
+
export declare function evalCurve(spec: Curve, x: number): number;
|
|
30
|
+
export declare function curve(spec: Curve): (x: number) => number;
|
|
31
|
+
export interface LevelProgress {
|
|
32
|
+
level: number;
|
|
33
|
+
xp: number;
|
|
34
|
+
xpMax: number;
|
|
35
|
+
levelsGained: number;
|
|
36
|
+
}
|
|
37
|
+
export interface LevelingConfig {
|
|
38
|
+
xpForLevel: Curve;
|
|
39
|
+
maxLevel: number;
|
|
40
|
+
startLevel?: number;
|
|
41
|
+
xpStat?: string;
|
|
42
|
+
levelStat?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface LevelingStatAccess {
|
|
45
|
+
get(userId: string, statId: string): {
|
|
46
|
+
current: number;
|
|
47
|
+
max: number;
|
|
48
|
+
} | null;
|
|
49
|
+
set(userId: string, statId: string, patch: {
|
|
50
|
+
current?: number;
|
|
51
|
+
max?: number;
|
|
52
|
+
}): unknown;
|
|
53
|
+
}
|
|
54
|
+
export interface LevelingTrack {
|
|
55
|
+
readonly maxLevel: number;
|
|
56
|
+
readonly startLevel: number;
|
|
57
|
+
xpForLevel(level: number): number;
|
|
58
|
+
resolve(level: number, xp: number): LevelProgress;
|
|
59
|
+
grantXp(access: LevelingStatAccess, userId: string, amount: number, onLevelUp?: (level: number) => void): number;
|
|
60
|
+
}
|
|
61
|
+
export declare function leveling(config: LevelingConfig): LevelingTrack;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
function rawCurve(def, x) {
|
|
2
|
+
switch (def.kind) {
|
|
3
|
+
case "const":
|
|
4
|
+
return def.value;
|
|
5
|
+
case "linear":
|
|
6
|
+
return def.base + def.per * x;
|
|
7
|
+
case "power":
|
|
8
|
+
return def.base * x ** def.exponent;
|
|
9
|
+
case "geometric":
|
|
10
|
+
return def.base * def.ratio ** x;
|
|
11
|
+
case "steps": {
|
|
12
|
+
const i = Math.max(0, Math.min(def.values.length - 1, Math.floor(x)));
|
|
13
|
+
return def.values[i];
|
|
14
|
+
}
|
|
15
|
+
case "piecewise":
|
|
16
|
+
return interpolate(def.points, x);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function interpolate(points, x) {
|
|
20
|
+
if (points.length === 0)
|
|
21
|
+
return 0;
|
|
22
|
+
if (x <= points[0][0])
|
|
23
|
+
return points[0][1];
|
|
24
|
+
const last = points[points.length - 1];
|
|
25
|
+
if (x >= last[0])
|
|
26
|
+
return last[1];
|
|
27
|
+
for (let i = 1; i < points.length; i += 1) {
|
|
28
|
+
const [x1, y1] = points[i];
|
|
29
|
+
if (x <= x1) {
|
|
30
|
+
const [x0, y0] = points[i - 1];
|
|
31
|
+
const span = x1 - x0;
|
|
32
|
+
const t = span === 0 ? 0 : (x - x0) / span;
|
|
33
|
+
return y0 + t * (y1 - y0);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return last[1];
|
|
37
|
+
}
|
|
38
|
+
export function evalCurve(spec, x) {
|
|
39
|
+
let y = rawCurve(spec, x);
|
|
40
|
+
if (spec.min !== undefined)
|
|
41
|
+
y = Math.max(spec.min, y);
|
|
42
|
+
if (spec.max !== undefined)
|
|
43
|
+
y = Math.min(spec.max, y);
|
|
44
|
+
switch (spec.round) {
|
|
45
|
+
case "floor":
|
|
46
|
+
return Math.floor(y);
|
|
47
|
+
case "ceil":
|
|
48
|
+
return Math.ceil(y);
|
|
49
|
+
case "round":
|
|
50
|
+
return Math.round(y);
|
|
51
|
+
default:
|
|
52
|
+
return y;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function curve(spec) {
|
|
56
|
+
return (x) => evalCurve(spec, x);
|
|
57
|
+
}
|
|
58
|
+
export function leveling(config) {
|
|
59
|
+
const maxLevel = config.maxLevel;
|
|
60
|
+
const startLevel = config.startLevel ?? 1;
|
|
61
|
+
const xpStat = config.xpStat ?? "xp";
|
|
62
|
+
const levelStat = config.levelStat ?? "level";
|
|
63
|
+
const xpForLevel = (level) => evalCurve(config.xpForLevel, level);
|
|
64
|
+
const resolve = (level, xp) => {
|
|
65
|
+
let currentLevel = level;
|
|
66
|
+
let currentXp = xp;
|
|
67
|
+
let levelsGained = 0;
|
|
68
|
+
while (currentLevel < maxLevel && currentXp >= xpForLevel(currentLevel)) {
|
|
69
|
+
currentXp -= xpForLevel(currentLevel);
|
|
70
|
+
currentLevel += 1;
|
|
71
|
+
levelsGained += 1;
|
|
72
|
+
}
|
|
73
|
+
if (currentLevel >= maxLevel)
|
|
74
|
+
currentXp = 0;
|
|
75
|
+
return {
|
|
76
|
+
level: currentLevel,
|
|
77
|
+
xp: currentXp,
|
|
78
|
+
xpMax: xpForLevel(Math.min(currentLevel, maxLevel)),
|
|
79
|
+
levelsGained,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const grantXp = (access, userId, amount, onLevelUp) => {
|
|
83
|
+
const xp = access.get(userId, xpStat);
|
|
84
|
+
const level = access.get(userId, levelStat);
|
|
85
|
+
if (xp === null || level === null)
|
|
86
|
+
return 0;
|
|
87
|
+
const progress = resolve(level.current, xp.current + amount);
|
|
88
|
+
access.set(userId, xpStat, { current: progress.xp, max: progress.xpMax });
|
|
89
|
+
if (progress.levelsGained > 0) {
|
|
90
|
+
access.set(userId, levelStat, { current: progress.level });
|
|
91
|
+
onLevelUp?.(progress.level);
|
|
92
|
+
}
|
|
93
|
+
return progress.levelsGained;
|
|
94
|
+
};
|
|
95
|
+
return { maxLevel, startLevel, xpForLevel, resolve, grantXp };
|
|
96
|
+
}
|
package/dist/game/quest.d.ts
CHANGED
|
@@ -91,3 +91,42 @@ export interface QuestJournal {
|
|
|
91
91
|
hydrate(userId: string, data: QuestSnapshotEntry[]): void;
|
|
92
92
|
}
|
|
93
93
|
export declare function createQuestJournal(deps: QuestJournalDeps): QuestJournal;
|
|
94
|
+
export interface QuestAcceptOptions {
|
|
95
|
+
hasUnlock?(id: string): boolean;
|
|
96
|
+
}
|
|
97
|
+
export interface QuestTurnIn {
|
|
98
|
+
state: QuestSnapshotEntry[];
|
|
99
|
+
rewards: QuestRewards | null;
|
|
100
|
+
}
|
|
101
|
+
export interface QuestEvaluator {
|
|
102
|
+
has(questId: string): boolean;
|
|
103
|
+
get(questId: string): QuestDef | null;
|
|
104
|
+
canAccept(state: readonly QuestSnapshotEntry[], questId: string, options?: QuestAcceptOptions): {
|
|
105
|
+
reason: string;
|
|
106
|
+
} | null;
|
|
107
|
+
accept(state: readonly QuestSnapshotEntry[], questId: string, options?: QuestAcceptOptions): QuestSnapshotEntry[] | {
|
|
108
|
+
reason: string;
|
|
109
|
+
};
|
|
110
|
+
abandon(state: readonly QuestSnapshotEntry[], questId: string): QuestSnapshotEntry[];
|
|
111
|
+
progress(state: readonly QuestSnapshotEntry[], questId: string, objectiveId: string, delta: number): QuestSnapshotEntry[];
|
|
112
|
+
canTurnIn(state: readonly QuestSnapshotEntry[], questId: string): {
|
|
113
|
+
reason: string;
|
|
114
|
+
} | null;
|
|
115
|
+
turnIn(state: readonly QuestSnapshotEntry[], questId: string): QuestTurnIn | {
|
|
116
|
+
reason: string;
|
|
117
|
+
};
|
|
118
|
+
grant(state: readonly QuestSnapshotEntry[], questId: string, options?: {
|
|
119
|
+
completed?: boolean;
|
|
120
|
+
}): QuestSnapshotEntry[];
|
|
121
|
+
revoke(state: readonly QuestSnapshotEntry[], questId: string): QuestSnapshotEntry[];
|
|
122
|
+
creditKill(state: readonly QuestSnapshotEntry[], targetCatalogId: string): QuestSnapshotEntry[];
|
|
123
|
+
creditCollect(state: readonly QuestSnapshotEntry[], itemId: string, count: number): QuestSnapshotEntry[];
|
|
124
|
+
list(state: readonly QuestSnapshotEntry[]): QuestInstance[];
|
|
125
|
+
}
|
|
126
|
+
export declare function applyQuestRewards(rewards: QuestRewards, appliers: {
|
|
127
|
+
grantXp?(amount: number): void;
|
|
128
|
+
grantEconomy?(currencyId: string, amount: number): void;
|
|
129
|
+
grantItem?(inventoryId: string, itemId: string, count: number): void;
|
|
130
|
+
grantUnlock?(unlockId: string): void;
|
|
131
|
+
}): void;
|
|
132
|
+
export declare function createQuestEvaluator(defs: QuestDef[] | Record<string, QuestDef>): QuestEvaluator;
|
package/dist/game/quest.js
CHANGED
|
@@ -226,3 +226,198 @@ export function createQuestJournal(deps) {
|
|
|
226
226
|
},
|
|
227
227
|
};
|
|
228
228
|
}
|
|
229
|
+
export function applyQuestRewards(rewards, appliers) {
|
|
230
|
+
if (rewards.xp)
|
|
231
|
+
appliers.grantXp?.(rewards.xp.amount);
|
|
232
|
+
for (const [currencyId, amount] of Object.entries(rewards.economy ?? {})) {
|
|
233
|
+
appliers.grantEconomy?.(currencyId, amount);
|
|
234
|
+
}
|
|
235
|
+
for (const entry of rewards.items ?? []) {
|
|
236
|
+
appliers.grantItem?.(entry.inventory, entry.item, entry.count);
|
|
237
|
+
}
|
|
238
|
+
for (const unlockId of rewards.unlocks ?? []) {
|
|
239
|
+
appliers.grantUnlock?.(unlockId);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function toWorking(state) {
|
|
243
|
+
const working = new Map();
|
|
244
|
+
for (const entry of state) {
|
|
245
|
+
working.set(entry.questId, {
|
|
246
|
+
status: entry.status,
|
|
247
|
+
progress: new Map(Object.entries(entry.progress)),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return working;
|
|
251
|
+
}
|
|
252
|
+
function toSnapshot(working) {
|
|
253
|
+
return Array.from(working, ([questId, state]) => ({
|
|
254
|
+
questId,
|
|
255
|
+
status: state.status,
|
|
256
|
+
progress: Object.fromEntries(state.progress),
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
export function createQuestEvaluator(defs) {
|
|
260
|
+
const catalog = new Map();
|
|
261
|
+
for (const def of Array.isArray(defs) ? defs : Object.values(defs))
|
|
262
|
+
catalog.set(def.id, def);
|
|
263
|
+
function canAcceptWorking(working, questId, options) {
|
|
264
|
+
const def = catalog.get(questId);
|
|
265
|
+
if (def === undefined)
|
|
266
|
+
return { reason: `unknown quest "${questId}"` };
|
|
267
|
+
const state = working.get(questId);
|
|
268
|
+
if (state?.status === "active")
|
|
269
|
+
return { reason: `quest "${questId}" already active` };
|
|
270
|
+
if (state?.status === "completed")
|
|
271
|
+
return { reason: `quest "${questId}" already completed` };
|
|
272
|
+
for (const requirementId of def.requires ?? []) {
|
|
273
|
+
const met = working.get(requirementId)?.status === "completed" ||
|
|
274
|
+
(options?.hasUnlock?.(requirementId) ?? false);
|
|
275
|
+
if (!met)
|
|
276
|
+
return { reason: `quest "${questId}" requires "${requirementId}"` };
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
function progressWorking(working, questId, objectiveId, delta) {
|
|
281
|
+
const def = catalog.get(questId);
|
|
282
|
+
const state = working.get(questId);
|
|
283
|
+
if (def === undefined || state === undefined || state.status !== "active")
|
|
284
|
+
return;
|
|
285
|
+
const objective = def.objectives.find((candidate) => candidate.id === objectiveId);
|
|
286
|
+
if (objective === undefined)
|
|
287
|
+
return;
|
|
288
|
+
const previous = state.progress.get(objectiveId) ?? 0;
|
|
289
|
+
const next = clamp(previous + delta, 0, objective.count);
|
|
290
|
+
if (next !== previous)
|
|
291
|
+
state.progress.set(objectiveId, next);
|
|
292
|
+
}
|
|
293
|
+
function creditWorking(working, predicate, delta) {
|
|
294
|
+
for (const [questId, state] of working) {
|
|
295
|
+
if (state.status !== "active")
|
|
296
|
+
continue;
|
|
297
|
+
const def = catalog.get(questId);
|
|
298
|
+
if (def === undefined)
|
|
299
|
+
continue;
|
|
300
|
+
for (const objective of def.objectives) {
|
|
301
|
+
if (predicate(objective))
|
|
302
|
+
progressWorking(working, questId, objective.id, delta);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function canTurnInWorking(working, questId) {
|
|
307
|
+
const def = catalog.get(questId);
|
|
308
|
+
if (def === undefined)
|
|
309
|
+
return { reason: `unknown quest "${questId}"` };
|
|
310
|
+
const state = working.get(questId);
|
|
311
|
+
if (state === undefined || state.status !== "active") {
|
|
312
|
+
return { reason: `quest "${questId}" is not active` };
|
|
313
|
+
}
|
|
314
|
+
for (const objective of def.objectives) {
|
|
315
|
+
if ((state.progress.get(objective.id) ?? 0) < objective.count) {
|
|
316
|
+
return { reason: `objective "${objective.id}" incomplete` };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
has(questId) {
|
|
323
|
+
return catalog.has(questId);
|
|
324
|
+
},
|
|
325
|
+
get(questId) {
|
|
326
|
+
return catalog.get(questId) ?? null;
|
|
327
|
+
},
|
|
328
|
+
canAccept(state, questId, options) {
|
|
329
|
+
return canAcceptWorking(toWorking(state), questId, options);
|
|
330
|
+
},
|
|
331
|
+
accept(state, questId, options) {
|
|
332
|
+
const working = toWorking(state);
|
|
333
|
+
const denied = canAcceptWorking(working, questId, options);
|
|
334
|
+
if (denied !== null)
|
|
335
|
+
return denied;
|
|
336
|
+
working.set(questId, { status: "active", progress: new Map() });
|
|
337
|
+
return toSnapshot(working);
|
|
338
|
+
},
|
|
339
|
+
abandon(state, questId) {
|
|
340
|
+
const working = toWorking(state);
|
|
341
|
+
if (working.get(questId)?.status === "active")
|
|
342
|
+
working.delete(questId);
|
|
343
|
+
return toSnapshot(working);
|
|
344
|
+
},
|
|
345
|
+
progress(state, questId, objectiveId, delta) {
|
|
346
|
+
const working = toWorking(state);
|
|
347
|
+
progressWorking(working, questId, objectiveId, delta);
|
|
348
|
+
return toSnapshot(working);
|
|
349
|
+
},
|
|
350
|
+
canTurnIn(state, questId) {
|
|
351
|
+
return canTurnInWorking(toWorking(state), questId);
|
|
352
|
+
},
|
|
353
|
+
turnIn(state, questId) {
|
|
354
|
+
const working = toWorking(state);
|
|
355
|
+
const denied = canTurnInWorking(working, questId);
|
|
356
|
+
if (denied !== null)
|
|
357
|
+
return denied;
|
|
358
|
+
const def = catalog.get(questId);
|
|
359
|
+
working.get(questId).status = "completed";
|
|
360
|
+
for (const nextQuestId of def.rewards?.quests ?? []) {
|
|
361
|
+
if (canAcceptWorking(working, nextQuestId) === null) {
|
|
362
|
+
working.set(nextQuestId, { status: "active", progress: new Map() });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return { state: toSnapshot(working), rewards: def.rewards ?? null };
|
|
366
|
+
},
|
|
367
|
+
grant(state, questId, options) {
|
|
368
|
+
const def = catalog.get(questId);
|
|
369
|
+
if (def === undefined)
|
|
370
|
+
return state.slice();
|
|
371
|
+
const working = toWorking(state);
|
|
372
|
+
const completed = options?.completed ?? false;
|
|
373
|
+
const progressMap = new Map();
|
|
374
|
+
if (completed) {
|
|
375
|
+
for (const objective of def.objectives)
|
|
376
|
+
progressMap.set(objective.id, objective.count);
|
|
377
|
+
}
|
|
378
|
+
working.set(questId, {
|
|
379
|
+
status: completed ? "completed" : "active",
|
|
380
|
+
progress: progressMap,
|
|
381
|
+
});
|
|
382
|
+
return toSnapshot(working);
|
|
383
|
+
},
|
|
384
|
+
revoke(state, questId) {
|
|
385
|
+
const working = toWorking(state);
|
|
386
|
+
working.delete(questId);
|
|
387
|
+
return toSnapshot(working);
|
|
388
|
+
},
|
|
389
|
+
creditKill(state, targetCatalogId) {
|
|
390
|
+
const working = toWorking(state);
|
|
391
|
+
creditWorking(working, (objective) => objective.kind === "kill" && objective.target === targetCatalogId, 1);
|
|
392
|
+
return toSnapshot(working);
|
|
393
|
+
},
|
|
394
|
+
creditCollect(state, itemId, count) {
|
|
395
|
+
const working = toWorking(state);
|
|
396
|
+
creditWorking(working, (objective) => objective.kind === "collect" && objective.item === itemId, count);
|
|
397
|
+
return toSnapshot(working);
|
|
398
|
+
},
|
|
399
|
+
list(state) {
|
|
400
|
+
const instances = [];
|
|
401
|
+
for (const entry of state) {
|
|
402
|
+
const def = catalog.get(entry.questId);
|
|
403
|
+
if (def === undefined)
|
|
404
|
+
continue;
|
|
405
|
+
instances.push({
|
|
406
|
+
questId: entry.questId,
|
|
407
|
+
status: entry.status,
|
|
408
|
+
objectives: def.objectives.map((objective) => {
|
|
409
|
+
const current = entry.progress[objective.id] ?? 0;
|
|
410
|
+
return {
|
|
411
|
+
id: objective.id,
|
|
412
|
+
kind: objective.kind,
|
|
413
|
+
count: objective.count,
|
|
414
|
+
progress: current,
|
|
415
|
+
complete: current >= objective.count,
|
|
416
|
+
};
|
|
417
|
+
}),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return instances;
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
package/dist/game/trade.d.ts
CHANGED
|
@@ -32,4 +32,27 @@ export interface TradeSystem {
|
|
|
32
32
|
sell(itemId: string, count: number | undefined, party: TradeParty): TradeRejection | null;
|
|
33
33
|
tradableAt(shopId: string, allItemIds: string[]): string[];
|
|
34
34
|
}
|
|
35
|
+
export declare function canAffordCosts(balances: Record<string, number>, costs: Record<string, number>): string | null;
|
|
36
|
+
export interface TradeOutcome {
|
|
37
|
+
itemId: string;
|
|
38
|
+
count: number;
|
|
39
|
+
currency: Record<string, number>;
|
|
40
|
+
}
|
|
41
|
+
export type TradeResolution = {
|
|
42
|
+
status: "ok";
|
|
43
|
+
outcome: TradeOutcome;
|
|
44
|
+
} | {
|
|
45
|
+
status: "rejected";
|
|
46
|
+
reason: string;
|
|
47
|
+
};
|
|
48
|
+
export declare function resolveBuy(itemId: string, trade: TradeField | null | undefined, shopId: string, count: number, balances: Record<string, number>): TradeResolution;
|
|
49
|
+
export declare function resolveSell(itemId: string, trade: TradeField | null | undefined, count: number): TradeResolution;
|
|
50
|
+
export declare function applyTradeOutcome(outcome: TradeOutcome, appliers: {
|
|
51
|
+
adjustItem(itemId: string, count: number): {
|
|
52
|
+
reason: string;
|
|
53
|
+
} | null;
|
|
54
|
+
adjustCurrency(delta: Record<string, number>): void;
|
|
55
|
+
}): {
|
|
56
|
+
reason: string;
|
|
57
|
+
} | null;
|
|
35
58
|
export declare function createTradeSystem(deps: TradeSystemDeps): TradeSystem;
|
package/dist/game/trade.js
CHANGED
|
@@ -4,21 +4,63 @@ function scale(amounts, count) {
|
|
|
4
4
|
scaled[currency] = amount * count;
|
|
5
5
|
return scaled;
|
|
6
6
|
}
|
|
7
|
+
function negate(amounts) {
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const [currency, amount] of Object.entries(amounts))
|
|
10
|
+
out[currency] = -amount;
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
export function canAffordCosts(balances, costs) {
|
|
14
|
+
for (const [currency, amount] of Object.entries(costs)) {
|
|
15
|
+
if ((balances[currency] ?? 0) < amount)
|
|
16
|
+
return "insufficient-funds";
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function buyEligibility(trade, shopId) {
|
|
21
|
+
if (!trade?.buy)
|
|
22
|
+
return "not-purchasable";
|
|
23
|
+
if (!trade.shops?.includes(shopId))
|
|
24
|
+
return "not-stocked";
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
function sellEligibility(trade) {
|
|
28
|
+
return trade?.sell ? null : "not-sellable";
|
|
29
|
+
}
|
|
30
|
+
export function resolveBuy(itemId, trade, shopId, count, balances) {
|
|
31
|
+
const ineligible = buyEligibility(trade, shopId);
|
|
32
|
+
if (ineligible)
|
|
33
|
+
return { status: "rejected", reason: ineligible };
|
|
34
|
+
const costs = scale(trade.buy, count);
|
|
35
|
+
const unaffordable = canAffordCosts(balances, costs);
|
|
36
|
+
if (unaffordable)
|
|
37
|
+
return { status: "rejected", reason: unaffordable };
|
|
38
|
+
return { status: "ok", outcome: { itemId, count, currency: negate(costs) } };
|
|
39
|
+
}
|
|
40
|
+
export function resolveSell(itemId, trade, count) {
|
|
41
|
+
const ineligible = sellEligibility(trade);
|
|
42
|
+
if (ineligible)
|
|
43
|
+
return { status: "rejected", reason: ineligible };
|
|
44
|
+
return { status: "ok", outcome: { itemId, count: -count, currency: scale(trade.sell, count) } };
|
|
45
|
+
}
|
|
46
|
+
export function applyTradeOutcome(outcome, appliers) {
|
|
47
|
+
const itemResult = appliers.adjustItem(outcome.itemId, outcome.count);
|
|
48
|
+
if (itemResult)
|
|
49
|
+
return itemResult;
|
|
50
|
+
appliers.adjustCurrency(outcome.currency);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
7
53
|
export function createTradeSystem(deps) {
|
|
8
54
|
const { resolveTrade, wallet, inventory } = deps;
|
|
9
55
|
function canBuy(itemId, shopId, count = 1) {
|
|
10
56
|
const trade = resolveTrade(itemId);
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return "not-stocked";
|
|
57
|
+
const ineligible = buyEligibility(trade, shopId);
|
|
58
|
+
if (ineligible)
|
|
59
|
+
return ineligible;
|
|
15
60
|
return wallet.canAfford(scale(trade.buy, count));
|
|
16
61
|
}
|
|
17
62
|
function canSell(itemId, _count = 1) {
|
|
18
|
-
|
|
19
|
-
if (!trade?.sell)
|
|
20
|
-
return "not-sellable";
|
|
21
|
-
return null;
|
|
63
|
+
return sellEligibility(resolveTrade(itemId));
|
|
22
64
|
}
|
|
23
65
|
return {
|
|
24
66
|
canBuy,
|