Files
Faset_Engine/src/authoring/schema.cpp
T

304 lines
16 KiB
C++

#include <array>
#include <cmath>
#include <faset/authoring/schema.hpp>
#include <limits>
#include <set>
namespace faset::authoring {
void validate_field(const Json& value, const Json& descriptor) {
const auto kind = descriptor.value("type", std::string("any"));
bool valid = true;
if (kind == "number" || kind == "float")
valid = value.is_number() && std::isfinite(value.get<double>());
else if (kind == "integer" || kind == "int")
valid = value.is_number_integer();
else if (kind == "boolean" || kind == "bool")
valid = value.is_boolean();
else if (kind == "string" || kind == "asset_ref" || kind == "entity_ref")
valid = value.is_string();
else if (kind == "vec2" || kind == "vec3" || kind == "vec4" || kind == "color") {
const auto size = kind == "vec2" ? 2u : (kind == "vec3" ? 3u : 4u);
valid = value.is_array() && value.size() == size;
if (valid)
for (const auto& entry : value)
valid = valid && entry.is_number() && std::isfinite(entry.get<double>());
} else if (kind == "array")
valid = value.is_array();
else if (kind == "object")
valid = value.is_object();
else
require(kind == "any", "schema.field_type", "Unsupported schema field type: " + kind);
require(valid, "validation.field_type",
"Invalid value for field " + descriptor.value("id", std::string("?")) + " (expected " +
kind + ")");
if (value.is_number()) {
if (descriptor.contains("min"))
require(value.get<double>() >= descriptor["min"].get<double>(), "validation.minimum",
"Field is below its minimum");
if (descriptor.contains("max"))
require(value.get<double>() <= descriptor["max"].get<double>(), "validation.maximum",
"Field exceeds its maximum");
}
if (descriptor.contains("enum")) {
bool found = false;
for (const auto& option : descriptor["enum"])
found = found || option == value;
require(found, "validation.enum", "Field value is not an allowed choice");
}
}
void SchemaRegistry::register_schema(const Json& value) {
require(value.is_object() && value.contains("id") && value["id"].is_string() &&
value.contains("fields") && value["fields"].is_object(),
"schema.invalid", "Invalid component schema");
Json normalized = value;
const auto id = value.at("id").get<std::string>();
require(!id.empty(), "schema.invalid", "TypeId cannot be empty");
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()) {
require(field.is_object() && field.contains("default"), "schema.invalid",
"Each field requires a typed default");
require(field.value("id", key) == key, "schema.field_id",
"Field map keys must be stable FieldIds");
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);
}
if (auto found = schemas_.find(id); found != schemas_.end())
require(found->second == normalized, "schema.duplicate_type",
"A different schema is already registered for " + id);
schemas_[id] = std::move(normalized);
}
void SchemaRegistry::register_schemas(const Json& values) {
const auto& array = values.is_array() ? values : values.at("types");
auto candidate = *this;
for (const auto& schema : array)
candidate.register_schema(schema);
*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);
}
for (const auto& schema : types) {
if (!schema.contains("migrations"))
continue;
require(schema.at("migrations").is_array(), "migration.invalid",
"Migrations must be an array of version steps");
for (const auto& step : schema.at("migrations")) {
require(
step.is_object() && step.contains("from_version") &&
step.at("from_version").is_number_integer() && step.at("from_version") > 0 &&
step.at("from_version") < schema.value("version", 1) &&
step.contains("fields") && step.at("fields").is_object(),
"migration.invalid", "Migration requires a supported earlier version and fields");
for (const auto& [key, unused] : step.items())
require(key == "from_version" || key == "fields", "migration.invalid",
"Unsupported migration step property: " + key);
result.add_migration(schema.at("id"), step.at("from_version").get<int>(),
step.at("fields"));
}
}
return result;
}
bool SchemaRegistry::contains(const std::string& type) const {
return schemas_.contains(type);
}
Json SchemaRegistry::schema(const std::string& type) const {
const auto found = schemas_.find(type);
require(found != schemas_.end(), "schema.missing", "Component schema unavailable: " + type);
return found->second;
}
Json SchemaRegistry::manifest() const {
Json types = Json::array();
for (const auto& [id, type] : schemas_)
types.push_back(type);
return {{"format", "faset.schema"}, {"version", 1}, {"types", types}};
}
Json SchemaRegistry::default_fields(const std::string& type) const {
Json fields = Json::object();
const auto metadata = schema(type);
for (const auto& [id, field] : metadata["fields"].items())
fields[id] = field["default"];
return fields;
}
void SchemaRegistry::validate_component(const Json& component) const {
require(component.is_object() && component.contains("type") && component["type"].is_string() &&
component.contains("fields") && component["fields"].is_object(),
"component.invalid", "Invalid component record");
const auto type = component.at("type").get<std::string>();
if (!contains(type))
return;
const auto metadata = schema(type);
// Future or missing-module schemas are preserved, not interpreted with the wrong version.
if (component.value("version", 1) != metadata.value("version", 1))
return;
for (const auto& [id, value] : component["fields"].items())
if (metadata["fields"].contains(id))
validate_field(value, metadata["fields"][id]);
if (type == "faset.light") {
auto effective = default_fields(type);
effective.update(component.at("fields"));
if (effective.at("kind") == "spot")
require(effective.at("inner_angle").get<double>() <=
effective.at("outer_angle").get<double>(),
"validation.light_cone",
"Spotlight inner_angle must not exceed outer_angle");
}
}
void SchemaRegistry::add_migration(const std::string& type, int from_version, Json rules) {
require(contains(type) && from_version > 0 && from_version < schema(type).value("version", 1) &&
rules.is_object(),
"migration.invalid", "Migration requires a registered type and an earlier version");
require(!migrations_.contains({type, from_version}), "migration.duplicate",
"Migration already exists");
for (const auto& [field, rule] : rules.items()) {
require(!field.empty() && rule.is_object(), "migration.invalid",
"Migration fields require nonempty IDs and rule objects");
for (const auto& [operation, value] : rule.items()) {
require(operation == "default" || operation == "scale" || operation == "require_manual",
"migration.invalid", "Unsupported migration operation: " + operation);
if (operation == "scale")
require(value.is_number() && std::isfinite(value.get<double>()),
"migration.invalid", "Migration scale must be a finite number");
else if (operation == "require_manual")
require(value.is_boolean(), "migration.invalid",
"Migration require_manual must be a boolean");
}
}
migrations_[{type, from_version}] = std::move(rules);
}
Json SchemaRegistry::migrate_component(const Json& source) const {
Json result = source;
const auto type = result.at("type").get<std::string>();
if (!contains(type))
return result;
const auto current = schema(type).value("version", 1);
if (result.contains("version"))
require(result.at("version").is_number_integer() && result.at("version") > 0 &&
result.at("version") <= std::numeric_limits<int>::max(),
"migration.invalid", "Component version must be a positive supported integer");
auto version = result.value("version", 1);
if (version > current)
return result;
while (version < current) {
const auto found = migrations_.find({type, version});
require(found != migrations_.end(), "migration.required",
"Explicit migration required for " + type);
for (const auto& [field, rule] : found->second.items()) {
if (rule.contains("default") && !result["fields"].contains(field))
result["fields"][field] = rule["default"];
if (rule.contains("scale") && result["fields"].contains(field)) {
require(result["fields"][field].is_number(), "migration.type",
"Cannot scale a nonnumeric field");
const auto scaled =
result["fields"][field].get<double>() * rule["scale"].get<double>();
require(std::isfinite(scaled), "migration.nonfinite",
"Migration produced a non-finite field value");
result["fields"][field] = scaled;
}
if (rule.value("require_manual", false) && result["fields"].contains(field))
throw Error("migration.manual", "Field requires explicit manual migration",
{{"type", type}, {"field", field}});
}
result["version"] = ++version;
}
const auto metadata = schema(type);
for (const auto& [field, descriptor] : metadata["fields"].items())
if (!result["fields"].contains(field))
result["fields"][field] = descriptor["default"];
validate_component(result);
return result;
}
SchemaRegistry builtin_schemas() {
SchemaRegistry registry;
struct Transform {
std::array<float, 3> position, rotation, scale;
};
TypeRegistration<Transform>(registry, "faset.transform", "Transform")
.field("position", "Position", &Transform::position, std::array<float, 3>{0, 0, 0}, "vec3")
.field("rotation", "Rotation", &Transform::rotation, std::array<float, 3>{0, 0, 0}, "vec3",
{{"unit", "radians"}})
.field("scale", "Scale", &Transform::scale, std::array<float, 3>{1, 1, 1}, "vec3")
.commit();
auto add = [&](std::string id, std::string name, Json fields) {
registry.register_schema({{"id", id}, {"name", name}, {"version", 1}, {"fields", fields}});
};
auto field = [](std::string type, Json value) {
return Json{{"type", type}, {"default", value}};
};
add("faset.sprite", "Sprite",
{{"color", field("color", {0.65, 0.6, 0.85, 1.0})},
{"size", field("vec2", {1, 1})},
{"texture", field("asset_ref", "")},
{"layer", field("integer", 0)}});
add("faset.mesh", "Mesh",
{{"asset", field("asset_ref", "")},
{"color", field("color", {0.65, 0.65, 0.68, 1.0})},
{"primitive",
Json{{"type", "string"}, {"default", "cube"}, {"enum", {"cube", "plane", "asset"}}}}});
add("faset.camera", "Camera",
{{"fov", Json{{"type", "number"}, {"default", 60.0}, {"min", 1.0}, {"max", 179.0}}},
{"near", Json{{"type", "number"}, {"default", 0.1}, {"min", 0.001}}},
{"far", Json{{"type", "number"}, {"default", 1000.0}, {"min", 0.01}}}});
add("faset.light", "Light",
{{"kind", Json{{"type", "string"},
{"default", "directional"},
{"enum", {"directional", "point", "spot"}}}},
{"enabled", field("boolean", true)},
{"color", field("color", {1, 1, 1, 1})},
{"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}},
{"range", Json{{"type", "number"}, {"default", 10.0}, {"min", 0.001}}},
{"inner_angle", Json{{"type", "number"},
{"default", 0.35}, {"min", 0.0}, {"max", 1.55},
{"unit", "radians"}}},
{"outer_angle", Json{{"type", "number"},
{"default", 0.7}, {"min", 0.001}, {"max", 1.55},
{"unit", "radians"}}},
{"casts_shadow", field("boolean", true)},
{"shadow_priority", Json{{"type", "integer"},
{"default", 0},
{"min", std::numeric_limits<int>::min()},
{"max", std::numeric_limits<int>::max()}}}});
for (int dimension : {2, 3}) {
Json vector = dimension == 2 ? Json{0, 0} : Json{0, 0, 0};
Json extents = dimension == 2 ? Json{0.5, 0.5} : Json{0.5, 0.5, 0.5};
add("faset.rigid_body_" + std::to_string(dimension) + "d",
"Rigid Body " + std::to_string(dimension) + "D",
{{"body_type", Json{{"type", "string"},
{"default", "dynamic"},
{"enum", {"static", "dynamic", "kinematic"}}}},
{"half_extents", field(dimension == 2 ? "vec2" : "vec3", extents)},
{"linear_velocity", field(dimension == 2 ? "vec2" : "vec3", vector)},
{"density", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.001}}},
{"friction", Json{{"type", "number"}, {"default", 0.5}, {"min", 0.0}}},
{"restitution",
Json{{"type", "number"}, {"default", 0.0}, {"min", 0.0}, {"max", 1.0}}},
{"gravity_scale", field("number", 1.0)},
{"category_bits", Json{{"type", "integer"}, {"default", 1}, {"min", 0}}},
{"mask_bits", Json{{"type", "integer"}, {"default", 65535}, {"min", 0}}}});
}
return registry;
}
} // namespace faset::authoring