Skip to content

Make the economy actually run: close the loop, fix pricing and trade - #1

Closed
abogun-product wants to merge 1 commit into
drevendev:masterfrom
abogun-product:feature/working-economy
Closed

abogun-product wants to merge 1 commit into
drevendev:masterfrom
abogun-product:feature/working-economy

Conversation

@abogun-product

Copy link
Copy Markdown
Collaborator

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

  • Trade did not exist. FindBestDeal was never called from anywhere, and nothing ever wrote to tradeStorage, so City.Trade() was a no-op. No code moved goods or money between cities.
  • The arbitrage maths mixed up its units. amount multiplied 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.
  • Pricing destroyed itself on turn one. CalculatePrice ran first in the turn, before demand and offer were filled in, so max(0,1)/max(0,1) overwrote every base price with 1. All relative prices collapsed to parity and there was nothing left to arbitrage.
  • The loop never closed. productionAmount += accumulated forever and nothing was ever consumed, storage only mirrored that running total, Pop.coins was never assigned so wealth was always 0, and supply grew linearly without bound.
  • netcoreapp3.1 has 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 MaxPriceStep per turn and clamped to a range. Two details turned out to matter far more than they look:

  • Demand counts only what buyers can actually pay for. Counting the wishes of a bankrupt city keeps pushing its prices up, which is the last thing its broke citizens need — with gross demand the whole economy seizes up inside a hundred turns.
  • Demand is elastic: cheap goods are worth stockpiling, dear ones are done without. Otherwise demand is a hard ceiling and supply a hard floor, any lasting imbalance compounds at 10% a turn until it hits a clamp, and a pop that has accumulated money has nothing left to spend it on, so the money never returns to the producers.

4. Makes arbitrage real. Deal is 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:

dotnet run --project TradeCraftSimulation -- --turns 300 --quiet --csv run.csv --config TransportLossShare=0.02

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:

seed food cheapest in wood cheapest in tools cheapest in
1 Farmland (0.49) Forresty (0.14) Craftovo (1.55)
7 Farmland (0.49) Forresty (0.15) Craftovo (1.56)
42 Farmland (0.49) Forresty (0.14) Craftovo (1.41)
99 Farmland (0.52) Forresty (0.15) Craftovo (1.64)

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:

  • The merchant class had to shrink (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.
  • The capital gets a 1.25x output bonus on everything. A quarter of its people produce nothing, so without it the capital is permanently short of every good, pays for the imports out of a fixed money stock and goes broke.
  • Per-capita needs are calibrated to total world output over total population, so the world is exactly self-sufficient while every individual city is short of something. That gap is what makes trade worth doing.

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.

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.
@abogun-product

Copy link
Copy Markdown
Collaborator Author

Closing: moving this work to a private repository under abogun-product instead of a fork.

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