Added politics: opinions, ethnicities, propaganda and minority policies

This commit is contained in:
2026-09-19 00:17:29 +02:00
parent cf75b31e68
commit 256b4d3468
22 changed files with 2844 additions and 15 deletions
+107 -1
View File
@@ -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); }
+11
View File
@@ -138,6 +138,7 @@
<div id="tile-owner"></div>
<div id="tile-terrain"></div>
<div id="tile-population"></div>
<div id="tile-ethnicity"></div>
<div id="tile-gdp"></div>
<div id="tile-improvements"></div>
</div>
@@ -184,6 +185,7 @@
<div class="tabs">
<button class="tab active" data-tab="train">Train</button>
<button class="tab" data-tab="buildings">Buildings</button>
<button class="tab" data-tab="people">People</button>
<button class="tab" data-tab="budget">Budget</button>
</div>
<div class="tab-page active" id="city-tab-train">
@@ -198,6 +200,10 @@
<div id="city-buildings-budget" class="modal-sub"></div>
<div id="city-buildings-list" class="modal-body"></div>
</div>
<div class="tab-page" id="city-tab-people">
<div id="city-people-sub" class="modal-sub"></div>
<div id="city-people-list" class="modal-body"></div>
</div>
<div class="tab-page" id="city-tab-budget">
<div id="city-budget-sub" class="modal-sub"></div>
<div id="city-budget-list" class="modal-body"></div>
@@ -242,6 +248,7 @@
<button class="tab active" data-tab="government">Government</button>
<button class="tab" data-tab="technology">Technology</button>
<button class="tab" data-tab="diplomacy">Diplomacy</button>
<button class="tab" data-tab="politics">Politics</button>
<button class="tab" data-tab="cities">Cities</button>
<button class="tab" data-tab="conflicts">Ongoing conflicts</button>
<button class="tab" data-tab="budget">Budget</button>
@@ -258,6 +265,10 @@
<div id="diplomacy-sub" class="modal-sub"></div>
<div id="diplomacy-list" class="modal-body"></div>
</div>
<div class="tab-page" id="tab-politics">
<div id="politics-sub" class="modal-sub"></div>
<div id="politics-list" class="modal-body"></div>
</div>
<div class="tab-page" id="tab-cities">
<div id="cities-sub" class="modal-sub"></div>
<div id="cities-list" class="modal-body"></div>
+4
View File
@@ -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]])
);
+86 -3
View File
@@ -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($("<div class='info-title'></div>").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);
},
+7
View File
@@ -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 = $("<table class='budget-table'></table>");
+29
View File
@@ -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($("<div class='desc'></div>").text("No population data."));
}
if (taxMultiplier !== undefined && taxMultiplier < 0.999) {
this.$peopleList.append(
$("<div class='desc politics-penalty'></div>").text(
`Discontent: only ${Math.round(taxMultiplier * 100)}% of taxes are collected and ` +
"construction is slowed until approval recovers."
)
);
}
}
// -------------------------------------------------------------- training --
_renderTrainList(gdp, trainableUnits) {
+10
View File
@@ -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:
+529 -3
View File
@@ -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 = $("<table class='diplomacy-table'></table>");
@@ -302,6 +324,7 @@ export class NationModal {
$tr.append($("<td class='numeric'></td>").text(`€${compact(row.gdp)}`));
$tr.append($("<td class='numeric'></td>").text(`€${compact(row.gdpPerCapita)}`));
$tr.append($("<td class='numeric'></td>").text(compact(row.population)));
$tr.append($("<td class='numeric'></td>").text(`${Math.round(row.approval * 100)}%`));
const $status = $("<td class='status'></td>");
if (row.self) {
$status.append($("<span class='relation'></span>").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 = $("<div class='politics-card'></div>");
this.$politicsCampaigns = $("<div class='politics-card'></div>");
this.$politicsMigrations = $("<div class='politics-card'></div>");
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($("<div class='name'></div>").text("Your people"));
const makeup = (info.viewerStats && info.viewerStats.ethnicMakeup) || [];
if (makeup.length > 0) this.$politicsMakeup.append(ethnicityChart(makeup, civs));
else this.$politicsMakeup.append($("<div class='desc'></div>").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($("<div class='name'></div>").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 = $("<div class='politics-row'></div>");
$row.append(
$("<div></div>").text(
`${this._civName(campaign.civ)}: ${verb} the ${observer} people's view of the ` +
`${target} ${onWhat}`
)
);
const $cost = $("<div class='politics-cost'></div>");
$row.append($cost);
const row = { $cost };
if (campaign.civ === localCiv) {
const $slider = $("<input type='range' class='politics-slider'>").attr({
min: 0,
max: PROPAGANDA.maxHourlyCost,
step: 1000,
value: campaign.hourlyCost,
});
const $spend = $("<span class='politics-readout'></span>").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 = $("<div class='politics-spend'></div>").append(
$("<span class='politics-label'></span>").text("Spending"),
$slider,
$spend
);
$row.append($controls);
row.$slider = $slider;
row.$spend = $spend;
const $cancel = $("<button class='aero'></button>").text("Cancel campaign");
$cancel.on("click", () => this.onCancelCampaign(campaign.id));
$row.append($("<div class='politics-row-actions'></div>").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 = $("<div class='politics-card'></div>");
$card.append($("<div class='name'></div>").text("Launch a propaganda campaign"));
$card.append(
$("<div class='desc'></div>").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(
$("<div class='desc politics-cost'></div>").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 = $("<select class='politics-select'></select>").append(
$("<option value='government'></option>").text("their government"),
$("<option value='ethnicity'></option>").text("their population")
);
$kind.val(campaign.kind);
const $target = this._civSelect(civs, campaign.target);
const $direction = $("<select class='politics-select'></select>").append(
$("<option value='1'></option>").text("Warm opinion"),
$("<option value='-1'></option>").text("Cool opinion")
);
$direction.val(String(campaign.direction));
const $readout = $("<span class='politics-readout'></span>");
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 = $("<div class='politics-controls'></div>");
$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 = $("<button class='aero'></button>").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 = $("<div class='politics-estimate politics-penalty'></div>");
$controls.append($("<div class='politics-field'></div>").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 = $("<div class='politics-card'></div>");
$card.append($("<div class='name'></div>").text("Minority policies"));
$card.append(
$("<div class='desc'></div>").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 = $("<div></div>");
$card.append(this.$politicsActive);
this.$politicsNew = $("<button class='aero politics-new'></button>").text(
"New minority policy"
);
this.$politicsWizard = $("<div class='politics-wizard hidden'></div>");
this.$politicsNew.on("click", () => {
this._politicsWizardOpen = !this._politicsWizardOpen;
this.$politicsWizard.toggleClass("hidden", !this._politicsWizardOpen);
});
const policy = this._politicsState.policy;
const $type = $("<select class='politics-select'></select>");
for (const proto of POLICIES) {
$type.append($("<option></option>").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 = $("<div class='politics-estimate'></div>");
this.$policyCost = $("<div class='politics-estimate politics-cost'></div>");
this.$policyDuplicate = $("<div class='politics-estimate politics-penalty'></div>");
this.$policyEnact = $("<button class='aero'></button>").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 = $("<div class='politics-controls'></div>");
$controls.append(this._labelled("Policy", $type));
$controls.append(this._labelled("Ethnicity", $eth));
$controls.append($("<div class='politics-field'></div>").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(
$("<div class='desc'></div>").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 = $("<div class='politics-row politics-policy-row'></div>");
$row.append($("<span></span>").text(`${proto ? proto.name : policy.type} — ${people}`));
const $revoke = $("<button class='aero'></button>").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(
$("<div class='name'></div>").text("Migration (last day)")
);
const describe = (flows, sign) => {
for (const [ethnicity, amount] of flows) {
this.$politicsMigrations.append(
$("<div class='politics-row'></div>").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 = $("<div class='politics-card'></div>");
$card.append($("<div class='name'></div>").text("Opinions by population"));
$card.append(
$("<div class='desc'></div>").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 = $("<div class='opinion-list'></div>");
const $controls = $("<div class='politics-controls'></div>");
$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($("<div class='opinion-heading'></div>").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($("<div class='opinion-heading'></div>").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 = $("<div class='opinion-fill'></div>")
.css("width", `${pct}%`)
.toggleClass("negative", pct < 50);
const $bar = $("<div class='opinion-bar'></div>").append($fill);
return $("<div class='opinion-row'></div>")
.append($("<span class='opinion-name'></span>").text(label))
.append($bar)
.append($("<span class='opinion-pct'></span>").text(`${pct}%`));
}
_civSelect(civs, selected) {
const $select = $("<select class='politics-select'></select>");
civs.forEach((civ, index) => {
const label = civ.demonym ? `The ${civ.demonym}` : civ.name;
const $option = $("<option></option>").attr("value", index).text(label);
if (index === selected) $option.prop("selected", true);
$select.append($option);
});
return $select;
}
_labelled(label, $control) {
return $("<label class='politics-field'></label>")
.append($("<span class='politics-label'></span>").text(label))
.append($control);
}
close() {
this.$modal.addClass("hidden");
}
+115
View File
@@ -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;
}
+43
View File
@@ -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.
+1
View File
@@ -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";
+155
View File
@@ -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,
};
+2
View File
@@ -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";
+25
View File
@@ -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<ethnicity, people>.
this.migrations = new Map();
// Signed migration change over the last day, per scope: tile, city and
// civ, each a Map<key, Map<ethnicity, people>>. 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
+3
View File
@@ -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;
},
+14 -4
View File
@@ -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);
+4 -2
View File
@@ -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;
},
+890
View File
@@ -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;
}
+165 -2
View File
@@ -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<ethnicity, number> 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;
}
+2
View File
@@ -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.
+212
View File
@@ -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) {
+430
View File
@@ -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;
}