2e97bb1a29
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>
293 lines
8.1 KiB
Python
293 lines
8.1 KiB
Python
"""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)
|