Compare commits
7 Commits
0.1.2
...
44c3c83080
| Author | SHA1 | Date | |
|---|---|---|---|
| 44c3c83080 | |||
| 410c9646b8 | |||
| 17ef87dec2 | |||
| 19ed70ca92 | |||
| 2b993af08c | |||
| c042f8e38c | |||
| 3148c9154c |
@@ -81,6 +81,7 @@ id = "salvage_bay"
|
||||
cost = 25
|
||||
player_placeable = true
|
||||
construction_time_seconds = 1
|
||||
output_buffer_capacity = 20
|
||||
surface_mask = [
|
||||
"<AAS",
|
||||
" AAS",
|
||||
|
||||
@@ -83,6 +83,7 @@ id = "salvage_bay"
|
||||
cost = 25
|
||||
player_placeable = true
|
||||
construction_time_seconds = 15
|
||||
output_buffer_capacity = 20
|
||||
surface_mask = [
|
||||
"SAA",
|
||||
"SAA>",
|
||||
|
||||
@@ -120,7 +120,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces the selected output item at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE).
|
||||
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one higher-level intermediate product per cycle via weighted random pick. The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE); the pool of eligible outputs is restricted to implicitly unlocked item types (REQ-LOCK-REPROCESSING-POOL). The output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
|
||||
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration.
|
||||
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. Scrap delivered here is placed onto connected output belts.
|
||||
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has an output buffer whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed onto connected output belts.
|
||||
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED).
|
||||
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Each output can optionally have a filter (a list of item types), configurable via the selected building panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
|
||||
- An item matching only one output's filter is routed to that output.
|
||||
@@ -313,13 +313,13 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
|
||||
In addition to the conditions above, a schematic is included in the eligible drop pool only when every prerequisite in its optional `unlock_requires` list is currently satisfied (REQ-LOCK-PREREQ). Because the pool is rebuilt for each drop, a schematic gated behind prerequisites first appears only after all of its prerequisites have themselves been unlocked.
|
||||
|
||||
Each option in the dialog displays: the schematic name (ship `display_name` from `ships.toml`, module `id` from `modules.toml`, or the output item type for assembler recipes) and the schematic type (ship, module, or assembler recipe). The artifact option (if present) is displayed as a distinct entry with the name "Artifact".
|
||||
Each option in the dialog displays: the schematic name (ship `id` from `ships.toml`, module `id` from `modules.toml`, or assembler recipe `id` from `recipes.toml`) and the schematic type (ship, module, or assembler recipe). The artifact option (if present) is displayed as a distinct entry with the name "Artifact".
|
||||
|
||||
Each option additionally displays a vertical list of item names labeled "Unlocks recipes for:", showing which recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the output items of miner recipes and assembler recipes (without `unlock_at_station_level`) that are not currently implicitly unlocked but would become so after applying this option's effect:
|
||||
Each option additionally displays a vertical list of recipe names labeled "Unlocks recipes:", showing which miner and assembler recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the miner recipes and assembler recipes (without `unlock_at_station_level`) that are not currently implicitly unlocked but would become so after applying this option's effect:
|
||||
- For a ship or module schematic, its `materials` are added to the base set per REQ-LOCK-IMPLICIT step 1a before recomputation.
|
||||
- For an assembler recipe schematic, its output item is added to the base set per REQ-LOCK-IMPLICIT step 1b before recomputation.
|
||||
|
||||
Item names are deduplicated and sorted alphabetically. If no recipes would be newly unlocked, the list shows "None".
|
||||
Each recipe is listed by its `id` (using the same display convention as the assembler recipe-selection dialog), sorted alphabetically. Hovering a recipe in this list displays the recipe info tooltip described for a recipe in REQ-UI-SELECT-TOOLTIP (the recipe name; the name and quantity of each input item; the completion time; and the name and quantity of the produced output item). If no recipes would be newly unlocked, the list shows "None".
|
||||
|
||||
The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no schematic is applied. Otherwise, the selected schematic is applied and the dialog closes:
|
||||
|
||||
@@ -471,7 +471,7 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
|
||||
### Blueprint Panel
|
||||
|
||||
- REQ-UI-BLUEPRINT-PANEL: The blueprint panel is shown to the right of the build button grid. It contains, from top to bottom: a "Create Blueprint" button, and a list of blueprint entries (one per saved blueprint, in creation order).
|
||||
- REQ-UI-BLUEPRINT-PANEL: The blueprint panel is shown to the right of the build button grid. It contains, from top to bottom: a "Create Blueprint" button, and a list of blueprint entries (one per saved blueprint, in creation order). The panel has no Save or Load buttons; blueprints are persisted automatically (REQ-UI-BLUEPRINT-SAVE) and restored at startup (REQ-UI-BLUEPRINT-LOAD).
|
||||
|
||||
- REQ-UI-BLUEPRINT-CREATE: The "Create Blueprint" button is enabled only when at least one player-placeable building (i.e. a building with a button in the build button grid) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition. When clicked, a modal dialog appears prompting the player to enter a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, and appends its button to the blueprint list.
|
||||
|
||||
@@ -485,9 +485,9 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
|
||||
- REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint entry immediately removes that blueprint from the list. If the deleted blueprint was active in blueprint placement mode, that mode is exited.
|
||||
|
||||
- REQ-UI-BLUEPRINT-SAVE: A "Save" button is shown at the bottom of the blueprint panel. Clicking it serializes all current blueprints to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. If writing fails, a modal error dialog is shown describing the failure.
|
||||
- REQ-UI-BLUEPRINT-SAVE: On application shutdown, all current blueprints are serialized to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. Write errors are silently ignored on shutdown (no button, no dialog).
|
||||
|
||||
- REQ-UI-BLUEPRINT-LOAD: A "Load" button is shown at the bottom of the blueprint panel, to the right of the "Save" button. Clicking it shows a confirmation dialog ("Load blueprints? This will replace all current blueprints.") with Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm reads `blueprints.toml` from the same directory as the application executable, replaces all current blueprints with those from the file (in the order they appear in the file), and exits any active blueprint-related mode (blueprint placement mode, delete mode). If the file does not exist or cannot be parsed, a modal error dialog is shown describing the failure and the current blueprint list is left unchanged.
|
||||
- REQ-UI-BLUEPRINT-LOAD: At application startup, blueprints are loaded from `blueprints.toml` in the same directory as the application executable, populating the blueprint list (in the order they appear in the file). If the file does not exist, the blueprint list starts empty with no error. If the file exists but cannot be parsed (malformed TOML), a modal error dialog describes the failure and the blueprint list starts empty. There is no Load button and no runtime reload.
|
||||
|
||||
## Balancing Tool
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -18,6 +19,10 @@ struct BuildingDef
|
||||
// Stored as raw strings here; parsing into per-cell tiles + output ports
|
||||
// happens when buildings are placed, not at load time.
|
||||
std::vector<std::string> surfaceMask;
|
||||
|
||||
// Output-buffer holding size for buildings without a recipe-driven buffer.
|
||||
// Only the Salvage Bay sets this (REQ-BLD-SALVAGE-BAY).
|
||||
std::optional<int> outputBufferCapacity;
|
||||
};
|
||||
|
||||
struct BuildingsConfig
|
||||
|
||||
@@ -337,6 +337,12 @@ BuildingsConfig ConfigLoader::loadBuildings(const std::string& path)
|
||||
def.constructionTimeSeconds = requireDouble(mt["construction_time_seconds"], file, elemPath + ".construction_time_seconds");
|
||||
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
|
||||
|
||||
if (mt.contains("output_buffer_capacity"))
|
||||
{
|
||||
def.outputBufferCapacity = static_cast<int>(
|
||||
requireInt(mt["output_buffer_capacity"], file, elemPath + ".output_buffer_capacity"));
|
||||
}
|
||||
|
||||
const std::optional<BuildingType> parsedType = parseBuildingType(def.id);
|
||||
if (!parsedType)
|
||||
{
|
||||
|
||||
@@ -20,8 +20,8 @@ struct SchematicChoiceOption
|
||||
SchematicType type;
|
||||
std::string displayName;
|
||||
|
||||
// Display names of items produced by recipes that would newly become
|
||||
// implicitly unlocked (REQ-LOCK-IMPLICIT) if this option is selected.
|
||||
// Deduplicated and sorted alphabetically; empty if none.
|
||||
std::vector<std::string> newlyUnlockedItemNames;
|
||||
// Ids of miner/assembler recipes that would newly become implicitly
|
||||
// unlocked (REQ-LOCK-IMPLICIT) if this option is selected. Sorted
|
||||
// alphabetically by display name; empty if none.
|
||||
std::vector<std::string> newlyUnlockedRecipeIds;
|
||||
};
|
||||
|
||||
@@ -154,6 +154,16 @@ void BuildingSystem::initShipyardBuffers(Building& b) const
|
||||
}
|
||||
}
|
||||
|
||||
void BuildingSystem::initSalvageBayBuffer(Building& b) const
|
||||
{
|
||||
// Salvage Bay has no recipe-driven buffer; its output-buffer holding size for
|
||||
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY).
|
||||
b.outputBuffer.items.clear();
|
||||
const BuildingDef* def = findBuildingDef(BuildingType::SalvageBay);
|
||||
b.outputBuffer.capacity =
|
||||
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
|
||||
}
|
||||
|
||||
std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
|
||||
{
|
||||
// Build lookup sets for quick membership checks.
|
||||
@@ -577,7 +587,11 @@ void BuildingSystem::tickConstruction(Tick currentTick)
|
||||
}
|
||||
building.inputPorts = computeInputPorts(building);
|
||||
|
||||
if (!building.recipeId.empty())
|
||||
if (building.type == BuildingType::SalvageBay)
|
||||
{
|
||||
initSalvageBayBuffer(building);
|
||||
}
|
||||
else if (!building.recipeId.empty())
|
||||
{
|
||||
if (building.type == BuildingType::Shipyard)
|
||||
{
|
||||
@@ -1238,6 +1252,11 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
|
||||
}
|
||||
building.inputPorts = computeInputPorts(building);
|
||||
|
||||
if (type == BuildingType::SalvageBay)
|
||||
{
|
||||
initSalvageBayBuffer(building);
|
||||
}
|
||||
|
||||
m_buildings.push_back(std::move(building));
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ private:
|
||||
const ModuleDef* findModuleDef(const std::string& id) const;
|
||||
void initBuffers(Building& b, const RecipeDef& recipe) const;
|
||||
void initShipyardBuffers(Building& b) const;
|
||||
void initSalvageBayBuffer(Building& b) const;
|
||||
std::vector<Port> computeInputPorts(const Building& b) const;
|
||||
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
|
||||
bool bodyCellsWithinWorldBounds(
|
||||
|
||||
@@ -707,14 +707,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||
else
|
||||
{
|
||||
option.type = SchematicType::Recipe;
|
||||
for (const RecipeDef& def : m_config.recipes.recipes)
|
||||
{
|
||||
if (def.id == entry.id && !def.outputs.empty())
|
||||
{
|
||||
option.displayName = toDisplayName(def.outputs[0].item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
option.displayName = toDisplayName(entry.id);
|
||||
}
|
||||
|
||||
// REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by this option.
|
||||
@@ -737,7 +730,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||
|
||||
const UnlockedSets hypothetical = computeUnlockedSets(
|
||||
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
|
||||
option.newlyUnlockedItemNames = computeNewlyUnlockedItemNames(hypothetical);
|
||||
option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical);
|
||||
|
||||
m_pendingSchematicChoices.push_back(option);
|
||||
}
|
||||
@@ -925,23 +918,20 @@ Simulation::UnlockedSets Simulation::computeUnlockedSets(
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<std::string> Simulation::computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const
|
||||
std::vector<std::string> Simulation::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const
|
||||
{
|
||||
std::set<std::string> itemNames;
|
||||
std::vector<std::string> recipeIds;
|
||||
for (const std::string& recipeId : hypothetical.recipeIds)
|
||||
{
|
||||
if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; }
|
||||
for (const RecipeDef& def : m_config.recipes.recipes)
|
||||
{
|
||||
if (def.id != recipeId) { continue; }
|
||||
for (const RecipeOutput& out : def.outputs)
|
||||
{
|
||||
itemNames.insert(toDisplayName(out.item));
|
||||
}
|
||||
break;
|
||||
}
|
||||
recipeIds.push_back(recipeId);
|
||||
}
|
||||
return std::vector<std::string>(itemNames.begin(), itemNames.end());
|
||||
std::sort(recipeIds.begin(), recipeIds.end(),
|
||||
[](const std::string& lhs, const std::string& rhs)
|
||||
{
|
||||
return toDisplayName(lhs) < toDisplayName(rhs);
|
||||
});
|
||||
return recipeIds;
|
||||
}
|
||||
|
||||
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const
|
||||
|
||||
@@ -233,9 +233,9 @@ private:
|
||||
// True if every prerequisite in unlockRequires is explicitly unlocked (REQ-LOCK-PREREQ).
|
||||
bool prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const;
|
||||
|
||||
// Display names (deduplicated, alphabetical) of output items of recipes in
|
||||
// Ids (sorted alphabetically by display name) of the recipes in
|
||||
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
|
||||
std::vector<std::string> computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const;
|
||||
std::vector<std::string> computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const;
|
||||
|
||||
EntityAdmin m_admin;
|
||||
BeltSystem m_beltSystem;
|
||||
|
||||
@@ -984,6 +984,45 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
|
||||
REQUIRE(i.target.x() < pos(f.admin, ship).value.x());
|
||||
}
|
||||
|
||||
TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
|
||||
const BuildingId bayId = f.buildings.place(BuildingType::SalvageBay,
|
||||
QPoint(-4, 0), Rotation::East, 0);
|
||||
Tick t = 0;
|
||||
for (int i = 0; i < 500; ++i)
|
||||
{
|
||||
f.buildings.tickConstruction(t++);
|
||||
if (f.buildings.findBuilding(bayId) != nullptr) { break; }
|
||||
}
|
||||
const Building* bay = f.buildings.findBuilding(bayId);
|
||||
REQUIRE(bay != nullptr);
|
||||
// Config-driven output-buffer capacity is applied on placement (REQ-BLD-SALVAGE-BAY).
|
||||
REQUIRE(bay->outputBuffer.capacity == 20);
|
||||
|
||||
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||
bay->anchor.y() + bay->footprint.height() / 2.0f);
|
||||
|
||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||
const entt::entity ship = f.ships.spawn("salvage_ship", bayCenter, false, salvageLayout);
|
||||
f.admin.get<PositionComponent>(ship).value = bayCenter;
|
||||
CargoComponent& cargo = f.admin.get<CargoComponent>(ship);
|
||||
cargo.current = cargo.maxCapacity; // full cargo
|
||||
const int before = cargo.current;
|
||||
REQUIRE(before > 0);
|
||||
f.admin.get<DeliverScrapBehavior>(ship).deliveryBay = bayId;
|
||||
|
||||
f.salvageTick();
|
||||
|
||||
// One unit handed over from cargo into the bay's output buffer.
|
||||
REQUIRE(f.admin.get<CargoComponent>(ship).current == before - 1);
|
||||
const Building* bayAfter = f.buildings.findBuilding(bayId);
|
||||
REQUIRE(bayAfter != nullptr);
|
||||
REQUIRE(bayAfter->outputBuffer.items.size() == 1);
|
||||
REQUIRE(bayAfter->outputBuffer.items.front().type.id == "scrap");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collection range (per-module)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -103,6 +103,15 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
|
||||
REQUIRE(minerIt != cfg.buildings.buildings.end());
|
||||
REQUIRE(minerIt->cost == 15);
|
||||
REQUIRE(minerIt->surfaceMask.size() == 2);
|
||||
// Miner has no output-buffer-capacity override; the Salvage Bay does.
|
||||
REQUIRE_FALSE(minerIt->outputBufferCapacity.has_value());
|
||||
|
||||
const auto salvageBayIt = std::find_if(
|
||||
cfg.buildings.buildings.begin(), cfg.buildings.buildings.end(),
|
||||
[](const BuildingDef& b) { return b.type == BuildingType::SalvageBay; });
|
||||
REQUIRE(salvageBayIt != cfg.buildings.buildings.end());
|
||||
REQUIRE(salvageBayIt->outputBufferCapacity.has_value());
|
||||
REQUIRE(*salvageBayIt->outputBufferCapacity == 20);
|
||||
|
||||
// recipes.toml — reprocessing cycle has three weighted outputs.
|
||||
const auto reproIt = std::find_if(
|
||||
|
||||
@@ -283,7 +283,7 @@ TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items
|
||||
// Unlock dialog: newly-unlocked recipe preview (REQ-DEF-SCHEMATIC-DROP)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and empty for level-ups",
|
||||
TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and empty for level-ups",
|
||||
"[recipe_schematic]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
@@ -295,10 +295,11 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and
|
||||
|
||||
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
|
||||
{
|
||||
// Strictly ascending implies sorted and deduplicated.
|
||||
for (std::size_t j = 1; j < opt.newlyUnlockedItemNames.size(); ++j)
|
||||
// Strictly ascending display names imply sorted and deduplicated.
|
||||
for (std::size_t j = 1; j < opt.newlyUnlockedRecipeIds.size(); ++j)
|
||||
{
|
||||
CHECK(opt.newlyUnlockedItemNames[j - 1] < opt.newlyUnlockedItemNames[j]);
|
||||
CHECK(toDisplayName(opt.newlyUnlockedRecipeIds[j - 1])
|
||||
< toDisplayName(opt.newlyUnlockedRecipeIds[j]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +307,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually become unlocked",
|
||||
TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually become unlocked",
|
||||
"[recipe_schematic]")
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
@@ -336,21 +337,22 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually
|
||||
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
|
||||
std::set<std::string> expectedNames;
|
||||
std::vector<std::string> expected;
|
||||
for (const RecipeDef& def : cfg.recipes.recipes)
|
||||
{
|
||||
if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler)
|
||||
&& sim.isRecipeUnlocked(def.id) && unlockedBefore.count(def.id) == 0)
|
||||
{
|
||||
for (const RecipeOutput& out : def.outputs)
|
||||
{
|
||||
expectedNames.insert(toDisplayName(out.item));
|
||||
}
|
||||
expected.push_back(def.id);
|
||||
}
|
||||
}
|
||||
const std::vector<std::string> expected(expectedNames.begin(), expectedNames.end());
|
||||
std::sort(expected.begin(), expected.end(),
|
||||
[](const std::string& lhs, const std::string& rhs)
|
||||
{
|
||||
return toDisplayName(lhs) < toDisplayName(rhs);
|
||||
});
|
||||
|
||||
REQUIRE(choice.newlyUnlockedItemNames == expected);
|
||||
REQUIRE(choice.newlyUnlockedRecipeIds == expected);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,23 +53,15 @@ BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidge
|
||||
|
||||
connect(m_createBtn, &QPushButton::clicked, this, &BlueprintPanel::onCreateClicked);
|
||||
|
||||
QHBoxLayout* ioLayout = new QHBoxLayout();
|
||||
m_saveBtn = new QPushButton(tr("Save"), this);
|
||||
m_loadBtn = new QPushButton(tr("Load"), this);
|
||||
m_saveBtn->setFixedHeight(36);
|
||||
m_loadBtn->setFixedHeight(36);
|
||||
ioLayout->addWidget(m_saveBtn);
|
||||
ioLayout->addWidget(m_loadBtn);
|
||||
layout->addLayout(ioLayout);
|
||||
|
||||
connect(m_saveBtn, &QPushButton::clicked, this, &BlueprintPanel::onSaveClicked);
|
||||
connect(m_loadBtn, &QPushButton::clicked, this, &BlueprintPanel::onLoadClicked);
|
||||
loadFromDisk();
|
||||
rebuildButtons();
|
||||
|
||||
registerForEvents();
|
||||
}
|
||||
|
||||
BlueprintPanel::~BlueprintPanel()
|
||||
{
|
||||
saveToDisk();
|
||||
unregisterForEvents();
|
||||
}
|
||||
|
||||
@@ -281,65 +273,35 @@ void BlueprintPanel::rebuildButtons()
|
||||
refreshButtonStates();
|
||||
}
|
||||
|
||||
void BlueprintPanel::onSaveClicked()
|
||||
void BlueprintPanel::saveToDisk() const
|
||||
{
|
||||
// Persist on shutdown; write errors are silently ignored (REQ-UI-BLUEPRINT-SAVE).
|
||||
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; }
|
||||
try
|
||||
{
|
||||
const std::string content = BlueprintSerializer::serialize(m_blueprints);
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
|
||||
{
|
||||
QMessageBox::critical(this, tr("Save Failed"),
|
||||
tr("Could not open file for writing:\n%1").arg(path));
|
||||
return;
|
||||
}
|
||||
file.write(QByteArray::fromStdString(content));
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
QMessageBox::critical(this, tr("Save Failed"),
|
||||
tr("Failed to save blueprints:\n%1").arg(e.what()));
|
||||
}
|
||||
catch (...) {}
|
||||
}
|
||||
|
||||
void BlueprintPanel::onLoadClicked()
|
||||
void BlueprintPanel::loadFromDisk()
|
||||
{
|
||||
QMessageBox box(this);
|
||||
box.setWindowTitle(tr("Load Blueprints"));
|
||||
box.setText(tr("Load blueprints? This will replace all current blueprints."));
|
||||
QPushButton* confirmBtn = box.addButton(tr("Confirm"), QMessageBox::AcceptRole);
|
||||
box.addButton(tr("Cancel"), QMessageBox::RejectRole);
|
||||
box.exec();
|
||||
if (box.clickedButton() != confirmBtn) { return; }
|
||||
|
||||
// Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error.
|
||||
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; }
|
||||
try
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||
{
|
||||
QMessageBox::critical(this, tr("Load Failed"),
|
||||
tr("Could not open file:\n%1").arg(path));
|
||||
return;
|
||||
}
|
||||
const std::string content = file.readAll().toStdString();
|
||||
std::vector<Blueprint> loaded = BlueprintSerializer::deserialize(content);
|
||||
|
||||
if (m_activeIndex >= 0)
|
||||
{
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<ExitBlueprintModeRequestedEvent>());
|
||||
m_activeIndex = -1;
|
||||
}
|
||||
|
||||
m_blueprints = std::move(loaded);
|
||||
rebuildButtons();
|
||||
m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString());
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
QMessageBox::critical(this, tr("Load Failed"),
|
||||
tr("Failed to load blueprints:\n%1").arg(e.what()));
|
||||
m_blueprints.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,6 @@ private slots:
|
||||
void onCreateClicked();
|
||||
void onDeleteBlueprintClicked(int index);
|
||||
void onBlueprintButtonClicked(int index);
|
||||
void onSaveClicked();
|
||||
void onLoadClicked();
|
||||
|
||||
private:
|
||||
void onSelectionChanged(const std::vector<BuildingId>& ids);
|
||||
@@ -48,6 +46,8 @@ private:
|
||||
int computeBlueprintCost(const Blueprint& bp) const;
|
||||
void rebuildButtons();
|
||||
void refreshButtonStates();
|
||||
void loadFromDisk();
|
||||
void saveToDisk() const;
|
||||
|
||||
Simulation* m_sim;
|
||||
const GameConfig* m_config;
|
||||
@@ -57,8 +57,6 @@ private:
|
||||
std::vector<Blueprint> m_blueprints;
|
||||
std::vector<QPushButton*> m_blueprintButtons;
|
||||
QPushButton* m_createBtn;
|
||||
QPushButton* m_saveBtn;
|
||||
QPushButton* m_loadBtn;
|
||||
QWidget* m_buttonsContainer;
|
||||
QVBoxLayout* m_buttonsLayout;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -30,5 +31,6 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -142,7 +142,7 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
|
||||
const double prevSpeed = m_gameWorldView->gameSpeed();
|
||||
m_gameWorldView->setGameSpeed(0.0);
|
||||
|
||||
SchematicChoiceDialog dialog(event->choices, this);
|
||||
SchematicChoiceDialog dialog(event->choices, m_sim->config().recipes, this);
|
||||
dialog.exec();
|
||||
|
||||
std::shared_ptr<ApplySchematicChoiceCommand> command =
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "DisplayName.h"
|
||||
#include "GameConfig.h"
|
||||
#include "RecipesConfig.h"
|
||||
#include "RecipeTooltip.h"
|
||||
#include "ShipsConfig.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
@@ -25,39 +26,6 @@ QString itemLine(const std::string& itemId, int amount)
|
||||
+ QStringLiteral(" ×") + QString::number(amount);
|
||||
}
|
||||
|
||||
QString recipeTooltip(const RecipeDef& recipe)
|
||||
{
|
||||
QStringList lines;
|
||||
lines << QObject::tr("Recipe: %1")
|
||||
.arg(QString::fromStdString(toDisplayName(recipe.id)));
|
||||
|
||||
if (recipe.inputs.empty())
|
||||
{
|
||||
lines << QObject::tr("Inputs: none");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines << QObject::tr("Inputs:");
|
||||
for (const RecipeIngredient& ingredient : recipe.inputs)
|
||||
{
|
||||
lines << itemLine(ingredient.item, ingredient.amount);
|
||||
}
|
||||
}
|
||||
|
||||
lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds);
|
||||
|
||||
if (!recipe.outputs.empty())
|
||||
{
|
||||
lines << QObject::tr("Produces:");
|
||||
for (const RecipeOutput& output : recipe.outputs)
|
||||
{
|
||||
lines << itemLine(output.item, output.amount);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
QString shipTooltip(const ShipDef& def)
|
||||
{
|
||||
const QString name = QString::fromStdString(toDisplayName(def.id));
|
||||
@@ -116,7 +84,7 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
|
||||
}
|
||||
options.push_back({recipe.id,
|
||||
QString::fromStdString(toDisplayName(recipe.id)),
|
||||
recipeTooltip(recipe)});
|
||||
buildRecipeTooltip(recipe)});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
src/ui/RecipeTooltip.cpp
Normal file
52
src/ui/RecipeTooltip.cpp
Normal file
@@ -0,0 +1,52 @@
|
||||
#include "RecipeTooltip.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
#include "DisplayName.h"
|
||||
#include "RecipesConfig.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
QString itemLine(const std::string& itemId, int amount)
|
||||
{
|
||||
return QStringLiteral(" ")
|
||||
+ QString::fromStdString(toDisplayName(itemId))
|
||||
+ QStringLiteral(" ×") + QString::number(amount);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString buildRecipeTooltip(const RecipeDef& recipe)
|
||||
{
|
||||
QStringList lines;
|
||||
lines << QObject::tr("Recipe: %1")
|
||||
.arg(QString::fromStdString(toDisplayName(recipe.id)));
|
||||
|
||||
if (recipe.inputs.empty())
|
||||
{
|
||||
lines << QObject::tr("Inputs: none");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines << QObject::tr("Inputs:");
|
||||
for (const RecipeIngredient& ingredient : recipe.inputs)
|
||||
{
|
||||
lines << itemLine(ingredient.item, ingredient.amount);
|
||||
}
|
||||
}
|
||||
|
||||
lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds);
|
||||
|
||||
if (!recipe.outputs.empty())
|
||||
{
|
||||
lines << QObject::tr("Produces:");
|
||||
for (const RecipeOutput& output : recipe.outputs)
|
||||
{
|
||||
lines << itemLine(output.item, output.amount);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
11
src/ui/RecipeTooltip.h
Normal file
11
src/ui/RecipeTooltip.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
struct RecipeDef;
|
||||
|
||||
// Builds the recipe info tooltip text (REQ-UI-SELECT-TOOLTIP): the recipe name,
|
||||
// each input item name and quantity, the completion time, and the produced
|
||||
// output item name and quantity. Shared by the recipe-selection dialog and the
|
||||
// schematic choice dialog's "Unlocks recipes:" list so both render identically.
|
||||
QString buildRecipeTooltip(const RecipeDef& recipe);
|
||||
@@ -3,11 +3,29 @@
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QStringList>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "DisplayName.h"
|
||||
#include "RecipeTooltip.h"
|
||||
#include "RecipesConfig.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
const RecipeDef* findRecipe(const RecipesConfig& recipes, const std::string& id)
|
||||
{
|
||||
for (const RecipeDef& recipe : recipes.recipes)
|
||||
{
|
||||
if (recipe.id == id) { return &recipe; }
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SchematicChoiceDialog::SchematicChoiceDialog(
|
||||
const std::vector<SchematicChoiceOption>& options,
|
||||
const RecipesConfig& recipes,
|
||||
QWidget* parent)
|
||||
: QDialog(parent)
|
||||
, m_chosenIndex(0)
|
||||
@@ -70,30 +88,33 @@ SchematicChoiceDialog::SchematicChoiceDialog(
|
||||
typeLabel->setAlignment(Qt::AlignCenter);
|
||||
cardLayout->addWidget(typeLabel);
|
||||
|
||||
QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes for:"), card);
|
||||
QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes:"), card);
|
||||
QFont unlocksHeaderFont = unlocksHeaderLabel->font();
|
||||
unlocksHeaderFont.setBold(true);
|
||||
unlocksHeaderLabel->setFont(unlocksHeaderFont);
|
||||
unlocksHeaderLabel->setAlignment(Qt::AlignCenter);
|
||||
cardLayout->addWidget(unlocksHeaderLabel);
|
||||
|
||||
QString unlocksText;
|
||||
if (option.newlyUnlockedItemNames.empty())
|
||||
if (option.newlyUnlockedRecipeIds.empty())
|
||||
{
|
||||
unlocksText = tr("None");
|
||||
QLabel* noneLabel = new QLabel(tr("None"), card);
|
||||
noneLabel->setAlignment(Qt::AlignCenter);
|
||||
cardLayout->addWidget(noneLabel);
|
||||
}
|
||||
else
|
||||
{
|
||||
QStringList itemLines;
|
||||
for (const std::string& itemName : option.newlyUnlockedItemNames)
|
||||
for (const std::string& recipeId : option.newlyUnlockedRecipeIds)
|
||||
{
|
||||
itemLines << QString::fromStdString(itemName);
|
||||
QLabel* recipeLabel = new QLabel(
|
||||
QString::fromStdString(toDisplayName(recipeId)), card);
|
||||
recipeLabel->setAlignment(Qt::AlignCenter);
|
||||
if (const RecipeDef* def = findRecipe(recipes, recipeId))
|
||||
{
|
||||
recipeLabel->setToolTip(buildRecipeTooltip(*def));
|
||||
}
|
||||
cardLayout->addWidget(recipeLabel);
|
||||
}
|
||||
unlocksText = itemLines.join("\n");
|
||||
}
|
||||
QLabel* unlocksLabel = new QLabel(unlocksText, card);
|
||||
unlocksLabel->setAlignment(Qt::AlignCenter);
|
||||
cardLayout->addWidget(unlocksLabel);
|
||||
}
|
||||
|
||||
QPushButton* selectButton = new QPushButton(tr("Select"), card);
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
|
||||
#include "SchematicChoiceOption.h"
|
||||
|
||||
struct RecipesConfig;
|
||||
|
||||
class SchematicChoiceDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
SchematicChoiceDialog(const std::vector<SchematicChoiceOption>& options,
|
||||
const RecipesConfig& recipes,
|
||||
QWidget* parent = nullptr);
|
||||
|
||||
int getChosenIndex() const;
|
||||
|
||||
Reference in New Issue
Block a user