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