"""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.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.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.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.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