Files
Battle-for-Tismo/shared/game_state/politics.js
T
adrien ede258d39f Added warfare depth, focused research and a reworked HUD
- Tile improvements (fortifications, radar towers, coastal cannons), trenches,
  zone of control, support-unit capture and launch transports, with parabolic
  barrage arcs drawn between attacker and target.
- Focused research that pours points straight into the chosen technology, with
  per-technology progress kept when the focus changes, plus repeatable levels.
- Popularity reworked to integer points, war and pillage effects, minority
  policies and month-over-month arrows on the nation summary.
- City progress bars, square improvement buttons, a technology tab of wide
  short cards with progress bars, transport cargo readout and double-click to
  open a city panel.
2026-09-21 01:07:36 +02:00

1013 lines
38 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 { 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;
export const politicsMethods = {
// ------------------------------------------------------- lifecycle --
// Seeds the per-civ policy list. The opinion stores start empty and fall
// back to the neutral baseline on read.
_initPolitics() {
this.policies = new Map();
for (let civ = 0; civ < this.civilisations.length; civ++) {
this.policies.set(civ, []);
}
},
_tickPolitics() {
this._tickCampaigns();
this._tickMigration();
this._tickUprisings();
this._tickCultureImpact();
this._expireGrudges();
},
_touchPolitics() {
this._politicsVersion += 1;
},
// ---------------------------------------------------- ethnic makeup --
// Lays a deterministic ethnic makeup over the generated population. Each
// tile's owner is its clear majority; nearby nations leave a minority that
// grows with proximity, crossing water as readily as land, so the French
// live in Britain just across the channel.
_generateEthnicMakeup() {
const civCount = this.civilisations.length;
this.tileEthnicity = new Map();
if (civCount === 0) return;
const distances = [];
for (let civ = 0; civ < civCount; civ++) {
distances.push(this._civEthnicDistance(civ));
}
for (const k of this.tilePopulation.keys()) {
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
const weights = new Map();
for (let civ = 0; civ < civCount; civ++) {
const distance = distances[civ].get(k);
let weight;
if (distance === undefined) {
// Every tile is always home to its owner's ethnicity, however far
// that nation's cities lie.
if (civ !== owner) continue;
weight = ETHNIC_OWNER_WEIGHT;
} else {
weight = 1 / (1 + ETHNIC_FALLOFF * distance * distance);
if (civ === owner) weight *= ETHNIC_OWNER_WEIGHT;
}
if (weight >= ETHNIC_MIN_SHARE) weights.set(civ, weight);
}
if (weights.size === 0) weights.set(owner >= 0 ? owner : 0, 1);
this.tileEthnicity.set(k, normalizeShares(weights));
}
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 scale = this._populationBalance.get(civ) || 1;
const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []);
return this._makeupList(counts, this._migrationDeltas.civ.get(civ), scale);
},
// 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.
getCityEthnicMakeup(city) {
const scale = this._populationBalance.get(city.civ) || 1;
const counts = this._ethnicCountsOver(this.regionTiles(city));
return this._makeupList(counts, this._migrationDeltas.city.get(city.id), scale);
},
_makeupList(counts, trend, scale = 1) {
let total = 0;
for (const count of counts.values()) total += count;
if (total <= 0) return [];
const result = [];
for (const [ethnicity, count] of counts) {
result.push({
ethnicity,
count: count * scale,
share: count / total,
trend: trend ? trend.get(ethnicity) || 0 : 0,
});
}
result.sort((a, b) => b.count - a.count);
return result;
},
// ------------------------------------------------------- opinions --
getEthnicOpinion(observer, target) {
if (observer < 0 || target < 0) return OPINION.baseline;
const inner = this.ethnicOpinions.get(observer);
const value = inner ? inner.get(target) : undefined;
return value === undefined ? OPINION.baseline : value;
},
// A government's standing with one population: its base opinion plus any war
// crime the government has not been forgiven for.
getGovOpinion(observer, targetCiv) {
if (observer < 0 || targetCiv < 0) return OPINION.baseline;
const inner = this.govOpinions.get(observer);
const base = inner && inner.has(targetCiv) ? inner.get(targetCiv) : OPINION.baseline;
return clampOpinion(base + this._grudgeTotal(observer, targetCiv));
},
_grudgeTotal(observer, targetCiv) {
const inner = this.grudges.get(observer);
if (!inner) return 0;
const grudge = inner.get(targetCiv);
if (!grudge || this.totalHours >= grudge.until) return 0;
return grudge.amount;
},
_shiftEthnicOpinion(observer, target, delta) {
if (observer < 0 || target < 0 || delta === 0) return;
if (!this.ethnicOpinions.has(observer)) this.ethnicOpinions.set(observer, new Map());
const inner = this.ethnicOpinions.get(observer);
const current = inner.has(target) ? inner.get(target) : OPINION.baseline;
inner.set(target, clampOpinion(current + delta));
this._touchPolitics();
},
_shiftGovOpinion(observer, targetCiv, delta) {
if (observer < 0 || targetCiv < 0 || delta === 0) return;
if (!this.govOpinions.has(observer)) this.govOpinions.set(observer, new Map());
const inner = this.govOpinions.get(observer);
const current = inner.has(targetCiv) ? inner.get(targetCiv) : OPINION.baseline;
inner.set(targetCiv, clampOpinion(current + delta));
this._touchPolitics();
},
_addGrudge(observer, aggressor, amount, until) {
if (observer < 0 || aggressor < 0 || amount === 0) return;
if (!this.grudges.has(observer)) this.grudges.set(observer, new Map());
const inner = this.grudges.get(observer);
const existing = inner.get(aggressor);
if (existing) {
existing.amount += amount;
existing.until = Math.max(existing.until, until);
} else {
inner.set(aggressor, { amount, until });
}
this._touchPolitics();
},
_expireGrudges() {
for (const [observer, inner] of this.grudges) {
for (const [aggressor, grudge] of inner) {
if (this.totalHours >= grudge.until) inner.delete(aggressor);
}
if (inner.size === 0) this.grudges.delete(observer);
}
},
// 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;
},
getCityPopularity(city) {
return this.getCityOpinions(city).popularity;
},
getCivPopularity(civ) {
return this.getCivOpinions(civ).popularity;
},
// What the player sees, in 0..1: the popularity points through the logistic.
getCityPopularityDisplay(city) {
return popularityDisplay(this.getCityPopularity(city));
},
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;
const opinion = this.getGovOpinion(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: PROPAGANDA.defaultHourlyCost,
dailyDelta: PROPAGANDA.dailyDelta,
});
this._touchPolitics();
this._emitChanged();
return true;
},
// Retunes what a campaign spends each hour. Spending more makes it land
// harder, spending nothing stops it outright.
requestSetCampaignSpending(civ, campaignId, amount) {
const campaign = this.campaigns.find(
(item) => item.id === campaignId && item.civ === civ
);
if (!campaign) return false;
const value = Number(amount);
if (!Number.isFinite(value) || value < 0) return false;
if (value <= 0) return this.requestCancelCampaign(civ, campaignId);
campaign.hourlyCost = Math.min(value, PROPAGANDA.maxHourlyCost);
this._touchPolitics();
this._emitChanged();
return true;
},
// Whether `civ` is already running a campaign on this population and target,
// whatever its direction.
hasCampaign(civ, observer, targetKind, target) {
return this.campaigns.some(
(campaign) =>
campaign.civ === civ &&
campaign.observer === observer &&
campaign.targetKind === targetKind &&
campaign.target === target
);
},
// Stops a running campaign.
requestCancelCampaign(civ, campaignId) {
const index = this.campaigns.findIndex(
(campaign) => campaign.id === campaignId && campaign.civ === civ
);
if (index < 0) return false;
this.campaigns.splice(index, 1);
this._touchPolitics();
this._emitChanged();
return true;
},
// The hourly price of a nation's running campaigns.
getCampaignUpkeep(civ) {
let total = 0;
for (const campaign of this.campaigns) {
if (campaign.civ === civ) total += campaign.hourlyCost;
}
return total;
},
// The campaign expense as one budget source, so the treasury table can show
// it alongside building and unit upkeep.
_campaignUpkeepSources(civ) {
const total = this.getCampaignUpkeep(civ);
if (total <= 0) return [];
let count = 0;
for (const campaign of this.campaigns) if (campaign.civ === civ) count += 1;
return [{ label: "Propaganda campaigns", kind: "campaign", amount: total, count }];
},
// Campaigns run for as long as they are left in place, moving opinion a
// little every day. Spending above the default amplifies the shift and
// spending below it dampens it. 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 = PROPAGANDA.defaultHourlyCost > 0
? campaign.hourlyCost / PROPAGANDA.defaultHourlyCost
: 0;
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() {
this.migrations = new Map();
this._migrationDeltas = { civ: new Map(), city: new Map(), tile: new Map() };
for (const [civ, record] of this.policies) {
for (const policy of record) {
if (policy.type === POLICY_EXPEL) this._migrateExpelled(civ, policy.ethnicity);
else if (policy.type === POLICY_IMMIGRATION) {
this._migrateInvited(civ, policy.ethnicity);
}
}
}
},
// Adds `amount` people to a civilisation's inbound or outbound tally.
_recordMigration(civ, direction, ethnicity, amount) {
if (civ < 0 || !(amount > 0)) return;
let flow = this.migrations.get(civ);
if (!flow) {
flow = { inbound: new Map(), outbound: new Map() };
this.migrations.set(civ, flow);
}
const counts = direction === "in" ? flow.inbound : flow.outbound;
counts.set(ethnicity, (counts.get(ethnicity) || 0) + amount);
},
// A people forced out drift across the border, preferring neighbours of their
// own ethnicity.
_migrateExpelled(civ, eth) {
for (const coords of this._territoryByCiv.get(civ) || []) {
const k = key(coords.x, coords.y);
const shares = this.tileEthnicity.get(k);
if (!shares || (shares.get(eth) || 0) <= 0.001) continue;
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
const owner = this.territory.has(nk) ? this.territory.get(nk) : -1;
if (owner === civ) continue;
const nShares = this.tileEthnicity.get(nk);
const alreadyHome = owner === eth || (nShares && (nShares.get(eth) || 0) >= 0.05);
// They prefer a land of their own people, but a trickle still crosses
// into any neighbouring country.
const share = alreadyHome
? MIGRATION.expelSharePerDay
: MIGRATION.expelSharePerDay * 0.2;
const moved = this._transferEthnicity(k, nk, eth, share);
if (moved > 0) {
this._recordMigration(civ, "out", eth, moved);
this._recordMigration(owner, "in", eth, moved);
}
}
}
},
// An invited people move in from lands whose government they dislike: the
// lower that government's standing with them, the faster they come.
_migrateInvited(civ, eth) {
for (const coords of this._territoryByCiv.get(civ) || []) {
const k = key(coords.x, coords.y);
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
const owner = this.territory.has(nk) ? this.territory.get(nk) : -1;
if (owner < 0 || owner === civ) continue;
const shares = this.tileEthnicity.get(nk);
if (!shares || (shares.get(eth) || 0) <= 0.001) continue;
const approval = this.getGovOpinion(eth, owner);
const willingness = Math.max(
0,
Math.min(1, (MIGRATION.comfortableApproval - approval) / MIGRATION.comfortableApproval)
);
if (willingness <= 0) continue;
const moved = this._transferEthnicity(
nk,
k,
eth,
MIGRATION.immigrationSharePerDay * willingness
);
if (moved > 0) {
this._recordMigration(owner, "out", eth, moved);
this._recordMigration(civ, "in", eth, moved);
}
}
}
},
// Moves `share` of one ethnicity's people from tile `fromK` to tile `toK`,
// renormalises both tiles' shares and returns how many people moved.
_transferEthnicity(fromK, toK, eth, share) {
const fromShares = this.tileEthnicity.get(fromK);
const toShares = this.tileEthnicity.get(toK);
if (!fromShares || !toShares) return 0;
const fromFraction = fromShares.get(eth) || 0;
if (fromFraction <= 0) return 0;
const fromPopulation = this.tilePopulation.get(fromK) || 0;
const toPopulation = this.tilePopulation.get(toK) || 0;
if (fromPopulation <= 0 || toPopulation <= 0) return 0;
const movedFraction = fromFraction * share;
const remaining = Math.max(0, fromFraction - movedFraction);
if (remaining <= 0.0001) fromShares.delete(eth);
else fromShares.set(eth, remaining);
toShares.set(eth, (toShares.get(eth) || 0) + movedFraction * (fromPopulation / toPopulation));
this._setTileEthnicFractions(parseKey(toK), new Map(toShares));
this._setTileEthnicFractions(parseKey(fromK), new Map(fromShares));
const movedPeople = movedFraction * fromPopulation;
this._trackMigration(fromK, toK, eth, movedPeople);
this._touchPolitics();
return movedPeople;
},
// Records a signed migration change for the tile, its city region and its
// country, so the UI can show whether each population is rising or falling.
_trackMigration(fromK, toK, eth, amount) {
if (!(amount > 0)) return;
const fromOwner = this.territory.has(fromK) ? this.territory.get(fromK) : -1;
const toOwner = this.territory.has(toK) ? this.territory.get(toK) : -1;
this._bumpDelta(this._migrationDeltas.civ, fromOwner, eth, -amount);
this._bumpDelta(this._migrationDeltas.civ, toOwner, eth, amount);
const fromCity = this.regionCityAt(parseKey(fromK));
const toCity = this.regionCityAt(parseKey(toK));
if (fromCity) this._bumpDelta(this._migrationDeltas.city, fromCity.id, eth, -amount);
if (toCity) this._bumpDelta(this._migrationDeltas.city, toCity.id, eth, amount);
this._bumpDelta(this._migrationDeltas.tile, fromK, eth, -amount);
this._bumpDelta(this._migrationDeltas.tile, toK, eth, amount);
},
_bumpDelta(store, key, eth, amount) {
if (key === undefined || key === null || key < 0) return;
let inner = store.get(key);
if (!inner) {
inner = new Map();
store.set(key, inner);
}
inner.set(eth, (inner.get(eth) || 0) + amount);
},
// The net migration of one ethnicity in a scope, 0 when unrecorded.
_trendAt(store, key, eth) {
const inner = store.get(key);
return inner ? inner.get(eth) || 0 : 0;
},
// ------------------------------------------------------ war crime --
// Marks a pillaged tile as a war crime: the people of the tile, its
// neighbours, its region, the victim nation and the whole watching world
// sour on the aggressor, and remember for a generation.
_recordWarCrime(aggressor, coords, victimCiv) {
if (aggressor < 0 || victimCiv < 0 || aggressor === victimCiv) return;
const k = key(coords.x, coords.y);
const until = this.totalHours + WAR_CRIME.memoryYears * HOURS_PER_YEAR;
// Each observer is hit once, at the strongest factor that reaches it.
const hits = new Map();
const hit = (observer, factor) => {
if (observer < 0) return;
const current = hits.get(observer) || 0;
if (factor > current) hits.set(observer, factor);
};
// The tile's own dominant people feel it most.
hit(this._dominantEthnicity(coords), 1);
// The immediate neighbours.
for (const neighbour of this._neighbours(coords)) {
hit(this._dominantEthnicity(neighbour), WAR_CRIME.neighbourFactor);
}
// The region and the victim country.
const region = this.regionCityAt(coords);
if (region) {
for (const tile of this.regionTiles(region)) {
hit(this._dominantEthnicity(tile), WAR_CRIME.regionalFactor);
}
}
for (const tile of this._territoryByCiv.get(victimCiv) || []) {
hit(this._dominantEthnicity(tile), WAR_CRIME.nationalFactor);
}
// And the rest of the planet, faintly.
for (let eth = 0; eth < this.civilisations.length; eth++) {
hit(eth, WAR_CRIME.worldFactor);
}
for (const [observer, factor] of hits) {
this._addGrudge(observer, aggressor, -WAR_CRIME.tileOpinion * factor, until);
this._shiftEthnicOpinion(observer, aggressor, -WAR_CRIME.tileEthnicOpinion * factor);
}
// 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. It is a grudge, so it fades
// on the same clock as the rest of the war-crime memory.
_applyPillagePopularity(aggressor, coords) {
if (!this._validCiv(aggressor)) return;
const until = this.totalHours + WAR_CRIME.memoryYears * HOURS_PER_YEAR;
const amount = -POPULARITY.pillage * POPULARITY.opinionPerPoint;
for (let observer = 0; observer < this.civilisations.length; observer++) {
this._addGrudge(observer, aggressor, amount, until);
}
// The tile's dominant people carry the double debuff.
this._addGrudge(this._dominantEthnicity(coords), aggressor, amount, until);
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;
}