"""Mod editor, revision history, three-way merge and rebase.""" import copy import tempfile from pathlib import Path from typing import Annotated, Any, Literal from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from sqlalchemy import select from starlette.concurrency import run_in_threadpool from app import mods from app.blobs import put_file from app.config import get_settings from app.converter import assets from app.deps import DB, has_perm, require from app.models import GameVersion, Revision, User from app.routers.versions import can_manage, get_version, lock_version from app.translation import file_json, put_json router = APIRouter(prefix="/api/versions", tags=["mods"]) Player = Annotated[User, Depends(require("games.play"))] Editor = Annotated[User, Depends(require("games.play", "versions.fork"))] def editable(v, user): if v.status != "ready" or not v.manifest: raise HTTPException(409, "Версия ещё не готова") if v.kind != "mod" or v.visibility != "draft" or not can_manage(user, v): raise HTTPException(403, "Редактировать можно свой черновик мода") def expected(v, revision): if v.revision_id != revision: raise HTTPException(409, "Версия изменилась. Загрузите её заново; локальные правки сохранены в редакторе") def update_manifest(v, manifest): v.manifest = manifest v.size_bytes = sum(f["size"] for f in manifest["files"].values()) @router.get("/{version_id}/editor") async def read_editor(version_id: int, user: Player, db: DB): v = await get_version(db, user, version_id) if not v.manifest: raise HTTPException(409, "Версия ещё не готова") base = await db.get(Revision, v.base_revision_id) if v.base_revision_id else None old = base.manifest["files"] if base else {} changes = [p for p in sorted(set(old) | set(v.manifest["files"])) if old.get(p) != v.manifest["files"].get(p)] if base else [] diff = await run_in_threadpool(lambda: mods.differences( mods.comparable(mods.prepare(mods.document(base.manifest))), mods.comparable(mods.prepare(mods.document(v.manifest))))) if base else [] return {"revision": v.revision_id, "baseRevision": v.base_revision_id, "changes": changes, "diff": diff, "document": await run_in_threadpool(lambda: mods.prepare(mods.document(v.manifest))), "files": v.manifest["files"], "editable": has_perm(user, "versions.fork") and v.kind == "mod" and v.visibility == "draft" and can_manage(user, v)} class EditIn(BaseModel): revision: str document: dict[str, Any] @router.put("/{version_id}/editor") async def save_editor(version_id: int, body: EditIn, user: Editor, db: DB): await get_version(db, user, version_id) v = await lock_version(db, version_id) editable(v, user) expected(v, body.revision) try: manifest = await run_in_threadpool(mods.store, body.document, v.manifest) except (ValueError, KeyError, TypeError, RecursionError) as e: raise HTTPException(422, f"Некорректный PGVN: {e}") from None update_manifest(v, manifest) await db.commit() return {"revision": v.revision_id, "files": v.manifest["files"]} @router.put("/{version_id}/asset") async def replace_asset(version_id: int, path: str, revision: str, filename: str, request: Request, user: Editor, db: DB): await get_version(db, user, version_id) v = await lock_version(db, version_id) editable(v, user) expected(v, revision) if path not in v.manifest["files"] or path == "game.json" or path.startswith(("script/", "locales/")): raise HTTPException(422, "Выберите существующий медиафайл") suffix = Path(filename).suffix.lower() if assets.kind_of(Path(path)) not in ("image", "audio", "video", "font") or assets.kind_of(Path(filename)) != assets.kind_of(Path(path)): raise HTTPException(422, "Тип загруженного файла не совпадает с заменяемым") get_settings().work_dir.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(dir=get_settings().work_dir) as folder: root = Path(folder) source = root / ("upload" + suffix) count = 0 with source.open("wb") as out: async for chunk in request.stream(): count += len(chunk) if count > 512 * 1024 * 1024: raise HTTPException(413, "Максимум 512 МБ") out.write(chunk) if not count: raise HTTPException(422, "Пустой файл") def convert(): # Do not accept unparsed webm passthrough for editor uploads. if suffix == ".webm": output = assets._ffmpeg(source, root / "verified.webm", ["-c:v", "libvpx-vp9", "-crf", "34", "-b:v", "0", "-deadline", "realtime", "-cpu-used", "8", "-c:a", "libopus"]) return put_file(output, ".webm", move=True) if assets.kind_of(source) == "font": signatures = {".ttf": b"\x00\x01\x00\x00", ".otf": b"OTTO", ".woff": b"wOFF", ".woff2": b"wOF2", ".ttc": b"ttcf"} with source.open("rb") as stream: if stream.read(4) != signatures.get(suffix): raise ValueError("Некорректный шрифт") output, ext = assets.convert(source, root) return put_file(output, ext, move=True) try: blob_id, size = await run_in_threadpool(convert) except Exception as e: raise HTTPException(422, f"Не удалось обработать файл: {e}") from None manifest = copy.deepcopy(v.manifest) manifest["files"][path] = {"hash": blob_id, "size": size} update_manifest(v, manifest) await db.commit() return {"revision": v.revision_id, "files": v.manifest["files"]} async def revision_graph(db, game_id): return {r.id: r for r in (await db.scalars(select(Revision).where(Revision.game_id == game_id))).all()} def ancestors(graph, rid): found, queue = {}, [(rid, 0)] while queue: key, depth = queue.pop(0) if key in found or key not in graph: continue found[key] = depth queue.extend((p, depth + 1) for p in graph[key].parents) return found @router.get("/{version_id}/revisions") async def history(version_id: int, user: Player, db: DB): v = await get_version(db, user, version_id) graph = await revision_graph(db, v.game_id) reachable = ancestors(graph, v.revision_id) return [{"id": key, "parents": graph[key].parents, "created_at": graph[key].created_at, "user_id": graph[key].user_id} for key in sorted(reachable, key=lambda k: graph[k].created_at, reverse=True)] class RestoreIn(BaseModel): revision: str restore: str @router.post("/{version_id}/revisions/restore") async def restore(version_id: int, body: RestoreIn, user: Editor, db: DB): await get_version(db, user, version_id) v = await lock_version(db, version_id) editable(v, user) expected(v, body.revision) graph = await revision_graph(db, v.game_id) if body.restore not in ancestors(graph, v.revision_id): raise HTTPException(404, "Ревизия не найдена") update_manifest(v, copy.deepcopy(graph[body.restore].manifest)) await db.commit() return {"revision": v.revision_id} class MergeIn(BaseModel): target_id: int source_revision: str target_revision: str mode: Literal["merge", "rebase"] = "merge" choices: dict[str, Literal["left", "right"]] = Field(default_factory=dict) mapping: dict[str, str] = Field(default_factory=dict) name: str = Field(default="Объединённый мод", min_length=1, max_length=255) apply: bool = False def merge_tree(manifest): doc = mods.comparable(mods.prepare(mods.document(manifest))) for info in doc["game"].get("locales", {}).values(): for key in ("progress", "lines", "strings_count"): info.pop(key, None) extras = {} for p, entry in manifest["files"].items(): if p == "game.json" or p in doc["scripts"]: continue extras[p] = {"json": copy.deepcopy(file_json(manifest, p))} if p.startswith("locales/") and p.endswith(".json") else entry return {"document": doc, "files": extras} @router.post("/{version_id}/merge") async def merge(version_id: int, body: MergeIn, user: Editor, db: DB): source = await get_version(db, user, version_id) target = await get_version(db, user, body.target_id) if source.game_id != target.game_id or source.id == target.id or source.kind != "mod": raise HTTPException(422, "Нужен мод и другая версия той же игры") if source.status != "ready" or target.status != "ready": raise HTTPException(409, "Версии ещё не готовы") if body.apply: # Stable lock ordering avoids deadlocks in simultaneous opposite merges. locked = {key: await lock_version(db, key) for key in sorted((source.id, target.id))} source, target = locked[source.id], locked[target.id] expected(source, body.source_revision) expected(target, body.target_revision) graph = await revision_graph(db, source.game_id) if body.mode == "rebase": base_id = source.base_revision_id else: a, b = ancestors(graph, source.revision_id), ancestors(graph, target.revision_id) common = set(a) & set(b) base_id = min(common, key=lambda k: (max(a[k], b[k]), a[k] + b[k], k)) if common else None if not base_id or base_id not in graph: raise HTTPException(409, "Нет достоверной базовой ревизии. Для старого мода создайте новый форк и перенесите правки вручную") def build(): base = merge_tree(graph[base_id].manifest) left, right = merge_tree(source.manifest), merge_tree(target.manifest) unmatched = [] aliases, target_tids = {}, {} if body.mode == "rebase": before = right["document"] right["document"], unmatched = mods.align(base["document"], before, body.mapping) def identities(raw, aligned): if isinstance(raw, dict) and isinstance(aligned, dict): if raw.get("id") and aligned.get("id"): aliases[aligned["id"]] = raw["id"] if raw.get("tid") and aligned.get("tid"): target_tids[aligned["tid"]] = raw["tid"] for key in raw.keys() & aligned.keys(): identities(raw[key], aligned[key]) elif isinstance(raw, list) and isinstance(aligned, list): for a, b in zip(raw, aligned): identities(a, b) identities(before, right["document"]) from app.converter.locales import walk tids = {} for path, module in before["scripts"].items(): for old_label, new_label in zip(module["labels"], right["document"]["scripts"][path]["labels"]): for old_node, new_node in zip(walk(old_label["body"]), walk(new_label["body"])): if old_node.get("tid") and new_node.get("tid"): tids[old_node["tid"]] = new_node["tid"] for entry in right["files"].values(): if set(entry) == {"json"} and isinstance(entry["json"], dict): entry["json"] = {tids.get(k, k): v for k, v in entry["json"].items()} conflicts = [] result = mods.merge_value(base, left, right, "", conflicts, body.choices) if body.mode == "rebase" and body.apply and not conflicts: # Compare in old-base identity, then adopt the new original's canonical # IDs. This makes the frozen target a valid base for the NEXT rebase. def canonical(value): if isinstance(value, dict): for key, v in value.items(): if key == "id" and isinstance(v, str): value[key] = aliases.get(v, v) elif key == "tid" and isinstance(v, str): value[key] = target_tids.get(v, v) else: canonical(v) elif isinstance(value, list): for v in value: canonical(v) canonical(result["document"]) for entry in result["files"].values(): if set(entry) == {"json"} and isinstance(entry["json"], dict): entry["json"] = {target_tids.get(k, k): v for k, v in entry["json"].items()} return result, conflicts, unmatched try: result, conflicts, unmatched = await run_in_threadpool(build) except (ValueError, TypeError, KeyError) as e: raise HTTPException(422, str(e)) from None if not body.apply: return {"baseRevision": base_id, "conflicts": conflicts, "unmatched": unmatched, "document": result["document"]} if conflicts: raise HTTPException(409, "Остались нерешённые конфликты") def write(): manifest = {"format": "pgvn/1", "files": {}} # Keep script entries until store() replaces them and validates references. for p, f in target.manifest["files"].items(): if p == "game.json" or p in result["document"]["scripts"]: manifest["files"][p] = f for p, entry in result["files"].items(): if set(entry) == {"json"}: put_json(manifest["files"], p, entry["json"]) else: manifest["files"][p] = entry return mods.store(result["document"], manifest) try: manifest = await run_in_threadpool(write) except (ValueError, KeyError, TypeError) as e: raise HTTPException(422, f"Результат не прошёл проверку: {e}") from None fork = GameVersion(game_id=source.game_id, parent_id=target.id, owner_id=user.id, name=body.name, kind="mod", visibility="draft", status="ready", manifest=manifest, base_revision_id=target.revision_id if body.mode == "rebase" else base_id, size_bytes=sum(f["size"] for f in manifest["files"].values())) fork._revision_parents = [source.revision_id, target.revision_id] db.add(fork) await db.commit() return {"id": fork.id, "revision": fork.revision_id}