diff --git a/README.md b/README.md index 076ad02..841353e 100644 --- a/README.md +++ b/README.md @@ -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`, …) и картинки, которые игра вычисляет +своими функциями. Такие места перечислены в отчёте конвертации у версии и +останавливают импорт. Игры-«песочницы», где навигация построена на экранах, пока не играбельны. diff --git a/backend/app/converter/atl.py b/backend/app/converter/atl.py new file mode 100644 index 0000000..09cc8e7 --- /dev/null +++ b/backend/app/converter/atl.py @@ -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 diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index e540ee0..10d10d4 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -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") diff --git a/backend/app/converter/pipeline.py b/backend/app/converter/pipeline.py index d23270d..f29d5a9 100644 --- a/backend/app/converter/pipeline.py +++ b/backend/app/converter/pipeline.py @@ -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, diff --git a/backend/app/converter/pyexpr.py b/backend/app/converter/pyexpr.py index 232775f..05447b4 100644 --- a/backend/app/converter/pyexpr.py +++ b/backend/app/converter/pyexpr.py @@ -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)} diff --git a/backend/app/converter/rpy_parser.py b/backend/app/converter/rpy_parser.py index 0e8a60f..2e36481 100644 --- a/backend/app/converter/rpy_parser.py +++ b/backend/app/converter/rpy_parser.py @@ -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") diff --git a/backend/app/pgvn.py b/backend/app/pgvn.py index a6d8bd6..02da68f 100644 --- a/backend/app/pgvn.py +++ b/backend/app/pgvn.py @@ -85,10 +85,24 @@ 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(key) - for item in seq("if"): - expression(item, depth + 1) + 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) else: raise ValueError(f"Неизвестное выражение: {kind}") @@ -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("У функции нет имени") diff --git a/backend/tests/test_compatibility.py b/backend/tests/test_compatibility.py index 79c34a7..ccc7b43 100644 --- a/backend/tests/test_compatibility.py +++ b/backend/tests/test_compatibility.py @@ -89,10 +89,37 @@ 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"') - with pytest.raises(CompatibilityError, match="ATL"): - require_supported_scripts(data) +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): diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 56a8da9..256b8d8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` (сейчас проверка справочная). diff --git a/frontend/src/player/atl.ts b/frontend/src/player/atl.ts new file mode 100644 index 0000000..1a7b1c6 --- /dev/null +++ b/frontend/src/player/atl.ts @@ -0,0 +1,226 @@ +// Runs ATL timelines (see backend/app/converter/atl.py) on the properties of one shown image. + +export type AtlProps = Record; + +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((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 { + 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 { + 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, 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 { + 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(() => {}); // 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 { + 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, 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; +} diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index eb537b5..2217800 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -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 ?? {}); diff --git a/frontend/src/player/python.ts b/frontend/src/player/python.ts index c574a85..46869fb 100644 --- a/frontend/src/player/python.ts +++ b/frontend/src/player/python.ts @@ -88,6 +88,39 @@ class PyDescriptor { export const OBJECT = new PyClass("object", []); const classRegistry = new Map(); +/** 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([["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; @@ -328,6 +361,9 @@ export class Python { warned = new Set(); 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(); 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; + } } + if (!broke && s.else?.length) this.exec(s.else); break; - case "for": + } + 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 { }; 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, diff --git a/frontend/src/player/renderer.ts b/frontend/src/player/renderer.ts index c0e946d..967dfd1 100644 --- a/frontend/src/player/renderer.ts +++ b/frontend/src/player/renderer.ts @@ -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> = { @@ -27,11 +28,13 @@ export interface RendererHost { resolveRef(name: string): ImageDef | null; evalCond(e: any): boolean; transform(name: string): Record | 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, 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 { const p: Record = { ...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) { 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}%`); diff --git a/frontend/src/player/types.ts b/frontend/src/player/types.ts index 3264862..f83ed52 100644 --- a/frontend/src/player/types.ts +++ b/frontend/src/player/types.ts @@ -51,6 +51,8 @@ export interface AtSpec { pos?: string; name?: string; props?: Record; + /** 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; images: Record; transforms: Record>; + atl?: Record; transitions: Record; audio: Record; channels?: Record; diff --git a/frontend/tests/atl.test.mjs b/frontend/tests/atl.test.mjs new file mode 100644 index 0000000..024dcc6 --- /dev/null +++ b/frontend/tests/atl.test.mjs @@ -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" }])), /вложенность/); +}); diff --git a/frontend/tests/fixtures/exceptions.ir.json b/frontend/tests/fixtures/exceptions.ir.json new file mode 100644 index 0000000..825a4b6 --- /dev/null +++ b/frontend/tests/fixtures/exceptions.ir.json @@ -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":"k"},"ops":["<"],"r":[{"k":"const","v":3}]},"body":[{"op":"aug","t":{"k":"name","id":"k"},"o":"+","v":{"k":"const","v":1}}],"else":[{"op":"set","t":{"k":"name","id":"k"},"v":{"k":"bin","op":"*","l":{"k":"name","id":"k"},"r":{"k":"const","v":10}}}]},{"op":"set","t":{"k":"name","id":"result_whileelse"},"v":{"k":"name","id":"k"}},{"op":"try","body":[{"op":"try","body":[{"op":"raise","v":{"k":"call","f":{"k":"name","id":"RuntimeError"},"a":[{"k":"const","v":"inner"}],"kw":{}}}],"handlers":[{"types":{"k":"name","id":"RuntimeError"},"name":null,"body":[{"op":"raise","v":null}]}],"else":[],"final":[]}],"handlers":[{"types":{"k":"name","id":"RuntimeError"},"name":"e3","body":[{"op":"set","t":{"k":"name","id":"result_reraise"},"v":{"k":"call","f":{"k":"name","id":"str"},"a":[{"k":"name","id":"e3"}],"kw":{}}}]}],"else":[],"final":[]},{"op":"set","t":{"k":"name","id":"result_pairs"},"v":{"k":"comp","e":{"k":"tuple","v":[{"k":"name","id":"x"},{"k":"name","id":"y"}]},"gens":[{"t":{"k":"name","id":"x"},"it":{"k":"list","v":[{"k":"const","v":1},{"k":"const","v":2}]},"if":[]},{"t":{"k":"name","id":"y"},"it":{"k":"list","v":[{"k":"const","v":3},{"k":"const","v":4}]},"if":[]}]}},{"op":"set","t":{"k":"name","id":"result_dictcomp"},"v":{"k":"comp","e":{"k":"name","id":"a"},"v":{"k":"name","id":"b"},"gens":[{"t":{"k":"name","id":"a"},"it":{"k":"list","v":[{"k":"const","v":1},{"k":"const","v":2}]},"if":[]},{"t":{"k":"name","id":"b"},"it":{"k":"list","v":[{"k":"bin","op":"*","l":{"k":"name","id":"a"},"r":{"k":"const","v":10}}]},"if":[{"k":"cmp","l":{"k":"name","id":"b"},"ops":[">"],"r":[{"k":"const","v":10}]}]}]}},{"op":"try","body":[{"op":"assert","c":{"k":"cmp","l":{"k":"const","v":1},"ops":["=="],"r":[{"k":"const","v":2}]},"m":{"k":"const","v":"boom"}}],"handlers":[{"types":{"k":"name","id":"AssertionError"},"name":"e4","body":[{"op":"set","t":{"k":"name","id":"result_assert"},"v":{"k":"call","f":{"k":"name","id":"str"},"a":[{"k":"name","id":"e4"}],"kw":{}}}]}],"else":[],"final":[]}] \ No newline at end of file diff --git a/frontend/tests/fixtures/exceptions.py b/frontend/tests/fixtures/exceptions.py new file mode 100644 index 0000000..8807861 --- /dev/null +++ b/frontend/tests/fixtures/exceptions.py @@ -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) diff --git a/frontend/tests/python-classes.test.mjs b/frontend/tests/python-classes.test.mjs index f38e1c6..d2872d9 100644 --- a/frontend/tests/python-classes.test.mjs +++ b/frontend/tests/python-classes.test.mjs @@ -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; +});