Files
Battle-for-Tismo/client/js/modals/nation.js
T
adrien 3e429ccb64 Gave production buildings their own economy and fed island regions
Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply.

Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory.

Bundles the in-progress tree cleanup that was already present in the working tree.
2026-09-22 14:34:14 +02:00

1889 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The nation panel: government, technology, budget, diplomacy and conflicts.
import { compact, groupDigits } from "../../../shared/text_format.js";
import {
governmentChangeCost,
prerequisitesMet,
} from "../../../shared/rules.js";
import {
POLICIES,
PROPAGANDA,
policyById,
TECH_THEMES,
technologyCost,
RESOURCES,
RESOURCE_IDS,
resourceById,
} from "../../../shared/data.js";
import { formatResourceAmount } from "../../../shared/resources.js";
import { renderMonthlyBudget } from "./budget.js";
import { effectsText, money, quotedMoney } from "./format.js";
import { bindTabs } from "./tabs.js";
import { ethnicityChart } from "../ui/ethnicity.js";
import { trendArrow } from "../ui/trend.js";
import { currencySymbol } from "../currency.js";
import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
export class NationModal {
constructor() {
this.$modal = $("#modal-nation");
this.$governmentSub = $("#government-sub");
this.$governmentList = $("#government-list");
this.$technologySub = $("#technology-sub");
this.$technologyList = $("#technology-list");
this.$budgetSub = $("#budget-sub");
this.$budgetMonths = $("#budget-months");
this.$budgetList = $("#budget-list");
this.$economySub = $("#economy-sub");
this.$economySubtabs = $("#economy-subtabs");
this.$economySummary = $("#economy-summary");
this.$economyConsumption = $("#economy-consumption");
this.$economyCurrency = $("#economy-currency");
this.$economyTaxes = $("#economy-taxes");
this.$economyCentralBank = $("#economy-central-bank");
this.$economyRates = $("#economy-rates");
// Which Economy sub-tab is open: the private sector, the trade table, the
// viewer's own currency, the central bank or the exchange rates.
this.economyView = "summary";
// The central-bank interest input is built once and only its value updated,
// so typing is not interrupted by the snapshot refresh.
this._centralBankBuilt = false;
// The tax controls are likewise built once; only their values refresh.
this._taxesBuilt = false;
this.$resourcesSub = $("#resources-sub");
this.$resourcesList = $("#resources-list");
this.$resourceTradeList = $("#resource-trade-list");
// Which Resources sub-tab is open: the nation's own stores and trade, or
// the world ranking of each commodity's major exporter and importer.
this.resourceView = "stores";
// Day-over-day baseline and the latest values for the Resources tab arrows.
this._resourceDay = null;
this._resourceBaseline = null;
this._resourceLast = null;
this.$diplomacySub = $("#diplomacy-sub");
this.$diplomacyList = $("#diplomacy-list");
this.$citiesSub = $("#cities-sub");
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;
this.researched = new Set();
this.governmentRows = [];
this.technologyRows = [];
// Focused research: which theme tab is open and the current focus figures.
this.activeTheme = TECH_THEMES[0].id;
this.techInfo = { focus: -1, progress: 0, repeatCounts: {} };
this.diplomacy = null;
this.diplomacySort = { key: "name", dir: 1 };
this.conflicts = [];
// Conflict cards the player expanded, keyed "a:b". Snapshot refreshes
// rebuild the list, so the open/closed state has to live outside the DOM.
this.expandedConflicts = new Set();
// How many months back the budget tab is paging: 0 is the current month.
this.budgetOffset = 0;
this._treasury = 0;
this._budgetMonths = [];
// Expense categories whose per-resource purchase detail is expanded.
this.expandedBudgetCategories = new Set();
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.onFocusTechnology = () => {};
this.onDeclareWar = () => {};
this.onCampaign = () => {};
this.onCancelCampaign = () => {};
this.onSetCampaignSpending = () => {};
this.onPolicy = () => {};
this.onRevokePolicy = () => {};
this.onSetInterestRate = () => {};
this.onSetTaxRate = () => {};
this.onConfirm = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
bindTabs(this.$modal, "tab-");
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
// Today's world market prices, from the Resources tab's data. Used to quote
// the money costs that carry no material bill of their own.
_prices() {
if (this.resources && this.resources.summary && this.resources.summary.prices) {
return this.resources.summary.prices;
}
const prices = {};
for (const entry of (this.resources && this.resources.market) || []) {
prices[entry.id] = entry.price;
}
return prices;
}
// A fixed money cost quoted at today's market.
_quoted(amount) {
return quotedMoney(amount, this._prices());
}
show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
this.governments = governments;
this.technologies = technologies;
this.diplomacy = diplomacy;
this.conflicts = conflicts || [];
this.politics = politics;
this.resources = resourcesInfo;
this.money = moneyInfo;
// 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.
this.$governmentList.empty();
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._buildTechnologyThemes();
this._buildResourceSubtabs();
this._buildEconomySubtabs();
this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo, resourcesInfo, moneyInfo);
this.$modal.removeClass("hidden");
}
// The technology tab's theme sub-tabs, built once per open. Switching a tab
// only shows and hides the rows that are already there.
_buildTechnologyThemes() {
const $themes = $("#technology-themes");
if (!$themes.length) return;
$themes.empty();
for (const theme of TECH_THEMES) {
const $button = $("<button type='button' class='subtab'></button>").text(theme.name);
if (theme.id === this.activeTheme) $button.addClass("active");
$button.on("click", () => {
this.activeTheme = theme.id;
$themes.find(".subtab").removeClass("active");
$button.addClass("active");
this._applyTechnologyFilter();
});
$themes.append($button);
}
}
_applyTechnologyFilter() {
for (const row of this.technologyRows) {
if (row.$card) row.$card.toggle(row.proto.theme === this.activeTheme);
}
}
// The Resources tab's sub-tabs: the nation's own stores and trade, and the
// world ranking of each commodity's major exporters and importers. Both views
// are rendered together; switching only shows and hides them.
_buildResourceSubtabs() {
const $tabs = $("#resources-subtabs");
if (!$tabs.length) return;
$tabs.empty();
const views = [
{ id: "stores", label: "Stores & trade" },
{ id: "leaders", label: "Major traders" },
];
for (const view of views) {
const $button = $("<button type='button' class='subtab'></button>")
.attr("data-view", view.id)
.text(view.label);
if (view.id === this.resourceView) $button.addClass("active");
$button.on("click", () => this._setResourceView(view.id));
$tabs.append($button);
}
}
_setResourceView(view) {
this.resourceView = view;
const $tabs = $("#resources-subtabs");
$tabs.find(".subtab").removeClass("active");
$tabs.find(`.subtab[data-view="${view}"]`).addClass("active");
if (this.$resourcesList) this.$resourcesList.toggleClass("hidden", view !== "stores");
if (this.$resourceTradeList) this.$resourceTradeList.toggleClass("hidden", view !== "leaders");
}
// The Economy tab's sub-tabs: the private sector, the day's consumption and
// trade, the viewer's own currency and where it is held, the central bank and
// the exchange-rate table.
_buildEconomySubtabs() {
if (!this.$economySubtabs || !this.$economySubtabs.length) return;
this.$economySubtabs.empty();
const currency = (this.money && this.money.currency) || null;
const views = [
{ id: "summary", label: "Private sector" },
{ id: "consumption", label: "Consumption & trade" },
{ id: "currency", label: currency ? `${currency.symbol} ${currency.name}` : "Currency" },
{ id: "taxes", label: "Taxes" },
{ id: "centralbank", label: "Central bank" },
{ id: "rates", label: "Exchange rates" },
];
for (const view of views) {
const $button = $("<button type='button' class='subtab'></button>")
.attr("data-view", view.id)
.text(view.label);
if (view.id === this.economyView) $button.addClass("active");
$button.on("click", () => this._setEconomyView(view.id));
this.$economySubtabs.append($button);
}
this._setEconomyView(this.economyView);
}
_setEconomyView(view) {
this.economyView = view;
if (this.$economySubtabs) {
this.$economySubtabs.find(".subtab").removeClass("active");
this.$economySubtabs.find(`.subtab[data-view="${view}"]`).addClass("active");
}
if (this.$economySummary) this.$economySummary.toggleClass("hidden", view !== "summary");
if (this.$economyConsumption) {
this.$economyConsumption.toggleClass("hidden", view !== "consumption");
}
if (this.$economyCurrency) this.$economyCurrency.toggleClass("hidden", view !== "currency");
if (this.$economyTaxes) this.$economyTaxes.toggleClass("hidden", view !== "taxes");
if (this.$economyCentralBank) {
this.$economyCentralBank.toggleClass("hidden", view !== "centralbank");
}
if (this.$economyRates) this.$economyRates.toggleClass("hidden", view !== "rates");
// Render the view that just became visible.
this._renderEconomy(this.money);
}
_renderEconomy(money) {
this.money = money || this.money || {};
if (this.economyView === "consumption") this._renderConsumption();
else if (this.economyView === "currency") this._renderCurrencyHoldings(this.money);
else if (this.economyView === "taxes") this._renderTaxes(this.money);
else if (this.economyView === "centralbank") this._renderCentralBank(this.money);
else if (this.economyView === "rates") this._renderExchangeRates(this.money);
else this._renderPrivateSector(this.money);
}
// The day's resource consumption and the trade that crossed the nation's
// border: what each neighbour and the world market sold us, and what we sold
// them, with the money it moved.
_renderConsumption() {
if (!this.$economyConsumption || !this.$economyConsumption.length) return;
if (this.$economySub && this.$economySub.length) {
this.$economySub.text("Daily consumption and cross-border trade");
}
this.$economyConsumption.empty();
const summary = this.resources && this.resources.summary;
if (!summary) {
this.$economyConsumption.append(
$("<div class='desc'></div>").text("No resource data yet.")
);
return;
}
const consumed = summary.consumed || {};
const external = summary.external || { buy: {}, sell: {} };
const $table = $("<table class='resources-table economy-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Resource"))
.append($("<th class='numeric'></th>").text("Consumed / day"))
.append($("<th class='numeric'></th>").text("Bought abroad"))
.append($("<th class='numeric'></th>").text("Spent"))
.append($("<th class='numeric'></th>").text("Sold abroad"))
.append($("<th class='numeric'></th>").text("Earned"))
);
const $body = $("<tbody></tbody>");
let totalSpent = 0;
let totalEarned = 0;
for (const id of RESOURCE_IDS) {
const proto = resourceById(id);
const name = proto ? proto.name : id;
const buy = (external.buy && external.buy[id]) || { qty: 0, value: 0 };
const sell = (external.sell && external.sell[id]) || { qty: 0, value: 0 };
totalSpent += buy.value || 0;
totalEarned += sell.value || 0;
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(name))
.append(
$("<td class='numeric'></td>").text(
formatResourceAmount(id, consumed[id] || 0)
)
)
.append($("<td class='numeric'></td>").text(
buy.qty > 0 ? formatResourceAmount(id, buy.qty) : "—"
))
.append($("<td class='numeric'></td>").text(
buy.value > 0 ? this._amount(buy.value) : "—"
))
.append($("<td class='numeric'></td>").text(
sell.qty > 0 ? formatResourceAmount(id, sell.qty) : "—"
))
.append($("<td class='numeric'></td>").text(
sell.value > 0 ? this._amount(sell.value) : "—"
))
);
}
$table.append($head, $body);
$table.append(
$("<tfoot></tfoot>").append(
$("<tr class='budget-total'></tr>")
.append($("<td colspan='3'></td>").text("Trade balance"))
.append($("<td class='numeric'></td>").text(this._amount(totalSpent)))
.append($("<td></td>"))
.append($("<td class='numeric'></td>").text(this._amount(totalEarned)))
)
);
this.$economyConsumption.append($table);
this.$economyConsumption.append(
$("<div class='desc'></div>").text(
"Bought and sold counts only trade across the border — foreign " +
"regions reached over the trade network. Neighbours at home are not " +
"a foreign sale."
)
);
}
// Where every unit of the viewer's currency is stored: the treasury, the
// nation's regions, and the foreign regions and central banks that hold it.
_renderCurrencyHoldings(money) {
if (!this.$economyCurrency || !this.$economyCurrency.length) return;
const holdings = money.currencyHoldings;
const currency = money.currency || { name: "Currency", symbol: "¤", code: "" };
if (this.$economySub && this.$economySub.length) {
this.$economySub.text(
`Holdings of the ${currency.name} (${currency.code})`
);
}
this.$economyCurrency.empty();
if (!holdings) {
this.$economyCurrency.append(
$("<div class='desc'></div>").text("No currency data yet.")
);
return;
}
const $table = $("<table class='resources-table economy-table'></table>");
const $body = $("<tbody></tbody>");
$body.append(
$("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text("Treasury")
)
);
$body.append(
$("<tr></tr>")
.append($("<td></td>").text("Government"))
.append($("<td class='numeric'></td>").text(this._amount(holdings.treasury || 0)))
);
if (holdings.treasuryDebt > 0) {
$body.append(
$("<tr class='expense-detail'></tr>")
.append($("<td></td>").text("Overdraft (debt)"))
.append($("<td class='numeric'></td>").text(`−${this._amount(holdings.treasuryDebt)}`))
);
}
$body.append(
$("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text(`Your regions (${(holdings.own || []).length})`)
)
);
for (const region of holdings.own || []) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(region.name))
.append($("<td class='numeric'></td>").text(this._amount(region.amount)))
);
}
if ((holdings.foreign || []).length > 0) {
$body.append(
$("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text("Held by foreign regions")
)
);
for (const region of holdings.foreign) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(`${region.name} (${region.civName})`))
.append($("<td class='numeric'></td>").text(this._amount(region.amount)))
);
}
}
if ((holdings.banks || []).length > 0) {
$body.append(
$("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text("Central-bank reserves")
)
);
for (const bank of holdings.banks) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(`Central bank of ${bank.name}`))
.append($("<td class='numeric'></td>").text(this._amount(bank.amount)))
);
}
}
$table.append($body);
const $foot = $("<tfoot></tfoot>").append(
$("<tr class='budget-total'></tr>")
.append($("<td></td>").text("Total in circulation"))
.append($("<td class='numeric'></td>").text(this._amount(holdings.total || 0)))
);
$table.append($foot);
this.$economyCurrency.append($table);
this.$economyCurrency.append(
$("<div class='desc'></div>").text(
"Foreign holdings are what exports earned; a foreign central bank " +
"holds your money as a reserve."
)
);
}
_amount(value) {
return `${currencySymbol()}${groupDigits(Math.round(value || 0))}`;
}
// The aggregate private-sector cash the nation's regions and its independent
// production buildings hold, each listed on its own row.
_renderPrivateSector(money) {
if (!this.$economySummary || !this.$economySummary.length) return;
const cash = money.privateCash || 0;
const buildingCash = money.privateBuildingCash || 0;
if (this.$economySub && this.$economySub.length) {
this.$economySub.text(
`Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash + buildingCash))}` +
` · regions ${currencySymbol()}${groupDigits(Math.round(cash))}` +
` · production ${currencySymbol()}${groupDigits(Math.round(buildingCash))}` +
` · ${(money.currency && money.currency.name) || ""}`
);
}
this.$economySummary.empty();
this.$economySummary.append(
$("<div class='desc'></div>").text(
"Regions are private economic agents: each holds its own cash reserve, " +
"trades with its neighbours and chases profit."
)
);
const cities = (money.cities || []).filter((city) => city.civ === money.viewerCiv);
if (cities.length > 0) {
const $table = $("<table class='resources-table economy-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Region"))
.append($("<th class='numeric'></th>").text("Private cash"))
);
const $body = $("<tbody></tbody>");
let total = 0;
for (const city of cities) {
total += city.cashValue || 0;
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(city.name))
.append(
$("<td class='numeric'></td>").text(
`${currencySymbol()}${groupDigits(Math.round(city.cashValue || 0))}`
)
)
);
}
$table.append($head, $body);
const $foot = $("<tfoot></tfoot>").append(
$("<tr class='budget-total'></tr>")
.append($("<td></td>").text("Total"))
.append($("<td class='numeric'></td>").text(
`${currencySymbol()}${groupDigits(Math.round(total))}`
))
);
$table.append($foot);
this.$economySummary.append($table);
}
this._renderProductionBuildings(money.productionBuildings || []);
}
// Production buildings are private-sector agents too, but a separate class
// from the regions: each keeps its own cash and reinvests it in upgrades.
_renderProductionBuildings(buildings) {
if (!this.$economySummary || buildings.length === 0) return;
this.$economySummary.append(
$("<div class='desc'></div>").text(
"Production buildings are independent private agents: they keep what " +
"their output sells for, and spend it upgrading when demand has grown."
)
);
const $table = $("<table class='resources-table economy-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Production building"))
.append($("<th></th>").text("Output / day"))
.append($("<th class='numeric'></th>").text("Level"))
.append($("<th class='numeric'></th>").text("Private cash"))
);
const $body = $("<tbody></tbody>");
let total = 0;
for (const building of buildings) {
total += building.cash || 0;
const level = building.level || 0;
const resource = resourceById(building.resource);
const $output = resource && building.outputPerDay
? $("<td></td>").text(formatResourceAmount(building.resource, building.outputPerDay))
: $("<td></td>").text("—");
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(building.name))
.append($output)
.append(
$("<td class='numeric'></td>").text(
level + (building.upgrading ? " ↑" : "")
)
)
.append(
$("<td class='numeric'></td>").text(
`${currencySymbol()}${groupDigits(Math.round(building.cash || 0))}`
)
)
);
}
$table.append($head, $body);
$table.append(
$("<tfoot></tfoot>").append(
$("<tr class='budget-total'></tr>")
.append($("<td colspan='3'></td>").text("Total"))
.append($("<td class='numeric'></td>").text(
`${currencySymbol()}${groupDigits(Math.round(total))}`
))
)
);
this.$economySummary.append($table);
}
// The trade taxes: three rates, each a national default that may be
// overridden per resource. There is no GDP tax, so these are the government's
// whole revenue.
_renderTaxes(money) {
if (!this.$economyTaxes || !this.$economyTaxes.length) return;
const taxes = (money && money.taxes) || {};
const take = (money && money.takeTax) || { sales: 0, export: 0, import: 0 };
if (this.$economySub && this.$economySub.length) {
const total = (take.sales || 0) + (take.export || 0) + (take.import || 0);
this.$economySub.text(
`Trade taxes · collected ${currencySymbol()}${groupDigits(Math.round(total))} today`
);
}
if (!this._taxesBuilt) {
this.$economyTaxes.empty();
this.$economyTaxes.append(
$("<div class='desc'></div>").text(
"The government lives on trade. A national sales tax falls on every purchase " +
"a region makes from another region at home; an export tariff on what foreign " +
"regions buy from your regions; an import tariff on the national currency your " +
"regions spend buying foreign goods. A resource box left blank uses the default."
)
);
const kinds = [
{ id: "sales", label: "Sales tax" },
{ id: "export", label: "Export tariff" },
{ id: "import", label: "Import tariff" },
];
const $table = $("<table class='tax-table economy-table'></table>");
const $head = $("<tr></tr>");
$head.append($("<th></th>").text("Tax"));
$head.append($("<th class='numeric'></th>").text("Default"));
for (const proto of RESOURCES) {
$head.append($("<th class='numeric'></th>").text(proto.name));
}
$head.append($("<th></th>").text(""));
$table.append($("<thead></thead>").append($head));
const $body = $("<tbody></tbody>");
for (const kind of kinds) {
const $tr = $("<tr></tr>");
$tr.append($("<td class='name'></td>").text(kind.label));
$tr.append(
$("<td class='numeric'></td>").append(
$("<input type='number' min='0' max='100' step='1'>")
.addClass("tax-input")
.attr("data-kind", kind.id)
)
);
for (const proto of RESOURCES) {
$tr.append(
$("<td class='numeric'></td>").append(
$("<input type='number' min='0' max='100' step='1'>")
.addClass("tax-input tax-override")
.attr("data-kind", kind.id)
.attr("data-resource", proto.id)
)
);
}
const $apply = $("<button type='button' class='aero tax-apply'></button>").text("Set");
$apply.on("click", () => this._applyTaxRow(kind.id));
$tr.append($("<td></td>").append($apply));
$body.append($tr);
}
$table.append($body);
this.$economyTaxes.append($table);
this._taxesBuilt = true;
}
// Refresh the boxes, leaving the one being typed in alone.
const active = document.activeElement;
this.$economyTaxes.find(".tax-input").each((_, element) => {
if (element === active) return;
const $input = $(element);
const kind = $input.data("kind");
const resource = $input.data("resource");
const entry = taxes[kind] || { rate: 0, resources: {} };
const value = resource ? entry.resources[resource] : entry.rate;
$input.val(value === undefined || value === null ? "" : (value * 100).toFixed(2));
});
}
// Sends every box on one tax row that has a value, the default and each
// per-resource override.
_applyTaxRow(kind) {
const $row = this.$economyTaxes.find(`.tax-input[data-kind="${kind}"]`).closest("tr");
const send = ($input, resource) => {
const raw = $input.val();
if (raw === "" || raw === null || raw === undefined) return;
const percent = Number(raw);
if (!Number.isFinite(percent) || percent < 0) return;
this.onSetTaxRate(kind, percent / 100, resource || null);
};
send($row.find(".tax-input").not(".tax-override"));
$row.find(".tax-override").each((_, element) => {
const $input = $(element);
send($input, $input.attr("data-resource"));
});
}
// The central bank: its interest rate, which compounds into every region's
// private cash, and the foreign reserves it has accumulated.
_renderCentralBank(money) {
if (!this.$economyCentralBank || !this.$economyCentralBank.length) return;
const bank = money.centralBank || { rate: 0, reserves: [] };
// The consumption-weighted move in world commodity prices over the last day.
const inflation = (money && money.commodityInflation) || 0;
const inflationText = `${inflation >= 0 ? "+" : "−"}${(Math.abs(inflation) * 100).toFixed(2)}%/day`;
if (this.$economySub && this.$economySub.length) {
this.$economySub.text(
`${(money.currency && money.currency.name) || "Central bank"} · ` +
`Interest rate ${(bank.rate * 100).toFixed(2)}%/year · ` +
`Commodity inflation ${inflationText}`
);
}
if (!this._centralBankBuilt) {
this.$economyCentralBank.empty();
const $rate = $("<div class='central-bank-rate'></div>");
$rate.append($("<label for='cb-rate'></label>").text("Interest rate (% per year)"));
const $input = $("<input id='cb-rate' type='number' min='0' max='50' step='0.25'>");
const $apply = $("<button type='button' class='aero cb-apply'></button>").text("Set");
const apply = () => {
const percent = Number($input.val());
if (!Number.isFinite(percent) || percent < 0) return;
this.onSetInterestRate(percent / 100);
};
$apply.on("click", apply);
$input.on("keydown", (event) => {
if (event.key === "Enter") apply();
});
$rate.append($input, $apply);
this.$economyCentralBank.append($rate);
this.$economyCentralBank.append(
$("<div class='desc'></div>").text(
"Regions borrow from the bank at this rate when their cash runs out, " +
"and it compounds on whatever they owe. The government may run an " +
"overdraft here too."
)
);
this.$economyCentralBank.append($("<div class='central-bank-inflation'></div>"));
this.$economyCentralBank.append($("<div class='central-bank-loans'></div>"));
const $reserves = $("<div class='central-bank-reserves'></div>");
this.$economyCentralBank.append($reserves);
this._centralBankBuilt = true;
}
const $input = this.$economyCentralBank.find("#cb-rate");
if ($input.length && document.activeElement !== $input.get(0)) {
$input.val((bank.rate * 100).toFixed(2));
}
this.$economyCentralBank.find(".central-bank-inflation").text(
`Commodity inflation: ${inflationText} — the price of the goods the world ` +
"consumes, weighted by how much of each it uses."
);
this.$economyCentralBank.find(".central-bank-loans").text(
`Outstanding loans: ${currencySymbol()}${groupDigits(Math.round(bank.loans || 0))}`
);
const $reserves = this.$economyCentralBank.find(".central-bank-reserves");
$reserves.empty();
$reserves.append($("<div class='subhead'></div>").text("Foreign reserves"));
if ((bank.reserves || []).length === 0) {
$reserves.append($("<div class='desc'></div>").text("No foreign currency held yet."));
return;
}
const $table = $("<table class='resources-table economy-table'></table>");
const $body = $("<tbody></tbody>");
for (const reserve of bank.reserves) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(`${reserve.symbol} ${reserve.name}`))
.append(
$("<td class='numeric'></td>").text(
groupDigits(Math.round(reserve.amount || 0))
)
)
);
}
$table.append($body);
$reserves.append($table);
}
// The exchange rate between the viewer's currency and every other.
_renderExchangeRates(money) {
if (!this.$economyRates || !this.$economyRates.length) return;
const mine = money.currency || { name: "Currency" };
if (this.$economySub && this.$economySub.length) {
this.$economySub.text(`Exchange rates against the ${mine.name}`);
}
this.$economyRates.empty();
const rows = money.exchangeRates || [];
const $table = $("<table class='resources-table economy-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Currency"))
.append($("<th class='numeric'></th>").text(`1 ${mine.code || ""} buys`))
);
const $body = $("<tbody></tbody>");
for (const entry of rows) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(`${entry.symbol} ${entry.name} (${entry.code})`))
.append($("<td class='numeric'></td>").text(entry.rate.toFixed(4)))
);
}
$table.append($head, $body);
this.$economyRates.append($table);
}
// The national budget: the selected month's actual income and expenses, with
// a pager back through the stored monthly digests. The treasury stays on the
// subtitle so the table is purely the month's flows.
_renderBudget(finances) {
if (!this.$budgetList || !this.$budgetList.length) return;
const months = (finances && finances.months) || [];
this._budgetMonths = months;
const maxOffset = Math.max(0, months.length - 1);
if (this.budgetOffset > maxOffset) this.budgetOffset = maxOffset;
if (this.budgetOffset < 0) this.budgetOffset = 0;
const index = months.length - 1 - this.budgetOffset;
const report = index >= 0 ? months[index] : null;
this._renderBudgetMonthsNav(months, index);
if (this.$budgetSub && this.$budgetSub.length) {
const net = report ? ` · ${report.label}: ${money(report.net)}` : "";
this.$budgetSub.text(`Treasury: ${currencySymbol()}${groupDigits(Math.round(this._treasury))}${net}`);
}
renderMonthlyBudget(this.$budgetList, report, {
expanded: this.expandedBudgetCategories,
onToggle: () => this._renderBudget({ months }),
});
}
_renderBudgetMonthsNav(months, index) {
if (!this.$budgetMonths || !this.$budgetMonths.length) return;
this.$budgetMonths.empty();
if (months.length === 0) return;
const older = this.budgetOffset < months.length - 1;
const newer = this.budgetOffset > 0;
const $prev = $("<button type='button' class='budget-month-nav'></button>")
.text("◀")
.prop("disabled", !older)
.on("click", () => {
if (!older) return;
this.budgetOffset += 1;
this._renderBudget({ months });
});
const $next = $("<button type='button' class='budget-month-nav'></button>")
.text("▶")
.prop("disabled", !newer)
.on("click", () => {
if (!newer) return;
this.budgetOffset -= 1;
this._renderBudget({ months });
});
const label = months[index] ? months[index].label : "";
this.$budgetMonths.append(
$prev,
$("<span class='budget-month-label'></span>").text(label),
$next
);
}
// The nation's regions: each city's population, GDP and the tax it collects.
_renderCities(cities) {
if (!this.$citiesList || !this.$citiesList.length) return;
this.$citiesList.empty();
const localCiv = this.diplomacy ? this.diplomacy.localCiv : -1;
const rows = (cities || []).filter((city) => city.civ === localCiv);
if (this.$citiesSub && this.$citiesSub.length) {
this.$citiesSub.text(
rows.length === 0
? "No cities."
: `${rows.length} region${rows.length === 1 ? "" : "s"} paying trade taxes.`
);
}
if (rows.length === 0) {
this.$citiesList.append($("<div class='desc'></div>").text("No cities."));
return;
}
rows.sort((a, b) => b.income - a.income);
const columns = [
{ key: "name", label: "City" },
{ key: "population", label: "Population", numeric: true },
{ key: "gdp", label: "GDP", numeric: true },
{ key: "income", label: "Taxes / h", numeric: true },
];
const $table = $("<table class='diplomacy-table budget-table'></table>");
const $head = $("<thead></thead>");
const $headRow = $("<tr></tr>");
for (const column of columns) {
const $th = $("<th></th>").text(column.label);
if (column.numeric) $th.addClass("numeric");
$headRow.append($th);
}
$head.append($headRow);
const $body = $("<tbody></tbody>");
let totalPopulation = 0;
let totalGdp = 0;
let totalIncome = 0;
for (const city of rows) {
totalPopulation += city.population;
totalGdp += city.gdp;
totalIncome += city.income;
const $tr = $("<tr></tr>");
const $name = $("<td class='name'></td>").text(city.isCapital ? `★ ${city.name}` : city.name);
$tr.append($name);
$tr.append($("<td class='numeric'></td>").text(compact(city.population)));
$tr.append($("<td class='numeric'></td>").text(`${currencySymbol()}${compact(city.gdp)}`));
$tr.append($("<td class='numeric income'></td>").text(`+${currencySymbol()}${compact(city.income)}`));
$body.append($tr);
}
$table.append($head, $body);
$table.append(
$("<tfoot></tfoot>").append(
$("<tr class='budget-total'></tr>")
.append($("<td></td>").text("TOTAL"))
.append($("<td class='numeric'></td>").text(compact(totalPopulation)))
.append($("<td class='numeric'></td>").text(`${currencySymbol()}${compact(totalGdp)}`))
.append($("<td class='numeric income'></td>").text(`+${currencySymbol()}${compact(totalIncome)}`))
)
);
this.$citiesList.append($table);
}
// The Resources tab: the nation's stores, what it consumes and makes, what it
// imports and exports, beside the reference price and the world's supply and
// demand. Energy is a daily flow, so it has no store: its row reads the
// cities' demand against the power plants' output instead.
_renderResources(info) {
if (!this.$resourcesList || !this.$resourcesList.length) return;
this.$resourcesList.empty();
const summary = info && info.summary;
if (this.$resourcesSub && this.$resourcesSub.length) {
this.$resourcesSub.text(
"Stores, daily consumption, production and cross-nation trade, with the " +
"reference price. Energy is delivered as a flow and cannot be stored."
);
}
if (!summary) {
this.$resourcesList.append($("<div class='desc'></div>").text("No resource data."));
} else {
this._renderResourceStores(info, summary);
}
this._renderResourceLeaders(info);
this._setResourceView(this.resourceView);
}
_renderResourceStores(info, summary) {
const market = new Map(((info && info.market) || []).map((entry) => [entry.id, entry]));
const trade = summary.trade || { imports: {}, exports: {} };
// Day-over-day arrows: when the game day rolls, the values last drawn become
// the baseline every cell compares against. Prices move with a lower
// threshold, so a ten-percent jump doubles its arrow.
const day = info && info.totalHours !== undefined
? Math.floor(info.totalHours / HOURS_PER_DAY)
: 0;
if (this._resourceDay !== day) {
this._resourceBaseline = this._resourceLast || null;
this._resourceDay = day;
}
const baseline = this._resourceBaseline || {};
const values = {};
const cell = ($tr, key, value, text, options = {}) => {
values[key] = value;
const $td = $("<td class='numeric'></td>").text(text);
const arrow = trendArrow(value, baseline[key], options);
if (arrow) $td.append(arrow);
$tr.append($td);
};
const $table = $("<table class='diplomacy-table budget-table resources-table'></table>");
const columns = [
{ label: "Resource" },
{ label: "Stored", numeric: true },
{ label: "Consumed / day", numeric: true },
{ label: "Made / day", numeric: true },
{ label: "Exported / day", numeric: true },
{ label: "Imported / day", numeric: true },
{ label: "Market price", numeric: true },
{ label: "World supply / day", numeric: true },
{ label: "World demand / day", numeric: true },
];
const $head = $("<thead></thead>");
const $headRow = $("<tr></tr>");
for (const column of columns) {
const $th = $("<th></th>").text(column.label);
if (column.numeric) $th.addClass("numeric");
$headRow.append($th);
}
$head.append($headRow);
const $body = $("<tbody></tbody>");
for (const proto of RESOURCES) {
const energy = proto.id === "energy";
const id = proto.id;
const stats = market.get(proto.id) || {};
const storedValue = energy ? null : (summary.stock[id] || 0);
const consumedValue = energy
? ((summary.consumed && summary.consumed.energy) || 0)
: ((summary.consumed && summary.consumed[id]) || 0);
const madeValue = energy
? (summary.energy ? summary.energy.supply : 0)
: (summary.production[id] || 0);
const exportedValue = (trade.exports && trade.exports[id]) || 0;
const importedValue = (trade.imports && trade.imports[id]) || 0;
const priceValue = stats.price || 0;
const supplyValue = Math.max(0, stats.supply || 0);
const demandValue = Math.max(0, stats.demand || 0);
const $tr = $("<tr></tr>");
const $name = $("<td class='name'></td>");
$name.append(
$("<img class='resource-icon' width='18' height='18'>")
.attr("src", `assets/${proto.icon}`)
.attr("alt", proto.name)
);
$name.append($("<span></span>").text(` ${proto.name}`));
$tr.append($name);
if (energy) {
$tr.append($("<td class='numeric'></td>").text("—"));
} else {
cell($tr, `${id}:stored`, storedValue, formatResourceAmount(id, storedValue));
}
cell($tr, `${id}:consumed`, consumedValue, formatResourceAmount(id, consumedValue));
cell($tr, `${id}:made`, madeValue, formatResourceAmount(id, madeValue));
cell($tr, `${id}:exported`, exportedValue, formatResourceAmount(id, exportedValue));
cell($tr, `${id}:imported`, importedValue, formatResourceAmount(id, importedValue));
cell($tr, `${id}:price`, priceValue, this._resourcePriceText(proto, priceValue), {
doubleAt: 0.1,
});
cell($tr, `${id}:supply`, supplyValue, formatResourceAmount(id, supplyValue));
cell($tr, `${id}:demand`, demandValue, formatResourceAmount(id, demandValue));
$body.append($tr);
}
this._resourceLast = values;
$table.append($head, $body);
this.$resourcesList.append($table);
this.$resourcesList.append(
$("<div class='desc'></div>").text(
"Consumption is the people's needs plus the materials buildings, units and works " +
"wear out. Exports and imports count yesterday's deliveries between nations. " +
"Prices drift with the world's supply and demand, and a region buys from its own " +
"stores first, then from reachable home regions, then from foreign ones. The " +
"arrows compare each figure with its value the previous day."
)
);
}
// The Major traders sub-tab: for every commodity, the nation that exported the
// most over the last day and the one that imported the most.
_renderResourceLeaders(info) {
if (!this.$resourceTradeList || !this.$resourceTradeList.length) return;
this.$resourceTradeList.empty();
const trade = (info && info.trade) || [];
const civs = (info && info.civs) || [];
const name = (civ) => (civs[civ] ? civs[civ].name : `Nation ${civ + 1}`);
const $table = $("<table class='diplomacy-table budget-table resources-table'></table>");
const columns = [
{ label: "Resource" },
{ label: "Major exporter" },
{ label: "Exported / day", numeric: true },
{ label: "Major importer" },
{ label: "Imported / day", numeric: true },
];
const $head = $("<thead></thead>");
const $headRow = $("<tr></tr>");
for (const column of columns) {
const $th = $("<th></th>").text(column.label);
if (column.numeric) $th.addClass("numeric");
$headRow.append($th);
}
$head.append($headRow);
const $body = $("<tbody></tbody>");
for (const proto of RESOURCES) {
const best = (kind) => {
let winner = null;
for (const entry of trade) {
const amount = (entry[kind] && entry[kind][proto.id]) || 0;
if (amount > 0 && (!winner || amount > winner.amount)) {
winner = { civ: entry.civ, amount };
}
}
return winner;
};
const exporter = best("exports");
const importer = best("imports");
const $tr = $("<tr></tr>");
const $name = $("<td class='name'></td>");
$name.append(
$("<img class='resource-icon' width='18' height='18'>")
.attr("src", `assets/${proto.icon}`)
.attr("alt", proto.name)
);
$name.append($("<span></span>").text(` ${proto.name}`));
$tr.append($name);
$tr.append($("<td></td>").text(exporter ? name(exporter.civ) : "—"));
$tr.append(
$("<td class='numeric'></td>").text(
exporter ? formatResourceAmount(proto.id, exporter.amount) : "—"
)
);
$tr.append($("<td></td>").text(importer ? name(importer.civ) : "—"));
$tr.append(
$("<td class='numeric'></td>").text(
importer ? formatResourceAmount(proto.id, importer.amount) : "—"
)
);
$body.append($tr);
}
$table.append($head, $body);
this.$resourceTradeList.append($table);
this.$resourceTradeList.append(
$("<div class='desc'></div>").text(
"The largest cross-nation deliveries of the last day, in each resource. " +
"Only trade between nations is counted, so goods bought from the global " +
"market never make a nation an importer here."
)
);
}
// A market price reads in euro per the resource's own unit; tiny energy prices
// keep enough decimals to be legible.
_resourcePriceText(proto, price) {
const value = price === undefined || price === null ? 0 : price;
const text = value >= 1000
? groupDigits(Math.round(value))
: value >= 1
? value.toFixed(0)
: value.toFixed(4);
return `${currencySymbol()}${text} / ${proto.unit}`;
}
_makeRow($list, proto, index, kind) {
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>");
const $desc = $("<div class='desc'></div>").text(proto.description || "");
const $effects = $("<div class='effects'></div>");
const $cost = $("<div class='cost'></div>");
$info.append($name, $desc, $effects, $cost);
const $actions = $("<div class='actions'></div>");
const $action = $("<button class='aero'></button>");
$action.on("click", () => {
if (kind === "government") this.onGovernment(index);
else this.onFocusTechnology(index);
});
$actions.append($action);
$card.append($info, $actions);
$list.append($card);
return { proto, index, kind, $name, $effects, $cost, $action, $card };
}
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
this.currentGovernment = currentGovernment;
if (techInfo) this.techInfo = techInfo;
if (resourcesInfo) this.resources = resourcesInfo;
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: ${currencySymbol()}${groupDigits(Math.round(budget))}`
);
// Research has no pool: every point flows into the focused technology, so
// the panel shows the rate instead of a stockpile.
this.$technologySub.text(`Research: +${(researchRate || 0).toFixed(1)}/h`);
const fin = finances || {};
this._treasury = budget;
if (moneyInfo) this.money = moneyInfo;
this._renderBudget(fin);
this._renderCities(fin.cities);
this._renderResources(this.resources);
this._renderEconomy(this.money);
for (const row of this.governmentRows) {
const proto = row.proto;
row.$name.text(proto.name);
row.$effects.text(effectsText(proto.effects, 1));
if (row.index === currentGovernment) {
row.$cost.text("Currently in power");
row.$action.text("Current government").prop("disabled", true);
} else {
const cost = governmentChangeCost(proto, gdp);
row.$cost.text(`Adoption cost ${currencySymbol()}${groupDigits(this._quoted(cost))}`);
row.$action
.text(`Adopt ${currencySymbol()}${groupDigits(this._quoted(cost))}`)
.prop("disabled", false);
}
}
const repeatCounts = this.techInfo.repeatCounts || {};
for (const row of this.technologyRows) {
const proto = row.proto;
const repeat = repeatCounts[proto.id] || 0;
const focused = this.techInfo.focus === row.index;
row.$name.text(repeat > 0 ? `${proto.name} — Level ${repeat}` : proto.name);
// Show the effect of the next level, so a repeatable's diminishing gain
// is clear before it is chosen.
row.$effects.text(effectsText(proto.effects, repeat > 0 ? repeat : 1));
if (this.researched.has(row.index) && !proto.repeatable) {
row.$cost.text("Researched");
row.$action.text("Researched").prop("disabled", true);
} else if (!prerequisitesMet(proto, this.researched, this.technologies)) {
const names = proto.prerequisites
.map((id) => {
const found = this.technologies.find((t) => t.id === id);
return found ? found.name : id;
})
.join(", ");
row.$cost.text(`Requires: ${names}`);
row.$action.text("Locked").prop("disabled", true);
} else if (focused) {
// A focused technology fills a progress bar, with the committed and
// total points to its right.
const cost = technologyCost(proto, repeat);
const progress = this.techInfo.progress || 0;
const pct = cost > 0 ? Math.max(0, Math.min(100, (progress / cost) * 100)) : 0;
row.$cost.empty().append(
$("<div class='tech-progress-track'></div>").append(
$("<div class='tech-progress-fill'></div>").css("width", `${pct}%`)
),
$("<span class='tech-progress-text'></span>").text(
`${groupDigits(Math.round(progress))} / ${groupDigits(Math.round(cost))}`
)
);
row.$action.text("Focused").prop("disabled", true);
} else {
const cost = technologyCost(proto, repeat);
row.$cost.text(`Cost ${groupDigits(Math.round(cost))} research points`);
row.$action.text("Focus").prop("disabled", false);
}
}
this._applyTechnologyFilter();
this._renderDiplomacy();
this._renderConflicts();
this._renderPolitics();
}
// Lists every other nation's economy in a sortable table, with its relation
// to the player and a button to declare war. `diplomacy` is
// { civs, localCiv, relations, stats }, where relations is the snapshot's
// [a, b, status] matrix and stats its per-civ { population, gdp, gdpPerCapita }.
// The table starts sorted alphabetically by nation and every header toggles
// the ordering.
_renderDiplomacy() {
if (!this.$diplomacyList || !this.$diplomacyList.length) return;
this.$diplomacyList.empty();
const info = this.diplomacy;
if (!info) return;
const civs = info.civs || [];
const localCiv = info.localCiv;
const wars = new Set();
for (const entry of info.relations || []) {
if (entry[2] !== "war") continue;
if (entry[0] === localCiv) wars.add(entry[1]);
else if (entry[1] === localCiv) wars.add(entry[0]);
}
if (this.$diplomacySub && this.$diplomacySub.length) {
const atWar = wars.size;
this.$diplomacySub.text(
atWar === 0
? "Your nation is at peace with every other nation."
: `Your nation is at war with ${atWar} nation${atWar === 1 ? "" : "s"}.`
);
}
const rows = [];
for (let index = 0; index < civs.length; index++) {
const stats = (info.stats || [])[index] || {};
const self = index === localCiv;
rows.push({
index,
name: civs[index].name,
gdp: stats.gdp || 0,
gdpPerCapita: stats.gdpPerCapita || 0,
population: stats.population || 0,
approval: stats.approval || 0,
atWar: self ? false : wars.has(index),
self,
});
}
this._sortDiplomacyRows(rows);
const columns = [
{ key: "name", label: "Nation" },
{ 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>");
const $head = $("<thead></thead>");
const $headRow = $("<tr></tr>");
for (const column of columns) {
const $th = $("<th></th>").attr("data-sort", column.key).text(column.label);
if (column.numeric) $th.addClass("numeric");
if (this.diplomacySort.key === column.key) {
const descending = this.diplomacySort.dir < 0;
$th.addClass(descending ? "sorted-desc" : "sorted-asc");
$th.append(
$("<span class='sort-arrow'></span>").text(descending ? "▼" : "▲")
);
}
$th.on("click", () => this._sortDiplomacyBy(column.key));
$headRow.append($th);
}
$head.append($headRow);
const $body = $("<tbody></tbody>");
for (const row of rows) {
const $tr = $("<tr></tr>");
if (row.self) $tr.addClass("local-nation");
$tr.append($("<td class='name'></td>").text(row.name));
$tr.append($("<td class='numeric'></td>").text(`${currencySymbol()}${compact(row.gdp)}`));
$tr.append($("<td class='numeric'></td>").text(`${currencySymbol()}${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"));
} else {
$status.append(
$("<span class='relation'></span>").text(row.atWar ? "At war" : "At peace")
);
const $action = $("<button class='aero'></button>");
if (row.atWar) {
$action.text("At war").prop("disabled", true);
} else {
$action.text("Declare war");
$action.on("click", () => this.onDeclareWar(row.index));
}
$status.append($action);
}
$tr.append($status);
$body.append($tr);
}
$table.append($head, $body);
this.$diplomacyList.append($table);
}
// Clicking a header sorts by it, reversing the order when it is already the
// active column.
_sortDiplomacyBy(key) {
if (this.diplomacySort.key === key) {
this.diplomacySort.dir *= -1;
} else {
this.diplomacySort.key = key;
this.diplomacySort.dir = 1;
}
this._renderDiplomacy();
}
_sortDiplomacyRows(rows) {
const { key, dir } = this.diplomacySort;
rows.sort((a, b) => {
let result;
if (key === "name") result = a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
else if (key === "status") result = (a.atWar ? 1 : 0) - (b.atWar ? 1 : 0);
else result = a[key] - b[key];
if (result === 0) result = a.index - b.index;
return dir * result;
});
}
// Lists every ongoing war between the world's nations, whoever is fighting.
// Clicking a conflict reveals the date it began and the military and civilian
// dead on each side.
_renderConflicts() {
if (!this.$conflictsList || !this.$conflictsList.length) return;
this.$conflictsList.empty();
const conflicts = this.conflicts || [];
if (this.$conflictsSub && this.$conflictsSub.length) {
this.$conflictsSub.text(
conflicts.length === 0
? "No ongoing conflicts."
: `${conflicts.length} ongoing conflict${conflicts.length === 1 ? "" : "s"} between the world's nations.`
);
}
if (conflicts.length === 0) {
this.$conflictsList.append(
$("<div class='desc'></div>").text("The world is at peace.")
);
return;
}
for (const conflict of conflicts) this._makeConflictRow(conflict);
}
_makeConflictRow(conflict) {
const $card = $("<div class='row-card conflict-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>").text(
`${this._civName(conflict.a)} vs ${this._civName(conflict.b)}`
);
const $desc = $("<div class='desc'></div>").text(`Ongoing since ${conflict.started}`);
$info.append($name, $desc);
const key = `${conflict.a}:${conflict.b}`;
const $detail = $("<div class='conflict-detail'></div>");
if (!this.expandedConflicts.has(key)) $detail.addClass("hidden");
$detail.append(
$("<div class='conflict-line'></div>").text(`Conflict started ${conflict.started}`)
);
for (const side of conflict.sides || []) {
$detail.append(
$("<div class='conflict-side'></div>").text(
`${this._civName(side.civ)} — Military: ${groupDigits(side.military || 0)} · ` +
`Civilians: ${groupDigits(side.civilians || 0)}`
)
);
}
$card.append($info, $detail);
$card.on("click", () => {
if (this.expandedConflicts.has(key)) this.expandedConflicts.delete(key);
else this.expandedConflicts.add(key);
$detail.toggleClass("hidden");
});
this.$conflictsList.append($card);
}
_civName(index) {
const civ = this.diplomacy && this.diplomacy.civs ? this.diplomacy.civs[index] : null;
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 (
`${currencySymbol()}${groupDigits(this._quoted(hourly))}/h · ${currencySymbol()}${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(
`${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`
);
$slider.on("input", () =>
$spend.text(`${currencySymbol()}${groupDigits(this._quoted(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(`${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`);
this.onConfirm(
"Stop this campaign?",
`Cutting the spending to ${currencySymbol()}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, and the last " +
"few popularity points are the hardest to win."
)
);
$card.append(
$("<div class='desc politics-cost'></div>").text(
`Costs ${currencySymbol()}${groupDigits(this._quoted(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");
}
}