Compare commits

...

6 Commits

30 changed files with 602 additions and 52 deletions

View File

@@ -16,6 +16,9 @@ hp_formula = "4"
[ship.movement]
speed_formula = "4"
[ship.sensor]
sensor_range_formula = "15"
[ship.combat]
damage_formula = "1"
attack_range_formula = "10"
@@ -43,6 +46,9 @@ hp_formula = "120 + 15*x"
[ship.movement]
speed_formula = "120"
[ship.sensor]
sensor_range_formula = "300"
[ship.combat]
damage_formula = "12 + 2*x"
attack_range_formula = "250"
@@ -70,6 +76,9 @@ hp_formula = "40 + 4*x"
[ship.movement]
speed_formula = "110"
[ship.sensor]
sensor_range_formula = "250"
[ship.salvage]
collection_range = 50
cargo_capacity = 10
@@ -96,6 +105,9 @@ hp_formula = "60 + 5*x"
[ship.movement]
speed_formula = "130"
[ship.sensor]
sensor_range_formula = "250"
[ship.repair]
repair_rate_formula = "5 + x"
repair_range_formula = "80"

View File

@@ -16,6 +16,9 @@ hp_formula = "40 + 5*x"
[ship.movement]
speed_formula = "200 + 5*x"
[ship.sensor]
sensor_range_formula = "200"
[ship.combat]
damage_formula = "10 + 2*x"
attack_range_formula = "150"
@@ -43,6 +46,9 @@ hp_formula = "120 + 15*x"
[ship.movement]
speed_formula = "120"
[ship.sensor]
sensor_range_formula = "300"
[ship.combat]
damage_formula = "12 + 2*x"
attack_range_formula = "250"
@@ -70,6 +76,9 @@ hp_formula = "40 + 4*x"
[ship.movement]
speed_formula = "110"
[ship.sensor]
sensor_range_formula = "250"
[ship.salvage]
collection_range = 50
cargo_capacity = 10
@@ -96,6 +105,9 @@ hp_formula = "60 + 5*x"
[ship.movement]
speed_formula = "130"
[ship.sensor]
sensor_range_formula = "250"
[ship.repair]
repair_rate_formula = "5 + x"
repair_range_formula = "80"

View File

@@ -60,8 +60,10 @@ Output port indicators are not building tiles themselves. A building may have mo
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed.
- REQ-BLD-ROTATE: While in builder mode, pressing E rotates the ghost 90° clockwise and Q rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock.
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color when the current cursor position fails any of these conditions.
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE, and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color when the current cursor position fails any of these conditions.
- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. This applies in both normal builder mode and blueprint placement mode; in blueprint placement mode it is evaluated per building in the blueprint independently — buildings in the blueprint whose footprint coincides with an existing same-type building or site are rotated in place, while the remaining buildings in the blueprint are placed as normal construction sites (subject to the usual validity checks and total cost).
- REQ-BLD-BELT-DRAG: For belts, the player can click and drag across multiple tiles to place a construction site on each tile in one gesture.
- REQ-BLD-TUNNEL-AUTO-SWITCH: After the player successfully places a Tunnel Entry construction site, builder mode automatically switches to Tunnel Exit (and vice versa), preserving the current ghost rotation. This makes it easy to immediately place the paired end without manually selecting the complementary type.
- REQ-BLD-DEMOLISH: The player can demolish a placed factory building. Demolition returns `world.toml [world].refund_percentage` percent of the original building block cost (default 75%) to the global stock. Exception: if the building is still in the construction queue (not yet fully built, including the one currently being constructed), it is removed from the queue and the **full** building block cost is refunded. The HQ and player defence stations cannot be demolished.
## Building Types

View File

@@ -177,7 +177,7 @@ set_target_properties(${TARGET_APP_NAME} PROPERTIES
VS_DEBUGGER_WORKING_DIRECTORY "${OUTPUT_ROOT_PATH}/$(Configuration)/app/"
)
target_compile_definitions(${TARGET_APP_NAME} PRIVATE
DOTA_FACTORY_CONFIG_DIR="${CMAKE_SOURCE_DIR}/bin/config"
CONFIG_DIR="${CMAKE_SOURCE_DIR}/bin/app/data/config"
)
target_link_libraries(${TARGET_APP_NAME} ${TARGET_UI_NAME})
@@ -206,7 +206,7 @@ set_property(TARGET ${TARGET_TEST_NAME} PROPERTY INCLUDE_DIRECTORIES
"${LIB_INCLUDE_PATH}"
)
target_compile_definitions(${TARGET_TEST_NAME} PRIVATE
DOTA_FACTORY_CONFIG_DIR="${CMAKE_SOURCE_DIR}/src/test/config"
CONFIG_DIR="${CMAKE_SOURCE_DIR}/bin/test/data/config"
)
target_link_libraries(${TARGET_TEST_NAME} ${TARGET_LIB_NAME})

View File

@@ -31,10 +31,10 @@ int main(int argc, char *argv[])
QDir().mkdir(dataDir.dirName());
}
GameConfig config = ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config));
MainWindow window(sim.get(), std::string(DOTA_FACTORY_CONFIG_DIR));
MainWindow window(sim.get(), std::string(CONFIG_DIR));
window.show();
const int ret = application.exec();

View File

@@ -397,6 +397,14 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
def.movement.speedFormula = requireFormula(mMt["speed_formula"], file, mPath + ".speed_formula");
}
// Sensor
{
const std::string snsPath = elemPath + ".sensor";
const toml::table& snsTable = requireTable(mt["sensor"], file, snsPath);
toml::table& snsMt = const_cast<toml::table&>(snsTable);
def.sensor.sensorRangeFormula = requireFormula(snsMt["sensor_range_formula"], file, snsPath + ".sensor_range_formula");
}
// Loot
{
const std::string lPath = elemPath + ".loot";

View File

@@ -33,6 +33,11 @@ struct ShipMovement
Formula speedFormula; // REQ-SHP-STATS, REQ-SHP-MOVEMENT
};
struct ShipSensor
{
Formula sensorRangeFormula; // REQ-SHP-SENSOR, REQ-SHP-STATS
};
struct ShipCombat
{
Formula damageFormula;
@@ -69,6 +74,7 @@ struct ShipDef
ShipThreat threat;
ShipHealth health;
ShipMovement movement;
ShipSensor sensor;
ShipLoot loot;
// Role-specific sections. A ship is a combat ship if combat is present,

View File

@@ -797,6 +797,112 @@ bool BuildingSystem::isTileOccupied(QPoint tile) const
return m_tileOccupancy.count({tile.x(), tile.y()}) > 0;
}
std::optional<EntityId> BuildingSystem::findRotateInPlaceTarget(
BuildingType type, QPoint anchor, Rotation rot) const
{
const BuildingDef* def = findBuildingDef(type);
if (!def) { return std::nullopt; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rot);
if (mask.bodyCells.empty()) { return std::nullopt; }
// All body cells must be occupied by the same entity.
const QPoint firstAbs = anchor + mask.bodyCells[0];
const auto firstIt = m_tileOccupancy.find({firstAbs.x(), firstAbs.y()});
if (firstIt == m_tileOccupancy.end()) { return std::nullopt; }
const EntityId candidateId = firstIt->second;
for (const QPoint& rel : mask.bodyCells)
{
const QPoint abs = anchor + rel;
const auto it = m_tileOccupancy.find({abs.x(), abs.y()});
if (it == m_tileOccupancy.end() || it->second != candidateId)
{
return std::nullopt;
}
}
// Verify the candidate is the same building type with the same cell count.
for (const ConstructionSite& site : m_constructionQueue)
{
if (site.id != candidateId) { continue; }
if (site.type != type) { return std::nullopt; }
if (site.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
return candidateId;
}
for (const Building& b : m_buildings)
{
if (b.id != candidateId) { continue; }
if (b.type != type) { return std::nullopt; }
if (b.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
return candidateId;
}
return std::nullopt;
}
void BuildingSystem::rotateInPlace(EntityId id, Rotation newRotation)
{
// Construction site path — just update rotation; no ports to recompute.
for (ConstructionSite& site : m_constructionQueue)
{
if (site.id == id)
{
site.rotation = newRotation;
return;
}
}
// Operational building path.
for (Building& b : m_buildings)
{
if (b.id != id) { continue; }
b.rotation = newRotation;
const BuildingDef* def = findBuildingDef(b.type);
if (!def) { return; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, newRotation);
b.outputPorts.clear();
for (const Port& port : mask.outputPorts)
{
Port absPort;
absPort.tile = b.anchor + port.tile;
absPort.direction = port.direction;
b.outputPorts.push_back(absPort);
}
b.inputPorts = computeInputPorts(b);
// Re-register with BeltSystem (items on tile are discarded).
if (b.type == BuildingType::Belt)
{
m_belts.removeTile(b.anchor);
m_belts.placeBelt(b.anchor, newRotation);
}
else if (b.type == BuildingType::Splitter)
{
m_belts.removeTile(b.anchor);
assert(mask.outputPorts.size() >= 2);
m_belts.placeSplitter(b.anchor,
mask.outputPorts[0].direction,
mask.outputPorts[1].direction);
}
else if (b.type == BuildingType::TunnelEntry)
{
m_belts.removeTile(b.anchor);
m_belts.placeTunnelEntry(b.anchor, newRotation, m_config.world.tunnelMaxDistance);
}
else if (b.type == BuildingType::TunnelExit)
{
m_belts.removeTile(b.anchor);
m_belts.placeTunnelExit(b.anchor, newRotation);
}
return;
}
}
const Building* BuildingSystem::findNearestBuilding(QVector2D worldPos,
BuildingType type) const
{

View File

@@ -74,6 +74,18 @@ public:
std::vector<BeltTileInfo> allBeltTiles() const;
bool isTileOccupied(QPoint tile) const;
// Returns the entity id of the building or construction site whose footprint
// exactly coincides with the ghost (type, anchor, rot) and is of the same
// building type. Returns nullopt otherwise.
std::optional<EntityId> findRotateInPlaceTarget(BuildingType type,
QPoint anchor,
Rotation rot) const;
// Rotate an existing building or construction site to newRotation in place.
// For belt-type operational buildings, re-registers with BeltSystem (items
// currently on the tile are discarded by BeltSystem::removeTile).
void rotateInPlace(EntityId id, Rotation newRotation);
// Find nearest operational building of the given type; nullptr if none.
const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const;

View File

@@ -41,7 +41,6 @@ struct RepairTool
struct ThreatResponse
{
float engagementRange;
std::optional<EntityId> currentTarget;
};
@@ -78,7 +77,8 @@ struct Ship
QVector2D velocity;
float hp;
float maxHp;
float speedPerTick; // pre-evaluated from speedFormula / kTickRateHz
float speedPerTick; // pre-evaluated from speedFormula / kTickRateHz
float sensorRange; // pre-evaluated from sensorRangeFormula (REQ-SHP-SENSOR)
int level;
std::string schematicId;

View File

@@ -47,6 +47,7 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
ship.speedPerTick = static_cast<float>(
def->movement.speedFormula.evaluate(x))
/ static_cast<float>(kTickRateHz);
ship.sensorRange = static_cast<float>(def->sensor.sensorRangeFormula.evaluate(x));
ship.level = level;
ship.schematicId = schematicId;
ship.isEnemy = isEnemy;
@@ -61,9 +62,7 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D
w.cooldownTicks = 0.0f;
ship.weapon = w;
ThreatResponse tr;
tr.engagementRange = w.range;
ship.threatResponse = tr;
ship.threatResponse = ThreatResponse{};
if (!isEnemy)
{
@@ -241,7 +240,7 @@ void ShipSystem::tickThreatResponse(const BuildingSystem& buildings)
continue;
}
const float range = s.threatResponse->engagementRange;
const float range = s.sensorRange;
if (!s.isEnemy)
{
@@ -394,7 +393,6 @@ void ShipSystem::tickThreatResponse(const BuildingSystem& buildings)
void ShipSystem::tickRepairBehavior(BuildingSystem& buildings)
{
const std::vector<Building> allBuildings = buildings.allBuildings();
const float kPatrolRange = 3.0f; // scan radius relative to repair range
for (Ship& s : m_ships)
{
@@ -410,7 +408,7 @@ void ShipSystem::tickRepairBehavior(BuildingSystem& buildings)
for (const Ship& candidate : m_ships)
{
if (candidate.isEnemy
&& (candidate.position - s.position).length() <= repairRange)
&& (candidate.position - s.position).length() <= s.sensorRange)
{
enemyNearby = true;
break;
@@ -450,7 +448,7 @@ void ShipSystem::tickRepairBehavior(BuildingSystem& buildings)
{
s.repairBehavior->currentTarget = std::nullopt;
currentId = kInvalidEntityId;
float bestDist = repairRange * kPatrolRange;
float bestDist = s.sensorRange;
for (const Ship& candidate : m_ships)
{
@@ -640,8 +638,7 @@ void ShipSystem::tickScrapCollector(ScrapSystem& scraps, const BuildingSystem& b
else
{
// Scan for nearest scrap within sensor range.
const float sensorRange = collectRange * 5.0f;
float bestDist = sensorRange;
float bestDist = s.sensorRange;
std::optional<QVector2D> bestPos;
for (const Scrap& sc : scraps.allScraps())
{

View File

@@ -22,7 +22,7 @@
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
struct Fixture
@@ -439,3 +439,132 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
REQUIRE(s->intent.priority == 1);
REQUIRE(s->intent.target.x() < s->position.x());
}
// ---------------------------------------------------------------------------
// Sensor range — spawn
// ---------------------------------------------------------------------------
TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn", "[sensor]")
{
Fixture f;
// interceptor sensor_range_formula = "200" (test config); verify at level 1.
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
REQUIRE(f.ships.findShip(id)->sensorRange == Approx(200.0f));
}
// ---------------------------------------------------------------------------
// Sensor range — tickThreatResponse
// ---------------------------------------------------------------------------
TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]")
{
Fixture f;
// interceptor sensor_range = 200 (test config); enemy at 190 tiles.
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f),
/*isEnemy=*/true);
f.ships.clearMovementIntents();
f.ships.tickThreatResponse(f.buildings);
const Ship* player = f.ships.findShip(playerId);
REQUIRE(player->threatResponse->currentTarget == enemyId);
}
TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]")
{
Fixture f;
// interceptor sensor_range = 200 (test config); enemy at 210 tiles.
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true);
f.ships.clearMovementIntents();
f.ships.tickThreatResponse(f.buildings);
const Ship* player = f.ships.findShip(playerId);
REQUIRE_FALSE(player->threatResponse->currentTarget.has_value());
}
TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]")
{
Fixture f;
// interceptor sensor_range = 200 (test config); player at 210 tiles from enemy.
f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f),
/*isEnemy=*/true);
f.ships.clearMovementIntents();
f.ships.tickThreatResponse(f.buildings);
const Ship* enemy = f.ships.findShip(enemyId);
REQUIRE_FALSE(enemy->threatResponse->currentTarget.has_value());
}
// ---------------------------------------------------------------------------
// Sensor range — tickRepairBehavior
// ---------------------------------------------------------------------------
TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]")
{
Fixture f;
// repair_ship sensor_range = 250; enemy at 200 tiles.
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true);
f.ships.clearMovementIntents();
f.ships.tickRepairBehavior(f.buildings);
const Ship* repair = f.ships.findShip(repairId);
REQUIRE(repair->intent.priority == 2);
REQUIRE(repair->intent.target.x() < 0.0f); // retreating leftward
}
TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]")
{
Fixture f;
// repair_ship sensor_range = 250; enemy at 300 tiles.
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true);
f.ships.clearMovementIntents();
f.ships.tickRepairBehavior(f.buildings);
// Enemy outside sensor range → repair ship patrols rightward instead of retreating.
const Ship* repair = f.ships.findShip(repairId);
REQUIRE(repair->intent.target.x() > repair->position.x());
}
TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor range", "[sensor]")
{
Fixture f;
// repair_ship sensor_range = 250; damaged friendly at 300 tiles.
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f));
f.ships.forEach([friendlyId](Ship& s) {
if (s.id == friendlyId) { s.hp = s.maxHp * 0.5f; }
});
f.ships.clearMovementIntents();
f.ships.tickRepairBehavior(f.buildings);
REQUIRE_FALSE(f.ships.findShip(repairId)->repairBehavior->currentTarget.has_value());
}
// ---------------------------------------------------------------------------
// Sensor range — tickScrapCollector
// ---------------------------------------------------------------------------
TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]")
{
Fixture f;
// salvage_ship sensor_range = 250; scrap at 300 tiles.
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000);
f.ships.clearMovementIntents();
f.ships.tickScrapCollector(f.scraps, f.buildings);
const Ship* s = f.ships.findShip(shipId);
REQUIRE(s->scrapCollector->scrapTarget == std::nullopt);
REQUIRE(s->intent.target.x() > s->position.x()); // patrolling rightward
}

View File

@@ -144,7 +144,7 @@ static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg)
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter:

View File

@@ -24,7 +24,7 @@
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
static Item makeItem(const std::string& id)
@@ -535,3 +535,245 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
// No new production: inputs were consumed and not replenished.
REQUIRE_FALSE(b->production.has_value());
}
// ---------------------------------------------------------------------------
// findRotateInPlaceTarget
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is empty",
"[building][rotate-in-place]")
{
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; },
[](const std::string&, QVector2D) {},
rng);
REQUIRE_FALSE(
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::East).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a queued belt (same type, different rotation)",
"[building][rotate-in-place]")
{
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; },
[](const std::string&, QVector2D) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const std::optional<EntityId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::North);
REQUIRE(result.has_value());
REQUIRE(*result == id);
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a completed operational belt",
"[building][rotate-in-place]")
{
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; },
[](const std::string&, QVector2D) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(bs.allSites().empty());
const std::optional<EntityId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::South);
REQUIRE(result.has_value());
REQUIRE(*result == id);
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building type differs",
"[building][rotate-in-place]")
{
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; },
[](const std::string&, QVector2D) {},
rng);
bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
// Querying with Splitter at the same tile — type mismatch → nullopt.
REQUIRE_FALSE(
bs.findRotateInPlaceTarget(BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap",
"[building][rotate-in-place]")
{
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; },
[](const std::string&, QVector2D) {},
rng);
// Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1).
bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
// Ghost anchored at (1,0) would cover (1,0),(2,0),(1,1),(2,1):
// only (1,0) and (1,1) are occupied — not a full coincidence.
REQUIRE_FALSE(
bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(1, 0), Rotation::East).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-tile building with rotated ghost",
"[building][rotate-in-place]")
{
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; },
[](const std::string&, QVector2D) {},
rng);
// Smelter is a fully filled 2×2 footprint — rotating the ghost produces the
// same four body tiles, so findRotateInPlaceTarget must still return the id.
const EntityId id = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const std::optional<EntityId> result =
bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(0, 0), Rotation::North);
REQUIRE(result.has_value());
REQUIRE(*result == id);
}
// ---------------------------------------------------------------------------
// rotateInPlace
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a construction site",
"[building][rotate-in-place]")
{
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; },
[](const std::string&, QVector2D) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
REQUIRE(bs.findSite(id)->rotation == Rotation::East);
bs.rotateInPlace(id, Rotation::North);
REQUIRE(bs.findSite(id)->rotation == Rotation::North);
}
TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of a queued site",
"[building][rotate-in-place]")
{
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; },
[](const std::string&, QVector2D) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const Tick completesAt = bs.findSite(id)->completesAt;
REQUIRE(completesAt > 0);
bs.rotateInPlace(id, Rotation::South);
REQUIRE(bs.findSite(id)->completesAt == completesAt);
}
TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direction on an operational building",
"[building][rotate-in-place]")
{
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; },
[](const std::string&, QVector2D) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(bs.findBuilding(id) != nullptr);
const Building& before = *bs.findBuilding(id);
REQUIRE(before.outputPorts[0].direction == Rotation::East);
bs.rotateInPlace(id, Rotation::North);
const Building& after = *bs.findBuilding(id);
REQUIRE(after.rotation == Rotation::North);
REQUIRE(after.outputPorts[0].direction == Rotation::North);
}
TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSystem so it still accepts items",
"[building][rotate-in-place]")
{
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; },
[](const std::string&, QVector2D) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
bs.rotateInPlace(id, Rotation::North);
// Belt tile must still be registered after rotation — items can be placed on it.
REQUIRE(belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore")));
}

View File

@@ -17,7 +17,7 @@
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// Find the first ShipDef with a combat component.

View File

@@ -64,7 +64,7 @@ private:
TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[config]")
{
const std::string configDir = DOTA_FACTORY_CONFIG_DIR;
const std::string configDir = CONFIG_DIR;
const GameConfig cfg = ConfigLoader::loadFromDirectory(configDir);
// world.toml

View File

@@ -15,7 +15,7 @@
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// ---------------------------------------------------------------------------
@@ -58,8 +58,8 @@ TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]
REQUIRE(ship->weapon->damage == Approx(12.0f));
// attack_range_formula = "150"
REQUIRE(ship->weapon->range == Approx(150.0f));
// threatResponse.engagementRange mirrors weapon range
REQUIRE(ship->threatResponse->engagementRange == Approx(150.0f));
// sensor_range_formula = "200"
REQUIRE(ship->sensorRange == Approx(200.0f));
// cooldownTicks starts at 0
REQUIRE(ship->weapon->cooldownTicks == Approx(0.0f));
}
@@ -192,3 +192,4 @@ TEST_CASE("ShipSystem: despawn removes the ship", "[ship]")
ss.despawn(id);
REQUIRE(ss.findShip(id) == nullptr);
}

View File

@@ -14,7 +14,7 @@
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
static const ShipDef* findAvailableSchematic(const GameConfig& cfg)

View File

@@ -8,7 +8,7 @@
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// ---------------------------------------------------------------------------

View File

@@ -15,7 +15,7 @@
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// ---------------------------------------------------------------------------

View File

@@ -384,6 +384,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot);
bool anyOccupied = false;
for (const QPoint& relCell : parsed.bodyCells)
{
const QPoint worldCell = anchor + relCell;
@@ -401,8 +402,12 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
if (isShipDock && worldCell.x() < 0) { return false; }
if (!isShipDock && worldCell.x() >= 0) { return false; }
// Occupancy check
if (m_sim->buildings().isTileOccupied(worldCell)) { return false; }
if (m_sim->buildings().isTileOccupied(worldCell)) { anyOccupied = true; }
}
if (anyOccupied)
{
return m_sim->buildings().findRotateInPlaceTarget(type, anchor, rot).has_value();
}
return true;
}
@@ -481,9 +486,15 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; }
}
// Cost only applies to buildings that are genuinely new (not rotate-in-place).
int totalCost = 0;
for (const BlueprintBuilding& bb : bp.buildings)
{
if (m_sim->buildings().findRotateInPlaceTarget(
bb.type, center + bb.offset, bb.rotation).has_value())
{
continue;
}
const BuildingDef* def = findBuildingDef(bb.type);
if (def) { totalCost += def->cost; }
}
@@ -491,7 +502,16 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
for (const BlueprintBuilding& bb : bp.buildings)
{
const EntityId id = m_sim->tryPlaceBuilding(bb.type, center + bb.offset, bb.rotation);
const QPoint anchor = center + bb.offset;
const std::optional<EntityId> rotateTarget =
m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
if (rotateTarget.has_value())
{
m_sim->buildings().rotateInPlace(*rotateTarget, bb.rotation);
continue;
}
const EntityId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation);
if (id == kInvalidEntityId || bb.recipeId.empty()) { continue; }
if (bb.type == BuildingType::Shipyard)
@@ -511,16 +531,24 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
void GameWorldView::placeAtTile(QPoint tile)
{
if (!m_builderType.has_value())
{
return;
{
return;
}
const BuildingType type = *m_builderType;
if (!isValidPlacement(type, tile, m_ghostRotation))
{
return;
{
return;
}
const std::optional<EntityId> rotateTarget =
m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
if (rotateTarget.has_value())
{
m_sim->buildings().rotateInPlace(*rotateTarget, m_ghostRotation);
return;
}
if (type == BuildingType::Belt)
{
if (m_beltDragTiles.count(tile) > 0)
@@ -543,7 +571,18 @@ void GameWorldView::placeAtTile(QPoint tile)
{
if (!m_sim->buildings().isTileOccupied(tile))
{
m_sim->tryPlaceBuilding(type, tile, m_ghostRotation);
const EntityId id = m_sim->tryPlaceBuilding(type, tile, m_ghostRotation);
if (id != kInvalidEntityId)
{
if (type == BuildingType::TunnelEntry)
{
m_builderType = BuildingType::TunnelExit;
}
else if (type == BuildingType::TunnelExit)
{
m_builderType = BuildingType::TunnelEntry;
}
}
}
}
else
@@ -804,23 +843,7 @@ void GameWorldView::drawDebugSensorRanges(QPainter& painter)
m_visuals->ships.find(role);
if (it == m_visuals->ships.end()) { continue; }
float range = 0.0f;
if (ship.weapon.has_value())
{
range = ship.weapon->range;
}
else if (ship.repairTool.has_value())
{
range = ship.repairTool->range;
}
else if (ship.cargo.has_value())
{
range = ship.cargo->collectionRange * 5.0f;
}
else
{
continue;
}
const float range = ship.sensorRange;
const QPointF center = worldToWidget(ship.position);
const qreal radiusPx = static_cast<qreal>(range)