diff --git a/doc/AddingCustomCampaign.md b/doc/AddingCustomCampaign.md index 19b8206d99..5d99d08fb4 100644 --- a/doc/AddingCustomCampaign.md +++ b/doc/AddingCustomCampaign.md @@ -6,7 +6,7 @@ SPDX-License-Identifier: GPL-2.0-or-later # Add a custom campaign -You can have a look into the already existing original Settlers 2 campaigns in the subfolders `RTTR/campaigns/roman` and `RTTR/campaigns/continent` for an example. +You can take a look into the already existing original Settlers 2 campaigns in the subfolders `RTTR/campaigns/roman` and `RTTR/campaigns/continent` for an example. We will now create an example campaign `garden`. ## Location for adding a new campaign @@ -41,18 +41,20 @@ campaign = { name = _"name", shortDescription = _"shortDescription", longDescription = _"longDescription", - image = "/campaigns/garden/garden.bmp", + image = "/campaigns/garden/garden.bmp", -- Same as: image = "garden.bmp", maxHumanPlayers= 1, difficulty = "easy", - mapFolder = "/campaigns/garden", - luaFolder = "/campaigns/garden", + mapFolder = "/campaigns/garden", -- optional + luaFolder = "/campaigns/garden", -- optional maps = { "MISS01.WLD","MISS02.WLD"}, selectionMap = { - background = {"/campaigns/garden/mapscreen/background.bmp", 0}, - map = {"/campaigns/garden/mapscreen/map.bmp", 0}, - missionMapMask = {"/campaigns/garden/mapscreen/map_mask.bmp", 0}, - marker = {"/campaigns/garden/mapscreen/marker.bmp", 0}, - conquered = {"/campaigns/garden/mapscreen/conquered.bmp", 0}, + background = {"/campaigns/garden/mapscreen/background.bmp", 0}, + map = {"/campaigns/garden/mapscreen/map.bmp", 0}, + missionMapMask = {"/campaigns/garden/mapscreen/map_mask.bmp", 0}, + marker = {"/campaigns/garden/mapscreen/marker.bmp", 0}, + conquered = {"/campaigns/garden/mapscreen/conquered.bmp", 0}, + -- Each '/campaigns/garden/' is optional: Paths are treated as relative to campaign file. + -- E.g.: conquered = {"mapscreen/conquered.bmp", 0}, backgroundOffset = {0, 0}, disabledColor = 0x70000000, missionSelectionInfos = { @@ -75,7 +77,7 @@ The Lua campaign interface is versioned using a major version. Every time a feat Every map script must have 1 function: `getRequiredLuaVersion()` -You need to implement this and return the version your script works with. If it does not match the current version an error will be shown and the script will not be used. +You need to implement this and return the version your script works with. If it is higher than the current version an error will be shown and the script will not be used. ### Explanation of the campaign table fields @@ -86,7 +88,7 @@ If you want a field to be translated you have to add the translation as describe 3. `name`: The name of the campaign 4. `shortDescription`: Short description of the campaign (like a headline to get a rough imagination of the campaign) 5. `longDescription`: Extended description describing the campaign in detail. Will be shown in the campaign selection screen, when the campaign is selected. - 6. `image`: Path to an image displayed in the campaign selection screen. You can omit this if you do no want to provide an image. + 6. `image`: Path to an image displayed in the campaign selection screen. Can be any "archive" with an image, e.g. `.bmp`, `.lbm`. You can omit this if you do no want to provide an image. 7. `maxHumanPlayers`: For now this is always 1 until we support multiplayer campaigns 8. `difficulty`: Difficulty of the campaign. Should be one of the values easy, medium or hard. 9. `mapFolder` and `luaFolder`: Path to the folder containing the campaign maps and associated Lua files. Usually your campaign folder or a subfolder of it. @@ -95,7 +97,7 @@ If you want a field to be translated you have to add the translation as describe Hints: -- To work on case-sensitive OS (like Linux) the file name of the Lua file must have the same case as the map file name. This applies to the map names in the campaign.lua file too. +- To work on case-sensitive OS (like Linux) the file name of the Lua file must have the same case as the map file name. This applies to the map names in the `campaign.lua` file too. For example: `MISS01.WLD, MISS01.lua` is correct and `MISS01.WLD, miss01.lua` will not work on Linux - The Lua file of a map must have the same name as the map itself but with the extension `.lua` to be found. - The Lua and the map file don't need to be in the same folder because the path can be specified separately. @@ -107,7 +109,7 @@ For example: `MISS01.WLD, MISS01.lua` is correct and `MISS01.WLD, miss01.lua` wi ### Optional map selection screen {#selection-map} -This parameter is optional and can be omitted in the Lua campaign file. If this parameter is specified the selection screen for the missions of a campaign is replaced by a selection map. Like the one used in the original settlers 2 world campaign. +This parameter is optional and can be omitted in the Lua campaign file. If this parameter is specified the selection screen for the missions of a campaign is replaced by a selection map. Like the one used in the original Settlers 2 world campaign. We have the following parameters: @@ -124,6 +126,11 @@ Hint: All the images are described by the path to the image file and an index parameter. Usually the index parameter is zero. For special image formats containing multiple images in an archive this is the index of the image to use. +### Image paths + +The paths to the campaign image and selection map images can be relative to the campaign folder and at most inside a single subfolder. +E.g. `images/garden.bmp` and `mapscreen/conquered.bmp` work, but `images/mapscreen/conquered.bmp` does not. + ## Final view of the example garden campaign folder ```sh @@ -140,3 +147,6 @@ RTTR/campaigns/garden/mapscreen/map_mask.bmp RTTR/campaigns/garden/mapscreen/marker.bmp RTTR/campaigns/garden/mapscreen/conquered.bmp ``` + +Note that for custom campaigns, placed in `/campaigns/` the top-level `RTTR` folder won't exist. +Hence, `/campaigns/` should not be used in the campaign description file to refer to campaign files. diff --git a/doc/lua/functions.md b/doc/lua/functions.md index bbbb687917..c651965623 100644 --- a/doc/lua/functions.md +++ b/doc/lua/functions.md @@ -1,5 +1,5 @@ @@ -24,7 +24,8 @@ Reference: [libs/libGamedata/lua/LuaInterfaceBase.cpp](../../libs/libGamedata/lu **rttr:GetFeatureLevel()** Get the current feature level of the LUA interface. Increases here indicate new features. -The current version is **6**. +The current version is **7**. +See [list of changes](main.md#versioning). **rttr:Log(message)** Log the message to console. diff --git a/doc/lua/main.md b/doc/lua/main.md index e0837adc8d..deb29602c3 100644 --- a/doc/lua/main.md +++ b/doc/lua/main.md @@ -1,5 +1,5 @@ @@ -47,6 +47,10 @@ You need to implement this and return the major/main version your script works w If it does not match the current version an error will be shown and the script will not be used. See also `rttr:GetFeatureLevel()`. +### Feature level 7 + +- Allow relative paths for images referenced by campaign files. + ## Example ```lua diff --git a/libs/libGamedata/gameData/CampaignDescription.cpp b/libs/libGamedata/gameData/CampaignDescription.cpp index ae44b8821f..fc1e82eb51 100644 --- a/libs/libGamedata/gameData/CampaignDescription.cpp +++ b/libs/libGamedata/gameData/CampaignDescription.cpp @@ -1,9 +1,10 @@ -// Copyright (C) 2005 - 2024 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later #include "CampaignDescription.h" #include "RttrConfig.h" +#include "helpers/containerUtils.h" #include "helpers/format.hpp" #include "lua/CheckedLuaTable.h" #include "lua/LuaHelpers.h" @@ -11,15 +12,45 @@ CampaignDescription::CampaignDescription(const boost::filesystem::path& campaignPath, const kaguya::LuaRef& table) { + const auto resolveCampaignPath = [campaignPath](const std::string& path, bool isFolder) { + const boost::filesystem::path tmpPath = path; + if(tmpPath.is_relative()) + { + // If it is only a file/folder name or empty use path relative to campaign folder + if(!tmpPath.has_parent_path()) + return (campaignPath / tmpPath).string(); + if(!isFolder) + { + // For files only allow a single sub folder + const auto parentPath = tmpPath.parent_path(); + if(!parentPath.parent_path().has_parent_path()) + { + // Only alpha-numeric folder names are allowed + const auto isAlNum = [](const char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); + }; + const auto isNonAlNum = [isAlNum](const char c) { return !isAlNum(c); }; + lua::assertTrue(!helpers::contains_if(parentPath.string(), isNonAlNum), + helpers::format(_("Invalid path '%1%': Must be alpha-numeric"), path)); + return (campaignPath / tmpPath).string(); + } + } + } + // Otherwise it must be a valid path inside the game files + lua::validatePath(path); + return path; + }; + CheckedLuaTable luaData(table); luaData.getOrThrow(version, "version"); luaData.getOrThrow(author, "author"); luaData.getOrThrow(name, "name"); luaData.getOrThrow(shortDescription, "shortDescription"); luaData.getOrThrow(longDescription, "longDescription"); - image = luaData.getOptional("image"); - if(image && image->empty()) - image = std::nullopt; + const auto imageValue = luaData.getOptional("image"); + if(imageValue && !imageValue->empty()) + image = resolveCampaignPath(*imageValue, false); + luaData.getOrThrow(maxHumanPlayers, "maxHumanPlayers"); if(maxHumanPlayers != 1) @@ -30,22 +61,24 @@ CampaignDescription::CampaignDescription(const boost::filesystem::path& campaign if(difficulty != gettext_noop("easy") && difficulty != gettext_noop("medium") && difficulty != gettext_noop("hard")) throw std::invalid_argument(helpers::format(_("Invalid difficulty: %1%"), difficulty)); - auto resolveFolder = [campaignPath](const std::string& folder) { - const boost::filesystem::path tmpPath = folder; - // If it is only a filename or empty use path relative to campaign folder - if(!tmpPath.has_parent_path()) - return campaignPath / tmpPath; - // Otherwise it must be a valid path inside the game files - lua::validatePath(folder); - return RTTRCONFIG.ExpandPath(folder); - }; - const auto mapFolder = luaData.getOrDefault("mapFolder", std::string{}); - mapFolder_ = resolveFolder(mapFolder); + mapFolder_ = RTTRCONFIG.ExpandPath(resolveCampaignPath(mapFolder, true)); // Default lua folder to map folder, i.e. LUA files are side by side with the maps - luaFolder_ = resolveFolder(luaData.getOrDefault("luaFolder", mapFolder)); + luaFolder_ = RTTRCONFIG.ExpandPath(resolveCampaignPath(luaData.getOrDefault("luaFolder", mapFolder), true)); mapNames_ = luaData.getOrDefault("maps", std::vector()); selectionMapData = luaData.getOptional("selectionMap"); + if(selectionMapData) + { + const auto updatePath = [resolveCampaignPath](std::string& path) { + if(!path.empty()) + path = resolveCampaignPath(path, false); + }; + updatePath(selectionMapData->background.filePath); + updatePath(selectionMapData->map.filePath); + updatePath(selectionMapData->missionMapMask.filePath); + updatePath(selectionMapData->marker.filePath); + updatePath(selectionMapData->conquered.filePath); + } luaData.checkUnused(); } diff --git a/libs/libGamedata/gameData/CampaignDescription.h b/libs/libGamedata/gameData/CampaignDescription.h index 7de41de861..4f2d63bfa0 100644 --- a/libs/libGamedata/gameData/CampaignDescription.h +++ b/libs/libGamedata/gameData/CampaignDescription.h @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2024 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later diff --git a/libs/libGamedata/gameData/SelectionMapInputData.cpp b/libs/libGamedata/gameData/SelectionMapInputData.cpp index 0369e6fab9..f430e89dd8 100644 --- a/libs/libGamedata/gameData/SelectionMapInputData.cpp +++ b/libs/libGamedata/gameData/SelectionMapInputData.cpp @@ -225,8 +225,7 @@ struct lua_type_traits const LuaStackRef table(l, index); if(table.type() != LUA_TTABLE || table.size() != 2) throw LuaTypeMismatch(); - std::string path = table[1]; - return get_type(boost::filesystem::path(path), table[2]); + return get_type(table[1], table[2]); } static int push(lua_State* l, push_type v) { return util::push_args(l, v.filePath, v.index); } }; diff --git a/libs/libGamedata/gameData/SelectionMapInputData.h b/libs/libGamedata/gameData/SelectionMapInputData.h index 155fc238e8..8565852fa8 100644 --- a/libs/libGamedata/gameData/SelectionMapInputData.h +++ b/libs/libGamedata/gameData/SelectionMapInputData.h @@ -15,10 +15,9 @@ class LuaRef; struct ImageResource { - boost::filesystem::path filePath; + std::string filePath; unsigned index; - ImageResource(boost::filesystem::path path = boost::filesystem::path(), unsigned index = 0) - : filePath(std::move(path)), index(index){}; + ImageResource(std::string path = "", unsigned index = 0) : filePath(std::move(path)), index(index){}; }; struct MissionSelectionInfo diff --git a/libs/libGamedata/lua/CampaignDataLoader.cpp b/libs/libGamedata/lua/CampaignDataLoader.cpp index e9eab06a81..4828cb8c8d 100644 --- a/libs/libGamedata/lua/CampaignDataLoader.cpp +++ b/libs/libGamedata/lua/CampaignDataLoader.cpp @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2023 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -17,7 +17,7 @@ unsigned CampaignDataLoader::GetVersion() { - return 2; + return 3; } CampaignDataLoader::CampaignDataLoader(CampaignDescription& campaignDesc, const boost::filesystem::path& basePath) diff --git a/libs/s25main/controls/ctrlMapSelection.cpp b/libs/s25main/controls/ctrlMapSelection.cpp index 87f673724c..8cd5aa351f 100644 --- a/libs/s25main/controls/ctrlMapSelection.cpp +++ b/libs/s25main/controls/ctrlMapSelection.cpp @@ -21,7 +21,7 @@ ctrlMapSelection::MapImages::MapImages(const SelectionMapInputData& data) { auto getImage = [](const ImageResource& res) { - auto* img = LOADER.GetImageN(ResourceId::make(res.filePath), res.index); + auto* img = LOADER.GetImageN(ResourceId::fromPath(res.filePath), res.index); if(!img) throw std::runtime_error( helpers::format(_("Loading of images %s for map selection failed."), res.filePath)); @@ -31,7 +31,7 @@ ctrlMapSelection::MapImages::MapImages(const SelectionMapInputData& data) { std::vector pathsToLoad; for(const auto& res : {data.background, data.map, data.missionMapMask, data.marker, data.conquered}) - pathsToLoad.push_back(res.filePath.string()); + pathsToLoad.push_back(res.filePath); LOADER.LoadFiles(pathsToLoad); } diff --git a/libs/s25main/desktops/dskCampaignSelection.cpp b/libs/s25main/desktops/dskCampaignSelection.cpp index 88c970485b..5b43e1b7ef 100644 --- a/libs/s25main/desktops/dskCampaignSelection.cpp +++ b/libs/s25main/desktops/dskCampaignSelection.cpp @@ -137,7 +137,7 @@ void dskCampaignSelection::Msg_TableSelectItem(const unsigned ctrl_id, const boo mapSelection->setMissionsStatus(std::vector(campaign.getNumMaps(), {true, true})); mapSelection->setPreview(true); } else if(campaign.image) - campaignImage_ = LOADER.GetImageN(ResourceId::fromPath(*campaign.image), 0); + campaignImage_ = LOADER.GetImageN(ResourceId::make(*campaign.image), 0); else campaignImage_ = nullptr; } diff --git a/libs/s25main/lua/LuaInterfaceGameBase.cpp b/libs/s25main/lua/LuaInterfaceGameBase.cpp index 7f6e4f49ae..f00ff7514a 100644 --- a/libs/s25main/lua/LuaInterfaceGameBase.cpp +++ b/libs/s25main/lua/LuaInterfaceGameBase.cpp @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2021 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -16,7 +16,7 @@ unsigned LuaInterfaceGameBase::GetVersion() unsigned LuaInterfaceGameBase::GetFeatureLevel() { - return 6; + return 7; } LuaInterfaceGameBase::LuaInterfaceGameBase(const ILocalGameState& localGameState) : localGameState(localGameState) diff --git a/tests/libGameData/testCampaignLuaFile.cpp b/tests/libGameData/testCampaignLuaFile.cpp index 51c56335bb..98794ca49b 100644 --- a/tests/libGameData/testCampaignLuaFile.cpp +++ b/tests/libGameData/testCampaignLuaFile.cpp @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2024 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -212,7 +212,8 @@ BOOST_AUTO_TEST_CASE(HandleMapAndLuaPaths) longDescription = "long", maxHumanPlayers = 1, difficulty = "easy", - maps = { "map.WLD" } + maps = { "map.WLD" }, + image = "myimage.LBM" } function getRequiredLuaVersion() return 1 end )"; @@ -225,6 +226,8 @@ BOOST_AUTO_TEST_CASE(HandleMapAndLuaPaths) BOOST_TEST_REQUIRE(loader.Load()); BOOST_TEST(desc.getMapFilePath(0) == tmp / "map.WLD"); BOOST_TEST(desc.getLuaFilePath(0) == tmp / "map.lua"); + // Similar for image + BOOST_TEST(desc.image == tmp / "myimage.LBM"); } // Only folder name is a subdirectory to the campaign @@ -298,6 +301,28 @@ BOOST_AUTO_TEST_CASE(HandleMapAndLuaPaths) BOOST_TEST_REQUIRE(!loader.Load()); RTTR_REQUIRE_LOG_CONTAINS_SOME("Invalid path 'subdir/maps", false); } + + { + bnw::ofstream file(tmp / "campaign.lua", std::ios_base::app); + file << R"( + campaign["mapFolder"] = "" + campaign["luaFolder"] = "" + )"; + } + // Relative image paths are only allowed to be a single sub folder with alpha-numeric name + for(const auto& invValue : {"sub/subsub/img", "../sub/img", "sub/../img", "../img", "Th!s/img", "/abs", "/abs/img"}) + { + BOOST_TEST_INFO_SCOPE("Value: " << invValue); + // Similar the image must only be a name if not a /GFX/PICS/SETUP990.LBM", 0}, map = {"/GFX/PICS/WORLD.LBM", 0}, missionMapMask = {"/GFX/PICS/WORLDMSK.LBM", 0}, - marker = {"/DATA/IO/IO.DAT", 231}, - conquered = {"/DATA/IO/IO.DAT", 232}, + -- Can be relative to campaign folder + marker = {"marker.DAT", 231}, + conquered = {"imgs/conquered.DAT", 232}, backgroundOffset = {64, 70}, disabledColor = 0x70000000, missionSelectionInfos = { @@ -521,9 +547,9 @@ BOOST_AUTO_TEST_CASE(OptionalSelectionMapLoadTest) BOOST_TEST(selectionMap->map.index == 0u); BOOST_TEST(selectionMap->missionMapMask.filePath == "/GFX/PICS/WORLDMSK.LBM"); BOOST_TEST(selectionMap->missionMapMask.index == 0u); - BOOST_TEST(selectionMap->marker.filePath == "/DATA/IO/IO.DAT"); + BOOST_TEST(selectionMap->marker.filePath == tmp / "marker.DAT"); BOOST_TEST(selectionMap->marker.index == 231u); - BOOST_TEST(selectionMap->conquered.filePath == "/DATA/IO/IO.DAT"); + BOOST_TEST(selectionMap->conquered.filePath == tmp / "imgs/conquered.DAT"); BOOST_TEST(selectionMap->conquered.index == 232u); BOOST_TEST(selectionMap->mapOffsetInBackground == Position(64, 70)); BOOST_TEST(selectionMap->disabledColor == 0x70000000u); diff --git a/tests/s25Main/UI/testMapSelection.cpp b/tests/s25Main/UI/testMapSelection.cpp index ff6373a81c..bc9e4c4ad0 100644 --- a/tests/s25Main/UI/testMapSelection.cpp +++ b/tests/s25Main/UI/testMapSelection.cpp @@ -104,11 +104,11 @@ SelectionMapInputData createInputForSelectionMap(rttr::test::TmpFolder const& tm storeBitmap(conqueredPath, createBitmapWithOneColor(overlaySize, red)); SelectionMapInputData selectionMapInputData; - selectionMapInputData.background = {backgroundPath, 0}; - selectionMapInputData.map = {mapPath, 0}; - selectionMapInputData.missionMapMask = {missionmapmaskPath, 0}; - selectionMapInputData.marker = {markerPath, 0}; - selectionMapInputData.conquered = {conqueredPath, 0}; + selectionMapInputData.background = {backgroundPath.string(), 0}; + selectionMapInputData.map = {mapPath.string(), 0}; + selectionMapInputData.missionMapMask = {missionmapmaskPath.string(), 0}; + selectionMapInputData.marker = {markerPath.string(), 0}; + selectionMapInputData.conquered = {conqueredPath.string(), 0}; selectionMapInputData.mapOffsetInBackground = mapOffsetInBackground; selectionMapInputData.disabledColor = disabledColor; selectionMapInputData.missionSelectionInfos = {{red, {Position((mapSize.x * 3) / 4, mapSize.y / 4)}},