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:
@@ -0,0 +1,43 @@
|
||||
"""ricsave — czytanie i edycja zapisów gry „Reksio i Czarodzieje" (silnik PIK, 2004).
|
||||
|
||||
>>> from ricsave import Game
|
||||
>>> g = Game("~/Reksio/Reksio i Czarodzieje")
|
||||
>>> s = g.slot(0) # 0 = żywy stan rozgrywki
|
||||
>>> s.summary()["location"]
|
||||
'GULDRYK'
|
||||
|
||||
Rdzeń nie ma zależności zewnętrznych. Opis formatu: docs/format-zapisow.md
|
||||
"""
|
||||
|
||||
from . import arr, catalog, dta, schema
|
||||
from .slot import (
|
||||
Game,
|
||||
ItemsView,
|
||||
SaveError,
|
||||
SceneView,
|
||||
Slot,
|
||||
backup,
|
||||
copy_slot,
|
||||
load_game,
|
||||
new_game,
|
||||
save_game,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Game",
|
||||
"ItemsView",
|
||||
"SaveError",
|
||||
"SceneView",
|
||||
"Slot",
|
||||
"arr",
|
||||
"backup",
|
||||
"catalog",
|
||||
"copy_slot",
|
||||
"dta",
|
||||
"load_game",
|
||||
"new_game",
|
||||
"save_game",
|
||||
"schema",
|
||||
]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Obiekt ARRAY silnika PIK — pliki .ARR i .SAV.
|
||||
|
||||
Format (little-endian, bez nagłówka):
|
||||
|
||||
uint32 count
|
||||
count × { uint32 type; type==1 ? int32 : (uint32 len; bytes[len]) }
|
||||
|
||||
type 1 = INTEGER, type 2 = STRING (cp1250, bez terminatora).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
ENCODING = "cp1250"
|
||||
|
||||
TYPE_INT = 1
|
||||
TYPE_STR = 2
|
||||
TYPE_BOOL = 3
|
||||
TYPE_FLOAT = 4
|
||||
|
||||
Value = int | str
|
||||
|
||||
|
||||
class ArrError(ValueError):
|
||||
"""Plik .ARR nie trzyma się formatu."""
|
||||
|
||||
|
||||
def loads(data: bytes) -> list[Value]:
|
||||
if len(data) < 4:
|
||||
raise ArrError(f"plik ma {len(data)} B, minimum to 4 B (samo count)")
|
||||
(count,) = struct.unpack_from("<I", data, 0)
|
||||
off = 4
|
||||
out: list[Value] = []
|
||||
for i in range(count):
|
||||
try:
|
||||
(kind,) = struct.unpack_from("<I", data, off)
|
||||
off += 4
|
||||
if kind == TYPE_INT:
|
||||
(value,) = struct.unpack_from("<i", data, off)
|
||||
off += 4
|
||||
out.append(value)
|
||||
elif kind == TYPE_STR:
|
||||
(length,) = struct.unpack_from("<I", data, off)
|
||||
off += 4
|
||||
raw = data[off : off + length]
|
||||
if len(raw) != length:
|
||||
raise ArrError(
|
||||
f"element {i}: deklarowana długość {length} B, dostępne {len(raw)} B"
|
||||
)
|
||||
off += length
|
||||
out.append(raw.decode(ENCODING))
|
||||
elif kind == TYPE_BOOL:
|
||||
(value,) = struct.unpack_from("<i", data, off)
|
||||
off += 4
|
||||
out.append(value != 0)
|
||||
elif kind == TYPE_FLOAT:
|
||||
(value,) = struct.unpack_from("<i", data, off)
|
||||
off += 4
|
||||
out.append(value / 1000) # w Reksio i Czarodzieje dzielone jest przez 1000
|
||||
else:
|
||||
raise ArrError(f"element {i} @0x{off - 4:x}: nieznany typ {kind}")
|
||||
except struct.error as exc:
|
||||
raise ArrError(f"element {i}: plik urwany @0x{off:x}") from exc
|
||||
if off != len(data):
|
||||
raise ArrError(f"{len(data) - off} B śmieci na końcu (po {count} elementach)")
|
||||
return out
|
||||
|
||||
|
||||
def dumps(values: list[Value]) -> bytes:
|
||||
parts = [struct.pack("<I", len(values))]
|
||||
for i, value in enumerate(values):
|
||||
if isinstance(value, bool):
|
||||
parts.append(struct.pack("<Ii", TYPE_BOOL, 1 if value else 0))
|
||||
elif isinstance(value, int):
|
||||
parts.append(struct.pack("<Ii", TYPE_INT, value))
|
||||
elif isinstance(value, str):
|
||||
raw = value.encode(ENCODING)
|
||||
parts.append(struct.pack("<II", TYPE_STR, len(raw)) + raw)
|
||||
elif isinstance(value, float):
|
||||
parts.append(struct.pack("<Ii", TYPE_FLOAT, int(value * 1000))) # w Reksio i Czarodzieje mnożone jest przez 1000
|
||||
else:
|
||||
raise ArrError(f"element {i}: typ {type(value).__name__} nieobsługiwany")
|
||||
return b"".join(parts)
|
||||
|
||||
|
||||
def read(path: str | Path) -> list[Value]:
|
||||
return loads(Path(path).read_bytes())
|
||||
|
||||
|
||||
def write(path: str | Path, values: list[Value]) -> None:
|
||||
Path(path).write_bytes(dumps(values))
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Manifest slotu zapisu — co, gdzie i pod jaką nazwą.
|
||||
|
||||
Wszystkie listy pochodzą wprost ze skryptów gry:
|
||||
|
||||
SCENES Game.cnv → G_ARRDATAS (BFITMP125)
|
||||
CHARACTERS MAINMENU.class:652 → M_ARRCHARACTERS (CONSTRUCTOR)
|
||||
MINIGAMES MAINMENU.class:652 → M_ARRPIOTR (CONSTRUCTOR)
|
||||
układ plików slotu → SAVEGAME (:667) i LOADGAME (:672)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
#: Lokacje mające własny plik <NAZWA><slot>.DTA. Kolejność jak w G_ARRDATAS.
|
||||
SCENES: tuple[str, ...] = (
|
||||
"PODWIECZOREK1", "PODWIECZOREK2", "PODWIECZOREK3", "TRZYWEJSCIA",
|
||||
"DOMALCHOMIKA", "GABINETDYR", "HOLGLOWNY", "UNIVFRONT",
|
||||
"BIALO", "MIASTO", "LUSTRO", "TELEP_PODWORKO", "TELEP_WARSZTAT", "TELEP_KURNIKI",
|
||||
"PIWNICA", "ALCHEMY", "RATUSZ", "LABIRYNTH", "PIKLIBIA", "DOMSPIEL", "PRZEPASC",
|
||||
"MIOTLISKO", "KAPTUREK", "CALINECZKA", "DRATEWKA", "SNIEZKA", "KROLEWNA", "JAGA",
|
||||
"OLBRZYM", "SZREKSIO", "TROL", "NORASZ", "SEZAM", "PRZEDSEZAMEM", "KROLOWA", "SMOK",
|
||||
"RATUSZIN", "GULDRYK", "KORYTARZ", "SALA", "UNIVBACK", "UNIVPIWNICA", "MIOTSHOP",
|
||||
"PRZYSTAN", "GABINETSNEJKA", "BARANMIOT", "WMWEJSCIE", "WMWNETRZE", "PIKMIOT",
|
||||
"SKLEPALCH", "ARRAS", "POMPA",
|
||||
)
|
||||
|
||||
#: Sceny samodzielne z Application.def (PRZYGODA:SCENES) — intro, outro, minigry,
|
||||
#: dialogi. Nie mają własnego .DTA. G_SARCADEOBJECTS może wskazywać i na nie:
|
||||
#: _LOADGAME_ sprawdza G_ARRDATAS^FIND() i albo podmienia .DTA w scenie ARCADE,
|
||||
#: albo robi PRZYGODA^GOTO() do sceny o tej nazwie (Arcade.cnv:1533).
|
||||
STANDALONE_SCENES: tuple[str, ...] = (
|
||||
"ARCADE", "INTRO_1", "INTRO_2", "INTRO_3", "INTRO_4", "MAGIC", "HILLS", "LIMBO",
|
||||
"DIALOGS", "CHOICE", "START", "LEBIODKA", "C_UCIECZKA", "CREDITS",
|
||||
"OUTRO_1", "OUTRO_2", "OUTRO_3", "OUTRO_4", "OUTRO_5", "OUTRO_6", "OUTRO_7",
|
||||
"OFERTA", "PRZYLOT", "PRZYLOT_2", "TELE_1", "TELE_2", "KONTROLA",
|
||||
"BARANDALF", "CAT", "FRED", "KUBKI", "MIOTLY", "LABIRYNT", "DRAGON", "SHOOTER",
|
||||
)
|
||||
|
||||
#: Wszystko, co może legalnie stać w G_SARCADEOBJECTS.
|
||||
VALID_LOCATIONS: frozenset[str] = frozenset(SCENES) | frozenset(STANDALONE_SCENES)
|
||||
|
||||
#: Postacie z historią dialogową <POSTAĆ>_HIST<slot>.ARR.
|
||||
CHARACTERS: tuple[str, ...] = (
|
||||
"BUREKTOR", "GULDRYK", "SNEJK", "WALDIMORS", "SPIELMAUSTER", "CHRUMBURAK",
|
||||
"BARANDALF", "KROLOWA", "KAMIEN", "SMOK", "GASIENICA",
|
||||
)
|
||||
|
||||
#: Minigry z własnym <MINIGRA><slot>.ARR.
|
||||
MINIGAMES: tuple[str, ...] = (
|
||||
"LABIRYNT", "MIOTLY", "CAT", "BARANDALF", "DRAGON", "SHOOTER",
|
||||
)
|
||||
|
||||
#: Sloty widoczne w menu gry. Slot 0 to żywy stan rozgrywki, nie pozycja w menu.
|
||||
MENU_SLOTS: tuple[int, ...] = (1, 2, 3, 4)
|
||||
LIVE_SLOT = 0
|
||||
|
||||
#: Stała liczba slotów ekwipunku — pętle w Arcade.cnv są zahardkodowane na 0..10.
|
||||
ITEM_SLOTS = 10
|
||||
|
||||
#: Wymagana długość SPELLS<slot>.ARR; krótsza → gra po cichu resetuje.
|
||||
SPELLS_LEN = 17
|
||||
|
||||
#: Wymagana długość GAME<slot>.ARR; krótsza → DEFSETS, czyli utrata pozycji.
|
||||
GAMESETS_LEN = 6
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Entry:
|
||||
"""Jeden plik należący do slotu."""
|
||||
|
||||
key: str # stabilny identyfikator, np. "scene:GULDRYK"
|
||||
kind: str # "dta" | "arr"
|
||||
template: str # nazwa pliku z {slot} do podstawienia
|
||||
label: str
|
||||
|
||||
def filename(self, slot: int) -> str:
|
||||
return self.template.format(slot=slot)
|
||||
|
||||
|
||||
def manifest() -> list[Entry]:
|
||||
"""Pełna lista plików tworzących jeden slot (bez miniaturki m_shot)."""
|
||||
entries: list[Entry] = [
|
||||
Entry("game", "arr", "GAME{slot}.ARR", "Zmienne globalne rozgrywki"),
|
||||
Entry("items", "dta", "ITEMS{slot}.DTA", "Ekwipunek"),
|
||||
Entry("spells", "arr", "SPELLS{slot}.ARR", "Znane czary"),
|
||||
Entry("cutscenes", "arr", "CUTSCENES{slot}.ARR", "Obejrzane przerywniki"),
|
||||
Entry("invest", "arr", "INVEST{slot}.ARR", "Karta śledztwa"),
|
||||
Entry("questions", "arr", "QUESTIONS{slot}.ARR", "Dostępne pytania"),
|
||||
Entry("questions_asked", "arr", "QUESTIONS_ASKED{slot}.ARR", "Zadane pytania"),
|
||||
]
|
||||
entries += [
|
||||
Entry(f"scene:{name}", "dta", name + "{slot}.DTA", f"Lokacja {name}")
|
||||
for name in SCENES
|
||||
]
|
||||
entries += [
|
||||
Entry(f"hist:{name}", "arr", name + "_HIST{slot}.ARR", f"Rozmowy: {name}")
|
||||
for name in CHARACTERS
|
||||
]
|
||||
entries += [
|
||||
Entry(f"minigame:{name}", "arr", name + "{slot}.ARR", f"Minigra {name}")
|
||||
for name in MINIGAMES
|
||||
]
|
||||
return entries
|
||||
|
||||
|
||||
MANIFEST: tuple[Entry, ...] = tuple(manifest())
|
||||
BY_KEY: dict[str, Entry] = {e.key: e for e in MANIFEST}
|
||||
|
||||
|
||||
def screenshot_filename(slot: int) -> str:
|
||||
"""Miniaturka zapisu pokazywana w menu — istnieje tylko dla slotów 1..4."""
|
||||
return f"m_shot{slot}.img"
|
||||
|
||||
|
||||
#: Pliki stanu leżące w common/, ale POZA manifestem slotu — nie są kopiowane przy
|
||||
#: zapisie/wczytaniu, więc przeciekają między rozgrywkami. Głównie alchemia.
|
||||
UNSLOTTED_GLOBS: tuple[str, ...] = (
|
||||
"SETTINGS.ARR", # globalne ustawienia: [?, głośność 0..800]
|
||||
"SLOT_*.SAV", # utrwalone mikstury (Alchemy.cnv:BEHSAVESLOTS)
|
||||
"DODOS_*.SAV", # stan dodo (Alchemy.cnv:BEHSAVEDODOS)
|
||||
)
|
||||
+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())
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Obiekt DATABASE silnika PIK — pliki .DTA.
|
||||
|
||||
Wiersze rozdzielone CRLF, kolumny pionową kreską, brak nagłówka. Nazwy i typy kolumn
|
||||
żyją w definicji STRUCT w pliku .cnv — patrz ricsave.schema.
|
||||
|
||||
Puste pole tekstowe zapisywane jest jako literalne NULL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ENCODING = "cp1250"
|
||||
ROW_SEP = "\r\n"
|
||||
COL_SEP = "|"
|
||||
NULL = "NULL"
|
||||
|
||||
|
||||
class DtaError(ValueError):
|
||||
"""Plik .DTA nie trzyma się formatu."""
|
||||
|
||||
|
||||
class Table:
|
||||
"""Tabela .DTA: lista wierszy, każdy to lista surowych stringów.
|
||||
|
||||
Świadomie trzymamy surowe stringi — konwersję na int robi dopiero warstwa schematu,
|
||||
dzięki czemu zapis jest bajt-w-bajt identyczny gdy nic nie zmieniasz.
|
||||
"""
|
||||
|
||||
def __init__(self, rows: list[list[str]], *, trailing_newline: bool = True) -> None:
|
||||
self.rows = rows
|
||||
# Pliki *_def.dta nie mają końcowego CRLF, te zapisane przez grę mają.
|
||||
self.trailing_newline = trailing_newline
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return len(self.rows[0]) if self.rows else 0
|
||||
|
||||
@property
|
||||
def widths(self) -> set[int]:
|
||||
return {len(row) for row in self.rows}
|
||||
|
||||
def check_width(self, expected: int, label: str = "tabela") -> None:
|
||||
"""Waliduje szerokość wobec znanego schematu STRUCT."""
|
||||
found = self.widths
|
||||
if found - {expected}:
|
||||
raise DtaError(
|
||||
f"{label}: oczekiwano {expected} kolumn, znaleziono {sorted(found)}"
|
||||
)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.rows)
|
||||
|
||||
def __getitem__(self, index: int) -> list[str]:
|
||||
return self.rows[index]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Table {len(self.rows)}×{self.width}>"
|
||||
|
||||
|
||||
def loads(data: bytes) -> Table:
|
||||
text = data.decode(ENCODING)
|
||||
trailing = text.endswith(ROW_SEP)
|
||||
body = text[: -len(ROW_SEP)] if trailing else text
|
||||
if not body:
|
||||
return Table([], trailing_newline=trailing)
|
||||
if "\r\n" not in body and "\n" in body:
|
||||
raise DtaError("wiersze rozdzielone samym LF — to nie jest plik .DTA z gry")
|
||||
# Bez walidacji szerokości: część statycznych tabel gry (np. fighters.dta) ma
|
||||
# wiersze różnej długości. Szerokość sprawdzamy tam, gdzie znamy STRUCT —
|
||||
# przez Table.check_width().
|
||||
rows = [line.split(COL_SEP) for line in body.split(ROW_SEP)]
|
||||
return Table(rows, trailing_newline=trailing)
|
||||
|
||||
|
||||
def dumps(table: Table) -> bytes:
|
||||
text = ROW_SEP.join(COL_SEP.join(row) for row in table.rows)
|
||||
if table.trailing_newline and table.rows:
|
||||
text += ROW_SEP
|
||||
return text.encode(ENCODING)
|
||||
|
||||
|
||||
def read(path: str | Path) -> Table:
|
||||
return loads(Path(path).read_bytes())
|
||||
|
||||
|
||||
def write(path: str | Path, table: Table) -> None:
|
||||
Path(path).write_bytes(dumps(table))
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Schematy: nazwy kolumn .DTA i znaczenie indeksów w .ARR.
|
||||
|
||||
Kolumny .DTA nie są zapisane w pliku — pochodzą z definicji STRUCT w Arcade.cnv
|
||||
(linie 1455-1489). Znaczenia pól ARR z MAINMENU.class (DEFSETS, SAVEGAME).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
INT = "INTEGER"
|
||||
STR = "STRING"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Column:
|
||||
name: str
|
||||
type: str
|
||||
note: str = ""
|
||||
|
||||
|
||||
#: SOBJECT — model bazy obiektów sceny (<SCENA><slot>.DTA).
|
||||
SOBJECT: tuple[Column, ...] = (
|
||||
Column("NAME", STR, "ścieżka do .ANN/.IMG/.SEK względem katalogu sceny"),
|
||||
Column("IDNAME", STR, "identyfikator logiczny, używany przez skrypty sceny"),
|
||||
Column("TYPE", INT, "rola wiersza — patrz OBJECT_TYPES"),
|
||||
Column("SPARAM0", STR),
|
||||
Column("SPARAM1", STR),
|
||||
Column("SPARAM2", STR),
|
||||
Column("IPARAM0", INT, "dla TYPE==2: 1 = widoczny, 0 = zabrany"),
|
||||
Column("IPARAM1", INT),
|
||||
Column("IPARAM2", INT),
|
||||
)
|
||||
|
||||
#: SITEM — model ekwipunku (ITEMS<slot>.DTA).
|
||||
SITEM: tuple[Column, ...] = (
|
||||
Column("GUID", INT, "indeks slotu 0..9, równy numerowi wiersza"),
|
||||
Column("NAME", STR, "identyfikator przedmiotu, NULL gdy pusto"),
|
||||
Column("PARENT", INT, "indeks wiersza w .DTA lokacji, z której podniesiono (-1 = brak)"),
|
||||
Column("BASE", STR, "lokacja pochodzenia"),
|
||||
Column("EMPTY", INT, "MYLĄCA NAZWA: 1 = slot ZAJĘTY, 0 = wolny"),
|
||||
)
|
||||
|
||||
DTA_SCHEMAS: dict[str, tuple[Column, ...]] = {
|
||||
"scene": SOBJECT,
|
||||
"items": SITEM,
|
||||
}
|
||||
|
||||
#: Kolumna TYPE w SOBJECT — dekoder z Arcade.cnv:__LOAD_DB__.
|
||||
OBJECT_TYPES: dict[int, str] = {
|
||||
101: "tło statyczne (.IMG)",
|
||||
102: "maska ścieżek chodzenia (.SEK)",
|
||||
103: "aktywacja przejścia: WPATH^SETACTIVE(IPARAM0, IPARAM1, IPARAM2)",
|
||||
104: "Reksio; IPARAM0 = prędkość maks.",
|
||||
105: "punkt startowy, zestaw 0; IDNAME = scena skąd, IPARAM0/1 = X/Y",
|
||||
106: "punkt docelowy, zestaw 0; IPARAM0/1 = X/Y",
|
||||
107: "tło animowane; IPARAM0/1 = szerokość/wysokość",
|
||||
108: "muzyka lokacji; IDNAME = plik .WAV",
|
||||
140: "Kret",
|
||||
145: "punkt startowy, zestaw 1",
|
||||
146: "punkt docelowy, zestaw 1",
|
||||
}
|
||||
|
||||
|
||||
def describe_object_type(value: int) -> str:
|
||||
if value in OBJECT_TYPES:
|
||||
return OBJECT_TYPES[value]
|
||||
if value < 100:
|
||||
extra = " — IPARAM0 to flaga widoczności" if value == 2 else ""
|
||||
return f"obiekt sceny (ANNOBJECT), podtyp {value}{extra}"
|
||||
return f"nieznany typ {value}"
|
||||
|
||||
|
||||
#: GAME<slot>.ARR — 6 elementów, wszystkie zapisane jako STRING.
|
||||
GAMESETS: tuple[Column, ...] = (
|
||||
Column("G_SARCADEOBJECTS", STR, "bieżąca lokacja"),
|
||||
Column("G_SLASTOBJECTS", STR, "poprzednia lokacja — wyznacza punkt wejścia"),
|
||||
Column("G_SDIALOGCHARACTER", STR, "rozmówca"),
|
||||
Column("G_SDIALOGRETURN", STR, "dokąd wrócić po rozmowie"),
|
||||
Column("G_ITKTPM", INT, "losowe 0..7, ustalane raz przy nowej grze"),
|
||||
Column("G_IKRETACTIVE", INT, "czy Kret towarzyszy Reksiowi (0/1)"),
|
||||
)
|
||||
|
||||
#: Stan fabryczny GAME0.ARR z DEFSETS (indeks 4 jest losowany 0..7).
|
||||
GAMESETS_DEFAULT: list[str] = ["INTRO_1", "NULL", "BUREKTOR", "NULL", "0", "0"]
|
||||
|
||||
#: SPELLS<slot>.ARR — 17 intów. Stan fabryczny z NEWGAME.
|
||||
SPELLS_DEFAULT: list[int] = [0] * 12 + [64] + [0] * 4
|
||||
|
||||
#: Próg doświadczenia na jeden poziom (MAINMENU.class: LEVELUP, BFITMP57/59/61).
|
||||
EXP_PER_LEVEL = 150
|
||||
|
||||
#: Ile klatek animacji klepsydry — klatka = exp // 10 + 1 (BFITMP55).
|
||||
EXP_PER_HOURGLASS_FRAME = 10
|
||||
|
||||
#: Indeksy 0..11 to flagi „czar znany" (0/1). Sześć czarów w wersji zwykłej (0-5)
|
||||
#: i obronnej (6-11, sufiks _OBR); czar N ma obronę N+6. Nazwy z uchwytów klikania
|
||||
#: kul w gabinetach — Guldryk.cnv, Gabinetsnejka.cnv, Wmwnetrze.cnv.
|
||||
SPELL_NAMES: tuple[str, ...] = (
|
||||
"Sen", "Zamiana w żabę", "Plaga much", "Kula budyniu", "Koła i wiry", "Ciemność",
|
||||
"Obrona przed Snem", "Obrona przed Zamianą w żabę", "Obrona przed Plagą much", "Obrona przed Kulą budyniu",
|
||||
"Obrona przed Kołami i wirami", "Obrona przed Ciemnością",
|
||||
)
|
||||
|
||||
#: Indeksy 12..16 — liczniki i flagi stanu klepsydry.
|
||||
SPELLS_NOTES: dict[int, str] = {
|
||||
12: "startuje na 64, SUBAT(12,3) przy każdym awansie — nikt tego nie czyta",
|
||||
13: f"DOŚWIADCZENIE, 0..{EXP_PER_LEVEL - 1}; po przekroczeniu progu reszta przechodzi dalej",
|
||||
14: "licznik awansów (ile razy klepsydra się przesypała)",
|
||||
15: "1 → klepsydra/różdżka widoczna w menu (SHOWEXP, M_WAND)",
|
||||
16: "1 → klepsydra miga, można nauczyć się czaru",
|
||||
}
|
||||
|
||||
#: Indeks doświadczenia w SPELLS<slot>.ARR.
|
||||
EXP_INDEX = 13
|
||||
LEVEL_INDEX = 14
|
||||
HOURGLASS_VISIBLE_INDEX = 15
|
||||
HOURGLASS_BLINKING_INDEX = 16
|
||||
|
||||
|
||||
def describe_spell_index(index: int) -> str:
|
||||
if index < len(SPELL_NAMES):
|
||||
return f"{SPELL_NAMES[index]} (1 = znany)"
|
||||
return SPELLS_NOTES.get(index, "nieznane")
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user