Files
pgames-manager/backend/app/converter/phone_register.py
T
mrleo1nidandClaude Sonnet 5.5 83cc7fdd5c 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>
2026-10-26 22:39:16 +03:00

193 lines
8.4 KiB
Python

"""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})"