From bf29cc40e3fa86a4434bc88d83f1c38bf00339b5 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Sun, 19 Apr 2026 20:50:42 +0200 Subject: [PATCH] implement building system --- bin/config/world.toml | 1 + docs/plan.md | 4 +- src/lib/config/CMakeLists.txt | 2 + src/lib/config/ConfigLoader.cpp | 1 + src/lib/config/SurfaceMask.cpp | 172 +++++++++ src/lib/config/SurfaceMask.h | 28 ++ src/lib/config/WorldConfig.h | 1 + src/lib/sim/BeltSystem.cpp | 25 ++ src/lib/sim/BeltSystem.h | 4 + src/lib/sim/Building.h | 76 ++++ src/lib/sim/BuildingSystem.cpp | 661 ++++++++++++++++++++++++++++++++ src/lib/sim/BuildingSystem.h | 88 +++++ src/lib/sim/CMakeLists.txt | 3 + src/lib/sim/Simulation.cpp | 43 +++ src/lib/sim/Simulation.h | 24 +- src/test/BuildingTest.cpp | 504 ++++++++++++++++++++++++ src/test/CMakeLists.txt | 2 + src/test/ConfigLoaderTest.cpp | 2 + src/test/SurfaceMaskTest.cpp | 184 +++++++++ 19 files changed, 1818 insertions(+), 7 deletions(-) create mode 100644 src/lib/config/SurfaceMask.cpp create mode 100644 src/lib/config/SurfaceMask.h create mode 100644 src/lib/sim/Building.h create mode 100644 src/lib/sim/BuildingSystem.cpp create mode 100644 src/lib/sim/BuildingSystem.h create mode 100644 src/test/BuildingTest.cpp create mode 100644 src/test/SurfaceMaskTest.cpp diff --git a/bin/config/world.toml b/bin/config/world.toml index fdf6572..cca5c77 100644 --- a/bin/config/world.toml +++ b/bin/config/world.toml @@ -1,6 +1,7 @@ [world] height_tiles = 60 refund_percentage = 75 +starting_building_blocks = 100 scrap_despawn_seconds = 30 belt_speed_tiles_per_second = 2 diff --git a/docs/plan.md b/docs/plan.md index afe2dca..8a1180c 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -9,8 +9,8 @@ Cross-references: `architecture.md` (design), `requirements.md` (REQ-* citations | 1 | Config loading (Formula, ConfigLoader, all config structs) | ✅ done | | 2 | Simulation shell + TickDriver + entity id allocator + event queues | ✅ done | | 3 | Belt subsystem (placement, port interface, per-tile v1, splitter routing, clearTiles, visual iteration) | ✅ done | -| 4 | Buildings + placement + belt↔building transport | ⬜ next | -| 5 | Scrap + ships skeleton (data + spawning, no AI) | ⬜ | +| 4 | Buildings + placement + belt↔building transport | ✅ done | +| 5 | Scrap + ships skeleton (data + spawning, no AI) | ⬜ next | | 6 | Ship behavior systems + movement arbitration | ⬜ | | 7 | Waves, threat accumulation, combat resolution, deaths & loot | ⬜ | | 8 | UI layer (GameWorldView, visuals.toml, panels, build/demolish, speed controls) | ⬜ | diff --git a/src/lib/config/CMakeLists.txt b/src/lib/config/CMakeLists.txt index 3fc76d2..5de8842 100644 --- a/src/lib/config/CMakeLists.txt +++ b/src/lib/config/CMakeLists.txt @@ -8,6 +8,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/StationsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/GameConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.h + ${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.h PARENT_SCOPE ) @@ -15,6 +16,7 @@ SET(SRCS ${SRCS} ${CMAKE_CURRENT_SOURCE_DIR}/Formula.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.cpp PARENT_SCOPE ) diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index 39ddb03..13db63a 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -221,6 +221,7 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path) cfg.heightTiles = static_cast(requireInt(tbl["world"]["height_tiles"], file, "world.height_tiles")); cfg.refundPercentage = static_cast(requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage")); + cfg.startingBuildingBlocks = static_cast(requireInt(tbl["world"]["starting_building_blocks"], file, "world.starting_building_blocks")); cfg.scrapDespawnSeconds = requireDouble(tbl["world"]["scrap_despawn_seconds"], file, "world.scrap_despawn_seconds"); cfg.beltSpeedTilesPerSecond = requireDouble(tbl["world"]["belt_speed_tiles_per_second"], file, "world.belt_speed_tiles_per_second"); diff --git a/src/lib/config/SurfaceMask.cpp b/src/lib/config/SurfaceMask.cpp new file mode 100644 index 0000000..0589343 --- /dev/null +++ b/src/lib/config/SurfaceMask.cpp @@ -0,0 +1,172 @@ +#include "SurfaceMask.h" + +#include +#include + +namespace +{ + +// Rotate direction character 90° clockwise. +char rotateDirCharCW(char c) +{ + switch (c) + { + case '>': return 'v'; + case 'v': return '<'; + case '<': return '^'; + case '^': return '>'; + default: return c; + } +} + +// Rotate the character grid 90° clockwise. +// Input grid[row][col]. Returns a new grid with swapped dimensions. +std::vector rotateCW(const std::vector& grid) +{ + if (grid.empty()) + { + return {}; + } + + const int srcH = static_cast(grid.size()); + // Pad all rows to the same width. + int srcW = 0; + for (const std::string& row : grid) + { + const int w = static_cast(row.size()); + if (w > srcW) + { + srcW = w; + } + } + + // After 90° CW: new width = srcH, new height = srcW. + const int dstW = srcH; + const int dstH = srcW; + std::vector dst(dstH, std::string(dstW, ' ')); + + for (int row = 0; row < srcH; ++row) + { + for (int col = 0; col < srcW; ++col) + { + const char ch = (col < static_cast(grid[row].size())) + ? grid[row][col] + : ' '; + // 90° CW mapping: (col, row) -> (dstCol = srcH-1-row, dstRow = col) + const int dstCol = srcH - 1 - row; + const int dstRow = col; + dst[dstRow][dstCol] = rotateDirCharCW(ch); + } + } + + return dst; +} + +} // namespace + +ParsedSurfaceMask parseSurfaceMask(const std::vector& rows, + Rotation rotation) +{ + // Number of 90° CW steps to apply. + int cwSteps = 0; + switch (rotation) + { + case Rotation::East: cwSteps = 0; break; + case Rotation::South: cwSteps = 1; break; + case Rotation::West: cwSteps = 2; break; + case Rotation::North: cwSteps = 3; break; + } + + // Apply rotations. + std::vector grid = rows; + for (int i = 0; i < cwSteps; ++i) + { + grid = rotateCW(grid); + } + + // Scan grid: collect body cells, ship dock cells, and output port indicators. + std::vector rawBodyCells; + std::vector rawShipDockCells; + struct RawPort + { + QPoint tile; + Rotation direction; + }; + std::vector rawPorts; + + for (int row = 0; row < static_cast(grid.size()); ++row) + { + for (int col = 0; col < static_cast(grid[row].size()); ++col) + { + const char ch = grid[row][col]; + if (ch == 'A') + { + rawBodyCells.push_back(QPoint(col, row)); + } + else if (ch == 'S') + { + rawBodyCells.push_back(QPoint(col, row)); + rawShipDockCells.push_back(QPoint(col, row)); + } + else if (ch == '>') + { + rawPorts.push_back({QPoint(col, row), Rotation::East}); + } + else if (ch == '<') + { + rawPorts.push_back({QPoint(col, row), Rotation::West}); + } + else if (ch == '^') + { + rawPorts.push_back({QPoint(col, row), Rotation::North}); + } + else if (ch == 'v') + { + rawPorts.push_back({QPoint(col, row), Rotation::South}); + } + } + } + + // Compute bounding box of body cells and normalize to (0,0). + int minCol = INT_MAX; + int minRow = INT_MAX; + int maxCol = INT_MIN; + int maxRow = INT_MIN; + + for (const QPoint& pt : rawBodyCells) + { + if (pt.x() < minCol) { minCol = pt.x(); } + if (pt.x() > maxCol) { maxCol = pt.x(); } + if (pt.y() < minRow) { minRow = pt.y(); } + if (pt.y() > maxRow) { maxRow = pt.y(); } + } + + // If there are no body cells, return an empty mask. + if (rawBodyCells.empty()) + { + return ParsedSurfaceMask{}; + } + + const QPoint offset(-minCol, -minRow); + + ParsedSurfaceMask result; + result.footprint = QSize(maxCol - minCol + 1, maxRow - minRow + 1); + + for (const QPoint& pt : rawBodyCells) + { + result.bodyCells.push_back(pt + offset); + } + for (const QPoint& pt : rawShipDockCells) + { + result.shipDockCells.push_back(pt + offset); + } + for (const RawPort& rp : rawPorts) + { + Port port; + port.tile = rp.tile + offset; + port.direction = rp.direction; + result.outputPorts.push_back(port); + } + + return result; +} diff --git a/src/lib/config/SurfaceMask.h b/src/lib/config/SurfaceMask.h new file mode 100644 index 0000000..361fffb --- /dev/null +++ b/src/lib/config/SurfaceMask.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +#include +#include + +#include "Port.h" +#include "Rotation.h" + +// Parsed representation of a building's surface_mask after applying rotation. +// All coordinates are relative to the building's anchor tile (the point passed +// to BuildingSystem::place), which corresponds to (0,0) in this system. +struct ParsedSurfaceMask +{ + QSize footprint; // bounding box of body cells (A + S tiles) + std::vector bodyCells; // relative positions of A and S tiles + std::vector outputPorts; // port.tile = cell adjacent to body, outside footprint; + // port.direction = flow direction away from building + std::vector shipDockCells; // relative positions of S tiles (subset of bodyCells) +}; + +// Parse a surface_mask definition (as loaded from TOML) and apply the given +// rotation. The canonical mask orientation corresponds to Rotation::East. +// Rotations are applied clockwise (East=0, South=90°, West=180°, North=270°). +ParsedSurfaceMask parseSurfaceMask(const std::vector& rows, + Rotation rotation); diff --git a/src/lib/config/WorldConfig.h b/src/lib/config/WorldConfig.h index 49c75f1..9280858 100644 --- a/src/lib/config/WorldConfig.h +++ b/src/lib/config/WorldConfig.h @@ -39,6 +39,7 @@ struct WorldConfig { int heightTiles; // REQ-GW-HEIGHT int refundPercentage; // REQ-BLD-DEMOLISH + int startingBuildingBlocks; // REQ-HQ-STARTING-BLOCKS double scrapDespawnSeconds; // REQ-RES-SCRAP-DROP double beltSpeedTilesPerSecond; // REQ-GW-BELT-SPEED diff --git a/src/lib/sim/BeltSystem.cpp b/src/lib/sim/BeltSystem.cpp index c069c36..8167574 100644 --- a/src/lib/sim/BeltSystem.cpp +++ b/src/lib/sim/BeltSystem.cpp @@ -135,6 +135,31 @@ std::optional BeltSystem::tryTakeItem(Port port) return std::nullopt; } +std::optional BeltSystem::peekItem(Port port) const +{ + const std::map, BeltTile>::const_iterator it = + m_belts.find(key(port.tile)); + if (it == m_belts.end()) + { + return std::nullopt; + } + if (it->second.direction != port.direction) + { + return std::nullopt; + } + + const BeltTile& bt = it->second; + if (bt.front) + { + return bt.front->item.type; + } + if (bt.back) + { + return bt.back->item.type; + } + return std::nullopt; +} + // --------------------------------------------------------------------------- // Maintenance // --------------------------------------------------------------------------- diff --git a/src/lib/sim/BeltSystem.h b/src/lib/sim/BeltSystem.h index 6dbbbb4..05d35c5 100644 --- a/src/lib/sim/BeltSystem.h +++ b/src/lib/sim/BeltSystem.h @@ -64,6 +64,10 @@ public: // Returns nullopt if tile is not a belt, direction mismatches, or tile empty. std::optional tryTakeItem(Port port); + // peekItem: return the type of the leading item without removing it. + // Returns nullopt if tile is not a belt, direction mismatches, or tile empty. + std::optional peekItem(Port port) const; + // -- Maintenance --------------------------------------------------------- void clearTiles(const std::vector& tiles); // REQ-UI-BELT-CLEAR void tick(); diff --git a/src/lib/sim/Building.h b/src/lib/sim/Building.h new file mode 100644 index 0000000..84af0bb --- /dev/null +++ b/src/lib/sim/Building.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include "BuildingType.h" +#include "EntityId.h" +#include "Item.h" +#include "ItemType.h" +#include "Port.h" +#include "Rotation.h" +#include "Tick.h" + +// Per-material input buffer for a production building. +struct InputBuffer +{ + std::map counts; // current item counts per material + std::map caps; // max items per material (2× per-cycle requirement) +}; + +// Output buffer shared by all output materials for a production building. +struct OutputBuffer +{ + std::vector items; + int capacity = 0; // 2× per-cycle output; 1× for ReprocessingPlant +}; + +// Active production cycle for a building. +struct Production +{ + std::string recipeId; + Tick completesAt = 0; + std::vector chosenOutputs; // resolved at cycle start (reprocessing rolls here) +}; + +// A building placed on the map that is still under construction. +// Occupies tiles but does not produce. +struct ConstructionSite +{ + EntityId id = kInvalidEntityId; + QPoint anchor; // top-left of body bounding box + QSize footprint; + std::vector bodyCells; // absolute world tile coordinates + Rotation rotation = Rotation::East; + BuildingType type = BuildingType::Miner; + std::string recipeId; // may be configured before completion + Tick completesAt = 0; // 0 = queued but not yet started +}; + +// A fully constructed, operational building. +struct Building +{ + EntityId id = kInvalidEntityId; + QPoint anchor; // top-left of body bounding box + QSize footprint; + Rotation rotation = Rotation::East; + BuildingType type = BuildingType::Miner; + float hp = 0.0f; + float maxHp = 0.0f; + std::string recipeId; // empty = none selected + + InputBuffer inputBuffer; + OutputBuffer outputBuffer; + std::optional production; + + // Pre-computed from surface mask at placement; in absolute world coordinates. + std::vector bodyCells; + std::vector outputPorts; + std::vector inputPorts; // perimeter tiles (minus output-port tiles), + // direction pointing INTO building +}; diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp new file mode 100644 index 0000000..afd595c --- /dev/null +++ b/src/lib/sim/BuildingSystem.cpp @@ -0,0 +1,661 @@ +#include "BuildingSystem.h" + +#include +#include +#include + +#include "SurfaceMask.h" + +BuildingSystem::BuildingSystem(const GameConfig& config, + BeltSystem& belts, + std::function allocateId, + std::function addBuildingBlocks, + std::mt19937& rng) + : m_config(config) + , m_belts(belts) + , m_allocateId(std::move(allocateId)) + , m_addBuildingBlocks(std::move(addBuildingBlocks)) + , m_rng(rng) +{ +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +const BuildingDef* BuildingSystem::findBuildingDef(BuildingType type) const +{ + for (const BuildingDef& def : m_config.buildings.buildings) + { + if (def.type == type) + { + return &def; + } + } + return nullptr; +} + +const RecipeDef* BuildingSystem::findRecipe(const std::string& id, + BuildingType type) const +{ + for (const RecipeDef& recipe : m_config.recipes.recipes) + { + if (recipe.id == id && recipe.building == type) + { + return &recipe; + } + } + return nullptr; +} + +void BuildingSystem::initBuffers(Building& b, const RecipeDef& recipe) const +{ + b.inputBuffer.counts.clear(); + b.inputBuffer.caps.clear(); + for (const RecipeIngredient& ing : recipe.inputs) + { + const ItemType type{ing.item}; + b.inputBuffer.counts[type] = 0; + b.inputBuffer.caps[type] = 2 * ing.amount; + } + + b.outputBuffer.items.clear(); + if (b.type == BuildingType::ReprocessingPlant) + { + // 1× max-per-roll (REQ-MAT-OUTPUT-BUFFER-REPROCESSING). + int maxAmount = 0; + for (const RecipeOutput& out : recipe.outputs) + { + if (out.amount > maxAmount) + { + maxAmount = out.amount; + } + } + b.outputBuffer.capacity = maxAmount; + } + else + { + // 2× per-cycle output. + int totalAmount = 0; + for (const RecipeOutput& out : recipe.outputs) + { + totalAmount += out.amount; + } + b.outputBuffer.capacity = 2 * totalAmount; + } +} + +std::vector BuildingSystem::computeInputPorts(const Building& b) const +{ + // Build lookup sets for quick membership checks. + std::set> bodySet; + for (const QPoint& cell : b.bodyCells) + { + bodySet.insert({cell.x(), cell.y()}); + } + + std::set> outputPortTiles; + for (const Port& port : b.outputPorts) + { + outputPortTiles.insert({port.tile.x(), port.tile.y()}); + } + + // Neighbour deltas and the corresponding "inward" belt direction. + const int dx[4] = {-1, 1, 0, 0}; + const int dy[4] = { 0, 0, -1, 1}; + const Rotation inward[4] = { + Rotation::East, // neighbour is to the West; belt flows East toward building + Rotation::West, // neighbour is to the East; belt flows West toward building + Rotation::South, // neighbour is above (row-1); belt flows South toward building + Rotation::North // neighbour is below (row+1); belt flows North toward building + }; + + std::set> seen; + std::vector inputPorts; + + for (const QPoint& cell : b.bodyCells) + { + for (int i = 0; i < 4; ++i) + { + const int nx = cell.x() + dx[i]; + const int ny = cell.y() + dy[i]; + const std::pair neighbor = {nx, ny}; + + if (bodySet.count(neighbor)) { continue; } + if (outputPortTiles.count(neighbor)){ continue; } + if (seen.count(neighbor)) { continue; } + + seen.insert(neighbor); + Port port; + port.tile = QPoint(nx, ny); + port.direction = inward[i]; + inputPorts.push_back(port); + } + } + + return inputPorts; +} + +std::vector BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe) +{ + std::vector weights; + weights.reserve(recipe.outputs.size()); + for (const RecipeOutput& out : recipe.outputs) + { + weights.push_back(out.probability.value_or(1.0)); + } + + std::discrete_distribution dist(weights.begin(), weights.end()); + const int idx = dist(m_rng); + + const RecipeOutput& chosen = recipe.outputs[static_cast(idx)]; + std::vector result; + Item item; + item.type.id = chosen.item; + for (int i = 0; i < chosen.amount; ++i) + { + result.push_back(item); + } + return result; +} + +// --------------------------------------------------------------------------- +// Placement +// --------------------------------------------------------------------------- + +EntityId BuildingSystem::place(BuildingType type, QPoint anchor, + Rotation rotation, Tick currentTick) +{ + const EntityId id = m_allocateId(); + + if (type == BuildingType::Belt) + { + m_belts.placeBelt(anchor, rotation); + m_tileOccupancy[{anchor.x(), anchor.y()}] = id; + m_beltEntities[id] = BeltEntry{anchor, BuildingType::Belt}; + return id; + } + + if (type == BuildingType::Splitter) + { + const BuildingDef* def = findBuildingDef(type); + assert(def != nullptr); + const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation); + assert(mask.outputPorts.size() >= 2); + const Rotation outA = mask.outputPorts[0].direction; + const Rotation outB = mask.outputPorts[1].direction; + m_belts.placeSplitter(anchor, outA, outB); + m_tileOccupancy[{anchor.x(), anchor.y()}] = id; + m_beltEntities[id] = BeltEntry{anchor, BuildingType::Splitter}; + return id; + } + + const BuildingDef* def = findBuildingDef(type); + assert(def != nullptr); + const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation); + + // Record tile occupancy for body cells. + for (const QPoint& cell : mask.bodyCells) + { + const QPoint absCell = anchor + cell; + m_tileOccupancy[{absCell.x(), absCell.y()}] = id; + } + + // Build construction site. + ConstructionSite site; + site.id = id; + site.anchor = anchor; + site.footprint = mask.footprint; + site.rotation = rotation; + site.type = type; + for (const QPoint& cell : mask.bodyCells) + { + site.bodyCells.push_back(anchor + cell); + } + + if (m_constructionQueue.empty()) + { + site.completesAt = currentTick + secondsToTicks(def->constructionTimeSeconds); + } + // else: completesAt remains 0 (queued, not yet started). + + m_constructionQueue.push_back(std::move(site)); + return id; +} + +// --------------------------------------------------------------------------- +// Demolish +// --------------------------------------------------------------------------- + +int BuildingSystem::demolish(EntityId id) +{ + // Belt / splitter? + const std::map::iterator beltIt = m_beltEntities.find(id); + if (beltIt != m_beltEntities.end()) + { + const QPoint tile = beltIt->second.tile; + const BuildingType btype = beltIt->second.type; + m_belts.removeTile(tile); + m_tileOccupancy.erase({tile.x(), tile.y()}); + m_beltEntities.erase(beltIt); + + const BuildingDef* def = findBuildingDef(btype); + if (def) + { + return def->cost * m_config.world.refundPercentage / 100; + } + return 0; + } + + // Construction queue? + for (std::deque::iterator it = m_constructionQueue.begin(); + it != m_constructionQueue.end(); + ++it) + { + if (it->id == id) + { + const BuildingDef* def = findBuildingDef(it->type); + for (const QPoint& cell : it->bodyCells) + { + m_tileOccupancy.erase({cell.x(), cell.y()}); + } + m_constructionQueue.erase(it); + if (def) + { + return def->cost * m_config.world.refundPercentage / 100; + } + return 0; + } + } + + // Operational building? + for (std::vector::iterator it = m_buildings.begin(); + it != m_buildings.end(); + ++it) + { + if (it->id == id) + { + const BuildingDef* def = findBuildingDef(it->type); + for (const QPoint& cell : it->bodyCells) + { + m_tileOccupancy.erase({cell.x(), cell.y()}); + } + m_buildings.erase(it); + if (def) + { + return def->cost * m_config.world.refundPercentage / 100; + } + return 0; + } + } + + return 0; +} + +// --------------------------------------------------------------------------- +// Set recipe +// --------------------------------------------------------------------------- + +void BuildingSystem::setRecipe(EntityId id, const std::string& recipeId) +{ + // Construction site: store recipe for when building completes. + for (ConstructionSite& site : m_constructionQueue) + { + if (site.id == id) + { + site.recipeId = recipeId; + return; + } + } + + // Operational building: clear buffers and re-init. + for (Building& building : m_buildings) + { + if (building.id == id) + { + building.recipeId = recipeId; + building.inputBuffer.counts.clear(); + building.inputBuffer.caps.clear(); + building.outputBuffer.items.clear(); + building.outputBuffer.capacity = 0; + building.production = std::nullopt; + + if (!recipeId.empty()) + { + const RecipeDef* recipe = findRecipe(recipeId, building.type); + if (recipe) + { + initBuffers(building, *recipe); + } + } + return; + } + } +} + +// --------------------------------------------------------------------------- +// Tick hooks +// --------------------------------------------------------------------------- + +void BuildingSystem::tickConstruction(Tick currentTick) +{ + if (m_constructionQueue.empty()) + { + return; + } + + ConstructionSite& front = m_constructionQueue.front(); + + // Guard: if somehow the front site was never started, start it now. + if (front.completesAt == 0) + { + const BuildingDef* def = findBuildingDef(front.type); + if (def) + { + front.completesAt = currentTick + secondsToTicks(def->constructionTimeSeconds); + } + return; + } + + if (currentTick < front.completesAt) + { + return; + } + + // Promote construction site to operational building. + const BuildingDef* def = findBuildingDef(front.type); + const ParsedSurfaceMask mask = parseSurfaceMask( + def ? def->surfaceMask : std::vector{}, + front.rotation); + + Building building; + building.id = front.id; + building.anchor = front.anchor; + building.footprint = front.footprint; + building.rotation = front.rotation; + building.type = front.type; + building.hp = 100.0f; + building.maxHp = 100.0f; + building.recipeId = front.recipeId; + + for (const QPoint& cell : mask.bodyCells) + { + building.bodyCells.push_back(front.anchor + cell); + } + for (const Port& port : mask.outputPorts) + { + Port absPort; + absPort.tile = front.anchor + port.tile; + absPort.direction = port.direction; + building.outputPorts.push_back(absPort); + } + building.inputPorts = computeInputPorts(building); + + if (!building.recipeId.empty()) + { + const RecipeDef* recipe = findRecipe(building.recipeId, building.type); + if (recipe) + { + initBuffers(building, *recipe); + } + } + + m_buildings.push_back(std::move(building)); + m_constructionQueue.pop_front(); + + // Start next queued site if present. + if (!m_constructionQueue.empty() && m_constructionQueue.front().completesAt == 0) + { + const BuildingDef* nextDef = findBuildingDef(m_constructionQueue.front().type); + if (nextDef) + { + m_constructionQueue.front().completesAt = + currentTick + secondsToTicks(nextDef->constructionTimeSeconds); + } + } +} + +void BuildingSystem::tickBeltPull() +{ + for (Building& building : m_buildings) + { + // HQ: pull building_block items and add to global stock. + if (building.type == BuildingType::Hq) + { + for (const Port& port : building.inputPorts) + { + const std::optional peeked = m_belts.peekItem(port); + if (peeked && peeked->id == "building_block") + { + const std::optional taken = m_belts.tryTakeItem(port); + if (taken) + { + m_addBuildingBlocks(1); + } + } + } + continue; + } + + if (building.recipeId.empty()) + { + continue; + } + + const RecipeDef* recipe = findRecipe(building.recipeId, building.type); + if (!recipe || recipe->inputs.empty()) + { + continue; + } + + for (const Port& port : building.inputPorts) + { + const std::optional peeked = m_belts.peekItem(port); + if (!peeked) + { + continue; + } + + const ItemType& type = *peeked; + + // Accept only if this type is a required input and buffer has space. + const std::map::const_iterator capIt = + building.inputBuffer.caps.find(type); + if (capIt == building.inputBuffer.caps.end() || capIt->second == 0) + { + continue; + } + + const int current = [&]() -> int + { + const std::map::const_iterator it = + building.inputBuffer.counts.find(type); + return (it != building.inputBuffer.counts.end()) ? it->second : 0; + }(); + + if (current >= capIt->second) + { + continue; + } + + const std::optional taken = m_belts.tryTakeItem(port); + if (taken) + { + building.inputBuffer.counts[taken->type]++; + } + } + } +} + +void BuildingSystem::tickProduction(Tick currentTick) +{ + for (Building& building : m_buildings) + { + // Skip types without a recipe-based production loop. + if (building.type == BuildingType::Belt || + building.type == BuildingType::Splitter || + building.type == BuildingType::Shipyard || + building.type == BuildingType::SalvageBay || + building.type == BuildingType::Hq || + building.type == BuildingType::PlayerDefenceStation || + building.type == BuildingType::EnemyDefenceStation) + { + continue; + } + + if (building.recipeId.empty()) + { + continue; + } + + const RecipeDef* recipe = findRecipe(building.recipeId, building.type); + if (!recipe) + { + continue; + } + + // If a production cycle is active, check for completion. + if (building.production) + { + if (currentTick >= building.production->completesAt) + { + for (const Item& item : building.production->chosenOutputs) + { + building.outputBuffer.items.push_back(item); + } + building.production = std::nullopt; + } + // Whether we just completed or are still running, do not start + // another cycle in the same tick. + continue; + } + + // Idle: check if a new cycle can start. + + // 1. All required inputs present? + bool inputsOk = true; + for (const RecipeIngredient& ing : recipe->inputs) + { + const ItemType type{ing.item}; + const std::map::const_iterator it = + building.inputBuffer.counts.find(type); + const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0; + if (have < ing.amount) + { + inputsOk = false; + break; + } + } + if (!inputsOk) + { + continue; + } + + // 2. Determine chosen outputs (roll for reprocessing). + std::vector chosen; + if (building.type == BuildingType::ReprocessingPlant) + { + chosen = rollReprocessingOutput(*recipe); + } + else + { + for (const RecipeOutput& out : recipe->outputs) + { + Item item; + item.type.id = out.item; + for (int i = 0; i < out.amount; ++i) + { + chosen.push_back(item); + } + } + } + + // 3. Output buffer has space for chosen outputs? + const int newSize = static_cast(building.outputBuffer.items.size()) + + static_cast(chosen.size()); + if (newSize > building.outputBuffer.capacity) + { + continue; + } + + // 4. Consume inputs and start cycle. + for (const RecipeIngredient& ing : recipe->inputs) + { + building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount; + } + + Production prod; + prod.recipeId = building.recipeId; + prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds); + prod.chosenOutputs = std::move(chosen); + building.production = std::move(prod); + } +} + +void BuildingSystem::tickBeltPush() +{ + for (Building& building : m_buildings) + { + if (building.outputBuffer.items.empty()) + { + continue; + } + + for (const Port& outputPort : building.outputPorts) + { + if (building.outputBuffer.items.empty()) + { + break; + } + const Item item = building.outputBuffer.items.front(); + if (m_belts.tryPutItem(outputPort, item)) + { + building.outputBuffer.items.erase(building.outputBuffer.items.begin()); + } + } + } +} + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +const Building* BuildingSystem::findBuilding(EntityId id) const +{ + for (const Building& building : m_buildings) + { + if (building.id == id) + { + return &building; + } + } + return nullptr; +} + +const ConstructionSite* BuildingSystem::findSite(EntityId id) const +{ + for (const ConstructionSite& site : m_constructionQueue) + { + if (site.id == id) + { + return &site; + } + } + return nullptr; +} + +std::vector BuildingSystem::allBuildings() const +{ + return m_buildings; +} + +std::vector BuildingSystem::allSites() const +{ + return std::vector(m_constructionQueue.begin(), + m_constructionQueue.end()); +} + +bool BuildingSystem::isTileOccupied(QPoint tile) const +{ + return m_tileOccupancy.count({tile.x(), tile.y()}) > 0; +} diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h new file mode 100644 index 0000000..582ffe1 --- /dev/null +++ b/src/lib/sim/BuildingSystem.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "BeltSystem.h" +#include "Building.h" +#include "BuildingType.h" +#include "EntityId.h" +#include "GameConfig.h" +#include "Rotation.h" +#include "Tick.h" + +// Manages building placement, construction queuing, and the per-tick +// production loop (belt→building pull, production, building→belt push). +// Belt and Splitter types are forwarded to BeltSystem rather than stored +// as Building instances. +class BuildingSystem +{ +public: + BuildingSystem(const GameConfig& config, + BeltSystem& belts, + std::function allocateId, + std::function addBuildingBlocks, + std::mt19937& rng); + + // -- Placement / demolish ------------------------------------------------ + // Returns the new entity id. Belt and Splitter register with BeltSystem + // directly; other types enter the construction queue. + EntityId place(BuildingType type, QPoint anchor, Rotation rotation, + Tick currentTick); + + // Remove a building or construction site by id. Returns the refund in + // building blocks (floor(cost * refundPercentage / 100)). Returns 0 for + // unknown ids. + int demolish(EntityId id); + + // Set the recipe (or blueprint id for shipyard) on a building or queued + // construction site. Clears both buffers on an operational building. + void setRecipe(EntityId id, const std::string& recipeId); + + // -- Tick hooks (called from Simulation::tick in the documented order) --- + void tickConstruction(Tick currentTick); + void tickBeltPull(); + void tickProduction(Tick currentTick); + void tickBeltPush(); + + // -- Queries ------------------------------------------------------------- + const Building* findBuilding(EntityId id) const; + const ConstructionSite* findSite(EntityId id) const; + std::vector allBuildings() const; + std::vector allSites() const; + bool isTileOccupied(QPoint tile) const; + +private: + struct BeltEntry + { + QPoint tile; + BuildingType type; // Belt or Splitter + }; + + const BuildingDef* findBuildingDef(BuildingType type) const; + const RecipeDef* findRecipe(const std::string& id, BuildingType type) const; + void initBuffers(Building& b, const RecipeDef& recipe) const; + std::vector computeInputPorts(const Building& b) const; + std::vector rollReprocessingOutput(const RecipeDef& recipe); + + const GameConfig& m_config; + BeltSystem& m_belts; + std::function m_allocateId; + std::function m_addBuildingBlocks; + std::mt19937& m_rng; + + std::vector m_buildings; + std::deque m_constructionQueue; + std::map m_beltEntities; + + // Maps every occupied body-cell coordinate to the entity that owns it. + std::map, EntityId> m_tileOccupancy; +}; diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index 459d85b..c4cf086 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -3,6 +3,8 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h + ${CMAKE_CURRENT_SOURCE_DIR}/Building.h + ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h PARENT_SCOPE ) @@ -11,6 +13,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp PARENT_SCOPE ) diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index ac1966f..9a81d79 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -1,15 +1,33 @@ #include "Simulation.h" +#include "BuildingSystem.h" + Simulation::Simulation(const GameConfig& config, unsigned int seed) : m_config(config) , m_rng(seed) , m_currentTick(0) , m_nextId(1) + , m_buildingBlocksStock(config.world.startingBuildingBlocks) + , m_beltSystem(config.world.beltSpeedTilesPerSecond) { + m_buildingSystem = std::make_unique( + config, + m_beltSystem, + [this]() { return allocateId(); }, + [this](int amount) { m_buildingBlocksStock += amount; }, + m_rng); } +Simulation::~Simulation() = default; + void Simulation::tick() { + m_buildingSystem->tickConstruction(m_currentTick); + m_buildingSystem->tickBeltPull(); // tick order step 3 + m_buildingSystem->tickProduction(m_currentTick); // step 4 + m_buildingSystem->tickBeltPush(); // step 5 + m_beltSystem.tick(); // step 6 + ++m_currentTick; } @@ -32,6 +50,31 @@ Tick Simulation::currentTick() const return m_currentTick; } +int Simulation::buildingBlocksStock() const +{ + return m_buildingBlocksStock; +} + +BuildingSystem& Simulation::buildings() +{ + return *m_buildingSystem; +} + +const BuildingSystem& Simulation::buildings() const +{ + return *m_buildingSystem; +} + +BeltSystem& Simulation::belts() +{ + return m_beltSystem; +} + +const BeltSystem& Simulation::belts() const +{ + return m_beltSystem; +} + EntityId Simulation::allocateId() { return m_nextId++; diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 7595b60..49de54b 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -1,21 +1,25 @@ #pragma once +#include #include #include +#include "BeltSystem.h" +#include "BlueprintDropEvent.h" #include "EntityId.h" #include "FireEvent.h" -#include "BlueprintDropEvent.h" #include "GameConfig.h" #include "Tick.h" +class BuildingSystem; + class Simulation { public: explicit Simulation(const GameConfig& config, unsigned int seed = 0); + ~Simulation(); // Advances the simulation by one tick. Tick order per architecture.md §Tick Order. - // Currently a stub; subsystems are plugged in across Steps 3-7. void tick(); // Returns all fire events accumulated since the last drain, clearing the @@ -26,6 +30,12 @@ public: std::vector drainBlueprintDropEvents(); Tick currentTick() const; + int buildingBlocksStock() const; + + BuildingSystem& buildings(); + const BuildingSystem& buildings() const; + BeltSystem& belts(); + const BeltSystem& belts() const; private: EntityId allocateId(); // Strictly increasing; never returns kInvalidEntityId. @@ -33,9 +43,13 @@ private: const GameConfig& m_config; std::mt19937 m_rng; - Tick m_currentTick; - EntityId m_nextId; // starts at 1; 0 is kInvalidEntityId. + Tick m_currentTick; + EntityId m_nextId; + int m_buildingBlocksStock; - std::vector m_fireEvents; + BeltSystem m_beltSystem; + std::unique_ptr m_buildingSystem; + + std::vector m_fireEvents; std::vector m_blueprintDropEvents; }; diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp new file mode 100644 index 0000000..1bf3bd5 --- /dev/null +++ b/src/test/BuildingTest.cpp @@ -0,0 +1,504 @@ +#include "catch.hpp" + +#include +#include +#include +#include + +#include + +#include "BeltSystem.h" +#include "Building.h" +#include "BuildingSystem.h" +#include "BuildingType.h" +#include "ConfigLoader.h" +#include "Item.h" +#include "ItemType.h" +#include "Port.h" +#include "Rotation.h" +#include "Tick.h" + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +static GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR); +} + +static Item makeItem(const std::string& id) +{ + Item item; + item.type.id = id; + return item; +} + +static Port eastPort(QPoint tile) +{ + Port p; + p.tile = tile; + p.direction = Rotation::East; + return p; +} + +static Port westPort(QPoint tile) +{ + Port p; + p.tile = tile; + p.direction = Rotation::West; + return p; +} + +// Run N full sim ticks: construction, belt-pull, production, belt-push, belt tick. +static void runTicks(BuildingSystem& bs, BeltSystem& belts, int n, Tick& tick) +{ + for (int i = 0; i < n; ++i) + { + bs.tickConstruction(tick); + bs.tickBeltPull(); + bs.tickProduction(tick); + bs.tickBeltPush(); + belts.tick(); + ++tick; + } +} + +// --------------------------------------------------------------------------- +// Placement +// --------------------------------------------------------------------------- + +TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + REQUIRE(id != kInvalidEntityId); + + // Miner mask ["AA","A>"] with East rotation → body at (0,0),(1,0),(0,1). + REQUIRE(bs.isTileOccupied(QPoint(0, 0))); + REQUIRE(bs.isTileOccupied(QPoint(1, 0))); + REQUIRE(bs.isTileOccupied(QPoint(0, 1))); + // (1,1) is the output-port tile, NOT a body cell. + REQUIRE_FALSE(bs.isTileOccupied(QPoint(1, 1))); +} + +TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem", "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0); + + REQUIRE(belts.tryPutItem(eastPort(QPoint(5, 5)), makeItem("iron_ore"))); + REQUIRE(bs.allBuildings().empty()); // belts do not create Building instances +} + +TEST_CASE("BuildingSystem: placed building enters construction queue", "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + + REQUIRE(bs.allSites().size() == 1); + REQUIRE(bs.allBuildings().empty()); + REQUIRE(bs.findSite(id) != nullptr); +} + +TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + const int refund = bs.demolish(id); + + // Miner cost = 15, refund = floor(15 * 75 / 100) = 11. + REQUIRE(refund == 15 * cfg.world.refundPercentage / 100); + REQUIRE_FALSE(bs.isTileOccupied(QPoint(0, 0))); + REQUIRE(bs.allSites().empty()); +} + +// --------------------------------------------------------------------------- +// Construction queue +// --------------------------------------------------------------------------- + +TEST_CASE("BuildingSystem: first queued building starts construction immediately", + "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + REQUIRE(bs.allSites().front().completesAt > 0); +} + +TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0); + + REQUIRE(bs.allSites().size() == 2); + REQUIRE(bs.allSites()[0].completesAt > 0); + REQUIRE(bs.allSites()[1].completesAt == 0); +} + +TEST_CASE("BuildingSystem: construction completes after configured duration", "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + + // Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300. + // We need to process tick 300 itself, so run 301 ticks (ticks 0..300). + Tick tick = 0; + runTicks(bs, belts, static_cast(secondsToTicks(10.0)) + 1, tick); + + REQUIRE(bs.allSites().empty()); + REQUIRE(bs.findBuilding(id) != nullptr); +} + +TEST_CASE("BuildingSystem: second building starts after first completes", "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + const EntityId id2 = bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0); + + // Process through tick 300 to complete first miner's construction. + Tick tick = 0; + runTicks(bs, belts, static_cast(secondsToTicks(10.0)) + 1, tick); + + REQUIRE(bs.allSites().size() == 1); + REQUIRE(bs.allSites().front().id == id2); + REQUIRE(bs.allSites().front().completesAt > 0); +} + +// --------------------------------------------------------------------------- +// Miner production cycle +// --------------------------------------------------------------------------- + +TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + bs.setRecipe(id, "mine_iron_ore"); + + Tick tick = 0; + // Construction completes on tick 300; production cycle starts tick 300, + // completes on tick 330. Process through tick 330: 331 ticks total. + runTicks(bs, belts, + static_cast(secondsToTicks(10.0)) + static_cast(secondsToTicks(1.0)) + 1, + tick); + + const Building* b = bs.findBuilding(id); + REQUIRE(b != nullptr); + REQUIRE_FALSE(b->outputBuffer.items.empty()); + REQUIRE(b->outputBuffer.items.front().type.id == "iron_ore"); +} + +TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + bs.setRecipe(id, "mine_iron_ore"); + + Tick tick = 0; + // Construction (10s) then cycle 1 starts at tick 300 (completesAt=330). + // Cycle 1 completes at tick 330: deposit item, continue (no same-tick restart). + // Cycle 2 starts at tick 331 (completesAt=361). + // Cycle 2 completes at tick 361: deposit item → buffer=2, cycle 3 stalls. + // Need to process through tick 361: 362 ticks total. + runTicks(bs, belts, + static_cast(secondsToTicks(10.0)) + + 2 * static_cast(secondsToTicks(1.0)) + 2, + tick); + + const Building* b = bs.findBuilding(id); + REQUIRE(b != nullptr); + REQUIRE(static_cast(b->outputBuffer.items.size()) == 2); + REQUIRE_FALSE(b->production.has_value()); +} + +// --------------------------------------------------------------------------- +// Belt pull → input buffer +// --------------------------------------------------------------------------- + +TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing belt", + "[building]") +{ + const GameConfig cfg = loadConfig(); + // Fast belt so items are immediately available for peek/take. + BeltSystem belts(static_cast(kTickRateHz)); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + // Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1). + // Output port (2,1) East. Input port example: (2,0) West. + const EntityId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0); + bs.setRecipe(sid, "iron_ingot"); + + // Complete construction (15s → tick 450+1 = 451 ticks). + Tick tick = 0; + runTicks(bs, belts, static_cast(secondsToTicks(15.0)) + 1, tick); + + // Place west-flowing belt at (2,0): belt flows West, delivers to smelter. + belts.placeBelt(QPoint(2, 0), Rotation::West); + belts.tryPutItem(westPort(QPoint(2, 0)), makeItem("iron_ore")); + + bs.tickBeltPull(); + + const Building* b = bs.findBuilding(sid); + REQUIRE(b != nullptr); + const std::map::const_iterator it = + b->inputBuffer.counts.find(ItemType{"iron_ore"}); + REQUIRE(it != b->inputBuffer.counts.end()); + REQUIRE(it->second >= 1); +} + +// --------------------------------------------------------------------------- +// Belt push → belt tile +// --------------------------------------------------------------------------- + +TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(static_cast(kTickRateHz)); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + bs.setRecipe(id, "mine_iron_ore"); + + // Belt at the miner's output port tile (1,1) flowing East. + belts.placeBelt(QPoint(1, 1), Rotation::East); + + Tick tick = 0; + // Construction (10s) + 1 production cycle (1s) + 1 extra tick. + runTicks(bs, belts, + static_cast(secondsToTicks(10.0)) + static_cast(secondsToTicks(1.0)) + 1, + tick); + + // Item should have been pushed onto the belt this tick or a subsequent one. + // Run one more tick to ensure tickBeltPush fires after the deposit tick. + runTicks(bs, belts, 1, tick); + + const std::optional item = belts.tryTakeItem(eastPort(QPoint(1, 1))); + REQUIRE(item.has_value()); + REQUIRE(item->type.id == "iron_ore"); +} + +// --------------------------------------------------------------------------- +// setRecipe clears buffers +// --------------------------------------------------------------------------- + +TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production", + "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(static_cast(kTickRateHz)); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + bs.setRecipe(id, "mine_iron_ore"); + + Tick tick = 0; + // Run until first item is in output buffer. + runTicks(bs, belts, + static_cast(secondsToTicks(10.0)) + static_cast(secondsToTicks(1.0)) + 1, + tick); + + { + const Building* b = bs.findBuilding(id); + REQUIRE(b != nullptr); + REQUIRE_FALSE(b->outputBuffer.items.empty()); + } + + bs.setRecipe(id, "mine_copper_ore"); + + const Building* b = bs.findBuilding(id); + REQUIRE(b->outputBuffer.items.empty()); + REQUIRE_FALSE(b->production.has_value()); +} + +// --------------------------------------------------------------------------- +// Reprocessing plant — output buffer capacity (REQ-MAT-OUTPUT-BUFFER-REPROCESSING) +// --------------------------------------------------------------------------- + +TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max output per roll", + "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + const EntityId id = bs.place(BuildingType::ReprocessingPlant, + QPoint(0, 0), Rotation::East, 0); + bs.setRecipe(id, "reprocessing_cycle"); + + // Complete construction (25s). + Tick tick = 0; + runTicks(bs, belts, static_cast(secondsToTicks(25.0)) + 1, tick); + + const Building* b = bs.findBuilding(id); + REQUIRE(b != nullptr); + // reprocessing_cycle outputs: 2 iron_ingot (60%), 1 circuit_board (30%), + // 1 advanced_alloy (10%). Max per roll = 2. Capacity = 2 (1× max). + REQUIRE(b->outputBuffer.capacity == 2); +} + +TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls", + "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(static_cast(kTickRateHz)); + int stock = 0; + // Seed chosen so first roll produces 2-item output (iron_ingot), filling buffer. + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + rng); + + const EntityId id = bs.place(BuildingType::ReprocessingPlant, + QPoint(0, 0), Rotation::East, 0); + bs.setRecipe(id, "reprocessing_cycle"); + + // Complete construction (25s). + Tick tick = 0; + runTicks(bs, belts, static_cast(secondsToTicks(25.0)) + 1, tick); + + // Feed 5 scrap into the building via a belt at an input port. + // Reprocessing plant body (East rotation) = 3×3 at (0,0). + // Valid input port: tile (-1,0) flowing East. + belts.placeBelt(QPoint(-1, 0), Rotation::East); + for (int i = 0; i < 5; ++i) + { + belts.tryPutItem(eastPort(QPoint(-1, 0)), makeItem("scrap")); + bs.tickBeltPull(); + } + + // Verify scrap is in input buffer. + { + const Building* b = bs.findBuilding(id); + REQUIRE(b != nullptr); + const std::map::const_iterator it = + b->inputBuffer.counts.find(ItemType{"scrap"}); + REQUIRE(it != b->inputBuffer.counts.end()); + REQUIRE(it->second == 5); + } + + // Run production cycle (3s = 90 ticks + 1 for the completion tick). + runTicks(bs, belts, static_cast(secondsToTicks(3.0)) + 1, tick); + + const Building* b = bs.findBuilding(id); + REQUIRE(b != nullptr); + // Cycle should have completed and output deposited. + REQUIRE_FALSE(b->outputBuffer.items.empty()); + // No new production: inputs were consumed and not replenished. + REQUIRE_FALSE(b->production.has_value()); +} diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 8ae2bf7..78aa677 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -7,4 +7,6 @@ add_files( ConfigLoaderTest.cpp SimulationTest.cpp BeltSystemTest.cpp + SurfaceMaskTest.cpp + BuildingTest.cpp ) diff --git a/src/test/ConfigLoaderTest.cpp b/src/test/ConfigLoaderTest.cpp index ad7fe74..32c83da 100644 --- a/src/test/ConfigLoaderTest.cpp +++ b/src/test/ConfigLoaderTest.cpp @@ -163,6 +163,7 @@ height_tiles = 60 refund_percentage = 75 scrap_despawn_seconds = 30 belt_speed_tiles_per_second = 2 +starting_building_blocks = 100 [regions] asteroid_width = 40 @@ -207,6 +208,7 @@ height_tiles = 60 refund_percentage = 75 scrap_despawn_seconds = 30 belt_speed_tiles_per_second = 2 +starting_building_blocks = 100 [regions] asteroid_width = 40 diff --git a/src/test/SurfaceMaskTest.cpp b/src/test/SurfaceMaskTest.cpp new file mode 100644 index 0000000..2ea91c2 --- /dev/null +++ b/src/test/SurfaceMaskTest.cpp @@ -0,0 +1,184 @@ +#include "catch.hpp" + +#include +#include + +#include + +#include "Port.h" +#include "Rotation.h" +#include "SurfaceMask.h" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static bool hasBodyCell(const ParsedSurfaceMask& mask, int x, int y) +{ + const QPoint pt(x, y); + return std::find(mask.bodyCells.begin(), mask.bodyCells.end(), pt) + != mask.bodyCells.end(); +} + +static bool hasOutputPort(const ParsedSurfaceMask& mask, int x, int y, Rotation dir) +{ + for (const Port& port : mask.outputPorts) + { + if (port.tile == QPoint(x, y) && port.direction == dir) + { + return true; + } + } + return false; +} + +static bool hasShipDockCell(const ParsedSurfaceMask& mask, int x, int y) +{ + const QPoint pt(x, y); + return std::find(mask.shipDockCells.begin(), mask.shipDockCells.end(), pt) + != mask.shipDockCells.end(); +} + +// --------------------------------------------------------------------------- +// Belt ["A>"] — 1×1 body, East output +// --------------------------------------------------------------------------- + +TEST_CASE("SurfaceMask: belt East — 1 body cell, port at (1,0) East", "[surface_mask]") +{ + const ParsedSurfaceMask mask = parseSurfaceMask({"A>"}, Rotation::East); + + REQUIRE(mask.footprint.width() == 1); + REQUIRE(mask.footprint.height() == 1); + REQUIRE(mask.bodyCells.size() == 1); + REQUIRE(hasBodyCell(mask, 0, 0)); + REQUIRE(mask.outputPorts.size() == 1); + REQUIRE(hasOutputPort(mask, 1, 0, Rotation::East)); +} + +TEST_CASE("SurfaceMask: belt South — body at (0,0), port at (0,1) South", "[surface_mask]") +{ + const ParsedSurfaceMask mask = parseSurfaceMask({"A>"}, Rotation::South); + + REQUIRE(mask.bodyCells.size() == 1); + REQUIRE(hasBodyCell(mask, 0, 0)); + REQUIRE(mask.outputPorts.size() == 1); + REQUIRE(hasOutputPort(mask, 0, 1, Rotation::South)); +} + +TEST_CASE("SurfaceMask: belt West — body at (0,0), port at (-1,0) West", "[surface_mask]") +{ + const ParsedSurfaceMask mask = parseSurfaceMask({"A>"}, Rotation::West); + + REQUIRE(mask.bodyCells.size() == 1); + REQUIRE(hasBodyCell(mask, 0, 0)); + REQUIRE(mask.outputPorts.size() == 1); + REQUIRE(hasOutputPort(mask, -1, 0, Rotation::West)); +} + +TEST_CASE("SurfaceMask: belt North — body at (0,0), port at (0,-1) North", "[surface_mask]") +{ + const ParsedSurfaceMask mask = parseSurfaceMask({"A>"}, Rotation::North); + + REQUIRE(mask.bodyCells.size() == 1); + REQUIRE(hasBodyCell(mask, 0, 0)); + REQUIRE(mask.outputPorts.size() == 1); + REQUIRE(hasOutputPort(mask, 0, -1, Rotation::North)); +} + +// --------------------------------------------------------------------------- +// Miner ["AA", "A>"] — 3 body cells, East output +// --------------------------------------------------------------------------- + +TEST_CASE("SurfaceMask: miner East — 3 body cells, port at (1,1) East, footprint 2×2", + "[surface_mask]") +{ + const ParsedSurfaceMask mask = parseSurfaceMask({"AA", "A>"}, Rotation::East); + + REQUIRE(mask.footprint.width() == 2); + REQUIRE(mask.footprint.height() == 2); + REQUIRE(mask.bodyCells.size() == 3); + REQUIRE(hasBodyCell(mask, 0, 0)); + REQUIRE(hasBodyCell(mask, 1, 0)); + REQUIRE(hasBodyCell(mask, 0, 1)); + REQUIRE(mask.outputPorts.size() == 1); + REQUIRE(hasOutputPort(mask, 1, 1, Rotation::East)); +} + +TEST_CASE("SurfaceMask: miner South — port faces South", "[surface_mask]") +{ + const ParsedSurfaceMask mask = parseSurfaceMask({"AA", "A>"}, Rotation::South); + + REQUIRE(mask.outputPorts.size() == 1); + REQUIRE(mask.outputPorts[0].direction == Rotation::South); +} + +TEST_CASE("SurfaceMask: miner West — port faces West", "[surface_mask]") +{ + const ParsedSurfaceMask mask = parseSurfaceMask({"AA", "A>"}, Rotation::West); + + REQUIRE(mask.outputPorts.size() == 1); + REQUIRE(mask.outputPorts[0].direction == Rotation::West); +} + +TEST_CASE("SurfaceMask: miner North — port faces North", "[surface_mask]") +{ + const ParsedSurfaceMask mask = parseSurfaceMask({"AA", "A>"}, Rotation::North); + + REQUIRE(mask.outputPorts.size() == 1); + REQUIRE(mask.outputPorts[0].direction == Rotation::North); +} + +// --------------------------------------------------------------------------- +// Splitter [""] — 1 body cell, West + East outputs +// --------------------------------------------------------------------------- + +TEST_CASE("SurfaceMask: splitter East — 1 body cell, ports at (-1,0) West and (1,0) East", + "[surface_mask]") +{ + const ParsedSurfaceMask mask = parseSurfaceMask({""}, Rotation::East); + + REQUIRE(mask.footprint.width() == 1); + REQUIRE(mask.footprint.height() == 1); + REQUIRE(mask.bodyCells.size() == 1); + REQUIRE(hasBodyCell(mask, 0, 0)); + REQUIRE(mask.outputPorts.size() == 2); + REQUIRE(hasOutputPort(mask, -1, 0, Rotation::West)); + REQUIRE(hasOutputPort(mask, 1, 0, Rotation::East)); +} + +// --------------------------------------------------------------------------- +// Shipyard ["AAAS>", "AAAS "] — 6 A + 2 S body cells, 1 port +// --------------------------------------------------------------------------- + +TEST_CASE("SurfaceMask: shipyard East — 6 A bodyCells, 2 S shipDockCells, port at (4,0) East", + "[surface_mask]") +{ + const std::vector rows = {"AAAS>", "AAAS "}; + const ParsedSurfaceMask mask = parseSurfaceMask(rows, Rotation::East); + + REQUIRE(mask.footprint.width() == 4); + REQUIRE(mask.footprint.height() == 2); + REQUIRE(mask.bodyCells.size() == 8); // 6 A + 2 S + REQUIRE(mask.shipDockCells.size() == 2); + REQUIRE(hasShipDockCell(mask, 3, 0)); + REQUIRE(hasShipDockCell(mask, 3, 1)); + REQUIRE(mask.outputPorts.size() == 1); + REQUIRE(hasOutputPort(mask, 4, 0, Rotation::East)); +} + +// --------------------------------------------------------------------------- +// Assembler ["AAA ", "AAA>", "AAA "] — 9 body cells, East output +// --------------------------------------------------------------------------- + +TEST_CASE("SurfaceMask: assembler East — 9 body cells, port at (3,1) East, footprint 3×3", + "[surface_mask]") +{ + const std::vector rows = {"AAA ", "AAA>", "AAA "}; + const ParsedSurfaceMask mask = parseSurfaceMask(rows, Rotation::East); + + REQUIRE(mask.footprint.width() == 3); + REQUIRE(mask.footprint.height() == 3); + REQUIRE(mask.bodyCells.size() == 9); + REQUIRE(mask.outputPorts.size() == 1); + REQUIRE(hasOutputPort(mask, 3, 1, Rotation::East)); +}