feat: converter reads the Phone library's register statement, named stores and generators
`init phone register` becomes a default (the group chat) plus a function of library calls that runs with the other defaults of a new game (phone_register.py). `init/python [early|hide] in some.store` wraps its code in an in_store node and `default some.store.name` may name a store; yield works as an expression (generator functions are marked gen); `import x as y`, `transform -150 name(...)`, `init:` with if/else, `config.special_namespaces` lines, `at` lists and tuples, transforms made by Python functions, `choice` weights that are expressions, `__len__`/`__bool__`, and the whitelist for os, calendar, itertools, pygame_sdl2 and the new runtime. The converter also records the size of every picture it converts (`imageSizes`) so `renpy.image_size` answers without loading. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e94f9b0d9e
commit
83cc7fdd5c
11 files changed
+379
-26
No files matched your search
@@ -399,7 +399,9 @@ class AtlParser:
|
|||||||
break
|
break
|
||||||
weight: Any = 1.0
|
weight: Any = 1.0
|
||||||
if first == "choice" and len(words) > 1:
|
if first == "choice" and len(words) > 1:
|
||||||
weight = self.number(words[1], current) or 1.0
|
# the weight may be any expression, `not flag` included; a false flag means no chance at all
|
||||||
|
weight = self.number(" ".join(words[1:]), current)
|
||||||
|
weight = 1.0 if weight is None else weight
|
||||||
group.append((weight, self.block(current.block)))
|
group.append((weight, self.block(current.block)))
|
||||||
j += 1
|
j += 1
|
||||||
if first == "parallel":
|
if first == "parallel":
|
||||||
|
|||||||
@@ -124,6 +124,16 @@ CALLS |= set(NATIVE_CLASSES)
|
|||||||
CALLS |= {"renpy.Render", "renpy.render", "renpy.redraw", "renpy.displayable", "renpy.IgnoreEvent",
|
CALLS |= {"renpy.Render", "renpy.render", "renpy.redraw", "renpy.displayable", "renpy.IgnoreEvent",
|
||||||
"renpy.display.layout.LiveComposite", "renpy.display.layout.Fixed", "pygame.key.get_mods",
|
"renpy.display.layout.LiveComposite", "renpy.display.layout.Fixed", "pygame.key.get_mods",
|
||||||
"pygame.mouse.get_pressed", "pygame.mouse.get_pos"}
|
"pygame.mouse.get_pressed", "pygame.mouse.get_pos"}
|
||||||
|
CALLS |= {"object", "anim.Blink", "ExplodeText", "MouseDisplayable", "ui.saybehavior", "renpy.display.layout.AdjustTimes",
|
||||||
|
"renpy.display.layout.Motion", "renpy.display.matrix.Matrix", "renpy.display.matrix.offset",
|
||||||
|
"renpy.display.matrix.rotate", "renpy.display.matrix.perspective", "renpy.display.matrix.texture_projection",
|
||||||
|
"renpy.display.matrix.screen_projection", "renpy.gl2.gl2mesh2.Mesh2.texture_rectangle", "get_power_info"}
|
||||||
|
CALLS |= {"SnowBlossom", "chain", "itertools.chain", "calendar.Calendar", "calendar.monthrange", "calendar.isleap",
|
||||||
|
"os.listdir", "os.path.join", "os.path.basename", "os.path.dirname", "os.path.splitext", "os.path.exists",
|
||||||
|
"os.path.isfile", "time.gmtime", "time.localtime", "_preferences.set_volume", "_preferences.set_mute",
|
||||||
|
"Layer", "renpy.known_languages", "renpy.predict_screen", "renpy.predicting", "renpy.rollback",
|
||||||
|
"renpy.invoke_in_new_context", "renpy.add_layer", "renpy.show_layer_at", "renpy.filter_text_tags",
|
||||||
|
"renpy.text.text.Text", "renpy.easy.displayable_or_none", "renpy.easy.displayable"}
|
||||||
CALLS |= {"ConditionSwitch", "DynamicImage", "renpy.register_sl_displayable", "renpy.capture_focus", "renpy.clear_capture_focus", "renpy.get_widget", "renpy.display.focus.set_focused",
|
CALLS |= {"ConditionSwitch", "DynamicImage", "renpy.register_sl_displayable", "renpy.capture_focus", "renpy.clear_capture_focus", "renpy.get_widget", "renpy.display.focus.set_focused",
|
||||||
"renpy.file", "renpy.open_file", "loads", "dumps", "json.loads", "json.dumps", "Animation", "im.matrix", "renpy.context", "renpy.call_in_new_context", "renpy.get_focus_rect", "gui.rebuild", "FontGroup", "RenpyText",
|
"renpy.file", "renpy.open_file", "loads", "dumps", "json.loads", "json.dumps", "Animation", "im.matrix", "renpy.context", "renpy.call_in_new_context", "renpy.get_focus_rect", "gui.rebuild", "FontGroup", "RenpyText",
|
||||||
"MatrixColor", "im.Image", "im.AlphaMask", "GLMovie", "renpy.display.im.Image", "colorsys.rgb_to_hls",
|
"MatrixColor", "im.Image", "im.AlphaMask", "GLMovie", "renpy.display.im.Image", "colorsys.rgb_to_hls",
|
||||||
@@ -158,6 +168,7 @@ SCREEN_CALLS |= set("""FileAction FileSave FileLoad FileDelete FileTakeScreensho
|
|||||||
FileLoadable FileUsedSlot FileNewest FileSaveName FileTime FileJson FileScreenshot FileCurrentScreenshot
|
FileLoadable FileUsedSlot FileNewest FileSaveName FileTime FileJson FileScreenshot FileCurrentScreenshot
|
||||||
FileCurrentPage FilePageName FileSlotName""".split())
|
FileCurrentPage FilePageName FileSlotName""".split())
|
||||||
SCREEN_CALLS |= {"renpy.translation.translate_string"}
|
SCREEN_CALLS |= {"renpy.translation.translate_string"}
|
||||||
|
SCREEN_CALLS |= {"Skip", "Rollback", "ToggleLocalVariable", "Layer"}
|
||||||
# Text fields and the actions around them (see InputValue in screens.ts)
|
# Text fields and the actions around them (see InputValue in screens.ts)
|
||||||
SCREEN_CALLS |= set("""VariableInputValue ScreenVariableInputValue FieldInputValue DictInputValue FilePageNameInputValue
|
SCREEN_CALLS |= set("""VariableInputValue ScreenVariableInputValue FieldInputValue DictInputValue FilePageNameInputValue
|
||||||
CurrentScreenName CaptureFocus ClearFocus InvertSelected UploadSync DownloadSync Confirm""".split())
|
CurrentScreenName CaptureFocus ClearFocus InvertSelected UploadSync DownloadSync Confirm""".split())
|
||||||
@@ -186,7 +197,8 @@ METHODS = set("append extend insert pop remove index count reverse sort clear co
|
|||||||
|
|
||||||
METHODS |= NATIVE_METHODS
|
METHODS |= NATIVE_METHODS
|
||||||
# Methods of generators made by `renpy.random.Random()` / `random.Random()`
|
# Methods of generators made by `renpy.random.Random()` / `random.Random()`
|
||||||
METHODS |= {"add_style_property", "add_prefix_style_property", "add_pattern", "get_context", "read", "readlines", "close", "sub", "subn", "search", "match", "findall", "finditer", "split", "time", "_clear", "Enable", "Disable", "Toggle", "encode", "blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
|
METHODS |= {"send", "_duplicate", "_unique", "set_child", "get_placement", "subsurface", "update_state",
|
||||||
|
"adjusted_times", "get_range", "interpolate", "from_iterable", "itermonthdays", "isnumeric", "isdecimal", "add_style_property", "add_prefix_style_property", "add_pattern", "get_context", "read", "readlines", "close", "sub", "subn", "search", "match", "findall", "finditer", "split", "time", "_clear", "Enable", "Disable", "Toggle", "encode", "blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
|
||||||
"add_focus", "circle", "rect", "line", "lines", "polygon"}
|
"add_focus", "circle", "rect", "line", "lines", "polygon"}
|
||||||
METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"}
|
METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"}
|
||||||
|
|
||||||
@@ -350,7 +362,8 @@ def mark_screen_calls(data) -> None:
|
|||||||
visit([live, data.defaults, [data.screens[n] for n in data.screen_used if n in data.screens]])
|
visit([live, data.defaults, [data.screens[n] for n in data.screen_used if n in data.screens]])
|
||||||
|
|
||||||
|
|
||||||
IMPLICIT_METHODS = {"__init__", "__str__", "__repr__", "__hash__", "__eq__", "__iter__", "__getitem__", "__contains__"}
|
IMPLICIT_METHODS = {"__init__", "__str__", "__repr__", "__hash__", "__eq__", "__iter__", "__getitem__", "__contains__", "__len__",
|
||||||
|
"__bool__"}
|
||||||
|
|
||||||
# Labels Ren'Py itself defines (renpy/common); jumping to them goes to the player's own menu
|
# Labels Ren'Py itself defines (renpy/common); jumping to them goes to the player's own menu
|
||||||
ENGINE_LABELS = {"main_menu_screen", "_main_menu_screen", "main_menu"}
|
ENGINE_LABELS = {"main_menu_screen", "_main_menu_screen", "main_menu"}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""The ``init phone register`` statement of the Phone library (a chat app games put on a phone).
|
||||||
|
|
||||||
|
In Ren'Py the library registers the statement with its own parser, which runs while the game's scripts are read;
|
||||||
|
the converter cannot run it, so the statement is read here and turned into the Python the library would run:
|
||||||
|
|
||||||
|
init phone register:
|
||||||
|
define "[nova]":
|
||||||
|
add "mc" add "nova"
|
||||||
|
icon phone.asset("nova.png")
|
||||||
|
as novaTexts key "novaTexts"
|
||||||
|
label "Previous messages cannot be loaded."
|
||||||
|
time year 2023 month 12 day 25 hour 7 minute 7
|
||||||
|
"nova" "Morning!"
|
||||||
|
|
||||||
|
becomes ``default novaTexts = phone.group_chat.GroupChat(...)`` plus a function that adds the characters and calls
|
||||||
|
``phone.discussion.register_message/_label/_date/_image/_audio`` for each line. The function runs with the other
|
||||||
|
defaults of a new game, after the group chat exists. Only what the library's own parser accepts is read; anything
|
||||||
|
else raises ``PhoneSyntax`` and the statement is reported as unsupported.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.converter.rpy_lexer import Line
|
||||||
|
|
||||||
|
STRING = r"""(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')"""
|
||||||
|
TIME_PARTS = ("month", "day", "year", "hour", "minute", "second")
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneSyntax(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _split_expression(text: str) -> tuple[str, str]:
|
||||||
|
"""The first simple expression of `text` (a string, or a name with brackets) and what follows it."""
|
||||||
|
text = text.lstrip()
|
||||||
|
if (m := re.match(STRING, text)):
|
||||||
|
return m[0], text[m.end():].lstrip()
|
||||||
|
depth, i = 0, 0
|
||||||
|
while i < len(text):
|
||||||
|
ch = text[i]
|
||||||
|
if ch in "([{":
|
||||||
|
depth += 1
|
||||||
|
elif ch in ")]}":
|
||||||
|
depth -= 1
|
||||||
|
elif ch.isspace() and depth == 0:
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
if not text[:i]:
|
||||||
|
raise PhoneSyntax("ожидалось выражение")
|
||||||
|
return text[:i], text[i:].lstrip()
|
||||||
|
|
||||||
|
|
||||||
|
class Register:
|
||||||
|
"""One `init phone register` block turned into Python source."""
|
||||||
|
|
||||||
|
def __init__(self, line: Line, number: int):
|
||||||
|
self.line = line
|
||||||
|
self.number = number
|
||||||
|
header = line.text.strip()
|
||||||
|
match = re.fullmatch(r"phone\s+register\s*(.*?):", header, re.S)
|
||||||
|
if not match:
|
||||||
|
raise PhoneSyntax("ожидалось `phone register`")
|
||||||
|
self.target = match[1].strip() or None # an existing group chat, or None when the block defines one
|
||||||
|
self.default: tuple[str, str] | None = None # (variable, expression)
|
||||||
|
self.body: list[str] = []
|
||||||
|
|
||||||
|
def source(self) -> tuple[str, tuple[str, str] | None]:
|
||||||
|
children = list(self.line.block)
|
||||||
|
lines: list[str] = []
|
||||||
|
if self.target is None:
|
||||||
|
if not children or not children[0].text.strip().startswith("define"):
|
||||||
|
raise PhoneSyntax("ожидалось `define`")
|
||||||
|
define = children.pop(0)
|
||||||
|
lines.extend(self.define(define))
|
||||||
|
else:
|
||||||
|
lines.append(f"gc = phone.group_chat.group_chat({self.target})")
|
||||||
|
if not children and self.target is not None:
|
||||||
|
raise PhoneSyntax("ожидался хотя бы один оператор")
|
||||||
|
lines.extend(self.statements(children, 1))
|
||||||
|
function = f"__phone_register_{self.number}"
|
||||||
|
text = f"def {function}():\n" + "\n".join(" " + item for item in lines) + "\n pass\n"
|
||||||
|
return text, self.default
|
||||||
|
|
||||||
|
# -- the group chat -------------------------------------------------------------------
|
||||||
|
|
||||||
|
def define(self, line: Line) -> list[str]:
|
||||||
|
match = re.fullmatch(rf"define\s+({STRING})\s*:", line.text.strip(), re.S)
|
||||||
|
if not match:
|
||||||
|
raise PhoneSyntax("ожидалось `define \"имя\":`")
|
||||||
|
name = match[1]
|
||||||
|
icon = key = variable = None
|
||||||
|
characters: list[str] = []
|
||||||
|
transient = False
|
||||||
|
for child in line.block:
|
||||||
|
rest = child.text.strip()
|
||||||
|
while rest:
|
||||||
|
word, _, tail = rest.partition(" ")
|
||||||
|
rest = tail.lstrip()
|
||||||
|
if word == "key":
|
||||||
|
key, rest = _split_expression(rest)
|
||||||
|
elif word == "icon":
|
||||||
|
icon, rest = _split_expression(rest)
|
||||||
|
elif word == "add":
|
||||||
|
character, rest = _split_expression(rest)
|
||||||
|
characters.append(character)
|
||||||
|
elif word == "transient":
|
||||||
|
transient = True
|
||||||
|
elif word == "as":
|
||||||
|
variable, rest = _split_expression(rest)
|
||||||
|
else:
|
||||||
|
raise PhoneSyntax(f"неизвестное свойство {word}")
|
||||||
|
if key is None:
|
||||||
|
raise PhoneSyntax("ожидалось свойство key")
|
||||||
|
icon = icon or 'phone.asset("default_icon.png")'
|
||||||
|
create = f"phone.group_chat.GroupChat({name}, {icon}, {key}, transient={transient})"
|
||||||
|
out = []
|
||||||
|
if variable is not None:
|
||||||
|
self.default = (variable, create)
|
||||||
|
out.append(f"gc = {variable}")
|
||||||
|
else:
|
||||||
|
out.append(f"gc = {create}")
|
||||||
|
out.extend(f"gc.add_character(phone.character.character({c}))" for c in characters)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# -- the lines ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def statements(self, lines: list[Line], depth: int) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
text = line.text.strip()
|
||||||
|
i += 1
|
||||||
|
if text.startswith("if ") or text.startswith("if("):
|
||||||
|
condition = text[2:].strip().removesuffix(":").strip()
|
||||||
|
out.append(f"if {condition}:")
|
||||||
|
out.extend(" " + item for item in self.statements(line.block, depth + 1) or ["pass"])
|
||||||
|
while i < len(lines) and re.match(r"(elif\b|else\s*:)", lines[i].text.strip()):
|
||||||
|
branch = lines[i].text.strip().removesuffix(":").strip()
|
||||||
|
out.append(f"{branch}:")
|
||||||
|
out.extend(" " + item for item in self.statements(lines[i].block, depth + 1) or ["pass"])
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
out.extend(self.one(line, text))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def one(self, line: Line, text: str) -> list[str]:
|
||||||
|
if text == "pass":
|
||||||
|
return []
|
||||||
|
if text.startswith("$"):
|
||||||
|
return [text[1:].strip()]
|
||||||
|
if re.fullmatch(r"python\s*:", text):
|
||||||
|
return [row for row in line.block_source().splitlines() if row.strip()]
|
||||||
|
if (m := re.match(r"label\s+(" + STRING + r")\s*$", text)):
|
||||||
|
return [f"phone.discussion.register_label(gc, {m[1]})"]
|
||||||
|
if text.startswith("time ") or text == "time":
|
||||||
|
return [self.date(text[4:].strip())]
|
||||||
|
if (m := re.match(r"image\s+(.*)", text)):
|
||||||
|
sender, rest = _split_expression(m[1])
|
||||||
|
image, rest = _split_expression(rest)
|
||||||
|
if rest:
|
||||||
|
raise PhoneSyntax("лишнее после image")
|
||||||
|
return [f"phone.discussion.register_image(gc, {sender}, {image})"]
|
||||||
|
if (m := re.match(r"audio\s+(.*)", text)):
|
||||||
|
sender, rest = _split_expression(m[1])
|
||||||
|
audio, rest = _split_expression(rest)
|
||||||
|
if rest:
|
||||||
|
raise PhoneSyntax("лишнее после audio")
|
||||||
|
return [f"phone.discussion.register_audio(gc, {sender}, {audio})"]
|
||||||
|
sender, rest = _split_expression(text)
|
||||||
|
message, rest = _split_expression(rest)
|
||||||
|
if rest or not re.fullmatch(STRING, message):
|
||||||
|
raise PhoneSyntax("ожидалось: отправитель \"сообщение\"")
|
||||||
|
return [f"phone.discussion.register_message(gc, {sender}, {message})"]
|
||||||
|
|
||||||
|
def date(self, text: str) -> str:
|
||||||
|
values = {part: "None" for part in TIME_PARTS}
|
||||||
|
auto = "False"
|
||||||
|
seen: set[str] = set()
|
||||||
|
rest = text
|
||||||
|
while rest:
|
||||||
|
word, _, tail = rest.partition(" ")
|
||||||
|
if word not in values and word != "auto":
|
||||||
|
raise PhoneSyntax(f"`{word}` не свойство time")
|
||||||
|
if word in seen:
|
||||||
|
raise PhoneSyntax(f"`{word}` указано дважды")
|
||||||
|
if word == "auto":
|
||||||
|
raise PhoneSyntax("`auto` нельзя в init phone register")
|
||||||
|
value, rest = _split_expression(tail)
|
||||||
|
values[word] = value
|
||||||
|
seen.add(word)
|
||||||
|
arguments = ", ".join(f"{k}={v}" for k, v in values.items())
|
||||||
|
return f"phone.discussion.register_date(gc, auto={auto}, {arguments})"
|
||||||
@@ -134,11 +134,16 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
|||||||
failed: list[str] = []
|
failed: list[str] = []
|
||||||
done = 0
|
done = 0
|
||||||
|
|
||||||
|
image_sizes: dict[str, list[int]] = {}
|
||||||
|
|
||||||
def work_one(p: Path) -> tuple[str, str, str, int]:
|
def work_one(p: Path) -> tuple[str, str, str, int]:
|
||||||
|
dimensions = _dimensions(p)
|
||||||
out, ext = assets.convert(p, out_dir)
|
out, ext = assets.convert(p, out_dir)
|
||||||
blob_id, size = put_file(out, ext, move=True)
|
blob_id, size = put_file(out, ext, move=True)
|
||||||
rel = p.relative_to(game_dir).as_posix()
|
rel = p.relative_to(game_dir).as_posix()
|
||||||
logical = str(PurePosixPath(rel).with_suffix(ext))
|
logical = str(PurePosixPath(rel).with_suffix(ext))
|
||||||
|
if dimensions:
|
||||||
|
image_sizes[logical] = dimensions # the game asks for them (`renpy.image_size`) before anything is loaded
|
||||||
return rel, logical, blob_id, size
|
return rel, logical, blob_id, size
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=get_settings().worker_threads) as pool:
|
with ThreadPoolExecutor(max_workers=get_settings().worker_threads) as pool:
|
||||||
@@ -223,6 +228,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
|||||||
"build": data.build,
|
"build": data.build,
|
||||||
"files": aliases,
|
"files": aliases,
|
||||||
"dataFiles": data_files,
|
"dataFiles": data_files,
|
||||||
|
"imageSizes": image_sizes,
|
||||||
"defaults": data.defaults,
|
"defaults": data.defaults,
|
||||||
"init": data.init_code,
|
"init": data.init_code,
|
||||||
"ui": _ui(gui, aliases),
|
"ui": _ui(gui, aliases),
|
||||||
@@ -342,6 +348,19 @@ def _used_screens(data: ScriptData) -> set[str]:
|
|||||||
DATA_FILE_LIMIT = 4 * 1024 * 1024
|
DATA_FILE_LIMIT = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _dimensions(path: Path) -> list[int] | None:
|
||||||
|
"""Width and height of a picture, read from its header (None for other media)."""
|
||||||
|
if assets.kind_of(path) != "image":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with Image.open(path) as img:
|
||||||
|
return [int(img.size[0]), int(img.size[1])]
|
||||||
|
except Exception: # noqa: BLE001 - the conversion itself reports a broken picture
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _is_engine_file(game_dir: Path, p: Path) -> bool:
|
def _is_engine_file(game_dir: Path, p: Path) -> bool:
|
||||||
parts = [x.lower() for x in p.relative_to(game_dir).parts]
|
parts = [x.lower() for x in p.relative_to(game_dir).parts]
|
||||||
return len(parts) > 1 and parts[0] in ENGINE_DIRS
|
return len(parts) > 1 and parts[0] in ENGINE_DIRS
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
|
|||||||
|
|
||||||
# Modules whose import is harmless: the player provides the functions it supports.
|
# Modules whose import is harmless: the player provides the functions it supports.
|
||||||
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections",
|
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections",
|
||||||
"itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid", "pygame", "colorsys", "fnmatch", "traceback", "builtins"}
|
"itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid", "pygame", "colorsys", "fnmatch", "traceback", "builtins", "os", "calendar", "pygame_sdl2"}
|
||||||
|
|
||||||
|
|
||||||
class Unsupported(Exception):
|
class Unsupported(Exception):
|
||||||
@@ -146,6 +146,8 @@ def expr(node: ast.AST) -> Expr:
|
|||||||
"r": [expr(r) for r in rs]}
|
"r": [expr(r) for r in rs]}
|
||||||
case ast.IfExp(test=t, body=b, orelse=e):
|
case ast.IfExp(test=t, body=b, orelse=e):
|
||||||
return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)}
|
return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)}
|
||||||
|
case ast.Yield(value=value): # `x = yield` and `x = yield v`: the value sent in by `.send(...)`
|
||||||
|
return {"k": "yield", "v": None if value is None else expr(value)}
|
||||||
case ast.List(elts=elts) | ast.Set(elts=elts):
|
case ast.List(elts=elts) | ast.Set(elts=elts):
|
||||||
return {"k": "list", "v": [element(e) for e in elts]}
|
return {"k": "list", "v": [element(e) for e in elts]}
|
||||||
case ast.Tuple(elts=elts):
|
case ast.Tuple(elts=elts):
|
||||||
@@ -282,7 +284,7 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
|||||||
# Pickle hooks only matter for Ren'Py's own save files; the player keeps its own
|
# Pickle hooks only matter for Ren'Py's own save files; the player keeps its own
|
||||||
IGNORED_DUNDERS = {"__setstate__", "__getstate__", "__reduce__", "__reduce_ex__"}
|
IGNORED_DUNDERS = {"__setstate__", "__getstate__", "__reduce__", "__reduce_ex__"}
|
||||||
SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", "__getitem__",
|
SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", "__getitem__",
|
||||||
"__contains__"}
|
"__contains__", "__len__", "__bool__"}
|
||||||
INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"}
|
INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"}
|
||||||
TRANSPARENT_DECORATORS = {"renpy.pure"} # marks a function as side-effect free; no runtime meaning
|
TRANSPARENT_DECORATORS = {"renpy.pure"} # marks a function as side-effect free; no runtime meaning
|
||||||
DECORATORS = {"staticmethod", "classmethod", "property"}
|
DECORATORS = {"staticmethod", "classmethod", "property"}
|
||||||
@@ -314,18 +316,17 @@ def decorator(node: ast.expr) -> Expr:
|
|||||||
|
|
||||||
|
|
||||||
def is_generator(body: list[ast.stmt]) -> bool:
|
def is_generator(body: list[ast.stmt]) -> bool:
|
||||||
"""A function with `yield` statements. The player runs it to the end and hands out what it yielded, which is
|
"""A function with `yield` in it: the player runs it step by step (next, send). `yield from` is accepted as a
|
||||||
right for generators that end; `x = yield` (sending values in) and async code stay unsupported."""
|
statement only, and async code stays unsupported."""
|
||||||
module = ast.Module(body=body, type_ignores=[])
|
module = ast.Module(body=body, type_ignores=[])
|
||||||
statement_yields = {id(n.value) for n in ast.walk(module)
|
statement_froms = {id(n.value) for n in ast.walk(module) if isinstance(n, ast.Expr) and isinstance(n.value, ast.YieldFrom)}
|
||||||
if isinstance(n, ast.Expr) and isinstance(n.value, (ast.Yield, ast.YieldFrom))}
|
|
||||||
found = False
|
found = False
|
||||||
for n in ast.walk(module):
|
for n in ast.walk(module):
|
||||||
if isinstance(n, ast.Await):
|
if isinstance(n, ast.Await):
|
||||||
raise Unsupported("async")
|
raise Unsupported("async")
|
||||||
|
if isinstance(n, ast.YieldFrom) and id(n) not in statement_froms:
|
||||||
|
raise Unsupported("yield from как выражение")
|
||||||
if isinstance(n, (ast.Yield, ast.YieldFrom)):
|
if isinstance(n, (ast.Yield, ast.YieldFrom)):
|
||||||
if id(n) not in statement_yields:
|
|
||||||
raise Unsupported("yield как выражение")
|
|
||||||
found = True
|
found = True
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ScriptData:
|
class ScriptData:
|
||||||
|
phone_registers: int = 0 # `init phone register` blocks read so far (they get numbered functions)
|
||||||
characters: dict[str, dict[str, Any]] = field(default_factory=dict)
|
characters: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||||
images: dict[str, dict[str, Any]] = field(default_factory=dict)
|
images: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||||
transforms: dict[str, dict[str, Any]] = field(default_factory=dict)
|
transforms: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||||
@@ -468,12 +469,28 @@ class RpyParser:
|
|||||||
return []
|
return []
|
||||||
if c.keyword("python") or c.keyword("early"):
|
if c.keyword("python") or c.keyword("early"):
|
||||||
c.keyword("python")
|
c.keyword("python")
|
||||||
self.init_python(line.block_source(), line)
|
c.keyword("hide") # `init python hide in x:` keeps its temporary names out of the store; they are only locals here
|
||||||
|
self.init_python(line.block_source(), line, self.store_name(c))
|
||||||
return []
|
return []
|
||||||
rest = c.rest().strip()
|
rest = c.rest().strip()
|
||||||
|
if re.match(r"phone\s+register(?!\w)", rest): # the Phone library's statement (see phone_register.py)
|
||||||
|
self.init_phone_register(line, rest)
|
||||||
|
return []
|
||||||
if rest and rest != ":": # `init -490 screen name(...):` - one statement with a priority in front
|
if rest and rest != ":": # `init -490 screen name(...):` - one statement with a priority in front
|
||||||
return self.statement(replace(line, text=rest), top_level=True)
|
return self.statement(replace(line, text=rest), top_level=True)
|
||||||
for child in self.fold_live2d(line.block):
|
children = self.fold_live2d(line.block)
|
||||||
|
i = 0
|
||||||
|
while i < len(children):
|
||||||
|
child = children[i]
|
||||||
|
if re.match(r"if(?!\w)", child.text.strip()): # `init:` with an if/elif/else of `$` lines
|
||||||
|
branches, i = self.if_chain(children, i)
|
||||||
|
code = self.init_branches(branches)
|
||||||
|
if code is None:
|
||||||
|
self.d.warn("Оператор сцены внутри init", self.where(child))
|
||||||
|
else:
|
||||||
|
self.d.init_code.extend(code)
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
for node in self.statement(child, top_level=True):
|
for node in self.statement(child, top_level=True):
|
||||||
if node["op"] == "exec":
|
if node["op"] == "exec":
|
||||||
self.d.init_code.extend(node["code"])
|
self.d.init_code.extend(node["code"])
|
||||||
@@ -481,6 +498,52 @@ class RpyParser:
|
|||||||
self.d.warn("Оператор сцены внутри init", self.where(child))
|
self.d.warn("Оператор сцены внутри init", self.where(child))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def init_branches(self, branches: list[dict[str, Any]]) -> list[Stmt] | None:
|
||||||
|
"""An if/elif/else whose branches hold only Python lines, as one init statement (None when they hold more)."""
|
||||||
|
def body(nodes: list[Node]) -> list[Stmt] | None:
|
||||||
|
out: list[Stmt] = []
|
||||||
|
for node in nodes:
|
||||||
|
if node["op"] == "exec":
|
||||||
|
out.extend(node["code"])
|
||||||
|
elif node["op"] == "if":
|
||||||
|
nested = self.init_branches(node["branches"])
|
||||||
|
if nested is None:
|
||||||
|
return None
|
||||||
|
out.extend(nested)
|
||||||
|
elif node["op"] != "window":
|
||||||
|
return None
|
||||||
|
return out
|
||||||
|
|
||||||
|
chain: dict[str, Any] | None = None
|
||||||
|
for branch in reversed(branches):
|
||||||
|
inner = body(branch["body"])
|
||||||
|
if inner is None:
|
||||||
|
return None
|
||||||
|
if branch["c"] is None:
|
||||||
|
chain = {"op": "if", "c": {"k": "const", "v": True}, "then": inner, "else": []}
|
||||||
|
else:
|
||||||
|
chain = {"op": "if", "c": branch["c"], "then": inner, "else": [chain] if chain else []}
|
||||||
|
return [chain] if chain else []
|
||||||
|
|
||||||
|
def init_phone_register(self, line: Line, rest: str) -> None:
|
||||||
|
from app.converter.phone_register import PhoneSyntax, Register
|
||||||
|
|
||||||
|
self.d.phone_registers += 1
|
||||||
|
number = self.d.phone_registers
|
||||||
|
try:
|
||||||
|
source, default = Register(replace(line, text=rest), number).source()
|
||||||
|
except PhoneSyntax as exc:
|
||||||
|
self.d.warn(f"Phone register: {exc}", self.where(line))
|
||||||
|
return
|
||||||
|
if default is not None: # the group chat object, created with the other defaults of a new game
|
||||||
|
created = self.expr(default[1], line)
|
||||||
|
if created is not None:
|
||||||
|
self.d.defaults.append([default[0], created])
|
||||||
|
self.d.init_code.extend(self.python(source, line))
|
||||||
|
# ...and filled right after it: a default whose value is a call of the function just defined
|
||||||
|
self.d.defaults.append([f"__phone_register_{number}_done",
|
||||||
|
{"k": "call", "f": {"k": "name", "id": f"__phone_register_{number}"}, "a": [], "kw": {}}])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def fold_live2d(children: list[Line]) -> list[Line]:
|
def fold_live2d(children: list[Line]) -> list[Line]:
|
||||||
"""`if renpy.has_live2d() and ...:` is false in the player (it has no Live2D), so only its `else` counts."""
|
"""`if renpy.has_live2d() and ...:` is false in the player (it has no Live2D), so only its `else` counts."""
|
||||||
@@ -502,14 +565,31 @@ class RpyParser:
|
|||||||
c = self.cursor(line)
|
c = self.cursor(line)
|
||||||
c.keyword("python")
|
c.keyword("python")
|
||||||
if c.keyword("early"):
|
if c.keyword("early"):
|
||||||
self.init_python(line.block_source(), line)
|
self.init_python(line.block_source(), line, self.store_name(c))
|
||||||
return []
|
return []
|
||||||
|
if c.keyword("in"): # `python in some_store:` at run time: runs in that store
|
||||||
|
store = c.dotted()
|
||||||
|
return [{"op": "exec", "code": [{"op": "in_store", "ns": store, "body": self.python(line.block_source(), line)}]}]
|
||||||
return self.python_nodes(self.python(line.block_source(), line), line)
|
return self.python_nodes(self.python(line.block_source(), line), line)
|
||||||
|
|
||||||
def st_rpy(self, line: Line) -> list[Node]:
|
def st_rpy(self, line: Line) -> list[Node]:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def init_python(self, source: str, line: Line) -> None:
|
@staticmethod
|
||||||
|
def store_name(c: Cursor) -> str | None:
|
||||||
|
"""The `in phone.discussion` of `init python in phone.discussion:`."""
|
||||||
|
return (c.dotted() or None) if c.keyword("in") else None
|
||||||
|
|
||||||
|
def init_python(self, source: str, line: Line, store: str | None = None) -> None:
|
||||||
|
start = len(self.d.init_code)
|
||||||
|
self.init_python_body(source, line)
|
||||||
|
if store: # the names this block defines belong to a named store
|
||||||
|
body = self.d.init_code[start:]
|
||||||
|
del self.d.init_code[start:]
|
||||||
|
if body:
|
||||||
|
self.d.init_code.append({"op": "in_store", "ns": store, "body": body})
|
||||||
|
|
||||||
|
def init_python_body(self, source: str, line: Line) -> None:
|
||||||
try:
|
try:
|
||||||
tree = _parse_py(source)
|
tree = _parse_py(source)
|
||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
@@ -535,6 +615,9 @@ class RpyParser:
|
|||||||
and ast.unparse(node.value.func) == "renpy.music.register_channel"):
|
and ast.unparse(node.value.func) == "renpy.music.register_channel"):
|
||||||
self.register_channel(node.value)
|
self.register_channel(node.value)
|
||||||
continue
|
continue
|
||||||
|
if (isinstance(node, ast.Assign) and len(node.targets) == 1
|
||||||
|
and ast.unparse(node.targets[0]).startswith("config.special_namespaces")):
|
||||||
|
continue # lets `define phone.config.x = ...` name a store; named stores are plain objects here
|
||||||
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
||||||
tgt = node.targets[0]
|
tgt = node.targets[0]
|
||||||
name = _assign_name(tgt)
|
name = _assign_name(tgt)
|
||||||
@@ -822,7 +905,7 @@ class RpyParser:
|
|||||||
layers.append({"pos": list(pos), "img": sub})
|
layers.append({"pos": list(pos), "img": sub})
|
||||||
return {"composite": layers, "size": list(size) if isinstance(size, (list, tuple))
|
return {"composite": layers, "size": list(size) if isinstance(size, (list, tuple))
|
||||||
else None}
|
else None}
|
||||||
if fname == "Animation": # pictures that take turns, named by values known only while playing
|
if fname in ("Animation", "SnowBlossom"): # pictures that take turns / fall, built from values known while playing
|
||||||
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
|
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
|
||||||
return {"expr": e}
|
return {"expr": e}
|
||||||
# wrappers: Transform("x.png", zoom=.5), im.Scale(...), im.Flip(...), etc.
|
# wrappers: Transform("x.png", zoom=.5), im.Scale(...), im.Flip(...), etc.
|
||||||
@@ -1159,6 +1242,7 @@ class RpyParser:
|
|||||||
def st_transform(self, line: Line) -> list[Node]:
|
def st_transform(self, line: Line) -> list[Node]:
|
||||||
c = self.cursor(line)
|
c = self.cursor(line)
|
||||||
c.keyword("transform")
|
c.keyword("transform")
|
||||||
|
c.number() # `transform -150 _fits(size):`: an init priority
|
||||||
name = c.word()
|
name = c.word()
|
||||||
if name:
|
if name:
|
||||||
issues: list[tuple[str, str]] = []
|
issues: list[tuple[str, str]] = []
|
||||||
@@ -1178,7 +1262,7 @@ class RpyParser:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def transform_params(self, line: Line) -> list[dict[str, Any]]:
|
def transform_params(self, line: Line) -> list[dict[str, Any]]:
|
||||||
match = re.fullmatch(r"transform\s+[A-Za-z_]\w*\s*\((.*)\)\s*:?", line.text.strip(), re.S)
|
match = re.fullmatch(r"transform\s+(?:-?\d+\s+)?[A-Za-z_]\w*\s*\((.*)\)\s*:?", line.text.strip(), re.S)
|
||||||
if not match:
|
if not match:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
@@ -1278,7 +1362,7 @@ class RpyParser:
|
|||||||
self.d.warn("Не разобран фрагмент show/scene", f"{self.where(line)}: {c.rest()}")
|
self.d.warn("Не разобран фрагмент show/scene", f"{self.where(line)}: {c.rest()}")
|
||||||
break
|
break
|
||||||
|
|
||||||
def transform_ref(self, source: str, line: Line) -> dict[str, Any]:
|
def transform_ref(self, source: str, line: Line, dynamic: bool = False) -> dict[str, Any]:
|
||||||
source = source.strip()
|
source = source.strip()
|
||||||
if source in POSITIONS:
|
if source in POSITIONS:
|
||||||
return {"pos": source}
|
return {"pos": source}
|
||||||
@@ -1318,6 +1402,10 @@ class RpyParser:
|
|||||||
if e and e["k"] == "name" and e["id"] not in self.d.transforms:
|
if e and e["k"] == "name" and e["id"] not in self.d.transforms:
|
||||||
# transform defined later in another file; the player resolves it by name
|
# transform defined later in another file; the player resolves it by name
|
||||||
return {"name": e["id"]}
|
return {"name": e["id"]}
|
||||||
|
if dynamic and e and e["k"] == "call" and e["f"].get("k") in ("name", "attr"):
|
||||||
|
# in a screen, a Python function that makes a transform (`CurriedRoundedCorners(radius=...)`): evaluated when it is shown
|
||||||
|
self.d.warn("Трансформация вычисляется при показе", f"{self.where(line)}: {source}", blocking=False)
|
||||||
|
return {"dyn": e}
|
||||||
self.d.warn("Неизвестная трансформация", f"{self.where(line)}: {source}")
|
self.d.warn("Неизвестная трансформация", f"{self.where(line)}: {source}")
|
||||||
return {"name": source}
|
return {"name": source}
|
||||||
|
|
||||||
@@ -1344,7 +1432,7 @@ class RpyParser:
|
|||||||
else:
|
else:
|
||||||
collect.append((message, self.where(line)))
|
collect.append((message, self.where(line)))
|
||||||
|
|
||||||
return ScreenParser(warn, lambda source, line: self.transform_ref(source, line),
|
return ScreenParser(warn, lambda source, line: self.transform_ref(source, line, dynamic=True),
|
||||||
lambda lines: self.atl_spec(lines, collect.append if collect is not None else None, set()))
|
lambda lines: self.atl_spec(lines, collect.append if collect is not None else None, set()))
|
||||||
|
|
||||||
def st_screen(self, line: Line) -> list[Node]:
|
def st_screen(self, line: Line) -> list[Node]:
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ class ScreenParser:
|
|||||||
while i < len(lines):
|
while i < len(lines):
|
||||||
line = lines[i]
|
line = lines[i]
|
||||||
text = line.text.rstrip(":").strip() if line.block else line.text.strip()
|
text = line.text.rstrip(":").strip() if line.block else line.text.strip()
|
||||||
text = re.sub(r"^(add|text|label|image|textbutton)(?=[\"'])", r"\1 ", text) # `add"cover"`
|
text = re.sub(r"^(add|text|label|image|textbutton)(?=[\"'(])", r"\1 ", text) # `add"cover"`, `textbutton(_("x"))`
|
||||||
words = tokens(re.sub(r"^(if|elif|showif)\(", r"\1 (", text))
|
words = tokens(re.sub(r"^(if|elif|showif)\(", r"\1 (", text))
|
||||||
i += 1
|
i += 1
|
||||||
if not words:
|
if not words:
|
||||||
@@ -412,6 +412,18 @@ class ScreenParser:
|
|||||||
spec["atl"] = animation
|
spec["atl"] = animation
|
||||||
props["at"] = [*props.get("at", []), spec]
|
props["at"] = [*props.get("at", []), spec]
|
||||||
|
|
||||||
|
def at_refs(self, source: str, line: Line) -> list[Node]:
|
||||||
|
"""The entries of one `at` item: `(a, b)` is two of them."""
|
||||||
|
text = source.strip()
|
||||||
|
if text[:1] in "([" and text[-1:] in ")]":
|
||||||
|
try:
|
||||||
|
tree = ast.parse(text, mode="eval").body
|
||||||
|
except SyntaxError:
|
||||||
|
tree = None
|
||||||
|
if isinstance(tree, (ast.Tuple, ast.List)):
|
||||||
|
return [spec for element in tree.elts for spec in self.at_refs(ast.unparse(element), line)]
|
||||||
|
return [self.at_ref(source, line)]
|
||||||
|
|
||||||
def at_ref(self, source: str, line: Line) -> Node:
|
def at_ref(self, source: str, line: Line) -> Node:
|
||||||
"""One `at` entry; `(a if cond else b)` picks a transform while the screen is shown."""
|
"""One `at` entry; `(a if cond else b)` picks a transform while the screen is shown."""
|
||||||
text = source.strip()
|
text = source.strip()
|
||||||
@@ -427,7 +439,7 @@ class ScreenParser:
|
|||||||
"else": self.at_ref(ast.unparse(tree.orelse), line)}}
|
"else": self.at_ref(ast.unparse(tree.orelse), line)}}
|
||||||
if text == "None":
|
if text == "None":
|
||||||
return {}
|
return {}
|
||||||
if re.fullmatch(r"[A-Za-z_]\w*(?:\.\w+)*\[[^\]]+\]|[A-Za-z_]\w*\.\w+", text): # `icon["at"]`, `item.at`
|
if re.fullmatch(r"[A-Za-z_]\w*(?:\.\w+)*\[[^\]]+\]|[A-Za-z_]\w*(?:\.\w+)+", text): # `icon["at"]`, `item.at`
|
||||||
dynamic = self.expr(text, line)
|
dynamic = self.expr(text, line)
|
||||||
if dynamic is not None:
|
if dynamic is not None:
|
||||||
return {"dyn": dynamic}
|
return {"dyn": dynamic}
|
||||||
@@ -466,7 +478,7 @@ class ScreenParser:
|
|||||||
i += 1
|
i += 1
|
||||||
if not more:
|
if not more:
|
||||||
break
|
break
|
||||||
props["at"] = [self.at_ref(item, line) for item in items if item]
|
props["at"] = [spec for item in items if item for spec in self.at_refs(item, line)]
|
||||||
continue
|
continue
|
||||||
if glued is not None:
|
if glued is not None:
|
||||||
source, i = glued, i + 1
|
source, i = glued, i + 1
|
||||||
|
|||||||
+7
-1
@@ -71,6 +71,8 @@ def expression(value, depth=0):
|
|||||||
expression(item, depth + 1)
|
expression(item, depth + 1)
|
||||||
elif kind == "star":
|
elif kind == "star":
|
||||||
ex("v")
|
ex("v")
|
||||||
|
elif kind == "yield":
|
||||||
|
ex("v", optional=True)
|
||||||
elif kind == "lambda":
|
elif kind == "lambda":
|
||||||
parameters(value.get("params"), depth + 1)
|
parameters(value.get("params"), depth + 1)
|
||||||
ex("body")
|
ex("body")
|
||||||
@@ -127,12 +129,16 @@ def statements(code, depth=0):
|
|||||||
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
|
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
|
||||||
"if": ("c",), "while": ("c",), "for": ("t", "it"),
|
"if": ("c",), "while": ("c",), "for": ("t", "it"),
|
||||||
"del": ("t",), "break": (), "continue": (), "def": (), "class": (),
|
"del": ("t",), "break": (), "continue": (), "def": (), "class": (),
|
||||||
"return": (), "global": (), "nonlocal": (), "try": (), "raise": (), "yield": (), "yield_from": ("v",),
|
"return": (), "global": (), "nonlocal": (), "try": (), "raise": (), "yield": (), "yield_from": ("v",), "in_store": (),
|
||||||
"assert": ("c",)}
|
"assert": ("c",)}
|
||||||
if op not in keys:
|
if op not in keys:
|
||||||
raise ValueError(f"Неизвестный оператор AST: {op}")
|
raise ValueError(f"Неизвестный оператор AST: {op}")
|
||||||
for key in keys[op]:
|
for key in keys[op]:
|
||||||
expression(s.get(key), depth + 1)
|
expression(s.get(key), depth + 1)
|
||||||
|
if op == "in_store":
|
||||||
|
if not isinstance(s.get("ns"), str) or not all(part.isidentifier() for part in s["ns"].split(".")):
|
||||||
|
raise ValueError("Некорректное имя хранилища")
|
||||||
|
statements(s.get("body"), depth + 1)
|
||||||
if op == "if":
|
if op == "if":
|
||||||
statements(s.get("then"), depth + 1)
|
statements(s.get("then"), depth + 1)
|
||||||
statements(s.get("else"), depth + 1)
|
statements(s.get("else"), depth + 1)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("source,reason", [
|
@pytest.mark.parametrize("source,reason", [
|
||||||
('init python:\n def helper():\n received = yield 1\nlabel start:\n $ helper()', "init python"),
|
('init python:\n def helper():\n received = yield from other()\nlabel start:\n $ helper()', "init python"),
|
||||||
('label start:\n $ result = renpy.show_displayable("map")', "renpy.show_displayable"),
|
('label start:\n $ result = renpy.show_displayable("map")', "renpy.show_displayable"),
|
||||||
('label start:\n jump missing', "missing"),
|
('label start:\n jump missing', "missing"),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ def test_if_chain_and_menu():
|
|||||||
|
|
||||||
|
|
||||||
def test_unsupported_python_is_reported_not_fatal():
|
def test_unsupported_python_is_reported_not_fatal():
|
||||||
d = parse("init python:\n def f():\n received = yield 1\n x = 1\nlabel start:\n $ y = x\n")
|
d = parse("init python:\n def f():\n received = yield from other()\n x = 1\nlabel start:\n $ y = x\n")
|
||||||
assert d.init_code[0]["op"] == "set" and "f" in d.deferred # reported once something calls it
|
assert d.init_code[0]["op"] == "set" and "f" in d.deferred # reported once something calls it
|
||||||
from app.converter.compatibility import audit_ir
|
from app.converter.compatibility import audit_ir
|
||||||
d = parse("init python:\n def f():\n received = yield 1\nlabel start:\n $ f()\n")
|
d = parse("init python:\n def f():\n received = yield from other()\nlabel start:\n $ f()\n")
|
||||||
audit_ir(d)
|
audit_ir(d)
|
||||||
assert d.blockers
|
assert d.blockers
|
||||||
|
|
||||||
|
|||||||
@@ -265,3 +265,23 @@ def test_starred_tuple_items_and_pickle_hooks_are_accepted():
|
|||||||
pgvn.statements(code)
|
pgvn.statements(code)
|
||||||
assert code[0]["v"]["v"][1] == {"k": "star", "v": {"k": "name", "id": "rest"}}
|
assert code[0]["v"]["v"][1] == {"k": "star", "v": {"k": "name", "id": "rest"}}
|
||||||
assert [m["name"] for m in code[1]["body"]] == ["run"]
|
assert [m["name"] for m in code[1]["body"]] == ["run"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_phone_register_becomes_a_default_and_a_function_of_library_calls():
|
||||||
|
data = parse('init phone register:\n define "[suz]":\n add "mc" add "suzie"\n icon phone.asset("suzie.png")\n'
|
||||||
|
' as suzieTexts key "suzieTexts"\n label "Earlier"\n time year 2023 month 12 day 25 hour 7 minute 7\n'
|
||||||
|
' "suzie" "Merry Christmas!"\n if flag:\n "mc" "hi"\n else:\n "mc" "no"\n'
|
||||||
|
'label start:\n return\n')
|
||||||
|
assert data.defaults[0][0] == "suzieTexts" and data.defaults[0][1]["f"]["a"] == "GroupChat"
|
||||||
|
assert data.defaults[1][0] == "__phone_register_1_done"
|
||||||
|
function = next(s for s in data.init_code if s.get("op") == "def")
|
||||||
|
assert function["name"] == "__phone_register_1"
|
||||||
|
assert not data.blockers
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_stores_wrap_their_code_and_defaults_may_name_a_store():
|
||||||
|
data = parse('init python in phone.group_chat:\n class GroupChat(object):\n pass\n def make():\n return GroupChat()\n'
|
||||||
|
'default -100 phone.menu = False\nlabel start:\n $ x = phone.group_chat.make()\n return\n')
|
||||||
|
assert data.init_code[0]["op"] == "in_store" and data.init_code[0]["ns"] == "phone.group_chat"
|
||||||
|
assert [s["name"] for s in data.init_code[0]["body"]] == ["GroupChat", "make"]
|
||||||
|
assert data.defaults[0][0] == "phone.menu"
|
||||||
Reference in new issue
Block a user