Files
Battle-for-Tismo/shared/game_state/politics.js
T
adrien d5305da449 Gave the world a simple economy: one pool per nation and a global market
The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days.

Money is euro only: a per-inhabitant daily tax funds a treasury spent in a global market, where buy and sell lots trade against the world stock and move its price. The hard economy keeps its per-nation currencies, central banks and exchange rates.

Simple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy.
2026-09-25 14:58:49 +02:00

1504 lines
60 KiB
JavaScript

// 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,
POPULARITY,
} from "../data/politics.js";
import { NEWS_POLICY } from "../data/relations.js";
import { EFFECT_CULTURE } from "../data/effects.js";
import { BUILDING_MECHANIC } from "../data/buildings.js";
import { popularityDisplay, popularityPoints } from "../rules.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;
// How many past days the city-to-city migration graph sums, so the population
// map draws the flow over the last month rather than a single day.
const MIGRATION_GRAPH_DAYS = 30;
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, []);
}
// City-to-city migration for the population map: the current day's flows
// keyed "fromCityId:toCityId", the completed days behind them (a rolling
// month), and a version that lets a unchanged graph be delta-dropped.
this._migrationDayFlows = new Map();
this._migrationFlowDays = [];
this._migrationFlowVersion = 0;
this._serializedMigrationGraphCache = null;
},
_tickPolitics() {
this._tickCampaigns();
// One day's out-migration budget per tile, shared by every mechanism that
// moves people below, so no tile sheds more than its daily share.
this._migrationOutDay = new Map();
try {
this._tickMigration();
this._migrateForIncome();
} finally {
this._migrationOutDay = null;
}
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));
}
this._ethnicityVersion += 1;
},
// 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));
this._ethnicityVersion += 1;
},
// 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 stamp = `${this._ethnicityVersion}:${this._populationVersion}:${this._territoryVersion}`;
const cached = this._civEthnicMakeupCache.get(civ);
if (cached && cached.stamp === stamp && cached.deltas === this._migrationDeltas) {
return cached.value;
}
const scale = this._populationBalance.get(civ) || 1;
const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []);
const value = this._makeupList(counts, this._migrationDeltas.civ.get(civ), scale);
this._civEthnicMakeupCache.set(civ, { stamp, deltas: this._migrationDeltas, value });
return value;
},
// One ethnicity's share of a civilisation's whole population, 0 when it has
// no presence there. Used to bound expulsions.
getCivEthnicShare(civ, ethnicity) {
const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []);
let total = 0;
let target = 0;
for (const [eth, count] of counts) {
total += count;
if (eth === ethnicity) target += count;
}
return total > 0 ? target / total : 0;
},
// The ethnic makeup of one city's region. Like the national figure it is
// rebuilt only when the population, ethnicity or territory moves, so the
// region walk is not repeated on every snapshot the server broadcasts.
getCityEthnicMakeup(city) {
const stamp = `${this._ethnicityVersion}:${this._populationVersion}:${this._territoryVersion}:${this._regionVersion}`;
const cached = this._cityEthnicMakeupCache.get(city.id);
if (cached && cached.stamp === stamp && cached.deltas === this._migrationDeltas) {
return cached.value;
}
const scale = this._populationBalance.get(city.civ) || 1;
const counts = this._ethnicCountsOver(this.regionTiles(city));
const value = this._makeupList(counts, this._migrationDeltas.city.get(city.id), scale);
this._cityEthnicMakeupCache.set(city.id, { stamp, deltas: this._migrationDeltas, value });
return value;
},
_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 population's standing with a government, from the opinion shifts applied
// to it, with no war-crime memory. Popularity and approval read this: a war
// crime moves them when it happens, then they drift back like any other shift.
getBaseGovOpinion(observer, targetCiv) {
if (observer < 0 || targetCiv < 0) return OPINION.baseline;
const inner = this.govOpinions.get(observer);
return inner && inner.has(targetCiv) ? inner.get(targetCiv) : OPINION.baseline;
},
// A government's standing with one population for diplomacy: its base opinion
// plus any war crime the government has not been forgiven for. Grudges are a
// diplomatic memory, so they are deliberately kept out of approval.
getGovOpinion(observer, targetCiv) {
if (observer < 0 || targetCiv < 0) return OPINION.baseline;
return clampOpinion(
this.getBaseGovOpinion(observer, targetCiv) + 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);
}
},
// Opinions slide one point an hour back toward neutral, so the rate is a
// constant 1 point/h and the time to neutrality is simply the points left.
_tickOpinionDrift() {
const step = POPULARITY.driftPerHour * POPULARITY.opinionPerPoint;
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 opinion of its government, in one pass: the linear
// population-weighted approval (which drives tax and construction) and the
// same figure in integer popularity points (what the player is shown).
getCityOpinions(city) {
const version = this._politicsVersion;
const cached = this._approvalCache.get(city.id);
if (cached && cached.version === version) return cached;
const value = this._weightedOpinions(this.regionTiles(city), city.civ);
const record = { version, ...value };
this._approvalCache.set(city.id, record);
return record;
},
// A nation's opinion figures among its own people. Memoised against the
// politics version, like getCityOpinions, since the snapshot reads it for
// every nation on every broadcast.
getCivOpinions(civ) {
const version = this._politicsVersion;
const cached = this._civApprovalCache.get(civ);
if (cached && cached.version === version) return cached;
const value = this._weightedOpinions(this._territoryByCiv.get(civ) || [], civ);
const record = { version, ...value };
this._civApprovalCache.set(civ, record);
return record;
},
getCityApproval(city) {
return this.getCityOpinions(city).approval;
},
getCivApproval(civ) {
return this.getCivOpinions(civ).approval;
},
getCivPopularity(civ) {
return this.getCivOpinions(civ).popularity;
},
// What the player sees, in 0..1: the popularity points through the logistic.
getCivPopularityDisplay(civ) {
return popularityDisplay(this.getCivPopularity(civ));
},
_weightedOpinions(coordsList, government) {
let total = 0;
let points = 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;
// Popularity is the base opinion, not the diplomatic memory: a war crime
// is a one-off hit, not a generation-long tax on approval.
const opinion = this.getBaseGovOpinion(eth, government);
total += w * opinion;
points += w * popularityPoints(opinion);
weight += w;
}
}
if (weight <= 0) return { approval: OPINION.baseline, popularity: 0 };
return { approval: total / weight, popularity: Math.round(points / weight) };
},
// 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: this._campaignDefaultCost(),
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, this._campaignMaxCost());
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 }];
},
// How hard a campaign lands relative to its default spend. The hard economy
// prices a campaign in money; the simple economy overrides the two cost hooks
// to price it in culture, so the opinion shift tracks whichever currency
// funds it.
_campaignSpendFactor(campaign) {
const base = this._campaignDefaultCost();
return base > 0 ? campaign.hourlyCost / base : 0;
},
// The hourly spend a new campaign opens at, and the ceiling it may be raised
// to. The hard model's figures are money; the simple model overrides these.
_campaignDefaultCost() {
return PROPAGANDA.defaultHourlyCost;
},
_campaignMaxCost() {
return PROPAGANDA.maxHourlyCost;
},
// 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. The shift also shrinks as the target opinion
// nears its ceiling or floor, so the last percentage points are the hardest
// to win.
_tickCampaigns() {
if (this.campaigns.length === 0) return;
for (const campaign of this.campaigns) {
const spendFactor = this._campaignSpendFactor(campaign);
const delta =
campaign.direction *
campaign.dailyDelta *
spendFactor *
this._campaignEffectiveness(campaign) *
campaignHeadroomFactor(campaign.direction, this._campaignOpinion(campaign));
if (campaign.targetKind === CAMPAIGN_GOVERNMENT) {
this._shiftGovOpinion(campaign.observer, campaign.target, delta);
} else {
this._shiftEthnicOpinion(campaign.observer, campaign.target, delta);
}
}
},
// The opinion a campaign is trying to move: a government's standing with the
// observer for a government campaign, the observer's view of the target
// people for an ethnicity campaign.
_campaignOpinion(campaign) {
if (campaign.targetKind === CAMPAIGN_GOVERNMENT) {
return this.getGovOpinion(campaign.observer, campaign.target);
}
return this.getEthnicOpinion(campaign.observer, campaign.target);
},
// 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;
// A minority that makes up more than a tenth of the country cannot simply
// be expelled: its people are too many to drive out.
if (policyId === POLICY_EXPEL && this.getCivEthnicShare(civ, ethnicity) > 0.1) {
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() {
// Close out the day just ended: its city-to-city flows join the rolling
// month the population map reads, and a fresh day starts empty.
this._rollMigrationFlows();
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);
}
}
}
this._migrateByAir();
},
// Whether migration of `eth` from one country into `toCiv` is forbidden:
// people never move into a country they are at war with, and a nation that
// expels a people does not let them back in.
_migrationBlocked(fromCiv, toCiv, eth) {
if (fromCiv >= 0 && toCiv >= 0 && fromCiv !== toCiv && this.isAtWar(fromCiv, toCiv)) {
return true;
}
return toCiv >= 0 && this._expelsEthnicity(toCiv, eth);
},
// Whether a nation has an active expel policy against a people.
_expelsEthnicity(civ, eth) {
const record = this.policies.get(civ);
if (!record) return false;
return record.some(
(policy) => policy.type === POLICY_EXPEL && policy.ethnicity === eth
);
},
// Clamps the `people` leaving one tile to the day's remaining out-migration
// budget and books what is spent. With no daily budget active (a direct call
// outside the tick) nothing is clamped.
_takeMigrationAllowance(fromK, people) {
if (!(people > 0)) return 0;
if (!this._migrationOutDay) return people;
const population = this.tilePopulation.get(fromK) || 0;
const cap = population * MIGRATION.maxDailyOutShare;
const used = this._migrationOutDay.get(fromK) || 0;
const allowed = Math.max(0, Math.min(people, cap - used));
if (allowed > 0) this._migrationOutDay.set(fromK, used + allowed);
return allowed;
},
// ------------------------------------------------- income migration --
// People drift from poorer regions to richer ones, but only to places clearly
// richer: a destination must be at least `incomeRatioBase` times the source's
// income per head, and the bar rises the longer the journey takes. A flight
// crosses the distance in a flat few hours, but each airport can only move so
// many people a day.
_migrateForIncome() {
if (this.cities.length < 2) return;
const income = this._regionIncomePerCapita(this.cities);
// Each airport's level and daily intake. The inner sweep reads them for
// every ordered pair, so they are gathered once here instead of walking
// every city's buildings again for each pair.
const airLevel = new Map();
const airBudget = new Map();
for (const city of this.cities) {
const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_IMMIGRATION);
airLevel.set(city.id, level);
if (level > 0) airBudget.set(city.id, MIGRATION.airPeoplePerDayPerLevel * level);
}
for (const from of this.cities) {
const fromIncome = income.get(from.id) || 0;
if (fromIncome <= 0) continue;
let source = this.regionPopulation(from) - MIGRATION.minRegionPopulation;
if (source <= 0) continue;
// The time bar only ever raises the requirement above the base ratio, so a
// region with no destination that much richer has nowhere to send anyone.
// Skip the route search entirely -- the common, balanced case.
const worthwhile = this.cities.some((other) =>
other !== from &&
(income.get(other.id) || 0) >= fromIncome * MIGRATION.incomeRatioBase
);
if (!worthwhile) continue;
const fromAirport = airLevel.get(from.id) || 0;
const travel = this._migrationTravel(from);
for (const to of this.cities) {
if (to === from || source <= 0) continue;
const toIncome = income.get(to.id) || 0;
if (toIncome <= 0) continue;
// Two nations at war do not trade people.
if (this.isAtWar(from.civ, to.civ)) continue;
const toAirport = airLevel.get(to.id) || 0;
const air = fromAirport > 0 && toAirport > 0;
const hours = air ? MIGRATION.airHours : travel.get(to.id);
if (hours === undefined) continue;
// The longer it takes, the richer the destination has to be.
const required = MIGRATION.incomeRatioBase * (1 + hours * MIGRATION.incomeTimePenalty);
const ratio = toIncome / fromIncome;
if (ratio < required) continue;
let amount;
if (air) {
const budget = airBudget.get(to.id) || 0;
amount = Math.min(budget, source);
if (amount <= 0) continue;
airBudget.set(to.id, budget - amount);
} else {
const rate = Math.min(
MIGRATION.incomeMaxSharePerDay,
MIGRATION.incomeSharePerDay * Math.min(ratio - required, MIGRATION.incomeGapCap)
);
amount = source * rate;
}
amount = Math.min(amount, source);
if (!(amount > 0)) continue;
this._movePopulation(from, to, amount);
source -= amount;
}
}
},
// The travel time in hours from one region to every city it can reach over the
// transport network, capped at `maxTravelHours`. Land is walked (roads and
// railways are faster), open water is crossed at the sea rate.
_migrationTravel(fromCity) {
// Travel times depend only on the transport network, so they are kept until
// a road, railway or port changes it.
if (!this._travelCache) this._travelCache = new Map();
const cached = this._travelCache.get(fromCity.id);
if (cached && cached.version === this._improvementVersion) return cached.result;
const hours = new Map();
const result = new Map();
const startKey = key(fromCity.coords.x, fromCity.coords.y);
hours.set(startKey, 0);
const queue = [fromCity.coords];
let head = 0;
while (head < queue.length) {
const coords = queue[head++];
const k = key(coords.x, coords.y);
const here = hours.get(k) || 0;
const city = this.cityAt(coords);
if (city && city !== fromCity) result.set(city.id, here);
if (here >= MIGRATION.maxTravelHours) continue;
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (hours.has(nk) || !this.tiles[nk]) continue;
const step = this._peopleHoursPerTile(neighbour);
if (!Number.isFinite(step)) continue;
hours.set(nk, here + step);
queue.push(neighbour);
}
}
this._travelCache.set(fromCity.id, { version: this._improvementVersion, result });
return result;
},
// What one tile costs a migrant in hours: open water is a flat sea rate, land
// is the terrain (or road/railway) multiplier over the walking speed.
_peopleHoursPerTile(coords) {
const tile = this.tiles[key(coords.x, coords.y)];
if (!tile) return Infinity;
if (tile.terrainClass === "Sea" || tile.terrainClass === "Ice") {
return MIGRATION.seaHoursPerTile;
}
return this._tileMovementMultiplier(coords, -1) / MIGRATION.peopleSpeed;
},
// The production per head of every region, as the economy shows it. The
// nation-wide balance is folded in here too, so two regions in different
// countries compare on the same footing. The walk is the day's region-by-
// region GDP rebuild, so its result is kept until a counter it reads moves:
// population, territory, the region map, buildings/ports (modifiers) or a
// GDP-figure regeneration (`_gdpEpoch`, bumped by the daily natural-growth
// reset and by any tile edit that drops the per-tile figure).
_regionIncomePerCapita(cities) {
const stamp = `${this._gdpEpoch}:${this._populationVersion}:${this._territoryVersion}` +
`:${this._regionVersion}:${this._modifiersVersion}`;
const cached = this._regionIncomeCache;
// The cache is the full-world map `_migrateForIncome` asks for; a caller
// passing a subset gets a fresh walk rather than a wrongly-narrowed one.
if (cities === this.cities && cached && cached.stamp === stamp) return cached.value;
const result = new Map();
for (const city of cities) {
const economy = this.getCityEconomy(city);
result.set(city.id, economy.population > 0 ? economy.gdp / economy.population : 0);
}
if (cities === this.cities) this._regionIncomeCache = { stamp, value: result };
return result;
},
// Moves `amount` people from one region to another, carrying their ethnic
// make-up with them. Source tiles lose people in proportion to what they hold
// and destination tiles gain the arrivals the same way, so a region's mix of
// terrain is preserved and only its density changes. Returns how many moved.
_movePopulation(fromCity, toCity, amount, record = true) {
if (!(amount > 0) || fromCity === toCity) return 0;
const source = this.regionTiles(fromCity)
.map((coords) => ({ coords, k: key(coords.x, coords.y) }))
.filter((entry) => (this.tilePopulation.get(entry.k) || 0) > 0);
const dest = this.regionTiles(toCity)
.map((coords) => ({ coords, k: key(coords.x, coords.y) }))
.filter((entry) => (this.tilePopulation.get(entry.k) || 0) > 0);
if (source.length === 0 || dest.length === 0) return 0;
const totalFrom = source.reduce((sum, entry) => sum + this.tilePopulation.get(entry.k), 0);
if (totalFrom <= 0) return 0;
let moved = Math.min(amount, totalFrom);
// No tile may shed more than its daily out-migration share, so the move is
// bounded by the budget still open across the source region.
if (this._migrationOutDay) {
let allowance = 0;
for (const entry of source) {
const people = this.tilePopulation.get(entry.k) || 0;
const cap = people * MIGRATION.maxDailyOutShare;
allowance += Math.max(0, cap - (this._migrationOutDay.get(entry.k) || 0));
}
if (moved > allowance) moved = allowance;
}
if (!(moved > 0)) return 0;
// The aggregate ethnic mix of the people leaving.
const mix = new Map();
for (const entry of source) {
const people = this.tilePopulation.get(entry.k) || 0;
const shares = this.tileEthnicity.get(entry.k);
if (!shares) continue;
for (const [eth, share] of shares) mix.set(eth, (mix.get(eth) || 0) + share * people);
}
const totalMix = Array.from(mix.values()).reduce((sum, value) => sum + value, 0) || 1;
for (const [eth, value] of mix) mix.set(eth, value / totalMix);
let populationChanged = false;
for (const entry of source) {
const people = this.tilePopulation.get(entry.k) || 0;
const leave = moved * people / totalFrom;
const after = people - leave;
if (after !== people) populationChanged = true;
this.tilePopulation.set(entry.k, after);
if (this._migrationOutDay && leave > 0) {
this._migrationOutDay.set(entry.k, (this._migrationOutDay.get(entry.k) || 0) + leave);
}
}
const totalTo = dest.reduce((sum, entry) => sum + this.tilePopulation.get(entry.k), 0);
for (const entry of dest) {
const people = this.tilePopulation.get(entry.k) || 0;
const add = totalTo > 0 ? moved * people / totalTo : moved / dest.length;
const after = people + add;
if (after !== people) populationChanged = true;
this.tilePopulation.set(entry.k, after);
const shares = new Map(this.tileEthnicity.get(entry.k) || []);
for (const [eth, share] of mix) {
const existing = shares.get(eth) || 0;
shares.set(eth, (existing * people + share * add) / after);
}
this._setTileEthnicFractions(parseKey(entry.k), shares);
}
// A move too small to change any tile's stored population still updates the
// ethnic mix, but it must not churn `_populationVersion` and every memo keyed
// on it (GDP included) for a no-op.
if (populationChanged) {
this._populationVersion += 1;
this._clearTileGdpCache();
}
// Keep each city's headline population in step with its city tile.
for (const city of [fromCity, toCity]) {
city.population = Math.round(
this.tilePopulation.get(key(city.coords.x, city.coords.y)) || 0
);
}
if (record) {
for (const [eth, share] of mix) {
const people = moved * share;
this._recordMigration(fromCity.civ, "out", eth, people);
this._recordMigration(toCity.civ, "in", eth, people);
this._trackMigration(
key(fromCity.coords.x, fromCity.coords.y),
key(toCity.coords.x, toCity.coords.y),
eth,
people
);
}
}
return moved;
},
// Start-of-game smoothing: within each nation, redistribute people so every
// region has the same production per head. A region's GDP does not depend on
// how many people live on its land -- food comes from the land and industry
// from its works -- so moving people in proportion leaves every region's GDP
// untouched and only changes the divisor. Equal income per head therefore
// means population proportional to GDP, which is one exact pass, no iteration.
_equalizeRegionIncomes() {
for (let civ = 0; civ < this.civilisations.length; civ++) {
const cities = this.cities.filter((city) => city.civ === civ);
if (cities.length < 2) continue;
// The population-proportional-to-GDP target is exact only while a region's
// GDP is independent of its people; flat per-head bonuses (ports, natural
// growth) make it depend a little, so a few passes settle it.
for (let pass = 0; pass < MIGRATION.equalizePasses; pass++) {
const population = new Map();
const gdp = new Map();
let totalPopulation = 0;
let totalGdp = 0;
let lowest = Infinity;
let highest = 0;
for (const city of cities) {
const people = this.regionPopulation(city);
const product = this.getCityEconomy(city).gdp;
population.set(city.id, people);
gdp.set(city.id, product);
totalPopulation += people;
totalGdp += product;
if (people > 0) {
const perHead = product / people;
if (perHead < lowest) lowest = perHead;
if (perHead > highest) highest = perHead;
}
}
if (totalGdp <= 0 || totalPopulation <= 0) break;
if (highest <= 0 || (highest - lowest) / highest <= MIGRATION.equalizeTolerance) {
break;
}
// The share of the nation's people each region should hold, with a floor
// so no region is emptied entirely.
let target = cities.map((city) => Math.max(
MIGRATION.minRegionPopulation,
totalPopulation * (gdp.get(city.id) || 0) / totalGdp
));
const targetTotal = target.reduce((sum, value) => sum + value, 0);
if (targetTotal > totalPopulation) {
target = target.map((value) => value * totalPopulation / targetTotal);
}
const targetByCity = new Map(cities.map((city, index) => [city.id, target[index]]));
this._redistributePopulation(cities, population, targetByCity);
}
}
this._clearTileGdpCache();
},
// Moves people from the regions holding more than their target to those
// holding less, so each ends at its target.
_redistributePopulation(cities, population, target) {
const surplus = [];
const deficit = [];
for (const city of cities) {
const delta = (population.get(city.id) || 0) - (target.get(city.id) || 0);
if (delta > 1) surplus.push({ city, amount: delta });
else if (delta < -1) deficit.push({ city, amount: -delta });
}
surplus.sort((a, b) => b.amount - a.amount);
deficit.sort((a, b) => b.amount - a.amount);
let index = 0;
for (const source of surplus) {
let remaining = source.amount;
while (remaining > 1 && index < deficit.length) {
const sink = deficit[index];
const moved = Math.min(remaining, sink.amount);
if (moved > 1) this._movePopulation(source.city, sink.city, moved, false);
remaining -= moved;
sink.amount -= moved;
if (sink.amount <= 1) index += 1;
}
}
},
// People fly between airport cities. Every city with an airport draws a small
// share of every other airport city's people, scaled by its own airport level,
// so a busy hub grows at the expense of quieter ones. The move ignores
// distance and borders: only cities with a runway are connected.
_migrateByAir() {
const airports = [];
for (const city of this.cities) {
const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_IMMIGRATION);
if (level > 0) airports.push({ city, level });
}
if (airports.length < 2) return;
for (const destination of airports) {
const toK = key(destination.city.coords.x, destination.city.coords.y);
if (!this.tileEthnicity.has(toK)) continue;
const share = MIGRATION.airportSharePerDay * destination.level;
if (share <= 0) continue;
for (const source of airports) {
if (source.city === destination.city) continue;
const fromK = key(source.city.coords.x, source.city.coords.y);
const fromShares = this.tileEthnicity.get(fromK);
if (!fromShares) continue;
for (const eth of Array.from(fromShares.keys())) {
const moved = this._transferEthnicity(fromK, toK, eth, share);
if (moved > 0) {
this._recordMigration(source.city.civ, "out", eth, moved);
this._recordMigration(destination.city.civ, "in", eth, moved);
}
}
}
}
},
// 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);
},
// Archives the day that just ended into the rolling month and opens a fresh
// day. Only the last `MIGRATION_GRAPH_DAYS` days are kept.
_rollMigrationFlows() {
this._migrationFlowDays.push(this._migrationDayFlows);
if (this._migrationFlowDays.length > MIGRATION_GRAPH_DAYS) {
this._migrationFlowDays.shift();
}
this._migrationDayFlows = new Map();
this._migrationFlowVersion += 1;
},
// Tallies one city-to-city movement for the migration graph. Same-city and
// unknown flows are ignored, so the graph only carries real journeys.
_addMigrationFlow(fromCityId, toCityId, amount) {
if (!(amount > 0) || fromCityId === toCityId) return;
if (fromCityId === undefined || toCityId === undefined || fromCityId < 0 || toCityId < 0) {
return;
}
const flowKey = `${fromCityId}:${toCityId}`;
this._migrationDayFlows.set(
flowKey,
(this._migrationDayFlows.get(flowKey) || 0) + amount
);
this._migrationFlowVersion += 1;
},
// The people who moved between each pair of cities over the rolling month,
// keyed "fromCityId:toCityId", as a Map. Summed fresh each time the graph is
// serialised; the memo on the version keeps that cheap.
_migrationFlowTotals() {
const totals = new Map();
const add = (flows) => {
for (const [flowKey, amount] of flows) {
totals.set(flowKey, (totals.get(flowKey) || 0) + amount);
}
};
for (const day of this._migrationFlowDays) add(day);
add(this._migrationDayFlows);
return totals;
},
// 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. Migration
// into a hostile country, or a people the destination expels, is refused, and
// the move is clamped to the tile's daily out-migration budget.
_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 fromCiv = this.territory.has(fromK) ? this.territory.get(fromK) : -1;
const toCiv = this.territory.has(toK) ? this.territory.get(toK) : -1;
if (this._migrationBlocked(fromCiv, toCiv, eth)) return 0;
let movedFraction = fromFraction * share;
let movedPeople = movedFraction * fromPopulation;
const allowed = this._takeMigrationAllowance(fromK, movedPeople);
if (!(allowed > 0)) return 0;
if (allowed < movedPeople) {
movedPeople = allowed;
movedFraction = movedPeople / fromPopulation;
}
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));
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);
if (fromCity && toCity) this._addMigrationFlow(fromCity.id, toCity.id, 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);
}
// The world's governments also simply lose standing over the atrocity.
this._applyPillagePopularity(aggressor, coords);
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. On top
// of that the aggressor's own people resent the war, and a people rallied by
// an unprovoked attack on a government it already loathes take heart.
_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._shiftOwnPopularity(actor, -POPULARITY.declareWar);
// A people already hostile to the aggressor rallies behind its own
// government when that aggressor attacks.
const aggressorStanding =
this._weightedOpinions(this._territoryByCiv.get(target) || [], actor).approval;
if (aggressorStanding < POPULARITY.declaredOnThreshold) {
this._shiftOwnPopularity(target, POPULARITY.declaredOnBoost);
}
this._touchPolitics();
},
// Move a government's standing with its own people by `points`.
_shiftOwnPopularity(civ, points) {
if (!this._validCiv(civ) || points === 0) return;
const delta = points * POPULARITY.opinionPerPoint;
const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []);
for (const eth of counts.keys()) this._shiftGovOpinion(eth, civ, delta);
},
// The world turns against an aggressor that pillages a tile, with the peoples
// directly affected feeling it twice as sharply. The popularity cost is an
// instant, one-off shift per pillage (it drifts back like any other opinion),
// not a grudge; only the long diplomatic memory is a grudge.
_applyPillagePopularity(aggressor, coords) {
if (!this._validCiv(aggressor)) return;
const amount = -POPULARITY.pillage * POPULARITY.opinionPerPoint;
for (let observer = 0; observer < this.civilisations.length; observer++) {
this._shiftGovOpinion(observer, aggressor, amount);
}
// The tile's dominant people carry the double debuff.
this._shiftGovOpinion(this._dominantEthnicity(coords), aggressor, amount);
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;
}
// The fraction of a campaign's full daily shift that applies given how far the
// opinion still is from its ceiling (warming) or floor (cooling). At or below
// the neutral midpoint the full rate applies; closing on the goal the rate
// falls away, raised to `difficultyExponent` so the last percent is very hard.
function campaignHeadroomFactor(direction, opinion) {
const room = direction > 0 ? OPINION.max - opinion : opinion - OPINION.min;
const neutralRoom = OPINION.max - OPINION.baseline;
if (!(neutralRoom > 0)) return 1;
const ratio = Math.max(0, Math.min(1, room / neutralRoom));
return Math.pow(ratio, PROPAGANDA.difficultyExponent || 1);
}
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;
}