diff --git a/src/lib/config/BlueprintSerializer.cpp b/src/lib/config/BlueprintSerializer.cpp index 5d5f572..381ed8e 100644 --- a/src/lib/config/BlueprintSerializer.cpp +++ b/src/lib/config/BlueprintSerializer.cpp @@ -7,6 +7,7 @@ #include "BuildingType.h" #include "Rotation.h" +#include "ShipLayout.h" namespace { @@ -50,6 +51,20 @@ std::string serialize(const std::vector& blueprints) bldTbl.insert("offset_x", static_cast(b.offset.x())); bldTbl.insert("offset_y", static_cast(b.offset.y())); bldTbl.insert("recipe_id", b.recipeId); + if (b.shipLayout.has_value()) + { + toml::array modArr; + for (const PlacedModule& pm : b.shipLayout->placedModules) + { + toml::table modTbl; + modTbl.insert("type", pm.moduleId); + modTbl.insert("x", static_cast(pm.position.x())); + modTbl.insert("y", static_cast(pm.position.y())); + modTbl.insert("rotation", rotationToString(pm.rotation)); + modArr.push_back(std::move(modTbl)); + } + bldTbl.insert("modules", std::move(modArr)); + } bldArr.push_back(std::move(bldTbl)); } @@ -123,6 +138,27 @@ std::vector deserialize(const std::string& tomlContent) bb.offset.setX(static_cast((*bldTbl)["offset_x"].value_or(int64_t{0}))); bb.offset.setY(static_cast((*bldTbl)["offset_y"].value_or(int64_t{0}))); bb.recipeId = (*bldTbl)["recipe_id"].value_or(std::string{}); + const toml::array* modArr = (*bldTbl)["modules"].as_array(); + if (modArr) + { + ShipLayoutConfig layout; + for (std::size_t k = 0; k < modArr->size(); ++k) + { + const toml::table* modTbl = (*modArr)[k].as_table(); + if (!modTbl) { continue; } + const std::optional modType = (*modTbl)["type"].value(); + const std::optional x = (*modTbl)["x"].value(); + const std::optional y = (*modTbl)["y"].value(); + const std::optional rotStr = (*modTbl)["rotation"].value(); + if (!modType || !x || !y || !rotStr) { continue; } + PlacedModule pm; + pm.moduleId = *modType; + pm.position = QPoint(static_cast(*x), static_cast(*y)); + pm.rotation = parseRotation(*rotStr); + layout.placedModules.push_back(std::move(pm)); + } + bb.shipLayout = std::move(layout); + } bp.buildings.push_back(std::move(bb)); } } diff --git a/src/lib/core/Blueprint.h b/src/lib/core/Blueprint.h index c2b679c..c1a7642 100644 --- a/src/lib/core/Blueprint.h +++ b/src/lib/core/Blueprint.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -8,6 +9,7 @@ #include "BuildingType.h" #include "Rotation.h" +#include "ShipLayout.h" struct BlueprintBuilding { @@ -15,6 +17,7 @@ struct BlueprintBuilding Rotation rotation; QPoint offset; // tile offset from bounding-box center (floor for even sizes) std::string recipeId; // empty = none selected + std::optional shipLayout; }; struct Blueprint diff --git a/src/test/BlueprintSerializerTest.cpp b/src/test/BlueprintSerializerTest.cpp index 7edc694..145ede3 100644 --- a/src/test/BlueprintSerializerTest.cpp +++ b/src/test/BlueprintSerializerTest.cpp @@ -1,5 +1,6 @@ #include "catch.hpp" +#include #include #include @@ -10,6 +11,7 @@ #include "BlueprintSerializer.h" #include "BuildingType.h" #include "Rotation.h" +#include "ShipLayout.h" namespace { @@ -26,13 +28,15 @@ Blueprint makeBlueprintWith(const std::vector& buildings, BlueprintBuilding makeBuilding(BuildingType type, Rotation rotation, QPoint offset, - std::string recipeId = "") + std::string recipeId = "", + std::optional shipLayout = std::nullopt) { BlueprintBuilding b; - b.type = type; - b.rotation = rotation; - b.offset = offset; - b.recipeId = std::move(recipeId); + b.type = type; + b.rotation = rotation; + b.offset = offset; + b.recipeId = std::move(recipeId); + b.shipLayout = std::move(shipLayout); return b; } @@ -185,3 +189,77 @@ TEST_CASE("BlueprintSerializer: unknown building type throws", "[serializer]") BlueprintSerializer::deserialize(badToml), std::runtime_error); } + +// --------------------------------------------------------------------------- +// Ship layout round-trip through TOML serialization +// --------------------------------------------------------------------------- + +TEST_CASE("BlueprintSerializer: shipyard with modules round-trips ship layout", "[serializer]") +{ + ShipLayoutConfig layout; + PlacedModule pmA; + pmA.moduleId = "laser_cannon"; + pmA.position = QPoint(1, 1); + pmA.rotation = Rotation::East; + PlacedModule pmB; + pmB.moduleId = "sensor_booster"; + pmB.position = QPoint(0, 1); + pmB.rotation = Rotation::South; + layout.placedModules = { pmA, pmB }; + + const Blueprint original = makeBlueprintWith( + { makeBuilding(BuildingType::Shipyard, Rotation::East, QPoint(0, 0), + "interceptor", layout) }, + "Gunship"); + + const std::string toml = BlueprintSerializer::serialize({ original }); + const std::vector loaded = BlueprintSerializer::deserialize(toml); + + REQUIRE(loaded.size() == 1); + REQUIRE(loaded[0].buildings.size() == 1); + + const BlueprintBuilding& b = loaded[0].buildings[0]; + REQUIRE(b.shipLayout.has_value()); + REQUIRE(b.shipLayout->placedModules.size() == 2); + REQUIRE(b.shipLayout->placedModules[0].moduleId == "laser_cannon"); + REQUIRE(b.shipLayout->placedModules[0].position == QPoint(1, 1)); + REQUIRE(b.shipLayout->placedModules[0].rotation == Rotation::East); + REQUIRE(b.shipLayout->placedModules[1].moduleId == "sensor_booster"); + REQUIRE(b.shipLayout->placedModules[1].position == QPoint(0, 1)); + REQUIRE(b.shipLayout->placedModules[1].rotation == Rotation::South); +} + +TEST_CASE("BlueprintSerializer: building without ship layout round-trips with nullopt", "[serializer]") +{ + // A shipyard with no shipLayout set — no modules key should be emitted, + // and deserialization must leave shipLayout as nullopt. + const Blueprint original = makeBlueprintWith( + { makeBuilding(BuildingType::Shipyard, Rotation::East, QPoint(0, 0), "interceptor") }, + "EmptyYard"); + + const std::string toml = BlueprintSerializer::serialize({ original }); + const std::vector loaded = BlueprintSerializer::deserialize(toml); + + REQUIRE(loaded.size() == 1); + REQUIRE(loaded[0].buildings.size() == 1); + REQUIRE_FALSE(loaded[0].buildings[0].shipLayout.has_value()); +} + +TEST_CASE("BlueprintSerializer: shipyard with empty modules list round-trips", "[serializer]") +{ + // An explicitly empty layout (player cleared all modules) should round-trip + // as a present but empty ShipLayoutConfig, distinct from nullopt. + ShipLayoutConfig emptyLayout; + const Blueprint original = makeBlueprintWith( + { makeBuilding(BuildingType::Shipyard, Rotation::East, QPoint(0, 0), + "interceptor", emptyLayout) }, + "EmptyLayout"); + + const std::string toml = BlueprintSerializer::serialize({ original }); + const std::vector loaded = BlueprintSerializer::deserialize(toml); + + REQUIRE(loaded.size() == 1); + REQUIRE(loaded[0].buildings.size() == 1); + REQUIRE(loaded[0].buildings[0].shipLayout.has_value()); + REQUIRE(loaded[0].buildings[0].shipLayout->placedModules.empty()); +} diff --git a/src/test/BlueprintTest.cpp b/src/test/BlueprintTest.cpp index 432abe8..3bd1bef 100644 --- a/src/test/BlueprintTest.cpp +++ b/src/test/BlueprintTest.cpp @@ -14,6 +14,7 @@ #include "ConfigLoader.h" #include "BuildingId.h" #include "Rotation.h" +#include "ShipLayout.h" #include "Simulation.h" #include "SurfaceMask.h" #include "Tick.h" @@ -61,6 +62,7 @@ struct BuildingSpec BuildingType type; Rotation rotation; std::string recipeId; // empty = none + std::optional shipLayout; }; static Blueprint buildBlueprint(const std::vector& specs) @@ -83,10 +85,11 @@ static Blueprint buildBlueprint(const std::vector& specs) for (const BuildingSpec& s : specs) { BlueprintBuilding bb; - bb.type = s.type; - bb.rotation = s.rotation; - bb.offset = s.anchor - center; - bb.recipeId = s.recipeId; + bb.type = s.type; + bb.rotation = s.rotation; + bb.offset = s.anchor - center; + bb.recipeId = s.recipeId; + bb.shipLayout = s.shipLayout; bp.buildings.push_back(bb); } return bp; @@ -665,3 +668,108 @@ TEST_CASE("Blueprint placement: repair_ship schematic is locked at game start", Simulation sim(loadConfig()); REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship")); } + +// --------------------------------------------------------------------------- +// Ship layout capture and re-application via blueprints +// --------------------------------------------------------------------------- + +TEST_CASE("Blueprint: shipLayout is captured in BlueprintBuilding", "[blueprint]") +{ + // sensor_booster has surface_mask ["O"] — fits a single buildable cell. + // The interceptor layout ["XOX","OOO","XOX"] has a buildable cell at (1, 0). + ShipLayoutConfig layout; + PlacedModule pm; + pm.moduleId = "sensor_booster"; + pm.position = QPoint(1, 0); + pm.rotation = Rotation::North; + layout.placedModules.push_back(pm); + + const BuildingSpec spec{ + QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Shipyard, Rotation::East, + "interceptor", layout + }; + const Blueprint bp = buildBlueprint({ spec }); + + REQUIRE(bp.buildings.size() == 1); + REQUIRE(bp.buildings[0].shipLayout.has_value()); + REQUIRE(bp.buildings[0].shipLayout->placedModules.size() == 1); + REQUIRE(bp.buildings[0].shipLayout->placedModules[0].moduleId == "sensor_booster"); + REQUIRE(bp.buildings[0].shipLayout->placedModules[0].position == QPoint(1, 0)); + REQUIRE(bp.buildings[0].shipLayout->placedModules[0].rotation == Rotation::North); +} + +TEST_CASE("Blueprint: building without layout has nullopt shipLayout", "[blueprint]") +{ + const BuildingSpec spec{ + QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Shipyard, Rotation::East, "interceptor" + }; + const Blueprint bp = buildBlueprint({ spec }); + + REQUIRE(bp.buildings.size() == 1); + REQUIRE_FALSE(bp.buildings[0].shipLayout.has_value()); +} + +TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout", "[blueprint]") +{ + Simulation sim(loadConfig()); + + // Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East: + // A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles. + // S-tile at (0,0) and (0,1) — x >= 0, valid space tiles. + const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East); + REQUIRE(id != kInvalidBuildingId); + + ShipLayoutConfig layout; + PlacedModule pm; + pm.moduleId = "laser_cannon"; + pm.position = QPoint(1, 1); + pm.rotation = Rotation::East; + layout.placedModules.push_back(pm); + + sim.buildings().setShipLayout(id, layout); + + const ConstructionSite* site = sim.buildings().findSite(id); + REQUIRE(site != nullptr); + REQUIRE(site->shipLayout.has_value()); + REQUIRE(site->shipLayout->placedModules.size() == 1); + REQUIRE(site->shipLayout->placedModules[0].moduleId == "laser_cannon"); +} + +TEST_CASE("Blueprint placement: ship layout transfers to building after construction completes", + "[blueprint]") +{ + Simulation sim(loadConfig()); + + const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East); + REQUIRE(id != kInvalidBuildingId); + + ShipLayoutConfig layout; + PlacedModule pm; + pm.moduleId = "sensor_booster"; + pm.position = QPoint(1, 0); + pm.rotation = Rotation::North; + layout.placedModules.push_back(pm); + + sim.buildings().setShipLayout(id, layout); + + // Shipyard construction_time_seconds = 30 in the test config. + double constructionTime = 0.0; + for (const BuildingDef& def : sim.config().buildings.buildings) + { + if (def.type == BuildingType::Shipyard) { constructionTime = def.constructionTimeSeconds; break; } + } + REQUIRE(constructionTime > 0.0); + + for (int i = 0; i <= static_cast(secondsToTicks(constructionTime)); ++i) + { + sim.tick(); + } + + const Building* b = sim.buildings().findBuilding(id); + REQUIRE(b != nullptr); + REQUIRE(b->shipLayout.has_value()); + REQUIRE(b->shipLayout->placedModules.size() == 1); + REQUIRE(b->shipLayout->placedModules[0].moduleId == "sensor_booster"); + REQUIRE(b->shipLayout->placedModules[0].position == QPoint(1, 0)); + REQUIRE(b->shipLayout->placedModules[0].rotation == Rotation::North); +} diff --git a/src/ui/BlueprintPanel.cpp b/src/ui/BlueprintPanel.cpp index 8f80ad1..150aeca 100644 --- a/src/ui/BlueprintPanel.cpp +++ b/src/ui/BlueprintPanel.cpp @@ -182,10 +182,11 @@ Blueprint BlueprintPanel::createBlueprintFromSelection() const for (const Entry& e : entries) { BlueprintBuilding bb; - bb.type = e.building->type; - bb.rotation = e.building->rotation; - bb.offset = e.building->anchor - center; - bb.recipeId = e.building->recipeId; + bb.type = e.building->type; + bb.rotation = e.building->rotation; + bb.offset = e.building->anchor - center; + bb.recipeId = e.building->recipeId; + bb.shipLayout = e.building->shipLayout; bp.buildings.push_back(bb); } return bp; diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index b1bf47d..32e6b53 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -504,18 +504,26 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) } const BuildingId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation); - if (id == kInvalidBuildingId || bb.recipeId.empty()) { continue; } + if (id == kInvalidBuildingId) { continue; } - if (bb.type == BuildingType::Shipyard) + if (!bb.recipeId.empty()) { - if (m_sim->isSchematicUnlocked(bb.recipeId)) + if (bb.type == BuildingType::Shipyard) + { + if (m_sim->isSchematicUnlocked(bb.recipeId)) + { + m_sim->buildings().setRecipe(id, bb.recipeId); + } + } + else { m_sim->buildings().setRecipe(id, bb.recipeId); } } - else + + if (bb.shipLayout.has_value()) { - m_sim->buildings().setRecipe(id, bb.recipeId); + m_sim->buildings().setShipLayout(id, *bb.shipLayout); } } }