Validate gameplay metadata before publishing build generations

This commit is contained in:
Emil
2026-09-18 05:21:31 +03:00
parent afd773fffb
commit 7481a2e029
8 changed files with 255 additions and 21 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ endif()
if(TARGET faset_authoring AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/EditorCommands.cmake") if(TARGET faset_authoring AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/EditorCommands.cmake")
include(cmake/EditorCommands.cmake) include(cmake/EditorCommands.cmake)
endif() endif()
if(TARGET faset_assets AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/BuildService.cmake") if(TARGET faset_assets AND TARGET faset_authoring AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/BuildService.cmake")
include(cmake/BuildService.cmake) include(cmake/BuildService.cmake)
endif() endif()
if(TARGET faset_editor_commands AND TARGET faset_build_service) if(TARGET faset_editor_commands AND TARGET faset_build_service)
+8 -1
View File
@@ -1,10 +1,17 @@
add_library(faset_build_service STATIC ${PROJECT_SOURCE_DIR}/src/editor/build_service.cpp) add_library(faset_build_service STATIC ${PROJECT_SOURCE_DIR}/src/editor/build_service.cpp)
target_include_directories(faset_build_service PUBLIC ${PROJECT_SOURCE_DIR}/include) target_include_directories(faset_build_service PUBLIC ${PROJECT_SOURCE_DIR}/include)
target_compile_features(faset_build_service PUBLIC cxx_std_20) target_compile_features(faset_build_service PUBLIC cxx_std_20)
target_link_libraries(faset_build_service PUBLIC faset_core PRIVATE faset_asset_data Threads::Threads) target_link_libraries(faset_build_service PUBLIC faset_core PRIVATE faset_asset_data faset_authoring Threads::Threads)
if(BUILD_TESTING) if(BUILD_TESTING)
add_executable(faset_build_service_tests ${PROJECT_SOURCE_DIR}/tests/build_service_tests.cpp) add_executable(faset_build_service_tests ${PROJECT_SOURCE_DIR}/tests/build_service_tests.cpp)
target_link_libraries(faset_build_service_tests PRIVATE faset_build_service faset_assets) target_link_libraries(faset_build_service_tests PRIVATE faset_build_service faset_assets)
target_compile_definitions(faset_build_service_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}") target_compile_definitions(faset_build_service_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}")
add_test(NAME process_and_cook COMMAND faset_build_service_tests) add_test(NAME process_and_cook COMMAND faset_build_service_tests)
add_executable(faset_build_schema_tool ${PROJECT_SOURCE_DIR}/tests/build_schema_tool.cpp)
target_link_libraries(faset_build_schema_tool PRIVATE faset_core)
add_executable(faset_build_schema_tests ${PROJECT_SOURCE_DIR}/tests/build_schema_tests.cpp)
target_link_libraries(faset_build_schema_tests PRIVATE faset_build_service faset_authoring)
add_dependencies(faset_build_schema_tests faset_build_schema_tool)
add_test(NAME build_schema_publication COMMAND faset_build_schema_tests $<TARGET_FILE:faset_build_schema_tool> ${PROJECT_SOURCE_DIR})
set_tests_properties(build_schema_publication PROPERTIES TIMEOUT 60)
endif() endif()
+3
View File
@@ -56,5 +56,8 @@ template <class T> class TypeRegistration {
Json schema_; Json schema_;
}; };
SchemaRegistry builtin_schemas(); SchemaRegistry builtin_schemas();
// Validate a gameplay schema array/types manifest, reserving native TypeIds and
// rejecting repeated gameplay TypeIds. The result includes built-in schemas.
SchemaRegistry gameplay_schemas(const Json& manifest);
void validate_field(const Json& value, const Json& descriptor); void validate_field(const Json& value, const Json& descriptor);
} // namespace faset::authoring } // namespace faset::authoring
+30 -1
View File
@@ -1,6 +1,7 @@
#include <array> #include <array>
#include <cmath> #include <cmath>
#include <faset/authoring/schema.hpp> #include <faset/authoring/schema.hpp>
#include <limits>
#include <set> #include <set>
namespace faset::authoring { namespace faset::authoring {
@@ -52,13 +53,25 @@ void SchemaRegistry::register_schema(const Json& value) {
Json normalized = value; Json normalized = value;
const auto id = value.at("id").get<std::string>(); const auto id = value.at("id").get<std::string>();
require(!id.empty(), "schema.invalid", "TypeId cannot be empty"); require(!id.empty(), "schema.invalid", "TypeId cannot be empty");
require(value.value("version", 1) > 0, "schema.invalid", "Schema version must be positive"); if (value.contains("version")) {
const auto& version = value.at("version");
require(version.is_number_integer() && version > 0 &&
version <= std::numeric_limits<int>::max(),
"schema.invalid", "Schema version must be a positive supported integer");
}
for (auto& [key, field] : normalized["fields"].items()) { for (auto& [key, field] : normalized["fields"].items()) {
require(field.is_object() && field.contains("default"), "schema.invalid", require(field.is_object() && field.contains("default"), "schema.invalid",
"Each field requires a typed default"); "Each field requires a typed default");
require(field.value("id", key) == key, "schema.field_id", require(field.value("id", key) == key, "schema.field_id",
"Field map keys must be stable FieldIds"); "Field map keys must be stable FieldIds");
field["id"] = key; field["id"] = key;
for (const auto* limit : {"min", "max"})
if (field.contains(limit))
require(field.at(limit).is_number() && std::isfinite(field.at(limit).get<double>()),
"schema.invalid", "Field limits must be finite numbers");
if (field.contains("enum"))
require(field.at("enum").is_array(), "schema.invalid",
"Field enum choices must be an array");
validate_field(field["default"], field); validate_field(field["default"], field);
} }
if (auto found = schemas_.find(id); found != schemas_.end()) if (auto found = schemas_.find(id); found != schemas_.end())
@@ -73,6 +86,22 @@ void SchemaRegistry::register_schemas(const Json& values) {
candidate.register_schema(schema); candidate.register_schema(schema);
*this = std::move(candidate); *this = std::move(candidate);
} }
SchemaRegistry gameplay_schemas(const Json& manifest) {
require(manifest.is_array() || (manifest.is_object() && manifest.contains("types") &&
manifest.at("types").is_array()),
"schema.invalid", "Gameplay schemas require an array or a types manifest");
const auto& types = manifest.is_array() ? manifest : manifest.at("types");
auto result = builtin_schemas();
for (const auto& schema : types) {
require(schema.is_object() && schema.contains("id") && schema.at("id").is_string(),
"schema.invalid", "Gameplay schema requires a TypeId");
const auto id = schema.at("id").get<std::string>();
require(!result.contains(id), "schema.duplicate_type",
"Gameplay schema duplicates a builtin or gameplay TypeId: " + id);
result.register_schema(schema);
}
return result;
}
bool SchemaRegistry::contains(const std::string& type) const { bool SchemaRegistry::contains(const std::string& type) const {
return schemas_.contains(type); return schemas_.contains(type);
} }
+1 -3
View File
@@ -311,9 +311,7 @@ void AuthoringService::register_schemas(const Json& manifest) {
} }
void AuthoringService::replace_external_schemas(const Json& manifest) { void AuthoringService::replace_external_schemas(const Json& manifest) {
std::lock_guard lock(mutex_); std::lock_guard lock(mutex_);
auto candidate = builtin_schemas(); schemas_ = gameplay_schemas(manifest);
candidate.register_schemas(manifest);
schemas_ = std::move(candidate);
} }
void AuthoringService::apply(Json& scene, const Json& command) { void AuthoringService::apply(Json& scene, const Json& command) {
require(command.is_object() && command.contains("op") && command["op"].is_string(), require(command.is_object() && command.contains("op") && command["op"].is_string(),
+12 -15
View File
@@ -4,6 +4,7 @@
#include <condition_variable> #include <condition_variable>
#include <deque> #include <deque>
#include <faset/assets/asset_data.hpp> #include <faset/assets/asset_data.hpp>
#include <faset/authoring/schema.hpp>
#include <faset/core/hash.hpp> #include <faset/core/hash.hpp>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/core/process.hpp> #include <faset/core/process.hpp>
@@ -33,23 +34,14 @@ void validate_scene(const Json& scene) {
throw std::runtime_error("Scene dimension must be 2 or 3"); throw std::runtime_error("Scene dimension must be 2 or 3");
} }
void validate_component_types(const Json& scene, const Json& schema) { void validate_component_types(const Json& scene, const Json& schema) {
std::map<std::string, int> versions; const auto registry =
for (const auto* type : {"faset.transform", "faset.sprite", "faset.mesh", "faset.camera", schema.is_null() ? authoring::builtin_schemas() : authoring::gameplay_schemas(schema);
"faset.light", "faset.rigid_body_2d", "faset.rigid_body_3d"})
versions[type] = 1;
for (const auto& type : schema.value("types", Json::array())) {
auto id = type.at("id").get<std::string>();
if (versions.contains(id))
throw std::runtime_error("Gameplay schema duplicates a component type: " + id);
versions[id] = type.value("version", 1);
}
for (const auto& entity : scene.at("entities")) for (const auto& entity : scene.at("entities"))
for (const auto& component : entity.value("components", Json::array())) { for (const auto& component : entity.value("components", Json::array())) {
const auto id = component.at("type").get<std::string>(); const auto id = component.at("type").get<std::string>();
auto found = versions.find(id); if (!registry.contains(id))
if (found == versions.end())
throw std::runtime_error("Cannot cook unresolved component type: " + id); throw std::runtime_error("Cannot cook unresolved component type: " + id);
if (component.value("version", 1) != found->second) if (component.value("version", 1) != registry.schema(id).value("version", 1))
throw std::runtime_error("Migrate component '" + id + throw std::runtime_error("Migrate component '" + id +
"' to the current gameplay schema before cooking"); "' to the current gameplay schema before cooking");
} }
@@ -298,6 +290,9 @@ struct BuildService::Impl {
if (schema.value("format", "") != "faset.schema" || schema.value("version", 0) != 1 || if (schema.value("format", "") != "faset.schema" || schema.value("version", 0) != 1 ||
!schema.contains("types") || !schema.at("types").is_array()) !schema.contains("types") || !schema.at("types").is_array())
throw std::runtime_error("SchemaExporter returned an invalid manifest"); throw std::runtime_error("SchemaExporter returned an invalid manifest");
// Validate the complete metadata before publishing either the schema or
// its Player generation. Session uses this same authoring contract.
(void)authoring::gameplay_schemas(schema);
std::string fingerprint = sha256_file(player) + sha256_file(exporter) + std::string fingerprint = sha256_file(player) + sha256_file(exporter) +
read_text(native_directory / "CMakeCache.txt"); read_text(native_directory / "CMakeCache.txt");
for (const auto& file : {"Gameplay.cpp", "Gameplay.hpp"}) for (const auto& file : {"Gameplay.cpp", "Gameplay.hpp"})
@@ -393,7 +388,7 @@ struct BuildService::Impl {
checkpoint(job, "Validating scene and assets", .1); checkpoint(job, "Validating scene and assets", .1);
validate_scene(job.scene); validate_scene(job.scene);
validate_assets(job.scene); validate_assets(job.scene);
Json schema = Json::object(); Json schema;
if (fs::exists(config.cache_root / "last_build.json")) { if (fs::exists(config.cache_root / "last_build.json")) {
auto pointer = read_json(config.cache_root / "last_build.json"); auto pointer = read_json(config.cache_root / "last_build.json");
auto schema_path = project_path( auto schema_path = project_path(
@@ -403,7 +398,9 @@ struct BuildService::Impl {
} }
validate_component_types(job.scene, schema); validate_component_types(job.scene, schema);
auto source_hash = sha256(job.scene.dump()); auto source_hash = sha256(job.scene.dump());
auto schema_fingerprint = schema.value("build_fingerprint", std::string("builtin-v1")); auto schema_fingerprint =
schema.is_null() ? std::string("builtin-v1")
: schema.value("build_fingerprint", std::string("builtin-v1"));
auto digest = sha256(source_hash + schema_fingerprint); auto digest = sha256(source_hash + schema_fingerprint);
auto directory = config.cache_root / "cooked" / digest; auto directory = config.cache_root / "cooked" / digest;
auto staging = config.cache_root / "cooked" / (".staging-" + job.status.id); auto staging = config.cache_root / "cooked" / (".staging-" + job.status.id);
+150
View File
@@ -0,0 +1,150 @@
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/editor/build_service.hpp>
#include <iostream>
using namespace faset;
namespace fs = std::filesystem;
namespace {
void check(bool value, std::string_view message) {
if (!value)
throw std::runtime_error(std::string(message));
}
Json manifest() {
return {
{"format", "faset.schema"},
{"version", 1},
{"types",
Json::array(
{{{"id", "game.mover"},
{"version", 2},
{"fields",
{{"speed", {{"type", "number"}, {"default", 2.5}, {"min", 0}, {"max", 10}}}}}}})}};
}
int test_main(int argc, char** argv) {
const auto root =
fs::temp_directory_path() / path_from_utf8("Faset schema Café 世界 " + new_id());
try {
check(argc == 3, "Expected native fixture tool and engine root");
editor::BuildConfig config;
config.project_root = root / "project";
config.engine_root = fs::absolute(path_from_utf8(argv[2]));
config.cmake = path_to_utf8(fs::absolute(path_from_utf8(argv[1])));
config.configure_arguments = {"-DCMAKE_CXX_COMPILER=fixture-does-not-compile"};
editor::BuildService builds(config);
builds.scaffold("Schema publication", 2);
authoring::AuthoringService authoring(config.project_root, authoring::builtin_schemas());
const auto valid = manifest();
atomic_write_json(config.project_root / "schema-fixture.json", valid);
const auto first = builds.wait(builds.start_build());
check(first.state == "succeeded", "Valid custom schema v2 publishes: " + first.error);
const auto directory = path_from_utf8(first.result.at("directory").get<std::string>());
const auto player = path_from_utf8(first.result.at("player").get<std::string>());
const auto schema = path_from_utf8(first.result.at("schema").get<std::string>());
const auto last_build = builds.config().cache_root / "last_build.json";
const auto previous_pointer = read_text(last_build);
const auto previous_player = sha256_file(player);
const auto previous_schema = read_text(schema);
const auto previous_manifest = read_text(directory / "manifest.json");
authoring.replace_external_schemas(read_json(schema));
const auto previous_registry = authoring.schemas().manifest();
check(authoring.schemas().schema("game.mover").at("version") == 2,
"Matching custom v2 metadata reaches authoring");
Json scene{
{"format", "faset.scene"},
{"version", 1},
{"id", "fixture-scene"},
{"dimension", 2},
{"instances", Json::array()},
{"entities",
Json::array({{{"id", "mover"},
{"components", Json::array({{{"id", "behavior"},
{"type", "game.mover"},
{"version", 2},
{"fields", {{"speed", 2.5}}}}})}}})}};
check(builds.wait(builds.start_cook(scene)).state == "succeeded",
"Matching component v2 cooks against the published schema");
std::vector<Json> invalid;
auto candidate = valid;
candidate["types"][0]["fields"]["speed"]["default"] = "not a number";
invalid.push_back(candidate);
candidate = valid;
candidate["types"][0]["fields"]["speed"]["type"] = "unsupported-kind";
invalid.push_back(candidate);
candidate = valid;
candidate["types"][0]["fields"]["speed"]["min"] = "not a number";
invalid.push_back(candidate);
candidate = valid;
candidate["types"][0]["fields"]["speed"]["enum"] = 2.5;
invalid.push_back(candidate);
candidate = valid;
candidate["types"][0]["fields"]["speed"]["id"] = "different-field-id";
invalid.push_back(candidate);
candidate = valid;
candidate["types"].push_back(candidate["types"][0]);
invalid.push_back(candidate);
candidate = valid;
candidate["types"] = Json::array({authoring::builtin_schemas().schema("faset.transform")});
invalid.push_back(candidate);
for (const Json& version : {Json(0), Json(2.5), Json("2")}) {
candidate = valid;
candidate["types"][0]["version"] = version;
invalid.push_back(candidate);
}
candidate = valid;
candidate["types"][0].erase("fields");
invalid.push_back(candidate);
candidate = valid;
candidate["types"] = Json::object();
invalid.push_back(candidate);
for (std::size_t index = 0; index < invalid.size(); ++index) {
atomic_write_json(config.project_root / "schema-fixture.json", invalid[index]);
atomic_write(config.project_root / "Scripts/Gameplay.cpp",
"// Invalid metadata fixture " + std::to_string(index));
const auto failed = builds.wait(builds.start_build());
check(failed.state == "failed" && failed.result.empty() && !failed.error.empty(),
"Malformed schema must fail before a successful build result is published");
check(read_text(last_build) == previous_pointer,
"Last good build pointer is preserved");
check(sha256_file(player) == previous_player && read_text(schema) == previous_schema &&
read_text(directory / "manifest.json") == previous_manifest,
"Last good binary, schema and build manifest remain unchanged");
std::size_t generations{};
for (const auto& entry : fs::directory_iterator(directory.parent_path())) {
++generations;
check(entry.path() == directory, "Invalid build leaves no staging or generation");
}
check(generations == 1, "Only the validated build generation remains");
bool rejected{};
try {
authoring.replace_external_schemas(invalid[index]);
} catch (const std::exception&) {
rejected = true;
}
check(rejected && authoring.schemas().manifest() == previous_registry,
"Cached-schema validation uses the same contract and preserves the registry");
}
std::cout << "Valid v2 schema and atomic rejection of " << invalid.size()
<< " malformed metadata generations passed\n";
fs::remove_all(root);
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
std::error_code ignored;
fs::remove_all(root, ignored);
return 1;
}
}
} // namespace
#ifdef _WIN32
int wmain(int argc, wchar_t** argv) {
return run_utf8_main(argc, argv, test_main);
}
#else
int main(int argc, char** argv) {
return test_main(argc, argv);
}
#endif
+50
View File
@@ -0,0 +1,50 @@
#include <faset/core/io.hpp>
#include <iostream>
// Native stand-in for CMake and SchemaExporter. Tests exercise the real
// asynchronous BuildService and publication code without compiling a game.
namespace fs = std::filesystem;
using namespace faset;
int tool_main(int argc, char** argv) {
try {
if (argc == 3 && std::string_view(argv[1]) == "--output") {
atomic_write_json(path_from_utf8(argv[2]), read_json("schema-fixture.json"));
return 0;
}
if (argc > 2 && std::string_view(argv[1]) == "--build")
return 0;
fs::path build;
for (int i = 1; i + 1 < argc; ++i)
if (std::string_view(argv[i]) == "-B")
build = path_from_utf8(argv[i + 1]);
if (build.empty())
throw std::runtime_error("Fixture expects CMake configure or SchemaExporter arguments");
fs::create_directories(build / "shaders");
atomic_write(build / "CMakeCache.txt", "Native schema publication fixture\n");
const auto self = fs::absolute(path_from_utf8(argv[0]));
#ifdef _WIN32
constexpr auto suffix = ".exe";
#else
constexpr auto suffix = "";
#endif
for (const auto* target : {"faset_player", "faset_schema_exporter"})
fs::copy_file(self, build / (std::string(target) + suffix),
fs::copy_options::overwrite_existing);
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
for (const auto* extension : {".spv", ".reflection.json"})
atomic_write(build / "shaders" / (std::string(entry) + extension), "fixture\n");
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
#ifdef _WIN32
int wmain(int argc, wchar_t** argv) {
return run_utf8_main(argc, argv, tool_main);
}
#else
int main(int argc, char** argv) {
return tool_main(argc, argv);
}
#endif