@jgengine/core 0.32.0 → 0.34.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 +58 -0
- package/dist/ai/driver.d.ts +41 -2
- package/dist/ai/driver.js +33 -2
- package/dist/combat/damageRuntime.d.ts +10 -1
- package/dist/combat/damageRuntime.js +8 -1
- package/dist/combat/effects.d.ts +2 -0
- package/dist/combat/effects.js +2 -0
- package/dist/devtools/report.d.ts +13 -0
- package/dist/game/events.d.ts +6 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +54 -1
- package/dist/movement/solidObstacles.d.ts +34 -5
- package/dist/movement/solidObstacles.js +146 -27
- package/dist/run/runLoop.d.ts +88 -0
- package/dist/run/runLoop.js +92 -0
- package/dist/runtime/context/combat.js +1 -0
- package/dist/runtime/context/scene.js +7 -2
- package/dist/scene/objectStore.d.ts +4 -0
- package/dist/scene/objectStore.js +103 -15
- package/dist/settings/graphicsSettings.js +3 -2
- package/dist/settings/settingsModel.d.ts +18 -0
- package/dist/settings/settingsModel.js +67 -15
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-combat/api.md +2 -2
- package/skills/jgengine-gameplay/api.md +13 -2
- package/skills/jgengine-gameplay/capabilities.md +4 -0
- package/skills/jgengine-world/api.md +7 -5
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -48,34 +48,91 @@ export function solidObstacleReach(ctx) {
|
|
|
48
48
|
}
|
|
49
49
|
/** {@link solidObstacleReach} against a bare source and caller-owned cache. */
|
|
50
50
|
export function sourceObstacleReach(source, cache) {
|
|
51
|
+
ensureSourceCache(source, cache);
|
|
52
|
+
return cache.value;
|
|
53
|
+
}
|
|
54
|
+
/** Coarse XZ cell for the obstacle index: a movement box spans tens of metres, so 1 m cells would be
|
|
55
|
+
* almost all empty lookups while a handful of these cover the whole query. */
|
|
56
|
+
const INDEX_CELL_SIZE = 16;
|
|
57
|
+
/** Beyond this many coarse cells a query walks the whole group list instead. */
|
|
58
|
+
const INDEX_CELL_VOLUME_LIMIT = 4096;
|
|
59
|
+
/** Two signed axis indices packed into one small-integer key (no string allocation, no boxing). */
|
|
60
|
+
const INDEX_KEY_RADIX = 32768;
|
|
61
|
+
const INDEX_KEY_BIAS = 16384;
|
|
62
|
+
const INDEX_KEY_LIMIT = 16384;
|
|
63
|
+
function indexCell(value) {
|
|
64
|
+
return Math.floor(value / INDEX_CELL_SIZE);
|
|
65
|
+
}
|
|
66
|
+
function indexKey(cx, cz) {
|
|
67
|
+
return (cx + INDEX_KEY_BIAS) * INDEX_KEY_RADIX + (cz + INDEX_KEY_BIAS);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Refresh `cache` for `source`: one scan resolves the broadphase reach and, when the source reports a
|
|
71
|
+
* version, every blocking object's obstacles into a coarse XZ index. A versioned source is scanned only
|
|
72
|
+
* when that version changes, so a per-frame query costs index lookups instead of a world scan.
|
|
73
|
+
*/
|
|
74
|
+
function ensureSourceCache(source, cache) {
|
|
75
|
+
const version = source.version?.();
|
|
76
|
+
if (version !== undefined) {
|
|
77
|
+
if (cache.version === version && cache.index !== undefined)
|
|
78
|
+
return;
|
|
79
|
+
cache.version = version;
|
|
80
|
+
cache.count = -1;
|
|
81
|
+
cache.index = undefined;
|
|
82
|
+
buildSourceCache(source, cache, source.list(), true);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
51
85
|
const objects = source.list();
|
|
52
86
|
if (cache.count === objects.length)
|
|
53
|
-
return
|
|
87
|
+
return;
|
|
88
|
+
cache.count = objects.length;
|
|
89
|
+
cache.version = undefined;
|
|
90
|
+
cache.index = undefined;
|
|
91
|
+
buildSourceCache(source, cache, objects, false);
|
|
92
|
+
}
|
|
93
|
+
function buildSourceCache(source, cache, objects, index) {
|
|
94
|
+
const groups = index ? [] : EMPTY_GROUPS;
|
|
95
|
+
const cells = index ? new Map() : null;
|
|
54
96
|
let horizontal = OBSTACLE_MAX_HALF_EXTENT;
|
|
55
97
|
let vertical = OBSTACLE_VERTICAL_FLOOR;
|
|
56
98
|
for (const object of objects) {
|
|
57
99
|
const set = source.collidersOf(object.instanceId);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
horizontal = Math.max(horizontal,
|
|
70
|
-
vertical = Math.max(vertical,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// Horizontal bound sums both axes so any yaw of the box stays covered.
|
|
74
|
-
const h = shape.halfExtents;
|
|
75
|
-
horizontal = Math.max(horizontal, ox + oz + h[0] + h[2]);
|
|
76
|
-
vertical = Math.max(vertical, oy + h[1]);
|
|
100
|
+
const obstacles = [];
|
|
101
|
+
if (set === null) {
|
|
102
|
+
// No resolved collider: preserve the default 1×1×1 box.
|
|
103
|
+
if (index)
|
|
104
|
+
obstacles.push({ position: object.position });
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
for (const collider of resolveColliders(set)) {
|
|
108
|
+
if (!collider.blocks || collider.purpose !== "physical")
|
|
109
|
+
continue;
|
|
110
|
+
const reach = blockingReach(collider);
|
|
111
|
+
horizontal = Math.max(horizontal, reach[0]);
|
|
112
|
+
vertical = Math.max(vertical, reach[1]);
|
|
113
|
+
if (index)
|
|
114
|
+
obstacles.push(obstacleFromCollider(collider, object.position, object.rotationY));
|
|
77
115
|
}
|
|
78
116
|
}
|
|
117
|
+
if (!index || obstacles.length === 0)
|
|
118
|
+
continue;
|
|
119
|
+
const group = {
|
|
120
|
+
x: object.position[0],
|
|
121
|
+
y: object.position[1],
|
|
122
|
+
z: object.position[2],
|
|
123
|
+
obstacles,
|
|
124
|
+
};
|
|
125
|
+
groups.push(group);
|
|
126
|
+
const cx = indexCell(group.x);
|
|
127
|
+
const cz = indexCell(group.z);
|
|
128
|
+
if (cx <= -INDEX_KEY_LIMIT || cx >= INDEX_KEY_LIMIT || cz <= -INDEX_KEY_LIMIT || cz >= INDEX_KEY_LIMIT)
|
|
129
|
+
continue;
|
|
130
|
+
const key = indexKey(cx, cz);
|
|
131
|
+
const bucket = cells.get(key);
|
|
132
|
+
if (bucket === undefined)
|
|
133
|
+
cells.set(key, [group]);
|
|
134
|
+
else
|
|
135
|
+
bucket.push(group);
|
|
79
136
|
}
|
|
80
137
|
const value = {
|
|
81
138
|
horizontal: Math.min(horizontal, OBSTACLE_HORIZONTAL_CAP),
|
|
@@ -83,7 +140,56 @@ export function sourceObstacleReach(source, cache) {
|
|
|
83
140
|
};
|
|
84
141
|
cache.count = objects.length;
|
|
85
142
|
cache.value = value;
|
|
86
|
-
|
|
143
|
+
if (index)
|
|
144
|
+
cache.index = { cells: cells, groups };
|
|
145
|
+
}
|
|
146
|
+
const EMPTY_GROUPS = [];
|
|
147
|
+
/** A blocking collider's center-to-face reach, split so tower height cannot inflate the XZ query. */
|
|
148
|
+
function blockingReach(collider) {
|
|
149
|
+
const shape = collider.shape;
|
|
150
|
+
const offset = shape.offset;
|
|
151
|
+
const ox = offset !== undefined ? Math.abs(offset[0]) : 0;
|
|
152
|
+
const oy = offset !== undefined ? Math.abs(offset[1]) : 0;
|
|
153
|
+
const oz = offset !== undefined ? Math.abs(offset[2]) : 0;
|
|
154
|
+
if (shape.kind === "sphere")
|
|
155
|
+
return [ox + oz + shape.radius, oy + shape.radius];
|
|
156
|
+
// Horizontal bound sums both axes so any yaw of the box stays covered.
|
|
157
|
+
const h = shape.halfExtents;
|
|
158
|
+
return [ox + oz + h[0] + h[2], oy + h[1]];
|
|
159
|
+
}
|
|
160
|
+
/** Every indexed obstacle whose object centre lies inside `min`..`max` — the set the broadphase promises. */
|
|
161
|
+
function indexedObstaclesNear(index, min, max, out) {
|
|
162
|
+
const minCx = indexCell(min[0]);
|
|
163
|
+
const maxCx = indexCell(max[0]);
|
|
164
|
+
const minCz = indexCell(min[2]);
|
|
165
|
+
const maxCz = indexCell(max[2]);
|
|
166
|
+
const volume = Math.max(0, maxCx - minCx + 1) * Math.max(0, maxCz - minCz + 1);
|
|
167
|
+
if (volume > INDEX_CELL_VOLUME_LIMIT ||
|
|
168
|
+
minCx <= -INDEX_KEY_LIMIT || maxCx >= INDEX_KEY_LIMIT ||
|
|
169
|
+
minCz <= -INDEX_KEY_LIMIT || maxCz >= INDEX_KEY_LIMIT) {
|
|
170
|
+
for (const group of index.groups)
|
|
171
|
+
if (groupInBox(group, min, max))
|
|
172
|
+
pushGroup(group, out);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (let cx = minCx; cx <= maxCx; cx += 1) {
|
|
176
|
+
const rowBase = (cx + INDEX_KEY_BIAS) * INDEX_KEY_RADIX + minCz + INDEX_KEY_BIAS;
|
|
177
|
+
for (let cz = minCz; cz <= maxCz; cz += 1) {
|
|
178
|
+
const bucket = index.cells.get(rowBase + (cz - minCz));
|
|
179
|
+
if (bucket === undefined)
|
|
180
|
+
continue;
|
|
181
|
+
for (const group of bucket)
|
|
182
|
+
if (groupInBox(group, min, max))
|
|
183
|
+
pushGroup(group, out);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function groupInBox(group, min, max) {
|
|
188
|
+
return group.x >= min[0] && group.x <= max[0] && group.y >= min[1] && group.y <= max[1] && group.z >= min[2] && group.z <= max[2];
|
|
189
|
+
}
|
|
190
|
+
function pushGroup(group, out) {
|
|
191
|
+
for (const obstacle of group.obstacles)
|
|
192
|
+
out.push(obstacle);
|
|
87
193
|
}
|
|
88
194
|
/** Yaw-rotate an entity-local AABB about the object origin and re-fit it to an axis-aligned box, expressed
|
|
89
195
|
* as `min`/`max` relative to `position`. Conservative: the rotated box is grown to its enclosing AABB. */
|
|
@@ -188,7 +294,7 @@ export function obstacleFromSolid(solid) {
|
|
|
188
294
|
* @capability solid-obstacles the one blocking-geometry query the player resolver and NPC movers share
|
|
189
295
|
*/
|
|
190
296
|
export function solidObstaclesNear(ctx, position, reachX, reachZ, height = WALKER_HEIGHT) {
|
|
191
|
-
const obstacles = sourceObstaclesNear(ctx.scene.object, solidObstacleReach(ctx), position, reachX, reachZ, height);
|
|
297
|
+
const obstacles = sourceObstaclesNear(ctx.scene.object, solidObstacleReach(ctx), position, reachX, reachZ, height, reachCache(ctx));
|
|
192
298
|
appendWorldSolidObstacles(obstacles, ctx.world.solids, position, reachX, reachZ, height);
|
|
193
299
|
return obstacles;
|
|
194
300
|
}
|
|
@@ -199,8 +305,12 @@ function appendWorldSolidObstacles(obstacles, solids, position, reachX, reachZ,
|
|
|
199
305
|
obstacles.push(obstacleFromSolid(solid));
|
|
200
306
|
}
|
|
201
307
|
}
|
|
202
|
-
/**
|
|
203
|
-
|
|
308
|
+
/**
|
|
309
|
+
* {@link solidObstaclesNear} against a bare source and an already-resolved reach. Pass the source's
|
|
310
|
+
* {@link ObstacleReachCache} to use the version-keyed obstacle index; a source that reports no
|
|
311
|
+
* `version()` keeps the per-query `inBox` gather.
|
|
312
|
+
*/
|
|
313
|
+
export function sourceObstaclesNear(source, reach, position, reachX, reachZ, height = WALKER_HEIGHT, cache) {
|
|
204
314
|
const min = [
|
|
205
315
|
position[0] - reachX - reach.horizontal,
|
|
206
316
|
position[1] - reach.vertical,
|
|
@@ -212,6 +322,15 @@ export function sourceObstaclesNear(source, reach, position, reachX, reachZ, hei
|
|
|
212
322
|
position[2] + reachZ + reach.horizontal,
|
|
213
323
|
];
|
|
214
324
|
const obstacles = [];
|
|
325
|
+
if (cache !== undefined && source.version !== undefined) {
|
|
326
|
+
ensureSourceCache(source, cache);
|
|
327
|
+
const index = cache.index;
|
|
328
|
+
if (index !== undefined) {
|
|
329
|
+
indexedObstaclesNear(index, min, max, obstacles);
|
|
330
|
+
appendWorldSolidObstacles(obstacles, source.solids, position, reachX, reachZ, height);
|
|
331
|
+
return obstacles;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
215
334
|
for (const object of source.inBox(min, max)) {
|
|
216
335
|
const set = source.collidersOf(object.instanceId);
|
|
217
336
|
if (set === null) {
|
|
@@ -233,8 +352,8 @@ export function sourceObstaclesNear(source, reach, position, reachX, reachZ, hei
|
|
|
233
352
|
* X/Z it may actually take this tick. A step into a wall is cut on the blocked axis and preserved on the
|
|
234
353
|
* other, exactly as {@link stepPlayerMovement} resolves the player's.
|
|
235
354
|
*
|
|
236
|
-
* Bounded by the same broadphase as the player's gather: one
|
|
237
|
-
* full-world scan, so it stays affordable per NPC per tick.
|
|
355
|
+
* Bounded by the same broadphase as the player's gather: one query sized to this step, answered from the
|
|
356
|
+
* source's obstacle index, never a full-world scan, so it stays affordable per NPC per tick.
|
|
238
357
|
*
|
|
239
358
|
* The sliding is already axis-separated: X and Z are resolved independently inside, so a caller that
|
|
240
359
|
* also splits its own movement by axis must hand this exactly one axis per call (`stepZ: 0`, then
|
|
@@ -265,7 +384,7 @@ export function resolveSourceWalkerStep(source, cache, position, stepX, stepZ, o
|
|
|
265
384
|
if (stepX === 0 && stepZ === 0)
|
|
266
385
|
return { stepX, stepZ };
|
|
267
386
|
const radius = options.radius ?? DEFAULT_OBSTACLE_PLAYER_RADIUS;
|
|
268
|
-
const obstacles = sourceObstaclesNear(source, sourceObstacleReach(source, cache), position, Math.abs(stepX) + radius, Math.abs(stepZ) + radius);
|
|
387
|
+
const obstacles = sourceObstaclesNear(source, sourceObstacleReach(source, cache), position, Math.abs(stepX) + radius, Math.abs(stepZ) + radius, WALKER_HEIGHT, cache);
|
|
269
388
|
return slideStep(position, stepX, stepZ, obstacles, radius, options.stepUpHeight ?? 0);
|
|
270
389
|
}
|
|
271
390
|
/** {@link resolveWalkerStep} against an already-gathered obstacle set. */
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** How a run ended: what it carried reached the bank, or it was lost. */
|
|
2
|
+
export type RunOutcome = "banked" | "lost";
|
|
3
|
+
/**
|
|
4
|
+
* One change the next run can open with. `T` is the game's own payload (a tighter rival line, an
|
|
5
|
+
* unlocked part, a route modifier) - the loop only draws and remembers which id was drawn.
|
|
6
|
+
*/
|
|
7
|
+
export interface RunChange<T> {
|
|
8
|
+
id: string;
|
|
9
|
+
value: T;
|
|
10
|
+
/** Relative draw weight (default `1`); a non-positive weight is never drawn. */
|
|
11
|
+
weight?: number;
|
|
12
|
+
/** Completed runs this change needs before it can be drawn (default `0`). */
|
|
13
|
+
after?: number;
|
|
14
|
+
}
|
|
15
|
+
/** The change a run opens with, and which run it is. */
|
|
16
|
+
export interface RunOffer<T> {
|
|
17
|
+
/** 1-based run number: `1` is the first run, before anything has been banked. */
|
|
18
|
+
run: number;
|
|
19
|
+
changeId: string | null;
|
|
20
|
+
change: T | null;
|
|
21
|
+
}
|
|
22
|
+
/** Everything a run loop stores as plain JSON: the run in progress plus lifetime totals. */
|
|
23
|
+
export interface RunLoopState {
|
|
24
|
+
/** The run in progress, or `0` before the first `start`. */
|
|
25
|
+
run: number;
|
|
26
|
+
/** Runs settled so far; also the number `after` gates are read against. */
|
|
27
|
+
completed: number;
|
|
28
|
+
/** What the run in progress carries and can still lose. */
|
|
29
|
+
haul: number;
|
|
30
|
+
/** Change drawn for the run in progress, or `null`. */
|
|
31
|
+
changeId: string | null;
|
|
32
|
+
bankedTotal: number;
|
|
33
|
+
lostTotal: number;
|
|
34
|
+
bestHaul: number;
|
|
35
|
+
}
|
|
36
|
+
/** What one closed run banked or lost, and what the next run changes. */
|
|
37
|
+
export interface RunSettlement<T> {
|
|
38
|
+
outcome: RunOutcome;
|
|
39
|
+
/** What the run carried before the multiplier. */
|
|
40
|
+
haul: number;
|
|
41
|
+
/** What reached the bank: the haul after the line multiplier on a bank, `0` on a loss. */
|
|
42
|
+
banked: number;
|
|
43
|
+
/** What the run lost: the whole haul on a loss, `0` on a bank. */
|
|
44
|
+
lost: number;
|
|
45
|
+
completed: number;
|
|
46
|
+
bestHaul: number;
|
|
47
|
+
/** The next run's change, drawn here so a results screen can name it. */
|
|
48
|
+
next: RunOffer<T>;
|
|
49
|
+
}
|
|
50
|
+
/** Configuration for {@link createRunLoop}. */
|
|
51
|
+
export interface RunLoopConfig<T> {
|
|
52
|
+
/** Seed for the run-to-run draw: the same seed and history replay the same sequence. */
|
|
53
|
+
seed: string | number;
|
|
54
|
+
/** Authored table of next-run changes; omit a table for a loop that never varies. */
|
|
55
|
+
changes?: readonly RunChange<T>[];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A run's lifecycle: what it carries and can lose, and the change it hands the next run.
|
|
59
|
+
*/
|
|
60
|
+
export interface RunLoop<T> {
|
|
61
|
+
/** Open the next run and hand back its offer. Calling it on an open run returns that run's offer and keeps its haul. */
|
|
62
|
+
start(): RunOffer<T>;
|
|
63
|
+
/** Add to what the run in progress carries; negative or non-finite amounts are ignored. Returns the haul. */
|
|
64
|
+
carry(amount: number): number;
|
|
65
|
+
/** What the run in progress carries. */
|
|
66
|
+
haul(): number;
|
|
67
|
+
/** The run in progress's offer, or the offer the next `start` would open. */
|
|
68
|
+
offer(): RunOffer<T>;
|
|
69
|
+
/**
|
|
70
|
+
* Close the run in progress: bank the haul scaled by `multiplier`, or lose it. Throws when no run
|
|
71
|
+
* is open, so a double settle cannot bank the same haul twice.
|
|
72
|
+
*/
|
|
73
|
+
settle(outcome: RunOutcome, multiplier?: number): RunSettlement<T>;
|
|
74
|
+
snapshot(): RunLoopState;
|
|
75
|
+
restore(next: RunLoopState): void;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Bank-or-lose between runs, with one deterministic change per run drawn from an authored table.
|
|
79
|
+
*
|
|
80
|
+
* A run's haul banks when it ends in a bank and is lost when it ends in a loss; the money itself stays
|
|
81
|
+
* with the game (pair it with `progression/careerBank`). After each settled run the loop draws the next
|
|
82
|
+
* run's change from `changes`: eligible when `after <= completed`, never the change just played unless
|
|
83
|
+
* it is the only one eligible, weighted by `weight`. The draw is `seededRng(seed:run:N)`, so the same
|
|
84
|
+
* seed and the same run history give the same sequence, before or after a save/load.
|
|
85
|
+
*
|
|
86
|
+
* @capability run-loop bank or lose what a run carries, with one deterministic change per run
|
|
87
|
+
*/
|
|
88
|
+
export declare function createRunLoop<T>(config: RunLoopConfig<T>): RunLoop<T>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { pickWeighted } from "../random/pick.js";
|
|
2
|
+
import { seededRng } from "../random/rng.js";
|
|
3
|
+
function positive(value) {
|
|
4
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Bank-or-lose between runs, with one deterministic change per run drawn from an authored table.
|
|
8
|
+
*
|
|
9
|
+
* A run's haul banks when it ends in a bank and is lost when it ends in a loss; the money itself stays
|
|
10
|
+
* with the game (pair it with `progression/careerBank`). After each settled run the loop draws the next
|
|
11
|
+
* run's change from `changes`: eligible when `after <= completed`, never the change just played unless
|
|
12
|
+
* it is the only one eligible, weighted by `weight`. The draw is `seededRng(seed:run:N)`, so the same
|
|
13
|
+
* seed and the same run history give the same sequence, before or after a save/load.
|
|
14
|
+
*
|
|
15
|
+
* @capability run-loop bank or lose what a run carries, with one deterministic change per run
|
|
16
|
+
*/
|
|
17
|
+
export function createRunLoop(config) {
|
|
18
|
+
const table = config.changes ?? [];
|
|
19
|
+
const known = new Set();
|
|
20
|
+
for (const change of table) {
|
|
21
|
+
if (known.has(change.id))
|
|
22
|
+
throw new Error(`run change "${change.id}" is declared twice`);
|
|
23
|
+
known.add(change.id);
|
|
24
|
+
}
|
|
25
|
+
let run = 0;
|
|
26
|
+
let completed = 0;
|
|
27
|
+
let haul = 0;
|
|
28
|
+
let changeId = null;
|
|
29
|
+
let bankedTotal = 0;
|
|
30
|
+
let lostTotal = 0;
|
|
31
|
+
let bestHaul = 0;
|
|
32
|
+
function drawFor(index, previous) {
|
|
33
|
+
const eligible = table.filter((change) => (change.after ?? 0) <= index - 1);
|
|
34
|
+
const fresh = eligible.filter((change) => change.id !== previous);
|
|
35
|
+
// A one-entry table still offers its change rather than nothing.
|
|
36
|
+
const pool = fresh.length > 0 ? fresh : eligible;
|
|
37
|
+
const rng = seededRng(`${config.seed}:run:${index}`);
|
|
38
|
+
const drawn = pickWeighted(rng, pool, (change) => change.weight ?? 1) ?? null;
|
|
39
|
+
return { run: index, changeId: drawn?.id ?? null, change: drawn?.value ?? null };
|
|
40
|
+
}
|
|
41
|
+
function offer() {
|
|
42
|
+
// An open run's change was adopted at `start`, so the offer repeats it instead of redrawing.
|
|
43
|
+
if (run > completed) {
|
|
44
|
+
const adopted = table.find((change) => change.id === changeId);
|
|
45
|
+
return { run, changeId, change: adopted?.value ?? null };
|
|
46
|
+
}
|
|
47
|
+
return drawFor(completed + 1, changeId);
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
start() {
|
|
51
|
+
if (run > completed)
|
|
52
|
+
return offer();
|
|
53
|
+
const next = offer();
|
|
54
|
+
run = next.run;
|
|
55
|
+
haul = 0;
|
|
56
|
+
changeId = next.changeId;
|
|
57
|
+
return next;
|
|
58
|
+
},
|
|
59
|
+
carry(amount) {
|
|
60
|
+
haul += positive(amount);
|
|
61
|
+
return haul;
|
|
62
|
+
},
|
|
63
|
+
haul: () => haul,
|
|
64
|
+
offer,
|
|
65
|
+
settle(outcome, multiplier) {
|
|
66
|
+
if (run <= completed)
|
|
67
|
+
throw new Error("run loop: no run is open to settle");
|
|
68
|
+
const settledHaul = haul;
|
|
69
|
+
const scale = Number.isFinite(multiplier) ? Math.max(0, multiplier ?? 1) : 1;
|
|
70
|
+
const banked = outcome === "banked" ? Math.round(settledHaul * scale) : 0;
|
|
71
|
+
const lost = outcome === "lost" ? settledHaul : 0;
|
|
72
|
+
bankedTotal += banked;
|
|
73
|
+
lostTotal += lost;
|
|
74
|
+
bestHaul = Math.max(bestHaul, banked);
|
|
75
|
+
completed = run;
|
|
76
|
+
haul = 0;
|
|
77
|
+
return { outcome, haul: settledHaul, banked, lost, completed, bestHaul, next: drawFor(completed + 1, changeId) };
|
|
78
|
+
},
|
|
79
|
+
snapshot: () => ({ run, completed, haul, changeId, bankedTotal, lostTotal, bestHaul }),
|
|
80
|
+
restore(next) {
|
|
81
|
+
run = Math.max(0, Math.floor(next.run));
|
|
82
|
+
completed = Math.max(0, Math.floor(next.completed));
|
|
83
|
+
if (run < completed)
|
|
84
|
+
run = completed;
|
|
85
|
+
haul = positive(next.haul);
|
|
86
|
+
changeId = next.changeId !== null && known.has(next.changeId) ? next.changeId : null;
|
|
87
|
+
bankedTotal = positive(next.bankedTotal);
|
|
88
|
+
lostTotal = positive(next.lostTotal);
|
|
89
|
+
bestHaul = positive(next.bestHaul);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -68,6 +68,7 @@ export function createCombatSubsystem(d) {
|
|
|
68
68
|
absorbed: Math.max(0, application.incoming - application.dealt),
|
|
69
69
|
remaining: application.remaining,
|
|
70
70
|
lethal: application.lethal,
|
|
71
|
+
...(application.at === undefined ? {} : { position: application.at }),
|
|
71
72
|
});
|
|
72
73
|
},
|
|
73
74
|
onLethal(instanceId, lethalCtx) {
|
|
@@ -51,20 +51,22 @@ export function createSceneSubsystem(d) {
|
|
|
51
51
|
return object === null ? undefined : content.objectById?.(object.catalogId);
|
|
52
52
|
}
|
|
53
53
|
// `moveToward` reads the same blocking colliders the player resolver does, so a chaser stops at the
|
|
54
|
-
// wall that stops the player rather than lerping through it.
|
|
54
|
+
// wall that stops the player rather than lerping through it. The obstacle index behind it rebuilds
|
|
55
|
+
// only when this version moves: an object placed, moved, rotated or re-collidered.
|
|
55
56
|
const walkerReach = createObstacleReachCache();
|
|
56
57
|
const solidSource = {
|
|
57
58
|
solids: d.solids,
|
|
58
59
|
list: () => objects.list(),
|
|
59
60
|
inBox: (min, max) => objects.inBox(min, max),
|
|
60
61
|
collidersOf: (instanceId) => objectCollidersOf(instanceId),
|
|
62
|
+
version: () => objects.version(),
|
|
61
63
|
};
|
|
62
64
|
const spatial = createSpatialApi({
|
|
63
65
|
resolvePosition: (instanceId) => entities.get(instanceId)?.position,
|
|
64
66
|
candidates: entities.ids,
|
|
65
67
|
grid: { cellSize: 8 },
|
|
66
68
|
incremental: true,
|
|
67
|
-
resolveStep: (position, stepX, stepZ) => slideStep(position, stepX, stepZ, sourceObstaclesNear(solidSource, sourceObstacleReach(solidSource, walkerReach), position, Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS, Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS)),
|
|
69
|
+
resolveStep: (position, stepX, stepZ) => slideStep(position, stepX, stepZ, sourceObstaclesNear(solidSource, sourceObstacleReach(solidSource, walkerReach), position, Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS, Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS, undefined, walkerReach)),
|
|
68
70
|
...(occluder !== undefined ? { occluder } : {}),
|
|
69
71
|
});
|
|
70
72
|
const entityColliders = new Map();
|
|
@@ -123,6 +125,7 @@ export function createSceneSubsystem(d) {
|
|
|
123
125
|
return true;
|
|
124
126
|
}
|
|
125
127
|
function reportObjectCollisionMesh(catalogId, mesh) {
|
|
128
|
+
objects.markSpatialChange();
|
|
126
129
|
if (mesh === null) {
|
|
127
130
|
const existed = meshObjectByCatalogId.delete(catalogId);
|
|
128
131
|
if (existed)
|
|
@@ -138,6 +141,7 @@ export function createSceneSubsystem(d) {
|
|
|
138
141
|
return true;
|
|
139
142
|
}
|
|
140
143
|
function reportObjectBounds(catalogId, bounds) {
|
|
144
|
+
objects.markSpatialChange();
|
|
141
145
|
if (bounds === null) {
|
|
142
146
|
const existed = measuredObjectByCatalogId.delete(catalogId);
|
|
143
147
|
if (existed)
|
|
@@ -350,6 +354,7 @@ export function createSceneSubsystem(d) {
|
|
|
350
354
|
objectColliders.delete(instanceId);
|
|
351
355
|
else
|
|
352
356
|
objectColliders.set(instanceId, colliders);
|
|
357
|
+
objects.markSpatialChange();
|
|
353
358
|
signalNotify();
|
|
354
359
|
},
|
|
355
360
|
collidersOf: objectCollidersOf,
|
|
@@ -69,6 +69,10 @@ export interface ObjectStore {
|
|
|
69
69
|
ids(): readonly string[];
|
|
70
70
|
at(x: number, y: number, z: number, tolerance?: number): SceneObject[];
|
|
71
71
|
inBox(min: EntityPosition, max: EntityPosition): readonly SceneObject[];
|
|
72
|
+
/** Bumps whenever membership, a position or a rotation changes — cheap cache key for consumers. */
|
|
73
|
+
version(): number;
|
|
74
|
+
/** Bump {@link version} for a change the store cannot see, such as a consumer-owned collider set. */
|
|
75
|
+
markSpatialChange(): void;
|
|
72
76
|
clear(): void;
|
|
73
77
|
subscribe(listener: () => void): () => void;
|
|
74
78
|
/** Notified only when the object set changes (place/remove), never on move/rotate/setVisual — markers read live poses imperatively. */
|