diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index adcbfe9c3..2ae70bb7c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -159,6 +159,10 @@ jobs: scons -j$(nproc) release=1 server=0 hiring-bucket-test python3 test/run-savegame-safety-tests.py --check-preferences build/src/HiringBucketHarness . + - name: Build and run the market-fetch regression + run: | + scons -j$(nproc) release=1 server=0 market-fetch-test + python3 test/run-savegame-safety-tests.py --check-preferences build/src/MarketFetchHarness . - name: Build and run the resource-fetch target regression run: | scons -j$(nproc) release=1 server=0 resource-fetch-target-test diff --git a/.local/state/gh/device-id b/.local/state/gh/device-id new file mode 100644 index 000000000..0d14fd0e0 --- /dev/null +++ b/.local/state/gh/device-id @@ -0,0 +1 @@ +9f877a11-305a-44a7-ab94-ee15a2cc948c \ No newline at end of file diff --git a/src/FileFormatVersions.h b/src/FileFormatVersions.h index ef8a211ca..a8a671047 100644 --- a/src/FileFormatVersions.h +++ b/src/FileFormatVersions.h @@ -91,6 +91,9 @@ static constexpr int FILE_FORMAT_VERSION_CONTINUATION_STATE = 91; //! A building's round-trip fields and gradient use stamps join the cached routing fields. static constexpr int FILE_FORMAT_VERSION_ROUND_TRIP_FIELDS = 95; +/// The "with markets" twin of each resource gradient travels with the map +/// runtime state, like the plain one. +static constexpr int FILE_FORMAT_VERSION_MARKET_GRADIENTS = 98; // === Save-file section signatures (4-byte ASCII tags) === // Embedded as four chars at the start of each save section so a corrupted diff --git a/src/ReplayReader.h b/src/ReplayReader.h index ce358faa2..b61ba2c26 100644 --- a/src/ReplayReader.h +++ b/src/ReplayReader.h @@ -23,10 +23,12 @@ static constexpr Uint32 REPLAY_MIN_VALID_ORDERS = 5; //! Oldest replay format (the VERSION_MINOR the replay was written with) that //! the reader still accepts. Replays older than this are rejected: versions 90, 92, -//! 93, 94 and 95 changed the simulation (weighted pathfinding and diagonal timing, +//! 93, 94, 95, 96, 97 and 98 changed the simulation (weighted pathfinding and diagonal timing, //! hiring-bucket iteration, trapped-colony elimination, fetch-job apportionment, -//! round-trip routing and hiring), so earlier replays would diverge from what happened. -static constexpr Uint16 REPLAY_MINIMUM_VERSION_MINOR = 95; +//! round-trip routing and hiring, Echo building-order ids surviving a load, +//! per-delivery hiring, markets on the fetch gradients), so earlier replays +//! would diverge from what happened. +static constexpr Uint16 REPLAY_MINIMUM_VERSION_MINOR = 98; /// This class is used for reading replays. /// The replay stream is kept open and read every time you do retrieveOrder. diff --git a/src/SConscript b/src/SConscript index 35abb8883..0aa1ac36d 100644 --- a/src/SConscript +++ b/src/SConscript @@ -659,6 +659,12 @@ if not env['server'] and 'hiring-bucket-test' in COMMAND_LINE_TARGETS: hiring_sources += local.Object('HiringBucketHarness.o', '#test/HiringBucketHarness.cpp') local.Alias('hiring-bucket-test', local.Program('HiringBucketHarness', hiring_sources)) +# A building's fetch is led to a stocked market of its team when that is the shorter trip. +if not env['server'] and 'market-fetch-test' in COMMAND_LINE_TARGETS: + market_sources = [source for source in source_files if source != 'Glob2.cpp'] + market_sources += local.Object('MarketFetchHarness.o', '#test/MarketFetchHarness.cpp') + local.Alias('market-fetch-test', local.Program('MarketFetchHarness', market_sources)) + if not env['server'] and 'custom-setup-test' in COMMAND_LINE_TARGETS: custom_sources = [source for source in source_files if source != 'Glob2.cpp'] custom_sources += local.Object('CustomGameSetupHarness.o', '#test/CustomGameSetupHarness.cpp') diff --git a/src/Version.h b/src/Version.h index a48e034bb..5a583b807 100644 --- a/src/Version.h +++ b/src/Version.h @@ -6,7 +6,7 @@ // This is the version of map and savegame format, and all of the recorded data on the server #define VERSION_MAJOR 0 #define MINIMUM_VERSION_MINOR 58 -#define VERSION_MINOR 95 +#define VERSION_MINOR 98 // version 91 saves the live RNG and routing state for deterministic continuation. // version 10 adds script saved in game // version 11 the gamesfiles do saves which building has been seen under fog of war. @@ -102,6 +102,15 @@ // rather than refusing it: the simulation changed again // version 95 routes and hires by round trip (fetch plus carry) and saves the // round-trip fields with the map runtime state: the simulation changed again +// version 96 saves AIEcho::Construction::BuildingOrder::id, which was assigned at +// runtime and never serialised, so every pending building order restored +// from a save carried an uninitialised heap value as its register key +// version 97 hires a fetcher for one delivery at a time: a unit that has just +// dropped off goes back to the free pool instead of re-hiring itself +// for its building's next trip, so every trip is auctioned +// version 98 puts the team's stocked markets into the resource gradients a +// building's fetchers walk and are hired by, so the market path +// survives per-delivery hiring: the simulation changed again //This must be updated when there are changes to YOG, MapHeader, GameHeader, BasePlayer, BaseTeam, //NetMessage, and the likes, in parallel to change of the VERSION_MINOR above diff --git a/src/ai/echo/BuildingOrder.cpp b/src/ai/echo/BuildingOrder.cpp index 98ab517f7..f3cb8f714 100644 --- a/src/ai/echo/BuildingOrder.cpp +++ b/src/ai/echo/BuildingOrder.cpp @@ -27,6 +27,9 @@ bool BuildingOrder::load(GAGCore::InputStream *stream, Player *player, Sint32 ve building_type=stream->readUint32("building_type"); number_of_workers=stream->readUint32("number_of_workers"); + // Saves older than 96 do not carry it; Echo::load registers a fresh one. + if (versionMinor>=96) + id=static_cast(stream->readUint32("id")); stream->readEnterSection("constraints"); Uint32 size = stream->readUint32("size"); @@ -62,6 +65,7 @@ void BuildingOrder::save(GAGCore::OutputStream *stream) stream->writeUint32(building_type, "building_type"); stream->writeUint32(number_of_workers, "number_of_workers"); + stream->writeUint32(static_cast(id), "id"); stream->writeEnterSection("constraints"); stream->writeUint32(constraints.size(), "size"); diff --git a/src/ai/echo/Construction.h b/src/ai/echo/Construction.h index 85a76ef27..8566af511 100644 --- a/src/ai/echo/Construction.h +++ b/src/ai/echo/Construction.h @@ -248,7 +248,11 @@ namespace AIEcho int get_number_of_workers() const { return number_of_workers; } int building_type; int number_of_workers; - int id; + /// Assigned by Echo::add_building_order from BuildingRegister, and the key + /// this order is known by in BuildingRegister::pending_buildings. Defaulted + /// so an order that is constructed and never registered is still readable; + /// load() leaves it at -1 for saves written before it was serialised. + int id = -1; std::vector > constraints; std::vector > conditions; }; diff --git a/src/ai/echo/EchoSerialization.cpp b/src/ai/echo/EchoSerialization.cpp index 128b1e155..64b29d8e6 100644 --- a/src/ai/echo/EchoSerialization.cpp +++ b/src/ai/echo/EchoSerialization.cpp @@ -64,6 +64,12 @@ bool Echo::load(GAGCore::InputStream *stream, Player *player, Sint32 versionMino stream->readEnterSection(buildingIndex); building_orders[buildingIndex]=std::shared_ptr(new BuildingOrder); building_orders[buildingIndex]->load(stream, player, versionMinor); + // A save from before the id was serialised leaves it at -1. Hand out a + // fresh registration rather than a sentinel: the id is used as a + // BuildingRegister map key and passed to AssignWorkers, so it has to be + // a real one. br is already loaded at this point. + if (building_orders[buildingIndex]->id < 0) + building_orders[buildingIndex]->id = static_cast(br.register_building()); stream->readLeaveSection(); } stream->readLeaveSection(); diff --git a/src/building/Building.h b/src/building/Building.h index 668b6177b..bfef67575 100644 --- a/src/building/Building.h +++ b/src/building/Building.h @@ -208,6 +208,10 @@ class Building : public BuildingUtils bool subscribeToBringResourcesStep(void); //! Whether the unit's type and level qualify it to work for this building. bool canUnitWorkHere(Unit* unit); + /// Whether fetches for this building may take from the team's stocked + /// markets. Markets fetch for themselves from the map only, so stock never + /// circulates between markets. + bool fetchesFromMarkets() const; ///This function subscribes any flag that needs units. ///It is considered greedy, hiring as many units as it needs in order of its preference ///Returns true if a unit was hired diff --git a/src/building/Misc.cpp b/src/building/Misc.cpp index b590454c4..65a7625b8 100644 --- a/src/building/Misc.cpp +++ b/src/building/Misc.cpp @@ -103,6 +103,10 @@ void Building::kill(void) } buildingState=DEAD; + if (type->canExchange) + for (int r=0; r0) + owner->map->dirtyMarketGradients(owner->teamNumber, r); updateUnitsHarvesting(); @@ -112,6 +116,11 @@ void Building::kill(void) } +bool Building::fetchesFromMarkets() const +{ + return !type->canExchange; +} + bool Building::canUnitWorkHere(Unit* unit) { if(type->isVirtual) @@ -192,6 +201,8 @@ void Building::updateResourcesPointer() void Building::addResourceIntoBuilding(int resourceType) { + if (type->canExchange && resources[resourceType]<=0) + owner->map->dirtyMarketGradients(owner->teamNumber, resourceType); resources[resourceType]+=type->multiplierResource[resourceType]; //You can not exceed the maximum amount resources[resourceType] = std::min(resources[resourceType], type->maxResource[resourceType]); @@ -232,6 +243,8 @@ void Building::removeResourceFromBuilding(int resourceType) { resources[resourceType]-=type->multiplierResource[resourceType]; resources[resourceType]= std::max(resources[resourceType], 0); + if (type->canExchange && resources[resourceType]<=0) + owner->map->dirtyMarketGradients(owner->teamNumber, resourceType); updateCallLists(); } diff --git a/src/building/Step.cpp b/src/building/Step.cpp index c730b1ea8..55491c7c3 100644 --- a/src/building/Step.cpp +++ b/src/building/Step.cpp @@ -89,7 +89,7 @@ bool Building::considerUnitForResource(Unit* unit, int wantedResource, int* dist int timeLeft=(unit->hungry-unit->trigHungry)/unit->race->hungriness; int distResource = 0; if(!owner->map->resourceAvailable(owner->teamNumber, wantedResource, unit->swimClass(), - unit->posX, unit->posY, &distResource)) + unit->posX, unit->posY, &distResource, fetchesFromMarkets())) { if(wantedResource bool isGradientPeak(const T *gradient, int x, int y) const; - Uint16 getGradient(int teamNumber, Uint8 resourceType, int swimClass, int x, int y) + Uint16 getGradient(int teamNumber, Uint8 resourceType, int swimClass, int x, int y, bool withMarkets = false) { - return getResourceGradient(teamNumber, resourceType, swimClass)[coordToIndex(x, y)]; + return getResourceGradient(teamNumber, resourceType, swimClass, withMarkets)[coordToIndex(x, y)]; } // Chamfer distance transform on a pre-seeded Uint8 buffer. Caller fills the @@ -646,11 +654,11 @@ class Map //! Step toward the neighbour with the highest value minus step cost. strict requires //! real progress; otherwise a random sidestep to an equal cell is accepted when blocked. bool directionByGradient(Uint32 teamMask, int swimClass, int x, int y, const Uint16 *gradient, int *dx, int *dy, bool strict) const; - void updateResourcesGradient(int teamNumber, Uint8 resourceType, int swimClass); + void updateResourcesGradient(int teamNumber, Uint8 resourceType, int swimClass, bool withMarkets = false); //! Direction toward a resource of resourceType. With a target building the round-trip //! gradient is descended, so the unit heads for the resource that is nearest for //! fetching and carrying it there; without one, for the resource nearest to itself. - bool pathfindResource(int teamNumber, Uint8 resourceType, int swimClass, int x, int y, int *dx, int *dy, bool *stopWork, Building *target); + bool pathfindResource(int teamNumber, Uint8 resourceType, int swimClass, int x, int y, int *dx, int *dy, bool *stopWork, Building *target, bool withMarkets = false); #ifndef YOG_SERVER_ONLY void pathfindRandom(Unit *unit); #endif // !YOG_SERVER_ONLY @@ -746,6 +754,8 @@ class Map // Used to go to resources //[int team][int resourceNumber][int swimClass] Uint16 *resourcesGradient[Team::MAX_COUNT][MAX_NB_RESOURCES][SWIM_CLASS_COUNT]; + //! Same, with the team's stocked markets as goals (see getResourceGradient). + Uint16 *marketResourcesGradient[Team::MAX_COUNT][MAX_NB_RESOURCES][SWIM_CLASS_COUNT]; // Used to go out of forbidden areas Uint16 *forbiddenGradient[Team::MAX_COUNT][SWIM_CLASS_COUNT]; @@ -776,6 +786,11 @@ class Map protected: //Used for scheduling computation time. bool gradientUpdated[Team::MAX_COUNT][MAX_NB_RESOURCES][SWIM_CLASS_COUNT]; + //! A market's stock of the resource switched: rebuild the twin before its plain + //! gradient's next turn in the round robin. + bool marketGradientDirty[Team::MAX_COUNT][MAX_NB_RESOURCES][SWIM_CLASS_COUNT]; + //! Whether tile gid is one of teamNumber's alive markets holding resourceType. + bool isStockedMarketTile(Uint16 gid, int teamNumber, int resourceType) const; //Used for scheduling computation time on the guard area gradients bool guardGradientUpdated[Team::MAX_COUNT][SWIM_CLASS_COUNT]; //Used for scheduling computation time on the clear area gradients diff --git a/src/map/MapInternal.h b/src/map/MapInternal.h index 8a02d1d49..870417752 100644 --- a/src/map/MapInternal.h +++ b/src/map/MapInternal.h @@ -43,6 +43,13 @@ constexpr std::uint16_t GRADIENT_FORBIDDEN = 0; constexpr std::uint16_t GRADIENT_UNREACHABLE = 1; constexpr std::uint16_t GRADIENT_AT_GOAL = 0xFFFF; constexpr std::uint16_t GRADIENT_FORBIDDEN_BORDER = GRADIENT_AT_GOAL - GRADIENT_STEP; +/// Tiles a fetch out of a market is charged on top of the walk, for the +/// resource having been carried there once already. Markets sit next to what +/// they teleport, so the detour is small. +constexpr int MARKET_DETOUR_TILES = 5; +/// Seed of a stocked market's tiles in a "with markets" resource gradient: a +/// goal that costs the detour more than a tile of the resource itself. +constexpr std::uint16_t GRADIENT_MARKET_SEED = GRADIENT_AT_GOAL - MARKET_DETOUR_TILES * GRADIENT_STEP; // Weighted cost rounded to whole land-step equivalents, for a reachable value. // This is not a geometric tile count: water and diagonal steps change the cost. diff --git a/src/map/MapQuery.cpp b/src/map/MapQuery.cpp index 2e7c0a606..cd3a033f1 100644 --- a/src/map/MapQuery.cpp +++ b/src/map/MapQuery.cpp @@ -6,6 +6,8 @@ #include "Utilities.h" #include "BuildingType.h" #include "Unit.h" +#include "Building.h" +#include "Team.h" #include "MapInternal.h" @@ -144,6 +146,27 @@ std::optional Map::doesUnitTouchResource(Unit *unit, int resourceType) c return std::nullopt; } +bool Map::isStockedMarketTile(Uint16 gid, int teamNumber, int resourceType) const +{ + if (gid == NOGBID || Building::GIDtoTeam(gid) != teamNumber) + return false; + const Building *b = game->teams[teamNumber]->myBuildings[Building::GIDtoID(gid)]; + return b && b->type->canExchange && b->buildingState == Building::ALIVE && b->resources[resourceType] > 0; +} + +Building *Map::touchedStockedMarket(Unit *unit, int resourceType) const +{ + const int teamNumber=unit->owner->teamNumber; + for (int tdx=-1; tdx<=1; tdx++) + for (int tdy=-1; tdy<=1; tdy++) + { + Uint16 gid=getBuilding(unit->posX+tdx, unit->posY+tdy); + if (isStockedMarketTile(gid, teamNumber, resourceType)) + return game->teams[teamNumber]->myBuildings[Building::GIDtoID(gid)]; + } + return NULL; +} + std::optional Map::doesPosTouchResource(int x, int y, int resourceType) const { for (int tdx=-1; tdx<=1; tdx++) diff --git a/src/map/MapResources.cpp b/src/map/MapResources.cpp index ca552b150..7099c7c72 100644 --- a/src/map/MapResources.cpp +++ b/src/map/MapResources.cpp @@ -161,15 +161,15 @@ void Map::setAreaName(int n, std::string name) } -bool Map::resourceAvailable(int teamNumber, int resourceType, int swimClass, int x, int y) +bool Map::resourceAvailable(int teamNumber, int resourceType, int swimClass, int x, int y, bool withMarkets) { - Uint16 g = getGradient(teamNumber, resourceType, swimClass, x, y); + Uint16 g = getGradient(teamNumber, resourceType, swimClass, x, y, withMarkets); return g>GRADIENT_UNREACHABLE; //Because 0==obstacle, 1==no obstacle, but you don't know if there is anything around. } -bool Map::resourceAvailable(int teamNumber, int resourceType, int swimClass, int x, int y, int *dist) +bool Map::resourceAvailable(int teamNumber, int resourceType, int swimClass, int x, int y, int *dist, bool withMarkets) { - Uint16 g = getGradient(teamNumber, resourceType, swimClass, x, y); + Uint16 g = getGradient(teamNumber, resourceType, swimClass, x, y, withMarkets); if (g>GRADIENT_UNREACHABLE) { *dist = gradientTiles(g); @@ -179,17 +179,17 @@ bool Map::resourceAvailable(int teamNumber, int resourceType, int swimClass, int return false; } -bool Map::resourceAvailableUpdate(int teamNumber, int resourceType, int swimClass, int x, int y, Sint32 *targetX, Sint32 *targetY, int *dist) +bool Map::resourceAvailableUpdate(int teamNumber, int resourceType, int swimClass, int x, int y, Sint32 *targetX, Sint32 *targetY, int *dist, bool withMarkets) { // distance and availability bool result; if (dist) - result = resourceAvailable(teamNumber, resourceType, swimClass, x, y, dist); + result = resourceAvailable(teamNumber, resourceType, swimClass, x, y, dist, withMarkets); else - result = resourceAvailable(teamNumber, resourceType, swimClass, x, y); + result = resourceAvailable(teamNumber, resourceType, swimClass, x, y, withMarkets); // target position - const Uint16 *gradient = getResourceGradient(teamNumber, resourceType, swimClass); + const Uint16 *gradient = getResourceGradient(teamNumber, resourceType, swimClass, withMarkets); getGlobalGradientDestination(gradient, x, y, targetX, targetY); return result; diff --git a/src/map/MapStep.cpp b/src/map/MapStep.cpp index e99d6c47b..6d1346028 100644 --- a/src/map/MapStep.cpp +++ b/src/map/MapStep.cpp @@ -108,6 +108,16 @@ void Map::syncStep(Uint32 stepCounter) gradientUpdated[t][r][s]=true; return; } + // The "with markets" twins are rebuilt together with their plain gradient; + // on their own only when a market's stock switched since. + for (int t=0; t @@ -136,14 +137,19 @@ void Map::updateRoundTripGradient(Building *building, int resourceType, int swim assert(gradient); building->roundTripGradientStep[resourceType][swimClass]=game->stepCounter; const Uint16 *toBuilding=building->globalGradient[swimClass]; - const Uint16 *toResource=getResourceGradient(building->owner->teamNumber, resourceType, swimClass); + // Building::fetchesFromMarkets, spelled out: the YOG server links the map + // but not the building code. + const bool withMarkets=!building->type->canExchange; + const Uint16 *toResource=getResourceGradient(building->owner->teamNumber, resourceType, swimClass, withMarkets); // Same obstacles as the resource gradient. A resource tile is seeded with // the cost of carrying from the cheapest free cell next to it, where the - // unit harvests, to the building. + // unit harvests, to the building. A stocked market's tile is a goal as + // well, its seed the detour dearer. Uint16 bestSeed=GRADIENT_UNREACHABLE; for (size_t i=0; iGRADIENT_UNREACHABLE; + if (toResource[i]!=GRADIENT_AT_GOAL && !marketGoal) { gradient[i]=toResource[i]==GRADIENT_FORBIDDEN ? GRADIENT_FORBIDDEN : GRADIENT_UNREACHABLE; continue; @@ -157,6 +163,8 @@ void Map::updateRoundTripGradient(Building *building, int resourceType, int swim if (toResource[n]>GRADIENT_UNREACHABLE && toBuilding[n]>best) best=toBuilding[n]; } + if (marketGoal && best>GRADIENT_UNREACHABLE) + best=std::max(GRADIENT_UNREACHABLE+1, best-MARKET_DETOUR_TILES*GRADIENT_STEP); gradient[i]=best; if (best>bestSeed) bestSeed=best; diff --git a/src/map/gradient/MapGradientGlobal.cpp b/src/map/gradient/MapGradientGlobal.cpp index 13c13162f..009b01195 100644 --- a/src/map/gradient/MapGradientGlobal.cpp +++ b/src/map/gradient/MapGradientGlobal.cpp @@ -4,6 +4,9 @@ #include "Map.h" #include "GlobalContainer.h" #include "Unit.h" +#include "Building.h" +#include "Team.h" +#include "Game.h" #include "MapInternal.h" #include @@ -106,20 +109,30 @@ void Map::updateGlobalGradient(Uint8 *gradient) } -Uint16 *Map::getResourceGradient(int teamNumber, int resourceType, int swimClass) +Uint16 *Map::getResourceGradient(int teamNumber, int resourceType, int swimClass, bool withMarkets) { - Uint16 *&gradient = resourcesGradient[teamNumber][resourceType][swimClass]; + Uint16 *&gradient = withMarkets + ? marketResourcesGradient[teamNumber][resourceType][swimClass] + : resourcesGradient[teamNumber][resourceType][swimClass]; if (gradient == NULL) { gradient = new Uint16[size]; - updateResourcesGradient(teamNumber, resourceType, swimClass); + updateResourcesGradient(teamNumber, resourceType, swimClass, withMarkets); } return gradient; } -void Map::updateResourcesGradient(int teamNumber, Uint8 resourceType, int swimClass) +void Map::dirtyMarketGradients(int teamNumber, int resourceType) { - Uint16 *gradient=resourcesGradient[teamNumber][resourceType][swimClass]; + for (int s = 0; s < SWIM_CLASS_COUNT; s++) + marketGradientDirty[teamNumber][resourceType][s] = true; +} + +void Map::updateResourcesGradient(int teamNumber, Uint8 resourceType, int swimClass, bool withMarkets) +{ + Uint16 *gradient=withMarkets + ? marketResourcesGradient[teamNumber][resourceType][swimClass] + : resourcesGradient[teamNumber][resourceType][swimClass]; assert(gradient); bool canSwim = swimClass > 0; @@ -135,7 +148,14 @@ void Map::updateResourcesGradient(int teamNumber, Uint8 resourceType, int swimCl else if (c.resource.type==NO_RES_TYPE) { if (c.building!=NOGBID) - gradient[i]=GRADIENT_FORBIDDEN; + { + // A stocked market of the team is one more place the resource can + // be fetched from, a detour dearer than a tile of it. + if (withMarkets && isStockedMarketTile(c.building, teamNumber, resourceType)) + gradient[i]=GRADIENT_MARKET_SEED; + else + gradient[i]=GRADIENT_FORBIDDEN; + } else if (!canSwim && isWater(i)) gradient[i]=GRADIENT_FORBIDDEN; else @@ -153,4 +173,10 @@ void Map::updateResourcesGradient(int teamNumber, Uint8 resourceType, int swimCl } propagateGradient(gradient, swimClass); + if (withMarkets) + marketGradientDirty[teamNumber][resourceType][swimClass]=false; + // The twin is the same field plus the markets: rebuild it from the same + // tiles so the two never disagree about the map. + else if (marketResourcesGradient[teamNumber][resourceType][swimClass]) + updateResourcesGradient(teamNumber, resourceType, swimClass, true); } diff --git a/src/map/io/MapIO.cpp b/src/map/io/MapIO.cpp index 328eaad8a..135cfc2a4 100644 --- a/src/map/io/MapIO.cpp +++ b/src/map/io/MapIO.cpp @@ -258,7 +258,10 @@ void Map::addTeam(void) int t=oldNumberOfTeam; for (int r=0; rwriteEnterSection(r); saveGradient(stream, resourcesGradient[t][r][sw], size); stream->writeUint8(gradientUpdated[t][r][sw], "updated"); + // The "with markets" twin is runtime state like its plain gradient: + // a resumed game must walk the same field it left. Its own section: + // the text format keys tiles by section name, and the plain + // gradient's tiles live in this one. + stream->writeEnterSection("markets"); + saveGradient(stream, marketResourcesGradient[t][r][sw], size); + stream->writeUint8(marketGradientDirty[t][r][sw], "dirty"); + stream->writeLeaveSection(); stream->writeLeaveSection(); } stream->writeLeaveSection(); @@ -479,6 +493,13 @@ void Map::loadRuntimeState(GAGCore::InputStream *stream, Sint32 versionMinor) stream->readEnterSection(r); loadGradient(stream, resourcesGradient[t][r][sw], size); gradientUpdated[t][r][sw]=loadFlag(stream,"updated"); + if (versionMinor >= FILE_FORMAT_VERSION_MARKET_GRADIENTS) + { + stream->readEnterSection("markets"); + loadGradient(stream, marketResourcesGradient[t][r][sw], size); + marketGradientDirty[t][r][sw]=loadFlag(stream,"dirty"); + stream->readLeaveSection(); + } stream->readLeaveSection(); } stream->readLeaveSection(); diff --git a/src/map/pathfind/MapPathfindRessource.cpp b/src/map/pathfind/MapPathfindRessource.cpp index f02ae0a73..ab5140610 100644 --- a/src/map/pathfind/MapPathfindRessource.cpp +++ b/src/map/pathfind/MapPathfindRessource.cpp @@ -11,10 +11,10 @@ // Resource pathfinding for units (pathfindResource, pathfindRandom) #ifndef YOG_SERVER_ONLY -bool Map::pathfindResource(int teamNumber, Uint8 resourceType, int swimClass, int x, int y, int *dx, int *dy, bool *stopWork, Building *target) +bool Map::pathfindResource(int teamNumber, Uint8 resourceType, int swimClass, int x, int y, int *dx, int *dy, bool *stopWork, Building *target, bool withMarkets) { assert(resourceTypebuildingAvailable(b, swimClass, u->posX, u->posY, &toBuilding) - || !map->resourceAvailable(b->owner->teamNumber, resource, swimClass, u->posX, u->posY, &toResource)) + || !map->resourceAvailable(b->owner->teamNumber, resource, swimClass, u->posX, u->posY, &toResource, b->fetchesFromMarkets())) return false; *cost = toBuilding + toResource; return true; @@ -254,7 +254,7 @@ namespace { u->displacement = Unit::DIS_GOING_TO_RESOURCE; u->setTargetBuilding(NULL); - b->owner->map->resourceAvailableUpdate(b->owner->teamNumber, resource, u->swimClass(), u->posX, u->posY, &u->targetX, &u->targetY, NULL); + b->owner->map->resourceAvailableUpdate(b->owner->teamNumber, resource, u->swimClass(), u->posX, u->posY, &u->targetX, &u->targetY, NULL, b->fetchesFromMarkets()); } u->validTarget = true; } diff --git a/src/unit/Unit.cpp b/src/unit/Unit.cpp index 12feba625..13d706fb6 100644 --- a/src/unit/Unit.cpp +++ b/src/unit/Unit.cpp @@ -205,7 +205,7 @@ void Unit::subscriptionSuccess(Building* building, bool inside) { displacement=DIS_GOING_TO_RESOURCE; targetBuilding=NULL; - owner->map->resourceAvailableUpdate(owner->teamNumber, destinationPurpose, swimClass(), posX, posY, &targetX, &targetY, NULL); + owner->map->resourceAvailableUpdate(owner->teamNumber, destinationPurpose, swimClass(), posX, posY, &targetX, &targetY, NULL, attachedBuilding->fetchesFromMarkets()); validTarget=true; } } diff --git a/src/unit/UnitDisplacement.cpp b/src/unit/UnitDisplacement.cpp index 1011c40f8..04deb1a68 100644 --- a/src/unit/UnitDisplacement.cpp +++ b/src/unit/UnitDisplacement.cpp @@ -49,6 +49,21 @@ void Unit::handleDisplacement(void) displacement=DIS_HARVESTING; validTarget=false; } + else if (attachedBuilding->fetchesFromMarkets()) + { + // The gradient led here to a stocked market of ours: take the + // resource at its door and carry it home. + if (Building *market = owner->map->touchedStockedMarket(this, destinationPurpose)) + { + market->removeResourceFromBuilding(destinationPurpose); + carriedResource=destinationPurpose; + setTargetBuilding(attachedBuilding); + displacement=DIS_GOING_TO_BUILDING; + validTarget=true; + if (verbose) + printf("guid=(%d) took resource (%d) out of market gbid=(%d)\n", gid, destinationPurpose, market->gid); + } + } } else if (displacement==DIS_HARVESTING) { @@ -142,117 +157,21 @@ void Unit::handleDisplacement(void) } else { - ///Find a resource that the building wants and a location to get it from - ///The location may be a market, or the harvesting the resource from the - ///map. - int needs[MAX_NB_RESOURCES]; - attachedBuilding->computeWishedResources(needs); - int teamNumber=owner->teamNumber; - int timeLeft = numberOfStepsLeftUntilHungry(); - if (timeLeft > 0) - { - int bestResource=-1; - int minValue=owner->map->getW()+owner->map->getW(); - bool takeInExchangeBuilding=false; - Map* map=owner->map; - for (int r=0; r0) - { - int distToResource; - bool available=map->roundTripDistance(attachedBuilding, r, swimClass(), posX, posY, &distToResource); - if (available) - distToResource=(distToResource+1)/2; // half the round trip: the unit is at the building - else - available=map->resourceAvailable(teamNumber, r, swimClass(), posX, posY, &distToResource); - if (available) - { - if ((distToResource<<1)>=timeLeft) - continue; //We don't choose this resource, because it won't have time to reach the resource and bring it back. - int value=distToResource/need; - if (valuetype->canFeedUnit) - for (std::list::iterator bi=owner->canExchange.begin(); bi!=owner->canExchange.end(); ++bi) - if ((*bi)->resources[r]>0) - { - int buildingDist; - if (map->buildingAvailable(*bi, swimClass(), posX, posY, &buildingDist)) - { - // We increase the cost to get a resource in an exchange building to reflect the costs to get the resources to the exchange building. - // increase is +5 as markets will in general be very close to fruits as they are the fruit teleporters. - int value=(buildingDist+5)/need; - if (value=0) - { - destinationPurpose=bestResource; - assert(activity==ACT_FILLING); - if (takeInExchangeBuilding) - { - displacement=DIS_GOING_TO_BUILDING; - targetX=targetBuilding->getMidX(); - targetY=targetBuilding->getMidY(); - targetBuilding->insertUnitToHarvesting(this); - validTarget=true; - } - else - { - int dummyDist; - if (auto off = owner->map->doesUnitTouchResource(this, destinationPurpose)) - { - dx = off->dx; - dy = off->dy; - displacement=DIS_HARVESTING; - validTarget=false; - } - else if (map->resourceAvailableUpdate(teamNumber, destinationPurpose, swimClass(), posX, posY, &targetX, &targetY, &dummyDist)) - { - displacement=DIS_GOING_TO_RESOURCE; - validTarget=true; - } - else - { - assert(false);//You can remove this assert(), but *do* notice me! - stopAttachedForBuilding(false); - } - } - } - else - { - if (verbose) - printf("guid=(%d) can't find any wished resource, unsubscribing.\n", gid); - stopAttachedForBuilding(false); - } - } - else - { - if (verbose) - printf("guid=(%d) not enough time for anything, unsubscribing.\n", gid); - stopAttachedForBuilding(false); - } + // One delivery is one gig. Hand the unit back to the free pool + // instead of letting it re-hire itself for the next trip out of + // its own building's wish list: Team::updateAllBuildingTasks runs + // later in this same tick, after every unit has stepped, and only + // ACT_RANDOM units are candidates. So the next trip is auctioned + // among every worker and every building that wants one, instead of + // belonging to whoever happened to deliver here last. + // + // The unit standing at the door is usually the cheapest hire and + // wins its own job back. When it does not, the random step it + // starts below is still in flight while the auction runs, so + // losing costs it that one tile and nothing else. + if (verbose) + printf("guid=(%d) delivered; back on the market.\n", gid); + stopAttachedForBuilding(false); } } } diff --git a/src/unit/UnitMovement.cpp b/src/unit/UnitMovement.cpp index 0c21e2a51..d49c5cd3e 100644 --- a/src/unit/UnitMovement.cpp +++ b/src/unit/UnitMovement.cpp @@ -559,7 +559,8 @@ void Unit::handleMovementGoingToResource() int teamNumber=owner->teamNumber; int swim=swimClass(); bool stopWork; - if (map->pathfindResource(teamNumber, destinationPurpose, swim, posX, posY, &dx, &dy, &stopWork, attachedBuilding)) + const bool withMarkets=attachedBuilding && attachedBuilding->fetchesFromMarkets(); + if (map->pathfindResource(teamNumber, destinationPurpose, swim, posX, posY, &dx, &dy, &stopWork, attachedBuilding, withMarkets)) { directionFromDxDy(); movement=MOV_GOING_DX_DY; @@ -575,7 +576,7 @@ void Unit::handleMovementGoingToResource() // itself only when it actually goes stale. const Uint16 *roundTrip = attachedBuilding ? map->roundTripGradient(attachedBuilding, destinationPurpose, swim) : NULL; const Uint16 *gradient = (roundTrip && roundTrip[map->coordToIndex(posX, posY)]>GRADIENT_UNREACHABLE) - ? roundTrip : map->getResourceGradient(teamNumber, destinationPurpose, swim); + ? roundTrip : map->getResourceGradient(teamNumber, destinationPurpose, swim, withMarkets); if (!map->isGradientPeak(gradient, targetX, targetY)) map->getGlobalGradientDestination(gradient, posX, posY, &targetX, &targetY); } diff --git a/test/MarketFetchHarness.cpp b/test/MarketFetchHarness.cpp new file mode 100644 index 000000000..6e206aa6a --- /dev/null +++ b/test/MarketFetchHarness.cpp @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Real-engine regression: a building's fetch gradient counts the team's +// stocked markets as goals, so a worker hired for a resource no tile holds is +// led to a market, takes the resource at its door and carries it home; a +// nearer tile still wins; a market that runs dry stops being a goal. +#define SDL_MAIN_HANDLED +#ifdef main +#undef main +#endif +#include "GlobalContainer.h" +#include "FileManager.h" +#include +#include +#include "Game.h" +#include "GameGUI.h" +#include "Unit.h" +#include "Team.h" +#include "MapInternal.h" +#include "Race.h" +#include "Ressource.h" +#include "IntBuildingType.h" +#include +#include + +GlobalContainer* globalContainer = nullptr; + +static void require(bool ok, const char* message) +{ + if (!ok) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); } +} + +struct TestUnit : Unit +{ + using Unit::Unit; + void arrive() { handleDisplacement(); } +}; + +struct Bed +{ + GameGUI gui; + Game& game; + Team* team; + Building* inn; + Building* market; + TestUnit* unit; + Bed(int unitX, int unitY) : game(gui.game) + { + game.map.setSize(6, 6, GRASS); // 64x64 + game.map.setGame(&game); + for (int y = 0; y < game.map.getH(); ++y) + for (int x = 0; x < game.map.getW(); ++x) + game.map.clearImmobileUnit(x, y); + game.addTeam(0); + team = game.teams[0]; + team->race.loadDefault(); + // Fruit is only a goal while seen: give the team vision of the whole bed. + game.map.setMapDiscovered(0, 0, game.map.getW(), game.map.getH(), team->me); + // A level-2 inn wants 80 of each fruit. + int innType = globalContainer->buildingsTypes.getTypeNum("inn", 1, false); + require(innType >= 0, "inn type exists"); + inn = game.addBuilding(6, 6, innType, 0); + require(inn != nullptr, "inn placed"); + game.map.setBuilding(6, 6, inn->type->width, inn->type->height, inn->gid); + inn->maxUnitWorking = 2; + inn->resources[CORN] = inn->type->maxResource[CORN]; + inn->updateCallLists(); + int marketType = globalContainer->buildingsTypes.getTypeNum("market", 0, false); + require(marketType >= 0, "market type exists"); + market = game.addBuilding(40, 40, marketType, 0); + require(market != nullptr, "market placed"); + game.map.setBuilding(40, 40, market->type->width, market->type->height, market->gid); + unit = new TestUnit(unitX, unitY, Unit::GIDfrom(0, 0), WORKER, team, 1); + team->myUnits[0] = unit; + game.map.setGroundUnit(unitX, unitY, unit->gid); + unit->activity = Unit::ACT_RANDOM; + unit->medical = Unit::MED_FREE; + } + void hire() { team->updateAllBuildingTasks(); } + bool marketTile(int x, int y) const { return game.map.getBuilding(x, y) == market->gid; } +}; + +int main(int argc, char** argv) +{ + SDL_SetMainReady(); + require(argc == 3, "usage: harness PROFILE ROOT"); + require(std::string(argv[1]).find("glob2-save-test-") == 0, "disposable profile required"); + GlobalContainer globals(argv[1]); + globals.fileManager->addDir(argv[2]); + globalContainer = &globals; + globals.runNoX = true; + globals.settings.rememberUnit = false; + globals.buildingsTypes.init(); + IntBuildingType::init(); + Race::loadDefault(); + + { + // No cherries on the map, ten in the market four tiles from the worker. + Bed bed(36, 36); + bed.market->resources[CHERRY] = 10; + int dist = 0; + require(bed.game.map.resourceAvailable(0, CHERRY, bed.unit->swimClass(), 36, 36, &dist, true), "the with-markets gradient reaches the stocked market"); + require(!bed.game.map.resourceAvailable(0, CHERRY, bed.unit->swimClass(), 36, 36, &dist, false), "the plain gradient knows no cherries"); + bed.hire(); + require(bed.inn->unitsWorking.size() == 1 && bed.unit->destinationPurpose == CHERRY, "inn hires the worker for cherries held by the market"); + require(bed.unit->displacement == Unit::DIS_GOING_TO_RESOURCE, "walking the fetch gradient"); + require(bed.marketTile(bed.unit->targetX, bed.unit->targetY), "the gradient's goal is the market"); + std::printf("market fetch: hired via the market, distance %d tiles\n", dist); + } + { + // Standing at the market's door: the take happens on arrival. A fruit + // delivery is ten units (multiplierResource), so is a take. + Bed bed(39, 39); + bed.market->resources[CHERRY] = 20; + bed.hire(); + require(bed.inn->unitsWorking.size() == 1 && bed.unit->displacement == Unit::DIS_GOING_TO_RESOURCE, "hired and walking"); + bed.unit->arrive(); + require(bed.unit->carriedResource == CHERRY, "took a cherry out of the market"); + require(bed.market->resources[CHERRY] == 10, "market stock went down by one take"); + require(bed.unit->displacement == Unit::DIS_GOING_TO_BUILDING && bed.unit->targetBuilding == bed.inn, "carrying it to the inn"); + std::puts("market fetch: the resource is taken at the market's door"); + } + { + // Cherries two tiles from the worker beat the market. + Bed bed(36, 36); + bed.market->resources[CHERRY] = 10; + require(bed.game.map.incResource(34, 36, CHERRY, 0), "seed a cherry tile near the worker"); + bed.hire(); + require(bed.inn->unitsWorking.size() == 1 && bed.unit->displacement == Unit::DIS_GOING_TO_RESOURCE, "hired for cherries"); + require(bed.unit->targetX == 34 && bed.unit->targetY == 36, "the nearer tile is the goal, not the market"); + std::puts("market fetch: a nearer tile wins over the market"); + } + { + // The market runs dry: after the rebuild it is no goal any more. + Bed bed(36, 36); + bed.market->resources[CHERRY] = 1; + int dist = 0; + require(bed.game.map.resourceAvailable(0, CHERRY, bed.unit->swimClass(), 36, 36, &dist, true), "stocked market is a goal"); + bed.market->removeResourceFromBuilding(CHERRY); + bed.game.map.updateResourcesGradient(0, CHERRY, bed.unit->swimClass(), true); + require(!bed.game.map.resourceAvailable(0, CHERRY, bed.unit->swimClass(), 36, 36, &dist, true), "an empty market is no goal"); + bed.hire(); + require(bed.inn->unitsWorking.empty() && bed.inn->unitsFailingRequirements[Building::UnitCantAccessFruit] >= 1, "nobody hired, counted as no fruit reachable"); + std::puts("market fetch: an empty market is no source"); + } + std::puts("PASS stocked markets are goals of the fetch gradients"); + return 0; +}