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.
- exp_runtime/creator/__init__.py +8 -0
- exp_runtime/creator/__main__.py +69 -0
- exp_runtime/creator/assets.py +56 -0
- exp_runtime/creator/catalog.py +30 -0
- exp_runtime/creator/compiler.py +448 -0
- exp_runtime/creator/debugger.py +123 -0
- exp_runtime/creator/diagnostics.py +86 -0
- exp_runtime/creator/editing.py +80 -0
- exp_runtime/creator/expressions.py +81 -0
- exp_runtime/creator/features.py +90 -0
- exp_runtime/creator/graph.py +124 -0
- exp_runtime/creator/kiwi-hammer.svg +73 -0
- exp_runtime/creator/live.py +116 -0
- exp_runtime/creator/mcp_results.py +53 -0
- exp_runtime/creator/mcp_server.py +428 -0
- exp_runtime/creator/mcp_types.py +49 -0
- exp_runtime/creator/media.py +144 -0
- exp_runtime/creator/minigames.py +149 -0
- exp_runtime/creator/model.py +377 -0
- exp_runtime/creator/native.py +174 -0
- exp_runtime/creator/operations.py +83 -0
- exp_runtime/creator/panels.py +221 -0
- exp_runtime/creator/playtest.py +185 -0
- exp_runtime/creator/preview.py +30 -0
- exp_runtime/creator/schema.py +102 -0
- exp_runtime/creator/sharing.py +169 -0
- exp_runtime/creator/ui.py +1041 -0
- exp_runtime/creator/workspace.py +271 -0
- exp_runtime_creator-0.6.0.dist-info/METADATA +75 -0
- exp_runtime_creator-0.6.0.dist-info/RECORD +34 -0
- exp_runtime_creator-0.6.0.dist-info/WHEEL +5 -0
- exp_runtime_creator-0.6.0.dist-info/entry_points.txt +5 -0
- exp_runtime_creator-0.6.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_creator-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|