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,149 @@
|
|
|
1
|
+
"""Encode authored minigame tables using the recovered SHS native ABI."""
|
|
2
|
+
from .model import ProjectError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def integer(value, low, high, name):
|
|
6
|
+
if type(value) is not int or not low <= value <= high:
|
|
7
|
+
raise ProjectError(f'{name} must be an integer from {low} to {high}')
|
|
8
|
+
return value
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def words(values, name, minimum=1):
|
|
12
|
+
if (not isinstance(values, list) or not minimum <= len(values) <= 256
|
|
13
|
+
or any(not isinstance(v, str) or not v or '|' in v or '\0' in v for v in values)):
|
|
14
|
+
raise ProjectError(f'{name} needs {minimum}–256 nonempty strings without pipes or NULs')
|
|
15
|
+
return values
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate(kind, p):
|
|
19
|
+
if kind == 'word_game':
|
|
20
|
+
words(p['good'], 'Positive words', 2); words(p['bad'], 'Negative words', 3)
|
|
21
|
+
integer(p['duration_ms'], 1, 32767, 'Word-game duration')
|
|
22
|
+
integer(p['refresh_ms'], 0, 32767, 'Word-game refresh')
|
|
23
|
+
elif kind == 'football':
|
|
24
|
+
if not isinstance(p['plans'], list) or len(p['plans']) != 6:
|
|
25
|
+
raise ProjectError('Football needs six interleaved offense/defense play tables')
|
|
26
|
+
for i, plan in enumerate(p['plans']):
|
|
27
|
+
if not isinstance(plan, list) or not 1 <= len(plan) <= 10:
|
|
28
|
+
raise ProjectError('Each football table needs 1–10 plays')
|
|
29
|
+
ordinary = False
|
|
30
|
+
for row in plan:
|
|
31
|
+
if not isinstance(row, list) or len(row) != 4:
|
|
32
|
+
raise ProjectError('Play rows are [code, yards, variation, weight]')
|
|
33
|
+
for value in row: integer(value, -32768, 32767, 'Football play value')
|
|
34
|
+
code, yards, variation, weight = row
|
|
35
|
+
allowed = {-1, -2, -3, -4, -5, -6} if i % 2 else {1, 2, 3, 4, 5, 6, 7}
|
|
36
|
+
if code not in allowed or variation < 0 or weight < 0:
|
|
37
|
+
raise ProjectError('Invalid football play code, variation or weight')
|
|
38
|
+
ordinary |= weight > 0 and code in ({-1, -2, -3, -4, -5} if i % 2 else {1, 2, 3, 4, 5})
|
|
39
|
+
if not ordinary or sum(row[3] for row in plan) > 32767:
|
|
40
|
+
raise ProjectError('Football tables need a positive ordinary outcome and bounded total weight')
|
|
41
|
+
if len(p['instructions']) != 6:
|
|
42
|
+
raise ProjectError('Football needs six instruction strings')
|
|
43
|
+
words(p['instructions'], 'Football instructions', 6)
|
|
44
|
+
for key in ('first_seconds', 'second_seconds'):
|
|
45
|
+
integer(p[key], 0, 32767, key)
|
|
46
|
+
if not (p['first_seconds'] or p['second_seconds']):
|
|
47
|
+
raise ProjectError('At least one football half must have positive duration')
|
|
48
|
+
for key in ('home_team', 'away_team'): integer(p[key], 1, 9, key)
|
|
49
|
+
for key in ('home_position', 'away_position'): integer(p[key], 1, 99, key)
|
|
50
|
+
for key in ('home_score', 'away_score'): integer(p[key], 0, 32767, key)
|
|
51
|
+
elif kind == 'grid':
|
|
52
|
+
integer(p['seconds'], 1, 32767, 'Grid duration')
|
|
53
|
+
integer(p['target'], 1, 32767, 'Grid target')
|
|
54
|
+
integer(p['round_ms'], 1, 32767, 'Grid board duration')
|
|
55
|
+
integer(p['bonus_ms'], 0, 32767, 'Grid time bonus')
|
|
56
|
+
if not isinstance(p['problems'], list) or not 1 <= len(p['problems']) <= 256:
|
|
57
|
+
raise ProjectError('Grid needs 1–256 problems')
|
|
58
|
+
if not isinstance(p['tutorials'], list) or len(p['tutorials']) > 256:
|
|
59
|
+
raise ProjectError('Grid supports at most 256 tutorial records')
|
|
60
|
+
if not isinstance(p['symbols'], list) or len(p['symbols']) > 256:
|
|
61
|
+
raise ProjectError('Grid supports at most 256 picture symbols')
|
|
62
|
+
symbols = set()
|
|
63
|
+
for row in p['symbols']:
|
|
64
|
+
if not isinstance(row, list) or len(row) != 2 or not isinstance(row[0], str) or len(row[0]) != 1:
|
|
65
|
+
raise ProjectError('Picture symbols are [single Latin-1 character, image ID]')
|
|
66
|
+
integer(ord(row[0]), 1, 255, 'Picture symbol')
|
|
67
|
+
integer(row[1], 0, 32767, 'Picture image ID')
|
|
68
|
+
if row[0] in symbols: raise ProjectError('Picture symbols must be unique')
|
|
69
|
+
symbols.add(row[0])
|
|
70
|
+
for tutorial, records in ((False, p['problems']), (True, p['tutorials'])):
|
|
71
|
+
for record in records:
|
|
72
|
+
if not isinstance(record, dict): raise ProjectError('Grid records must be objects')
|
|
73
|
+
width = integer(record.get('width'), 1, 5, 'Grid width')
|
|
74
|
+
height = integer(record.get('height'), 1, 5, 'Grid height')
|
|
75
|
+
allowed = ({'title', 'body', 'position', 'tap_to_advance', 'hide_board', 'width', 'height',
|
|
76
|
+
'heading', 'prompt', 'words', 'highlight', 'fixed_board', 'show_hints', 'animate',
|
|
77
|
+
'timed', 'failure_next', 'success_next', 'minimum_starts'} if tutorial else
|
|
78
|
+
{'width', 'height', 'heading', 'prompt', 'words', 'alphabet', 'highlight',
|
|
79
|
+
'advance_after_one', 'minimum_starts', 'id'})
|
|
80
|
+
if set(record) - allowed: raise ProjectError('Unknown grid record fields')
|
|
81
|
+
for key in ('title', 'body', 'heading', 'prompt', 'alphabet', 'fixed_board'):
|
|
82
|
+
if key in record and (not isinstance(record[key], str) or '\0' in record[key]):
|
|
83
|
+
raise ProjectError(f'Grid {key} must be text without NULs')
|
|
84
|
+
for key in ('tap_to_advance', 'hide_board', 'highlight', 'show_hints', 'animate', 'timed', 'advance_after_one'):
|
|
85
|
+
if key in record and type(record[key]) is not bool:
|
|
86
|
+
raise ProjectError(f'Grid {key} must be true or false')
|
|
87
|
+
for key in ('position', 'id'):
|
|
88
|
+
if key in record: integer(record[key], -32768, 32767, f'Grid {key}')
|
|
89
|
+
targets = record.get('words', [])
|
|
90
|
+
if not isinstance(targets, list): raise ProjectError('Grid words must be a list')
|
|
91
|
+
if targets: words(targets, 'Grid target words')
|
|
92
|
+
if not targets and not (tutorial and record.get('tap_to_advance') and not record.get('timed')):
|
|
93
|
+
raise ProjectError('Playable grids require target words')
|
|
94
|
+
if any(len(w) > width * height for w in targets):
|
|
95
|
+
raise ProjectError('Grid targets cannot exceed the number of cells')
|
|
96
|
+
integer(record.get('minimum_starts', 1), 1, width * height, 'Minimum starting cells')
|
|
97
|
+
if tutorial:
|
|
98
|
+
fixed = record.get('fixed_board', '')
|
|
99
|
+
if len(fixed.split('\n')) != height or any(len(row) != width for row in fixed.split('\n')):
|
|
100
|
+
raise ProjectError('Tutorial board needs fixed-width rows separated by a single newline')
|
|
101
|
+
for key in ('failure_next', 'success_next'):
|
|
102
|
+
value = record.get(key, -2)
|
|
103
|
+
if not (value == -2 or (key == 'failure_next' and value == -1 and not record.get('timed'))
|
|
104
|
+
or type(value) is int and 0 <= value < len(records)):
|
|
105
|
+
raise ProjectError('Tutorial branches must identify a tutorial or -2 (finish)')
|
|
106
|
+
else:
|
|
107
|
+
alphabet = record.get('alphabet', '')
|
|
108
|
+
if not alphabet or width * height < 2 or any(c not in alphabet for word in targets for c in word):
|
|
109
|
+
raise ProjectError('Grid alphabet and dimensions must be able to form the targets')
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def emit(asm, kind, p, text, character):
|
|
113
|
+
"""Return the result in the native R register; caller stores it explicitly."""
|
|
114
|
+
if kind == 'word_game':
|
|
115
|
+
asm.call(71, -1, text('text'), asm.text('|'.join(text(f'good.{i}', v, raw=True) for i, v in enumerate(p['good']))),
|
|
116
|
+
asm.text('|'.join(text(f'bad.{i}', v, raw=True) for i, v in enumerate(p['bad']))), p['duration_ms'], character('character'), 0, p['refresh_ms'])
|
|
117
|
+
elif kind == 'football':
|
|
118
|
+
tables = [asm.table([word for row in plan for word in row] + [0x7ffe]) for plan in p['plans']]
|
|
119
|
+
instructions = asm.table([text(f'instructions.{i}', value) for i, value in enumerate(p['instructions'])])
|
|
120
|
+
asm.call(94, p['first_seconds'], p['second_seconds'], *tables, instructions, int(p['start_offense']),
|
|
121
|
+
p['home_team'], p['away_team'], p['home_position'], p['away_position'], p['home_score'],
|
|
122
|
+
p['away_score'], int(p['allow_block']), int(p['pass_bonus']), int(p['run_bonus']), int(p['coach']),
|
|
123
|
+
text('extra_offense') if p['extra_offense'] else -1,
|
|
124
|
+
text('extra_defense') if p['extra_defense'] else -1)
|
|
125
|
+
else:
|
|
126
|
+
problems = []
|
|
127
|
+
for i, r in enumerate(p['problems']):
|
|
128
|
+
ref = lambda key, default='': text(f'problems.{i}.{key}', r.get(key, default))
|
|
129
|
+
# A trailing delimiter protects the native splitter's final one-byte word.
|
|
130
|
+
targets = '|'.join(text(f'problems.{i}.words.{j}', w, raw=True) for j, w in enumerate(r['words'])) + '|'
|
|
131
|
+
problems.extend((r['width'], r['height'], ref('heading'), ref('prompt'), asm.text(targets), ref('alphabet'),
|
|
132
|
+
int(r.get('highlight', False)), int(r.get('advance_after_one', False)),
|
|
133
|
+
r.get('minimum_starts', 1), r.get('id', 1001)))
|
|
134
|
+
problem_ref = asm.table(problems)
|
|
135
|
+
symbols = asm.table([item for char, resource in p['symbols'] for item in (ord(char), resource)])
|
|
136
|
+
tutorials = []
|
|
137
|
+
for i, r in enumerate(p['tutorials']):
|
|
138
|
+
ref = lambda key, default='': text(f'tutorials.{i}.{key}', r.get(key, default))
|
|
139
|
+
targets = '|'.join(text(f'tutorials.{i}.words.{j}', w, raw=True) for j, w in enumerate(r.get('words', []))) + '|'
|
|
140
|
+
tutorials.extend((ref('title'), ref('body'), r.get('position', 0), int(r.get('tap_to_advance', False)),
|
|
141
|
+
int(r.get('hide_board', False)), r['width'], r['height'], ref('heading'), ref('prompt'),
|
|
142
|
+
asm.text(targets), int(r.get('highlight', False)), ref('fixed_board'),
|
|
143
|
+
int(r.get('show_hints', False)), int(r.get('animate', True)), int(r.get('timed', False)),
|
|
144
|
+
r.get('failure_next', -2), r.get('success_next', -2)))
|
|
145
|
+
tutorial_ref = asm.table(tutorials)
|
|
146
|
+
asm.call(96, p['seconds'], p['target'], p['round_ms'], p['bonus_ms'], p['variant'], p['background'],
|
|
147
|
+
character('left_character'), p['left_expression'], character('right_character'), p['right_expression'],
|
|
148
|
+
int(p['shuffle']), len(p['problems']), problem_ref, int(p['feedback']), text('success'), text('failure'),
|
|
149
|
+
len(p['symbols']), symbols, len(p['tutorials']), tutorial_ref)
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Portable, versioned authoring documents, separate from executable EXP files."""
|
|
2
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import tempfile
|
|
10
|
+
|
|
11
|
+
from exp_runtime.exp import ContentError
|
|
12
|
+
|
|
13
|
+
FORMAT = 'exp-runtime-project'
|
|
14
|
+
VERSION = 2
|
|
15
|
+
MAX_PROJECT_BYTES = 96 * 1024 * 1024
|
|
16
|
+
MAX_ASSET_BYTES = 64 * 1024 * 1024
|
|
17
|
+
KINDS = ('dialogue', 'choice', 'background', 'set', 'branch', 'music', 'sound', 'stop_audio',
|
|
18
|
+
'relationship', 'score', 'input', 'text_branch', 'title', 'message', 'notice',
|
|
19
|
+
'emphasis', 'scene_label', 'pan', 'transition', 'close_dialogue', 'random',
|
|
20
|
+
'word_game', 'football', 'grid', 'scene', 'end', 'native')
|
|
21
|
+
OPERATORS = ('==', '!=', '<', '<=', '>', '>=')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProjectError(ContentError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Choice:
|
|
30
|
+
text: str = 'Choice'
|
|
31
|
+
target: str = ''
|
|
32
|
+
condition: str = ''
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Node:
|
|
37
|
+
id: str
|
|
38
|
+
kind: str = 'dialogue'
|
|
39
|
+
label: str = ''
|
|
40
|
+
x: float = 0
|
|
41
|
+
y: float = 0
|
|
42
|
+
text: str = ''
|
|
43
|
+
speaker: str = ''
|
|
44
|
+
portrait: int | None = None
|
|
45
|
+
theme: int = 2
|
|
46
|
+
next: str = ''
|
|
47
|
+
options: list[Choice] = field(default_factory=list)
|
|
48
|
+
timeout_ms: int = 0
|
|
49
|
+
timeout_option: int = 0
|
|
50
|
+
asset: int | None = None
|
|
51
|
+
variable: str = ''
|
|
52
|
+
value: int = 0
|
|
53
|
+
operator: str = '=='
|
|
54
|
+
otherwise: str = ''
|
|
55
|
+
scene: int = 25001
|
|
56
|
+
character: str = ''
|
|
57
|
+
expression: int = 0
|
|
58
|
+
params: dict = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
def links(self):
|
|
61
|
+
if self.kind == 'native':
|
|
62
|
+
from exp_runtime.decode.bytecode import BytecodeInstruction
|
|
63
|
+
rows = self.params.get('instructions', [])
|
|
64
|
+
if not rows: return []
|
|
65
|
+
try:
|
|
66
|
+
pc = self.params['pc'] + len(rows) - 1
|
|
67
|
+
ins = BytecodeInstruction(pc, 0, *rows[-1])
|
|
68
|
+
targets = []
|
|
69
|
+
if ins.branch_target() is not None: targets.append(ins.branch_target())
|
|
70
|
+
if ins.opcode not in (0x29, 0x33, 0x43): targets.append(pc + 1)
|
|
71
|
+
return [('Native', f'{self.params["script"]}:{target}') for target in dict.fromkeys(targets)]
|
|
72
|
+
except (KeyError, TypeError, ValueError):
|
|
73
|
+
return []
|
|
74
|
+
if self.kind == 'end':
|
|
75
|
+
return []
|
|
76
|
+
if self.kind == 'choice':
|
|
77
|
+
return [(option.text, option.target) for option in self.options]
|
|
78
|
+
if self.kind in ('branch', 'text_branch'):
|
|
79
|
+
return [('Yes', self.next), ('No', self.otherwise)]
|
|
80
|
+
return [('Next', self.next)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Asset:
|
|
85
|
+
id: int
|
|
86
|
+
name: str
|
|
87
|
+
# None is an exact reference into the selected game's original base bank.
|
|
88
|
+
# Embedded assets are owned by this document, never by an external path.
|
|
89
|
+
data: bytes | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Character:
|
|
94
|
+
id: str
|
|
95
|
+
name: str = 'Character'
|
|
96
|
+
portrait: int | None = None
|
|
97
|
+
theme: int = 2
|
|
98
|
+
expressions: list[int | None] = field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Project:
|
|
103
|
+
title: str = 'Untitled episode'
|
|
104
|
+
game: str = 'shs'
|
|
105
|
+
pack_id: int = 30000
|
|
106
|
+
episode_id: int = 1
|
|
107
|
+
start: str = 'start'
|
|
108
|
+
nodes: list[Node] = field(default_factory=lambda: [
|
|
109
|
+
Node('start', label='Opening', text='Your story begins here.', next='end', x=40, y=80),
|
|
110
|
+
Node('end', 'end', label='The end', x=360, y=80),
|
|
111
|
+
])
|
|
112
|
+
assets: list[Asset] = field(default_factory=list)
|
|
113
|
+
library: str = ''
|
|
114
|
+
characters: list[Character] = field(default_factory=list)
|
|
115
|
+
locales: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
116
|
+
language: str = 'en'
|
|
117
|
+
native: dict = field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
def node(self, node_id):
|
|
120
|
+
return next(node for node in self.nodes if node.id == node_id)
|
|
121
|
+
|
|
122
|
+
def add_image(self, name: str, data: bytes) -> Asset:
|
|
123
|
+
if not data.startswith((b'\x89PNG\r\n\x1a\n', b'\xff\xd8')):
|
|
124
|
+
raise ProjectError('Choose a PNG or JPEG image')
|
|
125
|
+
if len(data) > MAX_ASSET_BYTES:
|
|
126
|
+
raise ProjectError('Image exceeds the EXP resource size limit')
|
|
127
|
+
# Reserve five IDs per imported image. Service 72 probes base+1..4
|
|
128
|
+
# for expressions; unrelated images must not become expressions.
|
|
129
|
+
used = {asset.id for asset in self.assets}
|
|
130
|
+
resource = next((i for i in range(26000, 32763, 5)
|
|
131
|
+
if not used.intersection(range(i, i + 5))), None)
|
|
132
|
+
if resource is None:
|
|
133
|
+
raise ProjectError('No free episode image IDs remain')
|
|
134
|
+
asset = Asset(resource, name, data)
|
|
135
|
+
self.assets.append(asset)
|
|
136
|
+
return asset
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _integer(value, low, high, label):
|
|
140
|
+
if type(value) is not int or not low <= value <= high:
|
|
141
|
+
raise ProjectError(f'{label} must be an integer between {low} and {high}')
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _string(value, label, limit=60000):
|
|
145
|
+
if not isinstance(value, str) or len(value) > limit or '\0' in value:
|
|
146
|
+
raise ProjectError(f'Invalid {label}')
|
|
147
|
+
try:
|
|
148
|
+
value.encode('utf-8')
|
|
149
|
+
except UnicodeError as error:
|
|
150
|
+
raise ProjectError(f'Invalid Unicode in {label}') from error
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def validate_structure(project: Project):
|
|
154
|
+
"""Check data types and sizes, while allowing unfinished stories to save."""
|
|
155
|
+
_string(project.title, 'episode title', 1000)
|
|
156
|
+
_string(project.start, 'start node ID', 100)
|
|
157
|
+
_string(project.library, 'library location', 4096)
|
|
158
|
+
if project.game not in ('shs', 'cod'):
|
|
159
|
+
raise ProjectError('Choose the SHS or CoD game profile')
|
|
160
|
+
_integer(project.pack_id, 0, 32767, 'Pack ID')
|
|
161
|
+
_integer(project.episode_id, 1, 32767, 'Episode ID')
|
|
162
|
+
if not isinstance(project.nodes, list) or not 1 <= len(project.nodes) <= 20000:
|
|
163
|
+
raise ProjectError('A project needs between 1 and 20000 nodes')
|
|
164
|
+
ids = set()
|
|
165
|
+
for node in project.nodes:
|
|
166
|
+
if not isinstance(node, Node) or node.kind not in KINDS:
|
|
167
|
+
raise ProjectError('Unknown node type')
|
|
168
|
+
for name in ('id', 'label', 'speaker', 'next', 'otherwise', 'variable', 'character'):
|
|
169
|
+
_string(getattr(node, name), name, 200)
|
|
170
|
+
_string(node.text, 'node text')
|
|
171
|
+
if not node.id or node.id in ids:
|
|
172
|
+
raise ProjectError(f'Duplicate or empty node ID: {node.id}')
|
|
173
|
+
ids.add(node.id)
|
|
174
|
+
for coord in (node.x, node.y):
|
|
175
|
+
if type(coord) not in (float, int) or not math.isfinite(coord) or abs(coord) > 100000:
|
|
176
|
+
raise ProjectError('Invalid node position')
|
|
177
|
+
for resource in (node.portrait, node.asset):
|
|
178
|
+
if resource is not None:
|
|
179
|
+
_integer(resource, 0, 32767, 'Image resource ID')
|
|
180
|
+
_integer(node.theme, 0, 3, 'Portrait theme')
|
|
181
|
+
_integer(node.timeout_ms, 0, 32767, 'Choice timer (milliseconds)')
|
|
182
|
+
_integer(node.timeout_option, 0, 7, 'Timeout option')
|
|
183
|
+
_integer(node.value, -32768, 32767, 'Variable value')
|
|
184
|
+
_integer(node.scene, 25001, 25999, 'Scene script ID')
|
|
185
|
+
_integer(node.expression, 0, 4, 'Expression slot')
|
|
186
|
+
if not isinstance(node.params, dict):
|
|
187
|
+
raise ProjectError('Node parameters must be an object')
|
|
188
|
+
try:
|
|
189
|
+
if len(json.dumps(node.params, allow_nan=False)) > MAX_PROJECT_BYTES:
|
|
190
|
+
raise ValueError('Parameters exceed the project size limit')
|
|
191
|
+
except (TypeError, ValueError, RecursionError) as error:
|
|
192
|
+
raise ProjectError(f'Invalid node parameters: {error}') from error
|
|
193
|
+
if node.operator not in OPERATORS:
|
|
194
|
+
raise ProjectError('Unknown comparison operator')
|
|
195
|
+
if not isinstance(node.options, list) or len(node.options) > 8:
|
|
196
|
+
raise ProjectError('A choice can have at most eight answers')
|
|
197
|
+
for option in node.options:
|
|
198
|
+
if not isinstance(option, Choice):
|
|
199
|
+
raise ProjectError('Invalid choice option')
|
|
200
|
+
_string(option.text, 'option text', 2000)
|
|
201
|
+
_string(option.target, 'option target', 200)
|
|
202
|
+
_string(option.condition, 'option condition', 2000)
|
|
203
|
+
if not isinstance(project.characters, list) or len(project.characters) > 199:
|
|
204
|
+
raise ProjectError('At most 199 reusable characters are supported')
|
|
205
|
+
character_ids = set()
|
|
206
|
+
for character in project.characters:
|
|
207
|
+
if not isinstance(character, Character):
|
|
208
|
+
raise ProjectError('Invalid character definition')
|
|
209
|
+
_string(character.id, 'character ID', 100)
|
|
210
|
+
_string(character.name, 'character name', 200)
|
|
211
|
+
if not character.id or character.id in character_ids:
|
|
212
|
+
raise ProjectError('Character IDs must be nonempty and unique')
|
|
213
|
+
character_ids.add(character.id)
|
|
214
|
+
_integer(character.theme, 0, 3, 'Character color')
|
|
215
|
+
if not isinstance(character.expressions, list) or len(character.expressions) not in (0, 5):
|
|
216
|
+
raise ProjectError('Character expressions need exactly five slots')
|
|
217
|
+
for resource in (character.portrait, *character.expressions):
|
|
218
|
+
if resource is not None:
|
|
219
|
+
_integer(resource, 0, 32767, 'Character image')
|
|
220
|
+
if project.language not in ('en', 'fr', 'it', 'de', 'es'):
|
|
221
|
+
raise ProjectError('Choose en, fr, it, de or es as the export language')
|
|
222
|
+
if not isinstance(project.locales, dict) or set(project.locales) - {'en', 'fr', 'it', 'de', 'es'}:
|
|
223
|
+
raise ProjectError('Invalid translation languages')
|
|
224
|
+
for language, translations in project.locales.items():
|
|
225
|
+
if not isinstance(translations, dict):
|
|
226
|
+
raise ProjectError('Translations must map field keys to text')
|
|
227
|
+
for key, value in translations.items():
|
|
228
|
+
_string(key, 'translation key', 300)
|
|
229
|
+
_string(value, 'translated text')
|
|
230
|
+
if not isinstance(project.native, dict):
|
|
231
|
+
raise ProjectError('Invalid native archive data')
|
|
232
|
+
if not isinstance(project.assets, list) or len(project.assets) > 2000:
|
|
233
|
+
raise ProjectError('Too many image assets')
|
|
234
|
+
ids, size = set(), 0
|
|
235
|
+
for asset in project.assets:
|
|
236
|
+
if not isinstance(asset, Asset):
|
|
237
|
+
raise ProjectError('Invalid asset')
|
|
238
|
+
_integer(asset.id, 0, 32767, 'Asset ID')
|
|
239
|
+
_string(asset.name, 'asset name', 1000)
|
|
240
|
+
if asset.id in ids:
|
|
241
|
+
raise ProjectError(f'Duplicate asset ID: {asset.id}')
|
|
242
|
+
ids.add(asset.id)
|
|
243
|
+
if asset.data is None:
|
|
244
|
+
if asset.id >= 25000:
|
|
245
|
+
raise ProjectError('Only base-bank images may be external references')
|
|
246
|
+
else:
|
|
247
|
+
if (asset.id < 26000 and not project.native) or not isinstance(asset.data, bytes):
|
|
248
|
+
raise ProjectError('Embedded images must use episode IDs starting at 26000')
|
|
249
|
+
size += len(asset.data)
|
|
250
|
+
if len(asset.data) > MAX_ASSET_BYTES or size > MAX_ASSET_BYTES:
|
|
251
|
+
raise ProjectError('Embedded images exceed 64 MiB')
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def atomic_write(path: Path, data: bytes):
|
|
255
|
+
"""Replace only after the complete file has reached disk."""
|
|
256
|
+
path = Path(path)
|
|
257
|
+
temporary = None
|
|
258
|
+
try:
|
|
259
|
+
with tempfile.NamedTemporaryFile(dir=path.parent, prefix='.' + path.name,
|
|
260
|
+
suffix='.tmp', delete=False) as file:
|
|
261
|
+
temporary = Path(file.name)
|
|
262
|
+
file.write(data)
|
|
263
|
+
file.flush()
|
|
264
|
+
os.fsync(file.fileno())
|
|
265
|
+
temporary.replace(path)
|
|
266
|
+
finally:
|
|
267
|
+
if temporary is not None:
|
|
268
|
+
temporary.unlink(missing_ok=True)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def project_record(project: Project):
|
|
272
|
+
validate_structure(project)
|
|
273
|
+
data = asdict(project)
|
|
274
|
+
for asset, record in zip(project.assets, data['assets']):
|
|
275
|
+
record['data'] = base64.b64encode(asset.data).decode('ascii') if asset.data is not None else None
|
|
276
|
+
return dict(format=FORMAT, version=VERSION, **data)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def project_bytes(project: Project, path: Path) -> bytes:
|
|
280
|
+
data = project_record(project)
|
|
281
|
+
if project.library:
|
|
282
|
+
try:
|
|
283
|
+
data['library'] = Path(os.path.relpath(Path(project.library).resolve(), Path(path).resolve().parent)).as_posix()
|
|
284
|
+
except ValueError: # Separate Windows volumes.
|
|
285
|
+
data['library'] = str(Path(project.library).resolve())
|
|
286
|
+
encoded = (json.dumps(data, indent=2,
|
|
287
|
+
ensure_ascii=False) + '\n').encode('utf-8')
|
|
288
|
+
if len(encoded) > MAX_PROJECT_BYTES:
|
|
289
|
+
raise ProjectError('Project file exceeds 96 MiB')
|
|
290
|
+
return encoded
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def save_project(project: Project, path: Path):
|
|
294
|
+
atomic_write(path, project_bytes(project, path))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _construct(kind, data):
|
|
298
|
+
if not isinstance(data, dict) or set(data) != {f.name for f in fields(kind)}:
|
|
299
|
+
raise ProjectError(f'Invalid {kind.__name__} fields')
|
|
300
|
+
return kind(**data)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _upgrade_episode_links(nodes):
|
|
304
|
+
"""Retire the unreleased link extension, preserving its optional message."""
|
|
305
|
+
ids = {node.id for node in nodes}
|
|
306
|
+
endings = []
|
|
307
|
+
for node in nodes:
|
|
308
|
+
if node.kind != 'episode_link': continue
|
|
309
|
+
if not isinstance(node.params, dict): raise ProjectError('Node parameters must be an object')
|
|
310
|
+
title = node.params.get('title', 'Next episode')
|
|
311
|
+
node.kind, node.params = 'end', {}
|
|
312
|
+
if node.text:
|
|
313
|
+
base = node.id[:180] + '_end'
|
|
314
|
+
target, suffix = base, 1
|
|
315
|
+
while target in ids:
|
|
316
|
+
target, suffix = f'{base}_{suffix}', suffix + 1
|
|
317
|
+
ids.add(target)
|
|
318
|
+
endings.append(Node(target, 'end', label='End episode', scene=node.scene,
|
|
319
|
+
x=min(100000, node.x + 320), y=node.y))
|
|
320
|
+
node.kind, node.params, node.next = 'message', {'title': title}, target
|
|
321
|
+
nodes.extend(endings)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def project_from_record(data):
|
|
325
|
+
from copy import deepcopy
|
|
326
|
+
data = deepcopy(data)
|
|
327
|
+
try:
|
|
328
|
+
if (not isinstance(data, dict) or data.pop('format', None) != FORMAT
|
|
329
|
+
or type(data.get('version')) is not int or data['version'] not in (1, VERSION)):
|
|
330
|
+
raise ProjectError('Unsupported episode project format or version')
|
|
331
|
+
version = data.pop('version')
|
|
332
|
+
if version == 1:
|
|
333
|
+
data.update(characters=[], locales={}, language='en', native={})
|
|
334
|
+
for node in data['nodes']:
|
|
335
|
+
node.update(scene=25001, character='', expression=0, params={})
|
|
336
|
+
for option in node['options']:
|
|
337
|
+
option['condition'] = ''
|
|
338
|
+
nodes = []
|
|
339
|
+
for record in data['nodes']:
|
|
340
|
+
node = _construct(Node, record)
|
|
341
|
+
node.options = [_construct(Choice, option) for option in node.options]
|
|
342
|
+
nodes.append(node)
|
|
343
|
+
_upgrade_episode_links(nodes)
|
|
344
|
+
data['nodes'] = nodes
|
|
345
|
+
data['characters'] = [_construct(Character, c) for c in data['characters']]
|
|
346
|
+
assets = []
|
|
347
|
+
for record in data['assets']:
|
|
348
|
+
asset = _construct(Asset, record)
|
|
349
|
+
if asset.data is not None:
|
|
350
|
+
asset.data = base64.b64decode(asset.data, validate=True)
|
|
351
|
+
assets.append(asset)
|
|
352
|
+
data['assets'] = assets
|
|
353
|
+
project = _construct(Project, data)
|
|
354
|
+
validate_structure(project)
|
|
355
|
+
return project
|
|
356
|
+
except (KeyError, TypeError, ValueError, UnicodeError, binascii.Error, RecursionError) as error:
|
|
357
|
+
raise ProjectError(f'Cannot read episode project: {error}') from error
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def project_from_bytes(data: bytes, path: Path) -> Project:
|
|
361
|
+
path = Path(path)
|
|
362
|
+
if len(data) > MAX_PROJECT_BYTES:
|
|
363
|
+
raise ProjectError('Project file exceeds 96 MiB')
|
|
364
|
+
try:
|
|
365
|
+
project = project_from_record(json.loads(data.decode('utf-8')))
|
|
366
|
+
if project.library:
|
|
367
|
+
project.library = str((path.resolve().parent / project.library).resolve())
|
|
368
|
+
return project
|
|
369
|
+
except (KeyError, TypeError, ValueError, UnicodeError, binascii.Error, RecursionError) as error:
|
|
370
|
+
raise ProjectError(f'Cannot read episode project: {error}') from error
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def load_project(path: Path) -> Project:
|
|
374
|
+
path = Path(path)
|
|
375
|
+
if path.stat().st_size > MAX_PROJECT_BYTES:
|
|
376
|
+
raise ProjectError('Project file exceeds 96 MiB')
|
|
377
|
+
return project_from_bytes(path.read_bytes(), path)
|