Repository navigation
Make the economy actually run: close the loop, fix pricing and trade - #1
Closed
abogun-product wants to merge 1 commit into
Closed
abogun-product wants to merge 1 commit into
abogun-product wants to merge 1 commit into
Conversation
The simulation had all four layers sketched out but none of them connected, so nothing could actually happen: * Trade did not exist. FindBestDeal was never called from anywhere, tradeStorage was never written to, so City.Trade() was a no-op, and no code ever moved goods or money between cities. * The arbitrage maths mixed up its units. Amounts of the buyer's resource were multiplied by a rate that had to divide, and the two legs of the round trip used inverted barter rates, so the profit was neither in the right units nor of a reliable sign. * Pricing destroyed itself on turn one. CalculatePrice ran before demand and offer had been filled in, so max(0,1)/max(0,1) overwrote every base price with 1 and all relative prices collapsed to parity, leaving nothing to arbitrage. * The loop never closed. productionAmount accumulated forever and was never consumed, storage only mirrored that running total, nobody ever ate, Pop.coins was never assigned, and supply grew linearly without bound. What this commit does: * Separates stocks from flows and adds consumption, so goods are produced, bought, eaten and spoiled instead of piling up forever. * Makes money a real thing that pops hold, with coins no longer a resource: the crafter now produces tools, and money is the numeraire prices are quoted in. The money stock of the world is closed and never changes. * Rewrites price formation: a price is nudged towards the demand/supply ratio by at most MaxPriceStep per turn and clamped, demand counts only what buyers can actually pay for, and demand is elastic so a lasting imbalance cannot compound to a clamp and money cannot get stuck in a pop that has nothing left to buy. * Rewrites Deal around one resource and the gap between two markets, sized against trade power, the surplus on offer, the traders' capital and the destination's affordable demand, and then actually executes: goods and money change hands, both directions are traded, and transport costs a share of the cargo rather than a fee in money that would burn the fixed money stock. * Reorders the turn to produce -> price -> trade -> local market -> consume -> spoil. Trade has to run before the local market, otherwise the locals spend their money on the insufficient local supply and the imports have no buyer left. * Makes the run observable: deterministic seed, a per-turn CSV time series, every tunable number in SimulationConfig and overridable with --config, and a real CLI. * Targets net9.0 instead of the long dead netcoreapp3.1, moves the solution to the repository root, and adds a test project. 42 tests cover the invariants that are easy to break and hard to spot: money and goods conservation, no pop spending money it does not have, prices staying in range, seed determinism, and trade not quietly dying out mid-run. The headline behaviour is covered too: after 300 turns every good is cheapest in the city that specialises in it, on every seed tested.
Collaborator
Author
|
Closing: moving this work to a private repository under abogun-product instead of a fork. |
This was referenced Sep 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The four layers of the simulation were all sketched out, but none of them were connected, so nothing could actually happen. This makes the whole loop run, and adds the instrumentation needed to see whether the economy converges or quietly explodes.
What was broken
FindBestDealwas never called from anywhere, and nothing ever wrote totradeStorage, soCity.Trade()was a no-op. No code moved goods or money between cities.amountmultiplied an amount of the buyer's resource by a rate that had to divide it, and the two legs of the round trip used inverted barter rates, so the profit was neither in the right units nor of a reliable sign.CalculatePriceran first in the turn, before demand and offer were filled in, somax(0,1)/max(0,1)overwrote every base price with1. All relative prices collapsed to parity and there was nothing left to arbitrage.productionAmount +=accumulated forever and nothing was ever consumed,storageonly mirrored that running total,Pop.coinswas never assigned so wealth was always 0, and supply grew linearly without bound.netcoreapp3.1has been out of support since December 2022.What this PR does
1. Builds and runs again.
net9.0, nullable and implicit usings on, warnings as errors, dead usings gone, solution moved to the repository root, a test project added.2. Closes the economic loop. Stocks and flows are separated, pops own their goods and their money, they buy what they cannot produce, eat every turn, and leftovers spoil. Coins stop being a resource: the crafter now produces tools, and money is the numeraire every price is quoted in. The money stock of the world is closed and never changes — there is a test for it.
3. Rewrites price formation. A price is a stock nudged towards the demand/supply ratio by at most
MaxPriceStepper turn and clamped to a range. Two details turned out to matter far more than they look:4. Makes arbitrage real.
Dealis now one resource and the gap between two markets, sized against every constraint that applies — trade power left this turn, the share of the local surplus that may be stripped, the traders' capital, and the demand the destination can pay for — and it executes: goods and money change hands. Traders work in both directions, exporting a local surplus and importing what a neighbour sells cheaply. Transport costs a share of the cargo rather than a fee in money, because a money fee burns the fixed money stock turn after turn until everyone is broke (it did: 3300 of 4000 money gone in 200 turns).The turn order is now
produce -> price -> trade -> local market -> consume -> spoil. Trade has to run before the local market: the other way round the locals spend their money on the insufficient local supply first, the imports have no buyer left, and the traders go bankrupt and stop trading for good.5. Makes a run observable. Deterministic seed, a per-turn CSV time series in long format, and every tunable number in
SimulationConfig, overridable from the command line so balancing does not need a rebuild:Does it work?
After 300 turns, prices settle into stable bands with no runaway and no collapse, and the result the model was built to produce shows up on its own — every good ends up cheapest in the city that specialises in it, on every seed tested:
The capital works as the entrepot the provinces trade through, satisfaction settles around 0.8, and trade is still running at turn 500.
Tests
42 tests,
dotnet test. They cover the invariants that are easy to break and hard to notice: money and goods conservation, no pop ever spending money it does not have, prices staying inside their range, the same seed replaying the same run, stocks not running away — and that trade does not quietly die out mid-run, which is the failure mode that hid behind every other bug here.Balancing decisions worth arguing about
These changed behaviour rather than fixing a bug, and are the parts most worth a second opinion:
TraderShare = 0.3, so the capital has 6 traders rather than 20). Arbitrageurs compete away the very margin they live on, so a merchant class of 35 in a world of 200 people cannot be fed by the margin on ~200 units of trade per turn. With the original counts the traders went bankrupt by turn 30 every single time, and trade stopped for good.Known limitations (documented in the README)
Nobody changes trade, so a glutted trade stays glutted: a woodcutter in a city drowning in wood is poor forever instead of becoming a farmer, and a bankrupt merchant stays bankrupt. Labour mobility is the single biggest thing still missing, and demography is the second.