Checkpoint 1: implement native subsystems and begin the gameplay manual
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests actual GLB bundle publication without requiring Blender's GUI/Python runtime."""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import tempfile
|
||||
import sys
|
||||
sys.dont_write_bytecode = True
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
MODULE = Path(__file__).resolve().parents[1] / "tools/blender_addon/bundle.py"
|
||||
spec = importlib.util.spec_from_file_location("faset_bundle", MODULE)
|
||||
bundle = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(bundle)
|
||||
|
||||
|
||||
def glb(path, identity, name="Door", duplicate=False):
|
||||
nodes = [{"name": name, "extras": {"faset_id": identity}}]
|
||||
if duplicate:
|
||||
nodes.append(nodes[0].copy())
|
||||
doc = {"asset": {"version": "2.0"}, "scene": 0, "scenes": [{"nodes": [0]}], "nodes": nodes}
|
||||
raw = json.dumps(doc).encode()
|
||||
raw += b" " * (-len(raw) % 4)
|
||||
path.write_bytes(struct.pack("<IIIII", 0x46546C67, 2, 20 + len(raw), len(raw), 0x4E4F534A) + raw)
|
||||
|
||||
|
||||
class BundleTests(unittest.TestCase):
|
||||
def test_atomic_roundtrip_and_duplicate_failure(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
identity, asset_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
source, out = root / "scene.glb", root / "published"
|
||||
glb(source, identity)
|
||||
first = bundle.publish_bundle(source, out, asset_id, "fixture")
|
||||
payload = out / first["files"][0]["path"]
|
||||
self.assertEqual(hashlib.sha256(payload.read_bytes()).hexdigest(), first["files"][0]["sha256"])
|
||||
self.assertEqual(json.loads((out / "manifest.json").read_text()), first)
|
||||
glb(source, identity, name="Renamed door")
|
||||
second = bundle.publish_bundle(source, out, asset_id, "fixture")
|
||||
self.assertNotEqual(first["generation"], second["generation"])
|
||||
self.assertEqual(first["outputs"][0]["source_id"], second["outputs"][0]["source_id"])
|
||||
self.assertTrue(payload.exists(), "old immutable payload prematurely destroyed")
|
||||
previous = (out / "manifest.json").read_bytes()
|
||||
glb(source, identity, duplicate=True)
|
||||
with self.assertRaisesRegex(ValueError, "DuplicateSourceId"):
|
||||
bundle.publish_bundle(source, out, asset_id, "fixture")
|
||||
self.assertEqual((out / "manifest.json").read_bytes(), previous)
|
||||
source.write_bytes(b"broken")
|
||||
with self.assertRaises(ValueError):
|
||||
bundle.publish_bundle(source, out, asset_id, "fixture")
|
||||
self.assertEqual((out / "manifest.json").read_bytes(), previous)
|
||||
|
||||
def test_rejects_external_uri_before_publication(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
raw = json.dumps({"asset": {"version": "2.0"}, "buffers": [{"uri": "missing.bin", "byteLength": 4}]}).encode()
|
||||
raw += b" " * (-len(raw) % 4)
|
||||
source = root / "source.glb"
|
||||
source.write_bytes(struct.pack("<IIIII", 0x46546C67, 2, 20 + len(raw), len(raw), 0x4E4F534A) + raw)
|
||||
with self.assertRaisesRegex(ValueError, "embedded"):
|
||||
bundle.publish_bundle(source, root / "out", str(uuid.uuid4()), "fixture")
|
||||
self.assertFalse((root / "out" / "manifest.json").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
#include <faset/assets/asset_pipeline.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <bit>
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace faset::assets;
|
||||
namespace fs=std::filesystem;
|
||||
namespace {
|
||||
void require(bool value,const std::string& message){if(!value)throw std::runtime_error(message);}
|
||||
void u32(std::vector<unsigned char>& data,std::uint32_t value){for(int i=0;i<4;++i)data.push_back(static_cast<unsigned char>(value>>(8*i)));}
|
||||
void save(const fs::path& file,const std::vector<unsigned char>& data){std::ofstream out(file,std::ios::binary);out.write(reinterpret_cast<const char*>(data.data()),static_cast<std::streamsize>(data.size()));}
|
||||
void save(const fs::path& file,const std::string& text){std::ofstream(file,std::ios::binary)<<text;}
|
||||
std::vector<unsigned char> geometry(float x){std::vector<unsigned char> bin;for(float f:{x,0.f,0.f,1.f,0.f,0.f,0.f,1.f,0.f})u32(bin,std::bit_cast<std::uint32_t>(f));for(unsigned char c:{0,0,1,0,2,0})bin.push_back(c);return bin;}
|
||||
Json document(const std::string& name,bool stable,bool second,float x=0) {
|
||||
Json node{{"name",name},{"mesh",0}};if(stable)node["extras"]={{"faset_id","node-door"}};
|
||||
Json j={{"asset",{{"version","2.0"}}},{"scene",0},{"scenes",Json::array({{{"nodes",Json::array({0})}}})},{"nodes",Json::array({node})},{"buffers",Json::array({{{"byteLength",42}}})},{"bufferViews",Json::array({{{"buffer",0},{"byteOffset",0},{"byteLength",36},{"target",34962}},{{"buffer",0},{"byteOffset",36},{"byteLength",6},{"target",34963}}})},{"accessors",Json::array({{{"bufferView",0},{"componentType",5126},{"count",3},{"type","VEC3"},{"min",{std::min(0.f,x),0,0}},{"max",{std::max(1.f,x),1,0}}},{{"bufferView",1},{"componentType",5123},{"count",3},{"type","SCALAR"}}})},{"meshes",Json::array({{{"name","Triangle"},{"extras",{{"faset_id","mesh-triangle"}}},{"primitives",Json::array({{{"attributes",{{"POSITION",0}}},{"indices",1},{"material",0}}})}}})},{"materials",Json::array({{{"name","Red"},{"pbrMetallicRoughness",{{"baseColorFactor",{1.0,0.2,0.1,1.0}},{"metallicFactor",0.2},{"roughnessFactor",0.6}}}}})}};
|
||||
if(second){j["nodes"].push_back({{"name","Handle"},{"mesh",0},{"extras",{{"faset_id","node-handle"}}}});j["scenes"][0]["nodes"].push_back(1);}
|
||||
return j;
|
||||
}
|
||||
void glb(const fs::path& path,const std::string& name="Door",bool stable=true,bool second=false,float x=0) {
|
||||
auto json=document(name,stable,second,x).dump();while(json.size()%4)json+=' ';
|
||||
auto binary=geometry(x);while(binary.size()%4)binary.push_back(0);
|
||||
std::vector<unsigned char> result;u32(result,0x46546c67);u32(result,2);u32(result,static_cast<std::uint32_t>(12+8+json.size()+8+binary.size()));u32(result,static_cast<std::uint32_t>(json.size()));u32(result,0x4e4f534a);result.insert(result.end(),json.begin(),json.end());u32(result,static_cast<std::uint32_t>(binary.size()));u32(result,0x004e4942);result.insert(result.end(),binary.begin(),binary.end());save(path,result);
|
||||
}
|
||||
void success(const ImportResult& result){if(!result.ok()){std::string text="Import failed: ";for(const auto& d:result.diagnostics)text+=d+"; ";throw std::runtime_error(text);}}
|
||||
}
|
||||
int main() {
|
||||
const auto root=fs::temp_directory_path()/("faset-assets-test-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
fs::create_directories(root);
|
||||
try {
|
||||
AssetPipeline pipeline(root/"cache");const auto source=root/"door.glb";glb(source);
|
||||
auto first=pipeline.import_asset({source});success(first);require(!first.asset_id.empty(),"persistent identity missing");
|
||||
auto asset=pipeline.load_asset(first.asset_id);require(asset.meshes.size()==1&&asset.nodes.size()==1,"mesh/node extraction");require(asset.meshes[0].primitives[0].indices==std::vector<std::uint32_t>({0,1,2}),"index extraction");require(asset.meshes[0].primitives[0].vertices[0].normal[2]==1,"generated normal");require(asset.materials[0].base_color[1]>.19f&&asset.materials[0].metallic==.2f,"PBR extraction");
|
||||
const auto node_id=asset.nodes[0].id;
|
||||
const Json custom{{node_id,{{"gameplay",{{"locked",true}}},{"physics",{{"mass",12}}},{"material","custom-brass"}}}};
|
||||
pipeline.set_overrides(first.asset_id,custom);
|
||||
auto unchanged=pipeline.import_asset({source});success(unchanged);require(unchanged.cache_hit&&unchanged.generation==first.generation,"content cache hit");
|
||||
glb(source,"Renamed panel",true,false,.25f);auto modified=pipeline.import_asset({source});success(modified);require(modified.asset_id==first.asset_id&&modified.generation!=first.generation,"stable asset identity and changed generation");
|
||||
asset=pipeline.load_asset(first.asset_id);require(asset.nodes[0].id==node_id&&asset.nodes[0].name=="Renamed panel","faset_id survives rename");require(asset.meshes[0].primitives[0].vertices[0].position[0]==.25f,"new binary geometry loaded");require(pipeline.overrides(first.asset_id)==custom,"reimport erased authoring overrides");
|
||||
glb(source,"Renamed panel",true,true,.25f);auto added=pipeline.import_asset({source});success(added);
|
||||
glb(source,"Renamed panel",true,false,.25f);auto removed=pipeline.import_asset({source});require(removed.status==ImportStatus::conflict&&!removed.removed_output_ids.empty(),"deletion must conflict");require(pipeline.load_asset(first.asset_id).generation==added.generation,"conflict replaced active generation");require(pipeline.overrides(first.asset_id)==custom,"conflict erased overrides");
|
||||
ImportRequest resolve{source};resolve.allow_removed_outputs=true;auto resolved=pipeline.import_asset(resolve);success(resolved);require(pipeline.load_asset(first.asset_id).nodes.size()==1,"explicit deletion resolution");
|
||||
const auto active=resolved.generation;
|
||||
save(source,std::string("not a GLB"));auto failed=pipeline.import_asset({source});require(failed.status==ImportStatus::failed,"invalid source accepted");require(pipeline.load_asset(first.asset_id).generation==active,"failed import replaced active");
|
||||
glb(source,"Renamed panel",true,false,.5f);ImportJob* job_ptr=nullptr;ImportJob job([&](const ImportProgress& p){if(p.fraction>=.9f)job_ptr->cancel();});job_ptr=&job;
|
||||
auto cancelled=pipeline.import_asset({source},job);require(cancelled.status==ImportStatus::cancelled,"cancel before commit failed");require(pipeline.load_asset(first.asset_id).generation==active,"cancel replaced active");
|
||||
ImportRequest changed_settings{source};changed_settings.settings={{"target","test-profile"}};auto settings=pipeline.import_asset(changed_settings);success(settings);require(settings.generation!=active,"recipe omitted from cache key");
|
||||
const auto plain=root/"ordinary.glb";glb(plain,"Ordinary",false);auto standard=pipeline.import_asset({plain});success(standard);require(!pipeline.load_asset(standard.asset_id).nodes[0].stable_source_id,"ordinary GLB wrongly marked stable source");
|
||||
glb(plain,"Renamed without ID",false);require(pipeline.import_asset({plain}).status==ImportStatus::conflict,"ambiguous rename silently matched");
|
||||
auto duplicate=document("Duplicate",true,true);duplicate["nodes"][1]["extras"]["faset_id"]="node-door";duplicate["buffers"][0]["uri"]="mesh.bin";save(root/"mesh.bin",geometry(0));save(root/"duplicate.gltf",duplicate.dump());require(pipeline.import_asset({root/"duplicate.gltf"}).status==ImportStatus::failed,"duplicate source ID accepted");
|
||||
auto external=document("External",true,false);external["buffers"][0]["uri"]="mesh.bin";external["images"]=Json::array({{{"uri","pixel.png"},{"mimeType","image/png"}}});external["textures"]=Json::array({{{"source",0}}});external["materials"][0]["pbrMetallicRoughness"]["baseColorTexture"]={{"index",0}};
|
||||
// Real 1x1 PNG payload, transparent pixel; importer owns encoded bytes.
|
||||
const std::vector<unsigned char> png={137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,11,73,68,65,84,120,156,99,96,0,2,0,0,5,0,1,165,246,69,64,0,0,0,0,73,69,78,68,174,66,96,130};
|
||||
save(root/"pixel.png",png);save(root/"external.gltf",external.dump());auto ext=pipeline.import_asset({root/"external.gltf"});success(ext);auto ext_asset=pipeline.load_asset(ext.asset_id);require(ext_asset.textures.size()==1&&ext_asset.textures[0].bytes.size()==png.size(),"external image not extracted");require(ext_asset.materials[0].base_color_texture==0,"material texture reference lost");
|
||||
save(root/"mesh.bin",geometry(.3f));auto dependent=pipeline.import_asset({root/"external.gltf"});success(dependent);require(dependent.generation!=ext.generation,"buffer dependency not invalidated");
|
||||
// A Blender bundle keeps its logical source stable while immutable payload paths change.
|
||||
const auto bundle_dir=root/"bundle";fs::create_directories(bundle_dir/"payload");
|
||||
glb(bundle_dir/"payload"/"first.glb","Bundle",true,false);
|
||||
Json bundle{{"schema_version",1},{"asset_id","bundle-asset"},{"files",Json::array({{{"path","payload/first.glb"},{"sha256",faset::sha256_file(bundle_dir/"payload"/"first.glb")}}})}};
|
||||
save(bundle_dir/"manifest.json",bundle.dump());auto bundle_first=pipeline.import_asset({bundle_dir/"manifest.json"});success(bundle_first);require(bundle_first.asset_id=="bundle-asset","bundle identity lost");
|
||||
glb(bundle_dir/"payload"/"second.glb","Bundle renamed",true,false,.4f);bundle["files"][0]={{"path","payload/second.glb"},{"sha256",faset::sha256_file(bundle_dir/"payload"/"second.glb")}};
|
||||
save(bundle_dir/"manifest.json",bundle.dump());auto bundle_second=pipeline.import_asset({bundle_dir/"manifest.json"});success(bundle_second);require(bundle_second.asset_id==bundle_first.asset_id&&bundle_second.generation!=bundle_first.generation,"bundle reimport identity/generation");
|
||||
bundle["files"][0]["sha256"]=std::string(64,'0');save(bundle_dir/"manifest.json",bundle.dump());require(pipeline.import_asset({bundle_dir/"manifest.json"}).status==ImportStatus::failed,"bundle checksum ignored");require(pipeline.load_asset("bundle-asset").generation==bundle_second.generation,"bad bundle replaced active");
|
||||
// Changing a dependency after it was snapshotted cannot publish mixed content.
|
||||
const auto dependency_active=pipeline.load_asset(ext.asset_id).generation;
|
||||
ImportJob mutate([&](const ImportProgress& progress){if(progress.fraction==.5f)save(root/"mesh.bin",geometry(.7f));});
|
||||
require(pipeline.import_asset({root/"external.gltf"},mutate).status==ImportStatus::failed,"concurrent dependency edit accepted");require(pipeline.load_asset(ext.asset_id).generation==dependency_active,"concurrent edit changed active");
|
||||
fs::remove_all(root/"cache");auto restored=pipeline.import_asset({source});success(restored);require(restored.asset_id==first.asset_id,"cleared cache changed AssetId");require(restored.manifest["settings"]==changed_settings.settings,"persisted recipe lost");require(pipeline.overrides(first.asset_id)==custom,"cleared cache lost authoring overrides");
|
||||
fs::remove_all(root);std::cout<<"assets: geometry/PBR/texture, GLB/glTF, cache, rename, deletion, overrides, failure, cancellation OK\n";return 0;
|
||||
} catch(const std::exception& e){std::cerr<<e.what()<<"\nFixtures retained: "<<root<<'\n';return 1;}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#include <faset/authoring/service.hpp>
|
||||
#include <faset/authoring/templates.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <iostream>
|
||||
|
||||
#define CHECK(x) do {if(!(x))throw std::runtime_error("Check failed at line "+std::to_string(__LINE__)+": " #x);}while(false)
|
||||
template<class Fn> void fails(Fn fn,const std::string& code) {try{fn();}catch(const faset::Error& error){CHECK(error.code()==code);return;}throw std::runtime_error("Expected error "+code);}
|
||||
int main() {
|
||||
using namespace faset;using namespace faset::authoring;
|
||||
const auto root=std::filesystem::temp_directory_path()/("faset-authoring-"+new_id());
|
||||
try {
|
||||
auto schemas=builtin_schemas();AuthoringService service(root,schemas);
|
||||
auto created=service.create("Courtyard",3);const std::string id=created["id"];
|
||||
auto first=make_entity(schemas,"Door");const std::string entity_id=first["id"],transform_id=first["components"][0]["id"];
|
||||
Json commands=Json::array({{{"op","entity.create"},{"entity",first}}});
|
||||
const auto after=service.transact(id,0,commands,"request-1");CHECK(after["revision"]==1);CHECK(after["scene"]["entities"].size()==1);
|
||||
CHECK(service.transact(id,0,commands,"request-1")==after);
|
||||
fails([&]{service.transact(id,0,commands);},"revision.conflict");
|
||||
fails([&]{service.transact(id,1,commands,"request-1");},"idempotency.conflict");
|
||||
auto bad=Json::array({{{"op","entity.rename"},{"entity",entity_id},{"name","Should not survive"}},{{"op","component.set"},{"entity",entity_id},{"component",transform_id},{"field","position"},{"value","not a vector"}}});
|
||||
fails([&]{service.transact(id,1,bad);},"validation.field_type");CHECK(service.query(id)==after);
|
||||
auto edited=service.transact(id,1,Json::array({{{"op","component.set"},{"entity",entity_id},{"component",transform_id},{"field","position"},{"value",{4,0,2}}}}));
|
||||
CHECK(edited["revision"]==2);CHECK(service.undo(id,2)["scene"]==after["scene"]);CHECK(service.redo(id,3)["scene"]==edited["scene"]);
|
||||
CHECK(service.save(id,"Scenes/courtyard.scene.json")["dirty"]==false);
|
||||
service.transact(id,4,Json::array({{{"op","entity.rename"},{"entity",entity_id},{"name","Дверь 世界"}}}));
|
||||
AuthoringService restarted(root,schemas);const auto recovered=restarted.open("Scenes/courtyard.scene.json",true);CHECK(recovered["scene"]["entities"][0]["name"]=="Дверь 世界");CHECK(recovered["dirty"]==true);
|
||||
atomic_write(root/"Scenes/courtyard.scene.json",read_text(root/"Scenes/courtyard.scene.json")+"\n");
|
||||
fails([&]{restarted.save(id);},"save.disk_conflict");
|
||||
// Parent cycles are rejected atomically; names never provide identity.
|
||||
fails([&]{service.transact(id,5,Json::array({{{"op","entity.reparent"},{"entity",entity_id},{"parent",entity_id}}}));},"entity.cycle");
|
||||
CHECK(service.query(id)["revision"]==5);
|
||||
// Unavailable extension data is retained, including fields unknown to this SDK.
|
||||
auto unknown=make_entity(schemas,"Plugin object");unknown["components"].push_back({{"id",new_id()},{"type","plugin.future"},{"version",5},{"fields",{{"unknown",Json::array({1,2,3})}}}});
|
||||
service.transact(id,5,Json::array({{{"op","entity.create"},{"entity",unknown}}}));CHECK(service.query(id)["scene"]["entities"][1]==unknown);
|
||||
// Nested template addresses remain valid after source rename and source reparent.
|
||||
auto source=make_scene("Door template");source["entities"].push_back(first);
|
||||
auto middle=make_scene("Nested");middle["instances"].push_back({{"id","nested"},{"source","door"}});
|
||||
auto outer=make_scene("Level");
|
||||
Json address={{"path",Json::array({"nested"})},{"object",entity_id},{"component",transform_id},{"field","position"}};
|
||||
outer["instances"].push_back({{"id","one"},{"source","middle"},{"overrides",Json::array({{{"address",address},{"value",{8,0,0}}}})}});
|
||||
outer["instances"].push_back({{"id","two"},{"source","middle"}});
|
||||
auto loader=[&](const std::string& name){return name=="door"?source:middle;};
|
||||
auto resolved=resolve_templates(outer,schemas,loader);CHECK(resolved.conflicts.empty());CHECK(resolved.scene["entities"].size()==2);
|
||||
CHECK(resolved.scene["entities"][0]["components"][0]["fields"]["position"]==Json::array({8,0,0}));
|
||||
CHECK(resolved.scene["entities"][1]["components"][0]["fields"]["position"]==Json::array({0,0,0}));
|
||||
const auto stable=resolved.scene["entities"][0]["id"];source["entities"][0]["name"]="Renamed";
|
||||
CHECK(resolve_templates(outer,schemas,loader).scene["entities"][0]["id"]==stable);
|
||||
source["entities"]=Json::array();CHECK(resolve_templates(outer,schemas,loader).conflicts.size()==1);CHECK(outer["instances"][0]["overrides"].size()==1);
|
||||
// Stable FieldId survives a label rename; incompatible migrations require an explicit decision.
|
||||
SchemaRegistry newer;newer.register_schema({{"id","sample.type"},{"name","Sample"},{"version",2},{"fields",{{"speed",{{"id","speed"},{"name","Movement speed"},{"type","number"},{"default",2}}},{"enabled",{{"type","boolean"},{"default",true}}}}}});
|
||||
newer.add_migration("sample.type",1,{{"speed",{{"scale",0.01}}}});
|
||||
const auto migrated=newer.migrate_component({{"id",new_id()},{"type","sample.type"},{"version",1},{"fields",{{"speed",300},{"unrecognized","preserve"}}}});
|
||||
CHECK(migrated["fields"]["speed"]==3.0);CHECK(migrated["fields"]["enabled"]==true);CHECK(migrated["fields"]["unrecognized"]=="preserve");
|
||||
std::filesystem::remove_all(root);std::cout<<"Authoring transactions, conflict/retry, Undo, recovery, unknown fields, nested IDs and migrations passed\n";return 0;
|
||||
} catch(const std::exception& error) {std::filesystem::remove_all(root);std::cerr<<error.what()<<'\n';return 1;}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/error.hpp>
|
||||
#include <iostream>
|
||||
#include <set>
|
||||
|
||||
#define CHECK(x) do { if(!(x)) throw std::runtime_error("Check failed: " #x); } while(false)
|
||||
int main() {
|
||||
const auto directory=std::filesystem::temp_directory_path()/("faset-core-"+faset::new_id());
|
||||
try {
|
||||
CHECK(faset::sha256("")=="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
CHECK(faset::sha256("abc")=="ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
CHECK(faset::sha256(std::string(1000000,'a'))=="cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
|
||||
std::set<std::string> ids;
|
||||
for(int i=0;i<1000;++i) { const auto id=faset::new_id();CHECK(id.size()==36);CHECK(id[14]=='4');CHECK(ids.insert(id).second); }
|
||||
faset::atomic_write(directory/"state.json","{\"value\":1}");
|
||||
faset::atomic_write_json(directory/"state.json",{{"value",2},{"text","Привет 世界"}});
|
||||
CHECK(faset::read_json(directory/"state.json").at("value")==2);
|
||||
CHECK(faset::sha256_file(directory/"state.json")==faset::sha256(faset::read_text(directory/"state.json")));
|
||||
CHECK(faset::project_path(directory,"assets/../state.json")==directory/"state.json");
|
||||
bool rejected=false;try { faset::project_path(directory,"../escape"); } catch(const faset::Error&) {rejected=true;} CHECK(rejected);
|
||||
rejected=false;try { faset::project_path(directory,directory/"state.json"); } catch(const faset::Error&) {rejected=true;} CHECK(rejected);
|
||||
std::filesystem::remove_all(directory);
|
||||
std::cout<<"Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path boundaries passed\n";
|
||||
return 0;
|
||||
} catch(const std::exception& error) {std::filesystem::remove_all(directory);std::cerr<<error.what()<<'\n';return 1;}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <faset/render/render_graph.hpp>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace faset::render;
|
||||
void require(bool test,const char* message){if(!test)throw std::runtime_error(message);}
|
||||
int main(int argc,char** argv){try{
|
||||
if(argc>1&&std::string(argv[1])=="--unit"){
|
||||
int count{};RenderGraph invalid;invalid.add("consumer",{"missing"},{},[&]{++count;});bool caught{};try{invalid.execute();}catch(const std::runtime_error&){caught=true;}require(caught&&count==0,"Graph must validate before side effects");RenderGraph graph;graph.import("external");graph.add("first",{"external"},{"color"},[&]{require(count==0,"Pass order");++count;});graph.add("second",{"color"},{},[&]{++count;});graph.execute();require(count==2,"Pass execution count");auto t=transform({2,3,4},{},{2,3,4});require(t[12]==2&&t[13]==3&&t[14]==4,"Transform translation");auto m=multiply(identity,t);require(m==t,"Matrix multiplication identity");require(cube_mesh()->indices.size()==36,"Cube triangle topology");std::cout<<"Render graph and math contracts passed\n";return 0;
|
||||
}
|
||||
bool visible=argc>1&&std::string(argv[1])=="--visible";
|
||||
Renderer renderer({320,240,"Faset render validation",!visible,true});
|
||||
Snapshot scene;scene.eye={4,3,5};scene.view_projection=multiply(perspective(.85f,320.f/240.f,.1f,100),look_at(scene.eye,{0,0,0}));scene.draws.push_back({cube_mesh(),transform({0,0,0}),{.2f,.65f,.95f,1},.4f,.15f,true});scene.draws.push_back({cube_mesh(),transform({0,-1,0},{},{8,.2f,8}),{.45f,.48f,.5f,1},.8f,0,true});scene.ui_quads.push_back({8,8,70,16,{.8f,.1f,.15f,1}});
|
||||
auto texture=std::make_shared<Texture>();texture->width=texture->height=1;texture->rgba={20,220,40,255};scene.ui_quads.push_back({260,8,40,20,{1,1,1,1},texture});
|
||||
renderer.render(scene);require(renderer.stats().validation_errors==0,"Vulkan validation reported an error");auto pixels=renderer.pixels();require(pixels.size()==320*240*4,"Readback dimensions");auto index=(10*320+10)*4;require(pixels[index]>190&&pixels[index+1]<50,"Colored UI pixel");index=(10*320+270)*4;require(pixels[index]<30&&pixels[index+1]>200,"Textured UI pixel");
|
||||
auto shadowed=pixels;
|
||||
if(argc>2)renderer.capture(std::string(argv[2])+".shadowed.ppm");
|
||||
for(auto& draw:scene.draws)draw.cast_shadow=false;
|
||||
renderer.render(scene);pixels=renderer.pixels();std::size_t shadow_difference{};for(std::size_t i=0;i<pixels.size();i+=4)if(pixels[i]>shadowed[i]+8)++shadow_difference;if(shadow_difference<=20){std::cerr<<"Shadow difference pixels: "<<shadow_difference<<"\n";if(argc>2)renderer.capture(std::string(argv[2])+".unshadowed.ppm");}require(shadow_difference>20,"Directional shadow must darken rendered surface pixels");
|
||||
for(auto& draw:scene.draws)draw.cast_shadow=true;
|
||||
std::string reload_error;require(renderer.reload_shaders(reload_error),"Compatible shader pipeline reload");
|
||||
texture->rgba={40,30,230,255};++texture->revision;renderer.render(scene);pixels=renderer.pixels();require(pixels[index+2]>220,"Texture revision upload");
|
||||
if(argc>2)renderer.capture(argv[2]);renderer.resize(400,300);renderer.poll_events();renderer.render(scene);require(renderer.width()==400&&renderer.height()==300,"Render target resize");require(renderer.stats().validation_errors==0,"Resize validation error");
|
||||
std::cout<<"Vulkan frame, shadow/PBR, atlas upload, readback and resize passed on "<<renderer.stats().device<<'\n';
|
||||
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}return 0;}
|
||||
@@ -0,0 +1,89 @@
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include "Gameplay.hpp"
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace faset::runtime;
|
||||
using Json=nlohmann::json;
|
||||
namespace {
|
||||
void check(bool result,const char* text){if(!result)throw std::runtime_error(text);}
|
||||
void near(float actual,float expected,float tolerance,const char* text){check(std::abs(actual-expected)<tolerance,text);}
|
||||
template<class F>void rejects(F&& fn,const char* message){bool caught=false;try{fn();}catch(const std::exception&){caught=true;}check(caught,message);}
|
||||
Json component(std::string type,Json fields=Json::object()){return {{"id",type+"-id"},{"type",type},{"version",1},{"fields",fields}};}
|
||||
Json entity(std::string id,float y=0){return {{"id",id},{"name",id},{"parent",nullptr},{"components",Json::array({component("faset.transform",{{"position",{0,y,0}}})})}};}
|
||||
Json scene(int dim=2){return {{"format","faset.scene"},{"version",1},{"id","test-scene"},{"name","Test"},{"dimension",dim},{"entities",Json::array()},{"instances",Json::array()}};}
|
||||
void physics(int dimension){
|
||||
Runtime world;auto doc=scene(dimension);auto floor=entity("ground",-0.5f);auto falling=entity("falling",4);
|
||||
auto type=dimension==2?"faset.rigid_body_2d":"faset.rigid_body_3d";
|
||||
Json extents=dimension==2?Json{10,0.5}:Json{10,0.5,10};
|
||||
floor["components"].push_back(component(type,{{"body_type","static"},{"half_extents",extents}}));
|
||||
falling["components"].push_back(component(type));doc["entities"]={floor,falling};world.load(doc);auto h=world.find("falling");
|
||||
bool contact=false;
|
||||
for(int i=0;i<240;++i){world.advance(1.0/60);for(const auto& event:world.collisions())contact=contact||event.began;}
|
||||
near(world.transform(h).position[1],0.5f,0.09f,"body must fall and settle on actual solver floor");check(contact,"native contact event must be delivered");
|
||||
auto pose=world.transform(h);pose.position[1]=6;world.teleport(h,pose);
|
||||
near(world.presentation(h).position[1],6,0.0001f,"teleport resets interpolation");
|
||||
rejects([&]{world.setTransform(h,pose);},"physics transform cannot be casually overwritten");
|
||||
world.applyImpulse(h,{0,2,0});check(world.velocity(h)[1]>0,"impulse changes solver velocity");
|
||||
}
|
||||
void lifecycle(){
|
||||
Runtime world;std::vector<std::string> events;bool spawned=false;float presented=-1;int pressedTicks=0;
|
||||
Behavior behavior;
|
||||
behavior.onStart=[&](Runtime&,EntityHandle,double){events.push_back("start");};
|
||||
behavior.fixedUpdate=[&](Runtime& r,EntityHandle h,double){events.push_back("fixed");if(r.input().jumpPressed)++pressedTicks;auto t=r.transform(h);t.position[0]+=1;r.setTransform(h,t);if(!spawned){r.spawn(entity("spawned"));spawned=true;}};
|
||||
behavior.update=[&](Runtime&,EntityHandle,double){events.push_back("update");};
|
||||
behavior.lateUpdate=[&](Runtime& r,EntityHandle h,double){events.push_back("late");presented=r.presentation(h).position[0];};
|
||||
behavior.onDestroy=[&](Runtime& r,EntityHandle h,double){check(r.valid(h),"OnDestroy still sees a valid handle");events.push_back("destroy");};
|
||||
world.registerBehavior("test.behavior",behavior);auto doc=scene();auto object=entity("main");object["components"].push_back(component("test.behavior"));doc["entities"].push_back(object);world.load(doc);
|
||||
check(events==std::vector<std::string>{"start"},"load runs OnStart once");
|
||||
world.advance(1.0/120,{0,0,true,false});check(!world.find("spawned"),"zero-tick frame applies no structural commands");
|
||||
world.advance(1.0/60);check(!world.find("spawned"),"FixedUpdate spawn must wait until next tick");near(presented,0.5f,0.001f,"LateUpdate receives interpolated transform");check(pressedTicks==1,"input edge preserved across zero-tick frame");
|
||||
world.advance(3.0/60);check(bool(world.find("spawned")),"spawn appears next tick");check(pressedTicks==1,"edge not repeated in catchup ticks");
|
||||
auto old=world.find("main");world.destroy(old);check(world.valid(old),"destroy deferred");world.singleStep();check(!world.valid(old),"handle invalid after removal");
|
||||
check(events.back()=="destroy","destroy lifecycle runs exactly at barrier");
|
||||
world.spawn(entity("replacement"));world.singleStep();check(!world.valid(old),"reused slot never revives a stale handle");
|
||||
auto replacement=world.find("replacement");world.load(doc);check(!world.valid(replacement),"load creates a new session");
|
||||
// Ensure captured state remains alive while Runtime's destructor calls OnDestroy.
|
||||
world.clear();
|
||||
}
|
||||
void clockAndValidation(){
|
||||
Runtime world;auto doc=scene();doc["entities"].push_back(entity("object"));world.load(doc);
|
||||
auto stats=world.advance(1.0);check(stats.fixedTicks==4,"catchup bounded to four ticks");check(stats.droppedTime>0.9,"excess time reported");check(stats.interpolationAlpha>=0&&stats.interpolationAlpha<1,"interpolation fraction bounded");
|
||||
auto tick=stats.tick;world.setPaused(true);world.advance(100);check(world.snapshot().tick==tick,"pause does not accumulate");world.singleStep();check(world.snapshot().tick==tick+1,"single-step advances exactly once");world.setPaused(false);check(world.advance(0).fixedTicks==0,"resume does not catch up pause");
|
||||
auto old=world.find("object");auto invalid=doc;invalid["entities"][0]["parent"]="object";rejects([&]{world.load(invalid);},"reject hierarchy cycle");check(world.valid(old),"invalid load preserves old world");
|
||||
invalid=doc;invalid["entities"][0]["components"].push_back(component("faset.rigid_body_3d"));rejects([&]{world.load(invalid);},"reject physics dimension mismatch");
|
||||
rejects([&]{world.advance(-1);},"reject negative time");
|
||||
world.addComponent(old,component("faset.sprite"));check(!world.snapshot().entities[0].sprite,"component addition deferred");world.singleStep();check(world.snapshot().entities[0].sprite.has_value(),"component added at barrier");world.removeComponent(old,"faset.sprite");world.singleStep();check(!world.snapshot().entities[0].sprite,"component removed at barrier");
|
||||
Runtime other;other.load(doc);check(!other.valid(old),"handle cannot cross worlds");
|
||||
auto schema=faset::gameplay::schema();check(schema.size()==2,"sample has explicit metadata without world");
|
||||
}
|
||||
void structuralFailuresAndCallbacks(){
|
||||
Runtime world;auto doc=scene();doc["entities"].push_back(entity("object"));world.load(doc);auto h=world.find("object");
|
||||
world.addComponent(h,component("faset.sprite",{{"size",{-1,2}}}));world.singleStep();check(!world.snapshot().entities[0].sprite,"invalid deferred component leaves entity unchanged");check(!world.diagnostics().empty(),"invalid deferred command reports diagnostic");
|
||||
auto zero=world.transform(h);zero.scale[0]=0;world.setTransform(h,zero);world.addComponent(h,component("faset.rigid_body_2d"));world.singleStep();rejects([&]{world.fields(h,"faset.rigid_body_2d");},"invalid runtime collider scale must not half-add component");
|
||||
zero.scale[0]=1;world.setTransform(h,zero);world.addComponent(h,component("faset.rigid_body_2d"));world.singleStep();check(world.velocity(h)[1]<0,"deferred body runs actual physics");
|
||||
world.removeComponent(h,"faset.rigid_body_2d");world.singleStep();rejects([&]{world.velocity(h);},"removed physics adapter no longer accessible");
|
||||
world.destroy(h);world.destroy(h);world.singleStep();check(!world.valid(h),"repeated deferred destroy is safe");
|
||||
rejects([&]{world.advance(std::numeric_limits<double>::quiet_NaN());},"nonfinite time rejected");
|
||||
rejects([&]{world.advance(0,{std::numeric_limits<float>::infinity(),0,false,false});},"nonfinite input rejected");
|
||||
Runtime callbacks;std::vector<std::string> order;
|
||||
Behavior b;b.onStart=[&](Runtime& r,EntityHandle,double){order.push_back("start");rejects([&]{r.singleStep();},"OnStart cannot recursively advance");};
|
||||
b.fixedUpdate=[&](Runtime&,EntityHandle,double){order.push_back("fixed");throw std::runtime_error("intentional callback failure");};
|
||||
b.update=[&](Runtime&,EntityHandle,double){order.push_back("update");};
|
||||
b.lateUpdate=[&](Runtime& r,EntityHandle h,double){order.push_back("late");auto p=r.presentation(h);p.position[2]=9;r.setPresentation(h,p);};
|
||||
callbacks.registerBehavior("test",b);auto object=entity("callbacks");object["components"].push_back(component("test"));doc["entities"]={object};callbacks.load(doc);callbacks.singleStep();
|
||||
check(order==std::vector<std::string>{"start","fixed","update","late"},"callback failure does not skip remaining phases");check(callbacks.diagnostics().size()==1,"callback exception diagnostic");near(callbacks.snapshot().entities[0].transform.position[2],9,0.001f,"LateUpdate changes final presentation only");near(callbacks.transform(callbacks.find("callbacks")).position[2],0,0.001f,"presentation does not overwrite simulation");callbacks.clear();
|
||||
}
|
||||
void sampleGameplay(){
|
||||
Runtime world;faset::gameplay::registerGameplay(world);auto doc=scene(3);auto door=entity("door");door["components"].push_back(component("gameplay.door",{{"speed",2.0}}));doc["entities"]={door};world.load(doc);
|
||||
world.advance(1.0/60,{0,0,false,true});for(int i=0;i<59;++i)world.advance(1.0/60);
|
||||
near(world.transform(world.find("door")).rotation[1],1.5707963f,0.001f,"sample door opens through real static gameplay callback");
|
||||
world.advance(1.0/60,{0,0,false,true});for(int i=0;i<59;++i)world.advance(1.0/60);
|
||||
near(world.transform(world.find("door")).rotation[1],0,0.001f,"sample door toggles closed");
|
||||
}
|
||||
}
|
||||
int main(){try{physics(2);physics(3);lifecycle();clockAndValidation();structuralFailuresAndCallbacks();sampleGameplay();std::cout<<"runtime contracts passed: actual Box2D/Box3D collisions, lifecycle, handles, interpolation, deferred mutation, catchup, pause, validation, gameplay\n";return 0;}catch(const std::exception& ex){std::cerr<<ex.what()<<'\n';return 1;}}
|
||||
Reference in New Issue
Block a user