- Add app/games: GameProfile base, registry, ReaderHooks and renpy/naninovel/ tomie profiles. pipeline, preflight, detect and mods dispatch via the registry - Move tomie_* converter modules to app/games/tomie and split out reader hooks, import, mod validation and known defects - Tolerate the 31 authoring defects of the pinned Tomie build as report warnings (exact list, per-script counts); any new defect still blocks import - Frontend: runtime registry (runtimes.ts) with lazy Tomie launcher - Add profile/registry tests and docs/GAME_PROFILES.md; update IMPORT.md Full media export of the original Tomie build is not yet verified. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
122 lines
7.0 KiB
Python
122 lines
7.0 KiB
Python
"""Editable PGVN envelope for the version-pinned native Naninovel interpreter."""
|
|
import copy
|
|
import hashlib
|
|
from dataclasses import asdict
|
|
|
|
from app.converter.compatibility import CompatibilityError, Issue
|
|
from app.games.tomie.defects import split_known
|
|
from app.games.tomie.profile import runtime_data
|
|
from app.games.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, known = split_known(validate_runtime(data))
|
|
if issues:
|
|
raise CompatibilityError("naninovel", issues)
|
|
data["known_defects"] = [asdict(issue) for issue in known]
|
|
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
|