diff --git a/CMakeLists.txt b/CMakeLists.txt index 21f79d3..4342b79 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -281,7 +281,8 @@ if(EMSCRIPTEN) -sALLOW_MEMORY_GROWTH=1 -sSTACK_SIZE=2097152 -sEXPORTED_RUNTIME_METHODS=ccall,cwrap,FS - -sEXPORTED_FUNCTIONS=_main,_run_python_string,_run_python_string_with_output,_reset_python_environment,_notify_canvas_resize + -sEXPORTED_FUNCTIONS=_main,_run_python_string,_run_python_string_with_output,_reset_python_environment,_notify_canvas_resize,_sync_storage + -lidbfs.js -sASSERTIONS=2 -sSTACK_OVERFLOW_CHECK=2 -fexceptions diff --git a/src/GameEngine.cpp b/src/GameEngine.cpp index 27a06a8..6973b92 100644 --- a/src/GameEngine.cpp +++ b/src/GameEngine.cpp @@ -1,5 +1,6 @@ #include "GameEngine.h" #include "ActionCode.h" +#include #include "McRFPy_API.h" #include "PyScene.h" #include "UITestScene.h" @@ -77,6 +78,15 @@ GameEngine::GameEngine(const McRogueFaceConfig& cfg) Resources::font.loadFromFile("./assets/JetbrainsMono.ttf"); Resources::game = this; window_title = "McRogueFace Engine"; + + // Ensure save/ directory exists for persistent game data +#ifndef __EMSCRIPTEN__ + // Desktop: create save/ in working directory (WASM uses IDBFS mount from JS) + struct stat st; + if (stat("save", &st) != 0) { + mkdir("save", 0755); + } +#endif // Initialize rendering based on headless mode if (headless) { diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 62c731a..a900713 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -44,6 +44,10 @@ #include "ldtk/PyAutoRuleSet.h" // LDtk auto-rule sets #include "McRogueFaceVersion.h" #include "GameEngine.h" +#ifdef __EMSCRIPTEN__ +#include +#endif +#include // mkdir // ImGui is only available for SFML builds #if !defined(MCRF_HEADLESS) && !defined(MCRF_SDL2) #include "ImGuiConsole.h" @@ -103,6 +107,20 @@ static bool McRFPyMetaclass_initialized = false; // ============================================================================ +#ifdef __EMSCRIPTEN__ +extern "C" void sync_storage(); +#endif + +// Flush persistent storage (IDBFS on WASM, no-op on desktop) +static PyObject* mcrfpy_sync_storage(PyObject* self, PyObject* args) +{ +#ifdef __EMSCRIPTEN__ + sync_storage(); +#endif + // On desktop, writes go directly to disk — nothing to sync + Py_RETURN_NONE; +} + // #151: Module-level __getattr__ for dynamic properties (current_scene, scenes) static PyObject* mcrfpy_module_getattr(PyObject* self, PyObject* args) { @@ -135,6 +153,14 @@ static PyObject* mcrfpy_module_getattr(PyObject* self, PyObject* args) return PyFloat_FromDouble(PyTransition::default_duration); } + if (strcmp(name, "save_dir") == 0) { +#ifdef __EMSCRIPTEN__ + return PyUnicode_FromString("/save"); +#else + return PyUnicode_FromString("save"); +#endif + } + // Attribute not found - raise AttributeError PyErr_Format(PyExc_AttributeError, "module 'mcrfpy' has no attribute '%s'", name); return NULL; @@ -347,6 +373,16 @@ static PyMethodDef mcrfpyMethods[] = { "The algorithm ensures minimal grid traversal between two points.") )}, + {"_sync_storage", mcrfpy_sync_storage, METH_NOARGS, + MCRF_FUNCTION(_sync_storage, + MCRF_SIG("()", "None"), + MCRF_DESC("Flush save directory to persistent storage."), + MCRF_RETURNS("None") + MCRF_NOTE("On WebAssembly, flushes the /save/ directory to IndexedDB via IDBFS. " + "On desktop, this is a no-op since writes go directly to disk. " + "Call this after writing files to mcrfpy.save_dir to ensure persistence.") + )}, + {NULL, NULL, 0, NULL} }; @@ -757,6 +793,53 @@ PyObject* PyInit_mcrfpy() // - line() functionality replaced by mcrfpy.bresenham() // - compute_fov() redundant with Grid.compute_fov() +#ifdef __EMSCRIPTEN__ + // WASM: Monkeypatch builtins.open() so that writes to /save/ auto-sync IDBFS. + // This makes `with open(path, 'w') as f: ...` persist transparently on web. + PyRun_SimpleString(R"( +import builtins as _builtins +_mcrf_original_open = _builtins.open + +class _McRF_SyncingFile: + __slots__ = ('_file',) + def __init__(self, f): + object.__setattr__(self, '_file', f) + def __getattr__(self, name): + return getattr(self._file, name) + def __enter__(self): + self._file.__enter__() + return self + def __exit__(self, *args): + result = self._file.__exit__(*args) + import mcrfpy + mcrfpy._sync_storage() + return result + def close(self): + self._file.close() + import mcrfpy + mcrfpy._sync_storage() + def __iter__(self): + return iter(self._file) + def __next__(self): + return next(self._file) + def writable(self): + return self._file.writable() + def readable(self): + return self._file.readable() + def seekable(self): + return self._file.seekable() + +def _mcrf_syncing_open(path, mode='r', *args, **kwargs): + f = _mcrf_original_open(path, mode, *args, **kwargs) + if any(c in mode for c in 'wxa+') and str(path).startswith('/save'): + return _McRF_SyncingFile(f) + return f + +_builtins.open = _mcrf_syncing_open +del _builtins +)"); +#endif + //McRFPy_API::mcrf_module = m; return m; } diff --git a/src/platform/EmscriptenStubs.cpp b/src/platform/EmscriptenStubs.cpp index 95b8d61..51075c6 100644 --- a/src/platform/EmscriptenStubs.cpp +++ b/src/platform/EmscriptenStubs.cpp @@ -292,6 +292,18 @@ int get_python_globals_count() { return (int)PyDict_Size(main_dict); } +// Flush IDBFS to IndexedDB (called from Python via mcrfpy._sync_storage()) +EMSCRIPTEN_KEEPALIVE +void sync_storage() { + EM_ASM( + FS.syncfs(false, function(err) { + if (err) { + console.error('McRogueFace: Failed to sync /save/ to IndexedDB:', err); + } + }); + ); +} + // Notify the engine that the browser canvas was resized (called from JS) EMSCRIPTEN_KEEPALIVE void notify_canvas_resize(int width, int height) { diff --git a/src/shell.html b/src/shell.html index 87b81b5..7a5c2df 100644 --- a/src/shell.html +++ b/src/shell.html @@ -673,6 +673,18 @@ // =========================================== var Module = { + preRun: [function() { + // Mount IDBFS at /save/ for persistent game data + FS.mkdir('/save'); + FS.mount(IDBFS, {}, '/save'); + // Restore saved data from IndexedDB (synchronous before main()) + Module.addRunDependency('idbfs-restore'); + FS.syncfs(true, function(err) { + if (err) console.error('McRogueFace: Failed to restore /save/:', err); + else console.log('McRogueFace: /save/ restored from IndexedDB'); + Module.removeRunDependency('idbfs-restore'); + }); + }], print: (function() { return function(text) { if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' '); diff --git a/src/shell_game.html b/src/shell_game.html index 8584e21..490b8b1 100644 --- a/src/shell_game.html +++ b/src/shell_game.html @@ -82,6 +82,18 @@ }); var Module = { + preRun: [function() { + // Mount IDBFS at /save/ for persistent game data + FS.mkdir('/save'); + FS.mount(IDBFS, {}, '/save'); + // Restore saved data from IndexedDB (synchronous before main()) + Module.addRunDependency('idbfs-restore'); + FS.syncfs(true, function(err) { + if (err) console.error('McRogueFace: Failed to restore /save/:', err); + else console.log('McRogueFace: /save/ restored from IndexedDB'); + Module.removeRunDependency('idbfs-restore'); + }); + }], print: function(text) { if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' '); console.log(text); diff --git a/tests/unit/save_dir_test.py b/tests/unit/save_dir_test.py new file mode 100644 index 0000000..5741960 --- /dev/null +++ b/tests/unit/save_dir_test.py @@ -0,0 +1,81 @@ +"""Test mcrfpy.save_dir and mcrfpy._sync_storage() persistence API""" +import mcrfpy +import os +import json +import sys + +# Test 1: save_dir attribute exists and is a string +save_dir = mcrfpy.save_dir +assert isinstance(save_dir, str), f"save_dir should be str, got {type(save_dir)}" +print(f"PASS: mcrfpy.save_dir = '{save_dir}'") + +# Test 2: save directory exists on disk +assert os.path.isdir(save_dir), f"save_dir '{save_dir}' does not exist as a directory" +print(f"PASS: save directory exists") + +# Test 3: _sync_storage is callable and returns None +result = mcrfpy._sync_storage() +assert result is None, f"_sync_storage should return None, got {result}" +print(f"PASS: _sync_storage() returns None") + +# Test 4: Can write a file to save_dir with context manager +test_path = os.path.join(save_dir, "test_persistence.json") +test_data = {"lore_flags": {"sundering": True}, "zone": 3, "enemies": [1, 2, 3]} +with open(test_path, 'w') as f: + json.dump(test_data, f) +print(f"PASS: wrote {test_path}") + +# Test 5: Can read it back +with open(test_path, 'r') as f: + loaded = json.load(f) +assert loaded == test_data, f"Data mismatch: {loaded} != {test_data}" +print(f"PASS: read back matches") + +# Test 6: Can write binary data +bin_path = os.path.join(save_dir, "test_binary.dat") +with open(bin_path, 'wb') as f: + f.write(b'\x00\x01\x02\xff' * 100) +with open(bin_path, 'rb') as f: + data = f.read() +assert len(data) == 400 +assert data[:4] == b'\x00\x01\x02\xff' +print(f"PASS: binary read/write works") + +# Test 7: sync after writes (no-op on desktop, but should not error) +mcrfpy._sync_storage() +print(f"PASS: _sync_storage() after writes") + +# Test 8: open() on non-save paths is not affected +# (On WASM, the monkeypatch only wraps writes to /save/) +import tempfile +tmp = os.path.join(save_dir, "test_unwrapped.txt") +with open(tmp, 'r+' if os.path.exists(tmp) else 'w') as f: + f.write("test") +os.remove(tmp) +print(f"PASS: open() works for other paths") + +# Test 9: Verify the cross-platform API contract +# Game code that works identically on desktop and WASM: +game_state_path = os.path.join(mcrfpy.save_dir, "game_state.json") +game_state = { + "player": {"hp": 50, "zone": 3}, + "lore": {"sundering": True, "librarian_met": False}, + "enemies": {"zone_3": {"goblin_chief": False}} +} +# Write (on WASM, context manager auto-syncs IDBFS; on desktop, just writes) +with open(game_state_path, 'w') as f: + json.dump(game_state, f) +# Read +with open(game_state_path, 'r') as f: + loaded_state = json.load(f) +assert loaded_state == game_state +os.remove(game_state_path) +print(f"PASS: cross-platform save/load contract works") + +# Cleanup +os.remove(test_path) +os.remove(bin_path) +print(f"PASS: cleanup done") + +print("\nAll save_dir tests passed!") +sys.exit(0)