Add web-based save editor for "Reksio i Czarodzieje"
- Implemented a local web server using http.server to serve the save editor. - Created a user interface with HTML, CSS, and JavaScript for managing game slots and editing save files. - Added API endpoints for retrieving slot information, overview, and raw data for editing. - Implemented functionality for saving changes back to the game files. - Added tests for ARR and DTA formats to ensure data integrity during round-trip serialization. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
"""Lokalny edytor w przeglądarce. Stdlib http.server — zero zależności.
|
||||
|
||||
ricsave --game "~/Reksio/Reksio i Czarodzieje" serve
|
||||
|
||||
Serwer celowo słucha tylko na 127.0.0.1: daje pełny zapis do katalogu gry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import webbrowser
|
||||
from functools import partial
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from ricsave import catalog, schema, slot as slotmod
|
||||
from ricsave.slot import Game, SaveError
|
||||
|
||||
STATIC = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
game: Game
|
||||
|
||||
def __init__(self, *args, game: Game, **kwargs):
|
||||
self.game = game
|
||||
super().__init__(*args, directory=str(STATIC), **kwargs)
|
||||
|
||||
def log_message(self, fmt, *args): # cisza w konsoli
|
||||
pass
|
||||
|
||||
# ---- API ----
|
||||
|
||||
def do_GET(self):
|
||||
url = urlparse(self.path)
|
||||
if not url.path.startswith("/api/"):
|
||||
return super().do_GET()
|
||||
query = parse_qs(url.query)
|
||||
try:
|
||||
payload = self._get(url.path, query)
|
||||
except SaveError as exc:
|
||||
return self._json({"error": str(exc)}, status=400)
|
||||
except KeyError as exc:
|
||||
return self._json({"error": f"nie znaleziono: {exc}"}, status=404)
|
||||
return self._json(payload)
|
||||
|
||||
def do_POST(self):
|
||||
url = urlparse(self.path)
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
try:
|
||||
payload = self._post(url.path, body)
|
||||
except SaveError as exc:
|
||||
return self._json({"error": str(exc)}, status=400)
|
||||
except (KeyError, ValueError) as exc:
|
||||
return self._json({"error": str(exc)}, status=400)
|
||||
return self._json(payload)
|
||||
|
||||
def _slot_card(self, number: int) -> dict:
|
||||
s = self.game.slot(number)
|
||||
card = {"number": number, "live": number == catalog.LIVE_SLOT, "exists": s.exists()}
|
||||
if card["exists"]:
|
||||
try:
|
||||
card.update(s.summary())
|
||||
except SaveError as exc:
|
||||
# slot niekompletny — pokazujemy go, ale nie udajemy że da się otworzyć
|
||||
card["exists"] = False
|
||||
card["error"] = str(exc)
|
||||
return card
|
||||
|
||||
def _get(self, path: str, query: dict) -> dict:
|
||||
if path == "/api/slots":
|
||||
return {
|
||||
"root": str(self.game.root),
|
||||
"slots": [
|
||||
self._slot_card(n) for n in (catalog.LIVE_SLOT, *catalog.MENU_SLOTS)
|
||||
],
|
||||
"scenes": list(catalog.SCENES),
|
||||
"characters": list(catalog.CHARACTERS),
|
||||
"minigames": list(catalog.MINIGAMES),
|
||||
}
|
||||
|
||||
slot = self.game.slot(int(query["slot"][0]))
|
||||
|
||||
if path == "/api/overview":
|
||||
spells = slot.spells
|
||||
exp = slot.experience
|
||||
return {
|
||||
"summary": slot.summary(),
|
||||
"problems": slot.validate(),
|
||||
# Osobny blok zamiast grzebania w summary — front nie zna indeksów
|
||||
# SPELLS i nie musi liczyć klatki klepsydry sam.
|
||||
"experience": {
|
||||
"value": exp,
|
||||
"threshold": schema.EXP_PER_LEVEL,
|
||||
"frame": exp // schema.EXP_PER_HOURGLASS_FRAME + 1,
|
||||
"frames": schema.EXP_PER_LEVEL // schema.EXP_PER_HOURGLASS_FRAME + 1,
|
||||
"level": int(spells[schema.LEVEL_INDEX]),
|
||||
"blinking": bool(spells[schema.HOURGLASS_BLINKING_INDEX]),
|
||||
"known": slot.known_spells(),
|
||||
},
|
||||
"gamesets": [
|
||||
{
|
||||
"index": i,
|
||||
"name": col.name,
|
||||
"note": col.note,
|
||||
"value": slot.gamesets[i] if i < len(slot.gamesets) else None,
|
||||
}
|
||||
for i, col in enumerate(schema.GAMESETS)
|
||||
],
|
||||
"spells": [
|
||||
{"index": i, "value": v, "note": schema.describe_spell_index(i)}
|
||||
for i, v in enumerate(slot.spells)
|
||||
],
|
||||
"items": slot.items.rows(),
|
||||
}
|
||||
|
||||
if path == "/api/scene":
|
||||
view = slot.scene(query["name"][0])
|
||||
return {
|
||||
"name": view.name,
|
||||
"columns": [
|
||||
{"name": c.name, "type": c.type, "note": c.note} for c in view.columns
|
||||
],
|
||||
"rows": view.table.rows,
|
||||
"types": {
|
||||
row[2]: schema.describe_object_type(int(row[2]))
|
||||
for row in view.table.rows
|
||||
if row[2].lstrip("-").isdigit()
|
||||
},
|
||||
}
|
||||
|
||||
if path == "/api/raw":
|
||||
key = query["key"][0]
|
||||
entry = catalog.BY_KEY[key]
|
||||
value = slot.raw(key)
|
||||
return {
|
||||
"key": key,
|
||||
"kind": entry.kind,
|
||||
"label": entry.label,
|
||||
"value": value if entry.kind == "arr" else value.rows,
|
||||
}
|
||||
|
||||
if path == "/api/manifest":
|
||||
return {
|
||||
"entries": [
|
||||
{
|
||||
"key": e.key,
|
||||
"kind": e.kind,
|
||||
"label": e.label,
|
||||
"file": e.filename(slot.number),
|
||||
}
|
||||
for e in catalog.MANIFEST
|
||||
]
|
||||
}
|
||||
|
||||
raise KeyError(path)
|
||||
|
||||
def _post(self, path: str, body: dict) -> dict:
|
||||
if path == "/api/write":
|
||||
slot = self.game.slot(int(body["slot"]))
|
||||
key = body["key"]
|
||||
entry = catalog.BY_KEY[key]
|
||||
current = slot.raw(key)
|
||||
if entry.kind == "arr":
|
||||
slot._cache[key] = body["value"]
|
||||
else:
|
||||
rows = [[str(cell) for cell in row] for row in body["value"]]
|
||||
if len(rows) != len(current.rows):
|
||||
raise SaveError(
|
||||
f"liczba wierszy musi zostać {len(current.rows)} — "
|
||||
"skrypty scen adresują wiersze po indeksie"
|
||||
)
|
||||
current.rows = rows
|
||||
slot.touch(key)
|
||||
return {"written": slot.flush()}
|
||||
|
||||
if path == "/api/copy":
|
||||
files = slotmod.copy_slot(self.game, int(body["src"]), int(body["dst"]))
|
||||
return {"copied": len(files)}
|
||||
|
||||
if path == "/api/backup":
|
||||
return {"path": str(slotmod.backup(self.game, body["dest"]))}
|
||||
|
||||
raise KeyError(path)
|
||||
|
||||
def _json(self, payload: dict, status: int = 200) -> None:
|
||||
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
|
||||
def serve(game: Game, host: str = "127.0.0.1", port: int = 8765, open_browser: bool = True) -> None:
|
||||
handler = partial(Handler, game=game)
|
||||
server = HTTPServer((host, port), handler)
|
||||
url = f"http://{host}:{port}/"
|
||||
print(f"edytor: {url}")
|
||||
print(f"gra: {game.common}")
|
||||
print("Ctrl-C kończy.")
|
||||
if open_browser:
|
||||
webbrowser.open(url)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nkoniec")
|
||||
Reference in New Issue
Block a user