@jts-studios/web-game-engine 0.1.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.
@@ -0,0 +1,119 @@
1
+ export class CameraController {
2
+ constructor(camera, input, { moveSpeed = 500, zoomSpeed = 0.0015, minZoom = 0.15, maxZoom = 4 } = {}) {
3
+ this.camera = camera
4
+ this.input = input
5
+ this.moveSpeed = moveSpeed
6
+ this.zoomSpeed = zoomSpeed
7
+ this.minZoom = minZoom
8
+ this.maxZoom = maxZoom
9
+ this.followTarget = null
10
+ this.followSmoothing = 0.16
11
+ this.deadZone = { x: 36, y: 24 }
12
+ this.bounds = null
13
+ }
14
+
15
+ setFollowTarget(target) {
16
+ this.followTarget = target ?? null
17
+ return this
18
+ }
19
+
20
+ setFollowSmoothing(value) {
21
+ if (!Number.isFinite(value) || value <= 0) {
22
+ throw new TypeError("CameraController follow smoothing must be a positive number")
23
+ }
24
+
25
+ this.followSmoothing = value
26
+ return this
27
+ }
28
+
29
+ setDeadZone(width, height) {
30
+ if (!Number.isFinite(width) || width < 0 || !Number.isFinite(height) || height < 0) {
31
+ throw new TypeError("CameraController dead-zone dimensions must be non-negative numbers")
32
+ }
33
+
34
+ this.deadZone.x = width
35
+ this.deadZone.y = height
36
+ return this
37
+ }
38
+
39
+ setBounds(bounds = null) {
40
+ if (!bounds) {
41
+ this.bounds = null
42
+ return this
43
+ }
44
+
45
+ this.bounds = {
46
+ minX: Number.isFinite(bounds.minX) ? bounds.minX : -Infinity,
47
+ minY: Number.isFinite(bounds.minY) ? bounds.minY : -Infinity,
48
+ maxX: Number.isFinite(bounds.maxX) ? bounds.maxX : Infinity,
49
+ maxY: Number.isFinite(bounds.maxY) ? bounds.maxY : Infinity,
50
+ }
51
+ return this
52
+ }
53
+
54
+ update(deltaTime) {
55
+ const moveX = this.input.getAxis("KeyA", "KeyD") + this.input.getAxis("ArrowLeft", "ArrowRight")
56
+ const moveY = this.input.getAxis("KeyW", "KeyS") + this.input.getAxis("ArrowUp", "ArrowDown")
57
+
58
+ if (moveX !== 0 || moveY !== 0) {
59
+ const distance = (this.moveSpeed * deltaTime) / this.camera.zoom
60
+ this.camera.setPosition(this.camera.position.x + moveX * distance, this.camera.position.y + moveY * distance)
61
+ }
62
+
63
+ if (this.input.isPointerDragging()) {
64
+ const pointerDelta = this.input.consumePointerDelta()
65
+ this.camera.setPosition(
66
+ this.camera.position.x - pointerDelta.x / this.camera.zoom,
67
+ this.camera.position.y - pointerDelta.y / this.camera.zoom,
68
+ )
69
+ } else {
70
+ this.input.consumePointerDelta()
71
+ }
72
+
73
+ const wheelDelta = this.input.consumeWheelDelta()
74
+ if (wheelDelta !== 0) {
75
+ const targetZoom = this.camera.zoom * Math.exp(-wheelDelta * this.zoomSpeed)
76
+ const clampedZoom = Math.min(this.maxZoom, Math.max(this.minZoom, targetZoom))
77
+ this.camera.setZoom(clampedZoom)
78
+ }
79
+
80
+ this.#updateFollow(deltaTime)
81
+ this.#clampToBounds()
82
+ }
83
+
84
+ #updateFollow(deltaTime) {
85
+ const targetPosition = this.followTarget?.position
86
+ if (!targetPosition) {
87
+ return
88
+ }
89
+
90
+ const dx = targetPosition.x - this.camera.position.x
91
+ const dy = targetPosition.y - this.camera.position.y
92
+
93
+ const thresholdX = this.deadZone.x / this.camera.zoom
94
+ const thresholdY = this.deadZone.y / this.camera.zoom
95
+
96
+ const shouldMoveX = Math.abs(dx) > thresholdX
97
+ const shouldMoveY = Math.abs(dy) > thresholdY
98
+ if (!shouldMoveX && !shouldMoveY) {
99
+ return
100
+ }
101
+
102
+ const smoothing = 1 - Math.exp((-deltaTime * 60) / this.followSmoothing)
103
+ const nextX = shouldMoveX ? this.camera.position.x + dx * smoothing : this.camera.position.x
104
+ const nextY = shouldMoveY ? this.camera.position.y + dy * smoothing : this.camera.position.y
105
+ this.camera.setPosition(nextX, nextY)
106
+ }
107
+
108
+ #clampToBounds() {
109
+ if (!this.bounds) {
110
+ return
111
+ }
112
+
113
+ const clampedX = Math.min(this.bounds.maxX, Math.max(this.bounds.minX, this.camera.position.x))
114
+ const clampedY = Math.min(this.bounds.maxY, Math.max(this.bounds.minY, this.camera.position.y))
115
+ if (clampedX !== this.camera.position.x || clampedY !== this.camera.position.y) {
116
+ this.camera.setPosition(clampedX, clampedY)
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,207 @@
1
+ export class InputController {
2
+ constructor({ element = null } = {}) {
3
+ this.element = element
4
+ this._keys = new Set()
5
+ this._wheelDelta = 0
6
+ this._dragging = false
7
+ this._pointerDeltaX = 0
8
+ this._pointerDeltaY = 0
9
+ this._lastPointerX = 0
10
+ this._lastPointerY = 0
11
+ this._actionProfile = null
12
+
13
+ this._onKeyDown = this.#onKeyDown.bind(this)
14
+ this._onKeyUp = this.#onKeyUp.bind(this)
15
+ this._onBlur = this.#onBlur.bind(this)
16
+ this._onWheel = this.#onWheel.bind(this)
17
+ this._onPointerDown = this.#onPointerDown.bind(this)
18
+ this._onPointerMove = this.#onPointerMove.bind(this)
19
+ this._onPointerUp = this.#onPointerUp.bind(this)
20
+ }
21
+
22
+ attach() {
23
+ window.addEventListener("keydown", this._onKeyDown)
24
+ window.addEventListener("keyup", this._onKeyUp)
25
+ window.addEventListener("blur", this._onBlur)
26
+ window.addEventListener("wheel", this._onWheel, { passive: false })
27
+
28
+ const target = this.element ?? window
29
+ target.addEventListener("pointerdown", this._onPointerDown)
30
+ window.addEventListener("pointermove", this._onPointerMove)
31
+ window.addEventListener("pointerup", this._onPointerUp)
32
+ }
33
+
34
+ detach() {
35
+ window.removeEventListener("keydown", this._onKeyDown)
36
+ window.removeEventListener("keyup", this._onKeyUp)
37
+ window.removeEventListener("blur", this._onBlur)
38
+ window.removeEventListener("wheel", this._onWheel)
39
+
40
+ const target = this.element ?? window
41
+ target.removeEventListener("pointerdown", this._onPointerDown)
42
+ window.removeEventListener("pointermove", this._onPointerMove)
43
+ window.removeEventListener("pointerup", this._onPointerUp)
44
+
45
+ this._keys.clear()
46
+ this._wheelDelta = 0
47
+ this._dragging = false
48
+ this._pointerDeltaX = 0
49
+ this._pointerDeltaY = 0
50
+ }
51
+
52
+ isKeyDown(code) {
53
+ return this._keys.has(code)
54
+ }
55
+
56
+ getAxis(negativeCode, positiveCode) {
57
+ const negative = this.isKeyDown(negativeCode) ? -1 : 0
58
+ const positive = this.isKeyDown(positiveCode) ? 1 : 0
59
+ return negative + positive
60
+ }
61
+
62
+ consumeWheelDelta() {
63
+ const delta = this._wheelDelta
64
+ this._wheelDelta = 0
65
+ return delta
66
+ }
67
+
68
+ consumePointerDelta() {
69
+ const deltaX = this._pointerDeltaX
70
+ const deltaY = this._pointerDeltaY
71
+ this._pointerDeltaX = 0
72
+ this._pointerDeltaY = 0
73
+ return { x: deltaX, y: deltaY }
74
+ }
75
+
76
+ isPointerDragging() {
77
+ return this._dragging
78
+ }
79
+
80
+ captureSnapshot() {
81
+ return {
82
+ keys: Array.from(this._keys),
83
+ pointerX: this._pointerX,
84
+ pointerY: this._pointerY,
85
+ pointerDeltaX: this._pointerDeltaX,
86
+ pointerDeltaY: this._pointerDeltaY,
87
+ dragging: this._dragging,
88
+ }
89
+ }
90
+
91
+ applySnapshot(snapshot) {
92
+ if (!snapshot || typeof snapshot !== "object") {
93
+ return this
94
+ }
95
+
96
+ this._keys.clear()
97
+ const keys = Array.isArray(snapshot.keys) ? snapshot.keys : []
98
+ for (let i = 0; i < keys.length; i++) {
99
+ if (typeof keys[i] === "string") {
100
+ this._keys.add(keys[i])
101
+ }
102
+ }
103
+
104
+ this._pointerX = Number.isFinite(snapshot.pointerX) ? snapshot.pointerX : this._pointerX
105
+ this._pointerY = Number.isFinite(snapshot.pointerY) ? snapshot.pointerY : this._pointerY
106
+ this._pointerDeltaX = Number.isFinite(snapshot.pointerDeltaX) ? snapshot.pointerDeltaX : this._pointerDeltaX
107
+ this._pointerDeltaY = Number.isFinite(snapshot.pointerDeltaY) ? snapshot.pointerDeltaY : this._pointerDeltaY
108
+ this._dragging = !!snapshot.dragging
109
+ return this
110
+ }
111
+
112
+ applyProfile(profile) {
113
+ this._actionProfile = profile && typeof profile === "object" ? JSON.parse(JSON.stringify(profile)) : null
114
+ return this
115
+ }
116
+
117
+ clearProfile() {
118
+ this._actionProfile = null
119
+ return this
120
+ }
121
+
122
+ isActionDown(actionName) {
123
+ if (!this._actionProfile || typeof actionName !== "string") {
124
+ return false
125
+ }
126
+
127
+ const bindings = this._actionProfile[actionName]
128
+ if (!Array.isArray(bindings)) {
129
+ return false
130
+ }
131
+
132
+ for (let i = 0; i < bindings.length; i++) {
133
+ const binding = bindings[i]
134
+ if (!binding || typeof binding !== "object") {
135
+ continue
136
+ }
137
+
138
+ if (binding.type === "keyboard") {
139
+ if (Array.isArray(binding.chord) && binding.chord.length > 0) {
140
+ let chordPressed = true
141
+ for (let c = 0; c < binding.chord.length; c++) {
142
+ if (!this.isKeyDown(binding.chord[c])) {
143
+ chordPressed = false
144
+ break
145
+ }
146
+ }
147
+ if (chordPressed) {
148
+ return true
149
+ }
150
+ continue
151
+ }
152
+
153
+ if (typeof binding.code === "string" && this.isKeyDown(binding.code)) {
154
+ return true
155
+ }
156
+ }
157
+ }
158
+
159
+ return false
160
+ }
161
+
162
+ #onKeyDown(event) {
163
+ this._keys.add(event.code)
164
+ }
165
+
166
+ #onKeyUp(event) {
167
+ this._keys.delete(event.code)
168
+ }
169
+
170
+ #onBlur() {
171
+ this._keys.clear()
172
+ this._dragging = false
173
+ this._wheelDelta = 0
174
+ this._pointerDeltaX = 0
175
+ this._pointerDeltaY = 0
176
+ }
177
+
178
+ #onWheel(event) {
179
+ this._wheelDelta += event.deltaY
180
+ event.preventDefault()
181
+ }
182
+
183
+ #onPointerDown(event) {
184
+ if (event.button !== 1 && event.button !== 0) {
185
+ return
186
+ }
187
+
188
+ this._dragging = true
189
+ this._lastPointerX = event.clientX
190
+ this._lastPointerY = event.clientY
191
+ }
192
+
193
+ #onPointerMove(event) {
194
+ if (!this._dragging) {
195
+ return
196
+ }
197
+
198
+ this._pointerDeltaX += event.clientX - this._lastPointerX
199
+ this._pointerDeltaY += event.clientY - this._lastPointerY
200
+ this._lastPointerX = event.clientX
201
+ this._lastPointerY = event.clientY
202
+ }
203
+
204
+ #onPointerUp() {
205
+ this._dragging = false
206
+ }
207
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Stores named input action profiles for gameplay integration.
3
+ */
4
+ export class InputProfileCatalog {
5
+ constructor() {
6
+ this._profiles = new Map()
7
+ }
8
+
9
+ define(name, profile) {
10
+ if (typeof name !== "string" || name.length === 0) {
11
+ throw new TypeError("InputProfileCatalog.define requires a non-empty profile name")
12
+ }
13
+ if (!profile || typeof profile !== "object") {
14
+ throw new TypeError("InputProfileCatalog.define requires a profile object")
15
+ }
16
+
17
+ this._profiles.set(name, JSON.parse(JSON.stringify(profile)))
18
+ return this
19
+ }
20
+
21
+ get(name) {
22
+ const profile = this._profiles.get(name)
23
+ return profile ? JSON.parse(JSON.stringify(profile)) : null
24
+ }
25
+
26
+ list() {
27
+ return Array.from(this._profiles.keys())
28
+ }
29
+ }
@@ -0,0 +1,30 @@
1
+ export class FixedStepClock {
2
+ constructor({ fixedDelta = 1 / 60, maxSubSteps = 8 } = {}) {
3
+ this.fixedDelta = Number.isFinite(fixedDelta) && fixedDelta > 0 ? fixedDelta : 1 / 60
4
+ this.maxSubSteps = Number.isFinite(maxSubSteps) ? Math.max(1, Math.floor(maxSubSteps)) : 8
5
+ this.accumulator = 0
6
+ }
7
+
8
+ reset() {
9
+ this.accumulator = 0
10
+ }
11
+
12
+ consume(deltaSeconds) {
13
+ const safeDelta = Number.isFinite(deltaSeconds) ? Math.max(0, deltaSeconds) : 0
14
+ this.accumulator += safeDelta
15
+
16
+ const steps = []
17
+ let subSteps = 0
18
+ while (this.accumulator >= this.fixedDelta && subSteps < this.maxSubSteps) {
19
+ steps.push(this.fixedDelta)
20
+ this.accumulator -= this.fixedDelta
21
+ subSteps += 1
22
+ }
23
+
24
+ const alpha = this.fixedDelta > 0 ? Math.min(1, this.accumulator / this.fixedDelta) : 0
25
+ return {
26
+ steps,
27
+ alpha,
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,155 @@
1
+ export class InputReplayTimeline {
2
+ constructor() {
3
+ this._events = []
4
+ this._recording = false
5
+ this._playing = false
6
+ this._cursor = 0
7
+ this._elapsed = 0
8
+ }
9
+
10
+ clear() {
11
+ this._events.length = 0
12
+ this._cursor = 0
13
+ this._elapsed = 0
14
+ return this
15
+ }
16
+
17
+ startRecording() {
18
+ this.clear()
19
+ this._recording = true
20
+ this._playing = false
21
+ return this
22
+ }
23
+
24
+ stopRecording() {
25
+ this._recording = false
26
+ return this.toJSON()
27
+ }
28
+
29
+ startPlayback(events = this._events) {
30
+ this._events = Array.isArray(events) ? events.map((entry) => ({ ...entry })) : []
31
+ this._events.sort((a, b) => (a.time ?? 0) - (b.time ?? 0))
32
+ this._recording = false
33
+ this._playing = true
34
+ this._cursor = 0
35
+ this._elapsed = 0
36
+ return this
37
+ }
38
+
39
+ stopPlayback() {
40
+ this._playing = false
41
+ this._cursor = 0
42
+ this._elapsed = 0
43
+ return this
44
+ }
45
+
46
+ record(deltaTime, snapshot, { checkpoint = false } = {}) {
47
+ if (!this._recording) {
48
+ return
49
+ }
50
+
51
+ this._elapsed += Number.isFinite(deltaTime) ? Math.max(0, deltaTime) : 0
52
+ const clonedSnapshot = snapshot && typeof snapshot === "object" ? JSON.parse(JSON.stringify(snapshot)) : null
53
+ this._events.push({
54
+ time: this._elapsed,
55
+ snapshot: clonedSnapshot,
56
+ hash: this.#hashSnapshot(clonedSnapshot),
57
+ checkpoint: !!checkpoint,
58
+ })
59
+ }
60
+
61
+ consume(deltaTime) {
62
+ if (!this._playing) {
63
+ return []
64
+ }
65
+
66
+ this._elapsed += Number.isFinite(deltaTime) ? Math.max(0, deltaTime) : 0
67
+ const emitted = []
68
+ while (this._cursor < this._events.length) {
69
+ const event = this._events[this._cursor]
70
+ if ((event?.time ?? 0) > this._elapsed) {
71
+ break
72
+ }
73
+ emitted.push(event)
74
+ this._cursor += 1
75
+ }
76
+
77
+ if (this._cursor >= this._events.length) {
78
+ this._playing = false
79
+ }
80
+
81
+ return emitted
82
+ }
83
+
84
+ toJSON() {
85
+ return this._events.map((entry) => ({
86
+ time: entry.time,
87
+ snapshot: entry.snapshot,
88
+ hash: entry.hash,
89
+ checkpoint: !!entry.checkpoint,
90
+ }))
91
+ }
92
+
93
+ validate(events = this._events) {
94
+ const list = Array.isArray(events) ? events : []
95
+ for (let i = 0; i < list.length; i++) {
96
+ const entry = list[i]
97
+ const expected = this.#hashSnapshot(entry?.snapshot ?? null)
98
+ if (typeof entry?.hash === "string" && entry.hash !== expected) {
99
+ return {
100
+ valid: false,
101
+ index: i,
102
+ expected,
103
+ actual: entry.hash,
104
+ }
105
+ }
106
+ }
107
+
108
+ return {
109
+ valid: true,
110
+ count: list.length,
111
+ }
112
+ }
113
+
114
+ compareWith(otherEvents) {
115
+ const a = this.toJSON()
116
+ const b = Array.isArray(otherEvents) ? otherEvents : []
117
+ const max = Math.max(a.length, b.length)
118
+ for (let i = 0; i < max; i++) {
119
+ const left = a[i]
120
+ const right = b[i]
121
+ if (!left || !right) {
122
+ return {
123
+ equal: false,
124
+ index: i,
125
+ reason: "length-mismatch",
126
+ }
127
+ }
128
+
129
+ if (left.hash !== right.hash) {
130
+ return {
131
+ equal: false,
132
+ index: i,
133
+ reason: "hash-mismatch",
134
+ left: left.hash,
135
+ right: right.hash,
136
+ }
137
+ }
138
+ }
139
+
140
+ return {
141
+ equal: true,
142
+ count: a.length,
143
+ }
144
+ }
145
+
146
+ #hashSnapshot(snapshot) {
147
+ const input = JSON.stringify(snapshot ?? null)
148
+ let hash = 2166136261
149
+ for (let i = 0; i < input.length; i++) {
150
+ hash ^= input.charCodeAt(i)
151
+ hash = Math.imul(hash, 16777619)
152
+ }
153
+ return `fnv1a-${(hash >>> 0).toString(16)}`
154
+ }
155
+ }
@@ -0,0 +1,43 @@
1
+ import test from "node:test"
2
+ import assert from "node:assert/strict"
3
+
4
+ import { InputReplayTimeline } from "./InputReplayTimeline.js"
5
+
6
+ test("InputReplayTimeline records hashes and validates deterministic payloads", () => {
7
+ const replay = new InputReplayTimeline()
8
+ replay.startRecording()
9
+ replay.record(1 / 60, { actions: { jump: true } }, { checkpoint: true })
10
+ const events = replay.stopRecording()
11
+
12
+ assert.equal(events.length, 1)
13
+ assert.equal(typeof events[0].hash, "string")
14
+ assert.equal(events[0].hash.startsWith("fnv1a-"), true)
15
+ assert.equal(events[0].checkpoint, true)
16
+
17
+ const diagnostics = replay.validate(events)
18
+ assert.equal(diagnostics.valid, true)
19
+ })
20
+
21
+ test("InputReplayTimeline validate detects tampered snapshots", () => {
22
+ const replay = new InputReplayTimeline()
23
+ replay.startRecording()
24
+ replay.record(1 / 60, { actions: { fire: false } })
25
+ const events = replay.stopRecording()
26
+ events[0].snapshot.actions.fire = true
27
+
28
+ const diagnostics = replay.validate(events)
29
+ assert.equal(diagnostics.valid, false)
30
+ assert.equal(diagnostics.index, 0)
31
+ })
32
+
33
+ test("InputReplayTimeline compareWith detects divergence", () => {
34
+ const replay = new InputReplayTimeline()
35
+ replay.startRecording()
36
+ replay.record(1 / 60, { actions: { left: true } })
37
+ const baseline = replay.stopRecording()
38
+
39
+ const modified = baseline.map((entry) => ({ ...entry, hash: `${entry.hash}-changed` }))
40
+ const comparison = replay.compareWith(modified)
41
+ assert.equal(comparison.equal, false)
42
+ assert.equal(comparison.reason, "hash-mismatch")
43
+ })
@@ -0,0 +1,28 @@
1
+ export class UpdateLoop {
2
+ constructor() {
3
+ this._updatables = new Set()
4
+ }
5
+
6
+ add(updatable) {
7
+ if (!updatable || typeof updatable.update !== "function") {
8
+ throw new TypeError("UpdateLoop.add requires an object with update(deltaTime)")
9
+ }
10
+
11
+ this._updatables.add(updatable)
12
+ return updatable
13
+ }
14
+
15
+ remove(updatable) {
16
+ this._updatables.delete(updatable)
17
+ }
18
+
19
+ clear() {
20
+ this._updatables.clear()
21
+ }
22
+
23
+ update(deltaTime) {
24
+ for (const updatable of this._updatables) {
25
+ updatable.update(deltaTime)
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,54 @@
1
+ export class RuntimeBridge {
2
+ constructor() {
3
+ this._eventListeners = new Set()
4
+ this._commandHandlers = new Map()
5
+ }
6
+
7
+ onEvent(listener) {
8
+ if (typeof listener !== "function") {
9
+ return () => {}
10
+ }
11
+ this._eventListeners.add(listener)
12
+ return () => this._eventListeners.delete(listener)
13
+ }
14
+
15
+ emit(type, payload = null) {
16
+ if (typeof type !== "string" || type.length === 0) {
17
+ return
18
+ }
19
+
20
+ const event = {
21
+ type,
22
+ payload,
23
+ time: Date.now(),
24
+ }
25
+
26
+ for (const listener of this._eventListeners) {
27
+ try {
28
+ listener(event)
29
+ } catch {
30
+ // Bridge listeners are isolated from runtime execution.
31
+ }
32
+ }
33
+ }
34
+
35
+ registerCommand(commandName, handler) {
36
+ if (typeof commandName !== "string" || commandName.length === 0) {
37
+ throw new TypeError("RuntimeBridge.registerCommand requires a non-empty commandName")
38
+ }
39
+ if (typeof handler !== "function") {
40
+ throw new TypeError("RuntimeBridge.registerCommand requires a handler function")
41
+ }
42
+
43
+ this._commandHandlers.set(commandName, handler)
44
+ return this
45
+ }
46
+
47
+ async execute(commandName, payload = null, context = null) {
48
+ const handler = this._commandHandlers.get(commandName)
49
+ if (!handler) {
50
+ throw new Error(`RuntimeBridge.execute unknown command '${commandName}'`)
51
+ }
52
+ return await handler(payload, context)
53
+ }
54
+ }
@@ -0,0 +1,25 @@
1
+ import test from "node:test"
2
+ import assert from "node:assert/strict"
3
+
4
+ import { RuntimeBridge } from "./RuntimeBridge.js"
5
+
6
+ test("RuntimeBridge emits events to listeners", () => {
7
+ const bridge = new RuntimeBridge()
8
+ const events = []
9
+ const off = bridge.onEvent((event) => events.push(event))
10
+
11
+ bridge.emit("runtime:init", { ok: true })
12
+ off()
13
+ bridge.emit("runtime:update", { dt: 0.016 })
14
+
15
+ assert.equal(events.length, 1)
16
+ assert.equal(events[0].type, "runtime:init")
17
+ })
18
+
19
+ test("RuntimeBridge executes registered commands", async () => {
20
+ const bridge = new RuntimeBridge()
21
+ bridge.registerCommand("echo", async (payload) => payload)
22
+
23
+ const response = await bridge.execute("echo", { value: 42 })
24
+ assert.deepEqual(response, { value: 42 })
25
+ })