From 2e97bb1a29d86cc40e06e11869df0833a055f6c3 Mon Sep 17 00:00:00 2001 From: Seppe De Loore Date: Sat, 4 Apr 2026 09:56:03 +0200 Subject: [PATCH] Add save/load game with named save files Serialize full game state (objects, flags, containment, clocks) to JSON files in ~/.h2g2_saves/. Players can name saves, list existing ones, and restore by name or number. Co-Authored-By: Claude Opus 4.6 (1M context) --- h2g2/engine/save_load.py | 157 +++++++++++++++++++++ h2g2/engine/verbs.py | 78 +++++++++++ tests/test_save_load.py | 292 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 527 insertions(+) create mode 100644 h2g2/engine/save_load.py create mode 100644 tests/test_save_load.py diff --git a/h2g2/engine/save_load.py b/h2g2/engine/save_load.py new file mode 100644 index 0000000..cdd82ec --- /dev/null +++ b/h2g2/engine/save_load.py @@ -0,0 +1,157 @@ +"""Save and restore game state to/from JSON files.""" + +from __future__ import annotations + +import copy +import json +import os +from pathlib import Path +from typing import Any + +from h2g2.engine.game_object import Flag +from h2g2.engine.state import GameState + +SAVE_DIR = Path.home() / ".h2g2_saves" +SAVE_EXTENSION = ".json" + + +def _ensure_save_dir() -> Path: + SAVE_DIR.mkdir(parents=True, exist_ok=True) + return SAVE_DIR + + +def list_saves() -> list[str]: + """Return sorted list of save-file names (without extension).""" + if not SAVE_DIR.exists(): + return [] + names = [ + p.stem for p in sorted(SAVE_DIR.iterdir()) + if p.suffix == SAVE_EXTENSION and p.is_file() + ] + return names + + +# -- Serialization ----------------------------------------------------------- + +def _obj_id(obj: Any) -> str | None: + if obj is None: + return None + return obj.id + + +def _serialize_state(state: GameState) -> dict[str, Any]: + """Snapshot all mutable game state into a JSON-safe dict.""" + # Object flags + containment for every object in the world + objects: dict[str, Any] = {} + for obj_id, obj in state.world.objects.items(): + objects[obj_id] = { + "flags": [f.name for f in obj.flags], + "parent": _obj_id(obj.parent), + } + + # Clock entries (matched by name) + clocks: list[dict[str, Any]] = [] + for entry in state.clock.entries: + clocks.append({ + "name": entry.name, + "tick": entry.tick, + "enabled": entry.enabled, + }) + + return { + "version": 1, + "here": _obj_id(state.here), + "protagonist": _obj_id(state.protagonist), + "winner": _obj_id(state.winner), + "identity_flag": _obj_id(state.identity_flag), + "score": state.score, + "moves": state.moves, + "lit": state.lit, + "lying_down": state.lying_down, + "verbosity": state.verbosity, + "dreaming": state.dreaming, + "running": state.running, + "l_prsa": state.l_prsa, + "l_prso": _obj_id(state.l_prso), + "l_prsi": _obj_id(state.l_prsi), + "flags": copy.deepcopy(state.flags), + "objects": objects, + "clocks": clocks, + } + + +def _restore_state(state: GameState, data: dict[str, Any]) -> None: + """Apply a serialized snapshot back onto the live game state.""" + world = state.world + + def resolve(obj_id: str | None) -> Any: + if obj_id is None: + return None + return world.objects.get(obj_id) + + def resolve_room(obj_id: str | None) -> Any: + if obj_id is None: + return None + return world.rooms.get(obj_id) + + # Scalar state + state.here = resolve_room(data["here"]) + state.protagonist = resolve(data["protagonist"]) + state.winner = resolve(data["winner"]) + state.identity_flag = resolve(data.get("identity_flag")) + state.score = data["score"] + state.moves = data["moves"] + state.lit = data["lit"] + state.lying_down = data["lying_down"] + state.verbosity = data["verbosity"] + state.dreaming = data["dreaming"] + state.running = data["running"] + state.l_prsa = data["l_prsa"] + state.l_prso = resolve(data["l_prso"]) + state.l_prsi = resolve(data["l_prsi"]) + state.flags = data["flags"] + + # Restore object flags and rebuild containment tree + # First pass: set flags and detach all children + for obj in world.objects.values(): + obj.parent = None + obj.children.clear() + + # Second pass: restore flags and re-attach parents + for obj_id, obj_data in data["objects"].items(): + obj = world.objects.get(obj_id) + if obj is None: + continue + obj.flags = {Flag[name] for name in obj_data["flags"]} + parent_id = obj_data["parent"] + if parent_id is not None: + parent = world.objects.get(parent_id) + if parent is not None: + obj.parent = parent + parent.children.append(obj) + + # Restore clock state (match by name) + clock_data = {c["name"]: c for c in data.get("clocks", []) if c["name"]} + for entry in state.clock.entries: + saved = clock_data.get(entry.name) + if saved is not None: + entry.tick = saved["tick"] + entry.enabled = saved["enabled"] + + +# -- File I/O ---------------------------------------------------------------- + +def save_game(state: GameState, name: str) -> str: + """Save game to file. Returns the full path written.""" + _ensure_save_dir() + path = SAVE_DIR / (name + SAVE_EXTENSION) + data = _serialize_state(state) + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + return str(path) + + +def load_game(state: GameState, name: str) -> None: + """Load game from file. Raises FileNotFoundError if missing.""" + path = SAVE_DIR / (name + SAVE_EXTENSION) + data = json.loads(path.read_text(encoding="utf-8")) + _restore_state(state, data) diff --git a/h2g2/engine/verbs.py b/h2g2/engine/verbs.py index 6e9babc..01013ff 100644 --- a/h2g2/engine/verbs.py +++ b/h2g2/engine/verbs.py @@ -358,6 +358,84 @@ def v_superbrief(state: GameState, prso: GameObject | None, prsi: GameObject | N return True +@verb_handler("save") +def v_save(state: GameState, prso: GameObject | None, prsi: GameObject | None) -> bool: + from h2g2.engine.save_load import save_game, list_saves + out = state.output + + existing = list_saves() + if existing: + out.tell("Existing saves: " + ", ".join(existing) + "\n") + + out.tell("Enter a name for your save (or blank to cancel): ") + # Flush so the prompt appears before we block on input + print(out.flush(), end="") + + try: + name = input().strip() + except (EOFError, KeyboardInterrupt): + out.tell("Save cancelled.\n") + return True + + if not name: + out.tell("Save cancelled.\n") + return True + + try: + path = save_game(state, name) + out.tell(f"Game saved to {path}\n") + except OSError as exc: + out.tell(f"Save failed: {exc}\n") + return True + + +@verb_handler("restore") +def v_restore(state: GameState, prso: GameObject | None, prsi: GameObject | None) -> bool: + from h2g2.engine.save_load import load_game, list_saves + from h2g2.engine.loop import GameLoop + out = state.output + + existing = list_saves() + if not existing: + out.tell("No saved games found.\n") + return True + + out.tell("Available saves:\n") + for i, name in enumerate(existing, 1): + out.tell(f" {i}. {name}\n") + out.tell("Enter name or number to restore (or blank to cancel): ") + print(out.flush(), end="") + + try: + choice = input().strip() + except (EOFError, KeyboardInterrupt): + out.tell("Restore cancelled.\n") + return True + + if not choice: + out.tell("Restore cancelled.\n") + return True + + # Allow picking by number + if choice.isdigit(): + idx = int(choice) - 1 + if 0 <= idx < len(existing): + choice = existing[idx] + else: + out.tell("Invalid selection.\n") + return True + + try: + load_game(state, choice) + out.tell("Game restored.\n") + GameLoop._describe_room_static(state) + except FileNotFoundError: + out.tell(f"No save named '{choice}' found.\n") + except (OSError, KeyError, ValueError) as exc: + out.tell(f"Restore failed: {exc}\n") + return True + + @verb_handler("quit") def v_quit(state: GameState, prso: GameObject | None, prsi: GameObject | None) -> bool: state.output.tell("Thanks for playing!\n") diff --git a/tests/test_save_load.py b/tests/test_save_load.py new file mode 100644 index 0000000..bac2071 --- /dev/null +++ b/tests/test_save_load.py @@ -0,0 +1,292 @@ +"""Tests for save/load game state serialization.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from h2g2.engine.game_object import Flag +from h2g2.engine.save_load import ( + SAVE_DIR, + SAVE_EXTENSION, + _serialize_state, + _restore_state, + save_game, + load_game, + list_saves, +) + + +@pytest.fixture(autouse=True) +def _clean_save_dir(): + """Ensure a clean save directory for every test.""" + if SAVE_DIR.exists(): + shutil.rmtree(SAVE_DIR) + SAVE_DIR.mkdir(parents=True, exist_ok=True) + yield + if SAVE_DIR.exists(): + shutil.rmtree(SAVE_DIR) + + +# --------------------------------------------------------------------------- +# list_saves +# --------------------------------------------------------------------------- + +class TestListSaves: + def test_empty_directory(self): + assert list_saves() == [] + + def test_returns_save_names(self): + (SAVE_DIR / "alpha.json").write_text("{}") + (SAVE_DIR / "beta.json").write_text("{}") + names = list_saves() + assert "alpha" in names + assert "beta" in names + + def test_ignores_non_json_files(self): + (SAVE_DIR / "notes.txt").write_text("not a save") + (SAVE_DIR / "real.json").write_text("{}") + assert list_saves() == ["real"] + + +# --------------------------------------------------------------------------- +# Round-trip: serialize -> restore +# --------------------------------------------------------------------------- + +class TestSerializeRestore: + def test_scalar_state_round_trips(self, game): + state, _, _ = game + state.score = 42 + state.moves = 17 + state.verbosity = 2 + state.lying_down = False + state.dreaming = True + state.flags["headache"] = False + state.flags["beer_counter"] = 3 + + data = _serialize_state(state) + # Mutate state to prove restore actually changes it + state.score = 0 + state.moves = 0 + state.verbosity = 0 + state.lying_down = True + state.dreaming = False + state.flags["headache"] = True + state.flags["beer_counter"] = 0 + + _restore_state(state, data) + + assert state.score == 42 + assert state.moves == 17 + assert state.verbosity == 2 + assert state.lying_down is False + assert state.dreaming is True + assert state.flags["headache"] is False + assert state.flags["beer_counter"] == 3 + + def test_room_round_trips(self, game): + state, _, _ = game + bedroom = state.world.get_room("BEDROOM") + state.here = bedroom + + data = _serialize_state(state) + state.here = None + _restore_state(state, data) + + assert state.here is bedroom + + def test_protagonist_round_trips(self, game): + state, _, _ = game + proto = state.protagonist + + data = _serialize_state(state) + state.protagonist = None + _restore_state(state, data) + + assert state.protagonist is proto + + def test_object_flags_round_trip(self, game): + state, _, _ = game + obj = state.world.get("TOWEL") + obj.fset(Flag.WORNBIT) + assert obj.fset_q(Flag.WORNBIT) + + data = _serialize_state(state) + obj.fclear(Flag.WORNBIT) + assert not obj.fset_q(Flag.WORNBIT) + + _restore_state(state, data) + assert obj.fset_q(Flag.WORNBIT) + + def test_containment_round_trip(self, game): + state, _, _ = game + towel = state.world.get("TOWEL") + proto = state.protagonist + + # Move towel into inventory + towel.move_to(proto) + assert towel.parent is proto + assert towel in proto.children + + data = _serialize_state(state) + + # Move towel somewhere else to prove restore works + bedroom = state.world.get_room("BEDROOM") + towel.move_to(bedroom) + assert towel.parent is bedroom + + _restore_state(state, data) + assert towel.parent is proto + assert towel in proto.children + + def test_last_action_round_trips(self, game): + state, _, _ = game + towel = state.world.get("TOWEL") + state.l_prsa = "take" + state.l_prso = towel + state.l_prsi = None + + data = _serialize_state(state) + state.l_prsa = None + state.l_prso = None + _restore_state(state, data) + + assert state.l_prsa == "take" + assert state.l_prso is towel + + def test_clock_state_round_trips(self, game): + state, _, _ = game + + # Find a named clock entry and modify it + named = [e for e in state.clock.entries if e.name] + if not named: + pytest.skip("No named clock entries in test fixture") + entry = named[0] + original_tick = entry.tick + entry.tick = 99 + entry.enabled = False + + data = _serialize_state(state) + entry.tick = 0 + entry.enabled = True + _restore_state(state, data) + + assert entry.tick == 99 + assert entry.enabled is False + + # Restore original for other tests + entry.tick = original_tick + entry.enabled = True + + def test_identity_flag_round_trips(self, game): + state, _, _ = game + arthur = state.world.objects.get("ARTHUR") + if arthur is None: + pytest.skip("ARTHUR object not in test fixture") + state.identity_flag = arthur + + data = _serialize_state(state) + state.identity_flag = None + _restore_state(state, data) + + assert state.identity_flag is arthur + + +# --------------------------------------------------------------------------- +# File I/O: save_game / load_game +# --------------------------------------------------------------------------- + +class TestFileIO: + def test_save_creates_file(self, game): + state, _, _ = game + path = save_game(state, "test1") + assert Path(path).exists() + assert Path(path).suffix == SAVE_EXTENSION + + def test_save_file_is_valid_json(self, game): + state, _, _ = game + save_game(state, "test1") + data = json.loads((SAVE_DIR / "test1.json").read_text()) + assert data["version"] == 1 + assert "objects" in data + assert "flags" in data + + def test_load_restores_state(self, game): + state, _, _ = game + state.score = 100 + state.moves = 50 + save_game(state, "mysave") + + state.score = 0 + state.moves = 0 + load_game(state, "mysave") + + assert state.score == 100 + assert state.moves == 50 + + def test_load_nonexistent_raises(self, game): + state, _, _ = game + with pytest.raises(FileNotFoundError): + load_game(state, "doesnotexist") + + def test_overwrite_existing_save(self, game): + state, _, _ = game + state.score = 10 + save_game(state, "slot") + + state.score = 99 + save_game(state, "slot") + + state.score = 0 + load_game(state, "slot") + assert state.score == 99 + + def test_multiple_independent_saves(self, game): + state, _, _ = game + state.score = 10 + save_game(state, "early") + + state.score = 200 + save_game(state, "late") + + load_game(state, "early") + assert state.score == 10 + + load_game(state, "late") + assert state.score == 200 + + def test_full_round_trip_with_mutations(self, game): + state, _, _ = game + proto = state.protagonist + towel = state.world.get("TOWEL") + + # Mutate the game + state.score = 42 + state.moves = 15 + state.lying_down = False + state.flags["earth_demolished"] = True + towel.move_to(proto) + towel.fset(Flag.TOUCHBIT) + + save_game(state, "full") + + # Trash everything + state.score = 0 + state.moves = 0 + state.lying_down = True + state.flags["earth_demolished"] = False + bedroom = state.world.get_room("BEDROOM") + towel.move_to(bedroom) + towel.fclear(Flag.TOUCHBIT) + + load_game(state, "full") + + assert state.score == 42 + assert state.moves == 15 + assert state.lying_down is False + assert state.flags["earth_demolished"] is True + assert towel.parent is proto + assert towel.fset_q(Flag.TOUCHBIT)