Skip to content

Put the team's stocked markets on the fetch gradients - #257

Open
Giszmo wants to merge 3 commits into
masterfrom
fix/market-round-trip
Open

Put the team's stocked markets on the fetch gradients#257
Giszmo wants to merge 3 commits into
masterfrom
fix/market-round-trip

Conversation

@Giszmo

@Giszmo Giszmo commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Stacked on #254 (first two commits are #253 and #254; the third is this one). Closes the "known gap" called out in #254, the way Leo asked for: markets become part of the gradient, and the gradient goes dirty whenever a market's stock switches availability.

Why

Fetching a resource out of a market (an inn pulling fruit a market of its own team holds) lived only in the self-renewal block a worker ran after a drop-off. #254 removes that block, and neither the resource gradient units walk nor the round-trip field hiring scores with ever knew a market, so with per-delivery hiring market stock became unreachable.

What

  • Every resource gradient gets a "with markets" twin (Map::getResourceGradient(..., withMarkets)): the tiles of the team's alive markets holding the resource are seeded MARKET_DETOUR_TILES (5) below a tile of the resource, the same detour the old code charged. A building that is not a market fetches by the twin (Building::fetchesFromMarkets()); markets fetch for themselves by the plain one, so stock never circulates between markets.
  • The round-trip field (updateRoundTripGradient) takes market tiles as goals with the same detour, so hiring, task swaps (Swap fetching jobs between units when both trips get shorter #197) and the walk all price a market like a tile and the nearer one wins.
  • Arrival: next to a stocked market of ours, the unit takes one unit of the resource at the door (Map::touchedStockedMarket) and carries it home. The old branch keyed on ownExchangeBuilding stays; nothing sets it any more.
  • Refresh: a twin is rebuilt together with its plain gradient (same tiles, same turn in the round robin), so the two never disagree about the map; on its own only when Map::dirtyMarketGradients marks it, which happens when a market's stock of a resource appears or runs out (addResourceIntoBuilding, removeResourceFromBuilding, kill). Markets keep their stock in the team's shared pool (useTeamResources), so one change covers every market.
  • Generic over resources: nothing here is fruit-specific, so once markets can hold wood or wheat (draft follows), sites and inns near a market pull from it the same way.
  • The twin is runtime state like its plain gradient and is saved with the map runtime state (gated on FILE_FORMAT_VERSION_MARKET_GRADIENTS = 98), so a resumed game walks the same field it left; the savegame-safety continuation check enforces that (it caught the first push, where the twin was not saved). Saves older than 98 load with no twin and build one on first use.
  • VERSION_MINOR → 98, replay floor with it. Memory: one extra Uint16 field per (team, resource, swim class) actually fetched by a non-market building, lazily allocated.

Verification

  • market-fetch-test harness (new, in CI), one hiring pass on a level-2 inn: stocked market and no cherries on the map → hired, gradient goal is the market, plain gradient reports no cherries; at the market's door → take on arrival, stock 10 → 9, carrying to the inn; cherry tile nearer than the market → the tile is the goal; market runs dry → after the rebuild it is no goal and nobody is hired (UnitCantAccessFruit).
  • TestsRunner (187), savegame-safety continuation, resource-fetch-target.
  • Not measured on the AI beds: the exchange path fired zero times in six 40000-tick eight-Nicowar runs on master, so those beds cannot show it. An AINone bed with a market between an inn and remote fruit is the one that would.

🤖 Generated with Claude Code

`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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant