Cross-platform persistent save directory (IDBFS on WASM, filesystem on desktop)
Game code uses standard Python file I/O to mcrfpy.save_dir with no platform branching. On WASM, builtins.open() is monkeypatched so writes to /save/ auto-sync IDBFS on close, making persistence transparent. API: mcrfpy.save_dir (str), mcrfpy._sync_storage() (auto-called on WASM) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
453ea4a7eb
commit
e2d3e56968
7 changed files with 212 additions and 1 deletions
|
|
@ -281,7 +281,8 @@ if(EMSCRIPTEN)
|
||||||
-sALLOW_MEMORY_GROWTH=1
|
-sALLOW_MEMORY_GROWTH=1
|
||||||
-sSTACK_SIZE=2097152
|
-sSTACK_SIZE=2097152
|
||||||
-sEXPORTED_RUNTIME_METHODS=ccall,cwrap,FS
|
-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
|
-sASSERTIONS=2
|
||||||
-sSTACK_OVERFLOW_CHECK=2
|
-sSTACK_OVERFLOW_CHECK=2
|
||||||
-fexceptions
|
-fexceptions
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "GameEngine.h"
|
#include "GameEngine.h"
|
||||||
#include "ActionCode.h"
|
#include "ActionCode.h"
|
||||||
|
#include <sys/stat.h>
|
||||||
#include "McRFPy_API.h"
|
#include "McRFPy_API.h"
|
||||||
#include "PyScene.h"
|
#include "PyScene.h"
|
||||||
#include "UITestScene.h"
|
#include "UITestScene.h"
|
||||||
|
|
@ -77,6 +78,15 @@ GameEngine::GameEngine(const McRogueFaceConfig& cfg)
|
||||||
Resources::font.loadFromFile("./assets/JetbrainsMono.ttf");
|
Resources::font.loadFromFile("./assets/JetbrainsMono.ttf");
|
||||||
Resources::game = this;
|
Resources::game = this;
|
||||||
window_title = "McRogueFace Engine";
|
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
|
// Initialize rendering based on headless mode
|
||||||
if (headless) {
|
if (headless) {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,10 @@
|
||||||
#include "ldtk/PyAutoRuleSet.h" // LDtk auto-rule sets
|
#include "ldtk/PyAutoRuleSet.h" // LDtk auto-rule sets
|
||||||
#include "McRogueFaceVersion.h"
|
#include "McRogueFaceVersion.h"
|
||||||
#include "GameEngine.h"
|
#include "GameEngine.h"
|
||||||
|
#ifdef __EMSCRIPTEN__
|
||||||
|
#include <emscripten.h>
|
||||||
|
#endif
|
||||||
|
#include <sys/stat.h> // mkdir
|
||||||
// ImGui is only available for SFML builds
|
// ImGui is only available for SFML builds
|
||||||
#if !defined(MCRF_HEADLESS) && !defined(MCRF_SDL2)
|
#if !defined(MCRF_HEADLESS) && !defined(MCRF_SDL2)
|
||||||
#include "ImGuiConsole.h"
|
#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)
|
// #151: Module-level __getattr__ for dynamic properties (current_scene, scenes)
|
||||||
static PyObject* mcrfpy_module_getattr(PyObject* self, PyObject* args)
|
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);
|
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
|
// Attribute not found - raise AttributeError
|
||||||
PyErr_Format(PyExc_AttributeError, "module 'mcrfpy' has no attribute '%s'", name);
|
PyErr_Format(PyExc_AttributeError, "module 'mcrfpy' has no attribute '%s'", name);
|
||||||
return NULL;
|
return NULL;
|
||||||
|
|
@ -347,6 +373,16 @@ static PyMethodDef mcrfpyMethods[] = {
|
||||||
"The algorithm ensures minimal grid traversal between two points.")
|
"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}
|
{NULL, NULL, 0, NULL}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -757,6 +793,53 @@ PyObject* PyInit_mcrfpy()
|
||||||
// - line() functionality replaced by mcrfpy.bresenham()
|
// - line() functionality replaced by mcrfpy.bresenham()
|
||||||
// - compute_fov() redundant with Grid.compute_fov()
|
// - 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;
|
//McRFPy_API::mcrf_module = m;
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,18 @@ int get_python_globals_count() {
|
||||||
return (int)PyDict_Size(main_dict);
|
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)
|
// Notify the engine that the browser canvas was resized (called from JS)
|
||||||
EMSCRIPTEN_KEEPALIVE
|
EMSCRIPTEN_KEEPALIVE
|
||||||
void notify_canvas_resize(int width, int height) {
|
void notify_canvas_resize(int width, int height) {
|
||||||
|
|
|
||||||
|
|
@ -673,6 +673,18 @@
|
||||||
// ===========================================
|
// ===========================================
|
||||||
|
|
||||||
var Module = {
|
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() {
|
print: (function() {
|
||||||
return function(text) {
|
return function(text) {
|
||||||
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
|
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,18 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
var Module = {
|
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) {
|
print: function(text) {
|
||||||
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
|
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
|
||||||
console.log(text);
|
console.log(text);
|
||||||
|
|
|
||||||
81
tests/unit/save_dir_test.py
Normal file
81
tests/unit/save_dir_test.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue