#include "building/editing/BuildingTarget.h" #include "building/BuildingDef.h" #include "building/PlacementSystem.h" #include "building/PlacementWorld.h" #include "editing/EditingAuthority.h" #include "zeroerr/assert.h" #include "zeroerr/unittest.h" using namespace eve::building_editing; namespace { void registerHouse() { eve::building::BuildingDefinition definition; definition.id = "house"; definition.displayName = "House"; definition.footprintW = 2; definition.footprintH = 2; definition.rotationMode = "cardinal"; definition.tags = {"building", "home"}; eve::building::BuildingRegistry::registerBuilding(definition); } void registerEdges() { eve::building::BuildingDefinition fence; fence.id = "editor-fence"; fence.displayName = "Fence"; fence.placementKind = "edge"; fence.channel = "boundary"; fence.tags = {"building", "fence"}; eve::building::BuildingRegistry::registerBuilding(fence); eve::building::BuildingDefinition gate = fence; gate.id = "editor-gate"; gate.displayName = "Gate"; gate.tags = {"building", "gate"}; eve::building::BuildingRegistry::registerBuilding(gate); } void registerAreaTile() { eve::building::BuildingDefinition tile; tile.id = "editor-area-tile"; tile.displayName = "Area Tile"; tile.footprintW = 1; tile.footprintH = 1; tile.channel = "floor"; tile.tags = {"building", "floor"}; eve::building::BuildingRegistry::registerBuilding(tile); } void registerCorners() { eve::building::BuildingDefinition post; post.id = "editor-post"; post.placementKind = "corner"; post.channel = "posts"; post.tags = {"building", "post"}; eve::building::BuildingRegistry::registerBuilding(post); eve::building::BuildingDefinition reinforced = post; reinforced.id = "editor-reinforced-post"; reinforced.tags = {"building", "post", "reinforced"}; eve::building::BuildingRegistry::registerBuilding(reinforced); } void registerFreeProps() { eve::building::BuildingDefinition prop; prop.id = "editor-free-prop"; prop.placementKind = "free"; prop.snapMode = "free"; prop.rotationMode = "free"; prop.channel = "decor"; prop.freeRadiusCells = 0.2f; prop.freeFootprintWidthCells = 0.8f; prop.freeFootprintHeightCells = 0.4f; prop.freeFootprintVertices = {-0.4f, -0.2f, 0.4f, -0.2f, 0.4f, 0.2f, -0.4f, 0.2f}; eve::building::BuildingRegistry::registerBuilding(prop); } DomainOperation inverse(const DomainOperation& source) { DomainOperation result = source; result.type = source.inverseType; result.payload = source.inverse; return result; } BuildingInstanceSnapshot house(int id, int x, int y) { BuildingInstanceSnapshot result; result.instanceId = id; result.buildingId = "house"; result.cellX = x; result.cellY = y; result.worldX = x * 10.0; result.worldY = y * 10.0; result.tags = {"building", "home"}; return result; } } // namespace TEST_CASE("editor.building.preview_reports_snap_footprint_and_occupancy") { registerHouse(); eve::building::PlacementWorld world(8, 8, 10.f); BuildingPlacementTarget target("build-world", &world); const auto preview = target.preview("house", 21.0, 31.0, 0.0, 91.0); CHECK_EQ(static_cast(preview.status), static_cast(EditorStatus::Applied)); CHECK_EQ(preview.snappedCellX, 2); CHECK_EQ(preview.snappedCellY, 3); CHECK_EQ(preview.normalizedRotation, 90.0); CHECK_EQ(preview.cells.size(), size_t{4}); for (const BuildingFootprintCell& cell : preview.cells) { CHECK(cell.inBounds); CHECK_EQ(cell.occupant, 0); } auto place = target.makePlace(house(target.nextAvailableInstanceId(), 2, 3)); REQUIRE(place.ok()); REQUIRE(target.applyDomainOperation(place.value()).ok()); const auto occupied = target.preview("house", 21.0, 31.0, 0.0, 0.0); CHECK_EQ(static_cast(occupied.status), static_cast(EditorStatus::Rejected)); REQUIRE(!occupied.diagnostics.empty()); CHECK(occupied.cells[0].occupant > 0); } TEST_CASE("editor.building.place_move_remove_are_exactly_reversible") { registerHouse(); eve::building::PlacementWorld world(12, 12, 10.f); BuildingPlacementTarget target("reversible-world", &world); BuildingInstanceSnapshot placed = house(77, 1, 1); placed.level = 2; placed.properties["skin"] = "blue"; placed.garrison = {{"unit-1", "worker", {"builder"}}}; placed.garrisonRevision = 4; auto place = target.makePlace(placed); REQUIRE(place.ok()); REQUIRE(target.applyDomainOperation(place.value()).ok()); REQUIRE(world.hasBuilding(77)); CHECK_EQ(world.getBuildingProp(77, "skin"), "blue"); auto move = target.makeMove(77, 6, 5, 179.0); REQUIRE(move.ok()); REQUIRE(target.applyDomainOperation(move.value()).ok()); CHECK_EQ(world.getBuildingCellX(77), 6); CHECK_EQ(world.getBuildingCellY(77), 5); CHECK_EQ(world.getBuildingRotation(77), 180.f); REQUIRE(target.applyDomainOperation(inverse(move.value())).ok()); CHECK_EQ(world.getBuildingCellX(77), 1); CHECK_EQ(world.getBuildingCellY(77), 1); auto remove = target.makeRemove(77); REQUIRE(remove.ok()); REQUIRE(target.applyDomainOperation(remove.value()).ok()); CHECK(!world.hasBuilding(77)); REQUIRE(target.applyDomainOperation(inverse(remove.value())).ok()); REQUIRE(world.hasBuilding(77)); const auto restored = target.instance(77); REQUIRE(restored.ok()); CHECK_EQ(restored.value().properties.at("skin"), "blue"); CHECK_EQ(restored.value().garrison.size(), size_t{1}); CHECK_EQ(restored.value().garrison[0].id, "unit-1"); CHECK_EQ(restored.value().garrisonRevision, uint64_t{4}); CHECK_EQ(restored.value().level, 2); CHECK_EQ(world.getOccupantAtLevel("", 1, 1, 2), 77); } TEST_CASE("editor.building.undo_restore_conflicts_instead_of_overwriting_occupancy") { registerHouse(); eve::building::PlacementWorld world(8, 8, 10.f); BuildingPlacementTarget target("conflict-world", &world); auto first = target.makePlace(house(10, 1, 1)); REQUIRE(first.ok()); REQUIRE(target.applyDomainOperation(first.value()).ok()); auto remove = target.makeRemove(10); REQUIRE(remove.ok()); REQUIRE(target.applyDomainOperation(remove.value()).ok()); auto replacement = target.makePlace(house(11, 1, 1)); REQUIRE(replacement.ok()); REQUIRE(target.applyDomainOperation(replacement.value()).ok()); const auto conflict = target.applyDomainOperation(inverse(remove.value())); CHECK_EQ(static_cast(conflict.code()), static_cast(EditorStatus::Rejected)); CHECK(!world.hasBuilding(10)); CHECK(world.hasBuilding(11)); } TEST_CASE("editor.building.snapshot_is_deterministic_and_complete") { registerHouse(); eve::building::PlacementWorld world(8, 8, 10.f); world.setId("settlement"); BuildingPlacementTarget target("snapshot-world", &world); auto first = target.makePlace(house(3, 0, 0)); auto second = target.makePlace(house(9, 4, 4)); REQUIRE(first.ok()); REQUIRE(second.ok()); REQUIRE(target.applyDomainOperation(first.value()).ok()); REQUIRE(target.applyDomainOperation(second.value()).ok()); const EditorValue snapshot = target.snapshotValue(); const auto* root = snapshot.getIf(); REQUIRE(root); CHECK_EQ(*root->at("schemaVersion").getIf(), int64_t{8}); CHECK_EQ(*root->at("worldId").getIf(), "settlement"); const auto* instances = root->at("instances").getIf(); REQUIRE(instances); CHECK_EQ(instances->size(), size_t{2}); const auto* curveGroups = root->at("edgeCurveGroups").getIf(); REQUIRE(curveGroups); CHECK(curveGroups->empty()); CHECK_EQ(*instances->at(0).getIf()->at("instanceId").getIf(), int64_t{3}); CHECK_EQ(*instances->at(1).getIf()->at("instanceId").getIf(), int64_t{9}); } TEST_CASE("editor.building.corner_place_move_replace_and_undo_preserve_vertex_domain") { registerCorners(); eve::building::PlacementWorld world(8, 8, 10.f); BuildingPlacementTarget target("corner-world", &world); BuildingInstanceSnapshot post; post.instanceId = 81; post.buildingId = "editor-post"; post.placementKind = "corner"; post.cornerX = 2; post.cornerY = 3; post.cellX = 2; post.cellY = 3; post.worldX = 20.0; post.worldY = 30.0; post.channel = "posts"; post.tags = {"building", "post"}; auto place = target.makePlace(post); REQUIRE(place.ok()); REQUIRE(target.applyDomainOperation(place.value()).ok()); CHECK_EQ(world.getCornerOccupant("posts", 2, 3), 81); auto move = target.makeMove(81, 5, 6, 0.0); REQUIRE(move.ok()); REQUIRE(target.applyDomainOperation(move.value()).ok()); CHECK_EQ(world.getCornerOccupant("posts", 5, 6), 81); CHECK_EQ(world.getCornerOccupant("posts", 2, 3), 0); REQUIRE(target.applyDomainOperation(inverse(move.value())).ok()); CHECK_EQ(world.getCornerOccupant("posts", 2, 3), 81); auto replace = target.makeReplace(81, "editor-reinforced-post"); REQUIRE(replace.ok()); REQUIRE(target.applyDomainOperation(replace.value()).ok()); CHECK_EQ(world.getBuildingId(81), "editor-reinforced-post"); REQUIRE(target.applyDomainOperation(inverse(replace.value())).ok()); CHECK_EQ(world.getBuildingId(81), "editor-post"); const auto restored = target.instance(81); REQUIRE(restored.ok()); CHECK_EQ(restored.value().placementKind, "corner"); CHECK_EQ(restored.value().cornerX, 2); CHECK_EQ(restored.value().cornerY, 3); } TEST_CASE("editor.building.free_snapshot_move_and_undo_preserve_exact_anchor") { registerFreeProps(); eve::building::PlacementWorld world(8, 8, 10.f); BuildingPlacementTarget target("free-world", &world); BuildingInstanceSnapshot prop; prop.instanceId = 91; prop.buildingId = "editor-free-prop"; prop.placementKind = "free"; prop.worldX = 12.25; prop.worldY = 17.75; prop.elevation = 0.5; prop.rotationDegrees = 23.0; prop.channel = "decor"; prop.freeRadius = 2.0; prop.freeHalfWidth = 4.0; prop.freeHalfHeight = 2.0; prop.freeFootprintVertices = {-4.0, -2.0, 4.0, -2.0, 4.0, 2.0, -4.0, 2.0}; auto place = target.makePlace(prop); REQUIRE(place.ok()); REQUIRE(target.applyDomainOperation(place.value()).ok()); CHECK_EQ(world.getBuildingWorldX(91), 12.25f); auto move = target.makeMoveFree(91, 43.125, 51.875, 2.5, 71.0); REQUIRE(move.ok()); REQUIRE(target.applyDomainOperation(move.value()).ok()); CHECK_EQ(world.getBuildingWorldX(91), 43.125f); CHECK_EQ(world.getBuildingElevation(91), 2.5f); CHECK_EQ(world.getBuildingRotation(91), 71.f); REQUIRE(target.applyDomainOperation(inverse(move.value())).ok()); const auto restored = target.instance(91); REQUIRE(restored.ok()); CHECK_EQ(restored.value().placementKind, "free"); CHECK_EQ(restored.value().worldX, 12.25); CHECK_EQ(restored.value().worldY, 17.75); CHECK_EQ(restored.value().freeRadius, 2.0); CHECK_EQ(restored.value().freeHalfWidth, 4.0); CHECK_EQ(restored.value().freeHalfHeight, 2.0); CHECK_EQ(restored.value().freeFootprintVertices.size(), size_t{8}); CHECK_EQ(restored.value().freeFootprintVertices[0], -4.0); } TEST_CASE("editor.building.replace_commits_undoes_and_redoes_through_authority") { registerHouse(); eve::building::BuildingDefinition upgraded; upgraded.id = "upgraded-house"; upgraded.displayName = "Upgraded House"; upgraded.footprintW = 2; upgraded.footprintH = 2; upgraded.rotationMode = "cardinal"; upgraded.tags = {"building", "upgraded"}; eve::building::BuildingRegistry::registerBuilding(upgraded); eve::building::PlacementWorld world(10, 10, 10.f); BuildingPlacementTarget target("replace-world", &world); auto place = target.makePlace(house(42, 2, 2)); REQUIRE(place.ok()); REQUIRE(target.applyDomainOperation(place.value()).ok()); world.setBuildingProp(42, "owner", "player"); auto replacement = target.makeReplace(42, upgraded.id); REQUIRE(replacement.ok()); eve::editing::LocalWorldAuthority authority(&target); eve::editing::TransactionSpec transaction; transaction.id = eve::editing::TransactionId("building.replace.1"); transaction.label = "Replace building"; transaction.target = target.targetId(); transaction.baseRevision = target.revision(); const std::vector operations{replacement.value()}; auto plan = authority.preflight(transaction, operations); REQUIRE(plan.ok()); eve::building::PlacementSystem::clearEvents(); auto committed = authority.commit(plan.value()); REQUIRE(committed.ok()); CHECK_EQ(world.getBuildingId(42), upgraded.id); CHECK_EQ(world.getBuildingProp(42, "owner"), "player"); const size_t committedEventCount = eve::building::PlacementSystem::events().size(); auto undone = authority.compensate(committed.value()); REQUIRE(undone.ok()); CHECK_EQ(world.getBuildingId(42), "house"); CHECK_EQ(world.getBuildingProp(42, "owner"), "player"); CHECK_EQ(eve::building::PlacementSystem::events().size(), committedEventCount); transaction.id = eve::editing::TransactionId("building.replace.2"); transaction.baseRevision = target.revision(); auto redoPlan = authority.preflight(transaction, operations); REQUIRE(redoPlan.ok()); auto redone = authority.commit(redoPlan.value()); REQUIRE(redone.ok()); CHECK_EQ(world.getBuildingId(42), upgraded.id); } TEST_CASE("editor.building.edge_replace_and_undo_preserve_canonical_address") { registerEdges(); eve::building::PlacementWorld world(8, 8, 10.f); BuildingPlacementTarget target("edge-replace-world", &world); BuildingInstanceSnapshot edge; edge.instanceId = 51; edge.buildingId = "editor-fence"; edge.placementKind = "edge"; edge.edgeX = 3; edge.edgeY = 4; edge.edgeAxis = "vertical"; edge.cellX = 3; edge.cellY = 4; edge.worldX = 30.0; edge.worldY = 45.0; edge.rotationDegrees = 90.0; edge.channel = "boundary"; edge.properties["owner"] = "player"; auto place = target.makePlace(edge); REQUIRE(place.ok()); REQUIRE(target.applyDomainOperation(place.value()).ok()); auto replacement = target.makeReplace(51, "editor-gate"); REQUIRE(replacement.ok()); eve::editing::LocalWorldAuthority authority(&target); eve::editing::TransactionSpec transaction; transaction.id = eve::editing::TransactionId("building.edge.replace.1"); transaction.label = "Replace edge building"; transaction.target = target.targetId(); transaction.baseRevision = target.revision(); const std::vector operations{replacement.value()}; auto plan = authority.preflight(transaction, operations); REQUIRE(plan.ok()); auto committed = authority.commit(plan.value()); REQUIRE(committed.ok()); CHECK_EQ(world.getBuildingId(51), "editor-gate"); auto replaced = target.instance(51); REQUIRE(replaced.ok()); CHECK_EQ(replaced.value().edgeX, 3); CHECK_EQ(replaced.value().edgeY, 4); CHECK_EQ(replaced.value().edgeAxis, "vertical"); CHECK_EQ(replaced.value().properties.at("owner"), "player"); REQUIRE(authority.compensate(committed.value()).ok()); CHECK_EQ(world.getBuildingId(51), "editor-fence"); auto restored = target.instance(51); REQUIRE(restored.ok()); CHECK_EQ(restored.value().edgeAxis, "vertical"); CHECK_EQ(restored.value().properties.at("owner"), "player"); } TEST_CASE("editor.building.surface_attachment_survives_replace_and_undo") { registerHouse(); eve::building::BuildingDefinition upgraded = *eve::building::BuildingRegistry::find("house"); upgraded.id = "surface-upgraded-house"; eve::building::BuildingRegistry::registerBuilding(upgraded); eve::building::PlacementWorld world(8, 8, 10.f); BuildingPlacementTarget target("surface-replace-world", &world); BuildingInstanceSnapshot placed = house(61, 2, 2); placed.surfaceId = "terrain/main"; placed.surfaceRevision = 27; placed.surfaceNormalX = 0.25; placed.surfaceNormalY = 0.95; placed.surfaceNormalZ = 0.1; placed.surfaceTangentX = 0.9; placed.surfaceTangentY = -0.2; placed.surfaceTangentZ = 0.3; placed.surfaceSampleCount = 4; placed.surfaceMaxSlopeDegrees = 14.0; placed.surfaceHeightDelta = 0.6; auto place = target.makePlace(placed); REQUIRE(place.ok()); REQUIRE(target.applyDomainOperation(place.value()).ok()); auto replacement = target.makeReplace(61, upgraded.id); REQUIRE(replacement.ok()); REQUIRE(target.applyDomainOperation(replacement.value()).ok()); REQUIRE(target.applyDomainOperation(inverse(replacement.value())).ok()); const auto restored = target.instance(61); REQUIRE(restored.ok()); CHECK_EQ(restored.value().surfaceId, "terrain/main"); CHECK_EQ(restored.value().surfaceRevision, uint64_t{27}); CHECK_EQ(restored.value().surfaceSampleCount, 4); CHECK_EQ(restored.value().surfaceMaxSlopeDegrees, 14.0); CHECK_EQ(restored.value().surfaceHeightDelta, 0.6); } TEST_CASE("editor.building.set_preflights_final_definition_and_pose_atomically") { registerHouse(); eve::building::BuildingDefinition upgraded = *eve::building::BuildingRegistry::find("house"); upgraded.id = "atomic-upgraded-house"; eve::building::BuildingRegistry::registerBuilding(upgraded); eve::building::PlacementWorld world(8, 8, 10.f); BuildingPlacementTarget target("atomic-set-world", &world); auto source = target.makePlace(house(71, 0, 0)); auto blocker = target.makePlace(house(72, 4, 4)); REQUIRE(source.ok()); REQUIRE(blocker.ok()); REQUIRE(target.applyDomainOperation(source.value()).ok()); REQUIRE(target.applyDomainOperation(blocker.value()).ok()); auto replacement = target.makeReplace(71, upgraded.id); REQUIRE(replacement.ok()); DomainOperation conflicting = replacement.value(); auto* payload = conflicting.payload.getIf(); REQUIRE(payload); (*payload)["cellX"] = int64_t{4}; (*payload)["cellY"] = int64_t{4}; const auto rejected = target.applyDomainOperation(conflicting); CHECK_EQ(static_cast(rejected.code()), static_cast(EditorStatus::Rejected)); CHECK_EQ(world.getBuildingId(71), "house"); CHECK_EQ(world.getBuildingCellX(71), 0); CHECK_EQ(world.getBuildingCellY(71), 0); } TEST_CASE("editor.building.rectangle_is_one_atomic_undoable_gesture") { registerAreaTile(); eve::building::PlacementWorld world(8, 8, 10.f); BuildingPlacementTarget target("area-rectangle-world", &world); auto rectangle = target.makeRectangle("editor-area-tile", 1, 2, 3, 3, 0.0); REQUIRE(rectangle.ok()); CHECK_EQ(rectangle.value().affectedObjects.size(), size_t{6}); eve::editing::LocalWorldAuthority authority(&target); eve::editing::TransactionSpec transaction; transaction.id = eve::editing::TransactionId("building.area.rectangle.1"); transaction.label = "Paint rectangle"; transaction.target = target.targetId(); transaction.baseRevision = target.revision(); const std::vector operations{rectangle.value()}; auto plan = authority.preflight(transaction, operations); REQUIRE(plan.ok()); const auto revisionBefore = target.revision(); auto committed = authority.commit(plan.value()); REQUIRE(committed.ok()); CHECK_EQ(world.getBuildingCount(), 6); CHECK_EQ(target.revision(), revisionBefore + 1); REQUIRE(authority.compensate(committed.value()).ok()); CHECK_EQ(world.getBuildingCount(), 0); CHECK_EQ(target.revision(), revisionBefore + 2); } TEST_CASE("editor.building.edge_path_is_one_atomic_undoable_gesture") { registerEdges(); eve::building::PlacementWorld world(8, 8, 10.f); BuildingPlacementTarget target("edge-path-world", &world); auto path = target.makeEdgePath("editor-fence", {{1, 1}, {4, 1}, {4, 3}}); REQUIRE(path.ok()); CHECK_EQ(path.value().affectedObjects.size(), size_t{5}); eve::editing::LocalWorldAuthority authority(&target); eve::editing::TransactionSpec transaction; transaction.id = eve::editing::TransactionId("building.edge-path.1"); transaction.label = "Place edge path"; transaction.target = target.targetId(); transaction.baseRevision = target.revision(); const std::vector operations{path.value()}; auto plan = authority.preflight(transaction, operations); REQUIRE(plan.ok()); const auto revisionBefore = target.revision(); auto committed = authority.commit(plan.value()); REQUIRE(committed.ok()); CHECK_EQ(world.getBuildingCount(), 5); CHECK_EQ(target.revision(), revisionBefore + 1); CHECK(world.getEdgeOccupant("boundary", 3, 1, "north") > 0); CHECK(world.getEdgeOccupant("boundary", 4, 2, "west") > 0); REQUIRE(authority.compensate(committed.value()).ok()); CHECK_EQ(world.getBuildingCount(), 0); CHECK_EQ(target.revision(), revisionBefore + 2); } TEST_CASE("editor.building.edge_curve_is_one_atomic_undoable_gesture") { registerEdges(); eve::building::PlacementWorld world(10, 10, 10.f); BuildingPlacementTarget target("edge-curve-world", &world); auto curve = target.makeEdgeCubicBezier( "editor-fence", {{1.0, 1.0}, {1.0, 6.0}, {7.0, 6.0}, {7.0, 1.0}}, 24); REQUIRE(curve.ok()); CHECK(curve.value().affectedObjects.size() > size_t{7}); eve::editing::LocalWorldAuthority authority(&target); eve::editing::TransactionSpec transaction; transaction.id = eve::editing::TransactionId("building.edge-curve.1"); transaction.label = "Place cubic edge curve"; transaction.target = target.targetId(); transaction.baseRevision = target.revision(); const std::vector operations{curve.value()}; auto plan = authority.preflight(transaction, operations); REQUIRE(plan.ok()); const auto revisionBefore = target.revision(); auto committed = authority.commit(plan.value()); REQUIRE(committed.ok()); CHECK_EQ(world.getBuildingCount(), static_cast(curve.value().affectedObjects.size())); CHECK_EQ(world.getEdgeCurveGroupCount(), 1); const int firstMember = world.getBuildingInstanceAt(0); auto group = world.edgeCurveGroupForInstance(firstMember); REQUIRE(group.ok()); CHECK_EQ(group.value().subdivisions, 24); CHECK_EQ(group.value().controlPoints.size(), size_t{4}); const auto memberBeforeUndo = world.buildings().at(firstMember); CHECK_EQ(target.revision(), revisionBefore + 1); REQUIRE(authority.compensate(committed.value()).ok()); CHECK_EQ(world.getBuildingCount(), 0); CHECK_EQ(world.getEdgeCurveGroupCount(), 0); CHECK_EQ(target.revision(), revisionBefore + 2); transaction.baseRevision = target.revision(); const int blocker = world.placeEdge( "editor-fence", memberBeforeUndo.edge.x, memberBeforeUndo.edge.y, memberBeforeUndo.edge.axis == eve::building::EdgeAxis::Horizontal ? "north" : "west"); REQUIRE(blocker > 0); auto rejectedRedo = authority.preflight(transaction, operations); CHECK(!rejectedRedo.ok()); CHECK_EQ(world.getBuildingCount(), 1); CHECK_EQ(world.getEdgeCurveGroupCount(), 0); REQUIRE(world.removeBuilding(blocker)); auto redoPlan = authority.preflight(transaction, operations); REQUIRE(redoPlan.ok()); REQUIRE(authority.commit(redoPlan.value()).ok()); CHECK_EQ(world.getEdgeCurveGroupCount(), 1); auto restoredGroup = world.edgeCurveGroupForInstance(firstMember); REQUIRE(restoredGroup.ok()); CHECK_EQ(restoredGroup.value(), group.value()); const EditorValue snapshot = target.snapshotValue(); const auto* root = snapshot.getIf(); REQUIRE(root); const auto* curveGroups = root->at("edgeCurveGroups").getIf(); REQUIRE(curveGroups); CHECK_EQ(curveGroups->size(), size_t{1}); } TEST_CASE("editor.building.edge_curve_handles_preview_and_update_group_atomically") { registerEdges(); eve::building::PlacementWorld world(12, 12, 10.f); BuildingPlacementTarget target("edge-curve-handle-world", &world); const std::vector original{ {1.0, 1.0}, {1.0, 6.0}, {7.0, 6.0}, {7.0, 1.0}}; auto draft = target.previewEdgeCubicBezier("editor-fence", original, 24); CHECK_EQ(static_cast(draft.status), static_cast(EditorStatus::Applied)); CHECK_EQ(draft.controlPoints.size(), size_t{4}); CHECK(draft.edges.size() > size_t{7}); auto gizmo = target.edgeCubicBezierGizmo("editor-fence", original, 24); CHECK_EQ(static_cast(gizmo.status), static_cast(EditorStatus::Applied)); CHECK(gizmo.primitives.size() > draft.sampledVertices.size()); CHECK_EQ(gizmo.primitives.front().id, "curve.control.0"); CHECK_EQ(gizmo.primitives.front().kind, "sphere"); auto initial = target.makeEdgeCubicBezier("editor-fence", original, 24); REQUIRE(initial.ok()); REQUIRE(target.applyDomainOperation(initial.value()).ok()); const int member = world.getBuildingInstanceAt(0); auto before = world.edgeCurveGroupForInstance(member); REQUIRE(before.ok()); const std::vector dragged{ {1.0, 1.0}, {2.0, 7.0}, {8.0, 5.0}, {8.0, 1.0}}; auto liveDraft = target.previewEdgeCubicBezier("editor-fence", dragged, 32, member); CHECK_EQ(static_cast(liveDraft.status), static_cast(EditorStatus::Applied)); auto update = target.makeUpdateEdgeCubicBezier(member, dragged, 32); REQUIRE(update.ok()); eve::editing::LocalWorldAuthority authority(&target); eve::editing::TransactionSpec transaction; transaction.id = eve::editing::TransactionId("building.edge-curve.drag.1"); transaction.label = "Drag cubic edge curve handle"; transaction.target = target.targetId(); transaction.baseRevision = target.revision(); const std::vector operations{update.value()}; auto plan = authority.preflight(transaction, operations); REQUIRE(plan.ok()); auto committed = authority.commit(plan.value()); REQUIRE(committed.ok()); CHECK_EQ(world.getEdgeCurveGroupCount(), 1); auto after = world.edgeCurveGroup(before.value().id); REQUIRE(after.ok()); CHECK_EQ(after.value().subdivisions, 32); CHECK(after.value().controlPoints != before.value().controlPoints); REQUIRE(authority.compensate(committed.value()).ok()); auto undone = world.edgeCurveGroup(before.value().id); REQUIRE(undone.ok()); CHECK_EQ(undone.value(), before.value()); } TEST_CASE("editor.building.surface_curve_create_preview_and_gizmo_share_frames") { registerEdges(); eve::building::PlacementWorld world(12, 12, 10.f); world.setGridPlane("xz"); eve::building::PlacementSystem::registerSurfaceProvider( "editor.curve-terrain", [](const eve::building::PlacementWorld&, float x, float y) { eve::building::PlacementSystem::PlacementHit hit; hit.worldX = x; hit.worldY = x * 0.1f; hit.worldZ = y; hit.normalX = -0.1f; hit.normalY = 1.f; hit.surfaceId = "terrain.editor"; hit.surfaceRevision = 21; return eve::Result::success( std::move(hit)); }); BuildingPlacementTarget target("surface-curve-editor-world", &world); const std::vector controls{ {1.0, 1.0}, {1.0, 6.0}, {7.0, 6.0}, {7.0, 1.0}}; auto preview = target.previewEdgeCubicBezier( "editor-fence", controls, 16, 0, "editor.curve-terrain"); REQUIRE(static_cast(preview.status) == static_cast(EditorStatus::Applied)); CHECK_EQ(preview.surfaceProviderName, "editor.curve-terrain"); CHECK_EQ(preview.surfaceId, "terrain.editor"); CHECK_EQ(preview.surfaceRevision, uint64_t{21}); CHECK_EQ(preview.surfaceSamples.size(), size_t{17}); auto gizmo = target.edgeCubicBezierGizmo( "editor-fence", controls, 16, 0, 0, "editor.curve-terrain"); REQUIRE(static_cast(gizmo.status) == static_cast(EditorStatus::Applied)); bool foundElevatedPath = false; for (const auto& primitive : gizmo.primitives) if (primitive.id.rfind("curve.path.", 0) == 0 && primitive.position[1] > 1.0) foundElevatedPath = true; CHECK(foundElevatedPath); auto operation = target.makeEdgeCubicBezierOnSurface( "editor-fence", controls, 16, "editor.curve-terrain"); REQUIRE(operation.ok()); REQUIRE(target.applyDomainOperation(operation.value()).ok()); REQUIRE(world.getEdgeCurveGroupCount() == 1); auto committed = world.edgeCurveGroupForInstance(world.getBuildingInstanceAt(0)); REQUIRE(committed.ok()); CHECK_EQ(committed.value().surfaceProviderName, "editor.curve-terrain"); CHECK_EQ(committed.value().surfaceId, "terrain.editor"); CHECK_EQ(committed.value().surfaceRevision, uint64_t{21}); CHECK_EQ(committed.value().surfaceSamples.size(), size_t{17}); BuildingEdgeCurveDragSession drag(&target, "editor-fence", committed.value().instanceIds.front(), controls, 16, "editor.curve-terrain"); auto missed = drag.beginDrag(100.0, 100.0, 100.0, 0.0, 0.0, -1.0); CHECK(!missed.ok()); CHECK(!drag.isDragging()); auto begun = drag.beginDrag(10.0, 1.0, 30.0, 0.0, 0.0, -1.0); REQUIRE(begun.ok()); CHECK(drag.isDragging()); CHECK_EQ(drag.activeControlPointIndex(), 0); auto moved = drag.updateDrag(20.0, 2.0, 30.0, 0.0, 0.0, -1.0); REQUIRE(moved.ok()); CHECK_EQ(static_cast(moved.value().status), static_cast(EditorStatus::Applied)); CHECK(std::abs(moved.value().curve.controlPoints[0].x - 2.0) < 0.0001); auto draggedOperation = drag.finishDrag(); REQUIRE(draggedOperation.ok()); CHECK(!drag.isDragging()); REQUIRE(target.applyDomainOperation(draggedOperation.value()).ok()); auto afterDrag = world.edgeCurveGroup(committed.value().id); REQUIRE(afterDrag.ok()); CHECK(std::abs(afterDrag.value().controlPoints[0].x - 2.f) < 0.0001f); CHECK_EQ(afterDrag.value().surfaceId, "terrain.editor"); std::vector afterDragControls; for (const auto& point : afterDrag.value().controlPoints) afterDragControls.push_back({point.x, point.y}); BuildingEdgeCurveDragSession staleDrag( &target, "editor-fence", afterDrag.value().instanceIds.front(), afterDragControls, 16, "editor.curve-terrain"); REQUIRE(staleDrag.beginDrag(20.0, 2.0, 30.0, 0.0, 0.0, -1.0).ok()); REQUIRE(target.commitDomainState(target.cloneDomainState()).ok()); auto staleFinish = staleDrag.finishDrag(); CHECK(!staleFinish.ok()); CHECK_EQ(static_cast(staleFinish.code()), static_cast(EditorStatus::Conflict)); CHECK(!staleDrag.isDragging()); auto rejected = target.previewEdgeCubicBezier( "editor-fence", controls, 16, 0, "editor.missing-terrain"); CHECK_EQ(static_cast(rejected.status), static_cast(EditorStatus::Rejected)); CHECK(!rejected.diagnostics.empty()); auto rejectedGizmo = target.edgeCubicBezierGizmo( "editor-fence", controls, 16, 0, 0, "editor.missing-terrain"); CHECK_EQ(static_cast(rejectedGizmo.status), static_cast(EditorStatus::Rejected)); bool foundRejectedPath = false; for (const auto& primitive : rejectedGizmo.primitives) if (primitive.id.rfind("curve.path.", 0) == 0 && primitive.color[0] == 0.95) foundRejectedPath = true; CHECK(foundRejectedPath); eve::building::PlacementSystem::unregisterSurface("editor.curve-terrain"); eve::building::BuildingRegistry::clear(); } TEST_CASE("editor.building.brush_conflict_discards_complete_staging_candidate") { registerAreaTile(); eve::building::PlacementWorld world(9, 9, 10.f); BuildingPlacementTarget target("area-conflict-world", &world); auto brush = target.makeBrush("editor-area-tile", 4, 4, 1, 0.0); REQUIRE(brush.ok()); CHECK_EQ(brush.value().affectedObjects.size(), size_t{5}); BuildingInstanceSnapshot blockerSnapshot; blockerSnapshot.instanceId = 99; blockerSnapshot.buildingId = "editor-area-tile"; blockerSnapshot.cellX = 4; blockerSnapshot.cellY = 5; blockerSnapshot.worldX = 40.0; blockerSnapshot.worldY = 50.0; blockerSnapshot.channel = "floor"; auto blockerPlacement = target.makePlace(blockerSnapshot); REQUIRE(blockerPlacement.ok()); REQUIRE(target.applyDomainOperation(blockerPlacement.value()).ok()); const int countBefore = world.getBuildingCount(); const auto revisionBefore = target.revision(); eve::building::PlacementSystem::clearEvents(); const auto rejected = target.applyDomainOperation(brush.value()); CHECK_EQ(static_cast(rejected.code()), static_cast(EditorStatus::Rejected)); CHECK_EQ(world.getBuildingCount(), countBefore); CHECK(world.hasBuilding(99)); CHECK_EQ(target.revision(), revisionBefore); CHECK(eve::building::PlacementSystem::events().empty()); }