feat: add ATL animation timelines and try/raise to the Ren'Py player
Python sandbox - try/except/else/finally, raise (incl. re-raise), assert, for/while ... else - Multi-generator and dict comprehensions - Builtin exception hierarchy; user exceptions subclass it - Inside try, missing keys/indexes, division by zero, attribute access on None and unknown names raise Python exceptions; lookups stay lenient elsewhere - int()/float() raise ValueError on non-numeric strings ATL - converter/atl.py: set/move (linear, ease, easein, easeout), pause, repeat, parallel, choice, block, on show/hide and named transforms become a timeline shipped as game.json "atl"; Move() becomes a linear slide - player/atl.ts: timeline runner wired into the renderer; `on hide` plays before the picture is removed - function, time, contains, matrixcolor and expressions still block the import Tests: exceptions/ATL fixtures and runtime tests, converter ATL tests; README and ARCHITECTURE updated. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
962595c925
commit
ff82d882c0
18 files changed
+1046
-49
No files matched your search
@@ -119,8 +119,16 @@ python -m tests.make_sample sample.zip # тестовая игра (из ba
|
||||
|
||||
Для Naninovel/Tomie см. [docs/IMPORT.md](docs/IMPORT.md).
|
||||
|
||||
Python игры выполняется в песочнице плеера: свои `def` и `class` (наследование,
|
||||
`super()`, свойства, замыкания, `lambda`, `*args`/`**kwargs`), `try`/`except`/
|
||||
`finally`, `raise`, `assert`, `for`/`while … else`, comprehension. ATL: движение
|
||||
(`linear`/`ease`/`easein`/`easeout`), `pause`, `repeat`, `parallel`, `choice`,
|
||||
`block`, `on show`/`on hide`, вызов других transform.
|
||||
|
||||
Пока **не** поддерживается: `screen`-язык (кастомные экраны, карты,
|
||||
мини-игры), движение и твины в ATL, функции/классы в `python` и картинки,
|
||||
которые игра вычисляет своими функциями. Такие места перечислены в отчёте
|
||||
конвертации у версии. Игры-«песочницы», где навигация построена на экранах,
|
||||
мини-игры), в ATL — `function`, `time`, `contains`, `matrixcolor` и шейдеры,
|
||||
в Python — специальные методы кроме `__init__`/`__str__`/`__repr__`, генераторы,
|
||||
`with`, системные модули (`os`, `socket`, …) и картинки, которые игра вычисляет
|
||||
своими функциями. Такие места перечислены в отчёте конвертации у версии и
|
||||
останавливают импорт. Игры-«песочницы», где навигация построена на экранах,
|
||||
пока не играбельны.
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Ren'Py ATL (animation and transformation language) → PGVN timeline.
|
||||
|
||||
A block becomes ``{"steps": [...], "repeat": None | True | n}``. Steps:
|
||||
|
||||
* ``{"op": "set", "props": {...}}`` – assign properties at once
|
||||
* ``{"op": "move", "warp": "linear|ease|easein|easeout", "t": seconds, "props": {...}}``
|
||||
* ``{"op": "pause", "t": seconds}``
|
||||
* ``{"op": "parallel", "blocks": [block, ...]}``
|
||||
* ``{"op": "choice", "blocks": [{"w": weight, "block": block}, ...]}``
|
||||
* ``{"op": "block", "block": block}``
|
||||
* ``{"op": "on", "events": [...], "block": block}``
|
||||
* ``{"op": "use", "name": transform}``
|
||||
|
||||
Everything else (``function``, ``contains``, ``time``, expressions, shaders, matrix
|
||||
colours) is reported through ``warn`` so the game is not imported half-working.
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.converter.rpy_helpers import POSITION_PROPS
|
||||
from app.converter.rpy_lexer import Line
|
||||
|
||||
Block = dict[str, Any]
|
||||
|
||||
# Warpers differ only in easing shape; the player implements these four curves and
|
||||
# maps the named variants (easein_quad, ease_back, ...) onto the closest one.
|
||||
WARPER = re.compile(r"^(linear|ease|easein|easeout|easeinout)(_(quad|cubic|quart|quint|expo|circ|back|bounce|elastic|sine))?$")
|
||||
IGNORED = {"subpixel", "animation"} # no visible effect in the browser
|
||||
EVENTS = {"show", "hide", "replace", "replaced", "start", "update", "appear", "default"}
|
||||
|
||||
|
||||
def tokens(text: str) -> list[str]:
|
||||
"""Split on whitespace/commas outside brackets and quotes."""
|
||||
out, cur, depth, quote = [], [], 0, ""
|
||||
for ch in text:
|
||||
if quote:
|
||||
cur.append(ch)
|
||||
if ch == quote:
|
||||
quote = ""
|
||||
elif ch in "\"'":
|
||||
quote = ch
|
||||
cur.append(ch)
|
||||
elif ch in "([{":
|
||||
depth += 1
|
||||
cur.append(ch)
|
||||
elif ch in ")]}":
|
||||
depth -= 1
|
||||
cur.append(ch)
|
||||
elif (ch.isspace() or ch == ",") and depth == 0:
|
||||
if cur:
|
||||
out.append("".join(cur))
|
||||
cur = []
|
||||
else:
|
||||
cur.append(ch)
|
||||
if cur:
|
||||
out.append("".join(cur))
|
||||
return out
|
||||
|
||||
|
||||
class AtlParser:
|
||||
def __init__(self, warn: Callable[[str, Line], None], is_static: Callable[[str], dict | None]):
|
||||
self.warn = warn
|
||||
# name -> props when `name` is a known static transform (inlined), else None
|
||||
self.static_transform = is_static
|
||||
|
||||
# --- helpers -------------------------------------------------------------------
|
||||
|
||||
def number(self, raw: str, line: Line) -> float | None:
|
||||
try:
|
||||
value = ast.literal_eval(raw)
|
||||
except (ValueError, SyntaxError):
|
||||
self.warn(f"Не поддерживается ATL: выражение {raw}", line)
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
self.warn(f"Не поддерживается ATL: значение {raw}", line)
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
def props(self, parts: list[str], line: Line) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
i = 0
|
||||
while i < len(parts):
|
||||
key = parts[i]
|
||||
if key in IGNORED:
|
||||
i += 2
|
||||
continue
|
||||
if key not in POSITION_PROPS:
|
||||
self.warn(f"Не поддерживается ATL: свойство {key}", line)
|
||||
return out
|
||||
if i + 1 >= len(parts):
|
||||
self.warn(f"Не поддерживается ATL: у {key} нет значения", line)
|
||||
return out
|
||||
raw = parts[i + 1]
|
||||
try:
|
||||
value = ast.literal_eval(raw)
|
||||
except (ValueError, SyntaxError):
|
||||
self.warn(f"Не поддерживается ATL: выражение {raw}", line)
|
||||
return out
|
||||
if isinstance(value, tuple):
|
||||
value = list(value)
|
||||
out[key] = value
|
||||
i += 2
|
||||
return out
|
||||
|
||||
# --- block ----------------------------------------------------------------------
|
||||
|
||||
def block(self, lines: list[Line]) -> Block:
|
||||
steps: list[dict[str, Any]] = []
|
||||
repeat: Any = None
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
parts = tokens(line.text.rstrip(":").strip() if line.block else line.text.strip())
|
||||
if not parts:
|
||||
i += 1
|
||||
continue
|
||||
first = parts[0]
|
||||
i += 1
|
||||
if first == "repeat":
|
||||
repeat = True if len(parts) == 1 else self.number(parts[1], line)
|
||||
if repeat is not True and repeat is not None:
|
||||
repeat = int(repeat)
|
||||
elif first == "pause":
|
||||
seconds = self.number(parts[1], line) if len(parts) > 1 else None
|
||||
if seconds is not None:
|
||||
steps.append({"op": "pause", "t": seconds})
|
||||
elif first in ("parallel", "choice", "on", "block", "time", "contains", "function"):
|
||||
i = self.compound(first, parts, lines, i - 1, steps)
|
||||
elif WARPER.match(first):
|
||||
self.move(first, parts, line, steps)
|
||||
elif first in IGNORED:
|
||||
continue
|
||||
elif first in POSITION_PROPS:
|
||||
props = self.props(parts, line)
|
||||
if props:
|
||||
steps.append({"op": "set", "props": props})
|
||||
elif len(parts) == 1 and re.fullmatch(r"[A-Za-z_]\w*", first):
|
||||
known = self.static_transform(first)
|
||||
steps.append({"op": "set", "props": known} if known is not None else {"op": "use", "name": first})
|
||||
else:
|
||||
self.warn(f"Не поддерживается ATL: {first}", line)
|
||||
return {"steps": steps, "repeat": repeat}
|
||||
|
||||
def move(self, warp: str, parts: list[str], line: Line, steps: list) -> None:
|
||||
base = WARPER.match(warp).group(1) # type: ignore[union-attr]
|
||||
if len(parts) < 2:
|
||||
self.warn(f"Не поддерживается ATL: у {warp} нет длительности", line)
|
||||
return
|
||||
seconds = self.number(parts[1], line)
|
||||
props = self.props(parts[2:], line)
|
||||
for child in line.block: # `linear 1.0:` followed by indented property lines
|
||||
props.update(self.props(tokens(child.text), child))
|
||||
if seconds is None or not props:
|
||||
return
|
||||
steps.append({"op": "move", "warp": "ease" if base == "easeinout" else base, "t": seconds, "props": props})
|
||||
|
||||
def compound(self, first: str, parts: list[str], lines: list[Line], index: int, steps: list) -> int:
|
||||
"""Handle parallel/choice/on groups. Returns the index of the next unread line."""
|
||||
line = lines[index]
|
||||
if first in ("time", "contains", "function"):
|
||||
self.warn(f"Не поддерживается ATL: {first}", line)
|
||||
return index + 1
|
||||
if first == "block":
|
||||
steps.append({"op": "block", "block": self.block(line.block)})
|
||||
return index + 1
|
||||
if first == "on":
|
||||
events = [e for e in parts[1:] if e]
|
||||
if not events or any(e not in EVENTS for e in events):
|
||||
self.warn(f"Не поддерживается ATL: событие {' '.join(events)}", line)
|
||||
steps.append({"op": "on", "events": events, "block": self.block(line.block)})
|
||||
return index + 1
|
||||
# consecutive `parallel:` / `choice:` clauses form one statement
|
||||
group = []
|
||||
j = index
|
||||
while j < len(lines):
|
||||
current = lines[j]
|
||||
words = tokens(current.text.rstrip(":").strip())
|
||||
if not words or words[0] != first:
|
||||
break
|
||||
weight = 1.0
|
||||
if first == "choice" and len(words) > 1:
|
||||
weight = self.number(words[1], current) or 1.0
|
||||
group.append((weight, self.block(current.block)))
|
||||
j += 1
|
||||
if first == "parallel":
|
||||
steps.append({"op": "parallel", "blocks": [b for _, b in group]})
|
||||
else:
|
||||
steps.append({"op": "choice", "blocks": [{"w": w, "block": b} for w, b in group]})
|
||||
return j
|
||||
|
||||
|
||||
def static_props(block: Block) -> dict[str, Any] | None:
|
||||
"""Merged properties when the block only assigns, else None (it needs the timeline)."""
|
||||
if block["repeat"] is not None or any(step["op"] != "set" for step in block["steps"]):
|
||||
return None
|
||||
merged: dict[str, Any] = {}
|
||||
for step in block["steps"]:
|
||||
merged.update(step["props"])
|
||||
return merged
|
||||
|
||||
|
||||
def initial_props(block: Block) -> dict[str, Any]:
|
||||
"""Properties assigned before the first animated step (the state the element is created in)."""
|
||||
merged: dict[str, Any] = {}
|
||||
for step in block["steps"]:
|
||||
if step["op"] != "set":
|
||||
break
|
||||
merged.update(step["props"])
|
||||
return merged
|
||||
@@ -141,6 +141,10 @@ def _bound_names(root) -> tuple[set[str], set[str]]:
|
||||
names.update(p["n"] for p in value["params"])
|
||||
elif kind == "comp":
|
||||
target(value.get("t"))
|
||||
for gen in value.get("gens", []):
|
||||
target(gen.get("t"))
|
||||
elif op == "try":
|
||||
names.update(h["name"] for h in value.get("handlers", []) if h.get("name"))
|
||||
for key, child in value.items():
|
||||
visit(child, op == "class" and key == "body")
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
||||
"characters": data.characters,
|
||||
"images": images,
|
||||
"transforms": data.transforms,
|
||||
"atl": data.atl,
|
||||
"transitions": data.transitions,
|
||||
"audio": audio,
|
||||
"channels": data.channels,
|
||||
|
||||
@@ -120,14 +120,11 @@ def expr(node: ast.AST) -> Expr:
|
||||
elif isinstance(v, ast.FormattedValue):
|
||||
parts.append(expr(v.value))
|
||||
return {"k": "fmt", "v": parts}
|
||||
case (ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
|
||||
is_async=0)])
|
||||
| ast.GeneratorExp(elt=elt, generators=[ast.comprehension(target=t, iter=it,
|
||||
ifs=ifs, is_async=0)])
|
||||
| ast.SetComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
|
||||
is_async=0)])):
|
||||
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
|
||||
"if": [expr(i) for i in ifs]}
|
||||
case ast.ListComp(elt=elt, generators=gens) | ast.GeneratorExp(elt=elt, generators=gens) \
|
||||
| ast.SetComp(elt=elt, generators=gens):
|
||||
return {"k": "comp", "e": expr(elt), "gens": generators(gens)}
|
||||
case ast.DictComp(key=key, value=value, generators=gens):
|
||||
return {"k": "comp", "e": expr(key), "v": expr(value), "gens": generators(gens)}
|
||||
case ast.Lambda(args=args, body=body):
|
||||
return {"k": "lambda", "params": params(args), "body": expr(body)}
|
||||
raise Unsupported(f"выражение {type(node).__name__}")
|
||||
@@ -170,10 +167,18 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
||||
return [{"op": "eval", "e": expr(value)}]
|
||||
case ast.If(test=test, body=body, orelse=orelse):
|
||||
return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}]
|
||||
case ast.While(test=test, body=body, orelse=[]):
|
||||
return [{"op": "while", "c": expr(test), "body": stmts(body)}]
|
||||
case ast.For(target=t, iter=it, body=body, orelse=[]):
|
||||
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}]
|
||||
case ast.While(test=test, body=body, orelse=orelse):
|
||||
return [{"op": "while", "c": expr(test), "body": stmts(body), "else": stmts(orelse)}]
|
||||
case ast.For(target=t, iter=it, body=body, orelse=orelse):
|
||||
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body),
|
||||
"else": stmts(orelse)}]
|
||||
case ast.Try(body=body, handlers=handlers, orelse=orelse, finalbody=final):
|
||||
return [{"op": "try", "body": stmts(body), "handlers": [handler(h) for h in handlers],
|
||||
"else": stmts(orelse), "final": stmts(final)}]
|
||||
case ast.Raise(exc=exc):
|
||||
return [{"op": "raise", "v": None if exc is None else expr(exc)}] # `from cause` is dropped
|
||||
case ast.Assert(test=test, msg=msg):
|
||||
return [{"op": "assert", "c": expr(test), "m": None if msg is None else expr(msg)}]
|
||||
case ast.Delete(targets=targets):
|
||||
return [{"op": "del", "t": target(t)} for t in targets]
|
||||
case ast.Pass():
|
||||
@@ -248,3 +253,15 @@ def class_body(name: str, body: list[ast.stmt]) -> list[Stmt]:
|
||||
and n.name not in SUPPORTED_DUNDERS):
|
||||
raise Unsupported(f"специальный метод {name}.{n.name}()")
|
||||
return stmts(body)
|
||||
|
||||
|
||||
def generators(gens: list[ast.comprehension]) -> list[dict[str, Any]]:
|
||||
if any(g.is_async for g in gens):
|
||||
raise Unsupported("async comprehension")
|
||||
return [{"t": target(g.target), "it": expr(g.iter), "if": [expr(i) for i in g.ifs]} for g in gens]
|
||||
|
||||
|
||||
def handler(node: ast.ExceptHandler) -> dict[str, Any]:
|
||||
"""``types`` is None for a bare ``except:``; ``name`` binds the exception."""
|
||||
return {"types": None if node.type is None else expr(node.type), "name": node.name,
|
||||
"body": stmts(node.body)}
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.converter import pyexpr
|
||||
from app.converter.atl import AtlParser, initial_props, static_props
|
||||
from app.converter.pyexpr import Expr, Stmt, Unsupported, const
|
||||
from app.converter.rpy_helpers import (
|
||||
_CALL_LINE,
|
||||
@@ -67,6 +68,7 @@ class ScriptData:
|
||||
characters: 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)
|
||||
atl: dict[str, dict[str, Any]] = field(default_factory=dict) # animated transforms by name
|
||||
transitions: dict[str, dict[str, Any] | None] = field(default_factory=dict)
|
||||
audio: dict[str, str] = field(default_factory=dict)
|
||||
defaults: list[list[Any]] = field(default_factory=list)
|
||||
@@ -776,13 +778,24 @@ class RpyParser:
|
||||
c = self.cursor(line)
|
||||
c.keyword("transform")
|
||||
name = c.word()
|
||||
props: dict[str, Any] = {}
|
||||
for child in line.block:
|
||||
props.update(self.static_atl(child))
|
||||
if name:
|
||||
props, animation = self.atl_spec(line.block)
|
||||
self.d.transforms[name] = props
|
||||
if animation:
|
||||
self.d.atl[name] = animation
|
||||
return []
|
||||
|
||||
def atl_spec(self, lines: list[Line]) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""Parse an ATL block: (properties to start with, timeline or None when it is static)."""
|
||||
parser = AtlParser(lambda message, line: self.d.warn(message, self.where(line)),
|
||||
lambda name: self.d.transforms.get(name) if name not in self.d.atl
|
||||
and name in self.d.transforms else None)
|
||||
block = parser.block(lines)
|
||||
static = static_props(block)
|
||||
if static is not None:
|
||||
return static, None
|
||||
return initial_props(block), block
|
||||
|
||||
def static_atl(self, line: Line) -> dict[str, Any]:
|
||||
props = _atl_props(line.text)
|
||||
first = Cursor(line.text).peek_word()
|
||||
@@ -828,10 +841,10 @@ class RpyParser:
|
||||
elif c.keyword("with"):
|
||||
node["with"] = self.transition(c.until(set(), ":"), line)
|
||||
elif c.char(":"):
|
||||
props: dict[str, Any] = {}
|
||||
for child in line.block:
|
||||
props.update(self.static_atl(child))
|
||||
if props:
|
||||
props, animation = self.atl_spec(line.block)
|
||||
if animation:
|
||||
node["at"].append({"props": props, "atl": animation})
|
||||
elif props:
|
||||
node["at"].append({"props": props})
|
||||
else:
|
||||
self.d.warn("Не разобран фрагмент show/scene", f"{self.where(line)}: {c.rest()}")
|
||||
@@ -842,24 +855,50 @@ class RpyParser:
|
||||
if source in POSITIONS:
|
||||
return {"pos": source}
|
||||
if source in self.d.transforms:
|
||||
return {"props": self.d.transforms[source], "name": source}
|
||||
spec = {"props": self.d.transforms[source], "name": source}
|
||||
if source in self.d.atl:
|
||||
spec["atl"] = source
|
||||
return spec
|
||||
e = self.expr(source, line)
|
||||
if e and e["k"] == "call":
|
||||
fname = _dotted_name(e["f"])
|
||||
if fname in ("Position", "Transform"):
|
||||
return {"props": _static_props(e["kw"])}
|
||||
if fname == "Move":
|
||||
moved = self.move_transform(e)
|
||||
if moved:
|
||||
return moved
|
||||
if fname in ("Shake", "SnoopyShake"):
|
||||
args = [_const(a) for a in e["a"]]
|
||||
return {"effect": "shake", "d": args[1] if len(args) > 1 else 0.5,
|
||||
"dist": _const(e["kw"].get("dist")) or 20}
|
||||
if fname in self.d.transforms:
|
||||
return {"props": self.d.transforms[fname], "name": fname}
|
||||
spec = {"props": self.d.transforms[fname], "name": fname}
|
||||
if fname in self.d.atl:
|
||||
spec["atl"] = fname
|
||||
return spec
|
||||
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
|
||||
return {"name": e["id"]}
|
||||
self.d.warn("Неизвестная трансформация", f"{self.where(line)}: {source}")
|
||||
return {"name": source}
|
||||
|
||||
def move_transform(self, call: Expr) -> dict[str, Any] | None:
|
||||
"""``Move((x0, y0), (x1, y1), seconds)``: slide the picture between two positions."""
|
||||
args = [_const(a) for a in call["a"]]
|
||||
kw = {k: _const(v) for k, v in call["kw"].items()}
|
||||
start, end, seconds = (args + [None] * 3)[:3]
|
||||
start, end = start or kw.get("pos1"), end or kw.get("pos2")
|
||||
seconds = seconds if seconds is not None else kw.get("delay")
|
||||
if not (isinstance(start, (list, tuple)) and isinstance(end, (list, tuple))
|
||||
and len(start) == len(end) == 2 and isinstance(seconds, (int, float))):
|
||||
return None
|
||||
block = {"steps": [
|
||||
{"op": "set", "props": {"xpos": start[0], "ypos": start[1]}},
|
||||
{"op": "move", "warp": "linear", "t": float(seconds), "props": {"xpos": end[0], "ypos": end[1]}},
|
||||
], "repeat": None}
|
||||
return {"props": {"xpos": start[0], "ypos": start[1]}, "atl": block}
|
||||
|
||||
def st_scene(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("scene")
|
||||
|
||||
+35
-2
@@ -85,7 +85,21 @@ def expression(value, depth=0):
|
||||
for item in value["kw"].values():
|
||||
expression(item, depth + 1)
|
||||
elif kind == "comp":
|
||||
for key in ("e", "t", "it"):
|
||||
ex("e")
|
||||
ex("v", True)
|
||||
if "gens" in value:
|
||||
gens = seq("gens")
|
||||
if not gens or len(gens) > 8:
|
||||
raise ValueError("Некорректный генератор")
|
||||
for gen in gens:
|
||||
if not isinstance(gen, dict) or not isinstance(gen.get("if"), list):
|
||||
raise ValueError("Некорректный генератор")
|
||||
expression(gen.get("t"), depth + 1)
|
||||
expression(gen.get("it"), depth + 1)
|
||||
for item in gen["if"]:
|
||||
expression(item, depth + 1)
|
||||
else: # saved by an older converter
|
||||
for key in ("t", "it"):
|
||||
ex(key)
|
||||
for item in seq("if"):
|
||||
expression(item, depth + 1)
|
||||
@@ -113,7 +127,8 @@ def statements(code, depth=0):
|
||||
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
|
||||
"if": ("c",), "while": ("c",), "for": ("t", "it"),
|
||||
"del": ("t",), "break": (), "continue": (), "def": (), "class": (),
|
||||
"return": (), "global": (), "nonlocal": ()}
|
||||
"return": (), "global": (), "nonlocal": (), "try": (), "raise": (),
|
||||
"assert": ("c",)}
|
||||
if op not in keys:
|
||||
raise ValueError(f"Неизвестный оператор AST: {op}")
|
||||
for key in keys[op]:
|
||||
@@ -123,6 +138,24 @@ def statements(code, depth=0):
|
||||
statements(s.get("else"), depth + 1)
|
||||
if op in ("while", "for"):
|
||||
statements(s.get("body"), depth + 1)
|
||||
statements(s.get("else", []), depth + 1)
|
||||
if op == "try":
|
||||
statements(s.get("body"), depth + 1)
|
||||
statements(s.get("else", []), depth + 1)
|
||||
statements(s.get("final", []), depth + 1)
|
||||
handlers = s.get("handlers")
|
||||
if not isinstance(handlers, list) or len(handlers) > 32:
|
||||
raise ValueError("Некорректные обработчики исключений")
|
||||
for h in handlers:
|
||||
if not isinstance(h, dict) or not (h.get("name") is None or isinstance(h.get("name"), str)):
|
||||
raise ValueError("Некорректный обработчик исключения")
|
||||
if h.get("types") is not None:
|
||||
expression(h["types"], depth + 1)
|
||||
statements(h.get("body"), depth + 1)
|
||||
if op == "raise" and s.get("v") is not None:
|
||||
expression(s["v"], depth + 1)
|
||||
if op == "assert" and s.get("m") is not None:
|
||||
expression(s["m"], depth + 1)
|
||||
if op == "def":
|
||||
if not isinstance(s.get("name"), str):
|
||||
raise ValueError("У функции нет имени")
|
||||
|
||||
@@ -89,12 +89,39 @@ def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason):
|
||||
require_supported_scripts(data)
|
||||
|
||||
|
||||
def test_renpy_atl_animation_is_rejected_instead_of_becoming_static():
|
||||
data = script('transform moving:\n linear 1.0 xalign 1.0\nlabel start:\n "Hi"')
|
||||
def test_renpy_atl_animation_becomes_a_timeline_not_a_static_pose():
|
||||
data = script('transform moving:\n xalign 0.0\n linear 1.0 xalign 1.0\n repeat\nlabel start:\n "Hi"')
|
||||
require_supported_scripts(data)
|
||||
assert data.transforms["moving"] == {"xalign": 0.0}
|
||||
block = data.atl["moving"]
|
||||
assert block["repeat"] is True
|
||||
assert [step["op"] for step in block["steps"]] == ["set", "move"]
|
||||
assert block["steps"][1]["props"] == {"xalign": 1.0} and block["steps"][1]["warp"] == "linear"
|
||||
|
||||
|
||||
def test_renpy_atl_constructs_without_a_runtime_are_still_rejected():
|
||||
for body in ("function slide", "time 1.0:\n xalign 1.0", "matrixcolor TintMatrix('#f00')",
|
||||
"contains:\n 'a.png'"):
|
||||
data = script(f'transform odd:\n {body}\nlabel start:\n "Hi"')
|
||||
with pytest.raises(CompatibilityError, match="ATL"):
|
||||
require_supported_scripts(data)
|
||||
|
||||
|
||||
def test_renpy_atl_groups_events_and_named_transforms():
|
||||
data = script(
|
||||
"transform base:\n zoom 0.8\n"
|
||||
"transform bob:\n base\n parallel:\n ease 1.0 yoffset -10\n ease 1.0 yoffset 0\n"
|
||||
" repeat\n parallel:\n pause 0.5\n"
|
||||
" on hide:\n linear 0.3 alpha 0.0\n"
|
||||
'label start:\n "Hi"')
|
||||
require_supported_scripts(data)
|
||||
steps = data.atl["bob"]["steps"]
|
||||
assert steps[0] == {"op": "set", "props": {"zoom": 0.8}} # known static transform is inlined
|
||||
assert [s["op"] for s in steps] == ["set", "parallel", "on"]
|
||||
assert len(steps[1]["blocks"]) == 2 and steps[1]["blocks"][0]["repeat"] is True
|
||||
assert steps[2]["events"] == ["hide"]
|
||||
|
||||
|
||||
def test_source_encoding_is_preserved_or_explicitly_rejected(tmp_path):
|
||||
from app.converter.rpy_lexer import ParseError
|
||||
from app.converter.source import read_source
|
||||
|
||||
@@ -145,7 +145,8 @@ LOCKED`), Redis не нужен.
|
||||
|
||||
Планы:
|
||||
|
||||
* поддержка `screen`-языка и ATL-анимаций с движением в IR;
|
||||
* поддержка `screen`-языка и ATL `function`/`matrixcolor`/шейдеров (базовый ATL
|
||||
с движением уже есть: `backend/app/converter/atl.py`, `frontend/src/player/atl.ts`);
|
||||
* другие движки (TyranoBuilder, RPG Maker MV) через тот же IR;
|
||||
* дальнейшее дробление `engine.ts` на модули (рендер сцены, UI, сохранения);
|
||||
* типизация backend под `mypy` (сейчас проверка справочная).
|
||||
@@ -0,0 +1,226 @@
|
||||
// Runs ATL timelines (see backend/app/converter/atl.py) on the properties of one shown image.
|
||||
|
||||
export type AtlProps = Record<string, any>;
|
||||
|
||||
export interface AtlBlock {
|
||||
steps: AtlStep[];
|
||||
repeat: null | true | number;
|
||||
}
|
||||
|
||||
export type AtlStep =
|
||||
| { op: "set"; props: AtlProps }
|
||||
| { op: "move"; warp: "linear" | "ease" | "easein" | "easeout"; t: number; props: AtlProps }
|
||||
| { op: "pause"; t: number }
|
||||
| { op: "parallel"; blocks: AtlBlock[] }
|
||||
| { op: "choice"; blocks: { w: number; block: AtlBlock }[] }
|
||||
| { op: "block"; block: AtlBlock }
|
||||
| { op: "on"; events: string[]; block: AtlBlock }
|
||||
| { op: "use"; name: string };
|
||||
|
||||
export interface AtlHost {
|
||||
/** Timeline of a named animated transform. */
|
||||
block(name: string): AtlBlock | null;
|
||||
/** Properties of a named static transform. */
|
||||
props(name: string): AtlProps | null;
|
||||
/** Called after every change of the working properties. */
|
||||
apply(props: AtlProps): void;
|
||||
}
|
||||
|
||||
const SHOW_EVENTS = new Set(["show", "appear", "default", "start", "update", "replace"]);
|
||||
const HIDE_EVENTS = new Set(["hide", "replaced"]);
|
||||
const NEUTRAL: AtlProps = {
|
||||
alpha: 1, zoom: 1, xzoom: 1, yzoom: 1, rotate: 0, xoffset: 0, yoffset: 0,
|
||||
xpos: 0, ypos: 0, xanchor: 0, yanchor: 0, xalign: 0, yalign: 0, xcenter: 0, ycenter: 0,
|
||||
};
|
||||
const MAX_DEPTH = 16;
|
||||
|
||||
class Stopped extends Error {}
|
||||
|
||||
const curves = {
|
||||
linear: (t: number) => t,
|
||||
ease: (t: number) => 0.5 - Math.cos(Math.PI * t) / 2,
|
||||
easein: (t: number) => Math.cos((1 - t) * Math.PI / 2),
|
||||
easeout: (t: number) => 1 - Math.cos(t * Math.PI / 2),
|
||||
};
|
||||
|
||||
function nextFrame() {
|
||||
return new Promise<number>((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
|
||||
export class AtlRunner {
|
||||
private token = 0;
|
||||
|
||||
constructor(private host: AtlHost, private props: AtlProps) {}
|
||||
|
||||
/** Start the timeline; resolves when it ends (never, for endless loops) or is stopped. */
|
||||
async start(block: AtlBlock): Promise<void> {
|
||||
const token = ++this.token;
|
||||
try {
|
||||
await this.run(block, token, 0, "show");
|
||||
} catch (err) {
|
||||
if (!(err instanceof Stopped)) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.token++;
|
||||
}
|
||||
|
||||
/** Run the `on hide` handler of the block, if any; resolves once it has finished. */
|
||||
async hide(block: AtlBlock): Promise<void> {
|
||||
const handler = this.handlers(block, HIDE_EVENTS);
|
||||
this.stop();
|
||||
if (!handler.length) return;
|
||||
const token = ++this.token;
|
||||
try {
|
||||
for (const b of handler) await this.run(b, token, 0, "hide");
|
||||
} catch (err) {
|
||||
if (!(err instanceof Stopped)) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
hasHideHandler(block: AtlBlock): boolean {
|
||||
return this.handlers(block, HIDE_EVENTS).length > 0;
|
||||
}
|
||||
|
||||
private handlers(block: AtlBlock, events: Set<string>, depth = 0): AtlBlock[] {
|
||||
if (depth > MAX_DEPTH) return [];
|
||||
const out: AtlBlock[] = [];
|
||||
for (const step of block.steps) {
|
||||
if (step.op === "on" && step.events.some((e) => events.has(e))) out.push(step.block);
|
||||
else if (step.op === "parallel") for (const b of step.blocks) out.push(...this.handlers(b, events, depth + 1));
|
||||
else if (step.op === "block") out.push(...this.handlers(step.block, events, depth + 1));
|
||||
else if (step.op === "use") {
|
||||
const named = this.host.block(step.name);
|
||||
if (named) out.push(...this.handlers(named, events, depth + 1));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private alive(token: number) {
|
||||
if (token !== this.token) throw new Stopped();
|
||||
}
|
||||
|
||||
private apply() {
|
||||
this.host.apply(this.props);
|
||||
}
|
||||
|
||||
private async run(block: AtlBlock, token: number, depth: number, event: string): Promise<void> {
|
||||
if (depth > MAX_DEPTH) throw new Error("ATL: слишком глубокая вложенность");
|
||||
let loops = 0;
|
||||
for (;;) {
|
||||
const startedAt = performance.now();
|
||||
let waited = false;
|
||||
for (let i = 0; i < block.steps.length; i++) {
|
||||
this.alive(token);
|
||||
const step = block.steps[i];
|
||||
if (step.op === "on") {
|
||||
// `on` statements form an event loop: run the show-time handler, then wait for hide
|
||||
const matching = block.steps.filter((s) => s.op === "on" && s.events.some((e) => (event === "hide" ? HIDE_EVENTS : SHOW_EVENTS).has(e)));
|
||||
for (const handler of matching) await this.run((handler as { block: AtlBlock }).block, token, depth + 1, event);
|
||||
if (event === "hide") return;
|
||||
await new Promise<void>(() => {}); // idle until stopped
|
||||
}
|
||||
if (await this.step(step, token, depth, event)) waited = true;
|
||||
}
|
||||
loops++;
|
||||
if (block.repeat === null) return;
|
||||
if (block.repeat !== true && loops >= block.repeat) return;
|
||||
// a loop without any waiting step would freeze the page: yield one frame per turn
|
||||
if (!waited && performance.now() - startedAt < 1) await nextFrame();
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true when the step took time. */
|
||||
private async step(step: AtlStep, token: number, depth: number, event: string): Promise<boolean> {
|
||||
switch (step.op) {
|
||||
case "set":
|
||||
Object.assign(this.props, step.props);
|
||||
this.apply();
|
||||
return false;
|
||||
case "pause":
|
||||
await this.sleep(step.t, token);
|
||||
return step.t > 0;
|
||||
case "move":
|
||||
await this.move(step, token);
|
||||
return step.t > 0;
|
||||
case "parallel":
|
||||
await Promise.all(step.blocks.map((b) => this.run(b, token, depth + 1, event)));
|
||||
return true;
|
||||
case "choice": {
|
||||
const total = step.blocks.reduce((s, b) => s + b.w, 0);
|
||||
let pick = Math.random() * total;
|
||||
const chosen = step.blocks.find((b) => (pick -= b.w) < 0) ?? step.blocks[step.blocks.length - 1];
|
||||
if (chosen) await this.run(chosen.block, token, depth + 1, event);
|
||||
return true;
|
||||
}
|
||||
case "block":
|
||||
await this.run(step.block, token, depth + 1, event);
|
||||
return true;
|
||||
case "use": {
|
||||
const named = this.host.block(step.name);
|
||||
if (named) await this.run(named, token, depth + 1, event);
|
||||
else {
|
||||
const props = this.host.props(step.name);
|
||||
if (props) {
|
||||
Object.assign(this.props, props);
|
||||
this.apply();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "on":
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sleep(seconds: number, token: number) {
|
||||
const end = performance.now() + seconds * 1000;
|
||||
while (performance.now() < end) {
|
||||
await nextFrame();
|
||||
this.alive(token);
|
||||
}
|
||||
}
|
||||
|
||||
/** Value a property has right now, including values implied by its aliases (xalign <- xpos). */
|
||||
private current(key: string): any {
|
||||
const p = this.props;
|
||||
const pair = (a: string, b: string, fa = 0, fb = 0) => [p[a] ?? fa, p[b] ?? fb];
|
||||
switch (key) {
|
||||
case "xalign": return p.xalign ?? p.xpos ?? 0;
|
||||
case "yalign": return p.yalign ?? p.ypos ?? 0;
|
||||
case "xcenter": return p.xcenter ?? p.xpos ?? 0;
|
||||
case "ycenter": return p.ycenter ?? p.ypos ?? 0;
|
||||
case "pos": return p.pos ?? pair("xpos", "ypos");
|
||||
case "align": return p.align ?? [p.xalign ?? p.xpos ?? 0, p.yalign ?? p.ypos ?? 0];
|
||||
case "anchor": return p.anchor ?? pair("xanchor", "yanchor");
|
||||
default: return p[key] ?? NEUTRAL[key] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async move(step: Extract<AtlStep, { op: "move" }>, token: number) {
|
||||
const from: AtlProps = {};
|
||||
for (const key of Object.keys(step.props)) from[key] = this.current(key);
|
||||
const curve = curves[step.warp] ?? curves.linear;
|
||||
const start = performance.now();
|
||||
const duration = step.t * 1000;
|
||||
while (duration > 0) {
|
||||
await nextFrame();
|
||||
this.alive(token);
|
||||
const raw = Math.min(1, (performance.now() - start) / duration);
|
||||
const k = curve(raw);
|
||||
for (const [key, to] of Object.entries(step.props)) this.props[key] = mix(from[key], to, k);
|
||||
this.apply();
|
||||
if (raw >= 1) return;
|
||||
}
|
||||
Object.assign(this.props, step.props);
|
||||
this.apply();
|
||||
}
|
||||
}
|
||||
|
||||
function mix(a: any, b: any, k: number): any {
|
||||
if (typeof a === "number" && typeof b === "number") return a + (b - a) * k;
|
||||
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) return a.map((x, i) => mix(x, b[i], k));
|
||||
return k >= 1 ? b : a;
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export class Engine {
|
||||
resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null,
|
||||
evalCond: (c) => truthy(this.safeEval(c)),
|
||||
transform: (name) => game.transforms[name] ?? null,
|
||||
atl: (name) => game.atl?.[name] ?? null,
|
||||
text: (e) => this.interp(this.tr.str(pyStr(this.safeEval(e)))),
|
||||
});
|
||||
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
|
||||
|
||||
+179
-14
@@ -88,6 +88,39 @@ class PyDescriptor {
|
||||
export const OBJECT = new PyClass("object", []);
|
||||
const classRegistry = new Map<string, PyClass>();
|
||||
|
||||
/** Builtin exception hierarchy; ``args`` holds the constructor arguments. */
|
||||
export const BASE_EXCEPTION = new PyClass("BaseException", [OBJECT]);
|
||||
BASE_EXCEPTION.attrs.set("__init__", (self: PyInstance, ...args: any[]) => {
|
||||
const last = args[args.length - 1];
|
||||
self.attrs.args = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw")
|
||||
? args.slice(0, -1) : args;
|
||||
return null;
|
||||
});
|
||||
BASE_EXCEPTION.attrs.set("__str__", (self: PyInstance) => {
|
||||
const args = self.attrs.args;
|
||||
return Array.isArray(args) && args.length ? (args.length === 1 ? pyStr(args[0]) : pyStr(args)) : "";
|
||||
});
|
||||
for (const method of BASE_EXCEPTION.attrs.values()) method.__method = true;
|
||||
export const EXCEPTIONS = new Map<string, PyClass>([["BaseException", BASE_EXCEPTION]]);
|
||||
for (const [name, parent] of [
|
||||
["Exception", "BaseException"], ["ArithmeticError", "Exception"], ["ZeroDivisionError", "ArithmeticError"],
|
||||
["OverflowError", "ArithmeticError"], ["AssertionError", "Exception"], ["AttributeError", "Exception"],
|
||||
["ImportError", "Exception"], ["LookupError", "Exception"], ["IndexError", "LookupError"],
|
||||
["KeyError", "LookupError"], ["NameError", "Exception"], ["OSError", "Exception"], ["IOError", "OSError"],
|
||||
["EnvironmentError", "OSError"], ["RuntimeError", "Exception"], ["NotImplementedError", "RuntimeError"],
|
||||
["RecursionError", "RuntimeError"], ["StopIteration", "Exception"], ["SyntaxError", "Exception"],
|
||||
["TypeError", "Exception"], ["ValueError", "Exception"], ["UnicodeError", "ValueError"],
|
||||
] as const) {
|
||||
EXCEPTIONS.set(name, new PyClass(name, [EXCEPTIONS.get(parent)!]));
|
||||
}
|
||||
for (const cls of EXCEPTIONS.values()) classRegistry.set(cls.name, cls);
|
||||
|
||||
class PyException extends Error {
|
||||
constructor(public instance: PyInstance) {
|
||||
super(`${instance.cls.name}: ${instanceText(instance)}`);
|
||||
}
|
||||
}
|
||||
|
||||
type FnDef = { name: string; params: any[]; body?: PyStmt[]; expr?: Expr };
|
||||
type FnCtx = {
|
||||
vars: Map<string, any>;
|
||||
@@ -328,6 +361,9 @@ export class Python {
|
||||
warned = new Set<string>();
|
||||
fn: FnCtx | null = null;
|
||||
depth = 0;
|
||||
/** > 0 while a try block runs: lookups that normally yield None raise Python exceptions. */
|
||||
strict = 0;
|
||||
handling: PyInstance[] = [];
|
||||
|
||||
constructor(public scope: Scope) {
|
||||
activePython = this;
|
||||
@@ -345,6 +381,7 @@ export class Python {
|
||||
for (let i = this.locals.length - 1; i >= 0; i--)
|
||||
if (this.locals[i].has(name)) return this.locals[i].get(name);
|
||||
if (this.scope.has(name)) return this.scope.get(name);
|
||||
if (this.strict) this.raise("NameError", `имя ${name} не определено`);
|
||||
this.warnOnce(`неизвестное имя ${name}`);
|
||||
return null;
|
||||
}
|
||||
@@ -368,6 +405,7 @@ export class Python {
|
||||
}
|
||||
if (!safeProperty(e.a) || typeof o === "function") return null;
|
||||
if (o === null || o === undefined) {
|
||||
if (this.strict) this.raise("AttributeError", `у None нет атрибута ${e.a}`);
|
||||
this.warnOnce(`атрибут ${e.a} у None`);
|
||||
return null;
|
||||
}
|
||||
@@ -379,8 +417,15 @@ export class Python {
|
||||
case "sub": {
|
||||
const o = this.eval(e.o);
|
||||
const i = this.eval(e.i);
|
||||
if (o instanceof PyDict) return o.has(i) ? o.get(i) : null;
|
||||
if (o instanceof PyDict) {
|
||||
if (this.strict && !o.has(i)) this.raise("KeyError", pyStr(i));
|
||||
return o.has(i) ? o.get(i) : null;
|
||||
}
|
||||
if (!safeProperty(i) || typeof o === "function") return null;
|
||||
if (this.strict && (Array.isArray(o) || typeof o === "string")) {
|
||||
const at = index(o.length, i);
|
||||
if (at < 0 || at >= o.length) this.raise("IndexError", "индекс вне диапазона");
|
||||
}
|
||||
if (Array.isArray(o) || typeof o === "string") return o[index(o.length, i)] ?? null;
|
||||
return o?.[i] ?? null;
|
||||
}
|
||||
@@ -390,8 +435,13 @@ export class Python {
|
||||
const hi = e.hi ? this.eval(e.hi) : undefined;
|
||||
return o.slice(lo, hi);
|
||||
}
|
||||
case "bin":
|
||||
return binop(e.op, this.eval(e.l), this.eval(e.r));
|
||||
case "bin": {
|
||||
const left = this.eval(e.l);
|
||||
const right = this.eval(e.r);
|
||||
if (this.strict && right === 0 && (e.op === "/" || e.op === "//" || e.op === "%"))
|
||||
this.raise("ZeroDivisionError", "деление на ноль");
|
||||
return binop(e.op, left, right);
|
||||
}
|
||||
case "bool": {
|
||||
let v: any = null;
|
||||
for (const x of e.v) {
|
||||
@@ -426,18 +476,30 @@ export class Python {
|
||||
case "fmt":
|
||||
return e.v.map((p: any) => (typeof p === "string" ? p : pyStr(this.eval(p)))).join("");
|
||||
case "comp": {
|
||||
const gens = e.gens ?? [{ t: e.t, it: e.it, if: e.if }];
|
||||
const isDict = e.v !== undefined && e.v !== null;
|
||||
const out: any[] = [];
|
||||
const dict = new PyDict();
|
||||
const frame = new Map<string, any>();
|
||||
this.locals.push(frame);
|
||||
try {
|
||||
for (const item of iterate(this.eval(e.it))) {
|
||||
this.bind(e.t, item, frame);
|
||||
if (e.if.every((c: Expr) => truthy(this.eval(c)))) out.push(this.eval(e.e));
|
||||
const run = (level: number) => {
|
||||
if (level === gens.length) {
|
||||
if (isDict) dict.set(this.eval(e.e), this.eval(e.v));
|
||||
else out.push(this.eval(e.e));
|
||||
return;
|
||||
}
|
||||
const gen = gens[level];
|
||||
for (const item of [...iterate(this.eval(gen.it))]) {
|
||||
this.bind(gen.t, item, frame);
|
||||
if (gen.if.every((c: Expr) => truthy(this.eval(c)))) run(level + 1);
|
||||
}
|
||||
};
|
||||
try {
|
||||
run(0);
|
||||
} finally {
|
||||
this.locals.pop();
|
||||
}
|
||||
return out;
|
||||
return isDict ? dict : out;
|
||||
}
|
||||
case "lambda": {
|
||||
const closure = this.fn?.isClassBody ? this.locals.slice(0, this.fn.index) : this.locals.slice();
|
||||
@@ -530,17 +592,38 @@ export class Python {
|
||||
case "if":
|
||||
this.exec(truthy(this.eval(s.c)) ? s.then : s.else);
|
||||
break;
|
||||
case "while":
|
||||
case "while": {
|
||||
let broke = false;
|
||||
while (truthy(this.eval(s.c))) {
|
||||
if (++guard > 100000) throw new Error("бесконечный цикл в python");
|
||||
if (this.loopBody(s.body)) break;
|
||||
}
|
||||
if (this.loopBody(s.body)) {
|
||||
broke = true;
|
||||
break;
|
||||
case "for":
|
||||
}
|
||||
}
|
||||
if (!broke && s.else?.length) this.exec(s.else);
|
||||
break;
|
||||
}
|
||||
case "for": {
|
||||
let broke = false;
|
||||
for (const item of [...iterate(this.eval(s.it))]) {
|
||||
this.bind(s.t, item);
|
||||
if (this.loopBody(s.body)) break;
|
||||
if (this.loopBody(s.body)) {
|
||||
broke = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!broke && s.else?.length) this.exec(s.else);
|
||||
break;
|
||||
}
|
||||
case "try":
|
||||
this.execTry(s);
|
||||
break;
|
||||
case "raise":
|
||||
this.execRaise(s);
|
||||
break;
|
||||
case "assert":
|
||||
if (!truthy(this.eval(s.c))) this.raise("AssertionError", s.m ? pyStr(this.eval(s.m)) : "");
|
||||
break;
|
||||
case "del":
|
||||
if (s.t.k === "name") {
|
||||
@@ -577,6 +660,69 @@ export class Python {
|
||||
}
|
||||
}
|
||||
|
||||
// --- exceptions ---------------------------------------------------------------------
|
||||
|
||||
/** Throw a builtin Python exception, e.g. ``raise("KeyError", "x")``. */
|
||||
raise(name: string, message: string): never {
|
||||
const cls = EXCEPTIONS.get(name) ?? EXCEPTIONS.get("Exception")!;
|
||||
throw new PyException(this.instantiate(cls, message === "" ? [] : [message]));
|
||||
}
|
||||
|
||||
private execRaise(s: PyStmt) {
|
||||
if (!s.v) {
|
||||
const current = this.handling[this.handling.length - 1];
|
||||
if (!current) this.raise("RuntimeError", "нет исключения для повторного вызова");
|
||||
throw new PyException(current);
|
||||
}
|
||||
const v = this.eval(s.v);
|
||||
if (v instanceof PyInstance && v.cls.mro().includes(BASE_EXCEPTION)) throw new PyException(v);
|
||||
if (v instanceof PyClass && v.mro().includes(BASE_EXCEPTION)) throw new PyException(this.instantiate(v, []));
|
||||
this.raise("TypeError", "можно вызвать только исключение");
|
||||
}
|
||||
|
||||
/** Python exception for anything thrown while running a try body, or null for control flow. */
|
||||
private caught(err: unknown): PyInstance | null {
|
||||
if (err instanceof PyException) return err.instance;
|
||||
if (err instanceof BreakSignal || err instanceof ContinueSignal || err instanceof ReturnSignal
|
||||
|| err instanceof JumpSignal) return null;
|
||||
if (err instanceof TypeError) return this.instantiate(EXCEPTIONS.get("TypeError")!, [err.message]);
|
||||
if (err instanceof Error) return this.instantiate(EXCEPTIONS.get("RuntimeError")!, [err.message]);
|
||||
return null;
|
||||
}
|
||||
|
||||
private execTry(s: PyStmt) {
|
||||
try {
|
||||
let handled = false;
|
||||
this.strict++;
|
||||
try {
|
||||
this.exec(s.body);
|
||||
} catch (err) {
|
||||
this.strict--;
|
||||
const exception = this.caught(err);
|
||||
const handler = exception && s.handlers.find((h: any) => {
|
||||
if (h.types === null) return true;
|
||||
const kinds = this.eval(h.types);
|
||||
return (Array.isArray(kinds) ? kinds : [kinds]).some((k: any) =>
|
||||
k instanceof PyClass && exception.cls.mro().includes(k));
|
||||
});
|
||||
if (!exception || !handler) throw err;
|
||||
handled = true;
|
||||
if (handler.name) this.bindName(handler.name, exception);
|
||||
this.handling.push(exception);
|
||||
try {
|
||||
this.exec(handler.body);
|
||||
} finally {
|
||||
this.handling.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.strict--;
|
||||
if (!handled && s.else?.length) this.exec(s.else);
|
||||
} finally {
|
||||
if (s.final?.length) this.exec(s.final);
|
||||
}
|
||||
}
|
||||
|
||||
// --- functions and classes ---------------------------------------------------------
|
||||
|
||||
/** Assign a name in the scope the running code sees: function local, global or enclosing. */
|
||||
@@ -755,7 +901,7 @@ export class Python {
|
||||
if (value.kind === "static") return value.fn;
|
||||
return (...a: any[]) => value.fn(cls, ...a);
|
||||
}
|
||||
if (typeof value === "function" && value.__def) return (...a: any[]) => value(self, ...a);
|
||||
if (typeof value === "function" && (value.__def || value.__method)) return (...a: any[]) => value(self, ...a);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -828,6 +974,25 @@ export function userBuiltins(py: Python): Record<string, any> {
|
||||
};
|
||||
const less = (a: any, b: any) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
return {
|
||||
...Object.fromEntries(EXCEPTIONS),
|
||||
int: (x: any = 0, base?: number) => {
|
||||
if (typeof x === "string") {
|
||||
const text = x.trim();
|
||||
const ok = base ? new RegExp(`^[+-]?[0-9a-z]+$`, "i").test(text) : /^[+-]?\d+$/.test(text);
|
||||
const n = ok ? parseInt(text, base ?? 10) : NaN;
|
||||
if (Number.isNaN(n)) py.raise("ValueError", `недопустимое значение для int(): '${x}'`);
|
||||
return n;
|
||||
}
|
||||
const n = Math.trunc(Number(x));
|
||||
if (!Number.isFinite(n)) py.raise("ValueError", "недопустимое значение для int()");
|
||||
return n;
|
||||
},
|
||||
float: (x: any = 0) => {
|
||||
const n = typeof x === "string" ? Number(x.trim()) : Number(x);
|
||||
if (Number.isNaN(n) || (typeof x === "string" && x.trim() === ""))
|
||||
py.raise("ValueError", `недопустимое значение для float(): '${x}'`);
|
||||
return n;
|
||||
},
|
||||
object: OBJECT,
|
||||
python_object: OBJECT,
|
||||
NoRollback: OBJECT,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// DOM renderer for the image layer: one absolutely positioned element per shown tag.
|
||||
// The stage has the game's native resolution and is scaled with a CSS transform.
|
||||
|
||||
import { AtlRunner, type AtlBlock } from "./atl";
|
||||
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
|
||||
|
||||
const POSITIONS: Record<string, Record<string, number>> = {
|
||||
@@ -27,11 +28,13 @@ export interface RendererHost {
|
||||
resolveRef(name: string): ImageDef | null;
|
||||
evalCond(e: any): boolean;
|
||||
transform(name: string): Record<string, any> | null;
|
||||
atl(name: string): AtlBlock | null;
|
||||
text(e: any): string;
|
||||
}
|
||||
|
||||
interface Entry extends ShownImage {
|
||||
el: HTMLElement;
|
||||
runners: { runner: AtlRunner; block: AtlBlock }[];
|
||||
}
|
||||
|
||||
export class Renderer {
|
||||
@@ -64,7 +67,10 @@ export class Renderer {
|
||||
// --- scene state ---------------------------------------------------------
|
||||
|
||||
scene() {
|
||||
for (const e of this.shown.values()) e.el.remove();
|
||||
for (const e of this.shown.values()) {
|
||||
e.runners.forEach((r) => r.runner.stop());
|
||||
e.el.remove();
|
||||
}
|
||||
this.shown.clear();
|
||||
}
|
||||
|
||||
@@ -73,8 +79,10 @@ export class Renderer {
|
||||
const atSpecs = at.length ? at : prev?.at ?? [];
|
||||
const el = this.build(img.def);
|
||||
el.classList.add("pg-image");
|
||||
this.place(el, img.def, atSpecs);
|
||||
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el };
|
||||
const base = this.place(el, img.def, atSpecs);
|
||||
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el,
|
||||
runners: [] };
|
||||
prev?.runners.forEach((r) => r.runner.stop());
|
||||
if (prev) {
|
||||
prev.el.replaceWith(el);
|
||||
} else {
|
||||
@@ -83,13 +91,39 @@ export class Renderer {
|
||||
this.master.insertBefore(el, behind ?? later ?? null);
|
||||
}
|
||||
this.shown.set(tag, entry);
|
||||
this.animate(entry, base, atSpecs);
|
||||
}
|
||||
|
||||
/** Start the ATL timelines of an image; each works on its own copy of the properties. */
|
||||
private animate(entry: Entry, base: Record<string, any>, specs: AtSpec[]) {
|
||||
if (this.fast) return;
|
||||
const live = { ...base };
|
||||
for (const spec of specs) {
|
||||
const block = typeof spec.atl === "string" ? this.host.atl(spec.atl) : spec.atl;
|
||||
if (!block) continue;
|
||||
const runner = new AtlRunner({
|
||||
block: (name) => this.host.atl(name),
|
||||
props: (name) => this.host.transform(name),
|
||||
apply: (p) => this.style(entry.el, { ...p }),
|
||||
}, live);
|
||||
entry.runners.push({ runner, block });
|
||||
void runner.start(block).catch((err) => console.warn("[pgvn] ATL", err));
|
||||
}
|
||||
}
|
||||
|
||||
hide(tag: string) {
|
||||
const e = this.shown.get(tag);
|
||||
if (!e) return;
|
||||
e.el.remove();
|
||||
this.shown.delete(tag);
|
||||
const exit = e.runners.find((r) => r.runner.hasHideHandler(r.block));
|
||||
if (!exit || this.fast) {
|
||||
e.runners.forEach((r) => r.runner.stop());
|
||||
e.el.remove();
|
||||
return;
|
||||
}
|
||||
// `on hide` animation: the picture leaves the scene state at once but stays visible until it ends
|
||||
e.runners.forEach((r) => r.runner !== exit.runner && r.runner.stop());
|
||||
void exit.runner.hide(exit.block).finally(() => e.el.remove());
|
||||
}
|
||||
|
||||
isShown(tag: string) {
|
||||
@@ -243,7 +277,8 @@ export class Renderer {
|
||||
|
||||
cameraAt: AtSpec[] = [];
|
||||
|
||||
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) {
|
||||
/** Sets the element's position and look; returns the properties it was built from. */
|
||||
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]): Record<string, any> {
|
||||
const p: Record<string, any> = { ...POSITIONS.default, ...(def.props ?? {}) };
|
||||
for (const spec of [...(def.at ?? []), ...at]) {
|
||||
if (spec.effect === "shake") {
|
||||
@@ -256,6 +291,11 @@ export class Renderer {
|
||||
if (spec.name && !spec.props) Object.assign(p, this.host.transform(spec.name) ?? POSITIONS[spec.name] ?? {});
|
||||
if (spec.props) Object.assign(p, spec.props);
|
||||
}
|
||||
this.style(el, { ...p });
|
||||
return p;
|
||||
}
|
||||
|
||||
private style(el: HTMLElement, p: Record<string, any>) {
|
||||
normalize(p);
|
||||
const pos = (v: number, total: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * total}px`);
|
||||
const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`);
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface AtSpec {
|
||||
pos?: string;
|
||||
name?: string;
|
||||
props?: Record<string, any>;
|
||||
/** Animated transform: a name from GameData.atl or an inline timeline. */
|
||||
atl?: string | import("./atl").AtlBlock;
|
||||
effect?: "shake";
|
||||
d?: number;
|
||||
dist?: number;
|
||||
@@ -106,6 +108,7 @@ export interface GameData {
|
||||
characters: Record<string, CharacterDef>;
|
||||
images: Record<string, ImageDef>;
|
||||
transforms: Record<string, Record<string, any>>;
|
||||
atl?: Record<string, import("./atl").AtlBlock>;
|
||||
transitions: Record<string, Transition | null>;
|
||||
audio: Record<string, string>;
|
||||
channels?: Record<string, { mixer: string; loop: boolean }>;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 4);
|
||||
const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/atl.ts", import.meta.url))],
|
||||
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
||||
const { AtlRunner } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const block = (steps, repeat = null) => ({ steps, repeat });
|
||||
const set = (props) => ({ op: "set", props });
|
||||
const move = (t, props, warp = "linear") => ({ op: "move", warp, t, props });
|
||||
|
||||
function runner(named = {}) {
|
||||
const seen = [];
|
||||
const host = {
|
||||
block: (n) => named[n] ?? null,
|
||||
props: () => null,
|
||||
apply: (p) => seen.push({ ...p }),
|
||||
};
|
||||
const props = { xpos: 0.5, xanchor: 0.5 };
|
||||
return { run: new AtlRunner(host, props), props, seen };
|
||||
}
|
||||
|
||||
test("a linear move interpolates from the current value to the target", async () => {
|
||||
const { run, props, seen } = runner();
|
||||
await run.start(block([set({ xalign: 0 }), move(0.12, { xalign: 1 })]));
|
||||
assert.equal(props.xalign, 1);
|
||||
const values = seen.map((p) => p.xalign);
|
||||
assert.ok(values.some((v) => v > 0 && v < 1), "has intermediate frames");
|
||||
assert.deepEqual(values, [...values].sort((a, b) => a - b), "monotonic");
|
||||
});
|
||||
|
||||
test("a move starts from the value implied by xpos when xalign was never set", async () => {
|
||||
const { run, seen } = runner();
|
||||
await run.start(block([move(0.05, { xalign: 1 })]));
|
||||
assert.ok(seen[0].xalign > 0.4, `first frame ${seen[0].xalign} starts near xpos 0.5`);
|
||||
});
|
||||
|
||||
test("tuples move component-wise and parallel blocks run together", async () => {
|
||||
const { run, props } = runner();
|
||||
const started = performance.now();
|
||||
await run.start(block([{ op: "parallel", blocks: [
|
||||
block([move(0.1, { pos: [1, 1] })]), block([move(0.1, { alpha: 0 })]),
|
||||
] }]));
|
||||
assert.deepEqual(props.pos, [1, 1]);
|
||||
assert.equal(props.alpha, 0);
|
||||
assert.ok(performance.now() - started < 250, "blocks overlapped instead of running one after another");
|
||||
});
|
||||
|
||||
test("repeat loops until stopped, even when the body takes no time", async () => {
|
||||
const { run, seen } = runner();
|
||||
const done = run.start(block([set({ alpha: 0.5 })], true)); // would freeze the page without the frame yield
|
||||
await wait(60);
|
||||
run.stop();
|
||||
await done;
|
||||
assert.ok(seen.length > 1 && seen.length < 200);
|
||||
const counted = runner();
|
||||
const finished = counted.run.start(block([{ op: "pause", t: 0.01 }, set({ zoom: 2 })], 3));
|
||||
await finished;
|
||||
assert.equal(counted.seen.length, 3);
|
||||
});
|
||||
|
||||
test("named transforms, choice and block steps are followed", async () => {
|
||||
const named = { grow: block([move(0.03, { zoom: 2 })]) };
|
||||
const { run, props } = runner(named);
|
||||
await run.start(block([{ op: "use", name: "grow" }, { op: "choice", blocks: [{ w: 1, block: block([set({ alpha: 0.25 })]) }] },
|
||||
{ op: "block", block: block([set({ yoffset: 5 })]) }]));
|
||||
assert.equal(props.zoom, 2);
|
||||
assert.equal(props.alpha, 0.25);
|
||||
assert.equal(props.yoffset, 5);
|
||||
});
|
||||
|
||||
test("on show runs at once and `on hide` runs only when the image leaves", async () => {
|
||||
const { run, props } = runner();
|
||||
const timeline = block([
|
||||
{ op: "on", events: ["show"], block: block([set({ alpha: 1 })]) },
|
||||
{ op: "on", events: ["hide"], block: block([move(0.05, { alpha: 0 })]) },
|
||||
]);
|
||||
void run.start(timeline);
|
||||
await wait(30);
|
||||
assert.equal(props.alpha, 1);
|
||||
assert.equal(run.hasHideHandler(timeline), true);
|
||||
await run.hide(timeline);
|
||||
assert.equal(props.alpha, 0);
|
||||
});
|
||||
|
||||
test("excessive nesting is refused instead of overflowing the stack", async () => {
|
||||
const { run } = runner({ loop: { steps: [{ op: "use", name: "loop" }], repeat: null } });
|
||||
await assert.rejects(run.start(block([{ op: "use", name: "loop" }])), /вложенность/);
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
[{"op":"class","name":"AppError","bases":[{"k":"name","id":"Exception"}],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"msg","d":null,"kind":"p"},{"n":"code","d":{"k":"const","v":0},"kind":"p"}],"body":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"call","f":{"k":"name","id":"super"},"a":[],"kw":{}},"a":"__init__"},"a":[{"k":"name","id":"msg"}],"kw":{}}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"code"},"v":{"k":"name","id":"code"}}],"decos":[]}]},{"op":"def","name":"risky","params":[{"n":"n","d":null,"kind":"p"}],"body":[{"op":"if","c":{"k":"cmp","l":{"k":"name","id":"n"},"ops":["=="],"r":[{"k":"const","v":0}]},"then":[{"op":"raise","v":{"k":"call","f":{"k":"name","id":"AppError"},"a":[{"k":"const","v":"zero"}],"kw":{"code":{"k":"const","v":7}}}}],"else":[]},{"op":"return","v":{"k":"bin","op":"//","l":{"k":"const","v":10},"r":{"k":"name","id":"n"}}}],"decos":[]},{"op":"set","t":{"k":"name","id":"log"},"v":{"k":"list","v":[]}},{"op":"try","body":[{"op":"eval","e":{"k":"call","f":{"k":"name","id":"risky"},"a":[{"k":"const","v":0}],"kw":{}}}],"handlers":[{"types":{"k":"name","id":"AppError"},"name":"e","body":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"log"},"a":"append"},"a":[{"k":"call","f":{"k":"name","id":"str"},"a":[{"k":"name","id":"e"}],"kw":{}}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_code"},"v":{"k":"attr","o":{"k":"name","id":"e"},"a":"code"}}]}],"else":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"log"},"a":"append"},"a":[{"k":"const","v":"no"}],"kw":{}}}],"final":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"log"},"a":"append"},"a":[{"k":"const","v":"fin"}],"kw":{}}}]},{"op":"try","body":[{"op":"set","t":{"k":"name","id":"d"},"v":{"k":"dict","v":[[{"k":"const","v":"a"},{"k":"const","v":1}]]}},{"op":"set","t":{"k":"name","id":"v"},"v":{"k":"sub","o":{"k":"name","id":"d"},"i":{"k":"const","v":"b"}}}],"handlers":[{"types":{"k":"name","id":"KeyError"},"name":null,"body":[{"op":"set","t":{"k":"name","id":"result_key"},"v":{"k":"const","v":"keyerror"}}]}],"else":[],"final":[]},{"op":"try","body":[{"op":"set","t":{"k":"name","id":"xs"},"v":{"k":"list","v":[{"k":"const","v":1}]}},{"op":"eval","e":{"k":"sub","o":{"k":"name","id":"xs"},"i":{"k":"const","v":5}}}],"handlers":[{"types":{"k":"tuple","v":[{"k":"name","id":"IndexError"},{"k":"name","id":"ValueError"}]},"name":null,"body":[{"op":"set","t":{"k":"name","id":"result_index"},"v":{"k":"const","v":"indexerror"}}]}],"else":[],"final":[]},{"op":"try","body":[{"op":"eval","e":{"k":"call","f":{"k":"name","id":"int"},"a":[{"k":"const","v":"abc"}],"kw":{}}}],"handlers":[{"types":{"k":"name","id":"ValueError"},"name":null,"body":[{"op":"set","t":{"k":"name","id":"result_value"},"v":{"k":"const","v":"valueerror"}}]}],"else":[],"final":[]},{"op":"try","body":[{"op":"eval","e":{"k":"call","f":{"k":"name","id":"risky"},"a":[{"k":"const","v":0}],"kw":{}}}],"handlers":[{"types":{"k":"name","id":"Exception"},"name":null,"body":[{"op":"set","t":{"k":"name","id":"result_generic"},"v":{"k":"const","v":true}}]}],"else":[],"final":[]},{"op":"try","body":[{"op":"set","t":{"k":"name","id":"result_zero"},"v":{"k":"bin","op":"/","l":{"k":"const","v":1},"r":{"k":"const","v":0}}}],"handlers":[{"types":{"k":"name","id":"ZeroDivisionError"},"name":null,"body":[{"op":"set","t":{"k":"name","id":"result_zero"},"v":{"k":"const","v":"zerodiv"}}]}],"else":[],"final":[]},{"op":"def","name":"retry","params":[],"body":[{"op":"for","t":{"k":"name","id":"i"},"it":{"k":"call","f":{"k":"name","id":"range"},"a":[{"k":"const","v":3}],"kw":{}},"body":[{"op":"try","body":[{"op":"if","c":{"k":"cmp","l":{"k":"name","id":"i"},"ops":["<"],"r":[{"k":"const","v":2}]},"then":[{"op":"raise","v":{"k":"call","f":{"k":"name","id":"ValueError"},"a":[{"k":"const","v":"again"}],"kw":{}}}],"else":[]},{"op":"return","v":{"k":"name","id":"i"}}],"handlers":[{"types":{"k":"name","id":"ValueError"},"name":null,"body":[{"op":"continue"}]}],"else":[],"final":[]}],"else":[]},{"op":"return","v":{"k":"unary","op":"-","v":{"k":"const","v":1}}}],"decos":[]},{"op":"set","t":{"k":"name","id":"result_retry"},"v":{"k":"call","f":{"k":"name","id":"retry"},"a":[],"kw":{}}},{"op":"set","t":{"k":"name","id":"found"},"v":{"k":"const","v":null}},{"op":"for","t":{"k":"name","id":"n"},"it":{"k":"list","v":[{"k":"const","v":1},{"k":"const","v":3},{"k":"const","v":5}]},"body":[{"op":"if","c":{"k":"cmp","l":{"k":"bin","op":"%","l":{"k":"name","id":"n"},"r":{"k":"const","v":2}},"ops":["=="],"r":[{"k":"const","v":0}]},"then":[{"op":"set","t":{"k":"name","id":"found"},"v":{"k":"name","id":"n"}},{"op":"break"}],"else":[]}],"else":[{"op":"set","t":{"k":"name","id":"found"},"v":{"k":"const","v":"none"}}]},{"op":"set","t":{"k":"name","id":"result_forelse"},"v":{"k":"name","id":"found"}},{"op":"set","t":{"k":"name","id":"k"},"v":{"k":"const","v":0}},{"op":"while","c":{"k":"cmp","l":{"k":"name","id":"Line truncated
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
# Source of exceptions.ir.json. Regenerate from backend/ with:
|
||||
# python -m tests.make_ir ../frontend/tests/fixtures/exceptions.py ../frontend/tests/fixtures/exceptions.ir.json
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
def __init__(self, msg, code=0):
|
||||
super().__init__(msg)
|
||||
self.code = code
|
||||
|
||||
|
||||
def risky(n):
|
||||
if n == 0:
|
||||
raise AppError("zero", code=7)
|
||||
return 10 // n
|
||||
|
||||
|
||||
log = []
|
||||
try:
|
||||
risky(0)
|
||||
except AppError as e:
|
||||
log.append(str(e))
|
||||
result_code = e.code
|
||||
else:
|
||||
log.append("no")
|
||||
finally:
|
||||
log.append("fin")
|
||||
|
||||
try:
|
||||
d = {"a": 1}
|
||||
v = d["b"]
|
||||
except KeyError:
|
||||
result_key = "keyerror"
|
||||
|
||||
try:
|
||||
xs = [1]
|
||||
xs[5]
|
||||
except (IndexError, ValueError):
|
||||
result_index = "indexerror"
|
||||
|
||||
try:
|
||||
int("abc")
|
||||
except ValueError:
|
||||
result_value = "valueerror"
|
||||
|
||||
try:
|
||||
risky(0)
|
||||
except Exception:
|
||||
result_generic = True
|
||||
|
||||
try:
|
||||
result_zero = 1 / 0
|
||||
except ZeroDivisionError:
|
||||
result_zero = "zerodiv"
|
||||
|
||||
|
||||
def retry():
|
||||
for i in range(3):
|
||||
try:
|
||||
if i < 2:
|
||||
raise ValueError("again")
|
||||
return i
|
||||
except ValueError:
|
||||
continue
|
||||
return -1
|
||||
|
||||
|
||||
result_retry = retry()
|
||||
|
||||
found = None
|
||||
for n in [1, 3, 5]:
|
||||
if n % 2 == 0:
|
||||
found = n
|
||||
break
|
||||
else:
|
||||
found = "none"
|
||||
result_forelse = found
|
||||
|
||||
k = 0
|
||||
while k < 3:
|
||||
k += 1
|
||||
else:
|
||||
k = k * 10
|
||||
result_whileelse = k
|
||||
|
||||
try:
|
||||
try:
|
||||
raise RuntimeError("inner")
|
||||
except RuntimeError:
|
||||
raise
|
||||
except RuntimeError as e3:
|
||||
result_reraise = str(e3)
|
||||
|
||||
result_pairs = [(x, y) for x in [1, 2] for y in [3, 4]]
|
||||
result_dictcomp = {a: b for a in [1, 2] for b in [a * 10] if b > 10}
|
||||
|
||||
try:
|
||||
assert 1 == 2, "boom"
|
||||
except AssertionError as e4:
|
||||
result_assert = str(e4)
|
||||
@@ -14,7 +14,7 @@ const ir = (name) => JSON.parse(readFileSync(new URL(`./fixtures/${name}.ir.json
|
||||
function runtime() {
|
||||
const store = Object.create(null);
|
||||
const builtins = {
|
||||
any: (xs) => xs.some(Boolean), max: Math.max, int: Number, str: pyStr, list: (xs = []) => [...xs],
|
||||
any: (xs) => xs.some(Boolean), range: (n) => Array.from({ length: n }, (_, i) => i), max: Math.max, int: Number, str: pyStr, list: (xs = []) => [...xs],
|
||||
};
|
||||
const scope = {
|
||||
get: (n) => (n in store ? store[n] : builtins[n]),
|
||||
@@ -82,3 +82,31 @@ test("unbounded recursion and bad calls fail with explicit errors", () => {
|
||||
assert.throws(() => py.eval({ k: "call", f: { k: "name", id: "one" },
|
||||
a: [{ k: "const", v: 1 }, { k: "const", v: 2 }], kw: {} }), /слишком много/);
|
||||
});
|
||||
|
||||
test("try/except/else/finally, raise, loop else, assert and multi-generator comprehensions", () => {
|
||||
const { py, store } = runtime();
|
||||
py.exec(ir("exceptions"));
|
||||
assert.deepEqual(store.log, ["zero", "fin"]);
|
||||
assert.equal(store.result_code, 7);
|
||||
assert.equal(store.result_key, "keyerror"); // dict[...] is strict inside try
|
||||
assert.equal(store.result_index, "indexerror");
|
||||
assert.equal(store.result_value, "valueerror"); // int("abc")
|
||||
assert.equal(store.result_generic, true);
|
||||
assert.equal(store.result_zero, "zerodiv");
|
||||
assert.equal(store.result_retry, 2); // return/continue pass through try
|
||||
assert.equal(store.result_forelse, "none");
|
||||
assert.equal(store.result_whileelse, 30);
|
||||
assert.equal(store.result_reraise, "inner");
|
||||
assert.deepEqual(store.result_pairs, [[1, 3], [1, 4], [2, 3], [2, 4]]);
|
||||
assert.deepEqual([...store.result_dictcomp.map.entries()], [[2, 20]]);
|
||||
assert.equal(store.result_assert, "boom");
|
||||
});
|
||||
|
||||
test("lookups stay lenient outside try, uncaught exceptions are explicit errors", () => {
|
||||
const { py, store } = runtime();
|
||||
py.exec([{ op: "set", t: { k: "name", id: "d" }, v: { k: "dict", v: [] } }]);
|
||||
assert.equal(py.eval({ k: "sub", o: { k: "name", id: "d" }, i: { k: "const", v: "missing" } }), null);
|
||||
assert.throws(() => py.exec([{ op: "raise", v: { k: "name", id: "ValueError" } }]), /ValueError/);
|
||||
assert.equal(py.strict, 0);
|
||||
void store;
|
||||
});
|
||||
Reference in new issue
Block a user