implement building system
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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) | ⬜ |
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
|
||||
|
||||
cfg.heightTiles = static_cast<int>(requireInt(tbl["world"]["height_tiles"], file, "world.height_tiles"));
|
||||
cfg.refundPercentage = static_cast<int>(requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage"));
|
||||
cfg.startingBuildingBlocks = static_cast<int>(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");
|
||||
|
||||
|
||||
172
src/lib/config/SurfaceMask.cpp
Normal file
172
src/lib/config/SurfaceMask.cpp
Normal file
@@ -0,0 +1,172 @@
|
||||
#include "SurfaceMask.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
|
||||
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<std::string> rotateCW(const std::vector<std::string>& grid)
|
||||
{
|
||||
if (grid.empty())
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
const int srcH = static_cast<int>(grid.size());
|
||||
// Pad all rows to the same width.
|
||||
int srcW = 0;
|
||||
for (const std::string& row : grid)
|
||||
{
|
||||
const int w = static_cast<int>(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<std::string> 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<int>(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<std::string>& 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<std::string> 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<QPoint> rawBodyCells;
|
||||
std::vector<QPoint> rawShipDockCells;
|
||||
struct RawPort
|
||||
{
|
||||
QPoint tile;
|
||||
Rotation direction;
|
||||
};
|
||||
std::vector<RawPort> rawPorts;
|
||||
|
||||
for (int row = 0; row < static_cast<int>(grid.size()); ++row)
|
||||
{
|
||||
for (int col = 0; col < static_cast<int>(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;
|
||||
}
|
||||
28
src/lib/config/SurfaceMask.h
Normal file
28
src/lib/config/SurfaceMask.h
Normal file
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
#include <QSize>
|
||||
|
||||
#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<QPoint> bodyCells; // relative positions of A and S tiles
|
||||
std::vector<Port> outputPorts; // port.tile = cell adjacent to body, outside footprint;
|
||||
// port.direction = flow direction away from building
|
||||
std::vector<QPoint> 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<std::string>& rows,
|
||||
Rotation rotation);
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -135,6 +135,31 @@ std::optional<Item> BeltSystem::tryTakeItem(Port port)
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<ItemType> BeltSystem::peekItem(Port port) const
|
||||
{
|
||||
const std::map<std::pair<int, int>, 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -64,6 +64,10 @@ public:
|
||||
// Returns nullopt if tile is not a belt, direction mismatches, or tile empty.
|
||||
std::optional<Item> 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<ItemType> peekItem(Port port) const;
|
||||
|
||||
// -- Maintenance ---------------------------------------------------------
|
||||
void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
|
||||
void tick();
|
||||
|
||||
76
src/lib/sim/Building.h
Normal file
76
src/lib/sim/Building.h
Normal file
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
#include <QSize>
|
||||
|
||||
#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<ItemType, int> counts; // current item counts per material
|
||||
std::map<ItemType, int> caps; // max items per material (2× per-cycle requirement)
|
||||
};
|
||||
|
||||
// Output buffer shared by all output materials for a production building.
|
||||
struct OutputBuffer
|
||||
{
|
||||
std::vector<Item> 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<Item> 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<QPoint> 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> production;
|
||||
|
||||
// Pre-computed from surface mask at placement; in absolute world coordinates.
|
||||
std::vector<QPoint> bodyCells;
|
||||
std::vector<Port> outputPorts;
|
||||
std::vector<Port> inputPorts; // perimeter tiles (minus output-port tiles),
|
||||
// direction pointing INTO building
|
||||
};
|
||||
661
src/lib/sim/BuildingSystem.cpp
Normal file
661
src/lib/sim/BuildingSystem.cpp
Normal file
@@ -0,0 +1,661 @@
|
||||
#include "BuildingSystem.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <random>
|
||||
#include <set>
|
||||
|
||||
#include "SurfaceMask.h"
|
||||
|
||||
BuildingSystem::BuildingSystem(const GameConfig& config,
|
||||
BeltSystem& belts,
|
||||
std::function<EntityId()> allocateId,
|
||||
std::function<void(int)> 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<Port> BuildingSystem::computeInputPorts(const Building& b) const
|
||||
{
|
||||
// Build lookup sets for quick membership checks.
|
||||
std::set<std::pair<int, int>> bodySet;
|
||||
for (const QPoint& cell : b.bodyCells)
|
||||
{
|
||||
bodySet.insert({cell.x(), cell.y()});
|
||||
}
|
||||
|
||||
std::set<std::pair<int, int>> 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<std::pair<int, int>> seen;
|
||||
std::vector<Port> 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<int, int> 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<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe)
|
||||
{
|
||||
std::vector<double> weights;
|
||||
weights.reserve(recipe.outputs.size());
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
weights.push_back(out.probability.value_or(1.0));
|
||||
}
|
||||
|
||||
std::discrete_distribution<int> dist(weights.begin(), weights.end());
|
||||
const int idx = dist(m_rng);
|
||||
|
||||
const RecipeOutput& chosen = recipe.outputs[static_cast<std::size_t>(idx)];
|
||||
std::vector<Item> 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<EntityId, BeltEntry>::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<ConstructionSite>::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<Building>::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<std::string>{},
|
||||
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<ItemType> peeked = m_belts.peekItem(port);
|
||||
if (peeked && peeked->id == "building_block")
|
||||
{
|
||||
const std::optional<Item> 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<ItemType> 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<ItemType, int>::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<ItemType, int>::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<Item> 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<ItemType, int>::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<Item> 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<int>(building.outputBuffer.items.size())
|
||||
+ static_cast<int>(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<Building> BuildingSystem::allBuildings() const
|
||||
{
|
||||
return m_buildings;
|
||||
}
|
||||
|
||||
std::vector<ConstructionSite> BuildingSystem::allSites() const
|
||||
{
|
||||
return std::vector<ConstructionSite>(m_constructionQueue.begin(),
|
||||
m_constructionQueue.end());
|
||||
}
|
||||
|
||||
bool BuildingSystem::isTileOccupied(QPoint tile) const
|
||||
{
|
||||
return m_tileOccupancy.count({tile.x(), tile.y()}) > 0;
|
||||
}
|
||||
88
src/lib/sim/BuildingSystem.h
Normal file
88
src/lib/sim/BuildingSystem.h
Normal file
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#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<EntityId()> allocateId,
|
||||
std::function<void(int)> 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<Building> allBuildings() const;
|
||||
std::vector<ConstructionSite> 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<Port> computeInputPorts(const Building& b) const;
|
||||
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
|
||||
|
||||
const GameConfig& m_config;
|
||||
BeltSystem& m_belts;
|
||||
std::function<EntityId()> m_allocateId;
|
||||
std::function<void(int)> m_addBuildingBlocks;
|
||||
std::mt19937& m_rng;
|
||||
|
||||
std::vector<Building> m_buildings;
|
||||
std::deque<ConstructionSite> m_constructionQueue;
|
||||
std::map<EntityId, BeltEntry> m_beltEntities;
|
||||
|
||||
// Maps every occupied body-cell coordinate to the entity that owns it.
|
||||
std::map<std::pair<int, int>, EntityId> m_tileOccupancy;
|
||||
};
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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<BuildingSystem>(
|
||||
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++;
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <random>
|
||||
#include <vector>
|
||||
|
||||
#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<BlueprintDropEvent> 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<FireEvent> m_fireEvents;
|
||||
BeltSystem m_beltSystem;
|
||||
std::unique_ptr<BuildingSystem> m_buildingSystem;
|
||||
|
||||
std::vector<FireEvent> m_fireEvents;
|
||||
std::vector<BlueprintDropEvent> m_blueprintDropEvents;
|
||||
};
|
||||
|
||||
504
src/test/BuildingTest.cpp
Normal file
504
src/test/BuildingTest.cpp
Normal file
@@ -0,0 +1,504 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <map>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#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<int>(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<int>(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<int>(secondsToTicks(10.0)) + static_cast<int>(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<int>(secondsToTicks(10.0))
|
||||
+ 2 * static_cast<int>(secondsToTicks(1.0)) + 2,
|
||||
tick);
|
||||
|
||||
const Building* b = bs.findBuilding(id);
|
||||
REQUIRE(b != nullptr);
|
||||
REQUIRE(static_cast<int>(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<double>(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<int>(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<ItemType, int>::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<double>(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<int>(secondsToTicks(10.0)) + static_cast<int>(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> 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<double>(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<int>(secondsToTicks(10.0)) + static_cast<int>(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<int>(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<double>(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<int>(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<ItemType, int>::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<int>(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());
|
||||
}
|
||||
@@ -7,4 +7,6 @@ add_files(
|
||||
ConfigLoaderTest.cpp
|
||||
SimulationTest.cpp
|
||||
BeltSystemTest.cpp
|
||||
SurfaceMaskTest.cpp
|
||||
BuildingTest.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
|
||||
|
||||
184
src/test/SurfaceMaskTest.cpp
Normal file
184
src/test/SurfaceMaskTest.cpp
Normal file
@@ -0,0 +1,184 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#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 ["<A>"] — 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({"<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() == 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<std::string> 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<std::string> 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));
|
||||
}
|
||||
Reference in New Issue
Block a user