- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After) - Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build) - Apply ruff autofixes (import order, pyupgrade) - Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and translation-types.ts from oversized modules - Add dependency-free React UI tests - Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
227 lines
14 KiB
Python
227 lines
14 KiB
Python
"""Compile the supported Naninovel command subset to the shared PGVN player."""
|
|
import hashlib
|
|
import re
|
|
from collections import Counter
|
|
|
|
from app.converter import nani_expr
|
|
from app.converter.compatibility import CompatibilityError, Issue
|
|
from app.converter.rpy_parser import ScriptData
|
|
|
|
|
|
def target(path, script: str) -> str:
|
|
if isinstance(path, dict):
|
|
if "template" in path or set(path) - {"name", "value"}:
|
|
raise ValueError("Динамический адрес перехода Naninovel пока не поддерживается")
|
|
return "nani:" + str(path.get("name") or script) + ("." + str(path["value"]) if path.get("value") else "")
|
|
if not isinstance(path, str) or "{" in path:
|
|
raise ValueError("Динамический адрес перехода Naninovel пока не поддерживается")
|
|
return "nani:" + (script + path if path.startswith(".") else path)
|
|
|
|
|
|
def compile_package(package) -> tuple[ScriptData, str, dict]:
|
|
data, expressions = ScriptData(), {}
|
|
counts, locations = Counter(), {}
|
|
|
|
def issue(message, location):
|
|
counts[message] += 1
|
|
locations.setdefault(message, location)
|
|
|
|
resource_sources = {}
|
|
for record in package.texts.values():
|
|
key = (record.get("collection"), record["key"], record["part"], record.get("occurrence", 0))
|
|
if key in resource_sources and resource_sources[key] != record["text"]:
|
|
issue(f"Неоднозначный исходный текст Naninovel: {key[0]}/{key[1]}", record["origin"])
|
|
resource_sources[key] = record["text"]
|
|
|
|
def text(value):
|
|
value = value.get("template", "") if isinstance(value, dict) else value
|
|
if not isinstance(value, str):
|
|
raise ValueError("Ожидался текст Naninovel")
|
|
for match in re.finditer(r"\{([^{}]+)\}", value):
|
|
expressions[match[1]] = nani_expr.parse(match[1])
|
|
# Rich text is parsed by the browser. Inline script commands must have
|
|
# been decoded as commands, rather than silently displayed as prose.
|
|
if re.search(r"\[(?:@|goto\b|set\b|wait\b|char\b|back\b)", value):
|
|
raise ValueError("Неподдерживаемая встроенная команда в тексте Naninovel")
|
|
for tag in re.findall(r"</?[A-Za-z][^<>]*>", value):
|
|
if not re.fullmatch(r"</?(?:b|i|u|s)>|<(?:color|size)=[^<>]+>|</(?:color|size)>|<br\s*/?>", tag, re.I):
|
|
raise ValueError(f"Не поддерживается текстовый тег Naninovel: {tag}")
|
|
return value
|
|
|
|
def static(value, name="параметр"):
|
|
if isinstance(value, dict) and "template" in value:
|
|
raise ValueError(f"Динамический {name} Naninovel пока не поддерживается")
|
|
return value
|
|
|
|
def expression(value):
|
|
ast = nani_expr.parse(static(value, "сценарное выражение"))
|
|
# PGVN uses Python truthiness; Naninovel also treats strings "false",
|
|
# "0" and "null" as false. Convert conditions inside the Nani evaluator.
|
|
return {"k": "nani", "ast": ["unary", "!", ["unary", "!", ast]]}
|
|
|
|
all_targets, all_names = [], set()
|
|
for script_name, script in package.scripts.items():
|
|
if (not re.fullmatch(r"[\w/.-]+", script_name) or script_name.startswith(("/", ".")) or
|
|
".." in script_name.split("/")):
|
|
issue("Некорректное имя сценария Naninovel", script_name)
|
|
continue
|
|
body, extras, stack = [], [], []
|
|
current = body
|
|
choice_count = 0
|
|
for line in script["lines"]:
|
|
location = f"{script_name}:{line['line']}"
|
|
if line.get("label"):
|
|
current.append({"op": "label", "name": f"nani:{script_name}.{line['label']}"})
|
|
for number, command in enumerate(line["commands"]):
|
|
op, args = command["op"], dict(command["args"])
|
|
identity = hashlib.sha256(f"{script_name}:{line['line']}:{number}".encode()).hexdigest()[:24]
|
|
try:
|
|
condition = args.pop("ConditionalExpression", None)
|
|
wait = args.pop("Wait", True)
|
|
if wait is not True and wait != 1:
|
|
raise ValueError("Асинхронные команды Naninovel (wait:false) пока не поддерживаются")
|
|
if op == "BeginIf":
|
|
node = {"op": "if", "branches": [{"c": expression(args.pop("Expression")), "body": []}]}
|
|
current.append(node)
|
|
stack.append((current, node))
|
|
current = node["branches"][0]["body"]
|
|
nodes = []
|
|
elif op in {"Else", "ElseIf"}:
|
|
if not stack or stack[-1][1]["branches"][-1]["c"] is None:
|
|
raise ValueError("Несогласованные ветки if/else Naninovel")
|
|
branch = {"c": expression(args.pop("Expression")) if op == "ElseIf" else None, "body": []}
|
|
stack[-1][1]["branches"].append(branch)
|
|
current = branch["body"]
|
|
nodes = []
|
|
elif op == "EndIf":
|
|
if not stack:
|
|
raise ValueError("EndIf без BeginIf")
|
|
current, _ = stack.pop()
|
|
nodes = []
|
|
elif op in {"PrintText", "AppendText"}:
|
|
author = text(args.pop("AuthorId", ""))
|
|
reset = args.pop("ResetPrinter", op != "AppendText")
|
|
interact = bool(args.pop("WaitForInput", True))
|
|
reveal = static(args.pop("RevealSpeed", 1), "RevealSpeed")
|
|
if reveal != 1:
|
|
raise ValueError("Изменение RevealSpeed Naninovel пока не поддерживается")
|
|
nodes = [{"op": "say", "what": text(args.pop("Text", "")), "tid": "nani-" + identity,
|
|
**({"whoStr": author} if reset else {"who": "extend"}), "interact": interact}]
|
|
elif op == "SetCustomVariable":
|
|
nodes = [{"op": "exec", "code": [nani_expr.assignment(static(args.pop("Expression"), "присваивание"))]}]
|
|
elif op in {"Goto", "Gosub"}:
|
|
destination = target(args.pop("Path"), script_name)
|
|
all_targets.append((destination, location))
|
|
nodes = [{"op": "jump" if op == "Goto" else "call", "target": destination}]
|
|
elif op == "Return":
|
|
nodes = [{"op": "return"}]
|
|
elif op == "AddChoice":
|
|
destination = args.pop("GotoPath", None)
|
|
actions = args.pop("OnSelected", [])
|
|
if actions:
|
|
raise ValueError("Обработчики OnSelected Naninovel пока не поддерживаются")
|
|
if not destination:
|
|
raise ValueError("Выбор Naninovel без GotoPath пока не поддерживается")
|
|
destination = target(destination, script_name)
|
|
all_targets.append((destination, location))
|
|
node = {"op": "nani_add_choice", "what": text(args.pop("ChoiceSummary", "")),
|
|
"tid": "nani-" + identity, "target": destination,
|
|
"handler": static(args.pop("HandlerId", ""))}
|
|
nodes = [node]
|
|
choice_count += 1
|
|
elif op == "Stop":
|
|
if not choice_count:
|
|
raise ValueError("Stop Naninovel требует внешнего интерфейса или механики игры")
|
|
nodes, choice_count = [{"op": "nani_menu"}], 0
|
|
elif op == "ClearChoiceHandler":
|
|
nodes = [{"op": "nani_clear_choice", "handler": static(args.pop("HandlerId", "*"))}]
|
|
elif op == "Wait":
|
|
mode = str(static(args.pop("WaitMode", "0"), "WaitMode"))
|
|
if mode.startswith("i"):
|
|
raise ValueError("Прерываемая пауза Naninovel пока не поддерживается")
|
|
nodes = [{"op": "pause", "d": {"k": "const", "v": float(mode)}}]
|
|
elif op == "WaitForInput":
|
|
nodes = [{"op": "pause", "d": None}]
|
|
elif op in {"HidePrinter", "ShowPrinter"}:
|
|
nodes = [{"op": "window", "mode": "hide" if op == "HidePrinter" else "show"}]
|
|
elif op in {"PlayBgm", "PlaySfx", "PlayVoice"}:
|
|
key = {"PlayBgm": "BgmPath", "PlaySfx": "SfxPath", "PlayVoice": "VoicePath"}[op]
|
|
path = static(args.pop(key), key)
|
|
nodes = [{"op": "play", "ch": {"PlayBgm": "music", "PlaySfx": "sound", "PlayVoice": "voice"}[op],
|
|
"files": [{"k": "const", "v": path}], "loop": args.pop("Loop", op == "PlayBgm")}]
|
|
elif op in {"StopBgm", "StopSfx"}:
|
|
nodes = [{"op": "stop", "ch": "music" if op == "StopBgm" else "sound"}]
|
|
elif op in {"ModifyBackground", "ModifyCharacter"}:
|
|
spec = static(args.pop("AppearanceAndTransition" if op == "ModifyBackground" else "IdAndAppearance"))
|
|
if not isinstance(spec, dict) or not spec.get("name"):
|
|
raise ValueError(f"Не поддерживается адрес изображения {op}")
|
|
actor = str(spec["name"])
|
|
appearance = spec.get("value")
|
|
if op == "ModifyBackground" and appearance:
|
|
raise ValueError("Специальный переход Naninovel пока не поддерживается")
|
|
image = actor if op == "ModifyBackground" else actor + "/" + str(appearance or "Default")
|
|
key = "nani-bg" if op == "ModifyBackground" else "nani-char-" + actor
|
|
name = key + " " + image
|
|
data.images[name] = {"file": image}
|
|
props = {"xalign": 0.5, "yalign": 1}
|
|
if "ScenePosition" in args:
|
|
position = static(args.pop("ScenePosition"), "ScenePosition")
|
|
if not isinstance(position, list) or len(position) != 2:
|
|
raise ValueError("Неподдерживаемая позиция Naninovel")
|
|
props = {"xalign": position[0] / 100, "yalign": 1 - position[1] / 100}
|
|
duration = float(static(args.pop("Duration", 0), "Duration"))
|
|
nodes = [{"op": "show", "as": key, "img": {"name": name.split(" ")},
|
|
"at": [{"props": props}], "zorder": {"k": "const", "v": 0 if op == "ModifyBackground" else 10},
|
|
"with": {"t": "dissolve", "d": duration} if duration else None}]
|
|
elif op == "HideAllCharacters":
|
|
duration = float(static(args.pop("Duration", 0), "Duration"))
|
|
# The player preserves backgrounds and other image layers.
|
|
nodes = [{"op": "nani_hide_characters", "duration": duration}]
|
|
elif op == "ExitToTitle":
|
|
nodes = [{"op": "nani_exit"}]
|
|
else:
|
|
raise ValueError(f"Не поддерживается команда Naninovel: {op}")
|
|
if args:
|
|
raise ValueError(f"Не поддерживаются параметры {op}: {', '.join(sorted(args))}")
|
|
if condition is not None:
|
|
if not nodes:
|
|
raise ValueError("Условие на структурной команде Naninovel")
|
|
nodes = [{"op": "if", "branches": [{"c": expression(condition), "body": nodes}]}]
|
|
current.extend(nodes)
|
|
except (ValueError, KeyError, TypeError) as exc:
|
|
issue(str(exc), location)
|
|
if stack:
|
|
issue("Не закрыт BeginIf Naninovel", script_name)
|
|
labels = [{"name": "nani:" + script_name, "body": body}, *extras]
|
|
for label in labels:
|
|
all_names.add(label["name"])
|
|
def names(nodes):
|
|
for node in nodes:
|
|
if node["op"] == "label":
|
|
all_names.add(node["name"])
|
|
for branch in node.get("branches", []):
|
|
names(branch["body"])
|
|
names(label["body"])
|
|
data.modules[script_name + ".nani"] = labels
|
|
for destination, location in all_targets:
|
|
if destination not in all_names:
|
|
issue(f"Не найдена цель Naninovel: {destination}", location)
|
|
entry = next(("nani:" + name for name in ("Opening", "Main", "Start") if name in package.scripts), None)
|
|
if entry is None:
|
|
issue("Не определена точка входа Naninovel (Opening/Main/Start)", "")
|
|
# Init may include external functions or engine UI setup. Do not discard it.
|
|
if "Init" in package.scripts:
|
|
data.init_code = []
|
|
issue("Инициализация Naninovel требует отдельного адаптера Init", "Init")
|
|
issues = package.issues + [Issue("naninovel_command", msg, locations[msg], count)
|
|
for msg, count in counts.items()]
|
|
if issues:
|
|
raise CompatibilityError("naninovel", issues)
|
|
resources = []
|
|
for tid, record in package.texts.items():
|
|
resources.append({"op": "say", "what": record["text"], "tid": tid,
|
|
"whoStr": record["origin"], "resource": record})
|
|
if resources:
|
|
data.modules["resources.nani"] = [{"name": "nani:resources", "body": resources}]
|
|
return data, entry, expressions
|