The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days. Money is euro only: a per-inhabitant daily tax funds a treasury spent in a global market, where buy and sell lots trade against the world stock and move its price. The hard economy keeps its per-nation currencies, central banks and exchange rates. Simple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy.
132 lines
5.7 KiB
JavaScript
132 lines
5.7 KiB
JavaScript
// 08-migrations.md, tested from the 11-tests.md plan: the simple economy
|
|
// retires only the two *economic* migration mechanisms -- the daily income-ratio
|
|
// sweep (`_migrateForIncome`) and the opening income-levelling pass
|
|
// (`_equalizeRegionIncomes`) -- while political/policy flows and natural growth
|
|
// carry on. The hard model's income migration stays pinned by
|
|
// `migration_test.js`.
|
|
|
|
import { TestCase } from "./framework/test_case.js";
|
|
import { simpleState, smallState } from "./framework/helpers.js";
|
|
import { key } from "../shared/hex.js";
|
|
import { POLICY_EXPEL } from "../shared/data/politics.js";
|
|
|
|
// A pair of neighbouring tiles where `from` is owned by `fromCiv` and the
|
|
// neighbour `to` is owned by a different civilisation.
|
|
function borderPair(state, fromCiv, toCiv) {
|
|
for (const coords of state._territoryByCiv.get(fromCiv) || []) {
|
|
for (const neighbour of state._neighbours(coords)) {
|
|
if (state.territory.get(key(neighbour.x, neighbour.y)) === toCiv) {
|
|
return { from: coords, to: neighbour };
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// A sparse (so rich) region beside a packed (so poor) one. The hard income
|
|
// sweep sends people out of the packed region toward the sparse one.
|
|
function splitRichAndPoor(state) {
|
|
const cities = state.cities.filter((city) => city.civ === 0);
|
|
const rich = cities[0];
|
|
const poor = cities[1];
|
|
for (const coords of state.regionTiles(rich)) {
|
|
state.tilePopulation.set(key(coords.x, coords.y), 1_000);
|
|
}
|
|
for (const coords of state.regionTiles(poor)) {
|
|
state.tilePopulation.set(key(coords.x, coords.y), 5_000_000);
|
|
}
|
|
state._gdpPerCapitaCache.clear();
|
|
return { rich, poor };
|
|
}
|
|
|
|
export class SimpleMigrationTest extends TestCase {
|
|
// Two regions with very different income do not exchange people: the daily
|
|
// sweep is a no-op, so thirty days of politics leave every region untouched.
|
|
test_two_regions_with_different_income_do_not_exchange_people() {
|
|
const state = simpleState();
|
|
splitRichAndPoor(state);
|
|
const before = state.cities.map((city) => state.regionPopulation(city));
|
|
for (let day = 0; day < 30; day++) state._tickPolitics();
|
|
const after = state.cities.map((city) => state.regionPopulation(city));
|
|
for (let i = 0; i < before.length; i++) {
|
|
this.assertApprox(after[i], before[i], 1e-6, "no income-driven movement");
|
|
}
|
|
}
|
|
|
|
// The two overrides themselves are the decision: called directly with a
|
|
// glaring income gap, neither moves a single person.
|
|
test_the_income_sweep_and_the_levelling_pass_are_no_ops() {
|
|
const state = simpleState();
|
|
const { rich, poor } = splitRichAndPoor(state);
|
|
let moved = 0;
|
|
const realMove = state._movePopulation.bind(state);
|
|
state._movePopulation = (...args) => {
|
|
moved += 1;
|
|
return realMove(...args);
|
|
};
|
|
state._migrateForIncome();
|
|
this.assertEqual(moved, 0, "the income sweep moved nobody");
|
|
|
|
const before = state.cities.map((city) => state.regionPopulation(city));
|
|
state._equalizeRegionIncomes();
|
|
const after = state.cities.map((city) => state.regionPopulation(city));
|
|
for (let i = 0; i < before.length; i++) {
|
|
this.assertApprox(after[i], before[i], 1e-6, "the levelling pass moved nobody");
|
|
}
|
|
// The gap is still there: nothing quietly flattened it.
|
|
this.assertGreater(
|
|
state.regionPopulation(poor),
|
|
state.regionPopulation(rich),
|
|
"the regions keep their different sizes"
|
|
);
|
|
delete state._movePopulation;
|
|
}
|
|
|
|
// Natural population growth is not migration and must survive: the simple
|
|
// model only removes the income flows.
|
|
test_natural_growth_still_applies() {
|
|
const state = simpleState();
|
|
const before = state.getPlayerPopulation(0);
|
|
state._tickPopulation();
|
|
this.assertGreater(state.getPlayerPopulation(0), before, "a day of natural growth");
|
|
}
|
|
|
|
// Option A keeps political migration in the simple economy: the income sweep
|
|
// is a no-op, but an expelling nation still drives a people across the border.
|
|
test_political_migration_still_applies() {
|
|
const state = simpleState();
|
|
const border = borderPair(state, 0, 1);
|
|
this.assertNotNull(border, "the two nations share a border");
|
|
state._setTileEthnicFractions(border.from, new Map([[0, 0.6], [1, 0.4]]));
|
|
state._setTileEthnicFractions(border.to, new Map([[1, 0.9], [0, 0.1]]));
|
|
this.assertTrue(state.requestPolicy(0, POLICY_EXPEL, 1));
|
|
state._tickMigration();
|
|
const leaving = state.migrations.get(0);
|
|
this.assertNotNull(leaving, "the expelling country has a flow");
|
|
this.assertGreater(leaving.outbound.get(1) || 0, 0, "people still leave under a policy");
|
|
}
|
|
|
|
// The migration snapshot fields stay present and empty rather than going
|
|
// missing, so the client's migration graph keeps its shape.
|
|
test_the_migration_snapshot_is_present_and_empty() {
|
|
const state = simpleState();
|
|
const snap = state.snapshot(0);
|
|
this.assertHas(snap, "migrations");
|
|
this.assertEqual(snap.migrations.length, state.civilisations.length, "one entry per nation");
|
|
this.assertHas(snap, "migrationGraph");
|
|
this.assertEmpty(snap.migrationGraph.links, "the opening graph draws no flows");
|
|
}
|
|
|
|
// The hard model is the control: the same sparse-next-to-packed setup does
|
|
// send people to the rich region, so the simple no-op is a real difference.
|
|
test_the_hard_model_still_moves_people_for_income() {
|
|
const state = smallState();
|
|
const { rich, poor } = splitRichAndPoor(state);
|
|
const richBefore = state.regionPopulation(rich);
|
|
const poorBefore = state.regionPopulation(poor);
|
|
state._migrateForIncome();
|
|
this.assertGreater(state.regionPopulation(rich), richBefore, "the hard rich region gained");
|
|
this.assertLess(state.regionPopulation(poor), poorBefore, "the hard poor region lost");
|
|
}
|
|
}
|