From f4e566c521e6b672d4c19605ebd6a11954a8e6bc Mon Sep 17 00:00:00 2001 From: dmccoystephenson Date: Mon, 6 Jul 2026 14:39:24 +0000 Subject: [PATCH 1/3] fix: recover gracefully from corrupt/empty mapImage.png on startup In the web/WASM environment (OPFS), a partially-written or zero-byte mapImage.png passes os.path.exists() but then causes PIL to throw UnidentifiedImageError. Catch all image-open exceptions in getExistingMapImage() and fall back to createNewMapImage() so the game loads instead of crashing. Co-Authored-By: Claude Sonnet 4.6 --- src/mapimage/mapImageGenerator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mapimage/mapImageGenerator.py b/src/mapimage/mapImageGenerator.py index 807c60bf..5edfc8f0 100644 --- a/src/mapimage/mapImageGenerator.py +++ b/src/mapimage/mapImageGenerator.py @@ -60,7 +60,11 @@ def mapImageExists(self): def getExistingMapImage(self): mapImagePath = self.getMapImagePath() _logger.debug("loading existing map image", path=mapImagePath) - return Image.open(mapImagePath) + try: + return Image.open(mapImagePath) + except Exception: + _logger.warning("map image unreadable, recreating", path=mapImagePath) + return self.createNewMapImage() def createNewMapImage(self): _logger.debug("creating new map image") From 71f62a83b0f29d07a7ccd3ca69eae9602796c543 Mon Sep 17 00:00:00 2001 From: dmccoystephenson Date: Mon, 6 Jul 2026 15:03:47 +0000 Subject: [PATCH 2/3] fix: add Cache-Control: no-cache for .zip and .js responses Without explicit cache headers, browsers serve stale game.zip after a redeploy, masking fixes until the user hard-refreshes. Force revalidation for all .zip and .js paths so a new container image is always picked up. Co-Authored-By: Claude Sonnet 4.6 --- web/serve.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/serve.py b/web/serve.py index b9d737fa..a942d85f 100644 --- a/web/serve.py +++ b/web/serve.py @@ -43,6 +43,11 @@ def end_headers(self): self.send_header("Cross-Origin-Opener-Policy", "same-origin") self.send_header("Cross-Origin-Embedder-Policy", "require-corp") self.send_header("Cross-Origin-Resource-Policy", "same-origin") + # Force revalidation of the game bundle and worker on every request so + # a redeployed container is never masked by a stale browser cache. + path = getattr(self, "path", "") or "" + if any(path.split("?")[0].endswith(ext) for ext in (".zip", ".js")): + self.send_header("Cache-Control", "no-cache") super().end_headers() def log_message(self, *args): From eeab814ef9bb141197b36ef81bbe33c96bfde221 Mon Sep 17 00:00:00 2001 From: dmccoystephenson Date: Mon, 6 Jul 2026 15:22:28 +0000 Subject: [PATCH 3/3] fix: content-hash cache-busting for game.zip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_zip.py now writes a SHA-256 of game.zip to web/game_version.txt. game-worker.js fetches that file with cache:'no-store' and appends the hash as ?v= to the zip URL. A new deploy changes the hash, changes the URL, and forces every browser to download the new bundle — no hard refresh or cache clearing needed. serve.py applies no-store to game_version.txt and no-cache to .zip/.js. Both generated files (game.zip, game_version.txt) added to .gitignore. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 ++ web/build_zip.py | 12 +++++++++++- web/game-worker.js | 11 ++++++++++- web/serve.py | 9 ++++++--- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 42f590f1..bf07040e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ *.pyc +web/game.zip +web/game_version.txt screenshots/*.png saves/*/*.json saves/*/rooms/*.json diff --git a/web/build_zip.py b/web/build_zip.py index 5dd13990..2ab3c55a 100644 --- a/web/build_zip.py +++ b/web/build_zip.py @@ -3,7 +3,12 @@ Bundles the Python source tree, schemas, and config into a single zip that the browser's Pyodide Worker downloads and unpacks into its virtual filesystem. + +Also writes web/game_version.txt containing the SHA-256 of the zip so the +Worker can request game.zip?v= — a content-addressed URL that forces +the browser to fetch a new zip whenever the source changes. """ +import hashlib import os import zipfile @@ -26,4 +31,9 @@ if os.path.exists(_web_file): z.write(_web_file, _web_file) -print("Built web/game.zip") +with open("web/game.zip", "rb") as _f: + _digest = hashlib.sha256(_f.read()).hexdigest() +with open("web/game_version.txt", "w") as _f: + _f.write(_digest) + +print(f"Built web/game.zip ({_digest[:12]})") diff --git a/web/game-worker.js b/web/game-worker.js index 7504600f..829db995 100644 --- a/web/game-worker.js +++ b/web/game-worker.js @@ -178,7 +178,16 @@ self.onmessage = async (e) => { self.postMessage({ type: 'status', msg: 'Downloading game…' }); - const resp = await fetch('/web/game.zip'); + // Fetch the content-hash written by build_zip.py so the zip URL is + // unique per build. The browser may cache game.zip?v= freely; + // a new deploy changes the hash, forcing a fresh download. + let zipUrl = '/web/game.zip'; + try { + const verResp = await fetch('/web/game_version.txt', { cache: 'no-store' }); + if (verResp.ok) zipUrl += '?v=' + (await verResp.text()).trim(); + } catch (_) { /* no version file — fall back to unversioned URL */ } + + const resp = await fetch(zipUrl); if (!resp.ok) throw new Error(`game.zip fetch failed: ${resp.status}`); const buf = await resp.arrayBuffer(); pyodide.FS.mkdir('/game'); diff --git a/web/serve.py b/web/serve.py index a942d85f..bc252110 100644 --- a/web/serve.py +++ b/web/serve.py @@ -43,10 +43,13 @@ def end_headers(self): self.send_header("Cross-Origin-Opener-Policy", "same-origin") self.send_header("Cross-Origin-Embedder-Policy", "require-corp") self.send_header("Cross-Origin-Resource-Policy", "same-origin") - # Force revalidation of the game bundle and worker on every request so - # a redeployed container is never masked by a stale browser cache. path = getattr(self, "path", "") or "" - if any(path.split("?")[0].endswith(ext) for ext in (".zip", ".js")): + bare = path.split("?")[0] + if bare.endswith("game_version.txt"): + # Never cache — the Worker fetches this to get the current zip hash. + self.send_header("Cache-Control", "no-store") + elif any(bare.endswith(ext) for ext in (".zip", ".js")): + # Revalidate on every request so a new deploy is always picked up. self.send_header("Cache-Control", "no-cache") super().end_headers()