diff --git a/doc/lua/functions.md b/doc/lua/functions.md index b49cd978d0..927dab53d1 100644 --- a/doc/lua/functions.md +++ b/doc/lua/functions.md @@ -113,6 +113,11 @@ rttr:SetAddon(ADDON_FRONTIER_DISTANCE_REACHABLE, true) For addons which can be enabled / disabled simply use true or false, for addons with different settings, add the number according to your setting. +Deprecated addon names, kept working for older scripts: + +- `ADDON_INEXHAUSTIBLE_GRANITEMINES` is an alias for `ADDON_GRANITEMINE_RESOURCE_BEHAVIOR`. The old `true` still selects the inexhaustible behavior. +- `ADDON_INEXHAUSTIBLE_MINES` is no longer an addon of its own. Setting it to `true` makes every mine type that is still at its default behavior inexhaustible, setting it to `false` does nothing. Use the per-mine `ADDON_*MINE_RESOURCE_BEHAVIOR` addons instead. + **rttr:ResetAddons()** Set all addons to S2 defaults diff --git a/libs/common/include/helpers/MaxEnumValue.h b/libs/common/include/helpers/MaxEnumValue.h index 4593eff7e4..8b5aa1f40c 100644 --- a/libs/common/include/helpers/MaxEnumValue.h +++ b/libs/common/include/helpers/MaxEnumValue.h @@ -30,4 +30,11 @@ inline constexpr unsigned MaxEnumValue_v = static_cast inline constexpr unsigned NumEnumValues_v = MaxEnumValue_v + 1u; +/// Check whether a numeric value is a valid enumerator for an enum type +template +constexpr bool isValidEnumValue(unsigned value) +{ + return value <= MaxEnumValue_v; +} + } // namespace helpers diff --git a/libs/s25main/GlobalGameSettings.cpp b/libs/s25main/GlobalGameSettings.cpp index f5047f9893..ac0c65a02d 100644 --- a/libs/s25main/GlobalGameSettings.cpp +++ b/libs/s25main/GlobalGameSettings.cpp @@ -8,15 +8,23 @@ #include "addons/Addons.h" #include "helpers/containerUtils.h" #include "helpers/serializeEnums.h" +#include "gameTypes/BuildingType.h" +#include "gameTypes/MineResourceBehavior.h" #include "gameData/MilitaryConsts.h" #include "s25util/Log.h" #include "s25util/Serializer.h" #include #include #include +#include #include #include +namespace { +constexpr std::array MINE_BUILDING_TYPES = {BuildingType::GraniteMine, BuildingType::CoalMine, + BuildingType::IronMine, BuildingType::GoldMine}; +} // namespace + GlobalGameSettings::GlobalGameSettings() : speed(GameSpeed::Normal), objective(GameObjective::None), startWares(StartWares::Normal), lockedTeams(false), exploration(Exploration::FogOfWar), teamView(true), randomStartPosition(false) @@ -78,8 +86,11 @@ void GlobalGameSettings::registerAllAddons() AddonFrontierDistanceReachable, AddonHalfCostMilEquip, AddonInexhaustibleFish, - AddonInexhaustibleGraniteMines, - AddonInexhaustibleMines, + AddonGraniteMineResourceBehavior, + AddonCoalMineResourceBehavior, + AddonIronMineResourceBehavior, + AddonGoldMineResourceBehavior, + AddonMineNoOutputFallback, AddonLimitCatapults, AddonManualRoadEnlargement, AddonMaxRank, @@ -187,8 +198,21 @@ void GlobalGameSettings::LoadSettings() { resetAddons(); + bool migrateLegacyInexhaustibleMines = false; for(const auto& it : SETTINGS.addons.configuration) - setSelection(static_cast(it.first), it.second); + { + const auto id = static_cast(it.first); + const unsigned status = it.second; + if(id == AddonId::INEXHAUSTIBLE_MINES) + { + migrateLegacyInexhaustibleMines = status != 0; + continue; + } + + setSelection(id, status); + } + if(migrateLegacyInexhaustibleMines) + applyLegacyInexhaustibleMines(); } /** @@ -243,12 +267,34 @@ void GlobalGameSettings::Deserialize(Serializer& ser) resetAddons(); + bool migrateLegacyInexhaustibleMines = false; for(unsigned i = 0; i < count; ++i) { auto addon = static_cast(ser.PopUnsignedInt()); unsigned status = ser.PopUnsignedInt(); + if(addon == AddonId::INEXHAUSTIBLE_MINES) + { + migrateLegacyInexhaustibleMines = status != 0; + continue; + } + setSelection(addon, status); } + if(migrateLegacyInexhaustibleMines) + applyLegacyInexhaustibleMines(); +} + +/// Only mine types with an explicitly configured non-default behavior keep their setting. This is required because +/// GRANITEMINE_RESOURCE_BEHAVIOR reuses the id of the old INEXHAUSTIBLE_GRANITEMINES bool addon, so old data always +/// contains a value for it (usually 0), while the old global setting made granite mines inexhaustible as well. +void GlobalGameSettings::applyLegacyInexhaustibleMines() +{ + for(const BuildingType mineType : MINE_BUILDING_TYPES) + { + if(GetMineResourceBehavior(*this, mineType) == MineResourceBehavior::Default) + setSelection(GetMineResourceBehaviorAddonId(mineType), + static_cast(MineResourceBehavior::Inexhaustible)); + } } void GlobalGameSettings::setSelection(AddonId id, unsigned selection) diff --git a/libs/s25main/GlobalGameSettings.h b/libs/s25main/GlobalGameSettings.h index f37d52ab12..111c0c04df 100644 --- a/libs/s25main/GlobalGameSettings.h +++ b/libs/s25main/GlobalGameSettings.h @@ -47,6 +47,9 @@ class GlobalGameSettings bool isEnabled(AddonId id) const; unsigned getSelection(AddonId id) const; void setSelection(AddonId id, unsigned selection); + /// Apply the removed global INEXHAUSTIBLE_MINES addon: make every mine type that is still at its default behavior + /// inexhaustible. Used to migrate old settings, savegames and Lua scripts. + void applyLegacyInexhaustibleMines(); /// loads the saved addon configuration from the SETTINGS. void LoadSettings(); diff --git a/libs/s25main/addons/AddonInexhaustibleGraniteMines.h b/libs/s25main/addons/AddonInexhaustibleGraniteMines.h deleted file mode 100644 index 418d30e791..0000000000 --- a/libs/s25main/addons/AddonInexhaustibleGraniteMines.h +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (C) 2005 - 2021 Settlers Freaks -// -// SPDX-License-Identifier: GPL-2.0-or-later - -#pragma once - -#include "AddonBool.h" -#include "mygettext/mygettext.h" - -/** - * Addon for allowing to have unlimited resources. - */ -class AddonInexhaustibleGraniteMines : public AddonBool -{ -public: - AddonInexhaustibleGraniteMines() - : AddonBool(AddonId::INEXHAUSTIBLE_GRANITEMINES, AddonGroup::Economy, _("Inexhaustible Granite Mines"), - _("Granite mines will never be depleted.")) - {} -}; diff --git a/libs/s25main/addons/AddonInexhaustibleMines.h b/libs/s25main/addons/AddonInexhaustibleMines.h index 5f4f342eb1..2265781915 100644 --- a/libs/s25main/addons/AddonInexhaustibleMines.h +++ b/libs/s25main/addons/AddonInexhaustibleMines.h @@ -8,7 +8,13 @@ #include "mygettext/mygettext.h" /** - * Addon for allowing to have unlimited resources. + * Deprecated global mine setting. + * + * Not registered anymore. The ID is still decoded when loading old settings/savegames and migrated to the per-mine + * resource behavior settings. + * + * TODO(Replay) TODO(Savegame): Remove this compatibility class when legacy global mine settings no longer need + * migration. */ class AddonInexhaustibleMines : public AddonBool { diff --git a/libs/s25main/addons/AddonMineNoOutputFallback.h b/libs/s25main/addons/AddonMineNoOutputFallback.h new file mode 100644 index 0000000000..be8536f713 --- /dev/null +++ b/libs/s25main/addons/AddonMineNoOutputFallback.h @@ -0,0 +1,22 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "AddonList.h" +#include "const_addons.h" +#include "mygettext/mygettext.h" +#include "gameTypes/MineNoOutputFallback.h" + +class AddonMineNoOutputFallback : public AddonList +{ +public: + AddonMineNoOutputFallback() + : AddonList(AddonId::MINE_NO_OUTPUT_FALLBACK, AddonGroup::Economy, _("Mine No-Output Fallback"), + _("Configures what mines produce when S4-like exhaustion would produce nothing."), + {_("Produce nothing"), _("Produce granite 25%"), _("Produce granite 50%"), + _("Produce granite 100%"), _("Produce lower grade resource")}, + static_cast(MineNoOutputFallback::ProduceNothing)) + {} +}; diff --git a/libs/s25main/addons/AddonMineResourceBehavior.h b/libs/s25main/addons/AddonMineResourceBehavior.h new file mode 100644 index 0000000000..ba6a356824 --- /dev/null +++ b/libs/s25main/addons/AddonMineResourceBehavior.h @@ -0,0 +1,63 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "AddonList.h" +#include "const_addons.h" +#include "mygettext/mygettext.h" +#include "gameTypes/MineResourceBehavior.h" +#include + +class AddonMineResourceBehaviorBase : public AddonList +{ +protected: + AddonMineResourceBehaviorBase(AddonId id, const std::string& name, const std::string& description) + : AddonList(id, AddonGroup::Economy, name, description, + {_("Default"), _("Inexhaustible"), _("S4-like exhaustion"), _("Work everywhere")}, + static_cast(MineResourceBehavior::Default)) + {} +}; + +/** + * Granite mine resource behavior list. + * + * Reuses the legacy granite-mine addon value 0x00800000: saved value 0 remains the default behavior and saved value 1 + * selects the inexhaustible behavior. + */ +class AddonGraniteMineResourceBehavior : public AddonMineResourceBehaviorBase +{ +public: + AddonGraniteMineResourceBehavior() + : AddonMineResourceBehaviorBase(AddonId::GRANITEMINE_RESOURCE_BEHAVIOR, _("Granite Mine Resource Behavior"), + _("Configures how granite mines consume and exhaust stone deposits.")) + {} +}; + +class AddonCoalMineResourceBehavior : public AddonMineResourceBehaviorBase +{ +public: + AddonCoalMineResourceBehavior() + : AddonMineResourceBehaviorBase(AddonId::COALMINE_RESOURCE_BEHAVIOR, _("Coal Mine Resource Behavior"), + _("Configures how coal mines consume and exhaust coal deposits.")) + {} +}; + +class AddonIronMineResourceBehavior : public AddonMineResourceBehaviorBase +{ +public: + AddonIronMineResourceBehavior() + : AddonMineResourceBehaviorBase(AddonId::IRONMINE_RESOURCE_BEHAVIOR, _("Iron Mine Resource Behavior"), + _("Configures how iron mines consume and exhaust iron deposits.")) + {} +}; + +class AddonGoldMineResourceBehavior : public AddonMineResourceBehaviorBase +{ +public: + AddonGoldMineResourceBehavior() + : AddonMineResourceBehaviorBase(AddonId::GOLDMINE_RESOURCE_BEHAVIOR, _("Gold Mine Resource Behavior"), + _("Configures how gold mines consume and exhaust gold deposits.")) + {} +}; diff --git a/libs/s25main/addons/Addons.h b/libs/s25main/addons/Addons.h index 96b3203ff7..6f93c9226a 100644 --- a/libs/s25main/addons/Addons.h +++ b/libs/s25main/addons/Addons.h @@ -34,9 +34,10 @@ #include "addons/AddonToolOrdering.h" #include "addons/AddonInexhaustibleFish.h" -#include "addons/AddonInexhaustibleGraniteMines.h" #include "addons/AddonMaxRank.h" #include "addons/AddonMilitaryAid.h" +#include "addons/AddonMineNoOutputFallback.h" +#include "addons/AddonMineResourceBehavior.h" #include "addons/AddonSeaAttack.h" #include "addons/AddonBattlefieldPromotion.h" diff --git a/libs/s25main/addons/const_addons.h b/libs/s25main/addons/const_addons.h index f7ddf54f08..877f73ec38 100644 --- a/libs/s25main/addons/const_addons.h +++ b/libs/s25main/addons/const_addons.h @@ -38,6 +38,8 @@ // // Add the #include for your AddonXXX.h in Addons.h! // +// TODO(Replay) TODO(Savegame): Remove INEXHAUSTIBLE_MINES once the gamedata version can be raised and legacy +// settings/savegames no longer need migration. ENUM_WITH_STRING(AddonId, LIMIT_CATAPULTS = 0x00000000, INEXHAUSTIBLE_MINES = 0x00000001, REFUND_MATERIALS = 0x00000002, EXHAUSTIBLE_WATER = 0x00000003, REFUND_ON_EMERGENCY = 0x00000004, MANUAL_ROAD_ENLARGEMENT = 0x00000005, CATAPULT_GRAPHICS = 0x00000006, METALWORKSBEHAVIORONZERO = 0x00000007, @@ -57,7 +59,9 @@ ENUM_WITH_STRING(AddonId, LIMIT_CATAPULTS = 0x00000000, INEXHAUSTIBLE_MINES = 0x MILITARY_AID = 0x00700000, - INEXHAUSTIBLE_GRANITEMINES = 0x00800000, + GRANITEMINE_RESOURCE_BEHAVIOR = 0x00800000, COALMINE_RESOURCE_BEHAVIOR = 0x00800001, + IRONMINE_RESOURCE_BEHAVIOR = 0x00800002, GOLDMINE_RESOURCE_BEHAVIOR = 0x00800003, + MINE_NO_OUTPUT_FALLBACK = 0x00800004, MAX_RANK = 0x00900000, SEA_ATTACK = 0x00900001, INEXHAUSTIBLE_FISH = 0x00900002, MORE_ANIMALS = 0x00900003, BURN_DURATION = 0x00900004, NO_ALLIED_PUSH = 0x00900005, diff --git a/libs/s25main/ai/AIInterface.cpp b/libs/s25main/ai/AIInterface.cpp index 8a2a59f042..fe714702e0 100644 --- a/libs/s25main/ai/AIInterface.cpp +++ b/libs/s25main/ai/AIInterface.cpp @@ -17,6 +17,7 @@ #include "pathfinding/RoadPathFinder.h" #include "nodeObjs/noFlag.h" #include "nodeObjs/noTree.h" +#include "gameTypes/MineResourceBehavior.h" #include "gameData/TerrainDesc.h" #include #include @@ -45,6 +46,14 @@ bool IsPointOK_RoadPathEvenStep(const GameWorldBase& gwb, const MapPoint pt, con const auto* prp = static_cast(param); return prp->boat_road || gwb.GetBQ(pt, gwb.GetNode(pt).owner - 1) != BuildingQuality::Nothing; } + +int GetS4LikeMineResourceRating(const Resource resource, const unsigned defaultRating) +{ + if(resource.getAmount() == 0u) + return 0; + + return std::max(1u, std::min(static_cast(resource.getAmount()), defaultRating)); +} } // namespace AIInterface::AIInterface(const GameWorldBase& gwb, std::vector& gcs, unsigned char playerID) @@ -158,6 +167,23 @@ int AIInterface::GetResourceRating(const MapPoint pt, AIResource res) const case AIResource::Ironore: case AIResource::Coal: case AIResource::Granite: + { + const Resource subres = gwb.GetNode(pt).resources; + if(convertToNodeResource(GetSubsurfaceResource(pt)) == res) + { + const auto mineBuildingType = GetMineBuildingType(subres.getType()); + if(mineBuildingType + && GetMineResourceBehavior(gwb.GetGGS(), *mineBuildingType) + == MineResourceBehavior::S4LikeExhaustion) + return GetS4LikeMineResourceRating(subres, RES_RADIUS[res]); + + return RES_RADIUS[res]; + } + if(IsMineResourceWorkEverywhere(res) + && gwb.IsOfTerrain(pt, [](const TerrainDesc& desc) { return desc.Is(ETerrain::Mineable); })) + return RES_RADIUS[res]; + break; + } case AIResource::Fish: if(convertToNodeResource(GetSubsurfaceResource(pt)) == res) return RES_RADIUS[res]; @@ -166,6 +192,17 @@ int AIInterface::GetResourceRating(const MapPoint pt, AIResource res) const return 0; } +bool AIInterface::IsMineResourceWorkEverywhere(const AIResource res) const +{ + const auto resourceType = convertToResourceType(res); + if(!resourceType) + return false; + + const auto mineBuildingType = GetMineBuildingType(*resourceType); + return mineBuildingType + && GetMineResourceBehavior(gwb.GetGGS(), *mineBuildingType) == MineResourceBehavior::WorkEverywhere; +} + int AIInterface::CalcResourceValue(const MapPoint pt, AIResource res, helpers::OptionalEnum direction, int lastval) const { diff --git a/libs/s25main/ai/AIInterface.h b/libs/s25main/ai/AIInterface.h index 763f4b39f5..0fefdb780c 100644 --- a/libs/s25main/ai/AIInterface.h +++ b/libs/s25main/ai/AIInterface.h @@ -52,6 +52,8 @@ class AIInterface : public GameCommandFactory int lastval = 0xffff) const; /// Calculate the resource value for a given point int GetResourceRating(MapPoint pt, AIResource res) const; + /// Check whether the given mine resource can be produced on otherwise empty mineable mountain. + bool IsMineResourceWorkEverywhere(AIResource res) const; /// Test whether a given point is part of the border or not bool IsBorder(const MapPoint pt) const { diff --git a/libs/s25main/ai/AIResource.h b/libs/s25main/ai/AIResource.h index 9d2270e74b..7defefd42f 100644 --- a/libs/s25main/ai/AIResource.h +++ b/libs/s25main/ai/AIResource.h @@ -5,6 +5,8 @@ #pragma once #include "helpers/EnumArray.h" +#include "helpers/OptionalEnum.h" +#include "gameTypes/Resource.h" #include "s25util/warningSuppression.h" // Note: This enums are constructed for performance and easy conversion. @@ -86,6 +88,19 @@ constexpr bool operator==(AINodeResource lhs, AIResource rhs) return lhs == convertToNodeResource(rhs); } +inline helpers::OptionalEnum convertToResourceType(AIResource res) +{ + switch(res) + { + case AIResource::Gold: return ResourceType::Gold; + case AIResource::Ironore: return ResourceType::Iron; + case AIResource::Coal: return ResourceType::Coal; + case AIResource::Granite: return ResourceType::Granite; + case AIResource::Fish: return ResourceType::Fish; + default: return {}; + } +} + constexpr helpers::EnumArray SUPPRESS_UNUSED RES_RADIUS = { 2, // Gold 2, // Ironore diff --git a/libs/s25main/ai/aijh/AIPlayerJH.cpp b/libs/s25main/ai/aijh/AIPlayerJH.cpp index 72fbee111f..78eacd5263 100644 --- a/libs/s25main/ai/aijh/AIPlayerJH.cpp +++ b/libs/s25main/ai/aijh/AIPlayerJH.cpp @@ -32,6 +32,7 @@ #include "nodeObjs/noFlag.h" #include "nodeObjs/noShip.h" #include "nodeObjs/noTree.h" +#include "gameTypes/MineResourceBehavior.h" #include "gameData/BuildingConsts.h" #include "gameData/BuildingProperties.h" #include "gameData/GameConsts.h" @@ -139,11 +140,10 @@ static bool isUnlimitedResource(const AIResource res, const GlobalGameSettings& { switch(res) { - case AIResource::Gold: - case AIResource::Ironore: - case AIResource::Coal: return ggs.isEnabled(AddonId::INEXHAUSTIBLE_MINES); - case AIResource::Granite: - return ggs.isEnabled(AddonId::INEXHAUSTIBLE_MINES) || ggs.isEnabled(AddonId::INEXHAUSTIBLE_GRANITEMINES); + case AIResource::Gold: return !IsMineResourceDepletable(ggs, BuildingType::GoldMine); + case AIResource::Ironore: return !IsMineResourceDepletable(ggs, BuildingType::IronMine); + case AIResource::Coal: return !IsMineResourceDepletable(ggs, BuildingType::CoalMine); + case AIResource::Granite: return !IsMineResourceDepletable(ggs, BuildingType::GraniteMine); case AIResource::Fish: return ggs.isEnabled(AddonId::INEXHAUSTIBLE_FISH); default: return false; } @@ -153,8 +153,8 @@ static bool isUnlimitedResource(const AIResource res, const GlobalGameSettings& template static auto createResourceMaps(const AIInterface& aii, const AIMap& aiMap, std::index_sequence) { - return helpers::EnumArray{ - AIResourceMap(AIResource(I), isUnlimitedResource(AIResource(I), aii.gwb.GetGGS()), aii, aiMap)...}; + return helpers::EnumArray{AIResourceMap( + static_cast(I), isUnlimitedResource(static_cast(I), aii.gwb.GetGGS()), aii, aiMap)...}; } static auto createResourceMaps(const AIInterface& aii, const AIMap& aiMap) { diff --git a/libs/s25main/ai/aijh/BuildingPlanner.cpp b/libs/s25main/ai/aijh/BuildingPlanner.cpp index f626d89477..736ccfba9b 100644 --- a/libs/s25main/ai/aijh/BuildingPlanner.cpp +++ b/libs/s25main/ai/aijh/BuildingPlanner.cpp @@ -10,11 +10,21 @@ #include "buildings/nobMilitary.h" #include "gameTypes/BuildingType.h" #include "gameTypes/GoodTypes.h" +#include "gameTypes/MineResourceBehavior.h" #include "gameData/BuildingProperties.h" #include #include namespace AIJH { +namespace { + bool HasAnyInexhaustibleOreMine(const GlobalGameSettings& ggs) + { + return !IsMineResourceDepletable(ggs, BuildingType::CoalMine) + || !IsMineResourceDepletable(ggs, BuildingType::IronMine) + || !IsMineResourceDepletable(ggs, BuildingType::GoldMine); + } +} // namespace + BuildingPlanner::BuildingPlanner(const AIPlayerJH& aijh) : buildingsWanted(), expansionRequired(false) { RefreshBuildingNums(aijh); @@ -224,7 +234,7 @@ void BuildingPlanner::UpdateBuildingsWanted(const AIPlayerJH& aijh) // brewery count = 1+(armory/5) if there is at least 1 armory or armory /6 for exhaustible mines if(GetNumBuildings(BuildingType::Armory) > 0 && GetNumBuildings(BuildingType::Farm) > 0) { - if(aijh.ggs.isEnabled(AddonId::INEXHAUSTIBLE_MINES)) + if(HasAnyInexhaustibleOreMine(aijh.ggs)) buildingsWanted[BuildingType::Brewery] = 1 + GetNumBuildings(BuildingType::Armory) / 5; else buildingsWanted[BuildingType::Brewery] = 1 + GetNumBuildings(BuildingType::Armory) / 6; @@ -292,8 +302,7 @@ void BuildingPlanner::UpdateBuildingsWanted(const AIPlayerJH& aijh) (GetNumBuildings(BuildingType::Farm) + GetNumBuildings(BuildingType::Fishery)) / 2 + 2; if(GetNumBuildings(BuildingType::Farm) > 7) // quite the empire just scale mines with farms { - if(aijh.ggs.isEnabled( - AddonId::INEXHAUSTIBLE_MINES)) // inexhaustible mines? -> more farms required for each mine + if(HasAnyInexhaustibleOreMine(aijh.ggs)) // inexhaustible mines? -> more farms required for each mine buildingsWanted[BuildingType::IronMine] = std::min(GetNumBuildings(BuildingType::Ironsmelter) + 1, GetNumBuildings(BuildingType::Farm) * 2 / 5); else diff --git a/libs/s25main/buildings/nobUsual.cpp b/libs/s25main/buildings/nobUsual.cpp index 9ed430b514..32db3a2883 100644 --- a/libs/s25main/buildings/nobUsual.cpp +++ b/libs/s25main/buildings/nobUsual.cpp @@ -19,8 +19,11 @@ #include "ogl/glArchivItem_Bitmap_Player.h" #include "postSystem/PostMsgWithBuilding.h" #include "world/GameWorld.h" +#include "gameTypes/MineResourceBehavior.h" +#include "gameTypes/Resource.h" #include "gameData/BuildingConsts.h" #include "gameData/BuildingProperties.h" +#include "gameData/GameConsts.h" #include /// Number of GFs after which the productivity is recalculated, i.e. productivity is averaged over intervals of this @@ -519,6 +522,17 @@ bool nobUsual::HasWorker() const return worker && worker->GetState() != nofBuildingWorker::State::FigureWork; } +unsigned short nobUsual::GetProductivity() const +{ + if(!BuildingProperties::IsMine(bldType_) + || GetMineResourceBehavior(world->GetGGS(), bldType_) != MineResourceBehavior::S4LikeExhaustion) + return productivity; + + const unsigned resourceAmount = GetRemainingMineResources(*world, pos, GetMineResourceType(bldType_)); + return static_cast( + (static_cast(productivity) * GetS4LikeMineProductionChance(resourceAmount)) / 100u); +} + void nobUsual::OnOutOfResources() { // Post verschicken, keine Rohstoffe mehr da diff --git a/libs/s25main/buildings/nobUsual.h b/libs/s25main/buildings/nobUsual.h index 5b7cf0da1a..adb25ecdc1 100644 --- a/libs/s25main/buildings/nobUsual.h +++ b/libs/s25main/buildings/nobUsual.h @@ -99,9 +99,7 @@ class nobUsual : public noBuilding /// Bestellte Waren bool AreThereAnyOrderedWares() const; - /// Gibt Pointer auf Produktivität zurück - const unsigned short* GetProductivityPointer() const { return &productivity; } - unsigned short GetProductivity() const { return productivity; } + unsigned short GetProductivity() const; const nofBuildingWorker* GetWorker() const { return worker; } /// Stoppt/Erlaubt Produktion (visuell) diff --git a/libs/s25main/figures/nofMiner.cpp b/libs/s25main/figures/nofMiner.cpp index 912619b58a..5f5da8e384 100644 --- a/libs/s25main/figures/nofMiner.cpp +++ b/libs/s25main/figures/nofMiner.cpp @@ -10,7 +10,90 @@ #include "buildings/nobUsual.h" #include "network/GameClient.h" #include "ogl/glArchivItem_Bitmap_Player.h" +#include "random/Random.h" #include "world/GameWorld.h" +#include "gameTypes/MineNoOutputFallback.h" +#include "gameTypes/MineResourceBehavior.h" +#include "gameTypes/Resource.h" +#include "gameData/GameConsts.h" +#include +#include + +namespace { +constexpr unsigned MAX_PRODUCTION_PERCENT = 100; +constexpr unsigned GRANITE_FALLBACK_25_PERCENT = 25; +constexpr unsigned GRANITE_FALLBACK_50_PERCENT = 50; +constexpr unsigned S4LIKE_MIN_RESOURCE_AMOUNT = 1; + +MineNoOutputFallback GetConfiguredNoOutputFallback(const GlobalGameSettings& settings) +{ + const unsigned selection = settings.getSelection(AddonId::MINE_NO_OUTPUT_FALLBACK); + if(!helpers::isValidEnumValue(selection)) + return MineNoOutputFallback::ProduceNothing; + return static_cast(selection); +} + +unsigned GetGraniteFallbackChance(const MineNoOutputFallback fallback) +{ + switch(fallback) + { + case MineNoOutputFallback::ProduceGranite25: return GRANITE_FALLBACK_25_PERCENT; + case MineNoOutputFallback::ProduceGranite50: return GRANITE_FALLBACK_50_PERCENT; + case MineNoOutputFallback::ProduceGranite100: return MAX_PRODUCTION_PERCENT; + default: return 0; + } +} + +helpers::OptionalEnum GetLowerGradeFallbackGood(const BuildingType buildingType) +{ + switch(buildingType) + { + case BuildingType::GoldMine: return GoodType::IronOre; + case BuildingType::IronMine: return GoodType::Coal; + case BuildingType::CoalMine: return GoodType::Stones; + default: return {}; + } +} + +helpers::OptionalEnum GetNoOutputFallbackGood(const GlobalGameSettings& settings, + const BuildingType buildingType, const unsigned objId) +{ + const MineNoOutputFallback fallback = GetConfiguredNoOutputFallback(settings); + const unsigned graniteFallbackChance = GetGraniteFallbackChance(fallback); + if(graniteFallbackChance > 0) + { + if(graniteFallbackChance == MAX_PRODUCTION_PERCENT + || static_cast(RANDOM.Rand(RANDOM_CONTEXT2(objId), MAX_PRODUCTION_PERCENT)) + < graniteFallbackChance) + return GoodType::Stones; + + return {}; + } + + if(fallback == MineNoOutputFallback::ProduceLowerGradeResource) + return GetLowerGradeFallbackGood(buildingType); + + return {}; +} + +std::vector GetPointsWithResource(const GameWorld& world, const MapPoint pos, const ResourceType type) +{ + return world.GetMatchingPointsInRadius( + pos, MINER_RADIUS, [&world, type](const MapPoint pt) { return world.GetNode(pt).resources.has(type); }, true); +} + +void ReduceS4LikeResource(GameWorld& world, const std::vector& resourcePts) +{ + for(const MapPoint pt : resourcePts) + { + if(world.GetNode(pt).resources.getAmount() > S4LIKE_MIN_RESOURCE_AMOUNT) + { + world.ReduceResource(pt); + return; + } + } +} +} // namespace nofMiner::nofMiner(const MapPoint pos, const unsigned char player, nobUsual* workplace) : nofWorkman(Job::Miner, pos, player, workplace) @@ -60,41 +143,63 @@ unsigned short nofMiner::GetCarryID() const helpers::OptionalEnum nofMiner::ProduceWare() { - switch(workplace->GetBuildingType()) + const GlobalGameSettings& settings = world->GetGGS(); + const MineResourceBehavior behavior = GetMineResourceBehavior(settings, workplace->GetBuildingType()); + + if(behavior == MineResourceBehavior::S4LikeExhaustion) { - case BuildingType::GoldMine: return GoodType::Gold; - case BuildingType::IronMine: return GoodType::IronOre; - case BuildingType::CoalMine: return GoodType::Coal; - default: return GoodType::Stones; + const std::vector resourcePts = GetPointsWithResource(*world, pos, GetRequiredResType()); + const auto productionRoll = static_cast(RANDOM_RAND(MAX_PRODUCTION_PERCENT)); + const bool produceNothingThisCycle = resourcePts.empty() + || productionRoll >= GetS4LikeMineProductionChance( + GetRemainingMineResources(*world, pos, GetRequiredResType())); + if(produceNothingThisCycle) + return GetNoOutputFallbackGood(settings, workplace->GetBuildingType(), GetObjId()); + + // S4-like exhaustion always depletes, but only down to the minimum amount + ReduceS4LikeResource(*world, resourcePts); } + + return GetMineOutput(workplace->GetBuildingType()); } bool nofMiner::AreWaresAvailable() const { - return nofWorkman::AreWaresAvailable() && FindPointWithResource(GetRequiredResType()).isValid(); + if(!nofWorkman::AreWaresAvailable()) + return false; + + const MineResourceBehavior behavior = GetMineResourceBehavior(world->GetGGS(), workplace->GetBuildingType()); + if(behavior == MineResourceBehavior::WorkEverywhere) + return true; + + const bool hasResources = FindPointWithResource(GetRequiredResType()).isValid(); + if(!hasResources) + workplace->OnOutOfResources(); + return hasResources; } bool nofMiner::StartWorking() { - MapPoint resPt = FindPointWithResource(GetRequiredResType()); + const GlobalGameSettings& settings = world->GetGGS(); + const MineResourceBehavior behavior = GetMineResourceBehavior(settings, workplace->GetBuildingType()); + if(behavior == MineResourceBehavior::WorkEverywhere) + return nofWorkman::StartWorking(); + + const MapPoint resPt = FindPointWithResource(GetRequiredResType()); if(!resPt.isValid()) + { + workplace->OnOutOfResources(); return false; - const GlobalGameSettings& settings = world->GetGGS(); - bool inexhaustibleRes = settings.isEnabled(AddonId::INEXHAUSTIBLE_MINES) - || (workplace->GetBuildingType() == BuildingType::GraniteMine - && settings.isEnabled(AddonId::INEXHAUSTIBLE_GRANITEMINES)); - if(!inexhaustibleRes) + } + + if(behavior != MineResourceBehavior::S4LikeExhaustion + && IsMineResourceDepletable(settings, workplace->GetBuildingType())) world->ReduceResource(resPt); + return nofWorkman::StartWorking(); } ResourceType nofMiner::GetRequiredResType() const { - switch(workplace->GetBuildingType()) - { - case BuildingType::GoldMine: return ResourceType::Gold; - case BuildingType::IronMine: return ResourceType::Iron; - case BuildingType::CoalMine: return ResourceType::Coal; - default: return ResourceType::Granite; - } + return GetMineResourceType(workplace->GetBuildingType()); } diff --git a/libs/s25main/figures/nofWellguy.cpp b/libs/s25main/figures/nofWellguy.cpp index dc7f600376..af76f1f6bd 100644 --- a/libs/s25main/figures/nofWellguy.cpp +++ b/libs/s25main/figures/nofWellguy.cpp @@ -118,14 +118,20 @@ helpers::OptionalEnum nofWellguy::ProduceWare() bool nofWellguy::AreWaresAvailable() const { // Check for water - return FindPointWithResource(ResourceType::Water).isValid(); + const bool hasWater = FindPointWithResource(ResourceType::Water).isValid(); + if(!hasWater) + workplace->OnOutOfResources(); + return hasWater; } bool nofWellguy::StartWorking() { MapPoint resPt = FindPointWithResource(ResourceType::Water); if(!resPt.isValid()) + { + workplace->OnOutOfResources(); return false; + } if(world->GetGGS().getSelection(AddonId::EXHAUSTIBLE_WATER) == 2) world->ReduceResource(resPt); return nofWorkman::StartWorking(); diff --git a/libs/s25main/figures/nofWorkman.cpp b/libs/s25main/figures/nofWorkman.cpp index 889ef38cb6..a0a0e30d9e 100644 --- a/libs/s25main/figures/nofWorkman.cpp +++ b/libs/s25main/figures/nofWorkman.cpp @@ -102,7 +102,5 @@ MapPoint nofWorkman::FindPointWithResource(ResourceType type) const if(!pts.empty()) return pts.front(); - workplace->OnOutOfResources(); - return MapPoint::Invalid(); } diff --git a/libs/s25main/gameTypes/MineNoOutputFallback.h b/libs/s25main/gameTypes/MineNoOutputFallback.h new file mode 100644 index 0000000000..1e3d4dc832 --- /dev/null +++ b/libs/s25main/gameTypes/MineNoOutputFallback.h @@ -0,0 +1,21 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "helpers/MaxEnumValue.h" + +enum class MineNoOutputFallback +{ + ProduceNothing, + ProduceGranite25, + ProduceGranite50, + ProduceGranite100, + ProduceLowerGradeResource +}; + +constexpr auto maxEnumValue(MineNoOutputFallback) +{ + return MineNoOutputFallback::ProduceLowerGradeResource; +} diff --git a/libs/s25main/gameTypes/MineResourceBehavior.cpp b/libs/s25main/gameTypes/MineResourceBehavior.cpp new file mode 100644 index 0000000000..91ad66f940 --- /dev/null +++ b/libs/s25main/gameTypes/MineResourceBehavior.cpp @@ -0,0 +1,98 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "MineResourceBehavior.h" +#include "GlobalGameSettings.h" +#include "addons/const_addons.h" +#include "world/GameWorld.h" +#include "gameTypes/BuildingType.h" +#include "gameTypes/GoodTypes.h" +#include "gameTypes/MapCoordinates.h" +#include "gameTypes/Resource.h" +#include "gameData/GameConsts.h" +#include + +namespace { +constexpr unsigned MAX_PRODUCTION_PERCENT = 100; +// S4-like productivity reaches 100% at this remaining-resource amount instead of using the theoretical maximum +// resources in the mine radius. Below this reference amount, the production chance degrades linearly. +constexpr unsigned S4LIKE_FULL_PRODUCTIVITY_RESOURCE_AMOUNT = 20; +} // namespace + +AddonId GetMineResourceBehaviorAddonId(const BuildingType buildingType) +{ + switch(buildingType) + { + case BuildingType::GoldMine: return AddonId::GOLDMINE_RESOURCE_BEHAVIOR; + case BuildingType::IronMine: return AddonId::IRONMINE_RESOURCE_BEHAVIOR; + case BuildingType::CoalMine: return AddonId::COALMINE_RESOURCE_BEHAVIOR; + default: return AddonId::GRANITEMINE_RESOURCE_BEHAVIOR; + } +} + +ResourceType GetMineResourceType(const BuildingType buildingType) +{ + switch(buildingType) + { + case BuildingType::GoldMine: return ResourceType::Gold; + case BuildingType::IronMine: return ResourceType::Iron; + case BuildingType::CoalMine: return ResourceType::Coal; + default: return ResourceType::Granite; + } +} + +GoodType GetMineOutput(const BuildingType buildingType) +{ + switch(buildingType) + { + case BuildingType::GoldMine: return GoodType::Gold; + case BuildingType::IronMine: return GoodType::IronOre; + case BuildingType::CoalMine: return GoodType::Coal; + default: return GoodType::Stones; + } +} + +helpers::OptionalEnum GetMineBuildingType(const ResourceType resourceType) +{ + switch(resourceType) + { + case ResourceType::Gold: return BuildingType::GoldMine; + case ResourceType::Iron: return BuildingType::IronMine; + case ResourceType::Coal: return BuildingType::CoalMine; + case ResourceType::Granite: return BuildingType::GraniteMine; + default: return {}; + } +} + +unsigned GetRemainingMineResources(const GameWorld& world, const MapPoint pos, const ResourceType resourceType) +{ + unsigned resourceAmount = 0; + const auto resourcePts = world.GetMatchingPointsInRadius( + pos, MINER_RADIUS, + [&world, resourceType](const MapPoint pt) { return world.GetNode(pt).resources.has(resourceType); }, true); + for(const MapPoint pt : resourcePts) + resourceAmount += world.GetNode(pt).resources.getAmount(); + return resourceAmount; +} + +unsigned GetS4LikeMineProductionChance(const unsigned remainingMatchingResources) +{ + const unsigned chancePercent = + remainingMatchingResources * MAX_PRODUCTION_PERCENT / S4LIKE_FULL_PRODUCTIVITY_RESOURCE_AMOUNT; + return std::min(MAX_PRODUCTION_PERCENT, chancePercent); +} + +MineResourceBehavior GetMineResourceBehavior(const GlobalGameSettings& settings, const BuildingType buildingType) +{ + const unsigned selection = settings.getSelection(GetMineResourceBehaviorAddonId(buildingType)); + if(!helpers::isValidEnumValue(selection)) + return MineResourceBehavior::Default; + return static_cast(selection); +} + +bool IsMineResourceDepletable(const GlobalGameSettings& settings, const BuildingType buildingType) +{ + const MineResourceBehavior behavior = GetMineResourceBehavior(settings, buildingType); + return behavior == MineResourceBehavior::Default || behavior == MineResourceBehavior::S4LikeExhaustion; +} diff --git a/libs/s25main/gameTypes/MineResourceBehavior.h b/libs/s25main/gameTypes/MineResourceBehavior.h new file mode 100644 index 0000000000..cf2193664a --- /dev/null +++ b/libs/s25main/gameTypes/MineResourceBehavior.h @@ -0,0 +1,38 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "helpers/OptionalEnum.h" +#include "gameTypes/MapCoordinates.h" +#include + +class GlobalGameSettings; +class GameWorld; +enum class AddonId; +enum class BuildingType : unsigned char; +enum class ResourceType : uint8_t; +enum class GoodType : uint8_t; + +enum class MineResourceBehavior +{ + Default, + Inexhaustible, + S4LikeExhaustion, + WorkEverywhere +}; +constexpr auto maxEnumValue(MineResourceBehavior) +{ + return MineResourceBehavior::WorkEverywhere; +} + +AddonId GetMineResourceBehaviorAddonId(BuildingType buildingType); +ResourceType GetMineResourceType(BuildingType buildingType); +/// The ware a mine of the given type produces (granite/stones for any non-mineral mine) +GoodType GetMineOutput(BuildingType buildingType); +helpers::OptionalEnum GetMineBuildingType(ResourceType resourceType); +unsigned GetRemainingMineResources(const GameWorld& world, MapPoint pos, ResourceType resourceType); +unsigned GetS4LikeMineProductionChance(unsigned remainingMatchingResources); +MineResourceBehavior GetMineResourceBehavior(const GlobalGameSettings& settings, BuildingType buildingType); +bool IsMineResourceDepletable(const GlobalGameSettings& settings, BuildingType buildingType); diff --git a/libs/s25main/ingameWindows/iwBuilding.cpp b/libs/s25main/ingameWindows/iwBuilding.cpp index 55f48f66bf..28cd3ba4c4 100644 --- a/libs/s25main/ingameWindows/iwBuilding.cpp +++ b/libs/s25main/ingameWindows/iwBuilding.cpp @@ -32,7 +32,7 @@ const unsigned IODAT_SHIP_ID = 218; iwBuilding::iwBuilding(GameWorldView& gwv, GameCommandFactory& gcFactory, nobUsual* const building, Extent extent) : IngameWindow(CGI_BUILDING + MapBase::CreateGUIID(building->GetPos()), IngameWindow::posAtMouse, extent, _(BUILDING_NAMES[building->GetBuildingType()]), LOADER.GetImageN("resource", 41)), - gwv(gwv), gcFactory(gcFactory), building(building) + gwv(gwv), gcFactory(gcFactory), building(building), productivity(building->GetProductivity()) { // Arbeitersymbol AddImage(0, DrawPoint(28, 39), LOADER.GetMapTexture(2298)); @@ -83,8 +83,8 @@ iwBuilding::iwBuilding(GameWorldView& gwv, GameCommandFactory& gcFactory, nobUsu _("Go to place")); // Produktivitätsanzeige (bei Katapulten und Spähtürmen ausblenden) - Window* productivity = AddPercent(9, DrawPoint(59, 31), Extent(106, 16), TextureColor::Grey, 0xFFFFFF00, SmallFont, - building->GetProductivityPointer()); + Window* productivity = + AddPercent(9, DrawPoint(59, 31), Extent(106, 16), TextureColor::Grey, 0xFFFFFF00, SmallFont, &this->productivity); if(building->GetBuildingType() == BuildingType::Catapult || building->GetBuildingType() == BuildingType::LookoutTower) productivity->SetVisible(false); @@ -99,6 +99,7 @@ iwBuilding::iwBuilding(GameWorldView& gwv, GameCommandFactory& gcFactory, nobUsu void iwBuilding::Msg_PaintBefore() { IngameWindow::Msg_PaintBefore(); + productivity = building->GetProductivity(); // Haus unbesetzt ggf ausblenden GetCtrl(10)->SetVisible(!building->HasWorker()); diff --git a/libs/s25main/ingameWindows/iwBuilding.h b/libs/s25main/ingameWindows/iwBuilding.h index 0feb36adf6..03dce9dab9 100644 --- a/libs/s25main/ingameWindows/iwBuilding.h +++ b/libs/s25main/ingameWindows/iwBuilding.h @@ -16,6 +16,7 @@ class iwBuilding : public IngameWindow GameWorldView& gwv; GameCommandFactory& gcFactory; nobUsual* const building; /// Das zugehörige Gebäudeobjekt + unsigned short productivity; public: iwBuilding(GameWorldView& gwv, GameCommandFactory& gcFactory, nobUsual* building, Extent extent = Extent(226, 194)); diff --git a/libs/s25main/lua/LuaInterfaceGameBase.cpp b/libs/s25main/lua/LuaInterfaceGameBase.cpp index 7f6e4f49ae..2b369b6d9b 100644 --- a/libs/s25main/lua/LuaInterfaceGameBase.cpp +++ b/libs/s25main/lua/LuaInterfaceGameBase.cpp @@ -16,7 +16,9 @@ unsigned LuaInterfaceGameBase::GetVersion() unsigned LuaInterfaceGameBase::GetFeatureLevel() { - return 6; + // 7: Added the per-mine ADDON_*MINE_RESOURCE_BEHAVIOR addons with the legacy ADDON_INEXHAUSTIBLE_GRANITEMINES / + // ADDON_INEXHAUSTIBLE_MINES aliases, plus SetAI resetting the player name (see doc/lua/functions.md). + return 7; } LuaInterfaceGameBase::LuaInterfaceGameBase(const ILocalGameState& localGameState) : localGameState(localGameState) diff --git a/libs/s25main/lua/LuaInterfaceSettings.cpp b/libs/s25main/lua/LuaInterfaceSettings.cpp index 8f29d5658f..73bed6fc73 100644 --- a/libs/s25main/lua/LuaInterfaceSettings.cpp +++ b/libs/s25main/lua/LuaInterfaceSettings.cpp @@ -51,6 +51,9 @@ void LuaInterfaceSettings::Register(kaguya::State& state) { state[std::string("ADDON_") + rttrEnum::toString(id)] = AddonIdWrapper{id}; } + // Legacy alias: the bool addon was replaced by a list addon reusing the same id, where the old value 1 still means + // "inexhaustible". Keeps existing map scripts working; may be removed once INEXHAUSTIBLE_MINES is dropped too. + state["ADDON_INEXHAUSTIBLE_GRANITEMINES"] = AddonIdWrapper{AddonId::GRANITEMINE_RESOURCE_BEHAVIOR}; #pragma region ConstDefs #define ADD_LUA_CONST(name) state["GS_" + s25util::toUpper(#name)] = GameSpeed::name @@ -98,7 +101,14 @@ LuaServerPlayer LuaInterfaceSettings::GetPlayer(int idx) void LuaInterfaceSettings::SetAddon(AddonIdWrapper id, unsigned value) { GlobalGameSettings ggs = lobbyServerController_.GetGGS(); - ggs.setSelection(id, value); + // The global "inexhaustible mines" addon was replaced by per-mine-type settings and is no longer registered. + // Apply the same migration used for old settings and savegames instead of reporting an unknown addon. + if(static_cast(id) == AddonId::INEXHAUSTIBLE_MINES) + { + if(value != 0) + ggs.applyLegacyInexhaustibleMines(); + } else + ggs.setSelection(id, value); lobbyServerController_.ChangeGlobalGameSettings(ggs); } diff --git a/tests/s25Main/integration/testAI.cpp b/tests/s25Main/integration/testAI.cpp index 3efb3fef97..16fadab134 100644 --- a/tests/s25Main/integration/testAI.cpp +++ b/tests/s25Main/integration/testAI.cpp @@ -16,11 +16,15 @@ #include "network/GameMessage_Chat.h" #include "notifications/NodeNote.h" #include "worldFixtures/WorldWithGCExecution.h" +#include "worldFixtures/terrainHelpers.h" #include "nodeObjs/noFlag.h" #include "nodeObjs/noTree.h" #include "gameTypes/GameTypesOutput.h" +#include "gameTypes/MineResourceBehavior.h" +#include "gameTypes/Resource.h" #include "gameData/BuildingProperties.h" #include "gameData/MilitaryConsts.h" +#include "gameData/WorldDescription.h" #include "rttr/test/random.hpp" #include #include @@ -51,6 +55,39 @@ inline bool playerHasBld(const GamePlayer& player, BuildingType type) return !blds.GetBuildings(type).empty(); } +DescIdx GetMineableTerrain(const WorldDescription& desc) +{ + const auto terrain = desc.terrain.find([](const TerrainDesc& t) { return t.Is(ETerrain::Mineable); }); + BOOST_TEST_REQUIRE(terrain); + return terrain; +} + +void makeWorldMineable(GameWorld& world) +{ + const DescIdx mineableTerrain = GetMineableTerrain(world.GetDescription()); + RTTR_FOREACH_PT(MapPoint, world.GetSize()) + { + MapNode& node = world.GetNodeWriteable(pt); + node.t1 = node.t2 = mineableTerrain; + node.resources = Resource(); + } + world.InitAfterLoad(); +} + +// Mark every node as a usable, owned, reachable build spot so the AI's position search only depends on the +// mine resource behavior under test and not on unrelated terrain/ownership state. +void initAIJhNodes(AIJH::AIPlayerJH& aijh, const GameWorld& world, const unsigned player) +{ + RTTR_FOREACH_PT(MapPoint, world.GetSize()) + { + AIJH::Node& node = aijh.GetAINode(pt); + node.bq = world.GetBQ(pt, player); + node.owned = true; + node.reachable = true; + node.farmed = false; + } +} + struct MockAI final : public AIPlayer { MockAI(unsigned char playerId, const GameWorldBase& gwb, const AI::Level level) : AIPlayer(playerId, gwb, level) {} @@ -109,6 +146,83 @@ BOOST_FIXTURE_TEST_CASE(AIChat, EmptyWorldFixture2P) } } +BOOST_FIXTURE_TEST_CASE(MineResourceRatingAccountsForS4LikeExhaustion, EmptyWorldFixture1P) +{ + const MapPoint resourcePos = world.MakeMapPoint(world.GetPlayer(0).GetHQPos() + Position(2, 0)); + world.GetNodeWriteable(resourcePos).resources = Resource(ResourceType::Coal, 1); + + MockAI ai(0, world, AI::Level::Easy); + const auto ratingWith = [&](const MineResourceBehavior behavior) { + ggs.setSelection(AddonId::COALMINE_RESOURCE_BEHAVIOR, static_cast(behavior)); + return ai.getAIInterface().GetResourceRating(resourcePos, AIResource::Coal); + }; + + // A spot with a coal deposit gets the full radius rating for all behaviors that mine the deposit as-is... + const int defaultRating = ratingWith(MineResourceBehavior::Default); + BOOST_TEST(defaultRating == static_cast(RES_RADIUS[AIResource::Coal])); + BOOST_TEST(ratingWith(MineResourceBehavior::Inexhaustible) == defaultRating); + BOOST_TEST(ratingWith(MineResourceBehavior::WorkEverywhere) == defaultRating); + + // ...only S4-like exhaustion lowers the rating because a nearly depleted deposit produces less over its lifetime. + const int s4LikeRating = ratingWith(MineResourceBehavior::S4LikeExhaustion); + BOOST_TEST(s4LikeRating > 0); + BOOST_TEST(s4LikeRating < defaultRating); +} + +BOOST_FIXTURE_TEST_CASE(MineWorkEverywhereAffectsMatchingResourceOnly, EmptyWorldFixture1P) +{ + // Whole world is mineable terrain but has no mineral deposits at all. + makeWorldMineable(world); + ggs.setSelection(AddonId::COALMINE_RESOURCE_BEHAVIOR, static_cast(MineResourceBehavior::WorkEverywhere)); + + AIJH::AIPlayerJH ai(0, world, AI::Level::Hard); + initAIJhNodes(ai, world, 0); + + // The HQ is the only guaranteed reachable/owned starting spot, so searches are centered on it. + const MapPoint searchCenter = world.GetPlayer(0).GetHQPos(); + // The only iron deposit sits well outside the search radius below, so default iron mining has no reachable spot. + const MapPoint ironPos = world.MakeMapPoint(searchCenter + Position(8, 0)); + world.GetNodeWriteable(ironPos).resources = Resource(ResourceType::Iron, 4); + + // WorkEverywhere coal treats any mineable node as a coal spot, even one that actually holds an iron deposit. + BOOST_TEST(ai.getAIInterface().GetResourceRating(ironPos, AIResource::Coal) + == static_cast(RES_RADIUS[AIResource::Coal])); + // So coal finds a spot right around the HQ, while default iron finds none because its only deposit is out of range. + BOOST_TEST(ai.FindBestPosition(searchCenter, AIResource::Coal, BuildingQuality::Mine, 5).isValid()); + BOOST_TEST(!ai.FindBestPosition(searchCenter, AIResource::Ironore, BuildingQuality::Mine, 5).isValid()); +} + +BOOST_FIXTURE_TEST_CASE(GraniteMineResourceBehaviorAffectsAIMineSearch, EmptyWorldFixture1P) +{ + // Mineable terrain everywhere but no deposits, so only the granite behavior decides what the AI can place. + makeWorldMineable(world); + ggs.setSelection(AddonId::GRANITEMINE_RESOURCE_BEHAVIOR, + static_cast(MineResourceBehavior::WorkEverywhere)); + + AIJH::AIPlayerJH ai(0, world, AI::Level::Hard); + initAIJhNodes(ai, world, 0); + const MapPoint searchCenter = world.GetPlayer(0).GetHQPos(); + + // WorkEverywhere lets the AI place a granite mine on any mineable node, but only for granite, not coal. + BOOST_TEST(ai.FindBestPosition(searchCenter, AIResource::Granite, BuildingQuality::Mine, 5).isValid()); + BOOST_TEST(!ai.FindBestPosition(searchCenter, AIResource::Coal, BuildingQuality::Mine, 5).isValid()); + +} + +BOOST_FIXTURE_TEST_CASE(InexhaustibleGraniteDoesNotImplyWorkEverywhereForAI, EmptyWorldFixture1P) +{ + // Same setup as above, but the AI is constructed AFTER Inexhaustible is selected. + // Unlike WorkEverywhere, Inexhaustible requires an actual deposit. + makeWorldMineable(world); + ggs.setSelection(AddonId::GRANITEMINE_RESOURCE_BEHAVIOR, + static_cast(MineResourceBehavior::Inexhaustible)); + + AIJH::AIPlayerJH ai(0, world, AI::Level::Hard); + initAIJhNodes(ai, world, 0); + const MapPoint searchCenter = world.GetPlayer(0).GetHQPos(); + + BOOST_TEST(!ai.FindBestPosition(searchCenter, AIResource::Granite, BuildingQuality::Mine, 5).isValid()); +} BOOST_FIXTURE_TEST_CASE(KeepBQUpdated, BiggerWorldWithGCExecution) { addStartResources(); diff --git a/tests/s25Main/integration/testGamePlayer.cpp b/tests/s25Main/integration/testGamePlayer.cpp index b594e4e2bc..9c60f1ef19 100644 --- a/tests/s25Main/integration/testGamePlayer.cpp +++ b/tests/s25Main/integration/testGamePlayer.cpp @@ -12,7 +12,10 @@ #include "ingameWindows/iwBuildingProductivities.h" #include "worldFixtures/CreateEmptyWorld.h" #include "worldFixtures/WorldFixture.h" +#include "gameTypes/MineResourceBehavior.h" +#include "gameTypes/Resource.h" #include "gameData/BuildingProperties.h" +#include "gameData/GameConsts.h" #include "rttr/test/random.hpp" #include "s25util/warningSuppression.h" #include @@ -55,6 +58,55 @@ RTTR_ATTRIBUTE_NO_UBSAN(vptr) void setProductivity(nobUsual* bld, unsigned short } // namespace using WorldFixtureEmpty1P = WorldFixture + 14, 4>; +using WorldFixtureMineRadius1P = WorldFixture; + +namespace { +// S4-like mine productivity reaches the mine's base productivity once this many matching resources remain in the +// mine radius, degrading linearly below it (see GetS4LikeMineProductionChance). +constexpr unsigned S4LIKE_FULL_PRODUCTIVITY_AMOUNT = 20; + +// Places a coal mine and drives its S4-like productivity purely through the resources in its radius. +// The 20x12 map is larger than 2*MINER_RADIUS in each dimension, so the mine radius never wraps onto itself. +struct MineProductivityFixture : WorldFixtureMineRadius1P +{ + nobUsual* coalMine; + MapPoint minePos; + + MineProductivityFixture() + { + // BuildingFactory::CreateBuilding ignores the building quality, so any node works; offset from the HQ keeps + // the mine radius clear of the HQ. The empty world has no resources, but clear the radius to be explicit. + minePos = world.MakeMapPoint(world.GetPlayer(0).GetHQPos() + Position(4, 0)); + coalMine = static_cast( + BuildingFactory::CreateBuilding(world, BuildingType::CoalMine, minePos, 0, Nation::Romans)); + for(const MapPoint pt : world.GetPointsInRadiusWithCenter(minePos, MINER_RADIUS)) + world.SetResource(pt, Resource()); + } + + // Puts coal on the mine node and its eastern neighbor (0 == none), leaving the rest of the radius empty. + void setCoalAmounts(const unsigned atMine, const unsigned atNeighbor) + { + world.SetResource(minePos, atMine ? Resource(ResourceType::Coal, atMine) : Resource()); + world.SetResource(world.GetNeighbour(minePos, Direction::East), + atNeighbor ? Resource(ResourceType::Coal, atNeighbor) : Resource()); + } + + // Spreads the given total coal amount as evenly as possible over every node in the radius (rest set to none). + // Only the summed amount in range matters, so the exact distribution is irrelevant. + void spreadCoalInRadius(const unsigned total) + { + const std::vector pts = world.GetPointsInRadiusWithCenter(minePos, MINER_RADIUS); + unsigned remaining = total; + for(unsigned i = 0; i < pts.size(); ++i) + { + const unsigned here = remaining / (static_cast(pts.size()) - i); + world.SetResource(pts[i], here ? Resource(ResourceType::Coal, here) : Resource()); + remaining -= here; + } + } +}; +} // namespace + BOOST_FIXTURE_TEST_CASE(ProductivityStats, WorldFixtureEmpty1P) { using boost::test_tools::per_element; @@ -121,6 +173,63 @@ BOOST_FIXTURE_TEST_CASE(ProductivityStats, WorldFixtureEmpty1P) BOOST_TEST(buildingRegister.CalcAverageProductivity() == avgProd); } +BOOST_FIXTURE_TEST_CASE(MineProductivityAccountsForS4LikeResourceChance, MineProductivityFixture) +{ + setProductivity(coalMine, 100); + + // Without the S4-like behavior the base productivity is reported unchanged, regardless of the resources left. + setCoalAmounts(1, 0); + BOOST_TEST(coalMine->GetProductivity() == 100u); + + ggs.setSelection(AddonId::COALMINE_RESOURCE_BEHAVIOR, + static_cast(MineResourceBehavior::S4LikeExhaustion)); + + // S4-like scales productivity with the resources left, reaching the full base value at 20 (full productivity). + setCoalAmounts(15, 5); // == S4LIKE_FULL_PRODUCTIVITY_AMOUNT + BOOST_TEST(coalMine->GetProductivity() == 100u); + // Halving the resources halves the reported productivity. + setCoalAmounts(5, 5); + BOOST_TEST(coalMine->GetProductivity() == 50u); + // Lowering the base productivity scales the result by the same factor: 80% of the 50% chance -> 40%. + setProductivity(coalMine, 80); + BOOST_TEST(coalMine->GetProductivity() == 40u); + BOOST_TEST(world.GetPlayer(0).GetBuildingRegister().CalcProductivities()[BuildingType::CoalMine] == 40u); + // No resources left -> no production. + setProductivity(coalMine, 100); + setCoalAmounts(0, 0); + BOOST_TEST(coalMine->GetProductivity() == 0u); + + // Inexhaustible mines always report their base productivity again, ignoring the resources. + ggs.setSelection(AddonId::COALMINE_RESOURCE_BEHAVIOR, static_cast(MineResourceBehavior::Inexhaustible)); + BOOST_TEST(coalMine->GetProductivity() == 100u); +} + +BOOST_FIXTURE_TEST_CASE(MineProductivityUsesAllMatchingResourcesWithinMineRadius, MineProductivityFixture) +{ + // Base productivity 100 so GetProductivity() directly mirrors the resource-based production chance. + setProductivity(coalMine, 100); + ggs.setSelection(AddonId::COALMINE_RESOURCE_BEHAVIOR, + static_cast(MineResourceBehavior::S4LikeExhaustion)); + + // A different resource type in range and matching coal just outside the radius must not count as coal. + world.SetResource(world.GetNeighbour(minePos, Direction::NorthWest), Resource(ResourceType::Iron, 15)); + const MapPoint outOfRangePt = world.GetNeighbour( + world.GetNeighbour(world.GetNeighbour(minePos, Direction::East), Direction::East), Direction::East); + const auto inRangePts = world.GetPointsInRadiusWithCenter(minePos, MINER_RADIUS); + const bool outOfRange = std::find(inRangePts.begin(), inRangePts.end(), outOfRangePt) == inRangePts.end(); + BOOST_TEST_REQUIRE(outOfRange); + world.SetResource(outOfRangePt, Resource(ResourceType::Coal, 15)); + BOOST_TEST(GetRemainingMineResources(world, minePos, ResourceType::Coal) == 0u); + BOOST_TEST(coalMine->GetProductivity() == 0u); + + // Half of the full amount, but spread across every node of the radius: productivity depends only on the sum in + // range, not on how it is distributed. This overwrites the in-range iron, which no longer matters here. + spreadCoalInRadius(S4LIKE_FULL_PRODUCTIVITY_AMOUNT / 2); + BOOST_TEST(GetRemainingMineResources(world, minePos, ResourceType::Coal) == 10u); + BOOST_TEST(coalMine->GetProductivity() == 50u); + BOOST_TEST(world.GetPlayer(0).GetBuildingRegister().CalcProductivities()[BuildingType::CoalMine] == 50u); +} + BOOST_FIXTURE_TEST_CASE(IsHQTent_ReturnsFalse_IfPrimaryHQIsNotTent, WorldFixtureEmpty1P) { GamePlayer& p1 = world.GetPlayer(0); diff --git a/tests/s25Main/integration/testProduction.cpp b/tests/s25Main/integration/testProduction.cpp index 5ce05b76b4..16c312c762 100644 --- a/tests/s25Main/integration/testProduction.cpp +++ b/tests/s25Main/integration/testProduction.cpp @@ -7,12 +7,20 @@ #include "factories/BuildingFactory.h" #include "postSystem/PostBox.h" #include "postSystem/PostMsg.h" +#include "random/Random.h" #include "worldFixtures/WorldWithGCExecution.h" +#include "gameTypes/MineNoOutputFallback.h" +#include "gameTypes/MineResourceBehavior.h" #include "gameData/ToolConsts.h" #include +#include +#include #include #include #include +#include + +namespace dataset = boost::unit_test::data; // LCOV_EXCL_START static std::ostream& operator<<(std::ostream& os, const PostCategory& cat) @@ -23,6 +31,64 @@ static std::ostream& operator<<(std::ostream& os, const PostCategory& cat) BOOST_AUTO_TEST_SUITE(Production) +namespace { +// Provides a single, connected, staffed mine so tests only differ in the mine type, its resource spot and the +// addon settings under test. The miner supplies are added in the ctor (comment: setup belongs in the fixture); +// the mine itself is created per test because its type/resource is what varies. +struct MineProductionFixture : WorldWithGCExecution1P +{ + // Enough GFs for several miner production cycles. Reused so timing-based tests stay comparable. + static constexpr unsigned maxProductionGFs = 5000; + // Seed + window shared by the two S4-like exhaustion tests so they form a direct comparison: fed the SAME random + // sequence, a near-exhausted mine (1 resource -> ~5% chance) produces nothing within the window, while a full + // mine (many resources -> high chance) reliably completes a depleting cycle. Only the resource amount differs. + static constexpr unsigned s4LikeComparisonSeed = 2; + static constexpr unsigned s4LikeComparisonGFs = 5000; + + MineProductionFixture() + { + GoodsAndPeopleCounts inv; + inv[GoodType::Fish] = 40; + inv[GoodType::PickAxe] = 1; + inv[Job::Miner] = 1; + world.GetSpecObj(hqPos)->AddToInventory(inv, true); + } + + // Places a mine of the given type next to the HQ, optionally seeds its resource spot, connects it by road and + // waits until the miner has moved in. Returns the mine position. + MapPoint CreateMine(const BuildingType mineType, const Resource initialResource = Resource()) + { + const MapPoint minePos = hqPos + MapPoint(2, 0); + const auto* mine = static_cast( + BuildingFactory::CreateBuilding(world, mineType, minePos, curPlayer, Nation::Romans)); + if(initialResource.getType() != ResourceType::Nothing) + world.GetNodeWriteable(minePos).resources = initialResource; + BuildRoad(world.GetNeighbour(minePos, Direction::SouthEast), false, std::vector(2, Direction::West)); + RTTR_EXEC_TILL(500, mine->HasWorker()); + return minePos; + } + + static void SeedProductionRng(const unsigned seed) { RANDOM.Init(seed); } +}; + +// One S4-like "no output" fallback scenario: a nearly exhausted mine that keeps working but usually mines nothing, +// so the configured fallback ware is produced instead of the primary ware. +struct NoOutputFallbackCase +{ + AddonId mineBehaviorAddon; + BuildingType mineType; + ResourceType mineResource; + GoodType primaryGood; // must stay unchanged (deposit too small to actually mine) + MineNoOutputFallback fallback; + GoodType fallbackGood; // must be produced instead + unsigned seed; // chosen so the (probabilistic) fallback fires within maxProductionGFs + friend std::ostream& operator<<(std::ostream& os, const NoOutputFallbackCase& c) + { + return os << "fallback=" << static_cast(c.fallback); + } +}; +} // namespace + BOOST_FIXTURE_TEST_CASE(MetalWorkerStopped, WorldWithGCExecution1P) { addStartResources(); @@ -102,4 +168,208 @@ BOOST_FIXTURE_TEST_CASE(MetalWorkerOrders, WorldWithGCExecution1P) RTTR_EXEC_TILL(1300, mw->is_working); } +// Without any deposit under the mine, only the WorkEverywhere behavior lets it produce; Default and Inexhaustible +// both keep needing an actual resource spot. Depletion/production here is deterministic, so no RNG seed is needed. +BOOST_FIXTURE_TEST_CASE(GraniteMineWithoutResourcesNeedsAddon, MineProductionFixture) +{ + CreateMine(BuildingType::GraniteMine); + const Inventory& curInventory = world.GetPlayer(curPlayer).GetInventory(); + const unsigned initialStones = curInventory[GoodType::Stones]; + + RTTR_SKIP_GFS(maxProductionGFs); + + BOOST_TEST(curInventory[GoodType::Stones] == initialStones); +} + +BOOST_FIXTURE_TEST_CASE(InexhaustibleGraniteMineStillNeedsResourceSpot, MineProductionFixture) +{ + ggs.setSelection(AddonId::GRANITEMINE_RESOURCE_BEHAVIOR, static_cast(MineResourceBehavior::Inexhaustible)); + CreateMine(BuildingType::GraniteMine); + const Inventory& curInventory = world.GetPlayer(curPlayer).GetInventory(); + const unsigned initialStones = curInventory[GoodType::Stones]; + + RTTR_SKIP_GFS(maxProductionGFs); + + BOOST_TEST(curInventory[GoodType::Stones] == initialStones); +} + +BOOST_FIXTURE_TEST_CASE(GraniteMineWorkEverywhereProducesWithoutCreatingResource, MineProductionFixture) +{ + ggs.setSelection(AddonId::GRANITEMINE_RESOURCE_BEHAVIOR, + static_cast(MineResourceBehavior::WorkEverywhere)); + const MapPoint minePos = CreateMine(BuildingType::GraniteMine); + const Inventory& curInventory = world.GetPlayer(curPlayer).GetInventory(); + const unsigned initialStones = curInventory[GoodType::Stones]; + + RTTR_EXEC_TILL(maxProductionGFs, curInventory[GoodType::Stones] > initialStones); + // WorkEverywhere must not conjure a deposit into the ground + BOOST_TEST(static_cast(world.GetNode(minePos).resources.getType()) + == static_cast(ResourceType::Nothing)); + BOOST_TEST(world.GetNode(minePos).resources.getAmount() == 0u); +} + +BOOST_FIXTURE_TEST_CASE(GraniteMineWorkEverywhereIgnoresExistingResource, MineProductionFixture) +{ + ggs.setSelection(AddonId::GRANITEMINE_RESOURCE_BEHAVIOR, + static_cast(MineResourceBehavior::WorkEverywhere)); + // A granite mine on a foreign (coal) deposit still just makes stones and leaves the deposit untouched. + const Resource foreignDeposit(ResourceType::Coal, 4); + const MapPoint minePos = CreateMine(BuildingType::GraniteMine, foreignDeposit); + const Inventory& curInventory = world.GetPlayer(curPlayer).GetInventory(); + const unsigned initialStones = curInventory[GoodType::Stones]; + + RTTR_EXEC_TILL(maxProductionGFs, curInventory[GoodType::Stones] > initialStones); + BOOST_TEST(world.GetNode(minePos).resources.has(foreignDeposit.getType())); + BOOST_TEST(world.GetNode(minePos).resources.getAmount() == foreignDeposit.getAmount()); +} + +BOOST_FIXTURE_TEST_CASE(CoalMineInexhaustibleBehaviorDoesNotDepleteResource, MineProductionFixture) +{ + ggs.setSelection(AddonId::COALMINE_RESOURCE_BEHAVIOR, static_cast(MineResourceBehavior::Inexhaustible)); + const Resource initCoal(ResourceType::Coal, 4); + const MapPoint minePos = CreateMine(BuildingType::CoalMine, initCoal); + const Inventory& curInventory = world.GetPlayer(curPlayer).GetInventory(); + const unsigned initialCoal = curInventory[GetMineOutput(BuildingType::CoalMine)]; + + RTTR_EXEC_TILL(maxProductionGFs, curInventory[GoodType::Coal] > initialCoal); + + // Inexhaustible mines produce without ever reducing the deposit + BOOST_TEST(world.GetNode(minePos).resources.has(initCoal.getType())); + BOOST_TEST(world.GetNode(minePos).resources.getAmount() == initCoal.getAmount()); +} + +// Comparison A: an almost exhausted S4-like mine mostly mines nothing, so the coal count stays put (see the +// s4LikeComparison* constants). Comparison B is CoalMineS4LikeExhaustionReducesResourceOnSuccessfulCycle below. +BOOST_FIXTURE_TEST_CASE(CoalMineS4LikeExhaustionCanProduceNothing, MineProductionFixture) +{ + ggs.setSelection(AddonId::COALMINE_RESOURCE_BEHAVIOR, + static_cast(MineResourceBehavior::S4LikeExhaustion)); + const MapPoint minePos = CreateMine(BuildingType::CoalMine, Resource(ResourceType::Coal, 1)); + const Inventory& curInventory = world.GetPlayer(curPlayer).GetInventory(); + const unsigned initialCoal = curInventory[GoodType::Coal]; + + SeedProductionRng(s4LikeComparisonSeed); + RTTR_SKIP_GFS(s4LikeComparisonGFs); + + BOOST_TEST(curInventory[GoodType::Coal] == initialCoal); + BOOST_TEST(world.GetNode(minePos).resources.getAmount() == 1u); +} + +// Same scenario for every configured no-output fallback: an S4-like mine sitting on a single-unit deposit keeps +// working but (almost) never mines it, so the fallback ware appears while the primary ware and the deposit are +// untouched. The seeds are per-case because the fallback chance is probabilistic (25%/50% need a matching roll). +BOOST_DATA_TEST_CASE_F(MineProductionFixture, S4LikeNoOutputFallbackProducesFallbackWare, + dataset::make(std::array{ + NoOutputFallbackCase{AddonId::COALMINE_RESOURCE_BEHAVIOR, BuildingType::CoalMine, + ResourceType::Coal, GoodType::Coal, + MineNoOutputFallback::ProduceGranite25, GoodType::Stones, 2}, + NoOutputFallbackCase{AddonId::COALMINE_RESOURCE_BEHAVIOR, BuildingType::CoalMine, + ResourceType::Coal, GoodType::Coal, + MineNoOutputFallback::ProduceGranite50, GoodType::Stones, 7}, + NoOutputFallbackCase{AddonId::COALMINE_RESOURCE_BEHAVIOR, BuildingType::CoalMine, + ResourceType::Coal, GoodType::Coal, + MineNoOutputFallback::ProduceGranite100, GoodType::Stones, 2}, + NoOutputFallbackCase{AddonId::GOLDMINE_RESOURCE_BEHAVIOR, BuildingType::GoldMine, + ResourceType::Gold, GoodType::Gold, + MineNoOutputFallback::ProduceLowerGradeResource, GoodType::IronOre, 2}})) +{ + ggs.setSelection(sample.mineBehaviorAddon, static_cast(MineResourceBehavior::S4LikeExhaustion)); + ggs.setSelection(AddonId::MINE_NO_OUTPUT_FALLBACK, static_cast(sample.fallback)); + const MapPoint minePos = CreateMine(sample.mineType, Resource(sample.mineResource, 1)); + const Inventory& curInventory = world.GetPlayer(curPlayer).GetInventory(); + const unsigned initialPrimary = curInventory[sample.primaryGood]; + const unsigned initialFallback = curInventory[sample.fallbackGood]; + + SeedProductionRng(sample.seed); + RTTR_EXEC_TILL(maxProductionGFs, curInventory[sample.fallbackGood] > initialFallback); + + BOOST_TEST(curInventory[sample.primaryGood] == initialPrimary); + BOOST_TEST(world.GetNode(minePos).resources.getAmount() == 1u); +} + +// Comparison B (see CoalMineS4LikeExhaustionCanProduceNothing): same seed and window, but a full deposit reliably +// completes a producing cycle, which reduces the deposit by one (down to, but never below, the minimum of 1). +BOOST_FIXTURE_TEST_CASE(CoalMineS4LikeExhaustionReducesResourceOnSuccessfulCycle, MineProductionFixture) +{ + ggs.setSelection(AddonId::COALMINE_RESOURCE_BEHAVIOR, + static_cast(MineResourceBehavior::S4LikeExhaustion)); + const MapPoint minePos = CreateMine(BuildingType::CoalMine, Resource(ResourceType::Coal, 15)); + + SeedProductionRng(s4LikeComparisonSeed); + RTTR_EXEC_TILL(s4LikeComparisonGFs, world.GetNode(minePos).resources.getAmount() == 14u); + + BOOST_TEST(world.GetNode(minePos).resources.getAmount() == 14u); +} + +// A mine that actually produces its primary ware ignores the no-output fallback entirely (no stones), both for the +// S4-like and the default behavior. The two are structured identically and only differ in how the deposit is used. +BOOST_FIXTURE_TEST_CASE(CoalMineS4LikeSuccessfulCycleIgnoresNoOutputFallback, MineProductionFixture) +{ + ggs.setSelection(AddonId::COALMINE_RESOURCE_BEHAVIOR, + static_cast(MineResourceBehavior::S4LikeExhaustion)); + ggs.setSelection(AddonId::MINE_NO_OUTPUT_FALLBACK, static_cast(MineNoOutputFallback::ProduceGranite100)); + const MapPoint minePos = CreateMine(BuildingType::CoalMine, Resource(ResourceType::Coal, 15)); + const Inventory& curInventory = world.GetPlayer(curPlayer).GetInventory(); + const unsigned initialCoal = curInventory[GoodType::Coal]; + const unsigned initialStones = curInventory[GoodType::Stones]; + + // Seed chosen so the very first cycle succeeds: otherwise a failed cycle would emit a fallback stone first. + SeedProductionRng(21); + RTTR_EXEC_TILL(maxProductionGFs, curInventory[GoodType::Coal] > initialCoal); + + BOOST_TEST(curInventory[GoodType::Stones] == initialStones); + // S4-like consumes exactly one unit per successful cycle + BOOST_TEST(world.GetNode(minePos).resources.getAmount() == 14u); +} + +BOOST_FIXTURE_TEST_CASE(CoalMineDefaultProductionIgnoresNoOutputFallback, MineProductionFixture) +{ + ggs.setSelection(AddonId::MINE_NO_OUTPUT_FALLBACK, static_cast(MineNoOutputFallback::ProduceGranite100)); + const MapPoint minePos = CreateMine(BuildingType::CoalMine, Resource(ResourceType::Coal, 3)); + const Inventory& curInventory = world.GetPlayer(curPlayer).GetInventory(); + const unsigned initialCoal = curInventory[GoodType::Coal]; + const unsigned initialStones = curInventory[GoodType::Stones]; + + // Default production is deterministic (always mines when a deposit is present), so no seed is needed. + RTTR_EXEC_TILL(maxProductionGFs, curInventory[GoodType::Coal] > initialCoal); + + BOOST_TEST(curInventory[GoodType::Stones] == initialStones); + // The default behavior depletes the deposit on every production + BOOST_TEST(world.GetNode(minePos).resources.getAmount() < 3u); +} + +BOOST_FIXTURE_TEST_CASE(CoalMineWorkEverywhereBehaviorProducesWithoutCreatingResource, MineProductionFixture) +{ + ggs.setSelection(AddonId::COALMINE_RESOURCE_BEHAVIOR, static_cast(MineResourceBehavior::WorkEverywhere)); + const MapPoint minePos = CreateMine(BuildingType::CoalMine); + const Inventory& curInventory = world.GetPlayer(curPlayer).GetInventory(); + const unsigned initialCoal = curInventory[GoodType::Coal]; + + RTTR_EXEC_TILL(maxProductionGFs, curInventory[GoodType::Coal] > initialCoal); + BOOST_TEST(static_cast(world.GetNode(minePos).resources.getType()) + == static_cast(ResourceType::Nothing)); + BOOST_TEST(world.GetNode(minePos).resources.getAmount() == 0u); +} + +// Regression test: the out-of-resources notification was moved from nofWorkman::FindPointWithResource into its +// callers, so every caller (not only mines) must still report it. +BOOST_FIXTURE_TEST_CASE(WellWithoutWaterReportsOutOfResources, WorldWithGCExecution1P) +{ + PostBox& postbox = world.GetPostMgr().AddPostBox(curPlayer); + GoodsAndPeopleCounts inv; + inv[GoodType::Fish] = 40; + inv[Job::Helper] = 2; + world.GetSpecObj(hqPos)->AddToInventory(inv, true); + + const MapPoint wellPos = hqPos + MapPoint(2, 0); + const auto* well = static_cast( + BuildingFactory::CreateBuilding(world, BuildingType::Well, wellPos, curPlayer, Nation::Romans)); + BuildRoad(world.GetNeighbour(wellPos, Direction::SouthEast), false, std::vector(2, Direction::West)); + RTTR_EXEC_TILL(500, well->HasWorker()); + + // The empty test world has no water resources at all, so the well must report that it dried out + RTTR_EXEC_TILL(2000, postbox.GetNumMsgs() > 0u); + BOOST_TEST(well->GetProductivity() == 0u); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/s25Main/integration/testSerialization.cpp b/tests/s25Main/integration/testSerialization.cpp index 0b06c034b4..dd8367b293 100644 --- a/tests/s25Main/integration/testSerialization.cpp +++ b/tests/s25Main/integration/testSerialization.cpp @@ -5,6 +5,7 @@ #include "GameCommands.h" #include "GameEvent.h" #include "GamePlayer.h" +#include "GlobalGameSettings.h" #include "PointOutput.h" #include "Replay.h" #include "RttrForeachPt.h" @@ -19,6 +20,7 @@ #include "figures/nofHunter.h" #include "helpers/OptionalIO.h" #include "helpers/format.hpp" +#include "helpers/serializeEnums.h" #include "network/GameMessage_Chat.h" #include "network/PlayerGameCommands.h" #include "worldFixtures/CreateEmptyWorld.h" @@ -30,6 +32,7 @@ #include "nodeObjs/noFlag.h" #include "gameTypes/GameTypesOutput.h" #include "gameTypes/MapInfo.h" +#include "gameTypes/MineResourceBehavior.h" #include "s25util/tmpFile.h" #include #include @@ -40,6 +43,7 @@ // LCOV_EXCL_START BOOST_TEST_DONT_PRINT_LOG_VALUE(Resource) BOOST_TEST_DONT_PRINT_LOG_VALUE(AddonId) +BOOST_TEST_DONT_PRINT_LOG_VALUE(MineResourceBehavior) BOOST_TEST_DONT_PRINT_LOG_VALUE(nofBuildingWorker::State) // LCOV_EXCL_STOP @@ -159,6 +163,17 @@ void CheckReplayCmds(Replay& loadReplay, const PlayerGameCommands& recordedCmds) gf = loadReplay.ReadGF(); BOOST_TEST(!gf); } + +void PushSerializedGGSHeader(Serializer& ser) +{ + helpers::pushEnum(ser, GameSpeed::Normal); + helpers::pushEnum(ser, GameObjective::None); + helpers::pushEnum(ser, StartWares::Normal); + ser.PushBool(false); + helpers::pushEnum(ser, Exploration::FogOfWar); + ser.PushBool(true); + ser.PushBool(false); +} } // namespace BOOST_AUTO_TEST_SUITE(Serialization) @@ -201,6 +216,67 @@ BOOST_AUTO_TEST_CASE(SerializeGGS) } } +BOOST_AUTO_TEST_CASE(LegacyInexhaustibleMinesDeserializeMigratesToPerMineBehaviors) +{ + Serializer ser; + PushSerializedGGSHeader(ser); + ser.PushUnsignedInt(1); + ser.PushUnsignedInt(static_cast(AddonId::INEXHAUSTIBLE_MINES)); + ser.PushUnsignedInt(1); + + Serializer loader(ser.GetData(), ser.GetLength()); + GlobalGameSettings ggsLoaded; + ggsLoaded.Deserialize(loader); + + for(const BuildingType mineType : + {BuildingType::GraniteMine, BuildingType::CoalMine, BuildingType::IronMine, BuildingType::GoldMine}) + BOOST_TEST(GetMineResourceBehavior(ggsLoaded, mineType) == MineResourceBehavior::Inexhaustible); + BOOST_TEST(ggsLoaded.getSelection(AddonId::INEXHAUSTIBLE_MINES) == 0u); +} + +BOOST_AUTO_TEST_CASE(LegacyInexhaustibleMinesDeserializeAlsoMigratesLegacyGraniteAddonValue) +{ + // Old data always contains an explicit (usually disabled) value for GRANITEMINE_RESOURCE_BEHAVIOR, because that + // id reuses the slot of the former INEXHAUSTIBLE_GRANITEMINES addon which was always serialized. Before this + // refactor the separate INEXHAUSTIBLE_MINES setting turned granite mines inexhaustible on its own, so migration + // must not let a disabled granite value suppress that: granite still becomes Inexhaustible here. + Serializer ser; + PushSerializedGGSHeader(ser); + ser.PushUnsignedInt(2); + ser.PushUnsignedInt(static_cast(AddonId::INEXHAUSTIBLE_MINES)); + ser.PushUnsignedInt(1); + ser.PushUnsignedInt(static_cast(AddonId::GRANITEMINE_RESOURCE_BEHAVIOR)); + ser.PushUnsignedInt(0); + + Serializer loader(ser.GetData(), ser.GetLength()); + GlobalGameSettings ggsLoaded; + ggsLoaded.Deserialize(loader); + + for(const BuildingType mineType : + {BuildingType::GraniteMine, BuildingType::CoalMine, BuildingType::IronMine, BuildingType::GoldMine}) + BOOST_TEST(GetMineResourceBehavior(ggsLoaded, mineType) == MineResourceBehavior::Inexhaustible); +} + +BOOST_AUTO_TEST_CASE(LegacyInexhaustibleMinesDeserializeDoesNotOverridePerMineBehavior) +{ + Serializer ser; + PushSerializedGGSHeader(ser); + ser.PushUnsignedInt(2); + ser.PushUnsignedInt(static_cast(AddonId::INEXHAUSTIBLE_MINES)); + ser.PushUnsignedInt(1); + ser.PushUnsignedInt(static_cast(AddonId::COALMINE_RESOURCE_BEHAVIOR)); + ser.PushUnsignedInt(static_cast(MineResourceBehavior::S4LikeExhaustion)); + + Serializer loader(ser.GetData(), ser.GetLength()); + GlobalGameSettings ggsLoaded; + ggsLoaded.Deserialize(loader); + + BOOST_TEST(GetMineResourceBehavior(ggsLoaded, BuildingType::CoalMine) == MineResourceBehavior::S4LikeExhaustion); + BOOST_TEST(GetMineResourceBehavior(ggsLoaded, BuildingType::GraniteMine) == MineResourceBehavior::Inexhaustible); + BOOST_TEST(GetMineResourceBehavior(ggsLoaded, BuildingType::IronMine) == MineResourceBehavior::Inexhaustible); + BOOST_TEST(GetMineResourceBehavior(ggsLoaded, BuildingType::GoldMine) == MineResourceBehavior::Inexhaustible); +} + BOOST_FIXTURE_TEST_CASE(BaseSaveLoad, RandWorldFixture) { MockLocalGameState lgsGame; diff --git a/tests/s25Main/lua/testLuaSettings.cpp b/tests/s25Main/lua/testLuaSettings.cpp index 31be92dc24..29917b8786 100644 --- a/tests/s25Main/lua/testLuaSettings.cpp +++ b/tests/s25Main/lua/testLuaSettings.cpp @@ -10,7 +10,9 @@ #include "lua/LuaInterfaceSettings.h" #include "network/IGameLobbyController.h" #include "worldFixtures/MockLocalGameState.h" +#include "gameTypes/BuildingType.h" #include "gameTypes/GameTypesOutput.h" +#include "gameTypes/MineResourceBehavior.h" #include "s25util/colors.h" #include #include @@ -265,6 +267,43 @@ BOOST_AUTO_TEST_CASE(SettingsFunctions) } } +BOOST_AUTO_TEST_CASE(LegacyMineAddons) +{ + LogAccessor logAcc; + const auto behaviorOf = [this](BuildingType bld) { return ggs.getSelection(GetMineResourceBehaviorAddonId(bld)); }; + constexpr auto defaultBehavior = static_cast(MineResourceBehavior::Default); + constexpr auto inexhaustible = static_cast(MineResourceBehavior::Inexhaustible); + constexpr auto s4Like = static_cast(MineResourceBehavior::S4LikeExhaustion); + constexpr BuildingType allMines[] = {BuildingType::GraniteMine, BuildingType::CoalMine, BuildingType::IronMine, + BuildingType::GoldMine}; + + // Renamed addon: the old Lua name must still resolve to the same addon and value 1 must still mean inexhaustible + executeLua("rttr:SetAddon(ADDON_INEXHAUSTIBLE_GRANITEMINES, true)"); + BOOST_TEST_REQUIRE(behaviorOf(BuildingType::GraniteMine) == inexhaustible); + BOOST_TEST_REQUIRE(behaviorOf(BuildingType::CoalMine) == defaultBehavior); + + // Removed global addon: scripts using it must still make all mines inexhaustible + executeLua("rttr:ResetAddons()"); + executeLua("rttr:SetAddon(ADDON_INEXHAUSTIBLE_MINES, true)"); + for(const BuildingType bld : allMines) + BOOST_TEST_REQUIRE(behaviorOf(bld) == inexhaustible); + + // ... but mine types configured explicitly beforehand keep their setting + executeLua("rttr:ResetAddons()"); + executeLua("rttr:SetAddon(ADDON_GOLDMINE_RESOURCE_BEHAVIOR, 2)"); + executeLua("rttr:SetAddon(ADDON_INEXHAUSTIBLE_MINES, true)"); + BOOST_TEST_REQUIRE(behaviorOf(BuildingType::GoldMine) == s4Like); + BOOST_TEST_REQUIRE(behaviorOf(BuildingType::CoalMine) == inexhaustible); + + // Disabling the legacy addon changes nothing + executeLua("rttr:ResetAddons()"); + executeLua("rttr:SetAddon(ADDON_INEXHAUSTIBLE_MINES, false)"); + for(const BuildingType bld : allMines) + BOOST_TEST_REQUIRE(behaviorOf(bld) == defaultBehavior); + // The legacy addon must not be registered, so it cannot show up twice in the addon UI + BOOST_TEST_REQUIRE(ggs.getSelection(AddonId::INEXHAUSTIBLE_MINES) == 0u); +} + BOOST_AUTO_TEST_CASE(PlayerSettings) { LogAccessor logAcc;