exp-runtime-creator 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ """Optional stdio MCP interface to the creator's model, compiler and debugger."""
2
+ import argparse
3
+ from dataclasses import asdict
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from .editing import auto_layout
9
+ from .model import ProjectError, atomic_write
10
+ from .workspace import Workspace
11
+
12
+
13
+ def create_server(root):
14
+ # Keep MCP, Qt, and player dependencies out of headless creator imports.
15
+ from mcp.server.fastmcp import FastMCP
16
+ from mcp.types import ToolAnnotations
17
+
18
+ workspace = Workspace(root)
19
+ server = FastMCP('EXP Runtime Creator', instructions=(
20
+ 'Author SHS/CoD EXP episodes in this workspace. Use schema_get for node contracts. '
21
+ 'Use project_get revisions with project_apply_edits; project_preview_edits is read-only. '
22
+ 'Validate, playtest, then export. Unknown native calls remain explicit stops. '
23
+ 'Open desktop projects share unsaved state; edits enter desktop undo and need Save. '
24
+ 'Paths stay in the workspace. Large results include a result_read handle.'))
25
+ read = ToolAnnotations(readOnlyHint=True, destructiveHint=False, openWorldHint=False)
26
+ write = ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=False)
27
+ edit = ToolAnnotations(readOnlyHint=False, destructiveHint=True, openWorldHint=False)
28
+
29
+ from .mcp_results import register
30
+ from .mcp_types import Edit, Scenario
31
+ from .schema import node_schema, branching_example
32
+ from . import graph
33
+ read_tool, write_tool, edit_tool = (register(server, workspace, a) for a in (read, write, edit))
34
+
35
+ @server.resource('creator://schema')
36
+ def schema() -> str:
37
+ """Discover node kinds, languages and the authoring workflow."""
38
+ return json.dumps(node_schema())
39
+
40
+ @server.resource('creator://schema/{kind}')
41
+ def kind_schema(kind: str) -> str:
42
+ """One node kind's contract, defaults and example."""
43
+ return json.dumps(node_schema(kind))
44
+
45
+ @server.resource('creator://examples/branching')
46
+ def example() -> str:
47
+ """An authored branching edit and executable playtest scenario."""
48
+ return json.dumps(branching_example())
49
+
50
+ @read_tool(name='project_list')
51
+ def list_projects(query: str = '', offset: int = 0, limit: int = 50) -> dict:
52
+ """Find .expproj files by workspace-relative filename. Offset >=0; limit 1–200, default 50."""
53
+ graph.page(offset, limit)
54
+ paths = sorted(str(path.relative_to(workspace.root)) for path in workspace.root.rglob('*.expproj')
55
+ if path.resolve().is_relative_to(workspace.root) and '.creator-history' not in path.parts
56
+ and query.casefold() in str(path.relative_to(workspace.root)).casefold())
57
+ return dict(projects=paths[offset:offset + limit], total=len(paths),
58
+ next_offset=offset + limit if offset + limit < len(paths) else None)
59
+
60
+ @read_tool(name='project_get')
61
+ def read_project(path: str, view: Literal['summary', 'full'] = 'summary', offset: int = 0, limit: int = 50) -> dict:
62
+ """Inspect saved or live desktop state and its revision. live/unsaved identify an open document. Full includes node pages (offset >=0, limit 1–200)."""
63
+ if view == 'full': return workspace.describe(path, offset, limit)
64
+ state = workspace.snapshot(path)
65
+ return dict(revision=state.revision, live=state.live, unsaved=state.unsaved, **graph.overview(state.project))
66
+
67
+ @write_tool(name='project_create')
68
+ def create_project(path: str, title: str = 'Untitled episode', game: str = 'shs',
69
+ pack_id: int = 30000, episode_id: int = 1) -> dict:
70
+ """Create a project at a new .expproj path. Existing files are never replaced."""
71
+ return workspace.create(path, title, game, pack_id, episode_id)
72
+
73
+ @edit_tool(name='project_update')
74
+ def update_project(path: str, revision: str, changes: dict) -> dict:
75
+ """Change title/game/IDs/start/language/locales/library. Use project_get's revision."""
76
+ return workspace.update(path, revision, changes)
77
+
78
+ @edit_tool(name='graph_edit_node')
79
+ def edit_node(path: str, revision: str, node_id: str, changes: dict | None = None, remove: bool = False) -> dict:
80
+ """Add/update/remove a node. Options are text/target/condition objects. See creator://schema."""
81
+ return workspace.node(path, revision, node_id, changes, remove)
82
+
83
+ @edit_tool(name='character_edit')
84
+ def edit_character(path: str, revision: str, character_id: str,
85
+ changes: dict | None = None, remove: bool = False) -> dict:
86
+ """Edit reusable name, portrait, theme and five expression asset IDs."""
87
+ return workspace.character(path, revision, character_id, changes, remove)
88
+
89
+ @edit_tool(name='graph_arrange')
90
+ def arrange_graph(path: str, revision: str) -> dict:
91
+ """Arrange connected nodes in columns; preserve all execution connections."""
92
+ return workspace.edit(path, revision, auto_layout)
93
+
94
+ @edit_tool(name='asset_import_image')
95
+ def add_image(path: str, revision: str, source: str) -> dict:
96
+ """Embed a local PNG/JPEG from this workspace in the project."""
97
+ return workspace.import_image(path, revision, source)
98
+
99
+ @read_tool(name='asset_list')
100
+ def library_assets(library: str, episode: str | None = None, kind: str | None = None,
101
+ query: str = '', offset: int = 0, limit: int = 50) -> dict:
102
+ """Browse supplied resources with type, dimensions and audio duration. Offset >=0; limit 1–200. Numeric IDs are not invented names."""
103
+ from .catalog import library_assets
104
+ return library_assets(workspace, library, episode=episode, kind=kind, query=query, offset=offset, limit=limit)
105
+
106
+ @edit_tool(name='asset_import_library_image')
107
+ def add_library_image(path: str, revision: str, library: str,
108
+ resource: int, episode: str | None = None) -> dict:
109
+ """Reference a game's base image, or embed an image from an imported episode."""
110
+ return workspace.library_image(path, revision, library, resource, episode)
111
+
112
+ @write_tool(name='project_import_exp')
113
+ def import_exp(source: str, destination: str, game: str = 'shs') -> dict:
114
+ """Recover an authored graph or import lossless native blocks into a new project."""
115
+ return workspace.import_episode(source, destination, game)
116
+
117
+ @read_tool(name='native_get_data')
118
+ def native_data(path: str, script: int) -> dict:
119
+ """Read the fixed native data banks of an imported original script."""
120
+ from .native import _resources
121
+ from exp_runtime.decode.bytecode import decode_program
122
+ project, revision = workspace.read(path)
123
+ if not project.native: raise ProjectError('This is an authored high-level graph')
124
+ program = decode_program(_resources(project)[script])
125
+ return {'revision': revision, 'main': list(program.main_words), 'extra': list(program.extra_words),
126
+ 'gap_count': program.gap_count}
127
+
128
+ @read_tool(name='native_find_text')
129
+ def native_text(path: str, script: int, query: str = '', offset: int = 0, limit: int = 50) -> dict:
130
+ """Find printable main-bank text candidates; verify their addresses in the instructions. Offset >=0; limit 1–200, default 50."""
131
+ from .native import packed_strings
132
+ graph.page(offset, limit)
133
+ project, revision = workspace.read(path)
134
+ candidates = [row for row in packed_strings(project, script) if query.casefold() in row['text'].casefold()]
135
+ return dict(revision=revision, candidates=candidates[offset:offset + limit], total=len(candidates),
136
+ next_offset=offset + limit if offset + limit < len(candidates) else None)
137
+
138
+ @edit_tool(name='native_update_text')
139
+ def edit_native_text(path: str, revision: str, script: int, address: int, text: str) -> dict:
140
+ """Replace a confirmed packed string within its original allocation; preserve every address."""
141
+ from .native import edit_packed_text
142
+ return workspace.edit(path, revision, lambda project: edit_packed_text(project, script, address, text))
143
+
144
+ @edit_tool(name='native_update_data')
145
+ def edit_native_data(path: str, revision: str, script: int, words: list[int], extra: bool = False) -> dict:
146
+ """Replace a native data bank without changing its length or any addresses."""
147
+ from .native import set_data_words
148
+ return workspace.edit(path, revision, lambda project: set_data_words(project, script, words, extra=extra))
149
+
150
+ @read_tool(name='project_validate')
151
+ def validate_project(path: str) -> dict:
152
+ """Find export errors and graph warnings with node/field locations and recovery suggestions; writes nothing."""
153
+ from .diagnostics import diagnose
154
+ project, revision = workspace.read(path)
155
+ return dict(revision=revision, **diagnose(project))
156
+
157
+ @edit_tool(name='project_export_exp')
158
+ def export_exp(path: str, destination: str, revision: str, language: str | None = None,
159
+ replace_revision: str | None = None) -> dict:
160
+ """Export native EXP atomically. Replacing an EXP requires its current SHA-256."""
161
+ return workspace.export(path, destination, revision, language, replace_revision)
162
+
163
+ @write_tool(name='playtest_start')
164
+ def debug_start(path: str, start: str | None = None, seed: int | None = None,
165
+ library: str | None = None, language: Literal['en', 'fr', 'it', 'de', 'es'] | None = None) -> dict:
166
+ """Start isolated playback, paused at the first node. Optional seed 0–4294967295; imported library enables accurate layout/screenshots. Limit 8 sessions."""
167
+ result = workspace.start_debug(path, start, seed=seed, library=library, language=language)
168
+ return dict(session=result['session'], revision=result['revision'], **workspace.playtests[result['session']].state())
169
+
170
+ @read_tool(name='playtest_get_state')
171
+ def debug_state(session: str, verbose: bool = False) -> dict:
172
+ """Inspect the current node, variables, action, pending arguments and recent node trace."""
173
+ workspace.debugger(session)
174
+ return workspace.playtests[session].state(verbose=verbose)
175
+
176
+ @write_tool(name='playtest_debug_step')
177
+ def debug_step(session: str, step_node: bool = True, answer: str | int | None = None,
178
+ provide_answer: bool = False, milliseconds: int = 0,
179
+ breakpoints: list[str] | None = None) -> dict:
180
+ """Step/run, answer real inputs or advance time; minigame results are never fabricated."""
181
+ debugger = workspace.debugger(session)
182
+ if not 0 <= milliseconds <= 60000: raise ProjectError('Clock step must be 0–60000 ms')
183
+ if breakpoints is not None: debugger.breakpoints = set(breakpoints)
184
+ return debugger.resume(step=step_node, answer=answer, provide_answer=provide_answer, milliseconds=milliseconds)
185
+
186
+ @write_tool(name='playtest_set_variable')
187
+ def debug_set_variable(session: str, name: str, value: int) -> dict:
188
+ """Change an existing numeric variable in the isolated debug session."""
189
+ debugger = workspace.debugger(session)
190
+ debugger.set_variable(name, value)
191
+ return workspace.playtests[session].state()
192
+
193
+ @write_tool(name='playtest_grid_pointer')
194
+ def debug_grid_pointer(session: str, phase: str, cell: int | None = None) -> dict:
195
+ """Send down/move/up/cancel to the real SHS grid minigame."""
196
+ from .debugger import _Paused
197
+ if phase not in ('down', 'move', 'up', 'cancel'): raise ProjectError('Invalid grid input phase')
198
+ debugger = workspace.debugger(session)
199
+ try: debugger.session.grid_pointer(phase, cell)
200
+ except _Paused: pass
201
+ return debugger.state()
202
+
203
+ @write_tool(name='playtest_close')
204
+ def debug_close(session: str) -> dict:
205
+ """Discard an isolated debug session and its in-memory state."""
206
+ return workspace.close_debug(session)
207
+
208
+ @read_tool()
209
+ def schema_get(kind: str | None = None, example: bool = False) -> dict:
210
+ """Discover node kinds, or get one kind's fields, limits, defaults and example. example=true returns a branching transaction and test."""
211
+ return branching_example() if example else node_schema(kind)
212
+
213
+ @read_tool()
214
+ def project_preview_edits(path: str, revision: str, operations: list[Edit]) -> dict:
215
+ """Review 1–200 edits as one candidate and validate it without saving. Same operations as project_apply_edits; returns a diff and diagnostics."""
216
+ return workspace.batch(path, revision, [op.model_dump(exclude_unset=True) for op in operations], preview=True)
217
+
218
+ @edit_tool()
219
+ def project_apply_edits(path: str, revision: str, operations: list[Edit]) -> dict:
220
+ """Apply 1–200 related story edits atomically with one current revision. Forward references within the batch work. Failure leaves the project intact; success returns an undo checkpoint."""
221
+ return workspace.batch(path, revision, [op.model_dump(exclude_unset=True) for op in operations])
222
+
223
+ @read_tool()
224
+ def graph_search_nodes(path: str, text: str = '', character: str | None = None, kind: str | None = None,
225
+ scene: int | None = None, offset: int = 0, limit: int = 50) -> dict:
226
+ """Find dialogue, choices or other nodes by text, character ID, kind or scene. Offset >=0; limit 1–200, default 50."""
227
+ project, revision = workspace.read(path)
228
+ return dict(revision=revision, **graph.search(project, text=text, character=character, kind=kind,
229
+ scene=scene, offset=offset, limit=limit))
230
+
231
+ @read_tool()
232
+ def graph_get_subgraph(path: str, node_id: str, depth: int = 1,
233
+ direction: Literal['incoming', 'outgoing', 'both'] = 'both', limit: int = 100) -> dict:
234
+ """Inspect a node, nearby branches and boundary connections. Depth 0 reads one node; allowed 0–20. Limit 1–200, default 100."""
235
+ project, revision = workspace.read(path)
236
+ return dict(revision=revision, **graph.subgraph(project, node_id, depth=depth, direction=direction, limit=limit))
237
+
238
+ @read_tool()
239
+ def graph_find_paths(path: str, target: str, source: str | None = None, limit: int = 20, max_depth: int = 100) -> dict:
240
+ """Find structural routes to a scene or ending. Limit 1–200, depth 1–500. Conditions require playtesting; cycles are not expanded indefinitely."""
241
+ project, revision = workspace.read(path)
242
+ return dict(revision=revision, **graph.paths(project, target, source=source, limit=limit, max_depth=max_depth))
243
+
244
+ @write_tool()
245
+ def checkpoint_create(path: str, revision: str, label: str = 'Checkpoint') -> dict:
246
+ """Keep a restorable copy of this project revision. Label <=200 characters; retain the newest 40 checkpoints per project."""
247
+ return workspace.checkpoint(path, revision, label)
248
+
249
+ @read_tool()
250
+ def checkpoint_list(path: str) -> dict:
251
+ """List retained checkpoint IDs and labels for this project; copies survive server restarts."""
252
+ return dict(checkpoints=workspace.checkpoints(path))
253
+
254
+ @edit_tool()
255
+ def checkpoint_restore(path: str, revision: str, checkpoint: str) -> dict:
256
+ """Undo edits by restoring a checkpoint against the current revision; preserve the current project as another checkpoint first."""
257
+ return workspace.restore(path, revision, checkpoint)
258
+
259
+ @read_tool()
260
+ def export_inspect(path: str) -> dict:
261
+ """Read an existing EXP's checksum and metadata before an intentional replacement export."""
262
+ from exp_runtime.exp import ExpArchive
263
+ source = workspace.path(path, '.exp')
264
+ archive = ExpArchive(source.read_bytes())
265
+ return dict(path=path, sha256=workspace.revision(source), bytes=source.stat().st_size,
266
+ metadata=asdict(archive.metadata()), resources=len(archive.entries))
267
+
268
+ @read_tool()
269
+ def asset_list_episodes(library: str, offset: int = 0, limit: int = 50) -> dict:
270
+ """Find episode IDs and titles in a supplied library for asset discovery. Offset >=0; limit 1–200, default 50."""
271
+ from exp_runtime.content import ContentLibrary
272
+ graph.page(offset, limit)
273
+ with ContentLibrary(workspace.path(library)) as content:
274
+ records = content.episodes
275
+ return dict(game=content.game_id, episodes=[{key: row[key] for key in ('id', 'titles', 'pack_id', 'episode_id')}
276
+ for row in records[offset:offset + limit]], total=len(records),
277
+ next_offset=offset + limit if offset + limit < len(records) else None)
278
+
279
+ @read_tool()
280
+ def asset_list_project(path: str, query: str = '', offset: int = 0, limit: int = 50) -> dict:
281
+ """Find imported project images by user label or ID, with dimensions and embedded/reference status. Limit 1–200; offset >=0."""
282
+ from .media import inspect_asset
283
+ graph.page(offset, limit)
284
+ project, revision = workspace.read(path)
285
+ assets = [a for a in project.assets if query.casefold() in f'{a.id} {a.name}'.casefold()]
286
+ return dict(revision=revision, assets=[dict(id=a.id, name=a.name, embedded=a.data is not None,
287
+ **(inspect_asset(a.data) if a.data is not None else dict(type='image', library=project.library)))
288
+ for a in assets[offset:offset + limit]], total=len(assets),
289
+ next_offset=offset + limit if offset + limit < len(assets) else None)
290
+
291
+ @read_tool()
292
+ def asset_preview_image(resource: int, path: str | None = None, library: str | None = None,
293
+ episode: str | None = None, frame: int = 0, size: int = 256) -> dict:
294
+ """See an imported image/atlas frame. Supply exactly one project path or library. Frame >=0; thumbnail size 32–1024, default 256."""
295
+ from .catalog import read_asset
296
+ from .media import thumbnail
297
+ from .mcp_results import media_result
298
+ if bool(path) == bool(library): raise ProjectError('Supply exactly one project path or library')
299
+ if path:
300
+ project, revision = workspace.read(path)
301
+ asset = next((a for a in project.assets if a.id == resource), None)
302
+ if asset is None: raise ProjectError('Unknown project asset')
303
+ data = asset.data if asset.data is not None else read_asset(workspace, project.library, resource)
304
+ else: data = read_asset(workspace, library, resource, episode)
305
+ png, metadata = thumbnail(data, frame=frame, size=size)
306
+ return media_result(png, 'image/png', dict(resource=resource, **metadata))
307
+
308
+ @read_tool()
309
+ def asset_preview_audio(library: str, resource: int, episode: str | None = None,
310
+ start_ms: int = 0, duration_ms: int = 5000) -> dict:
311
+ """Hear a supplied audio resource as a bounded WAV preview. Start 0–3600000 ms; duration 1–10000 ms, default 5000."""
312
+ from .catalog import read_asset
313
+ from .media import audio_preview
314
+ from .mcp_results import media_result
315
+ wav = audio_preview(read_asset(workspace, library, resource, episode), start_ms=start_ms, duration_ms=duration_ms)
316
+ return media_result(wav, 'audio/wav', dict(resource=resource, start_ms=start_ms, duration_ms=duration_ms))
317
+
318
+ @read_tool()
319
+ def playtest_list() -> dict:
320
+ """List isolated playtest session handles and source revisions."""
321
+ return dict(sessions=[dict(session=key, path=d.path, revision=d.revision) for key, d in workspace.debuggers.items()])
322
+
323
+ @write_tool()
324
+ def playtest_run(session: str, until: Literal['input', 'choice', 'end', 'node'] = 'input', node_id: str | None = None,
325
+ max_operations: int = 500, max_ms: int = 60000) -> dict:
326
+ """Play until input, a choice, ending or node breakpoint. Drives real acknowledgement/clock gates. Budgets: 1–2000 operations and 0–60000 ms. Stops at unanswered choices and unsupported calls."""
327
+ workspace.debugger(session)
328
+ return workspace.playtests[session].run(until=until, node_id=node_id, max_operations=max_operations, max_ms=max_ms)
329
+
330
+ @write_tool()
331
+ def playtest_choose(session: str, option_id: str) -> dict:
332
+ """Select an enabled answer by the exact ID from the current playtest state, preserving identity across shuffled display order."""
333
+ workspace.debugger(session)
334
+ return workspace.playtests[session].choose(option_id)
335
+
336
+ @write_tool()
337
+ def playtest_input(session: str, action: Literal['continue', 'answer_text', 'answer', 'tick', 'step', 'grid_pointer'],
338
+ value: str | int | None = None, milliseconds: int = 0,
339
+ phase: Literal['down', 'move', 'up', 'cancel'] | None = None, cell: int | None = None) -> dict:
340
+ """Send one real player/debug input. Tick accepts integer 0–60000 ms. Text uses native input rules; minigames use actual selections or pointer cells, never injected results."""
341
+ workspace.debugger(session)
342
+ return workspace.playtests[session].input(action, value=value, milliseconds=milliseconds, phase=phase, cell=cell)
343
+
344
+ @read_tool()
345
+ def playtest_render(session: str) -> dict:
346
+ """Capture the current screen through the shared player renderer using its supplied library. Run to a visible input first; rendering does not advance the live session."""
347
+ from .media import render_playtest
348
+ from .mcp_results import media_result
349
+ debugger = workspace.debugger(session)
350
+ return media_result(render_playtest(debugger), 'image/png', dict(width=480, height=720, session=session))
351
+
352
+ @read_tool()
353
+ def playtest_run_scenario(path: str, scenario: Scenario, library: str | None = None) -> dict:
354
+ """Run a reproducible isolated test and assertions; no project edits or saves. At most 200 inputs/assertions. Reports failures, transcript, variable changes and visited-node coverage."""
355
+ from .debugger import DebugSession
356
+ from .playtest import run_scenario
357
+ project, revision = workspace.read(path)
358
+ settings = scenario.model_dump(exclude_unset=True)
359
+ settings.setdefault('seed', 0)
360
+ if library is not None: project.library = str(workspace.path(library)) if library else ''
361
+ if project.library: workspace.path(project.library)
362
+ debugger = DebugSession(project, start=settings.get('start'), seed=settings['seed'],
363
+ language=settings.get('language'), library=project.library or None)
364
+ try: return dict(revision=revision, **run_scenario(debugger, settings))
365
+ finally: debugger.close()
366
+
367
+ @edit_tool()
368
+ def playtest_save_scenario(destination: str, scenario: Scenario, replace_revision: str | None = None) -> dict:
369
+ """Save a reusable authored test to a workspace .exptest file. Replacing one requires its current SHA-256 from playtest_get_scenario."""
370
+ target = workspace.path(destination, '.exptest')
371
+ if target.exists() and workspace.revision(target) != replace_revision:
372
+ raise ProjectError('Scenario exists; provide its current SHA-256 to replace it')
373
+ data = dict(format='exp-creator-scenario', version=1, scenario=scenario.model_dump(exclude_unset=True))
374
+ encoded = json.dumps(data, ensure_ascii=False, indent=2).encode()
375
+ if len(encoded) > 1024 * 1024: raise ProjectError('Scenario must fit 1 MiB')
376
+ atomic_write(target, encoded)
377
+ return dict(path=destination, revision=workspace.revision(target))
378
+
379
+ def read_scenario(path):
380
+ source = workspace.path(path, '.exptest')
381
+ if source.stat().st_size > 1024 * 1024: raise ProjectError('Scenario must fit 1 MiB')
382
+ record = json.loads(source.read_text(encoding='utf-8'))
383
+ if not isinstance(record, dict) or record.get('format') != 'exp-creator-scenario' or record.get('version') != 1:
384
+ raise ProjectError('Unsupported scenario format')
385
+ return Scenario(**record['scenario']), workspace.revision(source)
386
+
387
+ @read_tool()
388
+ def playtest_get_scenario(path: str) -> dict:
389
+ """Read a saved .exptest scenario and its replacement revision."""
390
+ scenario, revision = read_scenario(path)
391
+ return dict(scenario=scenario.model_dump(exclude_unset=True), revision=revision)
392
+
393
+ @read_tool()
394
+ def playtest_replay_scenario(path: str, scenario_path: str, library: str | None = None) -> dict:
395
+ """Replay a saved .exptest against a project with real inputs and assertions; writes no player saves."""
396
+ scenario, _ = read_scenario(scenario_path)
397
+ return playtest_run_scenario(path, scenario, library)
398
+
399
+ @read_tool()
400
+ def result_read(result_id: str, offset: int = 0, limit: int = 6000) -> dict:
401
+ """Retrieve a full oversized JSON result in character slices. Offset >=0; limit 1–6000, default 6000. The newest eight results are retained in memory."""
402
+ if type(offset) is not int or offset < 0 or type(limit) is not int or not 1 <= limit <= 6000:
403
+ raise ProjectError('Use offset >=0 and limit 1–6000')
404
+ if result_id not in workspace.responses: raise ProjectError('Result expired; repeat the original read with a smaller page')
405
+ value = workspace.responses[result_id]
406
+ return dict(result_id=result_id, text=value[offset:offset + limit],
407
+ next_offset=offset + limit if offset + limit < len(value) else None)
408
+
409
+ return server
410
+
411
+
412
+ def main(argv=None):
413
+ parser = argparse.ArgumentParser(description='EXP Runtime Creator MCP server (stdio)')
414
+ parser.add_argument('--workspace', type=Path, required=True, help='Existing directory containing projects and assets')
415
+ args = parser.parse_args(argv)
416
+ try:
417
+ server = create_server(args.workspace)
418
+ except ModuleNotFoundError as error:
419
+ if error.name and error.name.startswith('mcp'):
420
+ parser.error('Install exp-runtime[creator-mcp] or exp-runtime-creator[mcp]')
421
+ raise
422
+ except (OSError, ProjectError) as error:
423
+ parser.error(str(error))
424
+ server.run(transport='stdio')
425
+
426
+
427
+ if __name__ == '__main__':
428
+ main()
@@ -0,0 +1,49 @@
1
+ """Compact transport schemas. Semantic limits are also enforced by the workspace."""
2
+ from typing import Any, Literal
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+
7
+ class Edit(BaseModel):
8
+ model_config = ConfigDict(extra='forbid', strict=True)
9
+ op: Literal['project', 'node', 'character', 'connect', 'rename_node', 'image', 'library_image', 'asset_label', 'layout']
10
+ id: str | int | None = Field(default=None, description='Node/character ID, or integer asset ID for asset_label.')
11
+ changes: dict[str, Any] = Field(default_factory=dict, description='Fields to patch for project/node/character. Get node fields and valid examples with schema_get.')
12
+ remove: bool = Field(default=False, description='For node/character only. Reconnect all references in the same batch.')
13
+ new_id: str | None = Field(default=None, description='New identity for rename_node; updates edges, start and translation keys.')
14
+ source: str | None = Field(default=None, description='Source node for connect; workspace-relative PNG/JPEG file for image.')
15
+ target: str | None = Field(default=None, description='Destination node for connect. May be created later in this batch.')
16
+ field: str = Field(default='next', description='Connection field: next, otherwise or options.N.target; default next.')
17
+ library: str | None = Field(default=None, description='Workspace-relative imported library directory for library_image.')
18
+ resource: int | None = Field(default=None, description='Exact resource ID, integer 0–32767, for library_image.')
19
+ episode: str | None = Field(default=None, description='Episode ID returned by asset_list_episodes; omit for a base image.')
20
+ label: str | None = Field(default=None, description='User label for asset_label, at most 1000 characters.')
21
+
22
+
23
+ class ScenarioStep(BaseModel):
24
+ model_config = ConfigDict(extra='forbid', strict=True)
25
+ action: Literal['run', 'choose', 'continue', 'answer_text', 'answer', 'tick', 'step', 'grid_pointer']
26
+ until: Literal['input', 'choice', 'end', 'node'] = 'input'
27
+ node_id: str | None = None
28
+ option_id: str | None = Field(default=None, description='For choose: exact enabled option ID returned by a playtest, stable within this project revision.')
29
+ value: str | int | None = None
30
+ milliseconds: int = Field(default=0, ge=0, le=60000, description='For tick: integer 0–60000 milliseconds; default 0.')
31
+ phase: Literal['down', 'move', 'up', 'cancel'] | None = None
32
+ cell: int | None = None
33
+
34
+
35
+ class Assertion(BaseModel):
36
+ model_config = ConfigDict(extra='forbid', strict=True)
37
+ kind: Literal['variable', 'text_variable', 'node', 'action', 'visited', 'ended']
38
+ name: str | None = None
39
+ equals: str | int | None = None
40
+ node_id: str | None = None
41
+
42
+
43
+ class Scenario(BaseModel):
44
+ model_config = ConfigDict(extra='forbid', strict=True)
45
+ seed: int = Field(default=0, ge=0, le=4294967295, description='Reproducible unsigned 32-bit seed, 0–4294967295; default 0.')
46
+ start: str | None = Field(default=None, description='Optional authored starting node; imported native entry points stay fixed.')
47
+ language: Literal['en', 'fr', 'it', 'de', 'es'] | None = None
48
+ steps: list[ScenarioStep] = Field(max_length=200, description='At most 200 real input operations. Stops on errors, unsupported calls or budgets.')
49
+ assertions: list[Assertion] = Field(default_factory=list, max_length=200, description='At most 200 checks against the final state or visited nodes.')
@@ -0,0 +1,144 @@
1
+ """Asset inspection and isolated rendering with the production player."""
2
+ import base64
3
+ from io import BytesIO
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import wave
9
+
10
+ from .model import ProjectError
11
+
12
+
13
+ def audio_type(data):
14
+ if data[:4] == b'RIFF' and data[8:12] == b'WAVE': return 'audio/wav'
15
+ if data[:4] == b'FORM' and data[8:12] in (b'AIFF', b'AIFC'): return 'audio/aiff'
16
+ if data[:3] == b'ID3' or data[:1] == b'\xff' and len(data) > 1 and data[1] & 0xe0 == 0xe0:
17
+ return 'audio/mpeg'
18
+ if data[:4] == b'OggS': return 'audio/ogg'
19
+ return None
20
+
21
+
22
+ def worker(request):
23
+ """SDL stays in a short-lived child: no windows, audio devices or live-state mutation."""
24
+ env = dict(os.environ, PYGAME_HIDE_SUPPORT_PROMPT='1', SDL_VIDEODRIVER='dummy', SDL_AUDIODRIVER='dummy')
25
+ try:
26
+ result = subprocess.run([sys.executable, '-m', 'exp_runtime.creator.media'],
27
+ input=json.dumps(request).encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
28
+ env=env, timeout=30, check=False)
29
+ except subprocess.TimeoutExpired as error:
30
+ raise ProjectError('Media preview exceeded its 30-second budget') from error
31
+ if result.returncode:
32
+ raise ProjectError('Media preview failed: ' + result.stderr.decode(errors='replace')[-2000:])
33
+ return result.stdout
34
+
35
+
36
+ def inspect_asset(data):
37
+ result = dict(bytes=len(data), type='unknown')
38
+ mime = audio_type(data)
39
+ if mime:
40
+ result.update(type='audio', mime_type=mime)
41
+ try:
42
+ if mime == 'audio/wav':
43
+ with wave.open(BytesIO(data)) as sound:
44
+ result['duration_ms'] = round(sound.getnframes() * 1000 / sound.getframerate())
45
+ else:
46
+ result.update(json.loads(worker(dict(mode='audio_info', data=base64.b64encode(data).decode()))))
47
+ except (ValueError, wave.Error) as error: result['preview_error'] = str(error)
48
+ return result
49
+ if data.startswith(b'kiwi'):
50
+ result['type'] = 'script'; return result
51
+ try:
52
+ from PIL import Image
53
+ from exp_runtime.ui_assets import ImagePack
54
+ if data.startswith((b'\x89PNG', b'\xff\xd8')):
55
+ with Image.open(BytesIO(data)) as image:
56
+ result.update(type='image', width=image.width, height=image.height, frames=1)
57
+ else:
58
+ pack = ImagePack.parse(data)
59
+ result.update(type='image' if len(pack.images) == 1 else 'atlas', frames=len(pack.images),
60
+ width=pack.images[0].width if pack.images else 0,
61
+ height=pack.images[0].height if pack.images else 0)
62
+ except (ValueError, OSError): pass
63
+ return result
64
+
65
+
66
+ def thumbnail(data, *, frame=0, size=256):
67
+ from PIL import Image
68
+ from exp_runtime.ui_assets import ImagePack
69
+ if type(frame) is not int or frame < 0 or type(size) is not int or not 32 <= size <= 1024:
70
+ raise ProjectError('Use a nonnegative image frame and thumbnail size 32–1024')
71
+ if data.startswith((b'\x89PNG', b'\xff\xd8')):
72
+ if frame: raise ProjectError('This image has only frame 0')
73
+ with Image.open(BytesIO(data)) as original: image = original.convert('RGBA')
74
+ else:
75
+ pack = ImagePack.parse(data)
76
+ if frame >= len(pack.images): raise ProjectError('Frame does not exist in this image pack')
77
+ raster = pack.images[frame]
78
+ if raster.mode != 'RGBA': raise ProjectError('This image frame is a mask, not an RGBA image')
79
+ image = Image.frombytes('RGBA', (raster.width, raster.height), raster.pixels)
80
+ image.thumbnail((size, size))
81
+ output = BytesIO(); image.save(output, format='PNG')
82
+ return output.getvalue(), dict(width=image.width, height=image.height, frame=frame)
83
+
84
+
85
+ def audio_preview(data, *, start_ms=0, duration_ms=5000):
86
+ if type(start_ms) is not int or not 0 <= start_ms <= 3600000 or type(duration_ms) is not int or not 1 <= duration_ms <= 10000:
87
+ raise ProjectError('Audio start must be 0–3600000 ms and duration 1–10000 ms')
88
+ if not audio_type(data): raise ProjectError('This resource is not a recognized audio stream')
89
+ return worker(dict(mode='audio', data=base64.b64encode(data).decode(), start_ms=start_ms, duration_ms=duration_ms))
90
+
91
+
92
+ def render_playtest(debugger):
93
+ if debugger.context is None:
94
+ raise ProjectError('Start the playtest with an imported library for visual preview')
95
+ if debugger.session.pending is None:
96
+ raise ProjectError('Run to a visible input screen before rendering a preview')
97
+ return worker(dict(mode='render', library=str(debugger.project.library), game=debugger.project.game,
98
+ data=base64.b64encode(debugger.compilation.data).decode(), snapshot=debugger.session.snapshot()))
99
+
100
+
101
+ def main():
102
+ request = json.load(sys.stdin)
103
+ import pygame
104
+ data = base64.b64decode(request['data'], validate=True)
105
+ output = BytesIO()
106
+ try:
107
+ if request['mode'] in ('audio', 'audio_info'):
108
+ pygame.mixer.init(frequency=22050, size=-16, channels=2)
109
+ sound = pygame.mixer.Sound(file=BytesIO(data))
110
+ if request['mode'] == 'audio_info':
111
+ sys.stdout.write(json.dumps(dict(duration_ms=round(sound.get_length() * 1000)))); return
112
+ frequency, size, channels = pygame.mixer.get_init()
113
+ pcm = sound.get_raw()
114
+ stride = channels * abs(size) // 8
115
+ first = request['start_ms'] * frequency // 1000 * stride
116
+ length = request['duration_ms'] * frequency // 1000 * stride
117
+ if first >= len(pcm): raise ProjectError('Audio start is beyond the end of the resource')
118
+ clip = pcm[first:first + length]
119
+ if sys.byteorder == 'big':
120
+ from array import array
121
+ words = array('h'); words.frombytes(clip); words.byteswap(); clip = words.tobytes()
122
+ with wave.open(output, 'wb') as wav:
123
+ wav.setparams((channels, 2, frequency, 0, 'NONE', 'not compressed')); wav.writeframes(clip)
124
+ elif request['mode'] == 'render':
125
+ from .preview import preview_resources
126
+ from exp_runtime.runtime import Session
127
+ from exp_runtime.desktop import Desktop
128
+ with preview_resources(data, request['library'], request['game']) as resources:
129
+ session = Session.from_snapshot(resources, request['snapshot'])
130
+ pygame.display.init(); window = pygame.display.set_mode((480, 720))
131
+ app = Desktop(session, audio=False, window=window)
132
+ app.render()
133
+ if app.error: raise ProjectError(app.error)
134
+ pygame.image.save(app.canvas, output, 'preview.png')
135
+ else: raise ProjectError('Unknown media operation')
136
+ sys.stdout.buffer.write(output.getvalue())
137
+ finally:
138
+ pygame.quit()
139
+
140
+
141
+ if __name__ == '__main__':
142
+ try: main()
143
+ except (ValueError, OSError, ImportError) as error:
144
+ print(str(error), file=sys.stderr); sys.exit(1)