@waica/cli 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +42 -37
  2. package/dist/editor/assets/{cssMode-BO8FrG9g.js → cssMode-DfLN2qUF.js} +1 -1
  3. package/dist/editor/assets/{freemarker2-C6rCz24J.js → freemarker2-BM-wWcXR.js} +1 -1
  4. package/dist/editor/assets/{handlebars-MZdu9Qw_.js → handlebars-C68SV5M9.js} +1 -1
  5. package/dist/editor/assets/{html-BrjX1TJ1.js → html-dQgLbIki.js} +1 -1
  6. package/dist/editor/assets/{htmlMode-cEvpMwiW.js → htmlMode-Dx91fUdd.js} +1 -1
  7. package/dist/editor/assets/{index-eTEBznqW.js → index-592ABIaz.js} +7154 -3186
  8. package/dist/editor/assets/{index-Btzc6zxr.css → index-Cy5pmBhN.css} +1 -1
  9. package/dist/editor/assets/{javascript-DHUgp9_L.js → javascript-BmsSq5ka.js} +1 -1
  10. package/dist/editor/assets/{jsonMode-D0PmIuaM.js → jsonMode-CWoPtX2M.js} +1 -1
  11. package/dist/editor/assets/{liquid-UM67NCct.js → liquid-DwnJlNJZ.js} +1 -1
  12. package/dist/editor/assets/{mdx-D3xXLqrP.js → mdx-DaLkvlR1.js} +1 -1
  13. package/dist/editor/assets/{python-BhLrcAva.js → python-Cs5X5zFp.js} +1 -1
  14. package/dist/editor/assets/{razor-DcG373mQ.js → razor-PTwWoRk8.js} +1 -1
  15. package/dist/editor/assets/{tsMode-ByTYaf45.js → tsMode-BqUQdgsR.js} +1 -1
  16. package/dist/editor/assets/{typescript-DfJlvcTN.js → typescript-BsJ7r2RJ.js} +1 -1
  17. package/dist/editor/assets/{xml-22dL0oCq.js → xml-DyyoPey4.js} +1 -1
  18. package/dist/editor/assets/{yaml-mgCu6yV6.js → yaml-Cjqtlp29.js} +1 -1
  19. package/dist/editor/index.html +2 -2
  20. package/dist/mcp/component-metadata.js +23 -0
  21. package/dist/mcp/introspection.js +4 -14
  22. package/dist/mcp/node_modules/@waica/archetype-platformer/dist/prefabs.js +7 -1
  23. package/dist/mcp/node_modules/@waica/archetype-platformer/dist/registry-data.js +3 -1
  24. package/dist/mcp/node_modules/@waica/archetype-platformer/package.json +3 -3
  25. package/dist/mcp/node_modules/@waica/behaviors/dist/chaser.d.ts +1 -0
  26. package/dist/mcp/node_modules/@waica/behaviors/dist/chaser.js +1 -0
  27. package/dist/mcp/node_modules/@waica/behaviors/dist/collectible.d.ts +1 -0
  28. package/dist/mcp/node_modules/@waica/behaviors/dist/collectible.js +1 -1
  29. package/dist/mcp/node_modules/@waica/behaviors/dist/hazard.d.ts +42 -1
  30. package/dist/mcp/node_modules/@waica/behaviors/dist/hazard.js +49 -3
  31. package/dist/mcp/node_modules/@waica/behaviors/dist/health.d.ts +82 -0
  32. package/dist/mcp/node_modules/@waica/behaviors/dist/health.js +147 -0
  33. package/dist/mcp/node_modules/@waica/behaviors/dist/index.d.ts +2 -0
  34. package/dist/mcp/node_modules/@waica/behaviors/dist/index.js +2 -0
  35. package/dist/mcp/node_modules/@waica/behaviors/dist/lifetime.d.ts +1 -0
  36. package/dist/mcp/node_modules/@waica/behaviors/dist/lifetime.js +1 -0
  37. package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.d.ts +30 -0
  38. package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.js +38 -0
  39. package/dist/mcp/node_modules/@waica/behaviors/dist/patrol.d.ts +1 -0
  40. package/dist/mcp/node_modules/@waica/behaviors/dist/patrol.js +1 -0
  41. package/dist/mcp/node_modules/@waica/behaviors/dist/platformer-motor.d.ts +1 -0
  42. package/dist/mcp/node_modules/@waica/behaviors/dist/platformer-motor.js +10 -0
  43. package/dist/mcp/node_modules/@waica/behaviors/dist/player-states.js +26 -0
  44. package/dist/mcp/node_modules/@waica/behaviors/dist/respawnable.d.ts +5 -13
  45. package/dist/mcp/node_modules/@waica/behaviors/dist/respawnable.js +5 -11
  46. package/dist/mcp/node_modules/@waica/behaviors/package.json +2 -2
  47. package/dist/mcp/node_modules/@waica/engine/README.md +85 -0
  48. package/dist/mcp/node_modules/@waica/engine/dist/authoring-defaults.d.ts +11 -0
  49. package/dist/mcp/node_modules/@waica/engine/dist/authoring-defaults.js +51 -0
  50. package/dist/mcp/node_modules/@waica/engine/dist/component-update-schedule.d.ts +39 -0
  51. package/dist/mcp/node_modules/@waica/engine/dist/component-update-schedule.js +157 -0
  52. package/dist/mcp/node_modules/@waica/engine/dist/component.d.ts +15 -1
  53. package/dist/mcp/node_modules/@waica/engine/dist/component.js +2 -0
  54. package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.d.ts +2 -0
  55. package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.js +11 -0
  56. package/dist/mcp/node_modules/@waica/engine/dist/components/sprite.d.ts +1 -0
  57. package/dist/mcp/node_modules/@waica/engine/dist/components/sprite.js +1 -0
  58. package/dist/mcp/node_modules/@waica/engine/dist/game.d.ts +7 -0
  59. package/dist/mcp/node_modules/@waica/engine/dist/game.js +79 -1
  60. package/dist/mcp/node_modules/@waica/engine/dist/index.d.ts +8 -1
  61. package/dist/mcp/node_modules/@waica/engine/dist/index.js +4 -0
  62. package/dist/mcp/node_modules/@waica/engine/dist/input.d.ts +10 -0
  63. package/dist/mcp/node_modules/@waica/engine/dist/input.js +40 -2
  64. package/dist/mcp/node_modules/@waica/engine/dist/runtime-bridge.d.ts +69 -0
  65. package/dist/mcp/node_modules/@waica/engine/dist/runtime-bridge.js +105 -0
  66. package/dist/mcp/node_modules/@waica/engine/dist/runtime-inspection.d.ts +71 -0
  67. package/dist/mcp/node_modules/@waica/engine/dist/runtime-inspection.js +279 -0
  68. package/dist/mcp/node_modules/@waica/engine/dist/state/state-machine.d.ts +2 -1
  69. package/dist/mcp/node_modules/@waica/engine/dist/state/state-machine.js +7 -1
  70. package/dist/mcp/node_modules/@waica/engine/package.json +3 -2
  71. package/dist/mcp/package-resolver.js +10 -2
  72. package/dist/mcp/project-component-loader.js +553 -0
  73. package/dist/mcp/project-component-runner.js +237 -0
  74. package/dist/mcp/runtime-browser.js +336 -0
  75. package/dist/mcp/runtime-dev-server.js +273 -0
  76. package/dist/mcp/runtime-preflight.js +249 -0
  77. package/dist/mcp/runtime-service.js +8 -0
  78. package/dist/mcp/runtime-session-manager.js +265 -0
  79. package/dist/mcp/scaffolds.js +126 -6
  80. package/dist/mcp/server.js +330 -9
  81. package/dist/mcp/template/src/characters/player.character.json +29 -2
  82. package/dist/mcp/template/src/characters/slime.character.json +6 -0
  83. package/dist/mcp/template/src/main.ts +6 -1
  84. package/dist/mcp/validation.js +240 -24
  85. package/package.json +3 -2
@@ -0,0 +1,265 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { startRuntimeBrowser } from './runtime-browser.js';
4
+ import { startRuntimeDevServer } from './runtime-dev-server.js';
5
+ import { preflightRuntimeProject, } from './runtime-preflight.js';
6
+ import { RuntimeToolError, } from './runtime-service.js';
7
+ function message(error) {
8
+ return error instanceof Error ? error.message : String(error);
9
+ }
10
+ export class RuntimeSessionManager {
11
+ adapters;
12
+ sessions = new Map();
13
+ starts = new Map();
14
+ closing = false;
15
+ constructor(adapters) {
16
+ this.adapters = adapters;
17
+ }
18
+ async start(input) {
19
+ if (this.closing) {
20
+ throw new RuntimeToolError({
21
+ code: 'runtime-invalid-state',
22
+ stage: 'cleanup',
23
+ message: 'The MCP server is closing and cannot start another Run Session.',
24
+ projectPath: input.projectPath,
25
+ });
26
+ }
27
+ const canonical = await this.adapters.canonicalize(input.projectPath);
28
+ const current = this.sessions.get(canonical);
29
+ if (current) {
30
+ if (current.state !== 'active') {
31
+ throw new RuntimeToolError({
32
+ code: 'runtime-invalid-state',
33
+ stage: 'game',
34
+ message: `The Run Session is ${current.state}; retry after it is active.`,
35
+ projectPath: canonical,
36
+ });
37
+ }
38
+ return this.startResult(current, true);
39
+ }
40
+ const concurrent = this.starts.get(canonical);
41
+ if (concurrent)
42
+ return this.startResult(await concurrent, true);
43
+ const creation = this.createCheckedSession({ ...input, projectPath: canonical });
44
+ this.starts.set(canonical, creation);
45
+ try {
46
+ const session = await creation;
47
+ this.sessions.set(canonical, session);
48
+ return this.startResult(session, false);
49
+ }
50
+ finally {
51
+ this.starts.delete(canonical);
52
+ }
53
+ }
54
+ async stop(projectPath) {
55
+ const canonical = await this.adapters.canonicalize(projectPath);
56
+ const starting = this.starts.get(canonical);
57
+ if (starting)
58
+ await starting.catch(() => { });
59
+ const session = this.sessions.get(canonical);
60
+ if (!session)
61
+ return { projectPath: canonical, stopped: false };
62
+ try {
63
+ await this.cleanupSession(session);
64
+ }
65
+ finally {
66
+ this.sessions.delete(canonical);
67
+ }
68
+ return { projectPath: canonical, stopped: true };
69
+ }
70
+ async inspect(input) {
71
+ const session = await this.requireSession(input.projectPath);
72
+ const inspected = await session.browser.inspect({
73
+ ...(input.entityIds ? { entityIds: input.entityIds } : {}),
74
+ ...(input.entityNames ? { entityNames: input.entityNames } : {}),
75
+ ...(input.componentTypes ? { componentTypes: input.componentTypes } : {}),
76
+ });
77
+ return {
78
+ ...this.sharedMetadata(session, inspected),
79
+ snapshot: inspected.snapshot ?? inspected,
80
+ };
81
+ }
82
+ async control(input) {
83
+ const session = await this.requireSession(input.projectPath);
84
+ const { projectPath: _projectPath, ...request } = input;
85
+ const controlled = await session.browser.control(request);
86
+ return { ...this.sharedMetadata(session, controlled), heldActions: controlled.heldActions ?? [] };
87
+ }
88
+ async captureScreenshot(projectPath) {
89
+ const session = await this.requireSession(projectPath);
90
+ const screenshot = await session.browser.captureScreenshot();
91
+ const { data, ...metadata } = screenshot;
92
+ return { metadata: this.sharedMetadata(session, metadata), data };
93
+ }
94
+ async close() {
95
+ if (this.closing)
96
+ return;
97
+ this.closing = true;
98
+ await Promise.allSettled([...this.starts.values()]);
99
+ const sessions = [...this.sessions.values()];
100
+ const results = await Promise.allSettled(sessions.map((session) => this.cleanupSession(session)));
101
+ this.sessions.clear();
102
+ const failed = results.find((result) => result.status === 'rejected');
103
+ if (failed)
104
+ throw failed.reason;
105
+ }
106
+ async createCheckedSession(input) {
107
+ const preflight = await this.adapters.preflight(input);
108
+ return this.createSession(preflight);
109
+ }
110
+ async createSession(preflight) {
111
+ let devServer;
112
+ let browser;
113
+ try {
114
+ devServer = await this.adapters.startDevServer(preflight);
115
+ browser = await this.adapters.startBrowser(preflight, devServer);
116
+ const ready = await browser.ready();
117
+ if (ready.bridgeVersion !== 1) {
118
+ throw new RuntimeToolError({
119
+ code: 'runtime-incompatible',
120
+ stage: 'bridge',
121
+ message: `The Project engine does not provide Runtime Bridge protocol 1; upgrade @waica/engine to at least ${preflight.engine.version}.`,
122
+ projectPath: preflight.projectPath,
123
+ diagnostics: { minimumEngineVersion: preflight.engine.version },
124
+ });
125
+ }
126
+ const session = {
127
+ preflight,
128
+ devServer,
129
+ browser,
130
+ state: 'active',
131
+ ready,
132
+ };
133
+ devServer.setExitHandler?.((detail) => {
134
+ void this.failSession(session, new Error(`Project dev process exited: ${JSON.stringify(detail)}`));
135
+ });
136
+ browser.setLifecycleHandlers({
137
+ reloading: () => {
138
+ if (session.state === 'active')
139
+ session.state = 'reloading';
140
+ },
141
+ reloaded: (nextReady) => {
142
+ if (session.state !== 'stopped') {
143
+ session.ready = nextReady;
144
+ session.state = 'active';
145
+ }
146
+ },
147
+ failed: (error) => {
148
+ void this.failSession(session, error);
149
+ },
150
+ });
151
+ return session;
152
+ }
153
+ catch (error) {
154
+ await browser?.close().catch(() => { });
155
+ await devServer?.stop().catch(() => { });
156
+ if (error instanceof RuntimeToolError)
157
+ throw error;
158
+ throw new RuntimeToolError({
159
+ code: 'runtime-start-failed',
160
+ stage: browser ? 'bridge' : devServer ? 'browser' : 'dev-server',
161
+ message: message(error),
162
+ projectPath: preflight.projectPath,
163
+ diagnostics: devServer?.diagnostics(),
164
+ });
165
+ }
166
+ }
167
+ async startResult(session, reused) {
168
+ const { preflight, devServer, ready } = session;
169
+ const current = reused ? await session.browser.inspect({}) : ready;
170
+ return {
171
+ projectPath: preflight.projectPath,
172
+ url: devServer.url,
173
+ reused,
174
+ viewport: preflight.viewport,
175
+ engineVersion: String(current.engineVersion ?? ready.engineVersion),
176
+ bridgeVersion: Number(current.bridgeVersion ?? ready.bridgeVersion),
177
+ mode: (current.mode ?? ready.mode),
178
+ frame: Number(current.frame ?? ready.frame),
179
+ simulationTime: Number(current.simulationTime ?? ready.simulationTime),
180
+ provenance: [preflight.engine],
181
+ initialSnapshot: ready.initialSnapshot,
182
+ };
183
+ }
184
+ sharedMetadata(session, value) {
185
+ return {
186
+ projectPath: session.preflight.projectPath,
187
+ url: session.devServer.url,
188
+ engineVersion: value.engineVersion ?? session.ready.engineVersion,
189
+ bridgeVersion: value.bridgeVersion ?? session.ready.bridgeVersion,
190
+ mode: value.mode ?? session.ready.mode,
191
+ frame: value.frame ?? session.ready.frame,
192
+ simulationTime: value.simulationTime ?? session.ready.simulationTime,
193
+ provenance: [session.preflight.engine],
194
+ };
195
+ }
196
+ async requireSession(projectPath) {
197
+ const canonical = await this.adapters.canonicalize(projectPath);
198
+ const session = this.sessions.get(canonical);
199
+ if (!session) {
200
+ throw new RuntimeToolError({
201
+ code: 'runtime-not-running',
202
+ stage: 'game',
203
+ message: 'No Run Session is active for this Project.',
204
+ projectPath: canonical,
205
+ });
206
+ }
207
+ if (session.state !== 'active') {
208
+ throw new RuntimeToolError({
209
+ code: 'runtime-invalid-state',
210
+ stage: 'game',
211
+ message: `The Run Session is ${session.state}; retry after it is active.`,
212
+ projectPath: canonical,
213
+ });
214
+ }
215
+ return session;
216
+ }
217
+ cleanupSession(session) {
218
+ session.cleanup ??= this.performCleanup(session);
219
+ return session.cleanup;
220
+ }
221
+ async performCleanup(session) {
222
+ if (session.state === 'stopped')
223
+ return;
224
+ session.state = 'stopping';
225
+ const failures = [];
226
+ await session.browser.close().catch((error) => failures.push(`browser: ${message(error)}`));
227
+ await session.devServer.stop().catch((error) => failures.push(`dev-server: ${message(error)}`));
228
+ session.state = 'stopped';
229
+ if (failures.length > 0) {
230
+ throw new RuntimeToolError({
231
+ code: 'runtime-operation-failed',
232
+ stage: 'cleanup',
233
+ message: `Could not prove Run Session cleanup: ${failures.join('; ')}`,
234
+ projectPath: session.preflight.projectPath,
235
+ diagnostics: { failures, ...session.devServer.diagnostics() },
236
+ });
237
+ }
238
+ }
239
+ async failSession(session, _error) {
240
+ this.sessions.delete(session.preflight.projectPath);
241
+ await this.cleanupSession(session).catch(() => { });
242
+ }
243
+ }
244
+ export function createDefaultRuntimeSessionManager() {
245
+ return new RuntimeSessionManager({
246
+ canonicalize: async (projectPath) => {
247
+ try {
248
+ return await realpath(projectPath);
249
+ }
250
+ catch (error) {
251
+ if (error.code === 'ENOENT')
252
+ return path.resolve(projectPath);
253
+ throw new RuntimeToolError({
254
+ code: 'runtime-prerequisite-missing',
255
+ stage: 'project',
256
+ message: `Project path is not accessible: ${message(error)}`,
257
+ projectPath,
258
+ });
259
+ }
260
+ },
261
+ preflight: preflightRuntimeProject,
262
+ startDevServer: startRuntimeDevServer,
263
+ startBrowser: startRuntimeBrowser,
264
+ });
265
+ }
@@ -1,6 +1,7 @@
1
1
  import { access, mkdir, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { assertSafeSlug } from './project-path.js';
3
+ import { activeArchetypeId, discoverArchetypes, pickArchetype } from './archetypes.js';
4
+ import { WaicaToolError, assertSafeSlug } from './project-path.js';
4
5
  function nameWords(name) {
5
6
  return name
6
7
  .trim()
@@ -31,11 +32,6 @@ function componentTemplate(name) {
31
32
  // Project-owned behavior — loaded by the shipped game and editor Play.
32
33
  export class ${Class} extends Component {
33
34
  static override componentName = '${Class}'
34
- static override params = {
35
- speed: { label: 'Speed', min: 0, max: 30, step: 0.5 },
36
- }
37
-
38
- speed = 5
39
35
 
40
36
  override onUpdate(dt: number): void {
41
37
  // Add this component's per-frame behavior here.
@@ -104,6 +100,75 @@ defineStates('${role}', {
104
100
  })
105
101
  `;
106
102
  }
103
+ /**
104
+ * Prefab directory under src/ per type — the editor's PREFAB_DIRS read the
105
+ * other way round. Duplicated here for the same reason every template above
106
+ * is: the published server cannot import the private editor package, so a
107
+ * parity test holds the two copies together instead of the type system.
108
+ */
109
+ const PREFAB_DIRECTORIES = {
110
+ character: 'characters',
111
+ object: 'objects',
112
+ tile: 'tiles',
113
+ };
114
+ const IDENTITIES = ['player', 'enemy', 'npc', 'custom'];
115
+ const DEFAULT_SPRITE = { width: 1, height: 1, color: 0x8ecae6 };
116
+ /** Mirrors the editor's IDENTITY_EXTRAS: what an identity is born with. */
117
+ const IDENTITY_EXTRAS = {
118
+ player: [
119
+ { type: 'Respawnable' },
120
+ { type: 'Health', props: { invulnerability: 1 } },
121
+ { type: 'OutOfBounds' },
122
+ ],
123
+ enemy: [{ type: 'Hazard' }, { type: 'Health', props: { max: 1 } }],
124
+ npc: [],
125
+ custom: [],
126
+ };
127
+ /**
128
+ * The editor's newPrefabComponents, with the role handed in instead of read
129
+ * from the engine's global registry — this server resolves the archetype
130
+ * itself and never installs a bundle into its own process.
131
+ */
132
+ function prefabComponents(type, role, definition, identity) {
133
+ switch (type) {
134
+ case 'character': {
135
+ const driver = definition?.driver ? [{ type: definition.driver }] : [];
136
+ return [
137
+ { type: 'Sprite', props: { ...DEFAULT_SPRITE, layer: 2 } },
138
+ {
139
+ type: 'StateMachine',
140
+ props: {
141
+ role,
142
+ initial: definition?.graph?.initial ?? '',
143
+ states: definition?.graph?.states ?? {},
144
+ },
145
+ },
146
+ ...driver,
147
+ ...(identity ? IDENTITY_EXTRAS[identity] : []),
148
+ { type: 'Hitbox', props: { width: 0.9, height: 0.95 } },
149
+ ];
150
+ }
151
+ case 'object':
152
+ return [
153
+ { type: 'Sprite', props: { ...DEFAULT_SPRITE, layer: 1 } },
154
+ { type: 'Hitbox', props: { width: 1, height: 1 } },
155
+ ];
156
+ case 'tile':
157
+ return [
158
+ { type: 'Sprite', props: { ...DEFAULT_SPRITE } },
159
+ { type: 'Solid', props: { width: 1, height: 1 } },
160
+ ];
161
+ }
162
+ }
163
+ /** The active archetype's roles — the only ones a scaffolded character may adopt. */
164
+ async function archetypeRoles(projectPath) {
165
+ const activeId = await activeArchetypeId(projectPath);
166
+ const available = await discoverArchetypes(projectPath, undefined, [], activeId ? [activeId] : []);
167
+ return pickArchetype(available, activeId, projectPath).manifest.bundle.roles;
168
+ }
169
+ function invalidInput(projectPath, message) {
170
+ return new WaicaToolError({ code: 'invalid-input', message, projectPath });
171
+ }
107
172
  const UI_TEMPLATE = `<style>
108
173
  .my-ui {
109
174
  position: absolute;
@@ -133,6 +198,61 @@ export async function scaffoldComponent(projectPath, name) {
133
198
  const className = componentClassName(name);
134
199
  return { ...(await writeOnce(projectPath, componentFilePath(name), componentTemplate(name))), className };
135
200
  }
201
+ /**
202
+ * A starter prefab of the given type, byte-identical to what the editor
203
+ * writes for the same inputs — for roles the active archetype defines. The
204
+ * editor also accepts a project's own custom roles (defineRole); this
205
+ * function does not, and rejects them instead of guessing (see the
206
+ * unknown-role error below). The caller names it: no auto-numbering, and an
207
+ * existing file is left exactly as it is.
208
+ */
209
+ export async function scaffoldPrefab(projectPath, name, type, role, identity) {
210
+ assertSafeSlug(name, 'name');
211
+ // A plain index lookup on an object literal lets inherited
212
+ // Object.prototype keys ('toString', 'constructor', 'valueOf', …) through
213
+ // as a truthy "directory" — Object.hasOwn keeps this to the type's own keys.
214
+ if (!Object.hasOwn(PREFAB_DIRECTORIES, type)) {
215
+ throw invalidInput(projectPath, `type must be one of ${Object.keys(PREFAB_DIRECTORIES).join(', ')}.`);
216
+ }
217
+ const prefabType = type;
218
+ const directory = PREFAB_DIRECTORIES[prefabType];
219
+ if (prefabType !== 'character' && (role !== undefined || identity !== undefined)) {
220
+ throw invalidInput(projectPath, `role and identity are character-only: a ${prefabType} prefab has neither.`);
221
+ }
222
+ if (identity !== undefined && !IDENTITIES.includes(identity)) {
223
+ throw invalidInput(projectPath, `identity must be one of ${IDENTITIES.join(', ')}.`);
224
+ }
225
+ // The editor's creation dialog never lets a non-player identity keep the
226
+ // player's role: picking "enemy" forces a movement-role pick too. Defaulting
227
+ // to 'player' here would silently hand an enemy/npc/custom the player's own
228
+ // driver instead of the role that identity is supposed to move it with.
229
+ if (identity !== undefined && identity !== 'player' && role === undefined) {
230
+ throw invalidInput(projectPath, `role is required when identity is "${identity}": the "player" default only applies when identity is "player" or omitted.`);
231
+ }
232
+ let definition;
233
+ // Same default the editor's creation dialog starts from.
234
+ const roleName = role ?? 'player';
235
+ if (prefabType === 'character') {
236
+ const roles = await archetypeRoles(projectPath);
237
+ definition = roles[roleName];
238
+ if (!definition) {
239
+ // Falling back to an empty graph would write a character that stands
240
+ // still in Play and says nothing about why.
241
+ throw new WaicaToolError({
242
+ code: 'unknown-role',
243
+ message: `Unknown role "${roleName}". Available roles: ${Object.keys(roles).sort().join(', ')}.`,
244
+ projectPath,
245
+ available: Object.keys(roles).sort(),
246
+ });
247
+ }
248
+ }
249
+ const prefab = {
250
+ waicaPrefab: 1,
251
+ type: prefabType,
252
+ components: prefabComponents(prefabType, roleName, definition, identity),
253
+ };
254
+ return writeOnce(projectPath, `src/${directory}/${name}.${prefabType}.json`, JSON.stringify(prefab, null, 2) + '\n');
255
+ }
136
256
  export async function scaffoldRole(projectPath, role) {
137
257
  assertSafeSlug(role, 'role');
138
258
  return writeOnce(projectPath, `src/roles/${role}.ts`, roleTemplate(role));