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,174 @@
1
+ """Round-trip authored graphs or edit lossless native basic blocks.
2
+
3
+ Original bytecode is never guessed into dialogue or flattened past unknown
4
+ services. Fixed instruction counts preserve every native PC and callback.
5
+ """
6
+ import base64
7
+ from dataclasses import replace
8
+
9
+ from exp_runtime.authored import authoring_record
10
+ from exp_runtime.exp import ExpArchive, Metadata, encode_exp
11
+ from exp_runtime.decode.bytecode import OPERAND_OPCODES, decode_program
12
+ from .model import Asset, Node, Project, ProjectError, project_from_record, validate_structure
13
+
14
+
15
+ def _resources(project):
16
+ try:
17
+ return {int(key): base64.b64decode(value, validate=True)
18
+ for key, value in project.native['resources'].items()}
19
+ except (KeyError, TypeError, ValueError) as error:
20
+ raise ProjectError('Invalid native resource bank') from error
21
+
22
+
23
+ def import_exp(data, *, game='shs'):
24
+ archive = ExpArchive(data)
25
+ record = authoring_record(archive)
26
+ if record and record.get('project'):
27
+ document = record['project']
28
+ for asset in document.get('assets', []):
29
+ if asset.get('data') == '@resource':
30
+ asset['data'] = base64.b64encode(archive.read(asset['id'])).decode('ascii')
31
+ project = project_from_record(document)
32
+ project.library = ''
33
+ return project
34
+ meta = archive.metadata()
35
+ resources = {key: archive.read(key) for key in archive.entries}
36
+ project = Project(title=meta.title, game=game, pack_id=meta.pack_id, episode_id=meta.episode_id,
37
+ nodes=[], native={'resources': {str(key): base64.b64encode(value).decode('ascii')
38
+ for key, value in resources.items()}})
39
+ project.locales = {language: {'title': title} for language, title in
40
+ zip(('en', 'fr', 'it', 'de', 'es'), meta.titles)}
41
+ for script, program in sorted(archive.programs().items()):
42
+ instructions = program.instructions
43
+ if not instructions:
44
+ continue
45
+ boundaries = {instructions[0].pc}
46
+ pcs = {ins.pc for ins in instructions}
47
+ for ins in instructions:
48
+ target = ins.branch_target()
49
+ if target in pcs:
50
+ boundaries.add(target)
51
+ if target is not None or ins.opcode in (0x1e, 0x1f, 0x33, 0x43):
52
+ if ins.pc + 1 in pcs:
53
+ boundaries.add(ins.pc + 1)
54
+ starts = sorted(boundaries)
55
+ for index, start in enumerate(starts):
56
+ end = starts[index + 1] if index + 1 < len(starts) else instructions[-1].pc + 1
57
+ base = instructions[0].pc
58
+ block = instructions[start - base:end - base]
59
+ last = block[-1]
60
+ targets = []
61
+ if last.branch_target() in pcs:
62
+ targets.append(last.branch_target())
63
+ if last.opcode not in (0x29, 0x33, 0x43) and end in pcs:
64
+ targets.append(end)
65
+ node = Node(f'{script}:{start}', 'native', f'Script {script} · PC {start}',
66
+ x=index % 6 * 290, y=(index // 6 + len(project.nodes) // 6) * 160,
67
+ scene=script, params={'script': script, 'pc': start,
68
+ 'instructions': [[ins.opcode, ins.operand] for ins in block],
69
+ 'edges': [f'{script}:{pc}' for pc in dict.fromkeys(targets)]})
70
+ project.nodes.append(node)
71
+ if not project.nodes:
72
+ raise ProjectError('EXP has no editable KiWi programs')
73
+ project.start = next((n.id for n in project.nodes if n.scene == 25001), project.nodes[0].id)
74
+ project.assets = [Asset(key, f'Resource {key}', value) for key, value in resources.items()
75
+ if value.startswith((b'\x89PNG\r\n\x1a\n', b'\xff\xd8'))]
76
+ from .editing import auto_layout
77
+ auto_layout(project)
78
+ validate_structure(project)
79
+ return project
80
+
81
+
82
+ def compile_native(project):
83
+ resources = _resources(project)
84
+ programs = {key: decode_program(value) for key, value in resources.items() if value.startswith(b'kiwi')}
85
+ originals = {key: {ins.pc: ins for ins in program.instructions} for key, program in programs.items()}
86
+ edits = {key: {} for key in programs}
87
+ for node in project.nodes:
88
+ if node.kind != 'native':
89
+ raise ProjectError('Native projects require native blocks; create a new project for high-level nodes')
90
+ p = node.params
91
+ try:
92
+ script, start, rows = p['script'], p['pc'], p['instructions']
93
+ if type(start) is not int or type(script) is not int or not isinstance(rows, list) or not rows:
94
+ raise ValueError('invalid block')
95
+ original = originals[script]
96
+ for index, row in enumerate(rows):
97
+ pc = start + index
98
+ op, arg = row
99
+ if pc not in original or pc in edits[script] or type(op) is not int or not 0 <= op <= 255:
100
+ raise ValueError('overlapping block or invalid PC/opcode')
101
+ if (op in OPERAND_OPCODES and (type(arg) is not int or not 0 <= arg <= 65535)
102
+ or op not in OPERAND_OPCODES and arg is not None):
103
+ raise ValueError('operand does not match opcode width')
104
+ edits[script][pc] = replace(original[pc], opcode=op, operand=arg)
105
+ except (KeyError, TypeError, ValueError) as error:
106
+ raise ProjectError(f'{node.label}: invalid native block: {error}') from error
107
+ for script, program in programs.items():
108
+ if set(edits[script]) != {ins.pc for ins in program.instructions}:
109
+ raise ProjectError('Preserve every instruction in an imported native project')
110
+ resources[script] = replace(program, instructions=tuple(edits[script][ins.pc] for ins in program.instructions)).to_bytes()
111
+ for asset in project.assets:
112
+ if asset.data is not None:
113
+ resources[asset.id] = asset.data
114
+ old = ExpArchive(encode_exp(resources)).metadata()
115
+ titles = tuple(project.title if lang == 'en' and project.title != old.title else
116
+ project.locales.get(lang, {}).get('title', project.title)
117
+ for lang in ('en', 'fr', 'it', 'de', 'es'))
118
+ if (old.pack_id, old.episode_id, old.titles) != (project.pack_id, project.episode_id, titles):
119
+ resources[1] = Metadata(project.pack_id, project.episode_id, titles).to_bytes()
120
+ return encode_exp(resources)
121
+
122
+
123
+ def validate_native(project):
124
+ compile_native(project)
125
+
126
+
127
+ def set_data_words(project, script, words, *, extra=False):
128
+ """Explicit data-bank editing; addresses and bank lengths remain stable."""
129
+ resources = _resources(project)
130
+ program = decode_program(resources[script])
131
+ key = 'extra_words' if extra else 'main_words'
132
+ if (len(words) != len(getattr(program, key))
133
+ or any(type(word) is not int or not 0 <= word <= 65535 for word in words)):
134
+ raise ProjectError('Keep the original data-bank length and use unsigned 16-bit words')
135
+ resources[script] = replace(program, **{key: tuple(words)}).to_bytes()
136
+ project.native['resources'][str(script)] = base64.b64encode(resources[script]).decode('ascii')
137
+
138
+
139
+ def packed_strings(project, script):
140
+ """Offer printable packed-text candidates with exact main-bank addresses.
141
+
142
+ Data words are untyped; this list is a viewing aid, never decompilation.
143
+ """
144
+ import struct
145
+ program = decode_program(_resources(project)[script])
146
+ data = struct.pack(f'>{len(program.main_words)}H', *program.main_words)
147
+ result, position = [], 0
148
+ while position < len(data):
149
+ end = data.find(b'\0', position)
150
+ if end < 0: break
151
+ text = data[position:end].decode('latin-1')
152
+ if len(text) >= 2 and all(c.isprintable() or c in '\n\r\t' for c in text):
153
+ result.append({'address': position // 2, 'text': text})
154
+ position = (end + 2) // 2 * 2
155
+ return result
156
+
157
+
158
+ def edit_packed_text(project, script, address, text):
159
+ """Replace a confirmed main-bank string without relocating other addresses."""
160
+ import struct
161
+ resources = _resources(project)
162
+ program = decode_program(resources[script])
163
+ data = bytearray(struct.pack(f'>{len(program.main_words)}H', *program.main_words))
164
+ if type(address) is not int or not 0 <= address < len(program.main_words):
165
+ raise ProjectError('Text address must identify a main-bank word')
166
+ start = address * 2
167
+ end = data.find(b'\0', start)
168
+ if end < 0: raise ProjectError('Text must have an existing NUL terminator')
169
+ try: encoded = text.encode('latin-1')
170
+ except UnicodeError as error: raise ProjectError('Native text uses Latin-1') from error
171
+ if b'\0' in encoded or len(encoded) > end - start:
172
+ raise ProjectError('Replacement must fit the original string allocation without NULs')
173
+ data[start:end] = encoded + b'\0' * (end - start - len(encoded))
174
+ set_data_words(project, script, list(struct.unpack(f'>{len(program.main_words)}H', data)))
@@ -0,0 +1,83 @@
1
+ """One transaction vocabulary shared by workspace edits and MCP schemas."""
2
+ from dataclasses import fields
3
+
4
+ from .model import Character, Choice, Node, ProjectError
5
+ from . import graph
6
+
7
+
8
+ def patch_record(record, changes, allowed):
9
+ if not isinstance(changes, dict) or set(changes) - set(allowed):
10
+ raise ProjectError('Unsupported fields: ' + ', '.join(sorted(set(changes) - set(allowed)))
11
+ if isinstance(changes, dict) else 'Changes must be an object')
12
+ for key, value in changes.items():
13
+ if key == 'options':
14
+ if not isinstance(value, list): raise ProjectError('Options must be a list')
15
+ try: value = [Choice(**option) for option in value]
16
+ except TypeError as error: raise ProjectError(f'Invalid choice: {error}') from error
17
+ setattr(record, key, value)
18
+
19
+
20
+ def apply(workspace, project, operation):
21
+ """Apply to a disposable candidate. The workspace validates and commits once."""
22
+ if not isinstance(operation, dict): raise ProjectError('An edit operation must be an object')
23
+ op = operation.get('op')
24
+ allowed = {
25
+ 'project': {'changes'}, 'node': {'id', 'changes', 'remove'},
26
+ 'character': {'id', 'changes', 'remove'}, 'rename_node': {'id', 'new_id'},
27
+ 'connect': {'source', 'target', 'field'}, 'image': {'source'},
28
+ 'library_image': {'library', 'resource', 'episode'}, 'asset_label': {'id', 'label'}, 'layout': set(),
29
+ }
30
+ if op not in allowed or set(operation) - {'op'} - allowed[op]:
31
+ raise ProjectError('Unknown edit operation or operation fields')
32
+ if op == 'project':
33
+ changes = dict(operation['changes'])
34
+ if 'game' in changes and changes['game'] != project.game and (project.assets or project.native or project.library):
35
+ raise ProjectError('Create a new project to change the game after importing resources')
36
+ if changes.get('library'): changes['library'] = str(workspace.path(changes['library']))
37
+ patch_record(project, changes, {'title', 'game', 'pack_id', 'episode_id', 'start', 'language', 'locales', 'library'})
38
+ elif op in ('node', 'character'):
39
+ key = operation['id']
40
+ if not isinstance(key, str) or not key: raise ProjectError('An edit needs a nonempty ID')
41
+ collection = project.nodes if op == 'node' else project.characters
42
+ record = next((r for r in collection if r.id == key), None)
43
+ if project.native and (op == 'character' or record is None or operation.get('remove')):
44
+ raise ProjectError('Native projects retain their original blocks and character definitions')
45
+ if operation.get('remove'):
46
+ if record is None: raise ProjectError(f'Unknown {op}: {key}')
47
+ collection.remove(record)
48
+ else:
49
+ if record is None:
50
+ record = Node(key) if op == 'node' else Character(key)
51
+ collection.append(record)
52
+ patch_record(record, operation.get('changes', {}), {f.name for f in fields(record)} - {'id'})
53
+ elif op == 'rename_node': graph.rename_node(project, operation['id'], operation['new_id'])
54
+ elif op == 'connect': graph.connect(project, operation['source'], operation['target'], operation.get('field', 'next'))
55
+ elif op == 'image':
56
+ from .model import MAX_ASSET_BYTES
57
+ path = workspace.path(operation['source'])
58
+ if path.stat().st_size > MAX_ASSET_BYTES: raise ProjectError('Image is too large')
59
+ project.add_image(path.name, path.read_bytes())
60
+ elif op == 'library_image':
61
+ from exp_runtime.content import ContentLibrary
62
+ from .assets import import_library_image
63
+ with ContentLibrary(workspace.path(operation['library'])) as content:
64
+ episode = operation.get('episode')
65
+ import_library_image(project, content, operation['resource'], content.open_episode(episode) if episode else None)
66
+ elif op == 'asset_label':
67
+ asset = next((a for a in project.assets if a.id == operation['id']), None)
68
+ if asset is None: raise ProjectError('Import the asset into the project before labeling it')
69
+ asset.name = operation['label']
70
+ elif op == 'layout':
71
+ from .editing import auto_layout
72
+ auto_layout(project)
73
+
74
+
75
+ def check_removals(before, after):
76
+ removed = {n.id for n in before.nodes} - {n.id for n in after.nodes}
77
+ references = [e for e in graph.edges(after) if e['target'] in removed]
78
+ if after.start in removed or references:
79
+ raise ProjectError('Removed node is still referenced; reconnect its incoming edges and project start in the same batch')
80
+ removed_characters = {c.id for c in before.characters} - {c.id for c in after.characters}
81
+ if any(n.character in removed_characters or n.params.get('left_character') in removed_characters or
82
+ n.params.get('right_character') in removed_characters for n in after.nodes):
83
+ raise ProjectError('Removed character is still referenced by a node')
@@ -0,0 +1,221 @@
1
+ """Structured authoring dialogs shared by character and minigame inspectors."""
2
+ from copy import deepcopy
3
+ import json
4
+
5
+ from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout,
6
+ QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton, QScrollArea,
7
+ QSpinBox, QTableWidget, QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget)
8
+
9
+ from .model import ProjectError
10
+
11
+
12
+ LIST_TEMPLATES = {
13
+ 'symbols': ['A', 26000],
14
+ 'expressions': None,
15
+ 'tutorials': dict(title='Practice', body='Connect the word.', position=0, tap_to_advance=False,
16
+ hide_board=False, width=3, height=3, heading='Practice', prompt='Find CAT',
17
+ words=['CAT'], highlight=True, fixed_board='CAT\nAAA\nAAA', show_hints=True,
18
+ animate=True, timed=False, failure_next=-1, success_next=-2),
19
+ }
20
+
21
+ def value_field(value, parent=None, key=None):
22
+ """Return a widget and a typed getter; containers have their own row editor."""
23
+ if isinstance(value, (dict, list)):
24
+ current = deepcopy(value)
25
+ button = QPushButton(f'Edit {len(value)} ' + ('fields…' if isinstance(value, dict) else 'items…'), parent)
26
+ def edit():
27
+ dialog = (RecordDialog(current, parent) if isinstance(current, dict) else
28
+ ListDialog([None] * 5 if key == 'expressions' and not current else current,
29
+ parent, template=LIST_TEMPLATES.get(key)))
30
+ if dialog.exec() == QDialog.DialogCode.Accepted:
31
+ current.clear(); current.update(dialog.value) if isinstance(current, dict) else current.extend(dialog.value)
32
+ button.setText(f'Edit {len(current)} items…')
33
+ button.clicked.connect(edit)
34
+ return button, lambda: deepcopy(current)
35
+ if type(value) is bool:
36
+ widget = QCheckBox(parent); widget.setChecked(value)
37
+ return widget, widget.isChecked
38
+ if type(value) is int:
39
+ widget = QSpinBox(parent); widget.setRange(-32768, 65535); widget.setValue(value)
40
+ return widget, widget.value
41
+ widget = QLineEdit('null' if value is None else str(value), parent)
42
+ return widget, (lambda: json.loads(widget.text())) if value is None else widget.text
43
+
44
+
45
+ class RecordDialog(QDialog):
46
+ def __init__(self, record, parent=None, title='Edit fields'):
47
+ super().__init__(parent)
48
+ self.setWindowTitle(title); self.resize(560, 650)
49
+ layout = QVBoxLayout(self)
50
+ scroll = QScrollArea(); scroll.setWidgetResizable(True)
51
+ panel = QWidget(); form = QFormLayout(panel); self.getters = {}
52
+ for key, value in record.items():
53
+ field, getter = value_field(value, self, key)
54
+ field.setObjectName(key); self.getters[key] = getter
55
+ form.addRow(key.replace('_', ' ').capitalize(), field)
56
+ scroll.setWidget(panel); layout.addWidget(scroll)
57
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
58
+ buttons.accepted.connect(self.accept); buttons.rejected.connect(self.reject); layout.addWidget(buttons)
59
+
60
+ def accept(self):
61
+ try:
62
+ self.value = {key: getter() for key, getter in self.getters.items()}
63
+ except (ValueError, TypeError) as error:
64
+ QMessageBox.warning(self, 'Invalid value', str(error)); return
65
+ super().accept()
66
+
67
+
68
+ class ListDialog(QDialog):
69
+ def __init__(self, values, parent=None, title='Edit items', template=None):
70
+ super().__init__(parent)
71
+ self.value = deepcopy(values); self.template = template
72
+ self.setWindowTitle(title); self.resize(650, 480)
73
+ layout = QVBoxLayout(self); self.items = QListWidget(); layout.addWidget(self.items)
74
+ buttons = QHBoxLayout()
75
+ for label, callback in (('Edit', self.edit), ('Add', self.add), ('Duplicate', self.duplicate),
76
+ ('Remove', self.remove), ('Move up', lambda: self.move(-1)),
77
+ ('Move down', lambda: self.move(1))):
78
+ button = QPushButton(label); button.clicked.connect(callback); buttons.addWidget(button)
79
+ layout.addLayout(buttons)
80
+ self.items.itemDoubleClicked.connect(self.edit)
81
+ done = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
82
+ done.accepted.connect(self.accept); done.rejected.connect(self.reject); layout.addWidget(done)
83
+ self.refresh()
84
+
85
+ def refresh(self, row=0):
86
+ self.items.clear()
87
+ for index, value in enumerate(self.value):
88
+ self.items.addItem(f'{index + 1}. ' + (value.get('name', value.get('heading', str(value)))
89
+ if isinstance(value, dict) else str(value)))
90
+ self.items.setCurrentRow(row)
91
+
92
+ def edit(self, *unused):
93
+ row = self.items.currentRow()
94
+ if row < 0: return
95
+ value = self.value[row]
96
+ if isinstance(value, list):
97
+ dialog = ListDialog(value, self)
98
+ else:
99
+ dialog = RecordDialog(value if isinstance(value, dict) else {'value': value}, self)
100
+ if dialog.exec() == QDialog.DialogCode.Accepted:
101
+ self.value[row] = dialog.value if isinstance(value, (list, dict)) else dialog.value['value']
102
+ self.refresh(row)
103
+
104
+ def add(self):
105
+ self.value.append(deepcopy(self.template if self.template is not None else self.value[-1] if self.value else ''))
106
+ if isinstance(self.value[-1], dict) and 'id' in self.value[-1] and isinstance(self.value[-1]['id'], str):
107
+ import uuid
108
+ self.value[-1]['id'] = uuid.uuid4().hex[:10]
109
+ self.refresh(len(self.value) - 1); self.edit()
110
+
111
+ def duplicate(self):
112
+ row = self.items.currentRow()
113
+ if row >= 0:
114
+ self.value.insert(row + 1, deepcopy(self.value[row]))
115
+ if isinstance(self.value[row + 1], dict) and isinstance(self.value[row + 1].get('id'), str):
116
+ import uuid
117
+ self.value[row + 1]['id'] = uuid.uuid4().hex[:10]
118
+ self.refresh(row + 1)
119
+
120
+ def remove(self):
121
+ row = self.items.currentRow()
122
+ if row >= 0:
123
+ self.value.pop(row); self.refresh(max(0, row - 1))
124
+
125
+ def move(self, delta):
126
+ row = self.items.currentRow(); other = row + delta
127
+ if 0 <= row < len(self.value) and 0 <= other < len(self.value):
128
+ self.value[row], self.value[other] = self.value[other], self.value[row]; self.refresh(other)
129
+
130
+
131
+ class TranslationsDialog(QDialog):
132
+ def __init__(self, project, parent=None):
133
+ from .features import text_fields
134
+ super().__init__(parent)
135
+ self.setWindowTitle('Translations'); self.resize(1000, 600)
136
+ self.locales = deepcopy(project.locales); self.fields = text_fields(project)
137
+ layout = QVBoxLayout(self); self.language = QComboBox()
138
+ for name, code in (('English', 'en'), ('French', 'fr'), ('Italian', 'it'), ('German', 'de'), ('Spanish', 'es')):
139
+ self.language.addItem(name, code)
140
+ self.language.setCurrentIndex(self.language.findData(project.language))
141
+ layout.addWidget(self.language)
142
+ layout.addWidget(QLabel('Empty translations use the original text. Story text uses native Latin-1 encoding.'))
143
+ self.table = QTableWidget(0, 3); self.table.setHorizontalHeaderLabels(['Field', 'Original', 'Translation'])
144
+ layout.addWidget(self.table); self.current = self.language.currentData(); self.populate()
145
+ self.language.currentIndexChanged.connect(self.change)
146
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
147
+ buttons.accepted.connect(self.accept); buttons.rejected.connect(self.reject); layout.addWidget(buttons)
148
+
149
+ def populate(self):
150
+ from PySide6.QtCore import Qt
151
+ self.table.setRowCount(len(self.fields))
152
+ for row, (key, value) in enumerate(self.fields.items()):
153
+ for column, text in enumerate((key, value, self.locales.get(self.current, {}).get(key, ''))):
154
+ item = QTableWidgetItem(text)
155
+ if column < 2: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
156
+ self.table.setItem(row, column, item)
157
+ self.table.resizeColumnsToContents()
158
+
159
+ def store(self):
160
+ self.locales[self.current] = {key: self.table.item(row, 2).text() for row, key in enumerate(self.fields)
161
+ if self.table.item(row, 2).text()}
162
+
163
+ def change(self):
164
+ self.store(); self.current = self.language.currentData(); self.populate()
165
+
166
+ def accept(self):
167
+ self.store(); super().accept()
168
+
169
+
170
+ class DebugDialog(QDialog):
171
+ def __init__(self, project, parent=None, select=None, start=None):
172
+ from .debugger import DebugSession
173
+ super().__init__(parent)
174
+ self.debug = DebugSession(project, start=start); self.select = select
175
+ self.setWindowTitle('Story debugger'); self.resize(800, 650)
176
+ layout = QVBoxLayout(self); row = QHBoxLayout()
177
+ for label, callback in (('Step node', lambda: self.execute(step=True)), ('Run', self.execute)):
178
+ button = QPushButton(label); button.clicked.connect(callback); row.addWidget(button)
179
+ layout.addLayout(row)
180
+ self.breakpoints = QLineEdit(); self.breakpoints.setPlaceholderText('Breakpoint node IDs, separated by commas')
181
+ self.breakpoints.editingFinished.connect(lambda: setattr(self.debug, 'breakpoints',
182
+ set(value.strip() for value in self.breakpoints.text().split(',') if value.strip())))
183
+ layout.addWidget(self.breakpoints)
184
+ self.output = QTextEdit(); self.output.setReadOnly(True); layout.addWidget(self.output)
185
+ row = QHBoxLayout(); self.answer = QLineEdit(); self.answer.setPlaceholderText('Text, or zero-based choice/word/play index')
186
+ row.addWidget(self.answer)
187
+ button = QPushButton('Submit / continue'); button.clicked.connect(self.submit); row.addWidget(button)
188
+ button = QPushButton('Advance clock 1s'); button.clicked.connect(lambda: self.execute(milliseconds=1000)); row.addWidget(button)
189
+ layout.addLayout(row)
190
+ row = QHBoxLayout(); self.variable = QLineEdit(); self.variable.setPlaceholderText('Numeric variable name')
191
+ self.value = QSpinBox(); self.value.setRange(-32768, 32767)
192
+ row.addWidget(self.variable); row.addWidget(self.value)
193
+ button = QPushButton('Set value'); button.clicked.connect(self.set_variable); row.addWidget(button)
194
+ layout.addLayout(row); self.refresh()
195
+
196
+ def refresh(self):
197
+ state = self.debug.state()
198
+ self.output.setPlainText(json.dumps(state, indent=2, ensure_ascii=False))
199
+ if self.select and state['node']: self.select(state['node'])
200
+
201
+ def execute(self, checked=False, **kwargs):
202
+ try:
203
+ self.debug.resume(**kwargs); self.refresh()
204
+ except ValueError as error:
205
+ QMessageBox.warning(self, 'Execution stopped', str(error)); self.refresh()
206
+
207
+ def submit(self):
208
+ action = self.debug.state()['action']
209
+ value = self.answer.text()
210
+ if action in ('choice', 'word_game', 'football'):
211
+ try: value = int(value)
212
+ except ValueError:
213
+ QMessageBox.warning(self, 'Choose an answer', 'Enter a zero-based answer index.'); return
214
+ elif action != 'text_input': value = None
215
+ self.execute(answer=value, provide_answer=True)
216
+
217
+ def set_variable(self):
218
+ try:
219
+ self.debug.set_variable(self.variable.text(), self.value.value()); self.refresh()
220
+ except ProjectError as error:
221
+ QMessageBox.warning(self, 'Variable', str(error))