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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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)