From 3c48922bdd96c92c5b11caadd55e1fb76873b3c7 Mon Sep 17 00:00:00 2001 From: Leo Wandersleb Date: Fri, 11 Sep 2026 13:13:38 +0200 Subject: [PATCH 1/3] Save the Echo AI's building-order id `BuildingOrder::id` is assigned at runtime by `Echo::add_building_order` from `BuildingRegister::register_building`, and is the key the order is known by in `BuildingRegister::pending_buildings`. Neither `save()` nor `load()` ever touched it, and the constructor `load()` uses -- the private `BuildingOrder()` -- initialised nothing, so every pending building order restored from a savegame carried whatever happened to be in the heap. `Echo::update_building_orders` then used that as a map key, in `br.issue_order((*i)->id, ...)` and in the `AssignWorkers` management orders it queues. So the AI's building placement, and through it the whole simulation, depended on heap layout. Observable on master: eight Nicowars on Playground resumed from a tick-25000 save and run to 27000 settle on one of two outcomes, 502 or 510 deliveries, roughly coin-flipped across runs. `setarch -R` makes it stable; with ASLR already off, `MALLOC_PERTURB_=85` alone flips it, and so does enabling `GLOB2_CHECKSUM_SIDECAR`, which only moves the heap. Memcheck names it: uninitialised value read in `BuildingRegister::issue_order`, origin the `operator new` in `Echo::load`. Serialise it, and default the member to -1 so an order that is built and never registered is still readable. Saves written before version 96 do not carry the field; `Echo::load` hands those a fresh registration rather than a sentinel, since the value has to be a real register key. `BuildingRegister` is loaded earlier in the same function, so the registration is valid by then. A game played from the start was never affected -- the id is only garbage on the load path. Opus 5 helped authoring this commit. --- src/ReplayReader.h | 7 ++++--- src/Version.h | 5 ++++- src/ai/echo/BuildingOrder.cpp | 4 ++++ src/ai/echo/Construction.h | 6 +++++- src/ai/echo/EchoSerialization.cpp | 6 ++++++ 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/ReplayReader.h b/src/ReplayReader.h index ce358faa2..c0332d926 100644 --- a/src/ReplayReader.h +++ b/src/ReplayReader.h @@ -23,10 +23,11 @@ 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 and 96 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), so +//! earlier replays would diverge from what happened. +static constexpr Uint16 REPLAY_MINIMUM_VERSION_MINOR = 96; /// This class is used for reading replays. /// The replay stream is kept open and read every time you do retrieveOrder. diff --git a/src/Version.h b/src/Version.h index a48e034bb..4f513f95d 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 96 // 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,9 @@ // 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 //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(); From a85c8b0a3da9dedc36f08671a08a4c8f43657642 Mon Sep 17 00:00:00 2001 From: Leo Wandersleb Date: Fri, 11 Sep 2026 12:48:56 +0200 Subject: [PATCH 2/3] Hire a fetcher for one delivery at a time A worker stayed subscribed to its building until that building's whole wish list was satisfied. After every drop-off it picked its own next resource from `attachedBuilding->computeWishedResources()`, so the trip belonged to whoever happened to deliver there last -- never to whoever was closest to it, and never to another building that wanted a worker more. Release it instead. `Team::updateAllBuildingTasks` runs later in the same tick, after every unit has stepped, and `considerUnitForBuilding` only accepts ACT_RANDOM units, so a unit freed during its own step is back on the market before that tick ends. The random step it starts on release is still in flight while the auction runs, so a unit that loses its own job back pays one tile for it. This is not a new mechanism, only a wider one: the release path already ran whenever a building wanted nothing the unit could reach, which was 35-40% of deposits in an eight-Nicowar Playground run. Known gap: fetching a resource out of a market -- an inn pulling fruit a market holds -- lives only in the self-renewal block this removes, and the building-side hiring scorer has no exchange option, so that path is now unreachable. It never fired in six 40000-tick eight-Nicowar runs (exchange_picked=0, exchange_taken=0), but it is a real gap and porting the exchange option into `Building::considerUnitForResource` is the fix. `Building::insertUnitToHarvesting` is left in place for that. Simulation behaviour changes, so VERSION_MINOR goes to 97 and replays older than that are refused. (cherry picked from commit b1ab3cb65b3c2540dfa78077b459dfc78aeed383) Fable 5.1 helped authoring this commit. --- src/ReplayReader.h | 8 +-- src/Version.h | 5 +- src/unit/UnitDisplacement.cpp | 126 ++++------------------------------ 3 files changed, 23 insertions(+), 116 deletions(-) diff --git a/src/ReplayReader.h b/src/ReplayReader.h index c0332d926..5f1cceb21 100644 --- a/src/ReplayReader.h +++ b/src/ReplayReader.h @@ -23,11 +23,11 @@ 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, 95 and 96 changed the simulation (weighted pathfinding and diagonal timing, +//! 93, 94, 95, 96 and 97 changed the simulation (weighted pathfinding and diagonal timing, //! hiring-bucket iteration, trapped-colony elimination, fetch-job apportionment, -//! round-trip routing and hiring, Echo building-order ids surviving a load), so -//! earlier replays would diverge from what happened. -static constexpr Uint16 REPLAY_MINIMUM_VERSION_MINOR = 96; +//! round-trip routing and hiring, Echo building-order ids surviving a load, +//! per-delivery hiring), so earlier replays would diverge from what happened. +static constexpr Uint16 REPLAY_MINIMUM_VERSION_MINOR = 97; /// This class is used for reading replays. /// The replay stream is kept open and read every time you do retrieveOrder. diff --git a/src/Version.h b/src/Version.h index 4f513f95d..29a1b016b 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 96 +#define VERSION_MINOR 97 // 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. @@ -105,6 +105,9 @@ // 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 //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/unit/UnitDisplacement.cpp b/src/unit/UnitDisplacement.cpp index 1011c40f8..e322393ff 100644 --- a/src/unit/UnitDisplacement.cpp +++ b/src/unit/UnitDisplacement.cpp @@ -142,117 +142,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); } } } From b86526d936a94c9eca079cca002c199b54dbe0d4 Mon Sep 17 00:00:00 2001 From: Leo Wandersleb Date: Fri, 11 Sep 2026 14:53:29 +0200 Subject: [PATCH 3/3] Put the team's stocked markets on the fetch gradients Fetching a resource out of a market -- an inn pulling fruit a market of its own team holds -- lived only in the block a worker ran after a drop-off to pick its own next trip, comparing the walk to the nearest fruit tile with the walk to a stocked market plus five tiles. Per- delivery hiring removed that block, and neither the resource gradient units walk nor the round-trip field hiring scores with ever knew a market, so a freed worker could never be sent to one: market stock was unreachable. Make a stocked market a goal of the gradient itself. Every resource gradient now has a "with markets" twin in which the tiles of the team's alive markets holding the resource are seeded MARKET_DETOUR_TILES below a tile of the resource -- the same five-tile detour as before, for the resource having been carried there once already. A building that is not a market fetches by that twin (Building::fetchesFromMarkets); markets fetch for themselves by the plain one, so stock never circulates between markets. The round-trip field a fetcher builds takes the market tiles as goals with the same detour, so hiring, task swaps and the walk all price a market like a tile of the resource and the nearer one wins. On arrival next to a market the unit takes one unit of the resource at its door and carries it home; the old take-at-the-door branch keyed on ownExchangeBuilding is left as is, nothing sets it any more. A twin is rebuilt together with its plain gradient, from the same tiles in the same turn of the round robin, so the two never disagree about the map; on its own only when a market's stock of the resource appears or runs out (Building::addResourceIntoBuilding, removeResourceFromBuilding, kill), so a unit walking to a market that ran dry is redirected at the next step instead of arriving to nothing. Markets hold their stock in the team's shared pool, so one change dirties every market at once. Memory: one more Uint16 field per team, resource and swim class that a non-market building fetches, allocated lazily like the plain ones. The real-engine check in test/MarketFetchHarness.cpp runs one hiring pass on a level-2 inn: stocked market and no cherries on the map -> the worker is hired and the gradient's goal is the market; at the market's door -> the take happens on arrival and the unit carries the cherry to the inn; a cherry tile nearer than the market -> the tile is the goal; the market runs dry -> after the rebuild it is no goal and nobody is hired. Built with the market-fetch-test target and run in CI. The twin is runtime state like its plain gradient and is saved with the map runtime state (FILE_FORMAT_VERSION_MARKET_GRADIENTS, 98): a resumed game walks the same field it left, which the savegame-safety continuation check enforces. It sits in its own "markets" section: the text format keys tiles by section name and the plain gradient's tiles already use the numbered ones. Saves older than 98 load with no twin and build one on first use. Simulation behaviour changes, so VERSION_MINOR goes to 98 and replays older than that are refused. Fable 5.1 helped authoring this commit. --- .github/workflows/build.yml | 4 + .local/state/gh/device-id | 1 + src/FileFormatVersions.h | 3 + src/ReplayReader.h | 7 +- src/SConscript | 6 + src/Version.h | 5 +- src/building/Building.h | 4 + src/building/Misc.cpp | 13 ++ src/building/Step.cpp | 2 +- src/map/Map.cpp | 5 + src/map/Map.h | 31 +++-- src/map/MapInternal.h | 7 ++ src/map/MapQuery.cpp | 23 ++++ src/map/MapResources.cpp | 16 +-- src/map/MapStep.cpp | 10 ++ src/map/gradient/MapGradientBuilding.cpp | 14 ++- src/map/gradient/MapGradientGlobal.cpp | 38 +++++- src/map/io/MapIO.cpp | 21 ++++ src/map/pathfind/MapPathfindRessource.cpp | 4 +- src/team/TeamStep.cpp | 4 +- src/unit/Unit.cpp | 2 +- src/unit/UnitDisplacement.cpp | 15 +++ src/unit/UnitMovement.cpp | 5 +- test/MarketFetchHarness.cpp | 147 ++++++++++++++++++++++ 24 files changed, 350 insertions(+), 37 deletions(-) create mode 100644 .local/state/gh/device-id create mode 100644 test/MarketFetchHarness.cpp 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 5f1cceb21..b61ba2c26 100644 --- a/src/ReplayReader.h +++ b/src/ReplayReader.h @@ -23,11 +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, 95, 96 and 97 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, Echo building-order ids surviving a load, -//! per-delivery hiring), so earlier replays would diverge from what happened. -static constexpr Uint16 REPLAY_MINIMUM_VERSION_MINOR = 97; +//! 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 29a1b016b..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 97 +#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. @@ -108,6 +108,9 @@ // 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/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 e322393ff..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) { 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; +}