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,123 @@
|
|
|
1
|
+
"""Source-node stepping and breakpoints using the production VM and services."""
|
|
2
|
+
from dataclasses import asdict
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
from exp_runtime.exp import ExpArchive
|
|
7
|
+
from .compiler import VARIABLE_OWNER, compile_details
|
|
8
|
+
from .model import ProjectError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Paused(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DebugSession:
|
|
16
|
+
def __init__(self, project, *, start=None, seed=None, library=None, language=None):
|
|
17
|
+
from exp_runtime.runtime import Session
|
|
18
|
+
from exp_runtime.content import digest
|
|
19
|
+
from exp_runtime.vm import StopKind
|
|
20
|
+
self.project = project
|
|
21
|
+
self.context = None
|
|
22
|
+
self.compilation = compile_details(project, start=start, language=language)
|
|
23
|
+
self.archive = ExpArchive(self.compilation.data)
|
|
24
|
+
programs = self.archive.programs()
|
|
25
|
+
self.resources = SimpleNamespace(library=SimpleNamespace(game_id=project.game, directory=Path('.')),
|
|
26
|
+
identity={'profile': 'creator-debug', 'episode_sha256': digest(self.compilation.data)},
|
|
27
|
+
program=programs.__getitem__, exists=lambda key: key in self.archive.entries or
|
|
28
|
+
any(a.id == key for a in project.assets))
|
|
29
|
+
if library:
|
|
30
|
+
from .preview import preview_resources
|
|
31
|
+
self.context = preview_resources(self.compilation.data, library, project.game)
|
|
32
|
+
self.resources = self.context.__enter__()
|
|
33
|
+
try: self.session = Session(self.resources)
|
|
34
|
+
except Exception:
|
|
35
|
+
self.close()
|
|
36
|
+
raise
|
|
37
|
+
if seed is not None:
|
|
38
|
+
if type(seed) is not int or not 0 <= seed <= 0xffffffff:
|
|
39
|
+
self.close()
|
|
40
|
+
raise ProjectError('Seed must be an unsigned 32-bit integer')
|
|
41
|
+
self.session.engine.random.state = seed
|
|
42
|
+
self.session.engine.random48.state = (seed << 16) | 0x330e
|
|
43
|
+
self.seed = seed
|
|
44
|
+
self.locations = {script: {pc: node for node, pc in entries.items()}
|
|
45
|
+
for script, entries in self.compilation.locations.items()}
|
|
46
|
+
self.breakpoints = set()
|
|
47
|
+
self.trace = []
|
|
48
|
+
self.visited = set()
|
|
49
|
+
self.overrides = []
|
|
50
|
+
self.paused = False
|
|
51
|
+
self._skip = None
|
|
52
|
+
self._step = True
|
|
53
|
+
original_run = self.session.vm.run
|
|
54
|
+
def run(budget=100000):
|
|
55
|
+
for _ in range(budget):
|
|
56
|
+
position = (self.session.scene, self.session.vm.pc)
|
|
57
|
+
node = self.locations.get(position[0], {}).get(position[1])
|
|
58
|
+
if node and position != self._skip and (self._step or node in self.breakpoints):
|
|
59
|
+
self._skip = position
|
|
60
|
+
self.paused = True
|
|
61
|
+
raise _Paused()
|
|
62
|
+
self._skip = None
|
|
63
|
+
result = original_run(1)
|
|
64
|
+
if node:
|
|
65
|
+
self.visited.add(node)
|
|
66
|
+
self.trace.append(node)
|
|
67
|
+
del self.trace[:-500]
|
|
68
|
+
if result.kind != StopKind.BUDGET: return result
|
|
69
|
+
return result
|
|
70
|
+
self.session.vm.run = run
|
|
71
|
+
try: self.resume(step=True)
|
|
72
|
+
except Exception:
|
|
73
|
+
self.close()
|
|
74
|
+
raise
|
|
75
|
+
|
|
76
|
+
def resume(self, *, step=False, answer=None, provide_answer=False, milliseconds=0):
|
|
77
|
+
self.paused = False
|
|
78
|
+
self._step = step
|
|
79
|
+
try:
|
|
80
|
+
if milliseconds:
|
|
81
|
+
self.session.tick(milliseconds)
|
|
82
|
+
elif provide_answer:
|
|
83
|
+
self.session.answer(answer)
|
|
84
|
+
else:
|
|
85
|
+
self.session.advance()
|
|
86
|
+
except _Paused:
|
|
87
|
+
pass
|
|
88
|
+
return self.state()
|
|
89
|
+
|
|
90
|
+
def state(self):
|
|
91
|
+
session = self.session
|
|
92
|
+
pc = session.pending.request.pc if session.pending else session.vm.pc
|
|
93
|
+
entries = self.locations.get(session.scene, {})
|
|
94
|
+
before = [location for location in entries if location <= pc]
|
|
95
|
+
node = entries[max(before)] if before else None
|
|
96
|
+
values = {}
|
|
97
|
+
for name, address in self.compilation.variables.items():
|
|
98
|
+
values[name] = (session.engine.numbers.get(session.engine.number_key(VARIABLE_OWNER, address), 0)
|
|
99
|
+
if self.compilation.shared_variables else session.vm.data[address])
|
|
100
|
+
pending = session.pending
|
|
101
|
+
return {'node': node, 'scene': session.scene, 'pc': pc, 'variables': values,
|
|
102
|
+
'text_variables': dict(session.engine.strings),
|
|
103
|
+
'action': pending.name if pending else None,
|
|
104
|
+
'details': pending.details if pending else {}, 'trace': list(self.trace),
|
|
105
|
+
'minigames': {name: asdict(value) for name in ('word_game', 'football', 'word_grid')
|
|
106
|
+
if (value := getattr(session.engine, name)) is not None},
|
|
107
|
+
'stack': list(session.vm.stack), 'result': session.vm.result,
|
|
108
|
+
'pending_arguments': list(session.vm.pending.args) if session.vm.pending else []}
|
|
109
|
+
|
|
110
|
+
def set_variable(self, name, value):
|
|
111
|
+
if name not in self.compilation.variables or type(value) is not int or not -32768 <= value <= 32767:
|
|
112
|
+
raise ProjectError('Choose an existing numeric variable and signed 16-bit value')
|
|
113
|
+
address = self.compilation.variables[name]
|
|
114
|
+
if self.compilation.shared_variables:
|
|
115
|
+
self.session.engine.numbers[self.session.engine.number_key(VARIABLE_OWNER, address)] = value
|
|
116
|
+
else:
|
|
117
|
+
self.session.vm.write_word(address, value)
|
|
118
|
+
self.overrides.append(dict(variable=name, value=value))
|
|
119
|
+
|
|
120
|
+
def close(self):
|
|
121
|
+
if self.context is not None:
|
|
122
|
+
self.context.__exit__(None, None, None)
|
|
123
|
+
self.context = None
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Recoverable authoring diagnostics using the same checks as EXP compilation."""
|
|
2
|
+
from collections import deque
|
|
3
|
+
|
|
4
|
+
from .compiler import compile_details, validate_node
|
|
5
|
+
from .features import text_fields, parameters
|
|
6
|
+
from .graph import edges
|
|
7
|
+
from .model import ProjectError, validate_structure
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def diagnose(project):
|
|
11
|
+
issues = []
|
|
12
|
+
def issue(code, message, *, node=None, field=None, severity='error', suggestion=''):
|
|
13
|
+
issues.append(dict(code=code, severity=severity, node_id=node, field=field,
|
|
14
|
+
message=str(message), suggestion=suggestion))
|
|
15
|
+
try: validate_structure(project)
|
|
16
|
+
except ProjectError as error:
|
|
17
|
+
issue('invalid_structure', error, suggestion='Correct the document types and limits before checking execution.')
|
|
18
|
+
return dict(valid=False, issues=issues)
|
|
19
|
+
nodes = {n.id: n for n in project.nodes}
|
|
20
|
+
assets = {a.id: a for a in project.assets}
|
|
21
|
+
characters = {c.id: c for c in project.characters}
|
|
22
|
+
if not project.native:
|
|
23
|
+
if project.start not in nodes:
|
|
24
|
+
issue('missing_start', 'Starting node does not exist', field='start', suggestion='Select an existing node ID.')
|
|
25
|
+
for edge in edges(project):
|
|
26
|
+
if edge['target'] not in nodes:
|
|
27
|
+
issue('missing_target', f'Destination {edge["target"]!r} does not exist', node=edge['source'],
|
|
28
|
+
field=edge['field'], suggestion='Connect this edge to an existing node.')
|
|
29
|
+
for character in project.characters:
|
|
30
|
+
for field, resource in [('portrait', character.portrait), *[(f'expressions.{i}', v) for i, v in enumerate(character.expressions)]]:
|
|
31
|
+
if resource is not None and resource not in assets:
|
|
32
|
+
issue('missing_asset', f'Character {character.id} references image {resource}',
|
|
33
|
+
field=f'characters.{character.id}.{field}', suggestion='Import that image or clear the reference.')
|
|
34
|
+
for node in project.nodes:
|
|
35
|
+
try: validate_node(project, node, nodes, assets, characters)
|
|
36
|
+
except (ProjectError, TypeError, ValueError, KeyError) as error:
|
|
37
|
+
if not any(i['node_id'] == node.id and i['code'] == 'missing_target' for i in issues):
|
|
38
|
+
field = ('text' if node.kind in ('dialogue', 'choice') and not node.text.strip() else
|
|
39
|
+
'character' if node.character and node.character not in characters else 'params')
|
|
40
|
+
issue('invalid_node', error, node=node.id, field=field,
|
|
41
|
+
suggestion=f'Consult schema_get for the {node.kind} node requirements.')
|
|
42
|
+
from .expressions import parse
|
|
43
|
+
expressions = [(f'options.{i}.condition', o.condition) for i, o in enumerate(node.options)]
|
|
44
|
+
if node.kind in ('set', 'branch'): expressions.append(('params.expression', parameters(node)['expression']))
|
|
45
|
+
for field, expression in expressions:
|
|
46
|
+
if expression:
|
|
47
|
+
try: parse(expression)
|
|
48
|
+
except (ProjectError, TypeError, ValueError) as error:
|
|
49
|
+
issue('invalid_expression', error, node=node.id, field=field,
|
|
50
|
+
suggestion='Use integers, variable names, arithmetic, comparisons and and/or/not.')
|
|
51
|
+
if node.kind == 'choice' and node.options and all(o.condition for o in node.options):
|
|
52
|
+
issue('conditional_choices', 'Every answer has a condition; some states may offer no enabled answer.',
|
|
53
|
+
node=node.id, field='options', severity='warning', suggestion='Add an unconditional answer or test all relevant states.')
|
|
54
|
+
try:
|
|
55
|
+
texts = text_fields(project)
|
|
56
|
+
for key, original in texts.items():
|
|
57
|
+
value = project.locales.get(project.language, {}).get(key, original)
|
|
58
|
+
try: value.encode('latin-1')
|
|
59
|
+
except (AttributeError, UnicodeError):
|
|
60
|
+
node_id = next((n.id for n in project.nodes if key.startswith(f'node.{n.id}.')), None)
|
|
61
|
+
issue('text_encoding', 'Text cannot be encoded in native Latin-1', node=node_id, field=key,
|
|
62
|
+
suggestion='Use Latin-1 characters in the selected export language.')
|
|
63
|
+
for language, translations in project.locales.items():
|
|
64
|
+
for key in translations.keys() - texts.keys():
|
|
65
|
+
issue('unused_translation', f'{language}: unknown translation key', field=key, severity='warning',
|
|
66
|
+
suggestion='Remove or rename this translation to match a current field key.')
|
|
67
|
+
except (KeyError, TypeError, ValueError):
|
|
68
|
+
pass # Invalid structured tables already have a node diagnostic.
|
|
69
|
+
reached, queue = set(), deque([project.start])
|
|
70
|
+
while queue:
|
|
71
|
+
key = queue.popleft()
|
|
72
|
+
if key not in nodes or key in reached: continue
|
|
73
|
+
reached.add(key); queue.extend(target for _, target in nodes[key].links())
|
|
74
|
+
if not project.native:
|
|
75
|
+
for key in nodes.keys() - reached:
|
|
76
|
+
issue('unreachable_node', 'No graph path from the starting node', node=key, severity='warning',
|
|
77
|
+
suggestion='Connect this node or remove it if it is no longer needed.')
|
|
78
|
+
compilation = None
|
|
79
|
+
try: compilation = compile_details(project)
|
|
80
|
+
except (ProjectError, TypeError, ValueError, KeyError) as error:
|
|
81
|
+
if not any(i['severity'] == 'error' for i in issues):
|
|
82
|
+
issue('compile_error', error, suggestion='Correct the compiler error before export; no output was written.')
|
|
83
|
+
result = dict(valid=not any(i['severity'] == 'error' for i in issues), issues=issues,
|
|
84
|
+
coverage=dict(reachable=len(reached), total=len(nodes), analysis='structural'))
|
|
85
|
+
if compilation: result.update(bytes=len(compilation.data), scripts=sorted(compilation.locations))
|
|
86
|
+
return result
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Shared, UI-independent graph edits and crash-recovery documents."""
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
from .model import (Choice, Node, ProjectError, atomic_write, project_from_record,
|
|
9
|
+
project_record, validate_structure)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def copy_nodes(project, selected):
|
|
13
|
+
if project.native:
|
|
14
|
+
raise ProjectError('Native blocks retain fixed PCs and cannot be duplicated')
|
|
15
|
+
return json.dumps({'format': 'exp-creator-nodes', 'nodes': [asdict(n) for n in project.nodes if n.id in selected]})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def paste_nodes(project, text):
|
|
19
|
+
try:
|
|
20
|
+
data = json.loads(text)
|
|
21
|
+
if data['format'] != 'exp-creator-nodes' or not 1 <= len(data['nodes']) <= 2000 or project.native:
|
|
22
|
+
raise ValueError('unsupported clipboard')
|
|
23
|
+
candidate = deepcopy(project)
|
|
24
|
+
ids = {record['id']: uuid.uuid4().hex[:12] for record in data['nodes']}
|
|
25
|
+
if len(ids) != len(data['nodes']): raise ValueError('duplicate source IDs')
|
|
26
|
+
for record in data['nodes']:
|
|
27
|
+
node = Node(**dict(record, options=[Choice(**o) for o in record['options']]))
|
|
28
|
+
node.id = ids[node.id]
|
|
29
|
+
node.x += 40; node.y += 40
|
|
30
|
+
node.next, node.otherwise = ids.get(node.next, node.next), ids.get(node.otherwise, node.otherwise)
|
|
31
|
+
for option in node.options: option.target = ids.get(option.target, option.target)
|
|
32
|
+
candidate.nodes.append(node)
|
|
33
|
+
validate_structure(candidate)
|
|
34
|
+
project.nodes = candidate.nodes
|
|
35
|
+
return list(ids.values())
|
|
36
|
+
except (KeyError, TypeError, ValueError, RecursionError) as error:
|
|
37
|
+
raise ProjectError(f'Cannot paste nodes: {error}') from error
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def auto_layout(project):
|
|
41
|
+
if len(project.nodes) > 20000: raise ProjectError('A graph can contain at most 20000 nodes')
|
|
42
|
+
remaining = {node.id: node for node in project.nodes}
|
|
43
|
+
frontier, level = [project.start], 0
|
|
44
|
+
rows = [0] * 40
|
|
45
|
+
while remaining:
|
|
46
|
+
current = list(dict.fromkeys(key for key in frontier if key in remaining))
|
|
47
|
+
if not current: current = [next(iter(remaining))]
|
|
48
|
+
frontier = []
|
|
49
|
+
for row, key in enumerate(current):
|
|
50
|
+
node = remaining.pop(key)
|
|
51
|
+
column = level % 40
|
|
52
|
+
while rows[column] >= 500: column = (column + 1) % 40
|
|
53
|
+
node.x, node.y = 40 + column * 310, 60 + rows[column] * 180
|
|
54
|
+
rows[column] += 1
|
|
55
|
+
frontier.extend(target for _, target in node.links() if target in remaining)
|
|
56
|
+
level += 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Recovery:
|
|
60
|
+
def __init__(self, directory, identity=None):
|
|
61
|
+
self.path = Path(directory) / ((identity or uuid.uuid4().hex) + '.recovery.json')
|
|
62
|
+
|
|
63
|
+
def write(self, project, source=None):
|
|
64
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
record = {'source': str(Path(source).resolve()) if source else None, 'project': project_record(project)}
|
|
66
|
+
atomic_write(self.path, json.dumps(record, ensure_ascii=True).encode())
|
|
67
|
+
|
|
68
|
+
def remove(self):
|
|
69
|
+
self.path.unlink(missing_ok=True)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def read(path):
|
|
73
|
+
from .model import MAX_PROJECT_BYTES
|
|
74
|
+
path = Path(path)
|
|
75
|
+
if path.stat().st_size > MAX_PROJECT_BYTES: raise ProjectError('Recovery file exceeds the project limit')
|
|
76
|
+
try:
|
|
77
|
+
record = json.loads(path.read_text(encoding='utf-8'))
|
|
78
|
+
return project_from_record(record['project']), record.get('source')
|
|
79
|
+
except (KeyError, ValueError, TypeError) as error:
|
|
80
|
+
raise ProjectError(f'Invalid recovery document: {error}') from error
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""A small numeric expression language compiled to native KiWi instructions.
|
|
2
|
+
|
|
3
|
+
No Python evaluation, calls, attribute access, indexing or external data.
|
|
4
|
+
Arithmetic uses the VM's signed-word wrapping and truncating division.
|
|
5
|
+
"""
|
|
6
|
+
import ast
|
|
7
|
+
from .model import ProjectError
|
|
8
|
+
|
|
9
|
+
BINARY = {ast.Add: 0x50, ast.Sub: 0x51, ast.Mult: 0x52, ast.FloorDiv: 0x53, ast.Mod: 0x54}
|
|
10
|
+
COMPARE = {ast.Eq: 0x0a, ast.NotEq: 0x0b, ast.Gt: 0x0c, ast.GtE: 0x0d, ast.Lt: 0x0e, ast.LtE: 0x0f}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse(expression):
|
|
14
|
+
if not isinstance(expression, str) or len(expression) > 2000:
|
|
15
|
+
raise ProjectError('Expression must be at most 2000 characters')
|
|
16
|
+
try:
|
|
17
|
+
tree = ast.parse(expression or '0', mode='eval').body
|
|
18
|
+
except (SyntaxError, RecursionError) as error:
|
|
19
|
+
raise ProjectError(f'Invalid numeric expression: {expression}') from error
|
|
20
|
+
if sum(1 for _ in ast.walk(tree)) > 256:
|
|
21
|
+
raise ProjectError('Expression is too complex')
|
|
22
|
+
names = set()
|
|
23
|
+
def check(node):
|
|
24
|
+
if isinstance(node, ast.Constant) and type(node.value) in (int, bool):
|
|
25
|
+
if not -32768 <= node.value <= 32767:
|
|
26
|
+
raise ProjectError('Expression constants must fit a signed 16-bit word')
|
|
27
|
+
elif isinstance(node, ast.Name):
|
|
28
|
+
names.add(node.id)
|
|
29
|
+
elif isinstance(node, ast.BinOp) and type(node.op) in BINARY:
|
|
30
|
+
check(node.left); check(node.right)
|
|
31
|
+
if isinstance(node.op, (ast.FloorDiv, ast.Mod)) and isinstance(node.right, ast.Constant) and node.right.value == 0:
|
|
32
|
+
raise ProjectError('Division by zero in expression')
|
|
33
|
+
elif isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.Not, ast.UAdd)):
|
|
34
|
+
# -32768 is represented as unary minus over the positive literal.
|
|
35
|
+
if isinstance(node.op, ast.USub) and isinstance(node.operand, ast.Constant) and node.operand.value == 32768:
|
|
36
|
+
return
|
|
37
|
+
check(node.operand)
|
|
38
|
+
elif isinstance(node, ast.BoolOp) and isinstance(node.op, (ast.And, ast.Or)):
|
|
39
|
+
for value in node.values: check(value)
|
|
40
|
+
elif isinstance(node, ast.Compare) and all(type(op) in COMPARE for op in node.ops):
|
|
41
|
+
for value in (node.left, *node.comparators): check(value)
|
|
42
|
+
else:
|
|
43
|
+
raise ProjectError('Use integers, variables, arithmetic, comparisons, and/or/not only')
|
|
44
|
+
check(tree)
|
|
45
|
+
return tree, names
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def emit(asm, tree, read_variable):
|
|
49
|
+
if isinstance(tree, ast.Constant):
|
|
50
|
+
asm.emit(0x1a, int(tree.value))
|
|
51
|
+
elif isinstance(tree, ast.Name):
|
|
52
|
+
read_variable(tree.id)
|
|
53
|
+
elif isinstance(tree, ast.UnaryOp):
|
|
54
|
+
if isinstance(tree.op, ast.USub):
|
|
55
|
+
asm.emit(0x1a, 0)
|
|
56
|
+
emit(asm, tree.operand, read_variable)
|
|
57
|
+
asm.emit(0x51)
|
|
58
|
+
else:
|
|
59
|
+
emit(asm, tree.operand, read_variable)
|
|
60
|
+
if isinstance(tree.op, ast.Not):
|
|
61
|
+
asm.emit(0x1a, 0); asm.emit(0x0a)
|
|
62
|
+
elif isinstance(tree, ast.BinOp):
|
|
63
|
+
emit(asm, tree.left, read_variable); emit(asm, tree.right, read_variable)
|
|
64
|
+
asm.emit(BINARY[type(tree.op)])
|
|
65
|
+
elif isinstance(tree, ast.BoolOp):
|
|
66
|
+
stop, end = ('boolean', len(asm.code), 0), ('boolean', len(asm.code), 1)
|
|
67
|
+
conjunction = isinstance(tree.op, ast.And)
|
|
68
|
+
for value in tree.values:
|
|
69
|
+
emit(asm, value, read_variable)
|
|
70
|
+
if conjunction:
|
|
71
|
+
asm.emit(0x1a, 0); asm.emit(0x0a)
|
|
72
|
+
asm.jump_if(stop)
|
|
73
|
+
asm.emit(0x1a, int(conjunction)); asm.jump(end)
|
|
74
|
+
asm.labels[stop] = len(asm.code)
|
|
75
|
+
asm.emit(0x1a, int(not conjunction))
|
|
76
|
+
asm.labels[end] = len(asm.code)
|
|
77
|
+
elif isinstance(tree, ast.Compare):
|
|
78
|
+
for i, (left, op, right) in enumerate(zip((tree.left, *tree.comparators), tree.ops, tree.comparators)):
|
|
79
|
+
emit(asm, left, read_variable); emit(asm, right, read_variable)
|
|
80
|
+
asm.emit(COMPARE[type(op)])
|
|
81
|
+
if i: asm.emit(0x12)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Authoring controls backed by the recovered KiWi host contracts.
|
|
2
|
+
|
|
3
|
+
Defaults are authored examples, not extracted game content. They are shared
|
|
4
|
+
by the editor and the API; validation never fills in an unknown native call.
|
|
5
|
+
"""
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
|
|
8
|
+
NAMES = dict(dialogue='Dialogue', choice='Choice', background='Background', set='Set variable',
|
|
9
|
+
branch='Condition', music='Music', sound='Sound effect', stop_audio='Stop audio',
|
|
10
|
+
relationship='Relationship', score='Score / property', input='Text input',
|
|
11
|
+
text_branch='Text answer condition', title='Title card', message='Message',
|
|
12
|
+
notice='Dialogue notification', emphasis='Emphasize dialogue', scene_label='Scene label',
|
|
13
|
+
pan='Background pan', transition='Screen transition', close_dialogue='Close dialogue',
|
|
14
|
+
random='Random variable', word_game='Timed word choices', football='Football (SHS)',
|
|
15
|
+
grid='Word / picture grid (SHS)', scene='Scene link',
|
|
16
|
+
end='End episode', native='Native bytecode block')
|
|
17
|
+
|
|
18
|
+
DEFAULTS = {
|
|
19
|
+
'dialogue': {}, 'choice': {'shuffle': False}, 'background': {'variant': 0},
|
|
20
|
+
'set': {'expression': ''}, 'branch': {'expression': ''},
|
|
21
|
+
'music': {'resource': 8201, 'loop': True}, 'sound': {'resource': 8001},
|
|
22
|
+
'stop_audio': {'channel': 0},
|
|
23
|
+
'relationship': {'operation': 'add', 'value': 1, 'romantic': False, 'hide': False, 'result': 'relationship'},
|
|
24
|
+
'score': {'owner': 0, 'key': 400, 'operation': 'add', 'value': 1, 'notification': '', 'result': 'score'},
|
|
25
|
+
'input': {'prompt': 'Enter an answer.', 'default': '', 'result': 'answer'},
|
|
26
|
+
'text_branch': {'variable': 'answer', 'equals': 'Answer'},
|
|
27
|
+
'title': {'subtitle': '', 'background': -1, 'flag': 0},
|
|
28
|
+
'message': {'title': '', 'flag': 0}, 'notice': {}, 'emphasis': {},
|
|
29
|
+
'scene_label': {'icon': -1}, 'pan': {'automatic': True, 'alignment': 2},
|
|
30
|
+
'transition': {'selector': 0}, 'close_dialogue': {},
|
|
31
|
+
'random': {'bound': 2, 'result': 'roll'},
|
|
32
|
+
'word_game': {'good': ['Clear', 'Calm'], 'bad': ['Late', 'Lost', 'Loud'],
|
|
33
|
+
'duration_ms': 10000, 'refresh_ms': 1000, 'result': 'word_score'},
|
|
34
|
+
'football': {'first_seconds': 30, 'second_seconds': 30,
|
|
35
|
+
'plans': [[[1, 5, 2, 1]], [[-1, 5, 2, 1]]] * 3,
|
|
36
|
+
'instructions': ['Choose a play.'] * 6, 'start_offense': True,
|
|
37
|
+
'home_team': 1, 'away_team': 2, 'home_position': 20, 'away_position': 20,
|
|
38
|
+
'home_score': 0, 'away_score': 0, 'allow_block': False,
|
|
39
|
+
'pass_bonus': False, 'run_bonus': False, 'coach': False,
|
|
40
|
+
'extra_offense': '', 'extra_defense': '', 'result': 'football_margin'},
|
|
41
|
+
'grid': {'seconds': 30, 'target': 1, 'round_ms': 10000, 'bonus_ms': 1000,
|
|
42
|
+
'background': -1, 'variant': 0, 'left_character': '', 'right_character': '',
|
|
43
|
+
'left_expression': 0, 'right_expression': 0, 'shuffle': False,
|
|
44
|
+
'feedback': True, 'success': 'Well done!', 'failure': 'Try again.',
|
|
45
|
+
'problems': [{'width': 3, 'height': 3, 'heading': 'Find a word',
|
|
46
|
+
'prompt': 'Connect adjacent letters.', 'words': ['CAT'],
|
|
47
|
+
'alphabet': 'CAT', 'highlight': True, 'advance_after_one': True,
|
|
48
|
+
'minimum_starts': 1, 'id': 1001}],
|
|
49
|
+
'symbols': [], 'tutorials': [], 'result': 'grid_passed'},
|
|
50
|
+
'scene': {},
|
|
51
|
+
'end': {}, 'native': {},
|
|
52
|
+
}
|
|
53
|
+
INPUT_GATES = frozenset(('dialogue', 'choice', 'input', 'title', 'message', 'word_game', 'football', 'grid'))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parameters(node):
|
|
57
|
+
return dict(deepcopy(DEFAULTS[node.kind]), **node.params)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def text_fields(project):
|
|
61
|
+
"""Stable translation keys, including structured minigame text."""
|
|
62
|
+
result = {'title': project.title}
|
|
63
|
+
if project.native: return result
|
|
64
|
+
for character in project.characters:
|
|
65
|
+
result[f'character.{character.id}.name'] = character.name
|
|
66
|
+
for node in project.nodes:
|
|
67
|
+
for field in ('text', 'speaker'):
|
|
68
|
+
result[f'node.{node.id}.{field}'] = getattr(node, field)
|
|
69
|
+
for i, option in enumerate(node.options):
|
|
70
|
+
result[f'node.{node.id}.option.{i}'] = option.text
|
|
71
|
+
p = parameters(node)
|
|
72
|
+
for field in ('prompt', 'default', 'equals', 'subtitle', 'title', 'notification', 'success', 'failure',
|
|
73
|
+
'extra_offense', 'extra_defense'):
|
|
74
|
+
if field in p:
|
|
75
|
+
result[f'node.{node.id}.param.{field}'] = p[field]
|
|
76
|
+
for field in ('good', 'bad', 'instructions'):
|
|
77
|
+
for i, value in enumerate(p.get(field, [])):
|
|
78
|
+
result[f'node.{node.id}.param.{field}.{i}'] = value
|
|
79
|
+
for field in ('problems', 'tutorials'):
|
|
80
|
+
for i, record in enumerate(p.get(field, [])):
|
|
81
|
+
for key in ('title', 'body', 'heading', 'prompt', 'alphabet', 'fixed_board'):
|
|
82
|
+
if key in record:
|
|
83
|
+
result[f'node.{node.id}.param.{field}.{i}.{key}'] = record[key]
|
|
84
|
+
for j, word in enumerate(record.get('words', [])):
|
|
85
|
+
result[f'node.{node.id}.param.{field}.{i}.words.{j}'] = word
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def translated(project, key, fallback):
|
|
90
|
+
return project.locales.get(project.language, {}).get(key, fallback)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Bounded story queries and reference-aware edits, independent of MCP and Qt."""
|
|
2
|
+
from collections import Counter, deque
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
|
|
5
|
+
from .model import ProjectError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def page(offset, limit):
|
|
9
|
+
if type(offset) is not int or offset < 0 or type(limit) is not int or not 1 <= limit <= 200:
|
|
10
|
+
raise ProjectError('Use a nonnegative offset and limit from 1 to 200')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def edges(project):
|
|
14
|
+
result = []
|
|
15
|
+
for node in project.nodes:
|
|
16
|
+
for index, (label, target) in enumerate(node.links()):
|
|
17
|
+
field = (f'options.{index}.target' if node.kind == 'choice' else
|
|
18
|
+
'otherwise' if node.kind in ('branch', 'text_branch') and index else 'next')
|
|
19
|
+
if node.kind == 'native': field = 'params.instructions'
|
|
20
|
+
result.append(dict(source=node.id, target=target, label=label, field=field))
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def overview(project):
|
|
25
|
+
return dict(title=project.title, game=project.game, start=project.start, native=bool(project.native),
|
|
26
|
+
node_count=len(project.nodes), kinds=dict(Counter(n.kind for n in project.nodes)),
|
|
27
|
+
scenes=dict(Counter(n.scene for n in project.nodes)),
|
|
28
|
+
characters=[dict(id=c.id, name=c.name) for c in project.characters],
|
|
29
|
+
asset_count=len(project.assets), language=project.language,
|
|
30
|
+
languages=sorted({project.language, *project.locales}),
|
|
31
|
+
endings=[n.id for n in project.nodes if n.kind == 'end'])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def search(project, *, text='', character=None, kind=None, scene=None, offset=0, limit=50):
|
|
35
|
+
page(offset, limit)
|
|
36
|
+
found = []
|
|
37
|
+
needle = text.casefold()
|
|
38
|
+
for node in project.nodes:
|
|
39
|
+
if character is not None and character not in (node.character, node.params.get('left_character'),
|
|
40
|
+
node.params.get('right_character')): continue
|
|
41
|
+
if kind is not None and node.kind != kind: continue
|
|
42
|
+
if scene is not None and node.scene != scene: continue
|
|
43
|
+
if needle and needle not in str(asdict(node)).casefold(): continue
|
|
44
|
+
found.append(node)
|
|
45
|
+
return dict(total=len(found), nodes=[asdict(n) for n in found[offset:offset + limit]],
|
|
46
|
+
next_offset=offset + limit if offset + limit < len(found) else None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def subgraph(project, node_id, *, depth=1, direction='both', limit=100):
|
|
50
|
+
page(0, limit)
|
|
51
|
+
if type(depth) is not int or not 0 <= depth <= 20 or direction not in ('both', 'incoming', 'outgoing'):
|
|
52
|
+
raise ProjectError('Use depth 0–20 and direction incoming, outgoing or both')
|
|
53
|
+
nodes = {n.id: n for n in project.nodes}
|
|
54
|
+
if node_id not in nodes: raise ProjectError('Unknown node')
|
|
55
|
+
links = edges(project)
|
|
56
|
+
adjacent = {key: [] for key in nodes}
|
|
57
|
+
for edge in links:
|
|
58
|
+
a, b = edge['source'], edge['target']
|
|
59
|
+
if b not in nodes: continue
|
|
60
|
+
if direction != 'incoming': adjacent[a].append(b)
|
|
61
|
+
if direction != 'outgoing': adjacent[b].append(a)
|
|
62
|
+
queue, visited, selected = deque([(node_id, 0)]), {node_id}, []
|
|
63
|
+
while queue and len(selected) < limit:
|
|
64
|
+
key, level = queue.popleft(); selected.append(key)
|
|
65
|
+
if level < depth:
|
|
66
|
+
for target in adjacent[key]:
|
|
67
|
+
if target not in visited:
|
|
68
|
+
visited.add(target); queue.append((target, level + 1))
|
|
69
|
+
included = set(selected)
|
|
70
|
+
return dict(nodes=[asdict(nodes[key]) for key in selected], truncated=bool(queue),
|
|
71
|
+
edges=[e for e in links if e['source'] in included and e['target'] in included],
|
|
72
|
+
boundary=[e for e in links if (e['source'] in included) != (e['target'] in included)])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def paths(project, target, *, source=None, limit=20, max_depth=100, budget=10000):
|
|
76
|
+
"""Enumerate bounded simple structural paths, not claims of runtime reachability."""
|
|
77
|
+
page(0, limit)
|
|
78
|
+
if type(max_depth) is not int or not 1 <= max_depth <= 500:
|
|
79
|
+
raise ProjectError('Path depth must be 1–500')
|
|
80
|
+
nodes = {n.id: n for n in project.nodes}
|
|
81
|
+
source = source or project.start
|
|
82
|
+
if source not in nodes or target not in nodes: raise ProjectError('Unknown source or target node')
|
|
83
|
+
stack, found, examined, truncated = [(source,)], [], 0, False
|
|
84
|
+
while stack and len(found) < limit and examined < budget:
|
|
85
|
+
route = stack.pop(); examined += 1
|
|
86
|
+
if route[-1] == target:
|
|
87
|
+
found.append(list(route)); continue
|
|
88
|
+
if len(route) >= max_depth:
|
|
89
|
+
truncated |= any(t in nodes and t not in route for _, t in nodes[route[-1]].links())
|
|
90
|
+
continue
|
|
91
|
+
for _, next_id in reversed(nodes[route[-1]].links()):
|
|
92
|
+
if next_id in nodes and next_id not in route: stack.append((*route, next_id))
|
|
93
|
+
return dict(paths=found, truncated=bool(stack) or truncated, examined=examined,
|
|
94
|
+
semantics='Simple structural paths. Conditions, timers and native indirect jumps require playtesting.')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def rename_node(project, old, new):
|
|
98
|
+
if project.native: raise ProjectError('Native blocks retain fixed addresses and identities')
|
|
99
|
+
if not new or any(n.id == new for n in project.nodes): raise ProjectError('Choose a new, nonempty node ID')
|
|
100
|
+
node = next((n for n in project.nodes if n.id == old), None)
|
|
101
|
+
if node is None: raise ProjectError('Unknown node')
|
|
102
|
+
node.id = new
|
|
103
|
+
if project.start == old: project.start = new
|
|
104
|
+
for n in project.nodes:
|
|
105
|
+
if n.next == old: n.next = new
|
|
106
|
+
if n.otherwise == old: n.otherwise = new
|
|
107
|
+
for option in n.options:
|
|
108
|
+
if option.target == old: option.target = new
|
|
109
|
+
prefix = f'node.{old}.'
|
|
110
|
+
for language, translations in project.locales.items():
|
|
111
|
+
project.locales[language] = {(f'node.{new}.' + key[len(prefix):] if key.startswith(prefix) else key): value
|
|
112
|
+
for key, value in translations.items()}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def connect(project, source, target, field='next'):
|
|
116
|
+
if project.native: raise ProjectError('Edit native control flow through its instructions')
|
|
117
|
+
node = next((n for n in project.nodes if n.id == source), None)
|
|
118
|
+
if node is None: raise ProjectError('Unknown source node')
|
|
119
|
+
if field not in {e['field'] for e in edges(project) if e['source'] == source}:
|
|
120
|
+
raise ProjectError('Choose an existing edge field: next, otherwise or options.N.target')
|
|
121
|
+
if field.startswith('options.'):
|
|
122
|
+
node.options[int(field.split('.')[1])].target = target
|
|
123
|
+
else:
|
|
124
|
+
setattr(node, field, target)
|