Compare commits

...

2 Commits

13 changed files with 352 additions and 267 deletions

View File

@@ -1,61 +1,9 @@
[[module]]
id = "armor_plate"
surface_mask = ["OO", "OO"]
materials = [{item = "iron_ingot", amount = 2}]
player_production_level = 1
production_time_seconds = 3
threat_cost = 2.0
fill_color = "#808080"
glyph = "A"
[module.health]
multiplied_hp_formula = "1.0 + 0.2 * x"
[[module]]
id = "sensor_booster"
surface_mask = ["O"]
materials = [{item = "circuit_board", amount = 1}]
player_production_level = 1
production_time_seconds = 2
threat_cost = 1.0
fill_color = "#40A0FF"
glyph = "S"
[module.sensor]
added_sensor_range_formula = "2 + x"
[[module]]
id = "weapon_upgrade"
surface_mask = ["OO"]
materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}]
player_production_level = 1
production_time_seconds = 4
threat_cost = 3.0
fill_color = "#FF4040"
glyph = "W"
[module.weapon]
multiplied_damage_formula = "1.0 + 0.15 * x"
[[module]]
id = "engine_booster"
surface_mask = ["O", "O"]
materials = [{item = "iron_ingot", amount = 2}]
player_production_level = 1
production_time_seconds = 3
threat_cost = 1.5
fill_color = "#40FF80"
glyph = "E"
[module.movement]
added_speed_formula = "0.5 * x"
[[module]]
id = "laser_cannon"
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
materials = [{item = "laser_cannon_module", amount = 1}]
player_production_level = 1
production_time_seconds = 5
production_time_seconds = 2
threat_cost = 5.0
fill_color = "#FF8040"
glyph = "L"
@@ -66,11 +14,11 @@ attack_range_formula = "5"
attack_rate_formula = "2.0"
[[module]]
id = "salvage_bay_module"
surface_mask = ["OO"]
materials = [{item = "iron_ingot", amount = 2}]
id = "salvager"
surface_mask = ["O"]
materials = [{item = "salvager_module", amount = 1}]
player_production_level = 1
production_time_seconds = 5
production_time_seconds = 2
threat_cost = 0.0
fill_color = "#AACC44"
glyph = "Sv"
@@ -81,11 +29,11 @@ cargo_capacity_formula = "10"
collection_rate_formula = "0.5"
[[module]]
id = "repair_tool_module"
id = "repair_tool"
surface_mask = ["O"]
materials = [{item = "circuit_board", amount = 2}]
materials = [{item = "repair_tool_module", amount = 1}]
player_production_level = 1
production_time_seconds = 5
production_time_seconds = 2
threat_cost = 0.0
fill_color = "#66CCFF"
glyph = "Rp"

View File

@@ -29,10 +29,38 @@ duration_seconds = 2.5
[[recipe]]
id = "circuit_board"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 3}, {item = "copper_ingot", amount = 2}]
inputs = [{item = "iron_ingot", amount = 1}, {item = "copper_ingot", amount = 2}]
outputs = [{item = "circuit_board", amount = 1}]
duration_seconds = 5.0
[[recipe]]
id = "drone_hull"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 1}]
outputs = [{item = "drone_hull", amount = 1}]
duration_seconds = 12.0
[[recipe]]
id = "laser_cannon_module"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 2}, {item = "circuit_board", amount = 1}]
outputs = [{item = "laser_cannon_module", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "salvager_module"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}]
outputs = [{item = "salvager_module", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "repair_tool_module"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 2}]
outputs = [{item = "repair_tool_module", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "building_blocks"
building = "assembler"

View File

@@ -1,13 +1,13 @@
[[ship]]
id = "fighter"
id = "drone"
available_from_start = true
layout = ["XOX", "OOO", "XOX"]
default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
layout = ["O"]
default_modules = [{type = "laser_cannon", x = 0, y = 0, rotation = "east"}]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
materials = [{item = "drone_hull", amount = 1}]
player_production_level = 1
production_time_seconds = 10
production_time_seconds = 5
[ship.threat]
cost_formula = "10"
@@ -16,139 +16,14 @@ cost_formula = "10"
hp_formula = "3"
[ship.movement]
speed_formula = "4"
main_acceleration_formula = "8"
maneuvering_acceleration_formula = "4"
angular_acceleration_formula = "12.56"
max_rotation_speed_formula = "6.28"
speed_formula = "4"
main_acceleration_formula = "8"
maneuvering_acceleration_formula = "4"
angular_acceleration_formula = "12.56"
max_rotation_speed_formula = "6.28"
[ship.sensor]
sensor_range_formula = "15"
[ship.loot]
scrap_drop = 2
[[ship]]
id = "sniper"
available_from_start = true
layout = ["XOOX", "OOOO", "XOOX"]
default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
player_production_level = 1
production_time_seconds = 10
[ship.threat]
cost_formula = "10"
[ship.health]
hp_formula = "8"
[ship.movement]
speed_formula = "1"
main_acceleration_formula = "1.5"
maneuvering_acceleration_formula = "0.5"
angular_acceleration_formula = "9.42"
max_rotation_speed_formula = "3.14"
[ship.sensor]
sensor_range_formula = "25"
[ship.loot]
scrap_drop = 2
[[ship]]
id = "gunship"
available_from_start = true
layout = ["XOOOX", "OOOOO", "OOOOO", "XOOOX"]
default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
player_production_level = 1
production_time_seconds = 10
[ship.threat]
cost_formula = "10"
[ship.health]
hp_formula = "12"
[ship.movement]
speed_formula = "1"
main_acceleration_formula = "1.5"
maneuvering_acceleration_formula = "0.5"
angular_acceleration_formula = "15.7"
max_rotation_speed_formula = "3.14"
[ship.sensor]
sensor_range_formula = "20"
[ship.loot]
scrap_drop = 2
[[ship]]
id = "salvage_ship"
available_from_start = true
layout = ["OOO", "OOO"]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 4}]
player_production_level = 3
production_time_seconds = 10
[ship.threat]
cost_formula = "0"
[ship.health]
hp_formula = "40 + 4*x"
[ship.movement]
speed_formula = "110"
main_acceleration_formula = "220"
maneuvering_acceleration_formula = "110"
angular_acceleration_formula = "12.56"
max_rotation_speed_formula = "6.28"
[ship.sensor]
sensor_range_formula = "250"
[ship.loot]
scrap_drop = 2
[[ship]]
id = "repair_ship"
available_from_start = false
layout = ["XOX", "OOO", "XOX"]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}]
player_production_level = 3
production_time_seconds = 15
[ship.threat]
cost_formula = "0"
[ship.health]
hp_formula = "60 + 5*x"
[ship.movement]
speed_formula = "130"
main_acceleration_formula = "260"
maneuvering_acceleration_formula = "130"
angular_acceleration_formula = "12.56"
max_rotation_speed_formula = "6.28"
[ship.sensor]
sensor_range_formula = "250"
[ship.loot]
scrap_drop = 2

View File

@@ -138,32 +138,32 @@ outline = "#302810"
fill = "#7a7268"
outline = "#201a14"
[items.drone_hull]
fill = "#1b1b1b"
outline = "#1402b3"
[items.laser_cannon_module]
fill = "#691313"
outline = "#f3ff4f"
[items.salvager_module]
fill = "#b2cfdd"
outline = "#236137"
[items.repair_tool_module]
fill = "#2e9ba3"
outline = "#689275"
# -----------------------------------------------------------------------------
# Ships
#
# Ships are drawn as oriented triangles/arrows. Color is keyed to schematic id.
# -----------------------------------------------------------------------------
[ships.fighter]
[ships.drone]
fill = "#3366ff"
outline = "#ffffff"
[ships.sniper]
fill = "#3366ff"
outline = "#ffffff"
[ships.gunship]
fill = "#3366ff"
outline = "#ffffff"
[ships.salvage_ship]
fill = "#33cc66"
outline = "#ffffff"
[ships.repair_ship]
fill = "#66ccff"
outline = "#ffffff"
# -----------------------------------------------------------------------------
# Laser beams (REQ-SHP-FIRING-BEAM)
# -----------------------------------------------------------------------------

View File

@@ -53,7 +53,7 @@ attack_range_formula = "5"
attack_rate_formula = "2.0"
[[module]]
id = "salvage_bay_module"
id = "salvager"
surface_mask = ["OO"]
materials = [{item = "iron_ingot", amount = 2}]
player_production_level = 1
@@ -68,7 +68,7 @@ cargo_capacity_formula = "10"
collection_rate_formula = "0.5"
[[module]]
id = "repair_tool_module"
id = "repair_tool"
surface_mask = ["O"]
materials = [{item = "circuit_board", amount = 2}]
player_production_level = 1

View File

@@ -7,6 +7,7 @@
#include "BuildingType.h"
#include "Rotation.h"
#include "ShipLayout.h"
namespace
{
@@ -50,6 +51,20 @@ std::string serialize(const std::vector<Blueprint>& blueprints)
bldTbl.insert("offset_x", static_cast<int64_t>(b.offset.x()));
bldTbl.insert("offset_y", static_cast<int64_t>(b.offset.y()));
bldTbl.insert("recipe_id", b.recipeId);
if (b.shipLayout.has_value())
{
toml::array modArr;
for (const PlacedModule& pm : b.shipLayout->placedModules)
{
toml::table modTbl;
modTbl.insert("type", pm.moduleId);
modTbl.insert("x", static_cast<int64_t>(pm.position.x()));
modTbl.insert("y", static_cast<int64_t>(pm.position.y()));
modTbl.insert("rotation", rotationToString(pm.rotation));
modArr.push_back(std::move(modTbl));
}
bldTbl.insert("modules", std::move(modArr));
}
bldArr.push_back(std::move(bldTbl));
}
@@ -123,6 +138,27 @@ std::vector<Blueprint> deserialize(const std::string& tomlContent)
bb.offset.setX(static_cast<int>((*bldTbl)["offset_x"].value_or(int64_t{0})));
bb.offset.setY(static_cast<int>((*bldTbl)["offset_y"].value_or(int64_t{0})));
bb.recipeId = (*bldTbl)["recipe_id"].value_or(std::string{});
const toml::array* modArr = (*bldTbl)["modules"].as_array();
if (modArr)
{
ShipLayoutConfig layout;
for (std::size_t k = 0; k < modArr->size(); ++k)
{
const toml::table* modTbl = (*modArr)[k].as_table();
if (!modTbl) { continue; }
const std::optional<std::string> modType = (*modTbl)["type"].value<std::string>();
const std::optional<int64_t> x = (*modTbl)["x"].value<int64_t>();
const std::optional<int64_t> y = (*modTbl)["y"].value<int64_t>();
const std::optional<std::string> rotStr = (*modTbl)["rotation"].value<std::string>();
if (!modType || !x || !y || !rotStr) { continue; }
PlacedModule pm;
pm.moduleId = *modType;
pm.position = QPoint(static_cast<int>(*x), static_cast<int>(*y));
pm.rotation = parseRotation(*rotStr);
layout.placedModules.push_back(std::move(pm));
}
bb.shipLayout = std::move(layout);
}
bp.buildings.push_back(std::move(bb));
}
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
@@ -8,6 +9,7 @@
#include "BuildingType.h"
#include "Rotation.h"
#include "ShipLayout.h"
struct BlueprintBuilding
{
@@ -15,6 +17,7 @@ struct BlueprintBuilding
Rotation rotation;
QPoint offset; // tile offset from bounding-box center (floor for even sizes)
std::string recipeId; // empty = none selected
std::optional<ShipLayoutConfig> shipLayout;
};
struct Blueprint

View File

@@ -363,7 +363,7 @@ TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly shi
"[behavior]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f));
@@ -381,7 +381,7 @@ TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
"[behavior]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
@@ -399,7 +399,7 @@ TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, repairLayout);
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
@@ -425,7 +425,7 @@ TEST_CASE("BehaviorSystem: rt.currentTarget is set to preferred target when in r
"[behavior]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
@@ -447,7 +447,7 @@ TEST_CASE("BehaviorSystem: repair module falls back to in-range target when pref
"[behavior]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
// preferred: within sensor range (200) but beyond repair range (80)
@@ -476,7 +476,7 @@ TEST_CASE("BehaviorSystem: repair module falls back when preferred target is ful
"[behavior]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
@@ -500,7 +500,7 @@ TEST_CASE("BehaviorSystem: repair module falls back when preferred target is des
"[behavior]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
@@ -523,7 +523,7 @@ TEST_CASE("BehaviorSystem: rt.currentTarget is cleared when no repairable target
"[behavior]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
// friendly is beyond repair range (80) but within sensor range (200)
@@ -545,7 +545,7 @@ TEST_CASE("BehaviorSystem: two repair modules both heal preferred target additiv
"[behavior]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
const entt::entity targetA = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
@@ -573,7 +573,7 @@ TEST_CASE("BehaviorSystem: two repair modules both fall back and heal same targe
"[behavior]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
@@ -628,7 +628,7 @@ TEST_CASE("BehaviorSystem: tickRepairTools does not crash when owner lacks Repai
TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]")
{
Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module");
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f),
false, salvageLayout);
@@ -645,7 +645,7 @@ TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[b
TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]")
{
Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module");
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f),
false, salvageLayout);
const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
@@ -676,7 +676,7 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
}
REQUIRE(f.buildings.findBuilding(bayId) != nullptr);
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module");
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f),
false, salvageLayout);
{
@@ -714,7 +714,7 @@ TEST_CASE("BehaviorSystem: salvage module does not collect scrap beyond its coll
{
// collection_range_formula = "50"; scrap at distance 55 must not be collected.
Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module");
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f),
false, salvageLayout);
f.scraps.spawn(QVector2D(55.0f, 0.0f), 1, 100000);
@@ -730,7 +730,7 @@ TEST_CASE("BehaviorSystem: salvage module collects scrap within its collection r
{
// collection_range_formula = "50"; scrap at distance 45 must be collected.
Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module");
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f),
false, salvageLayout);
f.scraps.spawn(QVector2D(45.0f, 0.0f), 1, 100000);
@@ -748,7 +748,7 @@ TEST_CASE("BehaviorSystem: salvage module collects scrap within its collection r
TEST_CASE("BehaviorSystem: salvage collection sets cooldown on module", "[behavior]")
{
Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module");
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f),
false, salvageLayout);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
@@ -765,7 +765,7 @@ TEST_CASE("BehaviorSystem: salvage collection sets cooldown on module", "[behavi
TEST_CASE("BehaviorSystem: salvage module on cooldown does not collect scrap", "[behavior]")
{
Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module");
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f),
false, salvageLayout);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
@@ -781,7 +781,7 @@ TEST_CASE("BehaviorSystem: salvage module on cooldown does not collect scrap", "
TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires", "[behavior]")
{
Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module");
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f),
false, salvageLayout);
const entt::entity sc = firstSalvageChild(f.admin, ship);
@@ -809,7 +809,7 @@ TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires"
TEST_CASE("BehaviorSystem: two salvage modules collect independently in same tick", "[behavior]")
{
Fixture f;
const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvage_bay_module");
const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f),
false, salvageLayout);
@@ -827,7 +827,7 @@ TEST_CASE("BehaviorSystem: second salvage module does not collect when first mod
{
// One module on cooldown, one ready: only the ready module collects.
Fixture f;
const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvage_bay_module");
const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f),
false, salvageLayout);
@@ -913,7 +913,7 @@ TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[
TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true);
@@ -928,7 +928,7 @@ TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[
TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true);
@@ -942,7 +942,7 @@ TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor ra
TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor range", "[sensor]")
{
Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
false, repairLayout);
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f));
@@ -961,7 +961,7 @@ TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor
TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]")
{
Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module");
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f),
false, salvageLayout);
f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000);

View File

@@ -1,5 +1,6 @@
#include "catch.hpp"
#include <optional>
#include <stdexcept>
#include <vector>
@@ -10,6 +11,7 @@
#include "BlueprintSerializer.h"
#include "BuildingType.h"
#include "Rotation.h"
#include "ShipLayout.h"
namespace
{
@@ -26,13 +28,15 @@ Blueprint makeBlueprintWith(const std::vector<BlueprintBuilding>& buildings,
BlueprintBuilding makeBuilding(BuildingType type,
Rotation rotation,
QPoint offset,
std::string recipeId = "")
std::string recipeId = "",
std::optional<ShipLayoutConfig> shipLayout = std::nullopt)
{
BlueprintBuilding b;
b.type = type;
b.rotation = rotation;
b.offset = offset;
b.recipeId = std::move(recipeId);
b.type = type;
b.rotation = rotation;
b.offset = offset;
b.recipeId = std::move(recipeId);
b.shipLayout = std::move(shipLayout);
return b;
}
@@ -185,3 +189,77 @@ TEST_CASE("BlueprintSerializer: unknown building type throws", "[serializer]")
BlueprintSerializer::deserialize(badToml),
std::runtime_error);
}
// ---------------------------------------------------------------------------
// Ship layout round-trip through TOML serialization
// ---------------------------------------------------------------------------
TEST_CASE("BlueprintSerializer: shipyard with modules round-trips ship layout", "[serializer]")
{
ShipLayoutConfig layout;
PlacedModule pmA;
pmA.moduleId = "laser_cannon";
pmA.position = QPoint(1, 1);
pmA.rotation = Rotation::East;
PlacedModule pmB;
pmB.moduleId = "sensor_booster";
pmB.position = QPoint(0, 1);
pmB.rotation = Rotation::South;
layout.placedModules = { pmA, pmB };
const Blueprint original = makeBlueprintWith(
{ makeBuilding(BuildingType::Shipyard, Rotation::East, QPoint(0, 0),
"interceptor", layout) },
"Gunship");
const std::string toml = BlueprintSerializer::serialize({ original });
const std::vector<Blueprint> loaded = BlueprintSerializer::deserialize(toml);
REQUIRE(loaded.size() == 1);
REQUIRE(loaded[0].buildings.size() == 1);
const BlueprintBuilding& b = loaded[0].buildings[0];
REQUIRE(b.shipLayout.has_value());
REQUIRE(b.shipLayout->placedModules.size() == 2);
REQUIRE(b.shipLayout->placedModules[0].moduleId == "laser_cannon");
REQUIRE(b.shipLayout->placedModules[0].position == QPoint(1, 1));
REQUIRE(b.shipLayout->placedModules[0].rotation == Rotation::East);
REQUIRE(b.shipLayout->placedModules[1].moduleId == "sensor_booster");
REQUIRE(b.shipLayout->placedModules[1].position == QPoint(0, 1));
REQUIRE(b.shipLayout->placedModules[1].rotation == Rotation::South);
}
TEST_CASE("BlueprintSerializer: building without ship layout round-trips with nullopt", "[serializer]")
{
// A shipyard with no shipLayout set — no modules key should be emitted,
// and deserialization must leave shipLayout as nullopt.
const Blueprint original = makeBlueprintWith(
{ makeBuilding(BuildingType::Shipyard, Rotation::East, QPoint(0, 0), "interceptor") },
"EmptyYard");
const std::string toml = BlueprintSerializer::serialize({ original });
const std::vector<Blueprint> loaded = BlueprintSerializer::deserialize(toml);
REQUIRE(loaded.size() == 1);
REQUIRE(loaded[0].buildings.size() == 1);
REQUIRE_FALSE(loaded[0].buildings[0].shipLayout.has_value());
}
TEST_CASE("BlueprintSerializer: shipyard with empty modules list round-trips", "[serializer]")
{
// An explicitly empty layout (player cleared all modules) should round-trip
// as a present but empty ShipLayoutConfig, distinct from nullopt.
ShipLayoutConfig emptyLayout;
const Blueprint original = makeBlueprintWith(
{ makeBuilding(BuildingType::Shipyard, Rotation::East, QPoint(0, 0),
"interceptor", emptyLayout) },
"EmptyLayout");
const std::string toml = BlueprintSerializer::serialize({ original });
const std::vector<Blueprint> loaded = BlueprintSerializer::deserialize(toml);
REQUIRE(loaded.size() == 1);
REQUIRE(loaded[0].buildings.size() == 1);
REQUIRE(loaded[0].buildings[0].shipLayout.has_value());
REQUIRE(loaded[0].buildings[0].shipLayout->placedModules.empty());
}

View File

@@ -14,6 +14,7 @@
#include "ConfigLoader.h"
#include "BuildingId.h"
#include "Rotation.h"
#include "ShipLayout.h"
#include "Simulation.h"
#include "SurfaceMask.h"
#include "Tick.h"
@@ -61,6 +62,7 @@ struct BuildingSpec
BuildingType type;
Rotation rotation;
std::string recipeId; // empty = none
std::optional<ShipLayoutConfig> shipLayout;
};
static Blueprint buildBlueprint(const std::vector<BuildingSpec>& specs)
@@ -83,10 +85,11 @@ static Blueprint buildBlueprint(const std::vector<BuildingSpec>& specs)
for (const BuildingSpec& s : specs)
{
BlueprintBuilding bb;
bb.type = s.type;
bb.rotation = s.rotation;
bb.offset = s.anchor - center;
bb.recipeId = s.recipeId;
bb.type = s.type;
bb.rotation = s.rotation;
bb.offset = s.anchor - center;
bb.recipeId = s.recipeId;
bb.shipLayout = s.shipLayout;
bp.buildings.push_back(bb);
}
return bp;
@@ -665,3 +668,108 @@ TEST_CASE("Blueprint placement: repair_ship schematic is locked at game start",
Simulation sim(loadConfig());
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
}
// ---------------------------------------------------------------------------
// Ship layout capture and re-application via blueprints
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint: shipLayout is captured in BlueprintBuilding", "[blueprint]")
{
// sensor_booster has surface_mask ["O"] — fits a single buildable cell.
// The interceptor layout ["XOX","OOO","XOX"] has a buildable cell at (1, 0).
ShipLayoutConfig layout;
PlacedModule pm;
pm.moduleId = "sensor_booster";
pm.position = QPoint(1, 0);
pm.rotation = Rotation::North;
layout.placedModules.push_back(pm);
const BuildingSpec spec{
QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Shipyard, Rotation::East,
"interceptor", layout
};
const Blueprint bp = buildBlueprint({ spec });
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].shipLayout.has_value());
REQUIRE(bp.buildings[0].shipLayout->placedModules.size() == 1);
REQUIRE(bp.buildings[0].shipLayout->placedModules[0].moduleId == "sensor_booster");
REQUIRE(bp.buildings[0].shipLayout->placedModules[0].position == QPoint(1, 0));
REQUIRE(bp.buildings[0].shipLayout->placedModules[0].rotation == Rotation::North);
}
TEST_CASE("Blueprint: building without layout has nullopt shipLayout", "[blueprint]")
{
const BuildingSpec spec{
QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Shipyard, Rotation::East, "interceptor"
};
const Blueprint bp = buildBlueprint({ spec });
REQUIRE(bp.buildings.size() == 1);
REQUIRE_FALSE(bp.buildings[0].shipLayout.has_value());
}
TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout", "[blueprint]")
{
Simulation sim(loadConfig());
// Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East:
// A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles.
// S-tile at (0,0) and (0,1) — x >= 0, valid space tiles.
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
ShipLayoutConfig layout;
PlacedModule pm;
pm.moduleId = "laser_cannon";
pm.position = QPoint(1, 1);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(id, layout);
const ConstructionSite* site = sim.buildings().findSite(id);
REQUIRE(site != nullptr);
REQUIRE(site->shipLayout.has_value());
REQUIRE(site->shipLayout->placedModules.size() == 1);
REQUIRE(site->shipLayout->placedModules[0].moduleId == "laser_cannon");
}
TEST_CASE("Blueprint placement: ship layout transfers to building after construction completes",
"[blueprint]")
{
Simulation sim(loadConfig());
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
ShipLayoutConfig layout;
PlacedModule pm;
pm.moduleId = "sensor_booster";
pm.position = QPoint(1, 0);
pm.rotation = Rotation::North;
layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(id, layout);
// Shipyard construction_time_seconds = 30 in the test config.
double constructionTime = 0.0;
for (const BuildingDef& def : sim.config().buildings.buildings)
{
if (def.type == BuildingType::Shipyard) { constructionTime = def.constructionTimeSeconds; break; }
}
REQUIRE(constructionTime > 0.0);
for (int i = 0; i <= static_cast<int>(secondsToTicks(constructionTime)); ++i)
{
sim.tick();
}
const Building* b = sim.buildings().findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(b->shipLayout.has_value());
REQUIRE(b->shipLayout->placedModules.size() == 1);
REQUIRE(b->shipLayout->placedModules[0].moduleId == "sensor_booster");
REQUIRE(b->shipLayout->placedModules[0].position == QPoint(1, 0));
REQUIRE(b->shipLayout->placedModules[0].rotation == Rotation::North);
}

View File

@@ -147,7 +147,7 @@ TEST_CASE("ShipSystem: interceptor level 0 maxSpeedPerTick matches formula / kTi
}
// ---------------------------------------------------------------------------
// Salvage ship (spawned with salvage_bay_module layout)
// Salvage ship (spawned with salvager layout)
// ---------------------------------------------------------------------------
TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child and behavior, no weapon",
@@ -157,7 +157,7 @@ TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child an
const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin);
const ShipLayoutConfig layout = makeSingleModuleLayout("salvage_bay_module");
const ShipLayoutConfig layout = makeSingleModuleLayout("salvager");
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
REQUIRE(admin.isValid(firstSalvageChild(admin, e)));
@@ -172,10 +172,10 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin);
const ShipLayoutConfig layout = makeSingleModuleLayout("salvage_bay_module");
const ShipLayoutConfig layout = makeSingleModuleLayout("salvager");
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
// salvage_bay_module: cargo_capacity_formula = "10", collection_range_formula = "50"
// salvager: cargo_capacity_formula = "10", collection_range_formula = "50"
const entt::entity sc = firstSalvageChild(admin, e);
REQUIRE(admin.isValid(sc));
REQUIRE(admin.get<SalvageCargoComponent>(sc).capacity == 10);
@@ -186,7 +186,7 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
}
// ---------------------------------------------------------------------------
// Repair ship (spawned with repair_tool_module layout)
// Repair ship (spawned with repair_tool layout)
// ---------------------------------------------------------------------------
TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and behavior, no weapon",
@@ -196,7 +196,7 @@ TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and
const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin);
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool");
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
REQUIRE(admin.isValid(firstRepairChild(admin, e)));
@@ -211,10 +211,10 @@ TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas",
const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin);
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool_module");
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool");
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
// repair_tool_module: repair_rate_formula = "5 + x" at x=1 → 6 / kTickRateHz
// repair_tool: repair_rate_formula = "5 + x" at x=1 → 6 / kTickRateHz
const float expectedRate = 6.0f / static_cast<float>(kTickRateHz);
const entt::entity rc = firstRepairChild(admin, e);
REQUIRE(admin.isValid(rc));
@@ -235,7 +235,7 @@ TEST_CASE("ShipSystem: spawned ships are valid entities", "[ship]")
ShipSystem ss(cfg, admin);
const entt::entity e1 = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module");
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity e2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f), false, salvageLayout);
REQUIRE(admin.isValid(e1));

View File

@@ -182,10 +182,11 @@ Blueprint BlueprintPanel::createBlueprintFromSelection() const
for (const Entry& e : entries)
{
BlueprintBuilding bb;
bb.type = e.building->type;
bb.rotation = e.building->rotation;
bb.offset = e.building->anchor - center;
bb.recipeId = e.building->recipeId;
bb.type = e.building->type;
bb.rotation = e.building->rotation;
bb.offset = e.building->anchor - center;
bb.recipeId = e.building->recipeId;
bb.shipLayout = e.building->shipLayout;
bp.buildings.push_back(bb);
}
return bp;

View File

@@ -504,18 +504,26 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
}
const BuildingId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation);
if (id == kInvalidBuildingId || bb.recipeId.empty()) { continue; }
if (id == kInvalidBuildingId) { continue; }
if (bb.type == BuildingType::Shipyard)
if (!bb.recipeId.empty())
{
if (m_sim->isSchematicUnlocked(bb.recipeId))
if (bb.type == BuildingType::Shipyard)
{
if (m_sim->isSchematicUnlocked(bb.recipeId))
{
m_sim->buildings().setRecipe(id, bb.recipeId);
}
}
else
{
m_sim->buildings().setRecipe(id, bb.recipeId);
}
}
else
if (bb.shipLayout.has_value())
{
m_sim->buildings().setRecipe(id, bb.recipeId);
m_sim->buildings().setShipLayout(id, *bb.shipLayout);
}
}
}