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,185 @@
|
|
|
1
|
+
"""Bounded, reproducible player-input workflows over the production debugger."""
|
|
2
|
+
from .model import ProjectError
|
|
3
|
+
|
|
4
|
+
ENDS = {'episode_exit', 'finished'}
|
|
5
|
+
INPUTS = {'choice', 'text_input', 'word_game', 'word_grid', 'football', 'character_picker'}
|
|
6
|
+
ACKNOWLEDGE = {'dialogue', 'presentation', 'message_panel', 'survey_confirmation'}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Playtest:
|
|
10
|
+
def __init__(self, debugger):
|
|
11
|
+
self.debugger = debugger
|
|
12
|
+
self.transcript = []
|
|
13
|
+
self.last_event = None
|
|
14
|
+
self.sequence = 0
|
|
15
|
+
self.error = None
|
|
16
|
+
|
|
17
|
+
def capture(self):
|
|
18
|
+
raw = self.debugger.state()
|
|
19
|
+
details = raw['details']
|
|
20
|
+
token = (raw['scene'], raw['pc'], self.debugger.session.vm.steps_executed,
|
|
21
|
+
raw['action'], details.get('page_start'))
|
|
22
|
+
if raw['action'] and token != self.last_event:
|
|
23
|
+
self.last_event = token
|
|
24
|
+
self.sequence += 1
|
|
25
|
+
self.transcript.append(dict(sequence=self.sequence, node_id=raw['node'], action=raw['action'],
|
|
26
|
+
speaker=details.get('speaker', details.get('title', '')), text=details.get('text', details.get('prompt', '')),
|
|
27
|
+
options=self.choices(raw), page_start=details.get('page_start')))
|
|
28
|
+
del self.transcript[:-200]
|
|
29
|
+
return raw
|
|
30
|
+
|
|
31
|
+
def choices(self, raw):
|
|
32
|
+
if raw['action'] != 'choice': return []
|
|
33
|
+
details = raw['details']
|
|
34
|
+
return [dict(id=f'{raw["node"]}:{value}', text=text, enabled=enabled, visible_index=index)
|
|
35
|
+
for index, (text, value, enabled) in enumerate(zip(details['options'], details['values'], details['enabled']))]
|
|
36
|
+
|
|
37
|
+
def state(self, *, verbose=False):
|
|
38
|
+
raw = self.capture()
|
|
39
|
+
action = raw['action']
|
|
40
|
+
choices = self.choices(raw)
|
|
41
|
+
actions = []
|
|
42
|
+
if action in ACKNOWLEDGE or action == 'vm_pause': actions.append('continue')
|
|
43
|
+
if action == 'choice': actions.append('choose')
|
|
44
|
+
if action == 'text_input': actions.append('answer_text')
|
|
45
|
+
if action in ('word_game', 'football', 'character_picker'): actions.append('answer')
|
|
46
|
+
if action == 'word_grid': actions.append('grid_pointer')
|
|
47
|
+
if action not in ENDS and action is not None: actions.append('tick')
|
|
48
|
+
if action is None: actions.extend(('run', 'step'))
|
|
49
|
+
result = dict(node_id=raw['node'], scene=raw['scene'], pc=raw['pc'], action=action,
|
|
50
|
+
paused=self.debugger.paused, variables=raw['variables'], text_variables=raw['text_variables'],
|
|
51
|
+
text=raw['details'].get('text', raw['details'].get('prompt', '')),
|
|
52
|
+
speaker=raw['details'].get('speaker', raw['details'].get('title', '')), choices=choices,
|
|
53
|
+
remaining_ms=self.debugger.session.remaining_ms, next_actions=actions,
|
|
54
|
+
visited_count=len(self.debugger.visited), seed=self.debugger.seed,
|
|
55
|
+
manual_overrides=list(self.debugger.overrides), error=self.error)
|
|
56
|
+
if action not in INPUTS | ACKNOWLEDGE | ENDS | {'loading', None}:
|
|
57
|
+
result.update(pending_arguments=raw['pending_arguments'], details=raw['details'])
|
|
58
|
+
if verbose: result['vm'] = raw
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
def perform(self, operation):
|
|
62
|
+
before = self.debugger.state()['variables']
|
|
63
|
+
sequence = self.sequence
|
|
64
|
+
self.error = None
|
|
65
|
+
try:
|
|
66
|
+
reason = operation()
|
|
67
|
+
except (ValueError, KeyError, IndexError) as error:
|
|
68
|
+
# A real VM/service failure is visible and stays suspended. Do not
|
|
69
|
+
# supply a guessed callback result or roll gameplay forward.
|
|
70
|
+
self.error = str(error)
|
|
71
|
+
reason = 'error'
|
|
72
|
+
result = self.state()
|
|
73
|
+
result.update(stop_reason=reason, variable_changes={key: dict(before=before.get(key), after=value)
|
|
74
|
+
for key, value in result['variables'].items() if before.get(key) != value},
|
|
75
|
+
transcript=[row for row in self.transcript if row['sequence'] > sequence],
|
|
76
|
+
transcript_truncated=self.sequence - sequence > 200)
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
def run(self, *, until='input', node_id=None, max_operations=500, max_ms=60000):
|
|
80
|
+
if until not in ('input', 'choice', 'end', 'node'): raise ProjectError('Use until input, choice, end or node')
|
|
81
|
+
if until == 'node' and node_id not in {n.id for n in self.debugger.project.nodes}:
|
|
82
|
+
raise ProjectError('Select an existing node as the stopping point')
|
|
83
|
+
if type(max_operations) is not int or not 1 <= max_operations <= 2000:
|
|
84
|
+
raise ProjectError('Operation budget must be 1–2000')
|
|
85
|
+
if type(max_ms) is not int or not 0 <= max_ms <= 60000:
|
|
86
|
+
raise ProjectError('Clock budget must be 0–60000 ms')
|
|
87
|
+
def execute():
|
|
88
|
+
elapsed = 0
|
|
89
|
+
original = set(self.debugger.breakpoints)
|
|
90
|
+
if until == 'node': self.debugger.breakpoints.add(node_id)
|
|
91
|
+
try:
|
|
92
|
+
for _ in range(max_operations):
|
|
93
|
+
raw = self.capture(); action = raw['action']
|
|
94
|
+
if action in ENDS: return 'end'
|
|
95
|
+
if self.debugger.paused and (raw['node'] == node_id or raw['node'] in original): return 'breakpoint'
|
|
96
|
+
if action in INPUTS: return 'choice' if action == 'choice' else 'input'
|
|
97
|
+
if until == 'input' and action in ACKNOWLEDGE: return 'input'
|
|
98
|
+
if action in ACKNOWLEDGE:
|
|
99
|
+
session = self.debugger.session
|
|
100
|
+
ready = (session.engine.dialogue_animation.complete if action == 'dialogue' else
|
|
101
|
+
session.engine.title_screen.ready if action == 'presentation' else
|
|
102
|
+
session.engine.message_panel.ready)
|
|
103
|
+
if ready:
|
|
104
|
+
self.debugger.resume(provide_answer=True)
|
|
105
|
+
else:
|
|
106
|
+
if elapsed >= max_ms: return 'clock_budget'
|
|
107
|
+
step = min(100, max_ms - elapsed)
|
|
108
|
+
self.debugger.resume(milliseconds=step); elapsed += step
|
|
109
|
+
elif action == 'loading':
|
|
110
|
+
if elapsed >= max_ms: return 'clock_budget'
|
|
111
|
+
step = min(100, max_ms - elapsed)
|
|
112
|
+
self.debugger.resume(milliseconds=step); elapsed += step
|
|
113
|
+
elif action is None:
|
|
114
|
+
self.debugger.resume()
|
|
115
|
+
else:
|
|
116
|
+
return 'unsupported' # Includes explicit VM pauses; only manual input may resume those.
|
|
117
|
+
return 'operation_budget'
|
|
118
|
+
finally:
|
|
119
|
+
self.debugger.breakpoints = original
|
|
120
|
+
return self.perform(execute)
|
|
121
|
+
|
|
122
|
+
def choose(self, option_id):
|
|
123
|
+
raw = self.debugger.state()
|
|
124
|
+
option = next((o for o in self.choices(raw) if o['id'] == option_id), None)
|
|
125
|
+
if option is None or not option['enabled']: raise ProjectError('Choose an enabled option ID from the current state')
|
|
126
|
+
return self.perform(lambda: self.debugger.resume(answer=option['visible_index'], provide_answer=True) and 'input')
|
|
127
|
+
|
|
128
|
+
def input(self, action, *, value=None, milliseconds=0, phase=None, cell=None):
|
|
129
|
+
if action not in ('continue', 'answer_text', 'answer', 'tick', 'step', 'grid_pointer'):
|
|
130
|
+
raise ProjectError('Unknown playtest input')
|
|
131
|
+
if type(milliseconds) is not int or not 0 <= milliseconds <= 60000:
|
|
132
|
+
raise ProjectError('Clock step must be 0–60000 ms')
|
|
133
|
+
def execute():
|
|
134
|
+
if action == 'tick':
|
|
135
|
+
if milliseconds: self.debugger.resume(milliseconds=milliseconds)
|
|
136
|
+
elif action == 'step': self.debugger.resume(step=True)
|
|
137
|
+
elif action == 'grid_pointer':
|
|
138
|
+
from .debugger import _Paused
|
|
139
|
+
if phase not in ('down', 'move', 'up', 'cancel'): raise ProjectError('Invalid pointer phase')
|
|
140
|
+
try: self.debugger.session.grid_pointer(phase, cell)
|
|
141
|
+
except _Paused: pass
|
|
142
|
+
else:
|
|
143
|
+
self.debugger.resume(answer=None if action == 'continue' else value, provide_answer=True)
|
|
144
|
+
return 'input'
|
|
145
|
+
return self.perform(execute)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_scenario(debugger, scenario):
|
|
149
|
+
"""Replay a declarative scenario. No eval, arbitrary code or fabricated results."""
|
|
150
|
+
steps = scenario.get('steps', [])
|
|
151
|
+
assertions = scenario.get('assertions', [])
|
|
152
|
+
if (not isinstance(steps, list) or len(steps) > 200 or not isinstance(assertions, list) or
|
|
153
|
+
len(assertions) > 200 or set(scenario) - {'seed', 'start', 'language', 'steps', 'assertions'}):
|
|
154
|
+
raise ProjectError('A scenario allows seed/start/language, up to 200 steps and 200 assertions')
|
|
155
|
+
test, results, failures = Playtest(debugger), [], []
|
|
156
|
+
for index, step in enumerate(steps):
|
|
157
|
+
try:
|
|
158
|
+
args = dict(step); action = args.pop('action')
|
|
159
|
+
if action == 'run': result = test.run(**args)
|
|
160
|
+
elif action == 'choose': result = test.choose(**args)
|
|
161
|
+
else: result = test.input(action, **args)
|
|
162
|
+
results.append(dict(step=index, action=result['action'], node_id=result['node_id'],
|
|
163
|
+
stop_reason=result['stop_reason'], variable_changes=result['variable_changes']))
|
|
164
|
+
if result['stop_reason'] in ('error', 'unsupported', 'operation_budget', 'clock_budget'):
|
|
165
|
+
failures.append(dict(step=index, message=result['error'] or result['stop_reason'])); break
|
|
166
|
+
except (TypeError, ValueError, KeyError) as error:
|
|
167
|
+
failures.append(dict(step=index, message=str(error))); break
|
|
168
|
+
state = test.state()
|
|
169
|
+
for index, assertion in enumerate(assertions):
|
|
170
|
+
kind = assertion.get('kind') if isinstance(assertion, dict) else None
|
|
171
|
+
if kind in ('variable', 'text_variable'):
|
|
172
|
+
values = state['variables' if kind == 'variable' else 'text_variables']
|
|
173
|
+
if assertion.get('name') not in values or 'equals' not in assertion:
|
|
174
|
+
failures.append(dict(assertion=index, message='Name must identify an existing variable and equals is required')); continue
|
|
175
|
+
actual, expected = values[assertion['name']], assertion['equals']
|
|
176
|
+
elif kind == 'node': actual, expected = state['node_id'], assertion.get('equals')
|
|
177
|
+
elif kind == 'action': actual, expected = state['action'], assertion.get('equals')
|
|
178
|
+
elif kind == 'visited': actual, expected = assertion.get('node_id') in debugger.visited, True
|
|
179
|
+
elif kind == 'ended': actual, expected = state['action'] in ENDS, True
|
|
180
|
+
else:
|
|
181
|
+
failures.append(dict(assertion=index, message='Unknown assertion kind')); continue
|
|
182
|
+
if actual != expected:
|
|
183
|
+
failures.append(dict(assertion=index, message='Assertion failed', actual=actual, expected=expected))
|
|
184
|
+
return dict(passed=not failures, failures=failures, steps=results, state=state, transcript=test.transcript,
|
|
185
|
+
coverage=dict(visited=sorted(debugger.visited), total=len(debugger.project.nodes)))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Run an exported episode with the production player and isolated saves."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from copy import copy
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tempfile
|
|
6
|
+
|
|
7
|
+
from exp_runtime.content import ContentLibrary, EpisodeResources, digest
|
|
8
|
+
from exp_runtime.exp import ExpArchive
|
|
9
|
+
from .model import ProjectError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@contextmanager
|
|
13
|
+
def preview_resources(data, library_path, game):
|
|
14
|
+
archive = ExpArchive(data)
|
|
15
|
+
meta = archive.metadata()
|
|
16
|
+
with ContentLibrary(Path(library_path)) as library:
|
|
17
|
+
if library.game_id != game:
|
|
18
|
+
raise ProjectError('Preview library does not match the project game')
|
|
19
|
+
with tempfile.TemporaryDirectory(prefix='exp-creator-preview-') as temporary:
|
|
20
|
+
isolated = copy(library)
|
|
21
|
+
isolated.directory = Path(temporary)
|
|
22
|
+
record = dict(id=digest(data), sha256=digest(data), name=meta.title + '.exp',
|
|
23
|
+
titles=list(meta.titles), pack_id=meta.pack_id, episode_id=meta.episode_id)
|
|
24
|
+
yield EpisodeResources(isolated, record, archive)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def play_preview(path, library_path, game):
|
|
28
|
+
from exp_runtime.desktop import play
|
|
29
|
+
with preview_resources(Path(path).read_bytes(), library_path, game) as resources:
|
|
30
|
+
play(resources)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Small, discoverable authoring contracts and authored examples."""
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
|
|
5
|
+
from .features import DEFAULTS, NAMES
|
|
6
|
+
from .model import Node, ProjectError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
FIELDS = {
|
|
10
|
+
'dialogue': ('text', 'speaker', 'portrait', 'theme', 'character', 'expression', 'next'),
|
|
11
|
+
'choice': ('text', 'speaker', 'theme', 'character', 'options', 'timeout_ms', 'timeout_option'),
|
|
12
|
+
'background': ('asset', 'next'), 'set': ('variable', 'value', 'next'),
|
|
13
|
+
'branch': ('variable', 'operator', 'value', 'next', 'otherwise'),
|
|
14
|
+
'text_branch': ('next', 'otherwise'), 'relationship': ('character', 'next'),
|
|
15
|
+
'input': ('text', 'next'), 'title': ('text', 'next'), 'message': ('text', 'next'),
|
|
16
|
+
'notice': ('text', 'next'), 'scene_label': ('text', 'next'),
|
|
17
|
+
'word_game': ('text', 'character', 'next'), 'grid': ('next',), 'football': ('next',),
|
|
18
|
+
'end': (), 'native': (),
|
|
19
|
+
}
|
|
20
|
+
NOTES = {
|
|
21
|
+
'dialogue': 'Use character for a reusable speaker, or speaker/portrait for an inline speaker. Text must be nonempty.',
|
|
22
|
+
'choice': 'Provide 2–8 nonempty answers. Conditions enable answers; timeout_option is a zero-based visible row; native shuffling moves labels/return values, while enabled flags remain on rows.',
|
|
23
|
+
'background': 'asset must reference an imported image in this project.',
|
|
24
|
+
'set': 'variable is required; a nonempty params.expression overrides value.',
|
|
25
|
+
'branch': 'params.expression overrides variable/operator/value. Both destinations are required.',
|
|
26
|
+
'relationship': 'character is required. Changes the native relationship property, with the real emotion animation.',
|
|
27
|
+
'score': 'Choose the game/script property owner and key explicitly. There is no universal score property; owner -30000 is reserved.',
|
|
28
|
+
'input': 'Stores a text variable. Native input uses the selected game\'s character and width limits.',
|
|
29
|
+
'random': 'bound must be positive. Stores an actual native random draw in result.',
|
|
30
|
+
'music': 'Music IDs are 8201–8232 in SHS and 8201–8209 in CoD. Some SHS IDs select offsets in another audio asset.',
|
|
31
|
+
'word_game': 'good needs at least 2 words; bad at least 3. Duration 1–32767 ms. Words cannot contain pipes.',
|
|
32
|
+
'football': 'Six interleaved offense/defense tables, each 1–10 rows of [code, yards, variation, weight]. See defaults for a valid example.',
|
|
33
|
+
'grid': 'Problems define dimensions, targets and alphabet. Tutorials use a fixed board. Native limits and table checks apply.',
|
|
34
|
+
'end': 'Uses native episode-exit service 7. The player returns to its existing menu, where another installed episode can be selected.',
|
|
35
|
+
'native': 'Imported native projects only. Preserve script/PC, instruction counts and data-bank lengths; no high-level reconstruction is assumed.',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def value_schema(value):
|
|
40
|
+
if value is None: return {'type': ['integer', 'null'], 'minimum': 0, 'maximum': 32767}
|
|
41
|
+
kind = {str: 'string', int: 'integer', float: 'number', bool: 'boolean', dict: 'object', list: 'array'}[type(value)]
|
|
42
|
+
result = dict(type=kind, default=deepcopy(value))
|
|
43
|
+
if kind == 'integer': result.update(minimum=-32768, maximum=32767)
|
|
44
|
+
if kind == 'array' and value: result['items'] = value_schema(value[0])
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def node_schema(kind=None):
|
|
49
|
+
if kind is None:
|
|
50
|
+
return dict(kinds=[dict(kind=k, title=v, games=['shs'] if k in ('football', 'grid') else ['shs', 'cod'])
|
|
51
|
+
for k, v in NAMES.items()],
|
|
52
|
+
languages=['en', 'fr', 'it', 'de', 'es'],
|
|
53
|
+
expressions='Integers, variables, + - * // %, comparisons, and/or/not; signed 16-bit arithmetic, no calls.',
|
|
54
|
+
workflow='project_get → graph_search_nodes/graph_get_subgraph → project_preview_edits → project_apply_edits → project_validate → playtest → project_export_exp',
|
|
55
|
+
compatibility='Native KiWi/CSPUD export; full original-app playback remains unverified. Consult each kind for restrictions.',
|
|
56
|
+
resources=['creator://schema/{kind}', 'creator://examples/branching'],
|
|
57
|
+
limits=dict(nodes=20000, characters=199, options=8, batch_operations=200))
|
|
58
|
+
if kind not in NAMES: raise ProjectError('Unknown node kind')
|
|
59
|
+
defaults = asdict(Node('example', kind=kind))
|
|
60
|
+
names = ('id', 'kind', 'label', 'x', 'y', 'scene', *FIELDS.get(kind, ('next',)))
|
|
61
|
+
properties = {name: value_schema(defaults[name]) for name in names}
|
|
62
|
+
properties['id'].update(minLength=1, maxLength=200, description='Stable, unique node ID; use readable names such as school_arrival.')
|
|
63
|
+
properties['kind'] = {'const': kind}
|
|
64
|
+
properties['scene'].update(minimum=25001, maximum=25999)
|
|
65
|
+
properties['x'] = properties['y'] = dict(type='number', minimum=-100000, maximum=100000, default=0)
|
|
66
|
+
for name in ('theme', 'expression'):
|
|
67
|
+
if name in properties: properties[name].update(minimum=0, maximum=3 if name == 'theme' else 4)
|
|
68
|
+
if 'options' in properties:
|
|
69
|
+
properties['options'].update(minItems=2, maxItems=8, items=dict(type='object', additionalProperties=False,
|
|
70
|
+
properties=dict(text=dict(type='string', minLength=1), target=dict(type='string', minLength=1),
|
|
71
|
+
condition=dict(type='string', default='')), required=['text', 'target']))
|
|
72
|
+
properties['timeout_ms'].update(minimum=0, description='0 disables the timer; otherwise milliseconds, maximum 32767.')
|
|
73
|
+
properties['timeout_option'].update(minimum=0, maximum=7)
|
|
74
|
+
properties['params'] = dict(type='object', additionalProperties=False,
|
|
75
|
+
properties={key: value_schema(value) for key, value in DEFAULTS[kind].items()}, default=deepcopy(DEFAULTS[kind]))
|
|
76
|
+
if kind == 'native':
|
|
77
|
+
properties['params'] = dict(type='object', description='Use native_get_data and graph_get_subgraph to inspect the imported block before editing.')
|
|
78
|
+
example = dict(id='example', kind=kind, params=deepcopy(DEFAULTS[kind]))
|
|
79
|
+
if 'next' in properties: example['next'] = 'end'
|
|
80
|
+
if 'otherwise' in properties: example['otherwise'] = 'alternate_end'
|
|
81
|
+
if 'text' in properties: example['text'] = 'An authored example.'
|
|
82
|
+
if kind in ('set', 'branch'): example['variable'] = 'points'
|
|
83
|
+
if kind == 'choice':
|
|
84
|
+
example['options'] = [dict(text='Help', target='help'), dict(text='Leave', target='end')]
|
|
85
|
+
if kind == 'relationship': example['character'] = 'friend'
|
|
86
|
+
if kind == 'background': example['asset'] = 26000
|
|
87
|
+
return dict(kind=kind, title=NAMES[kind], games=['shs'] if kind in ('football', 'grid') else ['shs', 'cod'],
|
|
88
|
+
schema=dict(type='object', properties=properties, required=['id', 'kind'], additionalProperties=False),
|
|
89
|
+
notes=NOTES.get(kind, 'Uses the shared native compiler and host-service contract.'), example=example,
|
|
90
|
+
compatibility='native_format',
|
|
91
|
+
original_app_playback='unverified')
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def branching_example():
|
|
95
|
+
return dict(description='Apply to a new default project; uses no imported game assets.', operations=[
|
|
96
|
+
dict(op='node', id='start', changes=dict(text='A friend needs help.', next='decision')),
|
|
97
|
+
dict(op='node', id='decision', changes=dict(kind='choice', text='What do you do?', options=[
|
|
98
|
+
dict(text='Help', target='reward'), dict(text='Leave', target='end')])),
|
|
99
|
+
dict(op='node', id='reward', changes=dict(kind='set', variable='points', value=1, next='end'))],
|
|
100
|
+
scenario=dict(seed=7, steps=[dict(action='run', until='choice'), dict(action='choose', option_id='decision:0'),
|
|
101
|
+
dict(action='run', until='end')],
|
|
102
|
+
assertions=[dict(kind='variable', name='points', equals=1), dict(kind='ended')]))
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Local desktop discovery, bounded JSON transport and cooperative file locks.
|
|
2
|
+
|
|
3
|
+
The desktop owns its live document. MCP clients read snapshots and submit a
|
|
4
|
+
revision-checked replacement; an unavailable desktop never falls back to disk.
|
|
5
|
+
This module has no Qt or MCP dependency.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import secrets
|
|
13
|
+
import socket
|
|
14
|
+
import struct
|
|
15
|
+
|
|
16
|
+
from .model import (MAX_PROJECT_BYTES, Project, ProjectError, atomic_write, project_bytes,
|
|
17
|
+
project_from_bytes, project_from_record)
|
|
18
|
+
|
|
19
|
+
MAX_MESSAGE = MAX_PROJECT_BYTES * 2
|
|
20
|
+
TIMEOUT = 15
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SharingError(ProjectError):
|
|
24
|
+
code = 'desktop_unavailable'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProjectBusy(SharingError):
|
|
28
|
+
code = 'project_busy'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def digest(data):
|
|
32
|
+
return hashlib.sha256(data).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def state_path(path, suffix):
|
|
36
|
+
path = Path(path).resolve()
|
|
37
|
+
folder = path.parent / '.creator-session'
|
|
38
|
+
if folder.is_symlink(): raise SharingError('The creator session directory must not be a symlink')
|
|
39
|
+
return folder / (digest(path.name.encode()) + suffix)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FileLock:
|
|
43
|
+
"""Nonblocking process lock; the OS releases it if the owner crashes."""
|
|
44
|
+
def __init__(self, path):
|
|
45
|
+
self.path, self.stream = Path(path), None
|
|
46
|
+
|
|
47
|
+
def __enter__(self):
|
|
48
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
49
|
+
if self.path.is_symlink(): raise SharingError('Creator lock files must not be symlinks')
|
|
50
|
+
self.stream = open(self.path, 'a+b')
|
|
51
|
+
if not self.stream.seek(0, 2):
|
|
52
|
+
self.stream.write(b'\0'); self.stream.flush()
|
|
53
|
+
self.stream.seek(0)
|
|
54
|
+
try:
|
|
55
|
+
if os.name == 'nt':
|
|
56
|
+
import msvcrt
|
|
57
|
+
msvcrt.locking(self.stream.fileno(), msvcrt.LK_NBLCK, 1)
|
|
58
|
+
else:
|
|
59
|
+
import fcntl
|
|
60
|
+
fcntl.flock(self.stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
61
|
+
except OSError as error:
|
|
62
|
+
self.stream.close(); self.stream = None
|
|
63
|
+
raise ProjectBusy('Project is busy in another operation or desktop window; retry after it finishes.') from error
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, *args):
|
|
67
|
+
if self.stream is not None:
|
|
68
|
+
self.stream.close()
|
|
69
|
+
self.stream = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def guard(path):
|
|
73
|
+
if not Path(path).resolve().parent.is_dir(): raise ProjectError('Project directory must already exist')
|
|
74
|
+
return FileLock(state_path(path, '.lock'))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def session_info(path):
|
|
78
|
+
marker = state_path(path, '.json')
|
|
79
|
+
try:
|
|
80
|
+
if marker.is_symlink() or marker.stat().st_size > 4096:
|
|
81
|
+
raise ValueError('Invalid desktop session file')
|
|
82
|
+
record = json.loads(marker.read_bytes())
|
|
83
|
+
if (not isinstance(record, dict) or record.get('version') != 1
|
|
84
|
+
or record.get('path') != str(Path(path).resolve())
|
|
85
|
+
or type(record.get('port')) is not int or not 1 <= record['port'] <= 65535
|
|
86
|
+
or not isinstance(record.get('token'), str) or len(record['token']) != 64):
|
|
87
|
+
raise ValueError('Invalid desktop session record')
|
|
88
|
+
return record
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
return None
|
|
91
|
+
except (OSError, ValueError) as error:
|
|
92
|
+
raise SharingError('Cannot read the desktop session. Reopen the project in the creator.') from error
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Lease:
|
|
96
|
+
"""One desktop window may own a saved project at a time."""
|
|
97
|
+
def __init__(self, path, port):
|
|
98
|
+
self.path = Path(path).resolve()
|
|
99
|
+
self.owner = FileLock(state_path(self.path, '.owner'))
|
|
100
|
+
self.record = dict(version=1, path=str(self.path), port=port, token=secrets.token_hex(32))
|
|
101
|
+
with guard(self.path):
|
|
102
|
+
self.owner.__enter__()
|
|
103
|
+
try:
|
|
104
|
+
atomic_write(state_path(self.path, '.json'), json.dumps(self.record).encode())
|
|
105
|
+
except BaseException:
|
|
106
|
+
self.owner.__exit__()
|
|
107
|
+
raise
|
|
108
|
+
|
|
109
|
+
def close(self):
|
|
110
|
+
if self.owner.stream is not None:
|
|
111
|
+
# The owner lock prevents another window replacing this record.
|
|
112
|
+
try: state_path(self.path, '.json').unlink(missing_ok=True)
|
|
113
|
+
finally: self.owner.__exit__()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def encode_message(record):
|
|
117
|
+
data = json.dumps(record, ensure_ascii=False, allow_nan=False).encode()
|
|
118
|
+
if len(data) > MAX_MESSAGE: raise ProjectError('Desktop request exceeds the project size limit')
|
|
119
|
+
return struct.pack('>I', len(data)) + data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _receive(stream, size):
|
|
123
|
+
data = bytearray()
|
|
124
|
+
while len(data) < size:
|
|
125
|
+
part = stream.recv(min(size - len(data), 1024 * 1024))
|
|
126
|
+
if not part: raise OSError('Desktop connection closed')
|
|
127
|
+
data.extend(part)
|
|
128
|
+
return bytes(data)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def request(info, action, **values):
|
|
132
|
+
try:
|
|
133
|
+
with socket.create_connection(('127.0.0.1', info['port']), timeout=TIMEOUT) as stream:
|
|
134
|
+
stream.sendall(encode_message(dict(token=info['token'], path=info['path'], action=action, **values)))
|
|
135
|
+
size, = struct.unpack('>I', _receive(stream, 4))
|
|
136
|
+
if not 0 < size <= MAX_MESSAGE: raise ValueError('Invalid desktop response size')
|
|
137
|
+
response = json.loads(_receive(stream, size))
|
|
138
|
+
if not isinstance(response, dict): raise ValueError('Invalid desktop response')
|
|
139
|
+
except (OSError, ValueError) as error:
|
|
140
|
+
raise SharingError('Desktop session is unavailable; reopen the project in the creator, then reread it '
|
|
141
|
+
'before retrying. No fallback file write was attempted.') from error
|
|
142
|
+
if 'error' in response:
|
|
143
|
+
kind = ProjectBusy if response.get('code') == 'project_busy' else ProjectError
|
|
144
|
+
raise kind(response['error'])
|
|
145
|
+
return response
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class Snapshot:
|
|
150
|
+
project: Project
|
|
151
|
+
revision: str
|
|
152
|
+
data: bytes
|
|
153
|
+
live: bool = False
|
|
154
|
+
unsaved: bool = False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def snapshot(path):
|
|
158
|
+
path = Path(path).resolve()
|
|
159
|
+
with guard(path):
|
|
160
|
+
info = session_info(path)
|
|
161
|
+
if info is None:
|
|
162
|
+
if path.stat().st_size > MAX_PROJECT_BYTES: raise ProjectError('Project file exceeds 96 MiB')
|
|
163
|
+
data = path.read_bytes()
|
|
164
|
+
return Snapshot(project_from_bytes(data, path), digest(data), data)
|
|
165
|
+
response = request(info, 'read')
|
|
166
|
+
project = project_from_record(response['project'])
|
|
167
|
+
data = project_bytes(project, path)
|
|
168
|
+
if digest(data) != response['revision']: raise SharingError('Desktop snapshot checksum does not match')
|
|
169
|
+
return Snapshot(project, response['revision'], data, True, response['unsaved'])
|