"""Веб-панель сценария «Эдем-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",
})