feat: add native Tomie adapter and strict import compatibility checks
This commit is contained in:
1 parent
c8d83b3fe7
commit
57a4edcbfd
45 files changed
+2976
-62
No files matched your search
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||||
|
|
||||||
|
Пакетный импорт коллекции, строгая проверка совместимости и текущие ограничения
|
||||||
|
Naninovel описаны в [docs/IMPORT.md](docs/IMPORT.md).
|
||||||
|
|
||||||
Редактор модов, слияние, перенос на новую версию, ревизии и сборщик мусора —
|
Редактор модов, слияние, перенос на новую версию, ревизии и сборщик мусора —
|
||||||
в [docs/MODDING.md](docs/MODDING.md).
|
в [docs/MODDING.md](docs/MODDING.md).
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.converter.rpa import RpaError, extract_rpa, read_index
|
|||||||
from app.converter.rpy_lexer import ParseError
|
from app.converter.rpy_lexer import ParseError
|
||||||
from app.converter.rpy_parser import RpyParser, ScriptData
|
from app.converter.rpy_parser import RpyParser, ScriptData
|
||||||
from app.converter.rpyc import decompile_missing
|
from app.converter.rpyc import decompile_missing
|
||||||
|
from app.converter.source import read_source
|
||||||
|
|
||||||
SCRIPT_EXTS = (".rpy", ".rpyc", ".rpym", ".rpymc")
|
SCRIPT_EXTS = (".rpy", ".rpyc", ".rpym", ".rpymc")
|
||||||
|
|
||||||
@@ -73,9 +74,9 @@ def analyze(src: Path, dump: Path | None = None) -> dict:
|
|||||||
lines = 0
|
lines = 0
|
||||||
for p in rpy:
|
for p in rpy:
|
||||||
rel = p.relative_to(work).as_posix()
|
rel = p.relative_to(work).as_posix()
|
||||||
text = p.read_text(encoding="utf-8", errors="replace")
|
|
||||||
lines += text.count("\n")
|
|
||||||
try:
|
try:
|
||||||
|
text = read_source(p)
|
||||||
|
lines += text.count("\n")
|
||||||
RpyParser(data, rel).parse(text)
|
RpyParser(data, rel).parse(text)
|
||||||
except ParseError as e:
|
except ParseError as e:
|
||||||
data.warn(f"Ошибка разбора файла: {e.msg}", f"{e.filename}:{e.lineno}")
|
data.warn(f"Ошибка разбора файла: {e.msg}", f"{e.filename}:{e.lineno}")
|
||||||
@@ -104,7 +105,7 @@ def analyze(src: Path, dump: Path | None = None) -> dict:
|
|||||||
media = Counter(assets.kind_of(Path(v)) for v in files.values() if assets.kind_of(Path(v)))
|
media = Counter(assets.kind_of(Path(v)) for v in files.values() if assets.kind_of(Path(v)))
|
||||||
return {
|
return {
|
||||||
"issues": [asdict(Issue("unsupported_script", msg,
|
"issues": [asdict(Issue("unsupported_script", msg,
|
||||||
data.warning_examples.get(msg, ""), count))
|
data.blocker_examples.get(msg, ""), count))
|
||||||
for msg, count in data.blockers.items()] +
|
for msg, count in data.blockers.items()] +
|
||||||
[asdict(Issue("archive_error", msg)) for msg in rpa_errors],
|
[asdict(Issue("archive_error", msg)) for msg in rpa_errors],
|
||||||
"game": src.name,
|
"game": src.name,
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ def detect(root: Path) -> Detection:
|
|||||||
|
|
||||||
|
|
||||||
def require_supported_scripts(data, engine: str = "renpy") -> None:
|
def require_supported_scripts(data, engine: str = "renpy") -> None:
|
||||||
issues = [Issue("unsupported_script", message, data.warning_examples.get(message, ""), count)
|
issues = [Issue("unsupported_script", message, data.blocker_examples.get(message, ""), count)
|
||||||
for message, count in data.blockers.items()]
|
for message, count in data.blockers.items()]
|
||||||
if issues:
|
if issues:
|
||||||
raise CompatibilityError(engine, issues)
|
raise CompatibilityError(engine, issues)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
|
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
|
||||||
|
from app.converter.source import read_source
|
||||||
|
|
||||||
# Ren'Py language names -> BCP-47
|
# Ren'Py language names -> BCP-47
|
||||||
RENPY_LANGS = {
|
RENPY_LANGS = {
|
||||||
@@ -141,14 +142,14 @@ def import_tl(game_dir: Path, modules: dict[str, list[dict[str, Any]]],
|
|||||||
loc = LocaleData(renpy=lang_dir.name)
|
loc = LocaleData(renpy=lang_dir.name)
|
||||||
for f in sorted(lang_dir.rglob("*.rpy")):
|
for f in sorted(lang_dir.rglob("*.rpy")):
|
||||||
try:
|
try:
|
||||||
_read_tl_file(f.read_text(encoding="utf-8", errors="replace"), loc, by_line,
|
_read_tl_file(read_source(f), loc, by_line,
|
||||||
by_group)
|
by_group)
|
||||||
except ParseError as e:
|
except ParseError as e:
|
||||||
warn(f"Перевод {lang_dir.name}: ошибка разбора", f"{f.name}:{e.lineno}")
|
warn(f"Перевод {lang_dir.name}: ошибка разбора", f"{f.name}:{e.lineno}")
|
||||||
if loc.lines or loc.strings:
|
if loc.lines or loc.strings:
|
||||||
result[lang_code(lang_dir.name)] = loc
|
result[lang_code(lang_dir.name)] = loc
|
||||||
if loc.unmatched:
|
if loc.unmatched:
|
||||||
warn(f"Перевод {lang_dir.name}: реплики без пары в скрипте ({loc.unmatched})")
|
warn(f"Перевод {lang_dir.name}: реплики без пары в скрипте ({loc.unmatched})", blocking=False)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from app.converter.rpy_parser import ScriptData
|
|||||||
|
|
||||||
def target(path, script: str) -> str:
|
def target(path, script: str) -> str:
|
||||||
if isinstance(path, dict):
|
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 "")
|
return "nani:" + str(path.get("name") or script) + ("." + str(path["value"]) if path.get("value") else "")
|
||||||
if not isinstance(path, str) or "{" in path:
|
if not isinstance(path, str) or "{" in path:
|
||||||
raise ValueError("Динамический адрес перехода Naninovel пока не поддерживается")
|
raise ValueError("Динамический адрес перехода Naninovel пока не поддерживается")
|
||||||
@@ -24,6 +26,13 @@ def compile_package(package) -> tuple[ScriptData, str, dict]:
|
|||||||
counts[message] += 1
|
counts[message] += 1
|
||||||
locations.setdefault(message, location)
|
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):
|
def text(value):
|
||||||
value = value.get("template", "") if isinstance(value, dict) else value
|
value = value.get("template", "") if isinstance(value, dict) else value
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
@@ -45,7 +54,10 @@ def compile_package(package) -> tuple[ScriptData, str, dict]:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
def expression(value):
|
def expression(value):
|
||||||
return nani_expr.expr(static(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()
|
all_targets, all_names = [], set()
|
||||||
for script_name, script in package.scripts.items():
|
for script_name, script in package.scripts.items():
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Parse Naninovel expressions into data, never Python/JavaScript source code."""
|
"""Parse Naninovel expressions into data, never Python/JavaScript source code."""
|
||||||
import re
|
import re
|
||||||
|
import math
|
||||||
|
|
||||||
TOKEN = re.compile(r'''\s*(?:(\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\.\d+)|([A-Za-z_]\w*)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(&&|\|\||==|!=|<>|<=|>=|<<|>>|[()+\-*/%<>=!~?:,&|^]))''')
|
TOKEN = re.compile(r'''\s*(?:(\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\.\d+)|([A-Za-z_]\w*)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(&&|\|\||==|!=|<>|<=|>=|<<|>>|[()+\-*/%<>=!~?:,&|^]))''')
|
||||||
PRECEDENCE = {"or": 2, "||": 2, "and": 3, "&&": 3, "|": 4, "^": 5,
|
PRECEDENCE = {"or": 2, "||": 2, "and": 3, "&&": 3, "|": 4, "^": 5,
|
||||||
@@ -7,12 +8,41 @@ PRECEDENCE = {"or": 2, "||": 2, "and": 3, "&&": 3, "|": 4, "^": 5,
|
|||||||
"<=": 8, ">=": 8, "<<": 9, ">>": 9, "+": 10, "-": 10,
|
"<=": 8, ">=": 8, "<<": 9, ">>": 9, "+": 10, "-": 10,
|
||||||
"*": 11, "/": 11, "%": 11}
|
"*": 11, "/": 11, "%": 11}
|
||||||
FUNCTIONS = {"Random", "random", "Rand", "GetRandValue", "Min", "Max", "Abs", "Round", "Pow"}
|
FUNCTIONS = {"Random", "random", "Rand", "GetRandValue", "Min", "Max", "Abs", "Round", "Pow"}
|
||||||
|
ARITY = {"Random": (1, 2), "random": (1, 2), "Rand": (1, 2), "GetRandValue": (1, 1),
|
||||||
|
"Min": (1, 100), "Max": (1, 100), "Abs": (1, 1), "Round": (1, 2), "Pow": (2, 2)}
|
||||||
|
|
||||||
|
|
||||||
def parse(source: str, functions: set[str] | None = None) -> list:
|
def parse(source: str, functions: set[str] | None = None) -> list:
|
||||||
source = source.strip().removesuffix(";").strip()
|
source = source.strip().removesuffix(";").strip()
|
||||||
tokens, offset = [], 0
|
tokens, offset = [], 0
|
||||||
while offset < len(source):
|
while offset < len(source):
|
||||||
|
if source[offset].isspace():
|
||||||
|
offset += 1
|
||||||
|
continue
|
||||||
|
wrapped = source[offset] == "\\" and offset + 1 < len(source) and source[offset + 1] in "\"'"
|
||||||
|
if source[offset] in "\"'" or wrapped:
|
||||||
|
quote = source[offset + 1] if wrapped else source[offset]
|
||||||
|
offset += 2 if wrapped else 1
|
||||||
|
value, closed = [], False
|
||||||
|
while offset < len(source):
|
||||||
|
char = source[offset]
|
||||||
|
if wrapped and char == "\\" and offset + 1 < len(source) and source[offset + 1] == quote:
|
||||||
|
offset += 2
|
||||||
|
closed = True
|
||||||
|
break
|
||||||
|
if not wrapped and char == quote:
|
||||||
|
offset += 1
|
||||||
|
closed = True
|
||||||
|
break
|
||||||
|
if char == "\\" and offset + 1 < len(source):
|
||||||
|
offset += 1
|
||||||
|
char = {"n": "\n", "r": "\r", "t": "\t"}.get(source[offset], source[offset])
|
||||||
|
value.append(char)
|
||||||
|
offset += 1
|
||||||
|
if not closed:
|
||||||
|
raise ValueError("Не закрыта строка в выражении Naninovel")
|
||||||
|
tokens.append((2, "".join(value)))
|
||||||
|
continue
|
||||||
match = TOKEN.match(source, offset)
|
match = TOKEN.match(source, offset)
|
||||||
if not match:
|
if not match:
|
||||||
raise ValueError(f"Не поддерживается выражение Naninovel: {source[offset:offset + 60]}")
|
raise ValueError(f"Не поддерживается выражение Naninovel: {source[offset:offset + 60]}")
|
||||||
@@ -23,7 +53,7 @@ def parse(source: str, functions: set[str] | None = None) -> list:
|
|||||||
allowed = FUNCTIONS if functions is None else functions
|
allowed = FUNCTIONS if functions is None else functions
|
||||||
|
|
||||||
def peek():
|
def peek():
|
||||||
return tokens[position][1] if position < len(tokens) else ""
|
return ("\x00string" if tokens[position][0] == 2 else tokens[position][1]) if position < len(tokens) else ""
|
||||||
|
|
||||||
def take(expected=None):
|
def take(expected=None):
|
||||||
nonlocal position
|
nonlocal position
|
||||||
@@ -35,22 +65,23 @@ def parse(source: str, functions: set[str] | None = None) -> list:
|
|||||||
|
|
||||||
def expression(minimum=1):
|
def expression(minimum=1):
|
||||||
kind, token = take()
|
kind, token = take()
|
||||||
if token in {"-", "+", "!", "not", "~"}:
|
if kind != 2 and token in {"-", "+", "!", "not", "~"}:
|
||||||
left = ["unary", token, expression(12)]
|
left = ["unary", token, expression(12)]
|
||||||
elif token == "(":
|
elif kind == 3 and token == "(":
|
||||||
left = expression()
|
left = expression()
|
||||||
take(")")
|
take(")")
|
||||||
elif kind == 0:
|
elif kind == 0:
|
||||||
left = ["const", float(token) if any(c in token for c in ".eE") else int(token)]
|
value = float(token) if any(c in token for c in ".eE") else int(token)
|
||||||
|
if not math.isfinite(value):
|
||||||
|
raise ValueError("Неконечная числовая константа Naninovel")
|
||||||
|
left = ["const", value]
|
||||||
elif kind == 2:
|
elif kind == 2:
|
||||||
# Decode only conventional escapes; preserve all Unicode prose.
|
left = ["const", token]
|
||||||
left = ["const", re.sub(r"\\([\\\"'nrt])", lambda m:
|
|
||||||
{"n": "\n", "r": "\r", "t": "\t"}.get(m[1], m[1]), token[1:-1])]
|
|
||||||
elif kind == 1:
|
elif kind == 1:
|
||||||
if token.lower() in {"true", "false", "null"}:
|
if token.lower() in {"true", "false", "null"}:
|
||||||
left = ["const", {"true": True, "false": False, "null": None}[token.lower()]]
|
left = ["const", {"true": True, "false": False, "null": None}[token.lower()]]
|
||||||
elif peek() == "(":
|
elif peek() == "(":
|
||||||
if token not in allowed:
|
if token not in allowed and token.lower() != "if":
|
||||||
raise ValueError(f"Не поддерживается функция Naninovel: {token}")
|
raise ValueError(f"Не поддерживается функция Naninovel: {token}")
|
||||||
take("(")
|
take("(")
|
||||||
args = []
|
args = []
|
||||||
@@ -61,6 +92,13 @@ def parse(source: str, functions: set[str] | None = None) -> list:
|
|||||||
break
|
break
|
||||||
take(",")
|
take(",")
|
||||||
take(")")
|
take(")")
|
||||||
|
if functions is None and token in ARITY and not ARITY[token][0] <= len(args) <= ARITY[token][1]:
|
||||||
|
raise ValueError(f"Неподдерживаемое число аргументов Naninovel: {token}")
|
||||||
|
if token.lower() == "if":
|
||||||
|
if len(args) != 3:
|
||||||
|
raise ValueError("If Naninovel требует три аргумента")
|
||||||
|
left = ["if", *args]
|
||||||
|
else:
|
||||||
left = ["call", token, args]
|
left = ["call", token, args]
|
||||||
else:
|
else:
|
||||||
left = ["name", token]
|
left = ["name", token]
|
||||||
@@ -82,12 +120,15 @@ def parse(source: str, functions: set[str] | None = None) -> list:
|
|||||||
return ast
|
return ast
|
||||||
|
|
||||||
|
|
||||||
def expr(source: str) -> dict:
|
def expr(source: str, functions: set[str] | None = None) -> dict:
|
||||||
return {"k": "nani", "ast": parse(source)}
|
return {"k": "nani", "ast": parse(source, functions)}
|
||||||
|
|
||||||
|
|
||||||
def assignment(source: str) -> dict:
|
def assignment(source: str, functions: set[str] | None = None) -> dict:
|
||||||
match = re.fullmatch(r"\s*([A-Za-z_]\w*)\s*=(?!=)(.*?)\s*;?\s*", source, re.S)
|
match = re.fullmatch(r"\s*([A-Za-z_]\w*)\s*([+\-*/%&|^]?)=(?!=)(.*?)\s*;?\s*", source, re.S)
|
||||||
if not match:
|
if not match:
|
||||||
raise ValueError(f"Не поддерживается присваивание Naninovel: {source}")
|
raise ValueError(f"Не поддерживается присваивание Naninovel: {source}")
|
||||||
return {"op": "set", "t": {"k": "name", "id": match[1]}, "v": expr(match[2])}
|
ast = parse(match[3], functions)
|
||||||
|
if match[2]:
|
||||||
|
ast = ["bin", match[2], ["name", match[1]], ast]
|
||||||
|
return {"op": "set", "t": {"k": "name", "id": match[1]}, "v": {"k": "nani", "ast": ast}}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from app.converter.compatibility import CompatibilityError, Issue
|
from app.converter.compatibility import CompatibilityError, Issue
|
||||||
@@ -14,6 +15,33 @@ class Package:
|
|||||||
texts: dict = field(default_factory=dict)
|
texts: dict = field(default_factory=dict)
|
||||||
resources: list = field(default_factory=list)
|
resources: list = field(default_factory=list)
|
||||||
issues: list = field(default_factory=list)
|
issues: list = field(default_factory=list)
|
||||||
|
environments: list = field(default_factory=list, repr=False)
|
||||||
|
profile: str | None = None
|
||||||
|
defaults: dict = field(default_factory=dict)
|
||||||
|
function_data: dict = field(default_factory=dict)
|
||||||
|
actor_resources: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
# UnityPy readers can retain OS file handles through cyclic references.
|
||||||
|
# Explicitly close the owned readers before deleting a workspace on Windows.
|
||||||
|
seen = set()
|
||||||
|
def close(value):
|
||||||
|
if id(value) in seen:
|
||||||
|
return
|
||||||
|
seen.add(id(value))
|
||||||
|
stream = getattr(value, "stream", None)
|
||||||
|
if stream is not None:
|
||||||
|
stream.close()
|
||||||
|
reader = getattr(value, "reader", None)
|
||||||
|
if reader is not None:
|
||||||
|
close(reader)
|
||||||
|
for field in ("files", "cabs"):
|
||||||
|
for child in (getattr(value, field, None) or {}).values():
|
||||||
|
close(child)
|
||||||
|
for environment in self.environments:
|
||||||
|
close(environment)
|
||||||
|
self.resources.clear()
|
||||||
|
self.environments.clear()
|
||||||
|
|
||||||
|
|
||||||
def simplify(value):
|
def simplify(value):
|
||||||
@@ -60,7 +88,7 @@ def decode_script(tree: dict) -> dict:
|
|||||||
|
|
||||||
def text_records(name: str, text: str, origin: str) -> dict:
|
def text_records(name: str, text: str, origin: str) -> dict:
|
||||||
"""Addressable entries; delimiters and keys remain outside translatable prose."""
|
"""Addressable entries; delimiters and keys remain outside translatable prose."""
|
||||||
records, keys = {}, set()
|
records, keys = {}, {}
|
||||||
for number, line in enumerate(text.splitlines(), 1):
|
for number, line in enumerate(text.splitlines(), 1):
|
||||||
if not line.strip() or line.lstrip().startswith((";", "#", "//")):
|
if not line.strip() or line.lstrip().startswith((";", "#", "//")):
|
||||||
continue
|
continue
|
||||||
@@ -78,30 +106,45 @@ def text_records(name: str, text: str, origin: str) -> dict:
|
|||||||
values, kind = value.split("|") if name == "Tips" else [value], "dictionary"
|
values, kind = value.split("|") if name == "Tips" else [value], "dictionary"
|
||||||
else:
|
else:
|
||||||
key, values, kind = str(number), [line], "template"
|
key, values, kind = str(number), [line], "template"
|
||||||
if key.strip() in keys:
|
occurrence = keys.get(key.strip(), 0)
|
||||||
raise ValueError(f"Повторяющийся ключ локализации: {origin}:{key.strip()}")
|
keys[key.strip()] = occurrence + 1
|
||||||
keys.add(key.strip())
|
|
||||||
for index, value in enumerate(values):
|
for index, value in enumerate(values):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
continue
|
continue
|
||||||
address = f"{origin}:{key.strip()}:{index}"
|
address = f"{origin}:{key.strip()}:{index}"
|
||||||
|
if occurrence:
|
||||||
|
address += f":occurrence-{occurrence}"
|
||||||
tid = "nani-resource-" + hashlib.sha256(address.encode()).hexdigest()[:24]
|
tid = "nani-resource-" + hashlib.sha256(address.encode()).hexdigest()[:24]
|
||||||
records[tid] = {"text": value, "kind": kind, "origin": origin, "key": key.strip(),
|
records[tid] = {"text": value, "kind": kind, "collection": name, "origin": origin, "key": key.strip(),
|
||||||
"part": index, "line": number,
|
"part": index, "occurrence": occurrence, "line": number,
|
||||||
"delimiters": ",=\r\n" if kind == "synonym" else
|
"delimiters": ",=\r\n" if kind == "synonym" else
|
||||||
"|\r\n" if name == "Tips" else "\r\n"}
|
"|\r\n" if name == "Tips" else "\r\n"}
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
def read_package(root: Path, work: Path, *, media: bool = False) -> Package:
|
def read_package(root: Path, work: Path, *, media: bool = False) -> Package:
|
||||||
|
package = Package()
|
||||||
|
try:
|
||||||
|
return _read_package(root, work, package, media=media)
|
||||||
|
except BaseException:
|
||||||
|
package.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _read_package(root: Path, work: Path, package: Package, *, media: bool) -> Package:
|
||||||
import UnityPy
|
import UnityPy
|
||||||
from app.converter.unity_scripts import ScriptCodec, script_objects
|
from app.converter.unity_scripts import ScriptCodec, script_objects
|
||||||
|
from app.converter.tomie_profile import identify, extract_tables
|
||||||
|
|
||||||
|
package.profile = identify(root)
|
||||||
|
if package.profile:
|
||||||
|
package.function_data = extract_tables(root)
|
||||||
|
|
||||||
package = Package()
|
|
||||||
data_roots = [p for p in root.iterdir() if p.is_dir() and p.name.lower().endswith("_data")]
|
data_roots = [p for p in root.iterdir() if p.is_dir() and p.name.lower().endswith("_data")]
|
||||||
if len(data_roots) != 1:
|
if len(data_roots) != 1:
|
||||||
raise CompatibilityError("naninovel", [Issue("unity_layout", "Неоднозначный каталог Unity Data")])
|
raise CompatibilityError("naninovel", [Issue("unity_layout", "Неоднозначный каталог Unity Data")])
|
||||||
data = data_roots[0]
|
data = data_roots[0]
|
||||||
|
stream_names = {path.stem for path in (data / "StreamingAssets" / "text").glob("*.txt")}
|
||||||
sources = sorted(data.glob("*.assets")) + sorted(data.glob("*.unity3d"))
|
sources = sorted(data.glob("*.assets")) + sorted(data.glob("*.unity3d"))
|
||||||
sources += sorted((data / "StreamingAssets").rglob("*.bundle"))
|
sources += sorted((data / "StreamingAssets").rglob("*.bundle"))
|
||||||
codec = None
|
codec = None
|
||||||
@@ -114,6 +157,7 @@ def read_package(root: Path, work: Path, *, media: bool = False) -> Package:
|
|||||||
if path.name.endswith((".resource", ".resS")):
|
if path.name.endswith((".resource", ".resS")):
|
||||||
continue
|
continue
|
||||||
environment = UnityPy.load(str(path))
|
environment = UnityPy.load(str(path))
|
||||||
|
package.environments.append(environment)
|
||||||
for stream in data.glob("*.resource"):
|
for stream in data.glob("*.resource"):
|
||||||
environment.load_file(str(stream))
|
environment.load_file(str(stream))
|
||||||
for obj in script_objects(environment):
|
for obj in script_objects(environment):
|
||||||
@@ -123,14 +167,55 @@ def read_package(root: Path, work: Path, *, media: bool = False) -> Package:
|
|||||||
decoded = decode_script(codec.read(obj))
|
decoded = decode_script(codec.read(obj))
|
||||||
name = decoded["name"]
|
name = decoded["name"]
|
||||||
previous = package.scripts.get(name)
|
previous = package.scripts.get(name)
|
||||||
|
if previous is not None and previous != decoded and name == "Init" and package.profile:
|
||||||
|
# This pinned Tomie build contains an editor/addressable
|
||||||
|
# Init alongside the main corpus. The recovered port uses
|
||||||
|
# the main ResourceManager copy; it is read first here.
|
||||||
|
continue
|
||||||
if previous is not None and previous != decoded:
|
if previous is not None and previous != decoded:
|
||||||
package.issues.append(Issue("conflicting_script", "Различающиеся копии сценария", name))
|
package.issues.append(Issue("conflicting_script", "Различающиеся копии сценария", name))
|
||||||
else:
|
else:
|
||||||
package.scripts[name] = decoded
|
package.scripts[name] = decoded
|
||||||
for obj in environment.objects:
|
for obj in environment.objects:
|
||||||
if obj.type.name == "TextAsset":
|
if obj.type.name == "ResourceManager" and package.profile and media:
|
||||||
|
from UnityPy.classes import PPtr
|
||||||
|
actor_keys = set()
|
||||||
|
for key, pointer in obj.read_typetree()["m_Container"]:
|
||||||
|
if not key.lower().startswith("characters/"):
|
||||||
|
continue
|
||||||
|
name = key.lower().removeprefix("characters/")
|
||||||
|
actor_keys.add(name)
|
||||||
|
actor = PPtr(assetsfile=obj.assets_file, **pointer).deref()
|
||||||
|
# Unity exposes both the complete texture and its
|
||||||
|
# Sprite under the same Resources key. Naninovel's
|
||||||
|
# texture actor uses the complete texture, as does
|
||||||
|
# the recovered Godot port.
|
||||||
|
if actor.type.name == "Sprite":
|
||||||
|
continue
|
||||||
|
if actor.type.name != "Texture2D":
|
||||||
|
package.issues.append(Issue("unsupported_actor", "Не поддерживается тип персонажа Tomie", key))
|
||||||
|
continue
|
||||||
|
identity = f"{source.relative_to(root).as_posix()}:{actor.assets_file.name}:{actor.path_id}"
|
||||||
|
previous = package.actor_resources.get(name)
|
||||||
|
if previous and previous != identity:
|
||||||
|
package.issues.append(Issue("conflicting_actor", "Различающиеся изображения персонажа Tomie", key))
|
||||||
|
package.actor_resources[name] = identity
|
||||||
|
package.resources.append((source, actor))
|
||||||
|
for name in actor_keys - package.actor_resources.keys():
|
||||||
|
package.issues.append(Issue("missing_actor_texture", "Не найдена полная текстура персонажа Tomie", name))
|
||||||
|
elif obj.type.name == "MonoBehaviour" and package.profile and obj.peek_name() == "CustomVariablesConfiguration":
|
||||||
|
if codec is None:
|
||||||
|
codec = ScriptCodec(root, str(obj.assets_file.unity_version))
|
||||||
|
node = obj.serialized_type.node or codec.node("Elringus.Naninovel.Runtime", "Naninovel.CustomVariablesConfiguration")
|
||||||
|
config = obj.read_typetree(nodes=node)
|
||||||
|
defaults = {entry["name"]: json.loads(entry["value"]) for entry in config["PredefinedVariables"]}
|
||||||
|
if package.defaults and package.defaults != defaults:
|
||||||
|
package.issues.append(Issue("conflicting_defaults", "Различающиеся исходные переменные Naninovel"))
|
||||||
|
else:
|
||||||
|
package.defaults = defaults
|
||||||
|
elif obj.type.name == "TextAsset":
|
||||||
asset = obj.read()
|
asset = obj.read()
|
||||||
if asset.m_Name in {"DefaultUI", "Tips", "CharacterNames", "Inventory"}:
|
if asset.m_Name in {"DefaultUI", "Tips", "CharacterNames", "Inventory"} | stream_names:
|
||||||
origin = f"{source.relative_to(root).as_posix()}/{obj.path_id}/{asset.m_Name}"
|
origin = f"{source.relative_to(root).as_posix()}/{obj.path_id}/{asset.m_Name}"
|
||||||
package.texts.update(text_records(asset.m_Name, asset.m_Script, origin))
|
package.texts.update(text_records(asset.m_Name, asset.m_Script, origin))
|
||||||
elif media and obj.type.name in {"Texture2D", "AudioClip", "Font", "VideoClip"}:
|
elif media and obj.type.name in {"Texture2D", "AudioClip", "Font", "VideoClip"}:
|
||||||
|
|||||||
@@ -16,12 +16,22 @@ def inspect_naninovel(root: Path, *, work: Path | None = None) -> dict:
|
|||||||
with tempfile.TemporaryDirectory(prefix="pgames-naninovel-") as directory:
|
with tempfile.TemporaryDirectory(prefix="pgames-naninovel-") as directory:
|
||||||
return inspect_naninovel(root, work=Path(directory))
|
return inspect_naninovel(root, work=Path(directory))
|
||||||
package = read_package(root, work, media=True)
|
package = read_package(root, work, media=True)
|
||||||
|
try:
|
||||||
|
if package.profile:
|
||||||
|
from app.converter.tomie_format import prepare
|
||||||
|
native = prepare(package)
|
||||||
|
return {"engine": "naninovel", "profile": package.profile, "compatible": True,
|
||||||
|
"scripts": len(native["scripts"]), "textResources": len(package.texts),
|
||||||
|
"commands": sum(len(script["commands"]) for script in native["scripts"].values()),
|
||||||
|
"warnings": {}}
|
||||||
data, entry, _ = compile_package(package)
|
data, entry, _ = compile_package(package)
|
||||||
resolve_media(package, data)
|
resolve_media(package, data)
|
||||||
return {"engine": "naninovel", "compatible": True, "scripts": len(package.scripts),
|
return {"engine": "naninovel", "compatible": True, "scripts": len(package.scripts),
|
||||||
"textResources": len(package.texts), "entry": entry,
|
"textResources": len(package.texts), "entry": entry,
|
||||||
"commands": sum(len(line["commands"]) for script in package.scripts.values() for line in script["lines"]),
|
"commands": sum(len(line["commands"]) for script in package.scripts.values() for line in script["lines"]),
|
||||||
"warnings": dict(data.warnings)}
|
"warnings": dict(data.warnings)}
|
||||||
|
finally:
|
||||||
|
package.close()
|
||||||
|
|
||||||
|
|
||||||
def resolve_media(package, data):
|
def resolve_media(package, data):
|
||||||
@@ -99,10 +109,20 @@ def export_media(package, data, root: Path, work: Path, progress):
|
|||||||
|
|
||||||
|
|
||||||
def convert_naninovel(root: Path, work: Path, progress):
|
def convert_naninovel(root: Path, work: Path, progress):
|
||||||
from app.converter.pipeline import Result, anchors
|
|
||||||
|
|
||||||
progress("Чтение сценариев Naninovel и проверка совместимости")
|
progress("Чтение сценариев Naninovel и проверка совместимости")
|
||||||
package = read_package(root, work / "unity", media=True)
|
package = read_package(root, work / "unity", media=True)
|
||||||
|
try:
|
||||||
|
return _convert_package(root, work, progress, package)
|
||||||
|
finally:
|
||||||
|
package.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_package(root: Path, work: Path, progress, package):
|
||||||
|
from app.converter.pipeline import Result, anchors
|
||||||
|
|
||||||
|
if package.profile:
|
||||||
|
return _convert_tomie(root, work, progress, package)
|
||||||
|
|
||||||
data, entry, expressions = compile_package(package)
|
data, entry, expressions = compile_package(package)
|
||||||
# No media is decoded or written to the blob store before the script gate.
|
# No media is decoded or written to the blob store before the script gate.
|
||||||
files, aliases = export_media(package, data, root, work, progress)
|
files, aliases = export_media(package, data, root, work, progress)
|
||||||
@@ -119,14 +139,53 @@ def convert_naninovel(root: Path, work: Path, progress):
|
|||||||
store(logical, {"file": path, "labels": labels, "anchors": anchors(labels)})
|
store(logical, {"file": path, "labels": labels, "anchors": anchors(labels)})
|
||||||
scripts.append(logical)
|
scripts.append(logical)
|
||||||
source = locales.detect_language(data.modules) or "und"
|
source = locales.detect_language(data.modules) or "und"
|
||||||
|
resources = {}
|
||||||
|
for tid, record in package.texts.items():
|
||||||
|
resources.setdefault(record["collection"], {}).setdefault(record["key"], []).append({"tid": tid, **record})
|
||||||
game = {"format": "pgvn/1", "engine": "naninovel", "title": root.name,
|
game = {"format": "pgvn/1", "engine": "naninovel", "title": root.name,
|
||||||
"resolution": [1920, 1080], "entry": entry, "scripts": scripts,
|
"resolution": [1920, 1080], "entry": entry, "scripts": scripts,
|
||||||
"characters": data.characters, "images": data.images, "transforms": {},
|
"characters": data.characters, "images": data.images, "transforms": {},
|
||||||
"transitions": {}, "audio": {}, "files": aliases, "defaults": [], "init": [],
|
"transitions": {}, "audio": {}, "files": aliases, "defaults": [], "init": [],
|
||||||
"ui": {}, "sourceLang": source, "locales": {}, "naniExpressions": expressions}
|
"ui": {}, "sourceLang": source, "locales": {}, "naniExpressions": expressions,
|
||||||
|
"naniResources": resources}
|
||||||
store("game.json", game)
|
store("game.json", game)
|
||||||
total = sum(entry["size"] for entry in files.values())
|
total = sum(entry["size"] for entry in files.values())
|
||||||
report = {"engine": "naninovel", "compatible": True, "format": "pgvn/1",
|
report = {"engine": "naninovel", "compatible": True, "format": "pgvn/1",
|
||||||
"scripts": len(package.scripts), "textResources": len(package.texts),
|
"scripts": len(package.scripts), "textResources": len(package.texts),
|
||||||
"media": len(aliases), "warnings": []}
|
"media": len(aliases), "warnings": []}
|
||||||
return Result({"format": "pgvn/1", "files": files}, report, root.name, None, total)
|
return Result({"format": "pgvn/1", "files": files}, report, root.name, None, total)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_tomie(root, work, progress, package):
|
||||||
|
from app.converter.pipeline import Result, anchors
|
||||||
|
from app.converter.tomie_format import RUNTIME, modules, prepare
|
||||||
|
from app.converter.tomie_assets import export_media as export_tomie_media
|
||||||
|
|
||||||
|
native = prepare(package) # all script checks precede media decoding/blob writes
|
||||||
|
scripts = modules(native, package.texts)
|
||||||
|
files, media, counts = export_tomie_media(package, root, work, progress)
|
||||||
|
|
||||||
|
def store(path, value):
|
||||||
|
raw = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
|
||||||
|
digest, size = put_bytes(raw, ".json")
|
||||||
|
files[path] = {"hash": digest, "size": size}
|
||||||
|
|
||||||
|
resources = {}
|
||||||
|
for tid, record in package.texts.items():
|
||||||
|
resources.setdefault(record["collection"], {}).setdefault(record["key"], []).append({"tid": tid, **record})
|
||||||
|
for path, module in scripts.items():
|
||||||
|
module["anchors"] = anchors(module["labels"])
|
||||||
|
store(path, module)
|
||||||
|
game = {"format": "pgvn/1", "engine": "naninovel", "runtime": RUNTIME, "title": root.name,
|
||||||
|
"resolution": [1920, 1080], "entry": "nani:" + native["start"]["script"], "scripts": list(scripts),
|
||||||
|
"characters": {}, "images": {}, "transforms": {}, "transitions": {}, "audio": {}, "files": {},
|
||||||
|
"defaults": [], "init": [], "ui": {}, "sourceLang": locales.detect_language(
|
||||||
|
{path: module["labels"] for path, module in scripts.items()}) or "und", "locales": {},
|
||||||
|
"naniResources": resources,
|
||||||
|
"tomie": {**{key: value for key, value in native.items() if key != "scripts"}, "media": media}}
|
||||||
|
store("game.json", game)
|
||||||
|
report = {"engine": "naninovel", "profile": package.profile, "compatible": True, "format": "pgvn/1",
|
||||||
|
"runtime": RUNTIME, "scripts": len(package.scripts), "textResources": len(package.texts),
|
||||||
|
"media": counts, "warnings": []}
|
||||||
|
return Result({"format": "pgvn/1", "files": files}, report, root.name, None,
|
||||||
|
sum(entry["size"] for entry in files.values()))
|
||||||
@@ -19,6 +19,7 @@ from app.converter.rpa import RpaError, extract_rpa
|
|||||||
from app.converter.rpy_lexer import ParseError
|
from app.converter.rpy_lexer import ParseError
|
||||||
from app.converter.rpy_parser import RpyParser, ScriptData
|
from app.converter.rpy_parser import RpyParser, ScriptData
|
||||||
from app.converter.rpyc import decompile_missing
|
from app.converter.rpyc import decompile_missing
|
||||||
|
from app.converter.source import read_source
|
||||||
from app.converter.compatibility import CompatibilityError, Issue, audit_ir, detect, require_supported_scripts
|
from app.converter.compatibility import CompatibilityError, Issue, audit_ir, detect, require_supported_scripts
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -87,7 +88,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
|||||||
for p in rpy_files:
|
for p in rpy_files:
|
||||||
rel = p.relative_to(game_dir).as_posix()
|
rel = p.relative_to(game_dir).as_posix()
|
||||||
try:
|
try:
|
||||||
RpyParser(data, rel).parse(p.read_text(encoding="utf-8", errors="replace"))
|
RpyParser(data, rel).parse(read_source(p))
|
||||||
except ParseError as e:
|
except ParseError as e:
|
||||||
data.warn(f"Ошибка разбора файла: {e.msg}", f"{e.filename}:{e.lineno}")
|
data.warn(f"Ошибка разбора файла: {e.msg}", f"{e.filename}:{e.lineno}")
|
||||||
except RecursionError:
|
except RecursionError:
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ def probe_archive(source: Path, filename: str) -> dict:
|
|||||||
if not seven:
|
if not seven:
|
||||||
from app.converter.archive import ArchiveError
|
from app.converter.archive import ArchiveError
|
||||||
raise ArchiveError("Для проверки этого архива нужен 7-Zip")
|
raise ArchiveError("Для проверки этого архива нужен 7-Zip")
|
||||||
|
try:
|
||||||
process = subprocess.run([seven, "l", "-slt", "-ba", "-sccUTF-8", str(source)],
|
process = subprocess.run([seven, "l", "-slt", "-ba", "-sccUTF-8", str(source)],
|
||||||
capture_output=True, encoding="utf-8", errors="replace", timeout=120)
|
capture_output=True, encoding="utf-8", errors="replace", timeout=120)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
from app.converter.archive import ArchiveError
|
||||||
|
raise ArchiveError("Превышено время чтения списка файлов архива") from None
|
||||||
if process.returncode:
|
if process.returncode:
|
||||||
from app.converter.archive import ArchiveError
|
from app.converter.archive import ArchiveError
|
||||||
raise ArchiveError("Не удалось прочитать архив: " + (process.stderr or process.stdout)[-500:])
|
raise ArchiveError("Не удалось прочитать архив: " + (process.stderr or process.stdout)[-500:])
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class ScriptData:
|
|||||||
modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels
|
modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels
|
||||||
warnings: Counter = field(default_factory=Counter)
|
warnings: Counter = field(default_factory=Counter)
|
||||||
blockers: Counter = field(default_factory=Counter)
|
blockers: Counter = field(default_factory=Counter)
|
||||||
|
blocker_examples: dict[str, str] = field(default_factory=dict)
|
||||||
warning_examples: dict[str, str] = field(default_factory=dict)
|
warning_examples: dict[str, str] = field(default_factory=dict)
|
||||||
stats: Counter = field(default_factory=Counter)
|
stats: Counter = field(default_factory=Counter)
|
||||||
|
|
||||||
@@ -71,6 +72,8 @@ class ScriptData:
|
|||||||
self.warnings[kind] += 1
|
self.warnings[kind] += 1
|
||||||
if blocking:
|
if blocking:
|
||||||
self.blockers[kind] += 1
|
self.blockers[kind] += 1
|
||||||
|
if where:
|
||||||
|
self.blocker_examples.setdefault(kind, where)
|
||||||
if where and kind not in self.warning_examples:
|
if where and kind not in self.warning_examples:
|
||||||
self.warning_examples[kind] = where
|
self.warning_examples[kind] = where
|
||||||
|
|
||||||
@@ -591,10 +594,14 @@ class RpyParser:
|
|||||||
c = self.cursor(child)
|
c = self.cursor(child)
|
||||||
word = c.peek_word()
|
word = c.peek_word()
|
||||||
if word in ("contains", "block", "parallel", "choice"):
|
if word in ("contains", "block", "parallel", "choice"):
|
||||||
|
if word != "block":
|
||||||
|
self.d.warn(f"Не поддерживается ATL: {word}", self.where(child))
|
||||||
if not frames or word == "block":
|
if not frames or word == "block":
|
||||||
repeat = self.atl_frames(child.block, frames, props) or repeat
|
repeat = self.atl_frames(child.block, frames, props) or repeat
|
||||||
continue
|
continue
|
||||||
if word == "repeat":
|
if word == "repeat":
|
||||||
|
if child.text.strip() != "repeat":
|
||||||
|
self.d.warn("Не поддерживается ATL: конечное число повторов", self.where(child))
|
||||||
repeat = True
|
repeat = True
|
||||||
continue
|
continue
|
||||||
if word in ("pause",) or _NUMBER_LINE.match(child.text):
|
if word in ("pause",) or _NUMBER_LINE.match(child.text):
|
||||||
@@ -604,6 +611,7 @@ class RpyParser:
|
|||||||
frames[-1]["d"] = frames[-1].get("d", 0) + d
|
frames[-1]["d"] = frames[-1].get("d", 0) + d
|
||||||
continue
|
continue
|
||||||
if word in ("function", "on", "event", "time", "transform"):
|
if word in ("function", "on", "event", "time", "transform"):
|
||||||
|
self.d.warn(f"Не поддерживается ATL: {word}", self.where(child))
|
||||||
continue
|
continue
|
||||||
s = c.string()
|
s = c.string()
|
||||||
if s is not None:
|
if s is not None:
|
||||||
@@ -626,7 +634,7 @@ class RpyParser:
|
|||||||
frames.append({"img": {"expr": e}})
|
frames.append({"img": {"expr": e}})
|
||||||
continue
|
continue
|
||||||
if not frames or not frames[-1].get("d"):
|
if not frames or not frames[-1].get("d"):
|
||||||
props.update(_atl_props(child.text))
|
props.update(self.static_atl(child))
|
||||||
return repeat
|
return repeat
|
||||||
|
|
||||||
def st_layeredimage(self, line: Line) -> list[Node]:
|
def st_layeredimage(self, line: Line) -> list[Node]:
|
||||||
@@ -754,11 +762,18 @@ class RpyParser:
|
|||||||
name = c.word()
|
name = c.word()
|
||||||
props: dict[str, Any] = {}
|
props: dict[str, Any] = {}
|
||||||
for child in line.block:
|
for child in line.block:
|
||||||
props.update(_atl_props(child.text))
|
props.update(self.static_atl(child))
|
||||||
if name:
|
if name:
|
||||||
self.d.transforms[name] = props
|
self.d.transforms[name] = props
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def static_atl(self, line: Line) -> dict[str, Any]:
|
||||||
|
props = _atl_props(line.text)
|
||||||
|
first = Cursor(line.text).peek_word()
|
||||||
|
if first not in POSITION_PROPS or not props or line.block:
|
||||||
|
self.d.warn(f"Не поддерживается динамическая ATL-трансформация: {first or line.text}", self.where(line))
|
||||||
|
return props
|
||||||
|
|
||||||
# visual statements -------------------------------------------------------------
|
# visual statements -------------------------------------------------------------
|
||||||
|
|
||||||
def image_spec(self, c: Cursor, line: Line) -> dict[str, Any]:
|
def image_spec(self, c: Cursor, line: Line) -> dict[str, Any]:
|
||||||
@@ -799,7 +814,7 @@ class RpyParser:
|
|||||||
elif c.char(":"):
|
elif c.char(":"):
|
||||||
props: dict[str, Any] = {}
|
props: dict[str, Any] = {}
|
||||||
for child in line.block:
|
for child in line.block:
|
||||||
props.update(_atl_props(child.text))
|
props.update(self.static_atl(child))
|
||||||
if props:
|
if props:
|
||||||
node["at"].append({"props": props})
|
node["at"].append({"props": props})
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Decode source text without silently replacing characters in localisations."""
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import tokenize
|
||||||
|
|
||||||
|
from app.converter.rpy_lexer import ParseError
|
||||||
|
|
||||||
|
|
||||||
|
def read_source(path: Path) -> str:
|
||||||
|
raw = path.read_bytes()
|
||||||
|
try:
|
||||||
|
encoding, _ = tokenize.detect_encoding(io.BytesIO(raw).readline)
|
||||||
|
return raw.decode(encoding)
|
||||||
|
except (UnicodeError, SyntaxError, LookupError) as exc:
|
||||||
|
raise ParseError(f"Не удалось прочитать кодировку сценария: {exc}", path.name) from exc
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Recover the pinned Tomie build's media and ResourceManager actor bindings."""
|
||||||
|
from pathlib import Path
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from app.blobs import put_file
|
||||||
|
from app.converter import assets
|
||||||
|
from app.converter.compatibility import CompatibilityError, Issue
|
||||||
|
|
||||||
|
|
||||||
|
def recover_object(obj, data: Path, out: Path, stem: str):
|
||||||
|
"""Decode a single owned temporary file; never rename or edit a source file."""
|
||||||
|
asset = obj.read()
|
||||||
|
kind, size = obj.type.name, None
|
||||||
|
if kind == "Texture2D":
|
||||||
|
if asset.m_Width == 0 or asset.m_Height == 0:
|
||||||
|
return None # empty runtime font atlas
|
||||||
|
path = out / (stem + ".png")
|
||||||
|
asset.image.save(path, compress_level=1)
|
||||||
|
size = [asset.m_Width, asset.m_Height]
|
||||||
|
elif kind == "AudioClip":
|
||||||
|
samples = asset.samples
|
||||||
|
if len(samples) != 1:
|
||||||
|
raise ValueError("Не найден единственный аудиоклип")
|
||||||
|
filename, raw = next(iter(samples.items()))
|
||||||
|
extension = Path(filename).suffix.lower() or ".wav"
|
||||||
|
if extension not in assets.AUDIO_EXTS:
|
||||||
|
raise ValueError("Не поддерживается контейнер аудиоклипа " + extension)
|
||||||
|
path = out / (stem + extension)
|
||||||
|
path.write_bytes(raw)
|
||||||
|
elif kind == "Font":
|
||||||
|
raw = bytes(asset.m_FontData or b"")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
path = out / (stem + (".otf" if raw[:4] == b"OTTO" else ".ttf"))
|
||||||
|
path.write_bytes(raw)
|
||||||
|
elif kind == "VideoClip":
|
||||||
|
tree = obj.read_typetree()
|
||||||
|
reference = tree["m_ExternalResources"]
|
||||||
|
source_name = reference["m_Source"].replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
resource = data / source_name
|
||||||
|
offset, length = int(reference["m_Offset"]), int(reference["m_Size"])
|
||||||
|
if not resource.is_file() or offset < 0 or length <= 0 or offset + length > resource.stat().st_size:
|
||||||
|
raise ValueError("Некорректный диапазон исходного видео")
|
||||||
|
extension = Path(tree["m_OriginalPath"]).suffix.lower()
|
||||||
|
if extension not in assets.VIDEO_EXTS:
|
||||||
|
raise ValueError("Не поддерживается исходный видеоконтейнер " + extension)
|
||||||
|
path = out / (stem + extension)
|
||||||
|
with resource.open("rb") as incoming, path.open("wb") as outgoing:
|
||||||
|
incoming.seek(offset)
|
||||||
|
remaining = length
|
||||||
|
while remaining:
|
||||||
|
chunk = incoming.read(min(1 << 20, remaining))
|
||||||
|
if not chunk:
|
||||||
|
raise EOFError("Оборванный видеопоток")
|
||||||
|
outgoing.write(chunk)
|
||||||
|
remaining -= len(chunk)
|
||||||
|
else:
|
||||||
|
raise ValueError("Не поддерживается тип медиа " + kind)
|
||||||
|
return {"path": path, "name": asset.m_Name.lower(), "size": size,
|
||||||
|
"kind": {"Texture2D": "image", "AudioClip": "audio", "Font": "font", "VideoClip": "video"}[kind]}
|
||||||
|
|
||||||
|
|
||||||
|
def export_media(package, root: Path, work: Path, progress):
|
||||||
|
if package.issues:
|
||||||
|
raise CompatibilityError("naninovel", package.issues)
|
||||||
|
data = root / "TomieWGM_Data"
|
||||||
|
out = work / "media"
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
files, counts = {}, {"image": 0, "audio": 0, "video": 0, "font": 0}
|
||||||
|
index = {"backgrounds": {}, "characters": {}, "audio": {}, "videos": {}, "textures": {}, "fonts": {}}
|
||||||
|
exported = {}
|
||||||
|
|
||||||
|
def store(path: Path, kind: str):
|
||||||
|
converted, extension = assets.convert(path, out)
|
||||||
|
digest, size = put_file(converted, extension, move=True)
|
||||||
|
logical = "media/" + digest
|
||||||
|
files[logical] = {"hash": digest, "size": size}
|
||||||
|
counts[kind] += 1
|
||||||
|
return logical
|
||||||
|
|
||||||
|
for number, source in enumerate(sorted((data / "Resources/Backgrounds/MainBackground").rglob("*"))):
|
||||||
|
if not source.is_file():
|
||||||
|
continue
|
||||||
|
if assets.kind_of(source) != "image":
|
||||||
|
raise CompatibilityError("naninovel", [Issue("unsupported_background", "Не поддерживается формат фона Tomie", source.name)])
|
||||||
|
if number % 100 == 0:
|
||||||
|
progress(f"Фоны Tomie: {number}")
|
||||||
|
name = source.stem.lower()
|
||||||
|
if name in index["backgrounds"]:
|
||||||
|
raise CompatibilityError("naninovel", [Issue("ambiguous_background", "Неоднозначный фон Tomie", name)])
|
||||||
|
index["backgrounds"][name] = store(source, "image")
|
||||||
|
|
||||||
|
objects = {}
|
||||||
|
for source, obj in package.resources:
|
||||||
|
identity = f"{source.relative_to(root).as_posix()}:{obj.assets_file.name}:{obj.path_id}"
|
||||||
|
objects.setdefault(identity, (source, obj))
|
||||||
|
for number, (identity, (source, obj)) in enumerate(objects.items()):
|
||||||
|
if number % 100 == 0:
|
||||||
|
progress(f"Ресурсы Tomie: {number}/{len(objects)}")
|
||||||
|
try:
|
||||||
|
stem = hashlib.sha256(identity.encode()).hexdigest()[:24]
|
||||||
|
recovered = recover_object(obj, data, out, stem)
|
||||||
|
if recovered is None:
|
||||||
|
continue
|
||||||
|
path, name = recovered["path"], recovered["name"]
|
||||||
|
try:
|
||||||
|
logical = store(path, recovered["kind"])
|
||||||
|
finally:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
if obj.type.name == "Texture2D":
|
||||||
|
exported[identity] = {"file": logical, "size": recovered["size"]}
|
||||||
|
# Main resources take precedence over the UI bundle, as in the port.
|
||||||
|
index["textures"].setdefault(name, logical)
|
||||||
|
elif obj.type.name == "AudioClip":
|
||||||
|
index["audio"].setdefault(name, logical)
|
||||||
|
elif obj.type.name == "Font":
|
||||||
|
index["fonts"].setdefault(name, logical)
|
||||||
|
elif obj.type.name == "VideoClip":
|
||||||
|
index["videos"][name] = logical
|
||||||
|
except Exception as exc:
|
||||||
|
raise CompatibilityError("naninovel", [Issue("tomie_media", f"Не удалось восстановить медиа: {exc}", identity)]) from exc
|
||||||
|
for name, identity in package.actor_resources.items():
|
||||||
|
if identity not in exported:
|
||||||
|
raise CompatibilityError("naninovel", [Issue("missing_actor", "Не найдено исходное изображение персонажа", name)])
|
||||||
|
index["characters"][name] = exported[identity]
|
||||||
|
if not index["backgrounds"] or not index["characters"] or not index["videos"]:
|
||||||
|
raise CompatibilityError("naninovel", [Issue("missing_media", "Не восстановлены все категории медиа Tomie")])
|
||||||
|
return files, index, counts
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Editable PGVN envelope for the version-pinned native Naninovel interpreter."""
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from app.converter.compatibility import CompatibilityError, Issue
|
||||||
|
from app.converter.tomie_profile import runtime_data
|
||||||
|
from app.converter.tomie_validation import validate_runtime
|
||||||
|
|
||||||
|
RUNTIME = "tomie/1"
|
||||||
|
TEXT_FIELDS = {"PrintText": "Text", "AppendText": "Text", "AddChoice": "ChoiceSummary",
|
||||||
|
"ShowToastUI": "Text", "InputCustomVariable": "Summary"}
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(package):
|
||||||
|
if package.issues:
|
||||||
|
raise CompatibilityError("naninovel", package.issues)
|
||||||
|
try:
|
||||||
|
data = runtime_data(package)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CompatibilityError("naninovel", [Issue("tomie_metadata", str(exc))]) from exc
|
||||||
|
issues = validate_runtime(data)
|
||||||
|
if issues:
|
||||||
|
raise CompatibilityError("naninovel", issues)
|
||||||
|
routes = {"19": {"script": "DicePlaySub", "label": "DayEnd"},
|
||||||
|
"1000": {"script": "TRandbox", "label": "Start"}}
|
||||||
|
import re
|
||||||
|
for command in data["scripts"].get("DicePlaySub", {}).get("commands", []):
|
||||||
|
condition = command["args"].get("ConditionalExpression", "")
|
||||||
|
match = re.fullmatch(r"thisBlockNumber\s*==\s*(\d+)", condition) if isinstance(condition, str) else None
|
||||||
|
if command["op"] == "Goto" and match:
|
||||||
|
path = command["args"]["Path"]
|
||||||
|
if not isinstance(path, dict) or "template" in path:
|
||||||
|
raise CompatibilityError("naninovel", [Issue("tomie_board", "Динамический адрес клетки поля", str(command["line"]))])
|
||||||
|
routes[match[1]] = {"script": path.get("name") or "DicePlaySub", "label": path.get("value") or ""}
|
||||||
|
data["board_routes"] = routes
|
||||||
|
# Browser-native message dialog returns to the source's app screen, exactly
|
||||||
|
# as the two-command bridge in the recovered port does.
|
||||||
|
data["scripts"]["PGamesMessage"] = {"labels": {}, "label_depths": {}, "commands": [
|
||||||
|
{"op": "Gosub", "args": {"Path": "MessagePlay"}, "line": -1},
|
||||||
|
{"op": "Goto", "args": {"Path": "AppScreen"}, "line": -1},
|
||||||
|
]}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def identity(value):
|
||||||
|
return "tomie-" + hashlib.sha256(value.encode()).hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
def modules(data, texts):
|
||||||
|
result = {}
|
||||||
|
for name, script in data["scripts"].items():
|
||||||
|
body = []
|
||||||
|
for index, command in enumerate(script["commands"]):
|
||||||
|
node = {"op": "nani_command", "id": identity(f"{name}:{index}"), "command": copy.deepcopy(command)}
|
||||||
|
field = TEXT_FIELDS.get(command["op"])
|
||||||
|
value = command["args"].get(field) if field else None
|
||||||
|
if value is not None:
|
||||||
|
node.update(what=value["template"] if isinstance(value, dict) else str(value),
|
||||||
|
tid=command.get("tid") or identity(f"text:{name}:{index}"), textField=field,
|
||||||
|
whoStr=str(command["args"].get("AuthorId", "")))
|
||||||
|
body.append(node)
|
||||||
|
path = f"script/nani/{identity(name)}.json"
|
||||||
|
result[path] = {"file": name + ".nani", "labels": [
|
||||||
|
{"name": "nani:" + name, "id": identity("script:" + name), "body": body}],
|
||||||
|
"nani": {"name": name, "labels": script["labels"], "label_depths": script.get("label_depths", {}),
|
||||||
|
"label_ids": {label: body[index]["id"] if index < len(body) else ""
|
||||||
|
for label, index in script["labels"].items()}}}
|
||||||
|
resources = []
|
||||||
|
for tid, record in texts.items():
|
||||||
|
resources.append({"op": "say", "id": identity(tid), "tid": tid, "what": record["text"],
|
||||||
|
"whoStr": record["collection"], "resource": copy.deepcopy(record)})
|
||||||
|
result["script/nani/resources.json"] = {"file": "_resources.nani", "labels": [
|
||||||
|
{"name": "nani:resources", "id": identity("resources"), "body": resources}]}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def unpack_modules(scripts):
|
||||||
|
"""Rebuild the VM input from editable commands, including edited prose."""
|
||||||
|
result = {}
|
||||||
|
for module in scripts.values():
|
||||||
|
metadata = module.get("nani")
|
||||||
|
if metadata is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(metadata, dict) or not isinstance(metadata.get("name"), str) or metadata["name"] in result:
|
||||||
|
raise ValueError("Некорректный модуль Naninovel")
|
||||||
|
commands, positions = [], {}
|
||||||
|
for label in module["labels"]:
|
||||||
|
for node in label["body"]:
|
||||||
|
if node.get("op") != "nani_command" or not isinstance(node.get("command"), dict):
|
||||||
|
raise ValueError("Модуль Tomie должен содержать исходные команды Naninovel")
|
||||||
|
command = copy.deepcopy(node["command"])
|
||||||
|
positions[node.get("id")] = len(commands)
|
||||||
|
if not isinstance(command.get("op"), str) or not isinstance(command.get("args"), dict):
|
||||||
|
raise ValueError("Некорректная команда Naninovel")
|
||||||
|
field = TEXT_FIELDS.get(command["op"])
|
||||||
|
if field and field in command["args"] and "what" not in node:
|
||||||
|
raise ValueError("Отсутствует редактируемый текст команды Naninovel")
|
||||||
|
if field and "what" in node:
|
||||||
|
if node.get("textField") != field or not isinstance(node["what"], str):
|
||||||
|
raise ValueError("Некорректное поле локализации Naninovel")
|
||||||
|
command["args"][field] = {"template": node["what"]}
|
||||||
|
command["tid"] = node.get("tid")
|
||||||
|
commands.append(command)
|
||||||
|
labels, depths = metadata.get("labels"), metadata.get("label_depths", {})
|
||||||
|
if not isinstance(labels, dict):
|
||||||
|
raise ValueError("Некорректные метки Naninovel")
|
||||||
|
identities = metadata.get("label_ids")
|
||||||
|
if identities is not None:
|
||||||
|
if not isinstance(identities, dict) or set(identities) != set(labels or {}) or any(
|
||||||
|
not isinstance(value, str) or (value and value not in positions) for value in identities.values()):
|
||||||
|
raise ValueError("Удалена команда, на которую ссылается метка Naninovel")
|
||||||
|
labels = {key: positions[value] if value else len(commands) for key, value in identities.items()}
|
||||||
|
if not isinstance(labels, dict) or any(not isinstance(key, str) or type(value) is not int or not 0 <= value <= len(commands) for key, value in labels.items()):
|
||||||
|
raise ValueError("Некорректный адрес метки Naninovel")
|
||||||
|
if not isinstance(depths, dict) or any(key not in labels or type(value) is not int or not 0 <= value <= 64 for key, value in depths.items()):
|
||||||
|
raise ValueError("Некорректная вложенность метки Naninovel")
|
||||||
|
result[metadata["name"]] = {"commands": commands, "labels": labels, "label_depths": depths}
|
||||||
|
return result
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Version-pinned Tomie metadata. Reads IL constants; never executes managed code."""
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
PROFILE = "tomie/expansion-1.3801"
|
||||||
|
ASSEMBLY_HASH = "1c6aec12894362f1b9c62858a5a4c286eb93aee175ed1ef339218dc5a7b2729b"
|
||||||
|
FUNCTIONS = set("WillpowerDamage RoundValue MinMax Random NewRandList AddRandList MergeRandList HasValue StringContain StringReplace ItemExist itemExist ItemCount AddListString AddListString2 AddListStringWithRemoveDot RemoveListString StringContains StringReplaceSeed IntParse GetListCount GetArrayCount GetFromArray GetFromRandPick2 GetFromRandPick3 StringSplitComma RandPick RandPick2 RandSort RandPicks GetRandValue GetRandList GetRandListViaIndex ExtractRandList GetTargetList Pow Round random Rand HasLabel GetName GetRandName GetNormalComment GetLewdComment GetVODFileCount GetVODFileNameVidIndex GetMODFileNames GetModList OpenURL IsChristmas GetVODFileNames HasVideo ResetString SumString".split())
|
||||||
|
|
||||||
|
|
||||||
|
def identify(root: Path) -> str | None:
|
||||||
|
assembly = root / "TomieWGM_Data" / "Managed" / "Assembly-CSharp.dll"
|
||||||
|
if assembly.is_file() and hashlib.sha256(assembly.read_bytes()).hexdigest() == ASSEMBLY_HASH:
|
||||||
|
return PROFILE
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def constant_value(instructions, user_string):
|
||||||
|
"""Only a constant string / string-array initializer, not an IL interpreter."""
|
||||||
|
stack = []
|
||||||
|
for instruction in instructions:
|
||||||
|
op, value = instruction.mnemonic, instruction.operand
|
||||||
|
if op == "nop":
|
||||||
|
continue
|
||||||
|
if op in {"ldc.i4", "ldc.i4.s"}:
|
||||||
|
stack.append(int(value))
|
||||||
|
elif op.startswith("ldc.i4."):
|
||||||
|
stack.append(-1 if op.endswith("m1") else int(op.rsplit(".", 1)[-1]))
|
||||||
|
elif op == "ldstr":
|
||||||
|
stack.append(user_string(value.rid))
|
||||||
|
elif op == "newarr":
|
||||||
|
size = stack.pop()
|
||||||
|
if not isinstance(size, int) or not 0 <= size <= 100000:
|
||||||
|
raise ValueError("Некорректный размер таблицы Tomie")
|
||||||
|
stack.append([None] * size)
|
||||||
|
elif op == "dup":
|
||||||
|
stack.append(stack[-1])
|
||||||
|
elif op == "stelem.ref":
|
||||||
|
element, index, array = stack.pop(), stack.pop(), stack.pop()
|
||||||
|
if not isinstance(array, list) or not 0 <= index < len(array) or not isinstance(element, str):
|
||||||
|
raise ValueError("Некорректный элемент таблицы Tomie")
|
||||||
|
array[index] = element
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Неизвестная инициализация таблицы Tomie: {op}")
|
||||||
|
if len(stack) != 1 or not isinstance(stack[0], (str, list)):
|
||||||
|
raise ValueError("Не удалось восстановить таблицу Tomie")
|
||||||
|
if isinstance(stack[0], list) and any(not isinstance(value, str) for value in stack[0]):
|
||||||
|
raise ValueError("Неполная таблица Tomie")
|
||||||
|
return stack[0]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_tables(root: Path) -> dict[str, list[str]]:
|
||||||
|
if identify(root) != PROFILE:
|
||||||
|
raise ValueError("Версия Assembly-CSharp.dll не соответствует адаптеру Tomie")
|
||||||
|
import dnfile
|
||||||
|
from dncil.cil.body.reader import read_method_body_from_bytes
|
||||||
|
|
||||||
|
pe = dnfile.dnPE(str(root / "TomieWGM_Data/Managed/Assembly-CSharp.dll"))
|
||||||
|
try:
|
||||||
|
definition = next(row for row in pe.net.mdtables.TypeDef.rows
|
||||||
|
if str(row.TypeNamespace) == "Naninovel" and str(row.TypeName) == "KBCustom")
|
||||||
|
initializer = next(method.row for method in definition.MethodList if str(method.row.Name) == ".cctor")
|
||||||
|
body = read_method_body_from_bytes(pe.get_data(initializer.Rva))
|
||||||
|
wanted = {"names", "normalComment", "lewdComment", "VODstr"}
|
||||||
|
tables, segment = {}, []
|
||||||
|
for instruction in body.instructions:
|
||||||
|
if instruction.mnemonic == "stsfld":
|
||||||
|
field = str(pe.net.mdtables.Field.rows[instruction.operand.rid - 1].Name)
|
||||||
|
if field in wanted:
|
||||||
|
if field in tables:
|
||||||
|
raise ValueError(f"Повторная инициализация таблицы Tomie: {field}")
|
||||||
|
tables[field] = constant_value(segment, lambda rid: pe.net.user_strings.get(rid).value)
|
||||||
|
segment = []
|
||||||
|
else:
|
||||||
|
segment.append(instruction)
|
||||||
|
if set(tables) != wanted:
|
||||||
|
raise ValueError("Не найдены все исходные таблицы Tomie")
|
||||||
|
tables["videos"] = tables.pop("VODstr").split(",")
|
||||||
|
return tables
|
||||||
|
finally:
|
||||||
|
pe.close()
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_data(package) -> dict:
|
||||||
|
"""Keep the original flat command machine and exact label nesting depths."""
|
||||||
|
if package.profile != PROFILE or not package.defaults or not package.function_data:
|
||||||
|
raise ValueError("Не загружены исходные настройки проверенной сборки Tomie")
|
||||||
|
scripts = {}
|
||||||
|
for name, source in package.scripts.items():
|
||||||
|
commands, labels, depths, depth = [], {}, {}, 0
|
||||||
|
for line in source["lines"]:
|
||||||
|
if line.get("label"):
|
||||||
|
labels.setdefault(line["label"], len(commands))
|
||||||
|
depths.setdefault(line["label"], depth)
|
||||||
|
for index, command in enumerate(line["commands"]):
|
||||||
|
identity = hashlib.sha256(f"{name}:{line['line']}:{index}".encode()).hexdigest()[:24]
|
||||||
|
commands.append({**command, "line": line["line"], "tid": "nani-" + identity})
|
||||||
|
if command["op"] == "BeginIf":
|
||||||
|
depth += 1
|
||||||
|
elif command["op"] == "EndIf":
|
||||||
|
depth = max(0, depth - 1)
|
||||||
|
scripts[name] = {"commands": commands, "labels": labels, "label_depths": depths}
|
||||||
|
if "StartGameSetting" not in scripts or "Start" not in scripts["StartGameSetting"]["labels"]:
|
||||||
|
raise ValueError("Не найдено исходное создание персонажа Tomie")
|
||||||
|
return {"scripts": scripts, "defaults": package.defaults, "function_data": package.function_data,
|
||||||
|
"start": {"script": "StartGameSetting", "label": "Start"},
|
||||||
|
"initial_inventory": {"item_Phone": 1, "item_StudentID": 1, "item_Wardrobe": 1,
|
||||||
|
"item_Book": 1, "item_SecretDiary": 1}}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_expressions(scripts: dict) -> dict:
|
||||||
|
"""Check the full recovered expression corpus, including dynamic templates."""
|
||||||
|
from app.converter.nani_expr import assignment, parse
|
||||||
|
from collections import Counter
|
||||||
|
expressions, errors = {}, Counter()
|
||||||
|
examples = {}
|
||||||
|
|
||||||
|
def add(source, location):
|
||||||
|
try:
|
||||||
|
expressions[source] = parse(source, FUNCTIONS)
|
||||||
|
except ValueError as exc:
|
||||||
|
errors[str(exc)] += 1
|
||||||
|
examples.setdefault(str(exc), location)
|
||||||
|
|
||||||
|
def templates(value, location):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
if "template" in value:
|
||||||
|
for match in re.finditer(r"\{([^{}]+)\}", value["template"]):
|
||||||
|
add(match[1], location)
|
||||||
|
else:
|
||||||
|
for child in value.values():
|
||||||
|
templates(child, location)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for child in value:
|
||||||
|
templates(child, location)
|
||||||
|
|
||||||
|
for name, script in scripts.items():
|
||||||
|
for command in script["commands"]:
|
||||||
|
args, op = command["args"], command["op"]
|
||||||
|
location = f"{name}:{command.get('line', -1)}"
|
||||||
|
templates(args, location)
|
||||||
|
value = args.get("Expression")
|
||||||
|
if isinstance(value, str):
|
||||||
|
if op == "SetCustomVariable":
|
||||||
|
try:
|
||||||
|
expression = assignment(value, FUNCTIONS)["v"]["ast"]
|
||||||
|
expressions[value.split("=", 1)[1].strip()] = expression
|
||||||
|
except ValueError as exc:
|
||||||
|
errors[str(exc)] += 1
|
||||||
|
examples.setdefault(str(exc), location)
|
||||||
|
else:
|
||||||
|
add(value, location)
|
||||||
|
if isinstance(args.get("ConditionalExpression"), str):
|
||||||
|
add(args["ConditionalExpression"], location)
|
||||||
|
return {"count": len(expressions), "asts": expressions,
|
||||||
|
"issues": [{"message": message, "count": count, "location": examples[message]}
|
||||||
|
for message, count in errors.items()]}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Fail-closed validation of the command surface implemented by the Tomie VM.
|
||||||
|
|
||||||
|
The assembly hash selects the adapter; it does not exempt mutable script assets
|
||||||
|
from validation. Unknown command arguments are rejected along with unknown ops.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.converter.compatibility import Issue
|
||||||
|
from app.converter.nani_expr import assignment, parse
|
||||||
|
from app.converter.tomie_profile import FUNCTIONS, validate_expressions
|
||||||
|
|
||||||
|
|
||||||
|
ARGUMENTS = {
|
||||||
|
"ModifyBackground": "AppearanceAndTransition Duration Scale TintColor",
|
||||||
|
"ModifyTextPrinter": "Duration HideOther IdAndAppearance MakeDefault ScenePosition",
|
||||||
|
"ModifyCharacter": "Duration IdAndAppearance LookDirection Scale ScenePosition TintColor Transition Visible",
|
||||||
|
"PrintText": "AuthorId LineBreaks ResetPrinter RevealSpeed Text WaitForInput",
|
||||||
|
"SkipInput": "", "AddChoice": "AutoPlay ButtonPath ButtonPosition ChoiceSummary Duration GotoPath HandlerId OnSelected ShowHandler",
|
||||||
|
"Stop": "", "SetCustomVariable": "Expression", "HideAllCharacters": "Duration",
|
||||||
|
"BeginIf": "Expression", "Else": "", "EndIf": "", "AppendLineBreak": "Count",
|
||||||
|
"Spawn": "Params Path", "Wait": "WaitMode", "ElseIf": "Expression",
|
||||||
|
"Gosub": "Path", "Goto": "Path", "Return": "", "DestroySpawned": "Path",
|
||||||
|
"HideActors": "ActorIds Duration", "PlaySfx": "Duration FadeInDuration Loop SfxPath Volume",
|
||||||
|
"WaitForInput": "", "HidePrinter": "", "AddItem": "Amount ItemId",
|
||||||
|
"PlayVoice": "VoicePath Volume", "ClearChoiceHandler": "HandlerId Hide",
|
||||||
|
"StopBgm": "FadeOutDuration", "PlayBgm": "BgmPath Duration FadeInDuration Loop Volume",
|
||||||
|
"ShowToastUI": "Appearance Duration Text", "ModifyCamera": "Duration Zoom", "Skip": "Enable",
|
||||||
|
"HideUI": "AllowToggle Duration UINames", "ProcessInput": "InputEnabled",
|
||||||
|
"AutoSave": "", "ExitToTitle": "", "StopSfx": "FadeOutDuration",
|
||||||
|
"RemoveItem": "Amount ItemId", "ShowUI": "UINames", "ShowPrinter": "",
|
||||||
|
"InputCustomVariable": "PlayOnSubmit Summary VariableName", "ClearBacklog": "", "AppendText": "Text",
|
||||||
|
}
|
||||||
|
EFFECTS = set("ShakeCamera ShakePrinter ShakeCharacter DigitalGlitch PinkFlash RedFlash WhiteFlash Hands1 Hands2 Hands3 Rain EffectSuccess EffectFailed EffectHeart Gaze SkillUp LewdInswitterComment PregMiniGame CamShowUI".split())
|
||||||
|
UI_NAMES = {"MStat", "Calendar", "AppUI", "CGGalleryUI", "Inventory"}
|
||||||
|
|
||||||
|
|
||||||
|
def inline_commands(value):
|
||||||
|
"""Same bounded comma/quote splitting as the browser's choice interpreter."""
|
||||||
|
if isinstance(value, list):
|
||||||
|
if not all(isinstance(part, str) for part in value):
|
||||||
|
raise ValueError("Неверный список команд выбора")
|
||||||
|
return [part.strip() for part in value if part.strip()]
|
||||||
|
if not isinstance(value, str) or len(value) > 30000:
|
||||||
|
raise ValueError("Неверная команда выбора")
|
||||||
|
result, start, depth, quote, escaped = [], 0, 0, "", False
|
||||||
|
for index, char in enumerate(value):
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
continue
|
||||||
|
if quote:
|
||||||
|
if char == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif char == quote:
|
||||||
|
quote = ""
|
||||||
|
elif char in "\"'":
|
||||||
|
quote = char
|
||||||
|
elif char in "({":
|
||||||
|
depth += 1
|
||||||
|
elif char in ")}":
|
||||||
|
depth -= 1
|
||||||
|
if depth < 0:
|
||||||
|
raise ValueError("Некорректные скобки команды выбора")
|
||||||
|
elif char == "," and depth == 0:
|
||||||
|
result.append(value[start:index].strip())
|
||||||
|
start = index + 1
|
||||||
|
if depth or quote:
|
||||||
|
raise ValueError("Незакрытая команда выбора")
|
||||||
|
result.append(value[start:].strip())
|
||||||
|
return [item for item in result if item]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_runtime(data):
|
||||||
|
scripts, issues = data["scripts"], []
|
||||||
|
|
||||||
|
def issue(code, message, location):
|
||||||
|
issues.append(Issue(code, message, location))
|
||||||
|
|
||||||
|
def target(value, script, location):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
if "template" in value:
|
||||||
|
return # expressions checked separately; the VM checks the resolved target
|
||||||
|
name, label = value.get("name") or script, value.get("value") or ""
|
||||||
|
elif isinstance(value, str):
|
||||||
|
name, _, label = value.partition(".")
|
||||||
|
name = name or script
|
||||||
|
else:
|
||||||
|
issue("invalid_target", "Неверный переход Naninovel", location)
|
||||||
|
return
|
||||||
|
if name not in scripts or (label and label not in scripts[name]["labels"]):
|
||||||
|
issue("missing_target", f"Не найден переход {name}.{label}", location)
|
||||||
|
|
||||||
|
def inline(value, script, location):
|
||||||
|
try:
|
||||||
|
if isinstance(value, dict) and isinstance(value.get("template"), str):
|
||||||
|
# The template is parsed after interpolation by the VM. Its
|
||||||
|
# expressions are validated by validate_expressions below.
|
||||||
|
value = value["template"]
|
||||||
|
for command in inline_commands(value):
|
||||||
|
if not command.startswith("@"):
|
||||||
|
continue
|
||||||
|
name, _, body = command.partition(" ")
|
||||||
|
if name not in {"@set", "@goto", "@gosub", "@return", "@hideChars", "@addItem", "@additem", "@removeItem", "@voice", "@hide"}:
|
||||||
|
raise ValueError(f"Неизвестная команда выбора {name}")
|
||||||
|
if name == "@set" and "{" not in body:
|
||||||
|
assignment(body, FUNCTIONS)
|
||||||
|
elif name in {"@goto", "@gosub"} and "{" not in body:
|
||||||
|
target(body, script, location)
|
||||||
|
for match in re.finditer(r"\{([^{}]+)\}", body):
|
||||||
|
parse(match[1], FUNCTIONS)
|
||||||
|
except ValueError as exc:
|
||||||
|
issue("unsupported_choice", str(exc), location)
|
||||||
|
|
||||||
|
for name, script in scripts.items():
|
||||||
|
for index, command in enumerate(script["commands"]):
|
||||||
|
op, args = command["op"], command["args"]
|
||||||
|
location = f"{name}:{command.get('line', -1)}"
|
||||||
|
if op not in ARGUMENTS:
|
||||||
|
issue("unsupported_command", f"Не поддерживается команда {op}", location)
|
||||||
|
continue
|
||||||
|
extra = set(args) - set(ARGUMENTS[op].split()) - {"Wait", "ConditionalExpression"}
|
||||||
|
if extra:
|
||||||
|
issue("unsupported_argument", f"Не поддерживаются параметры {op}: {', '.join(sorted(extra))}", location)
|
||||||
|
if op in {"Spawn", "DestroySpawned"}:
|
||||||
|
effect = args.get("Path")
|
||||||
|
if not isinstance(effect, str) or effect not in EFFECTS:
|
||||||
|
issue("unsupported_effect", "Не поддерживается эффект Tomie: " + str(effect), location)
|
||||||
|
if op in {"ShowUI", "HideUI"}:
|
||||||
|
names = args.get("UINames", [])
|
||||||
|
if not isinstance(names, list) or any(not isinstance(value, str) or value not in UI_NAMES for value in names):
|
||||||
|
issue("unsupported_ui", "Не поддерживается интерфейс Tomie: " + str(names), location)
|
||||||
|
if op in {"BeginIf", "Else", "ElseIf"}:
|
||||||
|
# Native Naninovel seeks in the playlist instead of retaining a
|
||||||
|
# branch stack. An unmatched EndIf is a valid no-op; a branch
|
||||||
|
# without a forward closing EndIf cannot be traversed safely.
|
||||||
|
depth = 0
|
||||||
|
for following in script["commands"][index + 1:]:
|
||||||
|
if following["op"] == "BeginIf":
|
||||||
|
depth += 1
|
||||||
|
elif following["op"] == "EndIf":
|
||||||
|
if depth == 0:
|
||||||
|
break
|
||||||
|
depth -= 1
|
||||||
|
else:
|
||||||
|
issue("invalid_branch", f"{op} не имеет завершающего EndIf", location)
|
||||||
|
if op in {"Goto", "Gosub"}:
|
||||||
|
target(args.get("Path"), name, location)
|
||||||
|
if op == "AddChoice":
|
||||||
|
if "GotoPath" in args:
|
||||||
|
target(args["GotoPath"], name, location)
|
||||||
|
inline(args.get("OnSelected", []), name, location)
|
||||||
|
expressions = validate_expressions(scripts)
|
||||||
|
issues.extend(Issue("unsupported_expression", item["message"], item["location"], item["count"])
|
||||||
|
for item in expressions["issues"])
|
||||||
|
return issues
|
||||||
@@ -25,13 +25,20 @@ async def main() -> None:
|
|||||||
if not args.path.is_dir():
|
if not args.path.is_dir():
|
||||||
raise SystemExit(f"{args.path} is not a directory")
|
raise SystemExit(f"{args.path} is not a directory")
|
||||||
|
|
||||||
|
from app.converter.preflight import inspect_directory
|
||||||
|
from app.converter.compatibility import CompatibilityError
|
||||||
|
try:
|
||||||
|
report = await asyncio.to_thread(inspect_directory, args.path)
|
||||||
|
except CompatibilityError as exc:
|
||||||
|
raise SystemExit(str(exc)) from None
|
||||||
|
|
||||||
async with SessionLocal() as db:
|
async with SessionLocal() as db:
|
||||||
stmt = select(User).join(Role).order_by(User.id)
|
stmt = select(User).join(Role).order_by(User.id)
|
||||||
stmt = stmt.where(User.username == args.owner) if args.owner else stmt.where(Role.name == "admin")
|
stmt = stmt.where(User.username == args.owner) if args.owner else stmt.where(Role.name == "admin")
|
||||||
owner = await db.scalar(stmt.limit(1))
|
owner = await db.scalar(stmt.limit(1))
|
||||||
if owner is None:
|
if owner is None:
|
||||||
raise SystemExit("owner not found")
|
raise SystemExit("owner not found")
|
||||||
game = Game(title=args.title or args.path.name, created_by_id=owner.id)
|
game = Game(title=args.title or args.path.name, engine=report["engine"], created_by_id=owner.id)
|
||||||
db.add(game)
|
db.add(game)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
version = GameVersion(game_id=game.id, owner_id=owner.id, name="Оригинал",
|
version = GameVersion(game_id=game.id, owner_id=owner.id, name="Оригинал",
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ def check(source: Path) -> dict:
|
|||||||
if source.is_dir():
|
if source.is_dir():
|
||||||
return inspect_directory(source)
|
return inspect_directory(source)
|
||||||
if not source.name.lower().endswith(ARCHIVES):
|
if not source.name.lower().endswith(ARCHIVES):
|
||||||
|
if source.suffix.lower() == ".exe" and source.stat().st_size >= 4:
|
||||||
|
with source.open("rb") as handle:
|
||||||
|
handle.seek(-4, 2)
|
||||||
|
if handle.read(4) == b"GDPC":
|
||||||
|
raise CompatibilityError("godot", [Issue("unsupported_engine",
|
||||||
|
"В EXE обнаружен встроенный пакет Godot; его исполнение пока не поддерживается")])
|
||||||
raise CompatibilityError("unknown", [Issue("not_distribution",
|
raise CompatibilityError("unknown", [Issue("not_distribution",
|
||||||
"Нужна папка или архив полного дистрибутива; отдельные EXE и RPA не импортируются")])
|
"Нужна папка или архив полного дистрибутива; отдельные EXE и RPA не импортируются")])
|
||||||
return inspect_archive(source, source.name)
|
return inspect_archive(source, source.name)
|
||||||
|
|||||||
+28
-1
@@ -12,6 +12,7 @@ from app.translation import file_json, put_json
|
|||||||
|
|
||||||
OPS = set("label camera say scene show hide with play stop queue voice pause menu if while jump call return exec window noop nvl".split())
|
OPS = set("label camera say scene show hide with play stop queue voice pause menu if while jump call return exec window noop nvl".split())
|
||||||
OPS |= {"nani_add_choice", "nani_menu", "nani_clear_choice", "nani_hide_characters", "nani_exit"}
|
OPS |= {"nani_add_choice", "nani_menu", "nani_clear_choice", "nani_hide_characters", "nani_exit"}
|
||||||
|
OPS.add("nani_command")
|
||||||
MISSING = object()
|
MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ def validate(doc, manifest):
|
|||||||
for key in ("expr", "c", "d"):
|
for key in ("expr", "c", "d"):
|
||||||
if isinstance(n.get(key), dict):
|
if isinstance(n.get(key), dict):
|
||||||
expression(n[key])
|
expression(n[key])
|
||||||
if op in {"say", "nani_add_choice"}:
|
if op in {"say", "nani_add_choice"} or (op == "nani_command" and "what" in n):
|
||||||
if not isinstance(n.get("what"), str) or not isinstance(n.get("tid"), str) or n["tid"] in tids:
|
if not isinstance(n.get("what"), str) or not isinstance(n.get("tid"), str) or n["tid"] in tids:
|
||||||
raise ValueError("Некорректная реплика или повторяющийся tid")
|
raise ValueError("Некорректная реплика или повторяющийся tid")
|
||||||
tids.add(n["tid"])
|
tids.add(n["tid"])
|
||||||
@@ -110,6 +111,8 @@ def validate(doc, manifest):
|
|||||||
body(n.get("body"), depth + 1)
|
body(n.get("body"), depth + 1)
|
||||||
if op == "exec":
|
if op == "exec":
|
||||||
statements(n.get("code"))
|
statements(n.get("code"))
|
||||||
|
if op == "nani_command" and game.get("runtime") != "tomie/1":
|
||||||
|
raise ValueError("Исходные команды Naninovel требуют плеер Tomie")
|
||||||
|
|
||||||
for path, module in scripts.items():
|
for path, module in scripts.items():
|
||||||
check_path(path)
|
check_path(path)
|
||||||
@@ -131,6 +134,30 @@ def validate(doc, manifest):
|
|||||||
raise ValueError("Точка входа не найдена")
|
raise ValueError("Точка входа не найдена")
|
||||||
if any(t not in names for t in targets):
|
if any(t not in names for t in targets):
|
||||||
raise ValueError("Переход на отсутствующую метку")
|
raise ValueError("Переход на отсутствующую метку")
|
||||||
|
if game.get("runtime") == "tomie/1":
|
||||||
|
from app.converter.tomie_format import unpack_modules
|
||||||
|
from app.converter.tomie_validation import validate_runtime
|
||||||
|
native = unpack_modules(scripts)
|
||||||
|
issues = validate_runtime({"scripts": native})
|
||||||
|
if issues:
|
||||||
|
raise ValueError(issues[0].message + " (" + issues[0].location + ")")
|
||||||
|
config = game.get("tomie")
|
||||||
|
if not isinstance(config, dict) or not isinstance(config.get("defaults"), dict) or not isinstance(config.get("function_data"), dict):
|
||||||
|
raise ValueError("Отсутствуют исходные настройки Tomie")
|
||||||
|
start = config.get("start", {})
|
||||||
|
script = native.get(start.get("script"))
|
||||||
|
if script is None or start.get("label", "") not in script["labels"]:
|
||||||
|
raise ValueError("Точка входа Tomie не найдена")
|
||||||
|
media = config.get("media")
|
||||||
|
if not isinstance(media, dict):
|
||||||
|
raise ValueError("Не найден индекс медиа Tomie")
|
||||||
|
for collection in ("backgrounds", "characters", "textures", "videos", "audio", "fonts"):
|
||||||
|
if not isinstance(media.get(collection), dict):
|
||||||
|
raise ValueError("Некорректный индекс медиа Tomie: " + collection)
|
||||||
|
for entry in media[collection].values():
|
||||||
|
path = entry.get("file") if isinstance(entry, dict) else entry
|
||||||
|
if not isinstance(path, str) or path not in manifest["files"]:
|
||||||
|
raise ValueError("Отсутствует медиа Tomie: " + str(path))
|
||||||
files = manifest["files"]
|
files = manifest["files"]
|
||||||
for path, entry in files.items():
|
for path, entry in files.items():
|
||||||
check_path(path)
|
check_path(path)
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ async def upload_version(
|
|||||||
version.status = "queued"
|
version.status = "queued"
|
||||||
version.progress = "В очереди на проверку совместимости"
|
version.progress = "В очереди на проверку совместимости"
|
||||||
version.report = {"engine": probe["engine"], "compatible": None}
|
version.report = {"engine": probe["engine"], "compatible": None}
|
||||||
|
if getattr(game, "main_version_id", None) is None:
|
||||||
|
game.engine = probe["engine"]
|
||||||
version.size_bytes = size
|
version.size_bytes = size
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return version
|
return version
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ def _build_index(game_hash: str, scripts: tuple[tuple[str, str], ...]) -> TextIn
|
|||||||
idx.strings.setdefault(item["text"], "menu")
|
idx.strings.setdefault(item["text"], "menu")
|
||||||
elif op == "show" and isinstance((node.get("img") or {}).get("text"), str):
|
elif op == "show" and isinstance((node.get("img") or {}).get("text"), str):
|
||||||
idx.strings.setdefault(node["img"]["text"], "text")
|
idx.strings.setdefault(node["img"]["text"], "text")
|
||||||
elif op in {"say", "nani_add_choice"} and node.get("tid"):
|
elif op in {"say", "nani_add_choice", "nani_command"} and node.get("tid") and "what" in node:
|
||||||
who = node.get("whoStr") or ""
|
who = node.get("whoStr") or ""
|
||||||
if not who and node.get("who"):
|
if not who and node.get("who"):
|
||||||
c = chars.get(node["who"], {})
|
c = chars.get(node["who"], {})
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ async def _convert(version_id: int, source: Path, filename: str) -> bool:
|
|||||||
version.report = result.report
|
version.report = result.report
|
||||||
version.size_bytes = result.size
|
version.size_bytes = result.size
|
||||||
game = await db.get(Game, version.game_id)
|
game = await db.get(Game, version.game_id)
|
||||||
if game:
|
if game and game.main_version_id in (None, version.id):
|
||||||
game.engine = result.report.get("engine", "renpy")
|
game.engine = result.report.get("engine", "renpy")
|
||||||
if game and not game.cover_hash and result.cover_source:
|
if game and not game.cover_hash and result.cover_source:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ pyjwt==2.10.1
|
|||||||
pillow==11.2.1
|
pillow==11.2.1
|
||||||
UnityPy==1.25.4
|
UnityPy==1.25.4
|
||||||
TypeTreeGeneratorAPI==0.0.10
|
TypeTreeGeneratorAPI==0.0.10
|
||||||
|
dnfile==0.18.0
|
||||||
|
dncil==1.0.2
|
||||||
@@ -85,6 +85,23 @@ def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason):
|
|||||||
require_supported_scripts(data)
|
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_source_encoding_is_preserved_or_explicitly_rejected(tmp_path):
|
||||||
|
from app.converter.source import read_source
|
||||||
|
from app.converter.rpy_lexer import ParseError
|
||||||
|
source = tmp_path / "script.rpy"
|
||||||
|
source.write_bytes('# coding: cp1251\nlabel start:\n "Привет"'.encode("cp1251"))
|
||||||
|
assert "Привет" in read_source(source)
|
||||||
|
source.write_bytes(b'label start:\n "\xff"')
|
||||||
|
with pytest.raises(ParseError, match="кодировку"):
|
||||||
|
read_source(source)
|
||||||
|
|
||||||
|
|
||||||
def test_directory_rejection_precedes_copying_media(tmp_path, monkeypatch):
|
def test_directory_rejection_precedes_copying_media(tmp_path, monkeypatch):
|
||||||
write(tmp_path / "source/game/script.rpy", 'label start:\n call screen map')
|
write(tmp_path / "source/game/script.rpy", 'label start:\n call screen map')
|
||||||
def media(*args, **kwargs):
|
def media(*args, **kwargs):
|
||||||
@@ -191,8 +208,9 @@ def test_naninovel_resources_keep_keys_delimiters_and_context():
|
|||||||
records = text_records("Tips", "# comment\nHeading\nHelp:Title|Description\n", "Tips")
|
records = text_records("Tips", "# comment\nHeading\nHelp:Title|Description\n", "Tips")
|
||||||
assert [r["text"] for r in records.values()] == ["Title", "Description"]
|
assert [r["text"] for r in records.values()] == ["Title", "Description"]
|
||||||
assert len(text_records("_synonyms", "word=hello,hi\n", "synonyms")) == 2
|
assert len(text_records("_synonyms", "word=hello,hi\n", "synonyms")) == 2
|
||||||
with pytest.raises(ValueError, match="Повторяющийся"):
|
duplicates = text_records("Tips", "Key:A\nKey:B", "Tips")
|
||||||
text_records("DefaultUI", "Key:A\nKey:B", "UI")
|
assert [r["text"] for r in duplicates.values()] == ["A", "B"]
|
||||||
|
assert [r["occurrence"] for r in duplicates.values()] == [0, 1]
|
||||||
info = LineInfo("id", "node", "script/resources.json", "resources", "", "<b>Hello {name}</b>", None,
|
info = LineInfo("id", "node", "script/resources.json", "resources", "", "<b>Hello {name}</b>", None,
|
||||||
resource={"delimiters": "|\r\n"})
|
resource={"delimiters": "|\r\n"})
|
||||||
validate_nani_translation(info, "<b>Привет {name}</b>")
|
validate_nani_translation(info, "<b>Привет {name}</b>")
|
||||||
@@ -241,6 +259,34 @@ def test_unity_codec_restores_global_hooks_after_error():
|
|||||||
assert (helper.read_value, helper.write_value, helper.read_typetree_boost, helper.get_ref_type_node) == before
|
assert (helper.read_value, helper.write_value, helper.read_typetree_boost, helper.get_ref_type_node) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_unity_package_closes_cyclic_readers_and_optional_none_fields(tmp_path):
|
||||||
|
handle = (tmp_path / "asset").open("wb")
|
||||||
|
reader = SimpleNamespace(stream=handle, files=None, cabs=None)
|
||||||
|
asset = SimpleNamespace(reader=reader, files=None, cabs={"same": reader})
|
||||||
|
environment = SimpleNamespace(files={"asset": asset}, cabs={"asset": asset})
|
||||||
|
original = Package(environments=[environment], resources=[asset])
|
||||||
|
original.close()
|
||||||
|
assert handle.closed
|
||||||
|
assert not original.resources and not original.environments
|
||||||
|
(tmp_path / "asset").unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unused_renpy_translation_entries_are_not_script_blockers(tmp_path):
|
||||||
|
from app.converter import locales
|
||||||
|
from app.converter.rpy_parser import ScriptData
|
||||||
|
data = ScriptData()
|
||||||
|
data.modules = {"script.rpy": [{"name": "start", "body": [{"op": "say", "what": "Hello", "_ln": 1}]}]}
|
||||||
|
by_line = locales.assign_ids(data.modules)
|
||||||
|
path = tmp_path / "tl/russian/script.rpy"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text('# game/script.rpy:1\ntranslate russian start_123:\n "Привет"\n\n\n\n\n\n'
|
||||||
|
'translate russian removed_456:\n "Старая реплика"\n', encoding="utf-8")
|
||||||
|
translations = locales.import_tl(tmp_path, data.modules, by_line, data.warn)
|
||||||
|
assert translations["ru"].unmatched == 1
|
||||||
|
assert not data.blockers
|
||||||
|
assert any("реплики без пары" in message for message in data.warnings)
|
||||||
|
|
||||||
|
|
||||||
def test_upload_rejects_unsupported_engine_without_queueing(tmp_path, monkeypatch):
|
def test_upload_rejects_unsupported_engine_without_queueing(tmp_path, monkeypatch):
|
||||||
from app.routers import versions
|
from app.routers import versions
|
||||||
from app.uploads import upload_path
|
from app.uploads import upload_path
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_translation_and_mod_edits_rebuild_the_same_vm_input(tmp_path, monkeypatch):
|
||||||
|
from app import mods
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.converter.naninovel import _convert_package
|
||||||
|
from app.converter.nani_package import Package, text_records
|
||||||
|
from app.converter.tomie_format import unpack_modules
|
||||||
|
from app.converter.tomie_profile import PROFILE
|
||||||
|
from app.translation import apply_changes, text_index, locale_tables
|
||||||
|
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
|
||||||
|
get_settings.cache_clear()
|
||||||
|
package = Package(profile=PROFILE, defaults={"points": 3}, function_data={"names": ["Tomie"]})
|
||||||
|
|
||||||
|
def script(name, label, commands):
|
||||||
|
return {"name": name, "lines": [{"line": 1, "label": label, "commands": commands}]}
|
||||||
|
|
||||||
|
package.scripts = {
|
||||||
|
"StartGameSetting": script("StartGameSetting", "Start", [
|
||||||
|
{"op": "PrintText", "args": {"Text": {"template": "Points: {points}"}}},
|
||||||
|
{"op": "ExitToTitle", "args": {}}]),
|
||||||
|
"MessagePlay": script("MessagePlay", "Start", [{"op": "Return", "args": {}}]),
|
||||||
|
"AppScreen": script("AppScreen", "Start", [{"op": "Stop", "args": {}}]),
|
||||||
|
}
|
||||||
|
package.texts = text_records("Tips", "tip:Title|Description {points}", "Tips")
|
||||||
|
media = {key: {} for key in ("backgrounds", "characters", "textures", "videos", "audio", "fonts")}
|
||||||
|
monkeypatch.setattr("app.converter.tomie_assets.export_media", lambda *_: ({}, media, {}))
|
||||||
|
try:
|
||||||
|
result = _convert_package(tmp_path, tmp_path / "work", lambda _: None, package)
|
||||||
|
index = text_index(result.manifest)
|
||||||
|
assert {line.source for line in index.lines} == {"Points: {points}", "Title", "Description {points}"}
|
||||||
|
line = next(line for line in index.lines if line.source.startswith("Points:"))
|
||||||
|
translated = apply_changes(result.manifest, "ru", lines={line.tid: {"t": "Баллы: {points}"}})
|
||||||
|
assert locale_tables(translated, "ru")[0][line.tid]["t"] == "Баллы: {points}"
|
||||||
|
doc = mods.document(translated)
|
||||||
|
mods.validate(doc, translated)
|
||||||
|
node = next(node for module in doc["scripts"].values() for label in module["labels"]
|
||||||
|
for node in label["body"] if node.get("tid") == line.tid)
|
||||||
|
node["what"] = "Score: {points}"
|
||||||
|
updated = mods.store(doc, translated)
|
||||||
|
runtime = unpack_modules(mods.document(updated)["scripts"])
|
||||||
|
assert runtime["StartGameSetting"]["commands"][0]["args"]["Text"] == {"template": "Score: {points}"}
|
||||||
|
module = next(module for module in doc["scripts"].values() if module.get("nani", {}).get("name") == "StartGameSetting")
|
||||||
|
module["labels"][0]["body"].insert(0, {"id": "inserted", "op": "nani_command", "command": {
|
||||||
|
"op": "SetCustomVariable", "args": {"Expression": "points+=1"}}})
|
||||||
|
assert unpack_modules(doc["scripts"])["StartGameSetting"]["labels"]["Start"] == 1
|
||||||
|
mods.validate(doc, translated)
|
||||||
|
node["command"]["op"] = "NativePlugin"
|
||||||
|
with pytest.raises(ValueError, match="NativePlugin"):
|
||||||
|
mods.validate(doc, translated)
|
||||||
|
finally:
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_recovery_checks_ranges_and_copies_only_declared_bytes(tmp_path):
|
||||||
|
from app.converter.tomie_assets import recover_object
|
||||||
|
data = tmp_path / "data"
|
||||||
|
out = tmp_path / "out"
|
||||||
|
data.mkdir(); out.mkdir()
|
||||||
|
original = b"header-clip-trailer"
|
||||||
|
(data / "resources.resource").write_bytes(original)
|
||||||
|
tree = {"m_OriginalPath": "Assets/Video/clip.webm", "m_ExternalResources": {
|
||||||
|
"m_Source": "resources.resource", "m_Offset": 7, "m_Size": 4}}
|
||||||
|
obj = SimpleNamespace(type=SimpleNamespace(name="VideoClip"), read=lambda: SimpleNamespace(m_Name="Clip"),
|
||||||
|
read_typetree=lambda: tree)
|
||||||
|
recovered = recover_object(obj, data, out, "video")
|
||||||
|
assert recovered["path"].read_bytes() == b"clip"
|
||||||
|
assert (data / "resources.resource").read_bytes() == original
|
||||||
|
tree["m_ExternalResources"]["m_Size"] = len(original)
|
||||||
|
with pytest.raises(ValueError, match="диапазон"):
|
||||||
|
recover_object(obj, data, out, "invalid")
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.converter.nani_expr import assignment, parse
|
||||||
|
from app.converter.tomie_profile import ASSEMBLY_HASH, FUNCTIONS, constant_value, extract_tables, identify
|
||||||
|
|
||||||
|
|
||||||
|
def instruction(op, operand=None):
|
||||||
|
return SimpleNamespace(mnemonic=op, operand=operand)
|
||||||
|
|
||||||
|
|
||||||
|
def test_il_table_reader_accepts_constants_and_refuses_code():
|
||||||
|
code = [instruction("ldc.i4.2"), instruction("newarr"), instruction("dup"), instruction("ldc.i4.0"),
|
||||||
|
instruction("ldstr", SimpleNamespace(rid=1)), instruction("stelem.ref"),
|
||||||
|
instruction("dup"), instruction("ldc.i4.1"), instruction("ldstr", SimpleNamespace(rid=2)), instruction("stelem.ref")]
|
||||||
|
assert constant_value(code, lambda rid: {1: "A", 2: "B"}[rid]) == ["A", "B"]
|
||||||
|
with pytest.raises(ValueError, match="инициализация"):
|
||||||
|
constant_value([instruction("call")], lambda _: "")
|
||||||
|
with pytest.raises(ValueError, match="Неполная"):
|
||||||
|
constant_value([instruction("ldc.i4.1"), instruction("newarr")], lambda _: "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_tomie_profile_requires_exact_assembly(tmp_path):
|
||||||
|
path = tmp_path / "TomieWGM_Data/Managed/Assembly-CSharp.dll"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_bytes(b"a renamed unrelated game")
|
||||||
|
assert identify(tmp_path) is None
|
||||||
|
with pytest.raises(ValueError, match="не соответствует"):
|
||||||
|
extract_tables(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_expression_functions_are_opt_in():
|
||||||
|
with pytest.raises(ValueError, match="WillpowerDamage"):
|
||||||
|
parse("WillpowerDamage(100, 50, 20, 0, 100)")
|
||||||
|
assert parse("WillpowerDamage(100, 50, 20, 0, 100)", FUNCTIONS)[0] == "call"
|
||||||
|
assert parse('\\"quoted\\"', FUNCTIONS) == ["const", "quoted"]
|
||||||
|
assert parse('If(false, 1/0, 5)', FUNCTIONS)[0] == "if"
|
||||||
|
assert assignment("points+=3", FUNCTIONS)["v"]["ast"] == ["bin", "+", ["name", "points"], ["const", 3]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_vm_validation_rejects_unimplemented_and_broken_commands():
|
||||||
|
from app.converter.tomie_validation import validate_runtime
|
||||||
|
data = {"scripts": {"Main": {"labels": {"Start": 0}, "commands": [
|
||||||
|
{"op": "AddChoice", "args": {"ChoiceSummary": "OK", "OnSelected": ['@set result=If(true, 1, 2)']}, "line": 1},
|
||||||
|
{"op": "Goto", "args": {"Path": ".Missing"}, "line": 2},
|
||||||
|
{"op": "PlayBgm", "args": {"BgmPath": "song", "CustomPlugin": True}, "line": 3},
|
||||||
|
{"op": "NativeCode", "args": {}, "line": 4},
|
||||||
|
{"op": "BeginIf", "args": {"Expression": "true"}, "line": 5},
|
||||||
|
{"op": "AddChoice", "args": {"OnSelected": ['@set points+1.5']}, "line": 6},
|
||||||
|
]}}}
|
||||||
|
issues = validate_runtime(data)
|
||||||
|
assert {item.code for item in issues} == {"missing_target", "unsupported_argument", "unsupported_command", "invalid_branch", "unsupported_choice"}
|
||||||
|
assert not any(item.location == "Main:1" for item in issues)
|
||||||
|
|
||||||
|
|
||||||
|
def test_choice_templates_and_quoted_commas_are_validated_without_evaluating_them():
|
||||||
|
from app.converter.tomie_validation import inline_commands, validate_runtime
|
||||||
|
assert inline_commands('@set x=If(true, "a,b", "c"), @return') == ['@set x=If(true, "a,b", "c")', '@return']
|
||||||
|
data = {"scripts": {"Main": {"labels": {}, "commands": [
|
||||||
|
{"op": "AddChoice", "args": {"OnSelected": {"template": '@set result="{answer}"'}}},
|
||||||
|
{"op": "AddChoice", "args": {"OnSelected": ['@goto Other.Start']}},
|
||||||
|
]}, "Other": {"labels": {"Start": 0}, "commands": []}}}
|
||||||
|
assert validate_runtime(data) == []
|
||||||
|
with pytest.raises(ValueError, match="Незакрытая"):
|
||||||
|
inline_commands('@set x=If(true, 1')
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_effect_and_ui_extensions_are_rejected_before_media_export():
|
||||||
|
from app.converter.tomie_validation import validate_runtime
|
||||||
|
data = {"scripts": {"Main": {"labels": {}, "commands": [
|
||||||
|
{"op": "Spawn", "args": {"Path": "NativePlugin"}},
|
||||||
|
{"op": "ShowUI", "args": {"UINames": ["CustomScreen"]}},
|
||||||
|
{"op": "EndIf", "args": {}},
|
||||||
|
]}}}
|
||||||
|
assert {issue.code for issue in validate_runtime(data)} == {"unsupported_effect", "unsupported_ui"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not os.getenv("PGAMES_TOMIE_ORIGINAL"), reason="Requires the user's original Tomie build")
|
||||||
|
def test_original_il_tables_match_the_recovered_port():
|
||||||
|
from hashlib import sha256
|
||||||
|
root = Path(os.environ["PGAMES_TOMIE_ORIGINAL"])
|
||||||
|
assert sha256((root / "TomieWGM_Data/Managed/Assembly-CSharp.dll").read_bytes()).hexdigest() == ASSEMBLY_HASH
|
||||||
|
tables = extract_tables(root)
|
||||||
|
assert {key: len(value) for key, value in tables.items()} == {"names": 2738, "normalComment": 55, "lewdComment": 181, "videos": 191}
|
||||||
|
if os.getenv("PGAMES_TOMIE_PORT"):
|
||||||
|
reference = json.loads((Path(os.environ["PGAMES_TOMIE_PORT"]) / "story/function_data.json").read_text(encoding="utf-8"))
|
||||||
|
assert tables == reference
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
# Автоматический импорт и проверка совместимости
|
||||||
|
|
||||||
|
PGames исполняет восстановленные сценарии в браузере. Наличие ресурсов игры
|
||||||
|
или распознанного движка само по себе не означает, что игра совместима.
|
||||||
|
|
||||||
|
## Порядок обработки
|
||||||
|
|
||||||
|
После получения архива API читает список файлов. Неизвестный движок,
|
||||||
|
неподдерживаемые Unity, Godot, GameMaker и RPG Maker отклоняются до очереди
|
||||||
|
конвертации. EXE-файлы не запускаются и не принимаются вместо архива игры.
|
||||||
|
|
||||||
|
Воркер проверяет сценарии перед конвертацией медиа. Для архивов Ren'Py сначала
|
||||||
|
извлекаются только сценарии и RPA; отдельно лежащие изображения, аудио и видео
|
||||||
|
на этом этапе не распаковываются. RPA со сценариями необходимо прочитать из
|
||||||
|
внешнего архива, но из самого RPA для проверки извлекаются только скрипты.
|
||||||
|
Для уже распакованных игр проверка выполняется до копирования медиа.
|
||||||
|
|
||||||
|
Определения неподдерживаемых функций и классов, вызовы отсутствующих функций,
|
||||||
|
переходы к отсутствующим меткам, выполняемые screen-команды, динамические ATL
|
||||||
|
и ошибки декомпиляции останавливают импорт. Объявление неиспользуемого screen
|
||||||
|
само по себе не является выполнением экрана. Кодировки сценариев читаются по
|
||||||
|
объявлению `coding:` либо как UTF-8; повреждённые символы не заменяются молча.
|
||||||
|
|
||||||
|
Ошибка имеет `engine`, `compatible: false` и список `issues` с кодом, причиной,
|
||||||
|
местом и количеством обнаруженных случаев. В интерфейсе версии доступны все
|
||||||
|
причины остановки. Неподдерживаемая игра не получает статус `ready`.
|
||||||
|
|
||||||
|
Проверка сценариев не является доказательством прохождения всех веток игры.
|
||||||
|
Она обнаруживает известные неподдерживаемые конструкции и явные ошибки.
|
||||||
|
Ошибка обработки медиа также останавливает конвертацию. Промежуточные блобы
|
||||||
|
не опубликованы в манифесте; их впоследствии удаляет сборщик мусора.
|
||||||
|
|
||||||
|
## Коллекция на диске
|
||||||
|
|
||||||
|
Команды выполняются из каталога `backend` в окружении с зависимостями проекта,
|
||||||
|
FFmpeg, 7-Zip и настроенным `UNRPYC_PATH`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m app.import_library "D:\Torrents\Pgames" --check --output report.json
|
||||||
|
python -m app.import_library "D:\Torrents\Pgames" --import --owner admin --output report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`--check` не создаёт карточек или версий и не изменяет исходные игры.
|
||||||
|
`--import` создаёт версии только для прошедших проверку игр. Обработка
|
||||||
|
коллекции продолжается после отказа отдельной игры. При наличии хотя бы одного
|
||||||
|
отказа команда завершается с кодом 2. Полный JSON-отчёт обновляется после каждой
|
||||||
|
игры. Для одноимённых папки и архива проверяется распакованная папка.
|
||||||
|
Повторный запуск `--import` создаёт новые карточки: это не синхронизатор каталога.
|
||||||
|
|
||||||
|
Нужно подключить каталог коллекции к контейнеру для использования команды
|
||||||
|
на сервере. Путь клиента Windows автоматически не становится доступен серверу.
|
||||||
|
Входные каталоги используются только для чтения; временная распаковка и
|
||||||
|
декомпиляция происходят отдельно.
|
||||||
|
|
||||||
|
## Текущая поддержка Naninovel
|
||||||
|
|
||||||
|
Добавлен импорт скомпилированных сценариев из Unity-ресурсов Mono-сборок
|
||||||
|
Naninovel. Код игры не запускается. Для поддерживаемого старого формата
|
||||||
|
managed references схемы берутся из сохранённых метаданных или DLL игры,
|
||||||
|
результат чтения проверяется побайтным циклом записи. Неизвестная схема или
|
||||||
|
различающиеся копии сценария приводят к отказу.
|
||||||
|
|
||||||
|
Поддерживается ограниченное подмножество: реплики и продолжения реплик,
|
||||||
|
переменные, безопасно разобранные выражения, if/else, статические переходы
|
||||||
|
и подпрограммы, выборы с `GotoPath`, паузы, показ и скрытие текста,
|
||||||
|
статические изображения/персонажи, базовые аудиокоманды и выход в меню.
|
||||||
|
Все параметры команды проверяются; неизвестные параметры также вызывают отказ.
|
||||||
|
Изображения и аудио разрешаются по однозначному имени ресурса. Для иных схем
|
||||||
|
ресурсов, внешних интерфейсов и игровых расширений нужен дополнительный адаптер.
|
||||||
|
|
||||||
|
Служебные выражения не исполняются как Python или JavaScript: сервер разбирает
|
||||||
|
их в AST, а браузер вычисляет разрешённые операции. Текст переводится до
|
||||||
|
подстановки переменных. Переменные и накопленные варианты выбора входят в
|
||||||
|
состояние сохранения общего плеера.
|
||||||
|
|
||||||
|
Извлекаются адресуемые текстовые записи из `DefaultUI`, `Tips`, `CharacterNames`,
|
||||||
|
`Inventory`, файлов `StreamingAssets/text` и соответствующих встроенных TextAsset.
|
||||||
|
Части подсказок и синонимы имеют отдельные записи. Ключи и разделители остаются
|
||||||
|
в метаданных. Редактор переводов использует устойчивые ID записей; изменение
|
||||||
|
выражений, тегов и служебных разделителей отклоняется при сохранении перевода.
|
||||||
|
Имена персонажей используют словарь `CharacterNames` при его наличии.
|
||||||
|
|
||||||
|
### Tomie
|
||||||
|
|
||||||
|
Добавлен отдельный адаптер `tomie/1` для Expansion 1.3801. Он выбирается по
|
||||||
|
SHA-256 оригинальной `Assembly-CSharp.dll`; переименованная или новая сборка
|
||||||
|
не получает этот профиль автоматически. Таблицы имён, комментариев и видео
|
||||||
|
читаются из констант IL без запуска DLL. Исходные переменные восстанавливаются
|
||||||
|
из `CustomVariablesConfiguration`, образы — по ключам Unity ResourceManager.
|
||||||
|
Дубли Texture2D/Sprite под одним ключом разрешаются в пользу полной текстуры.
|
||||||
|
|
||||||
|
Плеер использует исходный список команд Naninovel. Проверены создание персонажа
|
||||||
|
до поля на извлечённых Unity-сценариях и цепочки дня/ночи на данных Godot-порта.
|
||||||
|
Перенесены игровые функции, динамические обработчики выбора, поле и кубики,
|
||||||
|
инвентарь, сообщения, сцена, видео, эффекты и мини-игра. Сохранение включает стек
|
||||||
|
подпрограмм, очередь команд, варианты выбора, инвентарь, генератор случайных
|
||||||
|
чисел, состояние сцены и мини-игры. Ветвления соответствуют реализации в DLL:
|
||||||
|
`BeginIf`/`Else` ищут следующую ветку в списке команд, `EndIf` — пустая команда.
|
||||||
|
|
||||||
|
Исходные команды хранятся в редактируемых модулях PGVN; видимый текст индексируется
|
||||||
|
общим редактором переводов. При запуске VM собирается заново из модулей, поэтому
|
||||||
|
редактирование текста мода не оставляет устаревшую копию сценария в плеере.
|
||||||
|
Адреса меток привязаны к ID команд: вставка команд не смещает переходы;
|
||||||
|
удаление команды, на которую указывает метка, требует исправления адреса.
|
||||||
|
Неизвестные команды, параметры, эффекты и интерфейсы останавливают импорт.
|
||||||
|
Предпросмотр Tomie с отдельной строки пока явно отклоняется; запуск с начала
|
||||||
|
поддерживается.
|
||||||
|
|
||||||
|
**Оригинальная папка Tomie пока не проходит строгую проверку целиком.** После
|
||||||
|
учёта исходной семантики ветвлений остаётся 31 диагностируемый случай:
|
||||||
|
22 отсутствующих адреса переходов, 6 условных блоков без завершающего `EndIf`
|
||||||
|
и 3 выражения выбора без поддерживаемого присваивания. Например, сценарии
|
||||||
|
обращаются к отсутствующему `B_Rest.Start` и к `AB_Studio.SkillUp`, хотя
|
||||||
|
`SkillUp` присутствует в `Common`. Автоматическая подмена сюжета не выполняется.
|
||||||
|
Импорт останавливается перед экспортом медиа, статус `ready` не выставляется.
|
||||||
|
Полное прохождение оригинальной игры и экспорт всего её медиакаталога ещё
|
||||||
|
не подтверждены; наличие адаптера не считается доказательством совместимости.
|
||||||
|
|
||||||
|
Проверка браузерного интерфейса находится в `frontend/tests/tomie-browser.test.mjs`.
|
||||||
|
Она использует уже установленный Playwright через `PGAMES_PLAYWRIGHT_PATH`;
|
||||||
|
при необходимости путь к Chromium задаётся `PGAMES_CHROMIUM_EXECUTABLE`.
|
||||||
|
Тесты оригинальных данных включаются переменными `PGAMES_TOMIE_ORIGINAL`
|
||||||
|
(папка дистрибутива), `PGAMES_TOMIE_PORT` (папка Godot-порта),
|
||||||
|
`PGAMES_TOMIE_FIXTURE` (его `story/opening.json`) и `PGAMES_TOMIE_RUNTIME`
|
||||||
|
(восстановленный `runtime_data` в JSON). Эти файлы не включаются в репозиторий.
|
||||||
|
|
||||||
|
Каталоги `.local-translator`, `migration-staging` и `migration-tools` не
|
||||||
|
считаются отдельными играми. Импорт читает текущие ресурсы дистрибутива:
|
||||||
|
установленный переводческий патч автоматически не откатывается, резервные
|
||||||
|
копии не подставляются вместо исходников без отдельной обработки профиля.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import type { Engine } from "../player/engine";
|
import type { PlayerHandle } from "../player";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
|
|
||||||
export function Play() {
|
export function Play() {
|
||||||
@@ -11,7 +11,7 @@ export function Play() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let engine: Engine | null = null;
|
let engine: PlayerHandle | null = null;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
// the player is a separate chunk: the library UI does not pay for it
|
// the player is a separate chunk: the library UI does not pay for it
|
||||||
import("../player").then(({ launch }) =>
|
import("../player").then(({ launch }) =>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface Channel {
|
|||||||
file: string | null;
|
file: string | null;
|
||||||
loop: boolean;
|
loop: boolean;
|
||||||
queue: string[];
|
queue: string[];
|
||||||
|
volume: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fade(from: number, to: number, seconds: number, update: (gain: number) => void,
|
function fade(from: number, to: number, seconds: number, update: (gain: number) => void,
|
||||||
@@ -34,6 +35,7 @@ function fade(from: number, to: number, seconds: number, update: (gain: number)
|
|||||||
interface Playback {
|
interface Playback {
|
||||||
channel: string;
|
channel: string;
|
||||||
gain: number;
|
gain: number;
|
||||||
|
volume: number;
|
||||||
cancelFade: () => void;
|
cancelFade: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,20 +57,21 @@ export class AudioSystem {
|
|||||||
private channel(name: string): Channel {
|
private channel(name: string): Channel {
|
||||||
let c = this.channels.get(name);
|
let c = this.channels.get(name);
|
||||||
if (!c) {
|
if (!c) {
|
||||||
c = { el: null, file: null, loop: false, queue: [] };
|
c = { el: null, file: null, loop: false, queue: [], volume: 1 };
|
||||||
this.channels.set(name, c);
|
this.channels.set(name, c);
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
play(ch: string, files: string[], opts: { loop?: boolean | null; fadein?: number; fadeout?: number;
|
play(ch: string, files: string[], opts: { loop?: boolean | null; fadein?: number; fadeout?: number;
|
||||||
ifChanged?: boolean }) {
|
ifChanged?: boolean; volume?: number }) {
|
||||||
const c = this.channel(ch);
|
const c = this.channel(ch);
|
||||||
const loop = opts.loop ?? this.registered[ch]?.loop ?? ch === "music";
|
const loop = opts.loop ?? this.registered[ch]?.loop ?? ch === "music";
|
||||||
if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return;
|
if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return;
|
||||||
this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0));
|
this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0));
|
||||||
c.queue = files.slice(1);
|
c.queue = files.slice(1);
|
||||||
c.loop = loop;
|
c.loop = loop;
|
||||||
|
c.volume = Number.isFinite(opts.volume) ? Math.max(0, Math.min(1, opts.volume!)) : 1;
|
||||||
this.start(ch, c, files[0], opts.fadein ?? 0);
|
this.start(ch, c, files[0], opts.fadein ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +93,7 @@ export class AudioSystem {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const el = new Audio(src);
|
const el = new Audio(src);
|
||||||
const playback: Playback = { channel: ch, gain: fadein > 0 ? 0 : 1, cancelFade: () => {} };
|
const playback: Playback = { channel: ch, gain: fadein > 0 ? 0 : 1, volume: c.volume, cancelFade: () => {} };
|
||||||
this.playing.set(el, playback);
|
this.playing.set(el, playback);
|
||||||
this.applyVolume(el, playback);
|
this.applyVolume(el, playback);
|
||||||
el.loop = c.loop && c.queue.length === 0;
|
el.loop = c.loop && c.queue.length === 0;
|
||||||
@@ -114,7 +117,7 @@ export class AudioSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyVolume(el: HTMLAudioElement, playback: Playback) {
|
private applyVolume(el: HTMLAudioElement, playback: Playback) {
|
||||||
el.volume = Math.max(0, Math.min(1, this.volumes[this.group(playback.channel)] * playback.gain));
|
el.volume = Math.max(0, Math.min(1, this.volumes[this.group(playback.channel)] * playback.gain * playback.volume));
|
||||||
}
|
}
|
||||||
|
|
||||||
private fadePlayback(el: HTMLAudioElement, playback: Playback, to: number, seconds: number,
|
private fadePlayback(el: HTMLAudioElement, playback: Playback, to: number, seconds: number,
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ export interface SaveMeta {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveApi {
|
export interface SaveApi<T = SaveData> {
|
||||||
list(): Promise<SaveMeta[]>;
|
list(): Promise<SaveMeta[]>;
|
||||||
get(slot: string): Promise<SaveData | null>;
|
get(slot: string): Promise<T | null>;
|
||||||
put(slot: string, title: string, data: SaveData): Promise<void>;
|
put(slot: string, title: string, data: T): Promise<void>;
|
||||||
remove(slot: string): Promise<void>;
|
remove(slot: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,6 +799,10 @@ export class Engine {
|
|||||||
|
|
||||||
private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } {
|
private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } {
|
||||||
const who: string | undefined = node.who;
|
const who: string | undefined = node.who;
|
||||||
|
if (this.game.engine === "naninovel" && node.whoStr !== undefined) {
|
||||||
|
const entry = this.game.naniResources?.CharacterNames?.[node.whoStr]?.[0];
|
||||||
|
return { char: {}, name: this.interp(entry ? this.tr.line(entry.tid, entry.text) : this.tr.str(node.whoStr)), mode: "adv" };
|
||||||
|
}
|
||||||
if (node.whoStr !== undefined) return { char: {}, name: this.interp(this.tr.str(node.whoStr)), mode: "adv" };
|
if (node.whoStr !== undefined) return { char: {}, name: this.interp(this.tr.str(node.whoStr)), mode: "adv" };
|
||||||
if (!who || who === "narrator") return { char: {}, name: "", mode: "adv" };
|
if (!who || who === "narrator") return { char: {}, name: "", mode: "adv" };
|
||||||
if (who === "centered" || who === "vcentered") return { char: {}, name: "", mode: "centered" };
|
if (who === "centered" || who === "vcentered") return { char: {}, name: "", mode: "centered" };
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function blob(manifest: Manifest, path: string) {
|
|||||||
return `/api/versions/${manifest.versionId}/files/${f.hash}`;
|
return `/api/versions/${manifest.versionId}/files/${f.hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveApi(versionId: number): SaveApi {
|
function saveApi<T = SaveData>(versionId: number): SaveApi<T> {
|
||||||
const base = `/api/versions/${versionId}/saves`;
|
const base = `/api/versions/${versionId}/saves`;
|
||||||
return {
|
return {
|
||||||
list: () => getJSON(base),
|
list: () => getJSON(base),
|
||||||
@@ -23,7 +23,7 @@ function saveApi(versionId: number): SaveApi {
|
|||||||
const r = await fetch(`${base}/${slot}`);
|
const r = await fetch(`${base}/${slot}`);
|
||||||
if (r.status === 404) return null;
|
if (r.status === 404) return null;
|
||||||
if (!r.ok) throw new Error(`${r.status}`);
|
if (!r.ok) throw new Error(`${r.status}`);
|
||||||
return (await r.json()).data as SaveData;
|
return (await r.json()).data as T;
|
||||||
},
|
},
|
||||||
async put(slot, title, data) {
|
async put(slot, title, data) {
|
||||||
const r = await fetch(`${base}/${slot}`, {
|
const r = await fetch(`${base}/${slot}`, {
|
||||||
@@ -39,8 +39,10 @@ function saveApi(versionId: number): SaveApi {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlayerHandle { destroy(): void }
|
||||||
|
|
||||||
export async function launch(root: HTMLElement, versionId: number, onExit: () => void,
|
export async function launch(root: HTMLElement, versionId: number, onExit: () => void,
|
||||||
onProgress: (text: string) => void): Promise<Engine> {
|
onProgress: (text: string) => void): Promise<PlayerHandle> {
|
||||||
onProgress("Загрузка манифеста");
|
onProgress("Загрузка манифеста");
|
||||||
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
|
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
|
||||||
const game = await getJSON<GameData>(blob(manifest, "game.json"));
|
const game = await getJSON<GameData>(blob(manifest, "game.json"));
|
||||||
@@ -53,6 +55,39 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
|
|||||||
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
|
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
|
||||||
loadTranslator(game, manifest, lang),
|
loadTranslator(game, manifest, lang),
|
||||||
]);
|
]);
|
||||||
|
if (game.runtime === "tomie/1") {
|
||||||
|
if (!game.tomie) throw new Error("Не найдены исходные настройки Tomie");
|
||||||
|
const { TomiePlayer } = await import("./tomie-player");
|
||||||
|
const scripts: import("./nani-runtime").NaniPackage["scripts"] = Object.create(null);
|
||||||
|
const resources: NonNullable<GameData["naniResources"]> = Object.create(null);
|
||||||
|
for (const module of modules) {
|
||||||
|
if (!module.nani) {
|
||||||
|
for (const node of module.labels.flatMap((label) => label.body)) {
|
||||||
|
const record = node.resource;
|
||||||
|
if (!record || typeof node.what !== "string" || typeof record.collection !== "string" || typeof record.key !== "string") continue;
|
||||||
|
const collection = resources[record.collection] ??= Object.create(null);
|
||||||
|
(collection[record.key] ??= []).push({ ...record, tid: node.tid, text: node.what });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nodes = module.labels.flatMap((label) => label.body);
|
||||||
|
const commands = nodes.map((node) => {
|
||||||
|
const command = structuredClone(node.command);
|
||||||
|
if (node.textField && typeof node.what === "string") command.args[node.textField] = { template: node.what };
|
||||||
|
command.tid = node.tid ?? command.tid; return command;
|
||||||
|
});
|
||||||
|
const labels = module.nani.label_ids ? Object.fromEntries(Object.entries(module.nani.label_ids).map(([label, id]) => {
|
||||||
|
const index = id ? nodes.findIndex((node) => node.id === id) : nodes.length;
|
||||||
|
if (index < 0) throw new Error("Не найдена команда метки Naninovel: " + label);
|
||||||
|
return [label, index];
|
||||||
|
})) : module.nani.labels;
|
||||||
|
scripts[module.nani.name] = { commands, labels, label_depths: module.nani.label_depths };
|
||||||
|
}
|
||||||
|
if (url.get("at")) throw new Error("Предпросмотр Tomie с отдельной строки ещё не поддерживается. Откройте игру с начала.");
|
||||||
|
const player = new TomiePlayer(root, { ...game.tomie, scripts, resources }, manifest,
|
||||||
|
saveApi<import("./tomie-player").TomieSave>(versionId), tr, onExit);
|
||||||
|
try { player.start(); return player; } catch (error) { player.destroy(); throw error; }
|
||||||
|
}
|
||||||
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
|
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
|
||||||
`pgvn:persistent:${versionId}`, onExit, tr,
|
`pgvn:persistent:${versionId}`, onExit, tr,
|
||||||
(code) => loadTranslator(game, manifest, code));
|
(code) => loadTranslator(game, manifest, code));
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Runtime parser for dynamically composed expressions. This accepts the same
|
||||||
|
// bounded language as the import parser; it never evaluates JavaScript source.
|
||||||
|
import type { NaniAst } from "./naninovel";
|
||||||
|
|
||||||
|
const precedence: Record<string, number> = { or: 2, "||": 2, and: 3, "&&": 3, "|": 4, "^": 5,
|
||||||
|
"&": 6, "==": 7, "=": 7, "!=": 7, "<>": 7, "<": 8, ">": 8, "<=": 8, ">=": 8,
|
||||||
|
"<<": 9, ">>": 9, "+": 10, "-": 10, "*": 11, "/": 11, "%": 11 };
|
||||||
|
|
||||||
|
export function parseNani(source: string, functions: Set<string>): NaniAst {
|
||||||
|
if (source.length > 30000) throw new Error("Naninovel: слишком длинное выражение");
|
||||||
|
source = source.trim().replace(/;$/, "").trim();
|
||||||
|
const tokens: { value: any; kind: string }[] = [];
|
||||||
|
for (let offset = 0; offset < source.length;) {
|
||||||
|
if (/\s/.test(source[offset])) { offset++; continue; }
|
||||||
|
const wrapped = source[offset] === "\\" && ["\"", "'"].includes(source[offset + 1]);
|
||||||
|
if (["\"", "'"].includes(source[offset]) || wrapped) {
|
||||||
|
const quote = source[offset + (wrapped ? 1 : 0)];
|
||||||
|
offset += wrapped ? 2 : 1;
|
||||||
|
let value = "", closed = false;
|
||||||
|
while (offset < source.length) {
|
||||||
|
let char = source[offset];
|
||||||
|
if (wrapped && char === "\\" && source[offset + 1] === quote) { offset += 2; closed = true; break; }
|
||||||
|
if (!wrapped && char === quote) { offset++; closed = true; break; }
|
||||||
|
if (char === "\\" && offset + 1 < source.length) {
|
||||||
|
char = source[++offset];
|
||||||
|
char = ({ n: "\n", r: "\r", t: "\t" } as Record<string, string>)[char] ?? char;
|
||||||
|
}
|
||||||
|
value += char; offset++;
|
||||||
|
}
|
||||||
|
if (!closed) throw new Error("Naninovel: не закрыта строка выражения");
|
||||||
|
tokens.push({ kind: "string", value }); continue;
|
||||||
|
}
|
||||||
|
const rest = source.slice(offset);
|
||||||
|
const numeric = rest.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
|
||||||
|
const name = rest.match(/^[A-Za-z_]\w*/);
|
||||||
|
const operator = rest.match(/^(?:&&|\|\||==|!=|<>|<=|>=|<<|>>|[()+\-*/%<>=!~?:,&|^])/);
|
||||||
|
const found = numeric ?? name ?? operator;
|
||||||
|
if (!found) throw new Error(`Naninovel: неизвестный токен ${rest.slice(0, 40)}`);
|
||||||
|
tokens.push({ kind: numeric ? "number" : name ? "name" : "operator", value: numeric ? Number(found[0]) : found[0] });
|
||||||
|
offset += found[0].length;
|
||||||
|
}
|
||||||
|
let cursor = 0, depth = 0;
|
||||||
|
const peek = () => tokens[cursor]?.kind === "string" ? "\u0000string" : tokens[cursor]?.value ?? "";
|
||||||
|
const take = (expected?: string) => {
|
||||||
|
if (cursor >= tokens.length || (expected !== undefined && peek() !== expected)) throw new Error(`Naninovel: ожидалось ${expected ?? "выражение"}`);
|
||||||
|
return tokens[cursor++];
|
||||||
|
};
|
||||||
|
const expression = (minimum = 1): NaniAst => {
|
||||||
|
if (++depth > 64) throw new Error("Naninovel: слишком глубокое выражение");
|
||||||
|
const token = take(), value = token.value;
|
||||||
|
let left: NaniAst;
|
||||||
|
if (token.kind !== "string" && ["-", "+", "!", "not", "~"].includes(value)) left = ["unary", value, expression(12)];
|
||||||
|
else if (token.kind === "operator" && value === "(") { left = expression(); take(")"); }
|
||||||
|
else if (["number", "string"].includes(token.kind)) {
|
||||||
|
if (token.kind === "number" && !Number.isFinite(value)) throw new Error("Naninovel: неконечное число");
|
||||||
|
left = ["const", value];
|
||||||
|
} else if (token.kind === "name") {
|
||||||
|
if (["true", "false", "null"].includes(value.toLowerCase())) left = ["const", { true: true, false: false, null: null }[value.toLowerCase() as "true"]];
|
||||||
|
else if (peek() === "(") {
|
||||||
|
if (!functions.has(value) && value.toLowerCase() !== "if") throw new Error(`Naninovel: функция ${value} не поддерживается`);
|
||||||
|
take("(");
|
||||||
|
const args: NaniAst[] = [];
|
||||||
|
if (peek() !== ")") while (true) { args.push(expression()); if (peek() !== ",") break; take(","); }
|
||||||
|
take(")");
|
||||||
|
if (value.toLowerCase() === "if") {
|
||||||
|
if (args.length !== 3) throw new Error("Naninovel: If требует три аргумента");
|
||||||
|
left = ["if", ...args];
|
||||||
|
} else left = ["call", value, args];
|
||||||
|
} else left = ["name", value];
|
||||||
|
} else throw new Error(`Naninovel: некорректный токен ${value}`);
|
||||||
|
while (Object.hasOwn(precedence, peek()) && precedence[peek()] >= minimum) {
|
||||||
|
const op = take().value;
|
||||||
|
left = ["bin", op, left, expression(precedence[op] + 1)];
|
||||||
|
}
|
||||||
|
if (minimum <= 1 && peek() === "?") { take("?"); const yes = expression(); take(":"); left = ["if", left, yes, expression()]; }
|
||||||
|
depth--; return left;
|
||||||
|
};
|
||||||
|
const ast = expression();
|
||||||
|
if (cursor !== tokens.length) throw new Error("Naninovel: лишние токены выражения");
|
||||||
|
return ast;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NaniCommand { op: string; args: Record<string, any>; line?: number; tid?: string }
|
||||||
|
|
||||||
|
export function inlineCommands(source: string | string[], line = -1): NaniCommand[] {
|
||||||
|
let lines: string[];
|
||||||
|
if (Array.isArray(source)) lines = source;
|
||||||
|
else {
|
||||||
|
lines = []; let start = 0, depth = 0, quote = "";
|
||||||
|
for (let index = 0; index < source.length; index++) {
|
||||||
|
const char = source[index];
|
||||||
|
if (quote) { if (char === "\\") index++; else if (char === quote) quote = ""; }
|
||||||
|
else if (["\"", "'"].includes(char)) quote = char;
|
||||||
|
else if (char === "(" || char === "{") depth++;
|
||||||
|
else if (char === ")" || char === "}") depth--;
|
||||||
|
else if (char === "," && depth === 0) { lines.push(source.slice(start, index)); start = index + 1; }
|
||||||
|
}
|
||||||
|
if (depth !== 0 || quote) throw new Error("Naninovel: незакрытая команда выбора");
|
||||||
|
lines.push(source.slice(start));
|
||||||
|
}
|
||||||
|
return lines.filter((line) => line.trim()).map((source) => {
|
||||||
|
source = source.trim();
|
||||||
|
if (!source.startsWith("@")) {
|
||||||
|
const speaker = source.match(/^([^:]{1,24}): (.*)$/s);
|
||||||
|
return { op: "PrintText", line, args: { Text: { template: speaker ? speaker[2] : source }, AuthorId: speaker?.[1] ?? "" } };
|
||||||
|
}
|
||||||
|
const [, name, body = ""] = source.match(/^(@\S+)(?:\s+(.*))?$/s)!;
|
||||||
|
const template = { template: body };
|
||||||
|
if (name === "@set") return { op: "SetCustomVariable", args: { Expression: template }, line };
|
||||||
|
if (["@goto", "@gosub"].includes(name)) return { op: name === "@goto" ? "Goto" : "Gosub", args: { Path: template }, line };
|
||||||
|
if (name === "@return") return { op: "Return", args: {}, line };
|
||||||
|
if (name === "@hideChars") return { op: "HideAllCharacters", args: { Duration: 0 }, line };
|
||||||
|
if (["@addItem", "@additem", "@removeItem"].includes(name)) return { op: name === "@removeItem" ? "RemoveItem" : "AddItem",
|
||||||
|
args: { ItemId: { template: body.split(/\s/, 1)[0] }, Amount: { template: body.match(/amount:([^ ]+)/)?.[1] ?? "1" } }, line };
|
||||||
|
if (name === "@voice") return { op: "PlayVoice", args: { VoicePath: { template: body.split(/\s/, 1)[0] } }, line };
|
||||||
|
if (name === "@hide") return { op: "HideActors", args: { ActorIds: body.split(",").filter(Boolean) }, line };
|
||||||
|
throw new Error(`Naninovel: неизвестная команда выбора ${name}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { evaluateNani, naniTruth, type NaniAst } from "./naninovel";
|
||||||
|
import { inlineCommands, parseNani, type NaniCommand } from "./nani-parser";
|
||||||
|
import { TomieFunctions, type TomieFunctionHost } from "./tomie-functions";
|
||||||
|
|
||||||
|
export interface NaniScript {
|
||||||
|
commands: NaniCommand[];
|
||||||
|
labels: Record<string, number>;
|
||||||
|
label_depths?: Record<string, number>;
|
||||||
|
}
|
||||||
|
export interface NaniPackage {
|
||||||
|
scripts: Record<string, NaniScript>;
|
||||||
|
defaults: Record<string, any>;
|
||||||
|
start: { script: string; label: string };
|
||||||
|
initial_inventory?: Record<string, number>;
|
||||||
|
function_data: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
interface Branch { parent: boolean; taken: boolean; active: boolean }
|
||||||
|
interface Frame { script: string; cursor: number; branches: Branch[]; pending: NaniCommand[] }
|
||||||
|
interface Choice extends Record<string, any> { tid?: string }
|
||||||
|
export interface NaniState {
|
||||||
|
version: 1;
|
||||||
|
script: string;
|
||||||
|
cursor: number;
|
||||||
|
variables: Record<string, any>;
|
||||||
|
branches: Branch[];
|
||||||
|
stack: Frame[];
|
||||||
|
choices: Choice[];
|
||||||
|
waiting: string;
|
||||||
|
pending: NaniCommand[];
|
||||||
|
last: NaniCommand;
|
||||||
|
timedChoice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = <T>(value: T): T => structuredClone(value);
|
||||||
|
|
||||||
|
export class NaniRuntime {
|
||||||
|
variables: Record<string, any> = {};
|
||||||
|
global: Record<string, any> = {};
|
||||||
|
script = "";
|
||||||
|
cursor = 0;
|
||||||
|
branches: Branch[] = [];
|
||||||
|
stack: Frame[] = [];
|
||||||
|
choices: Choice[] = [];
|
||||||
|
waiting = "";
|
||||||
|
pending: NaniCommand[] = [];
|
||||||
|
last: NaniCommand = { op: "", args: {} };
|
||||||
|
timedChoice = 0;
|
||||||
|
fastWaits = false;
|
||||||
|
readonly evaluator: TomieFunctions;
|
||||||
|
private ast = new Map<string, NaniAst>();
|
||||||
|
private functions: Set<string>;
|
||||||
|
|
||||||
|
constructor(public readonly content: NaniPackage,
|
||||||
|
private emit: (kind: string, data: Record<string, any>) => void,
|
||||||
|
private translate: (tid: string | undefined, text: string) => string = (_tid, text) => text,
|
||||||
|
host: Partial<Pick<TomieFunctionHost, "hasVideo" | "link" | "now">> = {}) {
|
||||||
|
this.evaluator = new TomieFunctions({ store: () => this.variables, tables: content.function_data,
|
||||||
|
hasLabel: (label) => Object.hasOwn(this.current().labels, label),
|
||||||
|
hasVideo: host.hasVideo ?? (() => false), link: host.link ?? (() => {}), now: host.now });
|
||||||
|
this.functions = new Set(Object.keys(this.evaluator.functions));
|
||||||
|
}
|
||||||
|
|
||||||
|
current(): NaniScript {
|
||||||
|
const script = this.content.scripts[this.script];
|
||||||
|
if (!script) throw new Error(`Naninovel: сценарий не найден ${this.script}`);
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
get onBoard(): boolean {
|
||||||
|
return this.waiting === "board" ||
|
||||||
|
(this.waiting === "choice" && this.script === "AppScreen" && this.last.op === "Stop" && this.last.line === 97);
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluate(source: string): any {
|
||||||
|
let ast = this.ast.get(source);
|
||||||
|
if (!ast) { ast = parseNani(source, this.functions); this.ast.set(source, ast); }
|
||||||
|
return evaluateNani(ast, (name) => Object.hasOwn(this.variables, name) ? this.variables[name] : undefined,
|
||||||
|
this.evaluator.random, this.evaluator.functions);
|
||||||
|
}
|
||||||
|
|
||||||
|
interpolate(text: string): string {
|
||||||
|
// Match the port's right-to-left evaluation order, including RNG calls.
|
||||||
|
const matches = [...text.matchAll(/\{([^{}]+)\}/g)];
|
||||||
|
for (const match of matches.reverse()) {
|
||||||
|
const value = this.evaluate(match[1]);
|
||||||
|
text = text.slice(0, match.index) + String(value) + text.slice(match.index! + match[0].length);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(value: any): any {
|
||||||
|
return value && typeof value === "object" && Object.hasOwn(value, "template") ? this.interpolate(value.template) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
text(value: any, tid?: string): string {
|
||||||
|
const template = value && typeof value === "object" && Object.hasOwn(value, "template") ? value.template : String(value ?? "");
|
||||||
|
return this.interpolate(this.translate(tid, template));
|
||||||
|
}
|
||||||
|
|
||||||
|
start(seed?: number): void {
|
||||||
|
this.variables = { ...copy(this.content.defaults), ...copy(this.global) };
|
||||||
|
this.branches = []; this.stack = []; this.pending = []; this.choices = []; this.waiting = "";
|
||||||
|
this.evaluator.state.inventory = copy(this.content.initial_inventory ?? {});
|
||||||
|
if (seed !== undefined) this.evaluator.state.rng = (seed >>> 0) || 1;
|
||||||
|
this.jump(this.content.start.script, this.content.start.label);
|
||||||
|
this.advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
path(value: any): { script: string; label: string } {
|
||||||
|
value = this.resolve(value);
|
||||||
|
if (value && typeof value === "object") return { script: value.name ?? this.script, label: value.value ?? "" };
|
||||||
|
const [script, ...label] = String(value).split(".");
|
||||||
|
return { script: script || this.script, label: label.join(".") };
|
||||||
|
}
|
||||||
|
|
||||||
|
jump(script: string, label = ""): void {
|
||||||
|
const target = this.content.scripts[script];
|
||||||
|
if (!target || (label && !Object.hasOwn(target.labels, label))) throw new Error(`Naninovel: не найдена метка ${script}.${label}`);
|
||||||
|
this.script = script; this.cursor = label ? target.labels[label] : 0;
|
||||||
|
this.branches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private branch(op: string, args: Record<string, any>): boolean {
|
||||||
|
// Recovered from BeginIf.HandleConditionalBlock in the pinned original DLL:
|
||||||
|
// branch commands seek forward in the playlist; EndIf itself is a no-op.
|
||||||
|
// A stack would mishandle gotos into/out of conditional blocks.
|
||||||
|
if (op === "EndIf") return true;
|
||||||
|
if (!["BeginIf", "Else", "ElseIf"].includes(op)) return false;
|
||||||
|
const skip = op !== "BeginIf";
|
||||||
|
if (!skip && naniTruth(this.evaluate(String(this.resolve(args.Expression))))) return true;
|
||||||
|
let depth = 0;
|
||||||
|
const commands = this.current().commands;
|
||||||
|
for (let index = this.cursor; index < commands.length; index++) {
|
||||||
|
const command = commands[index];
|
||||||
|
if (command.op === "BeginIf") { depth++; continue; }
|
||||||
|
if (depth && command.op === "EndIf") { depth--; continue; }
|
||||||
|
if (depth) continue;
|
||||||
|
if (command.op === "EndIf" || (!skip && (command.op === "Else" ||
|
||||||
|
(command.op === "ElseIf" && naniTruth(this.evaluate(String(this.resolve(command.args.Expression)))))))) {
|
||||||
|
this.cursor = index + 1; return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Naninovel: условный блок не имеет завершающей команды EndIf");
|
||||||
|
}
|
||||||
|
|
||||||
|
advance(): void {
|
||||||
|
if (["choice", "board", "complete", "error", "input", "stopped"].includes(this.waiting)) return;
|
||||||
|
this.waiting = "";
|
||||||
|
try {
|
||||||
|
let budget = 10000;
|
||||||
|
while (!this.waiting && budget-- > 0) {
|
||||||
|
const command = this.pending.length ? this.pending.shift() : this.current().commands[this.cursor++];
|
||||||
|
if (!command) throw new Error("Сценарий закончился без перехода");
|
||||||
|
this.last = command;
|
||||||
|
if (Object.hasOwn(command.args, "ConditionalExpression") &&
|
||||||
|
!naniTruth(this.evaluate(String(this.resolve(command.args.ConditionalExpression))))) continue;
|
||||||
|
if (this.branch(command.op, command.args)) continue;
|
||||||
|
this.execute(command);
|
||||||
|
}
|
||||||
|
if (!this.waiting) throw new Error("Превышен предел команд между репликами");
|
||||||
|
} catch (error) {
|
||||||
|
this.waiting = "error";
|
||||||
|
this.emit("error", { message: `${this.script}:${this.last.line ?? "?"} — ${(error as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pause(kind: string): void { this.waiting = kind; this.emit("paused", { kind }); }
|
||||||
|
|
||||||
|
private execute(command: NaniCommand): void {
|
||||||
|
const { op, args } = command;
|
||||||
|
switch (op) {
|
||||||
|
case "PrintText": case "AppendText":
|
||||||
|
this.emit("text", { text: this.text(args.Text ?? "", command.tid), author: String(this.resolve(args.AuthorId ?? "")),
|
||||||
|
reset: Boolean(args.ResetPrinter ?? op !== "AppendText"), tid: command.tid });
|
||||||
|
if (args.WaitForInput ?? true) this.pause("text");
|
||||||
|
return;
|
||||||
|
case "AppendLineBreak": this.emit("line_break", { count: Number(args.Count ?? 1) }); return;
|
||||||
|
case "WaitForInput": case "SkipInput": this.pause("text"); return;
|
||||||
|
case "SetCustomVariable": {
|
||||||
|
const expression = String(this.resolve(args.Expression));
|
||||||
|
const match = expression.trim().match(/^([A-Za-z_]\w*)\s*([+\-*/%&|^]?)=(?!=)\s*(.*)$/s);
|
||||||
|
if (!match || ["__proto__", "constructor", "prototype"].includes(match[1]) || match[1].startsWith("__pgames_")) throw new Error("Неподдерживаемое присваивание: " + expression);
|
||||||
|
const value = this.evaluate(match[3]);
|
||||||
|
this.variables[match[1]] = match[2] ? evaluateNani(["bin", match[2], ["const", this.variables[match[1]] ?? 0], ["const", value]], () => undefined) : value;
|
||||||
|
this.changed(match[1], this.variables[match[1]]); return;
|
||||||
|
}
|
||||||
|
case "AddChoice": this.choices.push({ ...copy(args), tid: command.tid, ChoiceSummary: this.text(args.ChoiceSummary ?? "", command.tid) }); return;
|
||||||
|
case "Stop":
|
||||||
|
if (this.choices.length) { this.pause("choice"); this.emit("choices", { items: copy(this.choices) }); }
|
||||||
|
else if (["AppScreen", "AB_Slum"].includes(this.script)) { this.pause("board"); this.emit("board", {}); }
|
||||||
|
else { this.pause("stopped"); this.emit("stopped", {}); }
|
||||||
|
return;
|
||||||
|
case "Goto": case "Gosub": {
|
||||||
|
const target = this.path(args.Path);
|
||||||
|
if (op === "Gosub") this.stack.push({ script: this.script, cursor: this.cursor, branches: copy(this.branches), pending: copy(this.pending) });
|
||||||
|
this.pending = []; this.jump(target.script, target.label); return;
|
||||||
|
}
|
||||||
|
case "Return": {
|
||||||
|
const frame = this.stack.pop();
|
||||||
|
if (!frame) throw new Error("Return без Gosub");
|
||||||
|
this.script = frame.script; this.cursor = frame.cursor; this.branches = frame.branches; this.pending = frame.pending; return;
|
||||||
|
}
|
||||||
|
case "Wait": {
|
||||||
|
const duration = Number(String(this.resolve(args.WaitMode ?? "0")).replace(/^i/, ""));
|
||||||
|
if (!this.choices.length && (duration <= 0 || this.fastWaits)) return;
|
||||||
|
if (this.choices.length && duration > 0) {
|
||||||
|
this.timedChoice = duration; this.pause("timed_choice"); this.emit("choices", { items: copy(this.choices) });
|
||||||
|
} else this.pause("timer");
|
||||||
|
this.emit("wait", { duration }); return;
|
||||||
|
}
|
||||||
|
case "AddItem": case "RemoveItem": {
|
||||||
|
const item = String(this.resolve(args.ItemId)), amount = Math.max(0, Math.trunc(Number(this.resolve(args.Amount ?? 1))));
|
||||||
|
const inventory = this.evaluator.state.inventory, count = inventory[item] ?? 0;
|
||||||
|
if (["__proto__", "constructor", "prototype"].includes(item)) throw new Error("Некорректный ID предмета");
|
||||||
|
if (op === "RemoveItem" && amount > count) throw new Error("Недостаточно предметов: " + item);
|
||||||
|
inventory[item] = op === "AddItem" ? count + amount : count - amount;
|
||||||
|
this.emit("inventory_changed", { item, count: inventory[item] }); return;
|
||||||
|
}
|
||||||
|
case "ClearChoiceHandler": {
|
||||||
|
const handler = this.resolve(args.HandlerId ?? "*");
|
||||||
|
this.choices = this.choices.filter((choice) => handler !== "*" && choice.HandlerId !== handler);
|
||||||
|
this.emit("clear_choices", {}); return;
|
||||||
|
}
|
||||||
|
case "InputCustomVariable": this.pause("input"); this.emit("input", {
|
||||||
|
variable: String(this.resolve(args.VariableName ?? "input")), summary: this.text(args.Summary ?? "", command.tid) }); return;
|
||||||
|
case "ExitToTitle": this.waiting = "complete"; this.emit("exit_to_title", {}); return;
|
||||||
|
case "ShowToastUI": this.emit(op, { ...args, Text: this.text(args.Text ?? "", command.tid) }); return;
|
||||||
|
case "ClearBacklog": this.emit("clear_history", {}); return;
|
||||||
|
case "AutoSave": this.emit("autosave", {}); return;
|
||||||
|
case "ProcessInput": this.emit("process_input", { enabled: Boolean(this.resolve(args.InputEnabled ?? true)) }); return;
|
||||||
|
case "Skip": this.emit("skip_control", { enabled: Boolean(this.resolve(args.Enable ?? false)) }); return;
|
||||||
|
case "HideUI": case "ShowUI": case "Spawn": case "DestroySpawned": case "ModifyCamera":
|
||||||
|
case "PlayVoice": case "ShowPrinter": case "ModifyBackground": case "ModifyCharacter":
|
||||||
|
case "HideAllCharacters": case "HideActors": case "ModifyTextPrinter": case "HidePrinter":
|
||||||
|
case "PlaySfx": case "PlayBgm": case "StopSfx": case "StopBgm": {
|
||||||
|
const resolved = Object.fromEntries(Object.entries(args).map(([key, value]) => [key, this.resolve(value)]));
|
||||||
|
this.emit(op, resolved);
|
||||||
|
const duration = Number(resolved.Duration ?? 0);
|
||||||
|
if (["ModifyBackground", "ModifyCharacter", "HideAllCharacters", "HideActors", "ModifyTextPrinter", "HidePrinter", "PlaySfx", "PlayBgm", "StopSfx", "StopBgm"].includes(op) &&
|
||||||
|
(resolved.Wait ?? true) && duration > 0 && !this.fastWaits && !this.waiting) {
|
||||||
|
this.pause("timer"); this.emit("wait", { duration });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default: throw new Error("Не поддержана команда Naninovel: " + op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
choiceSupported(choice: Choice): boolean {
|
||||||
|
try {
|
||||||
|
for (const command of inlineCommands(this.resolve(choice.OnSelected ?? []))) {
|
||||||
|
if (command.op === "RemoveItem" && Number(this.resolve(command.args.Amount ?? 1)) >
|
||||||
|
(this.evaluator.state.inventory[String(this.resolve(command.args.ItemId))] ?? 0)) return false;
|
||||||
|
}
|
||||||
|
if (choice.GotoPath) {
|
||||||
|
const target = this.path(choice.GotoPath), script = this.content.scripts[target.script];
|
||||||
|
if (!script || (target.label && !Object.hasOwn(script.labels, target.label))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
choose(index: number): void {
|
||||||
|
if (!["choice", "timed_choice"].includes(this.waiting) || !Number.isInteger(index) || !this.choices[index]) return;
|
||||||
|
const selected = this.choices[index];
|
||||||
|
if (!this.choiceSupported(selected)) return;
|
||||||
|
this.choices = []; this.waiting = "";
|
||||||
|
this.pending = inlineCommands(this.resolve(selected.OnSelected ?? []), this.last.line);
|
||||||
|
if (selected.GotoPath) this.pending.push({ op: "Goto", args: { Path: selected.GotoPath }, line: this.last.line });
|
||||||
|
this.advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
runExternal(script: string, label = ""): void {
|
||||||
|
this.stack = []; this.trigger(script, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger(script: string, label = ""): void {
|
||||||
|
this.pending = []; this.choices = []; this.waiting = "";
|
||||||
|
this.jump(script, label); this.advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
changed(key: string, value: any): void {
|
||||||
|
if (key.startsWith("G_")) this.global[key] = value;
|
||||||
|
if (["newContract", "newMessage", "newComment"].includes(key) && String(value)) {
|
||||||
|
const destination = { newContract: "contractString", newMessage: "messageString", newComment: "commentString" }[key as "newContract"];
|
||||||
|
const incoming = String(value).split("@"), identity = key === "newMessage" ? 2 : 1;
|
||||||
|
if (incoming.length <= identity) { this.variables[key] = ""; return; }
|
||||||
|
let exists = false;
|
||||||
|
const updated = String(this.variables[destination] ?? "").split("^").filter(Boolean).filter((entry) => {
|
||||||
|
const fields = entry.split("@");
|
||||||
|
if (fields.length <= identity) return false;
|
||||||
|
if (fields[0] !== incoming[0]) return true;
|
||||||
|
if (fields[identity] === incoming[identity]) { exists = true; return true; }
|
||||||
|
return ["newComment", "newMessage"].includes(key) && incoming[0] === "Anonymous";
|
||||||
|
});
|
||||||
|
if (!exists) updated.unshift(String(value));
|
||||||
|
this.variables[destination] = updated.join("^"); this.variables[key] = "";
|
||||||
|
}
|
||||||
|
if (key === "messageChanged" && String(value)) {
|
||||||
|
const [identity, ...rest] = String(value).split("#"), replacement = rest.join("#");
|
||||||
|
const entries = String(this.variables.messageString ?? "").split("^").filter((entry) => entry.split("@").length > 2 && entry.split("@")[2] !== identity);
|
||||||
|
if (rest.length && replacement !== "remove") entries.unshift(replacement);
|
||||||
|
this.variables.messageString = entries.join("^"); this.variables[key] = "";
|
||||||
|
}
|
||||||
|
this.emit("variable_changed", { name: key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
submitInput(name: string, value: string): void {
|
||||||
|
if (this.waiting !== "input" || ["__proto__", "constructor", "prototype"].includes(name) || name.startsWith("__pgames_")) return;
|
||||||
|
this.variables[name] = value; this.changed(name, value); this.waiting = ""; this.advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): NaniState {
|
||||||
|
return copy({ version: 1, script: this.script, cursor: this.cursor, variables: this.variables,
|
||||||
|
branches: this.branches, stack: this.stack, choices: this.choices, waiting: this.waiting,
|
||||||
|
pending: this.pending, last: this.last, timedChoice: this.timedChoice });
|
||||||
|
}
|
||||||
|
|
||||||
|
restore(state: NaniState): boolean {
|
||||||
|
const validFrame = (frame: { script: string; cursor: number }) => this.content.scripts[frame.script] &&
|
||||||
|
Number.isInteger(frame.cursor) && frame.cursor >= 0 && frame.cursor <= this.content.scripts[frame.script].commands.length;
|
||||||
|
if (state?.version !== 1 || !validFrame(state) || !Array.isArray(state.stack) || !state.stack.every(validFrame) ||
|
||||||
|
!["text", "choice", "board", "complete", "timed_choice", "timer", "input", "stopped"].includes(state.waiting)) return false;
|
||||||
|
const saved = copy(state);
|
||||||
|
this.script = saved.script; this.cursor = saved.cursor; this.variables = { ...saved.variables, ...copy(this.global) };
|
||||||
|
this.branches = saved.branches; this.stack = saved.stack; this.choices = saved.choices;
|
||||||
|
this.waiting = saved.waiting; this.pending = saved.pending; this.last = saved.last; this.timedChoice = saved.timedChoice;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,18 @@ function number(value: any): number {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random = Math.random): any {
|
function equal(a: any, b: any): boolean {
|
||||||
|
if (a === null || b === null) return a === null && b === null;
|
||||||
|
if ((typeof a === "string") !== (typeof b === "string")) {
|
||||||
|
const text = typeof a === "string" ? a : b;
|
||||||
|
return text.trim() !== "" && Number.isFinite(Number(text)) && Number(a) === Number(b);
|
||||||
|
}
|
||||||
|
if (["number", "boolean"].includes(typeof a) && ["number", "boolean"].includes(typeof b)) return Number(a) === Number(b);
|
||||||
|
return typeof a === typeof b && a === b;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random = Math.random,
|
||||||
|
functions: Record<string, (...args: any[]) => any> = {}): any {
|
||||||
const evaluate = (node: NaniAst): any => {
|
const evaluate = (node: NaniAst): any => {
|
||||||
switch (node[0]) {
|
switch (node[0]) {
|
||||||
case "const": return node[1];
|
case "const": return node[1];
|
||||||
@@ -37,12 +48,12 @@ export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random
|
|||||||
case "*": return number(a) * number(b);
|
case "*": return number(a) * number(b);
|
||||||
case "/": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) / number(b);
|
case "/": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) / number(b);
|
||||||
case "%": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) % number(b);
|
case "%": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) % number(b);
|
||||||
case "=": case "==": return String(a) === String(b);
|
case "=": case "==": return equal(a, b);
|
||||||
case "!=": case "<>": return String(a) !== String(b);
|
case "!=": case "<>": return !equal(a, b);
|
||||||
case "<": return number(a) < number(b);
|
case "<": return typeof a === "string" && typeof b === "string" ? a < b : number(a) < number(b);
|
||||||
case ">": return number(a) > number(b);
|
case ">": return typeof a === "string" && typeof b === "string" ? a > b : number(a) > number(b);
|
||||||
case "<=": return number(a) <= number(b);
|
case "<=": return typeof a === "string" && typeof b === "string" ? a <= b : number(a) <= number(b);
|
||||||
case ">=": return number(a) >= number(b);
|
case ">=": return typeof a === "string" && typeof b === "string" ? a >= b : number(a) >= number(b);
|
||||||
case "&": return number(a) & number(b);
|
case "&": return number(a) & number(b);
|
||||||
case "|": return number(a) | number(b);
|
case "|": return number(a) | number(b);
|
||||||
case "^": return number(a) ^ number(b);
|
case "^": return number(a) ^ number(b);
|
||||||
@@ -52,7 +63,9 @@ export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "call": {
|
case "call": {
|
||||||
const args = node[2].map((value: NaniAst) => number(evaluate(value)));
|
const values = node[2].map((value: NaniAst) => evaluate(value));
|
||||||
|
if (Object.hasOwn(functions, node[1])) return functions[node[1]](...values);
|
||||||
|
const args = values.map(number);
|
||||||
switch (node[1]) {
|
switch (node[1]) {
|
||||||
case "Random": case "random": case "Rand": {
|
case "Random": case "random": case "Rand": {
|
||||||
const min = args.length > 1 ? args[0] : 0, max = args.length > 1 ? args[1] : args[0];
|
const min = args.length > 1 ? args[0] : 0, max = args.length > 1 ? args[1] : args[0];
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Game-specific expression functions recovered in the Tomie Godot port.
|
||||||
|
// State belongs to the save store, including RNG and temporary random lists.
|
||||||
|
export interface TomieFunctionHost {
|
||||||
|
store(): Record<string, any>;
|
||||||
|
tables: Record<string, string[]>;
|
||||||
|
hasLabel(name: string): boolean;
|
||||||
|
hasVideo(name: string): boolean;
|
||||||
|
link(url: string): void;
|
||||||
|
now?(): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
rng: number;
|
||||||
|
randomList: string[];
|
||||||
|
inventory: Record<string, number>;
|
||||||
|
sum: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
|
||||||
|
const str = (value: any): string => value === null || value === undefined ? "<null>" : String(value);
|
||||||
|
const roundEven = (value: number) => {
|
||||||
|
const base = Math.floor(value);
|
||||||
|
return value - base === 0.5 ? (base % 2 === 0 ? base : base + 1) : Math.floor(value + 0.5);
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TomieFunctions {
|
||||||
|
constructor(private host: TomieFunctionHost) {}
|
||||||
|
|
||||||
|
get state(): State {
|
||||||
|
const store = this.host.store();
|
||||||
|
if (!Object.hasOwn(store, "__pgames_tomie")) {
|
||||||
|
store.__pgames_tomie = { rng: (Date.now() >>> 0) || 1, randomList: [], inventory: {}, sum: "" };
|
||||||
|
}
|
||||||
|
return store.__pgames_tomie;
|
||||||
|
}
|
||||||
|
|
||||||
|
random = (): number => {
|
||||||
|
let x = this.state.rng | 0;
|
||||||
|
x ^= x << 13; x ^= x >>> 17; x ^= x << 5;
|
||||||
|
this.state.rng = x >>> 0;
|
||||||
|
return this.state.rng / 0x100000000;
|
||||||
|
};
|
||||||
|
|
||||||
|
integer(min: number, max: number): number {
|
||||||
|
return Math.trunc(min) + Math.floor(this.random() * (Math.trunc(max) - Math.trunc(min) + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private pick(value: string, delimiter: string): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const entries = value.split(delimiter);
|
||||||
|
return entries[this.integer(0, entries.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private element(value: any, index: number, separator: string): string {
|
||||||
|
const values = str(value).split(separator);
|
||||||
|
return values[index >= 0 && index < values.length ? index : 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private table(name: string): string[] {
|
||||||
|
const values = this.host.tables[name];
|
||||||
|
if (!values?.length) throw new Error(`Tomie: не загружена исходная таблица ${name}`);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private comment(name: string): string {
|
||||||
|
const values = this.table(name);
|
||||||
|
return values[this.integer(0, Math.max(0, values.length - 2))];
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly functions: Record<string, (...args: any[]) => any> = {
|
||||||
|
WillpowerDamage: (current, corruption, amount, min, max) => clamp(current - amount * 0.7 - amount * 0.3 * ((100 - corruption) / 100), min, max),
|
||||||
|
RoundValue: (value) => roundEven(Number(value)),
|
||||||
|
MinMax: (value, min, max) => clamp(Number(value), min, max),
|
||||||
|
Random: (...args) => {
|
||||||
|
if (typeof args[0] === "string") return args[this.integer(0, args.length - 1)];
|
||||||
|
const [min, max] = args.map(Number);
|
||||||
|
return Number.isInteger(min) && Number.isInteger(max) ? this.integer(min, max) : min + this.random() * (max - min);
|
||||||
|
},
|
||||||
|
random: (...args) => this.functions.Random(...args),
|
||||||
|
Rand: (min, max = null) => max === null ? this.functions.GetRandValue(min) : this.functions.Random(min, max),
|
||||||
|
NewRandList: () => { this.state.randomList = []; return 0; },
|
||||||
|
AddRandList: (value, count = 1) => {
|
||||||
|
if (count > 100000) throw new Error("Tomie: слишком большой случайный список");
|
||||||
|
for (let i = 0; i < count; i++) this.state.randomList.push(str(value));
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
MergeRandList: () => this.state.randomList.join("&"),
|
||||||
|
HasValue: (name) => Object.hasOwn(this.host.store(), name),
|
||||||
|
StringContain: (value, part) => str(value).includes(part),
|
||||||
|
StringReplace: (value, old, replacement) => str(value).split(old).join(replacement),
|
||||||
|
ItemExist: (name) => (this.state.inventory[name] ?? 0) > 0,
|
||||||
|
itemExist: (name) => this.functions.ItemExist(name),
|
||||||
|
ItemCount: (name) => this.state.inventory[name] ?? 0,
|
||||||
|
AddListString: (value, entry) => str(value) ? str(value) + "@" + str(entry) : str(entry),
|
||||||
|
AddListString2: (value, entry) => str(value).includes(str(entry)) ? str(value) : this.functions.AddListString(value, entry),
|
||||||
|
AddListStringWithRemoveDot: (value, entry) => str(value) ? this.functions.AddListString(value, str(entry).replaceAll(".", "")) : str(entry),
|
||||||
|
RemoveListString: (value, entry) => !str(entry) ? str(value) : str(value).replaceAll(str(entry) + "@", "")
|
||||||
|
.replaceAll("@" + str(entry), "").replaceAll(str(entry), "").replaceAll("@@", ""),
|
||||||
|
StringContains: (value, targets) => str(targets).split(",").some((part) => str(value).includes(part)),
|
||||||
|
StringReplaceSeed: (value) => str(value).replaceAll("-", "_"),
|
||||||
|
IntParse: (value) => Math.trunc(Number(value)) || 0,
|
||||||
|
GetListCount: (value) => str(value) ? str(value).split("@").length : 0,
|
||||||
|
GetArrayCount: (value) => this.functions.GetListCount(value),
|
||||||
|
GetFromArray: (value, index) => this.element(value, index, "@"),
|
||||||
|
GetFromRandPick2: (value, index) => this.element(value, index, "@"),
|
||||||
|
GetFromRandPick3: (value, index) => this.element(value, index, "*"),
|
||||||
|
StringSplitComma: (value, index) => this.element(value, index, ","),
|
||||||
|
RandPick: (value) => this.pick(str(value).replace(/^,/, ""), ","),
|
||||||
|
RandPick2: (value) => this.pick(str(value).replace(/^@/, ""), "@"),
|
||||||
|
RandSort: (value) => {
|
||||||
|
const values = str(value).split("@");
|
||||||
|
for (let i = values.length - 1; i > 0; i--) {
|
||||||
|
const j = this.integer(0, i); [values[i], values[j]] = [values[j], values[i]];
|
||||||
|
}
|
||||||
|
return values.join("@");
|
||||||
|
},
|
||||||
|
RandPicks: (value) => {
|
||||||
|
const values = this.functions.RandSort(value).split("@");
|
||||||
|
return values.slice(0, this.integer(1, Math.max(1, values.length - 1))).join(",");
|
||||||
|
},
|
||||||
|
GetRandValue: (max) => this.integer(0, Math.max(0, Math.trunc(max) - 1)),
|
||||||
|
GetRandList: () => this.state.randomList.length ? this.state.randomList[this.integer(0, this.state.randomList.length - 1)] : "null",
|
||||||
|
GetRandListViaIndex: (index) => this.state.randomList.length ? this.state.randomList[clamp(index, 0, this.state.randomList.length - 1)] : "null",
|
||||||
|
ExtractRandList: (value) => { this.state.randomList = str(value).split("&"); return ""; },
|
||||||
|
GetTargetList: () => this.state.randomList.length ? this.state.randomList[this.integer(0, Math.max(0, this.state.randomList.length - 2))] : "null",
|
||||||
|
Pow: Math.pow,
|
||||||
|
Round: (value, digits = 0) => roundEven(value * 10 ** digits) / 10 ** digits,
|
||||||
|
HasLabel: (name) => this.host.hasLabel(name),
|
||||||
|
GetName: (index) => {
|
||||||
|
const names = this.table("names");
|
||||||
|
return names[index >= 0 && index < names.length ? index : this.integer(0, Math.max(0, names.length - 2))] + "_BF";
|
||||||
|
},
|
||||||
|
GetRandName: () => this.comment("names"),
|
||||||
|
GetNormalComment: () => this.comment("normalComment"),
|
||||||
|
GetLewdComment: () => this.comment("lewdComment"),
|
||||||
|
GetVODFileCount: () => this.table("videos").length,
|
||||||
|
GetVODFileNameVidIndex: (index) => this.host.tables.videos?.[index] ?? "none",
|
||||||
|
GetVODFileNames: () => false,
|
||||||
|
GetMODFileNames: () => false,
|
||||||
|
GetModList: () => "",
|
||||||
|
HasVideo: (name) => this.host.hasVideo(str(name).toLowerCase()),
|
||||||
|
OpenURL: (url) => { this.host.link(str(url)); return ""; },
|
||||||
|
IsChristmas: () => { const date = this.host.now?.() ?? new Date(); return date.getMonth() === 11 && [20, 21, 23, 24, 25].includes(date.getDate()); },
|
||||||
|
ResetString: () => { this.state.sum = ""; return ""; },
|
||||||
|
SumString: (value) => { this.state.sum += str(value); return this.state.sum; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { NaniRuntime } from "./nani-runtime";
|
||||||
|
|
||||||
|
export interface BoardCell { index: number; code: number; area: string; icon: string; title: string; detail: string }
|
||||||
|
export class TomieGameplay {
|
||||||
|
constructor(private runtime: NaniRuntime, private routes: Record<string, { script: string; label: string }>) {}
|
||||||
|
|
||||||
|
cells(): BoardCell[] {
|
||||||
|
return String(this.runtime.variables.savedBlock ?? "").split("&").filter(Boolean).flatMap((value) => {
|
||||||
|
const parts = value.split("@");
|
||||||
|
return parts.length < 6 ? [] : [{ index: Number(parts[0]), code: Number(parts[1]), area: parts[2], icon: parts[3], title: parts[4], detail: parts[5] }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
roll(type = 0): { ok: boolean; message?: string; roll?: number; destination?: number } {
|
||||||
|
const vm = this.runtime, values = vm.variables;
|
||||||
|
if (vm.waiting !== "board") return { ok: false, message: "Сначала завершите текущее действие." };
|
||||||
|
const cost = type === 2 ? 10 : [3, 4, 5].includes(type) ? 5 : 0;
|
||||||
|
if (Number(values.iWillpower ?? 0) < cost) return { ok: false, message: "Недостаточно воли для этого кубика." };
|
||||||
|
if (type > 10 && (type > 16 || Number(values.iJoker ?? 0) < 1)) return { ok: false, message: "Нужна одна карта Joker." };
|
||||||
|
const rng = vm.evaluator;
|
||||||
|
let number: number;
|
||||||
|
if (type === 0 || type === 1) {
|
||||||
|
number = rng.integer(1, 6);
|
||||||
|
if (type === 1 && rng.random() >= 0.6) number = Math.min(6, Math.max(1, Math.trunc(Number(values._godot_dice_slider ?? 0) * 3) + 1));
|
||||||
|
} else if (type === 2) number = rng.integer(1, 3);
|
||||||
|
else if (type === 3) number = rng.integer(4, 6);
|
||||||
|
else if (type === 4) number = rng.integer(1, 3) * 2 - 1;
|
||||||
|
else if (type === 5) number = rng.integer(1, 3) * 2;
|
||||||
|
else if (type > 10) number = type - 10;
|
||||||
|
else return { ok: false, message: "Этот тип кубика не подключён." };
|
||||||
|
const cells = this.cells(), destination = Math.min(Number(values.currentPosition ?? 0) + number, cells.length - 1);
|
||||||
|
const cell = cells[destination], target = cell && this.routes[String(cell.code)];
|
||||||
|
if (!cell || !target || !vm.content.scripts[target.script]) return { ok: false, message: "Неизвестный тип клетки. Позиция и ресурсы сохранены." };
|
||||||
|
values.mDiceResult = number; values.currentPosition = destination; values.thisBlockNumber = cell.code;
|
||||||
|
values.iWillpower = Number(values.iWillpower ?? 0) - cost;
|
||||||
|
if (type > 10) values.iJoker = Number(values.iJoker ?? 0) - 1;
|
||||||
|
values.isAppScreen = false;
|
||||||
|
vm.runExternal(target.script, target.label);
|
||||||
|
return { ok: true, roll: number, destination };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export interface MiniState {
|
||||||
|
elapsed: number;
|
||||||
|
spawnElapsed: number;
|
||||||
|
particles: { x: number; y: number; speed: number }[];
|
||||||
|
result: "waiting" | "running" | "won" | "lost";
|
||||||
|
difficulty: number;
|
||||||
|
wombState: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same 1920×1080 rules as the recovered port. The host owns timing and RNG,
|
||||||
|
// so modal dialogs/focus loss pause the game and saves retain exact progress.
|
||||||
|
export class TomieMinigame {
|
||||||
|
state: MiniState;
|
||||||
|
|
||||||
|
constructor(private random: () => number, difficulty = 1, wombState = 3) {
|
||||||
|
this.state = { elapsed: 0, spawnElapsed: 0, particles: [], result: "waiting", difficulty, wombState };
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.state.elapsed = 0; this.state.spawnElapsed = 0;
|
||||||
|
this.state.particles = []; this.state.result = "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
pulse(): void {
|
||||||
|
if (this.state.result !== "running") return;
|
||||||
|
const force = this.state.difficulty === 1 ? 6 : this.state.difficulty === 2 ? 4 : 25;
|
||||||
|
for (const particle of this.state.particles) {
|
||||||
|
const distance = Math.hypot(particle.x - 960, particle.y - 540);
|
||||||
|
if (distance) {
|
||||||
|
particle.x += (particle.x - 960) / distance * force;
|
||||||
|
particle.y += (particle.y - 540) / distance * force;
|
||||||
|
}
|
||||||
|
particle.speed += 0.01;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(delta: number, paused = false): void {
|
||||||
|
if (paused || this.state.result !== "running" || !Number.isFinite(delta) || delta <= 0) return;
|
||||||
|
// Fixed substeps make outcome independent of display refresh rate. The host
|
||||||
|
// must pass zero after returning from a background tab, not a wall-clock gap.
|
||||||
|
let remaining = Math.min(delta, 1);
|
||||||
|
while (remaining > 1e-9 && this.state.result === "running") {
|
||||||
|
const step = Math.min(remaining, 1 / 120);
|
||||||
|
remaining -= step;
|
||||||
|
this.step(step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private step(delta: number): void {
|
||||||
|
const state = this.state;
|
||||||
|
state.elapsed += delta; state.spawnElapsed += delta;
|
||||||
|
if (state.elapsed >= 20) { state.result = "won"; state.particles = []; return; }
|
||||||
|
const interval = state.wombState <= 3 ? 0.45 : state.wombState === 4 ? 0.33 : 0.25;
|
||||||
|
if (state.spawnElapsed >= interval) {
|
||||||
|
state.spawnElapsed -= interval;
|
||||||
|
const angle = this.random() * Math.PI * 2;
|
||||||
|
let speed = 45 + this.random() * 30;
|
||||||
|
if (speed >= 70) { speed = 70 + this.random() * 15; if (speed >= 84) speed = 84 + this.random() * 11; }
|
||||||
|
state.particles.push({ x: 960 + Math.sin(angle) * 700, y: 540 + Math.cos(angle) * 700, speed });
|
||||||
|
}
|
||||||
|
for (const particle of state.particles) {
|
||||||
|
const distance = Math.hypot(particle.x - 960, particle.y - 540);
|
||||||
|
const movement = Math.min(distance, particle.speed * delta);
|
||||||
|
if (distance) { particle.x -= (particle.x - 960) / distance * movement; particle.y -= (particle.y - 540) / distance * movement; }
|
||||||
|
if (distance - movement < 32) { state.result = "lost"; state.particles = []; return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): MiniState { return structuredClone(this.state); }
|
||||||
|
restore(state: MiniState): void { this.state = structuredClone(state); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import { AudioSystem } from "./audio";
|
||||||
|
import type { SaveApi } from "./engine";
|
||||||
|
import type { Translator } from "./i18n";
|
||||||
|
import { NaniRuntime, type NaniPackage, type NaniState } from "./nani-runtime";
|
||||||
|
import { TomieGameplay } from "./tomie-gameplay";
|
||||||
|
import { TomieMinigame, type MiniState } from "./tomie-minigame";
|
||||||
|
import { TomieScene, type TomieMedia, type TomieVisual } from "./tomie-scene";
|
||||||
|
import { parseText, renderText } from "./text";
|
||||||
|
import type { Manifest } from "./types";
|
||||||
|
import "./tomie.css";
|
||||||
|
|
||||||
|
export interface TomieContent extends NaniPackage {
|
||||||
|
media: TomieMedia;
|
||||||
|
board_routes: Record<string, { script: string; label: string }>;
|
||||||
|
resources: Record<string, Record<string, { tid: string; text: string }[]>>;
|
||||||
|
}
|
||||||
|
export interface TomieSave {
|
||||||
|
format: "tomie/1";
|
||||||
|
revision?: string;
|
||||||
|
runtime: NaniState;
|
||||||
|
scene: TomieVisual;
|
||||||
|
mini: MiniState | null;
|
||||||
|
delay: number;
|
||||||
|
history: string[];
|
||||||
|
ambient?: string;
|
||||||
|
ambientElapsed?: number;
|
||||||
|
lewdComment?: string;
|
||||||
|
}
|
||||||
|
const element = <K extends keyof HTMLElementTagNameMap>(name: K, parent?: HTMLElement, className = "") => {
|
||||||
|
const node = document.createElement(name); node.className = className; parent?.append(node); return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TomiePlayer {
|
||||||
|
readonly vm: NaniRuntime;
|
||||||
|
readonly scene: TomieScene;
|
||||||
|
readonly gameplay: TomieGameplay;
|
||||||
|
private shell: HTMLDivElement;
|
||||||
|
private stage: HTMLDivElement;
|
||||||
|
private backdrop: HTMLImageElement;
|
||||||
|
private actors: HTMLDivElement;
|
||||||
|
private video: HTMLVideoElement;
|
||||||
|
private effects: HTMLDivElement;
|
||||||
|
private dialogue: HTMLButtonElement;
|
||||||
|
private author: HTMLDivElement;
|
||||||
|
private text: HTMLDivElement;
|
||||||
|
private choices: HTMLDivElement;
|
||||||
|
private board: HTMLDivElement;
|
||||||
|
private status: HTMLDivElement;
|
||||||
|
private modal: HTMLDialogElement;
|
||||||
|
private miniCanvas: HTMLCanvasElement;
|
||||||
|
private miniControls: HTMLDivElement;
|
||||||
|
private mini: TomieMinigame | null = null;
|
||||||
|
private audio: AudioSystem;
|
||||||
|
private delay = 0;
|
||||||
|
private history: string[] = [];
|
||||||
|
private gallery = new Set<string>();
|
||||||
|
private profileKey: string;
|
||||||
|
private timer = 0;
|
||||||
|
private lastFrame = 0;
|
||||||
|
private disposed = false;
|
||||||
|
private autosave = false;
|
||||||
|
private mode: "normal" | "auto" | "skip" = "normal";
|
||||||
|
private playbackElapsed = 0;
|
||||||
|
private muted = false;
|
||||||
|
private inputName = "";
|
||||||
|
private ambient = "";
|
||||||
|
private ambientElapsed = 0;
|
||||||
|
private lewdComment = "";
|
||||||
|
private filters: SVGDefsElement;
|
||||||
|
private filterId = `tomie-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
constructor(root: HTMLElement, readonly content: TomieContent, private manifest: Manifest,
|
||||||
|
private saves: SaveApi<TomieSave>, private tr: Translator, private onExit: () => void) {
|
||||||
|
this.profileKey = `pgvn:tomie:${manifest.versionId}`;
|
||||||
|
this.scene = new TomieScene(content.media);
|
||||||
|
this.shell = element("div", root, "tomie-root");
|
||||||
|
this.stage = element("div", this.shell, "tomie-stage");
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
svg.style.cssText = "position:absolute;width:0;height:0";
|
||||||
|
this.filters = document.createElementNS(svg.namespaceURI, "defs") as SVGDefsElement;
|
||||||
|
svg.append(this.filters); this.stage.append(svg);
|
||||||
|
this.backdrop = element("img", this.stage, "tomie-background"); this.backdrop.alt = "";
|
||||||
|
this.actors = element("div", this.stage, "tomie-actors");
|
||||||
|
this.video = element("video", this.stage, "tomie-video");
|
||||||
|
this.video.autoplay = true; this.video.loop = true; this.video.muted = true; this.video.playsInline = true;
|
||||||
|
this.effects = element("div", this.stage, "tomie-effects");
|
||||||
|
this.dialogue = element("button", this.stage, "tomie-dialogue");
|
||||||
|
this.author = element("div", this.dialogue, "tomie-author");
|
||||||
|
this.text = element("div", this.dialogue, "tomie-text");
|
||||||
|
this.dialogue.onclick = () => this.next();
|
||||||
|
this.choices = element("div", this.stage, "tomie-choices");
|
||||||
|
this.board = element("div", this.stage, "tomie-board");
|
||||||
|
this.miniCanvas = element("canvas", this.stage, "tomie-mini"); this.miniCanvas.width = 1920; this.miniCanvas.height = 1080;
|
||||||
|
this.miniControls = element("div", this.stage, "tomie-mini-controls");
|
||||||
|
this.miniCanvas.onclick = () => this.mini?.pulse();
|
||||||
|
const toolbar = element("div", this.shell, "tomie-toolbar");
|
||||||
|
for (const [title, action] of [
|
||||||
|
["Сохранить", () => this.slots(false)], ["Загрузить", () => this.slots(true)],
|
||||||
|
["История", () => this.historyDialog()], ["Инвентарь", () => this.inventory()],
|
||||||
|
["Параметры", () => this.statistics()], ["Галерея", () => this.galleryDialog()],
|
||||||
|
["Авто", () => { this.mode = this.mode === "auto" ? "normal" : "auto"; }],
|
||||||
|
["Пропуск", () => { this.mode = this.mode === "skip" ? "normal" : "skip"; }],
|
||||||
|
["Звук", () => { this.muted = !this.muted; this.audio.setVolumes({ music: this.muted ? 0 : 0.7, sound: this.muted ? 0 : 0.8, voice: this.muted ? 0 : 1 }); }],
|
||||||
|
["Выйти", () => this.onExit()],
|
||||||
|
] as [string, () => void][]) this.button(toolbar, title, action);
|
||||||
|
this.status = element("div", this.shell, "tomie-status");
|
||||||
|
this.modal = element("dialog", this.shell, "tomie-modal");
|
||||||
|
this.audio = new AudioSystem({ music: 0.7, sound: 0.8, voice: 1 }, (file) => this.url(file));
|
||||||
|
this.vm = new NaniRuntime(content, (kind, data) => this.event(kind, data),
|
||||||
|
(tid, value) => this.tr.line(tid, this.tr.str(value)), {
|
||||||
|
hasVideo: (name) => Object.hasOwn(content.media.videos, name.toLowerCase()),
|
||||||
|
link: (url) => this.link(url),
|
||||||
|
});
|
||||||
|
this.gameplay = new TomieGameplay(this.vm, content.board_routes);
|
||||||
|
try {
|
||||||
|
const profile = JSON.parse(localStorage.getItem(this.profileKey) ?? "{}");
|
||||||
|
this.vm.global = Object.fromEntries(Object.entries(profile.variables ?? {}).filter(([name]) => name.startsWith("G_")));
|
||||||
|
this.gallery = new Set(profile.gallery ?? []);
|
||||||
|
} catch { /* storage unavailable or old profile */ }
|
||||||
|
document.addEventListener("keydown", this.key);
|
||||||
|
this.timer = requestAnimationFrame(this.frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void { this.vm.start(); this.render(); }
|
||||||
|
destroy(): void {
|
||||||
|
this.disposed = true; cancelAnimationFrame(this.timer); document.removeEventListener("keydown", this.key);
|
||||||
|
this.audio.stopAll(); this.video.pause(); this.video.removeAttribute("src"); this.video.load(); this.shell.remove();
|
||||||
|
}
|
||||||
|
private url(path: string): string {
|
||||||
|
const file = this.manifest.files[path];
|
||||||
|
if (!file) throw new Error("Tomie: отсутствует файл " + path);
|
||||||
|
return `/api/versions/${this.manifest.versionId}/files/${file.hash}`;
|
||||||
|
}
|
||||||
|
private button(parent: HTMLElement, title: string, action: () => void, disabled = false): HTMLButtonElement {
|
||||||
|
const button = element("button", parent); button.type = "button"; button.textContent = title; button.disabled = disabled;
|
||||||
|
button.onclick = () => { try { void Promise.resolve(action()).catch((error) => this.fail(error)); } catch (error) { this.fail(error); } }; return button;
|
||||||
|
}
|
||||||
|
private rich(node: HTMLElement, source: string): void {
|
||||||
|
const safe = source.replaceAll("{", "{{").replaceAll("[br]", "\n").replaceAll("\\n", "\n")
|
||||||
|
.replace(/<(\/?)(b|i|u|s)>/gi, "{$1$2}").replace(/<(color|size)=([^<>]+)>/gi, "{$1=$2}")
|
||||||
|
.replace(/<\/(color|size)>/gi, "{/$1}");
|
||||||
|
renderText(node, parseText(safe), Infinity, 25);
|
||||||
|
}
|
||||||
|
private plain(source: string): string { return source.replace(/<[^>]*>/g, "").replaceAll("[br]", "\n"); }
|
||||||
|
private dictionary(collection: string, key: string): string {
|
||||||
|
const entries = this.content.resources[collection]?.[key];
|
||||||
|
return entries?.length ? entries.map((entry) => this.tr.line(entry.tid, entry.text)).join(" · ") : key;
|
||||||
|
}
|
||||||
|
private paused(): boolean { return this.modal.open || document.hidden || !document.hasFocus(); }
|
||||||
|
private next(): void {
|
||||||
|
if (this.paused() || this.mini || !this.scene.state.inputEnabled) return;
|
||||||
|
if (["text", "timer"].includes(this.vm.waiting)) { this.delay = 0; this.vm.advance(); this.render(); }
|
||||||
|
}
|
||||||
|
private key = (event: KeyboardEvent) => {
|
||||||
|
if (this.modal.open || event.repeat || !["Space", "Enter"].includes(event.code)) return;
|
||||||
|
event.preventDefault(); if (this.mini) this.mini.pulse(); else this.next();
|
||||||
|
};
|
||||||
|
private frame = (time: number) => {
|
||||||
|
if (this.disposed) return;
|
||||||
|
const delta = this.lastFrame ? Math.min(0.1, (time - this.lastFrame) / 1000) : 0;
|
||||||
|
this.lastFrame = time;
|
||||||
|
const paused = this.paused();
|
||||||
|
if (this.scene.state.video && this.scene.state.video.speed > 0 && !paused) void this.video.play().catch(() => {});
|
||||||
|
else this.video.pause();
|
||||||
|
if (!paused) {
|
||||||
|
if (Number(this.vm.variables.Control_Progress ?? 0) === 1) {
|
||||||
|
this.ambientElapsed += delta;
|
||||||
|
if (this.ambientElapsed >= 8) { this.ambientElapsed = 0; this.refreshAmbient(); this.renderEffects(); }
|
||||||
|
}
|
||||||
|
if (this.mini) { this.mini.update(delta); this.drawMini(); }
|
||||||
|
else if (["timer", "timed_choice"].includes(this.vm.waiting)) {
|
||||||
|
this.delay -= delta;
|
||||||
|
if (this.delay <= 0) { this.vm.advance(); this.render(); }
|
||||||
|
} else if (this.vm.waiting === "text" && this.mode !== "normal") {
|
||||||
|
this.playbackElapsed += delta;
|
||||||
|
if (this.playbackElapsed > (this.mode === "skip" ? 0.07 : 1.5 + this.scene.state.text.length / 30)) this.next();
|
||||||
|
}
|
||||||
|
if (this.autosave && ["text", "board", "choice"].includes(this.vm.waiting)) {
|
||||||
|
this.autosave = false; void this.writeSave("auto").catch((error) => this.fail(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.timer = requestAnimationFrame(this.frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
private event(kind: string, data: Record<string, any>): void {
|
||||||
|
this.scene.apply(kind, data);
|
||||||
|
if (kind === "text") { this.history.push(`${data.author}: ${data.text}`); this.history = this.history.slice(-500); this.playbackElapsed = 0; }
|
||||||
|
if (kind === "clear_history") this.history = [];
|
||||||
|
if (kind === "wait") this.delay = Math.max(0, Number(data.duration));
|
||||||
|
if (kind === "ModifyBackground") { this.gallery.add(this.scene.state.background); this.persist(); }
|
||||||
|
if (kind === "variable_changed") {
|
||||||
|
if (data.name.startsWith("G_")) this.persist();
|
||||||
|
if (data.name === "SaveCall" && ["save", "load"].includes(data.value)) queueMicrotask(() => { if (!this.disposed) void this.slots(data.value === "load"); });
|
||||||
|
if (data.name === "Control_Progress" && Number(data.value) > 0) this.refreshAmbient();
|
||||||
|
}
|
||||||
|
if (kind === "autosave") this.autosave = true;
|
||||||
|
if (kind === "skip_control" && !data.enabled) this.mode = "normal";
|
||||||
|
if (kind === "Spawn" && data.Path === "PregMiniGame" && !this.mini) {
|
||||||
|
this.mini = new TomieMinigame(this.vm.evaluator.random, Number(this.vm.variables.G_PregMiniGameHard ?? 1), Number(this.vm.variables.iWombState ?? 3));
|
||||||
|
this.miniControls.replaceChildren(); this.button(this.miniControls, "Начать", () => { this.mini?.start(); this.miniControls.replaceChildren(); });
|
||||||
|
}
|
||||||
|
if (kind === "DestroySpawned" && data.Path === "PregMiniGame") this.mini = null;
|
||||||
|
if (kind === "Spawn" && data.Path === "LewdInswitterComment") this.lewdComment = String(this.vm.evaluator.functions.GetLewdComment(0));
|
||||||
|
if (kind === "input") { this.inputName = data.variable; queueMicrotask(() => this.input(data.summary)); }
|
||||||
|
if (kind === "ShowToastUI") this.status.textContent = this.plain(data.Text);
|
||||||
|
if (kind === "ShowUI" && data.UINames?.includes("CGGalleryUI")) queueMicrotask(() => this.galleryDialog());
|
||||||
|
if (kind === "ShowUI" && data.UINames?.includes("Inventory")) queueMicrotask(() => this.inventory());
|
||||||
|
if (kind === "exit_to_title") queueMicrotask(this.onExit);
|
||||||
|
if (kind === "error") this.status.textContent = data.message;
|
||||||
|
if (["PlayBgm", "PlaySfx", "PlayVoice"].includes(kind)) {
|
||||||
|
const channel = kind === "PlayBgm" ? "music" : kind === "PlayVoice" ? "voice" : "sound";
|
||||||
|
const track = this.scene.state[kind === "PlayBgm" ? "bgm" : kind === "PlayVoice" ? "voice" : "sfx"];
|
||||||
|
if (track) this.audio.play(channel, [track.file], { loop: track.loop, volume: track.volume, fadein: Number(data.FadeInDuration ?? 0) });
|
||||||
|
}
|
||||||
|
if (kind === "StopBgm" || kind === "StopSfx") this.audio.stop(kind === "StopBgm" ? "music" : "sound", Number(data.FadeOutDuration ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try { localStorage.setItem(this.profileKey, JSON.stringify({ variables: this.vm.global, gallery: [...this.gallery] })); } catch { /* browser storage unavailable */ }
|
||||||
|
}
|
||||||
|
private fail(error: unknown): void { this.status.textContent = (error as Error).message; this.mode = "normal"; }
|
||||||
|
|
||||||
|
private render(): void {
|
||||||
|
const state = this.scene.state;
|
||||||
|
this.filters.replaceChildren();
|
||||||
|
this.backdrop.hidden = !state.backgroundFile;
|
||||||
|
if (state.backgroundFile) this.backdrop.src = this.url(state.backgroundFile);
|
||||||
|
this.backdrop.style.transform = `scale(${state.zoom * state.backgroundScale[0]}, ${state.zoom * state.backgroundScale[1]})`;
|
||||||
|
this.backdrop.style.filter = this.tint(state.backgroundTint);
|
||||||
|
this.actors.replaceChildren();
|
||||||
|
for (const [id, actor] of Object.entries(state.actors)) {
|
||||||
|
if (!actor.visible) continue;
|
||||||
|
const image = element("img", this.actors); image.alt = id; image.src = this.url(actor.file);
|
||||||
|
const width = actor.size[0] * Math.abs(actor.scale[0]) / 1920 * 100, height = actor.size[1] * Math.abs(actor.scale[1]) / 1080 * 100;
|
||||||
|
image.style.cssText = `position:absolute;width:${width}%;height:${height}%;left:${actor.position[0] - width / 2}%;bottom:${actor.position[1]}%;z-index:${-(actor.position[2] ?? 0)};transform:scale(${Math.sign(actor.scale[0])},${Math.sign(actor.scale[1])})`;
|
||||||
|
image.style.filter = this.tint(actor.tint);
|
||||||
|
}
|
||||||
|
this.video.hidden = !state.video;
|
||||||
|
if (state.video) {
|
||||||
|
const url = this.url(state.video.file);
|
||||||
|
if (this.video.getAttribute("src") !== url) this.video.src = url;
|
||||||
|
const [, , size = "half", placement = "center"] = state.video.spec.split("@");
|
||||||
|
const width = ({ full: 100, half: 56.25, quater: 28.125, hafull: 84.375 } as Record<string, number>)[size] ?? 56.25;
|
||||||
|
this.video.style.width = width + "%"; this.video.style.left = (placement === "left" ? 0 : placement === "right" ? 100 - width : (100 - width) / 2) + "%";
|
||||||
|
if (state.video.speed > 0) this.video.playbackRate = Math.min(16, state.video.speed);
|
||||||
|
}
|
||||||
|
this.dialogue.hidden = !state.printerVisible || this.vm.onBoard;
|
||||||
|
this.author.textContent = this.dictionary("CharacterNames", state.author);
|
||||||
|
this.rich(this.text, state.text);
|
||||||
|
this.choices.replaceChildren(); this.board.replaceChildren();
|
||||||
|
if (this.vm.onBoard) this.renderBoard();
|
||||||
|
else if (["choice", "timed_choice"].includes(this.vm.waiting)) {
|
||||||
|
this.vm.choices.forEach((choice, index) => this.button(this.choices, this.plain(choice.ChoiceSummary), () => { this.vm.choose(index); this.render(); }, !this.vm.choiceSupported(choice)));
|
||||||
|
}
|
||||||
|
this.miniCanvas.hidden = !this.mini; this.miniControls.hidden = !this.mini;
|
||||||
|
this.drawMini();
|
||||||
|
this.renderEffects();
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshAmbient(): void {
|
||||||
|
const variables = this.vm.variables;
|
||||||
|
const line = (collection: string): string => {
|
||||||
|
const entries = Object.values(this.content.resources[collection] ?? {}).flat();
|
||||||
|
if (!entries.length) return "";
|
||||||
|
const selected = entries[this.vm.evaluator.integer(0, entries.length - 1)];
|
||||||
|
let text = this.tr.line(selected.tid, selected.text);
|
||||||
|
for (const [key, variants] of Object.entries(this.content.resources._synonyms ?? {})) {
|
||||||
|
if (!variants.length) continue;
|
||||||
|
const synonym = variants[this.vm.evaluator.integer(0, variants.length - 1)];
|
||||||
|
text = text.replaceAll(key, this.tr.line(synonym.tid, synonym.text));
|
||||||
|
}
|
||||||
|
return text.replaceAll("{mName}", String(variables.mName ?? "man"));
|
||||||
|
};
|
||||||
|
const suffix = Number(variables.Control_Progress) === 2 ? "_finish" : "";
|
||||||
|
const male = line("male_" + variables.Control_KeyMale + suffix), tomie = line("tomie_" + variables.Control_KeyTomie + suffix);
|
||||||
|
this.ambient = (male ? `${variables.mName}: ${male}` : "") + (tomie ? `\nTomie: ${tomie}` : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderEffects(): void {
|
||||||
|
this.effects.replaceChildren();
|
||||||
|
for (const [name, params] of Object.entries(this.scene.state.effects)) {
|
||||||
|
if (name === "PregMiniGame") continue;
|
||||||
|
const node = element("div", this.effects, "tomie-effect");
|
||||||
|
if (name.startsWith("Shake")) {
|
||||||
|
node.remove();
|
||||||
|
const target = name === "ShakePrinter" ? this.dialogue : name === "ShakeCamera" ? this.stage : this.actors;
|
||||||
|
target.classList.remove("tomie-shake"); void target.offsetWidth; target.classList.add("tomie-shake");
|
||||||
|
} else if (["RedFlash", "WhiteFlash", "PinkFlash", "Gaze"].includes(name))
|
||||||
|
node.style.background = ({ RedFlash: "#ff140d73", WhiteFlash: "#ffffffb3", PinkFlash: "#ff59b34d", Gaze: "#0a08124d" } as Record<string, string>)[name];
|
||||||
|
else if (name.startsWith("Hands")) {
|
||||||
|
const path = this.content.media.textures["hand" + name.slice(5)];
|
||||||
|
if (!path) throw new Error("Не найден эффект " + name);
|
||||||
|
const image = element("img", node); image.src = this.url(path); image.alt = ""; image.style.cssText = "width:100%;height:100%";
|
||||||
|
} else if (["EffectSuccess", "EffectFailed", "EffectHeart"].includes(name)) {
|
||||||
|
node.classList.add("tomie-symbol"); node.textContent = ({ EffectSuccess: "✓", EffectFailed: "×", EffectHeart: "♥" } as Record<string, string>)[name];
|
||||||
|
} else if (name === "DigitalGlitch") node.classList.add("tomie-glitch");
|
||||||
|
else if (name === "Rain") for (let index = 0; index < 140; index++) {
|
||||||
|
const drop = element("i", node, "tomie-drop"); drop.style.left = (index * 137 % 1920) / 19.2 + "%";
|
||||||
|
drop.style.animationDelay = -(index * 83 % 1080) / 950 + "s";
|
||||||
|
} else if (name === "CamShowUI") {
|
||||||
|
node.classList.add("tomie-caption"); const values = this.vm.variables;
|
||||||
|
node.textContent = `Время: ${values.L_CamTime ?? 0} · Посетители: ${values.L_CamVisit ?? 0}\nДоход: $${values.L_CamCashToday ?? 0} · Поклонники: ${values.L_CamFan ?? 0}`;
|
||||||
|
} else if (name === "LewdInswitterComment") { node.classList.add("tomie-caption"); this.rich(node, this.lewdComment); }
|
||||||
|
else if (name === "SkillUp") {
|
||||||
|
node.classList.add("tomie-caption"); this.rich(node, `${this.vm.variables.UpSkillTitle ?? params[0] ?? "Skill"}\n${this.vm.variables.UpSkillExplain ?? ""}`);
|
||||||
|
} else throw new Error("Не поддержан эффект Tomie: " + name);
|
||||||
|
}
|
||||||
|
if (Number(this.vm.variables.Control_Progress ?? 0) > 0 && this.ambient) this.rich(element("div", this.effects, "tomie-ambient"), this.ambient);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tint(value: string): string {
|
||||||
|
if (["white", "#ffffff", "#fff"].includes(value.toLowerCase())) return "none";
|
||||||
|
const color = value.match(/^#([\da-f]{6})([\da-f]{2})?$/i);
|
||||||
|
if (!color) throw new Error("Не поддерживается цвет Tomie: " + value);
|
||||||
|
const [r, g, b] = [0, 2, 4].map((offset) => parseInt(color[1].slice(offset, offset + 2), 16) / 255);
|
||||||
|
const alpha = color[2] ? parseInt(color[2], 16) / 255 : 1;
|
||||||
|
const id = `${this.filterId}-${this.filters.childElementCount}`;
|
||||||
|
const filter = document.createElementNS(this.filters.namespaceURI, "filter"), matrix = document.createElementNS(this.filters.namespaceURI, "feColorMatrix");
|
||||||
|
filter.setAttribute("id", id); filter.setAttribute("color-interpolation-filters", "sRGB");
|
||||||
|
matrix.setAttribute("type", "matrix"); matrix.setAttribute("values", `${r} 0 0 0 0 0 ${g} 0 0 0 0 0 ${b} 0 0 0 0 0 ${alpha} 0`);
|
||||||
|
filter.append(matrix); this.filters.append(filter); return `url(#${id})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderBoard(): void {
|
||||||
|
const values = this.vm.variables;
|
||||||
|
const heading = element("div", this.board); heading.textContent = `День ${values.day} · $${values.money} · Воля ${values.iWillpower} · AP ${values.dActionPoint}`;
|
||||||
|
const quest = element("div", this.board); this.rich(quest, String(values.iQuestMsg ?? ""));
|
||||||
|
if (this.vm.waiting === "choice") {
|
||||||
|
this.vm.choices.forEach((choice, index) => this.button(this.board, this.plain(choice.ChoiceSummary).replaceAll("@", " · "), () => { this.vm.choose(index); this.render(); }, !this.vm.choiceSupported(choice)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cells = element("div", this.board, "tomie-cells");
|
||||||
|
for (const cell of this.gameplay.cells()) {
|
||||||
|
const label = element("span", cells); label.textContent = cell.title; label.title = this.plain(cell.detail);
|
||||||
|
if (cell.index === Number(values.currentPosition)) label.classList.add("current");
|
||||||
|
}
|
||||||
|
const dice = element("div", this.board);
|
||||||
|
for (const [title, type] of [["1–6", 0], ["1–3 · −10 воли", 2], ["4–6 · −5 воли", 3], ["Нечётный · −5", 4], ["Чётный · −5", 5]] as [string, number][]) {
|
||||||
|
this.button(dice, title, () => { const result = this.gameplay.roll(type); if (!result.ok) this.status.textContent = result.message ?? ""; this.render(); });
|
||||||
|
}
|
||||||
|
const slider = element("input", this.board); slider.type = "range"; slider.min = "0"; slider.max = "2"; slider.step = "0.01";
|
||||||
|
slider.oninput = () => this.vm.variables._godot_dice_slider = Number(slider.value);
|
||||||
|
this.button(this.board, "Кубик со шкалой", () => { this.gameplay.roll(1); this.render(); });
|
||||||
|
const jokers = element("div", this.board); jokers.append(`Joker: ${values.iJoker ?? 0} `);
|
||||||
|
for (let value = 1; value <= 6; value++) this.button(jokers, String(value), () => { this.gameplay.roll(10 + value); this.render(); }, Number(values.iJoker ?? 0) < 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawMini(): void {
|
||||||
|
const game = this.mini;
|
||||||
|
if (!game) return;
|
||||||
|
const context = this.miniCanvas.getContext("2d"); if (!context) return;
|
||||||
|
context.fillStyle = "#0a101a"; context.fillRect(0, 0, 1920, 1080);
|
||||||
|
context.fillStyle = "#d477ac"; context.beginPath(); context.arc(960, 540, 32, 0, Math.PI * 2); context.fill();
|
||||||
|
context.fillStyle = "#d9f2ff";
|
||||||
|
for (const particle of game.state.particles) { context.beginPath(); context.arc(particle.x, particle.y, 8, 0, Math.PI * 2); context.fill(); }
|
||||||
|
context.font = "32px sans-serif"; context.textAlign = "center";
|
||||||
|
context.fillText(game.state.result === "waiting" ? "Клик или Space отталкивает частицы. Продержитесь 20 секунд." :
|
||||||
|
game.state.result === "running" ? (20 - game.state.elapsed).toFixed(1) : game.state.result === "won" ? "Вы удержали защиту." : "Защита прорвана.", 960, 180);
|
||||||
|
if (["won", "lost"].includes(game.state.result) && !this.miniControls.childElementCount) {
|
||||||
|
this.button(this.miniControls, "Продолжить", () => {
|
||||||
|
const won = game.state.result === "won"; this.mini = null;
|
||||||
|
this.vm.trigger("Common2", won ? "PregNO" : "PregOK"); this.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private open(title: string): HTMLDivElement {
|
||||||
|
this.modal.replaceChildren(); const header = element("h2", this.modal); header.textContent = title;
|
||||||
|
this.button(this.modal, "Закрыть", () => this.modal.close());
|
||||||
|
const body = element("div", this.modal); if (!this.modal.open) this.modal.showModal(); return body;
|
||||||
|
}
|
||||||
|
private input(summary: string): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
const body = this.open(summary), field = element("input", body); field.maxLength = 500;
|
||||||
|
this.button(body, "Продолжить", () => { this.modal.close(); this.vm.submitInput(this.inputName, field.value); this.render(); }); field.focus();
|
||||||
|
}
|
||||||
|
private historyDialog(): void { const body = this.open("История"); for (const line of this.history) this.rich(element("p", body), line); }
|
||||||
|
private inventory(): void {
|
||||||
|
const body = this.open("Инвентарь");
|
||||||
|
for (const [id, count] of Object.entries(this.vm.evaluator.state.inventory)) {
|
||||||
|
if (count <= 0) continue;
|
||||||
|
this.button(body, `${this.dictionary("Inventory", id)} ×${count}`, () => { this.modal.close(); this.vm.runExternal("UseItem", id); this.render(); },
|
||||||
|
!this.vm.onBoard || !Object.hasOwn(this.content.scripts.UseItem?.labels ?? {}, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private statistics(): void {
|
||||||
|
const body = this.open("Параметры и задания");
|
||||||
|
for (const key of ["day", "money", "iWillpower", "iPain", "iAttractive", "iPheromones", "iElegance", "iReputation", "iSubmissive", "iCorruption", "iQuestMsg", "contractString", "commentString"])
|
||||||
|
this.rich(element("p", body), `${key}: ${String(this.vm.variables[key] ?? "").replaceAll("^", "\n").replaceAll("@", " · ")}`);
|
||||||
|
for (const message of String(this.vm.variables.messageString ?? "").split("^").filter(Boolean)) {
|
||||||
|
const fields = message.split("@"); this.rich(element("p", body), fields.join(" · "));
|
||||||
|
this.button(body, "Открыть сообщение", () => {
|
||||||
|
this.modal.close(); [this.vm.variables.mName, this.vm.variables.mMsg, this.vm.variables.mInfo] = fields;
|
||||||
|
this.vm.runExternal("PGamesMessage"); this.render();
|
||||||
|
}, !this.vm.onBoard || fields.length < 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private galleryDialog(): void {
|
||||||
|
const body = this.open("Просмотренные иллюстрации"); const preview = element("img", body, "tomie-preview");
|
||||||
|
for (const name of this.gallery) if (this.content.media.backgrounds[name.toLowerCase()])
|
||||||
|
this.button(body, name, () => preview.src = this.url(this.content.media.backgrounds[name.toLowerCase()]));
|
||||||
|
}
|
||||||
|
private link(value: string): void {
|
||||||
|
let url: URL; try { url = new URL(value); } catch { return; }
|
||||||
|
if (!["https:", "http:"].includes(url.protocol)) return;
|
||||||
|
const body = this.open("Открыть ссылку"); const link = element("a", body);
|
||||||
|
link.href = url.href; link.target = "_blank"; link.rel = "noopener noreferrer"; link.textContent = url.href;
|
||||||
|
}
|
||||||
|
private snapshot(): TomieSave {
|
||||||
|
return { format: "tomie/1", revision: this.manifest.revision, runtime: this.vm.snapshot(), scene: this.scene.snapshot(),
|
||||||
|
mini: this.mini?.snapshot() ?? null, delay: this.delay, history: [...this.history], ambient: this.ambient, ambientElapsed: this.ambientElapsed, lewdComment: this.lewdComment };
|
||||||
|
}
|
||||||
|
private async writeSave(slot: string): Promise<void> {
|
||||||
|
await this.saves.put(slot, `День ${this.vm.variables.day ?? 1} · ${this.vm.script}`, this.snapshot());
|
||||||
|
}
|
||||||
|
private async slots(load: boolean): Promise<void> {
|
||||||
|
const body = this.open(load ? "Загрузить" : "Сохранить"), list = await this.saves.list();
|
||||||
|
if (!this.modal.open || this.disposed) return;
|
||||||
|
for (const slot of ["auto", "quick", ...Array.from({ length: 8 }, (_, index) => String(index + 1))]) {
|
||||||
|
const existing = list.find((entry) => entry.slot === slot);
|
||||||
|
this.button(body, `${slot} · ${existing?.title ?? "Пусто"}`, () => { void (async () => {
|
||||||
|
if (!load) await this.writeSave(slot);
|
||||||
|
else {
|
||||||
|
const saved = await this.saves.get(slot);
|
||||||
|
if (!saved || saved.format !== "tomie/1" || (saved.revision && saved.revision !== this.manifest.revision)) throw new Error("Сохранение относится к другой редакции игры.");
|
||||||
|
if (!this.vm.restore(saved.runtime)) throw new Error("Не удалось восстановить сценарий из сохранения.");
|
||||||
|
this.scene.restore(saved.scene); this.delay = saved.delay; this.history = saved.history; this.mode = "normal";
|
||||||
|
this.ambient = saved.ambient ?? ""; this.ambientElapsed = saved.ambientElapsed ?? 0;
|
||||||
|
this.lewdComment = saved.lewdComment ?? "";
|
||||||
|
this.mini = saved.mini ? new TomieMinigame(this.vm.evaluator.random) : null; if (saved.mini) this.mini!.restore(saved.mini);
|
||||||
|
this.miniControls.replaceChildren(); this.audio.stopAll();
|
||||||
|
if (this.mini?.state.result === "waiting") this.button(this.miniControls, "Начать", () => { this.mini?.start(); this.miniControls.replaceChildren(); });
|
||||||
|
if (this.scene.state.bgm) this.audio.play("music", [this.scene.state.bgm.file], { loop: this.scene.state.bgm.loop, volume: this.scene.state.bgm.volume });
|
||||||
|
}
|
||||||
|
this.modal.close(); this.render();
|
||||||
|
})().catch((error) => this.fail(error)); }, load && !existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Serializable presentation state. Restoring a save never replays script side effects.
|
||||||
|
export interface TomieMedia {
|
||||||
|
backgrounds: Record<string, string>;
|
||||||
|
characters: Record<string, { file: string; size: [number, number] }>;
|
||||||
|
textures: Record<string, string>;
|
||||||
|
videos: Record<string, string>;
|
||||||
|
audio: Record<string, string>;
|
||||||
|
fonts: Record<string, string>;
|
||||||
|
}
|
||||||
|
interface Actor {
|
||||||
|
appearance: string; file: string; size: [number, number]; visible: boolean;
|
||||||
|
position: number[]; scale: number[]; tint: string; direction: string;
|
||||||
|
}
|
||||||
|
interface Track { path: string; file: string; volume: number; loop: boolean }
|
||||||
|
export interface TomieVisual {
|
||||||
|
background: string;
|
||||||
|
backgroundFile: string;
|
||||||
|
backgroundScale: number[];
|
||||||
|
backgroundTint: string;
|
||||||
|
actors: Record<string, Actor>;
|
||||||
|
text: string;
|
||||||
|
author: string;
|
||||||
|
tid?: string;
|
||||||
|
printer: string;
|
||||||
|
printerVisible: boolean;
|
||||||
|
zoom: number;
|
||||||
|
bgm: Track | null;
|
||||||
|
sfx: Track | null;
|
||||||
|
voice: Track | null;
|
||||||
|
video: { spec: string; file: string; speed: number; loop: boolean } | null;
|
||||||
|
effects: Record<string, any[]>;
|
||||||
|
ui: string[];
|
||||||
|
inputEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const named = (value: any): { name: string; value: string } => {
|
||||||
|
if (value && typeof value === "object") return { name: String(value.name ?? ""), value: String(value.value ?? "") };
|
||||||
|
const [name, ...rest] = String(value ?? "").split(".");
|
||||||
|
return { name, value: rest.join(".") };
|
||||||
|
};
|
||||||
|
const number = (value: any, fallback: number) => Number.isFinite(Number(value)) ? Number(value) : fallback;
|
||||||
|
const scale = (value: any, fallback: number[]) => value == null ? [...fallback] :
|
||||||
|
Array.isArray(value) ? [number(value[0], 1), number(value[1] ?? value[0], 1)] : [number(value, 1), number(value, 1)];
|
||||||
|
const safeKey = (key: string) => {
|
||||||
|
if (["__proto__", "constructor", "prototype"].includes(key)) throw new Error("Некорректный ID ресурса Tomie");
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TomieScene {
|
||||||
|
state: TomieVisual = {
|
||||||
|
background: "black", backgroundFile: "", backgroundScale: [1, 1], backgroundTint: "white",
|
||||||
|
actors: {}, text: "", author: "", printer: "Wide", printerVisible: true, zoom: 1,
|
||||||
|
bgm: null, sfx: null, voice: null, video: null, effects: {}, ui: [], inputEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(readonly media: TomieMedia) {}
|
||||||
|
|
||||||
|
private resource(collection: Record<string, string>, name: string): string {
|
||||||
|
const path = Object.hasOwn(collection, name.toLowerCase()) ? collection[name.toLowerCase()] : "";
|
||||||
|
if (!path) throw new Error("Tomie: не найден ресурс " + name);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(kind: string, data: Record<string, any>): boolean {
|
||||||
|
const state = this.state;
|
||||||
|
switch (kind) {
|
||||||
|
case "text":
|
||||||
|
state.text = data.reset ? data.text : state.text + data.text;
|
||||||
|
state.author = data.author; state.tid = data.tid; state.printerVisible = true; return true;
|
||||||
|
case "line_break": state.text += "\n".repeat(Math.min(100, Math.max(0, number(data.count, 1)))); return true;
|
||||||
|
case "HidePrinter": state.printerVisible = false; return true;
|
||||||
|
case "ShowPrinter": state.printerVisible = true; return true;
|
||||||
|
case "ModifyTextPrinter":
|
||||||
|
state.printer = named(data.IdAndAppearance).name || state.printer;
|
||||||
|
state.printerVisible = true; return true;
|
||||||
|
case "ModifyBackground": {
|
||||||
|
const name = named(data.AppearanceAndTransition).name;
|
||||||
|
state.backgroundFile = name.toLowerCase() === "black" ? "" : this.resource(this.media.backgrounds, name);
|
||||||
|
state.background = name;
|
||||||
|
state.backgroundScale = scale(data.Scale, [1, 1]); state.backgroundTint = String(data.TintColor ?? "white"); return true;
|
||||||
|
}
|
||||||
|
case "ModifyCharacter": {
|
||||||
|
const value = named(data.IdAndAppearance), id = safeKey(value.name);
|
||||||
|
const previous = state.actors[id];
|
||||||
|
if (data.Visible === false || data.Visible === 0) {
|
||||||
|
if (previous) previous.visible = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const appearance = value.value || previous?.appearance || "";
|
||||||
|
const entry = this.media.characters[(id + "/" + appearance).toLowerCase()];
|
||||||
|
if (!entry) throw new Error(`Tomie: не найден образ ${id}/${appearance}`);
|
||||||
|
state.actors[id] = { appearance, ...entry, visible: true,
|
||||||
|
position: data.ScenePosition ?? previous?.position ?? [50, 0],
|
||||||
|
scale: scale(data.Scale, previous?.scale ?? [1, 1]), tint: data.TintColor ?? previous?.tint ?? "white",
|
||||||
|
direction: data.LookDirection ?? previous?.direction ?? "Center" };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "HideAllCharacters": Object.values(state.actors).forEach((actor) => actor.visible = false); return true;
|
||||||
|
case "HideActors":
|
||||||
|
for (const id of data.ActorIds ?? []) if (Object.hasOwn(state.actors, id)) state.actors[id].visible = false;
|
||||||
|
return true;
|
||||||
|
case "ModifyCamera": state.zoom = 1 + number(data.Zoom, 0); return true;
|
||||||
|
case "PlayBgm": case "PlaySfx": case "PlayVoice": {
|
||||||
|
const channel = kind === "PlayBgm" ? "bgm" : kind === "PlayVoice" ? "voice" : "sfx";
|
||||||
|
const path = String(data[kind === "PlayBgm" ? "BgmPath" : kind === "PlayVoice" ? "VoicePath" : "SfxPath"] ?? "");
|
||||||
|
state[channel] = path ? { path, file: this.resource(this.media.audio, path), volume: number(data.Volume, 1), loop: Boolean(data.Loop) } : null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "StopBgm": state.bgm = null; return true;
|
||||||
|
case "StopSfx": state.sfx = null; return true;
|
||||||
|
case "process_input": state.inputEnabled = Boolean(data.enabled); return true;
|
||||||
|
case "ShowUI": state.ui = [...new Set([...state.ui, ...(data.UINames ?? [])])]; return true;
|
||||||
|
case "HideUI": state.ui = state.ui.filter((name) => !(data.UINames ?? []).includes(name)); return true;
|
||||||
|
case "Spawn": state.effects[safeKey(String(data.Path))] = structuredClone(data.Params ?? []); return true;
|
||||||
|
case "DestroySpawned": delete state.effects[safeKey(String(data.Path))]; return true;
|
||||||
|
case "variable_changed":
|
||||||
|
if (["videoBack", "videoPlay"].includes(data.name)) {
|
||||||
|
const spec = String(data.value ?? "");
|
||||||
|
if (!spec || ["null", "none"].includes(spec)) state.video = null;
|
||||||
|
else {
|
||||||
|
const name = spec.split("@")[0];
|
||||||
|
state.video = { spec, file: this.resource(this.media.videos, name), speed: state.video?.speed ?? 1, loop: true };
|
||||||
|
}
|
||||||
|
} else if (data.name === "videoSpeed" && state.video) state.video.speed = Math.max(0, number(data.value, 1));
|
||||||
|
return true;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): TomieVisual { return structuredClone(this.state); }
|
||||||
|
restore(state: TomieVisual): void { this.state = structuredClone(state); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
.tomie-root { position: relative; width: 100%; height: 100%; background: #090b11; color: #f6f2ec; font-family: system-ui, sans-serif; display: flex; flex-direction: column; }
|
||||||
|
.tomie-stage { position: relative; flex: 1; overflow: hidden; background: #000; }
|
||||||
|
.tomie-background,.tomie-actors,.tomie-effects,.tomie-mini { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||||
|
.tomie-background { object-fit: fill; }
|
||||||
|
.tomie-actors { pointer-events: none; }
|
||||||
|
.tomie-video { position: absolute; top: 0; height: 100%; object-fit: fill; }
|
||||||
|
.tomie-effects { pointer-events: none; }
|
||||||
|
.tomie-dialogue { position: absolute; left: 8%; right: 8%; bottom: 3%; background: #111621ed; border: 1px solid #8f7e69; border-radius: 12px; text-align: left; padding: 18px 28px; color: inherit; max-height: 42%; overflow: auto; }
|
||||||
|
.tomie-author { color: #f7bc7c; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
.tomie-text { white-space: pre-wrap; }
|
||||||
|
.tomie-choices { position: absolute; right: 9%; top: 8%; max-height: 63%; overflow: auto; display: grid; gap: 8px; max-width: 75%; }
|
||||||
|
.tomie-board { position: absolute; right: 2%; top: 3%; max-height: 88%; max-width: 72%; overflow: auto; background: #121822ed; border-radius: 12px; padding: 16px; }
|
||||||
|
.tomie-board:empty { display: none; }
|
||||||
|
.tomie-root button { cursor: pointer; color: inherit; background: #293343; border: 1px solid #576579; border-radius: 7px; padding: 9px 13px; margin: 3px; white-space: pre-wrap; }
|
||||||
|
.tomie-root button:hover { background: #3a475a; }
|
||||||
|
.tomie-root button:disabled { opacity: .45; cursor: default; }
|
||||||
|
.tomie-cells { display: flex; flex-wrap: wrap; gap: 5px; margin: 14px 0; }
|
||||||
|
.tomie-cells span { padding: 7px; border: 1px solid #455167; }
|
||||||
|
.tomie-cells .current { border-color: #f5b77f; color: #ffd2a8; }
|
||||||
|
.tomie-toolbar { display: flex; flex-wrap: wrap; justify-content: center; background: #121620; }
|
||||||
|
.tomie-status { padding: 3px 12px; min-height: 20px; color: #ffd4ab; font-size: 13px; }
|
||||||
|
.tomie-modal { color: #f6f2ec; background: #171e2b; border: 1px solid #6d7c94; border-radius: 12px; max-width: min(900px, 92vw); width: 80vw; max-height: 80vh; }
|
||||||
|
.tomie-modal::backdrop { background: #0009; }
|
||||||
|
.tomie-preview { display: block; max-width: 100%; max-height: 45vh; }
|
||||||
|
.tomie-mini { z-index: 50; }
|
||||||
|
.tomie-mini-controls { position: absolute; z-index: 51; bottom: 15%; left: 40%; }
|
||||||
|
.tomie-root [hidden] { display: none !important; }
|
||||||
|
.tomie-effect { position: absolute; inset: 0; }
|
||||||
|
.tomie-symbol { display: grid; place-items: center; color: #ff90a0; font-size: 120px; }
|
||||||
|
.tomie-caption,.tomie-ambient { position: absolute; left: 59%; top: 14%; right: 4%; white-space: pre-wrap; background: #141a2be6; padding: 14px; border-radius: 10px; font-size: clamp(12px, 1.7vw, 25px); }
|
||||||
|
.tomie-caption { top: 35%; bottom: auto; }
|
||||||
|
.tomie-glitch { background: repeating-linear-gradient(0deg, #00ffa522 0 2px, transparent 2px 6px); animation: tomie-glitch .14s steps(2) infinite; }
|
||||||
|
.tomie-drop { position: absolute; top: -10%; width: 1px; height: 4%; background: #bed9ff66; transform: rotate(19deg); animation: tomie-rain 1.14s linear infinite; }
|
||||||
|
.tomie-shake { animation: tomie-shake .4s linear; }
|
||||||
|
@keyframes tomie-shake { 25%,75% { translate: 6px 3px; } 50%,90% { translate: -6px -3px; } }
|
||||||
|
@keyframes tomie-glitch { to { translate: 5px -2px; opacity: .3; } }
|
||||||
|
@keyframes tomie-rain { to { top: 110%; } }
|
||||||
@@ -89,11 +89,15 @@ export interface ScriptModule {
|
|||||||
file: string;
|
file: string;
|
||||||
labels: Label[];
|
labels: Label[];
|
||||||
anchors?: Record<string, Anchor>;
|
anchors?: Record<string, Anchor>;
|
||||||
|
nani?: { name: string; labels: Record<string, number>; label_depths: Record<string, number>; label_ids?: Record<string, string> };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameData {
|
export interface GameData {
|
||||||
|
runtime?: string;
|
||||||
|
tomie?: Omit<import("./tomie-player").TomieContent, "scripts" | "resources">;
|
||||||
engine?: string;
|
engine?: string;
|
||||||
naniExpressions?: Record<string, import("./naninovel").NaniAst>;
|
naniExpressions?: Record<string, import("./naninovel").NaniAst>;
|
||||||
|
naniResources?: Record<string, Record<string, { tid: string; text: string }[]>>;
|
||||||
format: string;
|
format: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
resolution: [number, number];
|
resolution: [number, number];
|
||||||
|
|||||||
@@ -56,6 +56,19 @@ test("fade-in respects live volume changes and stays muted at zero", () => {
|
|||||||
assert.equal(elements[0].volume, 0);
|
assert.equal(elements[0].volume, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("script track gain multiplies the mixer and survives fades and queued clips", () => {
|
||||||
|
const audio = system();
|
||||||
|
audio.play("voice", ["first", "second"], { volume: 0.25, fadein: 2 });
|
||||||
|
tick(1000);
|
||||||
|
assert.equal(elements[0].volume, 0.125);
|
||||||
|
audio.setVolumes({ ...volumes(), voice: 0.4 });
|
||||||
|
assert.equal(elements[0].volume, 0.05);
|
||||||
|
tick(1000);
|
||||||
|
elements[0].onended();
|
||||||
|
assert.equal(elements.at(-1).src, "second");
|
||||||
|
assert.equal(elements.at(-1).volume, 0.1);
|
||||||
|
});
|
||||||
|
|
||||||
test("outgoing tracks also obey the mixer during crossfade", () => {
|
test("outgoing tracks also obey the mixer during crossfade", () => {
|
||||||
const audio = system();
|
const audio = system();
|
||||||
audio.play("music", ["old"], {});
|
audio.play("music", ["old"], {});
|
||||||
|
|||||||
@@ -38,3 +38,32 @@ test("Naninovel AST runs through the shared store and survives JSON saves", asyn
|
|||||||
py.exec([{ op: "set", t: { k: "name", id: "score" }, v: { k: "nani", ast: bin("+", n("score"), c(4)) } }]);
|
py.exec([{ op: "set", t: { k: "name", id: "score" }, v: { k: "nani", ast: bin("+", n("score"), c(4)) } }]);
|
||||||
assert.equal(JSON.parse(JSON.stringify(store)).score, 6);
|
assert.equal(JSON.parse(JSON.stringify(store)).score, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the player presents translated Naninovel choices and jumps after selection", async () => {
|
||||||
|
const { Engine } = await load("engine");
|
||||||
|
class Element {
|
||||||
|
children = [];
|
||||||
|
style = {};
|
||||||
|
classList = { add() {}, remove() {} };
|
||||||
|
append(child) { this.children.push(child); }
|
||||||
|
set textContent(value) { this.children = value ? [value] : []; }
|
||||||
|
}
|
||||||
|
const previousDocument = globalThis.document;
|
||||||
|
globalThis.document = { createElement: () => new Element() };
|
||||||
|
try {
|
||||||
|
const engine = Object.create(Engine.prototype);
|
||||||
|
Object.assign(engine, { store: {}, game: { engine: "naninovel", naniExpressions: {} },
|
||||||
|
tr: { line: (_tid, text) => text === "Again" ? "Ещё раз" : text },
|
||||||
|
menuEl: new Element(), baseSize: 28, interactionStart() {}, setSkip() {},
|
||||||
|
advance() {}, jump(target) { this.chosenTarget = target; },
|
||||||
|
wait(setup) { return new Promise((resolve) => { setup(resolve); this.menuEl.children[0].onclick({ stopPropagation() {} }); }); } });
|
||||||
|
await engine.exec({ op: "nani_add_choice", what: "Again", tid: "choice-id", target: "nani:Opening", handler: "" });
|
||||||
|
assert.equal(JSON.parse(JSON.stringify(engine.store)).__nani_choices[0].tid, "choice-id");
|
||||||
|
await engine.exec({ op: "nani_menu" });
|
||||||
|
assert.equal(engine.menuEl.children[0].children[0], "Ещё раз");
|
||||||
|
assert.equal(engine.chosenTarget, "nani:Opening");
|
||||||
|
assert.deepEqual(engine.store.__nani_choices, []);
|
||||||
|
} finally {
|
||||||
|
globalThis.document = previousDocument;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Optional browser integration: PGAMES_PLAYWRIGHT_PATH points at a Playwright
|
||||||
|
// installation; its matching Chromium must already be installed.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { build } from "esbuild";
|
||||||
|
|
||||||
|
test("native Tomie UI plays, saves inside a call, restores and presents the board", {
|
||||||
|
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
|
||||||
|
}, async () => {
|
||||||
|
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
|
||||||
|
const entry = fileURLToPath(new URL("../src/player/tomie-player.ts", import.meta.url));
|
||||||
|
const bundle = await build({ entryPoints: [entry], bundle: true, write: false, format: "iife", globalName: "NativeTomie",
|
||||||
|
platform: "browser", loader: { ".css": "empty" }, logLevel: "silent" });
|
||||||
|
const browser = await chromium.launch({ headless: true, executablePath: process.env.PGAMES_CHROMIUM_EXECUTABLE });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||||
|
const faults = [];
|
||||||
|
page.on("pageerror", (error) => faults.push(error.message));
|
||||||
|
try {
|
||||||
|
await page.goto("about:blank");
|
||||||
|
await page.setContent('<style>body{margin:0}#host{height:800px}</style><div id="host"></div>');
|
||||||
|
await page.addStyleTag({ content: readFileSync(new URL("../src/player/tomie.css", import.meta.url), "utf8") });
|
||||||
|
await page.addScriptTag({ content: bundle.outputFiles[0].text });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const c = (op, args = {}) => ({ op, args });
|
||||||
|
const script = (commands, labels = {}) => ({ commands, labels });
|
||||||
|
const content = { defaults: { day: 1, money: 100, iWillpower: 100, dActionPoint: 4 }, function_data: {},
|
||||||
|
initial_inventory: { coin: 1 }, start: { script: "StartGameSetting", label: "Start" }, resources: {}, board_routes: {},
|
||||||
|
media: { backgrounds: {}, characters: {}, textures: {}, videos: {}, audio: {}, fonts: {} },
|
||||||
|
scripts: {
|
||||||
|
StartGameSetting: script([c("PrintText", { Text: "Hello" }), c("Gosub", { Path: "Sub" }),
|
||||||
|
c("InputCustomVariable", { VariableName: "name", Summary: "Your name" }),
|
||||||
|
c("AddChoice", { ChoiceSummary: "Choose", OnSelected: { template: '@set answer="{name}"' } }), c("Stop"),
|
||||||
|
c("Spawn", { Path: "Rain" }), c("PrintText", { Text: { template: "Saved {answer}" } }), c("Goto", { Path: "AppScreen" })], { Start: 0 }),
|
||||||
|
Sub: script([c("PrintText", { Text: "Subroutine" }), c("Return")]),
|
||||||
|
AppScreen: script([c("Stop")]),
|
||||||
|
UseItem: script([c("RemoveItem", { ItemId: "coin" }), c("PrintText", { Text: "Used coin" }),
|
||||||
|
c("Spawn", { Path: "PregMiniGame" }), c("Stop")], { coin: 0 }),
|
||||||
|
Common2: script([c("PrintText", { Text: "Won" }), c("Goto", { Path: "AppScreen" }),
|
||||||
|
c("PrintText", { Text: "Lost" }), c("Goto", { Path: "AppScreen" })], { PregNO: 0, PregOK: 2 }),
|
||||||
|
} };
|
||||||
|
const slots = new Map();
|
||||||
|
const api = { list: async () => [...slots].map(([slot]) => ({ slot, title: "Saved" })),
|
||||||
|
put: async (slot, _title, data) => slots.set(slot, structuredClone(data)),
|
||||||
|
get: async (slot) => structuredClone(slots.get(slot)), remove: async (slot) => slots.delete(slot) };
|
||||||
|
window.native = new NativeTomie.TomiePlayer(document.querySelector("#host"), content, { versionId: 1, revision: "test", files: {} },
|
||||||
|
api, { line: (_tid, text) => text, str: (text) => text }, () => {});
|
||||||
|
window.slots = slots; window.native.start();
|
||||||
|
});
|
||||||
|
await page.locator(".tomie-dialogue").click();
|
||||||
|
await page.getByRole("button", { name: "Сохранить", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "quick · Пусто", exact: true }).click();
|
||||||
|
assert.equal(await page.evaluate(() => window.slots.get("quick").runtime.stack.length), 1);
|
||||||
|
await page.locator(".tomie-dialogue").click();
|
||||||
|
await page.locator("dialog input").fill("Player");
|
||||||
|
await page.getByRole("button", { name: "Продолжить", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "Choose", exact: true }).click();
|
||||||
|
assert.equal(await page.locator(".tomie-text").textContent(), "Saved Player");
|
||||||
|
assert.equal(await page.locator(".tomie-drop").count(), 140);
|
||||||
|
await page.getByRole("button", { name: "Загрузить", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "quick · Saved", exact: true }).click();
|
||||||
|
assert.equal(await page.locator(".tomie-text").textContent(), "Subroutine");
|
||||||
|
await page.locator(".tomie-dialogue").click();
|
||||||
|
await page.locator("dialog input").fill("Restored");
|
||||||
|
await page.getByRole("button", { name: "Продолжить", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "Choose", exact: true }).click();
|
||||||
|
await page.locator(".tomie-dialogue").click();
|
||||||
|
assert.match(await page.locator(".tomie-board").textContent(), /День 1/);
|
||||||
|
await page.getByRole("button", { name: "Инвентарь", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "coin ×1", exact: true }).click();
|
||||||
|
assert.equal(await page.locator(".tomie-text").textContent(), "Used coin");
|
||||||
|
assert.equal(await page.evaluate(() => window.native.vm.evaluator.state.inventory.coin), 0);
|
||||||
|
await page.locator(".tomie-dialogue").click();
|
||||||
|
await page.getByRole("button", { name: "Начать", exact: true }).click();
|
||||||
|
await page.evaluate(() => window.native.mini.update(0.5));
|
||||||
|
await page.getByRole("button", { name: "Сохранить", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "8 · Пусто", exact: true }).click();
|
||||||
|
assert.ok(await page.evaluate(() => window.slots.get("8").mini.elapsed >= 0.5));
|
||||||
|
await page.evaluate(() => {
|
||||||
|
for (let step = 0; step < 1300; step++) { window.native.mini.pulse(); window.native.mini.update(1 / 60); }
|
||||||
|
window.native.drawMini();
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Продолжить", exact: true }).click();
|
||||||
|
assert.equal(await page.locator(".tomie-text").textContent(), "Won");
|
||||||
|
await page.getByRole("button", { name: "Загрузить", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "8 · Saved", exact: true }).click();
|
||||||
|
assert.equal(await page.evaluate(() => window.native.mini.state.result), "running");
|
||||||
|
assert.equal(await page.locator(".tomie-mini").isVisible(), true);
|
||||||
|
assert.deepEqual(faults, []);
|
||||||
|
await page.evaluate(() => window.native.destroy());
|
||||||
|
assert.equal(await page.locator(".tomie-root").count(), 0);
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { build } from "esbuild";
|
||||||
|
|
||||||
|
async function load(name) {
|
||||||
|
const result = await build({ entryPoints: [fileURLToPath(new URL(`../src/player/${name}.ts`, import.meta.url))],
|
||||||
|
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
||||||
|
return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString("base64")}`);
|
||||||
|
}
|
||||||
|
const { TomieFunctions } = await load("tomie-functions");
|
||||||
|
const { NaniRuntime } = await load("nani-runtime");
|
||||||
|
const { parseNani } = await load("nani-parser");
|
||||||
|
const { TomieScene } = await load("tomie-scene");
|
||||||
|
const { TomieMinigame } = await load("tomie-minigame");
|
||||||
|
const command = (op, args = {}) => ({ op, args });
|
||||||
|
|
||||||
|
test("Tomie functions preserve source formulas, string lists and saved RNG", () => {
|
||||||
|
let store = {};
|
||||||
|
const helpers = new TomieFunctions({ store: () => store, tables: { names: ["A", "B"] }, hasLabel: () => false, hasVideo: () => false, link() {} });
|
||||||
|
const f = helpers.functions;
|
||||||
|
assert.equal(f.WillpowerDamage(100, 50, 20, 0, 100), 83);
|
||||||
|
assert.equal(f.RoundValue(2.5), 2); assert.equal(f.RoundValue(3.5), 4);
|
||||||
|
assert.equal(f.RoundValue(-2.5), -2); assert.equal(f.RoundValue(-3.5), -4);
|
||||||
|
assert.equal(f.AddListString2("A@B", "B"), "A@B");
|
||||||
|
assert.equal(f.AddListString2("A", "B"), "A@B");
|
||||||
|
f.NewRandList(); f.AddRandList("A", 2); f.AddRandList("B");
|
||||||
|
assert.equal(f.MergeRandList(), "A&A&B");
|
||||||
|
helpers.state.rng = 12345;
|
||||||
|
const saved = structuredClone(store);
|
||||||
|
const first = [f.Random(1, 6), f.Random("A", "B", "C"), f.GetRandList()];
|
||||||
|
store = saved;
|
||||||
|
assert.deepEqual([f.Random(1, 6), f.Random("A", "B", "C"), f.GetRandList()], first);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runtime parser supports the recovered wrapped strings and lazy If", () => {
|
||||||
|
assert.deepEqual(parseNani('\\"hello\\"', new Set()), ["const", "hello"]);
|
||||||
|
assert.equal(parseNani('If(true, 1, 2)', new Set())[0], "if");
|
||||||
|
for (const invalid of ['globalThis.alert(1)', 'constructor()', 'a[0]', 'x; 2']) assert.throws(() => parseNani(invalid, new Set()));
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
return { start: { script: "Main", label: "Start" }, defaults: { points: 1 }, function_data: {},
|
||||||
|
scripts: { Main: { labels: { Start: 0, End: 5 }, commands: [
|
||||||
|
command("SetCustomVariable", { Expression: "points+=2" }),
|
||||||
|
command("Gosub", { Path: "Sub" }),
|
||||||
|
command("AddChoice", { ChoiceSummary: "Spend", OnSelected: ["@removeItem coin amount:1", "@set points=points+3"], GotoPath: ".End" }),
|
||||||
|
command("Stop"), command("PrintText", { Text: "unreachable" }),
|
||||||
|
command("PrintText", { Text: { template: "Score {points}" } }), command("ExitToTitle") ] },
|
||||||
|
Sub: { labels: {}, commands: [command("PrintText", { Text: "Subroutine" }), command("Return")] } }, initial_inventory: { coin: 1 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Naninovel call frames, choice continuations and inventory survive a save", () => {
|
||||||
|
const events = [];
|
||||||
|
const vm = new NaniRuntime(fixture(), (kind, data) => events.push({ kind, data }));
|
||||||
|
vm.start(); assert.equal(vm.waiting, "text"); assert.equal(vm.stack.length, 1);
|
||||||
|
const saved = vm.snapshot();
|
||||||
|
const restored = new NaniRuntime(fixture(), (kind, data) => events.push({ kind, data }));
|
||||||
|
assert.equal(restored.restore(saved), true);
|
||||||
|
restored.advance(); assert.equal(restored.waiting, "choice");
|
||||||
|
const atChoice = restored.snapshot();
|
||||||
|
assert.equal(restored.restore(atChoice), true);
|
||||||
|
restored.choose(0);
|
||||||
|
assert.equal(restored.variables.points, 6);
|
||||||
|
assert.equal(restored.evaluator.state.inventory.coin, 0);
|
||||||
|
assert.equal(restored.waiting, "text");
|
||||||
|
assert.equal(events.findLast((event) => event.kind === "text").data.text, "Score 6");
|
||||||
|
restored.advance(); assert.equal(restored.waiting, "complete");
|
||||||
|
assert.equal(restored.restore({ ...saved, cursor: 9999 }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native conditional seeking handles gotos across block boundaries and EndIf no-ops", () => {
|
||||||
|
const data = fixture();
|
||||||
|
data.scripts.Main = { labels: { Start: 0, Outside: 5 }, commands: [
|
||||||
|
command("BeginIf", { Expression: "true" }), command("Goto", { Path: ".Outside" }),
|
||||||
|
command("EndIf"), command("PrintText", { Text: "unreachable" }), command("EndIf"),
|
||||||
|
command("EndIf"), command("BeginIf", { Expression: "false" }), command("BeginIf", { Expression: "true" }),
|
||||||
|
command("PrintText", { Text: "nested unreachable" }), command("EndIf"), command("ElseIf", { Expression: "true" }),
|
||||||
|
command("PrintText", { Text: "selected" }), command("Else"), command("PrintText", { Text: "wrong else" }),
|
||||||
|
command("EndIf"), command("PrintText", { Text: "done" }) ] };
|
||||||
|
const texts = [], vm = new NaniRuntime(data, (kind, value) => { if (kind === "text") texts.push(value.text); });
|
||||||
|
vm.start(); assert.deepEqual(texts, ["selected"]); vm.advance(); assert.deepEqual(texts, ["selected", "done"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tomie message triggers preserve anonymous threads and remove by identity", () => {
|
||||||
|
const vm = new NaniRuntime(fixture(), () => {});
|
||||||
|
vm.variables = { messageString: "Anonymous@hello@id1^Other@old@id2" };
|
||||||
|
vm.changed("newMessage", "Anonymous@new@id3");
|
||||||
|
assert.equal(vm.variables.messageString, "Anonymous@new@id3^Anonymous@hello@id1^Other@old@id2");
|
||||||
|
vm.changed("messageChanged", "id1#remove");
|
||||||
|
assert.equal(vm.variables.messageString, "Anonymous@new@id3^Other@old@id2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dynamic OnSelected templates are resolved before parsing and preserve the chosen value", () => {
|
||||||
|
const data = fixture();
|
||||||
|
data.defaults.answer = "Tomie";
|
||||||
|
data.scripts.Main.commands = [command("AddChoice", { ChoiceSummary: "Choose", OnSelected: { template: '@set result="{answer}"' } }),
|
||||||
|
command("Stop"), command("PrintText", { Text: { template: "Selected {result}" } })];
|
||||||
|
const vm = new NaniRuntime(data, () => {});
|
||||||
|
vm.start();
|
||||||
|
assert.equal(vm.choiceSupported(vm.choices[0]), true);
|
||||||
|
vm.choose(0);
|
||||||
|
assert.equal(vm.waiting, "text");
|
||||||
|
assert.equal(vm.variables.result, "Tomie");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tomie scene snapshots retain actor placement, media and effects without replaying the VM", () => {
|
||||||
|
const media = { backgrounds: { city: "bg" }, characters: { "tomie/home": { file: "actor", size: [700, 1080] } },
|
||||||
|
audio: { song: "music" }, videos: { clip: "video" }, textures: {}, fonts: {} };
|
||||||
|
const stage = new TomieScene(media);
|
||||||
|
stage.apply("ModifyBackground", { AppearanceAndTransition: { name: "city" } });
|
||||||
|
stage.apply("ModifyCharacter", { IdAndAppearance: "Tomie.home", ScenePosition: [33, 0, 1], Scale: [-1, 1] });
|
||||||
|
stage.apply("ModifyCharacter", { IdAndAppearance: "Tomie", ScenePosition: [60, 0] });
|
||||||
|
stage.apply("PlayBgm", { BgmPath: "Song", Loop: 1 });
|
||||||
|
stage.apply("variable_changed", { name: "videoBack", value: "Clip@0@half@right" });
|
||||||
|
stage.apply("Spawn", { Path: "Rain" });
|
||||||
|
stage.apply("text", { text: "Hello", author: "Tomie", reset: true });
|
||||||
|
const saved = stage.snapshot();
|
||||||
|
stage.apply("HideAllCharacters", {});
|
||||||
|
stage.apply("StopBgm", {});
|
||||||
|
const restored = new TomieScene(media);
|
||||||
|
restored.restore(saved);
|
||||||
|
assert.deepEqual(restored.state.actors.Tomie.scale, [-1, 1]);
|
||||||
|
assert.deepEqual(restored.state.actors.Tomie.position, [60, 0]);
|
||||||
|
assert.equal(restored.state.actors.Tomie.visible, true);
|
||||||
|
assert.equal(restored.state.bgm.file, "music");
|
||||||
|
assert.equal(restored.state.video.file, "video");
|
||||||
|
assert.ok(Object.hasOwn(restored.state.effects, "Rain"));
|
||||||
|
assert.equal(restored.state.text, "Hello");
|
||||||
|
assert.throws(() => restored.apply("ModifyCharacter", { IdAndAppearance: "Tomie.missing" }), /не найден образ/);
|
||||||
|
assert.throws(() => restored.apply("PlaySfx", { SfxPath: "missing" }), /не найден ресурс/);
|
||||||
|
assert.equal(restored.apply("NativePlugin", {}), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native minigame pauses, saves mid-round and takes both original result paths", () => {
|
||||||
|
const game = new TomieMinigame(() => 0.5);
|
||||||
|
game.start();
|
||||||
|
for (let step = 0; step < 120; step++) game.update(1 / 60);
|
||||||
|
const saved = game.snapshot();
|
||||||
|
game.update(0.5, true);
|
||||||
|
assert.deepEqual(game.snapshot(), saved);
|
||||||
|
assert.ok(saved.particles.length > 0);
|
||||||
|
const resumed = new TomieMinigame(() => 0.5);
|
||||||
|
resumed.restore(saved);
|
||||||
|
for (let step = 0; step < 1200; step++) {
|
||||||
|
resumed.pulse(); resumed.update(1 / 60);
|
||||||
|
game.pulse(); game.update(1 / 60);
|
||||||
|
}
|
||||||
|
assert.equal(resumed.state.result, "won");
|
||||||
|
assert.deepEqual(resumed.snapshot(), game.snapshot());
|
||||||
|
const lost = new TomieMinigame(() => 0.5);
|
||||||
|
lost.start();
|
||||||
|
for (let step = 0; step < 1200 && lost.state.result === "running"; step++) lost.update(1 / 60);
|
||||||
|
assert.equal(lost.state.result, "lost");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the original Unity extraction runs character creation through to the board", { skip: !process.env.PGAMES_TOMIE_RUNTIME }, () => {
|
||||||
|
const data = JSON.parse(readFileSync(process.env.PGAMES_TOMIE_RUNTIME, "utf8"));
|
||||||
|
const faults = [];
|
||||||
|
const vm = new NaniRuntime(data, (kind, value) => { if (kind === "error") faults.push(value.message); });
|
||||||
|
vm.start(129);
|
||||||
|
for (let step = 0; step < 10000 && !vm.onBoard && !["error", "complete"].includes(vm.waiting); step++) {
|
||||||
|
if (["text", "timer", "timed_choice"].includes(vm.waiting)) vm.advance();
|
||||||
|
else if (vm.waiting === "input") vm.submitInput(String(vm.resolve(vm.last.args.VariableName)), "Player");
|
||||||
|
else if (vm.waiting === "choice") {
|
||||||
|
const index = vm.choices.findIndex((choice) => vm.choiceSupported(choice));
|
||||||
|
assert.ok(index >= 0, `No available choices in ${vm.script}:${vm.last.line}`); vm.choose(index);
|
||||||
|
} else throw new Error(`Unexpected wait ${vm.waiting} in ${vm.script}:${vm.last.line}`);
|
||||||
|
}
|
||||||
|
assert.deepEqual(faults, []);
|
||||||
|
assert.equal(vm.onBoard, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("real recovered Tomie opening reaches the board without an expression error", { skip: !process.env.PGAMES_TOMIE_FIXTURE }, () => {
|
||||||
|
const data = JSON.parse(readFileSync(process.env.PGAMES_TOMIE_FIXTURE, "utf8"));
|
||||||
|
data.function_data = JSON.parse(readFileSync(new URL("function_data.json", `file:///${process.env.PGAMES_TOMIE_FIXTURE.replaceAll('\\', '/')}`), "utf8"));
|
||||||
|
let fault;
|
||||||
|
const vm = new NaniRuntime(data, (kind, value) => { if (kind === "error") fault = value.message; });
|
||||||
|
vm.start();
|
||||||
|
for (let step = 0; step < 10000 && !["board", "error", "complete"].includes(vm.waiting); step++) {
|
||||||
|
if (["text", "timer", "timed_choice"].includes(vm.waiting)) vm.advance();
|
||||||
|
else if (vm.waiting === "choice") {
|
||||||
|
const index = vm.choices.findIndex((choice) => vm.choiceSupported(choice));
|
||||||
|
assert.ok(index >= 0); vm.choose(index);
|
||||||
|
} else throw new Error("Unexpected wait: " + vm.waiting);
|
||||||
|
}
|
||||||
|
assert.equal(fault, undefined);
|
||||||
|
assert.equal(vm.waiting, "board");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("real Tomie runs seven original day/night chains for three seeds", { skip: !process.env.PGAMES_TOMIE_FIXTURE }, () => {
|
||||||
|
const data = JSON.parse(readFileSync(process.env.PGAMES_TOMIE_FIXTURE, "utf8"));
|
||||||
|
data.function_data = JSON.parse(readFileSync(new URL("function_data.json", `file:///${process.env.PGAMES_TOMIE_FIXTURE.replaceAll('\\', '/')}`), "utf8"));
|
||||||
|
data.start = data.board_start;
|
||||||
|
const isBoard = (vm) => vm.waiting === "board" || (vm.waiting === "choice" && vm.script === "AppScreen" && vm.last.line === 97);
|
||||||
|
function settle(vm) {
|
||||||
|
const seen = new Map();
|
||||||
|
for (let step = 0; step < 3000; step++) {
|
||||||
|
if (isBoard(vm) || vm.waiting === "complete") return;
|
||||||
|
assert.notEqual(vm.waiting, "error", vm.lastFault);
|
||||||
|
if (["text", "timer"].includes(vm.waiting)) vm.advance();
|
||||||
|
else if (["choice", "timed_choice"].includes(vm.waiting)) {
|
||||||
|
const key = vm.script + ":" + vm.last.line, count = seen.get(key) ?? 0;
|
||||||
|
seen.set(key, count + 1);
|
||||||
|
const enabled = vm.choices.map((choice, i) => ({ choice, i })).filter(({ choice }) => vm.choiceSupported(choice));
|
||||||
|
assert.ok(enabled.length > 0, "All choices disabled: " + key);
|
||||||
|
const exit = vm.script === "HomeActivity" || count > 1 ? enabled.find(({ choice }) =>
|
||||||
|
["do nothing", "return", "leave", "end", "stop", "i got it", "decline", "refuse"].some((word) => choice.ChoiceSummary.toLowerCase().includes(word))) : undefined;
|
||||||
|
vm.choose((exit ?? enabled[count % enabled.length]).i);
|
||||||
|
} else if (vm.waiting === "input") vm.submitInput(String(vm.last.args.VariableName ?? "cheatCode"), "");
|
||||||
|
else throw new Error("Unexpected wait " + vm.waiting + " in " + vm.script);
|
||||||
|
}
|
||||||
|
throw new Error("Route did not settle: " + vm.script + ":" + vm.last.line);
|
||||||
|
}
|
||||||
|
for (const seed of [1, 7, 42]) {
|
||||||
|
const vm = new NaniRuntime(data, (kind, value) => { if (kind === "error") vm.lastFault = value.message; });
|
||||||
|
vm.fastWaits = true; vm.start(seed); settle(vm);
|
||||||
|
for (let day = 0; day < 7; day++) {
|
||||||
|
vm.runExternal("DicePlay", "DayEnd"); settle(vm);
|
||||||
|
assert.ok(isBoard(vm));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user