Files
Battle-for-Tismo/tests/politics_test.js
T
adrien 8a7db56b59 Added the approval summary and fixed the panel layout and propaganda curve
- the nation summary shows own-government approval, white in the neutral 40-60% band, red below and green above
- the city and government panels keep a fixed size per tab, dock below the summary and no longer cover it, and their tab bar wraps instead of being clipped
- propaganda campaigns grow exponentially harder near their opinion ceiling or floor, so the last percent are the hardest to win
- memoised city, nation and matrix approval against the politics version, invalidated by territory redraws, so snapshots stop recomputing it every frame
- extracted the duplicated modal tab wiring into modals/tabs.js
- tests: campaign-difficulty, approval-colour and cache-invalidation cases (513 pass)
2026-09-19 00:36:06 +02:00

504 lines
20 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.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);
near._shiftGovOpinion(0, 1, 0.4);
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);
near._shiftGovOpinion(0, 1, -0.4);
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);
daysPass(state, 4000);
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_and_taxes() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 0);
const baseline = state.getCityEconomy(city).income;
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.getCityEconomy(city).income, baseline, "the tax take drops");
this.assertLess(
state.taxApprovalMultiplier(city),
1,
"the tax 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.getGovOpinion(victim, 0),
before,
1e-9,
"the grudge has finally expired"
);
}
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();
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");
}
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");
}
}
// 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;
}