Checkpoint 1: implement native subsystems and begin the gameplay manual

This commit is contained in:
Emil
2026-09-18 03:01:30 +03:00
parent decf49084d
commit 903c97444b
73 changed files with 3932 additions and 6 deletions
+120
View File
@@ -0,0 +1,120 @@
#include <faset/authoring/schema.hpp>
#include <array>
#include <cmath>
#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");
require(value.value("version",1)>0,"schema.invalid","Schema version must be positive");
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;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);
}
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]);
}
void SchemaRegistry::add_migration(const std::string& type,int from_version,Json rules) {
require(from_version>0&&rules.is_object(),"migration.invalid","Invalid migration");
require(!migrations_.contains({type,from_version}),"migration.duplicate","Migration already exists");
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);
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");
result["fields"][field]=result["fields"][field].get<double>()*rule["scale"].get<double>();
}
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","Directional Light",{{"color",field("color",{1,1,1,1})},{"intensity",Json{{"type","number"},{"default",1.0},{"min",0.0}}}});
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;
}
}
+201
View File
@@ -0,0 +1,201 @@
#include <faset/authoring/service.hpp>
#include <faset/core/io.hpp>
#include <faset/core/hash.hpp>
#include <algorithm>
#include <cmath>
#include <set>
namespace faset::authoring {
namespace {
Json& entity(Json& scene,const std::string& id) {
for(auto& item:scene["entities"])if(item.at("id")==id)return item;
throw Error("entity.missing","Entity does not exist",{{"entity",id}});
}
Json& component(Json& item,const std::string& id) {
for(auto& value:item["components"])if(value.at("id")==id)return value;
throw Error("component.missing","Component does not exist",{{"component",id}});
}
std::string parent_id(const Json& item) {return item.contains("parent")&&!item["parent"].is_null()?item["parent"].get<std::string>():"";}
void check_revision(std::uint64_t current,std::uint64_t expected) {
if(current!=expected)throw Error("revision.conflict","Document changed since it was read",{{"expected",expected},{"current",current}});
}
bool finite_json(const Json& value) {
if(value.is_number_float())return std::isfinite(value.get<double>());
if(value.is_structured())for(const auto& child:value)if(!finite_json(child))return false;
return true;
}
}
Json make_scene(std::string name,int dimension) {
require(dimension==2||dimension==3,"scene.dimension","Scene dimension must be 2 or 3");
return {{"format","faset.scene"},{"version",1},{"id",new_id()},{"name",std::move(name)},{"dimension",dimension},{"entities",Json::array()},{"instances",Json::array()}};
}
Json make_entity(const SchemaRegistry& schemas,std::string name,const std::string& parent) {
Json transform={{"id",new_id()},{"type","faset.transform"},{"version",1},{"fields",schemas.default_fields("faset.transform")}};
return {{"id",new_id()},{"name",std::move(name)},{"parent",parent.empty()?Json(nullptr):Json(parent)},{"components",Json::array({transform})}};
}
void validate_scene(const Json& scene,const SchemaRegistry& schemas) {
require(scene.is_object()&&scene.value("format",std::string())=="faset.scene","scene.format","Expected a Faset scene");
require(scene.value("version",0)==1,"scene.version","Unsupported scene format version");
require(scene.contains("id")&&scene["id"].is_string()&&!scene["id"].get<std::string>().empty(),"scene.id","Scene requires a stable ID");
require(scene.contains("name")&&scene["name"].is_string(),"scene.name","Scene name must be text");
require(scene.value("dimension",0)==2||scene.value("dimension",0)==3,"scene.dimension","Scene dimension must be 2 or 3");
require(scene.contains("entities")&&scene["entities"].is_array(),"scene.entities","Scene entities must be an array");
require(finite_json(scene),"validation.finite","Scene contains a non-finite number");
std::set<std::string> ids;std::map<std::string,std::string> parents;
auto insert_id=[&](const Json& value) {require(value.is_string()&&!value.get<std::string>().empty(),"id.invalid","ID must be nonempty text");require(ids.insert(value.get<std::string>()).second,"id.duplicate","Duplicate document ID");};
for(const auto& item:scene["entities"]) {
require(item.is_object()&&item.contains("id")&&item.contains("name")&&item["name"].is_string(),"entity.invalid","Invalid entity record");
insert_id(item["id"]);parents[item["id"].get<std::string>()]=parent_id(item);
require(item.contains("components")&&item["components"].is_array(),"entity.components","Entity components must be an array");
std::set<std::string> types;
for(const auto& value:item["components"]) {
require(value.contains("id"),"component.id","Component requires stable ID");insert_id(value["id"]);schemas.validate_component(value);
require(types.insert(value.at("type").get<std::string>()).second,"component.duplicate_type","One component of each type is supported per entity");
}
}
for(const auto& [id,parent]:parents) {
std::set<std::string> visited{id};auto current=parent;
while(!current.empty()) {require(parents.contains(current),"entity.parent_missing","Parent entity is missing");require(visited.insert(current).second,"entity.cycle","Hierarchy contains a cycle");current=parents.at(current);}
}
if(scene.contains("instances")) {
require(scene["instances"].is_array(),"template.instances","Template instances must be an array");
for(const auto& instance:scene["instances"]) {
require(instance.contains("id")&&instance.contains("source")&&instance["source"].is_string(),"template.instance","Invalid template instance");
insert_id(instance["id"]);
}
}
}
AuthoringService::AuthoringService(std::filesystem::path root,SchemaRegistry schemas):root_(std::filesystem::absolute(std::move(root)).lexically_normal()),schemas_(std::move(schemas)) {std::filesystem::create_directories(root_);}
AuthoringService::State& AuthoringService::state(const std::string& id) {auto found=documents_.find(id);require(found!=documents_.end(),"document.missing","Document is not open");return found->second;}
const AuthoringService::State& AuthoringService::state(const std::string& id)const {auto found=documents_.find(id);require(found!=documents_.end(),"document.missing","Document is not open");return found->second;}
Json AuthoringService::summary(const State& value,bool include_data)const {
Json result={{"id",value.data.at("id")},{"name",value.data.at("name")},{"revision",value.revision},{"dirty",sha256(value.data.dump())!=value.saved_hash},{"path",value.path.generic_string()},{"can_undo",!value.undo.empty()},{"can_redo",!value.redo.empty()}};
if(include_data) result["scene"]=value.data;
return result;
}
void AuthoringService::journal(const State& value)const {
atomic_write_json(project_path(root_,std::filesystem::path(".faset/recovery")/(value.data.at("id").get<std::string>()+".json")),{{"format","faset.recovery"},{"version",1},{"path",value.path.generic_string()},{"revision",value.revision},{"saved_hash",value.saved_hash},{"disk_hash",value.disk_hash},{"scene",value.data}});
}
Json AuthoringService::create(std::string name,int dimension) {
std::lock_guard lock(mutex_);State value;value.data=make_scene(std::move(name),dimension);journal(value);
const auto id=value.data["id"].get<std::string>();documents_.emplace(id,std::move(value));return summary(state(id));
}
Json AuthoringService::open(const std::filesystem::path& relative,bool recover) {
std::lock_guard lock(mutex_);auto path=project_path(root_,relative);Json data=read_json(path);validate_scene(data,schemas_);
const auto id=data.at("id").get<std::string>();
if(documents_.contains(id)) {require(state(id).path==relative.lexically_normal(),"document.id_collision","Another open file has the same document ID");return summary(state(id));}
State value;value.data=data;value.path=relative.lexically_normal();value.saved_hash=sha256(data.dump());value.disk_hash=sha256_file(path);
const auto recovery=project_path(root_,std::filesystem::path(".faset/recovery")/(id+".json"));
if(recover&&std::filesystem::exists(recovery)) {
const auto recovered=read_json(recovery);
require(recovered.value("disk_hash",std::string())==value.disk_hash,"recovery.disk_conflict","Scene file changed since recovery was written");
validate_scene(recovered.at("scene"),schemas_);value.data=recovered.at("scene");value.revision=recovered.value("revision",0u);
}
for(auto& item:value.data["entities"])for(auto& component:item["components"])component=schemas_.migrate_component(component);
documents_.emplace(id,std::move(value));return summary(state(id));
}
Json AuthoringService::query(const std::string& id)const {std::lock_guard lock(mutex_);return summary(state(id));}
Json AuthoringService::documents()const {std::lock_guard lock(mutex_);Json result=Json::array();for(const auto& [id,value]:documents_)result.push_back(summary(value,false));return result;}
void AuthoringService::register_schemas(const Json& manifest) {std::lock_guard lock(mutex_);schemas_.register_schemas(manifest);}
void AuthoringService::apply(Json& scene,const Json& command) {
require(command.is_object()&&command.contains("op")&&command["op"].is_string(),"command.invalid","Command requires an operation name");
const auto op=command.at("op").get<std::string>();
if(op=="entity.create") {
Json value=command.contains("entity")&&command["entity"].is_object()?command["entity"]:make_entity(schemas_,command.value("name",std::string("Object")),command.value("parent",std::string()));
if(!value.contains("id")) value["id"]=new_id();
scene["entities"].push_back(std::move(value));
} else if(op=="entity.rename") {
entity(scene,command.at("entity").get<std::string>())["name"]=command.at("name");
} else if(op=="entity.delete") {
const auto id=command.at("entity").get<std::string>();entity(scene,id);
std::set<std::string> removed{id};bool changed=true;
while(changed) {changed=false;for(const auto& item:scene["entities"])if(removed.contains(parent_id(item)))changed=removed.insert(item.at("id").get<std::string>()).second||changed;}
auto& values=scene["entities"];values.erase(std::remove_if(values.begin(),values.end(),[&](const Json& value){return removed.contains(value.at("id").get<std::string>());}),values.end());
} else if(op=="entity.reparent") {
auto& value=entity(scene,command.at("entity").get<std::string>());
require(!command.value("keep_world",false),"transform.unsupported","World-preserving reparent requires the transform resolver");
const auto parent=command.value("parent",Json(nullptr));if(!parent.is_null())entity(scene,parent.get<std::string>());value["parent"]=parent;
} else if(op=="component.add") {
auto& value=entity(scene,command.at("entity").get<std::string>());const auto type=command.at("type").get<std::string>();
const auto metadata=schemas_.schema(type);Json fields=schemas_.default_fields(type);if(command.contains("fields"))fields.update(command["fields"]);
value["components"].push_back({{"id",command.value("id",new_id())},{"type",type},{"version",metadata.value("version",1)},{"fields",fields}});
} else if(op=="component.remove") {
auto& values=entity(scene,command.at("entity").get<std::string>())["components"];const auto id=command.at("component").get<std::string>();
auto found=std::find_if(values.begin(),values.end(),[&](const Json& value){return value.at("id")==id;});require(found!=values.end(),"component.missing","Component does not exist");values.erase(found);
} else if(op=="component.set") {
auto& value=component(entity(scene,command.at("entity").get<std::string>()),command.at("component").get<std::string>());
const auto field=command.at("field").get<std::string>();require(!field.empty(),"field.invalid","FieldId cannot be empty");value["fields"][field]=command.at("value");
} else if(op=="entity.duplicate") {
const auto id=command.at("entity").get<std::string>();entity(scene,id);
std::set<std::string> subtree{id};bool changed=true;
while(changed) {changed=false;for(const auto& item:scene["entities"])if(subtree.contains(parent_id(item)))changed=subtree.insert(item.at("id").get<std::string>()).second||changed;}
std::map<std::string,std::string> mapping;
for(const auto& item:scene["entities"])if(subtree.contains(item.at("id").get<std::string>())) {mapping[item.at("id")]=new_id();for(const auto& component:item["components"])mapping[component.at("id")]=new_id();}
Json duplicates=Json::array();
for(const auto& item:scene["entities"])if(subtree.contains(item.at("id").get<std::string>())) {
auto copy=item;copy["id"]=mapping.at(item.at("id").get<std::string>());const auto parent=parent_id(item);if(mapping.contains(parent))copy["parent"]=mapping.at(parent);
if(item.at("id")==id)copy["name"]=item.at("name").get<std::string>()+" Copy";
for(auto& component:copy["components"]) {
component["id"]=mapping.at(component.at("id").get<std::string>());const auto type=component.at("type").get<std::string>();
if(!schemas_.contains(type))continue;
const auto metadata=schemas_.schema(type);
for(auto& [field,value]:component["fields"].items())if(metadata["fields"].contains(field)&&metadata["fields"][field].value("type",std::string())=="entity_ref"&&value.is_string()&&mapping.contains(value.get<std::string>()))value=mapping.at(value.get<std::string>());
}
duplicates.push_back(std::move(copy));
}
for(auto& value:duplicates)scene["entities"].push_back(std::move(value));
} else if(op=="scene.rename")scene["name"]=command.at("name");
else if(op=="template.instance") {
Json value=command.at("instance");if(!value.contains("id"))value["id"]=new_id();if(!scene.contains("instances"))scene["instances"]=Json::array();scene["instances"].push_back(std::move(value));
} else if(op=="template.override"||op=="template.revert"||op=="template.suppress"||op=="template.add"||op=="template.reparent") {
auto& instances=scene["instances"];const auto id=command.at("instance").get<std::string>();
auto found=std::find_if(instances.begin(),instances.end(),[&](const Json& value){return value.at("id")==id;});require(found!=instances.end(),"template.missing","Instance not found");
const std::string key=op=="template.suppress"?"suppressed":op=="template.add"?"additions":op=="template.reparent"?"reparents":"overrides";
if(!found->contains(key)) (*found)[key]=Json::array();
auto& records=(*found)[key];
if(key=="overrides") {
const auto address=command.at("address");
auto old=std::find_if(records.begin(),records.end(),[&](const Json& value){return value.at("address")==address;});
if(old!=records.end())records.erase(old);
if(op!="template.revert")records.push_back({{"address",address},{"value",command.at("value")}});
} else records.push_back(command.at("value"));
} else throw Error("command.unknown","Unknown authoring command: "+op);
}
Json AuthoringService::transact(const std::string& id,std::uint64_t revision,const Json& operations,const std::string& key) {
std::lock_guard lock(mutex_);auto& current=state(id);require(operations.is_array()&&!operations.empty(),"transaction.empty","Transaction requires an array of operations");
const auto fingerprint=sha256(Json{{"revision",revision},{"operations",operations}}.dump());
if(!key.empty()&&current.requests.contains(key)) {
const auto& request=current.requests.at(key);require(request.first==fingerprint,"idempotency.conflict","Idempotency key was used with another payload");return request.second;
}
check_revision(current.revision,revision);State candidate=current;
for(const auto& operation:operations)apply(candidate.data,operation);
validate_scene(candidate.data,schemas_);
candidate.undo.push_back(current.data);if(candidate.undo.size()>100)candidate.undo.erase(candidate.undo.begin());candidate.redo.clear();++candidate.revision;
journal(candidate);auto result=summary(candidate);
if(!key.empty()) {if(candidate.requests.size()>=256)candidate.requests.erase(candidate.requests.begin());candidate.requests[key]={fingerprint,result};}
current=std::move(candidate);return result;
}
Json AuthoringService::history(const std::string& id,std::uint64_t revision,bool forward) {
std::lock_guard lock(mutex_);auto& current=state(id);check_revision(current.revision,revision);State candidate=current;
auto& source=forward?candidate.redo:candidate.undo;auto& target=forward?candidate.undo:candidate.redo;
require(!source.empty(),"history.empty",forward?"Nothing to redo":"Nothing to undo");target.push_back(candidate.data);candidate.data=source.back();source.pop_back();++candidate.revision;journal(candidate);current=std::move(candidate);return summary(current);
}
Json AuthoringService::undo(const std::string& id,std::uint64_t revision){return history(id,revision,false);}
Json AuthoringService::redo(const std::string& id,std::uint64_t revision){return history(id,revision,true);}
Json AuthoringService::save(const std::string& id,const std::filesystem::path& relative) {
std::lock_guard lock(mutex_);auto& current=state(id);const auto selected=relative.empty()?current.path:relative.lexically_normal();require(!selected.empty(),"save.path","Choose a scene path before saving");
const auto path=project_path(root_,selected);
if(std::filesystem::exists(path)) {
require(selected==current.path&&!current.disk_hash.empty(),"save.exists","Save As will not overwrite another file");
require(sha256_file(path)==current.disk_hash,"save.disk_conflict","File changed outside the Editor; reload or save to another path");
}
atomic_write_json(path,current.data);current.path=selected;current.saved_hash=sha256(current.data.dump());current.disk_hash=sha256_file(path);
journal(current);return summary(current);
}
Json AuthoringService::recovery_documents()const {
std::lock_guard lock(mutex_);Json result=Json::array();const auto path=project_path(root_,".faset/recovery");if(!std::filesystem::exists(path))return result;
for(const auto& entry:std::filesystem::directory_iterator(path))if(entry.is_regular_file()&&entry.path().extension()==".json") {
try {const auto value=read_json(entry.path());result.push_back({{"id",value.at("scene").at("id")},{"name",value.at("scene").at("name")},{"path",value.at("path")},{"dirty",sha256(value.at("scene").dump())!=value.value("saved_hash",std::string())}});}catch(const std::exception&) {result.push_back({{"error","Invalid recovery record"},{"file",entry.path().filename().string()}});}
}return result;
}
}
+99
View File
@@ -0,0 +1,99 @@
#include <faset/authoring/templates.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp>
#include <algorithm>
#include <set>
namespace faset::authoring {
namespace {
std::string scoped_id(const std::string& root,const Json& path,const std::string& source) {
const auto digest=sha256(Json::array({root,path,source}).dump());
return digest.substr(0,8)+"-"+digest.substr(8,4)+"-5"+digest.substr(13,3)+"-a"+digest.substr(17,3)+"-"+digest.substr(20,12);
}
struct Resolver {
const SchemaRegistry& schemas;
const SceneLoader& loader;
std::string root;
Json conflicts=Json::array();
std::set<std::string> sources;
void conflict(const Json& path,std::string code,const Json& record) {conflicts.push_back({{"instance_path",path},{"code",std::move(code)},{"record",record}});}
Json* target(Json& entities,const Json& path,const Json& address) {
Json full=path;for(const auto& entry:address.value("path",Json::array()))full.push_back(entry);
for(auto& item:entities)if(item.at("origin").at("path")==full&&item.at("origin").at("object")==address.at("object"))return &item;
return nullptr;
}
Json expand(const Json& scene,const Json& path) {
require(path.size()<=32,"template.depth","Maximum template nesting depth exceeded");
validate_scene(scene,schemas);
Json output=Json::array();std::map<std::string,std::string> ids;
for(const auto& item:scene["entities"]) {
const auto id=item.at("id").get<std::string>();ids[id]=path.empty()?id:scoped_id(root,path,id);
for(const auto& component:item["components"]) {const auto cid=component.at("id").get<std::string>();ids[cid]=path.empty()?cid:scoped_id(root,path,cid);}
}
for(const auto& source:scene["entities"]) {
Json item=source;item["id"]=ids.at(source.at("id").get<std::string>());
item["origin"]={{"path",path},{"object",source.at("id")},{"scene",scene.at("id")}};
if(source.contains("parent")&&!source["parent"].is_null())item["parent"]=ids.at(source["parent"].get<std::string>());
for(auto& component:item["components"]) {
const auto source_id=component.at("id").get<std::string>();component["id"]=ids.at(source_id);component["source_id"]=source_id;
const auto type=component.at("type").get<std::string>();if(!schemas.contains(type))continue;
const auto metadata=schemas.schema(type);
for(auto& [field,value]:component["fields"].items())if(metadata["fields"].contains(field)&&metadata["fields"][field].value("type",std::string())=="entity_ref"&&value.is_string()&&ids.contains(value.get<std::string>()))value=ids.at(value.get<std::string>());
}
output.push_back(std::move(item));
}
for(const auto& instance:scene.value("instances",Json::array())) {
Json nested_path=path;nested_path.push_back(instance.at("id"));const auto source_name=instance.at("source").get<std::string>();
Json expanded=Json::array();std::string source_id;
try {
const auto source=loader(source_name);source_id=source.at("id").get<std::string>();
require(sources.insert(source_id).second,"template.cycle","Template source cycle detected");
expanded=expand(source,nested_path);sources.erase(source_id);
} catch(const std::exception& error) {
if(!source_id.empty())sources.erase(source_id);
conflict(nested_path,"template.source_unavailable",{{"source",source_name},{"message",error.what()}});continue;
}
for(const auto& addition:instance.value("additions",Json::array())) {
Json item=addition;const auto id=item.at("id").get<std::string>();item["id"]=scoped_id(root,nested_path,id);
item["origin"]={{"path",nested_path},{"object",id},{"local",true}};
if(item.contains("parent")&&!item["parent"].is_null())item["parent"]=scoped_id(root,nested_path,item["parent"].get<std::string>());
for(auto& component:item["components"]) {const auto cid=component.at("id").get<std::string>();component["source_id"]=cid;component["id"]=scoped_id(root,nested_path,cid);}
expanded.push_back(std::move(item));
}
for(const auto& change:instance.value("overrides",Json::array())) {
const auto& address=change.at("address");auto* item=target(expanded,nested_path,address);
if(!item){conflict(nested_path,"override.object_missing",change);continue;}
auto found=std::find_if((*item)["components"].begin(),(*item)["components"].end(),[&](const Json& value){return value.at("source_id")==address.at("component");});
if(found==(*item)["components"].end()){conflict(nested_path,"override.component_missing",change);continue;}
const auto type=found->at("type").get<std::string>();const auto field=address.at("field").get<std::string>();
if(!schemas.contains(type)||!schemas.schema(type)["fields"].contains(field)){conflict(nested_path,"override.field_unavailable",change);continue;}
try {validate_field(change.at("value"),schemas.schema(type)["fields"][field]);(*found)["fields"][field]=change.at("value");}
catch(const std::exception& error){conflict(nested_path,"override.invalid",{{"change",change},{"message",error.what()}});}
}
std::set<std::string> suppressed;
for(const auto& address:instance.value("suppressed",Json::array())) {
auto* item=target(expanded,nested_path,address);if(item)suppressed.insert(item->at("id").get<std::string>());else conflict(nested_path,"suppression.object_missing",address);
}
bool changed=true;
while(changed) {changed=false;for(const auto& item:expanded)if(item.contains("parent")&&item["parent"].is_string()&&suppressed.contains(item["parent"].get<std::string>()))changed=suppressed.insert(item.at("id").get<std::string>()).second||changed;}
expanded.erase(std::remove_if(expanded.begin(),expanded.end(),[&](const Json& item){return suppressed.contains(item.at("id").get<std::string>());}),expanded.end());
for(const auto& reparent:instance.value("reparents",Json::array())) {
auto* item=target(expanded,nested_path,reparent.at("object"));
auto* parent=reparent.at("parent").is_null()?nullptr:target(expanded,nested_path,reparent.at("parent"));
if(!item||(!reparent.at("parent").is_null()&&!parent)){conflict(nested_path,"reparent.target_missing",reparent);continue;}
if(reparent.value("keep_world",false)){conflict(nested_path,"reparent.world_transform_required",reparent);continue;}
(*item)["parent"]=parent?parent->at("id"):Json(nullptr);
}
for(auto& item:expanded)output.push_back(std::move(item));
require(output.size()<=100000,"template.size","Resolved scene exceeds object limit");
}
return output;
}
};
}
ResolvedScene resolve_templates(const Json& scene,const SchemaRegistry& schemas,const SceneLoader& loader) {
Resolver resolver{schemas,loader,scene.at("id").get<std::string>()};resolver.sources.insert(scene.at("id").get<std::string>());
Json output=scene;output["entities"]=resolver.expand(scene,Json::array());output["instances"]=Json::array();
validate_scene(output,schemas);return {output,resolver.conflicts};
}
}