From 256b4d3468cd47c52273c06afa9ba76813bd6109 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Sat, 19 Sep 2026 00:17:29 +0200 Subject: [PATCH] Added politics: opinions, ethnicities, propaganda and minority policies --- client/css/style.css | 108 +++- client/index.html | 11 + client/js/game_screen.js | 4 + client/js/game_screen/panels.js | 89 ++- client/js/modals/budget.js | 7 + client/js/modals/city.js | 29 + client/js/modals/format.js | 10 + client/js/modals/nation.js | 532 ++++++++++++++++- client/js/ui/ethnicity.js | 115 ++++ server/game_server.js | 43 ++ shared/data/index.js | 1 + shared/data/politics.js | 155 +++++ shared/data/relations.js | 2 + shared/game_state.js | 25 + shared/game_state/diplomacy.js | 3 + shared/game_state/economy.js | 18 +- shared/game_state/orders.js | 6 +- shared/game_state/politics.js | 890 +++++++++++++++++++++++++++++ shared/game_state/serialization.js | 167 +++++- shared/game_state/world.js | 2 + tests/game_screen_test.js | 212 +++++++ tests/politics_test.js | 430 ++++++++++++++ 22 files changed, 2844 insertions(+), 15 deletions(-) create mode 100644 client/js/ui/ethnicity.js create mode 100644 shared/data/politics.js create mode 100644 shared/game_state/politics.js create mode 100644 tests/politics_test.js diff --git a/client/css/style.css b/client/css/style.css index ea403c0..3449ec5 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -613,6 +613,14 @@ input:focus, select:focus { border-color: var(--accent); } } .modal-card-small { width: 420px; } .modal-card-small .row { padding-bottom: 18px; } + +/* The nation panel carries wide tables and long policy/campaign rows, so it + takes most of the screen. */ +#modal-nation .modal-card { + width: 90vw; + max-width: 1600px; + max-height: 90vh; +} .modal-head { display: flex; justify-content: space-between; @@ -627,9 +635,19 @@ input:focus, select:focus { border-color: var(--accent); } .modal-close { padding: 4px 11px; } -.tabs { display: flex; gap: 6px; padding: 10px 18px 0; } +.tabs { + display: flex; + gap: 6px; + padding: 10px 18px 0; + /* With enough tabs the row overflows; let it scroll instead of being clipped + by the card's overflow: hidden. */ + overflow-x: auto; + scrollbar-width: thin; +} .tab { cursor: pointer; + flex: 0 0 auto; + white-space: nowrap; padding: 8px 16px; border-radius: 8px 8px 0 0; border: 1px solid transparent; @@ -828,3 +846,91 @@ input:focus, select:focus { border-color: var(--accent); } border: 1px solid rgba(255, 120, 120, 0.6); z-index: 60; } + +/* Politics: the nation Politics tab and the city People tab. */ +.politics-card { + padding: 12px; + margin-bottom: 10px; + border-radius: 9px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.politics-card > .name { font-weight: 700; margin-bottom: 4px; } +.politics-controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px; + margin-top: 8px; +} +.politics-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; +} +.politics-field .politics-label { color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; } +.politics-select { + background: rgba(0, 0, 0, 0.4); + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 4px 6px; +} +.politics-readout { color: var(--muted); font-size: 12px; margin-left: 8px; } +.politics-policy { + padding: 8px 0; + border-top: 1px solid rgba(255, 255, 255, 0.07); +} +.politics-policy:first-of-type { border-top: none; } +.politics-row { + padding: 4px 0; + font-size: 13px; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} +.politics-penalty { color: #f0b060; } +.ethnic-chart { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 8px; } +.ethnic-legend { display: flex; flex-direction: column; gap: 4px; font-size: 13px; } +.ethnic-legend-row { display: flex; align-items: center; gap: 8px; } +.ethnic-swatch { width: 12px; height: 12px; border-radius: 3px; display: inline-block; } +.ethnic-donut { flex: 0 0 auto; } +.politics-policy-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.politics-new { margin-top: 10px; } +.politics-wizard { + margin-top: 10px; + padding: 12px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.08); +} +.politics-estimate { margin-top: 8px; font-size: 13px; } +.politics-cost { color: #f0b060; } +.politics-wizard .politics-controls { margin-top: 0; } +.ethnic-trend { font-weight: 700; margin-left: 2px; } +.trend-up { color: #6ee787; } +.trend-down { color: #f27272; } +#tile-ethnicity { margin-top: 6px; } +#tile-ethnicity .ethnic-chart { margin-top: 4px; gap: 10px; } +#tile-ethnicity .ethnic-legend { font-size: 12px; } +.politics-row-actions { display: flex; justify-content: flex-end; margin-top: 4px; } +.politics-spend { display: flex; align-items: center; gap: 8px; margin-top: 6px; } +.politics-slider { flex: 1 1 auto; accent-color: #7fc4ff; } +.opinion-list { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; } +.opinion-heading { + margin-top: 8px; + color: var(--muted); + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.05em; +} +.opinion-row { + display: grid; + grid-template-columns: 170px 1fr 48px; + align-items: center; + gap: 8px; + font-size: 13px; +} +.opinion-bar { height: 10px; border-radius: 5px; background: rgba(0, 0, 0, 0.4); overflow: hidden; } +.opinion-fill { height: 100%; background: #6ee787; } +.opinion-fill.negative { background: #f27272; } +.opinion-pct { text-align: right; color: var(--muted); } diff --git a/client/index.html b/client/index.html index 16e5a3f..86aedf0 100644 --- a/client/index.html +++ b/client/index.html @@ -138,6 +138,7 @@
+
@@ -184,6 +185,7 @@
+
@@ -198,6 +200,10 @@
+
+ + +
@@ -242,6 +248,7 @@ + @@ -258,6 +265,10 @@
+
+ + +
diff --git a/client/js/game_screen.js b/client/js/game_screen.js index 2bf4938..f0f0c1f 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -27,6 +27,7 @@ export class GameScreen { this._trainingCityId = 0; this._cityBuildings = new Map(); this._population = new Map(); + this._tileEthnicity = new Map(); this._tileGdpPenalties = new Map(); this._tileBattleGdpDeficits = new Map(); this._territoryOwner = new Map(); @@ -117,6 +118,9 @@ export class GameScreen { (p) => PROTO_UNITS.find((full) => full.id === p.id) || { id: p.id, name: p.name } ); this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]])); + this._tileEthnicity = new Map( + (state.tileEthnicity || []).map((e) => [`${e[0]},${e[1]}`, e[2]]) + ); this._tileGdpPenalties = new Map( (state.tileGdpPenalties || []).map((e) => [`${e[0]},${e[1]}`, e[2]]) ); diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index a96290f..3c132eb 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -14,6 +14,7 @@ import { NEWS_WAR, ECONOMY, EFFECT_GDP, + APPROVAL, effectValue, pillageSteps, ROADS, @@ -22,6 +23,7 @@ import { import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors } from "../../../shared/rules.js"; import { dateString } from "../../../shared/game_clock.js"; import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js"; +import { ethnicityChart } from "../ui/ethnicity.js"; export const panelMethods = { _bindHud() { @@ -49,6 +51,18 @@ export const panelMethods = { this.sendOrder({ type: "technology", technology: index }); this.nationModal.onDeclareWar = (target) => this.sendOrder({ type: "declare_war", target }); + this.nationModal.onCampaign = (observer, targetKind, target, direction) => + this.sendOrder({ type: "campaign", observer, targetKind, target, direction }); + this.nationModal.onCancelCampaign = (campaign) => + this.sendOrder({ type: "cancel_campaign", campaign }); + this.nationModal.onSetCampaignSpending = (campaign, amount) => + this.sendOrder({ type: "campaign_spending", campaign, amount }); + this.nationModal.onConfirm = (title, message, onConfirm) => + this.confirmModal.show(title, message, onConfirm); + this.nationModal.onPolicy = (policy, ethnicity) => + this.sendOrder({ type: "policy", policy, ethnicity }); + this.nationModal.onRevokePolicy = (policy) => + this.sendOrder({ type: "revoke_policy", policy }); $("#unit-attack") .on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget))) @@ -549,6 +563,7 @@ export const panelMethods = { $("#tile-owner").text(`Owner: ${civ ? civ.name : "Unclaimed"}`); $("#tile-terrain").text(`Terrain: ${tile ? tile.terrainType : "Unknown"}`); $("#tile-population").text(`Population: ${groupDigits(this._population.get(k) || 0)}`); + this._renderTileEthnicity(k); this._renderTileGdp(owner >= 0 ? this._tileGdpBreakdown(coords, owner, city, tile) : null); const roads = this.map.roads && this.map.roads.has(k) ? [ROADS.improvementName] : []; const improvements = city @@ -557,6 +572,27 @@ export const panelMethods = { $("#tile-improvements").text(`Improvements: ${fmtImprovements(improvements)}`); }, + // The tile's people, by ethnicity, as a small donut plus a legend with head + // counts and last-day migration arrows. + _renderTileEthnicity(k) { + const $container = $("#tile-ethnicity"); + if (!$container || !$container.length) return; + $container.empty(); + const entries = this._tileEthnicity.get(k) || []; + if (entries.length === 0) return; + const population = this._population.get(k) || 0; + const makeup = entries + .map(([ethnicity, share, trend]) => ({ + ethnicity, + share, + count: share * population, + trend, + })) + .sort((a, b) => b.count - a.count); + $container.append($("
").text("People")); + $container.append(ethnicityChart(makeup, this.civilisations, 104)); + }, + // The base GDP per capita of a claimable tile plus every factor that shapes // it, mirroring the server's getTileGdpPerCapita through the shared helper. // The region that develops the tile supplies the building bonus; government @@ -703,6 +739,7 @@ export const panelMethods = { _cityView(city) { const stats = this.snapshot.viewerStats || {}; + const cityStats = this._cityStats(city.id); return { budget: stats.budget || 0, gdp: stats.gdp || 0, @@ -712,6 +749,10 @@ export const panelMethods = { buildings: BUILDINGS, levels: this._cityBuildings.get(city.id) || {}, queue: this._trainingQueue(city.id), + civs: this.civilisations, + ethnicMakeup: cityStats.ethnicMakeup || [], + approval: cityStats.approval, + taxMultiplier: this._taxMultiplier(cityStats.approval), }; }, @@ -730,7 +771,16 @@ export const panelMethods = { this.cityModal.renderQueue(queue); this._updateCityProgress(queue); const city = this._city(this.selectedCityId); - if (city) this.cityModal.refreshTrainList(this._trainableUnits(city)); + if (city) { + this.cityModal.refreshTrainList(this._trainableUnits(city)); + const cityStats = this._cityStats(this.selectedCityId); + this.cityModal.renderPeople( + cityStats.ethnicMakeup || [], + cityStats.approval, + this._taxMultiplier(cityStats.approval), + this.civilisations + ); + } this.cityModal.refreshBuildings( stats.budget || 0, this._cityBuildings.get(this.selectedCityId) || {}, @@ -778,6 +828,20 @@ export const panelMethods = { return stats ? stats.budget : null; }, + // The selected city's serialised region stats: GDP, tax and its people. + _cityStats(cityId) { + return (this.snapshot.cityStats || []).find((city) => city.id === cityId) || {}; + }, + + // Mirrors GameState.taxApprovalMultiplier so the People tab explains the + // penalty the server is already applying. + _taxMultiplier(approval) { + const value = approval === undefined || approval === null ? 1 : approval; + if (value >= APPROVAL.rebellionThreshold) return 1; + const floor = APPROVAL.taxFloorFraction; + return floor + (1 - floor) * (value / APPROVAL.rebellionThreshold); + }, + _openNationModal() { const stats = this.snapshot.viewerStats || {}; this.nationModal.show( @@ -790,7 +854,8 @@ export const panelMethods = { stats.research || 0, this._finances(stats), this._diplomacyInfo(), - this._conflicts + this._conflicts, + this._politicsInfo() ); }, @@ -805,7 +870,8 @@ export const panelMethods = { stats.research || 0, this._finances(stats), this._diplomacyInfo(), - this._conflicts + this._conflicts, + this._politicsInfo() ); }, @@ -818,6 +884,23 @@ export const panelMethods = { }; }, + // Everything the Politics tab needs from the snapshot. + _politicsInfo() { + const snapshot = this.snapshot || {}; + return { + civs: this.civilisations, + localCiv: this.localCiv, + totalHours: snapshot.totalHours || 0, + approval: snapshot.approval || [], + opinions: snapshot.opinions || { ethnic: [], government: [] }, + policies: snapshot.policies || [], + campaigns: snapshot.campaigns || [], + migrations: snapshot.migrations || [], + cultureImpact: snapshot.cultureImpact || [], + viewerStats: snapshot.viewerStats || {}, + }; + }, + _openNewsModal() { this.newsModal.show(this._news, this.civilisations); }, diff --git a/client/js/modals/budget.js b/client/js/modals/budget.js index 7c684e8..29ec602 100644 --- a/client/js/modals/budget.js +++ b/client/js/modals/budget.js @@ -17,6 +17,8 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo const buildingGroups = building.groups || []; const units = breakdown.unitUpkeep || { total: 0, groups: [], sources: [] }; const unitSources = units.sources || []; + const campaigns = breakdown.campaignUpkeep || { total: 0, sources: [] }; + const campaignSources = campaigns.sources || []; const net = breakdown.net || 0; $list.empty(); @@ -79,6 +81,11 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo detailRow(`${source.label} ×${source.count}`, -source.amount, "expense-detail") ); } + for (const source of campaignSources) { + $body.append( + detailRow(`${source.label} ×${source.count}`, -source.amount, "expense-detail") + ); + } } const $table = $("
"); diff --git a/client/js/modals/city.js b/client/js/modals/city.js index 6547b40..c0dc736 100644 --- a/client/js/modals/city.js +++ b/client/js/modals/city.js @@ -11,6 +11,7 @@ import { } from "../../../shared/rules.js"; import { effectsText, signedMoney } from "./format.js"; import { renderBudget } from "./budget.js"; +import { ethnicityChart } from "../ui/ethnicity.js"; export class CityModal { constructor() { @@ -26,6 +27,9 @@ export class CityModal { this.$buildingsBudget = $("#city-buildings-budget"); this.$buildingsList = $("#city-buildings-list"); + this.$peopleSub = $("#city-people-sub"); + this.$peopleList = $("#city-people-list"); + this.$budgetSub = $("#city-budget-sub"); this.$budgetList = $("#city-budget-list"); @@ -84,9 +88,34 @@ export class CityModal { this.refreshBuildings(view.budget || 0, view.levels || {}, this.gdp, {}); this.$budgetSub.text("Region net +€0/h"); this.renderBudget(null); + this.renderPeople(view.ethnicMakeup || [], view.approval, view.taxMultiplier, view.civs || []); this.$modal.removeClass("hidden"); } + // The region's people: their ethnic makeup and their government approval, + // which decides how much tax they pay and how fast they build. + renderPeople(makeup, approval, taxMultiplier, civs) { + if (!this.$peopleList || !this.$peopleList.length) return; + this.$peopleList.empty(); + const value = approval === undefined || approval === null ? 0.5 : approval; + if (this.$peopleSub && this.$peopleSub.length) { + this.$peopleSub.text(`Government approval ${Math.round(value * 100)}%`); + } + if (makeup && makeup.length > 0) { + this.$peopleList.append(ethnicityChart(makeup, civs || [])); + } else { + this.$peopleList.append($("
").text("No population data.")); + } + if (taxMultiplier !== undefined && taxMultiplier < 0.999) { + this.$peopleList.append( + $("
").text( + `Discontent: only ${Math.round(taxMultiplier * 100)}% of taxes are collected and ` + + "construction is slowed until approval recovers." + ) + ); + } + } + // -------------------------------------------------------------- training -- _renderTrainList(gdp, trainableUnits) { diff --git a/client/js/modals/format.js b/client/js/modals/format.js index ca6ff2f..df8a469 100644 --- a/client/js/modals/format.js +++ b/client/js/modals/format.js @@ -1,6 +1,7 @@ // Shared formatting helpers for the modal panels. import { groupDigits } from "../../../shared/text_format.js"; +import { policyById } from "../../../shared/data.js"; export function formatEffect(effect, level) { const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent); @@ -45,6 +46,15 @@ export function describeNews(entry, civs) { return `${people(entry.actor)} declared war on ${name(entry.target)}`; case "capture": return `${people(entry.to)} captured ${entry.city} from ${name(entry.from)}`; + case "policy": { + const policy = policyById(entry.policy); + const policyName = policy ? policy.name : entry.policy; + const who = + civs[entry.ethnicity] && civs[entry.ethnicity].demonym + ? `the ${civs[entry.ethnicity].demonym}` + : name(entry.ethnicity); + return `${name(entry.civ)} enacted ${policyName} for ${who}`; + } case "join": return `${entry.player} took control of ${name(entry.civ)}`; default: diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js index c04f0fa..2bdd859 100644 --- a/client/js/modals/nation.js +++ b/client/js/modals/nation.js @@ -5,8 +5,10 @@ import { governmentChangeCost, prerequisitesMet, } from "../../../shared/rules.js"; +import { POLICIES, PROPAGANDA, policyById } from "../../../shared/data.js"; import { renderBudget } from "./budget.js"; import { effectsText, signedMoney } from "./format.js"; +import { ethnicityChart } from "../ui/ethnicity.js"; export class NationModal { constructor() { @@ -23,6 +25,8 @@ export class NationModal { this.$citiesList = $("#cities-list"); this.$conflictsSub = $("#conflicts-sub"); this.$conflictsList = $("#conflicts-list"); + this.$politicsSub = $("#politics-sub"); + this.$politicsList = $("#politics-list"); this.governments = []; this.technologies = []; this.currentGovernment = 0; @@ -38,9 +42,22 @@ export class NationModal { // Building types whose per-city budget breakdown is expanded. this.expandedBudgetGroups = new Set(); this._lastBreakdown = {}; + this.politics = null; + // The Politics tab is built once and only its lists are refreshed, so the + // wizard and dropdowns survive the snapshots that arrive while it is open. + this._politicsBuilt = false; + this._politicsCiv = -1; + this._politicsState = null; + this._politicsWizardOpen = false; this.onGovernment = () => {}; this.onTechnology = () => {}; this.onDeclareWar = () => {}; + this.onCampaign = () => {}; + this.onCancelCampaign = () => {}; + this.onSetCampaignSpending = () => {}; + this.onPolicy = () => {}; + this.onRevokePolicy = () => {}; + this.onConfirm = () => {}; this.$modal.find(".modal-close").on("click", () => this.close()); this.$modal.find(".tab").on("click", (event) => { const tab = $(event.currentTarget).data("tab"); @@ -55,11 +72,12 @@ export class NationModal { return !this.$modal.hasClass("hidden"); } - show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances, diplomacy = null, conflicts = []) { + show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances, diplomacy = null, conflicts = [], politics = null) { this.governments = governments; this.technologies = technologies; this.diplomacy = diplomacy; this.conflicts = conflicts || []; + this.politics = politics; // Rebuild the rows from scratch: reopening the panel must not stack a // second set of buttons on top of the previous one, or the stale rows keep // their old labels and disabled state and appear unresponsive. @@ -67,7 +85,7 @@ export class NationModal { this.$technologyList.empty(); this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government")); this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology")); - this.refresh(currentGovernment, researched, gdp, budget, research, finances, diplomacy, conflicts); + this.refresh(currentGovernment, researched, gdp, budget, research, finances, diplomacy, conflicts, politics); this.$modal.removeClass("hidden"); } @@ -165,11 +183,12 @@ export class NationModal { return { proto, index, kind, $name, $effects, $cost, $action }; } - refresh(currentGovernment, researched, gdp, budget, research, finances = {}, diplomacy = null, conflicts = null) { + refresh(currentGovernment, researched, gdp, budget, research, finances = {}, diplomacy = null, conflicts = null, politics = null) { this.currentGovernment = currentGovernment; this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number)); if (diplomacy !== null) this.diplomacy = diplomacy; if (conflicts !== null) this.conflicts = conflicts || []; + if (politics !== null) this.politics = politics; this.$governmentSub.text( `Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: €${groupDigits(Math.round(budget))}` ); @@ -223,6 +242,7 @@ export class NationModal { this._renderDiplomacy(); this._renderConflicts(); + this._renderPolitics(); } // Lists every other nation's economy in a sortable table, with its relation @@ -263,6 +283,7 @@ export class NationModal { gdp: stats.gdp || 0, gdpPerCapita: stats.gdpPerCapita || 0, population: stats.population || 0, + approval: stats.approval || 0, atWar: self ? false : wars.has(index), self, }); @@ -274,6 +295,7 @@ export class NationModal { { key: "gdp", label: "GDP", numeric: true }, { key: "gdpPerCapita", label: "GDP / capita", numeric: true }, { key: "population", label: "Population", numeric: true }, + { key: "approval", label: "Approval", numeric: true }, { key: "status", label: "Status" }, ]; const $table = $("
"); @@ -302,6 +324,7 @@ export class NationModal { $tr.append($("").text(`€${compact(row.gdp)}`)); $tr.append($("").text(`€${compact(row.gdpPerCapita)}`)); $tr.append($("").text(compact(row.population))); + $tr.append($("").text(`${Math.round(row.approval * 100)}%`)); const $status = $(""); if (row.self) { $status.append($("").text("Your nation")); @@ -410,6 +433,509 @@ export class NationModal { return civ ? civ.name : `Nation ${index + 1}`; } + // --------------------------------------------------------- politics -- + + _pct(value) { + return `${Math.round((value || 0) * 100)}%`; + } + + _renderPolitics() { + if (!this.$politicsList || !this.$politicsList.length) return; + const info = this.politics; + if (!info) return; + const civs = info.civs || []; + const localCiv = info.localCiv; + // Build the tab's controls once. Snapshots arrive far more often than the + // panel changes, so only the dynamic lists are redrawn: the wizard and the + // open dropdowns keep their state instead of being torn down each refresh. + if (!this._politicsBuilt || this._politicsCiv !== localCiv) { + this._buildPolitics(info, civs, localCiv); + this._politicsBuilt = true; + this._politicsCiv = localCiv; + } + this._refreshPolitics(info, civs, localCiv); + } + + _buildPolitics(info, civs, localCiv) { + this.$politicsList.empty(); + this._politicsWizardOpen = false; + this._politicsState = { + campaign: { + observer: localCiv, + kind: "government", + target: this._otherCiv(localCiv, civs), + direction: 1, + }, + policy: { type: POLICIES[0].id, ethnicity: this._defaultEthnicity(civs, localCiv) }, + opinionObserver: localCiv, + }; + this._campaignSignature = null; + this._campaignRows = new Map(); + this.$politicsMakeup = $("
"); + this.$politicsCampaigns = $("
"); + this.$politicsMigrations = $("
"); + this.$politicsList.append( + this.$politicsMakeup, + this.$politicsCampaigns, + this._buildCampaignForm(civs), + this.$politicsMigrations, + this._buildPolicies(civs), + this._buildOpinions(civs) + ); + } + + // Redraws only the lists, leaving the form controls and their state alone. + _refreshPolitics(info, civs, localCiv) { + const own = info.approval && info.approval[localCiv]; + const ownApproval = own ? own[localCiv] : 0.5; + const impact = (info.cultureImpact && info.cultureImpact[localCiv]) || 0; + this.$politicsSub.text( + `Government approval ${this._pct(ownApproval)} · ` + + `Cultural impact ${(impact * 100).toFixed(1)}%` + ); + + this.$politicsMakeup.empty(); + this.$politicsMakeup.append($("
").text("Your people")); + const makeup = (info.viewerStats && info.viewerStats.ethnicMakeup) || []; + if (makeup.length > 0) this.$politicsMakeup.append(ethnicityChart(makeup, civs)); + else this.$politicsMakeup.append($("
").text("No population data.")); + + this._fillCampaigns(info, localCiv); + this._syncCampaignReadout(); + this._syncCampaignForm(); + this._fillMigrations(info, civs, localCiv); + this._fillActivePolicies(info, civs, localCiv); + this._syncPolicyWizard(); + this._fillOpinions(); + } + + // Campaigns the player is running or being targeted by. The rows are only + // rebuilt when the set of campaigns (or a spend level) changes, so a slider + // being dragged is not yanked away by an arriving snapshot; the cost text is + // refreshed separately. + _fillCampaigns(info, localCiv) { + const campaigns = (info.campaigns || []).filter( + (campaign) => + campaign.civ === localCiv || + campaign.observer === localCiv || + campaign.target === localCiv + ); + this.$politicsCampaigns.toggleClass("hidden", campaigns.length === 0); + const signature = campaigns + .map((campaign) => + [ + campaign.id, + campaign.civ, + campaign.observer, + campaign.targetKind, + campaign.target, + campaign.direction, + campaign.hourlyCost, + ].join(":") + ) + .join("|"); + if (signature !== this._campaignSignature) { + this._campaignSignature = signature; + this.$politicsCampaigns.empty(); + this._campaignRows = new Map(); + if (campaigns.length > 0) { + this.$politicsCampaigns.append($("
").text("Active campaigns")); + for (const campaign of campaigns) this._buildCampaignRow(info, localCiv, campaign); + } + } + for (const campaign of campaigns) { + const row = this._campaignRows.get(campaign.id); + if (row) row.$cost.text(this._campaignCostText(info, campaign)); + } + } + + _campaignCostText(info, campaign) { + const hourly = campaign.hourlyCost || 0; + const activeHours = Math.max(0, (info.totalHours || 0) - (campaign.startedHours || 0)); + const spent = hourly * activeHours; + const days = Math.floor(activeHours / 24); + return ( + `€${groupDigits(hourly)}/h · €${groupDigits(Math.round(spent))} spent over ` + + `${days} day${days === 1 ? "" : "s"}` + ); + } + + _buildCampaignRow(info, localCiv, campaign) { + const verb = campaign.direction > 0 ? "warming" : "cooling"; + const target = this._civName(campaign.target); + const observer = this._civName(campaign.observer); + const onWhat = campaign.targetKind === "government" ? "government" : "population"; + const $row = $("
"); + $row.append( + $("
").text( + `${this._civName(campaign.civ)}: ${verb} the ${observer} people's view of the ` + + `${target} ${onWhat}` + ) + ); + const $cost = $("
"); + $row.append($cost); + const row = { $cost }; + if (campaign.civ === localCiv) { + const $slider = $("").attr({ + min: 0, + max: PROPAGANDA.maxHourlyCost, + step: 1000, + value: campaign.hourlyCost, + }); + const $spend = $("").text( + `€${groupDigits(campaign.hourlyCost)}/h` + ); + $slider.on("input", () => + $spend.text(`€${groupDigits(Number($slider.val()))}/h`) + ); + $slider.on("change", () => { + const amount = Number($slider.val()); + if (amount <= 0) { + // Reverting to the last known spend keeps the control honest until + // the player confirms the stop. + $slider.val(campaign.hourlyCost); + $spend.text(`€${groupDigits(campaign.hourlyCost)}/h`); + this.onConfirm( + "Stop this campaign?", + "Cutting the spending to €0 will stop the campaign.", + () => this.onCancelCampaign(campaign.id) + ); + } else { + this.onSetCampaignSpending(campaign.id, amount); + } + }); + const $controls = $("
").append( + $("").text("Spending"), + $slider, + $spend + ); + $row.append($controls); + row.$slider = $slider; + row.$spend = $spend; + const $cancel = $("").text("Cancel campaign"); + $cancel.on("click", () => this.onCancelCampaign(campaign.id)); + $row.append($("
").append($cancel)); + } + this.$politicsCampaigns.append($row); + this._campaignRows.set(campaign.id, row); + } + + // The campaign launcher, built once so its selects survive a snapshot. + _buildCampaignForm(civs) { + const campaign = this._politicsState.campaign; + const $card = $("
"); + $card.append($("
").text("Launch a propaganda campaign")); + $card.append( + $("
").text( + "Spend money to warm or cool a population's view of another people or government. " + + "Your cultural impact decides how strongly the message lands." + ) + ); + $card.append( + $("
").text( + `Costs €${groupDigits(PROPAGANDA.defaultHourlyCost)}/h to begin; ` + + "adjust the spending on an active campaign to make it land harder or softer." + ) + ); + const $observer = this._civSelect(civs, campaign.observer); + const $kind = $("").append( + $("").text("their government"), + $("").text("their population") + ); + $kind.val(campaign.kind); + const $target = this._civSelect(civs, campaign.target); + const $direction = $("").append( + $("").text("Warm opinion"), + $("").text("Cool opinion") + ); + $direction.val(String(campaign.direction)); + const $readout = $(""); + this.$campaignReadout = $readout; + const sync = () => { + this._politicsState.campaign = { + observer: Number($observer.val()), + kind: String($kind.val()), + target: Number($target.val()), + direction: Number($direction.val()), + }; + this._syncCampaignReadout(); + this._syncCampaignForm(); + }; + $observer.on("change", sync); + $kind.on("change", sync); + $target.on("change", sync); + $direction.on("change", sync); + sync(); + + const $controls = $("
"); + $controls.append(this._labelled("Observer", $observer)); + $controls.append(this._labelled("Signals about", $kind)); + $controls.append(this._labelled("Target", $target)); + $controls.append(this._labelled("Direction", $direction)); + const $start = $("").text("Start campaign"); + this.$campaignStart = $start; + $start.on("click", () => { + const state = this._politicsState.campaign; + this.onCampaign(state.observer, state.kind, state.target, state.direction); + }); + this.$campaignDuplicate = $("
"); + $controls.append($("
").append($start, $readout)); + $card.append($controls, this.$campaignDuplicate); + return $card; + } + + _syncCampaignReadout() { + if (!this.$campaignReadout) return; + const info = this.politics; + if (!info) return; + const state = this._politicsState.campaign; + const matrix = + state.kind === "government" ? info.opinions.government : info.opinions.ethnic; + const row = (matrix && matrix[state.observer]) || []; + const value = row[state.target]; + this.$campaignReadout.text(`Current: ${this._pct(value === undefined ? 0.5 : value)}`); + } + + // Blocks a second campaign on the same population and target. + _syncCampaignForm() { + if (!this.$campaignStart) return; + const info = this.politics; + if (!info) return; + const state = this._politicsState.campaign; + const duplicate = (info.campaigns || []).some( + (campaign) => + campaign.civ === info.localCiv && + campaign.observer === state.observer && + campaign.targetKind === state.kind && + campaign.target === state.target + ); + this.$campaignDuplicate.text( + duplicate ? "A campaign on this population and target is already running." : "" + ); + this.$campaignStart.prop("disabled", duplicate); + } + + // The minority-policy card: the list of active policies, plus a wizard built + // once so its selects stay put (and open) across snapshot refreshes. + _buildPolicies(civs) { + const $card = $("
"); + $card.append($("
").text("Minority policies")); + $card.append( + $("
").text( + "Any number of policies may run at once. Each sways opinions, and migration " + + "policies move people in or out of the country." + ) + ); + this.$politicsActive = $("
"); + $card.append(this.$politicsActive); + + this.$politicsNew = $("").text( + "New minority policy" + ); + this.$politicsWizard = $(""); + this.$politicsNew.on("click", () => { + this._politicsWizardOpen = !this._politicsWizardOpen; + this.$politicsWizard.toggleClass("hidden", !this._politicsWizardOpen); + }); + + const policy = this._politicsState.policy; + const $type = $(""); + for (const proto of POLICIES) { + $type.append($("").attr("value", proto.id).text(proto.name)); + } + $type.val(policy.type); + const $eth = this._civSelect(civs, policy.ethnicity); + this.$policyType = $type; + this.$policyEth = $eth; + this.$policyTarget = $("
"); + this.$policyCost = $("
"); + this.$policyDuplicate = $("
"); + this.$policyEnact = $("").text("Enact policy"); + const sync = () => { + this._politicsState.policy = { + type: String($type.val()), + ethnicity: Number($eth.val()), + }; + this._syncPolicyWizard(); + }; + $type.on("change", sync); + $eth.on("change", sync); + this.$policyEnact.on("click", () => { + const state = this._politicsState.policy; + this.onPolicy(state.type, state.ethnicity); + }); + + const $controls = $("
"); + $controls.append(this._labelled("Policy", $type)); + $controls.append(this._labelled("Ethnicity", $eth)); + $controls.append($("
").append(this.$policyEnact)); + this.$politicsWizard.append( + $controls, + this.$policyTarget, + this.$policyCost, + this.$policyDuplicate + ); + $card.append(this.$politicsNew, this.$politicsWizard); + return $card; + } + + // Keeps the wizard's estimates and its duplicate guard in step with the + // policies actually in force. + _syncPolicyWizard() { + if (!this.$policyType) return; + const info = this.politics; + const entry = info && (info.policies || []).find((item) => item.civ === info.localCiv); + const active = entry ? entry.list || [] : []; + const type = String(this.$policyType.val()); + const ethnicity = Number(this.$policyEth.val()); + const proto = policyById(type); + this.$policyTarget.text(proto ? `Target: ${proto.targetEffect}` : ""); + this.$policyCost.text(proto ? `Political cost: ${proto.politicalCost}` : ""); + const duplicate = active.some( + (policy) => policy.type === type && policy.ethnicity === ethnicity + ); + this.$policyDuplicate.text(duplicate ? "Already enacted for this people." : ""); + this.$policyEnact.prop("disabled", duplicate); + } + + _fillActivePolicies(info, civs, localCiv) { + this.$politicsActive.empty(); + const entry = (info.policies || []).find((policy) => policy.civ === localCiv); + const active = entry ? entry.list || [] : []; + if (active.length === 0) { + this.$politicsActive.append( + $("
").text("No minority policies enacted.") + ); + return; + } + for (const policy of active) { + const proto = policyById(policy.type); + const people = this._peopleName(civs, policy.ethnicity); + const $row = $("
"); + $row.append($("").text(`${proto ? proto.name : policy.type} — ${people}`)); + const $revoke = $("").text("Revoke"); + $revoke.on("click", () => this.onRevokePolicy(policy.id)); + $row.append($revoke); + this.$politicsActive.append($row); + } + } + + // Shows the last day's people movements in and out of the player's country. + _fillMigrations(info, civs, localCiv) { + this.$politicsMigrations.empty(); + const entry = (info.migrations || []).find((flow) => flow.civ === localCiv); + const inbound = entry ? entry.inbound || [] : []; + const outbound = entry ? entry.outbound || [] : []; + const empty = inbound.length === 0 && outbound.length === 0; + this.$politicsMigrations.toggleClass("hidden", empty); + if (empty) return; + this.$politicsMigrations.append( + $("
").text("Migration (last day)") + ); + const describe = (flows, sign) => { + for (const [ethnicity, amount] of flows) { + this.$politicsMigrations.append( + $("
").text( + `${sign}${groupDigits(amount)} ${this._peopleName(civs, ethnicity)}` + ) + ); + } + }; + describe(inbound, "+"); + describe(outbound, "−"); + } + + _otherCiv(localCiv, civs) { + const other = localCiv === 0 ? 1 : 0; + return civs && other < civs.length ? other : localCiv; + } + + // The demonym of an ethnicity, for prose. + _peopleName(civs, ethnicity) { + const civ = civs && civs[ethnicity]; + if (!civ) return `Ethnicity ${ethnicity + 1}`; + return civ.demonym ? `the ${civ.demonym}` : civ.name; + } + + _defaultEthnicity(civs, localCiv) { + if (civs.length > 1) return localCiv === 0 ? 1 : 0; + return localCiv; + } + + // A full breakdown of one population's opinions: of every government, + // including its own, and of every other people. + _buildOpinions(civs) { + const $card = $("
"); + $card.append($("
").text("Opinions by population")); + $card.append( + $("
").text( + "How a people sees every government and every other people. Pick the people to inspect." + ) + ); + const $observer = this._civSelect(civs, this._politicsState.opinionObserver); + this.$opinionObserver = $observer; + $observer.on("change", () => { + this._politicsState.opinionObserver = Number($observer.val()); + this._fillOpinions(); + }); + this.$opinionList = $("
"); + const $controls = $("
"); + $controls.append(this._labelled("Population", $observer)); + $card.append($controls, this.$opinionList); + return $card; + } + + _fillOpinions() { + if (!this.$opinionList) return; + const info = this.politics; + if (!info) return; + const civs = info.civs || []; + const observer = this._politicsState.opinionObserver; + const gov = (info.opinions.government && info.opinions.government[observer]) || []; + const eth = (info.opinions.ethnic && info.opinions.ethnic[observer]) || []; + this.$opinionList.empty(); + this.$opinionList.append($("
").text("Governments")); + for (let index = 0; index < civs.length; index++) { + const note = index === observer ? " (its own)" : ""; + this.$opinionList.append(this._opinionRow(`${this._civName(index)}${note}`, gov[index])); + } + this.$opinionList.append($("
").text("Peoples")); + for (let index = 0; index < civs.length; index++) { + if (index === observer) continue; + this.$opinionList.append(this._opinionRow(this._peopleName(civs, index), eth[index])); + } + } + + // A percentage bar for one opinion. + _opinionRow(label, value) { + const pct = Math.round((value === undefined ? 0.5 : value) * 100); + const $fill = $("
") + .css("width", `${pct}%`) + .toggleClass("negative", pct < 50); + const $bar = $("
").append($fill); + return $("
") + .append($("").text(label)) + .append($bar) + .append($("").text(`${pct}%`)); + } + + _civSelect(civs, selected) { + const $select = $(""); + civs.forEach((civ, index) => { + const label = civ.demonym ? `The ${civ.demonym}` : civ.name; + const $option = $("").attr("value", index).text(label); + if (index === selected) $option.prop("selected", true); + $select.append($option); + }); + return $select; + } + + _labelled(label, $control) { + return $("") + .append($("").text(label)) + .append($control); + } + close() { this.$modal.addClass("hidden"); } diff --git a/client/js/ui/ethnicity.js b/client/js/ui/ethnicity.js new file mode 100644 index 0000000..d502724 --- /dev/null +++ b/client/js/ui/ethnicity.js @@ -0,0 +1,115 @@ +// A small SVG donut chart of an ethnic makeup, plus its legend. Shared by the +// nation Politics tab and the city People tab so a population always looks the +// same wherever it is shown. + +import { compact } from "../../../shared/text_format.js"; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +const FALLBACK_COLOURS = ["#7a7a7a", "#b06a3b", "#3b7ab0", "#7ab03b", "#b03b7a"]; + +export function ethnicityColour(ethnicity, civs) { + const civ = civs && civs[ethnicity]; + if (civ && civ.primaryColour) return civ.primaryColour; + return FALLBACK_COLOURS[Math.abs(ethnicity) % FALLBACK_COLOURS.length]; +} + +export function ethnicityName(ethnicity, civs) { + const civ = civs && civs[ethnicity]; + if (!civ) return `Ethnicity ${ethnicity + 1}`; + return civ.demonym || civ.name; +} + +// Builds the donut element. `makeup` is [{ ethnicity, share, count }]. +export function ethnicityDonut(makeup, civs, size = 132) { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", `0 0 ${size} ${size}`); + svg.setAttribute("width", String(size)); + svg.setAttribute("height", String(size)); + svg.setAttribute("class", "ethnic-donut"); + const total = (makeup || []).reduce((sum, entry) => sum + entry.share, 0); + if (total <= 0) return svg; + const cx = size / 2; + const cy = size / 2; + const radius = size * 0.4; + const stroke = size * 0.2; + let angle = -Math.PI / 2; + for (const entry of makeup) { + const fraction = entry.share / total; + if (fraction <= 0.002) continue; + const sweep = fraction * Math.PI * 2; + const path = document.createElementNS(SVG_NS, "path"); + if (fraction >= 0.9999) { + // A full circle needs two arcs, as one arc from a point back to itself + // draws nothing. + const top = { x: cx, y: cy - radius }; + const bottom = { x: cx, y: cy + radius }; + path.setAttribute( + "d", + `M ${top.x} ${top.y} A ${radius} ${radius} 0 1 1 ${bottom.x} ${bottom.y}` + + ` A ${radius} ${radius} 0 1 1 ${top.x} ${top.y}` + ); + } else { + const x1 = cx + radius * Math.cos(angle); + const y1 = cy + radius * Math.sin(angle); + const x2 = cx + radius * Math.cos(angle + sweep); + const y2 = cy + radius * Math.sin(angle + sweep); + const large = sweep > Math.PI ? 1 : 0; + path.setAttribute("d", `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2}`); + } + path.setAttribute("fill", "none"); + path.setAttribute("stroke", ethnicityColour(entry.ethnicity, civs)); + path.setAttribute("stroke-width", String(stroke)); + svg.appendChild(path); + angle += sweep; + } + return svg; +} + +// Builds a legend element matching `ethnicityDonut`. Each entry shows the +// share, the absolute head count and, when the population moved over the last +// day, a green up or red down arrow. +export function ethnicityLegend(makeup, civs) { + const list = document.createElement("div"); + list.className = "ethnic-legend"; + for (const entry of makeup || []) { + const row = document.createElement("div"); + row.className = "ethnic-legend-row"; + const swatch = document.createElement("span"); + swatch.className = "ethnic-swatch"; + swatch.style.background = ethnicityColour(entry.ethnicity, civs); + const label = document.createElement("span"); + const share = Math.round(entry.share * 1000) / 10; + let text = `${ethnicityName(entry.ethnicity, civs)} — ${share}%`; + if (entry.count !== undefined && entry.count !== null) { + text += ` (${compact(entry.count)})`; + } + label.textContent = text; + row.appendChild(swatch); + row.appendChild(label); + const arrow = trendArrow(entry.trend); + if (arrow) row.appendChild(arrow); + list.appendChild(row); + } + return list; +} + +// A green ▲ when the population grew, a red ▼ when it shrank, and nothing when +// it did not move. +function trendArrow(trend) { + if (!(trend > 0) && !(trend < 0)) return null; + const arrow = document.createElement("span"); + arrow.className = trend > 0 ? "ethnic-trend trend-up" : "ethnic-trend trend-down"; + arrow.textContent = trend > 0 ? "▲" : "▼"; + return arrow; +} + +// A donut plus legend in one block. +export function ethnicityChart(makeup, civs, size = 132) { + const wrapper = document.createElement("div"); + wrapper.className = "ethnic-chart"; + const donut = ethnicityDonut(makeup, civs, size); + if (makeup && makeup.length > 0) wrapper.appendChild(donut); + wrapper.appendChild(ethnicityLegend(makeup, civs)); + return wrapper; +} diff --git a/server/game_server.js b/server/game_server.js index 648e860..796618f 100644 --- a/server/game_server.js +++ b/server/game_server.js @@ -118,6 +118,16 @@ export class GameServer extends EventEmitter { return this._handleTechnology(civ, order); case "declare_war": return this._handleDeclareWar(civ, order); + case "campaign": + return this._handleCampaign(civ, order); + case "cancel_campaign": + return this._handleCancelCampaign(civ, order); + case "campaign_spending": + return this._handleCampaignSpending(civ, order); + case "policy": + return this._handlePolicy(civ, order); + case "revoke_policy": + return this._handleRevokePolicy(civ, order); default: return false; } @@ -221,6 +231,39 @@ export class GameServer extends EventEmitter { return this.state.requestDeclareWar(civ, target); } + _handleCampaign(civ, order) { + const observer = Number(order.observer); + const target = Number(order.target); + const direction = Number(order.direction) < 0 ? -1 : 1; + return this.state.requestCampaign( + civ, + observer, + String(order.targetKind || ""), + target, + direction + ); + } + + _handleCancelCampaign(civ, order) { + return this.state.requestCancelCampaign(civ, Number(order.campaign)); + } + + _handleCampaignSpending(civ, order) { + return this.state.requestSetCampaignSpending( + civ, + Number(order.campaign), + Number(order.amount) + ); + } + + _handlePolicy(civ, order) { + return this.state.requestPolicy(civ, String(order.policy || ""), Number(order.ethnicity)); + } + + _handleRevokePolicy(civ, order) { + return this.state.requestRevokePolicy(civ, Number(order.policy)); + } + onPeerJoined(peerId, playerName = "") { if (!this._configured) return; // A returning player keeps the nation they claimed before. diff --git a/shared/data/index.js b/shared/data/index.js index ce36789..aa5db59 100644 --- a/shared/data/index.js +++ b/shared/data/index.js @@ -12,6 +12,7 @@ export * from "./technologies.js"; export * from "./statuses.js"; export * from "./combat.js"; export * from "./economy.js"; +export * from "./politics.js"; export * from "./map_config.js"; export * from "./terrain.js"; export * from "./roads.js"; diff --git a/shared/data/politics.js b/shared/data/politics.js new file mode 100644 index 0000000..292dbf7 --- /dev/null +++ b/shared/data/politics.js @@ -0,0 +1,155 @@ +// Politics tuning: public opinion, approval penalties, propaganda campaigns, +// minority policies, migration, war crimes, uprisings and cultural impact. +// +// Every population (an ethnicity, indexed like the civilisations) holds an +// opinion of every other ethnicity and of every government. Opinions run from 0 +// (hatred) to 1 (devotion) and start at the neutral 50%. All of it lives in +// shared/game_state/politics.js so the server simulates it and the browser only +// reads it from the snapshot. + +// The opinion scale. `driftPerDay` pulls every opinion back toward the neutral +// baseline; at this rate the full 0..1 span recovers over roughly 45 years, so +// a campaign's goodwill lingers for years while a war crime is not forgotten +// for a generation. +export const OPINION = { + baseline: 0.5, + driftPerDay: 1.0 / (365.25 * 45), + min: 0.0, + max: 1.0, +}; + +// A region whose people approve of their government by less than +// `rebellionThreshold` suffers the penalties below. Construction slows +// exponentially as approval falls and the tax take collapses toward +// `taxFloorFraction` of its normal value. +export const APPROVAL = { + rebellionThreshold: 0.1, + constructionExponent: 2.5, + taxFloorFraction: 0.1, +}; + +// A campaign costs nothing to start: it charges `defaultHourlyCost` for as long +// as it runs, like any other upkeep and shown in the budget. The player may +// raise or lower the spending up to `maxHourlyCost`; the opinion shift scales +// with it. A campaign stays in force until cancelled. `dailyDelta` is the +// opinion shift per day at the default spend and balanced cultural impact; +// `culturalWeight` is how strongly the two nations' cultural impacts bend the +// result. +export const PROPAGANDA = { + defaultHourlyCost: 8_000, + maxHourlyCost: 40_000, + dailyDelta: 0.012, + culturalWeight: 0.6, +}; + +export const CAMPAIGN_ETHNICITY = "ethnicity"; +export const CAMPAIGN_GOVERNMENT = "government"; +export const CAMPAIGN_WARM = 1; +export const CAMPAIGN_COOL = -1; + +// Minority policies a government may proclaim. Any number may be active at +// once, but the same policy may not be enacted twice for the same ethnicity. +// `targetEffect` and `politicalCost` are the estimates the wizard shows; the +// model applies them through POLICY_OPINION below. +export const POLICY_CHAMPION = "champion"; +export const POLICY_EXPEL = "expel"; +export const POLICY_PROTECT = "protect"; +export const POLICY_IMMIGRATION = "immigration"; + +export const POLICIES = [ + { + id: POLICY_CHAMPION, + name: "Champion a minority", + description: + "Proclaim the state the protector of an ethnicity. Its people everywhere warm to you, but your own other populations resent the favouritism.", + targetEffect: "+15% approval among the chosen people", + politicalCost: "−8% approval among every other population in your country", + }, + { + id: POLICY_PROTECT, + name: "Protect a minority", + description: + "Shield an ethnicity inside your borders. It raises their view of you at home and abroad, at some cost with your other populations.", + targetEffect: "+12% approval among the chosen people", + politicalCost: "−4% approval among every other population in your country", + }, + { + id: POLICY_EXPEL, + name: "Expel a minority", + description: + "Force an ethnicity out of the country. Its people, in and outside your borders, will hate you for a long time, and they will press into your neighbours.", + targetEffect: "The people begin to leave for neighbouring countries", + politicalCost: "−40% approval among the expelled people, remembered for a generation", + }, + { + id: POLICY_IMMIGRATION, + name: "Encourage immigration", + description: + "Invite an ethnicity in. They will come from lands where their government is unpopular, but every other population in your country dislikes the influx.", + targetEffect: "The chosen people migrate into your country", + politicalCost: "−5% approval among every other population in your country", + }, +]; + +export function policyById(id) { + return POLICIES.find((policy) => policy.id === id) || null; +} + +// Immediate opinion shifts of proclaiming each policy, addressed to the target +// ethnicity and to the rest of the nation's people. +export const POLICY_OPINION = { + championTarget: 0.15, + championMajority: -0.08, + protectTarget: 0.12, + protectMajority: -0.04, + expelTarget: -0.4, + immigrationOthers: -0.05, +}; + +// Daily share of a minority that is expelled to neighbouring tiles, and the +// rate at which an encouraged ethnicity is drawn into the country. Immigration +// is scaled by how much the newcomer dislikes their current government: +// a 5% approval government sheds people fast, a 90% one holds them. +export const MIGRATION = { + expelSharePerDay: 0.004, + immigrationSharePerDay: 0.002, + // The approval at or above which nobody is inclined to leave. + comfortableApproval: 0.9, +}; + +// A pillaged tile is a war crime. The people of the tile loathe the aggressor +// immediately; the stain spreads to neighbours, the region, the victim nation +// and the watching world, and is remembered for a generation. +export const WAR_CRIME = { + tileOpinion: 0.35, + tileEthnicOpinion: 0.2, + neighbourFactor: 0.5, + regionalFactor: 0.3, + nationalFactor: 0.2, + worldFactor: 0.08, + memoryYears: 25, +}; + +// A mistreated minority rises up when it hates its government and adores the +// one that shares its ethnicity, taking up arms for the latter. +export const REBELLION = { + approvalMax: 0.1, + championApprovalMin: 0.7, + populationShareMin: 0.12, + cooldownDays: 60, + maxUnitsPerEthnicity: 8, +}; + +// Declaring war on a government its people like costs the aggressor dear; a war +// on a loathed government can even be popular. The penalty scales with how far +// the onlookers' opinion of the victim strays from neutral. +export const WAR_DECLARATION = { + penalty: 0.3, +}; + +// Cultural impact is a nation's share of the world's cultural output, so it is +// zero-sum by construction. A nation above the average impact slowly wins the +// world over to its government. +export const CULTURE_IMPACT = { + dailyPopularityGain: 0.0004, +}; diff --git a/shared/data/relations.js b/shared/data/relations.js index 45b0a51..0e61865 100644 --- a/shared/data/relations.js +++ b/shared/data/relations.js @@ -9,5 +9,7 @@ export const RELATION_WAR = "war"; // GameState with their in-game hour so every client can render them. export const NEWS_WAR = "war"; export const NEWS_CAPTURE = "capture"; +// A government enacted a minority policy. Recorded by the model. +export const NEWS_POLICY = "policy"; // A player took control of a nation. Recorded by the server, not the model. export const NEWS_JOIN = "join"; diff --git a/shared/game_state.js b/shared/game_state.js index 82c94d7..5fdbdd4 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -40,6 +40,7 @@ import { combatMethods } from "./game_state/combat.js"; import { airMethods } from "./game_state/air.js"; import { statusMethods } from "./game_state/statuses.js"; import { diplomacyMethods } from "./game_state/diplomacy.js"; +import { politicsMethods } from "./game_state/politics.js"; import { orderMethods } from "./game_state/orders.js"; import { visibilityMethods } from "./game_state/visibility.js"; import { serializationMethods } from "./game_state/serialization.js"; @@ -123,6 +124,28 @@ export class GameState { this.conflicts = new Map(); this.news = []; + // Politics: the ethnic makeup of every tile, the opinions each ethnicity + // holds of every other ethnicity and government, the war crime grudges a + // government carries, its active propaganda campaigns and minority + // policies, and small caches/indexes the daily politics tick leans on. + this.tileEthnicity = new Map(); + this.ethnicOpinions = new Map(); + this.govOpinions = new Map(); + this.grudges = new Map(); + this.campaigns = []; + this.policies = new Map(); + // The day's migration flows, rebuilt each migration tick: civ -> { inbound, + // outbound } as Map. + this.migrations = new Map(); + // Signed migration change over the last day, per scope: tile, city and + // civ, each a Map>. Drives the up/down arrows. + this._migrationDeltas = { civ: new Map(), city: new Map(), tile: new Map() }; + this._nextCampaignId = 1; + this._nextPolicyId = 1; + this._rebellionCooldown = new Map(); + this._politicsVersion = 0; + this._approvalCache = new Map(); + this._nextUnitId = 1; this._nextCityId = 1; this._nextNewsId = 1; @@ -235,6 +258,7 @@ export class GameState { } if (this.totalHours % HOURS_PER_DAY === 0) { this._tickPopulation(); + this._tickPolitics(); this._tickBattleTiles(); this._tickGdpRecovery(); this._tickEconomy(); @@ -263,6 +287,7 @@ Object.assign( airMethods, statusMethods, diplomacyMethods, + politicsMethods, orderMethods, visibilityMethods, serializationMethods diff --git a/shared/game_state/diplomacy.js b/shared/game_state/diplomacy.js index 57c328d..a74cae9 100644 --- a/shared/game_state/diplomacy.js +++ b/shared/game_state/diplomacy.js @@ -45,6 +45,9 @@ export const diplomacyMethods = { military: new Map(), civilians: new Map(), }); + // The world watches: a war on a well-liked government costs the aggressor + // its standing with onlookers, a war on a loathed one can be popular. + this._applyWarPopularity(actor, target); this._addNews({ type: NEWS_WAR, actor, target }); return true; }, diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index c56de14..b376375 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -100,6 +100,8 @@ export const economyMethods = { const statuses = this._effectiveStatuses(unit, battle.has(unit.id)); total += proto.upkeep * this._statusUpkeepMultiplier(statuses); } + // Running propaganda campaigns are upkeep too, paid hour by hour. + total += this.getCampaignUpkeep(civ); return total; }, @@ -144,7 +146,10 @@ export const economyMethods = { const national = this.getNationalModifiers(city.civ); const modifier = (cityMods[EFFECT_BUDGET_INCOME] || 0) + (national[EFFECT_BUDGET_INCOME] || 0); - return { population, gdp, base, modifier, income: base * (1 + modifier) }; + // A region whose people have turned against the government barely pays its + // taxes, so approval scales the collected income after every other bonus. + const approval = this.taxApprovalMultiplier(city); + return { population, gdp, base, modifier, approval, income: base * (1 + modifier) * approval }; }, // The tax the whole nation collects in one hour, summed over its regions. @@ -177,7 +182,8 @@ export const economyMethods = { gdp: economy.gdp, base: economy.base, buildingModifier, - modifier: economy.modifier, + modifier: economy.base > 0 ? economy.income / economy.base - 1 : 0, + approval: economy.approval, income: economy.income, }); } @@ -192,11 +198,14 @@ export const economyMethods = { const buildingUpkeep = groups.reduce((sum, group) => sum + group.amount, 0); const unitSources = this._unitUpkeepSources(civ); const unitUpkeep = unitSources.reduce((sum, source) => sum + source.amount, 0); + const campaignSources = this._campaignUpkeepSources(civ); + const campaignUpkeep = campaignSources.reduce((sum, source) => sum + source.amount, 0); return { income: { rate, base, modifier, total: income, sources: incomeSources, regions }, buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups }, unitUpkeep: { total: unitUpkeep, sources: unitSources, groups: unitSources }, - net: income - buildingUpkeep - unitUpkeep, + campaignUpkeep: { total: campaignUpkeep, sources: campaignSources, groups: campaignSources }, + net: income - buildingUpkeep - unitUpkeep - campaignUpkeep, }; }, @@ -216,7 +225,8 @@ export const economyMethods = { gdp: economy.gdp, base: economy.base, buildingModifier: this.getCityBuildingModifiers(city)[EFFECT_BUDGET_INCOME] || 0, - modifier: economy.modifier, + modifier: economy.base > 0 ? economy.income / economy.base - 1 : 0, + approval: economy.approval, income: economy.income, }; const incomeSources = this._incomeSources(city.civ, [region], region.base); diff --git a/shared/game_state/orders.js b/shared/game_state/orders.js index 2333e13..8ba9613 100644 --- a/shared/game_state/orders.js +++ b/shared/game_state/orders.js @@ -44,7 +44,7 @@ export const orderMethods = { protoIndex, capacity, elapsedHours: 0, - totalHours: proto.cost / capacity, + totalHours: (proto.cost / capacity) * this.constructionApprovalMultiplier(city), }; if (queue) queue.push(entry); else this.training.set(cityId, [entry]); @@ -108,7 +108,7 @@ export const orderMethods = { level, capacity, elapsedHours: 0, - totalHours: cost / capacity, + totalHours: (cost / capacity) * this.constructionApprovalMultiplier(city), }; if (queue) queue.push(entry); else this.training.set(cityId, [entry]); @@ -201,6 +201,8 @@ export const orderMethods = { // The people driven off the tile are civilians lost to its owner in the war // against the pillaging civilisation. if (owner >= 0) this._recordCasualties(owner, units[0].civ, "civilians", drivenOff); + // Pillaging the defenceless is a war crime the world remembers. + this._recordWarCrime(units[0].civ, coords, owner); this._emitChanged(); return true; }, diff --git a/shared/game_state/politics.js b/shared/game_state/politics.js new file mode 100644 index 0000000..58fdf23 --- /dev/null +++ b/shared/game_state/politics.js @@ -0,0 +1,890 @@ +// Politics: public opinion, ethnic populations, propaganda campaigns, minority +// policies, migration, war crimes, uprisings and cultural impact. +// +// Every civilisation is also an ethnicity. A tile carries an ethnic makeup (a +// Map of ethnicity -> share of the tile's people); a country's population is +// the sum of its tiles. Each ethnicity (observer) holds an opinion of every +// other ethnicity and of every government; opinions start neutral, drift back +// toward the middle and are moved by campaigns, policies, war and war crimes. +// +// Nothing here knows about transport or the DOM: the server owns the model, the +// browser only reads the snapshot. + +import { key, parseKey } from "../hex.js"; +import { + OPINION, + APPROVAL, + PROPAGANDA, + CAMPAIGN_ETHNICITY, + CAMPAIGN_GOVERNMENT, + POLICY_CHAMPION, + POLICY_EXPEL, + POLICY_PROTECT, + POLICY_IMMIGRATION, + POLICY_OPINION, + MIGRATION, + WAR_CRIME, + REBELLION, + WAR_DECLARATION, + CULTURE_IMPACT, +} from "../data/politics.js"; +import { NEWS_POLICY } from "../data/relations.js"; +import { EFFECT_CULTURE } from "../data/effects.js"; +import { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js"; + +// How far from one of its cities an ethnicity can meaningfully spread. Beyond +// this a nation leaves no measurable minority. +const ETHNIC_RADIUS = 6; +// Squared-distance falloff of a diaspora: closer nations leave more people. +const ETHNIC_FALLOFF = 0.5; +// A nation's own tile starts this many times weightier than a neighbour's +// influence, so the owner is always the majority. +const ETHNIC_OWNER_WEIGHT = 4; +// Diaspora shares below this are dropped, keeping the snapshot small. +const ETHNIC_MIN_SHARE = 0.03; + +export const politicsMethods = { + // ------------------------------------------------------- lifecycle -- + + // Seeds the per-civ policy list. The opinion stores start empty and fall + // back to the neutral baseline on read. + _initPolitics() { + this.policies = new Map(); + for (let civ = 0; civ < this.civilisations.length; civ++) { + this.policies.set(civ, []); + } + }, + + _tickPolitics() { + this._tickCampaigns(); + this._tickOpinionDrift(); + this._tickMigration(); + this._tickUprisings(); + this._tickCultureImpact(); + this._expireGrudges(); + }, + + _touchPolitics() { + this._politicsVersion += 1; + }, + + // ---------------------------------------------------- ethnic makeup -- + + // Lays a deterministic ethnic makeup over the generated population. Each + // tile's owner is its clear majority; nearby nations leave a minority that + // grows with proximity, crossing water as readily as land, so the French + // live in Britain just across the channel. + _generateEthnicMakeup() { + const civCount = this.civilisations.length; + this.tileEthnicity = new Map(); + if (civCount === 0) return; + const distances = []; + for (let civ = 0; civ < civCount; civ++) { + distances.push(this._civEthnicDistance(civ)); + } + for (const k of this.tilePopulation.keys()) { + const owner = this.territory.has(k) ? this.territory.get(k) : -1; + const weights = new Map(); + for (let civ = 0; civ < civCount; civ++) { + const distance = distances[civ].get(k); + let weight; + if (distance === undefined) { + // Every tile is always home to its owner's ethnicity, however far + // that nation's cities lie. + if (civ !== owner) continue; + weight = ETHNIC_OWNER_WEIGHT; + } else { + weight = 1 / (1 + ETHNIC_FALLOFF * distance * distance); + if (civ === owner) weight *= ETHNIC_OWNER_WEIGHT; + } + if (weight >= ETHNIC_MIN_SHARE) weights.set(civ, weight); + } + if (weights.size === 0) weights.set(owner >= 0 ? owner : 0, 1); + this.tileEthnicity.set(k, normalizeShares(weights)); + } + }, + + // Multi-source BFS over every tile (land and sea) from a civilisation's + // cities, out to ETHNIC_RADIUS. Missing keys mean "too far to matter". + _civEthnicDistance(civ) { + const dist = new Map(); + const queue = []; + for (const city of this.cities) { + if (city.civ !== civ) continue; + const k = key(city.coords.x, city.coords.y); + if (dist.has(k)) continue; + dist.set(k, 0); + queue.push(city.coords); + } + let head = 0; + while (head < queue.length) { + const coords = queue[head++]; + const d = dist.get(key(coords.x, coords.y)); + if (d >= ETHNIC_RADIUS) continue; + for (const neighbour of this._neighbours(coords)) { + const nk = key(neighbour.x, neighbour.y); + if (!this.tiles[nk] || dist.has(nk)) continue; + dist.set(nk, d + 1); + queue.push(neighbour); + } + } + return dist; + }, + + // The ethnic shares of a tile, defaulting to its owner's sole ethnicity. + getTileEthnicFractions(coords) { + const k = key(coords.x, coords.y); + const shares = this.tileEthnicity.get(k); + if (shares) return shares; + const owner = this.territory.has(k) ? this.territory.get(k) : -1; + if (owner < 0) return new Map(); + return new Map([[owner, 1]]); + }, + + _setTileEthnicFractions(coords, fractions) { + const k = key(coords.x, coords.y); + const compact = new Map(); + for (const [eth, share] of fractions) { + if (share > 0.0001) compact.set(eth, share); + } + this.tileEthnicity.set(k, normalizeShares(compact)); + }, + + // The tile's people split by ethnicity, richest population first, each with + // its net migration over the last day. + getTileEthnicityDistribution(coords) { + const k = key(coords.x, coords.y); + const population = this.tilePopulation.get(k) || 0; + const result = []; + for (const [ethnicity, share] of this.getTileEthnicFractions(coords)) { + result.push({ + ethnicity, + share, + count: share * population, + trend: this._trendAt(this._migrationDeltas.tile, k, ethnicity), + }); + } + result.sort((a, b) => b.count - a.count); + return result; + }, + + _dominantEthnicity(coords) { + let best = -1; + let bestShare = -1; + for (const [eth, share] of this.getTileEthnicFractions(coords)) { + if (share > bestShare) { + bestShare = share; + best = eth; + } + } + return best; + }, + + // The population of each ethnicity living on a list of tiles. + _ethnicCountsOver(coordsList) { + const counts = new Map(); + for (const coords of coordsList) { + const k = key(coords.x, coords.y); + const population = this.tilePopulation.get(k) || 0; + for (const [eth, share] of this.getTileEthnicFractions(coords)) { + counts.set(eth, (counts.get(eth) || 0) + share * population); + } + } + return counts; + }, + + // The ethnic makeup of a civilisation's territory, scaled by the same + // balancing factor the economy uses so the counts match the headline + // population the rest of the UI shows. + getCivEthnicMakeup(civ) { + const scale = this._populationBalance.get(civ) || 1; + const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []); + return this._makeupList(counts, this._migrationDeltas.civ.get(civ), scale); + }, + + // The ethnic makeup of one city's region. + getCityEthnicMakeup(city) { + const scale = this._populationBalance.get(city.civ) || 1; + const counts = this._ethnicCountsOver(this.regionTiles(city)); + return this._makeupList(counts, this._migrationDeltas.city.get(city.id), scale); + }, + + _makeupList(counts, trend, scale = 1) { + let total = 0; + for (const count of counts.values()) total += count; + if (total <= 0) return []; + const result = []; + for (const [ethnicity, count] of counts) { + result.push({ + ethnicity, + count: count * scale, + share: count / total, + trend: trend ? trend.get(ethnicity) || 0 : 0, + }); + } + result.sort((a, b) => b.count - a.count); + return result; + }, + + // ------------------------------------------------------- opinions -- + + getEthnicOpinion(observer, target) { + if (observer < 0 || target < 0) return OPINION.baseline; + const inner = this.ethnicOpinions.get(observer); + const value = inner ? inner.get(target) : undefined; + return value === undefined ? OPINION.baseline : value; + }, + + // A government's standing with one population: its base opinion plus any war + // crime the government has not been forgiven for. + getGovOpinion(observer, targetCiv) { + if (observer < 0 || targetCiv < 0) return OPINION.baseline; + const inner = this.govOpinions.get(observer); + const base = inner && inner.has(targetCiv) ? inner.get(targetCiv) : OPINION.baseline; + return clampOpinion(base + this._grudgeTotal(observer, targetCiv)); + }, + + _grudgeTotal(observer, targetCiv) { + const inner = this.grudges.get(observer); + if (!inner) return 0; + const grudge = inner.get(targetCiv); + if (!grudge || this.totalHours >= grudge.until) return 0; + return grudge.amount; + }, + + _shiftEthnicOpinion(observer, target, delta) { + if (observer < 0 || target < 0 || delta === 0) return; + if (!this.ethnicOpinions.has(observer)) this.ethnicOpinions.set(observer, new Map()); + const inner = this.ethnicOpinions.get(observer); + const current = inner.has(target) ? inner.get(target) : OPINION.baseline; + inner.set(target, clampOpinion(current + delta)); + this._touchPolitics(); + }, + + _shiftGovOpinion(observer, targetCiv, delta) { + if (observer < 0 || targetCiv < 0 || delta === 0) return; + if (!this.govOpinions.has(observer)) this.govOpinions.set(observer, new Map()); + const inner = this.govOpinions.get(observer); + const current = inner.has(targetCiv) ? inner.get(targetCiv) : OPINION.baseline; + inner.set(targetCiv, clampOpinion(current + delta)); + this._touchPolitics(); + }, + + _addGrudge(observer, aggressor, amount, until) { + if (observer < 0 || aggressor < 0 || amount === 0) return; + if (!this.grudges.has(observer)) this.grudges.set(observer, new Map()); + const inner = this.grudges.get(observer); + const existing = inner.get(aggressor); + if (existing) { + existing.amount += amount; + existing.until = Math.max(existing.until, until); + } else { + inner.set(aggressor, { amount, until }); + } + this._touchPolitics(); + }, + + _expireGrudges() { + for (const [observer, inner] of this.grudges) { + for (const [aggressor, grudge] of inner) { + if (this.totalHours >= grudge.until) inner.delete(aggressor); + } + if (inner.size === 0) this.grudges.delete(observer); + } + }, + + _tickOpinionDrift() { + const step = OPINION.driftPerDay; + this._driftStore(this.ethnicOpinions, step); + this._driftStore(this.govOpinions, step); + }, + + _driftStore(store, step) { + for (const inner of store.values()) { + for (const [target, value] of inner) { + if (value > OPINION.baseline) { + inner.set(target, Math.max(OPINION.baseline, value - step)); + } else if (value < OPINION.baseline) { + inner.set(target, Math.min(OPINION.baseline, value + step)); + } + } + } + this._touchPolitics(); + }, + + // ------------------------------------------------------ approval -- + + // A city region's approval of its government: the population-weighted mean of + // every resident population's opinion of it. + getCityApproval(city) { + const version = this._politicsVersion; + const cached = this._approvalCache.get(city.id); + if (cached && cached.version === version) return cached.value; + const value = this._weightedApproval(this.regionTiles(city), city.civ); + this._approvalCache.set(city.id, { version, value }); + return value; + }, + + // A nation's government approval among its own people. + getCivApproval(civ) { + return this._weightedApproval(this._territoryByCiv.get(civ) || [], civ); + }, + + _weightedApproval(coordsList, government) { + let total = 0; + let weight = 0; + for (const coords of coordsList) { + const k = key(coords.x, coords.y); + const population = this.tilePopulation.get(k) || 0; + for (const [eth, share] of this.getTileEthnicFractions(coords)) { + const w = population * share; + total += w * this.getGovOpinion(eth, government); + weight += w; + } + } + return weight > 0 ? total / weight : OPINION.baseline; + }, + + // The multiplier on a city's construction time. Above the approval floor it + // is 1; below it grows exponentially as the people turn against the + // government, so a hated region can barely build at all. + constructionApprovalMultiplier(city) { + return this._constructionMultiplier(this.getCityApproval(city)); + }, + + _constructionMultiplier(approval) { + if (approval >= APPROVAL.rebellionThreshold) return 1; + const shortfall = (APPROVAL.rebellionThreshold - approval) / APPROVAL.rebellionThreshold; + return Math.pow(1 + shortfall, APPROVAL.constructionExponent); + }, + + // The fraction of a city region's tax that is actually collected; it falls + // with approval to a hard floor. + taxApprovalMultiplier(city) { + const approval = this.getCityApproval(city); + if (approval >= APPROVAL.rebellionThreshold) return 1; + const floor = APPROVAL.taxFloorFraction; + return floor + (1 - floor) * (approval / APPROVAL.rebellionThreshold); + }, + + // ------------------------------------------------------ campaigns -- + + // Starts a propaganda campaign. `observer` is the population whose mind is to + // be changed, `targetKind` is "ethnicity" or "government" and `target` the + // index to warm to or cool on. Nothing is charged up front: the campaign + // accrues hourly upkeep at the default spend until cancelled or retuned. A + // nation may not run two campaigns on the same population and target. + requestCampaign(civ, observer, targetKind, target, direction) { + if (civ < 0 || civ >= this.civilisations.length) return false; + if (observer < 0 || observer >= this.civilisations.length) return false; + if (targetKind !== CAMPAIGN_ETHNICITY && targetKind !== CAMPAIGN_GOVERNMENT) return false; + if (target < 0 || target >= this.civilisations.length) return false; + if (this.hasCampaign(civ, observer, targetKind, target)) return false; + const dir = direction < 0 ? -1 : 1; + this.campaigns.push({ + id: this._nextCampaignId++, + civ, + observer, + targetKind, + target, + direction: dir, + startedHours: this.totalHours, + hourlyCost: PROPAGANDA.defaultHourlyCost, + dailyDelta: PROPAGANDA.dailyDelta, + }); + this._touchPolitics(); + this._emitChanged(); + return true; + }, + + // Retunes what a campaign spends each hour. Spending more makes it land + // harder, spending nothing stops it outright. + requestSetCampaignSpending(civ, campaignId, amount) { + const campaign = this.campaigns.find( + (item) => item.id === campaignId && item.civ === civ + ); + if (!campaign) return false; + const value = Number(amount); + if (!Number.isFinite(value) || value < 0) return false; + if (value <= 0) return this.requestCancelCampaign(civ, campaignId); + campaign.hourlyCost = Math.min(value, PROPAGANDA.maxHourlyCost); + this._touchPolitics(); + this._emitChanged(); + return true; + }, + + // Whether `civ` is already running a campaign on this population and target, + // whatever its direction. + hasCampaign(civ, observer, targetKind, target) { + return this.campaigns.some( + (campaign) => + campaign.civ === civ && + campaign.observer === observer && + campaign.targetKind === targetKind && + campaign.target === target + ); + }, + + // Stops a running campaign. + requestCancelCampaign(civ, campaignId) { + const index = this.campaigns.findIndex( + (campaign) => campaign.id === campaignId && campaign.civ === civ + ); + if (index < 0) return false; + this.campaigns.splice(index, 1); + this._touchPolitics(); + this._emitChanged(); + return true; + }, + + // The hourly price of a nation's running campaigns. + getCampaignUpkeep(civ) { + let total = 0; + for (const campaign of this.campaigns) { + if (campaign.civ === civ) total += campaign.hourlyCost; + } + return total; + }, + + // The campaign expense as one budget source, so the treasury table can show + // it alongside building and unit upkeep. + _campaignUpkeepSources(civ) { + const total = this.getCampaignUpkeep(civ); + if (total <= 0) return []; + let count = 0; + for (const campaign of this.campaigns) if (campaign.civ === civ) count += 1; + return [{ label: "Propaganda campaigns", kind: "campaign", amount: total, count }]; + }, + + // Campaigns run for as long as they are left in place, moving opinion a + // little every day. Spending above the default amplifies the shift and + // spending below it dampens it. + _tickCampaigns() { + if (this.campaigns.length === 0) return; + for (const campaign of this.campaigns) { + const spendFactor = PROPAGANDA.defaultHourlyCost > 0 + ? campaign.hourlyCost / PROPAGANDA.defaultHourlyCost + : 0; + const delta = + campaign.direction * + campaign.dailyDelta * + spendFactor * + this._campaignEffectiveness(campaign); + if (campaign.targetKind === CAMPAIGN_GOVERNMENT) { + this._shiftGovOpinion(campaign.observer, campaign.target, delta); + } else { + this._shiftEthnicOpinion(campaign.observer, campaign.target, delta); + } + } + }, + + // How strongly a campaign lands: a culturally dominant sponsor out-talks a + // weaker target. Both nations' cultural impacts shape the result. + _campaignEffectiveness(campaign) { + const sponsor = this.getCulturalImpact(campaign.civ); + const target = this.getCulturalImpact(campaign.target); + const factor = 1 + PROPAGANDA.culturalWeight * (sponsor - target) * 2; + return Math.max(0.25, Math.min(2, factor)); + }, + + // ------------------------------------------------------- policies -- + + getPolicies(civ) { + return this.policies.get(civ) || null; + }, + + // True when `civ` has already enacted this exact policy for this ethnicity. + hasPolicy(civ, policyId, ethnicity) { + const record = this.policies.get(civ); + if (!record) return false; + return record.some( + (policy) => policy.type === policyId && policy.ethnicity === ethnicity + ); + }, + + // Enacts one more minority policy for `civ`. Any number may be active, but + // the same policy never twice for the same people. Records the news and + // applies the immediate opinion consequences. + requestPolicy(civ, policyId, ethnicity) { + if (!this._validCiv(civ) || !this._validCiv(ethnicity)) return false; + const record = this.policies.get(civ); + if (!record) return false; + if (!isKnownPolicy(policyId)) return false; + if (this.hasPolicy(civ, policyId, ethnicity)) return false; + record.push({ id: this._nextPolicyId++, type: policyId, ethnicity }); + this._applyPolicyOpinion(civ, policyId, ethnicity); + this._addNews({ type: NEWS_POLICY, civ, policy: policyId, ethnicity }); + this._emitChanged(); + return true; + }, + + // Repeals a policy by its id. Nothing already done is undone, but its ongoing + // effects stop. + requestRevokePolicy(civ, policyId) { + const record = this.policies.get(civ); + if (!record) return false; + const index = record.findIndex((policy) => policy.id === policyId); + if (index < 0) return false; + record.splice(index, 1); + this._emitChanged(); + return true; + }, + + // The instant opinion shifts of one policy: the targeted people warm or cool + // to the government, every other resident population resents the favour. + _applyPolicyOpinion(civ, policyId, ethnicity) { + const residents = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []).keys(); + const shiftOthers = (amount) => { + for (const observer of residents) { + if (observer === ethnicity) continue; + this._shiftGovOpinion(observer, civ, amount); + } + }; + switch (policyId) { + case POLICY_CHAMPION: + this._shiftGovOpinion(ethnicity, civ, POLICY_OPINION.championTarget); + shiftOthers(POLICY_OPINION.championMajority); + break; + case POLICY_PROTECT: + this._shiftGovOpinion(ethnicity, civ, POLICY_OPINION.protectTarget); + shiftOthers(POLICY_OPINION.protectMajority); + break; + case POLICY_EXPEL: + this._shiftGovOpinion(ethnicity, civ, POLICY_OPINION.expelTarget); + this._addGrudge( + ethnicity, + civ, + POLICY_OPINION.expelTarget, + this.totalHours + WAR_CRIME.memoryYears * HOURS_PER_YEAR + ); + break; + case POLICY_IMMIGRATION: + shiftOthers(POLICY_OPINION.immigrationOthers); + break; + default: + break; + } + }, + + // ------------------------------------------------------ migration -- + + // Runs one day of migration and rebuilds the flow record the UI reports. + // `migrations[civ]` holds the people who arrived and left during that day. + _tickMigration() { + this.migrations = new Map(); + this._migrationDeltas = { civ: new Map(), city: new Map(), tile: new Map() }; + for (const [civ, record] of this.policies) { + for (const policy of record) { + if (policy.type === POLICY_EXPEL) this._migrateExpelled(civ, policy.ethnicity); + else if (policy.type === POLICY_IMMIGRATION) { + this._migrateInvited(civ, policy.ethnicity); + } + } + } + }, + + // Adds `amount` people to a civilisation's inbound or outbound tally. + _recordMigration(civ, direction, ethnicity, amount) { + if (civ < 0 || !(amount > 0)) return; + let flow = this.migrations.get(civ); + if (!flow) { + flow = { inbound: new Map(), outbound: new Map() }; + this.migrations.set(civ, flow); + } + const counts = direction === "in" ? flow.inbound : flow.outbound; + counts.set(ethnicity, (counts.get(ethnicity) || 0) + amount); + }, + + // A people forced out drift across the border, preferring neighbours of their + // own ethnicity. + _migrateExpelled(civ, eth) { + for (const coords of this._territoryByCiv.get(civ) || []) { + const k = key(coords.x, coords.y); + const shares = this.tileEthnicity.get(k); + if (!shares || (shares.get(eth) || 0) <= 0.001) continue; + for (const neighbour of this._neighbours(coords)) { + const nk = key(neighbour.x, neighbour.y); + const owner = this.territory.has(nk) ? this.territory.get(nk) : -1; + if (owner === civ) continue; + const nShares = this.tileEthnicity.get(nk); + const alreadyHome = owner === eth || (nShares && (nShares.get(eth) || 0) >= 0.05); + // They prefer a land of their own people, but a trickle still crosses + // into any neighbouring country. + const share = alreadyHome + ? MIGRATION.expelSharePerDay + : MIGRATION.expelSharePerDay * 0.2; + const moved = this._transferEthnicity(k, nk, eth, share); + if (moved > 0) { + this._recordMigration(civ, "out", eth, moved); + this._recordMigration(owner, "in", eth, moved); + } + } + } + }, + + // An invited people move in from lands whose government they dislike: the + // lower that government's standing with them, the faster they come. + _migrateInvited(civ, eth) { + for (const coords of this._territoryByCiv.get(civ) || []) { + const k = key(coords.x, coords.y); + for (const neighbour of this._neighbours(coords)) { + const nk = key(neighbour.x, neighbour.y); + const owner = this.territory.has(nk) ? this.territory.get(nk) : -1; + if (owner < 0 || owner === civ) continue; + const shares = this.tileEthnicity.get(nk); + if (!shares || (shares.get(eth) || 0) <= 0.001) continue; + const approval = this.getGovOpinion(eth, owner); + const willingness = Math.max( + 0, + Math.min(1, (MIGRATION.comfortableApproval - approval) / MIGRATION.comfortableApproval) + ); + if (willingness <= 0) continue; + const moved = this._transferEthnicity( + nk, + k, + eth, + MIGRATION.immigrationSharePerDay * willingness + ); + if (moved > 0) { + this._recordMigration(owner, "out", eth, moved); + this._recordMigration(civ, "in", eth, moved); + } + } + } + }, + + // Moves `share` of one ethnicity's people from tile `fromK` to tile `toK`, + // renormalises both tiles' shares and returns how many people moved. + _transferEthnicity(fromK, toK, eth, share) { + const fromShares = this.tileEthnicity.get(fromK); + const toShares = this.tileEthnicity.get(toK); + if (!fromShares || !toShares) return 0; + const fromFraction = fromShares.get(eth) || 0; + if (fromFraction <= 0) return 0; + const fromPopulation = this.tilePopulation.get(fromK) || 0; + const toPopulation = this.tilePopulation.get(toK) || 0; + if (fromPopulation <= 0 || toPopulation <= 0) return 0; + const movedFraction = fromFraction * share; + const remaining = Math.max(0, fromFraction - movedFraction); + if (remaining <= 0.0001) fromShares.delete(eth); + else fromShares.set(eth, remaining); + toShares.set(eth, (toShares.get(eth) || 0) + movedFraction * (fromPopulation / toPopulation)); + this._setTileEthnicFractions(parseKey(toK), new Map(toShares)); + this._setTileEthnicFractions(parseKey(fromK), new Map(fromShares)); + const movedPeople = movedFraction * fromPopulation; + this._trackMigration(fromK, toK, eth, movedPeople); + this._touchPolitics(); + return movedPeople; + }, + + // Records a signed migration change for the tile, its city region and its + // country, so the UI can show whether each population is rising or falling. + _trackMigration(fromK, toK, eth, amount) { + if (!(amount > 0)) return; + const fromOwner = this.territory.has(fromK) ? this.territory.get(fromK) : -1; + const toOwner = this.territory.has(toK) ? this.territory.get(toK) : -1; + this._bumpDelta(this._migrationDeltas.civ, fromOwner, eth, -amount); + this._bumpDelta(this._migrationDeltas.civ, toOwner, eth, amount); + const fromCity = this.regionCityAt(parseKey(fromK)); + const toCity = this.regionCityAt(parseKey(toK)); + if (fromCity) this._bumpDelta(this._migrationDeltas.city, fromCity.id, eth, -amount); + if (toCity) this._bumpDelta(this._migrationDeltas.city, toCity.id, eth, amount); + this._bumpDelta(this._migrationDeltas.tile, fromK, eth, -amount); + this._bumpDelta(this._migrationDeltas.tile, toK, eth, amount); + }, + + _bumpDelta(store, key, eth, amount) { + if (key === undefined || key === null || key < 0) return; + let inner = store.get(key); + if (!inner) { + inner = new Map(); + store.set(key, inner); + } + inner.set(eth, (inner.get(eth) || 0) + amount); + }, + + // The net migration of one ethnicity in a scope, 0 when unrecorded. + _trendAt(store, key, eth) { + const inner = store.get(key); + return inner ? inner.get(eth) || 0 : 0; + }, + + // ------------------------------------------------------ war crime -- + + // Marks a pillaged tile as a war crime: the people of the tile, its + // neighbours, its region, the victim nation and the whole watching world + // sour on the aggressor, and remember for a generation. + _recordWarCrime(aggressor, coords, victimCiv) { + if (aggressor < 0 || victimCiv < 0 || aggressor === victimCiv) return; + const k = key(coords.x, coords.y); + const until = this.totalHours + WAR_CRIME.memoryYears * HOURS_PER_YEAR; + // Each observer is hit once, at the strongest factor that reaches it. + const hits = new Map(); + const hit = (observer, factor) => { + if (observer < 0) return; + const current = hits.get(observer) || 0; + if (factor > current) hits.set(observer, factor); + }; + // The tile's own dominant people feel it most. + hit(this._dominantEthnicity(coords), 1); + // The immediate neighbours. + for (const neighbour of this._neighbours(coords)) { + hit(this._dominantEthnicity(neighbour), WAR_CRIME.neighbourFactor); + } + // The region and the victim country. + const region = this.regionCityAt(coords); + if (region) { + for (const tile of this.regionTiles(region)) { + hit(this._dominantEthnicity(tile), WAR_CRIME.regionalFactor); + } + } + for (const tile of this._territoryByCiv.get(victimCiv) || []) { + hit(this._dominantEthnicity(tile), WAR_CRIME.nationalFactor); + } + // And the rest of the planet, faintly. + for (let eth = 0; eth < this.civilisations.length; eth++) { + hit(eth, WAR_CRIME.worldFactor); + } + for (const [observer, factor] of hits) { + this._addGrudge(observer, aggressor, -WAR_CRIME.tileOpinion * factor, until); + this._shiftEthnicOpinion(observer, aggressor, -WAR_CRIME.tileEthnicOpinion * factor); + } + this._touchPolitics(); + }, + + // ------------------------------------------------ war and uprising -- + + // The world reacts to a declaration of war: onlookers who liked the victim + // turn on the aggressor, while those who loathed it quietly approve. + _applyWarPopularity(actor, target) { + for (let observer = 0; observer < this.civilisations.length; observer++) { + const stance = this.getGovOpinion(observer, target); + const delta = -WAR_DECLARATION.penalty * (stance - OPINION.baseline) * 2; + if (delta !== 0) this._shiftGovOpinion(observer, actor, delta); + } + this._touchPolitics(); + }, + + // A minority that despises its government and adores the government of its + // own ethnicity takes up arms for the latter. + _tickUprisings() { + for (let civ = 0; civ < this.civilisations.length; civ++) { + const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []); + let total = 0; + for (const count of counts.values()) total += count; + if (total <= 0) continue; + for (const [eth, count] of counts) { + if (eth === civ || eth < 0) continue; + if (count / total < REBELLION.populationShareMin) continue; + if (this.getGovOpinion(eth, civ) >= REBELLION.approvalMax) continue; + if (this.getGovOpinion(eth, eth) <= REBELLION.championApprovalMin) continue; + const cooldownKey = `${civ}:${eth}`; + if ((this._rebellionCooldown.get(cooldownKey) || 0) > this.totalHours) continue; + if (this._rebelUnitCount(eth) >= REBELLION.maxUnitsPerEthnicity) continue; + const coords = this._bestRebelTile(civ, eth); + if (!coords) continue; + const unit = this._spawnUnit(coords, eth, this.protoUnits[0]); + unit.rebel = true; + this._rebellionCooldown.set( + cooldownKey, + this.totalHours + REBELLION.cooldownDays * HOURS_PER_DAY + ); + this._visibilityDirty = true; + } + } + }, + + _rebelUnitCount(eth) { + let count = 0; + for (const unit of this.units) { + if (unit.civ === eth && unit.rebel) count += 1; + } + return count; + }, + + // The tile where a rebellion would be strongest: the one inside `civ`'s + // territory with the largest share of the aggrieved ethnicity. + _bestRebelTile(civ, eth) { + let best = null; + let bestShare = REBELLION.populationShareMin; + for (const coords of this._territoryByCiv.get(civ) || []) { + const k = key(coords.x, coords.y); + const shares = this.tileEthnicity.get(k); + const share = shares ? shares.get(eth) || 0 : 0; + if (share > bestShare) { + bestShare = share; + best = coords; + } + } + return best; + }, + + // ------------------------------------------------- culture impact -- + + // A nation's hourly culture production, from its buildings, government and + // research. + getCultureOutput(civ) { + return this.getCivModifiers(civ)[EFFECT_CULTURE] || 0; + }, + + getWorldCultureOutput() { + let total = 0; + for (let civ = 0; civ < this.civilisations.length; civ++) { + total += this.getCultureOutput(civ); + } + return total; + }, + + // A nation's share of the world's cultural output: by construction the shares + // of every nation add up to 1. + getCulturalImpact(civ) { + const total = this.getWorldCultureOutput(); + if (total <= 0) return 0; + return this.getCultureOutput(civ) / total; + }, + + // A culturally dominant nation slowly wins converts to its government across + // the planet. + _tickCultureImpact() { + const civCount = this.civilisations.length; + if (civCount === 0) return; + const total = this.getWorldCultureOutput(); + if (total <= 0) return; + const average = 1 / civCount; + for (let civ = 0; civ < civCount; civ++) { + const impact = this.getCultureOutput(civ) / total; + if (impact <= average) continue; + const gain = CULTURE_IMPACT.dailyPopularityGain * ((impact - average) / average); + for (let observer = 0; observer < civCount; observer++) { + this._shiftGovOpinion(observer, civ, gain); + } + } + }, +}; + +function isKnownPolicy(policyId) { + return ( + policyId === POLICY_CHAMPION || + policyId === POLICY_PROTECT || + policyId === POLICY_EXPEL || + policyId === POLICY_IMMIGRATION + ); +} + +function clampOpinion(value) { + if (value < OPINION.min) return OPINION.min; + if (value > OPINION.max) return OPINION.max; + return value; +} + +function normalizeShares(shares) { + let total = 0; + for (const share of shares.values()) total += share; + if (total <= 0) return shares; + const result = new Map(); + for (const [ethnicity, share] of shares) { + if (share <= 0) continue; + result.set(ethnicity, share / total); + } + return result; +} diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index 1216fd1..416a6c0 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -2,6 +2,7 @@ // each viewer, with fog-of-war applied per civilisation. import { parseKey } from "../hex.js"; +import { OPINION } from "../data/politics.js"; import { serializeSet } from "./helpers.js"; export const serializationMethods = { @@ -42,6 +43,23 @@ export const serializationMethods = { budgets: this._serializeCivValues(this.budgets), research: this._serializeCivValues(this.research), culture: this._serializeCivValues(this.culture), + // Each nation's share of the world's cultural output (zero-sum) and the + // people's approval of every government. + cultureImpact: this._serializeCultureImpact(), + approval: this._serializeApproval(), + // The ethnic makeup of every populated tile and the policies and + // propaganda campaigns in play. + tileEthnicity: this._serializeTileEthnicity(), + policies: this._serializePolicies(), + campaigns: this._serializeCampaigns(), + // The people who moved in and out of each country during the last day. + migrations: this._serializeMigrations(), + // Every population's opinion of every other population and government, + // so the client can show and target the propaganda. + opinions: { + ethnic: this._serializeOpinionMatrix(this.ethnicOpinions), + government: this._serializeGovOpinionMatrix(), + }, government: this._serializeCivValues(this.government), researched: this._serializeResearched(), diplomacy: this._serializeDiplomacy(), @@ -71,6 +89,7 @@ export const serializationMethods = { return { population: 0, gdp: 0, gdpPerCapita: 0, budget: 0, upkeep: 0, research: 0, culture: 0, government: 0, + approval: 0.5, cultureImpact: 0, ethnicMakeup: [], breakdown: null, }; } @@ -82,6 +101,9 @@ export const serializationMethods = { research: this.getResearch(viewerCiv), culture: this.getCulture(viewerCiv), government: this.getGovernment(viewerCiv), + approval: this.getCivApproval(viewerCiv), + cultureImpact: this.getCulturalImpact(viewerCiv), + ethnicMakeup: this.getCivEthnicMakeup(viewerCiv), breakdown: this.budgetBreakdown(viewerCiv), }; }, @@ -187,7 +209,11 @@ export const serializationMethods = { // One entry per civilisation with its headline economy figures, so the // diplomacy panel can show every nation's standing without a second lookup. _serializeCivStats() { - return this.civilisations.map((_, index) => this._civHeadline(index)); + return this.civilisations.map((_, index) => ({ + ...this._civHeadline(index), + approval: this.getCivApproval(index), + cultureImpact: this.getCulturalImpact(index), + })); }, // One entry per city with its region's population, GDP and tax, so the city @@ -203,10 +229,13 @@ export const serializationMethods = { population: Math.round(economy.population), gdp: economy.gdp, base: economy.base, - modifier: economy.modifier, + modifier: economy.base > 0 ? economy.income / economy.base - 1 : 0, + approval: economy.approval, income: economy.income, // The region's own income and building upkeep, for the city budget tab. budget: this.cityBudgetBreakdown(city), + // The ethnicity of the region's people, for the circle graph. + ethnicMakeup: this.getCityEthnicMakeup(city), }; }); }, @@ -309,4 +338,138 @@ export const serializationMethods = { } return civs.includes(owner) ? owner : civs[0]; }, + + // --------------------------------------------------------- politics -- + + // Every nation's share of world culture, as a fraction per civilisation. + _serializeCultureImpact() { + return this.civilisations.map((_, index) => + roundTo(this.getCulturalImpact(index), 4) + ); + }, + + // approval[a][b] is the share of civilisation a's people who approve of + // civilisation b's government. The diagonal is each nation's own standing. + _serializeApproval() { + const result = []; + for (let a = 0; a < this.civilisations.length; a++) { + const row = []; + for (let b = 0; b < this.civilisations.length; b++) { + row.push(roundTo(this._weightedApproval(this._territoryByCiv.get(a) || [], b), 3)); + } + result.push(row); + } + return result; + }, + + // Sparse [x, y, [[ethnicity, share, trend], ...]] for every populated tile, + // where trend is the net migration over the last day. + _serializeTileEthnicity() { + const result = []; + for (const [k, shares] of this.tileEthnicity) { + const coords = parseKey(k); + const entries = []; + for (const [ethnicity, share] of shares) { + if (share < 0.005) continue; + entries.push([ + ethnicity, + roundTo(share, 3), + Math.round(this._trendAt(this._migrationDeltas.tile, k, ethnicity)), + ]); + } + if (entries.length === 0) continue; + result.push([coords.x, coords.y, entries]); + } + return result; + }, + + _serializePolicies() { + const result = []; + for (let civ = 0; civ < this.civilisations.length; civ++) { + const record = this.policies.get(civ) || []; + result.push({ + civ, + list: record.map((policy) => ({ + id: policy.id, + type: policy.type, + ethnicity: policy.ethnicity, + })), + }); + } + return result; + }, + + _serializeCampaigns() { + return this.campaigns.map((campaign) => ({ + id: campaign.id, + civ: campaign.civ, + observer: campaign.observer, + targetKind: campaign.targetKind, + target: campaign.target, + direction: campaign.direction, + hourlyCost: campaign.hourlyCost, + startedHours: campaign.startedHours, + })); + }, + + // The last day's migration into and out of each country, with each flow as + // [ethnicity, people]. + _serializeMigrations() { + const result = []; + for (let civ = 0; civ < this.civilisations.length; civ++) { + const flow = this.migrations.get(civ); + result.push({ + civ, + inbound: mapToEntries(flow ? flow.inbound : null), + outbound: mapToEntries(flow ? flow.outbound : null), + }); + } + return result; + }, + + // A dense observer x target matrix. A missing entry is the neutral baseline. + _serializeOpinionMatrix(store) { + const result = []; + for (let observer = 0; observer < this.civilisations.length; observer++) { + const inner = store.get(observer); + const row = []; + for (let target = 0; target < this.civilisations.length; target++) { + row.push(roundTo(inner && inner.has(target) ? inner.get(target) : OPINION.baseline, 3)); + } + result.push(row); + } + return result; + }, + + // The government matrix folds each war crime grudge into the base opinion. + _serializeGovOpinionMatrix() { + const result = []; + for (let observer = 0; observer < this.civilisations.length; observer++) { + const row = []; + for (let target = 0; target < this.civilisations.length; target++) { + row.push(roundTo(this.getGovOpinion(observer, target), 3)); + } + result.push(row); + } + return result; + }, }; + +function roundTo(value, digits) { + const factor = Math.pow(10, digits); + return Math.round(value * factor) / factor; +} + +// A Map as sorted [ethnicity, roundedNumber] pairs, dropping +// zero entries. +function mapToEntries(map) { + if (!map) return []; + const result = []; + for (const [ethnicity, value] of map) { + const rounded = Math.round(value); + if (rounded === 0) continue; + result.push([ethnicity, rounded]); + } + result.sort((a, b) => b[1] - a[1]); + return result; +} diff --git a/shared/game_state/world.js b/shared/game_state/world.js index a11ce12..b0ff2c2 100644 --- a/shared/game_state/world.js +++ b/shared/game_state/world.js @@ -94,6 +94,8 @@ export const worldMethods = { } this._recomputeTerritory(); this._generatePopulation(); + this._initPolitics(); + this._generateEthnicMakeup(); this._balanceStartingEconomy(); // Budgets are founded once the balanced economy is known, so every nation // opens on 1 January 2000 with a tenth of its own starting GDP. diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index 878807e..bfbc4e1 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -971,6 +971,218 @@ export class GameScreenTest extends TestCase { teardownDom(env); } } + + async test_politics_tab_sends_campaign_and_policy_orders() { + const env = await setupDom(); + try { + const network = stubNetwork(); + const screen = new GameScreen(network, makeConfig()); + screen.enter(); + screen.onState(smallState().snapshot(0)); + + env.$("#civ-flag").click(); + env.$("#modal-nation .tab[data-tab='politics']").click(); + this.assertTrue(env.$("#tab-politics").hasClass("active")); + this.assertGreater(env.$("#politics-list .politics-card").length, 0); + + const $start = env.$("#tab-politics button").filter( + (index, element) => env.$(element).text() === "Start campaign" + ); + this.assertSize($start, 1); + $start.trigger("click"); + const campaign = network.orders.find((order) => order.type === "campaign"); + this.assertNotNull(campaign); + this.assertEqual(campaign.observer, 0); + this.assertEqual(campaign.targetKind, "government"); + this.assertEqual(campaign.target, 1); + + this.assertTrue(env.$("#tab-politics .politics-wizard").hasClass("hidden")); + const $new = env.$("#tab-politics button").filter( + (index, element) => env.$(element).text() === "New minority policy" + ); + this.assertSize($new, 1); + $new.trigger("click"); + this.assertFalse(env.$("#tab-politics .politics-wizard").hasClass("hidden")); + const $enactNow = env.$("#tab-politics button").filter( + (index, element) => env.$(element).text() === "Enact policy" + ); + this.assertSize($enactNow, 1); + $enactNow.trigger("click"); + const policy = network.orders.find((order) => order.type === "policy"); + this.assertNotNull(policy); + this.assertEqual(policy.policy, "champion"); + this.assertEqual(policy.ethnicity, 1); + + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_politics_tab_survives_snapshot_refresh() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), makeConfig()); + screen.enter(); + const state = smallState(); + screen.onState(state.snapshot(0)); + env.$("#civ-flag").click(); + env.$("#modal-nation .tab[data-tab='politics']").click(); + + env + .$("#tab-politics button") + .filter((index, element) => env.$(element).text() === "New minority policy") + .trigger("click"); + const $selects = env.$("#tab-politics .politics-wizard select"); + $selects.eq(0).val("expel").trigger("change"); + $selects.eq(1).val("1").trigger("change"); + + // A snapshot arrives while the panel is open: the wizard and its choices + // must not be torn down. + screen.onState(state.snapshot(0)); + + this.assertFalse( + env.$("#tab-politics .politics-wizard").hasClass("hidden"), + "the wizard stays open" + ); + this.assertEqual(env.$("#tab-politics .politics-wizard select").eq(0).val(), "expel"); + this.assertEqual(env.$("#tab-politics .politics-wizard select").eq(1).val(), "1"); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_politics_cancel_campaign_and_duplicate_guard() { + const env = await setupDom(); + try { + const network = stubNetwork(); + const screen = new GameScreen(network, makeConfig()); + screen.enter(); + screen.onState(smallState().snapshot(0)); + env.$("#civ-flag").click(); + env.$("#modal-nation .tab[data-tab='politics']").click(); + + // Inject a running campaign owned by the player. + const state = smallState(); + state.budgets.set(0, 1.0e15); + state.requestCampaign(0, 0, "government", 1, 1); + screen.onState(state.snapshot(0)); + + const $cancel = env.$("#tab-politics button").filter( + (index, element) => env.$(element).text() === "Cancel campaign" + ); + this.assertSize($cancel, 1); + $cancel.trigger("click"); + this.assertEqual(network.orders[network.orders.length - 1].type, "cancel_campaign"); + + // The launcher now refuses to duplicate the running campaign. + const $start = env.$("#tab-politics button").filter( + (index, element) => env.$(element).text() === "Start campaign" + ); + this.assertTrue($start.prop("disabled"), "duplicate campaign blocked"); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_politics_campaign_spending_controls() { + const env = await setupDom(); + try { + const network = stubNetwork(); + const screen = new GameScreen(network, makeConfig()); + screen.enter(); + screen.onState(smallState().snapshot(0)); + env.$("#civ-flag").click(); + env.$("#modal-nation .tab[data-tab='politics']").click(); + + const state = smallState(); + state.budgets.set(0, 1.0e15); + state.requestCampaign(0, 0, "government", 1, 1); + screen.onState(state.snapshot(0)); + + const $slider = env.$("#tab-politics .politics-slider"); + this.assertSize($slider, 1); + $slider.val(20000).trigger("change"); + const spending = network.orders.find((order) => order.type === "campaign_spending"); + this.assertNotNull(spending); + this.assertEqual(spending.amount, 20000); + + // Pushing it to zero asks first, and only stops on confirmation. + network.orders.length = 0; + $slider.val(0).trigger("change"); + this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "confirmation asked"); + this.assertNull( + network.orders.find((order) => order.type === "cancel_campaign"), + "nothing sent before confirming" + ); + env.$("#confirm-ok").click(); + this.assertNotNull(network.orders.find((order) => order.type === "cancel_campaign")); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_politics_opinion_breakdown() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), makeConfig()); + screen.enter(); + screen.onState(smallState().snapshot(0)); + env.$("#civ-flag").click(); + env.$("#modal-nation .tab[data-tab='politics']").click(); + this.assertGreater(env.$("#tab-politics .opinion-row").length, 0); + const headings = env.$("#tab-politics .opinion-heading"); + this.assertEqual(headings.eq(0).text(), "Governments"); + this.assertEqual(headings.eq(1).text(), "Peoples"); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_tile_detail_shows_ethnic_makeup() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), makeConfig()); + screen.enter(); + const state = smallState(); + screen.onState(state.snapshot(0)); + const k = Array.from(state.tilePopulation.keys()).find((key) => { + const parts = key.split(",").map(Number); + return state._isLand({ x: parts[0], y: parts[1] }); + }); + const parts = k.split(",").map(Number); + screen._showTile({ x: parts[0], y: parts[1] }); + this.assertGreater(env.$("#tile-ethnicity .ethnic-legend-row").length, 0); + this.assertTrue(env.$("#tile-ethnicity .ethnic-legend-row").first().text().includes("(")); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_city_people_tab_shows_ethnic_makeup() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), makeConfig()); + screen.enter(); + const state = smallState(); + screen.onState(state.snapshot(0)); + const capital = state.cities.find((city) => city.civ === 0 && city.isCapital); + screen.map.onCitySelected(capital.id); + env.$("#city-manage").click(); + env.$("#modal-city .tab[data-tab='people']").click(); + this.assertTrue(env.$("#city-tab-people").hasClass("active")); + this.assertGreater(env.$("#city-people-list .ethnic-chart").length, 0); + this.assertGreater(env.$("#city-people-list .ethnic-legend-row").length, 0); + screen.leave(); + } finally { + teardownDom(env); + } + } } function findEnemyTile(state) { diff --git a/tests/politics_test.js b/tests/politics_test.js new file mode 100644 index 0000000..cb0eb33 --- /dev/null +++ b/tests/politics_test.js @@ -0,0 +1,430 @@ +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_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_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; +}