@jgengine/core 0.3.0 → 0.5.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 ADDED
@@ -0,0 +1,42 @@
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.5.0
19
+
20
+ An additive release: **every 0.4.0 API is unchanged**, so upgrading is only a
21
+ version bump. New pure primitives across progression, inventory slots, world
22
+ geometry, and React store bindings.
23
+
24
+ ### Migrate
25
+
26
+ - Bump every `@jgengine/*` dependency to `^0.5.0` (the eight packages version in lockstep).
27
+ - No code change is required — 0.5.0 adds surface, it doesn't move or remove any.
28
+ - 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.
29
+
30
+ ### Added
31
+
32
+ - `@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.
33
+ - `@jgengine/core/inventory/slotModel` — pure slot-grid primitives (`createSlots`, `placeAt`, `removeAt`, `moveSlot`).
34
+ - `@jgengine/core/world/geometry`, `/world/interiors`, `/world/placement` — pure world primitives: grid snapping, footprint AABBs and overlap, interior/exterior spaces, and placement validation.
35
+ - `@jgengine/react/engineStore` — raw-store React bindings (`useEngineState`, `useEngineStore`, `useEngineEvent`).
36
+ - Pure/functional tiers for the `trade`, `unlocks`, `quest`, and `feed` verbs in `@jgengine/core/game`.
37
+
38
+ ## 0.4.0
39
+
40
+ Baseline release: the eight `@jgengine/*` packages (core, ws, sql, react,
41
+ convex, node, shell, assets) as the first tracked version. No migration —
42
+ this is the floor changelog entries are measured against.
@@ -13,12 +13,20 @@ export interface RaycastHit {
13
13
  at: EntityPosition;
14
14
  }
15
15
  export type Raycast = (from: string, aim: Aim, range: number) => RaycastHit[];
16
+ export interface ProjectileSettleReport {
17
+ from: string;
18
+ origin: EntityPosition;
19
+ at: EntityPosition;
20
+ effect: string;
21
+ hit: boolean;
22
+ }
16
23
  export interface ProjectileSystemDeps {
17
24
  effects: EffectSystem;
18
25
  spatial: CombatSpatialDeps;
19
26
  getStat(itemId: string, stat: string): number | null;
20
27
  raycast?: Raycast;
21
28
  now?: () => number;
29
+ onSettle?(report: ProjectileSettleReport): void;
22
30
  }
23
31
  export interface ProjectilePrediction {
24
32
  hits: {
@@ -132,8 +132,11 @@ export function createProjectileSystem(deps) {
132
132
  return { status: "rejected", shotId, reason: "already-settled" };
133
133
  shot.settled = true;
134
134
  const { input } = shot;
135
+ const origin = shotOrigin(input.from, input.aim) ?? [0, 0, 0];
135
136
  if (isBallistic(input.via)) {
136
- return { status: "settled", shotId, at: ballisticSettlePoint(input), hits: [] };
137
+ const at = ballisticSettlePoint(input);
138
+ deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: false });
139
+ return { status: "settled", shotId, at, hits: [] };
137
140
  }
138
141
  const { visible } = predictHits(input);
139
142
  const receivable = visible.filter((hit) => deps.effects.canReceive(hit.instanceId, input.effect) === null);
@@ -150,7 +153,9 @@ export function createProjectileSystem(deps) {
150
153
  }));
151
154
  }
152
155
  }
153
- return { status: "settled", shotId, at: settledAt(input, receivable), hits };
156
+ const at = settledAt(input, receivable);
157
+ deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: receivable.length > 0 });
158
+ return { status: "settled", shotId, at, hits };
154
159
  },
155
160
  };
156
161
  }
@@ -66,8 +66,23 @@ export interface StatLevelUpEvent {
66
66
  stat: string;
67
67
  level: number;
68
68
  }
69
+ export interface EntityFloatTextEvent {
70
+ instanceId?: string;
71
+ position: [number, number, number];
72
+ text: string;
73
+ kind: string;
74
+ amount?: number;
75
+ }
76
+ export interface ProjectileSettledEvent {
77
+ from: string;
78
+ origin: [number, number, number];
79
+ at: [number, number, number];
80
+ effect: string;
81
+ hit: boolean;
82
+ }
69
83
  export interface GameEventMap {
70
84
  "entity.died": EntityDiedEvent;
85
+ "entity.floatText": EntityFloatTextEvent;
71
86
  "loot.granted": LootGrantedEvent;
72
87
  "inventory.added": InventoryAddedEvent;
73
88
  "quest.accepted": QuestAcceptedEvent;
@@ -77,6 +92,7 @@ export interface GameEventMap {
77
92
  "social.party.joined": SocialPartyJoinedEvent;
78
93
  "social.party.left": SocialPartyLeftEvent;
79
94
  "stat.levelUp": StatLevelUpEvent;
95
+ "projectile.settled": ProjectileSettledEvent;
80
96
  }
81
97
  export type GameEventHandler<TPayload> = (payload: TPayload) => void;
82
98
  export interface GameEvents<TMap extends GameEventMap = GameEventMap> {
@@ -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
- const buffer = buffers.get(action) ?? [];
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
- const buffer = buffers.get(action) ?? [];
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);
@@ -15,7 +15,23 @@ export interface CameraFollowState {
15
15
  };
16
16
  distance: number;
17
17
  }
18
+ export interface FirstPersonCameraConfig {
19
+ /** Camera height above the followed entity's origin. Default 1.6. */
20
+ eyeHeight?: number;
21
+ /** Mouse-look radians per pixel of pointer movement. Default 0.0025. */
22
+ sensitivity?: number;
23
+ /** Clamp on look pitch in radians (± from horizon). Default 1.45. */
24
+ maxPitch?: number;
25
+ /** Show the centered crosshair overlay. Default true. */
26
+ reticle?: boolean;
27
+ /** Render a simple first-person weapon in view. Default true. */
28
+ viewmodel?: boolean;
29
+ }
18
30
  export interface GameCameraConfig {
31
+ /** "third" mounts the orbit camera (default); "first" mounts pointer-lock mouse-look. */
32
+ perspective?: "third" | "first";
33
+ /** First-person tuning; only read when perspective is "first". */
34
+ firstPerson?: FirstPersonCameraConfig;
19
35
  minDistance?: number;
20
36
  maxDistance?: number;
21
37
  targetHeight?: number;
@@ -41,6 +57,9 @@ export interface GameCameraConfig {
41
57
  targetSmoothing?: number;
42
58
  dragTargetSmoothing?: number;
43
59
  distanceSmoothing?: number;
60
+ /** Vertical look clamp (three.js polar angle, radians): 0 = top-down over the head, PI/2 = level, >PI/2 = look up from below. Widen for top-down or vertical aim; unset keeps the standard chase feel. */
61
+ minPolarAngle?: number;
62
+ maxPolarAngle?: number;
44
63
  }
45
64
  export interface EntitySpriteConfig {
46
65
  url: string;
@@ -48,6 +67,11 @@ export interface EntitySpriteConfig {
48
67
  height: number;
49
68
  y: number;
50
69
  }
70
+ export interface ModelConfig {
71
+ url: string;
72
+ scale?: number;
73
+ y?: number;
74
+ }
51
75
  export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown> {
52
76
  game: GameDefinition;
53
77
  content: GameContextContent;
@@ -57,10 +81,18 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown> {
57
81
  WorldOverlay?: TWorldOverlay;
58
82
  /** Billboard sprites keyed by entity kind name; unmatched entities get primitive markers. */
59
83
  entitySprites?: Record<string, EntitySpriteConfig>;
84
+ /** 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. */
85
+ entityModels?: Record<string, string | ModelConfig>;
86
+ /** GLB models keyed by object catalog id; a string resolves via game.assets, a ModelConfig renders directly. Replaces the colored box when present. */
87
+ objectModels?: Record<string, string | ModelConfig>;
60
88
  /** Optional scroll-selected hotbar index for primary ability (mouse0). */
61
89
  hotbarSelection?: () => number;
62
90
  /** Positioned proximity prompts for the interact key + HUD; single source shared with useActivePrompt. */
63
91
  prompts?: (ctx: GameContext) => readonly PositionedPrompt[];
64
- /** Third-person orbit camera tuning for the dev game player shell. */
92
+ /** Camera tuning (perspective, orbit, first-person) for the dev game player shell. */
65
93
  camera?: GameCameraConfig;
94
+ /** Opt in to world-space health bars floating over non-local entities that carry the stat. */
95
+ worldHealthBars?: boolean | {
96
+ statId?: string;
97
+ };
66
98
  }
@@ -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
+ }
@@ -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;
@@ -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
+ }
@@ -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;
@@ -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
- if (!trade?.buy)
12
- return "not-purchasable";
13
- if (!trade.shops?.includes(shopId))
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
- const trade = resolveTrade(itemId);
19
- if (!trade?.sell)
20
- return "not-sellable";
21
- return null;
63
+ return sellEligibility(resolveTrade(itemId));
22
64
  }
23
65
  return {
24
66
  canBuy,
@@ -2,6 +2,15 @@ export interface UnlockDef {
2
2
  id: string;
3
3
  category?: string;
4
4
  }
5
+ export type UnlockState = readonly string[];
6
+ export declare function hasUnlock(granted: UnlockState, unlockId: string): boolean;
7
+ export declare function grantUnlock(granted: UnlockState, unlockId: string): string[];
8
+ export declare function unlockTree(defs: readonly UnlockDef[], categoryId: string): UnlockDef[];
9
+ export interface UnlockCatalog {
10
+ has(unlockId: string): boolean;
11
+ tree(categoryId: string): UnlockDef[];
12
+ }
13
+ export declare function createUnlockCatalog(defs?: readonly UnlockDef[]): UnlockCatalog;
5
14
  export interface Unlocks {
6
15
  has(userId: string, unlockId: string): boolean;
7
16
  grant(userId: string, unlockId: string): void;
@@ -1,3 +1,21 @@
1
+ export function hasUnlock(granted, unlockId) {
2
+ return granted.includes(unlockId);
3
+ }
4
+ export function grantUnlock(granted, unlockId) {
5
+ return granted.includes(unlockId) ? granted.slice() : [...granted, unlockId];
6
+ }
7
+ export function unlockTree(defs, categoryId) {
8
+ return defs.filter((def) => def.category === categoryId);
9
+ }
10
+ export function createUnlockCatalog(defs = []) {
11
+ const byId = new Map();
12
+ for (const def of defs)
13
+ byId.set(def.id, def);
14
+ return {
15
+ has: (unlockId) => byId.has(unlockId),
16
+ tree: (categoryId) => unlockTree(defs, categoryId),
17
+ };
18
+ }
1
19
  export function createUnlocks(defs = []) {
2
20
  const definitions = new Map();
3
21
  for (const def of defs)
@@ -22,7 +40,7 @@ export function createUnlocks(defs = []) {
22
40
  return Array.from(granted.get(userId) ?? []);
23
41
  },
24
42
  tree(categoryId) {
25
- return Array.from(definitions.values()).filter((def) => def.category === categoryId);
43
+ return unlockTree(Array.from(definitions.values()), categoryId);
26
44
  },
27
45
  snapshot(userId) {
28
46
  return Array.from(granted.get(userId) ?? []);
@@ -0,0 +1,19 @@
1
+ export type Slot<T> = T | null;
2
+ export type SlotGrid<T> = readonly Slot<T>[];
3
+ export type SlotResult<T> = {
4
+ status: "ok";
5
+ grid: Slot<T>[];
6
+ } | {
7
+ status: "rejected";
8
+ reason: "invalid-slot" | "slot-occupied" | "empty-slot";
9
+ };
10
+ export declare function createSlots<T>(size: number): Slot<T>[];
11
+ export declare function normalizeSlots<T>(values: SlotGrid<T>, size: number): Slot<T>[];
12
+ export declare function placeAt<T>(grid: SlotGrid<T>, index: number, value: T): SlotResult<T>;
13
+ export declare function removeAt<T>(grid: SlotGrid<T>, index: number): SlotResult<T>;
14
+ export declare function moveSlot<T>(grid: SlotGrid<T>, from: number, to: number): SlotResult<T>;
15
+ export declare function firstEmpty<T>(grid: SlotGrid<T>): number | null;
16
+ export declare function insertFirst<T>(grid: SlotGrid<T>, value: T): SlotResult<T>;
17
+ export declare function compactSlots<T>(grid: SlotGrid<T>): Slot<T>[];
18
+ export declare function countFilled<T>(grid: SlotGrid<T>): number;
19
+ export declare function indexOfSlot<T>(grid: SlotGrid<T>, match: (value: T, index: number) => boolean): number | null;
@@ -0,0 +1,76 @@
1
+ export function createSlots(size) {
2
+ return new Array(Math.max(0, size)).fill(null);
3
+ }
4
+ export function normalizeSlots(values, size) {
5
+ const grid = createSlots(size);
6
+ for (let index = 0; index < size; index++)
7
+ grid[index] = values[index] ?? null;
8
+ return grid;
9
+ }
10
+ function inRange(grid, index) {
11
+ return Number.isInteger(index) && index >= 0 && index < grid.length;
12
+ }
13
+ export function placeAt(grid, index, value) {
14
+ if (!inRange(grid, index))
15
+ return { status: "rejected", reason: "invalid-slot" };
16
+ if (grid[index] !== null)
17
+ return { status: "rejected", reason: "slot-occupied" };
18
+ const next = grid.slice();
19
+ next[index] = value;
20
+ return { status: "ok", grid: next };
21
+ }
22
+ export function removeAt(grid, index) {
23
+ if (!inRange(grid, index))
24
+ return { status: "rejected", reason: "invalid-slot" };
25
+ if (grid[index] === null)
26
+ return { status: "rejected", reason: "empty-slot" };
27
+ const next = grid.slice();
28
+ next[index] = null;
29
+ return { status: "ok", grid: next };
30
+ }
31
+ export function moveSlot(grid, from, to) {
32
+ if (!inRange(grid, from) || !inRange(grid, to))
33
+ return { status: "rejected", reason: "invalid-slot" };
34
+ if (grid[from] === null)
35
+ return { status: "rejected", reason: "empty-slot" };
36
+ const next = grid.slice();
37
+ const moved = next[from];
38
+ next[from] = next[to];
39
+ next[to] = moved;
40
+ return { status: "ok", grid: next };
41
+ }
42
+ export function firstEmpty(grid) {
43
+ for (let index = 0; index < grid.length; index++) {
44
+ if (grid[index] === null)
45
+ return index;
46
+ }
47
+ return null;
48
+ }
49
+ export function insertFirst(grid, value) {
50
+ const index = firstEmpty(grid);
51
+ if (index === null)
52
+ return { status: "rejected", reason: "slot-occupied" };
53
+ return placeAt(grid, index, value);
54
+ }
55
+ export function compactSlots(grid) {
56
+ const filled = grid.filter((slot) => slot !== null);
57
+ const next = createSlots(grid.length);
58
+ for (let index = 0; index < filled.length; index++)
59
+ next[index] = filled[index];
60
+ return next;
61
+ }
62
+ export function countFilled(grid) {
63
+ let count = 0;
64
+ for (const slot of grid)
65
+ if (slot !== null)
66
+ count += 1;
67
+ return count;
68
+ }
69
+ export function indexOfSlot(grid, match) {
70
+ for (let index = 0; index < grid.length; index++) {
71
+ const slot = grid[index];
72
+ if (slot !== null && match(slot, index))
73
+ return index;
74
+ }
75
+ return null;
76
+ }
@@ -0,0 +1,8 @@
1
+ export declare const VERSION = "0.5.0";
2
+ export interface ChangelogEntry {
3
+ migrate: readonly string[];
4
+ added: readonly string[];
5
+ changed: readonly string[];
6
+ removed: readonly string[];
7
+ }
8
+ export declare const CHANGELOG: Record<string, ChangelogEntry>;
@@ -0,0 +1,25 @@
1
+ export const VERSION = "0.5.0";
2
+ export const CHANGELOG = {
3
+ "0.5.0": {
4
+ migrate: [
5
+ "Bump every @jgengine/* dependency to ^0.5.0 (the eight packages version in lockstep).",
6
+ "0.5.0 is additive — all 0.4.0 APIs are unchanged, so no code change is required to upgrade.",
7
+ "Optional: replace a game's hand-rolled progression/curves.ts with leveling({ xpForLevel: { kind: 'power', base, exponent, round: 'floor' }, maxLevel }) from @jgengine/core/game/progression; its xpForLevel/resolve/grantXp are drop-ins and ctx.scene.entity.stats satisfies LevelingStatAccess with no adapter.",
8
+ ],
9
+ added: [
10
+ "@jgengine/core/game/progression — declarative scalar curves (curve/evalCurve: const, linear, power, geometric, steps, piecewise with round + min/max) and a leveling() XP->level track built on an xpForLevel curve.",
11
+ "@jgengine/core/inventory/slotModel — pure slot-grid primitives (createSlots, placeAt, removeAt, moveSlot).",
12
+ "@jgengine/core/world/geometry, /world/interiors, /world/placement — pure world primitives: grid snapping, footprint AABBs/overlap, interior/exterior spaces, and placement validation.",
13
+ "@jgengine/react/engineStore — raw-store React bindings (useEngineState, useEngineStore, useEngineEvent).",
14
+ "Pure/functional tiers for the trade, unlocks, quest, and feed verbs in @jgengine/core/game.",
15
+ ],
16
+ changed: [],
17
+ removed: [],
18
+ },
19
+ "0.4.0": {
20
+ migrate: [],
21
+ added: ["Baseline release: core, ws, sql, react, convex, node, shell, assets."],
22
+ changed: [],
23
+ removed: [],
24
+ },
25
+ };
@@ -63,6 +63,13 @@ export interface GameContextOptions<TAssetRef extends ModelAssetRef = ModelAsset
63
63
  export interface SceneObjectContext extends ObjectStore {
64
64
  catalog(instanceId: string): GameContextObjectEntry | null;
65
65
  }
66
+ export interface FloatTextInput {
67
+ instanceId?: string;
68
+ position?: [number, number, number];
69
+ text?: string;
70
+ kind?: string;
71
+ amount?: number;
72
+ }
66
73
  export interface SceneEntityContext {
67
74
  spawn(name: string, options?: SpawnOptions): string;
68
75
  despawn(instanceId: string): boolean;
@@ -70,6 +77,7 @@ export interface SceneEntityContext {
70
77
  get(instanceId: string): SceneEntity | null;
71
78
  list(): readonly SceneEntity[];
72
79
  stats: EntityStatsApi;
80
+ floatText(input: FloatTextInput): void;
73
81
  setTarget(fromId: string, toId: string | null): void;
74
82
  getTarget(fromId: string): string | null;
75
83
  cycleTarget(fromId: string, options?: CycleTargetOptions): string | null;
@@ -280,11 +280,67 @@ export function createGameContext(options) {
280
280
  }
281
281
  },
282
282
  }), ["applyEffect"], signal.notify);
283
+ function emitFloatText(input) {
284
+ const position = input.position ??
285
+ (input.instanceId === undefined ? undefined : entities.get(input.instanceId)?.position);
286
+ if (position === undefined)
287
+ return;
288
+ const text = input.text ?? (input.amount === undefined ? "" : String(Math.round(input.amount)));
289
+ const event = {
290
+ position: [position[0], position[1], position[2]],
291
+ text,
292
+ kind: input.kind ?? "info",
293
+ };
294
+ if (input.instanceId !== undefined)
295
+ event.instanceId = input.instanceId;
296
+ if (input.amount !== undefined)
297
+ event.amount = input.amount;
298
+ events.emit("entity.floatText", event);
299
+ }
300
+ function applyEffectAndFloat(input) {
301
+ const positionsBefore = new Map();
302
+ for (const entity of entities.list())
303
+ positionsBefore.set(entity.id, entity.position);
304
+ const results = effects.applyEffect(input);
305
+ for (const result of results) {
306
+ let total = 0;
307
+ for (const delta of result.applied)
308
+ total += delta.delta;
309
+ if (total === 0)
310
+ continue;
311
+ const position = entities.get(result.instanceId)?.position ?? positionsBefore.get(result.instanceId);
312
+ if (position === undefined)
313
+ continue;
314
+ const magnitude = Math.abs(total);
315
+ emitFloatText({
316
+ instanceId: result.instanceId,
317
+ position: [position[0], position[1], position[2]],
318
+ text: String(Math.round(magnitude)),
319
+ kind: total < 0 ? "damage" : "heal",
320
+ amount: magnitude,
321
+ });
322
+ }
323
+ return results;
324
+ }
325
+ const floatingEffects = {
326
+ canReceive: effects.canReceive,
327
+ preview: effects.preview,
328
+ applyEffect: applyEffectAndFloat,
329
+ };
283
330
  const projectiles = notifyAfter(createProjectileSystem({
284
- effects,
331
+ effects: floatingEffects,
285
332
  spatial: combatSpatial,
286
333
  getStat: weapon.getStat,
287
334
  now,
335
+ onSettle(report) {
336
+ events.emit("projectile.settled", {
337
+ from: report.from,
338
+ origin: [report.origin[0], report.origin[1], report.origin[2]],
339
+ at: [report.at[0], report.at[1], report.at[2]],
340
+ effect: report.effect,
341
+ hit: report.hit,
342
+ });
343
+ },
288
344
  }), ["fireProjectile", "settleProjectile"], signal.notify);
289
345
  const sceneObjects = {
290
346
  ...objects,
@@ -300,12 +356,13 @@ export function createGameContext(options) {
300
356
  get: entities.get,
301
357
  list: entities.list,
302
358
  stats: entityStats,
359
+ floatText: emitFloatText,
303
360
  setTarget: targeting.setTarget,
304
361
  getTarget: targeting.getTarget,
305
362
  cycleTarget: targeting.cycleTarget,
306
363
  canReceive: effects.canReceive,
307
364
  preview: effects.preview,
308
- effect: effects.applyEffect,
365
+ effect: applyEffectAndFloat,
309
366
  willHitProjectile: projectiles.willHitProjectile,
310
367
  fireProjectile: projectiles.fireProjectile,
311
368
  settleProjectile: projectiles.settleProjectile,
@@ -0,0 +1,23 @@
1
+ export type Vec2 = readonly [number, number];
2
+ export interface Footprint {
3
+ w: number;
4
+ d: number;
5
+ }
6
+ export interface Aabb {
7
+ minX: number;
8
+ minZ: number;
9
+ maxX: number;
10
+ maxZ: number;
11
+ }
12
+ export interface MoveOptions {
13
+ bounds?: Aabb;
14
+ radius?: number;
15
+ }
16
+ export declare function snapToGrid(point: Vec2, size: number): Vec2;
17
+ export declare function footprintAabb(center: Vec2, footprint: Footprint, quarterTurns?: number): Aabb;
18
+ export declare function expandAabb(aabb: Aabb, margin: number): Aabb;
19
+ export declare function aabbOverlap(a: Aabb, b: Aabb): boolean;
20
+ export declare function aabbContains(outer: Aabb, inner: Aabb): boolean;
21
+ export declare function pointInAabb(point: Vec2, aabb: Aabb): boolean;
22
+ export declare function clampToAabb(point: Vec2, aabb: Aabb): Vec2;
23
+ export declare function resolveMove(from: Vec2, delta: Vec2, blockers: readonly Aabb[], options?: MoveOptions): Vec2;
@@ -0,0 +1,75 @@
1
+ export function snapToGrid(point, size) {
2
+ if (!(size > 0))
3
+ return point;
4
+ return [Math.round(point[0] / size) * size, Math.round(point[1] / size) * size];
5
+ }
6
+ export function footprintAabb(center, footprint, quarterTurns = 0) {
7
+ const turned = ((quarterTurns % 2) + 2) % 2 === 1;
8
+ const halfW = (turned ? footprint.d : footprint.w) / 2;
9
+ const halfD = (turned ? footprint.w : footprint.d) / 2;
10
+ return {
11
+ minX: center[0] - halfW,
12
+ maxX: center[0] + halfW,
13
+ minZ: center[1] - halfD,
14
+ maxZ: center[1] + halfD,
15
+ };
16
+ }
17
+ export function expandAabb(aabb, margin) {
18
+ return {
19
+ minX: aabb.minX - margin,
20
+ maxX: aabb.maxX + margin,
21
+ minZ: aabb.minZ - margin,
22
+ maxZ: aabb.maxZ + margin,
23
+ };
24
+ }
25
+ export function aabbOverlap(a, b) {
26
+ return a.minX < b.maxX && a.maxX > b.minX && a.minZ < b.maxZ && a.maxZ > b.minZ;
27
+ }
28
+ export function aabbContains(outer, inner) {
29
+ return (inner.minX >= outer.minX &&
30
+ inner.maxX <= outer.maxX &&
31
+ inner.minZ >= outer.minZ &&
32
+ inner.maxZ <= outer.maxZ);
33
+ }
34
+ export function pointInAabb(point, aabb) {
35
+ return point[0] >= aabb.minX && point[0] <= aabb.maxX && point[1] >= aabb.minZ && point[1] <= aabb.maxZ;
36
+ }
37
+ export function clampToAabb(point, aabb) {
38
+ return [
39
+ Math.min(Math.max(point[0], aabb.minX), aabb.maxX),
40
+ Math.min(Math.max(point[1], aabb.minZ), aabb.maxZ),
41
+ ];
42
+ }
43
+ function resolveAxis(main, cross, delta, blockers, axisX) {
44
+ let next = main + delta;
45
+ if (delta === 0)
46
+ return next;
47
+ for (const blocker of blockers) {
48
+ const minMain = axisX ? blocker.minX : blocker.minZ;
49
+ const maxMain = axisX ? blocker.maxX : blocker.maxZ;
50
+ const minCross = axisX ? blocker.minZ : blocker.minX;
51
+ const maxCross = axisX ? blocker.maxZ : blocker.maxX;
52
+ if (cross <= minCross || cross >= maxCross)
53
+ continue;
54
+ if (delta > 0) {
55
+ if (main <= minMain && next > minMain)
56
+ next = minMain;
57
+ }
58
+ else {
59
+ if (main >= maxMain && next < maxMain)
60
+ next = maxMain;
61
+ }
62
+ }
63
+ return next;
64
+ }
65
+ export function resolveMove(from, delta, blockers, options = {}) {
66
+ const radius = options.radius ?? 0;
67
+ const expanded = radius === 0 ? blockers : blockers.map((blocker) => expandAabb(blocker, radius));
68
+ let x = resolveAxis(from[0], from[1], delta[0], expanded, true);
69
+ if (options.bounds)
70
+ x = Math.min(Math.max(x, options.bounds.minX + radius), options.bounds.maxX - radius);
71
+ let z = resolveAxis(from[1], x, delta[1], expanded, false);
72
+ if (options.bounds)
73
+ z = Math.min(Math.max(z, options.bounds.minZ + radius), options.bounds.maxZ - radius);
74
+ return [x, z];
75
+ }
@@ -0,0 +1,35 @@
1
+ import { type Aabb, type Vec2 } from "./geometry.js";
2
+ export type SpaceRef = {
3
+ kind: "exterior";
4
+ } | {
5
+ kind: "interior";
6
+ id: string;
7
+ };
8
+ export interface EntityLocation {
9
+ space: SpaceRef;
10
+ position: Vec2;
11
+ }
12
+ export interface Interior {
13
+ id: string;
14
+ origin: Vec2;
15
+ rotation?: number;
16
+ bounds: Aabb;
17
+ obstacles?: readonly Aabb[];
18
+ }
19
+ export interface Exterior {
20
+ bounds?: Aabb;
21
+ obstacles?: readonly Aabb[];
22
+ }
23
+ export interface InteriorsConfig {
24
+ exterior?: Exterior;
25
+ interiors?: readonly Interior[];
26
+ radius?: number;
27
+ }
28
+ export interface Interiors {
29
+ move(location: EntityLocation, delta: Vec2): EntityLocation;
30
+ enter(location: EntityLocation, id: string): EntityLocation | null;
31
+ leave(location: EntityLocation): EntityLocation | null;
32
+ toInterior(id: string, exterior: Vec2): Vec2 | null;
33
+ toExterior(id: string, interior: Vec2): Vec2 | null;
34
+ }
35
+ export declare function createInteriors(config?: InteriorsConfig): Interiors;
@@ -0,0 +1,66 @@
1
+ import { resolveMove } from "./geometry.js";
2
+ function rotate(point, angle) {
3
+ if (angle === 0)
4
+ return point;
5
+ const cos = Math.cos(angle);
6
+ const sin = Math.sin(angle);
7
+ return [point[0] * cos - point[1] * sin, point[0] * sin + point[1] * cos];
8
+ }
9
+ function toInteriorFrame(interior, exterior) {
10
+ const local = [exterior[0] - interior.origin[0], exterior[1] - interior.origin[1]];
11
+ return rotate(local, -(interior.rotation ?? 0));
12
+ }
13
+ function toExteriorFrame(interior, local) {
14
+ const rotated = rotate(local, interior.rotation ?? 0);
15
+ return [rotated[0] + interior.origin[0], rotated[1] + interior.origin[1]];
16
+ }
17
+ export function createInteriors(config = {}) {
18
+ const radius = config.radius ?? 0;
19
+ const exterior = config.exterior ?? {};
20
+ const byId = new Map();
21
+ for (const interior of config.interiors ?? [])
22
+ byId.set(interior.id, interior);
23
+ return {
24
+ move(location, delta) {
25
+ if (location.space.kind === "exterior") {
26
+ const position = resolveMove(location.position, delta, exterior.obstacles ?? [], {
27
+ bounds: exterior.bounds,
28
+ radius,
29
+ });
30
+ return { space: { kind: "exterior" }, position };
31
+ }
32
+ const interior = byId.get(location.space.id);
33
+ if (interior === undefined)
34
+ return location;
35
+ const position = resolveMove(location.position, delta, interior.obstacles ?? [], {
36
+ bounds: interior.bounds,
37
+ radius,
38
+ });
39
+ return { space: { kind: "interior", id: interior.id }, position };
40
+ },
41
+ enter(location, id) {
42
+ if (location.space.kind !== "exterior")
43
+ return null;
44
+ const interior = byId.get(id);
45
+ if (interior === undefined)
46
+ return null;
47
+ return { space: { kind: "interior", id }, position: toInteriorFrame(interior, location.position) };
48
+ },
49
+ leave(location) {
50
+ if (location.space.kind !== "interior")
51
+ return null;
52
+ const interior = byId.get(location.space.id);
53
+ if (interior === undefined)
54
+ return null;
55
+ return { space: { kind: "exterior" }, position: toExteriorFrame(interior, location.position) };
56
+ },
57
+ toInterior(id, exterior) {
58
+ const interior = byId.get(id);
59
+ return interior === undefined ? null : toInteriorFrame(interior, exterior);
60
+ },
61
+ toExterior(id, interior) {
62
+ const frame = byId.get(id);
63
+ return frame === undefined ? null : toExteriorFrame(frame, interior);
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,30 @@
1
+ import { type Aabb, type Footprint, type Vec2 } from "./geometry.js";
2
+ export interface PlacementObstacle {
3
+ aabb: Aabb;
4
+ id?: string;
5
+ }
6
+ export interface PlacementRequest {
7
+ center: Vec2;
8
+ footprint: Footprint;
9
+ quarterTurns?: number;
10
+ }
11
+ export interface PlacementRules {
12
+ bounds?: Aabb;
13
+ obstacles?: readonly PlacementObstacle[];
14
+ snap?: number;
15
+ }
16
+ export type PlacementResult = {
17
+ status: "ok";
18
+ center: Vec2;
19
+ aabb: Aabb;
20
+ } | {
21
+ status: "rejected";
22
+ reason: "out-of-bounds";
23
+ } | {
24
+ status: "rejected";
25
+ reason: "overlap";
26
+ obstacle: PlacementObstacle;
27
+ index: number;
28
+ };
29
+ export declare function validatePlacement(request: PlacementRequest, rules?: PlacementRules): PlacementResult;
30
+ export declare function footprintObstacle(request: PlacementRequest, id?: string): PlacementObstacle;
@@ -0,0 +1,20 @@
1
+ import { aabbContains, aabbOverlap, footprintAabb, snapToGrid, } from "./geometry.js";
2
+ export function validatePlacement(request, rules = {}) {
3
+ const center = rules.snap === undefined ? request.center : snapToGrid(request.center, rules.snap);
4
+ const aabb = footprintAabb(center, request.footprint, request.quarterTurns ?? 0);
5
+ if (rules.bounds !== undefined && !aabbContains(rules.bounds, aabb)) {
6
+ return { status: "rejected", reason: "out-of-bounds" };
7
+ }
8
+ const obstacles = rules.obstacles ?? [];
9
+ for (let index = 0; index < obstacles.length; index++) {
10
+ const obstacle = obstacles[index];
11
+ if (aabbOverlap(aabb, obstacle.aabb)) {
12
+ return { status: "rejected", reason: "overlap", obstacle, index };
13
+ }
14
+ }
15
+ return { status: "ok", center, aabb };
16
+ }
17
+ export function footprintObstacle(request, id) {
18
+ const aabb = footprintAabb(request.center, request.footprint, request.quarterTurns ?? 0);
19
+ return id === undefined ? { aabb } : { aabb, id };
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Genre-agnostic TypeScript game engine SDK: game runtime, state store, entity scene, combat, movement, inventory, economy, quests, multiplayer primitives.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -10,7 +10,10 @@
10
10
  "url": "git+https://github.com/Noisemaker111/jgengine.git",
11
11
  "directory": "packages/core"
12
12
  },
13
- "files": ["dist"],
13
+ "files": [
14
+ "dist",
15
+ "CHANGELOG.md"
16
+ ],
14
17
  "exports": {
15
18
  ".": {
16
19
  "types": "./dist/index.d.ts",
@@ -22,8 +25,8 @@
22
25
  }
23
26
  },
24
27
  "scripts": {
25
- "build": "tsc -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist",
26
- "check-types": "tsc --noEmit -p tsconfig.json",
28
+ "build": "tsgo -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist",
29
+ "check-types": "tsgo --noEmit -p tsconfig.json",
27
30
  "test": "bun test src"
28
31
  },
29
32
  "publishConfig": {