@jgengine/shell 0.34.0 → 0.36.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/GamePlayerShell.js +2 -0
  3. package/dist/Shell3dPresentation.js +1 -1
  4. package/dist/audio/AudioComponents.js +7 -0
  5. package/dist/audio/audioEngine.d.ts +59 -0
  6. package/dist/audio/audioEngine.js +334 -15
  7. package/dist/audio/audioProbe.d.ts +10 -5
  8. package/dist/audio/audioProbe.js +6 -3
  9. package/dist/audio/occlusion.d.ts +11 -0
  10. package/dist/audio/occlusion.js +20 -0
  11. package/dist/play/index.d.ts +3 -0
  12. package/dist/play/index.js +3 -0
  13. package/dist/play/run.d.ts +27 -0
  14. package/dist/play/run.js +266 -0
  15. package/dist/play/script.d.ts +103 -0
  16. package/dist/play/script.js +52 -0
  17. package/dist/play/timeline.d.ts +43 -0
  18. package/dist/play/timeline.js +27 -0
  19. package/dist/pointer/pointerService.d.ts +2 -0
  20. package/dist/pointer/pointerService.js +4 -1
  21. package/dist/postfx/PostProcessing.d.ts +15 -1
  22. package/dist/postfx/PostProcessing.js +16 -6
  23. package/dist/postfx/postfxTierPolicy.d.ts +25 -0
  24. package/dist/postfx/postfxTierPolicy.js +17 -0
  25. package/dist/render/CascadedShadows.d.ts +4 -1
  26. package/dist/render/CascadedShadows.js +15 -7
  27. package/dist/render/CharacterCrowd.d.ts +11 -7
  28. package/dist/render/CharacterCrowd.js +103 -90
  29. package/dist/render/SceneLighting.js +14 -6
  30. package/dist/render/SceneWarmupGate.d.ts +6 -1
  31. package/dist/render/SceneWarmupGate.js +33 -7
  32. package/dist/render/SkinnedInstances.d.ts +16 -8
  33. package/dist/render/SkinnedInstances.js +215 -65
  34. package/dist/render/characterLod.d.ts +7 -0
  35. package/dist/render/characterLod.js +11 -0
  36. package/dist/render/crowdBatch.d.ts +45 -0
  37. package/dist/render/crowdBatch.js +68 -0
  38. package/dist/render/crowdRegistry.d.ts +55 -5
  39. package/dist/render/crowdRegistry.js +92 -9
  40. package/dist/render/shaderWarmup.d.ts +75 -18
  41. package/dist/render/shaderWarmup.js +221 -64
  42. package/dist/render/shadowCasters.d.ts +1 -0
  43. package/dist/render/shadowCasters.js +1 -1
  44. package/dist/render/shadowTiers.d.ts +36 -0
  45. package/dist/render/shadowTiers.js +50 -0
  46. package/dist/world/WorldScene.js +28 -11
  47. package/package.json +8 -4
  48. package/skills/jgengine/api.md +3 -3
  49. package/skills/jgengine-ui/api.md +79 -9
  50. package/skills/jgengine-ui/capabilities.md +7 -2
  51. package/skills/jgengine-world/api.md +18 -2
  52. package/skills/jgengine-world/capabilities.md +8 -0
@@ -0,0 +1,266 @@
1
+ import { createHeadlessRunner } from "@jgengine/core/runtime/headlessRunner";
2
+ import { resolveActivePrompt } from "@jgengine/core/interaction/proximityPrompt";
3
+ import { dispatchBoundAction, RESERVED_INPUT_ACTIONS, shouldFireBoundAction } from "../boundActionDispatch.js";
4
+ import { executeHotbarSlot, findHotbarSlotActions, hotbarIdFor } from "../hotbarActions.js";
5
+ import { shellDrivesPlayerPose } from "../shellMovement.js";
6
+ import { createPlayScript, PLAY_MOVE_ACTIONS, } from "./script.js";
7
+ import { playTimelineDigest } from "./timeline.js";
8
+ const RECORDED_EVENTS = [
9
+ "combat.damage",
10
+ "entity.died",
11
+ "stat.levelUp",
12
+ "quest.accepted",
13
+ "quest.updated",
14
+ "quest.completed",
15
+ "loot.granted",
16
+ "worldItem.dropped",
17
+ "feedback.cue",
18
+ "command.refused",
19
+ ];
20
+ function stepLabel(step) {
21
+ if (step.label !== undefined)
22
+ return step.label;
23
+ switch (step.action) {
24
+ case "move":
25
+ return `move ${step.move} ×${step.ticks ?? 1}`;
26
+ case "fire":
27
+ return `fire ×${step.ticks ?? 1}`;
28
+ case "aim":
29
+ return `aim yaw=${step.yaw.toFixed(3)}${step.pitch === undefined ? "" : ` pitch=${step.pitch.toFixed(3)}`}`;
30
+ case "use":
31
+ return `use ${step.actionId}`;
32
+ case "wait":
33
+ return `wait ×${step.ticks ?? 1}`;
34
+ case "command":
35
+ return `command ${step.name}`;
36
+ }
37
+ }
38
+ function eventValues(name, payload) {
39
+ const record = payload;
40
+ switch (name) {
41
+ case "combat.damage":
42
+ return {
43
+ to: String(record.target ?? ""),
44
+ ...(record.source === undefined ? {} : { from: String(record.source) }),
45
+ amount: typeof record.amount === "number" ? Math.round(record.amount * 100) / 100 : null,
46
+ remaining: typeof record.remaining === "number" ? Math.round(record.remaining * 100) / 100 : null,
47
+ ...(record.lethal === true ? { lethal: true } : {}),
48
+ };
49
+ case "entity.died":
50
+ return { id: String(record.instanceId ?? ""), kind: String(record.catalogId ?? "") };
51
+ case "stat.levelUp":
52
+ return { user: String(record.userId ?? ""), stat: String(record.stat ?? "") };
53
+ case "quest.accepted":
54
+ case "quest.completed":
55
+ return { quest: String(record.questId ?? "") };
56
+ case "quest.updated":
57
+ return {
58
+ quest: String(record.questId ?? ""),
59
+ ...(record.objectiveId === undefined ? {} : { objective: String(record.objectiveId) }),
60
+ ...(record.progress === undefined ? {} : { progress: Number(record.progress) }),
61
+ };
62
+ case "loot.granted":
63
+ return { item: String(record.itemId ?? record.lootId ?? ""), ...(record.amount === undefined ? {} : { amount: Number(record.amount) }) };
64
+ case "worldItem.dropped":
65
+ return { item: String(record.itemId ?? "") };
66
+ case "feedback.cue":
67
+ return { cue: String(record.cue ?? "") };
68
+ case "command.refused":
69
+ return { command: String(record.command ?? ""), reason: String(record.reason ?? "") };
70
+ }
71
+ }
72
+ /**
73
+ * Play a real game without a browser: boot its `PlayableGame` through the headless runner, apply a
74
+ * tick-based {@link PlayScript}, and return what happened plus whether the script's assertions held.
75
+ *
76
+ * Runs the shell's own per-tick pipeline, not an imitation of it — held movement actions reach the walk
77
+ * controller, a held `fire` (or any game action) resolves to its bound command on the press tick and on
78
+ * its repeat cadence, `interact` picks the active proximity prompt, and a hotbar press activates the
79
+ * slot. So a script proves the same code path a player's hands prove, in seconds.
80
+ *
81
+ * @capability play-script play a real game from a deterministic script — spawn, move, aim, fire, command, and assert world state with no browser and no wall clock
82
+ */
83
+ export function runPlayScript(playable, spec, options = {}) {
84
+ const script = createPlayScript(spec);
85
+ const definition = playable.game;
86
+ const dt = options.dt ?? script.dt;
87
+ const maxEvents = options.maxEvents ?? 1000;
88
+ let clockMs = 0;
89
+ const runner = createHeadlessRunner({
90
+ definition,
91
+ content: playable.content,
92
+ loop: playable.loop,
93
+ player: { userId: options.userId ?? "player", isNew: true },
94
+ seed: script.seed,
95
+ now: () => clockMs,
96
+ maxStepSeconds: dt,
97
+ playerMovement: shellDrivesPlayerPose(definition.input),
98
+ movement: playable.movement,
99
+ collision: playable.collision,
100
+ });
101
+ const ctx = runner.ctx;
102
+ const playerId = options.userId ?? "player";
103
+ const timeline = [];
104
+ let eventRows = 0;
105
+ let truncated = false;
106
+ const record = (entry) => {
107
+ if (entry.kind === "event") {
108
+ eventRows += 1;
109
+ if (eventRows > maxEvents) {
110
+ if (!truncated) {
111
+ truncated = true;
112
+ timeline.push({ tick: entry.tick, kind: "event", label: "timeline truncated", values: { maxEvents } });
113
+ }
114
+ return;
115
+ }
116
+ }
117
+ timeline.push(entry);
118
+ };
119
+ for (const name of RECORDED_EVENTS) {
120
+ ctx.game.events.on(name, (payload) => {
121
+ record({ tick: runner.tick(), kind: "event", label: name, values: eventValues(name, payload) });
122
+ });
123
+ }
124
+ /** Run a command and record its verdict once, at the tick it ran. */
125
+ const runCommand = (name, input, label = `command ${name}`) => {
126
+ const result = ctx.game.commands.run(name, input);
127
+ record({
128
+ tick: runner.tick(),
129
+ kind: "command",
130
+ label,
131
+ values: {
132
+ status: result.status,
133
+ ...(result.status === "rejected" && "reason" in result && result.reason !== undefined ? { reason: String(result.reason) } : {}),
134
+ },
135
+ });
136
+ };
137
+ let yaw = 0;
138
+ let pitch = 0;
139
+ const heldWindows = [];
140
+ const stepHolds = (step) => {
141
+ if (step.action === "move")
142
+ return PLAY_MOVE_ACTIONS[step.move];
143
+ if (step.action === "fire")
144
+ return "fire";
145
+ if (step.action === "use")
146
+ return step.actionId;
147
+ return null;
148
+ };
149
+ for (const step of script.steps) {
150
+ const action = stepHolds(step);
151
+ if (action !== null)
152
+ heldWindows.push({ action, from: step.tick, to: step.tick + (step.ticks ?? 1) });
153
+ }
154
+ if (script.setup !== undefined)
155
+ script.setup(ctx, runner);
156
+ const sample = (expectation, tick) => {
157
+ const value = expectation.read(ctx);
158
+ const initial = initialReadings.get(expectation.id) ?? value;
159
+ const pass = expectation.test === undefined ? Boolean(value) : expectation.test(value, initial);
160
+ record({
161
+ tick,
162
+ kind: "check",
163
+ label: expectation.id,
164
+ values: { value, initial, pass },
165
+ });
166
+ return { id: expectation.id, subject: expectation.subject ?? expectation.id, tick, pass, initial, value };
167
+ };
168
+ const initialReadings = new Map();
169
+ for (const expectation of script.expect) {
170
+ if (expectation.at === undefined || expectation.at > 0)
171
+ initialReadings.set(expectation.id, expectation.read(ctx));
172
+ }
173
+ const results = [];
174
+ for (const expectation of script.expect) {
175
+ if (expectation.at === 0)
176
+ results.push(sample(expectation, 0));
177
+ }
178
+ const repeatFiredAt = new Map();
179
+ const gameActions = Object.keys(definition.input ?? {});
180
+ const slotActions = findHotbarSlotActions(definition.input);
181
+ const hotbarId = hotbarIdFor(playable);
182
+ const sink = { run: (name, input) => runCommand(name, input, `action ${name}`) };
183
+ const commandAim = () => ({ yaw, pitch });
184
+ /** The shell's per-frame action phase: bound commands, prompts, hotbar — run after the tick it belongs to. */
185
+ const dispatchActions = () => {
186
+ const isDown = (action) => ctx.input.isDown(action);
187
+ const wasPressed = (action) => ctx.input.justPressed(action);
188
+ const tracker = { isDown, wasPressed };
189
+ for (const action of gameActions) {
190
+ if (action === "interact") {
191
+ if (!wasPressed(action))
192
+ continue;
193
+ const prompts = playable.prompts?.(ctx) ?? [];
194
+ const focus = ctx.scene.entity.get(playerId);
195
+ if (prompts.length === 0 || focus === null)
196
+ continue;
197
+ const active = resolveActivePrompt({ x: focus.position[0], z: focus.position[2] }, prompts);
198
+ if (active?.prompt.invoke != null)
199
+ runCommand(active.prompt.invoke.name, active.prompt.invoke.input, `prompt ${active.id}`);
200
+ continue;
201
+ }
202
+ if (!shouldFireBoundAction(tracker, action, definition.input, repeatFiredAt, clockMs))
203
+ continue;
204
+ repeatFiredAt.set(action, clockMs);
205
+ dispatchBoundAction(ctx, action, yaw, pitch, commandAim(), RESERVED_INPUT_ACTIONS, sink);
206
+ }
207
+ if (hotbarId !== null) {
208
+ for (const { action, slot } of slotActions) {
209
+ if (!wasPressed(action))
210
+ continue;
211
+ executeHotbarSlot(ctx, playerId, hotbarId, slot, yaw, pitch);
212
+ }
213
+ if (wasPressed("useAbility")) {
214
+ const slots = ctx.player.inventory.state(hotbarId).slots;
215
+ const preferred = playable.hotbarSelection?.() ?? -1;
216
+ const slot = preferred >= 0 && slots[preferred] != null ? preferred : slots.findIndex((stack) => stack != null);
217
+ if (slot >= 0)
218
+ executeHotbarSlot(ctx, playerId, hotbarId, slot, yaw, pitch);
219
+ }
220
+ }
221
+ };
222
+ const stepsByTick = new Map();
223
+ for (const step of script.steps) {
224
+ const bucket = stepsByTick.get(step.tick) ?? [];
225
+ bucket.push(step);
226
+ stepsByTick.set(step.tick, bucket);
227
+ }
228
+ for (let tick = 0; tick < script.totalTicks; tick += 1) {
229
+ for (const step of stepsByTick.get(tick) ?? []) {
230
+ record({ tick, kind: "step", label: stepLabel(step), values: { action: step.action, ticks: step.ticks ?? 1 } });
231
+ if (step.action === "command")
232
+ runCommand(step.name, step.input);
233
+ if (step.action === "aim") {
234
+ yaw = step.yaw;
235
+ if (step.pitch !== undefined)
236
+ pitch = step.pitch;
237
+ runner.aim({ heading: yaw, pitch });
238
+ }
239
+ }
240
+ const held = heldWindows.filter((window) => tick >= window.from && tick < window.to).map((window) => window.action);
241
+ clockMs = Math.round((tick + 1) * dt * 1000 * 1000) / 1000;
242
+ runner.step(dt, { held, pointer: null });
243
+ dispatchActions();
244
+ script.onTick?.(ctx, runner.tick());
245
+ options.onTick?.(runner.tick(), ctx);
246
+ const nowTick = runner.tick();
247
+ for (const expectation of script.expect) {
248
+ if (expectation.at === nowTick)
249
+ results.push(sample(expectation, nowTick));
250
+ }
251
+ }
252
+ const finalTick = runner.tick();
253
+ for (const expectation of script.expect) {
254
+ if (expectation.at === undefined)
255
+ results.push(sample(expectation, finalTick));
256
+ }
257
+ return {
258
+ seed: script.seed,
259
+ dt,
260
+ ticks: finalTick,
261
+ timeline,
262
+ expectations: results,
263
+ passed: results.every((result) => result.pass),
264
+ digest: playTimelineDigest(timeline),
265
+ };
266
+ }
@@ -0,0 +1,103 @@
1
+ import type { GameContext } from "@jgengine/core/runtime/gameContext";
2
+ import type { HeadlessRunner } from "@jgengine/core/runtime/headlessRunner";
3
+ /** A reading an assertion compares: a stat, a distance, a count, a phase id. */
4
+ export type PlayValue = number | string | boolean | null;
5
+ /** Direction a scripted step walks, using the shell's own movement actions. */
6
+ export type PlayMove = "forward" | "back" | "left" | "right";
7
+ /** Movement action ids a {@link PlayMove} holds, so a script walks through the same controller a player does. */
8
+ export declare const PLAY_MOVE_ACTIONS: Readonly<Record<PlayMove, string>>;
9
+ interface PlayStepBase {
10
+ /** Simulation tick the step starts on. Ticks, not seconds: the run advances one fixed step per tick. */
11
+ tick: number;
12
+ /** How many ticks the step lasts. Default 1 for every action. */
13
+ ticks?: number;
14
+ /** Shown in the timeline instead of the generated description. */
15
+ label?: string;
16
+ }
17
+ /** Hold a movement direction for `ticks` — the shell walk controller's own held actions. */
18
+ export interface PlayMoveStep extends PlayStepBase {
19
+ action: "move";
20
+ move: PlayMove;
21
+ }
22
+ /** Point the walk controller and the aim payload at a yaw/pitch, in radians. */
23
+ export interface PlayAimStep extends PlayStepBase {
24
+ action: "aim";
25
+ yaw: number;
26
+ pitch?: number;
27
+ }
28
+ /** Hold the `fire` action: the bound `fire` command runs on the press tick, then on its repeat cadence. */
29
+ export interface PlayFireStep extends PlayStepBase {
30
+ action: "fire";
31
+ }
32
+ /** Press one bound action id once (default one tick) — `interact`, `reload`, `useAbility`, a game's own action. */
33
+ export interface PlayUseStep extends PlayStepBase {
34
+ action: "use";
35
+ actionId: string;
36
+ }
37
+ /** Hold nothing for `ticks`, letting the world run. */
38
+ export interface PlayWaitStep extends PlayStepBase {
39
+ action: "wait";
40
+ ticks: number;
41
+ }
42
+ /** Run a registered command directly, with whatever input it declares. */
43
+ export interface PlayCommandStep extends PlayStepBase {
44
+ action: "command";
45
+ name: string;
46
+ input?: unknown;
47
+ }
48
+ /** One tick-based action in a script. */
49
+ export type PlayStep = PlayMoveStep | PlayAimStep | PlayFireStep | PlayUseStep | PlayWaitStep | PlayCommandStep;
50
+ /**
51
+ * One assertion about world state. Read happens twice — right after `setup` and on the assertion's own
52
+ * tick — so a script can prove change ("health dropped") as well as a level ("wave is 2").
53
+ */
54
+ export interface PlayExpectation {
55
+ /** Stable name the CLI prints and exits on. */
56
+ id: string;
57
+ /** What is being measured, in the game's words. Defaults to {@link PlayExpectation.id}. */
58
+ subject?: string;
59
+ /** Tick to read at. Defaults to the run's final tick. */
60
+ at?: number;
61
+ /** Read the observed value off the live world. */
62
+ read: (ctx: GameContext) => PlayValue;
63
+ /** Passes when this is true. Receives the reading at `at` and the reading taken right after `setup`. Default: the reading is truthy. */
64
+ test?: (now: PlayValue, initial: PlayValue) => boolean;
65
+ }
66
+ /** A script to play against a real game: what to do, on which tick, and what must be true afterwards. */
67
+ export interface PlayScript {
68
+ /** Seed for `ctx.rng` and the run's identity; the same seed and script replay to the same timeline. */
69
+ seed: string | number;
70
+ /** Fixed tick length in seconds. Default 1/60. */
71
+ dt: number;
72
+ /** Scripted actions, sorted by tick. */
73
+ steps: readonly PlayStep[];
74
+ /** Assertions sampled during the run. */
75
+ expect: readonly PlayExpectation[];
76
+ /** Run this many ticks even when no step or expectation needs them. Default 0. */
77
+ ticks: number;
78
+ /** Total ticks the run advances: steps, expectations and `ticks` folded together. */
79
+ totalTicks: number;
80
+ /** Boot work that must happen before the first tick: pick a class, open a wave, spawn the actors the script is about. */
81
+ setup?: (ctx: GameContext, runner: HeadlessRunner) => void;
82
+ /** Called after every tick — a game that needs a decision per tick (send a wave the moment it is ready) writes it as code instead of guessing the tick in advance. Keep it tick-based: read state, run commands, never a clock. */
83
+ onTick?: (ctx: GameContext, tick: number) => void;
84
+ }
85
+ /** What {@link createPlayScript} accepts: the same script with the derived fields optional. */
86
+ export interface PlayScriptSpec {
87
+ seed: string | number;
88
+ /** Fixed tick length in seconds. Default 1/60. */
89
+ dt?: number;
90
+ steps?: readonly PlayStep[];
91
+ expect?: readonly PlayExpectation[];
92
+ /** Minimum run length in ticks. */
93
+ ticks?: number;
94
+ setup?: (ctx: GameContext, runner: HeadlessRunner) => void;
95
+ onTick?: (ctx: GameContext, tick: number) => void;
96
+ }
97
+ /**
98
+ * Validate and normalize a script: whole-tick timings, sorted steps, a derived run length.
99
+ *
100
+ * @capability play-script write a game's behaviour as a tick-based script with declarative assertions instead of playing it in a browser
101
+ */
102
+ export declare function createPlayScript(spec: PlayScriptSpec): PlayScript;
103
+ export {};
@@ -0,0 +1,52 @@
1
+ /** Movement action ids a {@link PlayMove} holds, so a script walks through the same controller a player does. */
2
+ export const PLAY_MOVE_ACTIONS = {
3
+ forward: "moveForward",
4
+ back: "moveBack",
5
+ left: "moveLeft",
6
+ right: "moveRight",
7
+ };
8
+ function requireTick(value, what) {
9
+ if (!Number.isInteger(value) || value < 0) {
10
+ throw new Error(`play script: ${what} must be a non-negative whole tick, got ${String(value)}`);
11
+ }
12
+ return value;
13
+ }
14
+ /**
15
+ * Validate and normalize a script: whole-tick timings, sorted steps, a derived run length.
16
+ *
17
+ * @capability play-script write a game's behaviour as a tick-based script with declarative assertions instead of playing it in a browser
18
+ */
19
+ export function createPlayScript(spec) {
20
+ if (typeof spec.seed !== "string" && typeof spec.seed !== "number") {
21
+ throw new Error("play script: seed must be a string or number so a run can be replayed");
22
+ }
23
+ const dt = spec.dt ?? 1 / 60;
24
+ if (!Number.isFinite(dt) || dt <= 0)
25
+ throw new Error(`play script: dt must be a positive number of seconds, got ${String(spec.dt)}`);
26
+ const steps = (spec.steps ?? []).map((step, index) => {
27
+ requireTick(step.tick, `step ${index + 1} (${step.action}) tick`);
28
+ const ticks = step.ticks === undefined ? 1 : step.ticks;
29
+ if (!Number.isInteger(ticks) || ticks < 1) {
30
+ throw new Error(`play script: step ${index + 1} (${step.action}) ticks must be a positive whole number, got ${String(step.ticks)}`);
31
+ }
32
+ if (step.action === "use" && step.actionId.trim() === "") {
33
+ throw new Error(`play script: step ${index + 1} needs a non-empty actionId`);
34
+ }
35
+ if (step.action === "command" && step.name.trim() === "") {
36
+ throw new Error(`play script: step ${index + 1} needs a non-empty command name`);
37
+ }
38
+ return { ...step, ticks };
39
+ });
40
+ steps.sort((left, right) => left.tick - right.tick);
41
+ const expect = (spec.expect ?? []).map((entry, index) => {
42
+ if (entry.id.trim() === "")
43
+ throw new Error(`play script: expectation ${index + 1} needs an id`);
44
+ if (entry.at !== undefined)
45
+ requireTick(entry.at, `expectation "${entry.id}" tick`);
46
+ return { ...entry };
47
+ });
48
+ const lastStep = steps.reduce((max, step) => Math.max(max, step.tick + (step.ticks ?? 1)), 0);
49
+ const lastExpect = expect.reduce((max, entry) => Math.max(max, entry.at ?? 0), 0);
50
+ const totalTicks = Math.max(lastStep, lastExpect, spec.ticks ?? 0, 1);
51
+ return { seed: spec.seed, dt, steps, expect, ticks: spec.ticks ?? 0, totalTicks, ...(spec.setup === undefined ? {} : { setup: spec.setup }), ...(spec.onTick === undefined ? {} : { onTick: spec.onTick }) };
52
+ }
@@ -0,0 +1,43 @@
1
+ import type { PlayValue } from "./script.js";
2
+ /** What a timeline row records: a scripted action, a command result, a world event, or an assertion reading. */
3
+ export type PlayTimelineKind = "step" | "command" | "event" | "check";
4
+ /** One row of what happened, on the tick it happened. */
5
+ export interface PlayTimelineEntry {
6
+ tick: number;
7
+ kind: PlayTimelineKind;
8
+ /** The action, command, event name or assertion id. */
9
+ label: string;
10
+ /** The numbers behind the row: a health, a distance, a wave number, a status. */
11
+ values?: Readonly<Record<string, PlayValue>>;
12
+ }
13
+ /** One assertion's verdict, with both readings so a failure says what changed and by how much. */
14
+ export interface PlayExpectationResult {
15
+ id: string;
16
+ subject: string;
17
+ tick: number;
18
+ pass: boolean;
19
+ /** The reading taken right after `setup`, before the script's steps ran. */
20
+ initial: PlayValue;
21
+ /** The reading on the assertion's own tick. */
22
+ value: PlayValue;
23
+ }
24
+ /** Everything a scripted run produced: the timeline, the assertion verdicts, and a digest of the timeline for replay comparison. */
25
+ export interface PlayRunResult {
26
+ seed: string | number;
27
+ /** Fixed tick length the run advanced in seconds. */
28
+ dt: number;
29
+ ticks: number;
30
+ timeline: readonly PlayTimelineEntry[];
31
+ expectations: readonly PlayExpectationResult[];
32
+ /** True when every expectation passed. */
33
+ passed: boolean;
34
+ /** Deterministic hash of the timeline — equal digests mean two runs played identically. */
35
+ digest: string;
36
+ }
37
+ /**
38
+ * FNV-1a over the timeline's canonical JSON. Cheap, dependency-free and stable across runs and
39
+ * machines, which is all a replay comparison needs.
40
+ */
41
+ export declare function playTimelineDigest(timeline: readonly PlayTimelineEntry[]): string;
42
+ /** One compact timeline line: `t=120 event combat.damage to=bruiser amount=12 remaining=54`. */
43
+ export declare function formatPlayTimelineEntry(entry: PlayTimelineEntry): string;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * FNV-1a over the timeline's canonical JSON. Cheap, dependency-free and stable across runs and
3
+ * machines, which is all a replay comparison needs.
4
+ */
5
+ export function playTimelineDigest(timeline) {
6
+ const text = JSON.stringify(timeline.map((entry) => [entry.tick, entry.kind, entry.label, entry.values ?? null]));
7
+ let hash = 0x811c9dc5;
8
+ for (let index = 0; index < text.length; index += 1) {
9
+ hash ^= text.charCodeAt(index);
10
+ hash = Math.imul(hash, 0x01000193) >>> 0;
11
+ }
12
+ return hash.toString(16).padStart(8, "0");
13
+ }
14
+ function formatValue(value) {
15
+ if (value === undefined)
16
+ return "";
17
+ if (typeof value === "number")
18
+ return Number.isInteger(value) ? String(value) : value.toFixed(3).replace(/\.?0+$/, "");
19
+ return String(value);
20
+ }
21
+ /** One compact timeline line: `t=120 event combat.damage to=bruiser amount=12 remaining=54`. */
22
+ export function formatPlayTimelineEntry(entry) {
23
+ const values = Object.entries(entry.values ?? {})
24
+ .map(([key, value]) => `${key}=${formatValue(value)}`)
25
+ .join(" ");
26
+ return `t=${entry.tick} ${entry.kind} ${entry.label}${values === "" ? "" : ` ${values}`}`;
27
+ }
@@ -4,6 +4,8 @@ export declare const POINTER_ENTITY_KEY = "jgEntityId";
4
4
  export declare const POINTER_OBJECT_KEY = "jgObjectId";
5
5
  /** Object-store identities indexed by the current compacted GPU instance slot. @internal */
6
6
  export declare const POINTER_OBJECT_INSTANCES_KEY = "jgObjectInstances";
7
+ /** Entity identities indexed by the current compacted GPU batch slot. @internal */
8
+ export declare const POINTER_ENTITY_INSTANCES_KEY = "jgEntityInstances";
7
9
  export type PointerHitFilter = (object: THREE.Object3D) => boolean;
8
10
  interface PointerDeps {
9
11
  camera: THREE.Camera;
@@ -3,6 +3,8 @@ export const POINTER_ENTITY_KEY = "jgEntityId";
3
3
  export const POINTER_OBJECT_KEY = "jgObjectId";
4
4
  /** Object-store identities indexed by the current compacted GPU instance slot. @internal */
5
5
  export const POINTER_OBJECT_INSTANCES_KEY = "jgObjectInstances";
6
+ /** Entity identities indexed by the current compacted GPU batch slot. @internal */
7
+ export const POINTER_ENTITY_INSTANCES_KEY = "jgEntityInstances";
6
8
  function tagOf(object, key) {
7
9
  let cursor = object;
8
10
  while (cursor !== null) {
@@ -62,7 +64,8 @@ export function createPointerService() {
62
64
  return {
63
65
  point,
64
66
  normal,
65
- entity: tagOf(hit.object, POINTER_ENTITY_KEY),
67
+ entity: hit.instanceId === undefined ? tagOf(hit.object, POINTER_ENTITY_KEY) :
68
+ hit.object.userData[POINTER_ENTITY_INSTANCES_KEY]?.[hit.instanceId] ?? tagOf(hit.object, POINTER_ENTITY_KEY),
66
69
  object: hit.instanceId === undefined ? tagOf(hit.object, POINTER_OBJECT_KEY) :
67
70
  hit.object.userData[POINTER_OBJECT_INSTANCES_KEY]?.[hit.instanceId] ?? tagOf(hit.object, POINTER_OBJECT_KEY),
68
71
  ...(uv !== undefined ? { uv } : {}),
@@ -51,12 +51,20 @@ export interface PostfxStagePlan {
51
51
  grade: GradeConfig | null;
52
52
  /** The authored exposure the meter corrects around. */
53
53
  exposure: number;
54
+ /**
55
+ * Composer backing store as a fraction of the canvas. Every stage below `1` — AO, bloom, DOF,
56
+ * SMAA and the beauty pass itself — renders into a smaller target, and the OutputPass stretches it
57
+ * back over the canvas.
58
+ */
59
+ resolutionScale: number;
54
60
  }
55
61
  interface BuiltGraph {
56
62
  composer: EffectComposer;
57
63
  grade: ShaderPass | null;
58
64
  /** Stages this chain actually built, in pass order. A stage absent here costs no draw. */
59
65
  stages: readonly PostfxStage[];
66
+ /** The plan's {@link PostfxStagePlan.resolutionScale}, kept so a resize re-applies it. */
67
+ resolutionScale: number;
60
68
  }
61
69
  /**
62
70
  * Rounds an adapted exposure to 1/32 of a stop. Without it the sky's display-colour cache (which keys
@@ -66,7 +74,11 @@ interface BuiltGraph {
66
74
  export declare function quantizeExposure(value: number): number;
67
75
  /** Release each pass and the composer's owned targets. @internal */
68
76
  export declare function disposeGraph(built: BuiltGraph): void;
69
- /** Resize the composer and its passes at the renderer's pixel ratio. @internal */
77
+ /**
78
+ * Resize the composer and its passes to the plan's share of the renderer's backing store. The
79
+ * scaled size is rounded to whole pixels: a fractional target size reaches `texImage2D` as one.
80
+ * @internal
81
+ */
70
82
  export declare function syncSize(built: BuiltGraph, width: number, height: number, pixelRatio: number): void;
71
83
  /** AO the chain runs: authored config, {@link DEFAULT_AO} when unset, null when off or the tier skips it. @internal */
72
84
  export declare function resolveAoStage(ao: PostProcessingConfig["ao"], tierAllowsAo: boolean): AoConfig | null;
@@ -102,6 +114,8 @@ export declare function buildPostGraph(gl: THREE.WebGLRenderer, scene: THREE.Sce
102
114
  * independently. Medium keeps AO and DOF with reduced AO sampling; low can disable
103
115
  * them while retaining cheaper bloom, SMAA, tone mapping, and grade stages. A stage
104
116
  * that is off adds no pass at all, so it costs no draw and allocates no target.
117
+ * The tier also sets the composer's resolution scale, so the passes a lower tier
118
+ * keeps still cover fewer pixels.
105
119
  */
106
120
  export declare function PostProcessing({ config, quality, stages }: {
107
121
  config: PostProcessingConfig;
@@ -10,6 +10,7 @@ import { DEFAULT_AO, } from "@jgengine/core/render/postProcessing";
10
10
  import { adaptExposure, DEFAULT_AUTO_EXPOSURE, exposureForKey, keyLuminanceFromSamples, } from "@jgengine/core/render/colorPipeline";
11
11
  import { ExposureMeterPass } from "./autoExposure.js";
12
12
  import { createGradePass } from "./gradeShader.js";
13
+ import { postfxTierPolicy } from "./postfxTierPolicy.js";
13
14
  import { releasePostfxOverlayTracking } from "./postfxOverlay.js";
14
15
  import { StylizePass } from "./stylizePass.js";
15
16
  import { renderShadowlessPrepass } from "./shadowPrepass.js";
@@ -70,10 +71,15 @@ export function disposeGraph(built) {
70
71
  pass.dispose();
71
72
  built.composer.dispose();
72
73
  }
73
- /** Resize the composer and its passes at the renderer's pixel ratio. @internal */
74
+ /**
75
+ * Resize the composer and its passes to the plan's share of the renderer's backing store. The
76
+ * scaled size is rounded to whole pixels: a fractional target size reaches `texImage2D` as one.
77
+ * @internal
78
+ */
74
79
  export function syncSize(built, width, height, pixelRatio) {
75
- built.composer.setPixelRatio(pixelRatio);
76
- built.composer.setSize(width, height);
80
+ const scale = pixelRatio * built.resolutionScale;
81
+ built.composer.setPixelRatio(1);
82
+ built.composer.setSize(Math.max(1, Math.round(width * scale)), Math.max(1, Math.round(height * scale)));
77
83
  }
78
84
  /** AO the chain runs: authored config, {@link DEFAULT_AO} when unset, null when off or the tier skips it. @internal */
79
85
  export function resolveAoStage(ao, tierAllowsAo) {
@@ -84,7 +90,8 @@ export function resolveAoStage(ao, tierAllowsAo) {
84
90
  /** The stages one chain asks for, before any pass is built. Pure data. @internal */
85
91
  export function planPostStages(config, quality = "high", stages) {
86
92
  const aa = config.aa === undefined ? "smaa" : config.aa;
87
- const tierStages = stages ?? { ao: quality === "high", bloom: true, dof: quality === "high", smaa: true };
93
+ const policy = postfxTierPolicy(quality);
94
+ const tierStages = stages ?? policy.stages;
88
95
  const ao = resolveAoStage(config.ao, tierStages.ao);
89
96
  const authoredDof = tierStages.dof && config.dof !== undefined && config.dof !== false ? config.dof : null;
90
97
  const bloom = tierStages.bloom && config.bloom !== false ? (config.bloom ?? {}) : null;
@@ -92,7 +99,7 @@ export function planPostStages(config, quality = "high", stages) {
92
99
  return {
93
100
  samples: aa === false ? 0 : 2,
94
101
  ao,
95
- aoSamples: quality === "ultra" ? 16 : quality === "high" ? 12 : 8,
102
+ aoSamples: policy.aoSamples,
96
103
  aoGbuffer: smaa || aa === false ? "beauty" : "prepass",
97
104
  bloom,
98
105
  dof: authoredDof === null ? null : { ...authoredDof, aperture: authoredDof.aperture ?? (quality === "high" ? 0.00025 : 0.00012) },
@@ -101,6 +108,7 @@ export function planPostStages(config, quality = "high", stages) {
101
108
  stylize: config.stylize !== undefined && config.stylize !== false ? config.stylize : null,
102
109
  grade: config.grade !== false ? (config.grade ?? {}) : null,
103
110
  exposure: config.exposure ?? 1,
111
+ resolutionScale: policy.resolutionScale,
104
112
  };
105
113
  }
106
114
  /**
@@ -198,7 +206,7 @@ export function buildPostGraph(gl, scene, camera, plan, width, height) {
198
206
  composer.addPass(grade);
199
207
  built.push("grade");
200
208
  }
201
- return { composer, grade, stages: built };
209
+ return { composer, grade, stages: built, resolutionScale: plan.resolutionScale };
202
210
  }
203
211
  /**
204
212
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
@@ -210,6 +218,8 @@ export function buildPostGraph(gl, scene, camera, plan, width, height) {
210
218
  * independently. Medium keeps AO and DOF with reduced AO sampling; low can disable
211
219
  * them while retaining cheaper bloom, SMAA, tone mapping, and grade stages. A stage
212
220
  * that is off adds no pass at all, so it costs no draw and allocates no target.
221
+ * The tier also sets the composer's resolution scale, so the passes a lower tier
222
+ * keeps still cover fewer pixels.
213
223
  */
214
224
  export function PostProcessing({ config, quality = "high", stages }) {
215
225
  const gl = useThree((s) => s.gl);
@@ -0,0 +1,25 @@
1
+ import type { GraphicsQuality } from "@jgengine/core/settings/settingsModel";
2
+ /** What one graphics tier may spend on the post chain. @internal */
3
+ export interface PostfxTierPolicy {
4
+ /** Composer backing store as a fraction of the canvas; below 1 the OutputPass upscales the result. */
5
+ resolutionScale: number;
6
+ /** GTAO's sample count, when the chain runs AO at all. */
7
+ aoSamples: number;
8
+ /** The stages the tier offers before the player overrides them. */
9
+ stages: {
10
+ ao: boolean;
11
+ bloom: boolean;
12
+ dof: boolean;
13
+ smaa: boolean;
14
+ };
15
+ }
16
+ /**
17
+ * The post chain's quality tiers. `high` and `ultra` render at native size; the tiers below pay for
18
+ * their cheaper stage set twice, by building fewer passes and by rendering the ones left smaller.
19
+ * `stages` mirrors `DEFAULT_GRAPHICS_PROFILES.postStages`, so a plan built without the player's
20
+ * toggles spends the same passes the profile names.
21
+ * @internal
22
+ */
23
+ export declare const POSTFX_TIER_POLICY: Record<GraphicsQuality, PostfxTierPolicy>;
24
+ /** A tier's post-chain budget; `high` is the reference every lower tier is measured against. @internal */
25
+ export declare function postfxTierPolicy(quality: GraphicsQuality): PostfxTierPolicy;