#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "building/Building.h" #include "building/BuildingDef.h" #include "building/Ghost.h" #include "building/HeightfieldSurface.h" #include "building/PlacementSystem.h" #include "building/PlacementWorld.h" #include "building/PlacementSession.h" #include "building/StaticMeshSurface.h" #include "map/Map.h" #include "map/TileLayer.h" #include #include #include using namespace eve::building; namespace { bool approxEq(float a, float b, float eps = 1e-4f) { return std::fabs(a - b) < eps; } } // namespace TEST_CASE("building.registry.registerAndJson") { BuildingRegistry::clear(); BuildingDefinition house; house.id = "house.wood"; house.displayName = "Wood House"; house.footprintW = 2; house.footprintH = 2; house.tags = {"house", "housing"}; house.cost["wood"] = 20; house.extra["mesh"] = "models/house.glb"; BuildingRegistry::registerBuilding(house); CHECK(BuildingRegistry::count() == 1); CHECK(BuildingRegistry::find("house.wood") != nullptr); CHECK_EQ(BuildingRegistry::find("house.wood")->footprintW, 2); CHECK(BuildingRegistry::find("house.wood")->hasTag("house")); CHECK_EQ(BuildingRegistry::find("house.wood")->getCost("wood"), 20); CHECK_EQ(BuildingRegistry::find("house.wood")->getExtra("mesh"), "models/house.glb"); int n = BuildingRegistry::loadFromJson(R"([ {"id":"road.straight","displayName":"Road","footprintW":1,"footprintH":1, "tags":["road"],"category":"infra","cost":{"gold":1}}, {"id":"dock","displayName":"Dock","footprintW":3,"footprintH":2, "requireTerrain":[2],"tags":["dock"],"rotationMode":"cardinal", "maxSurfaceSlopeDegrees":22.5,"maxSurfaceHeightDelta":0.75, "freeFootprintWidthCells":2.5,"freeFootprintHeightCells":0.75, "freeFootprintVertices":[-1,0,0,-0.25,1,0,0,0.25]} ])"); CHECK_EQ(n, 2); CHECK_EQ(BuildingRegistry::count(), 3); CHECK_EQ(BuildingRegistry::find("dock")->requireTerrain.size(), size_t(1)); CHECK_EQ(BuildingRegistry::find("dock")->requireTerrain[0], 2); CHECK(approxEq(BuildingRegistry::find("dock")->maxSurfaceSlopeDegrees, 22.5f)); CHECK(approxEq(BuildingRegistry::find("dock")->maxSurfaceHeightDelta, 0.75f)); CHECK(approxEq(BuildingRegistry::find("dock")->freeFootprintWidthCells, 2.5f)); CHECK(approxEq(BuildingRegistry::find("dock")->freeFootprintHeightCells, 0.75f)); CHECK_EQ(BuildingRegistry::find("dock")->freeFootprintVertices.size(), size_t{8}); BuildingRegistry::clear(); CHECK_EQ(BuildingRegistry::count(), 0); } TEST_CASE("building.world.placeOccupyConflict") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); PlacementSystem::ensureBuiltins(); BuildingDefinition hut; hut.id = "hut"; hut.footprintW = 2; hut.footprintH = 2; BuildingRegistry::registerBuilding(hut); PlacementWorld world(8, 8, 32.f); world.setId("map1"); CHECK(world.canPlace("hut", 1, 1, 0.f)); int id = world.placeAt("hut", 1, 1, 0.f); CHECK(id > 0); CHECK_EQ(world.getBuildingCount(), 1); CHECK(!world.isCellEmpty(1, 1)); CHECK(!world.isCellEmpty(2, 2)); CHECK(world.isCellEmpty(3, 1)); CHECK(!world.canPlace("hut", 2, 2, 0.f)); CHECK_EQ(world.canPlaceReason("hut", 2, 2, 0.f), "occupied"); CHECK(world.canPlace("hut", 3, 1, 0.f)); CHECK_EQ(PlacementSystem::events().size(), size_t(1)); CHECK_EQ(PlacementSystem::events()[0].action, "place"); CHECK_EQ(PlacementSystem::events()[0].buildingId, "hut"); BuildingRegistry::clear(); } TEST_CASE("building.world.snapAndGhost") { BuildingRegistry::clear(); PlacementSystem::ensureBuiltins(); BuildingDefinition tower; tower.id = "tower"; tower.footprintW = 1; tower.footprintH = 1; tower.snapMode = "grid"; BuildingRegistry::registerBuilding(tower); PlacementWorld world(10, 10, 32.f); world.setOrigin(0.f, 0.f); Ghost ghost; ghost.setBuildingId("tower"); ghost.setFromWorld(&world, 40.f, 70.f); CHECK_EQ(ghost.getCellX(), 1); // floor(40/32)=1 CHECK_EQ(ghost.getCellY(), 2); // floor(70/32)=2 CHECK(approxEq(ghost.getWorldX(), 32.f)); CHECK(approxEq(ghost.getWorldY(), 64.f)); CHECK(ghost.validate(&world)); int id = world.placeGhost(&ghost); CHECK(id > 0); CHECK_EQ(world.getBuildingCellX(id), 1); CHECK_EQ(world.getBuildingCellY(id), 2); BuildingRegistry::clear(); } TEST_CASE("building.world.rotationFootprint") { BuildingRegistry::clear(); PlacementSystem::ensureBuiltins(); BuildingDefinition hall; hall.id = "hall"; hall.footprintW = 3; hall.footprintH = 1; hall.rotationMode = "cardinal"; BuildingRegistry::registerBuilding(hall); PlacementWorld world(6, 6, 1.f); // 0°: occupies (0,0)(1,0)(2,0) CHECK(world.canPlace("hall", 0, 0, 0.f)); int id = world.placeAt("hall", 0, 0, 0.f); CHECK(id > 0); CHECK(!world.isCellEmpty(2, 0)); CHECK(world.isCellEmpty(0, 2)); world.removeBuilding(id); // 90°: W/H swap conceptually — local (0,0)(1,0)(2,0) → (0,2)(0,1)(0,0) after 90 CCW remap CHECK(world.canPlace("hall", 0, 0, 90.f)); id = world.placeAt("hall", 0, 0, 90.f); CHECK(id > 0); CHECK(!world.isCellEmpty(0, 0)); CHECK(!world.isCellEmpty(0, 1)); CHECK(!world.isCellEmpty(0, 2)); CHECK(world.isCellEmpty(1, 0)); CHECK(approxEq(world.getBuildingRotation(id), 90.f)); BuildingRegistry::clear(); } TEST_CASE("building.world.terrainAndAdjacency") { BuildingRegistry::clear(); PlacementSystem::ensureBuiltins(); BuildingDefinition dock; dock.id = "dock"; dock.footprintW = 1; dock.footprintH = 1; dock.requireTerrain = {2}; // water BuildingRegistry::registerBuilding(dock); BuildingDefinition stall; stall.id = "stall"; stall.footprintW = 1; stall.footprintH = 1; stall.requireAdjacentTag = "road"; BuildingRegistry::registerBuilding(stall); BuildingDefinition road; road.id = "road"; road.footprintW = 1; road.footprintH = 1; road.tags = {"road"}; BuildingRegistry::registerBuilding(road); PlacementWorld world(5, 5, 1.f); world.fillTerrain(1); // land world.setTerrain(2, 2, 2); // water CHECK(!world.canPlace("dock", 1, 1, 0.f)); CHECK_EQ(world.canPlaceReason("dock", 1, 1, 0.f), "terrain_mismatch"); CHECK(world.canPlace("dock", 2, 2, 0.f)); CHECK(world.placeAt("dock", 2, 2, 0.f) > 0); CHECK(!world.canPlace("stall", 0, 0, 0.f)); CHECK_EQ(world.canPlaceReason("stall", 0, 0, 0.f), "adjacency_tag"); CHECK(world.placeAt("road", 0, 1, 0.f) > 0); CHECK(world.canPlace("stall", 0, 0, 0.f)); CHECK(world.placeAt("stall", 0, 0, 0.f) > 0); BuildingRegistry::clear(); } TEST_CASE("building.world.moveRemoveMask") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); PlacementSystem::ensureBuiltins(); BuildingDefinition L; L.id = "L"; L.footprintW = 2; L.footprintH = 2; // mask: ## / #. (row-major) L.footprintMask = {1, 1, 1, 0}; BuildingRegistry::registerBuilding(L); PlacementWorld world(5, 5, 1.f); int id = world.placeAt("L", 1, 1, 0.f); CHECK(id > 0); CHECK(!world.isCellEmpty(1, 1)); CHECK(!world.isCellEmpty(2, 1)); CHECK(!world.isCellEmpty(1, 2)); CHECK(world.isCellEmpty(2, 2)); // mask hole CHECK(world.moveBuilding(id, 2, 2, 0.f)); CHECK(world.isCellEmpty(1, 1)); CHECK(!world.isCellEmpty(2, 2)); CHECK_EQ(world.getBuildingCellX(id), 2); CHECK(world.removeBuilding(id)); CHECK_EQ(world.getBuildingCount(), 0); CHECK(world.isCellEmpty(2, 2)); BuildingRegistry::clear(); } TEST_CASE("building.system.customValidateSnapHook") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); PlacementSystem::ensureBuiltins(); BuildingDefinition barn; barn.id = "barn"; barn.footprintW = 1; barn.footprintH = 1; barn.validateRule = "needGold"; barn.snapMode = "half"; BuildingRegistry::registerBuilding(barn); int gold = 0; PlacementSystem::registerValidateRule( "needGold", [&](const PlacementWorld &, const PlacementQuery &, std::string *reason) { if (gold < 10) { if (reason) *reason = "not_enough_gold"; return false; } return true; }); PlacementSystem::registerSnapRule( "half", [](const PlacementWorld &world, float worldX, float worldY) { SnapResult r; const float cs = world.getCellSize(); r.worldX = std::floor(worldX / (cs * 0.5f)) * (cs * 0.5f); r.worldY = std::floor(worldY / (cs * 0.5f)) * (cs * 0.5f); r.cellX = world.worldToCellX(r.worldX); r.cellY = world.worldToCellY(r.worldY); return r; }); int hookCount = 0; PlacementSystem::registerChangeHook("count", [&](const BuildingChangeEvent &) { ++hookCount; }); PlacementWorld world(8, 8, 32.f); CHECK(!world.canPlace("barn", 0, 0, 0.f)); CHECK_EQ(world.canPlaceReason("barn", 0, 0, 0.f), "not_enough_gold"); gold = 10; CHECK(world.canPlace("barn", 0, 0, 0.f)); int id = world.placeAt("barn", 0, 0, 0.f); CHECK(id > 0); CHECK_EQ(hookCount, 1); SnapResult s = PlacementSystem::snap(world, "barn", 40.f, 40.f); CHECK(approxEq(s.worldX, 32.f)); // floor(40/16)*16 = 32 PlacementSystem::unregisterValidateRule("needGold"); PlacementSystem::unregisterSnapRule("half"); PlacementSystem::unregisterChangeHook("count"); BuildingRegistry::clear(); } TEST_CASE("building.facade.endToEnd") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); Building mod; int n = mod.registerBuildingsFromJson( R"({"id":"farm","displayName":"Farm","footprintW":2,"footprintH":2,"tags":["farm"], "cost":{"gold":50},"extra":{"icon":"farm.png"}})"); CHECK_EQ(n, 1); CHECK(mod.hasBuildingDefinition("farm")); CHECK_EQ(mod.getBuildingDisplayName("farm"), "Farm"); CHECK_EQ(mod.getBuildingCost("farm", "gold"), 50); CHECK_EQ(mod.getBuildingExtra("farm", "icon"), "farm.png"); CHECK(mod.hasValidateRule("default")); CHECK(mod.hasSnapRule("grid")); PlacementWorld *world = mod.newWorld(16, 16, 16.f); world->setId("w1"); Ghost *ghost = mod.newGhost(); ghost->setBuildingId("farm"); ghost->setFromWorld(world, 33.f, 17.f); CHECK(ghost->validate(world)); int id = world->placeGhost(ghost); CHECK(id > 0); CHECK_EQ(mod.getChangeEventCount(), 1); CHECK_EQ(mod.getChangeEventAction(0), "place"); CHECK_EQ(mod.getChangeEventBuildingId(0), "farm"); CHECK_EQ(mod.getChangeEventWorldId(0), "w1"); world->destroy(); ghost->destroy(); mod.clearBuildingDefinitions(); mod.clearChangeEvents(); } TEST_CASE("building.demo.townSandboxDefs") { // Mirrors examples/building/main.nut definitions: road / house / stall / dock / L-barn. BuildingRegistry::clear(); PlacementSystem::clearEvents(); PlacementSystem::ensureBuiltins(); Building mod; int n = mod.registerBuildingsFromJson(R"([ {"id":"road","displayName":"石板路","category":"infra", "footprintW":1,"footprintH":1,"tags":["road"],"cost":{"gold":2}}, {"id":"house.wood","displayName":"木屋","category":"housing", "footprintW":2,"footprintH":2,"tags":["house","housing"], "requireTerrain":[1],"cost":{"gold":15,"wood":20}}, {"id":"stall","displayName":"摊位","category":"commerce", "footprintW":1,"footprintH":1,"tags":["shop"], "requireTerrain":[1],"requireAdjacentTag":"road","cost":{"gold":8,"wood":6}}, {"id":"dock","displayName":"码头","category":"infra", "footprintW":2,"footprintH":1,"tags":["dock"], "requireTerrain":[2],"rotationMode":"cardinal","cost":{"gold":25,"wood":30}}, {"id":"barn.l","displayName":"L形仓","category":"storage", "footprintW":2,"footprintH":2,"tags":["barn"], "footprintMask":[1,1,1,0],"requireTerrain":[1],"cost":{"gold":18,"wood":24}} ])"); CHECK_EQ(n, 5); PlacementWorld world(24, 18, 28.f); world.fillTerrain(1); for (int x = 0; x < 24; ++x) world.setTerrain(x, 11, 2); CHECK(world.placeAt("road", 5, 5, 0.f) > 0); CHECK(!world.canPlace("stall", 10, 5, 0.f)); CHECK_EQ(world.canPlaceReason("stall", 10, 5, 0.f), "adjacency_tag"); CHECK(world.canPlace("stall", 5, 4, 0.f)); CHECK(world.placeAt("stall", 5, 4, 0.f) > 0); CHECK(!world.canPlace("dock", 1, 1, 0.f)); CHECK_EQ(world.canPlaceReason("dock", 1, 1, 0.f), "terrain_mismatch"); CHECK(world.canPlace("dock", 3, 11, 0.f)); CHECK(world.placeAt("dock", 3, 11, 0.f) > 0); CHECK(world.placeAt("house.wood", 8, 5, 0.f) > 0); CHECK(world.placeAt("barn.l", 12, 5, 0.f) > 0); CHECK(world.isCellEmpty(13, 6)); // L mask hole CHECK_EQ(world.getBuildingCount(), 5); BuildingRegistry::clear(); } TEST_CASE("building.grid.isometricPlacement") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); PlacementSystem::ensureBuiltins(); BuildingDefinition hut; hut.id = "hut"; hut.footprintW = 1; hut.footprintH = 1; BuildingRegistry::registerBuilding(hut); PlacementWorld world(16, 16, 64.f); world.setGridLayout("isometric"); world.setOrigin(0.f, 0.f); float px = 0.f, py = 0.f; world.cellToWorldPlane(1, 0, px, py); REQUIRE(approxEq(px, 32.f)); REQUIRE(approxEq(py, 32.f)); const int id = world.placeAt("hut", 1, 0, 0.f); REQUIRE(id > 0); REQUIRE(approxEq(world.getBuildingWorldX(id), 32.f)); REQUIRE(approxEq(world.getBuildingWorldY(id), 32.f)); REQUIRE(!world.canPlace("hut", 1, 0, 0.f)); // 已占用 REQUIRE(world.canPlace("hut", 2, 0, 0.f)); // 世界坐标吸附回格(iso 反投影)。 Ghost ghost; ghost.setBuildingId("hut"); ghost.setFromWorld(&world, 35.f, 40.f); REQUIRE_EQ(ghost.getCellX(), 1); REQUIRE_EQ(ghost.getCellY(), 0); BuildingRegistry::clear(); } TEST_CASE("building.grid.hexRotation") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); PlacementSystem::ensureBuiltins(); BuildingDefinition hut; hut.id = "hexhut"; hut.footprintW = 2; hut.footprintH = 1; hut.rotationMode = "hex"; BuildingRegistry::registerBuilding(hut); REQUIRE(approxEq(PlacementSystem::normalizeRotation("hexhut", 10.f), 0.f)); REQUIRE(approxEq(PlacementSystem::normalizeRotation("hexhut", 65.f), 60.f)); REQUIRE(approxEq(PlacementSystem::normalizeRotation("hexhut", 375.f), 0.f)); PlacementWorld world(12, 12, 32.f); world.setGridLayout("hexagon"); world.setStagger("y", "odd"); world.setHexSideLength(14.f); REQUIRE(world.placeAt("hexhut", 2, 2, 60.f) > 0); // 60° 旋转后的占地占 2 格;同一格再放冲突。 REQUIRE(!world.canPlace("hexhut", 2, 2, 0.f)); BuildingRegistry::clear(); } TEST_CASE("building.channel.stackedOccupancy") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); PlacementSystem::ensureBuiltins(); BuildingDefinition floor; floor.id = "floor"; floor.channel = "floor"; floor.footprintW = 1; floor.footprintH = 1; BuildingRegistry::registerBuilding(floor); BuildingDefinition furniture; furniture.id = "furniture"; furniture.channel = "furniture"; furniture.footprintW = 1; furniture.footprintH = 1; BuildingRegistry::registerBuilding(furniture); PlacementWorld world(8, 8, 32.f); const int a = world.placeAt("floor", 3, 3, 0.f); const int b = world.placeAt("furniture", 3, 3, 0.f); REQUIRE(a > 0); REQUIRE(b > 0); REQUIRE(world.isCellEmptyInChannel("", 3, 3)); REQUIRE_EQ(world.getOccupantInChannel("floor", 3, 3), a); REQUIRE_EQ(world.getOccupantInChannel("furniture", 3, 3), b); REQUIRE(world.getAnyOccupant(3, 3) != 0); REQUIRE(world.removeBuilding(a)); REQUIRE_EQ(world.getOccupantInChannel("floor", 3, 3), 0); REQUIRE_EQ(world.getOccupantInChannel("furniture", 3, 3), b); BuildingRegistry::clear(); } TEST_CASE("building.tilemap.terrainFromGid") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); PlacementSystem::ensureBuiltins(); BuildingDefinition dock; dock.id = "dock"; dock.footprintW = 1; dock.footprintH = 1; dock.requireTerrain = {2}; BuildingRegistry::registerBuilding(dock); auto *mapMod = eve::map::Map::create(); eve::map::TileLayer *layer = mapMod->newLayer(10, 10, 32.f, 32.f); REQUIRE(layer != nullptr); layer->fill(1); // GID 1 = 陆地 layer->setTile(4, 4, 2); // GID 2 = 水域 PlacementWorld world(10, 10, 32.f); world.bindTileLayer(layer); world.setTerrainGid(1, 1); world.setTerrainGid(2, 2); REQUIRE_EQ(world.getTerrain(0, 0), 1); REQUIRE_EQ(world.getTerrain(4, 4), 2); REQUIRE(!world.canPlace("dock", 0, 0, 0.f)); REQUIRE_EQ(world.canPlaceReason("dock", 0, 0, 0.f), "terrain_mismatch"); REQUIRE(world.canPlace("dock", 4, 4, 0.f)); // 手动覆盖优先于 GID。 world.setTerrain(4, 4, 1); REQUIRE_EQ(world.getTerrain(4, 4), 1); REQUIRE(!world.canPlace("dock", 4, 4, 0.f)); BuildingRegistry::clear(); } TEST_CASE("building.surface.plane3D") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); PlacementSystem::ensureBuiltins(); BuildingDefinition hut; hut.id = "hut"; hut.footprintW = 1; hut.footprintH = 1; BuildingRegistry::registerBuilding(hut); PlacementWorld world(10, 10, 1.f); world.setGridPlane("xz"); world.setOrigin(0.f, 0.f); Ghost ghost; ghost.setBuildingId("hut"); ghost.setFromSurface(&world, "plane", 4.2f, 7.8f); REQUIRE_EQ(ghost.getCellX(), 4); REQUIRE_EQ(ghost.getCellY(), 7); REQUIRE(approxEq(ghost.getElevation(), 0.f)); REQUIRE(ghost.validate(&world)); const int id = world.placeGhost(&ghost); REQUIRE(id > 0); REQUIRE(approxEq(world.getBuildingWorldX(id), 4.f)); REQUIRE(approxEq(world.getBuildingWorldY(id), 0.f)); // XZ 平面:worldY = 高度 REQUIRE(approxEq(world.getBuildingWorldZ(id), 7.f)); REQUIRE(approxEq(world.getBuildingElevation(id), 0.f)); PlacementSystem::setPlaneSurfaceHeight(1.5f); Ghost ghost2; ghost2.setBuildingId("hut"); ghost2.setFromSurface(&world, "plane", 1.2f, 2.3f); REQUIRE(approxEq(ghost2.getElevation(), 1.5f)); PlacementSystem::setPlaneSurfaceHeight(0.f); const int id2 = world.placeAtWorld3D("hut", 5.2f, 2.5f, 3.1f, 0.f); REQUIRE(id2 > 0); REQUIRE(approxEq(world.getBuildingWorldX(id2), 5.f)); REQUIRE(approxEq(world.getBuildingWorldY(id2), 2.5f)); REQUIRE(approxEq(world.getBuildingWorldZ(id2), 3.f)); REQUIRE(approxEq(world.getBuildingElevation(id2), 2.5f)); BuildingRegistry::clear(); } TEST_CASE("building.surface.structuredProviderFrameAndAttachment") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); PlacementSystem::ensureBuiltins(); BuildingDefinition hut; hut.id = "surface-hut"; hut.validateRule = "surfaceMetadata"; BuildingRegistry::registerBuilding(hut); PlacementSystem::registerValidateRule( "surfaceMetadata", [](const PlacementWorld &, const PlacementQuery &query, std::string *reason) { if (query.surfaceId != "terrain.chunk.4" || query.surfaceRevision != 17 || !approxEq(query.surfaceNormalY, 0.7071067f)) { if (reason) *reason = "surface_metadata_missing"; return false; } return true; }); PlacementWorld world(10, 10, 1.f); world.setGridPlane("xz"); PlacementSystem::registerSurfaceProvider( "terrain.test", [](const PlacementWorld &, float x, float y) { PlacementSystem::PlacementHit hit; hit.worldX = x; hit.worldY = 2.5f; hit.worldZ = y; hit.normalX = 0.f; hit.normalY = 2.f; hit.normalZ = 2.f; hit.tangentX = 3.f; hit.tangentY = 0.f; hit.tangentZ = 0.f; hit.surfaceId = "terrain.chunk.4"; hit.surfaceRevision = 17; return eve::Result::success(std::move(hit)); }); auto sample = PlacementSystem::sampleSurface(world, "terrain.test", 3.2f, 4.7f); REQUIRE(sample.ok()); REQUIRE_EQ(sample.value().surfaceId, "terrain.chunk.4"); REQUIRE_EQ(sample.value().surfaceRevision, uint64_t(17)); REQUIRE(approxEq(sample.value().normalY, 0.7071067f)); REQUIRE(approxEq(sample.value().normalZ, 0.7071067f)); REQUIRE(approxEq(sample.value().tangentX, 1.f)); Ghost ghost; ghost.setBuildingId("surface-hut"); ghost.setFromSurface(&world, "terrain.test", 3.2f, 4.7f); REQUIRE_EQ(ghost.getSurfaceId(), "terrain.chunk.4"); REQUIRE_EQ(ghost.getSurfaceRevision(), int64_t(17)); REQUIRE(approxEq(ghost.getSurfaceNormalY(), 0.7071067f)); REQUIRE(ghost.validate(&world)); const int id = world.placeGhost(&ghost); REQUIRE(id > 0); REQUIRE_EQ(world.getBuildingSurfaceId(id), "terrain.chunk.4"); REQUIRE_EQ(world.getBuildingSurfaceRevision(id), int64_t(17)); REQUIRE(approxEq(world.getBuildingSurfaceNormalY(id), 0.7071067f)); auto missing = PlacementSystem::sampleSurface(world, "missing.surface", 0.f, 0.f); REQUIRE(!missing.ok()); REQUIRE_EQ(missing.code(), eve::StatusCode::NotFound); PlacementSystem::registerSurfaceProvider( "invalid.normal", [](const PlacementWorld &, float, float) { PlacementSystem::PlacementHit hit; hit.normalX = 0.f; hit.normalY = 0.f; hit.normalZ = 0.f; return eve::Result::success(std::move(hit)); }); auto invalid = PlacementSystem::sampleSurface(world, "invalid.normal", 0.f, 0.f); REQUIRE(!invalid.ok()); REQUIRE_EQ(invalid.code(), eve::StatusCode::Rejected); PlacementSystem::unregisterSurface("terrain.test"); PlacementSystem::unregisterSurface("invalid.normal"); PlacementSystem::unregisterValidateRule("surfaceMetadata"); BuildingRegistry::clear(); } TEST_CASE("building.surface.patchSamplesFootprintAndEnforcesLimits") { BuildingRegistry::clear(); PlacementSystem::ensureBuiltins(); BuildingDefinition platform; platform.id = "surface-platform"; platform.footprintW = 2; platform.footprintH = 2; platform.maxSurfaceSlopeDegrees = 10.f; platform.maxSurfaceHeightDelta = 0.25f; BuildingRegistry::registerBuilding(platform); PlacementWorld world(8, 8, 1.f); world.setGridPlane("xz"); int patchSampleCalls = 0; PlacementSystem::registerSurfaceProvider( "terrain.patch", [&](const PlacementWorld &, float x, float y) { ++patchSampleCalls; PlacementSystem::PlacementHit hit; hit.worldX = x; hit.worldY = 0.1f * (x + y); hit.worldZ = y; hit.normalY = 1.f; hit.surfaceId = "terrain.main"; hit.surfaceRevision = 9; return eve::Result::success(std::move(hit)); }); auto patch = PlacementSystem::sampleSurfacePatch(world, platform.id, "terrain.patch", 2.2f, 2.2f, 0.f); REQUIRE(patch.ok()); REQUIRE_EQ(patch.value().samples.size(), size_t(4)); REQUIRE(approxEq(patch.value().heightDelta, 0.2f)); REQUIRE(approxEq(patch.value().maxSlopeDegrees, 0.f)); Ghost ghost; ghost.setBuildingId(platform.id); ghost.setFromSurface(&world, "terrain.patch", 2.2f, 2.2f); REQUIRE_EQ(ghost.getSurfaceSampleCount(), 4); REQUIRE(approxEq(ghost.getSurfaceHeightDelta(), 0.2f)); REQUIRE(ghost.validate(&world)); REQUIRE_EQ(patchSampleCalls, 8); ghost.rotateBy(90.f); REQUIRE(ghost.validate(&world)); REQUIRE_EQ(patchSampleCalls, 12); platform.maxSurfaceHeightDelta = 0.05f; BuildingRegistry::registerBuilding(platform); REQUIRE(!ghost.validate(&world)); REQUIRE_EQ(ghost.getReason(), "surface_height_delta"); PlacementSystem::registerSurfaceProvider( "terrain.split", [](const PlacementWorld &, float x, float y) { PlacementSystem::PlacementHit hit; hit.worldX = x; hit.worldZ = y; hit.surfaceId = x < 3.f ? "terrain.left" : "terrain.right"; hit.surfaceRevision = 1; return eve::Result::success(std::move(hit)); }); auto split = PlacementSystem::sampleSurfacePatch(world, platform.id, "terrain.split", 2.2f, 2.2f, 0.f); REQUIRE(!split.ok()); REQUIRE_EQ(split.code(), eve::StatusCode::Conflict); PlacementSystem::unregisterSurface("terrain.patch"); PlacementSystem::unregisterSurface("terrain.split"); BuildingRegistry::clear(); } TEST_CASE("building.surface.heightfieldValidatesInterpolatesAndOwnsRegistration") { HeightfieldSurface::Config invalidConfig; invalidConfig.width = 1; invalidConfig.height = 2; auto invalidDimensions = HeightfieldSurface::create(invalidConfig, {0.f, 1.f}); REQUIRE(!invalidDimensions.ok()); REQUIRE_EQ(invalidDimensions.code(), eve::StatusCode::Rejected); HeightfieldSurface::Config config; config.width = 3; config.height = 3; config.originX = 10.f; config.originY = 20.f; config.spacingX = 2.f; config.spacingY = 4.f; config.heightScale = 2.f; config.heightOffset = 3.f; config.surfaceId = "heightfield.chunk.7"; config.surfaceRevision = 11; config.tags = {"terrain", "buildable"}; auto created = HeightfieldSurface::create( config, {0.f, 1.f, 2.f, 2.f, 3.f, 4.f, 4.f, 5.f, 6.f}); REQUIRE(created.ok()); auto surface = std::move(created).takeValue(); std::weak_ptr lifetime = surface; PlacementWorld xzWorld(32, 32, 1.f); xzWorld.setGridPlane("xz"); auto registered = PlacementSystem::registerHeightfieldSurface("heightfield.test", surface); REQUIRE(registered.ok()); surface.reset(); REQUIRE(!lifetime.expired()); auto xz = PlacementSystem::sampleSurface(xzWorld, "heightfield.test", 11.f, 21.f); REQUIRE(xz.ok()); REQUIRE(approxEq(xz.value().worldX, 11.f)); REQUIRE(approxEq(xz.value().worldY, 5.f)); REQUIRE(approxEq(xz.value().worldZ, 21.f)); const float normalLength = std::sqrt(3.f); REQUIRE(approxEq(xz.value().normalX, -1.f / normalLength)); REQUIRE(approxEq(xz.value().normalY, 1.f / normalLength)); REQUIRE(approxEq(xz.value().normalZ, -1.f / normalLength)); REQUIRE_EQ(xz.value().surfaceId, "heightfield.chunk.7"); REQUIRE_EQ(xz.value().surfaceRevision, uint64_t(11)); REQUIRE_EQ(xz.value().primitiveId, uint64_t(0)); REQUIRE_EQ(xz.value().tags.size(), size_t(2)); PlacementWorld xyWorld(32, 32, 1.f); xyWorld.setGridPlane("xy"); auto xy = PlacementSystem::sampleSurface(xyWorld, "heightfield.test", 11.f, 21.f); REQUIRE(xy.ok()); REQUIRE(approxEq(xy.value().worldX, 11.f)); REQUIRE(approxEq(xy.value().worldY, 21.f)); REQUIRE(approxEq(xy.value().worldZ, 5.f)); REQUIRE(approxEq(xy.value().normalX, -1.f / normalLength)); REQUIRE(approxEq(xy.value().normalY, -1.f / normalLength)); REQUIRE(approxEq(xy.value().normalZ, 1.f / normalLength)); auto boundary = PlacementSystem::sampleSurface(xzWorld, "heightfield.test", 14.f, 28.f); REQUIRE(boundary.ok()); REQUIRE(approxEq(boundary.value().worldY, 15.f)); REQUIRE_EQ(boundary.value().primitiveId, uint64_t(3)); auto outside = PlacementSystem::sampleSurface(xzWorld, "heightfield.test", 14.01f, 28.f); REQUIRE(!outside.ok()); REQUIRE_EQ(outside.code(), eve::StatusCode::NotFound); HeightfieldSurface::Config smoothConfig; smoothConfig.width = 3; smoothConfig.height = 2; smoothConfig.surfaceId = "heightfield.smooth"; auto smoothCreated = HeightfieldSurface::create(smoothConfig, {0.f, 0.f, 4.f, 0.f, 2.f, 4.f}); REQUIRE(smoothCreated.ok()); auto smoothRegistered = PlacementSystem::registerHeightfieldSurface( "heightfield.smooth", std::move(smoothCreated).takeValue()); REQUIRE(smoothRegistered.ok()); auto leftOfBoundary = PlacementSystem::sampleSurface(xzWorld, "heightfield.smooth", 0.9999f, 0.5f); auto rightOfBoundary = PlacementSystem::sampleSurface(xzWorld, "heightfield.smooth", 1.0001f, 0.5f); REQUIRE(leftOfBoundary.ok()); REQUIRE(rightOfBoundary.ok()); REQUIRE(approxEq(leftOfBoundary.value().normalX, rightOfBoundary.value().normalX, 0.001f)); REQUIRE(approxEq(leftOfBoundary.value().normalY, rightOfBoundary.value().normalY, 0.001f)); REQUIRE(approxEq(leftOfBoundary.value().normalZ, rightOfBoundary.value().normalZ, 0.001f)); PlacementSystem::unregisterSurface("heightfield.smooth"); PlacementSystem::unregisterSurface("heightfield.test"); REQUIRE(lifetime.expired()); } TEST_CASE("building.surface.heightfieldFeedsFootprintsAndCurves") { BuildingRegistry::clear(); PlacementSystem::ensureBuiltins(); BuildingDefinition platform; platform.id = "heightfield-platform"; platform.footprintW = 2; platform.footprintH = 2; platform.maxSurfaceSlopeDegrees = 50.f; platform.maxSurfaceHeightDelta = 3.f; BuildingRegistry::registerBuilding(platform); BuildingDefinition wall; wall.id = "heightfield-wall"; wall.placementKind = "edge"; BuildingRegistry::registerBuilding(wall); HeightfieldSurface::Config config; config.width = 8; config.height = 8; config.surfaceId = "heightfield.integration"; config.surfaceRevision = 4; std::vector samples; samples.reserve(64); for (int y = 0; y < 8; ++y) { for (int x = 0; x < 8; ++x) samples.push_back(0.25f * float(x + y)); } auto created = HeightfieldSurface::create(config, std::move(samples)); REQUIRE(created.ok()); auto registered = PlacementSystem::registerHeightfieldSurface( "heightfield.integration", std::move(created).takeValue()); REQUIRE(registered.ok()); PlacementWorld world(8, 8, 1.f); world.setGridPlane("xz"); auto patch = PlacementSystem::sampleSurfacePatch( world, platform.id, "heightfield.integration", 2.2f, 2.2f); REQUIRE(patch.ok()); REQUIRE_EQ(patch.value().samples.size(), size_t(4)); REQUIRE(approxEq(patch.value().heightDelta, 0.5f)); REQUIRE_EQ(patch.value().anchor.surfaceId, "heightfield.integration"); Ghost ghost; ghost.setBuildingId(platform.id); ghost.setFromSurface(&world, "heightfield.integration", 2.2f, 2.2f); REQUIRE(ghost.validate(&world)); const int platformId = world.placeGhost(&ghost); REQUIRE(platformId > 0); REQUIRE_EQ(world.getBuildingSurfaceRevision(platformId), int64_t(4)); const std::vector controls{ {1.f, 1.f}, {2.f, 1.f}, {3.f, 2.f}, {4.f, 2.f}}; auto curveSurface = PlacementSystem::sampleEdgeCurveSurface( world, "heightfield.integration", controls, 8); REQUIRE(curveSurface.ok()); REQUIRE_EQ(curveSurface.value().surfaceId, "heightfield.integration"); REQUIRE_EQ(curveSurface.value().surfaceRevision, uint64_t(4)); REQUIRE_EQ(curveSurface.value().samples.size(), size_t(9)); auto curve = PlacementSystem::placeEdgeCubicBezierOnSurface( &world, wall.id, controls, 8, "heightfield.integration"); REQUIRE(curve.ok()); REQUIRE(!curve.value().instanceIds.empty()); auto group = world.edgeCurveGroupForInstance(curve.value().instanceIds.front()); REQUIRE(group.ok()); REQUIRE_EQ(group.value().surfaceProviderName, "heightfield.integration"); REQUIRE_EQ(group.value().surfaceId, "heightfield.integration"); REQUIRE_EQ(group.value().surfaceRevision, uint64_t(4)); REQUIRE_EQ(group.value().surfaceSamples.size(), size_t(9)); PlacementSystem::unregisterSurface("heightfield.integration"); BuildingRegistry::clear(); } TEST_CASE("building.surface.staticMeshBvhSelectsLayersAndInterpolatesFrames") { StaticMeshSurface::Config invalidConfig; auto invalid = StaticMeshSurface::create( invalidConfig, {0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 1.f, 0.f}, {0, 1, 9}); REQUIRE(!invalid.ok()); REQUIRE_EQ(invalid.code(), eve::StatusCode::Rejected); const std::vector layeredVertices{ 0.f, 1.f, 0.f, 2.f, 1.f, 0.f, 2.f, 1.f, 2.f, 0.f, 1.f, 2.f, 0.f, 3.f, 0.f, 2.f, 3.f, 0.f, 2.f, 3.f, 2.f, 0.f, 3.f, 2.f}; const std::vector layeredIndices{0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7}; StaticMeshSurface::Config highestConfig; highestConfig.surfaceId = "mesh.layers"; highestConfig.surfaceRevision = 12; highestConfig.tags = {"static", "walkable"}; highestConfig.leafTriangleCount = 1; auto highestCreated = StaticMeshSurface::create(highestConfig, layeredVertices, layeredIndices); REQUIRE(highestCreated.ok()); auto highest = std::move(highestCreated).takeValue(); REQUIRE_EQ(highest->triangleCount(), size_t(4)); REQUIRE(highest->nodeCount() > 1); std::weak_ptr lifetime = highest; auto highestRegistered = PlacementSystem::registerStaticMeshSurface("mesh.highest", highest); REQUIRE(highestRegistered.ok()); highest.reset(); REQUIRE(!lifetime.expired()); PlacementWorld xzWorld(8, 8, 1.f); xzWorld.setGridPlane("xz"); auto highHit = PlacementSystem::sampleSurface(xzWorld, "mesh.highest", 0.25f, 0.25f); REQUIRE(highHit.ok()); REQUIRE(approxEq(highHit.value().worldY, 3.f)); REQUIRE_EQ(highHit.value().primitiveId, uint64_t(2)); REQUIRE_EQ(highHit.value().surfaceId, "mesh.layers"); REQUIRE_EQ(highHit.value().surfaceRevision, uint64_t(12)); REQUIRE(approxEq(highHit.value().normalY, 1.f)); StaticMeshSurface::Config lowestConfig = highestConfig; lowestConfig.hitSelection = StaticMeshSurface::HitSelection::Lowest; auto lowestCreated = StaticMeshSurface::create(lowestConfig, layeredVertices, layeredIndices); REQUIRE(lowestCreated.ok()); auto lowestRegistered = PlacementSystem::registerStaticMeshSurface( "mesh.lowest", std::move(lowestCreated).takeValue()); REQUIRE(lowestRegistered.ok()); auto lowHit = PlacementSystem::sampleSurface(xzWorld, "mesh.lowest", 0.25f, 0.25f); REQUIRE(lowHit.ok()); REQUIRE(approxEq(lowHit.value().worldY, 1.f)); REQUIRE_EQ(lowHit.value().primitiveId, uint64_t(0)); StaticMeshSurface::Config closestConfig = highestConfig; closestConfig.hitSelection = StaticMeshSurface::HitSelection::ClosestToReference; closestConfig.referenceHeight = 2.6f; auto closestCreated = StaticMeshSurface::create(closestConfig, layeredVertices, layeredIndices); REQUIRE(closestCreated.ok()); auto closestRegistered = PlacementSystem::registerStaticMeshSurface( "mesh.closest", std::move(closestCreated).takeValue()); REQUIRE(closestRegistered.ok()); auto closestHit = PlacementSystem::sampleSurface(xzWorld, "mesh.closest", 1.5f, 1.5f); REQUIRE(closestHit.ok()); REQUIRE(approxEq(closestHit.value().worldY, 3.f)); StaticMeshSurface::Config xyConfig; xyConfig.surfaceId = "mesh.xy"; const std::vector xyVertices{0.f, 0.f, 1.f, 2.f, 0.f, 3.f, 0.f, 2.f, 1.f}; const std::vector xyNormals{0.f, 0.f, 1.f, 0.f, 0.f, 1.f, 0.f, 0.f, 1.f}; auto xyCreated = StaticMeshSurface::create(xyConfig, xyVertices, {0, 1, 2}, xyNormals); REQUIRE(xyCreated.ok()); auto xyRegistered = PlacementSystem::registerStaticMeshSurface( "mesh.xy", std::move(xyCreated).takeValue()); REQUIRE(xyRegistered.ok()); PlacementWorld xyWorld(8, 8, 1.f); auto xyHit = PlacementSystem::sampleSurface(xyWorld, "mesh.xy", 0.5f, 0.5f); REQUIRE(xyHit.ok()); REQUIRE(approxEq(xyHit.value().worldZ, 1.5f)); REQUIRE(approxEq(xyHit.value().normalX, 0.f)); REQUIRE(approxEq(xyHit.value().normalZ, 1.f)); REQUIRE(!PlacementSystem::sampleSurface(xyWorld, "mesh.xy", 1.8f, 1.8f).ok()); PlacementSystem::unregisterSurface("mesh.highest"); REQUIRE(lifetime.expired()); PlacementSystem::unregisterSurface("mesh.lowest"); PlacementSystem::unregisterSurface("mesh.closest"); PlacementSystem::unregisterSurface("mesh.xy"); } TEST_CASE("building.surface.staticMeshFeedsFootprintsAndCurves") { BuildingRegistry::clear(); BuildingDefinition platform; platform.id = "mesh-platform"; platform.footprintW = 2; platform.footprintH = 2; platform.maxSurfaceSlopeDegrees = 20.f; platform.maxSurfaceHeightDelta = 1.f; BuildingRegistry::registerBuilding(platform); BuildingDefinition wall; wall.id = "mesh-wall"; wall.placementKind = "edge"; BuildingRegistry::registerBuilding(wall); StaticMeshSurface::Config config; config.surfaceId = "mesh.integration"; config.surfaceRevision = 5; const std::vector vertices{ 0.f, 0.f, 0.f, 8.f, 0.8f, 0.f, 8.f, 1.6f, 8.f, 0.f, 0.8f, 8.f}; auto created = StaticMeshSurface::create(config, vertices, {0, 2, 1, 0, 3, 2}); REQUIRE(created.ok()); auto registered = PlacementSystem::registerStaticMeshSurface( "mesh.integration", std::move(created).takeValue()); REQUIRE(registered.ok()); PlacementWorld world(8, 8, 1.f); world.setGridPlane("xz"); auto patch = PlacementSystem::sampleSurfacePatch( world, platform.id, "mesh.integration", 2.2f, 2.2f); REQUIRE(patch.ok()); REQUIRE_EQ(patch.value().samples.size(), size_t(4)); REQUIRE(patch.value().heightDelta < 0.3f); Ghost ghost; ghost.setBuildingId(platform.id); ghost.setFromSurface(&world, "mesh.integration", 2.2f, 2.2f); REQUIRE(ghost.validate(&world)); REQUIRE(world.placeGhost(&ghost) > 0); const std::vector controls{ {1.f, 1.f}, {2.f, 1.f}, {3.f, 2.f}, {4.f, 2.f}}; auto curve = PlacementSystem::placeEdgeCubicBezierOnSurface( &world, wall.id, controls, 8, "mesh.integration"); REQUIRE(curve.ok()); auto group = world.edgeCurveGroupForInstance(curve.value().instanceIds.front()); REQUIRE(group.ok()); REQUIRE_EQ(group.value().surfaceId, "mesh.integration"); REQUIRE_EQ(group.value().surfaceRevision, uint64_t(5)); REQUIRE_EQ(group.value().surfaceSamples.size(), size_t(9)); PlacementSystem::unregisterSurface("mesh.integration"); BuildingRegistry::clear(); } TEST_CASE("building.edge.canonicalOccupancyAndTopology") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); BuildingDefinition wall; wall.id = "stone-wall"; wall.placementKind = "edge"; wall.connectionGroup = "stone"; wall.channel = "walls"; BuildingRegistry::registerBuilding(wall); BuildingDefinition gate = wall; gate.id = "stone-gate"; BuildingRegistry::registerBuilding(gate); PlacementWorld world(4, 3, 10.f); CHECK(world.canPlaceEdge("stone-wall", 1, 1, "north")); const int first = world.placeEdge("stone-wall", 1, 1, "north"); REQUIRE(first > 0); CHECK_EQ(world.getEdgeOccupant("walls", 1, 0, "south"), first); CHECK(!world.canPlaceEdge("stone-gate", 1, 0, "south")); CHECK_EQ(world.canPlaceEdgeReason("stone-gate", 1, 0, "south"), "edge_occupied"); const int second = world.placeEdge("stone-wall", 2, 1, "north"); const int branch = world.placeEdge("stone-gate", 1, 0, "east"); REQUIRE(second > 0); REQUIRE(branch > 0); CHECK((world.getEdgeConnectionMask(first) & (1 << 1)) != 0); CHECK((world.getEdgeConnectionMask(first) & 0x3c) != 0); CHECK_EQ(world.placeAt("stone-wall", 0, 0), 0); CHECK_EQ(world.canPlaceReason("stone-wall", 0, 0), "edge_requires_edge_address"); CHECK(world.removeBuilding(first)); CHECK(world.isEdgeEmpty("walls", 1, 1, "north")); PlacedBuilding snapshot = world.buildings().at(second); CHECK(world.removeBuilding(second)); std::string reason; CHECK_EQ(static_cast(PlacementSystem::restoreExact(&world, snapshot, &reason)), static_cast(PlacementRestoreStatus::Restored)); CHECK_EQ(world.getEdgeOccupant("walls", 2, 1, "north"), second); world.clearBuildings(); CHECK_EQ(world.getBuildingCount(), 0); CHECK(world.isEdgeEmpty("walls", 1, 0, "east")); PlacementSession session; REQUIRE(session.startPlacement(&world, "stone-wall")); REQUIRE_EQ(static_cast(session.updateEdge(&world, 0, 0, "east")), static_cast(EdgeUpdateStatus::Updated)); REQUIRE(session.isValid()); REQUIRE_EQ(session.getGhost()->getPlacementKind(), "edge"); REQUIRE_EQ(session.getGhost()->getEdgeAxis(), "vertical"); const int sessionWall = session.execute(); REQUIRE(sessionWall > 0); CHECK_EQ(world.getEdgeOccupant("walls", 0, 0, "east"), sessionWall); } TEST_CASE("building.edgeLine.atomicRollbackAndVariants") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); BuildingDefinition wall; wall.id = "line-wall"; wall.placementKind = "edge"; wall.channel = "walls"; BuildingRegistry::registerBuilding(wall); PlacementWorld world(5, 5, 1.f); const int blocker = world.placeEdge(wall.id, 1, 0, "north"); REQUIRE(blocker > 0); PlacementSystem::clearEvents(); const int countBefore = world.getBuildingCount(); auto rejected = PlacementSystem::placeEdgeLine(&world, wall.id, 0, 0, 3, 0); CHECK(!rejected.ok()); CHECK_EQ(world.getBuildingCount(), countBefore); CHECK_EQ(static_cast(PlacementSystem::events().size()), 0); CHECK(world.isEdgeEmpty("walls", 0, 0, "north")); CHECK(world.isEdgeEmpty("walls", 2, 0, "north")); REQUIRE(world.removeBuilding(blocker)); PlacementSystem::clearEvents(); auto placed = PlacementSystem::placeEdgeLine(&world, wall.id, 3, 0, 0, 0); REQUIRE(placed.ok()); const auto ids = std::move(placed).takeValue().instanceIds; REQUIRE_EQ(static_cast(ids.size()), 3); REQUIRE_EQ(static_cast(PlacementSystem::events().size()), 3); CHECK_EQ(world.getEdgeVariant(ids[0]), "end"); CHECK_EQ(world.getEdgeVariant(ids[1]), "straight"); CHECK_EQ(world.getEdgeVariant(ids[2]), "end"); PlacementSession session; REQUIRE(session.startPlacement(&world, wall.id)); const EdgeLineExecuteStatus lineStatus = session.executeEdgeLine(0, 2, 0, 5); REQUIRE_EQ(static_cast(lineStatus), static_cast(EdgeLineExecuteStatus::Placed)); REQUIRE_EQ(session.getLastPlacedCount(), 3); CHECK(session.getLastPlacedId(0) > 0); CHECK_EQ(session.getLastPlacedId(3), 0); } TEST_CASE("building.edgePathCommitsTurnsAtomicallyAndRejectsDuplicateEdges") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); BuildingDefinition wall; wall.id = "path-wall"; wall.placementKind = "edge"; wall.channel = "walls"; wall.connectionGroup = "path-wall"; BuildingRegistry::registerBuilding(wall); PlacementWorld world(8, 8, 1.f); const int blocker = world.placeEdge(wall.id, 3, 1, "west"); REQUIRE(blocker > 0); PlacementSystem::clearEvents(); const int countBefore = world.getBuildingCount(); auto rejected = PlacementSystem::placeEdgePath( &world, wall.id, {{0, 0}, {3, 0}, {3, 2}}); CHECK(!rejected.ok()); CHECK_EQ(world.getBuildingCount(), countBefore); CHECK(PlacementSystem::events().empty()); REQUIRE(world.removeBuilding(blocker)); auto duplicate = PlacementSystem::placeEdgePath( &world, wall.id, {{0, 0}, {2, 0}, {0, 0}}); CHECK(!duplicate.ok()); CHECK_EQ(world.getBuildingCount(), 0); PlacementSystem::clearEvents(); auto placed = PlacementSystem::placeEdgePath( &world, wall.id, {{0, 0}, {3, 0}, {3, 2}}); REQUIRE(placed.ok()); const auto ids = std::move(placed).takeValue().instanceIds; REQUIRE_EQ(ids.size(), size_t{5}); CHECK_EQ(PlacementSystem::events().size(), size_t{5}); CHECK_EQ(world.getEdgeVariant(ids[2]), "corner"); CHECK_EQ(world.getEdgeVariant(ids[3]), "corner"); PlacementSession session; REQUIRE(session.startPlacement(&world, wall.id)); CHECK_EQ(static_cast(session.beginEdgePath(5, 1)), static_cast(EdgePathUpdateStatus::Updated)); CHECK_EQ(static_cast(session.appendEdgePathVertex(5, 4)), static_cast(EdgePathUpdateStatus::Updated)); CHECK_EQ(static_cast(session.appendEdgePathVertex(7, 4)), static_cast(EdgePathUpdateStatus::Updated)); CHECK_EQ(session.getEdgePathVertexCount(), 3); CHECK_EQ(static_cast(session.executeEdgePath()), static_cast(EdgeLineExecuteStatus::Placed)); CHECK_EQ(session.getLastPlacedCount(), 5); CHECK_EQ(session.getEdgePathVertexCount(), 0); BuildingRegistry::clear(); } TEST_CASE("building.edgeCubicBezierSamplesDeterministicallyAndCommitsAtomically") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); BuildingDefinition wall; wall.id = "curve-wall"; wall.placementKind = "edge"; wall.channel = "walls"; wall.connectionGroup = "curve-wall"; BuildingRegistry::registerBuilding(wall); PlacementWorld world(10, 10, 1.f); const std::vector controls{ {1.f, 1.f}, {1.f, 6.f}, {7.f, 6.f}, {7.f, 1.f}}; auto sampled = PlacementSystem::sampleEdgeCubicBezier(controls, 24); REQUIRE(sampled.ok()); REQUIRE(sampled.value().size() > size_t{7}); CHECK_EQ(sampled.value().front().x, 1); CHECK_EQ(sampled.value().front().y, 1); CHECK_EQ(sampled.value().back().x, 7); CHECK_EQ(sampled.value().back().y, 1); for (size_t i = 1; i < sampled.value().size(); ++i) { const int distance = std::abs(sampled.value()[i].x - sampled.value()[i - 1].x) + std::abs(sampled.value()[i].y - sampled.value()[i - 1].y); CHECK_EQ(distance, 1); } auto sampledAgain = PlacementSystem::sampleEdgeCubicBezier(controls, 24); REQUIRE(sampledAgain.ok()); CHECK(sampledAgain.value() == sampled.value()); CHECK(!PlacementSystem::sampleEdgeCubicBezier(controls, 1).ok()); auto preview = PlacementSystem::previewEdgeCubicBezier(&world, wall.id, controls, 24); REQUIRE(preview.ok()); REQUIRE(!preview.value().edges.empty()); const EdgeAddress blockedEdge = preview.value().edges[preview.value().edges.size() / 2]; const int blocker = world.placeEdge( wall.id, blockedEdge.x, blockedEdge.y, blockedEdge.axis == EdgeAxis::Horizontal ? "north" : "west"); REQUIRE(blocker > 0); PlacementSystem::clearEvents(); auto rejected = PlacementSystem::placeEdgeCubicBezier(&world, wall.id, controls, 24); CHECK(!rejected.ok()); CHECK_EQ(world.getBuildingCount(), 1); CHECK(PlacementSystem::events().empty()); REQUIRE(world.removeBuilding(blocker)); auto placed = PlacementSystem::placeEdgeCubicBezier(&world, wall.id, controls, 24); REQUIRE(placed.ok()); CHECK_EQ(placed.value().instanceIds.size(), preview.value().edges.size()); CHECK_EQ(PlacementSystem::events().size(), preview.value().edges.size()); CHECK_EQ(world.getEdgeCurveGroupCount(), 1); auto group = world.edgeCurveGroupForInstance(placed.value().instanceIds.front()); REQUIRE(group.ok()); CHECK(group.value().id); CHECK_EQ(group.value().buildingId, wall.id); CHECK_EQ(group.value().controlPoints, controls); CHECK_EQ(group.value().subdivisions, 24); CHECK_EQ(group.value().instanceIds, placed.value().instanceIds); auto cloned = world.cloneState(); REQUIRE(static_cast(cloned)); auto clonedGroup = cloned->edgeCurveGroup(group.value().id); REQUIRE(clonedGroup.ok()); CHECK_EQ(clonedGroup.value(), group.value()); const int removedMember = placed.value().instanceIds[placed.value().instanceIds.size() / 2]; REQUIRE(world.removeBuilding(removedMember)); CHECK_EQ(world.getEdgeCurveGroupCount(), 0); CHECK(!world.edgeCurveGroup(group.value().id).ok()); for (int instanceId : placed.value().instanceIds) { if (instanceId == removedMember) continue; CHECK(!world.edgeCurveGroupForInstance(instanceId).ok()); } PlacementWorld sessionWorld(10, 10, 1.f); PlacementSession session; REQUIRE(session.startPlacement(&sessionWorld, wall.id)); CHECK_EQ(static_cast(session.beginEdgeCurve(1.f, 1.f)), static_cast(EdgePathUpdateStatus::Updated)); CHECK_EQ(static_cast(session.appendEdgeCurveControlPoint(1.f, 6.f)), static_cast(EdgePathUpdateStatus::Updated)); CHECK_EQ(static_cast(session.appendEdgeCurveControlPoint(7.f, 6.f)), static_cast(EdgePathUpdateStatus::Updated)); CHECK_EQ(static_cast(session.appendEdgeCurveControlPoint(7.f, 1.f)), static_cast(EdgePathUpdateStatus::Updated)); CHECK_EQ(static_cast(session.executeEdgeCurve(24)), static_cast(EdgeLineExecuteStatus::Placed)); CHECK(session.getLastPlacedCount() > 0); PlacementWorld surfaceSessionWorld(10, 10, 10.f); surfaceSessionWorld.setGridPlane("xz"); PlacementSystem::registerSurfaceProvider( "session.curve-terrain", [](const PlacementWorld &, float x, float y) { PlacementSystem::PlacementHit hit; hit.worldX = x; hit.worldY = x * 0.05f; hit.worldZ = y; hit.normalX = -0.05f; hit.normalY = 1.f; hit.surfaceId = "terrain.session"; hit.surfaceRevision = 3; return eve::Result::success(std::move(hit)); }); PlacementSession surfaceSession; REQUIRE(surfaceSession.startPlacement(&surfaceSessionWorld, wall.id)); REQUIRE(static_cast(surfaceSession.beginEdgeCurve(1.f, 1.f)) == static_cast(EdgePathUpdateStatus::Updated)); REQUIRE(static_cast(surfaceSession.appendEdgeCurveControlPoint(1.f, 6.f)) == static_cast(EdgePathUpdateStatus::Updated)); REQUIRE(static_cast(surfaceSession.appendEdgeCurveControlPoint(7.f, 6.f)) == static_cast(EdgePathUpdateStatus::Updated)); REQUIRE(static_cast(surfaceSession.appendEdgeCurveControlPoint(7.f, 1.f)) == static_cast(EdgePathUpdateStatus::Updated)); REQUIRE(static_cast(surfaceSession.executeEdgeCurveOnSurface( 24, "session.curve-terrain")) == static_cast(EdgeLineExecuteStatus::Placed)); REQUIRE(surfaceSession.getLastPlacedCount() > 0); auto surfaceGroup = surfaceSessionWorld.edgeCurveGroupForInstance( surfaceSession.getLastPlacedId(0)); REQUIRE(surfaceGroup.ok()); CHECK_EQ(surfaceGroup.value().surfaceProviderName, "session.curve-terrain"); CHECK_EQ(surfaceGroup.value().surfaceId, "terrain.session"); CHECK_EQ(surfaceGroup.value().surfaceRevision, uint64_t{3}); PlacementSystem::unregisterSurface("session.curve-terrain"); BuildingRegistry::clear(); } TEST_CASE("building.edit.moveReplaceTransactions") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); BuildingDefinition chair; chair.id = "edit-chair"; chair.channel = "furniture"; chair.tags = {"seat"}; BuildingRegistry::registerBuilding(chair); BuildingDefinition bench = chair; bench.id = "edit-bench"; bench.footprintW = 2; bench.tags = {"bench"}; BuildingRegistry::registerBuilding(bench); BuildingDefinition wall; wall.id = "edit-wall"; wall.placementKind = "edge"; wall.channel = "walls"; BuildingRegistry::registerBuilding(wall); BuildingDefinition gate = wall; gate.id = "edit-gate"; gate.tags = {"gate"}; BuildingRegistry::registerBuilding(gate); PlacementWorld world(6, 6, 1.f); const int source = world.placeAt(chair.id, 1, 1); const int blocker = world.placeAt(chair.id, 2, 1); REQUIRE(source > 0); REQUIRE(blocker > 0); world.buildings().at(source).setProp("owner", "player"); world.buildings().at(source).garrison.push_back({"worker-1", "worker", {"builder"}}); PlacementSystem::clearEvents(); auto rejectedMove = PlacementSystem::moveBuildingResult(&world, source, 2, 1, 0.f); CHECK(!rejectedMove.ok()); CHECK_EQ(world.getBuildingCellX(source), 1); CHECK_EQ(static_cast(PlacementSystem::events().size()), 0); auto moved = PlacementSystem::moveBuildingResult(&world, source, 1, 2, 0.f); REQUIRE(moved.ok()); PlacementEditReceipt moveReceipt = std::move(moved).takeValue(); CHECK_EQ(moveReceipt.before.originCellY, 1); CHECK_EQ(moveReceipt.after.originCellY, 2); CHECK_EQ(world.getBuildingCellY(source), 2); REQUIRE_EQ(static_cast(PlacementSystem::events().size()), 1); CHECK_EQ(PlacementSystem::events().back().action, "move"); REQUIRE(world.moveBuilding(blocker, 2, 2)); PlacementSystem::clearEvents(); auto rejectedReplace = PlacementSystem::replaceBuildingResult(&world, source, bench.id); CHECK(!rejectedReplace.ok()); CHECK_EQ(world.buildings().at(source).buildingId, chair.id); CHECK_EQ(static_cast(PlacementSystem::events().size()), 0); REQUIRE(world.moveBuilding(blocker, 4, 4)); PlacementSystem::clearEvents(); auto replaced = PlacementSystem::replaceBuildingResult(&world, source, bench.id); REQUIRE(replaced.ok()); PlacementEditReceipt replaceReceipt = std::move(replaced).takeValue(); CHECK_EQ(replaceReceipt.before.buildingId, chair.id); CHECK_EQ(replaceReceipt.after.buildingId, bench.id); CHECK_EQ(replaceReceipt.after.instanceId, source); CHECK_EQ(world.buildings().at(source).getProp("owner"), "player"); REQUIRE_EQ(static_cast(world.buildings().at(source).garrison.size()), 1); REQUIRE_EQ(static_cast(PlacementSystem::events().size()), 1); CHECK_EQ(PlacementSystem::events().back().action, "replace"); CHECK_EQ(PlacementSystem::events().back().otherBuildingId, chair.id); const int edge = world.placeEdge(wall.id, 0, 0, "north"); const int edgeBlocker = world.placeEdge(wall.id, 2, 0, "north"); REQUIRE(edge > 0); REQUIRE(edgeBlocker > 0); PlacementSystem::clearEvents(); auto rejectedEdgeMove = PlacementSystem::moveEdgeResult(&world, edge, 2, 0, "north"); CHECK(!rejectedEdgeMove.ok()); CHECK_EQ(world.getEdgeOccupant("walls", 0, 0, "north"), edge); CHECK_EQ(static_cast(PlacementSystem::events().size()), 0); auto movedEdge = PlacementSystem::moveEdgeResult(&world, edge, 1, 0, "east"); REQUIRE(movedEdge.ok()); CHECK_EQ(static_cast(std::move(movedEdge).takeValue().after.edge.axis), static_cast(EdgeAxis::Vertical)); CHECK_EQ(world.getEdgeOccupant("walls", 1, 0, "east"), edge); auto replacedEdge = PlacementSystem::replaceBuildingResult(&world, edge, gate.id); REQUIRE(replacedEdge.ok()); CHECK_EQ(std::move(replacedEdge).takeValue().after.instanceId, edge); CHECK_EQ(world.buildings().at(edge).buildingId, gate.id); PlacementSession session; REQUIRE(session.startPlacement(&world, chair.id)); REQUIRE(session.updateFromWorld(&world, 0.f, 3.f)); CHECK_EQ(static_cast(session.executeMove(source)), static_cast(PlacementEditExecuteStatus::Committed)); CHECK_EQ(session.getLastEditedId(), source); CHECK_EQ(world.getBuildingCellX(source), 0); CHECK_EQ(world.getBuildingCellY(source), 3); CHECK_EQ(static_cast(session.executeReplace(source)), static_cast(PlacementEditExecuteStatus::Committed)); CHECK_EQ(world.buildings().at(source).buildingId, chair.id); BuildingRegistry::clear(); PlacementSystem::clearEvents(); } TEST_CASE("building.session.placeAndRemove") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); PlacementSystem::ensureBuiltins(); BuildingDefinition hut; hut.id = "hut"; hut.footprintW = 1; hut.footprintH = 1; BuildingRegistry::registerBuilding(hut); PlacementWorld world(8, 8, 32.f); PlacementSession session; REQUIRE(session.startPlacement(&world, "hut")); REQUIRE(session.isActive()); REQUIRE(session.updateFromWorld(&world, 80.f, 90.f)); REQUIRE(session.isValid()); const int id = session.execute(); REQUIRE(id > 0); REQUIRE_EQ(world.getBuildingCount(), 1); session.setMode("remove"); REQUIRE(session.updateFromWorld(&world, 80.f, 90.f)); const int removed = session.execute(); REQUIRE_EQ(removed, id); REQUIRE_EQ(world.getBuildingCount(), 0); session.stopPlacement(); REQUIRE(!session.isActive()); BuildingRegistry::clear(); } TEST_CASE("building.area.previewAndAtomicCommit") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); BuildingDefinition tile; tile.id = "area-tile"; tile.channel = "floor"; BuildingRegistry::registerBuilding(tile); PlacementWorld world(5, 5, 1.f); REQUIRE(world.placeAt(tile.id, 1, 1) > 0); PlacementSystem::clearEvents(); auto previewResult = PlacementSystem::previewRectangle(&world, tile.id, 0, 0, 2, 1); REQUIRE(previewResult.ok()); auto preview = std::move(previewResult).takeValue(); CHECK_EQ(preview.acceptedCount, 5); CHECK_EQ(preview.rejectedCount, 1); CHECK_EQ(preview.cells[4].reason, "occupied"); auto rejected = PlacementSystem::placeRectangle(&world, tile.id, 0, 0, 2, 1); CHECK(!rejected.ok()); CHECK_EQ(world.getBuildingCount(), 1); CHECK_EQ(static_cast(PlacementSystem::events().size()), 0); auto brushPreview = PlacementSystem::previewBrush(&world, tile.id, 3, 3, 1); REQUIRE(brushPreview.ok()); CHECK_EQ(static_cast(brushPreview.value().cells.size()), 5); auto brush = PlacementSystem::placeBrush(&world, tile.id, 3, 3, 1); REQUIRE(brush.ok()); CHECK_EQ(static_cast(std::move(brush).takeValue().instanceIds.size()), 5); CHECK_EQ(world.getBuildingCount(), 6); CHECK_EQ(static_cast(PlacementSystem::events().size()), 5); PlacementSession session; REQUIRE(session.startPlacement(&world, tile.id)); CHECK_EQ(static_cast(session.previewRectangle(0, 3, 1, 4)), static_cast(AreaExecuteStatus::Accepted)); CHECK_EQ(session.getAreaPreviewCount(), 4); CHECK(session.getAreaPreviewAccepted(0)); CHECK_EQ(static_cast(session.executeRectangle(0, 3, 1, 4)), static_cast(AreaExecuteStatus::Accepted)); CHECK_EQ(session.getLastPlacedCount(), 4); BuildingRegistry::clear(); PlacementSystem::clearEvents(); } TEST_CASE("building.pattern.unifies_edge_area_outline_and_session_commit") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); BuildingDefinition tile; tile.id = "pattern-tile"; tile.channel = "pattern-floor"; BuildingRegistry::registerBuilding(tile); BuildingDefinition wall; wall.id = "pattern-wall"; wall.channel = "pattern-wall"; wall.placementKind = "edge"; BuildingRegistry::registerBuilding(wall); PlacementWorld areaWorld(12, 12, 1.f); PlacementSystem::PatternRequest request; request.kind = PlacementSystem::PatternKind::RectangleOutline; request.points = {{1.f, 1.f}, {4.f, 3.f}}; auto outline = PlacementSystem::previewPattern(&areaWorld, tile.id, request); REQUIRE(outline.ok()); CHECK_EQ(outline.value().anchorCount(), 10); CHECK_EQ(outline.value().area.acceptedCount, 10); auto placedOutline = PlacementSystem::placePattern(&areaWorld, tile.id, request); REQUIRE(placedOutline.ok()); CHECK_EQ(static_cast(placedOutline.value().instanceIds.size()), 10); CHECK_EQ(areaWorld.getOccupantAtLevel("pattern-floor", 2, 2, 0), 0); PlacementSystem::PatternRequest brush; brush.kind = PlacementSystem::PatternKind::CircleBrush; brush.points = {{8.f, 8.f}}; brush.radius = 1; auto brushPreview = PlacementSystem::previewPattern(&areaWorld, tile.id, brush); REQUIRE(brushPreview.ok()); CHECK_EQ(brushPreview.value().anchorCount(), 5); PlacementWorld edgeWorld(16, 16, 1.f); PlacementSystem::PatternRequest line; line.kind = PlacementSystem::PatternKind::EdgeLine; line.points = {{1.f, 1.f}, {5.f, 1.f}}; auto linePreview = PlacementSystem::previewPattern(&edgeWorld, wall.id, line); REQUIRE(linePreview.ok()); CHECK_EQ(linePreview.value().anchorCount(), 4); PlacementSystem::PatternRequest path; path.kind = PlacementSystem::PatternKind::EdgePath; path.points = {{1.f, 2.f}, {5.f, 2.f}, {5.f, 5.f}}; auto pathPreview = PlacementSystem::previewPattern(&edgeWorld, wall.id, path); REQUIRE(pathPreview.ok()); CHECK_EQ(pathPreview.value().anchorCount(), 7); PlacementSystem::PatternRequest curve; curve.kind = PlacementSystem::PatternKind::EdgeCubicBezier; curve.points = {{1.f, 7.f}, {1.f, 12.f}, {8.f, 12.f}, {8.f, 7.f}}; curve.subdivisions = 24; auto curvePreview = PlacementSystem::previewPattern(&edgeWorld, wall.id, curve); REQUIRE(curvePreview.ok()); CHECK(curvePreview.value().anchorCount() > 7); PlacementWorld sessionWorld(12, 12, 1.f); PlacementSession session; REQUIRE(session.startPlacement(&sessionWorld, tile.id)); CHECK_EQ(static_cast(session.beginPattern("rectangle_outline")), static_cast(PatternUpdateStatus::Updated)); CHECK_EQ(static_cast(session.appendPatternPoint(2.f, 2.f)), static_cast(PatternUpdateStatus::Updated)); CHECK_EQ(static_cast(session.appendPatternPoint(5.f, 4.f)), static_cast(PatternUpdateStatus::Updated)); CHECK_EQ(static_cast(session.previewPattern()), static_cast(PatternUpdateStatus::Updated)); CHECK_EQ(session.getPatternPreviewCount(), 10); CHECK(session.getPatternPreviewAccepted(0)); CHECK_EQ(session.getPatternPreviewAxis(0), std::string{}); CHECK_EQ(static_cast(session.executePattern()), static_cast(PatternExecuteStatus::Placed)); CHECK_EQ(session.getLastPlacedCount(), 10); CHECK_EQ(sessionWorld.getBuildingCount(), 10); CHECK_EQ(static_cast(session.beginPattern("not-a-pattern")), static_cast(PatternUpdateStatus::Rejected)); BuildingRegistry::clear(); PlacementSystem::clearEvents(); } TEST_CASE("building.levelsIsolateCellAndEdgeOccupancy") { BuildingRegistry::clear(); BuildingDefinition tile; tile.id = "level-tile"; tile.channel = "floor"; BuildingRegistry::registerBuilding(tile); BuildingDefinition wall; wall.id = "level-wall"; wall.channel = "walls"; wall.placementKind = "edge"; BuildingRegistry::registerBuilding(wall); PlacementWorld world(4, 4, 1.f); world.setFloorHeight(2.5f); const int groundTile = world.placeAt(tile.id, 1, 1); const int groundWall = world.placeEdge(wall.id, 1, 1, "north"); REQUIRE(groundTile > 0); REQUIRE(groundWall > 0); CHECK_EQ(world.getOccupantAtLevel("floor", 1, 1, 0), groundTile); CHECK_EQ(world.getEdgeOccupantAtLevel("walls", 1, 1, "north", 0), groundWall); world.setActiveLevel(1); const int upperTile = world.placeAt(tile.id, 1, 1); const int upperWall = world.placeEdge(wall.id, 1, 1, "north"); REQUIRE(upperTile > 0); REQUIRE(upperWall > 0); CHECK_EQ(world.getBuildingLevel(upperTile), 1); CHECK_EQ(world.getOccupantInChannel("floor", 1, 1), upperTile); CHECK_EQ(world.getOccupantAtLevel("floor", 1, 1, 0), groundTile); CHECK_EQ(world.getEdgeOccupant("walls", 1, 1, "north"), upperWall); CHECK_EQ(world.getEdgeOccupantAtLevel("walls", 1, 1, "north", 0), groundWall); CHECK_EQ(world.placeAt(tile.id, 1, 1), 0); CHECK_EQ(world.placeEdge(wall.id, 1, 1, "north"), 0); CHECK_EQ(world.getEdgeConnectionMask(groundWall), 0); auto clone = world.cloneState(); REQUIRE(static_cast(clone)); CHECK_EQ(clone->getActiveLevel(), 1); CHECK(std::abs(clone->getFloorHeight() - 2.5f) < 0.0001f); CHECK_EQ(clone->getOccupantAtLevel("floor", 1, 1, 1), upperTile); BuildingRegistry::clear(); } TEST_CASE("building.structuralSupportRequiresTaggedCellBelow") { BuildingRegistry::clear(); BuildingDefinition foundation; foundation.id = "foundation"; foundation.channel = "floor"; foundation.tags = {"structural.support"}; BuildingRegistry::registerBuilding(foundation); BuildingDefinition roof; roof.id = "roof"; roof.channel = "roof"; roof.structuralRole = "roof"; roof.supportMode = "cell_below"; roof.supportTag = "structural.support"; BuildingRegistry::registerBuilding(roof); BuildingDefinition reinforced = foundation; reinforced.id = "reinforced-foundation"; BuildingRegistry::registerBuilding(reinforced); BuildingDefinition decorative = foundation; decorative.id = "decorative-floor"; decorative.tags.clear(); BuildingRegistry::registerBuilding(decorative); PlacementWorld world(4, 4, 1.f); world.setActiveLevel(1); CHECK_EQ(world.canPlaceReason(roof.id, 2, 2), "support_missing"); CHECK_EQ(world.placeAt(roof.id, 2, 2), 0); world.setActiveLevel(0); const int foundationId = world.placeAt(foundation.id, 2, 2); REQUIRE(foundationId > 0); world.setActiveLevel(1); const int roofId = world.placeAt(roof.id, 2, 2); REQUIRE(roofId > 0); CHECK_EQ(world.getBuildingLevel(roofId), 1); CHECK_EQ(world.getBuildingSupportCount(roofId), 1); CHECK_EQ(world.getBuildingSupportAt(roofId, 0), foundationId); CHECK_EQ(world.getBuildingDependentCount(foundationId), 1); CHECK_EQ(world.canRemoveBuildingReason(foundationId), "support_in_use"); CHECK(!world.removeBuilding(foundationId)); auto moveSupport = PlacementSystem::moveBuildingResult(&world, foundationId, 1, 1); CHECK(!moveSupport.ok()); auto badReplace = PlacementSystem::replaceBuildingResult(&world, foundationId, decorative.id); CHECK(!badReplace.ok()); auto preservingReplace = PlacementSystem::replaceBuildingResult(&world, foundationId, reinforced.id); REQUIRE(preservingReplace.ok()); CHECK_EQ(world.getBuildingDependentCount(foundationId), 1); auto cascade = PlacementSystem::removeBuildingCascadeResult(&world, foundationId); REQUIRE(cascade.ok()); REQUIRE_EQ(cascade.value().removed.size(), size_t(2)); CHECK_EQ(cascade.value().removed[0].instanceId, roofId); CHECK_EQ(cascade.value().removed[1].instanceId, foundationId); CHECK(!world.hasBuilding(foundationId)); CHECK(!world.hasBuilding(roofId)); std::string error; CHECK_EQ(BuildingRegistry::loadFromJson( R"([{"id":"json-roof","structuralRole":"roof","supportMode":"cell_below","supportTag":"structural.support"}])", &error), 1); const BuildingDefinition *parsed = BuildingRegistry::find("json-roof"); REQUIRE(parsed != nullptr); CHECK_EQ(parsed->structuralRole, "roof"); CHECK_EQ(parsed->supportMode, "cell_below"); CHECK_EQ(parsed->supportTag, "structural.support"); BuildingRegistry::clear(); } TEST_CASE("building.structuralCascadeIsTransitiveAndRejectsCorruptCycles") { BuildingRegistry::clear(); BuildingDefinition support; support.id = "support"; support.channel = "structure"; support.tags = {"structural.support"}; support.supportMode = "cell_below"; support.supportTag = "structural.support"; BuildingRegistry::registerBuilding(support); PlacementWorld world(2, 2, 1.f); world.setActiveLevel(0); BuildingDefinition ground = support; ground.id = "ground-support"; ground.supportMode = "none"; BuildingRegistry::registerBuilding(ground); const int level0 = world.placeAt(ground.id, 0, 0); world.setActiveLevel(1); const int level1 = world.placeAt(support.id, 0, 0); world.setActiveLevel(2); const int level2 = world.placeAt(support.id, 0, 0); REQUIRE(level0 > 0); REQUIRE(level1 > 0); REQUIRE(level2 > 0); int removalHooks = 0; bool hookObservedPartialCommit = false; PlacementSystem::registerChangeHook("structural-atomic-observer", [&](const BuildingChangeEvent &event) { if (event.action != "remove") return; ++removalHooks; hookObservedPartialCommit = hookObservedPartialCommit || world.hasBuilding(level0) || world.hasBuilding(level1) || world.hasBuilding(level2); }); auto cascade = PlacementSystem::removeBuildingCascadeResult(&world, level0); REQUIRE(cascade.ok()); REQUIRE_EQ(cascade.value().removed.size(), size_t(3)); CHECK_EQ(cascade.value().removed[0].instanceId, level2); CHECK_EQ(cascade.value().removed[1].instanceId, level1); CHECK_EQ(cascade.value().removed[2].instanceId, level0); CHECK_EQ(removalHooks, 3); CHECK(!hookObservedPartialCommit); PlacementSystem::unregisterChangeHook("structural-atomic-observer"); world.setActiveLevel(0); const int a = world.placeAt(ground.id, 0, 0); const int b = world.placeAt(ground.id, 1, 0); REQUIRE(a > 0); REQUIRE(b > 0); world.buildings().at(a).supportInstanceIds = {b}; world.buildings().at(b).supportInstanceIds = {a}; auto corrupt = PlacementSystem::removeBuildingCascadeResult(&world, a); CHECK(!corrupt.ok()); CHECK(world.hasBuilding(a)); CHECK(world.hasBuilding(b)); BuildingRegistry::clear(); } TEST_CASE("building.structuralLinksRebuildAtomicallyAfterDefinitionReload") { BuildingRegistry::clear(); BuildingDefinition base; base.id = "reload-base"; base.channel = "base"; base.tags = {"load-bearing"}; BuildingRegistry::registerBuilding(base); BuildingDefinition upper; upper.id = "reload-upper"; upper.channel = "upper"; upper.supportMode = "cell_below"; upper.supportTag = "load-bearing"; BuildingRegistry::registerBuilding(upper); PlacementWorld world(2, 2, 1.f); const int baseId = world.placeAt(base.id, 0, 0); world.setActiveLevel(1); const int upperId = world.placeAt(upper.id, 0, 0); REQUIRE(baseId > 0); REQUIRE(upperId > 0); world.buildings().at(upperId).supportInstanceIds.clear(); auto rebuilt = PlacementSystem::rebuildStructuralLinksResult(&world); REQUIRE(rebuilt.ok()); CHECK_EQ(rebuilt.value().inspectedCount, 2); CHECK_EQ(rebuilt.value().changedCount, 1); CHECK_EQ(world.getBuildingSupportAt(upperId, 0), baseId); BuildingDefinition incompatible = base; incompatible.tags.clear(); BuildingRegistry::registerBuilding(incompatible); const auto linksBeforeFailure = world.buildings().at(upperId).supportInstanceIds; auto rejected = PlacementSystem::rebuildStructuralLinksResult(&world); CHECK(!rejected.ok()); CHECK_EQ(world.buildings().at(upperId).supportInstanceIds, linksBeforeFailure); CHECK(world.hasBuilding(baseId)); CHECK(world.hasBuilding(upperId)); BuildingRegistry::clear(); } TEST_CASE("building.cornerDomainOwnsVerticesAndSupportsVerticalStructures") { BuildingRegistry::clear(); BuildingDefinition post; post.id = "corner-post"; post.placementKind = "corner"; post.channel = "posts"; post.tags = {"post-support"}; BuildingRegistry::registerBuilding(post); BuildingDefinition reinforced = post; reinforced.id = "corner-post-reinforced"; BuildingRegistry::registerBuilding(reinforced); BuildingDefinition decorative = post; decorative.id = "corner-decoration"; decorative.tags.clear(); BuildingRegistry::registerBuilding(decorative); BuildingDefinition upper = post; upper.id = "corner-upper"; upper.channel = "upper-posts"; upper.supportMode = "corner_below"; upper.supportTag = "post-support"; BuildingRegistry::registerBuilding(upper); BuildingDefinition tile; tile.id = "corner-domain-cell"; tile.channel = "posts"; BuildingRegistry::registerBuilding(tile); PlacementWorld world(2, 2, 1.f); REQUIRE(world.placeAt(tile.id, 2 - 1, 2 - 1) > 0); auto base = PlacementSystem::placeCornerResult(&world, post.id, 2, 2); REQUIRE(base.ok()); const int baseId = base.value().instanceId; CHECK_EQ(world.getCornerOccupant("posts", 2, 2), baseId); CHECK_EQ(world.getOccupantInChannel("posts", 1, 1) != 0, true); CHECK_EQ(world.canPlaceReason(post.id, 0, 0), "corner_requires_corner_address"); CHECK_EQ(world.canPlaceCornerReason(post.id, 3, 2), "corner_out_of_bounds"); CHECK_EQ(world.canPlaceCornerReason(post.id, 2, 2), "corner_occupied"); world.setActiveLevel(1); CHECK_EQ(world.canPlaceCornerReason(upper.id, 2, 2), std::string{}); auto top = PlacementSystem::placeCornerResult(&world, upper.id, 2, 2); REQUIRE(top.ok()); const int topId = top.value().instanceId; CHECK_EQ(world.getBuildingSupportAt(topId, 0), baseId); CHECK_EQ(world.getCornerOccupantAtLevel("posts", 2, 2, 0), baseId); CHECK_EQ(world.getCornerOccupantAtLevel("upper-posts", 2, 2, 1), topId); auto invalidReplace = PlacementSystem::replaceBuildingResult(&world, baseId, decorative.id); CHECK(!invalidReplace.ok()); auto validReplace = PlacementSystem::replaceBuildingResult(&world, baseId, reinforced.id); REQUIRE(validReplace.ok()); auto removed = PlacementSystem::removeBuildingCascadeResult(&world, baseId); REQUIRE(removed.ok()); REQUIRE_EQ(removed.value().removed.size(), size_t(2)); CHECK_EQ(removed.value().removed[0].instanceId, topId); std::string reason; CHECK_EQ(static_cast( PlacementSystem::restoreExact(&world, removed.value().removed[1], &reason)), static_cast(PlacementRestoreStatus::Restored)); CHECK_EQ(static_cast( PlacementSystem::restoreExact(&world, removed.value().removed[0], &reason)), static_cast(PlacementRestoreStatus::Restored)); CHECK_EQ(world.getBuildingSupportAt(topId, 0), baseId); world.setActiveLevel(0); PlacementSession session; REQUIRE(session.startPlacement(&world, post.id)); CHECK_EQ(static_cast(session.updateCorner(&world, 0, 2)), static_cast(CornerUpdateStatus::Updated)); const int sessionPost = session.execute(); REQUIRE(sessionPost > 0); CHECK_EQ(world.getCornerOccupant("posts", 0, 2), sessionPost); CHECK_EQ(static_cast(session.updateCorner(&world, 1, 2)), static_cast(CornerUpdateStatus::Updated)); CHECK_EQ(static_cast(session.executeMove(sessionPost)), static_cast(PlacementEditExecuteStatus::Committed)); CHECK_EQ(world.getCornerOccupant("posts", 0, 2), 0); CHECK_EQ(world.getCornerOccupant("posts", 1, 2), sessionPost); session.setMode("remove"); CHECK_EQ(session.execute(), sessionPost); CHECK(!world.hasBuilding(sessionPost)); BuildingRegistry::clear(); } TEST_CASE("building.freeDomainPreservesExactAnchorsAndRejectsCircularOverlap") { BuildingRegistry::clear(); PlacementSystem::clearEvents(); BuildingDefinition prop; prop.id = "free-prop"; prop.placementKind = "free"; prop.snapMode = "free"; prop.rotationMode = "free"; prop.channel = "decor"; prop.freeRadiusCells = 0.25f; BuildingRegistry::registerBuilding(prop); PlacementWorld world(8, 8, 10.f); auto first = PlacementSystem::placeFreeResult(&world, prop.id, 12.25f, 17.75f, 1.5f, 37.f); REQUIRE(first.ok()); const int firstId = first.value().instanceId; CHECK_EQ(world.getBuildingWorldX(firstId), 12.25f); CHECK_EQ(world.getBuildingElevation(firstId), 1.5f); CHECK_EQ(world.getBuildingRotation(firstId), 37.f); CHECK_EQ(world.getFreeOccupant("decor", 12.25f, 17.75f), firstId); CHECK_EQ(world.canPlaceFreeReason(prop.id, 16.f, 17.75f), "free_overlap"); CHECK(world.canPlaceFree(prop.id, 18.f, 17.75f)); Ghost ghost; ghost.setBuildingId(prop.id); ghost.setFree(&world, 31.125f, 22.875f, 0.5f); ghost.setRotationDeg(83.f); REQUIRE(ghost.validate(&world)); const int secondId = world.placeGhost(&ghost); REQUIRE(secondId > 0); CHECK_EQ(world.getBuildingWorldX(secondId), 31.125f); PlacementSession session; REQUIRE(session.startPlacement(&world, prop.id)); CHECK_EQ(static_cast(session.updateFree(&world, 44.5f, 26.25f, 2.f)), static_cast(FreeUpdateStatus::Updated)); CHECK_EQ(static_cast(session.executeMove(secondId)), static_cast(PlacementEditExecuteStatus::Committed)); CHECK_EQ(world.getBuildingWorldX(secondId), 44.5f); CHECK_EQ(world.getBuildingElevation(secondId), 2.f); PlacedBuilding snapshot = world.buildings().at(secondId); REQUIRE(world.removeBuilding(secondId)); std::string reason; CHECK_EQ(static_cast(PlacementSystem::restoreExact(&world, snapshot, &reason)), static_cast(PlacementRestoreStatus::Restored)); CHECK_EQ(world.getBuildingWorldX(secondId), 44.5f); BuildingRegistry::clear(); } TEST_CASE("building.freeDomainCommitsSurfaceAttachmentAndLimits") { BuildingRegistry::clear(); BuildingDefinition prop; prop.id = "free-surface-prop"; prop.placementKind = "free"; prop.snapMode = "free"; prop.rotationMode = "free"; prop.maxSurfaceSlopeDegrees = 50.f; prop.maxSurfaceHeightDelta = 1.f; BuildingRegistry::registerBuilding(prop); PlacementWorld world(8, 8, 1.f); world.setGridPlane("xz"); PlacementSystem::registerSurfaceProvider( "free.surface", [](const PlacementWorld &, float x, float y) { PlacementSystem::PlacementHit hit; hit.worldX = x; hit.worldY = 2.5f; hit.worldZ = y; hit.normalY = 1.f; hit.normalZ = 1.f; hit.tangentX = 1.f; hit.surfaceId = "terrain/free"; hit.surfaceRevision = 42; return eve::Result::success(std::move(hit)); }); Ghost ghost; ghost.setBuildingId(prop.id); ghost.setFromSurface(&world, "free.surface", 2.25f, 3.75f); REQUIRE(ghost.validate(&world)); const int instanceId = world.placeGhost(&ghost); REQUIRE(instanceId > 0); CHECK_EQ(world.getBuildingWorldX(instanceId), 2.25f); CHECK_EQ(world.getBuildingWorldY(instanceId), 2.5f); CHECK_EQ(world.getBuildingWorldZ(instanceId), 3.75f); CHECK_EQ(world.getBuildingSurfaceId(instanceId), "terrain/free"); CHECK_EQ(world.getBuildingSurfaceRevision(instanceId), int64_t{42}); CHECK_EQ(world.buildings().at(instanceId).surfaceSampleCount, 1); PlacementSystem::registerSurfaceProvider( "free.surface", [](const PlacementWorld &, float x, float y) { PlacementSystem::PlacementHit hit; hit.worldX = x; hit.worldY = 4.f; hit.worldZ = y; hit.normalY = 1.f; hit.tangentX = 1.f; hit.surfaceId = "terrain/free-next"; hit.surfaceRevision = 77; return eve::Result::success(std::move(hit)); }); PlacementSession session; REQUIRE(session.startPlacement(&world, prop.id)); REQUIRE(session.updateFromSurface(&world, "free.surface", 5.5f, 6.25f)); PlacementSystem::clearEvents(); CHECK_EQ(static_cast(session.executeMove(instanceId)), static_cast(PlacementEditExecuteStatus::Committed)); CHECK_EQ(world.getBuildingWorldX(instanceId), 5.5f); CHECK_EQ(world.getBuildingWorldY(instanceId), 4.f); CHECK_EQ(world.getBuildingWorldZ(instanceId), 6.25f); CHECK_EQ(world.getBuildingSurfaceId(instanceId), "terrain/free-next"); CHECK_EQ(world.getBuildingSurfaceRevision(instanceId), int64_t{77}); CHECK_EQ(PlacementSystem::events().size(), size_t{1}); const PlacedBuilding beforeRejectedMove = world.buildings().at(instanceId); PlacementSystem::registerSurfaceProvider( "free.surface", [](const PlacementWorld &, float x, float y) { PlacementSystem::PlacementHit hit; hit.worldX = x; hit.worldY = 8.f; hit.worldZ = y; hit.normalX = 1.f; hit.normalY = 0.f; hit.tangentZ = 1.f; hit.surfaceId = "terrain/too-steep"; hit.surfaceRevision = 99; return eve::Result::success(std::move(hit)); }); REQUIRE(session.updateFromSurface(&world, "free.surface", 7.f, 7.f)); CHECK(!session.isValid()); CHECK_EQ(session.getReason(), "surface_slope"); CHECK_EQ(static_cast(session.executeMove(instanceId)), static_cast(PlacementEditExecuteStatus::Rejected)); const PlacedBuilding &afterRejectedMove = world.buildings().at(instanceId); CHECK_EQ(afterRejectedMove.worldX, beforeRejectedMove.worldX); CHECK_EQ(afterRejectedMove.surfaceId, beforeRejectedMove.surfaceId); CHECK_EQ(afterRejectedMove.surfaceRevision, beforeRejectedMove.surfaceRevision); CHECK_EQ(PlacementSystem::events().size(), size_t{1}); PlacementSystem::unregisterSurface("free.surface"); BuildingRegistry::clear(); } TEST_CASE("building.freeObbFootprintsUseRotationForCollisionMoveAndPointQuery") { BuildingRegistry::clear(); BuildingDefinition bench; bench.id = "free-bench"; bench.placementKind = "free"; bench.snapMode = "free"; bench.rotationMode = "free"; bench.channel = "decor"; bench.freeFootprintWidthCells = 2.f; bench.freeFootprintHeightCells = 0.5f; BuildingRegistry::registerBuilding(bench); PlacementWorld world(8, 8, 10.f); auto first = PlacementSystem::placeFreeResult(&world, bench.id, 20.f, 20.f, 0.f, 0.f); REQUIRE(first.ok()); CHECK_EQ(first.value().freeHalfWidth, 10.f); CHECK_EQ(first.value().freeHalfHeight, 2.5f); BuildingDefinition otherChannel = bench; otherChannel.id = "free-bench-overlay"; otherChannel.channel = "overlay"; BuildingRegistry::registerBuilding(otherChannel); REQUIRE(PlacementSystem::placeFreeResult(&world, otherChannel.id, 20.f, 20.f, 0.f, 90.f) .ok()); auto rotatedConflict = PlacementSystem::placeFreeResult(&world, bench.id, 20.f, 29.f, 0.f, 90.f); CHECK(!rotatedConflict.ok()); REQUIRE(rotatedConflict.error()); CHECK_EQ(rotatedConflict.error()->path(), "free_overlap"); auto second = PlacementSystem::placeFreeResult(&world, bench.id, 20.f, 29.f, 0.f, 0.f); REQUIRE(second.ok()); const int secondId = second.value().instanceId; CHECK_EQ(world.getFreeOccupant("decor", 29.f, 20.f), first.value().instanceId); CHECK_EQ(world.getFreeOccupant("decor", 20.f, 29.f), secondId); CHECK_EQ(world.getFreeOccupant("decor", 20.f, 35.f), 0); const PlacedBuilding beforeRejectedMove = world.buildings().at(secondId); auto rejectedMove = PlacementSystem::moveFreeResult(&world, secondId, 20.f, 29.f, 0.f, 90.f); CHECK(!rejectedMove.ok()); REQUIRE(rejectedMove.error()); CHECK_EQ(rejectedMove.error()->path(), "free_overlap"); CHECK_EQ(world.buildings().at(secondId).rotationDeg, beforeRejectedMove.rotationDeg); CHECK_EQ(world.buildings().at(secondId).worldY, beforeRejectedMove.worldY); PlacementSystem::registerSurfaceProvider( "obb.surface", [](const PlacementWorld &, float x, float y) { PlacementSystem::PlacementHit hit; hit.worldX = x; hit.worldY = y; hit.worldZ = 0.f; hit.normalZ = 1.f; hit.normalY = 0.f; hit.surfaceId = "floor/a"; hit.surfaceRevision = 3; return eve::Result::success(std::move(hit)); }); auto patch = PlacementSystem::sampleSurfacePatch(world, bench.id, "obb.surface", 40.f, 40.f, 30.f); REQUIRE(patch.ok()); CHECK_EQ(patch.value().samples.size(), size_t{5}); PlacementSystem::registerSurfaceProvider( "obb.surface", [](const PlacementWorld &, float x, float y) { PlacementSystem::PlacementHit hit; hit.worldX = x; hit.worldY = y; hit.worldZ = 0.f; hit.normalZ = 1.f; hit.normalY = 0.f; hit.surfaceId = x > 45.f ? "floor/b" : "floor/a"; hit.surfaceRevision = 3; return eve::Result::success(std::move(hit)); }); CHECK(!PlacementSystem::sampleSurfacePatch(world, bench.id, "obb.surface", 40.f, 40.f, 0.f) .ok()); PlacementSystem::unregisterSurface("obb.surface"); BuildingRegistry::clear(); } TEST_CASE("building.freeConvexPolygonFootprintsValidateAndDriveCollision") { BuildingRegistry::clear(); BuildingDefinition invalid; invalid.id = "free-concave"; invalid.placementKind = "free"; invalid.freeFootprintVertices = {0.f, 0.f, 1.f, 0.f, 0.5f, 0.25f, 1.f, 1.f, 0.f, 1.f}; BuildingRegistry::registerBuilding(invalid); REQUIRE(BuildingRegistry::find(invalid.id)); CHECK(BuildingRegistry::find(invalid.id)->freeFootprintVertices.empty()); BuildingDefinition diamond; diamond.id = "free-diamond"; diamond.placementKind = "free"; diamond.snapMode = "free"; diamond.rotationMode = "free"; diamond.channel = "decor"; diamond.freeFootprintVertices = {-1.f, 0.f, 0.f, -0.25f, 1.f, 0.f, 0.f, 0.25f}; BuildingRegistry::registerBuilding(diamond); PlacementWorld world(8, 8, 10.f); auto first = PlacementSystem::placeFreeResult(&world, diamond.id, 20.f, 20.f, 0.f, 0.f); REQUIRE(first.ok()); CHECK_EQ(first.value().freeFootprintVertices.size(), size_t{8}); CHECK_EQ(first.value().freeHalfWidth, 10.f); CHECK_EQ(first.value().freeHalfHeight, 2.5f); CHECK_EQ(world.getFreeOccupant("decor", 29.f, 20.f), first.value().instanceId); CHECK_EQ(world.getFreeOccupant("decor", 20.f, 23.f), 0); auto rotatedConflict = PlacementSystem::placeFreeResult(&world, diamond.id, 20.f, 29.f, 0.f, 90.f); CHECK(!rotatedConflict.ok()); REQUIRE(PlacementSystem::placeFreeResult(&world, diamond.id, 20.f, 29.f, 0.f, 0.f).ok()); BuildingDefinition circle; circle.id = "free-circle-mixed"; circle.placementKind = "free"; circle.snapMode = "free"; circle.channel = "decor"; circle.freeRadiusCells = 0.2f; BuildingRegistry::registerBuilding(circle); CHECK(!PlacementSystem::placeFreeResult(&world, circle.id, 31.f, 20.f).ok()); PlacementSystem::registerSurfaceProvider( "polygon.surface", [](const PlacementWorld &, float x, float y) { PlacementSystem::PlacementHit hit; hit.worldX = x; hit.worldY = y; hit.normalY = 0.f; hit.normalZ = 1.f; hit.surfaceId = "floor/polygon"; return eve::Result::success(std::move(hit)); }); auto patch = PlacementSystem::sampleSurfacePatch(world, diamond.id, "polygon.surface", 50.f, 50.f, 15.f); REQUIRE(patch.ok()); CHECK_EQ(patch.value().samples.size(), size_t{5}); PlacementSystem::unregisterSurface("polygon.surface"); BuildingRegistry::clear(); }