implicit item locking

This commit is contained in:
2026-06-11 22:35:29 +02:00
parent 69b35d2bfc
commit 54a6056b77
11 changed files with 171 additions and 14 deletions

View File

@@ -47,6 +47,7 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
[this]() { return allocateBuildingId(); },
[](int) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_gameConfig, m_admin);

View File

@@ -14,12 +14,14 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
std::function<void(int)> addBuildingBlocks,
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip,
std::function<bool(const std::string&)> isItemUnlocked,
std::mt19937& rng)
: m_config(config)
, m_belts(belts)
, m_allocateBuildingId(std::move(allocateBuildingId))
, m_addBuildingBlocks(std::move(addBuildingBlocks))
, m_spawnShip(std::move(spawnShip))
, m_isItemUnlocked(std::move(isItemUnlocked))
, m_rng(rng)
{
}
@@ -203,17 +205,19 @@ std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe)
{
std::vector<const RecipeOutput*> eligible;
std::vector<double> weights;
weights.reserve(recipe.outputs.size());
for (const RecipeOutput& out : recipe.outputs)
{
if (!m_isItemUnlocked(out.item)) { continue; }
eligible.push_back(&out);
weights.push_back(out.probability.value_or(1.0));
}
std::discrete_distribution<int> dist(weights.begin(), weights.end());
const int idx = dist(m_rng);
if (eligible.empty()) { return {}; }
const RecipeOutput& chosen = recipe.outputs[static_cast<std::size_t>(idx)];
std::discrete_distribution<int> dist(weights.begin(), weights.end());
const RecipeOutput& chosen = *eligible[static_cast<std::size_t>(dist(m_rng))];
std::vector<Item> result;
Item item;
item.type.id = chosen.item;
@@ -660,6 +664,7 @@ void BuildingSystem::tickProduction(Tick currentTick)
if (building.type == BuildingType::ReprocessingPlant)
{
chosen = rollReprocessingOutput(*recipe);
if (chosen.empty()) { continue; }
}
else
{

View File

@@ -36,6 +36,7 @@ public:
std::function<void(int)> addBuildingBlocks,
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip,
std::function<bool(const std::string&)> isItemUnlocked,
std::mt19937& rng);
// -- Placement / demolish ------------------------------------------------
@@ -134,6 +135,7 @@ private:
std::function<void(int)> m_addBuildingBlocks;
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> m_spawnShip;
std::function<bool(const std::string&)> m_isItemUnlocked;
std::mt19937& m_rng;
std::vector<Building> m_buildings;

View File

@@ -59,6 +59,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout,
moduleLevels);
},
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>();
@@ -86,6 +87,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
m_moduleSchematicLevels[def.id] = state;
}
recomputeUnlocked();
placeInitialStructures();
registerForEvents();
}
@@ -147,6 +149,7 @@ void Simulation::reset(unsigned int seed)
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout,
moduleLevels);
},
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>();
@@ -174,6 +177,7 @@ void Simulation::reset(unsigned int seed)
m_moduleSchematicLevels[def.id] = state;
}
recomputeUnlocked();
placeInitialStructures();
}
@@ -544,6 +548,83 @@ void Simulation::awardSchematicDrop(int destroyedStationLevel)
evt.wasNewUnlock = wasNew;
evt.isModuleSchematic = isModule;
m_schematicDropEvents.push_back(evt);
recomputeUnlocked();
}
// ---------------------------------------------------------------------------
// Implicit unlock computation (REQ-LOCK-IMPLICIT)
// ---------------------------------------------------------------------------
void Simulation::recomputeUnlocked()
{
m_unlockedItemIds.clear();
m_unlockedRecipeIds.clear();
for (const ShipDef& def : m_config.ships.ships)
{
if (!isSchematicUnlocked(def.id)) { continue; }
for (const RecipeIngredient& mat : def.schematic.materials)
{
m_unlockedItemIds.insert(mat.item);
}
}
for (const ModuleDef& def : m_config.modules.modules)
{
if (!isModuleSchematicUnlocked(def.id)) { continue; }
for (const RecipeIngredient& mat : def.materials)
{
m_unlockedItemIds.insert(mat.item);
}
}
bool changed = true;
while (changed)
{
changed = false;
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.building != BuildingType::Miner
&& recipe.building != BuildingType::Smelter
&& recipe.building != BuildingType::Assembler)
{
continue;
}
bool producesUnlocked = false;
for (const RecipeOutput& out : recipe.outputs)
{
if (m_unlockedItemIds.count(out.item) > 0)
{
producesUnlocked = true;
break;
}
}
if (!producesUnlocked) { continue; }
if (recipe.building == BuildingType::Miner
|| recipe.building == BuildingType::Assembler)
{
m_unlockedRecipeIds.insert(recipe.id);
}
for (const RecipeIngredient& ing : recipe.inputs)
{
if (m_unlockedItemIds.insert(ing.item).second)
{
changed = true;
}
}
}
}
}
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const
{
return m_unlockedRecipeIds.count(recipeId) > 0;
}
bool Simulation::isItemUnlocked(const std::string& itemId) const
{
return m_unlockedItemIds.count(itemId) > 0;
}
// ---------------------------------------------------------------------------

View File

@@ -3,6 +3,7 @@
#include <map>
#include <memory>
#include <random>
#include <set>
#include <string>
#include <vector>
@@ -70,6 +71,10 @@ public:
int moduleSchematicLevel(const std::string& moduleId) const;
bool isModuleSchematicUnlocked(const std::string& moduleId) const;
// Implicit recipe/item unlock queries (REQ-LOCK-IMPLICIT).
bool isRecipeUnlocked(const std::string& recipeId) const;
bool isItemUnlocked(const std::string& itemId) const;
// Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
@@ -131,6 +136,13 @@ private:
std::map<std::string, SchematicState> m_schematicLevels;
std::map<std::string, SchematicState> m_moduleSchematicLevels;
// Implicit unlock sets derived from schematic state (REQ-LOCK-IMPLICIT).
std::set<std::string> m_unlockedRecipeIds;
std::set<std::string> m_unlockedItemIds;
// Recomputes m_unlockedRecipeIds and m_unlockedItemIds from current schematic state.
void recomputeUnlocked();
EntityAdmin m_admin;
BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem;

View File

@@ -70,6 +70,7 @@ struct Fixture
[this]() { return nextBuildingId++; },
[this](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng)
, ships(cfg, admin)
, scraps(admin)

View File

@@ -79,6 +79,7 @@ TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -104,6 +105,7 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
@@ -132,6 +134,7 @@ TEST_CASE("BuildingSystem: placed building enters construction queue", "[buildin
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -152,6 +155,7 @@ TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -185,6 +189,7 @@ TEST_CASE("BuildingSystem: first queued building starts construction immediately
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -202,6 +207,7 @@ TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[b
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -223,6 +229,7 @@ TEST_CASE("BuildingSystem: construction completes after configured duration", "[
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -247,6 +254,7 @@ TEST_CASE("BuildingSystem: second building starts after first completes", "[buil
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -276,6 +284,7 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -305,6 +314,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -344,6 +354,7 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
@@ -385,6 +396,7 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -424,6 +436,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -464,6 +477,7 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
@@ -494,6 +508,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
@@ -552,6 +567,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
REQUIRE_FALSE(
@@ -570,6 +586,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a que
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -592,6 +609,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -618,6 +636,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -639,6 +658,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprin
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
// Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1).
@@ -662,6 +682,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
// Smelter is a fully filled 2×2 footprint — rotating the ghost produces the
@@ -690,6 +711,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a constru
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -712,6 +734,7 @@ TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -735,6 +758,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direct
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -765,6 +789,7 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);

View File

@@ -72,6 +72,7 @@ struct CombatFixture
[this]() { return nextBuildingId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng)
, combat(cfg)
{

View File

@@ -560,7 +560,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
}
else
{
m_sim->buildings().setRecipe(id, bb.recipeId);
const bool needsUnlockCheck = bb.type == BuildingType::Miner
|| bb.type == BuildingType::Assembler;
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
{
m_sim->buildings().setRecipe(id, bb.recipeId);
}
}
}

View File

@@ -296,12 +296,15 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
{
for (const RecipeDef& recipe : m_config->recipes.recipes)
{
if (recipe.building == b->type)
if (recipe.building != b->type) { continue; }
if ((b->type == BuildingType::Miner || b->type == BuildingType::Assembler)
&& !m_sim->isRecipeUnlocked(recipe.id))
{
m_recipeCombo->addItem(
QString::fromStdString(recipe.id),
QString::fromStdString(recipe.id));
continue;
}
m_recipeCombo->addItem(
QString::fromStdString(recipe.id),
QString::fromStdString(recipe.id));
}
}
@@ -648,6 +651,7 @@ void SelectedBuildingPanel::buildSplitterFilters(QPoint splitterTile)
list->clear();
for (const std::string& itemId : items)
{
if (!m_sim->isItemUnlocked(itemId)) { continue; }
QListWidgetItem* row = new QListWidgetItem(
QString::fromStdString(itemId), list);
const bool checked = filter.empty()