Lift unlocking into first-class unlock groups defined in unlocks.toml, so one defence-station drop can grant a bundle of ships/modules/buildings/ recipes at once (e.g. the salvager module + salvage bay), and buildings can now be locked at game start and unlocked via a drop. Config: - New UnlocksConfig + ConfigLoader::loadUnlocks + validateUnlocks (unknown/ duplicate/non-assembler grants, unknown requires, empty group all fail load). - Drop unlock_at_station_level/unlock_requires from ship/module/recipe defs; add unlocked_at_start bool to recipes for graph-unreachable base recipes. Simulation: - Track awarded groups + per-building unlock state; an item starts unlocked iff no group grants it. Rework generateSchematicChoices/applySchematicChoice to operate on groups (grant all members at once). Extend state checksum. - isBuildingUnlocked query; tryPlaceBuilding rejects locked types. UI: - Build-menu buttons for locked buildings start hidden, revealed via a new UnlockedBuildingsChangedEvent (emitted from GameWorldView's poll loop). - Schematic choice dialog lists a group's granted items. - Blueprint placement excludes locked building types (REQ-LOCK-UI-BLUEPRINT). Data: unlocks.toml (app + test) reproduces the prior per-item gates as singleton groups and adds salvage_operations (salvager + salvage_bay, lvl 1) and reprocessing (reprocessing_plant, lvl 2). Tests: rewrote unlock/config/recipe-schematic/artifact/wave/shipyard tests for the group model; added UnlockGroupTest. All 443 cases pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc
231 lines
6.5 KiB
C++
231 lines
6.5 KiB
C++
#include "catch.hpp"
|
|
|
|
#include <algorithm>
|
|
|
|
#include "Building.h"
|
|
#include "BuildingSystem.h"
|
|
#include "BuildingType.h"
|
|
#include "ConfigLoader.h"
|
|
#include "EntityAdmin.h"
|
|
#include "FactionComponent.h"
|
|
#include "GameConfig.h"
|
|
#include "ItemType.h"
|
|
#include "Rotation.h"
|
|
#include "ShipIdentityComponent.h"
|
|
#include "ShipSystem.h"
|
|
#include "Simulation.h"
|
|
#include "SimulationTestAccess.h"
|
|
#include "Tick.h"
|
|
|
|
static GameConfig loadConfig()
|
|
{
|
|
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
|
}
|
|
|
|
// A ship starts unlocked iff no unlock group grants it (REQ-LOCK-EXPLICIT).
|
|
static bool startsUnlocked(const GameConfig& cfg, const std::string& shipId)
|
|
{
|
|
for (const UnlockGroupDef& group : cfg.unlocks.groups)
|
|
{
|
|
if (std::find(group.ships.begin(), group.ships.end(), shipId) != group.ships.end())
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static const ShipDef* findAvailableSchematic(const GameConfig& cfg)
|
|
{
|
|
for (const ShipDef& def : cfg.ships.ships)
|
|
{
|
|
if (startsUnlocked(cfg, def.id) && !def.schematic.materials.empty())
|
|
{
|
|
return &def;
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
static const BuildingDef* findShipyardDef(const GameConfig& cfg)
|
|
{
|
|
for (const BuildingDef& def : cfg.buildings.buildings)
|
|
{
|
|
if (def.type == BuildingType::Shipyard)
|
|
{
|
|
return &def;
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
|
|
{
|
|
return SimulationTestAccess::buildings(sim).placeImmediate(
|
|
BuildingType::Shipyard,
|
|
yardDef.surfaceMask,
|
|
QPoint(0, 0),
|
|
Rotation::East);
|
|
}
|
|
|
|
static int countShips(Simulation& sim)
|
|
{
|
|
int n = 0;
|
|
sim.getAdmin().forEach<ShipIdentityComponent>(
|
|
[&n](entt::entity /*e*/, const ShipIdentityComponent& /*si*/) { ++n; });
|
|
return n;
|
|
}
|
|
|
|
static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def)
|
|
{
|
|
SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b)
|
|
{
|
|
if (b.id != yardId)
|
|
{
|
|
return;
|
|
}
|
|
for (const RecipeIngredient& ing : def.schematic.materials)
|
|
{
|
|
b.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shipyard production
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
|
|
"[shipyard]")
|
|
{
|
|
Simulation sim(loadConfig(), 42);
|
|
|
|
const ShipDef* def = findAvailableSchematic(sim.getConfig());
|
|
REQUIRE(def != nullptr);
|
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
|
REQUIRE(yardDef != nullptr);
|
|
|
|
const int shipsBefore = countShips(sim);
|
|
|
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
|
REQUIRE(yardId != kInvalidBuildingId);
|
|
|
|
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
|
|
fillMaterials(sim, yardId, *def);
|
|
|
|
// First tick: materials consumed, production cycle starts — no ship yet.
|
|
sim.tick();
|
|
REQUIRE(countShips(sim) == shipsBefore);
|
|
|
|
// Tick until the cycle completes.
|
|
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
|
|
for (Tick i = 1; i < cycleTicks; ++i)
|
|
{
|
|
sim.tick();
|
|
}
|
|
REQUIRE(countShips(sim) == shipsBefore);
|
|
|
|
// Final tick: cycle completes, ship spawns.
|
|
sim.tick();
|
|
REQUIRE(countShips(sim) == shipsBefore + 1);
|
|
|
|
bool foundPlayerShip = false;
|
|
sim.getAdmin().forEach<ShipIdentityComponent, FactionComponent>(
|
|
[&](entt::entity /*e*/, const ShipIdentityComponent& si, const FactionComponent& f)
|
|
{
|
|
if (!f.isEnemy && si.schematicId == def->id)
|
|
{
|
|
foundPlayerShip = true;
|
|
}
|
|
});
|
|
REQUIRE(foundPlayerShip);
|
|
}
|
|
|
|
TEST_CASE("Shipyard: does not spawn without a schematic set", "[shipyard]")
|
|
{
|
|
Simulation sim(loadConfig(), 42);
|
|
|
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
|
REQUIRE(yardDef != nullptr);
|
|
|
|
const int shipsBefore = countShips(sim);
|
|
|
|
placeShipyard(sim, *yardDef);
|
|
|
|
sim.tick();
|
|
|
|
REQUIRE(countShips(sim) == shipsBefore);
|
|
}
|
|
|
|
TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
|
|
{
|
|
Simulation sim(loadConfig(), 42);
|
|
|
|
const ShipDef* def = findAvailableSchematic(sim.getConfig());
|
|
REQUIRE(def != nullptr);
|
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
|
REQUIRE(yardDef != nullptr);
|
|
|
|
const int shipsBefore = countShips(sim);
|
|
|
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
|
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
|
|
// Materials remain at zero (default after setRecipe); no cycle starts.
|
|
|
|
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
|
|
for (Tick i = 0; i <= cycleTicks; ++i)
|
|
{
|
|
sim.tick();
|
|
}
|
|
|
|
REQUIRE(countShips(sim) == shipsBefore);
|
|
}
|
|
|
|
TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipyard]")
|
|
{
|
|
Simulation sim(loadConfig(), 42);
|
|
|
|
const ShipDef* def = findAvailableSchematic(sim.getConfig());
|
|
REQUIRE(def != nullptr);
|
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
|
REQUIRE(yardDef != nullptr);
|
|
|
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
|
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
|
|
|
|
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
|
|
|
|
// First cycle: capture count immediately after the spawn tick.
|
|
fillMaterials(sim, yardId, *def);
|
|
for (Tick i = 0; i <= cycleTicks; ++i)
|
|
{
|
|
sim.tick();
|
|
}
|
|
const int after1 = countShips(sim);
|
|
|
|
// Second cycle: capture count immediately after the next spawn tick.
|
|
fillMaterials(sim, yardId, *def);
|
|
for (Tick i = 0; i <= cycleTicks; ++i)
|
|
{
|
|
sim.tick();
|
|
}
|
|
const int after2 = countShips(sim);
|
|
|
|
// After each cycle one ship was added; ships from prior cycles may have died
|
|
// from enemy fire, so we only assert the most-recent spawn is still present.
|
|
REQUIRE(after2 >= 1);
|
|
|
|
// Verify the shipyard production field cleared (i.e. the cycle completed
|
|
// and is not still running).
|
|
bool productionCleared = false;
|
|
for (const Building& b : sim.getBuildings().getAllBuildings())
|
|
{
|
|
if (b.id == yardId)
|
|
{
|
|
productionCleared = !b.production.has_value();
|
|
break;
|
|
}
|
|
}
|
|
REQUIRE(productionCleared);
|
|
}
|