Newer
Older
rpg_game / panel / main.py
"""Веб-панель сценария «Эдем-4»: дерево сюжета, персонажи, кривая знаний.

Запуск из корня репозитория:

    python -m venv .venv && source .venv/bin/activate
    pip install -r requirements.txt
    uvicorn panel.main:app --reload --timeout-graceful-shutdown 3 --port 4591

Страницы:
    /            — дерево сюжета (граф: акты-колонки, квесты-узлы)
    /characters  — ростер персонажей по категориям
    /knowledge   — таблица знаний игрока по актам
    /node/<id>   — карточка любой сущности из story/

Данные — YAML в story/ (один файл = одна сущность). Правка файла
обновляет все открытые вкладки сама (SSE + watchfiles).
"""
from __future__ import annotations

import asyncio
import re
import time
from pathlib import Path
from typing import Any

import markdown
import watchfiles
import yaml
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

BASE_DIR = Path(__file__).resolve().parent
STORY_DIR = BASE_DIR.parent / "story"
TEMPLATES_DIR = BASE_DIR / "templates"

# Ссылки на доки в YAML — относительные пути файловой системы
# (../../docs/...) — markdown превращает их в href="../docs/...",
# в браузере их надо вести на /docs/<файл>.
_MD_LINK = re.compile(r'href="(?:\.\./)+(docs/)')


def md(text: str) -> str:
    html = markdown.markdown(text or "")
    return _MD_LINK.sub(r'href="/\1', html)


app = FastAPI(title="Эдем-4 — панель сценария")
templates = Jinja2Templates(directory=TEMPLATES_DIR)
templates.env.filters["md"] = md
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
app.mount("/docs", StaticFiles(directory=BASE_DIR.parent / "docs"), name="docs")

LANE_W = 540      # ширина колонки-акта на графе
MAIN_X = 150      # x-центр основной цепочки внутри колонки
SIDE_X = 370      # x-центр сайд-колонки внутри колонки
CHARS = 26        # символов в строке метки узла (оценка для высоты)
LINE_H = 16       # высота строки метки
PAD = 32          # вертикальный запас блока
GAP = 50          # зазор между звеньями


# --- раскладка дерева -------------------------------------------------------

def _line_count(text: str, chars: int = CHARS) -> int:
    """Оценка числа строк: cytoscape переносит по text-max-width."""
    return max(1, -(-len(text) // chars))


def quest_label(q: dict) -> str:
    """Метка узла: номер, заголовок, механика-туториал, локация."""
    lines = [f'{q.get("order", "?")}. {q.get("title", q["id"])}']
    mech = (q.get("mechanics") or [])
    if mech:
        prefix = "сайд · " if q.get("side") else ""
        lines.append(prefix + str(mech[0]))
    elif q.get("side"):
        lines.append("сайд-квест")
    loc = q.get("location")
    if loc and loc != "—":
        lines.append(loc)
    return "\n".join(lines)


def quest_height(q: dict) -> int:
    """Высота блока: строки метки + запас — блоки не наезжают друг на друга."""
    return sum(_line_count(l) for l in quest_label(q).split("\n")) * LINE_H + PAD


def quest_width(q: dict) -> int:
    """Ширина блока: самая длинная строка метки, с ограничениями."""
    longest = max(len(l) for l in quest_label(q).split("\n"))
    return int(min(max(longest * 7.0 + 34, 220), 260))


# --- загрузка данных -------------------------------------------------------

def _read_yaml(path: Path) -> Any:
    with path.open(encoding="utf-8") as f:
        return yaml.safe_load(f)


def load_acts() -> list[dict]:
    return _read_yaml(STORY_DIR / "acts.yaml")


def load_collection(name: str, sort_key=None) -> list[dict]:
    items = [_read_yaml(p) for p in sorted((STORY_DIR / name).glob("*.yaml"))]
    if sort_key:
        items.sort(key=sort_key)
    return items


def load_quests() -> list[dict]:
    return load_collection("quests", lambda q: (q.get("act", 0), q.get("order", 0)))


def load_characters() -> list[dict]:
    return load_collection("characters", lambda c: (c.get("id", "")))


def load_knowledge() -> list[dict]:
    return _read_yaml(STORY_DIR / "knowledge.yaml")["acts"]


def load_world() -> dict:
    quests, characters = load_quests(), load_characters()
    return {
        "acts": load_acts(),
        "quests": quests,
        "characters": characters,
        "knowledge": load_knowledge(),
        "by_id": {e["id"]: e for e in [*quests, *characters]},
    }


def node_label(entity: dict) -> str:
    return (
        entity.get("name")
        or entity.get("title")
        or entity.get("role")
        or entity.get("id", "?")
    )


def link_html(entity_id: str, by_id: dict) -> str:
    entity = by_id.get(entity_id)
    if not entity:
        return f'<span class="broken">нет в story/: {entity_id}</span>'
    return f'<a href="/node/{entity_id}">{node_label(entity)}</a>'


def build_rows(entity: dict, world: dict) -> list[tuple[str, str]]:
    """Карточка сущности как список (подпись, html) для node.html."""
    by_id = world["by_id"]
    rows: list[tuple[str, str]] = []

    def links(ids: list[str] | None) -> str:
        if not ids:
            return "—"
        return ", ".join(link_html(i, by_id) for i in ids)

    if entity.get("type") in ("quest", "event"):
        rows.append(("Акт / №", f"Акт {entity.get('act')} / звено {entity.get('order')}"))
        rows.append(("Тип", "событие" if entity.get("type") == "event" else "квест"))
        rows.append(("Сводка", md(entity.get("summary") or "")))
        if entity.get("mechanics"):
            rows.append(("Механики (туториал)", ", ".join(entity["mechanics"])))
        if entity.get("side"):
            rows.append(("Сайд", "да — отдельная ветка"))
        giver = entity.get("giver")
        if giver:
            rows.append(("Квестодатель", link_html(giver, by_id)))
        elif entity.get("giver_candidate"):
            rows.append(("Квестодатель", f'{entity["giver_candidate"]} <em>(кандидат, отложено)</em>'))
        else:
            rows.append(("Квестодатель", "—"))
        rows.append(("Дальше", links(entity.get("leads_to"))))
        rows.append(("Открывает", links(entity.get("unlocks"))))
        if entity.get("location"):
            rows.append(("Локация", entity["location"]))
        if entity.get("tags"):
            rows.append(("Теги", " · ".join(f"#{t}" for t in entity["tags"])))
    else:  # character
        rows.append(("Имя", entity.get("name") or "<em>имя не придумано (08)</em>"))
        rows.append(("Роль", entity.get("role") or "—"))
        rows.append(("Категория", entity.get("category") or "—"))
        rows.append(("Статус", entity.get("status") or "—"))
        rows.append(("Появляется", f"Акт {entity.get('act')}"))
        rows.append(("Хочет", md(entity.get("wants") or "")))
        rows.append(("Скрывает", md(entity.get("hides") or "")))
        rows.append(("Крючок в кривой", md(entity.get("hook") or "")))
        if entity.get("sidequest"):
            rows.append(("Сайдквест", entity["sidequest"]))
        if entity.get("relations"):
            rows.append(("Связи", "<br>".join(
                f'{link_html(r["to"], by_id)} — {r["kind"]}' for r in entity["relations"]
            )))

    rows.append(("Заметки", md(entity.get("notes") or "")))
    return [(label, html) for label, html in rows if html and html != "—"]


# --- страницы ---------------------------------------------------------------

@app.get("/")
def index():
    return RedirectResponse("/graph")


@app.get("/graph")
def graph(request: Request):
    return templates.TemplateResponse(request, "graph.html", {
        "acts": load_acts(),
    })


@app.get("/characters")
def characters(request: Request):
    category_order = [
        ("protagonist", "Протагонист"),
        ("cast", "Живые с диалогами (каста ≤ 12)"),
        ("ai", "ИИ"),
        ("offscreen", "За кадром / ушедшие"),
    ]
    chars = load_characters()
    groups = [
        (label, [c for c in chars if c.get("category") == key])
        for key, label in category_order
    ]
    rest = [c for c in chars if c.get("category") not in
            {key for key, _ in category_order}]
    if rest:
        groups.append(("Прочее", rest))
    return templates.TemplateResponse(request, "characters.html", {"groups": groups})


@app.get("/knowledge")
def knowledge(request: Request):
    return templates.TemplateResponse(request, "knowledge.html", {
        "acts": load_knowledge(),
    })


@app.get("/node/{node_id}")
def node(request: Request, node_id: str):
    world = load_world()
    entity = world["by_id"].get(node_id)
    if not entity:
        raise HTTPException(status_code=404, detail=f"Нет сущности с id {node_id}")
    return templates.TemplateResponse(request, "node.html", {
        "entity": entity,
        "title": node_label(entity),
        "rows": build_rows(entity, world),
    })


# --- API графа и live reload --------------------------------------------------

@app.get("/api/graph")
def api_graph():
    acts, quests = load_acts(), load_quests()
    elements: list[dict] = []

    for a in acts:
        elements.append({"data": {
            "id": f"lane{a['act']}",
            "label": f'АКТ {a["act"]}: {a["title"]}',
            "kind": "lane",
        }})

    # раскладка: в каждой колонке акта — основная цепочка (стопка блоков
    # с расчётной высотой) и сайд-колонка (сайды прицеплены под своим
    # родителем)
    positions: dict[str, dict] = {}
    by_act: dict[int, list[dict]] = {}
    for q in quests:
        by_act.setdefault(q.get("act", 0), []).append(q)

    for a in acts:
        lane_x = a["act"] * LANE_W
        y = 90.0
        for q in by_act.get(a["act"], []):
            h = quest_height(q)
            if q.get("side"):
                parent = positions.get(q.get("after"))
                y_q = (parent["y"] + parent["h"] + GAP) if parent else y
                x_q = lane_x + SIDE_X
            else:
                x_q, y_q = lane_x + MAIN_X, y
                y += h + GAP
            positions[q["id"]] = {"x": x_q, "y": y_q, "h": h}
        if not by_act.get(a["act"]):
            elements.append({"data": {
                "id": f"ghost{a['act']}",
                "label": "цепочка ещё не придумана",
                "kind": "ghost",
                "parent": f"lane{a['act']}",
                "w": 220, "h": 48,
            }, "position": {"x": lane_x + MAIN_X, "y": 90}})

    for q in quests:
        p = positions[q["id"]]
        elements.append({"data": {
            "id": q["id"],
            "label": quest_label(q),
            "kind": q.get("type", "quest"),
            "side": bool(q.get("side")),
            "parent": f'lane{q.get("act", 0)}',
            "w": quest_width(q),
            "h": p["h"],
        }, "position": {"x": p["x"], "y": p["y"]}})
        for target in q.get("leads_to") or []:
            elements.append({"data": {
                "id": f'{q["id"]}->{target}',
                "source": q["id"], "target": target, "kind": "main",
            }})
        if q.get("side") and q.get("after"):
            elements.append({"data": {
                "id": f'{q["after"]}~>{q["id"]}',
                "source": q["after"], "target": q["id"], "kind": "sideedge",
            }})
    return elements


@app.get("/sse")
async def sse(request: Request):
    """Live reload: сообщаем вкладке об изменениях в story/ и шаблонах."""
    async def gen():
        yield "retry: 3000\n\n"
        last = 0.0
        async for _ in watchfiles.awatch(STORY_DIR, TEMPLATES_DIR, BASE_DIR / "static"):
            if await request.is_disconnected():
                return
            now = time.monotonic()
            if now - last < 1.0:  # серия правок — одно уведомление
                continue
            last = now
            yield "event: change\ndata: reload\n\n"

    return StreamingResponse(gen(), media_type="text/event-stream", headers={
        "Cache-Control": "no-cache",
        "X-Accel-Buffering": "no",
    })