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.
1504 lines
60 KiB
JavaScript
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;
|
|
}
|