// 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,
technologyCost,
RESOURCES,
RESOURCE_IDS,
STORABLE_RESOURCE_IDS,
MARKET_LOTS,
resourceById,
PRICE_INDEX_WINDOW_DAYS,
TREATIES,
treatyById,
} from "../../../shared/data.js";
import { formatResourceAmount } from "../../../shared/resources.js";
import { renderMonthlyBudget } from "./budget.js";
import { effectNodes, money, quotedMoney } from "./format.js";
import { bindTabs } from "./tabs.js";
import { ethnicityChart } from "../ui/ethnicity.js";
import { researchNode } from "../ui/card.js";
import { TechnologyTreeView } from "../ui/tech_tree_view.js";
import { resourceNode, valueNode } from "../ui/value.js";
import { lineChart } from "../ui/line_chart.js";
import { trendArrow } from "../ui/trend.js";
import { currencySymbol, currencyFor, rateToNational, formatMoney } from "../currency.js";
import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
import { MONTH_NAMES, unixTime } from "../../../shared/game_clock.js";
export class NationModal {
constructor() {
this.$modal = $("#modal-nation");
this.$governmentSub = $("#government-sub");
this.$governmentList = $("#government-list");
this.$technologySub = $("#technology-sub");
this.$technologyStrip = $("#technology-research");
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.$resourcesTabs = $("#resources-subtabs");
this.$resourcesList = $("#resources-list");
this.$resourceTradeList = $("#resource-trade-list");
// Which economy model the game runs; the simple model turns the Resources
// tab into the global market and drops the hard money sub-tabs.
this.economyModel = "hard";
// 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.$intelligenceSub = $("#intelligence-sub");
this.$intelligenceSubtabs = $("#intelligence-subtabs");
this.$intelligenceSpyView = $("#intelligence-spies-view");
this.$intelligencePropagandaView = $("#intelligence-propaganda-view");
// Which Intelligence sub-tab is open: the spy controls or the campaigns.
this.intelligenceView = "spies";
this.governments = [];
this.technologies = [];
this.currentGovernment = 0;
this.researched = new Set();
this.governmentRows = [];
// The technology tree is drawn by a self-contained view, shared with the
// stand-alone catalogue preview; the modal only feeds it the snapshot.
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.intelligence = null;
// The Intelligence tab, like Politics, is built once so the propaganda
// controls survive an arriving snapshot; only its lists refresh.
this._intelligenceBuilt = false;
this._intelligenceCiv = -1;
this._intelligenceState = null;
this.onGovernment = () => {};
this.onFocusTechnology = () => {};
this.onUnlockTechnology = () => {};
this.onDeclareWar = () => {};
this.onCampaign = () => {};
this.onCancelCampaign = () => {};
this.onSetCampaignSpending = () => {};
this.onLocateUnit = () => {};
this.onLaunchSatellite = () => {};
this.onPolicy = () => {};
this.onRevokePolicy = () => {};
this.onSetInterestRate = () => {};
this.onSetTaxRate = () => {};
// The simple economy's global market: buy or sell a lot of one good.
this.onMarketTrade = () => {};
this.onConfirm = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
bindTabs(this.$modal, "tab-");
this.techTree = new TechnologyTreeView({
graph: this.$technologyList,
strip: this.$technologyStrip,
tipFor: (index) => this._technologyTip(index),
onFocusTechnology: (index) => this.onFocusTechnology(index),
onUnlockTechnology: (index) => this.onUnlockTechnology(index),
});
}
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, intelligence = null) {
this.governments = governments;
this.technologies = technologies;
// Needed before the technology cards are built: a testing game adds the
// instant-unlock button when each card is created.
if (techInfo) this.techInfo = techInfo;
this.diplomacy = diplomacy;
this.conflicts = conflicts || [];
this.politics = politics;
this.intelligence = intelligence;
this.resources = resourcesInfo;
this.money = moneyInfo;
// The snapshot names the economy model; a direct caller that omits it keeps
// the hard rendering (the default before the simple economy existed).
this.economyModel =
(resourcesInfo && resourcesInfo.economyModel) ||
(moneyInfo && moneyInfo.economyModel) ||
this.economyModel ||
"hard";
// 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.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
this.techTree.render({ technologies: this.technologies, researched: this.researched, techInfo: this.techInfo });
this._buildResourceSubtabs();
this._buildIntelligenceSubtabs();
// Reopening the panel shows the server's rates, not an unsent edit left
// over from a previous open.
this.$economyTaxes.find(".tax-input").each((_, element) =>
this._clearTaxDirty($(element))
);
this._buildEconomySubtabs();
this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo, resourcesInfo, moneyInfo, intelligence);
// Force the treaty compose controls to rebuild their options for this open.
$("#treaty-type").data("lastType", null);
$("#treaty-target").removeAttr("data-signature");
this.$modal.removeClass("hidden");
}
// 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. The simple
// economy has neither: its Resources tab is one global market, so the sub-tab
// bar is hidden and only the market list is drawn.
_buildResourceSubtabs() {
const $tabs = this.$resourcesTabs || $("#resources-subtabs");
if (!$tabs.length) return;
$tabs.empty();
if (this.economyModel === "simple") {
$tabs.addClass("hidden");
this.resourceView = "stores";
return;
}
$tabs.removeClass("hidden");
const views = [
{ id: "stores", label: "Stores & trade" },
{ id: "leaders", label: "Major traders" },
];
for (const view of views) {
const $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();
// The simple economy has one currency, no central bank and no FX, and its
// only money movement is the market: nothing is left to split into
// sub-tabs, so the bar is hidden and the one summary is drawn.
if (this.economyModel === "simple") {
this.$economySubtabs.addClass("hidden");
this.economyView = "summary";
this._setEconomyView("summary");
return;
}
this.$economySubtabs.removeClass("hidden");
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 = $("")
.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.economyModel === "simple") {
this._renderSimpleEconomy(this.money);
return;
}
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 simple economy's one Economy view: the euro treasury and the single
// daily tax income, with the day's income sources. There is no central bank,
// no FX and no private-sector cash to report.
_renderSimpleEconomy(money) {
if (!this.$economySummary || !this.$economySummary.length) return;
const treasury = money.budget !== undefined ? money.budget : this._treasury;
const take = (money && money.takeTax) || {};
const income = take.income || 0;
if (this.$economySub && this.$economySub.length) {
this.$economySub.text(
`Treasury ${formatMoney(treasury)} · tax income ${formatMoney(income)}/day`
);
}
this.$economySummary.empty();
this.$economySummary.append(
$("
").text(
"Money is used only in the global market. Taxes are collected each day from " +
"every inhabitant, and construction and upkeep draw on your resource pool " +
"instead of the treasury."
)
);
const $table = $("
").text(formatMoney(treasury)))
);
$table.append($body);
this.$economySummary.append($table);
}
// 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(
$("").text("No resource data yet.")
);
return;
}
const consumed = summary.consumed || {};
const external = summary.external || { buy: {}, sell: {} };
const $table = $("
");
const $head = $("").append(
$("
")
.append($("
").text("Resource"))
.append($("
").text("Consumed /\u00a0day"))
.append($("
").text("Bought abroad"))
.append($("
").text("Spent"))
.append($("
").text("Sold abroad"))
.append($("
").text("Earned"))
);
const $body = $("");
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(
$("
").text(this._amount(totalEarned)))
)
);
this.$economyConsumption.append($table);
this.$economyConsumption.append(
$("").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(
$("").text("No currency data yet.")
);
return;
}
const $table = $("
").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(
$("").text(
"Production buildings are independent private agents: they keep what " +
"their output sells for, and spend it upgrading when demand has grown."
)
);
const $table = $("
");
const $head = $("").append(
$("
")
.append($("
").text("Production building"))
.append($("
").text("Output /\u00a0day"))
.append($("
").text("Level"))
.append($("
").text("Private cash"))
);
const $body = $("");
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
? $("
").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(
$("").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 = $("
");
const $head = $("
");
$head.append($("
").text("Tax"));
$head.append($("
").text("Default"));
for (const proto of RESOURCES) {
$head.append($("
").text(proto.name));
}
$head.append($("
").text(""));
$table.append($("").append($head));
// One box: a themed stepper whose +/- buttons never sit on the digits,
// with the percent sign as a suffix. The input keeps the `tax-input`
// classes the refresh and the tests address it by.
const makeField = (kind, resource) => {
const $input = $("")
.addClass("tax-input")
.attr("data-kind", kind);
if (resource) $input.addClass("tax-override").attr("data-resource", resource);
const $dec = $("")
.attr("data-dir", "-1").text("−");
const $inc = $("")
.attr("data-dir", "1").text("+");
$input.on("input", () => this._markTaxDirty($input));
$dec.on("click", () => this._stepTaxField($input, -1));
$inc.on("click", () => this._stepTaxField($input, 1));
return $("")
.append($dec, $input, $("").text("%"), $inc);
};
const $body = $("");
for (const kind of kinds) {
const $tr = $("
");
$tr.append($("
").text(kind.label));
$tr.append($("
").append(makeField(kind.id, null)));
for (const proto of RESOURCES) {
$tr.append($("
").append($apply));
$body.append($tr);
}
$table.append($body);
this.$economyTaxes.append($table);
this._taxesBuilt = true;
}
// Refresh the boxes from the snapshot. The box being typed in, and any box
// the player has edited but not yet had confirmed, is left alone so the
// 10 Hz snapshot stream cannot undo work in progress.
const active = document.activeElement;
this.$economyTaxes.find(".tax-input").each((_, element) => {
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;
const text = value === undefined || value === null ? "" : (value * 100).toFixed(2);
if ($input.data("dirty")) {
// Release the box once the server agrees, then show its clean value.
const shown = value === undefined || value === null ? null : value * 100;
if (element !== active && shown !== null && Math.abs(Number($input.val()) - shown) < 0.005) {
this._clearTaxDirty($input);
$input.val(text);
}
return;
}
if (element === active) return;
$input.val(text);
});
}
// Marks a tax box as holding an unsent edit so the snapshot refresh leaves it
// alone; the amber outline tells the player it is not confirmed yet.
_markTaxDirty($input) {
$input.data("dirty", true);
$input.closest(".pct-stepper").addClass("unsaved");
}
_clearTaxDirty($input) {
$input.data("dirty", false);
$input.closest(".pct-stepper").removeClass("unsaved");
}
// One click of a stepper button nudges the rate by a whole percent.
_stepTaxField($input, delta) {
const current = Number($input.val());
const base = Number.isFinite(current) ? current : 0;
const next = Math.max(0, Math.min(100, base + delta));
$input.val(next.toFixed(2));
this._markTaxDirty($input);
}
// 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();
// A blank box has nothing to send (there is no "remove override" order),
// so drop the edit and let the next snapshot refill it.
if (raw === "" || raw === null || raw === undefined) {
this._clearTaxDirty($input);
return;
}
const percent = Number(raw);
if (!Number.isFinite(percent) || percent < 0) return;
const clamped = Math.max(0, Math.min(100, percent));
if (clamped !== percent) $input.val(clamped.toFixed(2));
this.onSetTaxRate(kind, clamped / 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, the world commodity price index and its monthly and yearly
// inflation, and the foreign reserves it has accumulated.
_renderCentralBank(money) {
if (!this.$economyCentralBank || !this.$economyCentralBank.length) return;
const bank = money.centralBank || { rate: 0, reserves: [] };
const entries = money.resourceInflation || [];
const average = (field) => entries.length
? entries.reduce((sum, entry) => sum + (entry[field] || 0), 0) / entries.length
: 0;
const percent = (value) =>
`${value >= 0 ? "+" : "−"}${(Math.abs(value) * 100).toFixed(2)}%`;
const monthly = average("monthly");
const yearly = average("yearly");
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 ${percent(monthly)}/month, ${percent(yearly)}/year`
);
}
if (!this._centralBankBuilt) {
this.$economyCentralBank.empty();
const $rate = $("");
$rate.append($("").text("Interest rate (% per year)"));
const $input = $("");
const $apply = $("").text("Set");
const apply = () => {
const percentValue = Number($input.val());
if (!Number.isFinite(percentValue) || percentValue < 0) return;
this.onSetInterestRate(percentValue / 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(
$("").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($(""));
this.$economyCentralBank.append($(""));
this.$economyCentralBank.append($(""));
const $reserves = $("");
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: ${percent(monthly)} over the month and ${percent(yearly)} ` +
"over the year — the average move in the world's commodity prices."
);
this._renderCommodityChart(money);
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($("").text("Foreign reserves"));
if ((bank.reserves || []).length === 0) {
$reserves.append($("").text("No foreign currency held yet."));
return;
}
const $table = $("
");
const $body = $("");
for (const reserve of bank.reserves) {
$body.append(
$("
").text(
groupDigits(Math.round(reserve.amount || 0))
)
)
);
}
$table.append($body);
$reserves.append($table);
}
// The commodity price index chart and the per-commodity inflation table. The
// server ships each commodity's world price already rebased to 100 in January
// 2000, one point per day. The plot has a fixed ninety-day axis, so a young
// game leaves its future days blank rather than stretching a short line across
// the width; once the series is full it rolls, dropping the oldest day. The
// axis is shaded in alternating month columns, each named once along the
// bottom, so no exact date has to compete for space at the ends.
_renderCommodityChart(money) {
const $container = this.$economyCentralBank.find(".central-bank-prices");
if (!$container.length) return;
const history = money.resourcePriceHistory || [];
const inflation = new Map((money.resourceInflation || []).map((entry) => [entry.id, entry]));
const series = [];
const rows = [];
let count = 0;
for (const [id, prices] of history) {
const resource = resourceById(id);
if (!resource || !prices || prices.length === 0) continue;
const values = prices.slice(-PRICE_INDEX_WINDOW_DAYS);
count = Math.max(count, values.length);
series.push({ label: resource.name, colour: resource.colour, values });
rows.push({ resource, info: inflation.get(id) || { index: 100, monthly: 0, yearly: 0 } });
}
$container.empty();
$container.append($("").text("Commodity prices"));
$container.append(
$("").text(
"Each commodity's world price as an index (100 in January 2000), " +
"one point a day over the last ninety days."
)
);
if (series.length === 0) return;
// The window's first day: the chart's last point is today, so the leftmost
// slot is that many days back. Slots past the newest point are future days.
const lastDay = Math.floor((money.totalHours || 0) / HOURS_PER_DAY);
const firstDay = lastDay - (count - 1);
// A slot's calendar month, keyed year*12+month so a band breaks only when
// the month really changes (and across a year boundary).
const slotDate = (i) => new Date(unixTime((firstDay + i) * HOURS_PER_DAY) * 1000);
const slotMonth = (i) => {
const date = slotDate(i);
return date.getUTCFullYear() * 12 + date.getUTCMonth();
};
// One band per month in the fixed window, named once at the bottom. A
// partial month at either edge still gets a full column and its name.
const xBands = [];
let bandStart = 0;
for (let i = 1; i <= PRICE_INDEX_WINDOW_DAYS; i++) {
if (i < PRICE_INDEX_WINDOW_DAYS && slotMonth(i) === slotMonth(bandStart)) continue;
xBands.push({
start: bandStart,
end: i - 1,
label: MONTH_NAMES[slotDate(bandStart).getUTCMonth()].slice(0, 3),
});
bandStart = i;
}
const options = {
window: PRICE_INDEX_WINDOW_DAYS,
min: 0,
xBands,
};
// The panel scrolls, but the chart must not swallow it: size it to the
// panel's width and cap it at half the visible height, so the inflation
// table below stays a short scroll away. A hidden panel measures zero, so
// leave the defaults there and let the next refresh size it properly.
const $panel = $container.closest(".modal-body");
const width = $panel.length ? $panel.width() : 0;
const height = $panel.length ? $panel.height() : 0;
if (width > 0) {
options.width = width;
options.height = Math.min(width * 0.45, height > 0 ? height / 2 : width * 0.45);
}
const chart = lineChart(series, options);
$container.append(chart);
const percent = (value) =>
`${value >= 0 ? "+" : "−"}${(Math.abs(value) * 100).toFixed(2)}%`;
const $table = $("
");
$table.append(
$("").append(
$("
")
.append($("
").text("Commodity"))
.append($("
").text("Index"))
.append($("
").text("Monthly"))
.append($("
").text("Yearly"))
)
);
const $body = $("");
for (const row of rows) {
$body.append(
$("
").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();
// The simple economy's Resources tab is a global market: one row per
// storable good with the pool, the world stock, the price and Buy/Sell
// lots. There is no per-region store table and no major-traders ranking.
if (this.economyModel === "simple") {
this._renderMarket(info || {});
if (this.$resourceTradeList) this.$resourceTradeList.empty().addClass("hidden");
return;
}
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($("").text("No resource data."));
} else {
this._renderResourceStores(info, summary);
}
this._renderResourceLeaders(info);
this._setResourceView(this.resourceView);
}
// The simple economy's market: the viewer's pool, the world stock and price
// of each storable good, and the three buy/sell lots. Everything is priced
// from the one world stock, so a purchase the treasury cannot afford or a
// sale the pool cannot cover is disabled rather than sent to be refused.
_renderMarket(info) {
const summary = info.summary || {};
const pool = summary.stock || {};
const market = new Map((info.market || []).map((entry) => [entry.id, entry]));
const budget = info.budget || 0;
if (this.$resourcesSub && this.$resourcesSub.length) {
this.$resourcesSub.text(
`Treasury ${formatMoney(budget)} · your pool is available in every region`
);
}
this.$resourcesList.append(
$("").text(
"The global market is shared by every nation: buying drains the world stock and " +
"raises the price, selling refills it and lowers the price. Goods go into your " +
"national pool, which every region can draw on."
)
);
const $table = $("
");
const $head = $("").append(
$("
")
.append($("
").text("Resource"))
.append($("
").text("In pool"))
.append($("
").text("World stock"))
.append($("
").text("Price"))
.append($("
").text("Buy"))
.append($("
").text("Sell"))
);
const $body = $("");
for (const proto of RESOURCES) {
if (proto.id === "energy") continue;
const id = proto.id;
const stats = market.get(id) || {};
const held = pool[id] || 0;
const stock = stats.stock;
const price = stats.price || 0;
const $row = $("
").append(
this._marketControls(id, "sell", held, price, budget, stock)
));
$body.append($row);
}
$table.append($head, $body);
this.$resourcesList.append($table);
this.$resourcesList.append(
$("").text(
"Selling takes goods from your pool; buying adds to it. A lot the treasury " +
"cannot afford or the pool cannot cover is disabled."
)
);
}
// The Buy or Sell buttons for one good, one per lot. The cost/revenue is
// checked against the treasury and the pool up front so a disabled button
// explains itself instead of sending an order the server would refuse.
_marketControls(id, side, held, price, budget, stock) {
const $group = $("");
for (const lot of MARKET_LOTS) {
const buying = side === "buy";
const cost = lot * price;
const affordable = buying
? budget + 1e-9 >= cost && stock !== null && stock !== undefined && stock + 1e-9 >= lot
: held + 1e-9 >= lot;
const $button = $("")
.text(compact(lot))
.prop("disabled", !affordable);
$button.attr(
"title",
buying
? `Buy ${groupDigits(lot)} for ${formatMoney(cost)}`
: `Sell ${groupDigits(lot)} for ${formatMoney(cost)}`
);
if (affordable) {
$button.on("click", () => this.onMarketTrade(id, side, lot));
}
$group.append($button);
}
return $group;
}
_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 template = document.getElementById("tpl-resource-store-row");
const $table = $("
").text(
importer ? formatResourceAmount(proto.id, importer.amount) : "—"
)
);
$body.append($tr);
}
$table.append($head, $body);
this.$resourceTradeList.append($table);
this.$resourceTradeList.append(
$("").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.
_resourcePriceNode(proto, price) {
const value = price === undefined || price === null ? 0 : price;
const decimals = value >= 1 ? 0 : 4;
return valueNode(value, { unit: "money", suffix: ` /\u00a0${proto.unit}`, decimals });
}
_makeRow($list, proto, index, kind) {
const $card = $("");
const $info = $("");
const $name = $("");
const $desc = $("").text(proto.description || "");
const $effects = $("");
const $cost = $("");
$info.append($name, $desc, $effects, $cost);
const $actions = $("");
const $action = $("");
$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 };
}
// The hover detail for a technology card: its description and what its next
// level yields. Outstanding requirements are traced on the tree itself rather
// than listed here.
_technologyTip(index) {
const proto = this.technologies[index];
// A repeatable's next level is what is being weighed, so its diminishing
// gain is shown rather than the level already held.
const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0;
const level = proto.repeatable ? repeat + 1 : 1;
const sections = [];
if (proto.description) {
sections.push({ label: "Research", cls: "tip-produce", sign: "+", suffix: "", items: [{ text: proto.description }] });
}
sections.push({
label: proto.repeatable ? `Next level (${repeat + 1})` : "Effect",
cls: "tip-produce",
sign: "+",
suffix: "",
items: [{ node: effectNodes(proto.effects, level) }],
});
return sections;
}
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null, intelligence = null) {
this.currentGovernment = currentGovernment;
// A snapshot rebuilds the open panels in place; hold each scroll container
// where the player left it so the panel does not jump back to the top under
// them (the central bank's foreign reserves sit below the fold).
const scrolls = this.$modal.find(".modal-body")
.map((_, el) => ({ el, top: el.scrollTop }))
.get();
if (techInfo) this.techInfo = techInfo;
if (resourcesInfo) {
this.resources = resourcesInfo;
if (resourcesInfo.economyModel) this.economyModel = resourcesInfo.economyModel;
}
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;
if (intelligence !== null) this.intelligence = intelligence;
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, with the erlenmeyer icon
// standing in for the word "research".
this.$technologySub
.empty()
.append(document.createTextNode("Research: "))
.append(researchNode((researchRate || 0) * HOURS_PER_DAY, { sign: "+", decimals: 1 }))
.append(document.createTextNode("/day"));
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.empty().append(effectNodes(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);
}
}
this.techTree.refresh({ researched: this.researched, techInfo: this.techInfo });
this._renderDiplomacy();
this._renderConflicts();
this._renderPolitics();
this._renderIntelligence();
for (const entry of scrolls) entry.el.scrollTop = entry.top;
}
// 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;
// Which nations a connected player commands. Everything else is run by the
// computer, so the table can say who is human and name the leader.
const controllers = new Map();
for (const player of Object.values(info.players || {})) {
if (!player || !Number.isInteger(player.civ) || player.civ < 0) continue;
controllers.set(player.civ, player.name || "");
}
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;
const human = controllers.has(index);
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,
human,
leader: human ? controllers.get(index) : "",
});
}
this._sortDiplomacyRows(rows);
const columns = [
{ key: "name", label: "Nation" },
{ key: "control", label: "Control" },
{ key: "leader", label: "Leader" },
{ key: "gdp", label: "GDP", numeric: true },
{ key: "gdpPerCapita", label: "GDP /\u00a0capita", numeric: true },
{ key: "population", label: "Population", numeric: true },
{ key: "approval", label: "Approval", numeric: true },
{ key: "status", label: "Status" },
];
const $table = $("
");
const $head = $("");
const $headRow = $("
");
for (const column of columns) {
const $th = $("
");
if (row.self) {
$status.append($("").text("Your nation"));
} else {
$status.append(
$("").text(row.atWar ? "At war" : "At peace")
);
const $action = $("");
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);
this._renderTreaties(info);
}
// The treaty controls: the compose row (type, target, payload), the offers
// waiting for an answer, and the active and past treaties. The compose row
// lives outside the table the snapshot refresh rebuilds, so a half-filled
// offer is not wiped by the next broadcast.
_renderTreaties(info) {
if (!info) return;
this._bindTreatyCompose(info);
const name = (index) => (info.civs[index] ? info.civs[index].name : `Nation ${index + 1}`);
const localCiv = info.localCiv;
const labelOf = (type) => {
const proto = treatyById(type);
return proto ? proto.name : type;
};
const $pending = $("#treaty-pending").empty();
for (const proposal of info.treatyProposals || []) {
const $row = $("");
if (proposal.to === localCiv) {
$row.append($("").text(
`${name(proposal.from)} proposes ${labelOf(proposal.type)}`
));
const $sign = $("");
$sign.on("click", () => this.onRespondTreaty && this.onRespondTreaty(proposal.id, true));
const $reject = $("");
$reject.on("click", () => this.onRespondTreaty && this.onRespondTreaty(proposal.id, false));
$row.append($sign, $reject);
} else if (proposal.from === localCiv) {
$row.append($("").text(
`You proposed ${labelOf(proposal.type)} to ${name(proposal.to)} — awaiting reply`
));
} else {
$row.append($("").text(
`${name(proposal.from)} proposes ${labelOf(proposal.type)} to ${name(proposal.to)}`
));
}
$pending.append($row);
}
const $active = $("#treaty-active").empty();
const active = info.treaties || [];
if (active.length === 0) $active.append($("").text("No treaties in force."));
for (const treaty of active) {
const $row = $("");
$row.append($("").text(
`${name(treaty.a)} — ${name(treaty.b)}: ${labelOf(treaty.type)} (signed ${treaty.signedDate})`
));
if (treaty.a === localCiv || treaty.b === localCiv) {
const $break = $("");
$break.on("click", () => this.onCancelTreaty && this.onCancelTreaty(treaty.id));
$row.append($break);
}
$active.append($row);
}
const $past = $("#treaty-past").empty();
const past = (info.treatyHistory || []).slice().reverse();
if (past.length === 0) $past.append($("").text("No past treaties."));
for (const treaty of past) {
$past.append($("
").each(function () {
$(this).find(".treaty-text").text(
`${name(treaty.a)} — ${name(treaty.b)}: ${labelOf(treaty.type)} (${treaty.reason} ${treaty.endedDate})`
);
}));
}
}
// Populates the compose controls once per opened panel. The target list only
// needs rebuilding when the nations themselves change.
_bindTreatyCompose(info) {
const $type = $("#treaty-type");
if (!$type.length) return;
if ($type.children().length === 0) {
for (const proto of TREATIES) {
$type.append($("").attr("value", proto.id).text(proto.name));
}
}
const signature = (info.civs || []).map((civ) => civ.name).join("|");
const $target = $("#treaty-target");
if ($target.attr("data-signature") !== signature) {
$target.empty();
for (let index = 0; index < (info.civs || []).length; index++) {
if (index === info.localCiv) continue;
$target.append($("").attr("value", index).text(info.civs[index].name));
}
$target.attr("data-signature", signature);
}
if (!$type.data("treatyBound")) {
$type.data("treatyBound", true);
$type.on("change", () => {
$type.data("lastType", $type.val());
this._renderTreatyPayload(info);
});
$("#treaty-propose").on("click", () => this._treatyPayloadFromForm(info));
}
// Only rebuild the payload controls when the type changes, so the snapshot
// refresh does not wipe a partly filled offer on every broadcast.
const current = $type.val();
if ($type.data("lastType") !== current) {
$type.data("lastType", current);
this._renderTreatyPayload(info);
}
}
// Rebuilds the per-type payload controls: a city picker, a money amount, a
// resource basket or a checklist of giftable units.
_renderTreatyPayload(info) {
const type = $("#treaty-type").val();
const $payload = $("#treaty-payload").empty();
if (type === "give_city") {
const $select = $("");
for (const city of info.localCities || []) {
$select.append($("").attr("value", city.id).text(city.name));
}
if (!(info.localCities || []).length) {
$select.append($("").attr("value", "").text("No city to give"));
}
$payload.append($select);
} else if (type === "give_money") {
$payload.append(
$("")
);
} else if (type === "give_resources") {
for (const id of STORABLE_RESOURCE_IDS) {
const proto = resourceById(id);
const $label = $("");
$label.append($("").text(proto ? proto.name : id));
$label.append(
$("").attr("data-id", id)
);
$payload.append($label);
}
} else if (type === "give_military") {
const options = (info.localUnits || []).filter(
(unit) => !((info.unitProtos || {})[unit.proto] || {}).infantry
);
for (const unit of options) {
const proto = (info.unitProtos || {})[unit.proto] || {};
const $label = $("");
$label.append($("").attr("value", unit.id));
$label.append($("").text(proto.name || `Unit ${unit.id}`));
$payload.append($label);
}
if (options.length === 0) {
$payload.append($("").text("No non-infantry unit to give"));
}
}
}
// Reads the compose controls into a treaty order. Returns null when the offer
// is incomplete, so an empty form sends nothing.
_treatyPayloadFromForm(info) {
const type = $("#treaty-type").val();
const to = Number($("#treaty-target").val());
if (!info || Number.isNaN(to) || to === info.localCiv) return null;
const payload = {};
if (type === "give_city") {
const city = Number($("#treaty-payload .treaty-city").val());
if (!city) return null;
payload.city = city;
} else if (type === "give_money") {
const amount = Number($("#treaty-payload .treaty-money").val());
if (!(amount > 0)) return null;
payload.amount = amount;
} else if (type === "give_resources") {
const resources = {};
$("#treaty-payload .treaty-resource").each((_, element) => {
const amount = Number($(element).val());
if (amount > 0) resources[$(element).data("id")] = amount;
});
if (Object.keys(resources).length === 0) return null;
payload.resources = resources;
} else if (type === "give_military") {
const units = [];
$("#treaty-payload .treaty-unit:checked").each((_, element) => {
units.push(Number($(element).val()));
});
if (units.length === 0) return null;
payload.units = units;
}
if (this.onProposeTreaty) this.onProposeTreaty(type, to, payload);
return payload;
}
// A nation's money figure, in that nation's own currency, with the viewer's
// equivalent at the snapshot's instant rate in a discreet parenthesis beside
// it. Our own figures are already in our money, so they carry no conversion.
_moneyCell(value, civ, self) {
const $td = $("
");
const info = self ? null : currencyFor(civ);
if (!info) {
$td.text(`${currencySymbol()}${compact(value)}`);
return $td;
}
$td.append(
document.createTextNode(`${info.symbol}${compact(value * rateToNational(civ))}`)
);
$td.append(
$("").text(` (${currencySymbol()}${compact(value)})`)
);
return $td;
}
// 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 === "leader") {
result = (a.leader || "").localeCompare(b.leader || "");
} else if (key === "control") result = (a.human ? 1 : 0) - (b.human ? 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(
$("").text("The world is at peace.")
);
return;
}
for (const conflict of conflicts) this._makeConflictRow(conflict);
}
_makeConflictRow(conflict) {
const $card = $("");
const $info = $("");
const $name = $("").text(
`${this._civName(conflict.a)} vs ${this._civName(conflict.b)}`
);
const $desc = $("").text(`Ongoing since ${conflict.started}`);
$info.append($name, $desc);
const key = `${conflict.a}:${conflict.b}`;
const $detail = $("");
if (!this.expandedConflicts.has(key)) $detail.addClass("hidden");
$detail.append(
$("").text(`Conflict started ${conflict.started}`)
);
for (const side of conflict.sides || []) {
$detail.append(
$("").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 = {
policy: { type: POLICIES[0].id, ethnicity: this._defaultEthnicity(civs, localCiv) },
opinionObserver: localCiv,
};
this.$politicsMakeup = $("");
this.$politicsMigrations = $("");
this.$politicsList.append(
this.$politicsMakeup,
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($("").text("Your people"));
const makeup = (info.viewerStats && info.viewerStats.ethnicMakeup) || [];
if (makeup.length > 0) this.$politicsMakeup.append(ethnicityChart(makeup, civs));
else this.$politicsMakeup.append($("").text("No population data."));
this._fillMigrations(info, civs, localCiv);
this._fillActivePolicies(info, civs, localCiv);
this._syncPolicyWizard();
this._fillOpinions();
}
// The Intelligence tab's sub-tabs, built once per open: the spy controls and
// the propaganda campaigns. Switching only shows and hides the two views.
_buildIntelligenceSubtabs() {
if (!this.$intelligenceSubtabs || !this.$intelligenceSubtabs.length) return;
this.$intelligenceSubtabs.empty();
const views = [
{ id: "spies", label: "Spies" },
{ id: "propaganda", label: "Propaganda" },
];
for (const view of views) {
const $button = $("")
.attr("data-view", view.id)
.text(view.label);
if (view.id === this.intelligenceView) $button.addClass("active");
$button.on("click", () => this._setIntelligenceView(view.id));
this.$intelligenceSubtabs.append($button);
}
this._setIntelligenceView(this.intelligenceView);
}
_setIntelligenceView(view) {
this.intelligenceView = view;
if (this.$intelligenceSubtabs) {
this.$intelligenceSubtabs.find(".subtab").removeClass("active");
this.$intelligenceSubtabs.find(`.subtab[data-view="${view}"]`).addClass("active");
}
if (this.$intelligenceSpyView) {
this.$intelligenceSpyView.toggleClass("hidden", view !== "spies");
}
if (this.$intelligencePropagandaView) {
this.$intelligencePropagandaView.toggleClass("hidden", view !== "propaganda");
}
}
// The Intelligence tab: the spy roster and the propaganda campaigns. Spies'
// covert operations are unit actions and live in the unit panel, not here.
// Built once per open like Politics, so its controls survive the snapshots
// that arrive while the tab is open.
_renderIntelligence() {
if (!this.$intelligenceSpyView || !this.$intelligenceSpyView.length) return;
const info = this.intelligence;
if (!info) return;
const civs = info.civs || [];
const localCiv = info.localCiv;
if (!this._intelligenceBuilt || this._intelligenceCiv !== localCiv) {
this._buildIntelligence(info, civs, localCiv);
this._intelligenceBuilt = true;
this._intelligenceCiv = localCiv;
}
this._refreshIntelligence(info, civs, localCiv);
}
_buildIntelligence(info, civs, localCiv) {
this.$intelligenceSpyView.empty();
this.$intelligencePropagandaView.empty();
this._intelligenceState = {
campaign: {
observer: localCiv,
kind: "government",
target: this._otherCiv(localCiv, civs),
direction: 1,
},
};
this._campaignSignature = null;
this._campaignRows = new Map();
this._spySignature = null;
this._spyRows = new Map();
this._reportsSignature = null;
this.$intelligenceSpies = $("");
this.$intelligenceCampaigns = $("");
this.$intelligenceSpyView.append(
this._buildSpiesCard(),
this._buildReportsCard(),
this._buildSatellitesCard()
);
this.$intelligencePropagandaView.append(
this.$intelligenceCampaigns,
this._buildCampaignForm(civs)
);
this._setIntelligenceView(this.intelligenceView);
}
_refreshIntelligence(info, civs, localCiv) {
this.$intelligenceSub.text(
"Your spies and the propaganda campaigns they serve."
);
this._fillSpies(info, localCiv);
this._fillReports(info);
this._fillSatellites(info);
this._fillCampaigns(info, localCiv);
this._syncCampaignReadout();
this._syncCampaignForm();
}
// The spy roster: one row per spy in the field, with a Locate button that
// selects it and brings the map to it. Rows are only rebuilt when the roster
// (or a spy's whereabouts) changes, so an arriving snapshot does not yank a
// button out from under the cursor.
_buildSpiesCard() {
this.$intelligenceSpies.empty();
this.$intelligenceSpies.append($("").text("Your spies"));
this.$intelligenceSpyList = $("");
this.$intelligenceSpies.append(this.$intelligenceSpyList);
return this.$intelligenceSpies;
}
_fillSpies(info, localCiv) {
if (!this.$intelligenceSpyList) return;
const spies = info.spies || [];
const signature = spies
.map((spy) => `${spy.id}:${spy.coords[0]},${spy.coords[1]}:${spy.location}`)
.join("|");
if (signature === this._spySignature) return;
this._spySignature = signature;
this.$intelligenceSpyList.empty();
this._spyRows = new Map();
if (spies.length === 0) {
this.$intelligenceSpyList.append(
$("").text(
this._hasResearched("intelligence_agencies")
? "No spies in the field. Train one in a city."
: "Research Intelligence Agencies to train spies."
)
);
return;
}
spies.forEach((spy, index) => {
const $row = $("");
$row.append($("").text(`Spy ${index + 1} — ${spy.location}`));
const $locate = $("").text("Locate");
$locate.on("click", () => this.onLocateUnit(spy.id));
$row.append($("").append($locate));
this.$intelligenceSpyList.append($row);
this._spyRows.set(spy.id, $row);
});
}
// The reports the viewer's spies have brought home: city dossiers and
// intercepted communications. Rebuilt only when a report id changes.
_buildReportsCard() {
this.$intelligenceReports = $("");
this.$intelligenceReports.append($("").text("Intelligence reports"));
this.$intelligenceReportsList = $("");
this.$intelligenceReports.append(this.$intelligenceReportsList);
return this.$intelligenceReports;
}
_fillReports(info) {
if (!this.$intelligenceReportsList) return;
const reports = (info.reports || []).slice().reverse();
const signature = reports.map((report) => report.id).join(",");
if (signature === this._reportsSignature) return;
this._reportsSignature = signature;
this.$intelligenceReportsList.empty();
if (reports.length === 0) {
this.$intelligenceReportsList.append(
$("").text("No reports yet. Send a spy to watch a foreign city.")
);
return;
}
for (const report of reports) this._buildReportRow(report);
}
_buildReportRow(report) {
const owner = this._civName(report.civ);
const $row = $("");
if (report.kind === "comms") {
$row.append($("").text(`${owner} communications from ${report.capital}`));
if ((report.messages || []).length === 0) {
$row.append($("").text("No messages intercepted."));
}
for (const message of report.messages || []) {
const from = message.from === null ? "public" : this._civName(message.from);
const to = message.to === null ? "public" : this._civName(message.to);
$row.append(
$("").text(`${from} → ${to}: ${message.text}`)
);
}
} else {
$row.append($("").text(`${owner} city of ${report.city}`));
$row.append(
$("").text(
`Population ${groupDigits(Math.round(report.population || 0))}`
)
);
if ((report.buildings || []).length > 0) {
$row.append(
$("").text(
`Buildings: ${report.buildings.map((b) => `${b.name} ${b.level}`).join(", ")}`
)
);
}
if ((report.garrison || []).length > 0) {
$row.append(
$("").text(
`Garrison: ${report.garrison.map((g) => `${g.name} ×${g.count}`).join(", ")}`
)
);
} else {
$row.append($("").text("Garrison: none seen."));
}
}
this.$intelligenceReportsList.append($row);
}
// The spy satellites in orbit, and the launch control for a nation that holds
// a space launch centre.
_buildSatellitesCard() {
this.$intelligenceSatellites = $("");
this.$intelligenceSatellites.append($("").text("Spy satellites"));
this.$intelligenceSatelliteStatus = $("");
this.$intelligenceSatelliteList = $("");
this.$intelligenceSatelliteLaunch = $("").text(
"Launch spy satellite"
);
this.$intelligenceSatelliteLaunch.on("click", () => {
const city = this.intelligence && this.intelligence.launchCity;
if (city) this.onLaunchSatellite(city.id);
});
this.$intelligenceSatellites.append(
this.$intelligenceSatelliteStatus,
this.$intelligenceSatelliteList,
$("").append(this.$intelligenceSatelliteLaunch)
);
return this.$intelligenceSatellites;
}
_fillSatellites(info) {
if (!this.$intelligenceSatelliteList) return;
const satellites = info.satellites || [];
const capacity = info.spaceLaunchCapacity || 0;
const totalHours = info.totalHours || 0;
this.$intelligenceSatelliteList.empty();
if (satellites.length === 0) {
this.$intelligenceSatelliteList.append(
$("").text(
capacity > 0
? "No satellites in orbit."
: "Build a space launch centre to launch spy satellites."
)
);
} else {
satellites.forEach((satellite, index) => {
const hours = Math.max(0, (satellite.nextRevealHour || 0) - totalHours);
const days = Math.ceil(hours / 24);
this.$intelligenceSatelliteList.append(
$("").text(
`Satellite ${index + 1} — next sweep in ${days} day${days === 1 ? "" : "s"}`
)
);
});
}
this.$intelligenceSatelliteStatus.text(
info.otherSatellites
? `${info.otherSatellites} foreign satellite${info.otherSatellites === 1 ? "" : "s"} in orbit.`
: ""
);
const can = capacity > satellites.length && !!info.launchCity;
this.$intelligenceSatelliteLaunch
.prop("disabled", !can)
.attr(
"title",
can ? "Everyone will see the launch" : "No free launch pad"
);
}
// Whether the viewer has researched the technology with the given id.
_hasResearched(id) {
const index = this.technologies.findIndex((tech) => tech.id === id);
return index >= 0 && this.researched.has(index);
}
// 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.$intelligenceCampaigns.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.$intelligenceCampaigns.empty();
this._campaignRows = new Map();
if (campaigns.length > 0) {
this.$intelligenceCampaigns.append($("").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 / HOURS_PER_DAY);
return (
`${currencySymbol()}${groupDigits(Math.round(this._quoted(hourly) * HOURS_PER_DAY))}/day · ${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 = $("");
$row.append(
$("").text(
`${this._civName(campaign.civ)}: ${verb} the ${observer} people's view of the ` +
`${target} ${onWhat}`
)
);
const $cost = $("");
$row.append($cost);
const row = { $cost };
if (campaign.civ === localCiv) {
const $slider = $("").attr({
min: 0,
max: PROPAGANDA.maxHourlyCost,
step: 1000,
value: campaign.hourlyCost,
});
const $spend = $("").text(
`${currencySymbol()}${groupDigits(Math.round(this._quoted(campaign.hourlyCost) * HOURS_PER_DAY))}/day`
);
$slider.on("input", () =>
$spend.text(
`${currencySymbol()}${groupDigits(Math.round(this._quoted(Number($slider.val())) * HOURS_PER_DAY))}/day`
)
);
$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(Math.round(this._quoted(campaign.hourlyCost) * HOURS_PER_DAY))}/day`
);
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 = $("").append(
$("").text("Spending"),
$slider,
$spend
);
$row.append($controls);
row.$slider = $slider;
row.$spend = $spend;
const $cancel = $("").text("Cancel campaign");
$cancel.on("click", () => this.onCancelCampaign(campaign.id));
$row.append($("").append($cancel));
}
this.$intelligenceCampaigns.append($row);
this._campaignRows.set(campaign.id, row);
}
// The campaign launcher, built once so its selects survive a snapshot.
_buildCampaignForm(civs) {
const campaign = this._intelligenceState.campaign;
const $card = $("");
$card.append($("").text("Launch a propaganda campaign"));
$card.append(
$("").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(
$("").text(
`Costs ${currencySymbol()}${groupDigits(Math.round(this._quoted(PROPAGANDA.defaultHourlyCost) * HOURS_PER_DAY))}/day 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 = $("").append(
$("").text("their government"),
$("").text("their population")
);
$kind.val(campaign.kind);
const $target = this._civSelect(civs, campaign.target);
const $direction = $("").append(
$("").text("Warm opinion"),
$("").text("Cool opinion")
);
$direction.val(String(campaign.direction));
const $readout = $("");
this.$campaignReadout = $readout;
const sync = () => {
this._intelligenceState.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 = $("");
$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 = $("").text("Start campaign");
this.$campaignStart = $start;
$start.on("click", () => {
const state = this._intelligenceState.campaign;
this.onCampaign(state.observer, state.kind, state.target, state.direction);
});
this.$campaignDuplicate = $("");
$controls.append($("").append($start, $readout));
$card.append($controls, this.$campaignDuplicate);
return $card;
}
_syncCampaignReadout() {
if (!this.$campaignReadout) return;
const info = this.intelligence;
if (!info) return;
const state = this._intelligenceState.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.intelligence;
if (!info) return;
const state = this._intelligenceState.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 = $("");
$card.append($("").text("Minority policies"));
$card.append(
$("").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 = $("");
$card.append(this.$politicsActive);
this.$politicsNew = $("").text(
"New minority policy"
);
this.$politicsWizard = $("");
this.$politicsNew.on("click", () => {
this._politicsWizardOpen = !this._politicsWizardOpen;
this.$politicsWizard.toggleClass("hidden", !this._politicsWizardOpen);
});
const policy = this._politicsState.policy;
const $type = $("");
for (const proto of POLICIES) {
$type.append($("").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.$policyDesc = $("");
this.$policyTarget = $("");
this.$policyCost = $("");
this.$policyDuplicate = $("");
this.$policyEnact = $("").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 = $("");
$controls.append(this._labelled("Policy", $type));
$controls.append(this._labelled("Ethnicity", $eth));
$controls.append($("").append(this.$policyEnact));
this.$politicsWizard.append(
$controls,
this.$policyDesc,
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.$policyDesc.text(proto ? proto.description : "");
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(
$("").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 = $("");
$row.append($("").text(`${proto ? proto.name : policy.type} — ${people}`));
const $revoke = $("").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(
$("").text("Migration (last day)")
);
const describe = (flows, sign) => {
for (const [ethnicity, amount] of flows) {
this.$politicsMigrations.append(
$("").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 = $("");
$card.append($("").text("Opinions by population"));
$card.append(
$("").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 = $("");
const $controls = $("");
$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($("").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($("").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 = $("")
.css("width", `${pct}%`)
.toggleClass("negative", pct < 50);
const $bar = $("").append($fill);
return $("")
.append($("").text(label))
.append($bar)
.append($("").text(`${pct}%`));
}
_civSelect(civs, selected) {
const $select = $("");
civs.forEach((civ, index) => {
const label = civ.demonym ? `The ${civ.demonym}` : civ.name;
const $option = $("").attr("value", index).text(label);
if (index === selected) $option.prop("selected", true);
$select.append($option);
});
return $select;
}
_labelled(label, $control) {
return $("")
.append($("").text(label))
.append($control);
}
close() {
this.$modal.addClass("hidden");
}
}