@volter/editor-game 0.5.65
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/LICENSE +661 -0
- package/NOTICE +23 -0
- package/contributions/asset-budget-asset.menu.ts +26 -0
- package/contributions/asset-budget.action.ts +18 -0
- package/contributions/asset-budget.document.tsx +21 -0
- package/contributions/asset-budget.menu.ts +22 -0
- package/contributions/audio-unlock.service.ts +17 -0
- package/contributions/audio.utility.tsx +15 -0
- package/contributions/autoplay.service.ts +48 -0
- package/contributions/bridge.command.ts +172 -0
- package/contributions/build-profiles.document.tsx +21 -0
- package/contributions/build-progress.status.tsx +45 -0
- package/contributions/build.action.ts +26 -0
- package/contributions/build.command.ts +27 -0
- package/contributions/build.header.tsx +37 -0
- package/contributions/build.menu.ts +31 -0
- package/contributions/build.service.ts +31 -0
- package/contributions/connection.status.tsx +43 -0
- package/contributions/coverage.service.ts +118 -0
- package/contributions/edit-mode-audio.service.ts +23 -0
- package/contributions/edit-mode-networking.service.ts +30 -0
- package/contributions/game-document.service.ts +25 -0
- package/contributions/game-eval.command.ts +210 -0
- package/contributions/game.layout.ts +19 -0
- package/contributions/gameplay.command.ts +255 -0
- package/contributions/generation.service.ts +96 -0
- package/contributions/generations.status.tsx +53 -0
- package/contributions/ingest.service.ts +80 -0
- package/contributions/instances.command.ts +75 -0
- package/contributions/navmesh.menu.ts +39 -0
- package/contributions/navmesh.service.ts +19 -0
- package/contributions/network.utility.tsx +17 -0
- package/contributions/play.command.ts +355 -0
- package/contributions/profiler.action.ts +16 -0
- package/contributions/profiler.menu.ts +22 -0
- package/contributions/profiler.utility.tsx +17 -0
- package/contributions/react/component-board.service.ts +21 -0
- package/contributions/react/design-time-mount.service.ts +60 -0
- package/contributions/react/pasteboard.action.ts +41 -0
- package/contributions/react/react-inspector.service.ts +85 -0
- package/contributions/react/story-documents.service.ts +44 -0
- package/contributions/scene-document.service.ts +32 -0
- package/contributions/state-watch.action.ts +17 -0
- package/contributions/state-watch.menu.ts +23 -0
- package/contributions/state-watch.utility.tsx +20 -0
- package/contributions/team-playtest.service.ts +124 -0
- package/contributions/three/camera-runtime.inspector.tsx +34 -0
- package/contributions/three/component-board.service.ts +24 -0
- package/contributions/three/component-verbs.command.ts +110 -0
- package/contributions/three/component-verbs.service.ts +92 -0
- package/contributions/three/constraints.inspector.tsx +34 -0
- package/contributions/three/model-asset-sections.service.ts +26 -0
- package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
- package/contributions/three/story-documents.service.ts +31 -0
- package/contributions/three/three-authoring.service.ts +66 -0
- package/contributions/transport.header.tsx +19 -0
- package/contributions/xstate-behavior.action.ts +42 -0
- package/contributions/xstate-behavior.document.tsx +73 -0
- package/contributions/xstate-behavior.inspector.tsx +28 -0
- package/contributions/xstate-behavior.menu.ts +23 -0
- package/package.json +144 -0
- package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
- package/src/asset-budget/asset-budget-model.ts +799 -0
- package/src/asset-budget/basis-encoder.ts +165 -0
- package/src/asset-budget/gltf-io.ts +154 -0
- package/src/asset-budget/gltf-optimize.ts +384 -0
- package/src/asset-budget/image-dims.ts +78 -0
- package/src/asset-budget/optimize-apply.ts +221 -0
- package/src/audio/AudioDebuggerPanel.tsx +457 -0
- package/src/audio/audio-debugger-model.ts +87 -0
- package/src/bridge/call.ts +60 -0
- package/src/bridge/dispatch.ts +459 -0
- package/src/bridge/live-frames.ts +25 -0
- package/src/bridge/screenshot.ts +316 -0
- package/src/build/BuildProfilesPanel.tsx +476 -0
- package/src/build/build-session.ts +247 -0
- package/src/build/format-bytes.ts +14 -0
- package/src/command-results.ts +36 -0
- package/src/coverage/live-authoring-surface.ts +30 -0
- package/src/coverage/live-project-verbs.ts +162 -0
- package/src/coverage/native-system-coverage.ts +143 -0
- package/src/coverage/root-coverage.ts +79 -0
- package/src/coverage/session-coverage.ts +193 -0
- package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
- package/src/edit-mode/edit-mode-audio.ts +56 -0
- package/src/edit-mode/edit-mode-networking.ts +112 -0
- package/src/game-document/DevicePresetPicker.tsx +84 -0
- package/src/game-document/GameCaptureFrameButton.tsx +45 -0
- package/src/game-document/GameDocument.tsx +215 -0
- package/src/game-document/GamePanel.tsx +662 -0
- package/src/game-document/InstanceInspectorPicker.tsx +183 -0
- package/src/game-document/crowd-debug.ts +183 -0
- package/src/game-document/device-preview.ts +336 -0
- package/src/game-document/game-view-store.ts +107 -0
- package/src/game-document/physics-debug.ts +187 -0
- package/src/generation/GenerationActivity.tsx +421 -0
- package/src/generation/GenerationGallery.css +231 -0
- package/src/generation/generation-documents.tsx +338 -0
- package/src/generation/generation-jobs.ts +128 -0
- package/src/generation/generation-presentation.ts +257 -0
- package/src/host/adapter-reach.ts +445 -0
- package/src/host/adapter-runtime-bindings.ts +303 -0
- package/src/host/after-paint.ts +112 -0
- package/src/host/api/configurations.ts +66 -0
- package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
- package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
- package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
- package/src/host/authoring/contract-scenes-stories.ts +204 -0
- package/src/host/authoring/creation-site-related.ts +55 -0
- package/src/host/authoring/ephemeral-persistence.ts +29 -0
- package/src/host/authoring/gesture-persist.ts +84 -0
- package/src/host/authoring/ingest-data-writer.ts +232 -0
- package/src/host/authoring/ingest-source-persistence.ts +826 -0
- package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
- package/src/host/authoring/mounted-authoring.ts +41 -0
- package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
- package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
- package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
- package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
- package/src/host/authoring/pixi-isolation-assets.ts +25 -0
- package/src/host/authoring/pixi-live-write-target.ts +979 -0
- package/src/host/authoring/pixi-source-identity.ts +141 -0
- package/src/host/authoring/pixi-still-presentation.ts +71 -0
- package/src/host/authoring/pixi-structure-history.ts +237 -0
- package/src/host/authoring/pixi-transform-channels.ts +205 -0
- package/src/host/authoring/selection-remount-handoff.ts +23 -0
- package/src/host/authoring/source-persistence-backend.ts +373 -0
- package/src/host/authoring/source-refresh-revisions.ts +81 -0
- package/src/host/authoring/struct-write-pipe.ts +143 -0
- package/src/host/auto-frame-window.ts +89 -0
- package/src/host/binding-resolver.ts +393 -0
- package/src/host/browser-transpile.ts +631 -0
- package/src/host/canvas-entry-runtime.ts +95 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
- package/src/host/components/HeaderTelemetry.tsx +341 -0
- package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
- package/src/host/components/ResolutionPicker.tsx +89 -0
- package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
- package/src/host/components/frame-debugger-model.ts +579 -0
- package/src/host/components/header-telemetry-model.ts +74 -0
- package/src/host/components/scene-document.tsx +447 -0
- package/src/host/components/utility-view-state.ts +87 -0
- package/src/host/components/world-root-stage-binding.tsx +133 -0
- package/src/host/components/world-root-stage.ts +1046 -0
- package/src/host/coverage/authoring-read-probe.ts +583 -0
- package/src/host/coverage/capability-coverage.ts +1587 -0
- package/src/host/coverage/coverage-accounting.ts +261 -0
- package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
- package/src/host/coverage/project-verb-coverage.ts +148 -0
- package/src/host/coverage/system-adapter-coverage.ts +373 -0
- package/src/host/design-system-stories/StoryLayout.tsx +104 -0
- package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
- package/src/host/document-preview-three.ts +110 -0
- package/src/host/entry-adjudication.ts +89 -0
- package/src/host/game-location-guard.ts +150 -0
- package/src/host/game-module-access.ts +196 -0
- package/src/host/game-realm-page.ts +358 -0
- package/src/host/game-realm-reclaim.ts +55 -0
- package/src/host/game-realm-storage.ts +104 -0
- package/src/host/gameplay-export.ts +288 -0
- package/src/host/gameplay-recording.ts +717 -0
- package/src/host/gated-globals.ts +1511 -0
- package/src/host/history/json-history-resource.ts +254 -0
- package/src/host/ingest/registry.ts +261 -0
- package/src/host/instance-extract-actions.ts +120 -0
- package/src/host/instance-fork-actions.ts +120 -0
- package/src/host/play-control-hook.ts +26 -0
- package/src/host/playwright-shim.ts +479 -0
- package/src/host/projection/dom.ts +295 -0
- package/src/host/projection/pixi.ts +288 -0
- package/src/host/r3f-entry-runtime.ts +77 -0
- package/src/host/react-mount-runtime.ts +162 -0
- package/src/host/realm-services.ts +148 -0
- package/src/host/recording-preview.ts +106 -0
- package/src/host/roots/module-root.ts +203 -0
- package/src/host/roots/react-root.ts +181 -0
- package/src/host/same-realm-loop-gate.ts +544 -0
- package/src/host/scene-view-drawability.ts +69 -0
- package/src/host/sdk/tools.ts +31 -0
- package/src/host/served-bundle-runtime-modules.ts +331 -0
- package/src/host/server-log-bridge.ts +60 -0
- package/src/host/staged-projects.ts +24 -0
- package/src/host/stories/mounted-story-viewport-source.ts +64 -0
- package/src/host/stories/story-arg-descriptors.ts +50 -0
- package/src/host/stories/story-media-presence.ts +152 -0
- package/src/host/surface-content.ts +87 -0
- package/src/host/take-named-export.ts +23 -0
- package/src/host/three-ingest-runtime.ts +76 -0
- package/src/host/types-fastnoise-lite.d.ts +7 -0
- package/src/host/types-mikktspace.d.ts +20 -0
- package/src/host/types-troika-three-text.d.ts +7 -0
- package/src/host/use-active-performance-source.ts +53 -0
- package/src/host/viewport-pose-memory.ts +48 -0
- package/src/host/viewport-root-presentation.ts +40 -0
- package/src/ingest/active-ingest.ts +251 -0
- package/src/ingest/active-scene-navigation.ts +43 -0
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
- package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
- package/src/ingest/capture-wait-report.ts +122 -0
- package/src/ingest/deferred-ingest-play.ts +216 -0
- package/src/ingest/deferred-ingest-session.ts +23 -0
- package/src/ingest/discovery-public-ingest.ts +242 -0
- package/src/ingest/dom-stub-mark.ts +7 -0
- package/src/ingest/entry-load.ts +56 -0
- package/src/ingest/game-contract-realm.ts +34 -0
- package/src/ingest/game-pointer-lock.ts +89 -0
- package/src/ingest/held-scene-repaint.ts +73 -0
- package/src/ingest/host-surface-box.ts +174 -0
- package/src/ingest/ingest-boot-viewport.ts +54 -0
- package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
- package/src/ingest/ingest-canvas-scene.ts +50 -0
- package/src/ingest/ingest-evidence-hook.ts +131 -0
- package/src/ingest/ingest-frame-snapshot.ts +183 -0
- package/src/ingest/ingest-play-commands.ts +134 -0
- package/src/ingest/ingest-play-control.ts +299 -0
- package/src/ingest/ingest-render-debug.ts +320 -0
- package/src/ingest/ingest-siblings.ts +487 -0
- package/src/ingest/ingest-status.ts +62 -0
- package/src/ingest/live-ingest-facet.ts +46 -0
- package/src/ingest/module-mode.ts +229 -0
- package/src/ingest/mount-canvas-ingest-root.ts +899 -0
- package/src/ingest/mount-coverage.ts +296 -0
- package/src/ingest/mount-dom-ingest-root.ts +282 -0
- package/src/ingest/mount-ingest-root.ts +744 -0
- package/src/ingest/mount-three-ingest-root.ts +366 -0
- package/src/ingest/resolve-canvas.ts +47 -0
- package/src/ingest/resolve-three.ts +123 -0
- package/src/ingest/served-bundle.ts +109 -0
- package/src/ingest/served-html-boot.ts +292 -0
- package/src/ingest/surface-canvas.ts +84 -0
- package/src/ingest/surface-dom.ts +84 -0
- package/src/ingest/surface-three.ts +128 -0
- package/src/ingest/types.ts +76 -0
- package/src/ingest/unmount-ingest-root.ts +224 -0
- package/src/navmesh/navmesh-actions.ts +27 -0
- package/src/navmesh/navmesh-handler.ts +237 -0
- package/src/navmesh/navmesh-workflow-store.ts +78 -0
- package/src/network/NetworkInspectorPanel.tsx +644 -0
- package/src/network/network-inspector-model.ts +225 -0
- package/src/play/play-boot-stall.ts +118 -0
- package/src/play/play-log-events.ts +26 -0
- package/src/play/play-mode.ts +2553 -0
- package/src/play/play-recording.ts +335 -0
- package/src/play/react-play-live-authoring.ts +165 -0
- package/src/play-bar/PlayBar.tsx +488 -0
- package/src/play-bar/PlayerCountPicker.tsx +100 -0
- package/src/profiler/FrameDebuggerPanel.tsx +458 -0
- package/src/profiler/PerformancePanel.tsx +1068 -0
- package/src/profiler/ProfilerPanel.tsx +53 -0
- package/src/profiler/frame-debugger-store.ts +97 -0
- package/src/profiler/main-thread-busy.ts +90 -0
- package/src/react/design-time-react-mount.ts +782 -0
- package/src/react/dom-authoring-adapter.ts +764 -0
- package/src/react/pasteboard-materialize.ts +154 -0
- package/src/react/react-inspector-section.tsx +2496 -0
- package/src/react/react-world-authoring-adapter.ts +3795 -0
- package/src/react/story-documents/story-args-section.ts +19 -0
- package/src/react/story-documents/story-documents.tsx +1380 -0
- package/src/react/story-paint-bounds.ts +94 -0
- package/src/react/ui-board-document.tsx +101 -0
- package/src/react/ui-board-title.ts +9 -0
- package/src/react/ui-component-board.ts +72 -0
- package/src/services/audio-pose-guard.ts +81 -0
- package/src/services/game-audio-unlock.ts +48 -0
- package/src/state-watch/StateWatchPanel.tsx +535 -0
- package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
- package/src/three/authoring/constraint-inspector-section.tsx +189 -0
- package/src/three/authoring/design-time-renderer.ts +189 -0
- package/src/three/authoring/model-asset-inspector-section.css +41 -0
- package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
- package/src/three/authoring/oid-source-persistence.ts +557 -0
- package/src/three/authoring/r3f-design-session.ts +1174 -0
- package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
- package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
- package/src/three/authoring/spatial-audio-handles.ts +215 -0
- package/src/three/authoring/spatial-collider-handles.ts +229 -0
- package/src/three/authoring/spatial-joint-handles.ts +114 -0
- package/src/three/authoring/spatial-light-handles.ts +178 -0
- package/src/three/authoring/spatial-lod-handles.ts +93 -0
- package/src/three/authoring/spatial-particle-handles.ts +368 -0
- package/src/three/authoring/three-authoring-adapter.ts +1953 -0
- package/src/three/authoring/three-scene-identity.ts +19 -0
- package/src/three/authoring/three-spatial-handles.ts +161 -0
- package/src/three/authoring/typed-three-inspector.ts +528 -0
- package/src/three/component-verbs/extract-menu.ts +71 -0
- package/src/three/component-verbs/fork-menu.ts +78 -0
- package/src/three/component-verbs/internals-menu.ts +91 -0
- package/src/three/story-documents/three-story-documents.tsx +607 -0
- package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
- package/src/three/three-board/board-framing.ts +412 -0
- package/src/three/three-board/board-layout.ts +401 -0
- package/src/three/three-board/board-scene.ts +901 -0
- package/src/three/three-board/three-component-board.ts +62 -0
- package/src/xstate/XStateBehaviorSection.tsx +130 -0
- package/src/xstate/XStateMachineInspector.tsx +566 -0
- package/src/xstate/character-animation-machine.fixture.ts +74 -0
- package/src/xstate/live-behaviors.ts +106 -0
- package/src/xstate/use-live-actor-state.ts +44 -0
- package/src/xstate/xstate-graph.ts +235 -0
- package/src/xstate/xstate-layout.ts +76 -0
|
@@ -0,0 +1,1511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gated game globals — pipe browser globals into the game's mount context.
|
|
3
|
+
*
|
|
4
|
+
* Arbitrary game code can reach for the real, shared `window`
|
|
5
|
+
* (`window.addEventListener('keydown', …)`), which fires regardless of whether
|
|
6
|
+
* the game viewport is focused — so keystrokes leak into the editor while you
|
|
7
|
+
* work in it. There is no cooperative way to stop that in a shared JS context.
|
|
8
|
+
*
|
|
9
|
+
* Instead of patching the real global (blunt, collateral damage to the editor),
|
|
10
|
+
* every project module lexically shadows `globalThis`, `window`, and `document`.
|
|
11
|
+
* This module supplies the mount-owned view: input and scheduling gates,
|
|
12
|
+
* listener lifecycle, isolated game-global properties, pane-shaped page reads,
|
|
13
|
+
* and attributed facades over ordinary shared-origin storage. Editor globals
|
|
14
|
+
* remain untouched.
|
|
15
|
+
*
|
|
16
|
+
* Shared-context best-effort: handler properties on OTHER targets
|
|
17
|
+
* (`el.onkeydown = …`) and listeners on other targets are out of scope — but
|
|
18
|
+
* `window.onkeydown = …` is not: it is a second registration path onto the very
|
|
19
|
+
* target we proxy, so the `set` trap gates it exactly like `addEventListener`
|
|
20
|
+
* (see {@link makeGatedProxy}). True cooperation-free isolation would need a
|
|
21
|
+
* separate browsing context (iframe) — rejected (breaks the shared heap the
|
|
22
|
+
* engine/editor singletons rely on; see T6.3 task notes).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The one-line prelude the transpile layer prepends to every project module.
|
|
26
|
+
* Defined in a DOM-free module so the Node-side Vite plugin can import the same
|
|
27
|
+
* constant — see `game-globals-prelude.ts`. Re-exported here for the browser
|
|
28
|
+
* callers that already reach for it alongside the proxies. */
|
|
29
|
+
export { GAME_GLOBALS_PRELUDE } from '@volter/editor-core/game-globals-prelude';
|
|
30
|
+
|
|
31
|
+
import { installCreationSiteRecorder } from '@volter/editor-core/creation-site-registry';
|
|
32
|
+
import { setConsoleRealmAttribution } from '@volter/editor-core/editor-console';
|
|
33
|
+
import { guardedGameLocation, refusedNavigationMessage } from './game-location-guard';
|
|
34
|
+
import { GameRealmPage } from './game-realm-page';
|
|
35
|
+
import {
|
|
36
|
+
createGameRealmStorage,
|
|
37
|
+
type GameRealmStorageStats,
|
|
38
|
+
type GameRealmStorageView,
|
|
39
|
+
} from './game-realm-storage';
|
|
40
|
+
import {
|
|
41
|
+
createSameRealmLoopGate,
|
|
42
|
+
type GatedSchedulers,
|
|
43
|
+
type SameRealmLoopDisposal,
|
|
44
|
+
type SameRealmLoopGate,
|
|
45
|
+
type SameRealmLoopGateStats,
|
|
46
|
+
} from './same-realm-loop-gate';
|
|
47
|
+
import { surfaceHoldsKeyboard } from '@volter/editor-core/surface-keyboard';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The SAME-REALM LOOP GATE (S-5) — the scheduling half of the same idea.
|
|
51
|
+
*
|
|
52
|
+
* `gated-globals` pipes input listeners through an editor-owned gate; this
|
|
53
|
+
* pipes SCHEDULING through one, so a same-realm ingested game's `setInterval`
|
|
54
|
+
* simulation tick can actually be paused. It is built once here and shadowed
|
|
55
|
+
* into game modules by {@link GAME_GLOBALS_PRELUDE}; the editor's own timers
|
|
56
|
+
* never touch it. Open (pure pass-through) until an ingest pause holds it —
|
|
57
|
+
* see `authoring/ingest-root-adapter.ts`'s `createIngestLoopGate`.
|
|
58
|
+
*/
|
|
59
|
+
let loopGate: SameRealmLoopGate | null = null;
|
|
60
|
+
|
|
61
|
+
/** The installed same-realm loop gate, or `null` outside a browser / before
|
|
62
|
+
* {@link installGatedGameGlobals} ran. Callers treat `null` as "no gate",
|
|
63
|
+
* which is what makes the probe report `self-driven` rather than fabricating
|
|
64
|
+
* control it does not have. */
|
|
65
|
+
export function gameLoopGate(): SameRealmLoopGate | null {
|
|
66
|
+
return loopGate;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Event types that constitute "game input" and are focus-gated. */
|
|
70
|
+
const INPUT_EVENT_TYPES = new Set<string>([
|
|
71
|
+
'keydown',
|
|
72
|
+
'keyup',
|
|
73
|
+
'keypress',
|
|
74
|
+
'mousedown',
|
|
75
|
+
'mouseup',
|
|
76
|
+
'mousemove',
|
|
77
|
+
'click',
|
|
78
|
+
'dblclick',
|
|
79
|
+
'contextmenu',
|
|
80
|
+
'wheel',
|
|
81
|
+
'pointerdown',
|
|
82
|
+
'pointerup',
|
|
83
|
+
'pointermove',
|
|
84
|
+
'pointercancel',
|
|
85
|
+
'touchstart',
|
|
86
|
+
'touchmove',
|
|
87
|
+
'touchend',
|
|
88
|
+
'touchcancel',
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A RELEASE IS NEVER DROPPED. The gate stops game input while the editor owns
|
|
93
|
+
* the keyboard and pointer, and it stopped keyup/mouseup/pointerup the same
|
|
94
|
+
* way — so a key pressed while the gate was open and released after it closed
|
|
95
|
+
* (Stop with W still held) left the game's OWN input manager holding KeyW for
|
|
96
|
+
* good: the editor flushes only the session's InputManager, never a
|
|
97
|
+
* project-owned copy (`src/lib/input`). With both vertical keys stuck the hero
|
|
98
|
+
* could not move up or down while A/D worked (runhuman pass 129; Opus
|
|
99
|
+
* reproduction 2026-09-03 — forcing KeyW+KeyS into `keysDown` reproduced the
|
|
100
|
+
* report verbatim, a single stuck key does not). Each proxy remembers the
|
|
101
|
+
* presses it let through and passes THEIR releases whatever the gate says; a
|
|
102
|
+
* release for a press the game never saw stays blocked, so a closed gate still
|
|
103
|
+
* fabricates no input.
|
|
104
|
+
*/
|
|
105
|
+
interface GatedInputBlocker {
|
|
106
|
+
/** True when `event` must not reach the game's listener. */
|
|
107
|
+
(type: string, event: Event): boolean;
|
|
108
|
+
/**
|
|
109
|
+
* A synthetic release for every press this proxy passed and never saw
|
|
110
|
+
* released — what a realm owes its listeners when it is torn down with a
|
|
111
|
+
* key still under the hand (Stop while W is held: the old instance kept
|
|
112
|
+
* `KeyW` because its listeners were removed before any keyup could reach
|
|
113
|
+
* them — measured on preview-b56). Touch presses are not synthesized: a
|
|
114
|
+
* `TouchEvent` cannot be built without its `Touch` list, and no input
|
|
115
|
+
* manager here keys state on touch identity.
|
|
116
|
+
*/
|
|
117
|
+
outstandingReleases(): Event[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function gatedInputBlocker(gate: () => boolean): GatedInputBlocker {
|
|
121
|
+
const passed = new Set<string>();
|
|
122
|
+
// ONE EVENT, EVERY LISTENER. Each listener's wrapper asks this blocker
|
|
123
|
+
// separately for the same event, and the press token is consumed by the
|
|
124
|
+
// first ask — so the second listener of the same type would have been
|
|
125
|
+
// blocked and left holding the key (read in review, 2026-09-03). A release
|
|
126
|
+
// admitted once is admitted for every wrapper that sees that event object.
|
|
127
|
+
const admitted = new WeakSet<Event>();
|
|
128
|
+
const blocked = ((type: string, event: Event): boolean => {
|
|
129
|
+
const presses = pressKeys(type, event);
|
|
130
|
+
const releases = releaseKeys(type, event);
|
|
131
|
+
if (gate()) {
|
|
132
|
+
for (const key of presses) passed.add(key);
|
|
133
|
+
for (const key of releases) passed.delete(key);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
if (admitted.has(event)) return false;
|
|
137
|
+
let seen = false;
|
|
138
|
+
for (const key of releases) if (passed.delete(key)) seen = true;
|
|
139
|
+
if (seen) admitted.add(event);
|
|
140
|
+
return !seen;
|
|
141
|
+
}) as GatedInputBlocker;
|
|
142
|
+
blocked.outstandingReleases = () => {
|
|
143
|
+
const out: Event[] = [];
|
|
144
|
+
for (const key of passed) {
|
|
145
|
+
const at = key.indexOf(':');
|
|
146
|
+
const kind = key.slice(0, at);
|
|
147
|
+
const value = key.slice(at + 1);
|
|
148
|
+
if (kind === 'key') {
|
|
149
|
+
out.push(
|
|
150
|
+
new KeyboardEvent('keyup', { code: value, key: keyNameForCode(value), bubbles: true }),
|
|
151
|
+
);
|
|
152
|
+
} else if (kind === 'mouse') {
|
|
153
|
+
out.push(new MouseEvent('mouseup', { button: Number(value), bubbles: true }));
|
|
154
|
+
} else if (kind === 'pointer' && typeof PointerEvent === 'function') {
|
|
155
|
+
out.push(new PointerEvent('pointerup', { pointerId: Number(value), bubbles: true }));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
};
|
|
160
|
+
return blocked;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** `KeyW` → `w`, `Digit1` → `1`, `Space` → ` `; anything else keeps its code
|
|
164
|
+
* as the key — a release listener reads `code` first and `key` as a label. */
|
|
165
|
+
function keyNameForCode(code: string): string {
|
|
166
|
+
const letter = /^Key([A-Z])$/.exec(code);
|
|
167
|
+
if (letter) return letter[1]!.toLowerCase();
|
|
168
|
+
const digit = /^Digit(\d)$/.exec(code);
|
|
169
|
+
if (digit) return digit[1]!;
|
|
170
|
+
if (code === 'Space') return ' ';
|
|
171
|
+
return code;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function touchKeys(event: Event): string[] {
|
|
175
|
+
const touches = (event as TouchEvent).changedTouches;
|
|
176
|
+
if (!touches) return ['touch'];
|
|
177
|
+
return Array.from(touches, (touch) => `touch:${touch.identifier}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function pressKeys(type: string, event: Event): string[] {
|
|
181
|
+
switch (type) {
|
|
182
|
+
case 'keydown':
|
|
183
|
+
return [`key:${(event as KeyboardEvent).code}`];
|
|
184
|
+
case 'mousedown':
|
|
185
|
+
return [`mouse:${(event as MouseEvent).button}`];
|
|
186
|
+
case 'pointerdown':
|
|
187
|
+
return [`pointer:${(event as PointerEvent).pointerId}`];
|
|
188
|
+
case 'touchstart':
|
|
189
|
+
return touchKeys(event);
|
|
190
|
+
default:
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function releaseKeys(type: string, event: Event): string[] {
|
|
196
|
+
switch (type) {
|
|
197
|
+
case 'keyup':
|
|
198
|
+
return [`key:${(event as KeyboardEvent).code}`];
|
|
199
|
+
case 'mouseup':
|
|
200
|
+
return [`mouse:${(event as MouseEvent).button}`];
|
|
201
|
+
case 'pointerup':
|
|
202
|
+
case 'pointercancel':
|
|
203
|
+
return [`pointer:${(event as PointerEvent).pointerId}`];
|
|
204
|
+
case 'touchend':
|
|
205
|
+
case 'touchcancel':
|
|
206
|
+
return touchKeys(event);
|
|
207
|
+
default:
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Scheduling entry points the loop gate owns — the SAME set the prelude
|
|
213
|
+
* shadows lexically (`game-globals-prelude.ts`), so `window.setInterval(…)`
|
|
214
|
+
* and a bare `setInterval(…)` can never resolve to different functions. */
|
|
215
|
+
const GATED_SCHEDULER_NAMES = new Set<string>([
|
|
216
|
+
'setTimeout',
|
|
217
|
+
'clearTimeout',
|
|
218
|
+
'setInterval',
|
|
219
|
+
'clearInterval',
|
|
220
|
+
'requestAnimationFrame',
|
|
221
|
+
'cancelAnimationFrame',
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Browser methods need their real Window/Document receiver, but constructors are realm values.
|
|
226
|
+
* Binding `Object`, `Array`, `Promise`, etc. creates a new bound function that loses the original
|
|
227
|
+
* constructor's static members (`Object.assign`, `Object.create`, ...). A function's own
|
|
228
|
+
* `prototype` is the stable language-level distinction needed here: ordinary receiver-dependent
|
|
229
|
+
* methods do not own one, while constructable globals do.
|
|
230
|
+
*/
|
|
231
|
+
function realmFunction(value: unknown, receiver: object): unknown {
|
|
232
|
+
if (typeof value !== 'function') return value;
|
|
233
|
+
if (Object.hasOwn(value, 'prototype')) return value;
|
|
234
|
+
return value.bind(receiver);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* ONE GAME REALM PER MOUNT.
|
|
239
|
+
*
|
|
240
|
+
* A realm is the set of globals a mounted game's code sees: its gated
|
|
241
|
+
* `window`/`document` proxies, its scheduling gate, and the input gate both
|
|
242
|
+
* consult. There is one per mount id, plus a DEFAULT realm for code served
|
|
243
|
+
* without one (an in-page transpile, an ingest fixture).
|
|
244
|
+
*
|
|
245
|
+
* Why a registry rather than the three module-level globals this replaced:
|
|
246
|
+
* with one shared set, N instances of a game share one input gate and one loop
|
|
247
|
+
* gate — input aimed at the focused instance reaches all of them, and pausing
|
|
248
|
+
* one pauses its siblings. Neither is fixable in the presentation layer.
|
|
249
|
+
*
|
|
250
|
+
* The lookup key comes from the MODULE'S OWN URL: every project module served
|
|
251
|
+
* under a mount carries `?vgai-mount=<id>` (the isolation transform), and the
|
|
252
|
+
* prelude passes `import.meta.url` to {@link gameRealmForModuleUrl}. So a
|
|
253
|
+
* module resolves its own realm with no plugin involvement and no threading.
|
|
254
|
+
*/
|
|
255
|
+
export interface GameRealm {
|
|
256
|
+
recordModuleUrl(url: string): void;
|
|
257
|
+
console: Console;
|
|
258
|
+
document: Document;
|
|
259
|
+
globalThis: Window;
|
|
260
|
+
timers: SameRealmLoopGate['schedulers'];
|
|
261
|
+
window: Window;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface GameRealmDiagnostics {
|
|
265
|
+
readonly globals: number;
|
|
266
|
+
readonly mountId: string;
|
|
267
|
+
readonly loop: SameRealmLoopGateStats;
|
|
268
|
+
readonly listeners: { readonly document: number; readonly window: number };
|
|
269
|
+
readonly storage: {
|
|
270
|
+
readonly local: GameRealmStorageStats;
|
|
271
|
+
readonly session: GameRealmStorageStats;
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface GameRealmDisposal {
|
|
276
|
+
readonly mountId: string;
|
|
277
|
+
readonly listeners: number;
|
|
278
|
+
readonly timers: SameRealmLoopDisposal;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The realm for code with no mount id. Keeps single-instance behaviour
|
|
282
|
+
* byte-identical: one game, one realm, exactly as before. */
|
|
283
|
+
const DEFAULT_REALM_ID = '';
|
|
284
|
+
|
|
285
|
+
interface OwnedProxy<T extends EventTarget> {
|
|
286
|
+
readonly proxy: T;
|
|
287
|
+
readonly count: () => number;
|
|
288
|
+
readonly expandoCount: () => number;
|
|
289
|
+
dispose(): number;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A realm GENERATION's liveness cell.
|
|
294
|
+
*
|
|
295
|
+
* Mount ids are reused in exactly one place — the DEFAULT realm, which every
|
|
296
|
+
* ingest mount and every module-served fallback resolves through. So "is this
|
|
297
|
+
* realm disposed?" cannot be answered by an id-keyed set: the next mount under
|
|
298
|
+
* the same id must start clean. It is answered by this object, which one
|
|
299
|
+
* generation of the realm closes over and the NEXT generation replaces.
|
|
300
|
+
*/
|
|
301
|
+
interface RealmLiveness {
|
|
302
|
+
alive: boolean;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
interface RealmRecord {
|
|
306
|
+
readonly moduleUrls: Map<string, string>;
|
|
307
|
+
readonly public: GameRealm;
|
|
308
|
+
readonly loop: SameRealmLoopGate;
|
|
309
|
+
readonly document: OwnedProxy<Document>;
|
|
310
|
+
readonly live: RealmLiveness;
|
|
311
|
+
readonly localStorage: () => GameRealmStorageView | null;
|
|
312
|
+
readonly sessionStorage: () => GameRealmStorageView | null;
|
|
313
|
+
readonly window: OwnedProxy<Window>;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const realms = new Map<string, RealmRecord>();
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Input gates live SEPARATELY from realms, keyed the same way.
|
|
320
|
+
*
|
|
321
|
+
* A gate is plain data and setting one must stay DOM-free — the editor host
|
|
322
|
+
* calls `setGameInputGate` from contexts with no `window`/`document` (and a
|
|
323
|
+
* Node-side test does too). Building a realm constructs Proxies over the real
|
|
324
|
+
* globals, so folding the two together made a gate assignment throw
|
|
325
|
+
* `document is not defined` outside a browser. Keeping them apart also means a
|
|
326
|
+
* gate can be set BEFORE the instance it belongs to has mounted, which is the
|
|
327
|
+
* ordinary case: the host knows the policy before the game's first module
|
|
328
|
+
* loads.
|
|
329
|
+
*/
|
|
330
|
+
const gates = new Map<string, () => boolean>();
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* PAGES live alongside gates, keyed the same way and for the same reason: a
|
|
334
|
+
* mount knows its surface before the game's first module loads, and the value
|
|
335
|
+
* must be settable from DOM-free contexts. See `game-realm-page.ts` for what a
|
|
336
|
+
* page is and why an in-realm game needs one.
|
|
337
|
+
*/
|
|
338
|
+
const pages = new Map<string, GameRealmPage>();
|
|
339
|
+
const pageLocations = new WeakMap<
|
|
340
|
+
GameRealmPage,
|
|
341
|
+
{ readonly source: Location; readonly view: Location }
|
|
342
|
+
>();
|
|
343
|
+
|
|
344
|
+
/** The page for one realm, created on first use (surface still unset). */
|
|
345
|
+
function pageFor(id: string): GameRealmPage {
|
|
346
|
+
let page = pages.get(id);
|
|
347
|
+
if (!page) {
|
|
348
|
+
page = new GameRealmPage();
|
|
349
|
+
pages.set(id, page);
|
|
350
|
+
}
|
|
351
|
+
return page;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Hand a realm the element its game should see as the whole page: the mount's
|
|
356
|
+
* adopted surface. Called by the ingest mount BEFORE the game's entry module
|
|
357
|
+
* runs, so the game's very first `document.body.appendChild` already lands in
|
|
358
|
+
* the pane.
|
|
359
|
+
*/
|
|
360
|
+
export function setGameSurface(el: HTMLElement, mountId = DEFAULT_REALM_ID): GameRealmPage {
|
|
361
|
+
const page = pageFor(mountId);
|
|
362
|
+
page.surface = el;
|
|
363
|
+
return page;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Drop a realm's PAGE when its mount disposes — the realm reverts to the real
|
|
368
|
+
* page, which is what "no game is running" has always meant.
|
|
369
|
+
*
|
|
370
|
+
* The entry is RESET, never deleted: `buildRealm` closes over the page object,
|
|
371
|
+
* so a replacement in the map would be invisible to the proxies the game's
|
|
372
|
+
* modules already hold. Resetting in place is what makes a second ingest mount
|
|
373
|
+
* in one tab lifetime a clean slate — see `GameRealmPage.reset`.
|
|
374
|
+
*/
|
|
375
|
+
export function clearGameSurface(mountId = DEFAULT_REALM_ID): void {
|
|
376
|
+
pages.get(mountId)?.reset();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The page a realm sees, for hosts that need to drive it (dispatch a resize,
|
|
380
|
+
* read the game's own size policy). */
|
|
381
|
+
export function gameRealmPage(mountId = DEFAULT_REALM_ID): GameRealmPage {
|
|
382
|
+
return pageFor(mountId);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Every registered game surface, newest last — the lookup
|
|
386
|
+
* `ingest/game-pointer-lock.ts` uses to decide whether an element belongs to
|
|
387
|
+
* a game rather than to the editor's own viewport. */
|
|
388
|
+
export function gameSurfaces(): { surface: HTMLElement; gate: () => boolean }[] {
|
|
389
|
+
const out: { surface: HTMLElement; gate: () => boolean }[] = [];
|
|
390
|
+
for (const [id, page] of pages) {
|
|
391
|
+
if (page.surface) out.push({ surface: page.surface, gate: gateFor(id) });
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* A live gate reader for one realm — resolves per event, so setting a gate
|
|
398
|
+
* later (or replacing it) takes effect without rebuilding the proxies.
|
|
399
|
+
*
|
|
400
|
+
* Exported as {@link gameInputGateFor} for callers that build their own proxy
|
|
401
|
+
* over a stand-in target and still want the REAL gate semantics.
|
|
402
|
+
*/
|
|
403
|
+
function gateFor(id: string): () => boolean {
|
|
404
|
+
// AND THE SURFACE TERM (U2). `surfaceHoldsKeyboard()` is true standalone and
|
|
405
|
+
// under the frame is the workbench's own "is the vgai pane the active
|
|
406
|
+
// editor?", so a keystroke aimed at Monaco in the group beside a running
|
|
407
|
+
// game never reaches the game's listeners. It is read HERE, once, rather
|
|
408
|
+
// than folded into each lane's own predicate, because this is the one place
|
|
409
|
+
// every project module's raw `window`/`document` listener passes through —
|
|
410
|
+
// Play, ingest and the module lane alike. See `surface-keyboard.ts`.
|
|
411
|
+
return () => surfaceHoldsKeyboard() && (gates.get(id) ?? (() => true))();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The gate ONE REALM GENERATION's own proxies read.
|
|
416
|
+
*
|
|
417
|
+
* Identical to {@link gateFor} while that generation is live. Once
|
|
418
|
+
* `disposeGameRealm` has reclaimed it, the gate is permanently CLOSED — because
|
|
419
|
+
* the id-keyed fallback above is always-TRUE and `disposeGameRealm` deletes the
|
|
420
|
+
* gate entry, so a listener that survived teardown (a late continuation, a
|
|
421
|
+
* promise that resolved after stop) would otherwise fire UNGATED for the rest
|
|
422
|
+
* of the tab's life, into a realm nothing will ever reclaim again.
|
|
423
|
+
*
|
|
424
|
+
* A later mount under the same id builds a NEW generation with its own
|
|
425
|
+
* {@link RealmLiveness}, so a closed generation never leaks into the next game.
|
|
426
|
+
*/
|
|
427
|
+
function realmGenerationGate(id: string, live: RealmLiveness): () => boolean {
|
|
428
|
+
const byId = gateFor(id);
|
|
429
|
+
return () => live.alive && byId();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Read a mount id off a module url. Kept in step with the server-side
|
|
433
|
+
* `project-module-instance.ts` by `project-module-instance.test.ts`. */
|
|
434
|
+
function mountIdOfUrl(moduleUrl: string): string {
|
|
435
|
+
const query = moduleUrl.split('?')[1];
|
|
436
|
+
if (query === undefined) return DEFAULT_REALM_ID;
|
|
437
|
+
return new URLSearchParams(query).get('vgai-mount') ?? DEFAULT_REALM_ID;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** The realm whose code is synchronously executing right now. Console capture
|
|
441
|
+
* reads this to label a row with the mount that produced it. */
|
|
442
|
+
const executingRealms: string[] = [];
|
|
443
|
+
|
|
444
|
+
export function currentGameRealmMountId(): string | null {
|
|
445
|
+
return executingRealms[executingRealms.length - 1] ?? null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The console asks; the realm answers. `editor-console.ts` used to IMPORT the
|
|
450
|
+
* reader above, which put this whole shim inside that pinned surface's closure
|
|
451
|
+
* (see `setConsoleRealmAttribution`'s own block for the measurement). The
|
|
452
|
+
* registration is at module scope because the reader can only ever be needed
|
|
453
|
+
* once a realm exists, and a realm existing means this module has loaded.
|
|
454
|
+
*/
|
|
455
|
+
setConsoleRealmAttribution(currentGameRealmMountId);
|
|
456
|
+
|
|
457
|
+
function inRealm<T>(mountId: string, run: () => T): T {
|
|
458
|
+
executingRealms.push(mountId);
|
|
459
|
+
try {
|
|
460
|
+
return run();
|
|
461
|
+
} finally {
|
|
462
|
+
executingRealms.pop();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function contextualSchedulers(id: string, schedulers: GatedSchedulers): GatedSchedulers {
|
|
467
|
+
return {
|
|
468
|
+
setTimeout(callback, ms, ...args) {
|
|
469
|
+
return schedulers.setTimeout(
|
|
470
|
+
(...received) => inRealm(id, () => callback(...received)),
|
|
471
|
+
ms,
|
|
472
|
+
...args,
|
|
473
|
+
);
|
|
474
|
+
},
|
|
475
|
+
clearTimeout: (timerId) => schedulers.clearTimeout(timerId),
|
|
476
|
+
setInterval(callback, ms, ...args) {
|
|
477
|
+
return schedulers.setInterval(
|
|
478
|
+
(...received) => inRealm(id, () => callback(...received)),
|
|
479
|
+
ms,
|
|
480
|
+
...args,
|
|
481
|
+
);
|
|
482
|
+
},
|
|
483
|
+
clearInterval: (timerId) => schedulers.clearInterval(timerId),
|
|
484
|
+
requestAnimationFrame(callback) {
|
|
485
|
+
return schedulers.requestAnimationFrame((time) => inRealm(id, () => callback(time)));
|
|
486
|
+
},
|
|
487
|
+
cancelAnimationFrame: (frameId) => schedulers.cancelAnimationFrame(frameId),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function contextualConsole(id: string): Console {
|
|
492
|
+
const methods = new Map<PropertyKey, unknown>();
|
|
493
|
+
return new Proxy(console, {
|
|
494
|
+
get(target, prop) {
|
|
495
|
+
const value = Reflect.get(target, prop, target);
|
|
496
|
+
if (typeof value !== 'function') return value;
|
|
497
|
+
let wrapped = methods.get(prop);
|
|
498
|
+
if (!wrapped) {
|
|
499
|
+
wrapped = (...args: unknown[]) => inRealm(id, () => value.apply(target, args));
|
|
500
|
+
methods.set(prop, wrapped);
|
|
501
|
+
}
|
|
502
|
+
return wrapped;
|
|
503
|
+
},
|
|
504
|
+
}) as Console;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function lazyRealmStorage(
|
|
508
|
+
name: 'localStorage' | 'sessionStorage',
|
|
509
|
+
): () => GameRealmStorageView | null {
|
|
510
|
+
let resolved = false;
|
|
511
|
+
let view: GameRealmStorageView | null = null;
|
|
512
|
+
return () => {
|
|
513
|
+
if (resolved) return view;
|
|
514
|
+
resolved = true;
|
|
515
|
+
try {
|
|
516
|
+
view = createGameRealmStorage(window[name]);
|
|
517
|
+
} catch {
|
|
518
|
+
// Browsers may deny storage for an opaque/blocked origin. Preserve that
|
|
519
|
+
// absence instead of making realm construction itself fail eagerly.
|
|
520
|
+
view = null;
|
|
521
|
+
}
|
|
522
|
+
return view;
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The host action `location.reload()` translates to while a game that can be
|
|
528
|
+
* RESTARTED is mounted — registered by the ingest session that owns the run,
|
|
529
|
+
* cleared with it. Null means the guard's loud refusal stands (first-party
|
|
530
|
+
* roots, module roots: nothing registered, nothing silently changes).
|
|
531
|
+
*/
|
|
532
|
+
let _gameRealmReloadHandler: (() => void) | null = null;
|
|
533
|
+
|
|
534
|
+
export function setGameRealmReloadHandler(handler: (() => void) | null): void {
|
|
535
|
+
_gameRealmReloadHandler = handler;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** The DEFAULT realm's loop gate, surfaced by {@link gameLoopGate} for the
|
|
539
|
+
* single-instance ingest callers that hold it. */
|
|
540
|
+
let defaultLoopGate: SameRealmLoopGate | null = null;
|
|
541
|
+
|
|
542
|
+
function buildRealm(id: string): RealmRecord {
|
|
543
|
+
const live: RealmLiveness = { alive: true };
|
|
544
|
+
const moduleUrls = new Map<string, string>();
|
|
545
|
+
const gate = realmGenerationGate(id, live);
|
|
546
|
+
const loop = createSameRealmLoopGate({
|
|
547
|
+
setTimeout: (cb, ms) => window.setTimeout(cb, ms),
|
|
548
|
+
clearTimeout: (id) => window.clearTimeout(id),
|
|
549
|
+
setInterval: (cb, ms) => window.setInterval(cb, ms),
|
|
550
|
+
clearInterval: (id) => window.clearInterval(id),
|
|
551
|
+
requestAnimationFrame: (cb) => window.requestAnimationFrame(cb),
|
|
552
|
+
cancelAnimationFrame: (id) => window.cancelAnimationFrame(id),
|
|
553
|
+
now: () => performance.now(),
|
|
554
|
+
});
|
|
555
|
+
const observedInputEvents = new WeakSet<Event>();
|
|
556
|
+
const recordInput = (event: Event, blocked: boolean): void => {
|
|
557
|
+
if (observedInputEvents.has(event)) return;
|
|
558
|
+
observedInputEvents.add(event);
|
|
559
|
+
loop.recordInput?.(blocked);
|
|
560
|
+
};
|
|
561
|
+
if (id === DEFAULT_REALM_ID) defaultLoopGate = loop;
|
|
562
|
+
const page = pageFor(id);
|
|
563
|
+
const timers = contextualSchedulers(id, loop.schedulers);
|
|
564
|
+
const localStorage = lazyRealmStorage('localStorage');
|
|
565
|
+
const sessionStorage = lazyRealmStorage('sessionStorage');
|
|
566
|
+
let windowProxy: Window;
|
|
567
|
+
let documentProxy: Document;
|
|
568
|
+
const ownedWindow = createOwnedRealmProxy(
|
|
569
|
+
makeGatedProxy(window, () => true, { page, role: 'window' }),
|
|
570
|
+
gate,
|
|
571
|
+
{
|
|
572
|
+
execute: (run) => inRealm(id, run),
|
|
573
|
+
realmId: id,
|
|
574
|
+
recordInput,
|
|
575
|
+
role: 'window',
|
|
576
|
+
schedulers: timers,
|
|
577
|
+
self: () => windowProxy,
|
|
578
|
+
document: () => documentProxy,
|
|
579
|
+
localStorage,
|
|
580
|
+
sessionStorage,
|
|
581
|
+
isolateExpandos: true,
|
|
582
|
+
},
|
|
583
|
+
);
|
|
584
|
+
windowProxy = ownedWindow.proxy;
|
|
585
|
+
const ownedDocument = createOwnedRealmProxy(
|
|
586
|
+
makeGatedProxy(document, () => true, { page, role: 'document' }),
|
|
587
|
+
gate,
|
|
588
|
+
{
|
|
589
|
+
execute: (run) => inRealm(id, run),
|
|
590
|
+
realmId: id,
|
|
591
|
+
recordInput,
|
|
592
|
+
role: 'document',
|
|
593
|
+
self: () => windowProxy,
|
|
594
|
+
},
|
|
595
|
+
);
|
|
596
|
+
documentProxy = ownedDocument.proxy;
|
|
597
|
+
return {
|
|
598
|
+
moduleUrls,
|
|
599
|
+
live,
|
|
600
|
+
loop,
|
|
601
|
+
document: ownedDocument,
|
|
602
|
+
localStorage,
|
|
603
|
+
sessionStorage,
|
|
604
|
+
window: ownedWindow,
|
|
605
|
+
public: {
|
|
606
|
+
recordModuleUrl(url) {
|
|
607
|
+
if (live.alive) moduleUrls.set(new URL(url).pathname, url);
|
|
608
|
+
},
|
|
609
|
+
console: contextualConsole(id),
|
|
610
|
+
document: documentProxy,
|
|
611
|
+
globalThis: windowProxy,
|
|
612
|
+
timers,
|
|
613
|
+
window: windowProxy,
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** The live input-gate reader for one instance (default realm when omitted).
|
|
619
|
+
* Pair with {@link makeGatedProxy} to gate a target the registry does not own. */
|
|
620
|
+
export function gameInputGateFor(mountId = DEFAULT_REALM_ID): () => boolean {
|
|
621
|
+
return gateFor(mountId);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** The realm a module belongs to, created on first use. */
|
|
625
|
+
export function gameRealmForModuleUrl(moduleUrl: string): GameRealm {
|
|
626
|
+
return gameRealmForMountId(mountIdOfUrl(moduleUrl));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Resolve the transform-baked mount id directly. */
|
|
630
|
+
export function gameRealmForMountId(mountId = DEFAULT_REALM_ID): GameRealm {
|
|
631
|
+
let realm = realms.get(mountId);
|
|
632
|
+
if (!realm) {
|
|
633
|
+
realm = buildRealm(mountId);
|
|
634
|
+
realms.set(mountId, realm);
|
|
635
|
+
}
|
|
636
|
+
return realm.public;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Exact URLs evaluated by this mount, retained only until its realm is disposed. */
|
|
640
|
+
export function loadedGameRealmModuleUrls(mountId: string): readonly string[] {
|
|
641
|
+
return [...(realms.get(mountId)?.moduleUrls.values() ?? [])];
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export function gameRealmDiagnostics(mountId: string): GameRealmDiagnostics | null {
|
|
645
|
+
const realm = realms.get(mountId);
|
|
646
|
+
if (!realm) return null;
|
|
647
|
+
const emptyStorage = { reads: 0, writes: 0 };
|
|
648
|
+
return {
|
|
649
|
+
globals: realm.window.expandoCount(),
|
|
650
|
+
mountId,
|
|
651
|
+
loop: realm.loop.stats(),
|
|
652
|
+
listeners: { document: realm.document.count(), window: realm.window.count() },
|
|
653
|
+
storage: {
|
|
654
|
+
local: realm.localStorage()?.stats() ?? emptyStorage,
|
|
655
|
+
session: realm.sessionStorage()?.stats() ?? emptyStorage,
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** End one mount's browser-resource ownership. `mountId` omitted means the
|
|
661
|
+
* DEFAULT realm — what ingest and every module served without a mount id
|
|
662
|
+
* resolve through, matching every other door in this file. Callers go through
|
|
663
|
+
* `game-realm-reclaim.ts`'s `reclaimGameRealm`, which also reports what the
|
|
664
|
+
* game left behind. */
|
|
665
|
+
export function disposeGameRealm(mountId = DEFAULT_REALM_ID): GameRealmDisposal {
|
|
666
|
+
gates.delete(mountId);
|
|
667
|
+
const page = pages.get(mountId);
|
|
668
|
+
page?.reset();
|
|
669
|
+
if (mountId !== DEFAULT_REALM_ID) pages.delete(mountId);
|
|
670
|
+
const realm = realms.get(mountId);
|
|
671
|
+
if (!realm) {
|
|
672
|
+
return {
|
|
673
|
+
mountId,
|
|
674
|
+
listeners: 0,
|
|
675
|
+
timers: { timeouts: 0, intervals: 0, animationFrames: 0, parkedCallbacks: 0 },
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
realms.delete(mountId);
|
|
679
|
+
realm.moduleUrls.clear();
|
|
680
|
+
// Close THIS generation before reclaiming it: from here on its proxies refuse
|
|
681
|
+
// new registrations and its gate reads false, so a late continuation from the
|
|
682
|
+
// dead mount cannot re-register into a realm nothing will ever reclaim again.
|
|
683
|
+
realm.live.alive = false;
|
|
684
|
+
const listeners = realm.document.dispose() + realm.window.dispose();
|
|
685
|
+
const timers = realm.loop.dispose?.() ?? {
|
|
686
|
+
timeouts: 0,
|
|
687
|
+
intervals: 0,
|
|
688
|
+
animationFrames: 0,
|
|
689
|
+
parkedCallbacks: 0,
|
|
690
|
+
};
|
|
691
|
+
if (mountId === DEFAULT_REALM_ID) {
|
|
692
|
+
// The DEFAULT realm is the one id that is REUSED — every ingest mount and
|
|
693
|
+
// every module served without a mount id resolves through it. Rebuild it
|
|
694
|
+
// immediately so "the default realm exists and is live" (the invariant
|
|
695
|
+
// `installGatedGameGlobals` established) survives its own teardown, and
|
|
696
|
+
// re-publish the host globals, which are captured VALUES: leaving them
|
|
697
|
+
// pointed at the generation just closed is how the next game would find a
|
|
698
|
+
// dead window/document/timer set.
|
|
699
|
+
publishDefaultRealmGlobals();
|
|
700
|
+
}
|
|
701
|
+
return { mountId, listeners, timers };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Set the gate that decides whether game input listeners fire right now, for
|
|
706
|
+
* ONE instance. The editor host sets this so a game only receives input while
|
|
707
|
+
* its surface is active; reset it to always-true when no game is running.
|
|
708
|
+
*
|
|
709
|
+
* `mountId` omitted means the default realm — the single-instance case, which
|
|
710
|
+
* is every caller today.
|
|
711
|
+
*/
|
|
712
|
+
export function setGameInputGate(fn: () => boolean, mountId = DEFAULT_REALM_ID): void {
|
|
713
|
+
gates.set(mountId, fn);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Drop one instance's gate when its mount ends.
|
|
718
|
+
*
|
|
719
|
+
* Mount ids are monotonic — an id is never reused — so a gate left behind is a
|
|
720
|
+
* closure retained for a game that no longer exists, one per play run. That
|
|
721
|
+
* cost nothing while every caller used the default realm and overwrote the
|
|
722
|
+
* single entry; it starts accruing the moment mounts register under their own
|
|
723
|
+
* id. Unsetting reverts the realm to the always-true default, which is the
|
|
724
|
+
* same thing "no game is running" has always meant.
|
|
725
|
+
*/
|
|
726
|
+
export function clearGameInputGate(mountId: string): void {
|
|
727
|
+
gates.delete(mountId);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** How a proxy answers the page-shaped questions: which realm page it belongs
|
|
731
|
+
* to, and whether it is standing in for `window` or for `document` (they
|
|
732
|
+
* answer different ones). Omitted entirely by the unit tests that only care
|
|
733
|
+
* about input gating, which is why it is optional. */
|
|
734
|
+
export interface GatedProxyPageOptions {
|
|
735
|
+
page: GameRealmPage;
|
|
736
|
+
role: 'window' | 'document';
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Build a Proxy over `real` that gates input-listener registration.
|
|
740
|
+
*
|
|
741
|
+
* The gate is a PARAMETER rather than this module's `inputGate` global,
|
|
742
|
+
* because multi-instance authoring needs one gate per instance: input aimed
|
|
743
|
+
* at instance 2 must not reach instance 1, and a paused instance must not
|
|
744
|
+
* pause its siblings. Reading an ambient gate made that inexpressible.
|
|
745
|
+
* Exported for unit tests (build a proxy over a stub EventTarget).
|
|
746
|
+
*
|
|
747
|
+
* `pageOpts` adds the PAGE half (game-realm-page.ts): `document.body`, the
|
|
748
|
+
* realm's viewport size, its `resize` bus, and the navigation refusal. All of
|
|
749
|
+
* it is inert until a mount registers a surface, so a realm with no game is
|
|
750
|
+
* byte-identical to the pre-page behaviour. */
|
|
751
|
+
export function makeGatedProxy<T extends EventTarget>(
|
|
752
|
+
real: T,
|
|
753
|
+
gate: () => boolean,
|
|
754
|
+
pageOpts?: GatedProxyPageOptions,
|
|
755
|
+
): T {
|
|
756
|
+
// original listener → gated wrapper, keyed by type, so removeEventListener
|
|
757
|
+
// detaches the wrapper actually registered.
|
|
758
|
+
const wrappedByType = new Map<string, Map<EventListenerOrEventListenerObject, EventListener>>();
|
|
759
|
+
const inputBlocked = gatedInputBlocker(gate);
|
|
760
|
+
|
|
761
|
+
/** The realm's surface, or null when no mount registered one. */
|
|
762
|
+
const surfaceEl = (): HTMLElement | null => pageOpts?.page.surface ?? null;
|
|
763
|
+
|
|
764
|
+
/** True when this proxy stands in for `window` AND a surface is registered —
|
|
765
|
+
* the condition under which the realm answers page-shaped questions with the
|
|
766
|
+
* PANE's answers instead of the browser window's. */
|
|
767
|
+
const windowOfSurface = (): GameRealmPage | null =>
|
|
768
|
+
pageOpts?.role === 'window' && pageOpts.page.surface ? pageOpts.page : null;
|
|
769
|
+
|
|
770
|
+
const gatedAdd = (
|
|
771
|
+
type: string,
|
|
772
|
+
listener: EventListenerOrEventListenerObject | null,
|
|
773
|
+
options?: boolean | AddEventListenerOptions,
|
|
774
|
+
): void => {
|
|
775
|
+
// The game's window IS its pane, so its `resize` listener belongs to the
|
|
776
|
+
// realm's own bus — the host dispatches when the PANE changes, and a
|
|
777
|
+
// browser-window resize that never touched the pane no longer lies to a
|
|
778
|
+
// game computing its letterbox (game-realm-page.ts, reason 3).
|
|
779
|
+
const page = windowOfSurface();
|
|
780
|
+
if (page && type === 'resize' && listener !== null) {
|
|
781
|
+
page.addResizeListener(listener, options);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
// PAGE LIFECYCLE, delivered to the realm. `load`/`DOMContentLoaded`
|
|
785
|
+
// fired on the HOST page long before a transplanted game evaluated, so a
|
|
786
|
+
// registration here can never fire from the real target — and a vanilla
|
|
787
|
+
// game that boots in `window.onload` (simcity's `window.onload = () =>
|
|
788
|
+
// new Game()`) or `DOMContentLoaded` (tanks) simply never constructed:
|
|
789
|
+
// zero errors, zero renders, measured 2026-08-28. The realm's page IS
|
|
790
|
+
// loaded by the time game code runs (the DOM transplant precedes entry
|
|
791
|
+
// evaluation), so the page-accurate answer is one immediate async
|
|
792
|
+
// dispatch — the same catch-up a real page gives `readyState` checkers.
|
|
793
|
+
if (
|
|
794
|
+
(type === 'load' || type === 'DOMContentLoaded' || type === 'pageshow') &&
|
|
795
|
+
listener !== null &&
|
|
796
|
+
document.readyState === 'complete'
|
|
797
|
+
) {
|
|
798
|
+
window.setTimeout(() => {
|
|
799
|
+
const event = new Event(type);
|
|
800
|
+
if (typeof listener === 'function') {
|
|
801
|
+
(listener as (this: unknown, e: Event) => unknown).call(proxy, event);
|
|
802
|
+
} else {
|
|
803
|
+
listener.handleEvent(event);
|
|
804
|
+
}
|
|
805
|
+
}, 0);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (INPUT_EVENT_TYPES.has(type) && listener !== null) {
|
|
809
|
+
let m = wrappedByType.get(type);
|
|
810
|
+
if (!m) {
|
|
811
|
+
m = new Map();
|
|
812
|
+
wrappedByType.set(type, m);
|
|
813
|
+
}
|
|
814
|
+
const inner = listener;
|
|
815
|
+
let wrapped = m.get(inner);
|
|
816
|
+
if (!wrapped) {
|
|
817
|
+
wrapped = function (this: unknown, e: Event) {
|
|
818
|
+
if (inputBlocked(type, e)) return undefined;
|
|
819
|
+
if (typeof inner === 'function') {
|
|
820
|
+
return (inner as (this: unknown, e: Event) => unknown).call(this, e);
|
|
821
|
+
}
|
|
822
|
+
return inner.handleEvent(e);
|
|
823
|
+
};
|
|
824
|
+
m.set(inner, wrapped);
|
|
825
|
+
}
|
|
826
|
+
real.addEventListener(type, wrapped, options);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
real.addEventListener(type, listener, options);
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
const gatedRemove = (
|
|
833
|
+
type: string,
|
|
834
|
+
listener: EventListenerOrEventListenerObject | null,
|
|
835
|
+
options?: boolean | EventListenerOptions,
|
|
836
|
+
): void => {
|
|
837
|
+
const page = windowOfSurface();
|
|
838
|
+
if (page && type === 'resize' && listener !== null) {
|
|
839
|
+
page.removeResizeListener(listener, options);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (INPUT_EVENT_TYPES.has(type) && listener !== null) {
|
|
843
|
+
const m = wrappedByType.get(type);
|
|
844
|
+
const wrapped = m?.get(listener);
|
|
845
|
+
if (wrapped) {
|
|
846
|
+
m?.delete(listener);
|
|
847
|
+
real.removeEventListener(type, wrapped, options);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
real.removeEventListener(type, listener, options);
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
// Gated `on<type>` wrapper → the handler the game actually assigned, so a read
|
|
855
|
+
// of `window.onkeydown` hands back what was written rather than our wrapper.
|
|
856
|
+
// Keyed by the wrapper (not the property name) so a value written to the REAL
|
|
857
|
+
// target behind our back is never mistaken for ours.
|
|
858
|
+
const handlerByWrapper = new WeakMap<object, EventListener>();
|
|
859
|
+
|
|
860
|
+
/** `'onkeydown'` → `'keydown'` for the gated event types only; else null. */
|
|
861
|
+
const gatedHandlerType = (prop: string | symbol): string | null => {
|
|
862
|
+
if (typeof prop !== 'string' || !prop.startsWith('on')) return null;
|
|
863
|
+
const type = prop.slice(2);
|
|
864
|
+
return INPUT_EVENT_TYPES.has(type) ? type : null;
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
/** Report a refused navigation on the page's own console — which is a real
|
|
868
|
+
* product door: `vgai status` reports page console errors. */
|
|
869
|
+
const refuseNavigation = (attempt: string): void => {
|
|
870
|
+
// A NEW native console.error site, suppressed to keep this task's diff at
|
|
871
|
+
// zero new lint warnings — and it is deliberately native rather than
|
|
872
|
+
// `editorConsole`: game code runs in the page, and the page console is the
|
|
873
|
+
// surface `vgai status` already reads (same reasoning as
|
|
874
|
+
// `ingest-root-adapter.ts`'s loop-gate warning).
|
|
875
|
+
// biome-ignore lint/suspicious/noConsole: see comment above
|
|
876
|
+
console.error(refusedNavigationMessage(attempt));
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
// Assigned below so the `get` trap can hand the PROXY back for `window`,
|
|
880
|
+
// `self`, `top` and `parent`: an in-realm game's window is its own, and
|
|
881
|
+
// returning the real one there would be a hole straight around every trap in
|
|
882
|
+
// this proxy (`window.top.location.href = …`).
|
|
883
|
+
let proxy: T;
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* The PAGE-shaped reads (game-realm-page.ts), answered with the pane's
|
|
887
|
+
* answers instead of the tab's. Returns a one-element box so "answered with
|
|
888
|
+
* `undefined`" stays distinguishable from "not a page question" — and keeps
|
|
889
|
+
* the `get` trap itself one thing.
|
|
890
|
+
*/
|
|
891
|
+
/** `location` — reads pass through, navigating writes are refused out loud
|
|
892
|
+
* (game-location-guard.ts). Both roles: `document.location` is the same
|
|
893
|
+
* object by another name. */
|
|
894
|
+
const locationAnswer = (): { value: unknown } | null => {
|
|
895
|
+
const realLocation = Reflect.get(real, 'location', real) as Location | undefined;
|
|
896
|
+
if (!realLocation || !pageOpts) return null;
|
|
897
|
+
const found = pageLocations.get(pageOpts.page);
|
|
898
|
+
if (found?.source === realLocation) return { value: found.view };
|
|
899
|
+
const view = guardedGameLocation(realLocation, refuseNavigation, {
|
|
900
|
+
reloadGame: () => {
|
|
901
|
+
const handler = _gameRealmReloadHandler;
|
|
902
|
+
if (!handler) return false;
|
|
903
|
+
handler();
|
|
904
|
+
return true;
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
pageLocations.set(pageOpts.page, { source: realLocation, view });
|
|
908
|
+
return { value: view };
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
/** The window role's page reads: the realm's own window identity, and the
|
|
912
|
+
* pane reported as the viewport. */
|
|
913
|
+
const windowPageAnswer = (prop: string | symbol): { value: unknown } | null => {
|
|
914
|
+
const page = windowOfSurface();
|
|
915
|
+
if (!page) return null;
|
|
916
|
+
if (prop === 'window' || prop === 'self' || prop === 'top' || prop === 'parent') {
|
|
917
|
+
return { value: proxy };
|
|
918
|
+
}
|
|
919
|
+
if (
|
|
920
|
+
prop !== 'innerWidth' &&
|
|
921
|
+
prop !== 'outerWidth' &&
|
|
922
|
+
prop !== 'innerHeight' &&
|
|
923
|
+
prop !== 'outerHeight'
|
|
924
|
+
) {
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
const viewport = page.viewport();
|
|
928
|
+
if (!viewport) return null;
|
|
929
|
+
if (prop === 'innerWidth' || prop === 'outerWidth') return { value: viewport.width };
|
|
930
|
+
if (prop === 'innerHeight' || prop === 'outerHeight') return { value: viewport.height };
|
|
931
|
+
return null;
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
const pageAnswer = (prop: string | symbol): { value: unknown } | null => {
|
|
935
|
+
if (!pageOpts) return null;
|
|
936
|
+
if (prop === 'location') return locationAnswer();
|
|
937
|
+
// `document.body` IS the game's surface while a mount owns one — the single
|
|
938
|
+
// redirect that contains a page-owning game's canvas AND every overlay it
|
|
939
|
+
// will ever append (game-realm-page.ts, reason 1).
|
|
940
|
+
if (pageOpts.role === 'document') {
|
|
941
|
+
const el = surfaceEl();
|
|
942
|
+
if (el && (prop === 'body' || prop === 'documentElement')) return { value: el };
|
|
943
|
+
if (el && prop === 'head') return { value: pageOpts.page.head() };
|
|
944
|
+
if (el && prop === 'title') return { value: pageOpts.page.title };
|
|
945
|
+
if (el && prop === 'currentScript') return { value: pageOpts.page.currentScript };
|
|
946
|
+
if (el && prop === 'activeElement') {
|
|
947
|
+
const active = Reflect.get(real, 'activeElement', real) as Element | null;
|
|
948
|
+
return { value: active && el.contains(active) ? active : el };
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return windowPageAnswer(prop);
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
const pageDocumentMethod = (prop: string | symbol): unknown => {
|
|
955
|
+
if (pageOpts?.role !== 'document' || typeof prop !== 'string') return undefined;
|
|
956
|
+
const surface = surfaceEl();
|
|
957
|
+
if (!surface) return undefined;
|
|
958
|
+
const realDocument = real as unknown as Document;
|
|
959
|
+
if (prop === 'querySelector') {
|
|
960
|
+
return (selector: string) => {
|
|
961
|
+
if (selector.trim().toLowerCase() === 'head') return pageOpts.page.head();
|
|
962
|
+
return surface.matches(selector) ? surface : surface.querySelector(selector);
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
if (prop === 'querySelectorAll') return surface.querySelectorAll.bind(surface);
|
|
966
|
+
if (prop === 'getElementById') {
|
|
967
|
+
return (id: string) => {
|
|
968
|
+
const found = realDocument.getElementById(id);
|
|
969
|
+
return found && surface.contains(found) ? found : null;
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
if (prop === 'getElementsByClassName') return surface.getElementsByClassName.bind(surface);
|
|
973
|
+
if (prop === 'getElementsByTagName') {
|
|
974
|
+
return (name: string) => {
|
|
975
|
+
if (name.toLowerCase() !== 'head') return surface.getElementsByTagName(name);
|
|
976
|
+
const head = pageOpts.page.head();
|
|
977
|
+
if (!head) return [];
|
|
978
|
+
return Object.assign([head], {
|
|
979
|
+
item: (index: number) => (index === 0 ? head : null),
|
|
980
|
+
namedItem: (id: string) => (head.id === id ? head : null),
|
|
981
|
+
});
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
if (prop === 'elementFromPoint' || prop === 'elementsFromPoint') {
|
|
985
|
+
return (x: number, y: number) => {
|
|
986
|
+
const rect = surface.getBoundingClientRect();
|
|
987
|
+
const method = realDocument[prop].bind(realDocument) as (
|
|
988
|
+
x: number,
|
|
989
|
+
y: number,
|
|
990
|
+
) => Element | Element[] | null;
|
|
991
|
+
const result = method(x + rect.left, y + rect.top);
|
|
992
|
+
if (Array.isArray(result)) return result.filter((element) => surface.contains(element));
|
|
993
|
+
return result && surface.contains(result) ? result : null;
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
return undefined;
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
proxy = new Proxy(real, {
|
|
1000
|
+
get(target, prop) {
|
|
1001
|
+
if (prop === 'addEventListener') return gatedAdd;
|
|
1002
|
+
if (prop === 'removeEventListener') return gatedRemove;
|
|
1003
|
+
const answered = pageAnswer(prop);
|
|
1004
|
+
if (answered) return answered.value;
|
|
1005
|
+
const pageMethod = pageDocumentMethod(prop);
|
|
1006
|
+
if (pageMethod) return pageMethod;
|
|
1007
|
+
// S-5: `window.setInterval(…)` is the same loop registration as a bare
|
|
1008
|
+
// `setInterval(…)` by another name — route both to the ONE gate, exactly
|
|
1009
|
+
// as the `set` trap already does for `window.onkeydown` vs
|
|
1010
|
+
// `addEventListener`. Only reachable on the window proxy (the document
|
|
1011
|
+
// proxy has no such properties), and a no-op before the gate is built.
|
|
1012
|
+
if (typeof prop === 'string' && GATED_SCHEDULER_NAMES.has(prop)) {
|
|
1013
|
+
const gate = loopGate;
|
|
1014
|
+
if (gate) return (gate.schedulers as unknown as Record<string, unknown>)[prop];
|
|
1015
|
+
}
|
|
1016
|
+
// Resolve against the real target (this === target), so window/document
|
|
1017
|
+
// getters (innerWidth, body, …) and methods (rAF, setTimeout) work.
|
|
1018
|
+
const value = Reflect.get(target, prop, target);
|
|
1019
|
+
if (typeof value !== 'function') return value;
|
|
1020
|
+
return handlerByWrapper.get(value) ?? realmFunction(value, target);
|
|
1021
|
+
},
|
|
1022
|
+
set(target, prop, value) {
|
|
1023
|
+
// `window.location = '…'` is `location.href = '…'` by another name.
|
|
1024
|
+
if (prop === 'location' && pageOpts) {
|
|
1025
|
+
refuseNavigation(`${pageOpts.role}.location = ${String(value)}`);
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
1028
|
+
if (pageOpts?.role === 'document' && prop === 'title' && pageOpts.page.surface) {
|
|
1029
|
+
pageOpts.page.title = String(value);
|
|
1030
|
+
return true;
|
|
1031
|
+
}
|
|
1032
|
+
// `window.onkeydown = fn` is an input-listener registration by another
|
|
1033
|
+
// name — gate it the same way, reading the gate live per event.
|
|
1034
|
+
const type = gatedHandlerType(prop);
|
|
1035
|
+
if (type !== null && typeof value === 'function') {
|
|
1036
|
+
const inner = value as EventListener;
|
|
1037
|
+
const wrapped = function (this: unknown, e: Event) {
|
|
1038
|
+
if (gate()) return (inner as (this: unknown, e: Event) => unknown).call(this, e);
|
|
1039
|
+
return undefined;
|
|
1040
|
+
};
|
|
1041
|
+
handlerByWrapper.set(wrapped, inner);
|
|
1042
|
+
return Reflect.set(target, prop, wrapped, target);
|
|
1043
|
+
}
|
|
1044
|
+
// `window.onload = fn` is a lifecycle registration by another name — and
|
|
1045
|
+
// writing it through to the REAL window both clobbers any host handler
|
|
1046
|
+
// and never fires (the host's load is long past). Deliver it the same
|
|
1047
|
+
// way the addEventListener door above does.
|
|
1048
|
+
if (prop === 'onload' && typeof value === 'function' && document.readyState === 'complete') {
|
|
1049
|
+
const handler = value as (this: unknown, e: Event) => unknown;
|
|
1050
|
+
window.setTimeout(() => handler.call(proxy, new Event('load')), 0);
|
|
1051
|
+
return true;
|
|
1052
|
+
}
|
|
1053
|
+
// Everything else (including `onresize`) writes straight through.
|
|
1054
|
+
// The receiver MUST be the real target: a native accessor's setter brand-
|
|
1055
|
+
// checks its `this`, and the default trap would hand it the Proxy —
|
|
1056
|
+
// which is why a plain `window.onload = …` used to throw "Illegal
|
|
1057
|
+
// invocation" and no vanilla game could evaluate a single line.
|
|
1058
|
+
return Reflect.set(target, prop, value, target);
|
|
1059
|
+
},
|
|
1060
|
+
}) as T;
|
|
1061
|
+
return proxy;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
interface RealmProxyOptions {
|
|
1065
|
+
readonly document?: () => Document;
|
|
1066
|
+
readonly schedulers?: GatedSchedulers;
|
|
1067
|
+
readonly execute?: <T>(run: () => T) => T;
|
|
1068
|
+
readonly isolateExpandos?: boolean;
|
|
1069
|
+
readonly localStorage?: () => GameRealmStorageView | null;
|
|
1070
|
+
readonly self?: () => Window;
|
|
1071
|
+
readonly sessionStorage?: () => GameRealmStorageView | null;
|
|
1072
|
+
readonly recordInput?: (event: Event, blocked: boolean) => void;
|
|
1073
|
+
readonly role: 'window' | 'document';
|
|
1074
|
+
/** The realm this proxy belongs to — used ONLY to name the dead generation
|
|
1075
|
+
* in the refusal warning below. Omitted by the unit fixtures that build a
|
|
1076
|
+
* proxy over a stand-in target. */
|
|
1077
|
+
readonly realmId?: string;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
interface OwnedListener {
|
|
1081
|
+
readonly type: string;
|
|
1082
|
+
readonly original: EventListenerOrEventListenerObject;
|
|
1083
|
+
readonly actual: EventListener;
|
|
1084
|
+
readonly capture: boolean;
|
|
1085
|
+
readonly abort?: { signal: AbortSignal; handler: EventListener };
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function listenerCapture(options?: boolean | EventListenerOptions): boolean {
|
|
1089
|
+
return typeof options === 'boolean' ? options : (options?.capture ?? false);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/** Add mount ownership, contextual execution, and local schedulers around the
|
|
1093
|
+
* page-aware proxy above. The inner proxy keeps its pane/location semantics;
|
|
1094
|
+
* this outer layer owns everything the mounted game registers through it. */
|
|
1095
|
+
function createOwnedRealmProxy<T extends EventTarget>(
|
|
1096
|
+
real: T,
|
|
1097
|
+
gate: () => boolean,
|
|
1098
|
+
options: RealmProxyOptions,
|
|
1099
|
+
): OwnedProxy<T> {
|
|
1100
|
+
const execute = options.execute ?? ((run) => run());
|
|
1101
|
+
const listeners: OwnedListener[] = [];
|
|
1102
|
+
const inputBlocked = gatedInputBlocker(gate);
|
|
1103
|
+
/**
|
|
1104
|
+
* TERMINAL. `dispose()` used to reclaim what the proxy held and hand it
|
|
1105
|
+
* straight back, fully functional — so a continuation that resolved after
|
|
1106
|
+
* teardown (a pending `import()`, a settled fetch, a React effect landing
|
|
1107
|
+
* late) re-registered a listener into a realm the registry had already
|
|
1108
|
+
* dropped: unreachable by any future `disposeGameRealm`, and UNGATED,
|
|
1109
|
+
* because disposal deletes the id's gate entry and the id-keyed fallback is
|
|
1110
|
+
* always-true. Registration after disposal is refused instead.
|
|
1111
|
+
*/
|
|
1112
|
+
let disposed = false;
|
|
1113
|
+
let refusalReported = false;
|
|
1114
|
+
const refuseAfterDispose = (what: string): void => {
|
|
1115
|
+
if (refusalReported) return;
|
|
1116
|
+
refusalReported = true;
|
|
1117
|
+
const realm = options.realmId ? `"${options.realmId}"` : 'the default realm';
|
|
1118
|
+
// biome-ignore lint/suspicious/noConsole: game code runs in the page, and the page console is the surface `vgai status` reads (same reasoning as `refuseNavigation` above)
|
|
1119
|
+
console.warn(
|
|
1120
|
+
`vgai: a stopped game tried to register ${what} on its ${options.role} after its realm ` +
|
|
1121
|
+
`(${realm}) was reclaimed; refused. Later refusals in this generation are silent.`,
|
|
1122
|
+
);
|
|
1123
|
+
};
|
|
1124
|
+
const expandos = new Map<PropertyKey, PropertyDescriptor>();
|
|
1125
|
+
const properties = new Map<
|
|
1126
|
+
PropertyKey,
|
|
1127
|
+
{ type: string; original: EventListener; actual: EventListener }
|
|
1128
|
+
>();
|
|
1129
|
+
const eventViews = new WeakMap<Event, Event>();
|
|
1130
|
+
let proxy: T;
|
|
1131
|
+
|
|
1132
|
+
const realmValue = (value: unknown): unknown => {
|
|
1133
|
+
if (
|
|
1134
|
+
options.role === 'window' &&
|
|
1135
|
+
options.self &&
|
|
1136
|
+
(value === real || (typeof window !== 'undefined' && value === window))
|
|
1137
|
+
) {
|
|
1138
|
+
return options.self();
|
|
1139
|
+
}
|
|
1140
|
+
if (
|
|
1141
|
+
options.document &&
|
|
1142
|
+
(value === options.document() || (typeof document !== 'undefined' && value === document))
|
|
1143
|
+
) {
|
|
1144
|
+
return options.document();
|
|
1145
|
+
}
|
|
1146
|
+
return value;
|
|
1147
|
+
};
|
|
1148
|
+
|
|
1149
|
+
const eventForRealm = (event: Event): Event => {
|
|
1150
|
+
const found = eventViews.get(event);
|
|
1151
|
+
if (found) return found;
|
|
1152
|
+
const view = new Proxy(event, {
|
|
1153
|
+
get(target, property) {
|
|
1154
|
+
if (property === 'currentTarget') return proxy;
|
|
1155
|
+
if (property === 'target' || property === 'srcElement') {
|
|
1156
|
+
const value = Reflect.get(target, property, target);
|
|
1157
|
+
return value === target.currentTarget ? proxy : realmValue(value);
|
|
1158
|
+
}
|
|
1159
|
+
if (property === 'view') return realmValue(Reflect.get(target, property, target));
|
|
1160
|
+
if (property === 'composedPath') {
|
|
1161
|
+
return () => target.composedPath().map(realmValue);
|
|
1162
|
+
}
|
|
1163
|
+
const value = Reflect.get(target, property, target);
|
|
1164
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
eventViews.set(event, view);
|
|
1168
|
+
return view;
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
const removeOwned = (owned: OwnedListener): void => {
|
|
1172
|
+
const index = listeners.indexOf(owned);
|
|
1173
|
+
if (index !== -1) listeners.splice(index, 1);
|
|
1174
|
+
real.removeEventListener(owned.type, owned.actual, owned.capture);
|
|
1175
|
+
owned.abort?.signal.removeEventListener('abort', owned.abort.handler);
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
const realmAdd = (
|
|
1179
|
+
type: string,
|
|
1180
|
+
listener: EventListenerOrEventListenerObject | null,
|
|
1181
|
+
listenerOptions?: boolean | AddEventListenerOptions,
|
|
1182
|
+
): void => {
|
|
1183
|
+
if (listener === null) return;
|
|
1184
|
+
if (disposed) {
|
|
1185
|
+
refuseAfterDispose(`a "${type}" listener`);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
const capture = listenerCapture(listenerOptions);
|
|
1189
|
+
const signal =
|
|
1190
|
+
listenerOptions && typeof listenerOptions !== 'boolean' ? listenerOptions.signal : undefined;
|
|
1191
|
+
if (signal?.aborted) return;
|
|
1192
|
+
if (
|
|
1193
|
+
listeners.some(
|
|
1194
|
+
(owned) => owned.type === type && owned.original === listener && owned.capture === capture,
|
|
1195
|
+
)
|
|
1196
|
+
)
|
|
1197
|
+
return;
|
|
1198
|
+
|
|
1199
|
+
let owned: OwnedListener;
|
|
1200
|
+
const actual: EventListener = function (this: unknown, event: Event) {
|
|
1201
|
+
if (listenerOptions && typeof listenerOptions !== 'boolean' && listenerOptions.once) {
|
|
1202
|
+
const index = listeners.indexOf(owned);
|
|
1203
|
+
if (index !== -1) listeners.splice(index, 1);
|
|
1204
|
+
owned.abort?.signal.removeEventListener('abort', owned.abort.handler);
|
|
1205
|
+
}
|
|
1206
|
+
if (INPUT_EVENT_TYPES.has(type)) {
|
|
1207
|
+
const blocked = inputBlocked(type, event);
|
|
1208
|
+
options.recordInput?.(event, blocked);
|
|
1209
|
+
if (blocked) return undefined;
|
|
1210
|
+
}
|
|
1211
|
+
const realmEvent = eventForRealm(event);
|
|
1212
|
+
return execute(() => {
|
|
1213
|
+
if (typeof listener === 'function') {
|
|
1214
|
+
return (listener as (this: unknown, event: Event) => unknown).call(proxy, realmEvent);
|
|
1215
|
+
}
|
|
1216
|
+
return listener.handleEvent(realmEvent);
|
|
1217
|
+
});
|
|
1218
|
+
};
|
|
1219
|
+
const abortHandler: EventListener = () => removeOwned(owned);
|
|
1220
|
+
owned = {
|
|
1221
|
+
type,
|
|
1222
|
+
original: listener,
|
|
1223
|
+
actual,
|
|
1224
|
+
capture,
|
|
1225
|
+
...(signal ? { abort: { signal, handler: abortHandler } } : {}),
|
|
1226
|
+
};
|
|
1227
|
+
listeners.push(owned);
|
|
1228
|
+
real.addEventListener(type, actual, listenerOptions);
|
|
1229
|
+
signal?.addEventListener('abort', abortHandler, { once: true });
|
|
1230
|
+
// PAGE LIFECYCLE CATCH-UP. `load`/`DOMContentLoaded` fired on the HOST
|
|
1231
|
+
// page long before a transplanted game evaluated, so this registration
|
|
1232
|
+
// can never fire from the real target — and a vanilla game that boots in
|
|
1233
|
+
// one of them never constructs (simcity's `window.onload = () => new
|
|
1234
|
+
// Game()`, tanks' DOMContentLoaded: zero errors, zero renders, measured
|
|
1235
|
+
// 2026-08-28). The realm's page IS loaded once game code runs (the DOM
|
|
1236
|
+
// transplant precedes entry evaluation), so deliver ONE async dispatch
|
|
1237
|
+
// through the same owned pipeline every real event takes.
|
|
1238
|
+
if (
|
|
1239
|
+
(type === 'load' || type === 'DOMContentLoaded' || type === 'pageshow') &&
|
|
1240
|
+
document.readyState === 'complete'
|
|
1241
|
+
) {
|
|
1242
|
+
window.setTimeout(() => {
|
|
1243
|
+
if (!disposed && listeners.includes(owned)) actual.call(real, new Event(type));
|
|
1244
|
+
}, 0);
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
const realmRemove = (
|
|
1249
|
+
type: string,
|
|
1250
|
+
listener: EventListenerOrEventListenerObject | null,
|
|
1251
|
+
listenerOptions?: boolean | EventListenerOptions,
|
|
1252
|
+
): void => {
|
|
1253
|
+
if (listener === null) return;
|
|
1254
|
+
const capture = listenerCapture(listenerOptions);
|
|
1255
|
+
const owned = listeners.find(
|
|
1256
|
+
(candidate) =>
|
|
1257
|
+
candidate.type === type && candidate.original === listener && candidate.capture === capture,
|
|
1258
|
+
);
|
|
1259
|
+
if (owned) removeOwned(owned);
|
|
1260
|
+
else real.removeEventListener(type, listener, listenerOptions);
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
const handlerType = (prop: PropertyKey): string | null =>
|
|
1264
|
+
typeof prop === 'string' && prop.startsWith('on') && prop.length > 2 ? prop.slice(2) : null;
|
|
1265
|
+
|
|
1266
|
+
proxy = new Proxy(real, {
|
|
1267
|
+
get(target, prop) {
|
|
1268
|
+
if (prop === 'addEventListener') return realmAdd;
|
|
1269
|
+
if (prop === 'removeEventListener') return realmRemove;
|
|
1270
|
+
const property = properties.get(prop);
|
|
1271
|
+
if (property) return property.original;
|
|
1272
|
+
if (options.self) {
|
|
1273
|
+
if (
|
|
1274
|
+
options.role === 'window' &&
|
|
1275
|
+
(prop === 'window' ||
|
|
1276
|
+
prop === 'self' ||
|
|
1277
|
+
prop === 'frames' ||
|
|
1278
|
+
prop === 'top' ||
|
|
1279
|
+
prop === 'parent')
|
|
1280
|
+
) {
|
|
1281
|
+
return options.self();
|
|
1282
|
+
}
|
|
1283
|
+
if (options.role === 'document' && prop === 'defaultView') return options.self();
|
|
1284
|
+
}
|
|
1285
|
+
if (options.role === 'window' && prop === 'document' && options.document) {
|
|
1286
|
+
return options.document();
|
|
1287
|
+
}
|
|
1288
|
+
if (options.role === 'window' && prop === 'globalThis' && options.self) {
|
|
1289
|
+
return options.self();
|
|
1290
|
+
}
|
|
1291
|
+
if (options.role === 'window' && prop === 'localStorage' && options.localStorage) {
|
|
1292
|
+
return options.localStorage()?.storage;
|
|
1293
|
+
}
|
|
1294
|
+
if (options.role === 'window' && prop === 'sessionStorage' && options.sessionStorage) {
|
|
1295
|
+
return options.sessionStorage()?.storage;
|
|
1296
|
+
}
|
|
1297
|
+
if (typeof prop === 'string' && GATED_SCHEDULER_NAMES.has(prop) && options.schedulers) {
|
|
1298
|
+
return (options.schedulers as unknown as Record<string, unknown>)[prop];
|
|
1299
|
+
}
|
|
1300
|
+
const expando = expandos.get(prop);
|
|
1301
|
+
if (expando) return expando.get ? expando.get.call(proxy) : expando.value;
|
|
1302
|
+
const value = Reflect.get(target, prop, target);
|
|
1303
|
+
if (typeof value !== 'function') return value;
|
|
1304
|
+
return realmFunction(value, target);
|
|
1305
|
+
},
|
|
1306
|
+
set(target, prop, value) {
|
|
1307
|
+
// The page-aware inner proxy owns navigation refusal.
|
|
1308
|
+
if (prop === 'location') return Reflect.set(target, prop, value, target);
|
|
1309
|
+
if (
|
|
1310
|
+
options.role === 'window' &&
|
|
1311
|
+
(prop === 'document' ||
|
|
1312
|
+
prop === 'globalThis' ||
|
|
1313
|
+
prop === 'localStorage' ||
|
|
1314
|
+
prop === 'sessionStorage')
|
|
1315
|
+
) {
|
|
1316
|
+
return true;
|
|
1317
|
+
}
|
|
1318
|
+
const type = handlerType(prop);
|
|
1319
|
+
if (type) {
|
|
1320
|
+
if (disposed) {
|
|
1321
|
+
// The other registration door (`window.onkeydown = …`), refused for
|
|
1322
|
+
// the same reason `realmAdd` refuses — see `refuseAfterDispose`.
|
|
1323
|
+
refuseAfterDispose(`an "${String(prop)}" handler`);
|
|
1324
|
+
return true;
|
|
1325
|
+
}
|
|
1326
|
+
const previous = properties.get(prop);
|
|
1327
|
+
if (previous) {
|
|
1328
|
+
real.removeEventListener(previous.type, previous.actual);
|
|
1329
|
+
properties.delete(prop);
|
|
1330
|
+
}
|
|
1331
|
+
if (typeof value !== 'function') return true;
|
|
1332
|
+
const original = value as EventListener;
|
|
1333
|
+
// Same lifecycle catch-up as realmAdd above — `window.onload = fn` is
|
|
1334
|
+
// the registration door simcity actually uses.
|
|
1335
|
+
const lifecycleCatchUp =
|
|
1336
|
+
(type === 'load' || type === 'DOMContentLoaded' || type === 'pageshow') &&
|
|
1337
|
+
document.readyState === 'complete';
|
|
1338
|
+
const actual: EventListener = function (this: unknown, event: Event) {
|
|
1339
|
+
if (INPUT_EVENT_TYPES.has(type)) {
|
|
1340
|
+
const blocked = inputBlocked(type, event);
|
|
1341
|
+
options.recordInput?.(event, blocked);
|
|
1342
|
+
if (blocked) return undefined;
|
|
1343
|
+
}
|
|
1344
|
+
const result = execute(() =>
|
|
1345
|
+
(original as (this: unknown, event: Event) => unknown).call(
|
|
1346
|
+
proxy,
|
|
1347
|
+
eventForRealm(event),
|
|
1348
|
+
),
|
|
1349
|
+
);
|
|
1350
|
+
if (result === false) event.preventDefault();
|
|
1351
|
+
return result;
|
|
1352
|
+
};
|
|
1353
|
+
properties.set(prop, { type, original, actual });
|
|
1354
|
+
real.addEventListener(type, actual);
|
|
1355
|
+
if (lifecycleCatchUp) {
|
|
1356
|
+
window.setTimeout(() => {
|
|
1357
|
+
if (!disposed && properties.get(prop)?.actual === actual) {
|
|
1358
|
+
actual.call(real, new Event(type));
|
|
1359
|
+
}
|
|
1360
|
+
}, 0);
|
|
1361
|
+
}
|
|
1362
|
+
return true;
|
|
1363
|
+
}
|
|
1364
|
+
if (options.isolateExpandos) {
|
|
1365
|
+
expandos.set(prop, {
|
|
1366
|
+
configurable: true,
|
|
1367
|
+
enumerable: true,
|
|
1368
|
+
value,
|
|
1369
|
+
writable: true,
|
|
1370
|
+
});
|
|
1371
|
+
return true;
|
|
1372
|
+
}
|
|
1373
|
+
return Reflect.set(target, prop, value, target);
|
|
1374
|
+
},
|
|
1375
|
+
defineProperty(target, prop, descriptor) {
|
|
1376
|
+
if (!options.isolateExpandos) return Reflect.defineProperty(target, prop, descriptor);
|
|
1377
|
+
if (Reflect.getOwnPropertyDescriptor(target, prop)?.configurable === false) return false;
|
|
1378
|
+
// A proxy may not report a non-configurable property that does not also
|
|
1379
|
+
// exist on its target. Realm expandos deliberately live off-target, so
|
|
1380
|
+
// their reflection descriptor must remain configurable.
|
|
1381
|
+
expandos.set(prop, {
|
|
1382
|
+
...descriptor,
|
|
1383
|
+
configurable: true,
|
|
1384
|
+
enumerable: descriptor.enumerable ?? true,
|
|
1385
|
+
});
|
|
1386
|
+
return true;
|
|
1387
|
+
},
|
|
1388
|
+
deleteProperty(target, prop) {
|
|
1389
|
+
if (expandos.delete(prop)) return true;
|
|
1390
|
+
if (options.isolateExpandos) {
|
|
1391
|
+
return Reflect.getOwnPropertyDescriptor(target, prop)?.configurable !== false;
|
|
1392
|
+
}
|
|
1393
|
+
return Reflect.deleteProperty(target, prop);
|
|
1394
|
+
},
|
|
1395
|
+
has(target, prop) {
|
|
1396
|
+
return expandos.has(prop) || Reflect.has(target, prop);
|
|
1397
|
+
},
|
|
1398
|
+
ownKeys(target) {
|
|
1399
|
+
const keys = new Set<string | symbol>(Reflect.ownKeys(target));
|
|
1400
|
+
for (const key of expandos.keys()) {
|
|
1401
|
+
if (typeof key !== 'number') keys.add(key);
|
|
1402
|
+
}
|
|
1403
|
+
return [...keys];
|
|
1404
|
+
},
|
|
1405
|
+
getOwnPropertyDescriptor(target, prop) {
|
|
1406
|
+
const native = Reflect.getOwnPropertyDescriptor(target, prop);
|
|
1407
|
+
if (native?.configurable === false) return native;
|
|
1408
|
+
return expandos.get(prop) ?? native;
|
|
1409
|
+
},
|
|
1410
|
+
}) as T;
|
|
1411
|
+
|
|
1412
|
+
return {
|
|
1413
|
+
proxy,
|
|
1414
|
+
count: () => listeners.length + properties.size,
|
|
1415
|
+
expandoCount: () => expandos.size,
|
|
1416
|
+
dispose() {
|
|
1417
|
+
// RELEASE WHAT IS STILL HELD before the listeners go: a press this
|
|
1418
|
+
// realm passed whose release never came (Stop with W down) would
|
|
1419
|
+
// otherwise stay held in the game's own input state for good.
|
|
1420
|
+
for (const release of inputBlocked.outstandingReleases()) {
|
|
1421
|
+
for (const owned of [...listeners]) {
|
|
1422
|
+
if (owned.type === release.type) owned.actual.call(proxy, release);
|
|
1423
|
+
}
|
|
1424
|
+
for (const property of properties.values()) {
|
|
1425
|
+
if (property.type === release.type) property.actual.call(proxy, release);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
disposed = true;
|
|
1429
|
+
const count = listeners.length + properties.size;
|
|
1430
|
+
for (const owned of [...listeners]) removeOwned(owned);
|
|
1431
|
+
for (const property of properties.values()) {
|
|
1432
|
+
real.removeEventListener(property.type, property.actual);
|
|
1433
|
+
}
|
|
1434
|
+
properties.clear();
|
|
1435
|
+
expandos.clear();
|
|
1436
|
+
return count;
|
|
1437
|
+
},
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
let installed = false;
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* Publish (or RE-publish) the DEFAULT realm's proxies under the names the
|
|
1445
|
+
* module prelude falls back to, and re-point `gameLoopGate()` at that realm's
|
|
1446
|
+
* loop gate.
|
|
1447
|
+
*
|
|
1448
|
+
* Called twice: once at install, and again whenever `disposeGameRealm`
|
|
1449
|
+
* reclaims the default realm. These are captured VALUES, not lookups, so a
|
|
1450
|
+
* default realm that is reclaimed and rebuilt must refresh them — otherwise the
|
|
1451
|
+
* names, and every ingest caller holding `gameLoopGate()`, keep addressing the
|
|
1452
|
+
* generation that was just closed.
|
|
1453
|
+
*
|
|
1454
|
+
* No-op outside a browser (`gameRealmForMountId` would build proxies over
|
|
1455
|
+
* globals that do not exist), which is also why {@link disposeGameRealm} can
|
|
1456
|
+
* call it unconditionally from Node-side unit fixtures.
|
|
1457
|
+
*/
|
|
1458
|
+
function publishDefaultRealmGlobals(): void {
|
|
1459
|
+
if (!installed) return;
|
|
1460
|
+
const g = globalThis as unknown as Record<string, unknown>;
|
|
1461
|
+
const base = gameRealmForMountId();
|
|
1462
|
+
loopGate = defaultLoopGate;
|
|
1463
|
+
g['__vgaiGameTimers'] = base.timers;
|
|
1464
|
+
g['__vgaiGameWindow'] = base.window;
|
|
1465
|
+
g['__vgaiGameDocument'] = base.document;
|
|
1466
|
+
g['__vgaiGameConsole'] = base.console;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Install the game-realm resolver on the host `globalThis` so the prelude
|
|
1471
|
+
* prepended to project modules resolves to them. Idempotent; no-op outside a
|
|
1472
|
+
* browser (the dev server never calls this).
|
|
1473
|
+
*/
|
|
1474
|
+
export function installGatedGameGlobals(): void {
|
|
1475
|
+
if (installed || typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
1476
|
+
installed = true;
|
|
1477
|
+
const g = globalThis as unknown as Record<string, unknown>;
|
|
1478
|
+
// The creation-site recorder is a game-realm global of exactly the
|
|
1479
|
+
// same kind as the ones below — published for a serve-time prelude to call,
|
|
1480
|
+
// installed once, browser-only. One owner, one lifecycle; see
|
|
1481
|
+
// `creation-site-registry.ts`'s header for why the WeakMap behind it is never
|
|
1482
|
+
// torn down.
|
|
1483
|
+
installCreationSiteRecorder();
|
|
1484
|
+
// The transform bakes the mount id into the prelude. Resolve that identity
|
|
1485
|
+
// directly; parsing it as a URL silently selected the default realm.
|
|
1486
|
+
//
|
|
1487
|
+
// Resolving is ALSO the evaluation marker. The prelude runs this at the top
|
|
1488
|
+
// of every project module body, so the synchronous run that follows —
|
|
1489
|
+
// including calls INTO shared bare deps, whose own module text carries no
|
|
1490
|
+
// prelude and reaches the NATIVE console — is this realm's code executing.
|
|
1491
|
+
// Without the marker, a library warn fired during graph evaluation (racing's
|
|
1492
|
+
// module-scope Supabase client is the measured case) reached the console
|
|
1493
|
+
// capture with no realm on the stack and was ledgered as editor-owned.
|
|
1494
|
+
// The microtask pop bounds the window: it runs when the current synchronous
|
|
1495
|
+
// evaluation chunk yields, before the mount's own import().then
|
|
1496
|
+
// continuation (queued later) can run editor code.
|
|
1497
|
+
g['__vgaiGameModuleUrls'] = loadedGameRealmModuleUrls;
|
|
1498
|
+
g['__vgaiGameRealm'] = (mountId: string): GameRealm => {
|
|
1499
|
+
executingRealms.push(mountId);
|
|
1500
|
+
queueMicrotask(() => {
|
|
1501
|
+
const at = executingRealms.lastIndexOf(mountId);
|
|
1502
|
+
if (at >= 0) executingRealms.splice(at, 1);
|
|
1503
|
+
});
|
|
1504
|
+
return gameRealmForMountId(mountId);
|
|
1505
|
+
};
|
|
1506
|
+
// The DEFAULT realm's globals stay published under their original names, and
|
|
1507
|
+
// the prelude still falls back to them. That is what keeps every non-isolated
|
|
1508
|
+
// surface — an in-page transpile, an ingest fixture, and any module served
|
|
1509
|
+
// without a mount id — on exactly today's behaviour.
|
|
1510
|
+
publishDefaultRealmGlobals();
|
|
1511
|
+
}
|