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:
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user