Files
rex-wizards-save-editor/ricsave/slot.py
T
Patryk GenschandCodex edd8ceda9e 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>
2026-08-03 01:18:08 +02:00

388 lines
14 KiB
Python

"""Model wysokopoziomowy: instalacja gry, slot, widoki na poszczególne pliki."""
from __future__ import annotations
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from . import arr, catalog, dta, schema
class SaveError(RuntimeError):
pass
class Game:
"""Katalog instalacji gry. Cały stan siedzi w podkatalogu common/."""
def __init__(self, root: str | Path) -> None:
self.root = Path(root).expanduser().resolve()
common = self.root / "common"
if not common.is_dir():
# ktoś mógł wskazać od razu na common/
if self.root.name.lower() == "common" and self.root.is_dir():
common = self.root
self.root = self.root.parent
else:
raise SaveError(f"nie znaleziono katalogu common/ w {self.root}")
self.common = common
if not self._resolve("GAME0.ARR").exists():
raise SaveError(f"{common} nie wygląda na katalog gry — brak GAME0.ARR")
def _resolve(self, filename: str) -> Path:
"""Dopasowanie nazwy bez oglądania się na wielkość liter.
Gra działa na Windows i miesza konwencje: ITEMS0.DTA, items_def.dta, m_shot1.img.
Na macOS/Linux trzeba to rozstrzygnąć ręcznie.
"""
direct = self.common / filename
if direct.exists():
return direct
lowered = filename.lower()
for candidate in self.common.iterdir():
if candidate.name.lower() == lowered:
return candidate
return direct # nie istnieje — zwracamy ścieżkę kanoniczną do zapisu
def path(self, entry: catalog.Entry, slot: int) -> Path:
return self._resolve(entry.filename(slot))
def template_path(self, entry: catalog.Entry) -> Path:
"""Ścieżka do fabrycznego szablonu (_DEF) dla danego wpisu."""
if entry.key in ("questions", "questions_asked", "invest") or entry.key.startswith("hist:"):
return self._resolve("QUESTIONS_DEF.ARR")
stem = entry.filename(0)
base, _, ext = stem.rpartition(".")
return self._resolve(f"{base[:-1]}_DEF.{ext}")
def slot(self, number: int) -> "Slot":
return Slot(self, number)
def occupied_slots(self) -> list[int]:
"""Sloty menu, które mają zapis (miniaturka + GAME<n>.ARR)."""
out = []
for n in catalog.MENU_SLOTS:
if self._resolve(f"GAME{n}.ARR").exists():
out.append(n)
return out
@dataclass
class SceneView:
"""Tabela obiektów jednej lokacji, z nazwami kolumn."""
name: str
table: dta.Table
columns: tuple[schema.Column, ...] = schema.SOBJECT
def rows(self) -> list[dict[str, str]]:
return [
{col.name: value for col, value in zip(self.columns, row)}
for row in self.table.rows
]
def find(self, idname: str) -> int:
for i, row in enumerate(self.table.rows):
if row[1] == idname:
return i
raise KeyError(f"{self.name}: brak obiektu o IDNAME={idname!r}")
def get(self, index: int, column: str) -> str:
return self.table.rows[index][self._col(column)]
def set(self, index: int, column: str, value: str | int) -> None:
self.table.rows[index][self._col(column)] = str(value)
def _col(self, column: str) -> int:
for i, col in enumerate(self.columns):
if col.name == column:
return i
raise KeyError(f"nieznana kolumna {column!r}")
@dataclass
class ItemsView:
table: dta.Table
columns: tuple[schema.Column, ...] = schema.SITEM
def rows(self) -> list[dict[str, str]]:
return [
{col.name: value for col, value in zip(self.columns, row)}
for row in self.table.rows
]
def occupied(self) -> list[dict[str, str]]:
# UWAGA: EMPTY == 1 oznacza slot ZAJĘTY (patrz __LOAD_ITEMS__).
return [row for row in self.rows() if row["EMPTY"] == "1"]
def clear(self, guid: int) -> None:
self.table.rows[guid] = [str(guid), "NULL", "0", "NULL", "0"]
def put(self, guid: int, name: str, base: str = "NULL", parent: int = -1) -> None:
self.table.rows[guid] = [str(guid), name, str(parent), base, "1"]
@dataclass
class Slot:
"""Jeden slot zapisu. Slot 0 to żywy stan rozgrywki, nie pozycja z menu."""
game: Game
number: int
_cache: dict[str, object] = field(default_factory=dict, repr=False)
_dirty: set[str] = field(default_factory=set, repr=False)
# ---- surowy dostęp ----------------------------------------------------
def raw(self, key: str):
"""Zawartość pliku o danym kluczu: list[Value] dla .ARR, dta.Table dla .DTA."""
if key not in self._cache:
entry = catalog.BY_KEY[key]
path = self.game.path(entry, self.number)
if not path.exists():
raise SaveError(f"brak pliku {path.name} (slot {self.number})")
self._cache[key] = arr.read(path) if entry.kind == "arr" else dta.read(path)
return self._cache[key]
def touch(self, key: str) -> None:
"""Oznacz plik jako zmieniony — flush() go zapisze."""
self._dirty.add(key)
def flush(self) -> list[str]:
"""Zapisz na dysk tylko to, co zostało dotknięte. Zwraca listę nazw plików."""
written = []
for key in sorted(self._dirty):
entry = catalog.BY_KEY[key]
path = self.game.path(entry, self.number)
value = self._cache[key]
if entry.kind == "arr":
arr.write(path, value) # type: ignore[arg-type]
else:
dta.write(path, value) # type: ignore[arg-type]
written.append(path.name)
self._dirty.clear()
return written
def exists(self) -> bool:
return self.game.path(catalog.BY_KEY["game"], self.number).exists()
# ---- widoki nazwane ---------------------------------------------------
@property
def gamesets(self) -> list:
"""GAME<slot>.ARR — 6 pól, patrz schema.GAMESETS."""
return self.raw("game") # type: ignore[return-value]
@property
def location(self) -> str:
return str(self.gamesets[0])
@location.setter
def location(self, value: str) -> None:
if value not in catalog.VALID_LOCATIONS:
raise SaveError(
f"{value!r} nie jest ani lokacją z G_ARRDATAS, ani sceną z Application.def"
)
self.gamesets[0] = value
self.touch("game")
@property
def spells(self) -> list:
return self.raw("spells") # type: ignore[return-value]
@property
def experience(self) -> int:
"""Doświadczenie (klepsydra) — SPELLS<slot>.ARR[13], próg awansu 150."""
return int(self.spells[schema.EXP_INDEX])
@experience.setter
def experience(self, value: int) -> None:
if not 0 <= value < schema.EXP_PER_LEVEL:
raise SaveError(
f"doświadczenie musi być w 0..{schema.EXP_PER_LEVEL - 1}; "
"gra sama odejmuje próg przy awansie, więc większa wartość zawiśnie"
)
self.spells[schema.EXP_INDEX] = value
self.touch("spells")
def known_spells(self) -> list[str]:
values = self.spells
return [
name for i, name in enumerate(schema.SPELL_NAMES)
if i < len(values) and values[i]
]
@property
def items(self) -> ItemsView:
return ItemsView(self.raw("items")) # type: ignore[arg-type]
def scene(self, name: str) -> SceneView:
name = name.upper()
if name not in catalog.SCENES:
raise SaveError(f"nieznana lokacja {name!r}")
return SceneView(name, self.raw(f"scene:{name}")) # type: ignore[arg-type]
def history(self, character: str) -> list:
character = character.upper()
if character not in catalog.CHARACTERS:
raise SaveError(f"nieznana postać {character!r}")
return self.raw(f"hist:{character}") # type: ignore[return-value]
# ---- diagnostyka ------------------------------------------------------
def validate(self) -> list[str]:
"""Sprawdza niezmienniki, których złamanie gra połyka po cichu."""
problems: list[str] = []
for entry in catalog.MANIFEST:
path = self.game.path(entry, self.number)
if not path.exists():
problems.append(f"brak pliku {path.name} ({entry.label})")
if problems:
return problems
gs = self.gamesets
if len(gs) < catalog.GAMESETS_LEN:
problems.append(
f"GAME{self.number}.ARR ma {len(gs)} elementów zamiast "
f"{catalog.GAMESETS_LEN} — gra zresetuje pozycję do DEFSETS"
)
elif str(gs[0]) not in catalog.VALID_LOCATIONS:
problems.append(
f"bieżąca lokacja {gs[0]!r} nie występuje ani w G_ARRDATAS, "
"ani w PRZYGODA:SCENES — gra nie będzie miała dokąd wejść"
)
sp = self.spells
if len(sp) < catalog.SPELLS_LEN:
problems.append(
f"SPELLS{self.number}.ARR ma {len(sp)} elementów zamiast "
f"{catalog.SPELLS_LEN} — gra dopcha domyślnymi"
)
items = self.items
if len(items.table) != catalog.ITEM_SLOTS:
problems.append(
f"ITEMS{self.number}.DTA ma {len(items.table)} wierszy zamiast "
f"{catalog.ITEM_SLOTS} — pętle w Arcade.cnv są zahardkodowane"
)
if items.table.widths - {len(schema.SITEM)}:
problems.append(
f"ITEMS{self.number}.DTA: kolumny {sorted(items.table.widths)}, "
f"schemat SITEM ma {len(schema.SITEM)}"
)
for name in catalog.SCENES:
table = self.raw(f"scene:{name}")
extra = table.widths - {len(schema.SOBJECT)} # type: ignore[union-attr]
if extra:
problems.append(
f"{name}{self.number}.DTA: wiersze o {sorted(extra)} kolumnach, "
f"schemat SOBJECT ma {len(schema.SOBJECT)}"
)
return problems
def summary(self) -> dict:
gs = self.gamesets
return {
"slot": self.number,
"location": str(gs[0]) if gs else None,
"previous": str(gs[1]) if len(gs) > 1 else None,
"dialog_with": str(gs[2]) if len(gs) > 2 else None,
"mole_active": str(gs[5]) if len(gs) > 5 else None,
"items": [r["NAME"] for r in self.items.occupied()],
"spells": list(self.spells),
"experience": self.experience,
"level": int(self.spells[schema.LEVEL_INDEX]),
"known_spells": self.known_spells(),
}
def to_dict(self) -> dict:
"""Pełny zrzut slotu — nadaje się do diffowania dwóch stanów."""
out: dict[str, object] = {}
for entry in catalog.MANIFEST:
try:
value = self.raw(entry.key)
except SaveError as exc:
out[entry.key] = {"error": str(exc)}
continue
out[entry.key] = value if entry.kind == "arr" else value.rows # type: ignore[union-attr]
return out
# ---- operacje slotowe (odwzorowanie MAINMENU.class) -----------------------
def copy_slot(game: Game, src: int, dst: int) -> list[str]:
"""Kopiuje wszystkie pliki manifestu src → dst. To jest cała treść SAVEGAME/LOADGAME."""
if src == dst:
raise SaveError("źródło i cel to ten sam slot")
copied = []
missing = []
for entry in catalog.MANIFEST:
source = game.path(entry, src)
if not source.exists():
missing.append(source.name)
continue
target = game.common / entry.filename(dst)
existing = game.path(entry, dst)
if existing.exists():
target = existing
shutil.copyfile(source, target)
copied.append(target.name)
if missing:
raise SaveError(f"slot {src} niekompletny, brakuje: {', '.join(missing[:5])}")
return copied
def save_game(game: Game, dst: int) -> list[str]:
"""Odpowiednik SAVEGAME(n): żywy stan (slot 0) → slot menu.
Miniaturki (m_shot<n>.img) nie ruszamy — gra robi ją ze zrzutu ekranu.
"""
if dst not in catalog.MENU_SLOTS:
raise SaveError(f"slot docelowy musi być z {catalog.MENU_SLOTS}")
return copy_slot(game, catalog.LIVE_SLOT, dst)
def load_game(game: Game, src: int) -> list[str]:
"""Odpowiednik LOADGAME(n): slot menu → żywy stan (slot 0).
Po tym gra wystartowana z „Kontynuuj" wejdzie w ten stan.
"""
if src not in catalog.MENU_SLOTS:
raise SaveError(f"slot źródłowy musi być z {catalog.MENU_SLOTS}")
return copy_slot(game, src, catalog.LIVE_SLOT)
def new_game(game: Game) -> list[str]:
"""Odpowiednik NEWGAME: szablony _DEF → slot 0."""
written = []
for entry in catalog.MANIFEST:
target = game.common / entry.filename(catalog.LIVE_SLOT)
existing = game.path(entry, catalog.LIVE_SLOT)
if existing.exists():
target = existing
if entry.key == "cutscenes":
arr.write(target, [])
elif entry.key == "spells":
arr.write(target, list(schema.SPELLS_DEFAULT))
elif entry.key == "game":
arr.write(target, list(schema.GAMESETS_DEFAULT))
else:
template = game.template_path(entry)
if not template.exists():
raise SaveError(f"brak szablonu {template.name} dla {entry.key}")
shutil.copyfile(template, target)
written.append(target.name)
return written
def backup(game: Game, destination: str | Path) -> Path:
"""Kopia całego common/ — rób to zanim cokolwiek zapiszesz."""
dest = Path(destination).expanduser().resolve()
if dest.exists():
raise SaveError(f"{dest} już istnieje")
shutil.copytree(game.common, dest)
return dest