diff --git a/libs/s25main/GlobalGameSettings.cpp b/libs/s25main/GlobalGameSettings.cpp index f5047f9893..1ab4a794a3 100644 --- a/libs/s25main/GlobalGameSettings.cpp +++ b/libs/s25main/GlobalGameSettings.cpp @@ -75,6 +75,7 @@ void GlobalGameSettings::registerAllAddons() AddonDurableGeologistSigns, AddonEconomyModeGameLength, AddonExhaustibleWater, + AddonFreeHarborSpots, AddonFrontierDistanceReachable, AddonHalfCostMilEquip, AddonInexhaustibleFish, diff --git a/libs/s25main/addons/AddonFreeHarborSpots.h b/libs/s25main/addons/AddonFreeHarborSpots.h new file mode 100644 index 0000000000..345877e973 --- /dev/null +++ b/libs/s25main/addons/AddonFreeHarborSpots.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 AddonFreeHarborSpots : public AddonBool +{ +public: + AddonFreeHarborSpots() + : AddonBool(AddonId::FREE_HARBOR_SPOTS, AddonGroup::GamePlay, _("Dangerous: Add limited extra harbor spots"), + _("Advanced option. Adds only a small deterministic set of suitable coastal castle sites as extra " + "harbor spots. May alter intended map seafaring design.")) + {} +}; diff --git a/libs/s25main/addons/Addons.h b/libs/s25main/addons/Addons.h index 96b3203ff7..27a00039b0 100644 --- a/libs/s25main/addons/Addons.h +++ b/libs/s25main/addons/Addons.h @@ -53,6 +53,7 @@ #include "addons/AddonCoinsCapturedBld.h" #include "addons/AddonDemolishBldWORes.h" +#include "addons/AddonFreeHarborSpots.h" #include "addons/AddonFrontierDistanceReachable.h" #include "addons/AddonDurableGeologistSigns.h" diff --git a/libs/s25main/addons/const_addons.h b/libs/s25main/addons/const_addons.h index f7ddf54f08..bd7cedfa4c 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, FREE_HARBOR_SPOTS = 0x01000004, FORESTER_FARM_FIELD_AVOIDANCE = 0x01100000, diff --git a/libs/s25main/world/BQCalculator.h b/libs/s25main/world/BQCalculator.h index 88db722d3c..750d77cf81 100644 --- a/libs/s25main/world/BQCalculator.h +++ b/libs/s25main/world/BQCalculator.h @@ -10,13 +10,16 @@ struct BQCalculator { - BQCalculator(const World& world) : world(world) {} + BQCalculator(const World& world, const bool allowHarborsWithoutMapMarkers = false) + : world(world), allowHarborsWithoutMapMarkers(allowHarborsWithoutMapMarkers) + {} template BuildingQuality operator()(MapPoint pt, T_IsOnRoad isOnRoad, bool flagOnly = false) const; private: const World& world; + bool allowHarborsWithoutMapMarkers; }; template @@ -219,8 +222,23 @@ BuildingQuality BQCalculator::operator()(const MapPoint pt, T_IsOnRoad isOnRoad, } // If we can build a castle and this is a harbor point -> Allow harbor - if(curBQ == BuildingQuality::Castle && world.GetNode(pt).harborId) - curBQ = BuildingQuality::Harbor; + if(curBQ == BuildingQuality::Castle) + { + if(world.GetNode(pt).harborId.isValid()) + curBQ = BuildingQuality::Harbor; + else if(allowHarborsWithoutMapMarkers) + { + for(const auto dir : helpers::EnumRange{}) + { + // Keep this in sync with harbor initialization: NW-only coasts are rejected there. + if(dir != Direction::NorthWest && world.GetSeaFromCoastalPoint(neighbours[dir])) + { + curBQ = BuildingQuality::Harbor; + break; + } + } + } + } ////////////////////////////////////////////////////////////////////////// // At this point we can still build a building/mine diff --git a/libs/s25main/world/MapLoader.cpp b/libs/s25main/world/MapLoader.cpp index da4d04a4a3..7f8735ee31 100644 --- a/libs/s25main/world/MapLoader.cpp +++ b/libs/s25main/world/MapLoader.cpp @@ -3,12 +3,14 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include "world/MapLoader.h" +#include "BQCalculator.h" #include "Game.h" #include "GamePlayer.h" #include "GameWorldBase.h" #include "GlobalGameSettings.h" #include "PointOutput.h" #include "RttrForeachPt.h" +#include "addons/const_addons.h" #include "buildings/nobHQ.h" #include "factories/BuildingFactory.h" #include "helpers/IdRange.h" @@ -53,7 +55,7 @@ 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::FREE_HARBOR_SPOTS))) return false; /// Schatten @@ -420,7 +422,52 @@ bool MapLoader::PlaceHQs(GameWorldBase& world, const std::vector& hqPo return true; } -bool MapLoader::InitSeasAndHarbors(World& world, const std::vector& additionalHarbors) +namespace { +bool hasHarborAt(const World& world, const MapPoint pt) +{ + for(const auto harborId : helpers::idRange(world.GetNumHarborPoints())) + { + if(world.GetHarborPoint(harborId) == pt) + return true; + } + return false; +} + +bool isFarEnoughFromHarbors(const World& world, const MapPoint pt, const std::vector& generatedHarbors) +{ + for(const auto harborId : helpers::idRange(world.GetNumHarborPoints())) + { + if(world.CalcDistance(pt, world.GetHarborPoint(harborId)) < MapLoader::MIN_GENERATED_HARBOR_DISTANCE) + return false; + } + for(const MapPoint generatedHarbor : generatedHarbors) + { + if(world.CalcDistance(pt, generatedHarbor) < MapLoader::MIN_GENERATED_HARBOR_DISTANCE) + return false; + } + return true; +} + +std::vector getGeneratedHarbors(const World& world) +{ + std::vector generatedHarbors; + BQCalculator calcBQ(world, true); + RTTR_FOREACH_PT(MapPoint, world.GetSize()) + { + if(!hasHarborAt(world, pt) && calcBQ(pt, [](const MapPoint&) { return false; }) == BuildingQuality::Harbor + && isFarEnoughFromHarbors(world, pt, generatedHarbors)) + { + generatedHarbors.push_back(pt); + if(generatedHarbors.size() == MapLoader::MAX_GENERATED_HARBOR_SPOTS) + return generatedHarbors; + } + } + 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)); @@ -446,6 +493,12 @@ bool MapLoader::InitSeasAndHarbors(World& world, const std::vector& ad } } + if(generateHarborSpots) + { + for(MapPoint pt : getGeneratedHarbors(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 4dd35e9a36..efe7976bfc 100644 --- a/libs/s25main/world/MapLoader.h +++ b/libs/s25main/world/MapLoader.h @@ -39,6 +39,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 @@ -57,7 +60,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..e0b50c93c3 100644 --- a/tests/s25Main/integration/testSeaWorldCreation.cpp +++ b/tests/s25Main/integration/testSeaWorldCreation.cpp @@ -3,8 +3,16 @@ // 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 @@ -61,6 +69,102 @@ 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 createMarkerlessIslandWorld(GameWorld& world) +{ + world.Unload(); + loadGameData(world.GetDescriptionWriteable()); + world.Init(MapExtent(30, 30)); + + 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(8, 8); pt.y < 22; ++pt.y) + { + for(pt.x = 8; pt.x < 22; ++pt.x) + { + MapNode& node = world.GetNodeWriteable(pt); + node.t1 = node.t2 = land; + } + } +} + +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, true); + RTTR_FOREACH_PT(MapPoint, world.GetSize()) + { + if(calcBQ(pt, [](const MapPoint&) { return false; }) == BuildingQuality::Harbor) + result.push_back(pt); + } + 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); } +}; } // namespace BOOST_AUTO_TEST_CASE(GetShipDir) @@ -128,6 +232,91 @@ BOOST_FIXTURE_TEST_CASE(HarborSpotCreation, SeaWorldWithGCExecution<>) } } +BOOST_FIXTURE_TEST_CASE(FreeHarborSpotsAddonAddsCoastalHarbors, SeaWorldFixture) +{ + const unsigned initialHarbors = world.GetNumHarborPoints(); + + ggs.setSelection(AddonId::FREE_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(FreeHarborSpotsAddonWorksWithoutMapMarkers, 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::FREE_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(FreeHarborSpotsAddonKeepsGeneratedHarborsAwayFromExistingOnes, 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(FreeHarborSpotsAddonDoesNotAffectRuntimeBQRecalculation, MarkerlessIslandFixture) +{ + BOOST_TEST_REQUIRE(MapLoader::InitSeasAndHarbors(world)); + const std::vector candidates = getMarkerlessHarborCandidates(world); + BOOST_TEST_REQUIRE(!candidates.empty()); + + ggs.setSelection(AddonId::FREE_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(HarborNeighbors, SeaWorldWithGCExecution<>) { // Now just test some assumptions: 2 harbor spots per possible HQ.