Adds a capture -> reload -> restore pipeline so code/asset changes can be hot-applied without resetting running state: - common: StateValue (JSON-compatible state tree) + IStateProvider capability interface. - devtools: Snapshot v2 (script roots + native provider section, v1 compatible, strict rejection of non-data roots), ReloadSession (begin/commit/abort), eve.dev script API (stateRoots / beginStateReload / commitStateReload / abortStateReload). - load.nut: three-phase soft reload (capture -> dofile -> restore) with migrate_instance / remap_instances helpers and eve_before_reload / eve_after_reload hooks. - dialogue + AnimStateMachine: IStateProvider for phase/vars/rng and params/current state. - animation: AnimClip::adopt + AnimClipRegistry + 'animclip' IAssetReloader so .eva content reloads keep clip identity. - rpg + card: IStateProvider for casting/cooldowns and card phases. - Tests: stateValue, snapshot v2/strict, reload script protocol, dialogue, animation state machine + reload, rpg/card state roundtrips.
198 lines
6.0 KiB
C++
198 lines
6.0 KiB
C++
#include "zeroerr/assert.h"
|
|
#include "zeroerr/unittest.h"
|
|
|
|
#include "common/Module.h"
|
|
#include "devtools/DevTool.hpp"
|
|
|
|
#include <simplesquirrel/simplesquirrel.hpp>
|
|
|
|
namespace {
|
|
|
|
const char* kReloadScript = R"(
|
|
// Mirror of the load.nut migrate_instance helper (tables fully; class
|
|
// instances via their declared members).
|
|
function migrate_inst(old, NewClass) {
|
|
local n = NewClass();
|
|
if (old == null) return n;
|
|
if (typeof old == "table") {
|
|
foreach (k, v in old) {
|
|
try { n[k] <- v; } catch (e) {}
|
|
}
|
|
return n;
|
|
}
|
|
if (typeof old != "instance") return n;
|
|
foreach (k, v in getclass(old)) {
|
|
if (typeof v == "function") continue;
|
|
try { n[k] <- old[k]; } catch (e) {}
|
|
}
|
|
return n;
|
|
}
|
|
|
|
function remap_inst(container, NewClass) {
|
|
if (typeof container == "array") {
|
|
for (local i = 0; i < container.len(); ++i) {
|
|
container[i] = migrate_inst(container[i], NewClass);
|
|
}
|
|
} else if (typeof container == "table") {
|
|
local keys = [];
|
|
foreach (k, v in container) keys.append(k);
|
|
foreach (k in keys) {
|
|
container[k] = migrate_inst(container[k], NewClass);
|
|
}
|
|
}
|
|
return container;
|
|
}
|
|
|
|
function reloadProtocolKeepsOldValuesAndNewFields() {
|
|
eve.dev.clearStateRoots();
|
|
getroottable().gameState <- { level = 3, hero = "alice" };
|
|
eve.dev.markStateRoot("gameState");
|
|
local err = eve.dev.beginStateReload();
|
|
if (err != "") return false;
|
|
// Simulate a script reload: the definition is replaced by new defaults.
|
|
getroottable().gameState <- { level = 0, hero = "nobody", quest = "intro" };
|
|
err = eve.dev.commitStateReload();
|
|
if (err != "") return false;
|
|
return gameState.level == 3 && gameState.hero == "alice" &&
|
|
gameState.quest == "intro";
|
|
}
|
|
|
|
function reloadProtocolRestoresMissingRoot() {
|
|
eve.dev.clearStateRoots();
|
|
getroottable().gameState <- { level = 7 };
|
|
eve.dev.markStateRoot("gameState");
|
|
local err = eve.dev.beginStateReload();
|
|
if (err != "") return false;
|
|
// The new script no longer defines the root.
|
|
delete getroottable().gameState;
|
|
err = eve.dev.commitStateReload();
|
|
if (err != "") return false;
|
|
return ("gameState" in getroottable()) && gameState.level == 7;
|
|
}
|
|
|
|
function reloadAbortRollsBack() {
|
|
eve.dev.clearStateRoots();
|
|
getroottable().gameState <- { hp = 50 };
|
|
eve.dev.markStateRoot("gameState");
|
|
local err = eve.dev.beginStateReload();
|
|
if (err != "") return false;
|
|
getroottable().gameState <- { hp = 1 };
|
|
err = eve.dev.abortStateReload();
|
|
if (err != "") return false;
|
|
return gameState.hp == 50;
|
|
}
|
|
|
|
function migrateInstanceCopiesFieldsAndKeepsDefaults() {
|
|
class HeroV1 {
|
|
level = 1;
|
|
name = "?";
|
|
}
|
|
class HeroV2 {
|
|
level = 1;
|
|
name = "?";
|
|
title = "novice";
|
|
}
|
|
local old = HeroV1();
|
|
old.level = 5;
|
|
old.name = "alice";
|
|
local n = migrate_inst(old, HeroV2);
|
|
return n.level == 5 && n.name == "alice" && n.title == "novice";
|
|
}
|
|
|
|
function migrateInstanceFromTable() {
|
|
class HeroV2 {
|
|
level = 1;
|
|
name = "?";
|
|
title = "novice";
|
|
}
|
|
local t = { level = 9, name = "bob" };
|
|
local n = migrate_inst(t, HeroV2);
|
|
return n.level == 9 && n.name == "bob" && n.title == "novice";
|
|
}
|
|
|
|
function remapInstancesContainer() {
|
|
class ItemV1 {
|
|
id = "?";
|
|
}
|
|
class ItemV2 {
|
|
id = "?";
|
|
tag = "new";
|
|
}
|
|
local arr = [ItemV1(), ItemV1()];
|
|
arr[0].id = "a";
|
|
arr[1].id = "b";
|
|
remap_inst(arr, ItemV2);
|
|
return arr[0].id == "a" && arr[0].tag == "new" && arr[1].id == "b" &&
|
|
arr[1].tag == "new";
|
|
}
|
|
|
|
function stateRootsReportsMarkedAndHeuristic() {
|
|
eve.dev.clearStateRoots();
|
|
eve.dev.markStateRoot("customState");
|
|
local roots = eve.dev.stateRoots();
|
|
if (roots.len() != 1 || roots[0] != "customState") return false;
|
|
|
|
eve.dev.clearStateRoots();
|
|
getroottable().myGame <- { x = 1 };
|
|
local found = false;
|
|
foreach (r in eve.dev.stateRoots()) {
|
|
if (r == "myGame") found = true;
|
|
}
|
|
return found;
|
|
}
|
|
)";
|
|
|
|
/** ScriptTest variant that also exposes `eve.dev` (DevTool script API). */
|
|
class ReloadScriptTest {
|
|
public:
|
|
explicit ReloadScriptTest(const char* script) : vm(2048, ssq::Libs::ALL) {
|
|
eve::ModuleManager::expose(vm);
|
|
eve::dev::DevTool::instance().attach(vm);
|
|
eve::dev::DevTool::instance().exposeScriptApi(vm);
|
|
ssq::Script s = vm.compileSource(script);
|
|
vm.run(s);
|
|
}
|
|
|
|
~ReloadScriptTest() { eve::dev::DevTool::instance().detach(); }
|
|
|
|
ssq::VM vm;
|
|
};
|
|
|
|
#define UnitReloadScriptTest(name, content) \
|
|
class name : public ReloadScriptTest { \
|
|
public: \
|
|
name() : ReloadScriptTest(content) {} \
|
|
}
|
|
|
|
UnitReloadScriptTest(ReloadScriptTestFixture, kReloadScript);
|
|
|
|
TEST_CASE_FIXTURE(ReloadScriptTestFixture, "reloadScript.protocolKeepsOldValuesAndNewFields") {
|
|
CHECK(vm.callFunc(vm.findFunc("reloadProtocolKeepsOldValuesAndNewFields"), vm).toBool());
|
|
}
|
|
|
|
TEST_CASE_FIXTURE(ReloadScriptTestFixture, "reloadScript.protocolRestoresMissingRoot") {
|
|
CHECK(vm.callFunc(vm.findFunc("reloadProtocolRestoresMissingRoot"), vm).toBool());
|
|
}
|
|
|
|
TEST_CASE_FIXTURE(ReloadScriptTestFixture, "reloadScript.abortRollsBack") {
|
|
CHECK(vm.callFunc(vm.findFunc("reloadAbortRollsBack"), vm).toBool());
|
|
}
|
|
|
|
TEST_CASE_FIXTURE(ReloadScriptTestFixture, "reloadScript.migrateInstanceCopiesFieldsAndKeepsDefaults") {
|
|
CHECK(vm.callFunc(vm.findFunc("migrateInstanceCopiesFieldsAndKeepsDefaults"), vm).toBool());
|
|
}
|
|
|
|
TEST_CASE_FIXTURE(ReloadScriptTestFixture, "reloadScript.migrateInstanceFromTable") {
|
|
CHECK(vm.callFunc(vm.findFunc("migrateInstanceFromTable"), vm).toBool());
|
|
}
|
|
|
|
TEST_CASE_FIXTURE(ReloadScriptTestFixture, "reloadScript.remapInstancesContainer") {
|
|
CHECK(vm.callFunc(vm.findFunc("remapInstancesContainer"), vm).toBool());
|
|
}
|
|
|
|
TEST_CASE_FIXTURE(ReloadScriptTestFixture, "reloadScript.stateRootsReportsMarkedAndHeuristic") {
|
|
CHECK(vm.callFunc(vm.findFunc("stateRootsReportsMarkedAndHeuristic"), vm).toBool());
|
|
}
|
|
|
|
} // namespace
|