"""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"]*>", value): if not re.fullmatch(r"|<(?:color|size)=[^<>]+>||", 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