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:
+301
@@ -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())
|
||||
Reference in New Issue
Block a user