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) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 09:56:03 +02:00
parent 91a02fda8e
commit 2e97bb1a29
3 changed files with 527 additions and 0 deletions
+157
View File
@@ -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)
+78
View File
@@ -358,6 +358,84 @@ def v_superbrief(state: GameState, prso: GameObject | None, prsi: GameObject | N
return True 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") @verb_handler("quit")
def v_quit(state: GameState, prso: GameObject | None, prsi: GameObject | None) -> bool: def v_quit(state: GameState, prso: GameObject | None, prsi: GameObject | None) -> bool:
state.output.tell("Thanks for playing!\n") state.output.tell("Thanks for playing!\n")
+292
View File
@@ -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)