diff --git a/libs/s25main/GlobalGameSettings.cpp b/libs/s25main/GlobalGameSettings.cpp index f5047f9893..d070375afd 100644 --- a/libs/s25main/GlobalGameSettings.cpp +++ b/libs/s25main/GlobalGameSettings.cpp @@ -105,6 +105,7 @@ void GlobalGameSettings::registerAllAddons() AddonLeather, AddonNoArmorDefault, AddonArmorCapturedBld, + AddonAdditionalHarborSpots, AddonForesterFarmFieldAvoidance, AddonForesterReachRadius, AddonWoodcutterReachRadius, diff --git a/libs/s25main/addons/AddonAdditionalHarborSpots.h b/libs/s25main/addons/AddonAdditionalHarborSpots.h new file mode 100644 index 0000000000..fa008ad4e8 --- /dev/null +++ b/libs/s25main/addons/AddonAdditionalHarborSpots.h @@ -0,0 +1,18 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "AddonBool.h" +#include "mygettext/mygettext.h" + +class AddonAdditionalHarborSpots : public AddonBool +{ +public: + AddonAdditionalHarborSpots() + : AddonBool(AddonId::ADDITIONAL_HARBOR_SPOTS, AddonGroup::GamePlay, _("Dangerous: Add extra harbor spots"), + _("Advanced option. Converts a small set of suitable coastal castle sites to harbor spots. " + "Caution: May alter intended map seafaring design.")) + {} +}; diff --git a/libs/s25main/addons/Addons.h b/libs/s25main/addons/Addons.h index 96b3203ff7..44a80556f0 100644 --- a/libs/s25main/addons/Addons.h +++ b/libs/s25main/addons/Addons.h @@ -61,6 +61,7 @@ #include "addons/AddonAutoFlags.h" +#include "addons/AddonAdditionalHarborSpots.h" #include "addons/AddonArmorCapturedBld.h" #include "addons/AddonForesterFarmFieldAvoidance.h" #include "addons/AddonLeather.h" diff --git a/libs/s25main/addons/const_addons.h b/libs/s25main/addons/const_addons.h index f7ddf54f08..2ff320f424 100644 --- a/libs/s25main/addons/const_addons.h +++ b/libs/s25main/addons/const_addons.h @@ -77,7 +77,7 @@ ENUM_WITH_STRING(AddonId, LIMIT_CATAPULTS = 0x00000000, INEXHAUSTIBLE_MINES = 0x AUTOFLAGS = 0x00F00000, WINE = 0x01000000, LEATHER = 0x01000001, NO_ARMOR_DEFAULT = 0x01000002, - ARMOR_CAPTURED_BLD = 0x01000003, + ARMOR_CAPTURED_BLD = 0x01000003, ADDITIONAL_HARBOR_SPOTS = 0x01000004, FORESTER_FARM_FIELD_AVOIDANCE = 0x01100000, diff --git a/libs/s25main/world/BQCalculator.h b/libs/s25main/world/BQCalculator.h index 88db722d3c..02f57d2e92 100644 --- a/libs/s25main/world/BQCalculator.h +++ b/libs/s25main/world/BQCalculator.h @@ -5,7 +5,9 @@ #pragma once #include "World.h" +#include "commonDefines.h" // absDiff #include "helpers/containerUtils.h" +#include "nodeObjs/noBase.h" // noBase (used via World::GetNO) and BlockingManner #include "gameData/TerrainDesc.h" struct BQCalculator diff --git a/libs/s25main/world/MapLoader.cpp b/libs/s25main/world/MapLoader.cpp index 5fd781ce56..d90d31556d 100644 --- a/libs/s25main/world/MapLoader.cpp +++ b/libs/s25main/world/MapLoader.cpp @@ -19,6 +19,7 @@ #include "lua/GameDataLoader.h" #include "pathfinding/PathConditionShip.h" #include "random/Random.h" +#include "world/BQCalculator.h" #include "world/World.h" #include "nodeObjs/noAnimal.h" #include "nodeObjs/noEnvObject.h" @@ -35,6 +36,7 @@ #include "s25util/Log.h" #include #include +#include #include #include @@ -57,7 +59,8 @@ bool MapLoader::Load(const libsiedler2::ArchivItem_Map& map, Exploration explora return false; PlaceObjects(map); PlaceAnimals(map); - if(!InitSeasAndHarbors(world_)) + if(!InitSeasAndHarbors(world_, std::vector(), + world_.GetGGS().isEnabled(AddonId::ADDITIONAL_HARBOR_SPOTS))) return false; /// Schatten @@ -523,7 +526,78 @@ bool MapLoader::PlaceHQs(GameWorldBase& world, const std::vector& hqPo return true; } -bool MapLoader::InitSeasAndHarbors(World& world, const std::vector& additionalHarbors) +namespace { +bool hasEligibleHarborCoast(const World& world, const MapPoint pt) +{ + for(const auto dir : helpers::EnumRange{}) + { + // Skip the NW point because a harbor north of an island often has no usable path from that coastal point. + if(dir != Direction::NorthWest && world.GetSeaFromCoastalPoint(world.GetNeighbour(pt, dir))) + return true; + } + return false; +} + +unsigned getMinimumHarborDistance(const World& world, const MapPoint pt, const std::vector& harborPositions) +{ + unsigned minDistance = std::numeric_limits::max(); + for(const MapPoint harborPt : harborPositions) + minDistance = std::min(minDistance, world.CalcDistance(pt, harborPt)); + return minDistance; +} + +std::vector selectAdditionalHarborSpots(const World& world) +{ + std::vector harborPositions; + harborPositions.reserve(world.GetNumHarborPoints() + MapLoader::MAX_GENERATED_HARBOR_SPOTS); + for(const auto harborId : helpers::idRange(world.GetNumHarborPoints())) + harborPositions.push_back(world.GetHarborPoint(harborId)); + + std::vector candidates; + BQCalculator calcBQ(world); + RTTR_FOREACH_PT(MapPoint, world.GetSize()) + { + if(helpers::contains(harborPositions, pt)) + continue; + if(calcBQ(pt, [](const MapPoint&) { return false; }) != BuildingQuality::Castle) + continue; + if(!hasEligibleHarborCoast(world, pt)) + continue; + if(helpers::contains_if(harborPositions, [&](const MapPoint harborPt) { + return world.CalcDistance(pt, harborPt) < MapLoader::MIN_GENERATED_HARBOR_DISTANCE; + })) + continue; + + candidates.push_back(pt); + } + + std::vector generatedHarbors; + while(generatedHarbors.size() < MapLoader::MAX_GENERATED_HARBOR_SPOTS) + { + MapPoint bestCandidate = MapPoint::Invalid(); + unsigned bestDistance = 0; + for(const MapPoint candidate : candidates) + { + const unsigned distance = getMinimumHarborDistance(world, candidate, harborPositions); + if(distance > bestDistance) + { + bestDistance = distance; + bestCandidate = candidate; + } + } + + if(bestDistance < MapLoader::MIN_GENERATED_HARBOR_DISTANCE) + break; + + generatedHarbors.push_back(bestCandidate); + harborPositions.push_back(bestCandidate); + } + return generatedHarbors; +} +} // namespace + +bool MapLoader::InitSeasAndHarbors(World& world, const std::vector& additionalHarbors, + const bool generateHarborSpots) { for(MapPoint pt : additionalHarbors) world.harborData.push_back(HarborPos(pt)); @@ -549,6 +623,12 @@ bool MapLoader::InitSeasAndHarbors(World& world, const std::vector& ad } } + if(generateHarborSpots) + { + for(MapPoint pt : selectAdditionalHarborSpots(world)) + world.harborData.push_back(HarborPos(pt)); + } + /// Determine seas adjacent to the harbor places HarborId curHarborId(1); for(auto it = world.harborData.begin(); it != world.harborData.end();) diff --git a/libs/s25main/world/MapLoader.h b/libs/s25main/world/MapLoader.h index 44d52d2a77..5d58dc956f 100644 --- a/libs/s25main/world/MapLoader.h +++ b/libs/s25main/world/MapLoader.h @@ -40,6 +40,9 @@ class MapLoader static void CalcHarborPosNeighbors(World& world); public: + static constexpr unsigned MAX_GENERATED_HARBOR_SPOTS = 4; + static constexpr unsigned MIN_GENERATED_HARBOR_DISTANCE = 12; + /// Construct a loader for the given world. explicit MapLoader(GameWorldBase& world); /// Load the map from the given archive, resetting previous state. Return false on error @@ -61,7 +64,8 @@ class MapLoader static void InitShadows(World& world); static void SetMapExplored(World& world); static bool InitSeasAndHarbors(World& world, - const std::vector& additionalHarbors = std::vector()); + const std::vector& additionalHarbors = std::vector(), + bool generateHarborSpots = false); /// Place the HQs on a loaded map and add starting wares if desired. /// Return false if there was an error. static bool PlaceHQs(GameWorldBase& world, const std::vector& hqPositions, bool addStartWares = true); diff --git a/tests/s25Main/integration/testSeaWorldCreation.cpp b/tests/s25Main/integration/testSeaWorldCreation.cpp index 499a480b10..105b80d35b 100644 --- a/tests/s25Main/integration/testSeaWorldCreation.cpp +++ b/tests/s25Main/integration/testSeaWorldCreation.cpp @@ -3,12 +3,21 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include "RTTR_AssertError.h" +#include "RttrForeachPt.h" +#include "addons/const_addons.h" #include "helpers/IdRange.h" +#include "lua/GameDataLoader.h" +#include "worldFixtures/CreateSeaWorld.h" #include "worldFixtures/SeaWorldWithGCExecution.h" +#include "worldFixtures/WorldFixture.h" +#include "worldFixtures/terrainHelpers.h" +#include "world/BQCalculator.h" +#include "world/MapLoader.h" #include "gameTypes/GameTypesOutput.h" #include "gameTypes/ShipDirection.h" #include #include +#include // LCOV_EXCL_START static std::ostream& operator<<(std::ostream& out, const ShipDirection& dir) @@ -61,6 +70,133 @@ void testShipDir(const MapBase& world, const MapPoint fromPt) BOOST_TEST_REQUIRE(getShipDir(world, fromPt, DiffPt(100, -173)) == ShipDirection::NorthEast); BOOST_TEST_REQUIRE(getShipDir(world, fromPt, DiffPt(100, -174)) == ShipDirection::North); } + +void createMarkerlessRectangularIslandWorld(GameWorld& world, const MapExtent size, const MapPoint topLeft, + const MapPoint bottomRight) +{ + world.Unload(); + loadGameData(world.GetDescriptionWriteable()); + world.Init(size); + + const auto water = GetWaterTerrain(world.GetDescription()); + RTTR_FOREACH_PT(MapPoint, world.GetSize()) + { + MapNode& node = world.GetNodeWriteable(pt); + node.t1 = node.t2 = water; + } + + const auto land = GetLandTerrain(world.GetDescription(), ETerrain::Buildable); + for(MapPoint pt(topLeft); pt.y < bottomRight.y; ++pt.y) + { + for(pt.x = topLeft.x; pt.x < bottomRight.x; ++pt.x) + { + MapNode& node = world.GetNodeWriteable(pt); + node.t1 = node.t2 = land; + } + } +} + +void createMarkerlessIslandWorld(GameWorld& world) +{ + createMarkerlessRectangularIslandWorld(world, MapExtent(30, 30), MapPoint(8, 8), MapPoint(22, 22)); +} + +unsigned countHarborBQ(const GameWorld& world) +{ + unsigned result = 0; + RTTR_FOREACH_PT(MapPoint, world.GetSize()) + { + if(world.GetNode(pt).bq == BuildingQuality::Harbor) + ++result; + } + return result; +} + +std::vector getMarkerlessHarborCandidates(const GameWorld& world) +{ + std::vector result; + BQCalculator calcBQ(world); + RTTR_FOREACH_PT(MapPoint, world.GetSize()) + { + if(calcBQ(pt, [](const MapPoint&) { return false; }) != BuildingQuality::Castle) + continue; + + for(const auto dir : helpers::EnumRange{}) + { + if(dir != Direction::NorthWest && world.GetSeaFromCoastalPoint(world.GetNeighbour(pt, dir))) + { + result.push_back(pt); + break; + } + } + } + return result; +} + +std::vector getHarborPointsFrom(const GameWorld& world, const unsigned firstHarborIdx) +{ + std::vector result; + for(unsigned harborIdx = firstHarborIdx; harborIdx <= world.GetNumHarborPoints(); ++harborIdx) + result.push_back(world.GetHarborPoint(HarborId(harborIdx))); + return result; +} + +void testMinimumHarborDistance(const GameWorld& world, const std::vector& harborPoints) +{ + for(unsigned i = 0; i < harborPoints.size(); ++i) + { + for(unsigned j = i + 1; j < harborPoints.size(); ++j) + { + BOOST_TEST_REQUIRE(world.CalcDistance(harborPoints[i], harborPoints[j]) + >= MapLoader::MIN_GENERATED_HARBOR_DISTANCE); + } + } +} + +void testHarborPoint(const GameWorld& world, const HarborId harborId) +{ + const MapPoint harborPt = world.GetHarborPoint(harborId); + BOOST_TEST_REQUIRE(harborPt.isValid()); + BOOST_TEST_REQUIRE(world.GetHarborPointID(harborPt) == harborId); + + bool hasSea = false; + for(const auto dir : helpers::EnumRange{}) + { + const SeaId seaId = world.GetSeaId(harborId, dir); + if(!seaId) + continue; + + hasSea = true; + const MapPoint coastalPt = world.GetCoastalPoint(harborId, seaId); + BOOST_TEST_REQUIRE(coastalPt.isValid()); + BOOST_TEST_REQUIRE(world.GetSeaFromCoastalPoint(coastalPt) == seaId); + } + BOOST_TEST_REQUIRE(hasSea); + BOOST_TEST_REQUIRE(world.GetNode(harborPt).bq == BuildingQuality::Harbor); +} + +using SeaWorldFixture = WorldFixture; + +struct MarkerlessIslandFixture : WorldFixtureBase +{ + MarkerlessIslandFixture() : WorldFixtureBase(3) { createMarkerlessIslandWorld(world); } +}; + +struct SmallMarkerlessIslandFixture : WorldFixtureBase +{ + SmallMarkerlessIslandFixture() : WorldFixtureBase(3) + { + createMarkerlessRectangularIslandWorld(world, MapExtent(24, 24), MapPoint(8, 8), MapPoint(16, 16)); + } +}; + +struct LargeMarkerlessIslandFixture : WorldFixtureBase +{ + LargeMarkerlessIslandFixture() : WorldFixtureBase(3) + { + createMarkerlessRectangularIslandWorld(world, MapExtent(96, 96), MapPoint(16, 16), MapPoint(80, 80)); + } +}; } // namespace BOOST_AUTO_TEST_CASE(GetShipDir) @@ -128,6 +264,163 @@ BOOST_FIXTURE_TEST_CASE(HarborSpotCreation, SeaWorldWithGCExecution<>) } } +BOOST_FIXTURE_TEST_CASE(AdditionalHarborSpotsAddonAddsCoastalHarbors, SeaWorldFixture) +{ + const unsigned initialHarbors = world.GetNumHarborPoints(); + + ggs.setSelection(AddonId::ADDITIONAL_HARBOR_SPOTS, 1); + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world, std::vector(), true)); + world.InitAfterLoad(); + + BOOST_TEST_REQUIRE(world.GetNumHarborPoints() > initialHarbors); + BOOST_TEST_REQUIRE(world.GetNumHarborPoints() <= initialHarbors + MapLoader::MAX_GENERATED_HARBOR_SPOTS); + const std::vector generatedHarbors = getHarborPointsFrom(world, initialHarbors + 1); + testMinimumHarborDistance(world, generatedHarbors); + for(unsigned harborIdx = initialHarbors + 1; harborIdx <= world.GetNumHarborPoints(); ++harborIdx) + { + const MapPoint harborPt = world.GetHarborPoint(HarborId(harborIdx)); + for(unsigned existingHarborIdx = 1; existingHarborIdx <= initialHarbors; ++existingHarborIdx) + { + BOOST_TEST_REQUIRE(world.CalcDistance(harborPt, world.GetHarborPoint(HarborId(existingHarborIdx))) + >= MapLoader::MIN_GENERATED_HARBOR_DISTANCE); + } + testHarborPoint(world, HarborId(harborIdx)); + } +} + +BOOST_FIXTURE_TEST_CASE(AdditionalHarborSpotsAddonWorksWithoutMapMarkers, MarkerlessIslandFixture) +{ + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world)); + world.InitAfterLoad(); + BOOST_TEST_REQUIRE(world.GetNumHarborPoints() == 0u); + BOOST_TEST_REQUIRE(countHarborBQ(world) == 0u); + const std::vector candidates = getMarkerlessHarborCandidates(world); + BOOST_TEST_REQUIRE(candidates.size() > MapLoader::MAX_GENERATED_HARBOR_SPOTS); + + ggs.setSelection(AddonId::ADDITIONAL_HARBOR_SPOTS, 1); + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world)); + world.InitAfterLoad(); + BOOST_TEST_REQUIRE(world.GetNumHarborPoints() == 0u); + BOOST_TEST_REQUIRE(countHarborBQ(world) == 0u); + + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world, std::vector(), true)); + world.InitAfterLoad(); + + BOOST_TEST_REQUIRE(world.GetNumHarborPoints() > 0u); + BOOST_TEST_REQUIRE(world.GetNumHarborPoints() <= MapLoader::MAX_GENERATED_HARBOR_SPOTS); + BOOST_TEST_REQUIRE(world.GetNumHarborPoints() < candidates.size()); + testMinimumHarborDistance(world, getHarborPointsFrom(world, 1)); + for(const auto harborId : helpers::idRange(world.GetNumHarborPoints())) + { + testHarborPoint(world, harborId); + } +} + +BOOST_FIXTURE_TEST_CASE(AdditionalHarborSpotsAddonStopsWhenRemainingCandidatesAreTooClose, SmallMarkerlessIslandFixture) +{ + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world)); + const std::vector candidates = getMarkerlessHarborCandidates(world); + BOOST_TEST_REQUIRE(candidates.size() > MapLoader::MAX_GENERATED_HARBOR_SPOTS); + + for(const MapPoint candidate : candidates) + { + BOOST_TEST_REQUIRE(world.CalcDistance(candidates.front(), candidate) + < MapLoader::MIN_GENERATED_HARBOR_DISTANCE); + } + + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world, std::vector(), true)); + world.InitAfterLoad(); + + const std::vector generatedHarbors = getHarborPointsFrom(world, 1); + BOOST_TEST_REQUIRE(generatedHarbors.size() == 1u); + BOOST_TEST_REQUIRE(generatedHarbors.front().x == candidates.front().x); + BOOST_TEST_REQUIRE(generatedHarbors.front().y == candidates.front().y); + testMinimumHarborDistance(world, generatedHarbors); + testHarborPoint(world, HarborId(1)); +} + +BOOST_FIXTURE_TEST_CASE(AdditionalHarborSpotsAddonIsDeterministic, MarkerlessIslandFixture) +{ + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world, std::vector(), true)); + world.InitAfterLoad(); + const std::vector generatedHarbors = getHarborPointsFrom(world, 1); + BOOST_TEST_REQUIRE(!generatedHarbors.empty()); + + MarkerlessIslandFixture repeatedWorld; + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(repeatedWorld.world, std::vector(), true)); + repeatedWorld.world.InitAfterLoad(); + const std::vector generatedHarborsAgain = getHarborPointsFrom(repeatedWorld.world, 1); + + BOOST_TEST_REQUIRE(generatedHarborsAgain.size() == generatedHarbors.size()); + for(unsigned i = 0; i < generatedHarbors.size(); ++i) + { + BOOST_TEST_REQUIRE(generatedHarborsAgain[i].x == generatedHarbors[i].x); + BOOST_TEST_REQUIRE(generatedHarborsAgain[i].y == generatedHarbors[i].y); + } +} + +BOOST_FIXTURE_TEST_CASE(AdditionalHarborSpotsAddonSpreadsGeneratedHarborsBeyondEarlyScanCluster, + LargeMarkerlessIslandFixture) +{ + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world, std::vector(), true)); + world.InitAfterLoad(); + + const std::vector generatedHarbors = getHarborPointsFrom(world, 1); + BOOST_TEST_REQUIRE(generatedHarbors.size() == MapLoader::MAX_GENERATED_HARBOR_SPOTS); + testMinimumHarborDistance(world, generatedHarbors); + + BOOST_TEST_REQUIRE( + std::any_of(generatedHarbors.begin(), generatedHarbors.end(), [](const MapPoint pt) { return pt.x >= 70; })); + BOOST_TEST_REQUIRE( + std::any_of(generatedHarbors.begin(), generatedHarbors.end(), [](const MapPoint pt) { return pt.y >= 70; })); +} + +BOOST_FIXTURE_TEST_CASE(AdditionalHarborSpotsAddonKeepsGeneratedHarborsAwayFromExistingOnes, MarkerlessIslandFixture) +{ + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world)); + const std::vector candidates = getMarkerlessHarborCandidates(world); + BOOST_TEST_REQUIRE(!candidates.empty()); + + const MapPoint existingHarbor = candidates.front(); + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world, {existingHarbor}, true)); + world.InitAfterLoad(); + + BOOST_TEST_REQUIRE(world.GetNumHarborPoints() > 1u); + BOOST_TEST_REQUIRE(world.GetHarborPoint(HarborId(1)).x == existingHarbor.x); + BOOST_TEST_REQUIRE(world.GetHarborPoint(HarborId(1)).y == existingHarbor.y); + for(unsigned harborIdx = 2; harborIdx <= world.GetNumHarborPoints(); ++harborIdx) + { + BOOST_TEST_REQUIRE(world.CalcDistance(existingHarbor, world.GetHarborPoint(HarborId(harborIdx))) + >= MapLoader::MIN_GENERATED_HARBOR_DISTANCE); + } +} + +BOOST_FIXTURE_TEST_CASE(AdditionalHarborSpotsAddonDoesNotAffectRuntimeBQRecalculation, MarkerlessIslandFixture) +{ + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world)); + const std::vector candidates = getMarkerlessHarborCandidates(world); + BOOST_TEST_REQUIRE(!candidates.empty()); + + ggs.setSelection(AddonId::ADDITIONAL_HARBOR_SPOTS, 1); + world.RecalcBQ(candidates.front()); + + BOOST_TEST_REQUIRE(world.GetNumHarborPoints() == 0u); + BOOST_TEST_REQUIRE(world.GetNode(candidates.front()).bq != BuildingQuality::Harbor); +} + +BOOST_FIXTURE_TEST_CASE(RuntimeBQRecalculationKeepsExistingHarborBQ, SeaWorldFixture) +{ + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world)); + world.InitAfterLoad(); + + const MapPoint harborPt = world.GetHarborPoint(HarborId(1)); + BOOST_TEST_REQUIRE(world.GetNode(harborPt).bq == BuildingQuality::Harbor); + + world.RecalcBQ(harborPt); + + BOOST_TEST_REQUIRE(world.GetNode(harborPt).bq == BuildingQuality::Harbor); +} + BOOST_FIXTURE_TEST_CASE(HarborNeighbors, SeaWorldWithGCExecution<>) { // Now just test some assumptions: 2 harbor spots per possible HQ.