From 37f5f7efc2085582bae7e5ad9a4cc18b0f4477ae Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 3 Oct 2026 21:56:02 -0600 Subject: [PATCH] Cloud saves: pin tak 9b539d8 (/tak/cloud.js), turn cloudSaves on, safe migration FishE joins Night Ferry under RFC 0016. A signed-in player can turn on cloud backup from the Saves panel on fishe.play.danielstephenson.dev; until then nothing changes and no /v1/saves request is made. The old alias host never loads cloud.js, so its saves stay in that browser. The pre-slot migration (data/*.json -> slot_1) wrote over slot_1 when slot_1 already held a save, and a test asserted it. With cloud saves a save set brought in from another device could hold both layouts, so the old save now goes into the next free slot (the kit's rule) and an existing slot is never written over; with all 99 slots taken nothing is moved. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 2 +- requirements.txt | 2 +- src/saveFileManager.py | 36 ++++++++++++-------- tests/test_saveFileManager.py | 62 ++++++++++++++++++++++++++++++++--- tests/web/test_serve.py | 2 ++ web/index.html | 7 +++- 6 files changed, 89 insertions(+), 22 deletions(-) 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 @@

FishE — fish a seaside village and build a fortune of $10,000

Starting…
-

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 @@

FishE — fish a seaside village and build a fortune o bundleUrl: "/web/game.zip", entry: "web/pyodide_main.py", logPrefix: "[fishe]", + // Cloud saves on arcade (RFC 0016): a signed-in player can back these + // saves up and get them on their other devices. Off until they turn it on, + // and only on fishe.play.danielstephenson.dev: the old alias host never + // loads cloud.js (tak's onArcadeHost), so its saves stay in its browser. + cloudSaves: true, messages: { download: "Downloading the village…", start: "Casting off…" }, });