数值编辑系统PoC

This commit is contained in:
wjsjwr 2026-01-17 20:59:45 +08:00
parent ac1cef31ac
commit d29c7e4881
13 changed files with 1851 additions and 23 deletions

View File

@ -1,7 +1,7 @@
# Numerical Design & Balancing System (NDBS) - Implementation Guide # Numerical Design & Balancing System (NDBS) - Implementation Guide
**Version:** 2.0 (AI-Actionable) **Version:** 2.0
**Target Stack:** Python (FastAPI), Vue.js (Vite), C# (Godot .NET) **Target Stack:** Python 3.14 (FastAPI, uv), Vue.js (Vite), C# (Godot .NET)
**Role:** AI Developer Guide **Role:** AI Developer Guide
## 1. System Overview ## 1. System Overview
@ -13,32 +13,40 @@ This guide is structured as a sequence of tasks for an AI agent to implement the
--- ---
## 2. Directory Structure Plan ## 2. Architecture
The tool will reside in a new `tools/ndbs/` directory, keeping it separate from the game assets but with access to them.
```text The system is designed as a **Unified Web Service**. A single Python process handles both the API logic and serving the frontend interface.
D:\code\super-mentor\
├── resources\definitions\ <-- Target Data Source 1. **NDBS Service (Python/FastAPI):**
├── scripts\Rules\Generated\ <-- Target Script Output * **API Layer:** Handles data reading/writing and script generation (`/api/*`).
├── tools\ * **Static Layer:** Serves the compiled Vue.js application (`index.html`, `js/`, `css/`) from the `client/dist` directory.
│ └── ndbs\ 2. **NDBS Web Client (Vue.js):**
│ ├── server\ <-- Python Backend * Developed as a standard SPA.
│ │ ├── main.py * Built into static files (`npm run build`) which are consumed by the Python service.
│ │ └── ...
│ └── client\ <-- Vue Frontend **Runtime Flow:**
│ ├── src\ User runs `python main.py` -> Browser opens `http://localhost:8000` -> Frontend loads -> Frontend calls `/api/...` -> Python modifies files.
│ └── ...
```mermaid
graph TD
User[Designer] -->|Browser| Service[NDBS Python Service]
Service -->|Serve Static| Frontend[Vue.js UI]
Service -->|API| Logic[Business Logic]
Logic -->|Read/Write| JSON[JSON/TRES Definitions]
Logic -->|Generate| CSharp[C# Rule Scripts]
``` ```
--- ---
## 3. Implementation Phases (AI Prompts) ## 3. Implementation Phases
### Phase 1: Python Backend Foundation ### Phase 1: Python Backend Foundation
**Goal:** Establish a FastAPI server that can read/write the game's JSON files. **Goal:** Establish a FastAPI server that can read/write the game's JSON files.
**Step 1.1: Environment Setup** **Step 1.1: Environment Setup**
* **Instruction:** Create `tools/ndbs/server/`. Initialize a Python environment. Install `fastapi`, `uvicorn`, `pydantic`. * **Instruction:** Create `tools/ndbs/server/`. Initialize a Python environment using `uv`.
* Run `uv init --python 3.14` to set up the project.
* Run `uv add fastapi uvicorn pydantic` to install dependencies.
* **Code Requirement:** Create `tools/ndbs/server/main.py`. * **Code Requirement:** Create `tools/ndbs/server/main.py`.
* **Key Functionality:** * **Key Functionality:**
* Enable CORS (allow all origins for dev). * Enable CORS (allow all origins for dev).
@ -58,6 +66,11 @@ D:\code\super-mentor\
**Step 1.3: Schema Inference (Dynamic Models)** **Step 1.3: Schema Inference (Dynamic Models)**
* **Instruction:** Since we don't have hardcoded Pydantic models for every file, create a utility that reads a file (JSON or TRES) and generates a generic "Schema" description (listing keys and value types) to help the frontend build forms dynamically. * **Instruction:** Since we don't have hardcoded Pydantic models for every file, create a utility that reads a file (JSON or TRES) and generates a generic "Schema" description (listing keys and value types) to help the frontend build forms dynamically.
**Step 1.4: Static Asset Serving (SPA Support)**
* **Instruction:** Configure FastAPI to serve the frontend.
* Mount the `client/dist` directory to `/` as static files.
* **Critical:** Implement a "Catch-All" route (after API routes) that returns `client/dist/index.html`. This ensures that Vue Router paths (e.g., `http://localhost:8000/editor/archetypes`) work when refreshed.
--- ---
@ -65,7 +78,7 @@ D:\code\super-mentor\
**Goal:** A clean UI to browse files and edit JSON data. **Goal:** A clean UI to browse files and edit JSON data.
**Step 2.1: Project Scaffolding** **Step 2.1: Project Scaffolding**
* **Instruction:** Create `tools/ndbs/client` using Vite + Vue 3 (TypeScript). Install `axios`, `pinia` (state management), and `naive-ui` (or `element-plus`) for UI components. * **Instruction:** Create `tools/ndbs/client` using Vite + Vue 3 (TypeScript). Install `axios`, `pinia`, and `naive-ui`.
**Step 2.2: File Explorer & Layout** **Step 2.2: File Explorer & Layout**
* **Instruction:** Create a standard "Sidebar + Main Content" layout. * **Instruction:** Create a standard "Sidebar + Main Content" layout.
@ -75,9 +88,13 @@ D:\code\super-mentor\
**Step 2.3: Generic JSON Editor** **Step 2.3: Generic JSON Editor**
* **Instruction:** Create a component `JsonEditor.vue`. * **Instruction:** Create a component `JsonEditor.vue`.
* Fetch content using `GET /api/files/content`. * Fetch content using `GET /api/files/content`.
* Display the raw JSON in a textarea (Monaco Editor preferred later, start simple). * Display the raw JSON in a textarea (Monaco Editor preferred later).
* Add a "Save" button that calls `POST /api/files/content`. * Add a "Save" button that calls `POST /api/files/content`.
* **Enhancement:** Use a library like `jsoneditor` or `v-jsoneditor` to provide a tree view/form view.
**Step 2.4: Build Integration**
* **Instruction:** Configure `vite.config.ts` to output to `../server/client_dist` (or just `dist` inside client, and server reads from there).
* Ensure the "Build" command produces the artifacts expected by Step 1.4.
--- ---
@ -143,11 +160,10 @@ D:\code\super-mentor\
### 4.1 File Paths ### 4.1 File Paths
* **Server Root:** `tools/ndbs/server` * **Server Root:** `tools/ndbs/server`
* **Client Root:** `tools/ndbs/client`
* **Game Root:** `../../` (Relative to server) * **Game Root:** `../../` (Relative to server)
### 4.2 Coding Style ### 4.2 Coding Style
* **Python:** Type hints (Python 3.10+), Pydantic models for request/response bodies. * **Python:** Type hints (Python 3.14), Pydantic models for request/response bodies. Use `uv` for dependency management.
* **Vue:** Composition API (`<script setup lang="ts">`), Scoped CSS. * **Vue:** Composition API (`<script setup lang="ts">`), Scoped CSS.
* **C#:** Namespace `Rules.Generated` for all generated scripts. * **C#:** Namespace `Rules.Generated` for all generated scripts.

214
tools/ndbs/server/main.py Normal file
View File

@ -0,0 +1,214 @@
from __future__ import annotations
import os
import subprocess
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from ndbs_core import (
ALLOWED_DEFINITION_EXTS,
ALLOWED_RULE_EXTS,
NdbsPaths,
create_rule as core_create_rule,
ensure_within_root,
infer_schema,
list_definition_files,
list_rule_files,
load_definition_file,
resolve_paths,
write_definition_file,
)
class FileContentRequest(BaseModel):
filename: str
content: Any
class RuleCreateRequest(BaseModel):
rule_id: str
class RuleContentRequest(BaseModel):
filename: str
content: str
api_router = APIRouter()
ui_router = APIRouter()
def get_paths(request: Request) -> NdbsPaths:
return request.app.state.paths
def create_app(paths: Optional[NdbsPaths] = None) -> FastAPI:
app = FastAPI(title="NDBS Server")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
resolved_paths = paths or resolve_paths()
app.state.paths = resolved_paths
if resolved_paths.web_root.exists():
app.mount(
"/static",
StaticFiles(directory=str(resolved_paths.web_root)),
name="static",
)
app.include_router(ui_router)
app.include_router(api_router, prefix="/api")
return app
@ui_router.get("/")
def serve_index(paths: NdbsPaths = Depends(get_paths)) -> FileResponse:
index_path = paths.web_root / "index.html"
if not index_path.exists():
raise HTTPException(status_code=500, detail="UI assets are missing.")
return FileResponse(index_path)
@api_router.get("/files/definitions")
def list_definitions(paths: NdbsPaths = Depends(get_paths)) -> Dict[str, List[str]]:
try:
files = list_definition_files(paths)
except FileNotFoundError:
raise HTTPException(status_code=500, detail="Definitions root not found.")
return {"files": files}
@api_router.get("/files/content")
def get_file_content(
filename: str = Query(...),
paths: NdbsPaths = Depends(get_paths),
) -> Dict[str, Any]:
try:
path = ensure_within_root(paths.definitions_root, filename, ALLOWED_DEFINITION_EXTS)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not path.exists():
raise HTTPException(status_code=404, detail="File not found.")
try:
content = load_definition_file(path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"filename": filename, "content": content}
@api_router.post("/files/content")
def save_file_content(
payload: FileContentRequest,
paths: NdbsPaths = Depends(get_paths),
) -> Dict[str, str]:
try:
path = ensure_within_root(
paths.definitions_root, payload.filename, ALLOWED_DEFINITION_EXTS
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not path.exists():
raise HTTPException(status_code=404, detail="File not found.")
try:
write_definition_file(path, payload.content, paths)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"status": "ok"}
@api_router.get("/files/schema")
def get_file_schema(
filename: str = Query(...),
paths: NdbsPaths = Depends(get_paths),
) -> Dict[str, Any]:
try:
path = ensure_within_root(paths.definitions_root, filename, ALLOWED_DEFINITION_EXTS)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not path.exists():
raise HTTPException(status_code=404, detail="File not found.")
content = load_definition_file(path)
return {"filename": filename, "schema": infer_schema(content)}
@api_router.post("/rules/create")
def create_rule(
payload: RuleCreateRequest,
paths: NdbsPaths = Depends(get_paths),
) -> Dict[str, str]:
try:
filename = core_create_rule(payload.rule_id, paths)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except FileExistsError:
raise HTTPException(status_code=409, detail="Rule already exists.")
return {"status": "ok", "filename": filename}
@api_router.get("/rules/list")
def list_rules(paths: NdbsPaths = Depends(get_paths)) -> Dict[str, List[str]]:
return {"files": list_rule_files(paths)}
@api_router.get("/rules/content")
def get_rule_content(
filename: str = Query(...),
paths: NdbsPaths = Depends(get_paths),
) -> Dict[str, str]:
try:
path = ensure_within_root(paths.rules_root, filename, ALLOWED_RULE_EXTS)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not path.exists():
raise HTTPException(status_code=404, detail="Rule not found.")
return {"filename": filename, "content": path.read_text(encoding="utf-8")}
@api_router.post("/rules/content")
def save_rule_content(
payload: RuleContentRequest,
paths: NdbsPaths = Depends(get_paths),
) -> Dict[str, str]:
try:
path = ensure_within_root(paths.rules_root, payload.filename, ALLOWED_RULE_EXTS)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not path.exists():
raise HTTPException(status_code=404, detail="Rule not found.")
path.write_text(payload.content, encoding="utf-8")
return {"status": "ok"}
@api_router.post("/build")
def build_game(paths: NdbsPaths = Depends(get_paths)) -> Dict[str, Any]:
if os.getenv("NDBS_DISABLE_BUILD") == "1":
raise HTTPException(status_code=503, detail="Build disabled.")
result = subprocess.run(
["dotnet", "build"],
cwd=str(paths.project_root),
capture_output=True,
text=True,
check=False,
)
return {
"success": result.returncode == 0,
"stdout": result.stdout,
"stderr": result.stderr,
}
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)

View File

@ -0,0 +1,353 @@
from __future__ import annotations
import json
import os
import re
import shutil
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
ALLOWED_DEFINITION_EXTS = {".json", ".tres"}
ALLOWED_RULE_EXTS = {".cs"}
SECTION_RE = re.compile(r"^\s*\[(?P<name>[^]]+)\]\s*$")
KEY_VALUE_RE = re.compile(r"^(?P<indent>\s*)(?P<key>[A-Za-z0-9_]+)\s*=\s*(?P<value>.+)$")
@dataclass(frozen=True)
class NdbsPaths:
project_root: Path
definitions_root: Path
rules_root: Path
backup_root: Path
web_root: Path
def _find_project_root(start: Path) -> Path:
for candidate in [start] + list(start.parents):
if (candidate / "project.godot").exists():
return candidate
if len(start.parents) > 3:
return start.parents[3]
return start.parent
def _env_path(name: str) -> Optional[Path]:
value = os.getenv(name)
if not value:
return None
return Path(value).expanduser().resolve()
def resolve_paths() -> NdbsPaths:
server_root = Path(__file__).resolve().parent
project_root = _env_path("NDBS_PROJECT_ROOT") or _find_project_root(server_root)
definitions_root = _env_path("NDBS_DEFINITIONS_ROOT") or (
project_root / "resources" / "definitions"
)
rules_root = _env_path("NDBS_RULES_ROOT") or (
project_root / "scripts" / "Rules" / "Generated"
)
backup_root = _env_path("NDBS_BACKUP_ROOT") or (
project_root / "tools" / "ndbs" / "backups"
)
web_root = _env_path("NDBS_WEB_ROOT") or (server_root / "web")
return NdbsPaths(
project_root=project_root,
definitions_root=definitions_root,
rules_root=rules_root,
backup_root=backup_root,
web_root=web_root,
)
def ensure_within_root(root: Path, filename: str, allowed_exts: set[str]) -> Path:
if not filename or Path(filename).is_absolute():
raise ValueError("Invalid filename.")
if Path(filename).suffix.lower() not in allowed_exts:
raise ValueError("Unsupported file type.")
target = (root / filename).resolve()
root_resolved = root.resolve()
if os.path.commonpath([str(root_resolved), str(target)]) != str(root_resolved):
raise ValueError("Path traversal is not allowed.")
return target
def backup_file(path: Path, paths: NdbsPaths) -> None:
if not path.exists():
return
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
relative = path.relative_to(paths.project_root)
backup_path = paths.backup_root / timestamp / relative
backup_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, backup_path)
def _unescape_tres_string(value: str) -> str:
if len(value) >= 2 and value[0] == "\"" and value[-1] == "\"":
value = value[1:-1]
return value.replace("\\\"", "\"").replace("\\\\", "\\")
def _escape_tres_string(value: str) -> str:
return value.replace("\\", "\\\\").replace("\"", "\\\"")
def _looks_like_expression(value: str) -> bool:
if value.startswith(("ExtResource(", "SubResource(", "Resource(")):
return True
return re.match(r"^[A-Za-z_][A-Za-z0-9_]*\(.+\)$", value) is not None
def parse_tres_value(raw: str) -> Any:
raw = raw.strip()
if raw.startswith("[") and raw.endswith("]"):
return parse_tres_array(raw)
if raw.startswith("\"") and raw.endswith("\""):
return _unescape_tres_string(raw)
if raw in ("true", "false"):
return raw == "true"
if raw == "null":
return None
try:
if "." in raw or "e" in raw or "E" in raw:
return float(raw)
return int(raw)
except ValueError:
return raw
def parse_tres_array(raw: str) -> List[Any]:
inner = raw[1:-1].strip()
if not inner:
return []
items: List[str] = []
current = ""
in_quotes = False
escape = False
for ch in inner:
if escape:
current += ch
escape = False
continue
if ch == "\\":
current += ch
escape = True
continue
if ch == "\"":
in_quotes = not in_quotes
current += ch
continue
if ch == "," and not in_quotes:
items.append(current.strip())
current = ""
continue
current += ch
if current.strip():
items.append(current.strip())
return [parse_tres_value(item) for item in items]
def serialize_tres_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return str(value)
if value is None:
return "null"
if isinstance(value, list):
inner = ", ".join(serialize_tres_value(item) for item in value)
return f"[ {inner} ]"
if isinstance(value, str):
if _looks_like_expression(value):
return value
return f"\"{_escape_tres_string(value)}\""
return f"\"{_escape_tres_string(str(value))}\""
def load_tres_resource(text: str) -> Dict[str, Any]:
lines = text.splitlines()
data: Dict[str, Any] = {}
in_resource = False
for line in lines:
section_match = SECTION_RE.match(line)
if section_match:
in_resource = section_match.group("name") == "resource"
continue
if not in_resource:
continue
kv_match = KEY_VALUE_RE.match(line)
if not kv_match:
continue
key = kv_match.group("key")
raw = kv_match.group("value")
data[key] = parse_tres_value(raw)
return data
def update_tres_resource(text: str, updates: Dict[str, Any]) -> str:
lines = text.splitlines()
resource_start = None
resource_end = None
for idx, line in enumerate(lines):
section_match = SECTION_RE.match(line)
if not section_match:
continue
section_name = section_match.group("name")
if section_name == "resource":
resource_start = idx
continue
if resource_start is not None and resource_end is None:
resource_end = idx
break
if resource_start is None:
raise ValueError("Missing [resource] section.")
if resource_end is None:
resource_end = len(lines)
updated_keys: set[str] = set()
for idx in range(resource_start + 1, resource_end):
kv_match = KEY_VALUE_RE.match(lines[idx])
if not kv_match:
continue
key = kv_match.group("key")
if key not in updates:
continue
indent = kv_match.group("indent")
value = serialize_tres_value(updates[key])
lines[idx] = f"{indent}{key} = {value}"
updated_keys.add(key)
new_lines: List[str] = []
for key, value in updates.items():
if key in updated_keys:
continue
new_lines.append(f"{key} = {serialize_tres_value(value)}")
if new_lines:
lines[resource_end:resource_end] = new_lines
return "\n".join(lines) + "\n"
def infer_schema(value: Any) -> Dict[str, Any]:
if isinstance(value, dict):
return {
"type": "object",
"fields": {key: infer_schema(val) for key, val in value.items()},
}
if isinstance(value, list):
if not value:
return {"type": "array", "items": {"type": "unknown"}}
item_schemas = [infer_schema(item) for item in value]
if all(item == item_schemas[0] for item in item_schemas):
return {"type": "array", "items": item_schemas[0]}
return {"type": "array", "items": item_schemas}
if isinstance(value, bool):
return {"type": "boolean"}
if isinstance(value, int):
return {"type": "integer"}
if isinstance(value, float):
return {"type": "number"}
if isinstance(value, str):
return {"type": "string"}
if value is None:
return {"type": "null"}
return {"type": "unknown"}
def load_definition_file(path: Path) -> Any:
if path.suffix.lower() == ".json":
return json.loads(path.read_text(encoding="utf-8"))
if path.suffix.lower() == ".tres":
return load_tres_resource(path.read_text(encoding="utf-8"))
raise ValueError("Unsupported definition file.")
def write_definition_file(path: Path, content: Any, paths: NdbsPaths) -> None:
if path.suffix.lower() == ".json":
backup_file(path, paths)
path.write_text(
json.dumps(content, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return
if path.suffix.lower() == ".tres":
if not isinstance(content, dict):
raise ValueError("TRES content must be an object.")
backup_file(path, paths)
updated = update_tres_resource(path.read_text(encoding="utf-8"), content)
path.write_text(updated, encoding="utf-8")
return
raise ValueError("Unsupported definition file.")
def list_definition_files(paths: NdbsPaths) -> List[str]:
if not paths.definitions_root.exists():
raise FileNotFoundError("Definitions root not found.")
files = [
path.relative_to(paths.definitions_root).as_posix()
for path in paths.definitions_root.rglob("*")
if path.is_file() and path.suffix.lower() in ALLOWED_DEFINITION_EXTS
]
return sorted(files)
def list_rule_files(paths: NdbsPaths) -> List[str]:
if not paths.rules_root.exists():
return []
files = [
path.relative_to(paths.rules_root).as_posix()
for path in paths.rules_root.rglob("*.cs")
if path.is_file()
]
return sorted(files)
def to_camel_case(value: str) -> str:
parts = re.split(r"[^A-Za-z0-9]+", value)
camel = "".join(part[:1].upper() + part[1:] for part in parts if part)
if not camel:
return ""
if camel[0].isdigit():
return f"R{camel}"
return camel
def render_rule_template(rule_id: str, class_name: str) -> str:
return (
"namespace Rules.Generated;\n"
"using Core.Interfaces;\n"
"using Models;\n"
"\n"
f"public sealed class {class_name} : IGameRule\n"
"{\n"
f"\tpublic string Id => \"rule:{rule_id}\";\n"
"\n"
"\tpublic void OnEvent(GameSession session, object gameEvent)\n"
"\t{\n"
"\t}\n"
"\n"
"\tpublic void ModifyStats(UnitModel unit, StatRequest request)\n"
"\t{\n"
"\t}\n"
"}\n"
)
def create_rule(rule_id: str, paths: NdbsPaths) -> str:
rule_id = rule_id.strip()
if not re.fullmatch(r"[A-Za-z0-9_]+", rule_id):
raise ValueError("Rule id must be alphanumeric/underscore.")
class_name = f"Rule_{to_camel_case(rule_id)}"
if not class_name or class_name == "Rule_":
raise ValueError("Rule id is invalid.")
paths.rules_root.mkdir(parents=True, exist_ok=True)
path = paths.rules_root / f"{class_name}.cs"
if path.exists():
raise FileExistsError("Rule already exists.")
path.write_text(render_rule_template(rule_id, class_name), encoding="utf-8")
return path.name

View File

@ -0,0 +1,16 @@
[project]
name = "ndbs-server"
version = "0.1.0"
description = "NDBS FastAPI backend"
requires-python = ">=3.14"
dependencies = [
"fastapi>=0.115.0",
"uvicorn>=0.30.0",
"pydantic>=2.8.0",
]
[dependency-groups]
dev = [
"httpx>=0.27.0",
"pytest>=8.2.2",
]

View File

@ -0,0 +1,167 @@
import json
import sys
from pathlib import Path
import pytest
SERVER_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SERVER_ROOT))
from ndbs_core import ( # noqa: E402
NdbsPaths,
create_rule,
ensure_within_root,
infer_schema,
list_definition_files,
list_rule_files,
load_definition_file,
parse_tres_array,
parse_tres_value,
resolve_paths,
update_tres_resource,
write_definition_file,
)
def make_paths(tmp_path: Path) -> NdbsPaths:
project_root = tmp_path / "project"
project_root.mkdir()
(project_root / "project.godot").write_text("[gd_project]\n")
definitions_root = project_root / "resources" / "definitions"
definitions_root.mkdir(parents=True)
rules_root = project_root / "scripts" / "Rules" / "Generated"
rules_root.mkdir(parents=True)
backup_root = project_root / "tools" / "ndbs" / "backups"
backup_root.mkdir(parents=True)
web_root = project_root / "tools" / "ndbs" / "server" / "web"
web_root.mkdir(parents=True)
(web_root / "index.html").write_text("<html>ndbs</html>")
return NdbsPaths(
project_root=project_root,
definitions_root=definitions_root,
rules_root=rules_root,
backup_root=backup_root,
web_root=web_root,
)
def seed_definitions(paths: NdbsPaths) -> None:
traits_path = paths.definitions_root / "traits.json"
traits_path.write_text(
json.dumps({"id": "core:trait_test", "value": 1}), encoding="utf-8"
)
tres_path = paths.definitions_root / "discipline_biology.tres"
tres_path.write_text(
"[gd_resource type=\"Resource\" format=3]\n"
"\n"
"[resource]\n"
"Id = \"core:discipline_test\"\n"
"NameFallback = \"Biology\"\n"
"Tags = [ \"discipline\", \"lab\" ]\n",
encoding="utf-8",
)
def test_parse_tres_values() -> None:
assert parse_tres_value("\"hello\"") == "hello"
assert parse_tres_value("true") is True
assert parse_tres_value("42") == 42
assert parse_tres_array('[ "a", "b" ]') == ["a", "b"]
def test_list_definitions_and_schema(tmp_path: Path) -> None:
paths = make_paths(tmp_path)
seed_definitions(paths)
files = list_definition_files(paths)
assert "traits.json" in files
assert "discipline_biology.tres" in files
content = load_definition_file(paths.definitions_root / "traits.json")
assert content["id"] == "core:trait_test"
schema = infer_schema(content)
assert schema["type"] == "object"
assert "id" in schema["fields"]
def test_write_json_creates_backup(tmp_path: Path) -> None:
paths = make_paths(tmp_path)
seed_definitions(paths)
write_definition_file(
paths.definitions_root / "traits.json",
{"id": "core:trait_test", "value": 2},
paths,
)
saved = json.loads(
(paths.definitions_root / "traits.json").read_text(encoding="utf-8")
)
assert saved["value"] == 2
backups = list(paths.backup_root.rglob("traits.json"))
assert backups
def test_tres_roundtrip(tmp_path: Path) -> None:
paths = make_paths(tmp_path)
seed_definitions(paths)
original = load_definition_file(paths.definitions_root / "discipline_biology.tres")
assert original["Tags"] == ["discipline", "lab"]
write_definition_file(
paths.definitions_root / "discipline_biology.tres",
{"Tags": ["alpha", "beta"], "NameFallback": "Bio 2"},
paths,
)
updated = load_definition_file(paths.definitions_root / "discipline_biology.tres")
assert updated["Tags"] == ["alpha", "beta"]
assert updated["NameFallback"] == "Bio 2"
def test_update_tres_resource_adds_keys() -> None:
original = "[resource]\nId = \"core:test\"\n"
updated = update_tres_resource(original, {"NewKey": "Value"})
assert "NewKey" in updated
def test_create_rule(tmp_path: Path) -> None:
paths = make_paths(tmp_path)
filename = create_rule("test_rule", paths)
assert filename.endswith(".cs")
rule_path = paths.rules_root / filename
assert rule_path.exists()
assert "rule:test_rule" in rule_path.read_text(encoding="utf-8")
rules = list_rule_files(paths)
assert filename in rules
def test_ensure_within_root_rejects_traversal(tmp_path: Path) -> None:
paths = make_paths(tmp_path)
with pytest.raises(ValueError):
ensure_within_root(paths.definitions_root, "../evil.json", {".json"})
def test_resolve_paths_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
project_root = tmp_path / "game"
project_root.mkdir()
(project_root / "project.godot").write_text("[gd_project]\n")
server_root = tmp_path / "server"
server_root.mkdir()
monkeypatch.chdir(server_root)
monkeypatch.setenv("NDBS_PROJECT_ROOT", str(project_root))
resolved = resolve_paths()
assert resolved.project_root == project_root
assert resolved.definitions_root.name == "definitions"

305
tools/ndbs/server/uv.lock Normal file
View File

@ -0,0 +1,305 @@
version = 1
revision = 2
requires-python = ">=3.14"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.128.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "ndbs-server"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "pydantic", specifier = ">=2.8.0" },
{ name = "uvicorn", specifier = ">=0.30.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "pytest", specifier = ">=8.2.2" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "starlette"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
]

View File

@ -0,0 +1,280 @@
const state = {
definitions: [],
rules: [],
activeType: null,
activeFile: null,
};
const elements = {
refreshButton: document.getElementById("refresh-button"),
filterInput: document.getElementById("filter-input"),
definitionsList: document.getElementById("definitions-list"),
rulesList: document.getElementById("rules-list"),
definitionsCount: document.getElementById("definitions-count"),
rulesCount: document.getElementById("rules-count"),
editorTitle: document.getElementById("editor-title"),
editorSubtitle: document.getElementById("editor-subtitle"),
editorTextarea: document.getElementById("editor-textarea"),
schemaSummary: document.getElementById("schema-summary"),
formatButton: document.getElementById("format-button"),
saveButton: document.getElementById("save-button"),
statusMessage: document.getElementById("status-message"),
newRuleInput: document.getElementById("new-rule-id"),
createRuleButton: document.getElementById("create-rule"),
buildButton: document.getElementById("build-button"),
buildOutput: document.getElementById("build-output"),
};
const fetchJson = async (url, options = {}) => {
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!response.ok) {
let detail = "Request failed.";
try {
const body = await response.json();
detail = body.detail || detail;
} catch (err) {
detail = response.statusText || detail;
}
throw new Error(detail);
}
return response.json();
};
const setStatus = (message, isError = false) => {
elements.statusMessage.textContent = message;
elements.statusMessage.classList.toggle("error", isError);
};
const clearSchema = () => {
elements.schemaSummary.innerHTML = "";
elements.schemaSummary.classList.add("hidden");
};
const renderSchema = (schema) => {
if (!schema || schema.type !== "object") {
clearSchema();
return;
}
const fields = Object.keys(schema.fields || {}).slice(0, 12);
elements.schemaSummary.innerHTML = "";
fields.forEach((field) => {
const chip = document.createElement("span");
chip.textContent = field;
elements.schemaSummary.appendChild(chip);
});
elements.schemaSummary.classList.toggle("hidden", fields.length === 0);
};
const createNavItem = (file, isActive, onClick) => {
const button = document.createElement("button");
button.className = "nav-item";
if (isActive) {
button.classList.add("active");
}
const label = document.createElement("span");
label.textContent = file;
button.appendChild(label);
button.addEventListener("click", () => onClick(file));
return button;
};
const renderLists = () => {
const filterValue = elements.filterInput.value.trim().toLowerCase();
const defFiles = state.definitions.filter((file) =>
file.toLowerCase().includes(filterValue),
);
const ruleFiles = state.rules.filter((file) =>
file.toLowerCase().includes(filterValue),
);
elements.definitionsList.innerHTML = "";
elements.rulesList.innerHTML = "";
defFiles.forEach((file) => {
elements.definitionsList.appendChild(
createNavItem(file, state.activeType === "definition" && state.activeFile === file, openDefinition),
);
});
ruleFiles.forEach((file) => {
elements.rulesList.appendChild(
createNavItem(file, state.activeType === "rule" && state.activeFile === file, openRule),
);
});
elements.definitionsCount.textContent = String(defFiles.length);
elements.rulesCount.textContent = String(ruleFiles.length);
};
const setEditorState = ({ title, subtitle, content, mode }) => {
elements.editorTitle.textContent = title;
elements.editorSubtitle.textContent = subtitle;
elements.editorTextarea.value = content;
elements.formatButton.classList.toggle("hidden", mode !== "definition");
};
const openDefinition = async (filename) => {
try {
setStatus("Loading definition...");
const [contentResponse, schemaResponse] = await Promise.all([
fetchJson(`/api/files/content?filename=${encodeURIComponent(filename)}`),
fetchJson(`/api/files/schema?filename=${encodeURIComponent(filename)}`),
]);
state.activeType = "definition";
state.activeFile = filename;
renderLists();
const rawContent = JSON.stringify(contentResponse.content || {}, null, 2);
setEditorState({
title: filename,
subtitle: filename.endsWith(".tres") ? "Godot resource (tres)" : "JSON definition",
content: rawContent,
mode: "definition",
});
renderSchema(schemaResponse.schema);
setStatus("Definition loaded.");
} catch (err) {
setStatus(err.message || "Failed to load definition.", true);
}
};
const openRule = async (filename) => {
try {
setStatus("Loading rule...");
const response = await fetchJson(
`/api/rules/content?filename=${encodeURIComponent(filename)}`,
);
state.activeType = "rule";
state.activeFile = filename;
renderLists();
setEditorState({
title: filename,
subtitle: "C# rule source",
content: response.content || "",
mode: "rule",
});
clearSchema();
setStatus("Rule loaded.");
} catch (err) {
setStatus(err.message || "Failed to load rule.", true);
}
};
const refreshAll = async () => {
try {
setStatus("Refreshing lists...");
const [definitions, rules] = await Promise.all([
fetchJson("/api/files/definitions"),
fetchJson("/api/rules/list"),
]);
state.definitions = definitions.files || [];
state.rules = rules.files || [];
renderLists();
setStatus("Lists refreshed.");
} catch (err) {
setStatus(err.message || "Failed to refresh.", true);
}
};
const saveCurrent = async () => {
if (!state.activeType || !state.activeFile) {
setStatus("Select a file before saving.", true);
return;
}
if (state.activeType === "definition") {
let parsed;
try {
parsed = JSON.parse(elements.editorTextarea.value);
} catch (err) {
setStatus("Invalid JSON. Fix errors before saving.", true);
return;
}
try {
await fetchJson("/api/files/content", {
method: "POST",
body: JSON.stringify({
filename: state.activeFile,
content: parsed,
}),
});
setStatus("Definition saved.");
} catch (err) {
setStatus(err.message || "Failed to save definition.", true);
}
return;
}
try {
await fetchJson("/api/rules/content", {
method: "POST",
body: JSON.stringify({
filename: state.activeFile,
content: elements.editorTextarea.value,
}),
});
setStatus("Rule saved.");
} catch (err) {
setStatus(err.message || "Failed to save rule.", true);
}
};
const formatJson = () => {
if (state.activeType !== "definition") {
return;
}
try {
const parsed = JSON.parse(elements.editorTextarea.value);
elements.editorTextarea.value = JSON.stringify(parsed, null, 2);
setStatus("Formatted JSON.");
} catch (err) {
setStatus("Invalid JSON. Cannot format.", true);
}
};
const createRule = async () => {
const ruleId = elements.newRuleInput.value.trim();
if (!ruleId) {
setStatus("Enter a rule id first.", true);
return;
}
try {
const response = await fetchJson("/api/rules/create", {
method: "POST",
body: JSON.stringify({ rule_id: ruleId }),
});
elements.newRuleInput.value = "";
await refreshAll();
if (response.filename) {
await openRule(response.filename);
}
setStatus("Rule created.");
} catch (err) {
setStatus(err.message || "Failed to create rule.", true);
}
};
const buildGame = async () => {
try {
elements.buildOutput.textContent = "Running build...";
const response = await fetchJson("/api/build", { method: "POST" });
const output = [response.stdout, response.stderr].filter(Boolean).join("\n");
elements.buildOutput.textContent = output || "Build finished.";
setStatus(response.success ? "Build complete." : "Build completed with errors.");
} catch (err) {
elements.buildOutput.textContent = "Build failed to start.";
setStatus(err.message || "Build failed.", true);
}
};
const init = () => {
elements.refreshButton.addEventListener("click", refreshAll);
elements.filterInput.addEventListener("input", renderLists);
elements.saveButton.addEventListener("click", saveCurrent);
elements.formatButton.addEventListener("click", formatJson);
elements.createRuleButton.addEventListener("click", createRule);
elements.buildButton.addEventListener("click", buildGame);
refreshAll();
setStatus("Ready.");
};
init();

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>

After

Width:  |  Height:  |  Size: 994 B

View File

@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/static/icon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/static/styles.css" />
<title>NDBS Console</title>
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-title">NDBS Console</div>
<div class="brand-subtitle">Single-service balance editor</div>
</div>
<div class="quick-actions">
<button id="refresh-button" class="ghost">Refresh</button>
</div>
<input id="filter-input" class="filter-input" placeholder="Filter files" />
<div class="list-section">
<div class="section-header">
<span>Definitions</span>
<span id="definitions-count" class="section-count">0</span>
</div>
<div id="definitions-list" class="nav-list"></div>
</div>
<div class="list-section">
<div class="section-header">
<span>Rules</span>
<span id="rules-count" class="section-count">0</span>
</div>
<div id="rules-list" class="nav-list"></div>
</div>
</aside>
<main class="main">
<section class="panel hero">
<div class="hero-top">NDBS 2.0</div>
<h1>Numerical design studio</h1>
<p>
Edit JSON and TRES definitions, generate new rule scripts, and sanity-check
builds without running a separate frontend stack.
</p>
</section>
<section class="panel controls">
<div class="panel-title">Rule controls</div>
<div class="control-row">
<input id="new-rule-id" class="text-input" placeholder="new_rule_id" />
<button id="create-rule">Create</button>
<button id="build-button" class="ghost">Verify build</button>
</div>
<div id="build-output" class="build-output"></div>
</section>
<section class="panel editor-panel">
<div class="editor-header">
<div>
<div id="editor-title" class="panel-title">No file selected</div>
<div id="editor-subtitle" class="panel-subtitle">
Pick a definition or rule from the sidebar.
</div>
</div>
<div class="editor-actions">
<button id="format-button" class="ghost">Format</button>
<button id="save-button">Save</button>
</div>
</div>
<div id="schema-summary" class="schema-summary hidden"></div>
<textarea
id="editor-textarea"
class="code-area"
spellcheck="false"
placeholder="Open a file to start editing."
></textarea>
<div id="status-message" class="status-message"></div>
</section>
</main>
</div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,394 @@
@font-face {
font-family: "AlibabaPuHuiTi";
src: url("/static/fonts/AlibabaPuHuiTi-3-65-Medium.ttf") format("truetype");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "AlibabaPuHuiTi";
src: url("/static/fonts/AlibabaPuHuiTi-3-85-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "KenneyFuture";
src: url("/static/fonts/KenneyFuture.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root {
color-scheme: light;
--bg-0: #f5f1ea;
--bg-1: #fff0d6;
--bg-2: #e3f4ec;
--ink-0: #182126;
--ink-1: #2b353a;
--ink-2: #556068;
--accent-0: #f59e0b;
--accent-1: #0f766e;
--accent-2: #d1495b;
--panel: rgba(255, 255, 255, 0.86);
--panel-border: rgba(18, 33, 38, 0.12);
--shadow: 0 24px 50px rgba(12, 20, 28, 0.15);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "AlibabaPuHuiTi", "Noto Serif", serif;
color: var(--ink-0);
background: radial-gradient(circle at 12% 18%, var(--bg-1) 0%, var(--bg-0) 50%, var(--bg-2) 100%);
}
button,
input,
textarea {
font-family: inherit;
}
button {
border: 1px solid rgba(24, 33, 38, 0.18);
background: rgba(245, 158, 11, 0.86);
color: var(--ink-0);
border-radius: 10px;
padding: 8px 14px;
cursor: pointer;
font-size: 13px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
button.ghost {
background: transparent;
}
input.text-input,
input.filter-input {
width: 100%;
border: 1px solid rgba(18, 33, 38, 0.16);
border-radius: 12px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.8);
font-size: 13px;
}
.app-shell {
position: relative;
display: grid;
grid-template-columns: 320px 1fr;
gap: 24px;
min-height: 100vh;
padding: 24px;
}
.app-shell::before,
.app-shell::after {
content: "";
position: absolute;
z-index: 0;
border-radius: 999px;
filter: blur(2px);
}
.app-shell::before {
width: 420px;
height: 420px;
top: -120px;
left: -120px;
background: radial-gradient(circle, rgba(245, 158, 11, 0.28) 0%, rgba(245, 158, 11, 0) 70%);
}
.app-shell::after {
width: 520px;
height: 520px;
bottom: -200px;
right: -180px;
background: radial-gradient(circle, rgba(15, 118, 110, 0.25) 0%, rgba(15, 118, 110, 0) 72%);
}
.sidebar,
.main {
position: relative;
z-index: 1;
}
.sidebar {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 20px;
box-shadow: var(--shadow);
overflow: hidden;
padding: 20px 18px;
display: flex;
flex-direction: column;
gap: 16px;
}
.brand-title {
font-family: "KenneyFuture", "AlibabaPuHuiTi", serif;
font-size: 20px;
letter-spacing: 0.05em;
}
.brand-subtitle {
font-size: 12px;
color: var(--ink-2);
margin-top: 4px;
}
.quick-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.list-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink-2);
}
.section-count {
font-size: 11px;
background: rgba(15, 118, 110, 0.16);
padding: 2px 8px;
border-radius: 999px;
}
.nav-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow: auto;
padding-right: 6px;
}
.nav-item {
border: 1px solid rgba(18, 33, 38, 0.12);
background: rgba(255, 255, 255, 0.82);
border-radius: 12px;
padding: 8px 10px;
text-align: left;
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease;
animation: list-in 420ms ease-out both;
}
.nav-item.active {
border-color: rgba(245, 158, 11, 0.8);
background: rgba(255, 240, 214, 0.95);
}
.nav-item:hover {
transform: translateY(-1px);
border-color: rgba(245, 158, 11, 0.5);
}
.nav-item span {
font-size: 13px;
color: var(--ink-1);
word-break: break-word;
}
.main {
display: flex;
flex-direction: column;
gap: 20px;
}
.panel {
background: var(--panel);
border-radius: 18px;
border: 1px solid var(--panel-border);
box-shadow: var(--shadow);
padding: 20px 22px;
animation: float-in 420ms ease-out both;
}
.panel-title {
font-family: "KenneyFuture", "AlibabaPuHuiTi", serif;
font-size: 18px;
letter-spacing: 0.04em;
}
.panel-subtitle {
color: var(--ink-2);
font-size: 13px;
margin-top: 4px;
}
.hero {
background: linear-gradient(135deg, rgba(255, 240, 214, 0.95) 0%, rgba(255, 255, 255, 0.9) 40%, rgba(227, 244, 236, 0.9) 100%);
}
.hero-top {
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 11px;
color: var(--ink-2);
}
.hero h1 {
font-family: "KenneyFuture", "AlibabaPuHuiTi", serif;
font-size: 34px;
margin: 10px 0 8px;
}
.hero p {
margin: 0;
color: var(--ink-1);
max-width: 540px;
}
.controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.control-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.editor-panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.editor-actions {
display: flex;
gap: 10px;
}
.code-area {
width: 100%;
min-height: 420px;
resize: vertical;
padding: 16px;
border-radius: 14px;
border: 1px solid rgba(24, 33, 38, 0.2);
background: rgba(247, 247, 244, 0.9);
color: var(--ink-0);
font-family: "Courier New", monospace;
font-size: 13px;
line-height: 1.5;
outline: none;
}
.schema-summary {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.schema-summary span {
padding: 3px 8px;
border-radius: 999px;
background: rgba(15, 118, 110, 0.15);
color: var(--ink-1);
font-size: 12px;
}
.schema-summary.hidden {
display: none;
}
.status-message {
font-size: 13px;
color: var(--ink-2);
}
.status-message.error {
color: var(--accent-2);
}
.build-output {
padding: 12px;
border-radius: 12px;
background: rgba(23, 37, 44, 0.06);
max-height: 200px;
overflow: auto;
font-size: 12px;
white-space: pre-wrap;
}
.hidden {
display: none;
}
@keyframes float-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes list-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 960px) {
.app-shell {
grid-template-columns: 1fr;
}
.nav-list {
max-height: 220px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}