- 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>
388 lines
14 KiB
Python
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
|