Files
Battle-for-Tismo/client/js/modals/nation.js
T
adrien da11e1d97d Showed the culture a propaganda campaign drains
The simple economy pays for propaganda out of the nation's culture stock and lapses a campaign it can no longer fund, but the tab quoted money and never named the store. Give culture the clapperboard icon as a non-tradeable resource like research, read the stock and the campaigns' daily drain out on the Propaganda tab, and price every campaign figure in culture when the simple economy runs.
2026-09-28 10:15:22 +02:00

2820 lines
116 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,
technologyCost,
RESOURCES,
RESOURCE_IDS,
STORABLE_RESOURCE_IDS,
MARKET_LOTS,
resourceById,
PRICE_INDEX_WINDOW_DAYS,
SIMPLE_ECONOMY,
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 { cultureNode, 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 = $("<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();
// 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 = $("<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.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(
$("<div class='desc'></div>").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 = $("<table class='resources-table economy-table'></table>");
$table.append(
$("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Item"))
.append($("<th class='numeric'></th>").text("Per day"))
)
);
const $body = $("<tbody></tbody>").append(
$("<tr></tr>")
.append($("<td></td>").text("Tax income"))
.append($("<td class='numeric positive'></td>").text(`+${formatMoney(income)}`)),
$("<tr></tr>")
.append($("<td></td>").text("Treasury"))
.append($("<td class='numeric'></td>").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(
$("<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 /\u00a0day"))
.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 /\u00a0day"))
.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));
// 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 = $("<input type='number' min='0' max='100' step='1'>")
.addClass("tax-input")
.attr("data-kind", kind);
if (resource) $input.addClass("tax-override").attr("data-resource", resource);
const $dec = $("<button type='button' class='pct-step'></button>")
.attr("data-dir", "-1").text("−");
const $inc = $("<button type='button' class='pct-step'></button>")
.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 $("<span class='pct-stepper'></span>")
.append($dec, $input, $("<span class='pct-sign'></span>").text("%"), $inc);
};
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(makeField(kind.id, null)));
for (const proto of RESOURCES) {
$tr.append($("<td class='numeric'></td>").append(makeField(kind.id, 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 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 = $("<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 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(
$("<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-prices'></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: ${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($("<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 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($("<div class='subhead'></div>").text("Commodity prices"));
$container.append(
$("<div class='desc'></div>").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 class='resources-table economy-table inflation-table'></table>");
$table.append(
$("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Commodity"))
.append($("<th class='numeric'></th>").text("Index"))
.append($("<th class='numeric'></th>").text("Monthly"))
.append($("<th class='numeric'></th>").text("Yearly"))
)
);
const $body = $("<tbody></tbody>");
for (const row of rows) {
$body.append(
$("<tr></tr>")
.append(
$("<td></td>")
.append(
$("<span class='inflation-swatch'></span>")
.css("background", row.resource.colour)
)
.append(document.createTextNode(row.resource.name))
)
.append($("<td class='numeric'></td>").text(row.info.index.toFixed(1)))
.append($("<td class='numeric'></td>").text(percent(row.info.monthly)))
.append($("<td class='numeric'></td>").text(percent(row.info.yearly)))
);
}
$table.append($body);
$container.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 /\u00a0h", 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();
// 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($("<div class='desc'></div>").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(
$("<div class='desc'></div>").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 = $("<table class='resources-table economy-table market-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Resource"))
.append($("<th class='numeric'></th>").text("In pool"))
.append($("<th class='numeric'></th>").text("World stock"))
.append($("<th class='numeric'></th>").text("Price"))
.append($("<th></th>").text("Buy"))
.append($("<th></th>").text("Sell"))
);
const $body = $("<tbody></tbody>");
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 = $("<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}`));
$row.append($name);
$row.append(
$("<td class='numeric'></td>").text(formatResourceAmount(id, held))
);
$row.append(
$("<td class='numeric'></td>").text(
stock === null || stock === undefined ? "—" : formatResourceAmount(id, stock)
)
);
$row.append(
$("<td class='numeric'></td>").append(this._resourcePriceNode(proto, price))
);
$row.append($("<td class='market-actions'></td>").append(
this._marketControls(id, "buy", held, price, budget, stock)
));
$row.append($("<td class='market-actions'></td>").append(
this._marketControls(id, "sell", held, price, budget, stock)
));
$body.append($row);
}
$table.append($head, $body);
this.$resourcesList.append($table);
this.$resourcesList.append(
$("<div class='desc'></div>").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 = $("<span class='market-lots'></span>");
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 = $("<button type='button' class='aero market-lot'></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 = $("<table class='diplomacy-table budget-table resources-table'></table>");
const columns = [
{ label: "Resource" },
{ label: "Stored", numeric: true },
{ label: "Consumed /\u00a0day", numeric: true },
{ label: "Made /\u00a0day", numeric: true },
{ label: "Exported /\u00a0day", numeric: true },
{ label: "Imported /\u00a0day", numeric: true },
{ label: "Market price", numeric: true },
{ label: "World supply /\u00a0day", numeric: true },
{ label: "World demand /\u00a0day", 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 $row = $(template.content.cloneNode(true).querySelector("tr"));
const $cells = $row.children("td");
$cells.eq(0).find(".resource-icon")
.attr("src", `assets/${proto.icon}`)
.attr("alt", proto.name);
$cells.eq(0).find("span").text(proto.name);
let column = 1;
const cell = (key, value, $content, options = {}) => {
values[key] = value;
const $td = $cells.eq(column);
column += 1;
$td.append($content);
const arrow = trendArrow(value, baseline[key], options);
if (arrow) $td.append(arrow);
};
if (energy) {
$cells.eq(column).text("—");
column += 1;
} else {
cell(`${id}:stored`, storedValue, resourceNode(id, storedValue));
}
cell(`${id}:consumed`, consumedValue, resourceNode(id, consumedValue));
cell(`${id}:made`, madeValue, resourceNode(id, madeValue));
cell(`${id}:exported`, exportedValue, resourceNode(id, exportedValue));
cell(`${id}:imported`, importedValue, resourceNode(id, importedValue));
cell(`${id}:price`, priceValue, this._resourcePriceNode(proto, priceValue), {
doubleAt: 0.1,
});
cell(`${id}:supply`, supplyValue, resourceNode(id, supplyValue));
cell(`${id}:demand`, demandValue, resourceNode(id, demandValue));
$body.append($row);
}
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 /\u00a0day", numeric: true },
{ label: "Major importer" },
{ label: "Imported /\u00a0day", 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.
_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 = $("<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 };
}
// 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 = $("<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));
const $control = $("<td class='control'></td>");
const $icon = $("<img class='control-icon'>")
.attr("src", row.human ? "assets/icon_human.svg" : "assets/icon_computer.svg")
.attr("alt", row.human ? "Player" : "Computer")
.attr("title", row.human ? "Played by a human" : "Run by the computer");
$control.append($icon);
$tr.append($control);
$tr.append($("<td class='leader'></td>").text(row.leader || "—"));
$tr.append(this._moneyCell(row.gdp, row.index, row.self));
$tr.append(this._moneyCell(row.gdpPerCapita, row.index, row.self));
$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);
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 = $("<div class='treaty-row'></div>");
if (proposal.to === localCiv) {
$row.append($("<span class='treaty-text'></span>").text(
`${name(proposal.from)} proposes ${labelOf(proposal.type)}`
));
const $sign = $("<button class='aero'>Sign</button>");
$sign.on("click", () => this.onRespondTreaty && this.onRespondTreaty(proposal.id, true));
const $reject = $("<button class='aero'>Reject</button>");
$reject.on("click", () => this.onRespondTreaty && this.onRespondTreaty(proposal.id, false));
$row.append($sign, $reject);
} else if (proposal.from === localCiv) {
$row.append($("<span class='treaty-text'></span>").text(
`You proposed ${labelOf(proposal.type)} to ${name(proposal.to)} — awaiting reply`
));
} else {
$row.append($("<span class='treaty-text'></span>").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($("<div class='desc'></div>").text("No treaties in force."));
for (const treaty of active) {
const $row = $("<div class='treaty-row'></div>");
$row.append($("<span class='treaty-text'></span>").text(
`${name(treaty.a)} — ${name(treaty.b)}: ${labelOf(treaty.type)} (signed ${treaty.signedDate})`
));
if (treaty.a === localCiv || treaty.b === localCiv) {
const $break = $("<button class='aero'>Break</button>");
$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($("<div class='desc'></div>").text("No past treaties."));
for (const treaty of past) {
$past.append($("<div class='treaty-row'><span class='treaty-text'></span></div>").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($("<option></option>").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($("<option></option>").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 = $("<select class='treaty-city'></select>");
for (const city of info.localCities || []) {
$select.append($("<option></option>").attr("value", city.id).text(city.name));
}
if (!(info.localCities || []).length) {
$select.append($("<option></option>").attr("value", "").text("No city to give"));
}
$payload.append($select);
} else if (type === "give_money") {
$payload.append(
$("<input class='treaty-money' type='number' min='0' step='1000' placeholder='amount'>")
);
} else if (type === "give_resources") {
for (const id of STORABLE_RESOURCE_IDS) {
const proto = resourceById(id);
const $label = $("<label class='treaty-resource-row'></label>");
$label.append($("<span></span>").text(proto ? proto.name : id));
$label.append(
$("<input class='treaty-resource' type='number' min='0' step='1'>").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 class='treaty-unit-row'></label>");
$label.append($("<input class='treaty-unit' type='checkbox'>").attr("value", unit.id));
$label.append($("<span></span>").text(proto.name || `Unit ${unit.id}`));
$payload.append($label);
}
if (options.length === 0) {
$payload.append($("<span class='desc'></span>").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 = $("<td class='numeric'></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(
$("<span class='conversion'></span>").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(
$("<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 = {
policy: { type: POLICIES[0].id, ethnicity: this._defaultEthnicity(civs, localCiv) },
opinionObserver: localCiv,
};
this.$politicsMakeup = $("<div class='politics-card'></div>");
this.$politicsMigrations = $("<div class='politics-card'></div>");
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($("<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._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 = $("<button type='button' class='subtab'></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 = $("<div class='politics-card'></div>");
this.$intelligenceCulture = $("<div class='politics-card'></div>");
this.$intelligenceCampaigns = $("<div class='politics-card'></div>");
this.$intelligenceSpyView.append(
this._buildSpiesCard(),
this._buildReportsCard(),
this._buildSatellitesCard()
);
this.$intelligencePropagandaView.append(
this._buildCultureCard(),
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._fillCulture(info, localCiv);
this._fillSpies(info, localCiv);
this._fillReports(info);
this._fillSatellites(info);
this._fillCampaigns(info, localCiv);
this._syncCampaignReadout();
this._syncCampaignForm();
}
// The culture readout: the simple economy funds propaganda from the culture
// stock, so the tab names the store and what the running campaigns drain from
// it each day. The hard economy pays campaigns from the treasury instead, so
// only the stock (soft power) is worth naming there.
_buildCultureCard() {
const $card = this.$intelligenceCulture;
$card.empty();
$card.append($("<div class='name'></div>").text("Culture"));
const $desc = $("<div class='desc'></div>").text(
this._campaignPaidInCulture()
? "Propaganda is paid for in culture, drained from this store each day; a " +
"campaign lapses when there is too little left to fund it."
: "Your stock of soft power. Campaigns are funded from the treasury."
);
this.$cultureReadout = $("<div class='politics-readout culture-readout'></div>");
$card.append($desc, this.$cultureReadout);
return $card;
}
_fillCulture(info, localCiv) {
if (!this.$cultureReadout || !this.$cultureReadout.length) return;
const culture = (info.culture && info.culture[localCiv]) || 0;
const upkeep = (info.campaigns || [])
.filter((campaign) => campaign.civ === localCiv)
.reduce((sum, campaign) => sum + (campaign.hourlyCost || 0), 0);
const $readout = this.$cultureReadout.empty().append(cultureNode(culture));
if (this._campaignPaidInCulture()) {
$readout
.append(document.createTextNode(" culture · Campaigns consume "))
.append(cultureNode(upkeep * HOURS_PER_DAY))
.append(document.createTextNode("/day"));
} else {
$readout.append(document.createTextNode(" culture"));
}
}
// 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($("<div class='name'></div>").text("Your spies"));
this.$intelligenceSpyList = $("<div></div>");
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(
$("<div class='desc'></div>").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 = $("<div class='politics-row politics-policy-row'></div>");
$row.append($("<div></div>").text(`Spy ${index + 1} — ${spy.location}`));
const $locate = $("<button class='aero'></button>").text("Locate");
$locate.on("click", () => this.onLocateUnit(spy.id));
$row.append($("<div class='politics-row-actions'></div>").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 = $("<div class='politics-card'></div>");
this.$intelligenceReports.append($("<div class='name'></div>").text("Intelligence reports"));
this.$intelligenceReportsList = $("<div></div>");
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(
$("<div class='desc'></div>").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 = $("<div class='politics-row spy-report'></div>");
if (report.kind === "comms") {
$row.append($("<div></div>").text(`${owner} communications from ${report.capital}`));
if ((report.messages || []).length === 0) {
$row.append($("<div class='desc'></div>").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(
$("<div class='desc'></div>").text(`${from} → ${to}: ${message.text}`)
);
}
} else {
$row.append($("<div></div>").text(`${owner} city of ${report.city}`));
$row.append(
$("<div class='desc'></div>").text(
`Population ${groupDigits(Math.round(report.population || 0))}`
)
);
if ((report.buildings || []).length > 0) {
$row.append(
$("<div class='desc'></div>").text(
`Buildings: ${report.buildings.map((b) => `${b.name} ${b.level}`).join(", ")}`
)
);
}
if ((report.garrison || []).length > 0) {
$row.append(
$("<div class='desc'></div>").text(
`Garrison: ${report.garrison.map((g) => `${g.name} ×${g.count}`).join(", ")}`
)
);
} else {
$row.append($("<div class='desc'></div>").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 = $("<div class='politics-card'></div>");
this.$intelligenceSatellites.append($("<div class='name'></div>").text("Spy satellites"));
this.$intelligenceSatelliteStatus = $("<div class='desc'></div>");
this.$intelligenceSatelliteList = $("<div></div>");
this.$intelligenceSatelliteLaunch = $("<button class='aero'></button>").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,
$("<div class='politics-row-actions'></div>").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(
$("<div class='desc'></div>").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(
$("<div class='politics-row'></div>").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($("<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.empty().append(this._campaignCostNode(info, campaign));
}
}
// Which currency propaganda is funded from: the simple economy drains the
// culture stock, the hard economy the treasury. The two models also price a
// campaign with their own figures, so the slider bounds come from here too.
_campaignPaidInCulture() {
return this.economyModel === "simple";
}
_campaignDefaultCost() {
return this._campaignPaidInCulture()
? SIMPLE_ECONOMY.campaign.defaultCulturePerHour
: PROPAGANDA.defaultHourlyCost;
}
_campaignMaxCost() {
return this._campaignPaidInCulture()
? SIMPLE_ECONOMY.campaign.maxCulturePerHour
: PROPAGANDA.maxHourlyCost;
}
// A campaign figure in its own currency: culture points with the clapperboard
// icon in the simple economy, quoted euros in the hard one. `amount` is
// already scaled to the period being shown.
_campaignAmountNode(amount) {
if (this._campaignPaidInCulture()) return cultureNode(amount);
return document.createTextNode(
`${currencySymbol()}${groupDigits(Math.round(this._quoted(amount)))}`
);
}
_campaignCostNode(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);
const $cost = $("<span class='politics-campaign-cost'></span>");
$cost
.append(this._campaignAmountNode(hourly * HOURS_PER_DAY))
.append(document.createTextNode("/day · "))
.append(this._campaignAmountNode(spent))
.append(document.createTextNode(` spent over ${days} day${days === 1 ? "" : "s"}`));
return $cost;
}
_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: this._campaignMaxCost(),
step: this._campaignPaidInCulture() ? 1 : 1000,
value: campaign.hourlyCost,
});
const spendNode = (hourly) =>
$("<span class='politics-readout'></span>")
.append(this._campaignAmountNode(hourly * HOURS_PER_DAY))
.append(document.createTextNode("/day"));
let $spend = spendNode(campaign.hourlyCost);
$slider.on("input", () => {
const next = spendNode(Number($slider.val()));
$spend.replaceWith(next);
$spend = next;
});
$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);
const next = spendNode(campaign.hourlyCost);
$spend.replaceWith(next);
$spend = next;
this.onConfirm(
"Stop this campaign?",
`Cutting the spending to ${this._campaignPaidInCulture() ? "0 culture" : `${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.$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 = $("<div class='politics-card'></div>");
$card.append($("<div class='name'></div>").text("Launch a propaganda campaign"));
$card.append(
$("<div class='desc'></div>").text(
`Spend ${this._campaignPaidInCulture() ? "culture" : "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."
)
);
const $beginCost = $("<div class='desc politics-cost'></div>").append(
document.createTextNode("Costs "),
this._campaignAmountNode(this._campaignDefaultCost() * HOURS_PER_DAY),
document.createTextNode(
"/day to begin; adjust the spending on an active campaign to make it land " +
"harder or softer."
)
);
$card.append($beginCost);
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._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 = $("<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._intelligenceState.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.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 = $("<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.$policyDesc = $("<div class='politics-estimate politics-desc'></div>");
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.$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(
$("<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");
}
}