Skip to content

Round-trip gradients: send workers to the resource nearest for the whole trip - #244

Merged
genixpro merged 5 commits into
masterfrom
feat/pathfinding-round-trip
Sep 11, 2026
Merged

Round-trip gradients: send workers to the resource nearest for the whole trip#244
genixpro merged 5 commits into
masterfrom
feat/pathfinding-round-trip

Conversation

@Giszmo

@Giszmo Giszmo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Successor to #193, which GitHub closed when its head branch was renamed from feat/pathfinding-lanes to feat/pathfinding-round-trip (the lanes commit had already left it; the traffic-lane work is #213). genixpro's review and Leo's measurements are on #193. Rebased onto current master (e3a01b0, after #223 fetch apportionment and #186); five commits, linear, no merge commits. #197 (task swaps) and #213 (traffic lanes) are stacked on this branch.

Round-trip gradients. A worker fetching for a building walked to the resource nearest to itself, often on the far side of the base from where it delivers. A building now owns a round-trip gradient per resource type and swim class: every tile of that resource is seeded with the cost of carrying from beside it to the building, and Map::propagateGradient runs from those seeds (it now takes seeds at any cost, and an optional cost bound). Descending it minimises trip-to-resource plus trip-back, so pathfindResource with the target building follows it, falling back to the plain resource gradient when blocked or stale. Hiring (considerUnitForResource, per wanted resource since #223) and the next-resource choice after a delivery score by the same distance when a fetcher has already built the field, and by an estimate otherwise: distResource + max(distBuilding, distResource), exact at both ends of the walk (hiring looks at every needed resource of every building, far more than ever get fetched, so no field is built for it). Refreshed at most every 120 ticks, propagated 128 tiles beyond the cheapest fetch, freed after 500 ticks unused; the building's own gradient is freed on the same timer.

Commits

  1. Round-trip gradients and their use in routing and hiring.
  2. Free a building's idle round-trip gradients on a timer.
  3. Free a building's gradients unused for 500 ticks, not only its round-trip ones.
  4. Estimate the carry leg when a building has no round-trip field yet, with RoundTripHungerGateHarness (now also run in CI). On master the hunger gate already measures the walk to the resource, so this commit is the scale fix plus the harness.
  5. Save the round-trip fields with the map runtime state (format 95, replay floor 95). Master's continuation state saves every cached routing field so a loaded game continues bit for bit; without the round-trip fields and stamps a loaded game hired by the estimate where the uninterrupted one read a field, and SavegameSafetyHarness caught the drift within twenty steps. Older saves load with the fields empty.

Verified on this rebase (Linux, release build): 187 CppUnit tests; RoundTripHungerGateHarness, ResourceFetchTargetHarness (7 swim classes), FetchApportionmentHarness, TrappedUnitLifecycleTest, BuildingExpelHarness, ImmobileUnitGradientHarness and SavegameSafetyHarness (save, load, 300-step continuation) all pass.

Measured on a human-paced test bed (Leo's petri maps: four AINone island colonies, more job slots than workers, no forbidden areas; details, screenshots and the 80-minute chart in this comment): on the 108-worker island with four far-off inns, deliveries go from 194 to 217 (+12%) and survivors from 72 to 79 of 108 in 10 game minutes, 66 to 74 over 80 minutes; on the island where two inns are the bottleneck the round trip alone changes nothing and #197's inn swap is what saves workers; on the 8-worker island with 215 open job slots the gain is +3% here and +23% with #197's job swaps. The earlier Nicowar-vs-Nicowar numbers on #193 (+6%) undersell it because the AI re-plans its job requests every 100 ticks and never saturates its labour market.

Feel: workers pick the wheat or wood patch on the building's side of the base rather than the one nearest to where they were freed; visible as fewer long diagonal hauls across a base. Save format 95: the round-trip fields travel with the map runtime state, like the other cached routing fields.

A worker fetching for a building walked to the resource nearest to
itself, which is often on the far side of the base from the building it
delivers to. Buildings now own a round-trip gradient per resource type
and swim class: every tile of that resource is seeded with the cost of
carrying from beside it to the building, so descending the gradient
minimises the trip to the resource plus the trip back. Hiring and the
choice of the next resource after a delivery score by the same distance,
and a unit heading for a resource for a building follows it, falling
back to the plain resource gradient when blocked or stale.

Map::propagateGradient accepts seeds at any cost for this; seeds beyond
its bucket window enter the sweep when it reaches their cost.

Round-trip gradients are built on demand, refreshed at most every 40
ticks and freed after 500 ticks without use.

(cherry picked from commit 02185c5)

Fable 5.1 helped authoring this commit.
They were only dropped when the same building rebuilt one of its
gradients, so a building that stopped fetching kept every field it had
ever built. On a 512x512 map with 1400 units that reached 820 MB. Each
building now sweeps its unused fields every 256 ticks.

(cherry picked from commit 53627ae)

Fable 5.1 helped authoring this commit.
…trip ones

Every building that ever hired kept its full-map gradient per swim class
for good, although at any moment only the buildings with fetchers need
one. In a two-hour game on a 512x512 map with 250 buildings alive that
was most of 895 MB. The sweep that already drops idle round-trip fields
now drops idle building gradients too; the next unit that asks rebuilds.

(cherry picked from commit 687b0c6)

Fable 5.1 helped authoring this commit.
The scorer's two arms were on different scales. With a field, roundTrip
is a real round trip; without one the fallback was distBuilding +
distResource, the walk out plus however far the unit happens to be from
the building, which for a unit standing at the building is half a round
trip. Which arm a resource gets turns on whether anything has fetched it
for this building yet, so a resource already being fetched was ranked
against a fresh one on two scales. Estimate the carry leg instead: a
unit at the building carries about as far as it walks out, and a unit
at the resource carries about the building distance, so distResource +
max(distBuilding, distResource) is exact at both ends and no worse than
the old sum in between. No field is built for this; the numbers are
already in hand.

RoundTripHungerGateHarness covers the hunger gate (the walk to the
resource, not the whole trip, decides whether a unit is hireable) and
the fallback score; the second case fails without this fix, reporting
a one-way 14 instead of the round trip of 24.

Fable 5.1 helped authoring this commit.
Since the continuation state (format 91) a saved game carries every
cached routing field and its timestamps, so a loaded game continues bit
for bit. The round-trip fields, their refresh and last-use stamps and
the building gradient's last-use stamp were not saved, so after a load
hiring scored by the estimate where the uninterrupted game read a field,
and the two games drifted apart within twenty steps
(SavegameSafetyHarness, continuation check).

They join the building part of the map runtime section under format 95.
Older saves load as before with the fields empty. The replay floor moves
to 95 as well: routing and hiring by round trip change the simulation.

Fable 5.1 helped authoring this commit.
@Giszmo

Giszmo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Petri benchmark: round trip vs master on a human-paced colony

Map: Leo's petri2.map (four AINone colonies on separate islands, no forbidden areas; screenshots below are Leo's, one per island), turned into a tick-0 save and run headless with glob2 -nox games/petri2.game <ticks> 1.

Binaries:

The delivery, walking, clearing, school and starvation counters and the perturbation seed are local measurement tooling, not part of this PR.

The islands are all taken from FourSqares1 with swapped resources.

Team 0: 108 workers, 4 corner inns with 1 fetcher each, wheat far (the starvation race)

Team 0 island: four corner inns, wheat away from each inn, 108 workers

Team 3: 8 workers, 12 inns asking 13 to 19 workers each, 3 schools (every freed worker is hired within the tick by whichever building asks first)

Team 3 island: two rows of inns with a large worker request each, three schools, 8 workers

Team 2: 108 workers, 2 inns far apart is inn-capacity bound: 72 starve on master and this PR, 70 with #197.

Team 2 island: two inns at opposite ends, 108 workers

Team 1: 8 workers, 8 inns with 1 fetcher each plus a papyrus clearing flag delivers 104 on every binary; the 25 papyrus tiles are gone after 3482 ticks on master and this PR, 3290 with #197.

Team 1 island: eight inns and hospitals around the wheat, papyrus patch under a clearing flag

80 game minutes, each colony cut where it settles

120000 ticks, 10 seeds. Red = master, blue = this PR, green = #197. Left column is each colony's own KPI (workers alive, workers fully trained, papyrus tiles left), right column deliveries per game minute over a 1-minute window. Lines are the mean over seeds, the band the min to max. The x axis stops where the colony reached its steady state: 30, 20, 22 and 5 minutes.

petri2: survivors, training, papyrus and deliveries per minute for master, round trip and swaps

  • Team 0 settles at 66 workers alive on master, 74 on this PR, 76 with swaps, delivering 24 per minute on master against 27 on both branches while feeding more mouths.
  • Team 2 keeps 22 alive on master and on this PR, 27 with swaps: with two inns far apart the round trip alone changes nothing, the whole gain is inn swap at booking time.
  • Team 3 has all 8 workers trained by minute 12 on swaps and by 16 to 17 on master and this PR. With swaps the whole colony goes to school at once between minute 11 and 13 and deliveries pause for those two minutes; on master and this PR workers trickle through school one at a time.
  • Team 1 clears the papyrus in 2.3 minutes on all three; the opening burst of 120 deliveries per minute is the eight inns being stocked from the adjacent wheat.

Why the earlier AI-vs-AI games showed little: Nicowar re-plans its job requests every 100 ticks and keeps slack labour everywhere, so the first-asker hire already picks a nearby worker. A human colony with more job slots than workers is where the walk-length choice decides who lives.

@Giszmo
Giszmo requested a review from a team September 10, 2026 21:08
@Giszmo

Giszmo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

I hope we can merge this and #197 as it's just so much more fun to play with less micro management.

@genixpro

Copy link
Copy Markdown
Contributor

Cross-referencing a finding from independent benchmarking on #197 (full methodology there): across a 48-scenario sweep (8 maps, 3 AI mixes, 2 seeds, same tick-0 saves replayed through master vs. this branch alone vs. this branch+#197), round-trip gradients on their own show a flat 0% median change in population/building growth at every checkpoint (early/mid/late) versus master — the measured +13–18% throughput cost from my earlier comment doesn't show a matching growth benefit in isolation. The benefit only shows up once #197's job/inn swaps are layered on top (+6.7%/+7.6% units, +9.1%/+6.7% buildings by mid/late game).

That doesn't mean this branch is wrong — #197 depends on it, and round-trip fields are presumably what make swap scoring cheap and correct in the first place — but on its own, as measured here, it's a cost without a demonstrated standalone benefit. Full data: #197 (comment)

@genixpro genixpro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving and merging with Bradley's explicit sign-off, per this repo's policy that substantive engine/gameplay changes need a human maintainer's approval beyond any validation an agent can attach.

Summary of what's on record for this change by the time it merges:

  • Save/replay compatibility checked directly: VERSION_MINOR 94→95, MINIMUM_VERSION_MINOR correctly left at 58 (old saves still load), REPLAY_MINIMUM_VERSION_MINOR correctly bumped to reject pre-95 replays (this is a real simulation change, so that's the right call), and the new roundTrip save section is properly gated behind versionMinor >= FILE_FORMAT_VERSION_ROUND_TRIP_FIELDS on load.
  • Cross-platform determinism confirmed independently: built this branch on macOS/Apple Clang and a clean Ubuntu 22.04/GCC 11 environment, ran identical seeded games on two save fixtures with GLOB2_CHECKSUM_SIDECAR=1 — replays and per-tick checksums came back byte-for-byte identical on both. (Both environments were arm64; x86_64 coverage — what CI's Linux runners actually use — is the one gap I couldn't close from here.)
  • Throughput cost measured: +13–18% simulation time on its own, attributable to round-trip gradient computation (see #197 for the fuller breakdown separating this PR's cost from #197's).
  • Standalone benefit: across a 48-scenario sweep (8 maps, 3 AI mixes, 2 seeds), round-trip gradients alone showed a flat 0% median population/building-growth change vs. master at every checkpoint — the benefit shows up once #197's swaps are layered on top. Noted here for the record; not a blocker, since #197 depends on this branch either way.

Merging.

@genixpro
genixpro merged commit cb7e39f into master Sep 11, 2026
3 checks passed
@genixpro
genixpro deleted the feat/pathfinding-round-trip branch September 11, 2026 03:44
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.

2 participants