@waica/engine 0.5.0 → 0.6.1

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/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # `@waica/engine`
2
+
3
+ Waica's public engine core: entities and components, the game loop, scene and prefab loading, state machines, input, collisions, sprites, camera, stats, and UI.
4
+
5
+ ```ts
6
+ import { Component, Game, loadScene } from '@waica/engine'
7
+ ```
8
+
9
+ ## Component lifecycle
10
+
11
+ Waica keeps the lifecycle boundaries distinct:
12
+
13
+ 1. `Entity.add()` mounts a component and calls its `onReady` immediately. This remains component insertion order so setup behavior does not silently move.
14
+ 2. During each simulated frame, entities keep their existing entity order. When an entity's turn begins, `Game` snapshots and resolves that entity's component `onUpdate` schedule, then dispatches only that schedule.
15
+ 3. Physical `onContact` hooks run from `DynamicBody` while it updates. Hitbox `onCollide` hooks run after all entity component updates. Their existing component dispatch order is unchanged.
16
+ 4. `Game.onUpdate` callbacks run after component updates, collisions, and camera work; input end-of-frame handling follows them.
17
+ 5. `Entity.destroy()` calls `onDestroy` in component insertion order.
18
+
19
+ Only classes whose prototype chain implements `onUpdate` participate in the update schedule. Passive components remain available to their siblings but receive no update position.
20
+
21
+ ## Declaring update constraints
22
+
23
+ An updateable component can declare the sibling writes it must observe with inherited static `updateAfter` metadata:
24
+
25
+ ```ts
26
+ import { Component, StateMachine } from '@waica/engine'
27
+
28
+ export class DamageFlash extends Component {
29
+ static override componentName = 'DamageFlash'
30
+ static override updateAfter: readonly string[] = ['StateMachine']
31
+
32
+ override onUpdate(dt: number): void {
33
+ const state = this.entity.get(StateMachine)?.current
34
+ // This update observes StateMachine's state for the same frame.
35
+ void state
36
+ void dt
37
+ }
38
+ }
39
+ ```
40
+
41
+ The relation is conditional on co-presence. `DamageFlash` does not require a `StateMachine`; when that target is registered but absent from this entity, no edge and no issue are created. A subclass inherits `updateAfter` when it declares nothing and replaces the inherited list when it declares its own list.
42
+
43
+ Constraints always win over the tie-break. Whenever several components are ready simultaneously, Waica compares their case-sensitive `componentName` values in ascending Unicode code-unit order. It never uses locale collation, prefab order, scene order, or editor card grouping. Repeated names in one `updateAfter` list describe one edge.
44
+
45
+ ## Invalid schedules fail closed
46
+
47
+ Component identity must be unique within an entity, and both sides of a present constraint must implement `onUpdate`. Waica rejects duplicate component names, unknown targets, passive declarers or present passive targets, self-edges, and multi-component cycles.
48
+
49
+ At runtime an invalid entity runs no partial update schedule and does not fall back to authored order. Other entities continue updating. The engine logs one diagnostic containing the entity and causes, then logs again only if that entity's composition changes.
50
+
51
+ Tools can inspect a composition without constructing components:
52
+
53
+ ```ts
54
+ import { resolveComponentUpdateSchedule, type ComponentClass } from '@waica/engine'
55
+
56
+ const registry: Record<string, ComponentClass> = { DamageFlash, StateMachine }
57
+ const result = resolveComponentUpdateSchedule(
58
+ ['DamageFlash', 'StateMachine'],
59
+ registry,
60
+ )
61
+
62
+ if (result.ok) {
63
+ console.log(result.order) // ['StateMachine', 'DamageFlash']
64
+ } else {
65
+ console.error(result.issues)
66
+ }
67
+ ```
68
+
69
+ The resolver is pure. Pass the effective component-name list and the complete class registry, including project-owned classes. A valid result contains `order` and no issues; an invalid result contains typed, actionable issues and no executable order.
70
+
71
+ ## Runtime inspection
72
+
73
+ The engine owns Runtime Bridge protocol 1, but it is dormant during ordinary execution: there is no string-named global, network endpoint or per-frame bridge work. An MCP-owned browser context can install the symbol-keyed ephemeral activation hook before navigation. In that context `Game.start()` registers the fully constructed Game at a paused frame-zero baseline; `Game.dispose()` or page unload unregisters it.
74
+
75
+ Runtime Snapshots automatically inspect public own component fields and setter-backed accessors while excluding `_` fields, `entity`, `game` and functions. A component can replace automatic discovery with the optional public contract:
76
+
77
+ ```ts
78
+ class PathFinder extends Component {
79
+ inspectState(): unknown {
80
+ return { target: this.target, remaining: this.path.length }
81
+ }
82
+ }
83
+ ```
84
+
85
+ The return value still passes through the bounded safe projector; it is not serialized with arbitrary `toJSON()`. The package root exports the Runtime Snapshot, projection marker, metadata, control and activation types plus `RUNTIME_BRIDGE_PROTOCOL_VERSION` and `RUNTIME_PROJECTION_LIMITS`.
@@ -0,0 +1,39 @@
1
+ import type { ComponentClass } from './component.js';
2
+ export type InvalidUpdateConstraintReason = 'unknown-target' | 'passive-declarer' | 'passive-target' | 'self-edge' | 'unregistered-component';
3
+ export interface DuplicateComponentUpdateIssue {
4
+ readonly code: 'duplicate-component';
5
+ readonly componentNames: readonly string[];
6
+ readonly componentName: string;
7
+ readonly count: number;
8
+ readonly cause: string;
9
+ }
10
+ export interface InvalidComponentUpdateConstraintIssue {
11
+ readonly code: 'invalid-update-constraint';
12
+ readonly componentNames: readonly string[];
13
+ readonly declarer: string;
14
+ readonly target?: string;
15
+ readonly reason: InvalidUpdateConstraintReason;
16
+ readonly cause: string;
17
+ }
18
+ export interface ComponentUpdateCycleIssue {
19
+ readonly code: 'component-update-cycle';
20
+ readonly componentNames: readonly string[];
21
+ readonly cause: string;
22
+ }
23
+ export type ComponentUpdateScheduleIssue = DuplicateComponentUpdateIssue | InvalidComponentUpdateConstraintIssue | ComponentUpdateCycleIssue;
24
+ export interface ValidComponentUpdateSchedule {
25
+ readonly ok: true;
26
+ readonly order: readonly string[];
27
+ readonly issues: readonly [];
28
+ }
29
+ export interface InvalidComponentUpdateSchedule {
30
+ readonly ok: false;
31
+ readonly issues: readonly ComponentUpdateScheduleIssue[];
32
+ }
33
+ export type ComponentUpdateScheduleResult = ValidComponentUpdateSchedule | InvalidComponentUpdateSchedule;
34
+ export type ComponentUpdateRegistry = Readonly<Record<string, ComponentClass | undefined>>;
35
+ /**
36
+ * Resolves one entity's deterministic component update schedule without
37
+ * constructing components or mutating either input.
38
+ */
39
+ export declare function resolveComponentUpdateSchedule(componentNames: readonly string[], registry: ComponentUpdateRegistry): ComponentUpdateScheduleResult;
@@ -0,0 +1,157 @@
1
+ function codeUnitCompare(left, right) {
2
+ return left < right ? -1 : left > right ? 1 : 0;
3
+ }
4
+ function registeredClass(registry, componentName) {
5
+ return Object.hasOwn(registry, componentName) ? registry[componentName] : undefined;
6
+ }
7
+ function updates(Class) {
8
+ return typeof Class?.prototype.onUpdate === 'function';
9
+ }
10
+ function updateCycles(nodes, outgoing) {
11
+ let nextIndex = 0;
12
+ const indices = new Map();
13
+ const lowLinks = new Map();
14
+ const stack = [];
15
+ const onStack = new Set();
16
+ const cycles = [];
17
+ const visit = (node) => {
18
+ const index = nextIndex++;
19
+ indices.set(node, index);
20
+ lowLinks.set(node, index);
21
+ stack.push(node);
22
+ onStack.add(node);
23
+ for (const dependent of [...(outgoing.get(node) ?? [])].sort(codeUnitCompare)) {
24
+ if (!indices.has(dependent)) {
25
+ visit(dependent);
26
+ lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(dependent)));
27
+ }
28
+ else if (onStack.has(dependent)) {
29
+ lowLinks.set(node, Math.min(lowLinks.get(node), indices.get(dependent)));
30
+ }
31
+ }
32
+ if (lowLinks.get(node) !== indices.get(node))
33
+ return;
34
+ const group = [];
35
+ while (stack.length > 0) {
36
+ const member = stack.pop();
37
+ onStack.delete(member);
38
+ group.push(member);
39
+ if (member === node)
40
+ break;
41
+ }
42
+ if (group.length > 1)
43
+ cycles.push(group.sort(codeUnitCompare));
44
+ };
45
+ for (const node of nodes) {
46
+ if (!indices.has(node))
47
+ visit(node);
48
+ }
49
+ return cycles.sort((left, right) => codeUnitCompare(left[0], right[0]));
50
+ }
51
+ /**
52
+ * Resolves one entity's deterministic component update schedule without
53
+ * constructing components or mutating either input.
54
+ */
55
+ export function resolveComponentUpdateSchedule(componentNames, registry) {
56
+ const present = new Set(componentNames);
57
+ const nodes = [...present]
58
+ .filter((name) => updates(registeredClass(registry, name)))
59
+ .sort(codeUnitCompare);
60
+ const outgoing = new Map(nodes.map((name) => [name, new Set()]));
61
+ const indegree = new Map(nodes.map((name) => [name, 0]));
62
+ const issues = [];
63
+ const counts = new Map();
64
+ for (const name of componentNames)
65
+ counts.set(name, (counts.get(name) ?? 0) + 1);
66
+ for (const [componentName, count] of [...counts].sort(([left], [right]) => codeUnitCompare(left, right))) {
67
+ if (count < 2)
68
+ continue;
69
+ issues.push({
70
+ code: 'duplicate-component',
71
+ componentName,
72
+ componentNames: [componentName],
73
+ count,
74
+ cause: `Component "${componentName}" appears ${count} times on the same entity; component identity must be unique.`,
75
+ });
76
+ }
77
+ for (const declarer of [...present].sort(codeUnitCompare)) {
78
+ const Class = registeredClass(registry, declarer);
79
+ if (Class?.updateAfter !== undefined && !updates(Class)) {
80
+ issues.push({
81
+ code: 'invalid-update-constraint',
82
+ reason: 'passive-declarer',
83
+ declarer,
84
+ componentNames: [declarer],
85
+ cause: `Passive component "${declarer}" declares updateAfter but does not implement onUpdate.`,
86
+ });
87
+ }
88
+ }
89
+ for (const declarer of nodes) {
90
+ const Class = registeredClass(registry, declarer);
91
+ for (const target of new Set(Class?.updateAfter ?? [])) {
92
+ if (!present.has(target)) {
93
+ if (!registeredClass(registry, target)) {
94
+ issues.push({
95
+ code: 'invalid-update-constraint',
96
+ reason: 'unknown-target',
97
+ declarer,
98
+ target,
99
+ componentNames: [declarer, target],
100
+ cause: `Component "${declarer}" declares updateAfter target "${target}", which is neither present nor registered.`,
101
+ });
102
+ }
103
+ continue;
104
+ }
105
+ if (target === declarer) {
106
+ issues.push({
107
+ code: 'invalid-update-constraint',
108
+ reason: 'self-edge',
109
+ declarer,
110
+ target,
111
+ componentNames: [declarer],
112
+ cause: `Component "${declarer}" cannot declare itself in updateAfter.`,
113
+ });
114
+ continue;
115
+ }
116
+ if (!outgoing.has(target)) {
117
+ issues.push({
118
+ code: 'invalid-update-constraint',
119
+ reason: 'passive-target',
120
+ declarer,
121
+ target,
122
+ componentNames: [declarer, target],
123
+ cause: `Component "${declarer}" declares updateAfter target "${target}", but "${target}" does not implement onUpdate.`,
124
+ });
125
+ continue;
126
+ }
127
+ const readers = outgoing.get(target);
128
+ if (readers.has(declarer))
129
+ continue;
130
+ readers.add(declarer);
131
+ indegree.set(declarer, (indegree.get(declarer) ?? 0) + 1);
132
+ }
133
+ }
134
+ for (const componentNames of updateCycles(nodes, outgoing)) {
135
+ issues.push({
136
+ code: 'component-update-cycle',
137
+ componentNames,
138
+ cause: `Component update cycle among ${componentNames.map((name) => `"${name}"`).join(', ')}.`,
139
+ });
140
+ }
141
+ if (issues.length > 0)
142
+ return { ok: false, issues };
143
+ const ready = nodes.filter((name) => indegree.get(name) === 0);
144
+ const order = [];
145
+ while (ready.length > 0) {
146
+ ready.sort(codeUnitCompare);
147
+ const next = ready.shift();
148
+ order.push(next);
149
+ for (const dependent of [...(outgoing.get(next) ?? [])].sort(codeUnitCompare)) {
150
+ const remaining = (indegree.get(dependent) ?? 0) - 1;
151
+ indegree.set(dependent, remaining);
152
+ if (remaining === 0)
153
+ ready.push(dependent);
154
+ }
155
+ }
156
+ return { ok: true, order, issues: [] };
157
+ }
@@ -23,6 +23,8 @@ export interface ComponentClass<T extends Component = Component> {
23
23
  displayName?: string;
24
24
  /** Which properties the inspector exposes, with their ranges. */
25
25
  params?: Record<string, ParamSpec>;
26
+ /** Sibling component updates that must complete before this one when present. */
27
+ updateAfter?: readonly string[];
26
28
  /**
27
29
  * Instance fields holding runtime state rather than authorable defaults.
28
30
  * Excluded from authoringDefaults(); a subclass that does not redeclare
@@ -51,9 +53,12 @@ export declare abstract class Component {
51
53
  static componentName: string;
52
54
  static displayName?: string;
53
55
  static params?: Record<string, ParamSpec>;
56
+ static updateAfter?: readonly string[];
54
57
  static transient?: readonly string[];
55
58
  entity: Entity;
56
59
  game: Game;
60
+ /** Replaces automatic runtime-state discovery for Runtime Snapshots. */
61
+ inspectState?(): unknown;
57
62
  /** Runs once the component is mounted on its entity. */
58
63
  onReady?(): void;
59
64
  /** Runs once per frame. */
package/dist/component.js CHANGED
@@ -6,6 +6,7 @@ export class Component {
6
6
  static componentName = 'Component';
7
7
  static displayName;
8
8
  static params;
9
+ static updateAfter;
9
10
  static transient;
10
11
  entity;
11
12
  game;
@@ -12,6 +12,7 @@ import { type SheetCell, type SheetDef } from '../animation/sheet.js';
12
12
  */
13
13
  export declare class AnimatedSprite extends Component {
14
14
  static componentName: string;
15
+ static updateAfter: readonly string[];
15
16
  static params: {
16
17
  offsetX: {
17
18
  label: string;
@@ -14,6 +14,7 @@ const loader = new THREE.TextureLoader();
14
14
  */
15
15
  export class AnimatedSprite extends Component {
16
16
  static componentName = 'AnimatedSprite';
17
+ static updateAfter = ['StateMachine'];
17
18
  static params = {
18
19
  offsetX: { label: 'x offset' },
19
20
  offsetY: { label: 'y offset' },
package/dist/game.d.ts CHANGED
@@ -57,10 +57,12 @@ export declare class Game {
57
57
  private readonly renderer;
58
58
  private readonly resizeObserver;
59
59
  private readonly updateFns;
60
+ private readonly invalidUpdateCompositions;
60
61
  private readonly resolution;
61
62
  private viewHeight;
62
63
  private sceneCamera;
63
64
  private lastTime;
65
+ private runtimeBridge;
64
66
  constructor(options: GameOptions);
65
67
  /** Creates a live entity in the scene. */
66
68
  spawn(name: string): Entity;
@@ -88,7 +90,12 @@ export declare class Game {
88
90
  setViewHeight(value: number): void;
89
91
  /** Shuts the game down completely (loop, input, GPU). */
90
92
  dispose(): void;
93
+ private resumeRuntime;
91
94
  private tick;
95
+ private runFrame;
96
+ private unregisterRuntimeBridge;
97
+ private renderSurface;
98
+ private componentUpdateSchedule;
92
99
  private updateSceneCamera;
93
100
  private dispatchCollisions;
94
101
  private resize;
package/dist/game.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import * as THREE from 'three';
2
2
  import { collisionOverlap } from './collision-shape.js';
3
3
  import { resolveSceneCamera, stepSceneCamera } from './camera.js';
4
+ import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
4
5
  import { Hitbox } from './components/hitbox.js';
5
6
  import { Entity } from './entity.js';
6
7
  import { Emitter } from './events.js';
7
8
  import { Input } from './input.js';
9
+ import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
10
+ import { RuntimeInspector } from './runtime-inspection.js';
8
11
  import { registryEntry, spawnFromJson } from './scene.js';
9
12
  import { Stats } from './stats.js';
10
13
  import { GameUi } from './ui.js';
@@ -33,10 +36,12 @@ export class Game {
33
36
  renderer;
34
37
  resizeObserver;
35
38
  updateFns = new Set();
39
+ invalidUpdateCompositions = new WeakMap();
36
40
  resolution;
37
41
  viewHeight;
38
42
  sceneCamera = null;
39
43
  lastTime = 0;
44
+ runtimeBridge = null;
40
45
  constructor(options) {
41
46
  const { canvas, background = 0x1a1a2e, viewHeight = 10 } = options;
42
47
  this.viewHeight = viewHeight;
@@ -124,6 +129,25 @@ export class Game {
124
129
  this.setViewHeight(this.sceneCamera.zoom);
125
130
  }
126
131
  start() {
132
+ const activation = activeRuntimeBridgeHook();
133
+ if (activation) {
134
+ if (!this.runtimeBridge) {
135
+ const inspector = new RuntimeInspector(this);
136
+ this.runtimeBridge = new EngineRuntimeBridge(this.renderer.domElement, activation, {
137
+ step: (dt) => this.runFrame(dt),
138
+ resume: (frame) => this.resumeRuntime(frame),
139
+ pause: () => this.stop(),
140
+ injectAction: (action, operation) => this.input.injectAction(action, operation),
141
+ availableActions: () => this.input.availableActions(),
142
+ heldActions: () => this.input.heldActions(),
143
+ inspect: (metadata, filters) => inspector.snapshot(metadata, filters),
144
+ });
145
+ activation.register(this.runtimeBridge);
146
+ window.addEventListener('pagehide', this.unregisterRuntimeBridge);
147
+ }
148
+ this.renderSurface();
149
+ return;
150
+ }
127
151
  this.renderer.setAnimationLoop((time) => this.tick(time));
128
152
  }
129
153
  stop() {
@@ -146,6 +170,7 @@ export class Game {
146
170
  /** Shuts the game down completely (loop, input, GPU). */
147
171
  dispose() {
148
172
  this.stop();
173
+ this.unregisterRuntimeBridge();
149
174
  this.input.dispose();
150
175
  this.resizeObserver.disconnect();
151
176
  this.ui.dispose();
@@ -153,13 +178,27 @@ export class Game {
153
178
  entity.destroy();
154
179
  this.renderer.dispose();
155
180
  }
181
+ resumeRuntime(frame) {
182
+ let previousTime = null;
183
+ this.renderer.setAnimationLoop((time) => {
184
+ const dt = previousTime === null ? 0 : Math.min((time - previousTime) / 1000, 0.1);
185
+ previousTime = time;
186
+ frame(dt);
187
+ });
188
+ }
156
189
  tick(time) {
157
190
  // Clamp dt: switching tabs or pausing doesn't fast-forward the simulation.
158
191
  const dt = Math.min((time - this.lastTime) / 1000, 0.1);
159
192
  this.lastTime = time;
193
+ this.runFrame(dt);
194
+ }
195
+ runFrame(dt) {
160
196
  if (this.simulate) {
161
197
  for (const entity of [...this.entities]) {
162
- for (const component of [...entity.components])
198
+ const schedule = this.componentUpdateSchedule(entity);
199
+ if (!schedule)
200
+ continue;
201
+ for (const component of schedule)
163
202
  component.onUpdate?.(dt);
164
203
  }
165
204
  this.dispatchCollisions();
@@ -170,6 +209,15 @@ export class Game {
170
209
  for (const fn of this.updateFns)
171
210
  fn(dt);
172
211
  this.input.endFrame();
212
+ this.renderSurface();
213
+ }
214
+ unregisterRuntimeBridge = () => {
215
+ window.removeEventListener('pagehide', this.unregisterRuntimeBridge);
216
+ this.runtimeBridge?.unregister();
217
+ this.runtimeBridge = null;
218
+ };
219
+ renderSurface() {
220
+ this.ui.setActive(this.simulate);
173
221
  if (this.resolution) {
174
222
  // Letterbox bars: clear the whole canvas, then render inside the scissor.
175
223
  this.renderer.setScissorTest(false);
@@ -179,6 +227,36 @@ export class Game {
179
227
  }
180
228
  this.renderer.render(this.scene, this.camera);
181
229
  }
230
+ componentUpdateSchedule(entity) {
231
+ const components = [...entity.components];
232
+ const registry = {
233
+ ...(this.registry?.components ?? {}),
234
+ };
235
+ const byName = new Map();
236
+ const names = [];
237
+ const signatureParts = [];
238
+ for (const component of components) {
239
+ const Class = component.constructor;
240
+ const name = Class.componentName;
241
+ registry[name] = Class;
242
+ names.push(name);
243
+ byName.set(name, component);
244
+ signatureParts.push(`${name}:${typeof Class.prototype.onUpdate === 'function' ? 'updates' : 'passive'}:` +
245
+ [...new Set(Class.updateAfter ?? [])].sort().join(','));
246
+ }
247
+ const result = resolveComponentUpdateSchedule(names, registry);
248
+ if (!result.ok) {
249
+ const signature = signatureParts.sort().join('|');
250
+ if (this.invalidUpdateCompositions.get(entity) !== signature) {
251
+ this.invalidUpdateCompositions.set(entity, signature);
252
+ console.error(`[waica] invalid component update schedule for "${entity.name}": ` +
253
+ result.issues.map((issue) => issue.cause).join(' '));
254
+ }
255
+ return null;
256
+ }
257
+ this.invalidUpdateCompositions.delete(entity);
258
+ return result.order.map((name) => byName.get(name));
259
+ }
182
260
  updateSceneCamera(dt) {
183
261
  const cam = this.sceneCamera;
184
262
  if (!cam)
package/dist/index.d.ts CHANGED
@@ -8,8 +8,14 @@ export { authoringDefaults } from './authoring-defaults.js';
8
8
  export type { ComponentClass, ContactNormal, ParamSpec, SolidContact, } from './component.js';
9
9
  export { collectModuleComponents, mergeRegistryComponents } from './component-registry.js';
10
10
  export type { ComponentModule } from './component-registry.js';
11
+ export { resolveComponentUpdateSchedule } from './component-update-schedule.js';
12
+ export type { ComponentUpdateCycleIssue, ComponentUpdateRegistry, ComponentUpdateScheduleIssue, ComponentUpdateScheduleResult, DuplicateComponentUpdateIssue, InvalidComponentUpdateConstraintIssue, InvalidComponentUpdateSchedule, InvalidUpdateConstraintReason, ValidComponentUpdateSchedule, } from './component-update-schedule.js';
11
13
  export { Input, DEFAULT_BINDINGS } from './input.js';
12
- export type { ActionName, InputBindings } from './input.js';
14
+ export type { ActionName, InjectedActionOperation, InputBindings } from './input.js';
15
+ export { RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
16
+ export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
17
+ export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
18
+ export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotFilters, RuntimeTransformSnapshot, } from './runtime-inspection.js';
13
19
  export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
14
20
  export { Stats } from './stats.js';
15
21
  export type { StatValue } from './stats.js';
package/dist/index.js CHANGED
@@ -4,7 +4,10 @@ export { Entity } from './entity.js';
4
4
  export { Component } from './component.js';
5
5
  export { authoringDefaults } from './authoring-defaults.js';
6
6
  export { collectModuleComponents, mergeRegistryComponents } from './component-registry.js';
7
+ export { resolveComponentUpdateSchedule } from './component-update-schedule.js';
7
8
  export { Input, DEFAULT_BINDINGS } from './input.js';
9
+ export { RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
10
+ export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
8
11
  export { Stats } from './stats.js';
9
12
  export { GameUi } from './ui.js';
10
13
  export { Sprite } from './components/sprite.js';
package/dist/input.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type ActionName = string;
2
+ export type InjectedActionOperation = 'press' | 'hold' | 'release';
2
3
  /** Action → KeyboardEvent.code list. */
3
4
  export type InputBindings = Record<string, string[]>;
4
5
  /** Neutral engine baseline; archetypes own their action vocabulary. */
@@ -11,6 +12,9 @@ export declare class Input {
11
12
  private readonly bindings;
12
13
  private readonly down;
13
14
  private readonly justDown;
15
+ private readonly injectedDown;
16
+ private readonly injectedJustDown;
17
+ private readonly injectedPresses;
14
18
  private readonly used;
15
19
  /** Installs exactly the action map supplied by the active archetype/project. */
16
20
  constructor(bindings?: Readonly<InputBindings>);
@@ -18,6 +22,12 @@ export declare class Input {
18
22
  held(action: ActionName): boolean;
19
23
  /** Was the action pressed exactly this frame? */
20
24
  justPressed(action: ActionName): boolean;
25
+ /** Installed semantic action names in deterministic order. */
26
+ availableActions(): ActionName[];
27
+ /** Currently held semantic action names in deterministic order. */
28
+ heldActions(): ActionName[];
29
+ /** Injects an action by semantic name; false means the action is not installed. */
30
+ injectAction(action: ActionName, operation: InjectedActionOperation): boolean;
21
31
  /** -1..1 axis from two actions (left/right by default). */
22
32
  axis(negative?: ActionName, positive?: ActionName): number;
23
33
  /**
package/dist/input.js CHANGED
@@ -8,6 +8,9 @@ export class Input {
8
8
  bindings = new Map();
9
9
  down = new Set();
10
10
  justDown = new Set();
11
+ injectedDown = new Set();
12
+ injectedJustDown = new Set();
13
+ injectedPresses = new Set();
11
14
  used = new Set();
12
15
  /** Installs exactly the action map supplied by the active archetype/project. */
13
16
  constructor(bindings = DEFAULT_BINDINGS) {
@@ -21,11 +24,39 @@ export class Input {
21
24
  }
22
25
  /** Is the action held this frame? */
23
26
  held(action) {
24
- return this.isActive(action, this.down);
27
+ return this.injectedDown.has(action) || this.isActive(action, this.down);
25
28
  }
26
29
  /** Was the action pressed exactly this frame? */
27
30
  justPressed(action) {
28
- return this.isActive(action, this.justDown);
31
+ return this.injectedJustDown.has(action) || this.isActive(action, this.justDown);
32
+ }
33
+ /** Installed semantic action names in deterministic order. */
34
+ availableActions() {
35
+ return [...this.bindings.keys()].sort();
36
+ }
37
+ /** Currently held semantic action names in deterministic order. */
38
+ heldActions() {
39
+ return this.availableActions().filter((action) => this.held(action));
40
+ }
41
+ /** Injects an action by semantic name; false means the action is not installed. */
42
+ injectAction(action, operation) {
43
+ if (!this.bindings.has(action))
44
+ return false;
45
+ if (operation === 'release') {
46
+ this.injectedDown.delete(action);
47
+ this.injectedPresses.delete(action);
48
+ return true;
49
+ }
50
+ if (this.held(action)) {
51
+ if (operation === 'hold')
52
+ this.injectedPresses.delete(action);
53
+ return true;
54
+ }
55
+ this.injectedDown.add(action);
56
+ this.injectedJustDown.add(action);
57
+ if (operation === 'press')
58
+ this.injectedPresses.add(action);
59
+ return true;
29
60
  }
30
61
  /** -1..1 axis from two actions (left/right by default). */
31
62
  axis(negative = 'left', positive = 'right') {
@@ -47,9 +78,16 @@ export class Input {
47
78
  /** Called by the Game at the end of each frame. */
48
79
  endFrame() {
49
80
  this.justDown.clear();
81
+ this.injectedJustDown.clear();
82
+ for (const action of this.injectedPresses)
83
+ this.injectedDown.delete(action);
84
+ this.injectedPresses.clear();
50
85
  this.used.clear();
51
86
  }
52
87
  dispose() {
88
+ this.injectedDown.clear();
89
+ this.injectedJustDown.clear();
90
+ this.injectedPresses.clear();
53
91
  window.removeEventListener('keydown', this.onKeyDown);
54
92
  window.removeEventListener('keyup', this.onKeyUp);
55
93
  window.removeEventListener('blur', this.releaseAll);
@@ -0,0 +1,69 @@
1
+ import type { RuntimeSnapshot, RuntimeSnapshotFilters } from './runtime-inspection.js';
2
+ export declare const RUNTIME_BRIDGE_PROTOCOL_VERSION: 1;
3
+ export declare const RUNTIME_BRIDGE_SYMBOL: unique symbol;
4
+ export type RuntimeMode = 'paused' | 'real-time';
5
+ export interface RuntimeMetadata {
6
+ bridgeVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
7
+ engineVersion: string;
8
+ mode: RuntimeMode;
9
+ frame: number;
10
+ simulationTime: number;
11
+ }
12
+ export type RuntimeControlRequest = {
13
+ operation: 'press' | 'hold' | 'release';
14
+ action: string;
15
+ } | {
16
+ operation: 'pause' | 'resume';
17
+ } | {
18
+ operation: 'step';
19
+ dt?: number;
20
+ frames?: number;
21
+ };
22
+ export interface RuntimeControlResult extends RuntimeMetadata {
23
+ heldActions: string[];
24
+ }
25
+ export declare class RuntimeBridgeOperationError extends Error {
26
+ readonly code: 'runtime-invalid-state' | 'runtime-operation-failed';
27
+ readonly availableActions?: string[] | undefined;
28
+ readonly stage: 'control';
29
+ constructor(code: 'runtime-invalid-state' | 'runtime-operation-failed', message: string, availableActions?: string[] | undefined);
30
+ }
31
+ /** Engine-owned capability registered only in an MCP-activated page. */
32
+ export interface RuntimeBridge {
33
+ readonly surface: HTMLCanvasElement;
34
+ metadata(): RuntimeMetadata;
35
+ inspect(filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
36
+ control(request: RuntimeControlRequest): RuntimeControlResult;
37
+ }
38
+ /** Ephemeral pre-page hook installed by the owner of a browser context. */
39
+ export interface RuntimeBridgeActivation {
40
+ readonly protocolVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
41
+ register(bridge: RuntimeBridge): void;
42
+ unregister(bridge: RuntimeBridge): void;
43
+ }
44
+ export declare function activeRuntimeBridgeHook(): RuntimeBridgeActivation | null;
45
+ export interface RuntimeBridgeHost {
46
+ step(dt: number): void;
47
+ resume(frame: (dt: number) => void): void;
48
+ pause(): void;
49
+ injectAction(action: string, operation: 'press' | 'hold' | 'release'): boolean;
50
+ availableActions(): string[];
51
+ heldActions(): string[];
52
+ inspect(metadata: RuntimeMetadata, filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
53
+ }
54
+ export declare class EngineRuntimeBridge implements RuntimeBridge {
55
+ readonly surface: HTMLCanvasElement;
56
+ private readonly activation;
57
+ private readonly host;
58
+ readonly engineVersion: string;
59
+ private registered;
60
+ private mode;
61
+ private frame;
62
+ private simulationTime;
63
+ constructor(surface: HTMLCanvasElement, activation: RuntimeBridgeActivation, host: RuntimeBridgeHost);
64
+ metadata(): RuntimeMetadata;
65
+ inspect(filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
66
+ control(request: RuntimeControlRequest): RuntimeControlResult;
67
+ private advance;
68
+ unregister(): void;
69
+ }
@@ -0,0 +1,105 @@
1
+ import enginePackage from '../package.json' with { type: 'json' };
2
+ export const RUNTIME_BRIDGE_PROTOCOL_VERSION = 1;
3
+ export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
4
+ export class RuntimeBridgeOperationError extends Error {
5
+ code;
6
+ availableActions;
7
+ stage = 'control';
8
+ constructor(code, message, availableActions) {
9
+ super(message);
10
+ this.code = code;
11
+ this.availableActions = availableActions;
12
+ this.name = 'RuntimeBridgeOperationError';
13
+ }
14
+ }
15
+ export function activeRuntimeBridgeHook() {
16
+ const candidate = globalThis[RUNTIME_BRIDGE_SYMBOL];
17
+ if (!candidate || typeof candidate !== 'object')
18
+ return null;
19
+ const hook = candidate;
20
+ if (hook.protocolVersion !== RUNTIME_BRIDGE_PROTOCOL_VERSION ||
21
+ typeof hook.register !== 'function' ||
22
+ typeof hook.unregister !== 'function') {
23
+ return null;
24
+ }
25
+ return hook;
26
+ }
27
+ export class EngineRuntimeBridge {
28
+ surface;
29
+ activation;
30
+ host;
31
+ engineVersion = enginePackage.version;
32
+ registered = true;
33
+ mode = 'paused';
34
+ frame = 0;
35
+ simulationTime = 0;
36
+ constructor(surface, activation, host) {
37
+ this.surface = surface;
38
+ this.activation = activation;
39
+ this.host = host;
40
+ }
41
+ metadata() {
42
+ return {
43
+ bridgeVersion: RUNTIME_BRIDGE_PROTOCOL_VERSION,
44
+ engineVersion: this.engineVersion,
45
+ mode: this.mode,
46
+ frame: this.frame,
47
+ simulationTime: this.simulationTime,
48
+ };
49
+ }
50
+ inspect(filters = {}) {
51
+ return this.host.inspect(this.metadata(), filters);
52
+ }
53
+ control(request) {
54
+ switch (request.operation) {
55
+ case 'pause':
56
+ if (this.mode === 'real-time') {
57
+ this.host.pause();
58
+ this.mode = 'paused';
59
+ }
60
+ break;
61
+ case 'resume':
62
+ if (this.mode === 'paused') {
63
+ this.mode = 'real-time';
64
+ this.host.resume((dt) => this.advance(dt));
65
+ }
66
+ break;
67
+ case 'press':
68
+ case 'hold':
69
+ case 'release':
70
+ if (!this.host.injectAction(request.action, request.operation)) {
71
+ const available = this.host.availableActions();
72
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unknown action "${request.action}". Available actions: ${available.join(', ') || '(none)'}.`, available);
73
+ }
74
+ break;
75
+ case 'step': {
76
+ if (this.mode !== 'paused') {
77
+ throw new RuntimeBridgeOperationError('runtime-invalid-state', 'step is only available while the Runtime Bridge is paused.');
78
+ }
79
+ const dt = request.dt ?? 1 / 60;
80
+ const frames = request.frames ?? 1;
81
+ if (!Number.isFinite(dt) || dt <= 0 || dt > 0.1) {
82
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', 'dt must be finite and greater than 0 and at most 0.1.');
83
+ }
84
+ if (!Number.isInteger(frames) || frames < 1 || frames > 600) {
85
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', 'frames must be an integer from 1 through 600.');
86
+ }
87
+ for (let index = 0; index < frames; index += 1)
88
+ this.advance(dt);
89
+ break;
90
+ }
91
+ }
92
+ return { ...this.metadata(), heldActions: this.host.heldActions() };
93
+ }
94
+ advance(dt) {
95
+ this.host.step(dt);
96
+ this.frame += 1;
97
+ this.simulationTime += dt;
98
+ }
99
+ unregister() {
100
+ if (!this.registered)
101
+ return;
102
+ this.registered = false;
103
+ this.activation.unregister(this);
104
+ }
105
+ }
@@ -0,0 +1,71 @@
1
+ import type { Game } from './game.js';
2
+ import type { RuntimeMetadata } from './runtime-bridge.js';
3
+ import type { StatValue } from './stats.js';
4
+ export type ProjectionMarkerKind = 'cycle' | 'unsupported' | 'error' | 'truncated';
5
+ export interface ProjectionMarker {
6
+ $waica: ProjectionMarkerKind | 'date' | 'bigint' | 'map' | 'set';
7
+ [key: string]: unknown;
8
+ }
9
+ export type ProjectedValue = null | boolean | number | string | ProjectionMarker | ProjectedValue[] | {
10
+ [key: string]: ProjectedValue;
11
+ };
12
+ export interface ProjectionIssue {
13
+ path: string;
14
+ marker: ProjectionMarkerKind;
15
+ omitted?: number;
16
+ }
17
+ export interface RuntimeSnapshotFilters {
18
+ entity_ids?: string[];
19
+ entity_names?: string[];
20
+ component_types?: string[];
21
+ }
22
+ export interface RuntimeTransformSnapshot {
23
+ position: {
24
+ x: number;
25
+ y: number;
26
+ z: number;
27
+ };
28
+ rotation: {
29
+ x: number;
30
+ y: number;
31
+ z: number;
32
+ order: string;
33
+ };
34
+ scale: {
35
+ x: number;
36
+ y: number;
37
+ z: number;
38
+ };
39
+ }
40
+ export interface RuntimeComponentSnapshot {
41
+ type: string;
42
+ index: number;
43
+ state: ProjectedValue;
44
+ }
45
+ export interface RuntimeEntitySnapshot {
46
+ id: string;
47
+ name: string;
48
+ transform: RuntimeTransformSnapshot;
49
+ components: RuntimeComponentSnapshot[];
50
+ }
51
+ export interface RuntimeSnapshot extends RuntimeMetadata {
52
+ stats: Record<string, StatValue>;
53
+ entities: RuntimeEntitySnapshot[];
54
+ projectionIssues: ProjectionIssue[];
55
+ }
56
+ export declare const RUNTIME_PROJECTION_LIMITS: {
57
+ readonly depth: 5;
58
+ readonly entries: 100;
59
+ readonly stringBytes: number;
60
+ readonly componentBytes: number;
61
+ readonly snapshotBytes: number;
62
+ };
63
+ export declare class RuntimeInspector {
64
+ private readonly game;
65
+ private readonly ids;
66
+ private nextId;
67
+ constructor(game: Game);
68
+ snapshot(metadata: RuntimeMetadata, filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
69
+ private capSnapshot;
70
+ private idFor;
71
+ }
@@ -0,0 +1,279 @@
1
+ export const RUNTIME_PROJECTION_LIMITS = {
2
+ depth: 5,
3
+ entries: 100,
4
+ stringBytes: 4 * 1024,
5
+ componentBytes: 64 * 1024,
6
+ snapshotBytes: 1024 * 1024,
7
+ };
8
+ function marker(context, path, kind, detail = {}) {
9
+ context.issues.push({ path, marker: kind });
10
+ return { $waica: kind, ...detail };
11
+ }
12
+ function isPlainRecord(value) {
13
+ const prototype = Object.getPrototypeOf(value);
14
+ return prototype === Object.prototype || prototype === null;
15
+ }
16
+ const textEncoder = new TextEncoder();
17
+ function utf8Bytes(value) {
18
+ return textEncoder.encode(value).byteLength;
19
+ }
20
+ function stringPreview(value, byteLimit) {
21
+ let low = 0;
22
+ let high = value.length;
23
+ while (low < high) {
24
+ const middle = Math.ceil((low + high) / 2);
25
+ if (utf8Bytes(value.slice(0, middle)) <= byteLimit)
26
+ low = middle;
27
+ else
28
+ high = middle - 1;
29
+ }
30
+ const preview = value.slice(0, low);
31
+ const last = preview.charCodeAt(preview.length - 1);
32
+ return last >= 0xd800 && last <= 0xdbff ? preview.slice(0, -1) : preview;
33
+ }
34
+ function projectValue(value, path, context, depth = 0) {
35
+ if (depth > RUNTIME_PROJECTION_LIMITS.depth) {
36
+ return marker(context, path, 'truncated', {
37
+ reason: 'depth',
38
+ maxDepth: RUNTIME_PROJECTION_LIMITS.depth,
39
+ });
40
+ }
41
+ if (value === null || typeof value === 'boolean')
42
+ return value;
43
+ if (typeof value === 'string') {
44
+ const bytes = utf8Bytes(value);
45
+ if (bytes <= RUNTIME_PROJECTION_LIMITS.stringBytes)
46
+ return value;
47
+ return marker(context, path, 'truncated', {
48
+ reason: 'string',
49
+ preview: stringPreview(value, RUNTIME_PROJECTION_LIMITS.stringBytes),
50
+ originalLength: value.length,
51
+ originalBytes: bytes,
52
+ });
53
+ }
54
+ if (typeof value === 'bigint')
55
+ return { $waica: 'bigint', value: value.toString() };
56
+ if (typeof value === 'number') {
57
+ return Number.isFinite(value)
58
+ ? value
59
+ : marker(context, path, 'unsupported', { type: 'non-finite-number' });
60
+ }
61
+ if (typeof value !== 'object') {
62
+ return marker(context, path, 'unsupported', { type: typeof value });
63
+ }
64
+ const previousPath = context.seen.get(value);
65
+ if (previousPath)
66
+ return marker(context, path, 'cycle', { path: previousPath });
67
+ context.seen.set(value, path);
68
+ if (value instanceof Date) {
69
+ return Number.isFinite(value.getTime())
70
+ ? { $waica: 'date', value: value.toISOString() }
71
+ : marker(context, path, 'error', { message: 'Invalid Date' });
72
+ }
73
+ if (Array.isArray(value)) {
74
+ const entries = value.slice(0, RUNTIME_PROJECTION_LIMITS.entries)
75
+ .map((entry, index) => projectValue(entry, `${path}[${index}]`, context, depth + 1));
76
+ const omitted = value.length - entries.length;
77
+ if (omitted > 0) {
78
+ entries.push(marker(context, path, 'truncated', { reason: 'entries', omitted }));
79
+ }
80
+ return entries;
81
+ }
82
+ if (value instanceof Map) {
83
+ const sourceEntries = [...value.entries()];
84
+ const entries = sourceEntries.slice(0, RUNTIME_PROJECTION_LIMITS.entries)
85
+ .map(([key, entry], index) => [
86
+ projectValue(key, `${path}.entries[${index}].key`, context, depth + 1),
87
+ projectValue(entry, `${path}.entries[${index}].value`, context, depth + 1),
88
+ ]);
89
+ const omitted = sourceEntries.length - entries.length;
90
+ return {
91
+ $waica: 'map',
92
+ entries,
93
+ ...(omitted > 0
94
+ ? { truncated: marker(context, path, 'truncated', { reason: 'entries', omitted }) }
95
+ : {}),
96
+ };
97
+ }
98
+ if (value instanceof Set) {
99
+ const sourceValues = [...value];
100
+ const values = sourceValues.slice(0, RUNTIME_PROJECTION_LIMITS.entries)
101
+ .map((entry, index) => projectValue(entry, `${path}.values[${index}]`, context, depth + 1));
102
+ const omitted = sourceValues.length - values.length;
103
+ return {
104
+ $waica: 'set',
105
+ values,
106
+ ...(omitted > 0
107
+ ? { truncated: marker(context, path, 'truncated', { reason: 'entries', omitted }) }
108
+ : {}),
109
+ };
110
+ }
111
+ if (isPlainRecord(value)) {
112
+ const keys = Object.keys(value).sort();
113
+ const projected = Object.fromEntries(keys.slice(0, RUNTIME_PROJECTION_LIMITS.entries)
114
+ .map((key) => [
115
+ key,
116
+ projectValue(value[key], `${path}.${key}`, context, depth + 1),
117
+ ]));
118
+ const omitted = keys.length - Object.keys(projected).length;
119
+ if (omitted === 0)
120
+ return projected;
121
+ return marker(context, path, 'truncated', {
122
+ reason: 'entries',
123
+ omitted,
124
+ value: projected,
125
+ });
126
+ }
127
+ return marker(context, path, 'unsupported', {
128
+ type: value.constructor?.name ?? 'object',
129
+ });
130
+ }
131
+ function errorMessage(error) {
132
+ return error instanceof Error ? error.message : String(error);
133
+ }
134
+ function componentState(component, path, context) {
135
+ if (typeof component.inspectState === 'function') {
136
+ try {
137
+ return projectValue(component.inspectState(), path, context);
138
+ }
139
+ catch (error) {
140
+ return marker(context, path, 'error', { message: errorMessage(error) });
141
+ }
142
+ }
143
+ const keys = new Set(Object.keys(component));
144
+ for (let prototype = Object.getPrototypeOf(component); prototype && prototype !== Object.prototype; prototype = Object.getPrototypeOf(prototype)) {
145
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(prototype))) {
146
+ if (typeof descriptor.set === 'function')
147
+ keys.add(key);
148
+ }
149
+ }
150
+ const state = {};
151
+ for (const key of [...keys].sort()) {
152
+ if (key === 'entity' || key === 'game' || key.startsWith('_'))
153
+ continue;
154
+ const valuePath = `${path}.${key}`;
155
+ try {
156
+ const value = component[key];
157
+ if (typeof value === 'function')
158
+ continue;
159
+ state[key] = projectValue(value, valuePath, context, 1);
160
+ }
161
+ catch (error) {
162
+ state[key] = marker(context, valuePath, 'error', { message: errorMessage(error) });
163
+ }
164
+ }
165
+ return state;
166
+ }
167
+ function boundedComponentState(component, path, context) {
168
+ const state = componentState(component, path, context);
169
+ const originalBytes = utf8Bytes(JSON.stringify(state));
170
+ if (originalBytes <= RUNTIME_PROJECTION_LIMITS.componentBytes)
171
+ return state;
172
+ return marker(context, path, 'truncated', {
173
+ reason: 'component-size',
174
+ limit: RUNTIME_PROJECTION_LIMITS.componentBytes,
175
+ originalBytes,
176
+ path,
177
+ });
178
+ }
179
+ export class RuntimeInspector {
180
+ game;
181
+ ids = new WeakMap();
182
+ nextId = 1;
183
+ constructor(game) {
184
+ this.game = game;
185
+ }
186
+ snapshot(metadata, filters = {}) {
187
+ const projectionIssues = [];
188
+ const idFilter = filters.entity_ids ? new Set(filters.entity_ids) : null;
189
+ const nameFilter = filters.entity_names ? new Set(filters.entity_names) : null;
190
+ const componentFilter = filters.component_types ? new Set(filters.component_types) : null;
191
+ const live = this.game.entities.map((entity) => ({ entity, id: this.idFor(entity) }));
192
+ const entities = live.flatMap(({ entity, id }) => {
193
+ if (idFilter && !idFilter.has(id))
194
+ return [];
195
+ if (nameFilter && !nameFilter.has(entity.name))
196
+ return [];
197
+ const components = entity.components.flatMap((component, index) => {
198
+ const Class = component.constructor;
199
+ if (componentFilter && !componentFilter.has(Class.componentName))
200
+ return [];
201
+ const context = { issues: projectionIssues, seen: new Map() };
202
+ return [{
203
+ type: Class.componentName,
204
+ index,
205
+ state: boundedComponentState(component, `entities[${id}].components[${index}].state`, context),
206
+ }];
207
+ });
208
+ if (componentFilter && components.length === 0)
209
+ return [];
210
+ return [{
211
+ id,
212
+ name: entity.name,
213
+ transform: {
214
+ position: {
215
+ x: entity.position.x,
216
+ y: entity.position.y,
217
+ z: entity.position.z,
218
+ },
219
+ rotation: {
220
+ x: entity.node.rotation.x,
221
+ y: entity.node.rotation.y,
222
+ z: entity.node.rotation.z,
223
+ order: entity.node.rotation.order,
224
+ },
225
+ scale: {
226
+ x: entity.scale.x,
227
+ y: entity.scale.y,
228
+ z: entity.scale.z,
229
+ },
230
+ },
231
+ components,
232
+ }];
233
+ });
234
+ return this.capSnapshot({
235
+ ...metadata,
236
+ stats: Object.fromEntries([...this.game.stats.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))),
237
+ entities,
238
+ projectionIssues,
239
+ });
240
+ }
241
+ capSnapshot(snapshot) {
242
+ if (utf8Bytes(JSON.stringify(snapshot)) <= RUNTIME_PROJECTION_LIMITS.snapshotBytes) {
243
+ return snapshot;
244
+ }
245
+ const retained = [...snapshot.entities];
246
+ const removedIds = new Set();
247
+ while (retained.length > 0) {
248
+ const removed = retained.pop();
249
+ if (removed)
250
+ removedIds.add(removed.id);
251
+ const omitted = snapshot.entities.length - retained.length;
252
+ const projectionIssues = snapshot.projectionIssues
253
+ .filter((issue) => [...removedIds].every((id) => !issue.path.startsWith(`entities[${id}]`)))
254
+ .concat({ path: `entities[${retained.length}]`, marker: 'truncated', omitted });
255
+ const candidate = { ...snapshot, entities: retained, projectionIssues };
256
+ if (utf8Bytes(JSON.stringify(candidate)) <= RUNTIME_PROJECTION_LIMITS.snapshotBytes) {
257
+ return candidate;
258
+ }
259
+ }
260
+ return {
261
+ ...snapshot,
262
+ entities: [],
263
+ projectionIssues: [{
264
+ path: 'entities[0]',
265
+ marker: 'truncated',
266
+ omitted: snapshot.entities.length,
267
+ }],
268
+ };
269
+ }
270
+ idFor(entity) {
271
+ const existing = this.ids.get(entity);
272
+ if (existing)
273
+ return existing;
274
+ const id = `entity-${this.nextId}`;
275
+ this.nextId += 1;
276
+ this.ids.set(entity, id);
277
+ return id;
278
+ }
279
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,7 +10,8 @@
10
10
  "directory": "packages/engine"
11
11
  },
12
12
  "files": [
13
- "dist"
13
+ "dist",
14
+ "README.md"
14
15
  ],
15
16
  "exports": {
16
17
  ".": {