diff --git a/Dockerfile b/Dockerfile index d1e8de0..31d3722 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ WORKDIR /app # front-end (examples/web_app.py) could not give them. COPY requirements.txt ./ RUN apt-get update && apt-get install -y --no-install-recommends git \ - && pip install --no-cache-dir "tak @ git+https://github.com/Stephenson-Software/tak@1bac386777d4b2468faeda00b31e960af8d57164" \ + && pip install --no-cache-dir "tak @ git+https://github.com/Stephenson-Software/tak@9b539d8760e2c02cc262a7dcff6d0c541572ab71" \ && apt-get purge -y git && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* COPY src/ ./src/ diff --git a/requirements.txt b/requirements.txt index d69fb7c..4d2a53a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,5 @@ # tak is the text-adventure kit extracted from this game # (https://github.com/Stephenson-Software/tak), which FishE now runs on. # Installing it needs git on the PATH. -tak @ git+https://github.com/Stephenson-Software/tak@1bac386777d4b2468faeda00b31e960af8d57164 +tak @ git+https://github.com/Stephenson-Software/tak@9b539d8760e2c02cc262a7dcff6d0c541572ab71 jsonschema>=4.0,<5.0 diff --git a/src/saveFileManager.py b/src/saveFileManager.py index 88f70b5..909175c 100644 --- a/src/saveFileManager.py +++ b/src/saveFileManager.py @@ -43,28 +43,36 @@ def __init__(self, data_directory="data"): ) def migrate_old_save_files(self): - """Migrate old save files (data/*.json) to slot_1 if they exist""" + """Move a pre-slot save (data/*.json) into a slot, if there is one. + + The save goes into slot_1 when slot_1 is free, otherwise into the next + free slot: an existing slot is never written over. (Before cloud saves + that could only happen with a hand-copied file; now a save set brought + in from another device can hold both layouts, and the one already in + a slot must survive.) With every slot taken, nothing is moved and the + old files stay where they are.""" old_player = os.path.join(self.data_directory, "player.json") - old_stats = os.path.join(self.data_directory, "stats.json") - old_time = os.path.join(self.data_directory, "timeService.json") + old_files = ["player.json", "stats.json", "timeService.json"] # Check if old save files exist if not os.path.exists(old_player): return False - # Create slot_1 directory - slot_1_path = os.path.join(self.data_directory, "slot_1") - if not os.path.exists(slot_1_path): - os.makedirs(slot_1_path, exist_ok=True) + # The kit's rule: the lowest slot whose directory holds no file. + slot_number = self.get_next_available_slot() + if slot_number is None: + return False + slot_path = os.path.join(self.data_directory, "slot_%d" % slot_number) + os.makedirs(slot_path, exist_ok=True) - # Move files to slot_1 + # Move files into the slot. The slot held no file, so nothing in it is + # replaced; a name that is somehow there already is left alone. try: - if os.path.exists(old_player): - shutil.move(old_player, os.path.join(slot_1_path, "player.json")) - if os.path.exists(old_stats): - shutil.move(old_stats, os.path.join(slot_1_path, "stats.json")) - if os.path.exists(old_time): - shutil.move(old_time, os.path.join(slot_1_path, "timeService.json")) + for name in old_files: + source = os.path.join(self.data_directory, name) + target = os.path.join(slot_path, name) + if os.path.exists(source) and not os.path.exists(target): + shutil.move(source, target) # The migration rewrote the save directory's layout; flush it so a # browser-storage player doesn't re-migrate on every page load. syncBrowserSaves() diff --git a/tests/test_saveFileManager.py b/tests/test_saveFileManager.py index 50f1bfa..e6a9e6c 100644 --- a/tests/test_saveFileManager.py +++ b/tests/test_saveFileManager.py @@ -714,7 +714,8 @@ def test_migrate_old_save_files_partial(): def test_migrate_old_save_files_slot1_exists(): - """Test migration when slot_1 already exists""" + """A pre-slot save never replaces a save already in slot_1: it goes into + the next free slot, and slot_1 is left exactly as it was.""" temp_dir = tempfile.mkdtemp() try: manager = SaveFileManager(temp_dir) @@ -728,15 +729,66 @@ def test_migrate_old_save_files_slot1_exists(): # Create old format files with open(os.path.join(temp_dir, "player.json"), "w") as f: json.dump({"money": 100}, f) + with open(os.path.join(temp_dir, "stats.json"), "w") as f: + json.dump({"totalFishCaught": 3}, f) - # Migration should still succeed (will overwrite) result = manager.migrate_old_save_files() assert result is True - # Check that old file was moved and overwrote existing with open(os.path.join(slot_1_path, "player.json"), "r") as f: - data = json.load(f) - assert data["money"] == 100 # Should be the migrated value + assert json.load(f)["money"] == 999 # untouched + assert not os.path.exists(os.path.join(slot_1_path, "stats.json")) + slot_2_path = os.path.join(temp_dir, "slot_2") + with open(os.path.join(slot_2_path, "player.json"), "r") as f: + assert json.load(f)["money"] == 100 + with open(os.path.join(slot_2_path, "stats.json"), "r") as f: + assert json.load(f)["totalFishCaught"] == 3 + assert not os.path.exists(os.path.join(temp_dir, "player.json")) + assert not os.path.exists(os.path.join(temp_dir, "stats.json")) + finally: + shutil.rmtree(temp_dir) + + +def test_migrate_old_save_files_skips_a_slot_holding_any_file(): + """A slot whose player.json is gone but whose other files survive is not + free either (the kit's rule): the old save goes past it.""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + os.makedirs(os.path.join(temp_dir, "slot_1")) + with open(os.path.join(temp_dir, "slot_1", "stats.json"), "w") as f: + f.write('{"totalFishCaught": 7}') + with open(os.path.join(temp_dir, "player.json"), "w") as f: + json.dump({"money": 5}, f) + + assert manager.migrate_old_save_files() is True + + assert not os.path.exists(os.path.join(temp_dir, "slot_1", "player.json")) + with open(os.path.join(temp_dir, "slot_1", "stats.json")) as f: + assert f.read() == '{"totalFishCaught": 7}' + assert os.path.exists(os.path.join(temp_dir, "slot_2", "player.json")) + finally: + shutil.rmtree(temp_dir) + + +def test_migrate_old_save_files_every_slot_taken_moves_nothing(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + for n in range(1, 100): + os.makedirs(os.path.join(temp_dir, "slot_%d" % n)) + with open(os.path.join(temp_dir, "slot_%d" % n, "player.json"), "w") as f: + json.dump({"money": n}, f) + with open(os.path.join(temp_dir, "player.json"), "w") as f: + json.dump({"money": 100}, f) + + with patch("src.saveFileManager.syncBrowserSaves") as sync: + assert manager.migrate_old_save_files() is False + sync.assert_not_called() + + assert os.path.exists(os.path.join(temp_dir, "player.json")) + with open(os.path.join(temp_dir, "slot_1", "player.json")) as f: + assert json.load(f)["money"] == 1 finally: shutil.rmtree(temp_dir) diff --git a/tests/web/test_serve.py b/tests/web/test_serve.py index 025919f..852faab 100644 --- a/tests/web/test_serve.py +++ b/tests/web/test_serve.py @@ -50,6 +50,8 @@ def test_the_page_keeps_the_saves_database_name(): html = page.read() assert 'idbName: "fishe-saves"' in html assert 'saveDirEnv: "FISHE_SAVE_DIR"' in html + # Cloud saves (RFC 0016), only on arcade and only once a player turns them on. + assert "cloudSaves: true," in html assert 'entry: "web/pyodide_main.py"' in html diff --git a/web/index.html b/web/index.html index ffa3523..e989cc5 100644 --- a/web/index.html +++ b/web/index.html @@ -22,7 +22,7 @@
Tip: click an option or press its number key (1-9). Enter or Space continues. Your saved games live in this browser.
+Tip: click an option or press its number key (1-9). Enter or Space continues. Your saved games live in this browser; at fishe.play.danielstephenson.dev you can also back them up to your account once signed in.
@@ -33,6 +33,11 @@