Add optional Lua scripting module, examples, and validation
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/build_service.hpp>
|
||||
#include <faset/editor/commands.hpp>
|
||||
#include <faset/scripting/project.hpp>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
|
||||
@@ -154,6 +155,12 @@ int test_main(int argc, char** argv) {
|
||||
const auto previous_player = sha256_file(player);
|
||||
const auto previous_schema = read_text(schema);
|
||||
const auto previous_manifest = read_text(directory / "manifest.json");
|
||||
check(!first.result.at("lua_enabled").get<bool>() &&
|
||||
!fs::exists(directory / "project.faset.json"),
|
||||
"C++-only build publishes no Lua sources or project manifest");
|
||||
check(read_json(config.project_root / "configure-fixture.json").back() ==
|
||||
"-DFASET_ENABLE_LUA=OFF",
|
||||
"C++-only build explicitly disables the Lua VM in CMake");
|
||||
authoring.replace_external_schemas(read_json(schema));
|
||||
const auto previous_registry = authoring.schemas().manifest();
|
||||
check(authoring.schemas().schema("game.mover").at("version") == 2,
|
||||
@@ -251,8 +258,96 @@ int test_main(int argc, char** argv) {
|
||||
check(rejected && authoring.schemas().manifest() == previous_registry,
|
||||
"Cached-schema validation uses the same contract and preserves the registry");
|
||||
}
|
||||
atomic_write_json(config.project_root / "schema-fixture.json", valid);
|
||||
auto project = read_json(config.project_root / "project.faset.json");
|
||||
project["scripting"]["lua"]["scripts"] = Json::array({"Scripts/main.lua"});
|
||||
atomic_write_json(config.project_root / "project.faset.json", project);
|
||||
const auto lua_source =
|
||||
"return faset.behavior { id = 'game.mover', version = 2, fields = {} }\n";
|
||||
atomic_write(config.project_root / "Scripts/main.lua", lua_source);
|
||||
atomic_write(config.project_root / "Scripts/lib/util.lua", "return {value = 1}\n");
|
||||
fs::remove(config.project_root / "Scripts/Gameplay.cpp");
|
||||
fs::remove(config.project_root / "Scripts/Gameplay.hpp");
|
||||
const auto lua_build = builds.wait(builds.start_build());
|
||||
check(lua_build.state == "succeeded" && lua_build.result.at("lua_enabled") == true,
|
||||
"Lua-only project builds without a Gameplay.cpp/Gameplay.hpp pair: " +
|
||||
lua_build.error);
|
||||
check(read_json(config.project_root / "configure-fixture.json").back() ==
|
||||
"-DFASET_ENABLE_LUA=ON",
|
||||
"Lua declaration enables the module in the native Player");
|
||||
const auto lua_directory =
|
||||
path_from_utf8(lua_build.result.at("directory").get<std::string>());
|
||||
const auto captured = scripting::loadLuaProject(lua_directory);
|
||||
check(captured.enabled() && captured.sources.size() == 2 &&
|
||||
captured.fingerprint ==
|
||||
lua_build.result.at("lua_fingerprint").get<std::string>() &&
|
||||
read_json(path_from_utf8(lua_build.result.at("schema").get<std::string>()))
|
||||
.at("lua_fingerprint")
|
||||
.get<std::string>() == captured.fingerprint,
|
||||
"Immutable source snapshot and merged schema share a Lua fingerprint");
|
||||
check(read_json(config.project_root / "exporter-project-fixture.json").at("scripting") ==
|
||||
project.at("scripting"),
|
||||
"Schema exporter receives the captured project's Lua entries");
|
||||
atomic_write(config.project_root / "Scripts/lib/util.lua", "return {value = 2}\n");
|
||||
const auto changed = builds.wait(builds.start_build());
|
||||
check(
|
||||
changed.state == "succeeded" &&
|
||||
changed.result.at("fingerprint") != lua_build.result.at("fingerprint") &&
|
||||
changed.result.at("lua_fingerprint") != lua_build.result.at("lua_fingerprint"),
|
||||
"Module-only Lua edits produce new build provenance without changing native fixtures");
|
||||
check(scripting::loadLuaProject(lua_directory).fingerprint == captured.fingerprint,
|
||||
"Later edits never mutate an already published Lua generation");
|
||||
const auto lua_pointer = read_text(last_build);
|
||||
for (const auto* marker : {"mutate-lua-during-build", "mutate-lua-snapshot"}) {
|
||||
atomic_write(config.project_root / marker, "fixture\n");
|
||||
const auto raced = builds.wait(builds.start_build());
|
||||
check(raced.state == "failed" && read_text(last_build) == lua_pointer,
|
||||
"Source or snapshot changes during schema export preserve the last good build");
|
||||
fs::remove(config.project_root / marker);
|
||||
atomic_write(config.project_root / "Scripts/main.lua", lua_source);
|
||||
}
|
||||
const auto exported = builds.wait(builds.start_export(scene, root / "lua-export"));
|
||||
check(exported.state == "succeeded",
|
||||
"Lua export publishes a captured source package: " + exported.error);
|
||||
const auto packaged = path_from_utf8(exported.result.at("directory").get<std::string>());
|
||||
const auto package_manifest = read_json(packaged / "manifest.json");
|
||||
check(scripting::loadLuaProject(packaged).fingerprint ==
|
||||
package_manifest.at("lua_fingerprint").get<std::string>() &&
|
||||
package_manifest.at("lua_enabled") == true &&
|
||||
read_json(packaged / "Notices/dependencies.json").contains("lua") &&
|
||||
fs::is_regular_file(packaged / "Notices/lua/LICENSE.txt"),
|
||||
"Export contains source, fingerprint and the selected Lua runtime's license");
|
||||
check(!fs::exists(packaged / "schema.json") &&
|
||||
!fs::exists(packaged / "faset_schema_exporter") &&
|
||||
!fs::exists(packaged / "faset_schema_exporter.exe") &&
|
||||
!fs::exists(packaged / ".luarc.json") &&
|
||||
!fs::exists(packaged / "Scripts/Gameplay.cpp"),
|
||||
"Runtime export omits schema tools, editor configuration and C++ source");
|
||||
std::size_t packaged_sources{};
|
||||
for (const auto& file : package_manifest.at("files"))
|
||||
if (file.at("path").get<std::string>().starts_with("Scripts/")) {
|
||||
++packaged_sources;
|
||||
check(sha256_file(packaged / path_from_utf8(file.at("path").get<std::string>())) ==
|
||||
file.at("sha256").get<std::string>(),
|
||||
"Every packaged source hash matches the export manifest");
|
||||
}
|
||||
check(packaged_sources == 2, "Entry and require module both appear in export provenance");
|
||||
project.erase("scripting");
|
||||
atomic_write_json(config.project_root / "project.faset.json", project);
|
||||
atomic_write(config.project_root / "Scripts/Gameplay.cpp", "// C++ fixture\n");
|
||||
atomic_write(config.project_root / "Scripts/Gameplay.hpp", "// C++ fixture\n");
|
||||
const auto cpp_export = builds.wait(builds.start_export(scene, root / "cpp-export"));
|
||||
check(cpp_export.state == "succeeded", "C++ export still succeeds: " + cpp_export.error);
|
||||
const auto cpp_package =
|
||||
path_from_utf8(cpp_export.result.at("directory").get<std::string>());
|
||||
check(!fs::exists(cpp_package / "Scripts") &&
|
||||
!fs::exists(cpp_package / "project.faset.json") &&
|
||||
!read_json(cpp_package / "Notices/dependencies.json").contains("lua") &&
|
||||
read_json(config.project_root / "configure-fixture.json").back() ==
|
||||
"-DFASET_ENABLE_LUA=OFF",
|
||||
"Removing Lua declarations drops scripts, notices and the Lua link dependency");
|
||||
std::cout << "Valid v2 schema and atomic rejection of " << invalid.size()
|
||||
<< " malformed metadata generations passed\n";
|
||||
<< " malformed metadata generations; Lua snapshots and export contracts passed\n";
|
||||
fs::remove_all(root);
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
|
||||
@@ -7,10 +7,33 @@ namespace fs = std::filesystem;
|
||||
using namespace faset;
|
||||
int tool_main(int argc, char** argv) {
|
||||
try {
|
||||
if (argc == 3 && std::string_view(argv[1]) == "--output") {
|
||||
if (argc >= 3 && std::string_view(argv[1]) == "--output") {
|
||||
if (argc == 5 && std::string_view(argv[3]) == "--project") {
|
||||
const auto snapshot = path_from_utf8(argv[4]);
|
||||
const auto project = read_json(snapshot / "project.faset.json");
|
||||
for (const auto& name : project.at("scripting").at("lua").at("scripts"))
|
||||
if (!fs::is_regular_file(snapshot / path_from_utf8(name.get<std::string>())))
|
||||
throw std::runtime_error(
|
||||
"Schema exporter did not receive captured sources");
|
||||
atomic_write_json("exporter-project-fixture.json", project);
|
||||
if (fs::exists("mutate-lua-during-build"))
|
||||
atomic_write("Scripts/main.lua", "-- changed while schema was exporting\n");
|
||||
if (fs::exists("mutate-lua-snapshot"))
|
||||
atomic_write(snapshot / "Scripts/main.lua", "-- corrupt snapshot\n");
|
||||
}
|
||||
atomic_write_json(path_from_utf8(argv[2]), read_json("schema-fixture.json"));
|
||||
return 0;
|
||||
}
|
||||
if (argc >= 2 && std::string_view(argv[1]) == "--validate") {
|
||||
if (fs::exists("project.faset.json")) {
|
||||
const auto manifest = read_json("project.faset.json");
|
||||
for (const auto& name : manifest.at("scripting").at("lua").at("scripts"))
|
||||
if (!fs::is_regular_file(path_from_utf8(name.get<std::string>())))
|
||||
throw std::runtime_error("Packaged Player is missing a Lua entry source");
|
||||
}
|
||||
std::cout << "Native packaging fixture validated\n";
|
||||
return 0;
|
||||
}
|
||||
if (argc > 2 && std::string_view(argv[1]) == "--build")
|
||||
return 0;
|
||||
fs::path build;
|
||||
@@ -19,8 +42,15 @@ int tool_main(int argc, char** argv) {
|
||||
build = path_from_utf8(argv[i + 1]);
|
||||
if (build.empty())
|
||||
throw std::runtime_error("Fixture expects CMake configure or SchemaExporter arguments");
|
||||
Json arguments = Json::array();
|
||||
for (int i = 1; i < argc; ++i)
|
||||
arguments.push_back(argv[i]);
|
||||
atomic_write_json("configure-fixture.json", arguments);
|
||||
fs::create_directories(build / "shaders");
|
||||
atomic_write(build / "CMakeCache.txt", "Native schema publication fixture\n");
|
||||
for (const auto* name : {"sdl3", "entt", "box2d", "box3d", "json", "stb"})
|
||||
atomic_write(build / "_deps" / (std::string(name) + "-src") / "LICENSE.txt",
|
||||
"Synthetic dependency notice for packaging tests only.\n");
|
||||
const auto self = fs::absolute(path_from_utf8(argv[0]));
|
||||
#ifdef _WIN32
|
||||
constexpr auto suffix = ".exe";
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/process.hpp>
|
||||
#include <faset/editor/build_service.hpp>
|
||||
#include <faset/scripting/project.hpp>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#ifndef _WIN32
|
||||
@@ -41,6 +42,87 @@ Json scene(int dimension) {
|
||||
{"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()},
|
||||
{"instances", Json::array()}};
|
||||
}
|
||||
void lua_project_contracts(const fs::path& root) {
|
||||
fs::create_directories(root);
|
||||
require(!scripting::loadLuaProject(root).enabled(), "No manifest means no Lua dependency");
|
||||
Json manifest{{"format", "faset.project"}, {"version", 1}};
|
||||
const auto save_manifest = [&] { atomic_write_json(root / "project.faset.json", manifest); };
|
||||
save_manifest();
|
||||
require(!scripting::loadLuaProject(root).enabled(), "C++ manifest requires no Lua sources");
|
||||
atomic_write(root / "Scripts/main.lua", "return {value = 1}\n");
|
||||
atomic_write(root / "Scripts/lib/util.lua", "return {answer = 42}\n");
|
||||
atomic_write(root / "Scripts/Gameplay.cpp", "// Not a Lua module\n");
|
||||
manifest["scripting"]["lua"]["scripts"] = Json::array({"Scripts/main.lua"});
|
||||
save_manifest();
|
||||
const auto original = scripting::loadLuaProject(root);
|
||||
require(original.enabled() && original.sources.size() == 2 && original.fingerprint.size() == 64,
|
||||
"Capture entry script and transitive module candidates, not C++");
|
||||
require(scripting::loadLuaProject(root).fingerprint == original.fingerprint,
|
||||
"Lua fingerprint is deterministic");
|
||||
atomic_write(root / "Scripts/lib/util.lua", "return {answer = 43}\n");
|
||||
require(scripting::loadLuaProject(root).fingerprint != original.fingerprint,
|
||||
"Changes to non-entry require modules invalidate the Lua fingerprint");
|
||||
const auto target = root.parent_path() / "lua-snapshot";
|
||||
scripting::writeLuaSources(original, target);
|
||||
atomic_write_json(target / "project.faset.json", manifest);
|
||||
require(scripting::loadLuaProject(target).fingerprint == original.fingerprint &&
|
||||
read_text(target / "Scripts/lib/util.lua") == "return {answer = 42}\n",
|
||||
"Publishing writes captured bytes rather than rereading live scripts");
|
||||
const auto valid_manifest = manifest;
|
||||
auto rejected = [&](const auto& operation) {
|
||||
try {
|
||||
operation();
|
||||
} catch (const std::exception&) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const auto& entries :
|
||||
{Json::array({"Scripts/main.lua", "Scripts/main.lua"}),
|
||||
Json::array({"Scripts/../main.lua"}), Json::array({"Scripts/missing.lua"}),
|
||||
Json::array({"Scripts/Gameplay.cpp"}), Json::array({"/Scripts/main.lua"}),
|
||||
Json::array({"Scripts\\main.lua"}), Json::array({"Scripts/./main.lua"}),
|
||||
Json::array({"Scripts//main.lua"}), Json::array({"C:/Scripts/main.lua"}),
|
||||
Json::array({12}), Json("Scripts/main.lua")}) {
|
||||
manifest["scripting"]["lua"]["scripts"] = entries;
|
||||
save_manifest();
|
||||
require(rejected([&] { (void)scripting::loadLuaProject(root); }),
|
||||
"Lua rejects malformed, duplicate, missing and escaping entry paths");
|
||||
}
|
||||
manifest = valid_manifest;
|
||||
manifest["scripting"]["lua"]["scripts"].push_back("Scripts/lib/util.lua");
|
||||
save_manifest();
|
||||
const auto with_second_entry = scripting::loadLuaProject(root).fingerprint;
|
||||
manifest["scripting"]["lua"]["scripts"] =
|
||||
Json::array({"Scripts/lib/util.lua", "Scripts/main.lua"});
|
||||
save_manifest();
|
||||
require(scripting::loadLuaProject(root).fingerprint != with_second_entry,
|
||||
"Entry order is part of the source fingerprint");
|
||||
manifest = valid_manifest;
|
||||
save_manifest();
|
||||
atomic_write(root / "Scripts/too-large.lua", std::string(1024 * 1024 + 1, ' '));
|
||||
require(rejected([&] { (void)scripting::loadLuaProject(root); }),
|
||||
"Source size is bounded before code execution");
|
||||
fs::remove(root / "Scripts/too-large.lua");
|
||||
auto corrupt = original;
|
||||
corrupt.sources["../outside.lua"] = "return {}";
|
||||
require(rejected([&] { scripting::writeLuaSources(corrupt, target); }),
|
||||
"Writing an externally supplied snapshot validates paths too");
|
||||
std::error_code symlink_error;
|
||||
fs::create_symlink(root / "Scripts/main.lua", root / "Scripts/link.lua", symlink_error);
|
||||
if (!symlink_error) {
|
||||
require(rejected([&] { (void)scripting::loadLuaProject(root); }),
|
||||
"Lua source snapshots reject even in-project symlink aliases");
|
||||
fs::remove(root / "Scripts/link.lua");
|
||||
fs::create_directory_symlink(root / "Scripts", target / "linked", symlink_error);
|
||||
if (!symlink_error)
|
||||
require(rejected([&] { scripting::writeLuaSources(original, target / "linked"); }),
|
||||
"Snapshot destination root cannot be a symlink");
|
||||
}
|
||||
manifest["scripting"]["lua"]["scripts"] = Json::array();
|
||||
save_manifest();
|
||||
require(!scripting::loadLuaProject(root).enabled(), "Empty Lua declaration links no Lua VM");
|
||||
}
|
||||
int integration(const fs::path& root) {
|
||||
fs::create_directories(root);
|
||||
editor::BuildConfig config;
|
||||
@@ -296,6 +378,7 @@ int test_main(int argc, char** argv) {
|
||||
fs::path temporary = fs::temp_directory_path() / path_from_utf8("Faset Café 世界 " + new_id());
|
||||
try {
|
||||
fs::create_directories(temporary);
|
||||
lua_project_contracts(temporary / "lua-project");
|
||||
const auto original_executable = fs::absolute(path_from_utf8(argv[0]));
|
||||
const auto executable = temporary / original_executable.filename();
|
||||
fs::copy_file(original_executable, executable);
|
||||
|
||||
+54
-2
@@ -1,15 +1,28 @@
|
||||
#include <chrono>
|
||||
#include <faset/core/error.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/process.hpp>
|
||||
#include <iostream>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
|
||||
#define CHECK(x) \
|
||||
do { \
|
||||
if (!(x)) \
|
||||
throw std::runtime_error("Check failed: " #x); \
|
||||
} while (false)
|
||||
int main() {
|
||||
int test_main(int argc, char** argv) {
|
||||
if (argc > 2 && std::string(argv[1]) == "--detached-child") {
|
||||
faset::Json arguments = faset::Json::array();
|
||||
for (int i = 3; i < argc; ++i)
|
||||
arguments.push_back(argv[i]);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
faset::atomic_write_json(
|
||||
faset::path_from_utf8(argv[2]),
|
||||
{{"args", arguments}, {"cwd", faset::path_to_utf8(std::filesystem::current_path())}});
|
||||
return 0;
|
||||
}
|
||||
const auto directory =
|
||||
std::filesystem::temp_directory_path() / ("faset-core-" + faset::new_id());
|
||||
try {
|
||||
@@ -85,9 +98,39 @@ int main() {
|
||||
rejected = true;
|
||||
}
|
||||
CHECK(rejected);
|
||||
const auto external_result = directory / faset::path_from_utf8("Editor 世界.json");
|
||||
const std::vector<std::string> arguments = {
|
||||
faset::path_to_utf8(std::filesystem::absolute(faset::path_from_utf8(argv[0]))),
|
||||
"--detached-child",
|
||||
faset::path_to_utf8(external_result),
|
||||
"space argument",
|
||||
"quote\"backslash\\",
|
||||
"$(touch not-executed); & |",
|
||||
"",
|
||||
"Café 世界 Привет 😀"};
|
||||
faset::launch_detached(arguments, directory);
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
||||
while (!std::filesystem::exists(external_result) &&
|
||||
std::chrono::steady_clock::now() < deadline)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
CHECK(std::filesystem::is_regular_file(external_result));
|
||||
const auto external = faset::read_json(external_result);
|
||||
CHECK(external.at("args") ==
|
||||
faset::Json::array({"space argument", "quote\"backslash\\",
|
||||
"$(touch not-executed); & |", "", "Café 世界 Привет 😀"}));
|
||||
CHECK(std::filesystem::equivalent(
|
||||
faset::path_from_utf8(external.at("cwd").get<std::string>()), directory));
|
||||
CHECK(!std::filesystem::exists(directory / "not-executed"));
|
||||
rejected = false;
|
||||
try {
|
||||
faset::launch_detached({"faset-nonexistent-editor-" + faset::new_id()}, directory);
|
||||
} catch (const std::exception&) {
|
||||
rejected = true;
|
||||
}
|
||||
CHECK(rejected);
|
||||
std::filesystem::remove_all(faset::native_io_path(directory));
|
||||
std::cout << "Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path "
|
||||
"boundaries passed\n";
|
||||
"boundaries and detached literal-argv launch passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::filesystem::remove_all(faset::native_io_path(directory));
|
||||
@@ -95,3 +138,12 @@ int main() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
#ifdef _WIN32
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
return faset::run_utf8_main(argc, argv, test_main);
|
||||
}
|
||||
#else
|
||||
int main(int argc, char** argv) {
|
||||
return test_main(argc, argv);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -55,6 +55,8 @@ int main() {
|
||||
}
|
||||
auto external = session.project();
|
||||
external["custom_tool"] = {{"keep", true}};
|
||||
external["scripting"] = {{"lua", {{"scripts", Json::array()}}}};
|
||||
external["editor"] = {{"script_editor", {"zed", "{file}"}}};
|
||||
atomic_write_json(root / "project.faset.json", external);
|
||||
rejects({{"revision", changed.at("revision")}, {"settings", {{"name", "Race"}}}},
|
||||
"revision.conflict");
|
||||
@@ -64,6 +66,48 @@ int main() {
|
||||
{"settings", {{"name", "Preserved"}}}});
|
||||
require(changed.at("settings").at("custom_tool").at("keep") == true, "test",
|
||||
"Saving settings erased unknown project metadata");
|
||||
require(changed.at("settings").at("scripting") == external.at("scripting") &&
|
||||
changed.at("settings").at("editor") == external.at("editor"),
|
||||
"test", "Project settings erased Lua configuration or external editor command");
|
||||
const auto setup = commands.call("faset_lua_setup", Json::object());
|
||||
require(setup.at("configuration_created") == true &&
|
||||
std::filesystem::is_regular_file(root / ".faset/lua/faset.lua") &&
|
||||
read_json(root / ".luarc.json").at("runtime.version") == "Lua 5.4",
|
||||
"test", "LuaLS setup did not install annotations and configuration");
|
||||
const Json custom_luarc = {{"runtime.version", "Lua 5.4"},
|
||||
{"workspace.library", {"Custom/Lua"}},
|
||||
{"custom_setting", true}};
|
||||
atomic_write_json(root / ".luarc.json", custom_luarc);
|
||||
require(commands.call("faset_lua_setup", Json::object()).at("configuration_created") ==
|
||||
false &&
|
||||
read_json(root / ".luarc.json") == custom_luarc,
|
||||
"test", "LuaLS setup overwrote the user's configuration");
|
||||
auto reject_command = [&](const std::string& name, const Json& args,
|
||||
const std::string& code) {
|
||||
try {
|
||||
commands.call(name, args);
|
||||
} catch (const Error& error) {
|
||||
require(error.json().at("code") == code, "test",
|
||||
"Unexpected error for invalid Lua editor command");
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("Invalid Lua editor command was accepted");
|
||||
};
|
||||
reject_command("faset_lua_refresh", Json::object(), "lua.disabled");
|
||||
reject_command("faset_lua_reload", Json::object(), "play.not_running");
|
||||
reject_command("faset_script_open", {{"path", "Scripts/Gameplay.cpp"}}, "lua.source");
|
||||
atomic_write(root / "Scripts/behavior.lua", "return faset.behavior { id = 'game.test' }\n");
|
||||
reject_command("faset_script_open",
|
||||
{{"path", "Scripts/behavior.lua"}, {"editor", {"{file}"}}}, "lua.editor");
|
||||
reject_command("faset_script_open", {{"path", "Scripts/behavior.lua"}, {"editor", {""}}},
|
||||
"lua.editor");
|
||||
auto bad_lua_project = session.project();
|
||||
bad_lua_project["scripting"] = {{"lua", {{"scripts", {"Scripts/missing.lua"}}}}};
|
||||
atomic_write_json(root / "project.faset.json", bad_lua_project);
|
||||
const auto invalid_lua_status = commands.call("faset_schema_status", Json::object());
|
||||
require(invalid_lua_status.at("stale") == true &&
|
||||
!invalid_lua_status.at("error").get<std::string>().empty(),
|
||||
"test", "Invalid Lua manifest did not mark schema stale with diagnostics");
|
||||
external["version"] = 999;
|
||||
atomic_write_json(root / "project.faset.json", external);
|
||||
bool rejected = false;
|
||||
@@ -74,8 +118,8 @@ int main() {
|
||||
}
|
||||
require(rejected, "test", "Future project version was reinterpreted");
|
||||
std::filesystem::remove_all(root);
|
||||
std::cout << "Project settings validation, save, external revision conflicts and opaque "
|
||||
"metadata passed\n";
|
||||
std::cout << "Project settings validation, revision conflicts, opaque metadata, LuaLS "
|
||||
"configuration preservation and Lua editor commands passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << "\nFixture retained at " << root << '\n';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""CPU-only schema-export and Player validation contracts for optional Lua."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--exporter", required=True)
|
||||
parser.add_argument("--player")
|
||||
parser.add_argument("--disabled", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def run(executable, *arguments, success=True):
|
||||
result = subprocess.run([executable, *map(str, arguments)], capture_output=True,
|
||||
text=True, encoding="utf-8", timeout=20)
|
||||
assert (result.returncode == 0) == success, (result.stdout, result.stderr)
|
||||
return result
|
||||
|
||||
|
||||
def behavior(type_id="test.lua.cli", callback='error("LUA_CALLBACK_MUST_NOT_RUN")'):
|
||||
return f'''local behavior = faset.behavior {{
|
||||
id = "{type_id}", version = 1, name = "CLI behavior",
|
||||
fields = {{ speed = {{ type = "number", default = 3, min = 0 }} }}
|
||||
}}
|
||||
function behavior:on_start() {callback} end
|
||||
return behavior
|
||||
'''
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="faset-lua-cli-") as temporary:
|
||||
root = Path(temporary) / "Lua Café 世界"
|
||||
scripts = root / "Scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
manifest = root / "project.faset.json"
|
||||
manifest.write_text(json.dumps({"format": "faset.project", "version": 1}), encoding="utf-8")
|
||||
native = json.loads(run(args.exporter).stdout)
|
||||
assert json.loads(run(args.exporter, "--project", root).stdout) == native
|
||||
manifest.write_text(json.dumps({"format": "faset.project", "version": 1,
|
||||
"scripting": {"lua": {"scripts": ["Scripts/cli.lua"]}}}),
|
||||
encoding="utf-8")
|
||||
source = scripts / "cli.lua"
|
||||
source.write_text(behavior(), encoding="utf-8")
|
||||
if args.disabled:
|
||||
result = run(args.exporter, "--project", root, success=False)
|
||||
assert "without Lua support" in result.stderr, result.stderr
|
||||
if args.player:
|
||||
run(args.player, "--help")
|
||||
print("Lua-disabled schema exporter accepts C++ projects and rejects Lua clearly")
|
||||
else:
|
||||
exported = root / "schema.json"
|
||||
run(args.exporter, "--project", root, "--output", exported)
|
||||
merged = json.loads(exported.read_text(encoding="utf-8"))
|
||||
assert merged["format"] == "faset.schema" and merged["version"] == 1
|
||||
by_id = {item["id"]: item for item in merged["types"]}
|
||||
assert len(by_id) == len(native["types"]) + 1
|
||||
assert by_id["test.lua.cli"]["fields"]["speed"]["default"] == 3
|
||||
for item in native["types"]:
|
||||
assert by_id[item["id"]] == item
|
||||
|
||||
# Lua lifecycle errors do not execute in the schema exporter or --validate.
|
||||
scene = root / "scene.scene.json"
|
||||
scene.write_text(json.dumps({
|
||||
"format": "faset.scene", "version": 1, "dimension": 2,
|
||||
"id": "lua-cli-scene", "name": "Lua CLI", "instances": [],
|
||||
"entities": [{"id": "lua-object", "name": "Lua", "parent": None,
|
||||
"components": [{"id": "lua-behavior", "type": "test.lua.cli",
|
||||
"version": 1, "fields": {"speed": 3}}]}]
|
||||
}), encoding="utf-8")
|
||||
if args.player:
|
||||
for project_arguments in [("--project", root), ()]:
|
||||
result = run(args.player, "--scene", scene, "--validate", *project_arguments)
|
||||
assert json.loads(result.stdout)["validated"]
|
||||
assert "LUA_CALLBACK_MUST_NOT_RUN" not in result.stderr
|
||||
valid_scene = scene.read_text(encoding="utf-8")
|
||||
invalid_scene = json.loads(valid_scene)
|
||||
for invalid_speed in ["fast", -1]:
|
||||
invalid_scene["entities"][0]["components"][0]["fields"]["speed"] = invalid_speed
|
||||
scene.write_text(json.dumps(invalid_scene), encoding="utf-8")
|
||||
result = run(args.player, "--scene", scene, "--validate", "--project", root,
|
||||
success=False)
|
||||
assert "speed" in result.stderr, result.stderr
|
||||
assert "LUA_CALLBACK_MUST_NOT_RUN" not in result.stderr, result.stderr
|
||||
scene.write_text(valid_scene, encoding="utf-8")
|
||||
run(args.player, "--scene", scene, "--validate", "--watch-lua", success=False)
|
||||
|
||||
# A failed schema candidate never overwrites a previously published file.
|
||||
previous = exported.read_bytes()
|
||||
source.write_text("this is invalid Lua!", encoding="utf-8")
|
||||
result = run(args.exporter, "--project", root, "--output", exported, success=False)
|
||||
assert "cli.lua" in result.stderr, result.stderr
|
||||
assert exported.read_bytes() == previous
|
||||
source.write_text(behavior("faset.transform"), encoding="utf-8")
|
||||
run(args.exporter, "--project", root, success=False)
|
||||
if native["types"]:
|
||||
source.write_text(behavior(native["types"][0]["id"]), encoding="utf-8")
|
||||
run(args.exporter, "--project", root, success=False)
|
||||
source.write_text("while true do end", encoding="utf-8")
|
||||
run(args.exporter, "--project", root, success=False)
|
||||
source.write_text(behavior(), encoding="utf-8")
|
||||
assert json.loads(run(args.exporter, "--project", root).stdout) == merged
|
||||
print("Lua schema merge, lifecycle-free validation, failure isolation and budgets passed")
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Production Player reloads Lua atomically and keeps the old world on failure."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def behavior(marker, fail=False):
|
||||
start = 'error("LUA_RELOAD_START_FAILED")' if fail else f'faset.log("LUA_START_{marker}")'
|
||||
return f'''local behavior = faset.behavior {{
|
||||
id = "test.lua.reload", version = 1, name = "Reload test", fields = {{}}
|
||||
}}
|
||||
function behavior:on_start() {start} end
|
||||
function behavior:on_destroy() faset.log("LUA_DESTROY_{marker}") end
|
||||
return behavior
|
||||
'''
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="faset-lua-reload-") as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "Scripts").mkdir()
|
||||
(root / "project.faset.json").write_text(json.dumps({
|
||||
"format": "faset.project", "version": 1,
|
||||
"scripting": {"lua": {"scripts": ["Scripts/reload.lua"]}}
|
||||
}), encoding="utf-8")
|
||||
source = root / "Scripts/reload.lua"
|
||||
source.write_text(behavior("A"), encoding="utf-8")
|
||||
scene = root / "scene.scene.json"
|
||||
scene.write_text(json.dumps({
|
||||
"format": "faset.scene", "version": 1, "id": "reload-scene", "dimension": 2,
|
||||
"entities": [{"id": "object", "name": "Lua", "parent": None,
|
||||
"components": [{"id": "lua", "type": "test.lua.reload",
|
||||
"version": 1, "fields": {}}]}]
|
||||
}), encoding="utf-8")
|
||||
control = root / "control.json"
|
||||
logs = []
|
||||
process = subprocess.Popen([sys.argv[1], "--scene", str(scene), "--project", str(root),
|
||||
"--control", str(control), "--watch-lua", "--headless",
|
||||
"--frames", "10000000"], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, encoding="utf-8")
|
||||
|
||||
def collect():
|
||||
for line in process.stderr:
|
||||
logs.append(line)
|
||||
|
||||
reader = threading.Thread(target=collect, daemon=True)
|
||||
reader.start()
|
||||
|
||||
def wait_for(marker, count=1):
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline:
|
||||
if "".join(logs).count(marker) >= count:
|
||||
return
|
||||
assert process.poll() is None, "".join(logs)
|
||||
time.sleep(.025)
|
||||
raise AssertionError((marker, "".join(logs)))
|
||||
|
||||
def send(sequence, command):
|
||||
staging = root / "control.tmp"
|
||||
staging.write_text(json.dumps({"sequence": sequence, "command": command}),
|
||||
encoding="utf-8")
|
||||
staging.replace(control)
|
||||
|
||||
try:
|
||||
wait_for("LUA_START_A")
|
||||
source.write_text("invalid Lua syntax !", encoding="utf-8")
|
||||
wait_for("Lua reload rejected;")
|
||||
time.sleep(.65)
|
||||
assert "".join(logs).count("Lua reload rejected;") == 1, logs
|
||||
assert "LUA_DESTROY_A" not in "".join(logs), logs
|
||||
source.write_text(behavior("BAD", fail=True), encoding="utf-8")
|
||||
wait_for("LUA_RELOAD_START_FAILED")
|
||||
assert "LUA_DESTROY_A" not in "".join(logs), logs
|
||||
source.write_text(behavior("B"), encoding="utf-8")
|
||||
wait_for("LUA_START_B")
|
||||
assert "".join(logs).count("LUA_DESTROY_A") == 1, logs
|
||||
send(1, "reload-lua")
|
||||
wait_for("LUA_START_B", count=2)
|
||||
assert "".join(logs).count("LUA_DESTROY_B") == 1, logs
|
||||
send(2, "stop")
|
||||
process.wait(timeout=15)
|
||||
reader.join(timeout=2)
|
||||
assert process.returncode == 0, "".join(logs)
|
||||
assert "".join(logs).count("LUA_DESTROY_B") == 2, logs
|
||||
assert json.loads(process.stdout.read())["frames"] > 0
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=3)
|
||||
reader.join(timeout=2)
|
||||
print("Lua Player watched/explicit reload, startup rollback and shutdown logs passed")
|
||||
@@ -0,0 +1,215 @@
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <faset/scripting/LuaModule.hpp>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
using namespace faset::runtime;
|
||||
using namespace faset::scripting;
|
||||
using Json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
void check(bool value, const char* message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
template <class Function> void rejects(Function function, const char* message) {
|
||||
bool rejected{};
|
||||
try {
|
||||
function();
|
||||
} catch (const std::exception&) {
|
||||
rejected = true;
|
||||
}
|
||||
check(rejected, message);
|
||||
}
|
||||
LuaProject project(std::string source) {
|
||||
LuaProject result;
|
||||
result.scripts = {"Scripts/safety.lua"};
|
||||
result.sources.emplace(result.scripts.front(), std::move(source));
|
||||
return result;
|
||||
}
|
||||
Json scene(unsigned count = 1) {
|
||||
Json entities = Json::array();
|
||||
for (unsigned i = 0; i < count; ++i)
|
||||
entities.push_back({{"id", "actor" + std::to_string(i)},
|
||||
{"name", "Safety actor"},
|
||||
{"parent", nullptr},
|
||||
{"components", Json::array({{{"id", "transform"},
|
||||
{"type", "faset.transform"},
|
||||
{"version", 1},
|
||||
{"fields", Json::object()}},
|
||||
{{"id", "behavior"},
|
||||
{"type", "test.safety"},
|
||||
{"version", 1},
|
||||
{"fields", {{"fail", i == 0}}}}})}});
|
||||
return {{"format", "faset.scene"}, {"version", 1}, {"dimension", 2}, {"entities", entities}};
|
||||
}
|
||||
void depthAndMetatables() {
|
||||
LuaModule lua(project(R"lua(
|
||||
assert(setmetatable == nil)
|
||||
assert(getmetatable("") == "string")
|
||||
local nested = 7
|
||||
for i = 1, 24 do nested = {nested} end
|
||||
local B = faset.behavior {
|
||||
id = "test.safety",
|
||||
fields = { nested = { type = "any", default = nested } }
|
||||
}
|
||||
function B:on_start()
|
||||
assert(getmetatable(self) == "faset.BehaviorInstance")
|
||||
assert(getmetatable(self.entity) == "faset.Entity")
|
||||
local value = self.fields.nested
|
||||
for i = 1, 24 do value = value[1] end
|
||||
assert(value == 7)
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene());
|
||||
check(world.diagnostics().empty(), "deep supported JSON uses reserved Lua stack slots");
|
||||
rejects(
|
||||
[] {
|
||||
LuaModule invalid(project(R"lua(
|
||||
local nested = 7
|
||||
for i = 1, 40 do nested = {nested} end
|
||||
return faset.behavior {id="test.safety",fields={value={type="any",default=nested}}}
|
||||
)lua"));
|
||||
},
|
||||
"over-depth JSON rejects cleanly");
|
||||
rejects(
|
||||
[] {
|
||||
LuaModule invalid(project(R"lua(
|
||||
local cyclic = {}
|
||||
cyclic.self = cyclic
|
||||
return faset.behavior {id="test.safety",fields={value={type="any",default=cyclic}}}
|
||||
)lua"));
|
||||
},
|
||||
"cyclic JSON rejects cleanly");
|
||||
}
|
||||
void jsonFanout() {
|
||||
// Lua owns only one 1-MiB string. Copying aliases into native JSON must not
|
||||
// evade the host's aggregate byte limit and expand into an enormous tree.
|
||||
rejects(
|
||||
[] {
|
||||
LuaModule invalid(project(R"lua(
|
||||
local text = string.rep("x", 1024 * 1024)
|
||||
local aliases = {}
|
||||
for i = 1, 64 do aliases[i] = text end
|
||||
return faset.behavior {id="test.safety",fields={value={type="array",default=aliases}}}
|
||||
)lua"));
|
||||
},
|
||||
"JSON string alias fanout is bounded before native copies");
|
||||
rejects(
|
||||
[] {
|
||||
LuaModule invalid(project(R"lua(
|
||||
local key = string.rep("x", 1024 * 1024)
|
||||
local aliases = {}
|
||||
for i = 1, 64 do aliases[i] = {[key]=true} end
|
||||
return faset.behavior {id="test.safety",fields={value={type="array",default=aliases}}}
|
||||
)lua"));
|
||||
},
|
||||
"JSON key alias fanout is bounded before native copies");
|
||||
}
|
||||
void structuralBudgets() {
|
||||
for (const char* body : {"for i=1,2048 do self.entity:destroy() end",
|
||||
"local text=string.rep('x',1024*1024)\n"
|
||||
"for i=1,64 do self.entity:add_component {id='data'..i,type='data'..i,"
|
||||
"version=1,fields={text=text}} end"}) {
|
||||
LuaModule lua(project(std::string("local B=faset.behavior{id='test.safety',fields={}}\n"
|
||||
"function B:on_start()\n") +
|
||||
body + "\nend\nreturn B"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene());
|
||||
check(world.diagnostics().size() == 1,
|
||||
"native structural queue has count and byte budgets");
|
||||
world.advance(0.001);
|
||||
check(world.diagnostics().size() == 1,
|
||||
"structural-budget error disables only that instance");
|
||||
}
|
||||
}
|
||||
void repeatedMemoryFailures() {
|
||||
LuaLimits limits;
|
||||
limits.memoryBytes = 256 * 1024;
|
||||
for (unsigned repetition = 0; repetition < 32; ++repetition) {
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior {
|
||||
id = "test.safety", fields = { fail = { type = "boolean", default = false } }
|
||||
}
|
||||
function B:update(dt)
|
||||
if self.fields.fail then
|
||||
self.state.too_large = string.rep("x", 1024 * 1024)
|
||||
else
|
||||
local value = self.entity:transform()
|
||||
value.position.x = value.position.x + 1
|
||||
self.entity:set_transform(value)
|
||||
end
|
||||
end
|
||||
return B
|
||||
)lua"),
|
||||
limits);
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(2));
|
||||
for (unsigned frame = 0; frame < 20; ++frame)
|
||||
world.advance(0.001);
|
||||
check(world.diagnostics().size() == 1, "OOM instance reports once across repeated frames");
|
||||
check(world.transform(world.find("actor1")).position[0] == 20,
|
||||
"OOM does not poison subsequent protected calls or Lua stack");
|
||||
}
|
||||
// VM bootstrap itself must report allocation failure, not invoke Lua panic.
|
||||
limits.memoryBytes = 1;
|
||||
rejects(
|
||||
[&] {
|
||||
LuaModule invalid(project("return faset.behavior{id='test.safety',fields={}}"), limits);
|
||||
},
|
||||
"tiny VM budget fails safely during construction");
|
||||
}
|
||||
void persistentStateMemoryFailure() {
|
||||
LuaLimits limits;
|
||||
limits.memoryBytes = 256 * 1024;
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior {
|
||||
id = "test.safety", fields = { fail = { type = "boolean", default = false } }
|
||||
}
|
||||
function B:update(dt)
|
||||
if self.fields.fail then
|
||||
self.state.allocations = {}
|
||||
while true do
|
||||
self.state.allocations[#self.state.allocations + 1] = string.rep("x", 1024)
|
||||
end
|
||||
else
|
||||
local value = self.entity:transform()
|
||||
value.position.x = value.position.x + 1
|
||||
self.entity:set_transform(value)
|
||||
end
|
||||
end
|
||||
return B
|
||||
)lua"),
|
||||
limits);
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(2));
|
||||
for (unsigned frame = 0; frame < 20; ++frame)
|
||||
world.advance(0.001);
|
||||
check(world.diagnostics().size() == 1,
|
||||
"incremental state exhaustion only disables the failed instance");
|
||||
check(world.transform(world.find("actor1")).position[0] == 20,
|
||||
"failed state is released before healthy instances run");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
depthAndMetatables();
|
||||
jsonFanout();
|
||||
structuralBudgets();
|
||||
repeatedMemoryFailures();
|
||||
persistentStateMemoryFailure();
|
||||
std::cout << "Lua safety tests passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "Lua safety failure: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <faset/runtime/schema.hpp>
|
||||
#include <faset/scripting/LuaModule.hpp>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace faset::runtime;
|
||||
using namespace faset::scripting;
|
||||
using Json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
void check(bool result, const char* message) {
|
||||
if (!result)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
void near(float actual, float expected, const char* message) {
|
||||
check(std::abs(actual - expected) < 0.001f, message);
|
||||
}
|
||||
template <class F> void rejects(F&& fn, const char* message) {
|
||||
bool rejected = false;
|
||||
try {
|
||||
fn();
|
||||
} catch (const std::exception&) {
|
||||
rejected = true;
|
||||
}
|
||||
check(rejected, message);
|
||||
}
|
||||
LuaProject project(std::string source) {
|
||||
LuaProject result;
|
||||
result.scripts = {"Scripts/main.lua"};
|
||||
result.sources.emplace(result.scripts.front(), std::move(source));
|
||||
return result;
|
||||
}
|
||||
Json component(const std::string& type, Json fields = Json::object()) {
|
||||
return {{"id", type + "-component"}, {"type", type}, {"version", 1}, {"fields", fields}};
|
||||
}
|
||||
Json entity(const std::string& id, const std::string& type = "test.lua",
|
||||
Json fields = Json::object()) {
|
||||
return {{"id", id},
|
||||
{"name", id},
|
||||
{"parent", nullptr},
|
||||
{"components", Json::array({component("faset.transform"), component(type, fields)})}};
|
||||
}
|
||||
Json scene(Json entities) {
|
||||
return {{"format", "faset.scene"},
|
||||
{"version", 1},
|
||||
{"id", "lua-tests"},
|
||||
{"dimension", 2},
|
||||
{"entities", std::move(entities)},
|
||||
{"instances", Json::array()}};
|
||||
}
|
||||
bool contains(const std::vector<std::string>& lines, const std::string& text) {
|
||||
return std::any_of(lines.begin(), lines.end(),
|
||||
[&](const auto& line) { return line.find(text) != std::string::npos; });
|
||||
}
|
||||
void defaultsAndIsolation() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior {
|
||||
id = "test.lua", version = 1, name = "Lua test",
|
||||
fields = {
|
||||
speed = { type = "number", default = 2, min = 0, max = 10 },
|
||||
enabled = { type = "boolean", default = true },
|
||||
label = { type = "string", default = "default" }
|
||||
}
|
||||
}
|
||||
function B:on_start()
|
||||
assert(self.fields.enabled and self.fields.label == "default")
|
||||
self.state.count = 0
|
||||
end
|
||||
function B:update(dt)
|
||||
assert(dt > 0)
|
||||
self.state.count = self.state.count + 1
|
||||
local pose = self.entity:transform()
|
||||
pose.position.x = self.state.count * self.fields.speed
|
||||
self.entity:set_transform(pose)
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
const auto metadata = lua.schema();
|
||||
check(metadata.is_array() && metadata.size() == 1, "one Lua behavior exports one schema");
|
||||
check(metadata[0]["fields"]["speed"]["default"] == 2, "schema retains defaults");
|
||||
check(metadata[0]["id"] == "test.lua", "schema retains stable TypeId");
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
auto doc =
|
||||
scene(Json::array({entity("default"), entity("override", "test.lua", {{"speed", 5}})}));
|
||||
validate_scene_schemas(doc, metadata);
|
||||
lua.validateScene(doc);
|
||||
auto invalid = doc;
|
||||
invalid["entities"][0]["components"][1]["fields"]["speed"] = "not a number";
|
||||
rejects([&] { lua.validateScene(invalid); }, "CPU validation rejects invalid Lua field type");
|
||||
invalid["entities"][0]["components"][1]["fields"]["speed"] = -1;
|
||||
rejects([&] { lua.validateScene(invalid); }, "CPU validation enforces Lua field constraints");
|
||||
const auto original = doc;
|
||||
world.load(doc);
|
||||
world.advance(1.0 / 60);
|
||||
near(world.transform(world.find("default")).position[0], 2, "Lua uses schema default");
|
||||
near(world.transform(world.find("override")).position[0], 5, "Lua uses scene override");
|
||||
world.advance(1.0 / 60);
|
||||
near(world.transform(world.find("default")).position[0], 4, "state persists per instance");
|
||||
near(world.transform(world.find("override")).position[0], 10, "instances do not share state");
|
||||
check(world.fields(world.find("default"), "test.lua").empty(),
|
||||
"defaults do not mutate stored runtime configuration");
|
||||
check(doc == original, "Lua cannot mutate authoring scene");
|
||||
check(world.diagnostics().empty(), "valid Lua behavior produces no diagnostics");
|
||||
}
|
||||
void lifecycleAndHandles() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local previous
|
||||
local B = faset.behavior { id = "test.lua", version = 1, fields = {} }
|
||||
function B:on_start()
|
||||
if previous then assert(not previous:valid()) end
|
||||
assert(self.entity:valid())
|
||||
assert(faset.find("missing") == nil)
|
||||
assert(faset.find("actor") == self.entity)
|
||||
previous = self.entity
|
||||
faset.log("phase:start")
|
||||
end
|
||||
function B:fixed_update(dt)
|
||||
assert(dt > 0)
|
||||
local input = faset.input()
|
||||
assert(input.horizontal == 1 and input.vertical == -1)
|
||||
assert(input.jump_pressed and input.interact_pressed)
|
||||
faset.log("phase:fixed")
|
||||
end
|
||||
function B:update(dt) faset.log("phase:update") end
|
||||
function B:late_update(dt)
|
||||
local pose = self.entity:presentation()
|
||||
pose.position.x = 17
|
||||
self.entity:set_presentation(pose)
|
||||
faset.log("phase:late")
|
||||
end
|
||||
function B:on_destroy()
|
||||
assert(self.entity:valid())
|
||||
faset.log("phase:destroy")
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
const auto doc = scene(Json::array({entity("actor")}));
|
||||
world.load(doc);
|
||||
world.advance(1.0 / 60, {1, -1, true, true});
|
||||
near(world.presentation(world.find("actor")).position[0], 17,
|
||||
"late_update can write presentation transform");
|
||||
near(world.transform(world.find("actor")).position[0], 0,
|
||||
"presentation write does not mutate simulation transform");
|
||||
world.clear();
|
||||
const auto lines = lua.takeLogs();
|
||||
const std::vector<std::string> phases = {"phase:start", "phase:fixed", "phase:update",
|
||||
"phase:late", "phase:destroy"};
|
||||
check(lines.size() == phases.size(), "each lifecycle callback runs exactly once");
|
||||
for (std::size_t i = 0; i < phases.size(); ++i)
|
||||
check(lines[i].find(phases[i]) != std::string::npos, "Lua lifecycle follows runtime order");
|
||||
check(lua.takeLogs().empty(), "taking logs drains the queue");
|
||||
world.load(doc);
|
||||
check(world.diagnostics().empty(), "old Lua handles stay stale across world reload");
|
||||
world.clear();
|
||||
}
|
||||
void moduleLifetime() {
|
||||
Runtime world;
|
||||
{
|
||||
LuaModule temporary(project(R"lua(
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:update(dt)
|
||||
local pose = self.entity:transform()
|
||||
pose.position.x = 23
|
||||
self.entity:set_transform(pose)
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
temporary.registerBehaviors(world);
|
||||
}
|
||||
world.load(scene(Json::array({entity("actor")})));
|
||||
world.advance(0.01);
|
||||
near(world.transform(world.find("actor")).position[0], 23,
|
||||
"registered callbacks retain VM after LuaModule facade destruction");
|
||||
world.clear();
|
||||
}
|
||||
void modulesAndSandbox() {
|
||||
auto snapshot = project(R"lua(
|
||||
assert(io == nil and os == nil and debug == nil)
|
||||
assert(load == nil and loadfile == nil and dofile == nil)
|
||||
assert(pcall == nil and xpcall == nil and coroutine == nil)
|
||||
local util = require("util.math")
|
||||
assert(require("util.math") == util)
|
||||
local directory = require("directory")
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:on_start()
|
||||
local pose = self.entity:transform()
|
||||
pose.position.x = util.answer + directory.answer
|
||||
self.entity:set_transform(pose)
|
||||
end
|
||||
return B
|
||||
)lua");
|
||||
snapshot.sources["Scripts/util/math.lua"] = "return { answer = 40 }";
|
||||
snapshot.sources["Scripts/directory/init.lua"] = "return { answer = 2 }";
|
||||
LuaModule lua(snapshot);
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({entity("actor")})));
|
||||
near(world.transform(world.find("actor")).position[0], 42, "require loads captured modules");
|
||||
check(world.diagnostics().empty(), "sandboxed standard operations succeed");
|
||||
auto missing = project("return require('missing')");
|
||||
rejects([&] { LuaModule invalid(missing); }, "missing modules fail loading");
|
||||
auto cycle = project("return require('cycle')");
|
||||
cycle.sources["Scripts/cycle.lua"] = "return require('cycle')";
|
||||
rejects([&] { LuaModule invalid(cycle); }, "cyclic require fails rather than looping");
|
||||
for (const auto& name : {"../escape", "/absolute", "foo/bar", "foo..bar"}) {
|
||||
const auto escape = project(std::string("return require('") + name + "')");
|
||||
rejects([&] { LuaModule invalid(escape); }, "require rejects path-like module names");
|
||||
}
|
||||
}
|
||||
void structuralCommands() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:on_start()
|
||||
self.state.ticks = 0
|
||||
self.entity:add_component {
|
||||
id = "extra-component", type = "test.data", version = 1, fields = { value = 7 }
|
||||
}
|
||||
faset.spawn {
|
||||
id = "spawned", name = "Spawned", parent = faset.null,
|
||||
components = {
|
||||
{ id = "spawned-transform", type = "faset.transform", version = 1,
|
||||
fields = { position = { 3, 4, 0 } } }
|
||||
}
|
||||
}
|
||||
assert(faset.find("spawned") == nil)
|
||||
end
|
||||
function B:fixed_update(dt)
|
||||
self.state.ticks = self.state.ticks + 1
|
||||
if self.state.ticks == 1 then
|
||||
assert(self.entity:fields("test.data").value == 7)
|
||||
self.entity:remove_component("test.data")
|
||||
local spawned = faset.find("spawned")
|
||||
assert(spawned and spawned:valid())
|
||||
assert(spawned:transform().position.x == 3)
|
||||
spawned:destroy()
|
||||
assert(spawned:valid())
|
||||
self.state.spawned = spawned
|
||||
elseif self.state.ticks == 2 then
|
||||
assert(not self.state.spawned:valid())
|
||||
assert(faset.find("spawned") == nil)
|
||||
faset.log("structural:done")
|
||||
end
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({entity("actor")})));
|
||||
check(!world.find("spawned"), "Lua spawn waits for fixed-tick barrier");
|
||||
world.singleStep();
|
||||
check(bool(world.find("spawned")), "Lua spawn applies at first barrier");
|
||||
world.singleStep();
|
||||
check(!world.find("spawned"), "Lua destroy applies at next barrier");
|
||||
rejects([&] { world.fields(world.find("actor"), "test.data"); },
|
||||
"Lua remove_component applies at next barrier");
|
||||
check(contains(lua.takeLogs(), "structural:done"), "Lua observes deferred structural changes");
|
||||
check(world.diagnostics().empty(), "valid structural API calls produce no errors");
|
||||
}
|
||||
void errorsAreContained() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior {
|
||||
id = "test.lua", fields = { fail = { type = "boolean", default = false } }
|
||||
}
|
||||
function B:update(dt)
|
||||
if self.fields.fail then error("intentional-lua-error") end
|
||||
local pose = self.entity:transform()
|
||||
pose.position.x = pose.position.x + 1
|
||||
self.entity:set_transform(pose)
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({entity("bad", "test.lua", {{"fail", true}}), entity("good")})));
|
||||
world.advance(0.01);
|
||||
world.advance(0.01);
|
||||
near(world.transform(world.find("good")).position[0], 2,
|
||||
"a failed Lua instance does not disable healthy instances");
|
||||
check(world.diagnostics().size() == 1, "failed instance logs once and is disabled");
|
||||
check(contains(world.diagnostics(), "intentional-lua-error"),
|
||||
"Lua errors reach runtime diagnostics");
|
||||
check(contains(world.diagnostics(), "Scripts/main.lua"), "Lua error includes source path");
|
||||
check(contains(world.diagnostics(), "stack traceback"), "Lua error includes traceback");
|
||||
for (const auto& body :
|
||||
{"self.entity:set_presentation(self.entity:transform())", "self.entity:velocity()",
|
||||
"self.entity:set_transform({position = {x = 0/0, y = 0, z = 0}})"}) {
|
||||
LuaModule invalid(project(std::string("local B=faset.behavior{id='test.lua',fields={}}\n") +
|
||||
"function B:update(dt) " + body + " end\nreturn B"));
|
||||
Runtime separate;
|
||||
invalid.registerBehaviors(separate);
|
||||
separate.load(scene(Json::array({entity("actor")})));
|
||||
separate.advance(0.01);
|
||||
check(!separate.diagnostics().empty(), "invalid bound calls become contained Lua errors");
|
||||
}
|
||||
}
|
||||
void staleHandleAccess() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local previous
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:on_start()
|
||||
if previous then
|
||||
assert(not previous:valid())
|
||||
previous:transform()
|
||||
end
|
||||
previous = self.entity
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
const auto document = scene(Json::array({entity("actor")}));
|
||||
world.load(document);
|
||||
check(world.diagnostics().empty(), "initial entity handle is valid");
|
||||
world.load(document);
|
||||
check(!world.diagnostics().empty(),
|
||||
"retained userdata rejects access after session replacement");
|
||||
|
||||
LuaModule shared(project(R"lua(
|
||||
local previous
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:on_start()
|
||||
if previous then
|
||||
assert(not previous:valid())
|
||||
previous:transform()
|
||||
end
|
||||
previous = self.entity
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime first;
|
||||
Runtime second;
|
||||
shared.registerBehaviors(first);
|
||||
shared.registerBehaviors(second);
|
||||
first.load(document);
|
||||
second.load(document);
|
||||
check(!second.diagnostics().empty(), "userdata from another world is rejected");
|
||||
}
|
||||
void componentInstanceLifetime() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:on_start()
|
||||
assert(self.state.counter == nil)
|
||||
self.state.counter = 0
|
||||
faset.log("component:start")
|
||||
end
|
||||
function B:update(dt) self.state.counter = self.state.counter + 1 end
|
||||
function B:on_destroy() faset.log("component:destroy") end
|
||||
return B
|
||||
)lua"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({entity("actor")})));
|
||||
world.advance(0.01);
|
||||
auto handle = world.find("actor");
|
||||
world.removeComponent(handle, "test.lua");
|
||||
world.singleStep();
|
||||
check(world.valid(handle), "removing behavior does not remove its owner");
|
||||
world.addComponent(handle, component("test.lua"));
|
||||
world.singleStep();
|
||||
auto lines = lua.takeLogs();
|
||||
check(lines.size() == 3 && lines[0].find("component:start") != std::string::npos &&
|
||||
lines[1].find("component:destroy") != std::string::npos &&
|
||||
lines[2].find("component:start") != std::string::npos,
|
||||
"re-attaching a behavior creates fresh instance state");
|
||||
check(world.diagnostics().empty(), "component state is released on removal");
|
||||
world.clear();
|
||||
}
|
||||
void physicsAndCollision() {
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:on_start()
|
||||
local velocity = self.entity:velocity()
|
||||
velocity.x = 1
|
||||
self.entity:set_velocity(velocity)
|
||||
self.entity:apply_impulse { x = 0, y = 0.1, z = 0 }
|
||||
assert(self.entity:velocity().y > 0)
|
||||
local pose = self.entity:transform()
|
||||
pose.position.y = 2
|
||||
self.entity:teleport(pose)
|
||||
assert(not self.entity:is_grounded())
|
||||
end
|
||||
function B:on_collision(event)
|
||||
assert(event.first:valid() and event.second:valid())
|
||||
if event.began then faset.log("contact:began") end
|
||||
end
|
||||
function B:fixed_update(dt)
|
||||
if self.entity:is_grounded() then faset.log("body:grounded") end
|
||||
end
|
||||
return B
|
||||
)lua"));
|
||||
auto floor = entity("floor", "test.floor");
|
||||
floor["components"][0]["fields"] = {{"position", {0, -0.5, 0}}};
|
||||
floor["components"].push_back(
|
||||
component("faset.rigid_body_2d", {{"body_type", "static"}, {"half_extents", {10, 0.5}}}));
|
||||
auto falling = entity("actor");
|
||||
falling["components"].push_back(component("faset.rigid_body_2d"));
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({floor, falling})));
|
||||
for (int i = 0; i < 180; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
const auto logs = lua.takeLogs();
|
||||
check(contains(logs, "contact:began"), "Lua receives native collision event");
|
||||
check(contains(logs, "body:grounded"), "Lua sees native grounded state");
|
||||
check(world.diagnostics().empty(), "valid physics bindings produce no diagnostics");
|
||||
}
|
||||
void invalidDefinitionsAndBudgets() {
|
||||
for (const auto& source :
|
||||
{"this is not lua", "return 42", "return faset.behavior { id = '', fields = {} }",
|
||||
"return faset.behavior { id = 'faset.transform', fields = {} }",
|
||||
"return faset.behavior { id = 'test.lua', version = 0, fields = {} }",
|
||||
"return faset.behavior { id = 'test.lua', fields = { speed = { type = 'number' } } }"}) {
|
||||
rejects([&] { LuaModule invalid(project(source)); },
|
||||
"invalid behavior declaration is rejected");
|
||||
}
|
||||
auto duplicate = project("return faset.behavior { id = 'test.lua', fields = {} }");
|
||||
duplicate.scripts.push_back("Scripts/second.lua");
|
||||
duplicate.sources["Scripts/second.lua"] = duplicate.sources.begin()->second;
|
||||
rejects([&] { LuaModule invalid(duplicate); }, "duplicate behavior TypeIds are rejected");
|
||||
LuaLimits limits;
|
||||
limits.instructions = 10'000;
|
||||
rejects([&] { LuaModule invalid(project("while true do end"), limits); },
|
||||
"instruction budget bounds module evaluation");
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior { id = "test.lua", fields = {} }
|
||||
function B:update(dt) while true do end end
|
||||
return B
|
||||
)lua"),
|
||||
limits);
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({entity("actor")})));
|
||||
world.advance(0.01);
|
||||
check(!world.diagnostics().empty(), "instruction budget bounds callback evaluation");
|
||||
const auto failures = world.diagnostics().size();
|
||||
world.advance(0.01);
|
||||
check(world.diagnostics().size() == failures, "runaway instance stays disabled");
|
||||
}
|
||||
void memoryLimits() {
|
||||
LuaLimits limits;
|
||||
limits.memoryBytes = 256 * 1024;
|
||||
rejects(
|
||||
[&] {
|
||||
LuaModule invalid(project("local oversized = string.rep('x', 1048576)\n"
|
||||
"return faset.behavior{id='test.lua',fields={}}"),
|
||||
limits);
|
||||
},
|
||||
"memory budget bounds module evaluation");
|
||||
LuaModule lua(project(R"lua(
|
||||
local B = faset.behavior {
|
||||
id = "test.lua", fields = { fail = { type = "boolean", default = false } }
|
||||
}
|
||||
function B:update(dt)
|
||||
if self.fields.fail then
|
||||
self.state.oversized = string.rep("x", 1048576)
|
||||
else
|
||||
local pose = self.entity:transform()
|
||||
pose.position.x = pose.position.x + 1
|
||||
self.entity:set_transform(pose)
|
||||
end
|
||||
end
|
||||
return B
|
||||
)lua"),
|
||||
limits);
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(scene(Json::array({entity("bad", "test.lua", {{"fail", true}}), entity("good")})));
|
||||
world.advance(0.01);
|
||||
check(!world.diagnostics().empty(), "memory budget bounds callback allocations");
|
||||
world.advance(0.01);
|
||||
near(world.transform(world.find("good")).position[0], 2,
|
||||
"VM survives a rejected allocation and runs other instances");
|
||||
}
|
||||
#ifdef FASET_SOURCE_DIR
|
||||
void exampleSmokeTest() {
|
||||
const auto root = std::filesystem::path(FASET_SOURCE_DIR) / "examples/lua";
|
||||
const auto snapshot = loadLuaProject(root);
|
||||
LuaModule lua(snapshot);
|
||||
check(lua.schema().size() == 2, "shipped Lua example exports both behaviors");
|
||||
const auto document = faset::read_json(root / "Scenes/main.scene.json");
|
||||
validate_scene_schemas(document, lua.schema());
|
||||
Runtime world;
|
||||
lua.registerBehaviors(world);
|
||||
world.load(document);
|
||||
for (int i = 0; i < 120; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
check(world.grounded(world.find("player")), "Lua example player lands on its floor");
|
||||
world.advance(1.0 / 60, {1, 0, true, false});
|
||||
check(world.velocity(world.find("player"))[0] > 4,
|
||||
"Lua example controller applies horizontal input");
|
||||
check(world.velocity(world.find("player"))[1] > 5,
|
||||
"Lua example controller jumps from native contact");
|
||||
world.advance(1.0 / 60, {0, 0, false, true});
|
||||
near(world.transform(world.find("player")).position[0], -2,
|
||||
"Lua example interaction resets player position");
|
||||
check(world.diagnostics().empty(), "shipped Lua scripts run without diagnostics");
|
||||
}
|
||||
#endif
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
defaultsAndIsolation();
|
||||
lifecycleAndHandles();
|
||||
moduleLifetime();
|
||||
modulesAndSandbox();
|
||||
structuralCommands();
|
||||
errorsAreContained();
|
||||
staleHandleAccess();
|
||||
componentInstanceLifetime();
|
||||
physicsAndCollision();
|
||||
invalidDefinitionsAndBudgets();
|
||||
memoryLimits();
|
||||
#ifdef FASET_SOURCE_DIR
|
||||
exampleSmokeTest();
|
||||
#endif
|
||||
std::cout << "Lua: schemas, state, lifecycle, safe handles, sandbox, modules, physics, "
|
||||
"deferred commands, diagnostics, and execution limits passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "Lua test failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user