Skip to content

Commit aefcca1

Browse files
committed
Add Engine V2 Custom Extensions hook runtime boundary - PR_26152_266-engine-v2-custom-extensions-hook-runtime
1 parent e798800 commit aefcca1

7 files changed

Lines changed: 467 additions & 12 deletions

File tree

GameFoundryStudio/assets/js/tools-page-accordions.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,10 @@
165165
group: "Development & System",
166166
tools: [
167167
{
168-
title: "Code Studio",
168+
title: "Custom Extensions",
169169
href: "../tools/code-studio.html",
170170
image: "assets/images/tools/code-studio.png",
171-
description: "Write code, extend systems and build custom logic.",
171+
description: "Register approved Engine V2 extension hooks and creator-private custom logic.",
172172
role: "Foundry Bot",
173173
mascot: "foundry-bot",
174174
theme: "bot"

GameFoundryStudio/assets/partials/header-nav.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
<a data-toolbox-menu-group-label data-route="tools" href="tools/index.html" aria-haspopup="true">Objects &#9656;</a>
1818
<div class="sub-menu sub-menu--nested" data-toolbox-submenu aria-label="Objects tools">
1919
<a data-nav-link data-toolbox-menu-item data-route="animation-studio" href="tools/animation-studio.html">Animated Sprite</a>
20+
<a data-nav-link data-toolbox-menu-item data-route="code-studio" href="tools/code-studio.html">Custom Extensions</a>
2021
<a data-nav-link data-toolbox-menu-item data-route="asset-studio" href="tools/asset-studio.html">Sprite</a>
21-
<a data-nav-link data-toolbox-menu-item data-route="code-studio" href="tools/code-studio.html">UI</a>
2222
<a data-nav-link data-toolbox-menu-item data-route="object-vector-studio" href="tools/object-vector-studio.html">Vector</a>
2323
</div>
2424
</div>
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# PR_26152_266 Engine V2 Custom Extensions Hook Runtime
2+
3+
## Scope
4+
5+
- Renamed the user-facing Code Studio concept to Custom Extensions.
6+
- Added Engine V2 Custom Extensions hook registration and dispatch context runtime.
7+
- Kept internal `code-studio` paths, asset names, and technical contract names unchanged.
8+
- No Marketplace, Publishing, Toolbox rebuild, or sample work.
9+
10+
## Runtime
11+
12+
- Supported hook names:
13+
- `onProjectLoad`
14+
- `onSceneStart`
15+
- `onTick`
16+
- `onCollision`
17+
- `onTrigger`
18+
- `onAction`
19+
- `onSceneEnd`
20+
21+
- Custom Extensions must use `extensionMode: "enhance"`.
22+
- Replace-mode extensions reject visibly.
23+
- Hook registrations reject forbidden executable/source fields.
24+
- Hook registrations reject forbidden capabilities:
25+
- `window`
26+
- `document`
27+
- `localStorage`
28+
- `sessionStorage`
29+
- `network`
30+
- `filesystem`
31+
- `engineInternals`
32+
- `eval`
33+
- `newFunction`
34+
- `globalMutation`
35+
36+
## Privacy And Publish Boundary
37+
38+
- Approved Custom Extensions may remain publish eligible.
39+
- Unapproved Custom Extensions are marked creator-private.
40+
- Any unapproved Custom Extension blocks publish eligibility.
41+
42+
## Files
43+
44+
- `src/engine/runtime/engineV2CustomExtensionsHookRuntime.js`
45+
- `tests/engine/EngineV2CustomExtensionsHookRuntime.test.mjs`
46+
- `tools/code-studio.html`
47+
- `tools/tools-page-accordions.js`
48+
- `GameFoundryStudio/assets/js/tools-page-accordions.js`
49+
- `GameFoundryStudio/assets/partials/header-nav.html`
50+
51+
## Validation
52+
53+
- PASS: `node tests/engine/EngineV2CustomExtensionsHookRuntime.test.mjs`
54+
55+
## Notes
56+
57+
- The runtime does not use `eval`.
58+
- The runtime does not use `new Function`.
59+
- Dispatch creates limited hook contexts only; it does not expose forbidden globals or engine internals.
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
/*
2+
Toolbox Aid
3+
David Quesenberry
4+
06/03/2026
5+
engineV2CustomExtensionsHookRuntime.js
6+
*/
7+
8+
export const ENGINE_V2_CUSTOM_EXTENSION_HOOKS = Object.freeze({
9+
ON_PROJECT_LOAD: "onProjectLoad",
10+
ON_SCENE_START: "onSceneStart",
11+
ON_TICK: "onTick",
12+
ON_COLLISION: "onCollision",
13+
ON_TRIGGER: "onTrigger",
14+
ON_ACTION: "onAction",
15+
ON_SCENE_END: "onSceneEnd",
16+
});
17+
18+
export const ENGINE_V2_CUSTOM_EXTENSION_HOOK_LIST = Object.freeze(Object.values(ENGINE_V2_CUSTOM_EXTENSION_HOOKS));
19+
20+
export const ENGINE_V2_CUSTOM_EXTENSION_APPROVAL_STATUS = Object.freeze({
21+
APPROVED: "approved",
22+
UNAPPROVED: "unapproved",
23+
});
24+
25+
export const ENGINE_V2_CUSTOM_EXTENSION_ALLOWED_CONTEXT_KEYS = Object.freeze([
26+
"projectId",
27+
"sceneId",
28+
"frameId",
29+
"deltaMs",
30+
"runtimeStateSummary",
31+
"collision",
32+
"trigger",
33+
"action",
34+
"metadata",
35+
]);
36+
37+
export const ENGINE_V2_CUSTOM_EXTENSION_FORBIDDEN_CAPABILITIES = Object.freeze([
38+
"window",
39+
"document",
40+
"localStorage",
41+
"sessionStorage",
42+
"network",
43+
"filesystem",
44+
"engineInternals",
45+
"eval",
46+
"newFunction",
47+
"globalMutation",
48+
]);
49+
50+
export const ENGINE_V2_CUSTOM_EXTENSION_FORBIDDEN_FIELDS = Object.freeze([
51+
"code",
52+
"source",
53+
"sourceCode",
54+
"script",
55+
"functionBody",
56+
"handler",
57+
"window",
58+
"document",
59+
"localStorage",
60+
"sessionStorage",
61+
"network",
62+
"filesystem",
63+
"engineInternals",
64+
]);
65+
66+
export const ENGINE_V2_CUSTOM_EXTENSION_ERRORS = Object.freeze({
67+
DEFINITIONS_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_DEFINITIONS_INVALID",
68+
DEFINITION_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_DEFINITION_INVALID",
69+
HOOK_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_HOOK_INVALID",
70+
MODE_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_MODE_INVALID",
71+
CONTEXT_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_CONTEXT_INVALID",
72+
CAPABILITY_FORBIDDEN: "ENGINE_V2_CUSTOM_EXTENSION_CAPABILITY_FORBIDDEN",
73+
FIELD_FORBIDDEN: "ENGINE_V2_CUSTOM_EXTENSION_FIELD_FORBIDDEN",
74+
RUNTIME_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_RUNTIME_INVALID",
75+
HOOK_NAME_INVALID: "ENGINE_V2_CUSTOM_EXTENSION_HOOK_NAME_INVALID",
76+
});
77+
78+
const APPROVAL_STATUS_LIST = Object.freeze(Object.values(ENGINE_V2_CUSTOM_EXTENSION_APPROVAL_STATUS));
79+
80+
export function registerEngineV2CustomExtensionHooks({ extensionDefinitions }) {
81+
const errors = [];
82+
83+
if (!Array.isArray(extensionDefinitions)) {
84+
errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.DEFINITIONS_INVALID, "Custom Extensions hook runtime requires extensionDefinitions array.", "extensionDefinitions"));
85+
return createCustomExtensionRuntimeResult({ runtime: null, registeredHooks: [], publishEligibility: null, errors });
86+
}
87+
88+
extensionDefinitions.forEach((definition, index) => {
89+
validateExtensionDefinition(definition, `extensionDefinitions[${index}]`).forEach((error) => errors.push(error));
90+
});
91+
92+
if (errors.length > 0) {
93+
return createCustomExtensionRuntimeResult({ runtime: null, registeredHooks: [], publishEligibility: null, errors });
94+
}
95+
96+
const registeredHooks = extensionDefinitions.flatMap((definition) => {
97+
const approved = definition.approvalStatus === ENGINE_V2_CUSTOM_EXTENSION_APPROVAL_STATUS.APPROVED;
98+
99+
return definition.hookRegistrations.map((registration) => Object.freeze({
100+
extensionId: definition.extensionId,
101+
displayName: definition.displayName,
102+
hookName: registration.hookName,
103+
extensionMode: registration.extensionMode,
104+
allowedContextKeys: Object.freeze([...registration.allowedContextKeys]),
105+
approved,
106+
creatorPrivate: !approved,
107+
publishEligible: approved,
108+
}));
109+
});
110+
const publishEligible = registeredHooks.every((hook) => hook.publishEligible);
111+
const runtime = Object.freeze({
112+
registeredHooks: Object.freeze(registeredHooks),
113+
publishEligibility: Object.freeze({
114+
eligible: publishEligible,
115+
blockedByExtensionIds: Object.freeze(registeredHooks.filter((hook) => !hook.publishEligible).map((hook) => hook.extensionId)),
116+
}),
117+
});
118+
119+
return createCustomExtensionRuntimeResult({
120+
runtime,
121+
registeredHooks,
122+
publishEligibility: runtime.publishEligibility,
123+
errors,
124+
});
125+
}
126+
127+
export function dispatchEngineV2CustomExtensionHook({ runtime, hookName, runtimeContext }) {
128+
const errors = [];
129+
130+
if (!isRecord(runtime) || !Array.isArray(runtime.registeredHooks)) {
131+
errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.RUNTIME_INVALID, "Custom Extensions dispatch requires registered runtime.", "runtime"));
132+
}
133+
134+
if (!ENGINE_V2_CUSTOM_EXTENSION_HOOK_LIST.includes(hookName)) {
135+
errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.HOOK_NAME_INVALID, "Custom Extensions dispatch requires approved hookName.", "hookName"));
136+
}
137+
138+
if (!isRecord(runtimeContext)) {
139+
errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.CONTEXT_INVALID, "Custom Extensions dispatch requires runtimeContext object.", "runtimeContext"));
140+
}
141+
142+
if (errors.length > 0) {
143+
return createCustomExtensionDispatchResult({ hookInvocations: [], errors });
144+
}
145+
146+
const hookInvocations = runtime.registeredHooks
147+
.filter((hook) => hook.hookName === hookName)
148+
.map((hook) => Object.freeze({
149+
extensionId: hook.extensionId,
150+
hookName: hook.hookName,
151+
context: createLimitedContext(runtimeContext, hook.allowedContextKeys),
152+
}));
153+
154+
return createCustomExtensionDispatchResult({ hookInvocations, errors });
155+
}
156+
157+
function validateExtensionDefinition(definition, path) {
158+
const errors = [];
159+
160+
if (!isRecord(definition) || !hasNonEmptyString(definition.extensionId) || !hasNonEmptyString(definition.displayName) || !APPROVAL_STATUS_LIST.includes(definition.approvalStatus) || !Array.isArray(definition.hookRegistrations)) {
161+
errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.DEFINITION_INVALID, "Custom Extension definition requires extensionId, displayName, approvalStatus, and hookRegistrations.", path));
162+
return errors;
163+
}
164+
165+
validateNoForbiddenFields(definition, path).forEach((error) => errors.push(error));
166+
validateRequestedCapabilities(definition.requestedCapabilities || [], `${path}.requestedCapabilities`).forEach((error) => errors.push(error));
167+
168+
if (definition.hookRegistrations.length === 0) {
169+
errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.DEFINITION_INVALID, "Custom Extension definition requires at least one hook registration.", `${path}.hookRegistrations`));
170+
}
171+
172+
definition.hookRegistrations.forEach((registration, index) => {
173+
validateHookRegistration(registration, `${path}.hookRegistrations[${index}]`).forEach((error) => errors.push(error));
174+
});
175+
176+
return errors;
177+
}
178+
179+
function validateHookRegistration(registration, path) {
180+
const errors = [];
181+
182+
if (!isRecord(registration) || !ENGINE_V2_CUSTOM_EXTENSION_HOOK_LIST.includes(registration.hookName) || !Array.isArray(registration.allowedContextKeys)) {
183+
errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.HOOK_INVALID, "Hook registration requires approved hookName and allowedContextKeys.", path));
184+
return errors;
185+
}
186+
187+
if (registration.extensionMode !== "enhance") {
188+
errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.MODE_INVALID, "Custom Extensions must enhance Engine V2 behavior, not replace it.", `${path}.extensionMode`));
189+
}
190+
191+
registration.allowedContextKeys.forEach((key, index) => {
192+
if (!ENGINE_V2_CUSTOM_EXTENSION_ALLOWED_CONTEXT_KEYS.includes(key)) {
193+
errors.push(createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.CONTEXT_INVALID, "Hook allowedContextKeys must use the approved limited context list.", `${path}.allowedContextKeys[${index}]`));
194+
}
195+
});
196+
197+
validateNoForbiddenFields(registration, path).forEach((error) => errors.push(error));
198+
validateRequestedCapabilities(registration.requestedCapabilities || [], `${path}.requestedCapabilities`).forEach((error) => errors.push(error));
199+
200+
return errors;
201+
}
202+
203+
function validateNoForbiddenFields(value, path) {
204+
return ENGINE_V2_CUSTOM_EXTENSION_FORBIDDEN_FIELDS
205+
.filter((field) => Object.hasOwn(value, field))
206+
.map((field) => createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.FIELD_FORBIDDEN, "Custom Extensions registration cannot include executable source or direct forbidden access fields.", `${path}.${field}`));
207+
}
208+
209+
function validateRequestedCapabilities(capabilities, path) {
210+
if (!Array.isArray(capabilities)) {
211+
return [createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.CAPABILITY_FORBIDDEN, "Custom Extensions requestedCapabilities must be an array when provided.", path)];
212+
}
213+
214+
return capabilities
215+
.filter((capability) => ENGINE_V2_CUSTOM_EXTENSION_FORBIDDEN_CAPABILITIES.includes(capability))
216+
.map((capability) => createCustomExtensionError(ENGINE_V2_CUSTOM_EXTENSION_ERRORS.CAPABILITY_FORBIDDEN, "Custom Extensions cannot request forbidden runtime capability.", `${path}.${capability}`));
217+
}
218+
219+
function createLimitedContext(runtimeContext, allowedContextKeys) {
220+
const limitedContext = {};
221+
222+
allowedContextKeys.forEach((key) => {
223+
if (Object.hasOwn(runtimeContext, key)) {
224+
limitedContext[key] = freezeJsonClone(runtimeContext[key]);
225+
}
226+
});
227+
228+
return Object.freeze(limitedContext);
229+
}
230+
231+
function createCustomExtensionRuntimeResult({ runtime, registeredHooks, publishEligibility, errors }) {
232+
return Object.freeze({
233+
valid: errors.length === 0,
234+
runtime,
235+
registeredHooks: Object.freeze(registeredHooks),
236+
publishEligibility,
237+
errors: Object.freeze(errors),
238+
});
239+
}
240+
241+
function createCustomExtensionDispatchResult({ hookInvocations, errors }) {
242+
return Object.freeze({
243+
valid: errors.length === 0,
244+
hookInvocations: Object.freeze(hookInvocations),
245+
errors: Object.freeze(errors),
246+
});
247+
}
248+
249+
function createCustomExtensionError(code, message, path) {
250+
return Object.freeze({ code, message, path });
251+
}
252+
253+
function freezeJsonClone(value) {
254+
return Object.freeze(JSON.parse(JSON.stringify(value)));
255+
}
256+
257+
function isRecord(value) {
258+
return value !== null && typeof value === "object" && !Array.isArray(value);
259+
}
260+
261+
function hasNonEmptyString(value) {
262+
return typeof value === "string" && value.trim().length > 0;
263+
}

0 commit comments

Comments
 (0)