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.
731 lines
30 KiB
JavaScript
731 lines
30 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { smallState, simpleState, grantBuilding } from "./framework/helpers.js";
|
|
import { key } from "../shared/hex.js";
|
|
import {
|
|
OPINION,
|
|
APPROVAL,
|
|
CAMPAIGN_ETHNICITY,
|
|
CAMPAIGN_GOVERNMENT,
|
|
POLICY_CHAMPION,
|
|
POLICY_EXPEL,
|
|
POLICY_IMMIGRATION,
|
|
POLICY_PROTECT,
|
|
PROPAGANDA,
|
|
WAR_CRIME,
|
|
} from "../shared/data/politics.js";
|
|
import { NEWS_POLICY } from "../shared/data/relations.js";
|
|
import { BUILDINGS } from "../shared/data.js";
|
|
import { HOURS_PER_YEAR, HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
|
|
|
const BIG_BUDGET = 1.0e15;
|
|
|
|
function daysPass(state, days) {
|
|
for (let i = 0; i < days * HOURS_PER_DAY; i++) state.tickHour();
|
|
}
|
|
|
|
export class PoliticsModelTest extends TestCase {
|
|
test_opinions_start_neutral() {
|
|
const state = smallState();
|
|
this.assertApprox(state.getEthnicOpinion(0, 1), OPINION.baseline);
|
|
this.assertApprox(state.getGovOpinion(0, 1), OPINION.baseline);
|
|
this.assertApprox(state.getGovOpinion(1, 0), OPINION.baseline);
|
|
}
|
|
|
|
test_ethnic_makeup_sums_to_one_and_owner_leads() {
|
|
const state = smallState();
|
|
this.assertNotEmpty(state.tileEthnicity);
|
|
for (const [k, shares] of state.tileEthnicity) {
|
|
let total = 0;
|
|
let best = -1;
|
|
let bestShare = -1;
|
|
for (const [eth, share] of shares) {
|
|
total += share;
|
|
if (share > bestShare) {
|
|
bestShare = share;
|
|
best = eth;
|
|
}
|
|
}
|
|
this.assertApprox(total, 1, 1e-6, `shares of ${k} sum to one`);
|
|
const owner = state.territory.get(k);
|
|
if (owner !== undefined && owner >= 0) {
|
|
this.assertEqual(best, owner, `owner majority on ${k}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
test_territory_makeup_matches_tile_shares() {
|
|
const state = smallState();
|
|
const makeup = state.getCivEthnicMakeup(0);
|
|
let total = 0;
|
|
for (const entry of makeup) total += entry.share;
|
|
this.assertApprox(total, 1, 1e-6);
|
|
const own = makeup.find((entry) => entry.ethnicity === 0);
|
|
this.assertNotNull(own, "the nation's own ethnicity is present");
|
|
this.assertGreater(own.share, 0.5, "the majority ethnicity dominates");
|
|
}
|
|
|
|
test_campaign_moves_opinion_and_costs_money() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
this.assertTrue(
|
|
state.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, -1),
|
|
"campaign accepted"
|
|
);
|
|
this.assertGreater(state.getCampaignUpkeep(0), 0, "it accrues an hourly upkeep");
|
|
daysPass(state, 10);
|
|
this.assertLess(state.getGovOpinion(0, 1), OPINION.baseline, "opinion cooled");
|
|
}
|
|
|
|
test_campaign_spending_is_adjustable_and_zero_stops_it() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
this.assertTrue(state.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1));
|
|
const id = state.campaigns[0].id;
|
|
this.assertTrue(state.requestSetCampaignSpending(0, id, 20_000));
|
|
this.assertEqual(state.campaigns[0].hourlyCost, 20_000);
|
|
this.assertEqual(state.getCampaignUpkeep(0), 20_000);
|
|
this.assertTrue(state.requestSetCampaignSpending(0, id, 10_000_000));
|
|
this.assertEqual(state.campaigns[0].hourlyCost, PROPAGANDA.maxHourlyCost, "capped");
|
|
this.assertTrue(state.requestSetCampaignSpending(0, id, 0));
|
|
this.assertEmpty(state.campaigns, "spending nothing stops the campaign");
|
|
}
|
|
|
|
test_more_spending_moves_opinion_faster() {
|
|
const low = smallState();
|
|
low.budgets.set(0, BIG_BUDGET);
|
|
low.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1);
|
|
low.requestSetCampaignSpending(0, low.campaigns[0].id, PROPAGANDA.defaultHourlyCost / 2);
|
|
const high = smallState();
|
|
high.budgets.set(0, BIG_BUDGET);
|
|
high.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1);
|
|
high.requestSetCampaignSpending(0, high.campaigns[0].id, PROPAGANDA.defaultHourlyCost * 2);
|
|
daysPass(low, 10);
|
|
daysPass(high, 10);
|
|
this.assertGreater(
|
|
high.getGovOpinion(0, 1),
|
|
low.getGovOpinion(0, 1),
|
|
"the bigger spender warms the people faster"
|
|
);
|
|
}
|
|
|
|
test_campaign_warm_reverses_the_cooling() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
state._shiftGovOpinion(0, 1, -0.2);
|
|
this.assertTrue(state.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1));
|
|
daysPass(state, 10);
|
|
this.assertGreater(state.getGovOpinion(0, 1), OPINION.baseline - 0.2, "opinion warmed");
|
|
}
|
|
|
|
test_campaign_has_diminishing_returns_near_the_goal() {
|
|
const mid = smallState();
|
|
mid.budgets.set(0, BIG_BUDGET);
|
|
const midStart = mid.getGovOpinion(0, 1);
|
|
mid.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1);
|
|
daysPass(mid, 10);
|
|
const midGain = mid.getGovOpinion(0, 1) - midStart;
|
|
|
|
const near = smallState();
|
|
near.budgets.set(0, BIG_BUDGET);
|
|
// Close enough to the ceiling that the drift nearly cancels the campaign.
|
|
near._shiftGovOpinion(0, 1, 0.05);
|
|
const nearStart = near.getGovOpinion(0, 1);
|
|
near.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1);
|
|
daysPass(near, 10);
|
|
const nearGain = near.getGovOpinion(0, 1) - nearStart;
|
|
|
|
this.assertGreater(nearGain, 0, "the campaign still moves a nearly convinced people");
|
|
this.assertLess(
|
|
nearGain,
|
|
midGain,
|
|
"the same spend wins less of the last stretch toward the goal"
|
|
);
|
|
}
|
|
|
|
test_cooling_campaign_also_slows_near_its_floor() {
|
|
const mid = smallState();
|
|
mid.budgets.set(0, BIG_BUDGET);
|
|
const midStart = mid.getGovOpinion(0, 1);
|
|
mid.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, -1);
|
|
daysPass(mid, 10);
|
|
const midDrop = midStart - mid.getGovOpinion(0, 1);
|
|
|
|
const near = smallState();
|
|
near.budgets.set(0, BIG_BUDGET);
|
|
// Close enough to the floor that the drift nearly cancels the campaign.
|
|
near._shiftGovOpinion(0, 1, -0.05);
|
|
const nearStart = near.getGovOpinion(0, 1);
|
|
near.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, -1);
|
|
daysPass(near, 10);
|
|
const nearDrop = nearStart - near.getGovOpinion(0, 1);
|
|
|
|
this.assertGreater(nearDrop, 0, "a hated people is still pushed a little further");
|
|
this.assertLess(nearDrop, midDrop, "cooling likewise slows near the floor");
|
|
}
|
|
|
|
test_campaign_never_pushes_opinion_past_its_bound() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
state._shiftGovOpinion(0, 1, 0.49);
|
|
state.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1);
|
|
state.requestSetCampaignSpending(0, state.campaigns[0].id, PROPAGANDA.maxHourlyCost);
|
|
// The opinion settles well inside a couple of hundred days; the ceiling is
|
|
// what matters, not the elapsed time.
|
|
daysPass(state, 200);
|
|
this.assertLess(
|
|
state.getGovOpinion(0, 1),
|
|
OPINION.max + 1e-9,
|
|
"opinion approaches but never passes its ceiling"
|
|
);
|
|
}
|
|
|
|
test_campaign_persists_until_cancelled() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
this.assertTrue(state.requestCampaign(0, 0, CAMPAIGN_ETHNICITY, 1, 1));
|
|
daysPass(state, 60);
|
|
this.assertSize(state.campaigns, 1, "the campaign keeps running");
|
|
const id = state.campaigns[0].id;
|
|
this.assertTrue(state.requestCancelCampaign(0, id));
|
|
this.assertEmpty(state.campaigns);
|
|
this.assertFalse(state.requestCancelCampaign(0, id), "already stopped");
|
|
}
|
|
|
|
test_duplicate_campaign_is_refused() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
this.assertTrue(state.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1));
|
|
const before = state.getBudget(0);
|
|
this.assertFalse(
|
|
state.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, -1),
|
|
"no duplicate, even in the opposite direction"
|
|
);
|
|
this.assertApprox(state.getBudget(0), before, 1e-9, "nothing was charged");
|
|
this.assertSize(state.campaigns, 1);
|
|
this.assertTrue(
|
|
state.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 0, 1),
|
|
"a different target is allowed"
|
|
);
|
|
this.assertSize(state.campaigns, 2);
|
|
}
|
|
|
|
test_campaign_starts_without_an_upfront_cost() {
|
|
const state = smallState();
|
|
state.budgets.set(0, 0);
|
|
this.assertTrue(state.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, -1));
|
|
this.assertSize(state.campaigns, 1);
|
|
this.assertEqual(state.getBudget(0), 0, "nothing was charged up front");
|
|
}
|
|
|
|
test_approval_cache_is_invalidated_by_territory_changes() {
|
|
const state = smallState();
|
|
// Make the two peoples feel very differently about civ 0's government.
|
|
state._shiftGovOpinion(0, 0, 0.5);
|
|
state._shiftGovOpinion(1, 0, -0.5);
|
|
const before = state.getCivApproval(0);
|
|
state.getCivApproval(0);
|
|
const tile = (state._territoryByCiv.get(1) || [])[0];
|
|
this.assertNotNull(tile, "civ 1 holds at least one tile");
|
|
state.territory.set(`${tile.x},${tile.y}`, 0);
|
|
state._reindexTerritory();
|
|
const after = state.getCivApproval(0);
|
|
this.assertLess(after, before, "the newly held, hostile people pull approval down");
|
|
}
|
|
|
|
test_low_approval_slows_building() {
|
|
const state = smallState();
|
|
const city = state.cities.find((c) => c.civ === 0);
|
|
for (let eth = 0; eth < state.civilisations.length; eth++) {
|
|
state._shiftGovOpinion(eth, 0, -0.5);
|
|
}
|
|
const approval = state.getCityApproval(city);
|
|
this.assertLess(approval, APPROVAL.rebellionThreshold, "the region turned against the government");
|
|
this.assertGreater(state.constructionApprovalMultiplier(city), 1, "building slows down");
|
|
this.assertLess(
|
|
state.taxApprovalMultiplier(city),
|
|
1,
|
|
"the approval multiplier is penalised"
|
|
);
|
|
}
|
|
|
|
test_war_on_a_friendly_government_costs_popularity() {
|
|
const state = smallState();
|
|
state._shiftGovOpinion(0, 1, 0.45);
|
|
state._shiftGovOpinion(1, 1, 0.45);
|
|
const before = state.getGovOpinion(1, 0);
|
|
this.assertTrue(state.requestDeclareWar(0, 1));
|
|
this.assertLess(state.getGovOpinion(1, 0), before, "the aggressor grew unpopular");
|
|
}
|
|
|
|
test_war_on_a_loathed_government_can_please() {
|
|
const state = smallState();
|
|
state._shiftGovOpinion(0, 1, -0.45);
|
|
const before = state.getGovOpinion(0, 0);
|
|
this.assertTrue(state.requestDeclareWar(0, 1));
|
|
this.assertGreater(state.getGovOpinion(0, 0), before, "the war was popular");
|
|
}
|
|
|
|
test_pillaging_is_a_long_remembered_war_crime() {
|
|
const state = smallState();
|
|
const unit = state.units.find((u) => u.civ === 0);
|
|
const target = state._territoryByCiv.get(1).find(
|
|
(coords) =>
|
|
!state.cityAt(coords) &&
|
|
state.unitsAt(coords).length === 0 &&
|
|
(state.tilePopulation.get(`${coords.x},${coords.y}`) || 0) > 0
|
|
);
|
|
this.assertNotNull(target, "an undefended enemy tile");
|
|
unit.coords = { x: target.x, y: target.y };
|
|
unit.path = [];
|
|
unit.pathIndex = 0;
|
|
const victim = state._dominantEthnicity(target);
|
|
const before = state.getGovOpinion(victim, 0);
|
|
this.assertTrue(state.requestPillage([unit.id]), "the pillage happened");
|
|
this.assertLess(state.getGovOpinion(victim, 0), before, "the aggressor is hated on the tile");
|
|
// The grudge outlives a generation.
|
|
state.totalHours = 24 * HOURS_PER_YEAR;
|
|
this.assertLess(
|
|
state.getGovOpinion(victim, 0),
|
|
before,
|
|
"the crime is still remembered 24 years on"
|
|
);
|
|
state.totalHours = WAR_CRIME.memoryYears * HOURS_PER_YEAR + 1;
|
|
state._expireGrudges();
|
|
this.assertApprox(
|
|
state._grudgeTotal(victim, 0),
|
|
0,
|
|
1e-9,
|
|
"the grudge has finally expired"
|
|
);
|
|
}
|
|
|
|
test_a_pillage_costs_popularity_once_then_drifts_back() {
|
|
const state = smallState();
|
|
const unit = state.units.find((u) => u.civ === 0);
|
|
const target = state._territoryByCiv.get(1).find(
|
|
(coords) =>
|
|
!state.cityAt(coords) &&
|
|
state.unitsAt(coords).length === 0 &&
|
|
(state.tilePopulation.get(`${coords.x},${coords.y}`) || 0) > 0
|
|
);
|
|
this.assertNotNull(target, "an undefended enemy tile");
|
|
unit.coords = { x: target.x, y: target.y };
|
|
unit.path = [];
|
|
unit.pathIndex = 0;
|
|
|
|
const before = state.getCivPopularity(0);
|
|
this.assertTrue(state.requestPillage([unit.id]), "the pillage happened");
|
|
const after = state.getCivPopularity(0);
|
|
this.assertLess(after, before, "popularity fell at once");
|
|
// It is an instant hit, not a lingering grudge: the hourly drift recovers it.
|
|
for (let hour = 0; hour < 12; hour++) state.tickHour();
|
|
this.assertApprox(state.getCivPopularity(0), before, 0.5, "popularity drifts back");
|
|
}
|
|
|
|
test_champion_policy_buys_minority_love_at_majority_cost() {
|
|
const state = smallState();
|
|
const minorityBefore = state.getGovOpinion(1, 0);
|
|
const majorityBefore = state.getGovOpinion(0, 0);
|
|
this.assertTrue(state.requestPolicy(0, POLICY_CHAMPION, 1));
|
|
this.assertTrue(state.hasPolicy(0, POLICY_CHAMPION, 1));
|
|
this.assertGreater(state.getGovOpinion(1, 0), minorityBefore);
|
|
this.assertLess(state.getGovOpinion(0, 0), majorityBefore);
|
|
}
|
|
|
|
test_expel_policy_moves_people_and_hurts_the_government() {
|
|
const state = smallState();
|
|
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]]));
|
|
const before = state.getGovOpinion(1, 0);
|
|
this.assertTrue(state.requestPolicy(0, POLICY_EXPEL, 1));
|
|
this.assertLess(state.getGovOpinion(1, 0), before, "expelling enrages the people");
|
|
const shareBefore = state.tileEthnicity.get(`${border.from.x},${border.from.y}`).get(1);
|
|
daysPass(state, 5);
|
|
const shareAfter = state.tileEthnicity.get(`${border.from.x},${border.from.y}`).get(1) || 0;
|
|
this.assertLess(shareAfter, shareBefore, "the expelled people are leaving");
|
|
}
|
|
|
|
test_immigration_policy_draws_people_from_unpopular_lands() {
|
|
const state = smallState();
|
|
const border = borderPair(state, 1, 0);
|
|
this.assertNotNull(border, "the two nations share a border");
|
|
// `from` is a Tile-1 tile rich in ethnicity 1; `to` is the Tile-0 tile next
|
|
// to it that the immigrants will move onto.
|
|
state._setTileEthnicFractions(border.from, new Map([[1, 0.9], [0, 0.1]]));
|
|
state._setTileEthnicFractions(border.to, new Map([[0, 0.9], [1, 0.1]]));
|
|
// Ethnicity 1 hates its own government, so its people emigrate.
|
|
state._shiftGovOpinion(1, 1, -0.45);
|
|
const target = border.to;
|
|
const before = fractionOf(state, target, 1);
|
|
this.assertTrue(state.requestPolicy(0, POLICY_IMMIGRATION, 1));
|
|
daysPass(state, 10);
|
|
this.assertGreater(fractionOf(state, target, 1), before, "the invited people arrived");
|
|
}
|
|
|
|
test_cultural_impact_is_zero_sum_and_wins_friends() {
|
|
const state = smallState();
|
|
// Funded treasuries and private-sector cash, so the test measures culture
|
|
// alone: a nation short of either runs a resource shortage, whose popularity
|
|
// hit drowns out the museum.
|
|
state.budgets.set(0, 1e15);
|
|
state.budgets.set(1, 1e15);
|
|
for (const city of state.cities) {
|
|
state.getRegionCash(city).set(state.currencyOf(city.civ).code, 1e15);
|
|
// With no global market, money alone does not feed anyone: stock the
|
|
// stores so no shortage can drown out the museum.
|
|
const stock = state.getCityResourceStock(city);
|
|
stock.food = 1e15;
|
|
stock.steel = 1e15;
|
|
stock.luxury = 1e15;
|
|
stock.hightech = 1e15;
|
|
}
|
|
const museum = BUILDINGS.findIndex((b) => b.id === "museum");
|
|
const city = state.cities.find((c) => c.civ === 0);
|
|
city.buildings[museum] = 1;
|
|
const impact0 = state.getCulturalImpact(0);
|
|
const impact1 = state.getCulturalImpact(1);
|
|
this.assertApprox(impact0 + impact1, 1, 1e-9, "shares add to one");
|
|
this.assertEqual(impact1, 0);
|
|
daysPass(state, 60);
|
|
this.assertGreater(state.getGovOpinion(1, 0), OPINION.baseline, "culture won the world over");
|
|
}
|
|
|
|
test_oppressed_minority_rises_up_for_its_own_nation() {
|
|
const state = smallState();
|
|
const tile = (state._territoryByCiv.get(0) || [])[0];
|
|
this.assertNotNull(tile);
|
|
state._setTileEthnicFractions(tile, new Map([[0, 0.4], [1, 0.6]]));
|
|
state._shiftGovOpinion(1, 0, -0.5);
|
|
state._shiftGovOpinion(1, 1, 0.45);
|
|
state._tickPolitics();
|
|
const rebels = state.units.filter((u) => u.civ === 1 && u.rebel);
|
|
this.assertNotEmpty(rebels, "the minority took up arms for its own nation");
|
|
this.assertEqual(
|
|
state.territory.get(`${rebels[0].coords.x},${rebels[0].coords.y}`),
|
|
0,
|
|
"the rebels rose inside the host nation"
|
|
);
|
|
}
|
|
|
|
test_snapshot_carries_politics() {
|
|
const state = smallState();
|
|
const snapshot = state.snapshot(0);
|
|
this.assertNotNull(snapshot.approval);
|
|
this.assertSize(snapshot.approval, 2);
|
|
this.assertNotNull(snapshot.tileEthnicity);
|
|
this.assertNotNull(snapshot.opinions);
|
|
this.assertNotNull(snapshot.opinions.ethnic);
|
|
this.assertNotNull(snapshot.policies);
|
|
this.assertNotNull(snapshot.policies[0].list);
|
|
this.assertNotNull(snapshot.migrations);
|
|
this.assertNotNull(snapshot.cultureImpact);
|
|
this.assertNotNull(snapshot.viewerStats.approval);
|
|
this.assertNotNull(snapshot.viewerStats.ethnicMakeup);
|
|
this.assertNotNull(snapshot.cityStats[0].ethnicMakeup);
|
|
}
|
|
|
|
test_duplicate_policy_is_refused_but_other_policies_are_allowed() {
|
|
const state = smallState();
|
|
this.assertTrue(state.requestPolicy(0, POLICY_CHAMPION, 1));
|
|
this.assertFalse(state.requestPolicy(0, POLICY_CHAMPION, 1), "no duplicate");
|
|
this.assertEqual(state.getPolicies(0).length, 1);
|
|
this.assertTrue(
|
|
state.requestPolicy(0, POLICY_PROTECT, 1),
|
|
"a different policy for the same people is fine"
|
|
);
|
|
this.assertEqual(state.getPolicies(0).length, 2);
|
|
}
|
|
|
|
test_revoke_policy_removes_it() {
|
|
const state = smallState();
|
|
this.assertTrue(state.requestPolicy(0, POLICY_CHAMPION, 1));
|
|
const policy = state.getPolicies(0)[0];
|
|
this.assertTrue(state.requestRevokePolicy(0, policy.id));
|
|
this.assertEmpty(state.getPolicies(0));
|
|
this.assertFalse(state.requestRevokePolicy(0, policy.id), "already gone");
|
|
}
|
|
|
|
test_enacting_a_policy_is_news() {
|
|
const state = smallState();
|
|
const before = state.getNews().length;
|
|
this.assertTrue(state.requestPolicy(0, POLICY_PROTECT, 1));
|
|
this.assertGreater(state.getNews().length, before);
|
|
const entry = state.getNews().find((item) => item.type === NEWS_POLICY);
|
|
this.assertNotNull(entry, "policy news recorded");
|
|
this.assertEqual(entry.civ, 0);
|
|
this.assertEqual(entry.policy, POLICY_PROTECT);
|
|
this.assertEqual(entry.ethnicity, 1);
|
|
}
|
|
|
|
test_running_campaigns_are_upkeep_in_the_budget() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const before = state.getPlayerUpkeep(0);
|
|
this.assertEqual(state.getCampaignUpkeep(0), 0);
|
|
this.assertTrue(state.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1));
|
|
const cost = state.getCampaignUpkeep(0);
|
|
this.assertGreater(cost, 0);
|
|
this.assertApprox(state.getPlayerUpkeep(0) - before, cost);
|
|
this.assertGreater(state.budgetBreakdown(0).campaignUpkeep.total, 0);
|
|
}
|
|
|
|
test_migration_flows_are_recorded() {
|
|
const state = smallState();
|
|
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 left");
|
|
const arriving = state.migrations.get(1);
|
|
this.assertNotNull(arriving, "the neighbour has a flow");
|
|
this.assertGreater(arriving.inbound.get(1) || 0, 0, "people arrived");
|
|
}
|
|
|
|
// 08-migrations: the simple economy keeps political flows but retires the
|
|
// economic ones, so a region far richer than another draws nobody, however
|
|
// long the world runs.
|
|
test_simple_economy_has_no_income_migration() {
|
|
const state = simpleState();
|
|
const cities = state.cities.filter((city) => city.civ === 0);
|
|
this.assertGreaterOrEqual(cities.length, 2, "two regions to compare");
|
|
// One sparse (and so rich) region, one packed and poor: the hard sweep would
|
|
// send people from the poor one to the rich one.
|
|
for (const coords of state.regionTiles(cities[0])) {
|
|
state.tilePopulation.set(key(coords.x, coords.y), 1_000);
|
|
}
|
|
for (const coords of state.regionTiles(cities[1])) {
|
|
state.tilePopulation.set(key(coords.x, coords.y), 5_000_000);
|
|
}
|
|
state._clearTileGdpCache();
|
|
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");
|
|
}
|
|
}
|
|
|
|
// 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_simple_economy_keeps_political_migration() {
|
|
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");
|
|
}
|
|
|
|
// Natural population growth is not migration and must survive: the simple
|
|
// model only removes the income flows.
|
|
test_simple_economy_still_grows_population_naturally() {
|
|
const state = simpleState();
|
|
const before = state.getPlayerPopulation(0);
|
|
state._tickPopulation();
|
|
this.assertGreater(state.getPlayerPopulation(0), before, "a day of natural growth");
|
|
}
|
|
|
|
// The migration snapshot fields stay present and empty in simple mode rather
|
|
// than going missing, so the client's migration graph keeps its shape.
|
|
test_simple_economy_snapshot_has_empty_migration_fields() {
|
|
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");
|
|
}
|
|
|
|
test_no_air_migration_without_airports() {
|
|
const state = smallState();
|
|
state._tickMigration();
|
|
this.assertNull(state.migrations.get(0), "no flights, no flows");
|
|
}
|
|
|
|
test_airport_pulls_people_from_another_airport_city() {
|
|
const state = smallState();
|
|
const home = state.cities.find((c) => c.civ === 0);
|
|
const away = state.cities.find((c) => c.civ === 1);
|
|
grantBuilding(state, home, "airport");
|
|
grantBuilding(state, away, "airport");
|
|
state._tickMigration();
|
|
const inbound = state.migrations.get(0);
|
|
this.assertNotNull(inbound, "the home nation records arrivals");
|
|
this.assertGreater(inbound.inbound.size, 0, "people flew in");
|
|
const outbound = state.migrations.get(1);
|
|
this.assertNotNull(outbound, "the far nation records departures");
|
|
this.assertGreater(outbound.outbound.size, 0, "people flew out");
|
|
}
|
|
|
|
test_airport_level_scales_immigration() {
|
|
const run = (level) => {
|
|
const state = smallState();
|
|
const home = state.cities.find((c) => c.civ === 0);
|
|
const away = state.cities.find((c) => c.civ === 1);
|
|
grantBuilding(state, home, "airport", level);
|
|
grantBuilding(state, away, "airport");
|
|
state._tickMigration();
|
|
const inbound = state.migrations.get(0);
|
|
let total = 0;
|
|
if (inbound) for (const amount of inbound.inbound.values()) total += amount;
|
|
return total;
|
|
};
|
|
const low = run(1);
|
|
const high = run(3);
|
|
this.assertGreater(low, 0, "some people fly in at level one");
|
|
this.assertGreater(high, low, "a higher airport level draws more people");
|
|
}
|
|
|
|
test_makeup_counts_match_the_headline_population() {
|
|
const state = smallState();
|
|
const makeup = state.getCivEthnicMakeup(0);
|
|
const total = makeup.reduce((sum, entry) => sum + entry.count, 0);
|
|
this.assertApprox(total, state.getPlayerPopulation(0), 1, "counts match the shown population");
|
|
}
|
|
|
|
test_makeup_trends_follow_migration() {
|
|
const state = smallState();
|
|
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 home = state.getCivEthnicMakeup(0).find((entry) => entry.ethnicity === 1);
|
|
this.assertNotNull(home);
|
|
this.assertLess(home.trend, 0, "the expelled people fall at home");
|
|
const host = state.getCivEthnicMakeup(1).find((entry) => entry.ethnicity === 1);
|
|
this.assertNotNull(host);
|
|
this.assertGreater(host.trend, 0, "they rise in the neighbour");
|
|
}
|
|
|
|
test_tile_distribution_carries_count_and_trend() {
|
|
const state = smallState();
|
|
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 dist = state.getTileEthnicityDistribution(border.from);
|
|
const moved = dist.find((entry) => entry.ethnicity === 1);
|
|
this.assertNotNull(moved);
|
|
this.assertGreater(moved.count, 0, "the tile still counts that people");
|
|
this.assertLess(moved.trend, 0, "the tile lost some of them");
|
|
}
|
|
|
|
test_opinions_drift_one_point_an_hour_toward_neutral() {
|
|
const state = smallState();
|
|
// Ten points (0.05 on the opinion scale) comes home in ten hours.
|
|
state._shiftGovOpinion(0, 1, 10 * 0.005);
|
|
for (let i = 0; i < 9; i++) state.tickHour();
|
|
this.assertGreater(state.getGovOpinion(0, 1), OPINION.baseline, "still above neutral");
|
|
state.tickHour();
|
|
this.assertApprox(state.getGovOpinion(0, 1), OPINION.baseline, 1e-9, "ten points, ten hours");
|
|
// Below neutral drifts up at the same constant speed.
|
|
state._shiftGovOpinion(0, 1, -3 * 0.005);
|
|
for (let i = 0; i < 3; i++) state.tickHour();
|
|
this.assertApprox(state.getGovOpinion(0, 1), OPINION.baseline, 1e-9, "constant speed either way");
|
|
}
|
|
|
|
test_popularity_points_and_display_follow_the_logistic() {
|
|
const state = smallState();
|
|
// Neutral reads 50%, +100 points reads 75%, -100 reads 25%.
|
|
this.assertApprox(state.getCivPopularityDisplay(0), 0.5, 1e-9);
|
|
for (let eth = 0; eth < state.civilisations.length; eth++) {
|
|
state._shiftGovOpinion(eth, 0, 0.5);
|
|
}
|
|
this.assertApprox(state.getCivPopularity(0), 100, 1, "the own people are fully won over");
|
|
this.assertApprox(state.getCivPopularityDisplay(0), 0.75, 1e-6);
|
|
}
|
|
|
|
test_a_large_minority_cannot_be_expelled() {
|
|
const state = smallState();
|
|
this.assertLess(state.getCivEthnicShare(0, 1), 0.1, "the minority is small at first");
|
|
this.assertTrue(state.requestPolicy(0, POLICY_EXPEL, 1), "a small minority can be expelled");
|
|
|
|
const big = smallState();
|
|
for (const coords of big._territoryByCiv.get(0) || []) {
|
|
big._setTileEthnicFractions(coords, new Map([[0, 0.7], [1, 0.3]]));
|
|
}
|
|
this.assertGreater(big.getCivEthnicShare(0, 1), 0.1, "now a tenth of the country");
|
|
this.assertFalse(big.requestPolicy(0, POLICY_EXPEL, 1), "too many to drive out");
|
|
}
|
|
|
|
test_declaring_war_costs_the_aggressor_popularity() {
|
|
const state = smallState();
|
|
state._shiftGovOpinion(0, 0, 0.2);
|
|
const before = state.getGovOpinion(0, 0);
|
|
this.assertTrue(state.requestDeclareWar(0, 1));
|
|
this.assertLess(state.getGovOpinion(0, 0), before, "the aggressor's own people disapprove");
|
|
}
|
|
|
|
test_being_attacked_by_a_loathed_rival_rallies_the_people() {
|
|
const state = smallState();
|
|
// Civ 1's people already loathe civ 0 below the 15% threshold.
|
|
for (let eth = 0; eth < state.civilisations.length; eth++) {
|
|
state._shiftGovOpinion(eth, 0, -0.4);
|
|
}
|
|
const before = state.getGovOpinion(1, 1);
|
|
this.assertTrue(state.requestDeclareWar(0, 1));
|
|
this.assertGreater(state.getGovOpinion(1, 1), before, "the victim's people rally");
|
|
}
|
|
|
|
test_losing_and_killing_units_moves_popularity() {
|
|
const state = smallState();
|
|
const tile = state.landCells.find(
|
|
(coords) => !state.unitAt(coords) && !state.cityAt(coords)
|
|
);
|
|
this.assertNotNull(tile, "an empty land tile");
|
|
const attacker = state._spawnUnit(tile, 0, state.protoUnits[0]);
|
|
const defender = state._spawnUnit(tile, 1, state.protoUnits[0]);
|
|
defender.hp = 1;
|
|
const attackerBefore = state.getGovOpinion(0, 0);
|
|
const defenderBefore = state.getGovOpinion(1, 1);
|
|
state._resolveCombatRound();
|
|
this.assertFalse(state.units.includes(defender), "the defender was destroyed");
|
|
this.assertLess(state.getGovOpinion(1, 1), defenderBefore, "losing a unit hurts at home");
|
|
this.assertGreater(state.getGovOpinion(0, 0), attackerBefore, "the killer's people cheer");
|
|
}
|
|
|
|
test_taking_a_city_moves_popularity() {
|
|
const state = smallState();
|
|
const city = state.cities.find((c) => c.civ === 1);
|
|
this.assertNotNull(city, "civ 1 has a city");
|
|
// Clear the garrison and walk a unit in.
|
|
for (const unit of state.unitsAt(city.coords)) state._destroyUnit(unit);
|
|
const conquerorBefore = state.getGovOpinion(0, 0);
|
|
const loserBefore = state.getGovOpinion(1, 1);
|
|
state._captureCity(city, 0);
|
|
this.assertGreater(state.getGovOpinion(0, 0), conquerorBefore, "the conqueror's people cheer");
|
|
this.assertLess(state.getGovOpinion(1, 1), loserBefore, "the losers despair");
|
|
}
|
|
}
|
|
|
|
// 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)) {
|
|
const owner = state.territory.get(`${neighbour.x},${neighbour.y}`);
|
|
if (owner === toCiv) return { from: coords, to: neighbour };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function fractionOf(state, coords, eth) {
|
|
const shares = state.tileEthnicity.get(`${coords.x},${coords.y}`);
|
|
return shares ? shares.get(eth) || 0 : 0;
|
|
}
|