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:
Patryk Gensch
2026-08-03 01:18:08 +02:00
co-authored by Codex
commit edd8ceda9e
17 changed files with 2572 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
"""CLI: ricsave <komenda>. Ścieżkę do gry bierze z --game albo ze zmiennej RIC_GAME."""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from . import catalog, schema, slot as slotmod
from .slot import Game, SaveError
def _game(args) -> Game:
root = args.game or os.environ.get("RIC_GAME")
if not root:
raise SaveError("podaj --game <katalog gry> albo ustaw RIC_GAME")
return Game(root)
def _print_table(headers: list[str], rows: list[list[str]]) -> None:
widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
widths[i] = max(widths[i], len(cell))
line = " ".join(h.ljust(w) for h, w in zip(headers, widths))
print(line)
print(" ".join("-" * w for w in widths))
for row in rows:
print(" ".join(cell.ljust(w) for cell, w in zip(row, widths)))
def cmd_info(args) -> int:
game = _game(args)
print(f"gra: {game.root}")
print(f"common: {game.common}")
print()
rows = []
for number in (catalog.LIVE_SLOT, *catalog.MENU_SLOTS):
s = game.slot(number)
if not s.exists():
rows.append([str(number), "(pusty)", "", "", ""])
continue
info = s.summary()
rows.append([
str(number) + (" (żywy)" if number == catalog.LIVE_SLOT else ""),
info["location"] or "?",
info["previous"] or "?",
"tak" if info["mole_active"] not in (None, "0") else "nie",
str(len(info["items"])),
])
_print_table(["slot", "lokacja", "poprzednia", "kret", "przedm."], rows)
return 0
def cmd_validate(args) -> int:
game = _game(args)
problems = game.slot(args.slot).validate()
if not problems:
print(f"slot {args.slot}: OK")
return 0
print(f"slot {args.slot}: {len(problems)} problem(ów)")
for p in problems:
print(f" - {p}")
return 1
def cmd_dump(args) -> int:
game = _game(args)
s = game.slot(args.slot)
if args.key:
data = {args.key: s.raw(args.key)}
if catalog.BY_KEY[args.key].kind == "dta":
data[args.key] = data[args.key].rows # type: ignore[union-attr]
else:
data = s.to_dict()
json.dump(data, sys.stdout, ensure_ascii=False, indent=2)
print()
return 0
def cmd_scene(args) -> int:
game = _game(args)
view = game.slot(args.slot).scene(args.name)
headers = [c.name for c in view.columns]
rows = [[str(i)] + row for i, row in enumerate(view.table.rows)]
_print_table(["#"] + headers, rows)
if args.explain:
print()
seen = sorted({int(r[2]) for r in view.table.rows if r[2].lstrip("-").isdigit()})
for value in seen:
print(f" TYPE {value:>3} = {schema.describe_object_type(value)}")
return 0
def cmd_items(args) -> int:
game = _game(args)
view = game.slot(args.slot).items
headers = [c.name for c in view.columns]
_print_table(headers, [list(r) for r in view.table.rows])
return 0
def cmd_gamesets(args) -> int:
game = _game(args)
values = game.slot(args.slot).gamesets
rows = []
for i, col in enumerate(schema.GAMESETS):
value = str(values[i]) if i < len(values) else "(BRAK)"
rows.append([str(i), col.name, value, col.note])
_print_table(["#", "zmienna", "wartość", "znaczenie"], rows)
return 0
def cmd_spells(args) -> int:
game = _game(args)
s = game.slot(args.slot)
values = s.spells
rows = []
for i, value in enumerate(values):
rows.append([str(i), str(value), schema.describe_spell_index(i)])
_print_table(["#", "wartość", "znaczenie"], rows)
exp = s.experience
frame = exp // schema.EXP_PER_HOURGLASS_FRAME + 1
print(
f"\ndoświadczenie: {exp}/{schema.EXP_PER_LEVEL}"
f" (klatka klepsydry {frame}/16)"
f" · awansów: {values[schema.LEVEL_INDEX]}"
f" · miga: {'tak' if values[schema.HOURGLASS_BLINKING_INDEX] else 'nie'}"
)
known = s.known_spells()
print("znane czary:", ", ".join(known) if known else "brak")
return 0
def cmd_exp(args) -> int:
game = _game(args)
s = game.slot(args.slot)
before = s.experience
s.experience = args.value
print(f"doświadczenie: {before}{args.value}")
print("zapisano:", ", ".join(s.flush()))
return 0
def cmd_set(args) -> int:
game = _game(args)
s = game.slot(args.slot)
view = s.scene(args.scene)
index = view.find(args.idname) if not args.idname.isdigit() else int(args.idname)
for assignment in args.assignments:
column, _, value = assignment.partition("=")
if not _:
raise SaveError(f"oczekiwano KOLUMNA=WARTOŚĆ, dostałem {assignment!r}")
before = view.get(index, column)
view.set(index, column, value)
print(f"{args.scene}[{index}].{column}: {before}{value}")
s.touch(f"scene:{view.name}")
if args.dry_run:
print("(--dry-run: nic nie zapisano)")
return 0
print("zapisano:", ", ".join(s.flush()))
return 0
def cmd_diff(args) -> int:
game = _game(args)
a, b = game.slot(args.a).to_dict(), game.slot(args.b).to_dict()
differences = 0
for key in sorted(set(a) | set(b)):
if a.get(key) != b.get(key):
differences += 1
print(f"~ {key} ({catalog.BY_KEY[key].label})")
if args.verbose:
left, right = a.get(key), b.get(key)
if isinstance(left, list) and isinstance(right, list):
for i in range(max(len(left), len(right))):
lv = left[i] if i < len(left) else "(brak)"
rv = right[i] if i < len(right) else "(brak)"
if lv != rv:
print(f" [{i}] {lv!r}{rv!r}")
print(f"\nróżnic: {differences} (slot {args.a} vs {args.b})")
return 0
def cmd_copy(args) -> int:
game = _game(args)
files = slotmod.copy_slot(game, args.src, args.dst)
print(f"skopiowano {len(files)} plików: slot {args.src}{args.dst}")
return 0
def cmd_backup(args) -> int:
game = _game(args)
print("kopia:", slotmod.backup(game, args.dest))
return 0
def cmd_newgame(args) -> int:
game = _game(args)
files = slotmod.new_game(game)
print(f"zresetowano żywy stan (slot 0), {len(files)} plików")
return 0
def cmd_serve(args) -> int:
from webapp.server import serve
serve(_game(args), host=args.host, port=args.port)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="ricsave", description=__doc__)
parser.add_argument("--game", help="katalog gry (albo RIC_GAME)")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("info", help="przegląd slotów")
p.set_defaults(func=cmd_info)
p = sub.add_parser("validate", help="sprawdź niezmienniki slotu")
p.add_argument("slot", type=int)
p.set_defaults(func=cmd_validate)
p = sub.add_parser("dump", help="zrzut slotu do JSON")
p.add_argument("slot", type=int)
p.add_argument("--key", help="pojedynczy plik, np. game / items / scene:GULDRYK")
p.set_defaults(func=cmd_dump)
p = sub.add_parser("scene", help="tabela obiektów lokacji")
p.add_argument("slot", type=int)
p.add_argument("name")
p.add_argument("--explain", action="store_true", help="rozwiń kolumnę TYPE")
p.set_defaults(func=cmd_scene)
p = sub.add_parser("items", help="ekwipunek")
p.add_argument("slot", type=int)
p.set_defaults(func=cmd_items)
p = sub.add_parser("gamesets", help="GAME<slot>.ARR z opisem pól")
p.add_argument("slot", type=int)
p.set_defaults(func=cmd_gamesets)
p = sub.add_parser("spells", help="czary i doświadczenie (klepsydra)")
p.add_argument("slot", type=int)
p.set_defaults(func=cmd_spells)
p = sub.add_parser("exp", help="ustaw doświadczenie (0..149)")
p.add_argument("slot", type=int)
p.add_argument("value", type=int)
p.set_defaults(func=cmd_exp)
p = sub.add_parser("set", help="zmień pola obiektu w lokacji")
p.add_argument("slot", type=int)
p.add_argument("scene")
p.add_argument("idname", help="IDNAME obiektu albo numer wiersza")
p.add_argument("assignments", nargs="+", metavar="KOLUMNA=WARTOŚĆ")
p.add_argument("--dry-run", action="store_true")
p.set_defaults(func=cmd_set)
p = sub.add_parser("diff", help="porównaj dwa sloty")
p.add_argument("a", type=int)
p.add_argument("b", type=int)
p.add_argument("-v", "--verbose", action="store_true")
p.set_defaults(func=cmd_diff)
p = sub.add_parser("copy", help="kopiuj slot (0 = żywy stan)")
p.add_argument("src", type=int)
p.add_argument("dst", type=int)
p.set_defaults(func=cmd_copy)
p = sub.add_parser("backup", help="kopia zapasowa całego common/")
p.add_argument("dest", type=Path)
p.set_defaults(func=cmd_backup)
p = sub.add_parser("newgame", help="zresetuj żywy stan z szablonów _DEF")
p.set_defaults(func=cmd_newgame)
p = sub.add_parser("serve", help="uruchom lokalny edytor w przeglądarce")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8765)
p.set_defaults(func=cmd_serve)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return args.func(args)
except SaveError as exc:
print(f"błąd: {exc}", file=sys.stderr)
return 2
except (KeyError, ValueError) as exc:
print(f"błąd: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())