// The nation panel: government, technology, budget, diplomacy and conflicts. import { compact, groupDigits } from "../../../shared/text_format.js"; import { governmentChangeCost, prerequisitesMet, } from "../../../shared/rules.js"; import { POLICIES, PROPAGANDA, policyById, technologyCost, RESOURCES, RESOURCE_IDS, STORABLE_RESOURCE_IDS, MARKET_LOTS, resourceById, PRICE_INDEX_WINDOW_DAYS, TREATIES, treatyById, } from "../../../shared/data.js"; import { formatResourceAmount } from "../../../shared/resources.js"; import { renderMonthlyBudget } from "./budget.js"; import { effectNodes, money, quotedMoney } from "./format.js"; import { bindTabs } from "./tabs.js"; import { ethnicityChart } from "../ui/ethnicity.js"; import { researchNode } from "../ui/card.js"; import { TechnologyTreeView } from "../ui/tech_tree_view.js"; import { resourceNode, valueNode } from "../ui/value.js"; import { lineChart } from "../ui/line_chart.js"; import { trendArrow } from "../ui/trend.js"; import { currencySymbol, currencyFor, rateToNational, formatMoney } from "../currency.js"; import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js"; import { MONTH_NAMES, unixTime } from "../../../shared/game_clock.js"; export class NationModal { constructor() { this.$modal = $("#modal-nation"); this.$governmentSub = $("#government-sub"); this.$governmentList = $("#government-list"); this.$technologySub = $("#technology-sub"); this.$technologyStrip = $("#technology-research"); this.$technologyList = $("#technology-list"); this.$budgetSub = $("#budget-sub"); this.$budgetMonths = $("#budget-months"); this.$budgetList = $("#budget-list"); this.$economySub = $("#economy-sub"); this.$economySubtabs = $("#economy-subtabs"); this.$economySummary = $("#economy-summary"); this.$economyConsumption = $("#economy-consumption"); this.$economyCurrency = $("#economy-currency"); this.$economyTaxes = $("#economy-taxes"); this.$economyCentralBank = $("#economy-central-bank"); this.$economyRates = $("#economy-rates"); // Which Economy sub-tab is open: the private sector, the trade table, the // viewer's own currency, the central bank or the exchange rates. this.economyView = "summary"; // The central-bank interest input is built once and only its value updated, // so typing is not interrupted by the snapshot refresh. this._centralBankBuilt = false; // The tax controls are likewise built once; only their values refresh. this._taxesBuilt = false; this.$resourcesSub = $("#resources-sub"); this.$resourcesTabs = $("#resources-subtabs"); this.$resourcesList = $("#resources-list"); this.$resourceTradeList = $("#resource-trade-list"); // Which economy model the game runs; the simple model turns the Resources // tab into the global market and drops the hard money sub-tabs. this.economyModel = "hard"; // Which Resources sub-tab is open: the nation's own stores and trade, or // the world ranking of each commodity's major exporter and importer. this.resourceView = "stores"; // Day-over-day baseline and the latest values for the Resources tab arrows. this._resourceDay = null; this._resourceBaseline = null; this._resourceLast = null; this.$diplomacySub = $("#diplomacy-sub"); this.$diplomacyList = $("#diplomacy-list"); this.$citiesSub = $("#cities-sub"); this.$citiesList = $("#cities-list"); this.$conflictsSub = $("#conflicts-sub"); this.$conflictsList = $("#conflicts-list"); this.$politicsSub = $("#politics-sub"); this.$politicsList = $("#politics-list"); this.$intelligenceSub = $("#intelligence-sub"); this.$intelligenceSubtabs = $("#intelligence-subtabs"); this.$intelligenceSpyView = $("#intelligence-spies-view"); this.$intelligencePropagandaView = $("#intelligence-propaganda-view"); // Which Intelligence sub-tab is open: the spy controls or the campaigns. this.intelligenceView = "spies"; this.governments = []; this.technologies = []; this.currentGovernment = 0; this.researched = new Set(); this.governmentRows = []; // The technology tree is drawn by a self-contained view, shared with the // stand-alone catalogue preview; the modal only feeds it the snapshot. this.techInfo = { focus: -1, progress: 0, repeatCounts: {} }; this.diplomacy = null; this.diplomacySort = { key: "name", dir: 1 }; this.conflicts = []; // Conflict cards the player expanded, keyed "a:b". Snapshot refreshes // rebuild the list, so the open/closed state has to live outside the DOM. this.expandedConflicts = new Set(); // How many months back the budget tab is paging: 0 is the current month. this.budgetOffset = 0; this._treasury = 0; this._budgetMonths = []; // Expense categories whose per-resource purchase detail is expanded. this.expandedBudgetCategories = new Set(); this.politics = null; // The Politics tab is built once and only its lists are refreshed, so the // wizard and dropdowns survive the snapshots that arrive while it is open. this._politicsBuilt = false; this._politicsCiv = -1; this._politicsState = null; this._politicsWizardOpen = false; this.intelligence = null; // The Intelligence tab, like Politics, is built once so the propaganda // controls survive an arriving snapshot; only its lists refresh. this._intelligenceBuilt = false; this._intelligenceCiv = -1; this._intelligenceState = null; this.onGovernment = () => {}; this.onFocusTechnology = () => {}; this.onUnlockTechnology = () => {}; this.onDeclareWar = () => {}; this.onCampaign = () => {}; this.onCancelCampaign = () => {}; this.onSetCampaignSpending = () => {}; this.onLocateUnit = () => {}; this.onLaunchSatellite = () => {}; this.onPolicy = () => {}; this.onRevokePolicy = () => {}; this.onSetInterestRate = () => {}; this.onSetTaxRate = () => {}; // The simple economy's global market: buy or sell a lot of one good. this.onMarketTrade = () => {}; this.onConfirm = () => {}; this.$modal.find(".modal-close").on("click", () => this.close()); bindTabs(this.$modal, "tab-"); this.techTree = new TechnologyTreeView({ graph: this.$technologyList, strip: this.$technologyStrip, tipFor: (index) => this._technologyTip(index), onFocusTechnology: (index) => this.onFocusTechnology(index), onUnlockTechnology: (index) => this.onUnlockTechnology(index), }); } isShowing() { return !this.$modal.hasClass("hidden"); } // Today's world market prices, from the Resources tab's data. Used to quote // the money costs that carry no material bill of their own. _prices() { if (this.resources && this.resources.summary && this.resources.summary.prices) { return this.resources.summary.prices; } const prices = {}; for (const entry of (this.resources && this.resources.market) || []) { prices[entry.id] = entry.price; } return prices; } // A fixed money cost quoted at today's market. _quoted(amount) { return quotedMoney(amount, this._prices()); } show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null, intelligence = null) { this.governments = governments; this.technologies = technologies; // Needed before the technology cards are built: a testing game adds the // instant-unlock button when each card is created. if (techInfo) this.techInfo = techInfo; this.diplomacy = diplomacy; this.conflicts = conflicts || []; this.politics = politics; this.intelligence = intelligence; this.resources = resourcesInfo; this.money = moneyInfo; // The snapshot names the economy model; a direct caller that omits it keeps // the hard rendering (the default before the simple economy existed). this.economyModel = (resourcesInfo && resourcesInfo.economyModel) || (moneyInfo && moneyInfo.economyModel) || this.economyModel || "hard"; // Rebuild the rows from scratch: reopening the panel must not stack a // second set of buttons on top of the previous one, or the stale rows keep // their old labels and disabled state and appear unresponsive. this.$governmentList.empty(); this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number)); this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government")); this.techTree.render({ technologies: this.technologies, researched: this.researched, techInfo: this.techInfo }); this._buildResourceSubtabs(); this._buildIntelligenceSubtabs(); // Reopening the panel shows the server's rates, not an unsent edit left // over from a previous open. this.$economyTaxes.find(".tax-input").each((_, element) => this._clearTaxDirty($(element)) ); this._buildEconomySubtabs(); this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo, resourcesInfo, moneyInfo, intelligence); // Force the treaty compose controls to rebuild their options for this open. $("#treaty-type").data("lastType", null); $("#treaty-target").removeAttr("data-signature"); this.$modal.removeClass("hidden"); } // The Resources tab's sub-tabs: the nation's own stores and trade, and the // world ranking of each commodity's major exporters and importers. Both views // are rendered together; switching only shows and hides them. The simple // economy has neither: its Resources tab is one global market, so the sub-tab // bar is hidden and only the market list is drawn. _buildResourceSubtabs() { const $tabs = this.$resourcesTabs || $("#resources-subtabs"); if (!$tabs.length) return; $tabs.empty(); if (this.economyModel === "simple") { $tabs.addClass("hidden"); this.resourceView = "stores"; return; } $tabs.removeClass("hidden"); const views = [ { id: "stores", label: "Stores & trade" }, { id: "leaders", label: "Major traders" }, ]; for (const view of views) { const $button = $("") .attr("data-view", view.id) .text(view.label); if (view.id === this.resourceView) $button.addClass("active"); $button.on("click", () => this._setResourceView(view.id)); $tabs.append($button); } } _setResourceView(view) { this.resourceView = view; const $tabs = $("#resources-subtabs"); $tabs.find(".subtab").removeClass("active"); $tabs.find(`.subtab[data-view="${view}"]`).addClass("active"); if (this.$resourcesList) this.$resourcesList.toggleClass("hidden", view !== "stores"); if (this.$resourceTradeList) this.$resourceTradeList.toggleClass("hidden", view !== "leaders"); } // The Economy tab's sub-tabs: the private sector, the day's consumption and // trade, the viewer's own currency and where it is held, the central bank and // the exchange-rate table. _buildEconomySubtabs() { if (!this.$economySubtabs || !this.$economySubtabs.length) return; this.$economySubtabs.empty(); // The simple economy has one currency, no central bank and no FX, and its // only money movement is the market: nothing is left to split into // sub-tabs, so the bar is hidden and the one summary is drawn. if (this.economyModel === "simple") { this.$economySubtabs.addClass("hidden"); this.economyView = "summary"; this._setEconomyView("summary"); return; } this.$economySubtabs.removeClass("hidden"); const currency = (this.money && this.money.currency) || null; const views = [ { id: "summary", label: "Private sector" }, { id: "consumption", label: "Consumption & trade" }, { id: "currency", label: currency ? `${currency.symbol} ${currency.name}` : "Currency" }, { id: "taxes", label: "Taxes" }, { id: "centralbank", label: "Central bank" }, { id: "rates", label: "Exchange rates" }, ]; for (const view of views) { const $button = $("") .attr("data-view", view.id) .text(view.label); if (view.id === this.economyView) $button.addClass("active"); $button.on("click", () => this._setEconomyView(view.id)); this.$economySubtabs.append($button); } this._setEconomyView(this.economyView); } _setEconomyView(view) { this.economyView = view; if (this.$economySubtabs) { this.$economySubtabs.find(".subtab").removeClass("active"); this.$economySubtabs.find(`.subtab[data-view="${view}"]`).addClass("active"); } if (this.$economySummary) this.$economySummary.toggleClass("hidden", view !== "summary"); if (this.$economyConsumption) { this.$economyConsumption.toggleClass("hidden", view !== "consumption"); } if (this.$economyCurrency) this.$economyCurrency.toggleClass("hidden", view !== "currency"); if (this.$economyTaxes) this.$economyTaxes.toggleClass("hidden", view !== "taxes"); if (this.$economyCentralBank) { this.$economyCentralBank.toggleClass("hidden", view !== "centralbank"); } if (this.$economyRates) this.$economyRates.toggleClass("hidden", view !== "rates"); // Render the view that just became visible. this._renderEconomy(this.money); } _renderEconomy(money) { this.money = money || this.money || {}; if (this.economyModel === "simple") { this._renderSimpleEconomy(this.money); return; } if (this.economyView === "consumption") this._renderConsumption(); else if (this.economyView === "currency") this._renderCurrencyHoldings(this.money); else if (this.economyView === "taxes") this._renderTaxes(this.money); else if (this.economyView === "centralbank") this._renderCentralBank(this.money); else if (this.economyView === "rates") this._renderExchangeRates(this.money); else this._renderPrivateSector(this.money); } // The simple economy's one Economy view: the euro treasury and the single // daily tax income, with the day's income sources. There is no central bank, // no FX and no private-sector cash to report. _renderSimpleEconomy(money) { if (!this.$economySummary || !this.$economySummary.length) return; const treasury = money.budget !== undefined ? money.budget : this._treasury; const take = (money && money.takeTax) || {}; const income = take.income || 0; if (this.$economySub && this.$economySub.length) { this.$economySub.text( `Treasury ${formatMoney(treasury)} · tax income ${formatMoney(income)}/day` ); } this.$economySummary.empty(); this.$economySummary.append( $("
").text( "Money is used only in the global market. Taxes are collected each day from " + "every inhabitant, and construction and upkeep draw on your resource pool " + "instead of the treasury." ) ); const $table = $("
"); $table.append( $("").append( $("") .append($("").text("Item")) .append($("").text("Per day")) ) ); const $body = $("").append( $("") .append($("").text("Tax income")) .append($("").text(`+${formatMoney(income)}`)), $("") .append($("").text("Treasury")) .append($("").text(formatMoney(treasury))) ); $table.append($body); this.$economySummary.append($table); } // The day's resource consumption and the trade that crossed the nation's // border: what each neighbour and the world market sold us, and what we sold // them, with the money it moved. _renderConsumption() { if (!this.$economyConsumption || !this.$economyConsumption.length) return; if (this.$economySub && this.$economySub.length) { this.$economySub.text("Daily consumption and cross-border trade"); } this.$economyConsumption.empty(); const summary = this.resources && this.resources.summary; if (!summary) { this.$economyConsumption.append( $("
").text("No resource data yet.") ); return; } const consumed = summary.consumed || {}; const external = summary.external || { buy: {}, sell: {} }; const $table = $("
"); const $head = $("").append( $("") .append($("").text("Resource")) .append($("").text("Consumed /\u00a0day")) .append($("").text("Bought abroad")) .append($("").text("Spent")) .append($("").text("Sold abroad")) .append($("").text("Earned")) ); const $body = $(""); let totalSpent = 0; let totalEarned = 0; for (const id of RESOURCE_IDS) { const proto = resourceById(id); const name = proto ? proto.name : id; const buy = (external.buy && external.buy[id]) || { qty: 0, value: 0 }; const sell = (external.sell && external.sell[id]) || { qty: 0, value: 0 }; totalSpent += buy.value || 0; totalEarned += sell.value || 0; $body.append( $("") .append($("").text(name)) .append( $("").text( formatResourceAmount(id, consumed[id] || 0) ) ) .append($("").text( buy.qty > 0 ? formatResourceAmount(id, buy.qty) : "—" )) .append($("").text( buy.value > 0 ? this._amount(buy.value) : "—" )) .append($("").text( sell.qty > 0 ? formatResourceAmount(id, sell.qty) : "—" )) .append($("").text( sell.value > 0 ? this._amount(sell.value) : "—" )) ); } $table.append($head, $body); $table.append( $("").append( $("") .append($("").text("Trade balance")) .append($("").text(this._amount(totalSpent))) .append($("")) .append($("").text(this._amount(totalEarned))) ) ); this.$economyConsumption.append($table); this.$economyConsumption.append( $("
").text( "Bought and sold counts only trade across the border — foreign " + "regions reached over the trade network. Neighbours at home are not " + "a foreign sale." ) ); } // Where every unit of the viewer's currency is stored: the treasury, the // nation's regions, and the foreign regions and central banks that hold it. _renderCurrencyHoldings(money) { if (!this.$economyCurrency || !this.$economyCurrency.length) return; const holdings = money.currencyHoldings; const currency = money.currency || { name: "Currency", symbol: "¤", code: "" }; if (this.$economySub && this.$economySub.length) { this.$economySub.text( `Holdings of the ${currency.name} (${currency.code})` ); } this.$economyCurrency.empty(); if (!holdings) { this.$economyCurrency.append( $("
").text("No currency data yet.") ); return; } const $table = $("
"); const $body = $(""); $body.append( $("").append( $("").text("Treasury") ) ); $body.append( $("") .append($("").text("Government")) .append($("").text(this._amount(holdings.treasury || 0))) ); if (holdings.treasuryDebt > 0) { $body.append( $("") .append($("").text("Overdraft (debt)")) .append($("").text(`−${this._amount(holdings.treasuryDebt)}`)) ); } $body.append( $("").append( $("").text(`Your regions (${(holdings.own || []).length})`) ) ); for (const region of holdings.own || []) { $body.append( $("") .append($("").text(region.name)) .append($("").text(this._amount(region.amount))) ); } if ((holdings.foreign || []).length > 0) { $body.append( $("").append( $("").text("Held by foreign regions") ) ); for (const region of holdings.foreign) { $body.append( $("") .append($("").text(`${region.name} (${region.civName})`)) .append($("").text(this._amount(region.amount))) ); } } if ((holdings.banks || []).length > 0) { $body.append( $("").append( $("").text("Central-bank reserves") ) ); for (const bank of holdings.banks) { $body.append( $("") .append($("").text(`Central bank of ${bank.name}`)) .append($("").text(this._amount(bank.amount))) ); } } $table.append($body); const $foot = $("").append( $("") .append($("").text("Total in circulation")) .append($("").text(this._amount(holdings.total || 0))) ); $table.append($foot); this.$economyCurrency.append($table); this.$economyCurrency.append( $("
").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( $("
").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 = $("
"); const $head = $("").append( $("") .append($("").text("Region")) .append($("").text("Private cash")) ); const $body = $(""); let total = 0; for (const city of cities) { total += city.cashValue || 0; $body.append( $("") .append($("").text(city.name)) .append( $("").text( `${currencySymbol()}${groupDigits(Math.round(city.cashValue || 0))}` ) ) ); } $table.append($head, $body); const $foot = $("").append( $("") .append($("").text("Total")) .append($("").text( `${currencySymbol()}${groupDigits(Math.round(total))}` )) ); $table.append($foot); this.$economySummary.append($table); } this._renderProductionBuildings(money.productionBuildings || []); } // Production buildings are private-sector agents too, but a separate class // from the regions: each keeps its own cash and reinvests it in upgrades. _renderProductionBuildings(buildings) { if (!this.$economySummary || buildings.length === 0) return; this.$economySummary.append( $("
").text( "Production buildings are independent private agents: they keep what " + "their output sells for, and spend it upgrading when demand has grown." ) ); const $table = $("
"); const $head = $("").append( $("") .append($("").text("Production building")) .append($("").text("Output /\u00a0day")) .append($("").text("Level")) .append($("").text("Private cash")) ); const $body = $(""); let total = 0; for (const building of buildings) { total += building.cash || 0; const level = building.level || 0; const resource = resourceById(building.resource); const $output = resource && building.outputPerDay ? $("").text(formatResourceAmount(building.resource, building.outputPerDay)) : $("").text("—"); $body.append( $("") .append($("").text(building.name)) .append($output) .append( $("").text( level + (building.upgrading ? " ↑" : "") ) ) .append( $("").text( `${currencySymbol()}${groupDigits(Math.round(building.cash || 0))}` ) ) ); } $table.append($head, $body); $table.append( $("").append( $("") .append($("").text("Total")) .append($("").text( `${currencySymbol()}${groupDigits(Math.round(total))}` )) ) ); this.$economySummary.append($table); } // The trade taxes: three rates, each a national default that may be // overridden per resource. There is no GDP tax, so these are the government's // whole revenue. _renderTaxes(money) { if (!this.$economyTaxes || !this.$economyTaxes.length) return; const taxes = (money && money.taxes) || {}; const take = (money && money.takeTax) || { sales: 0, export: 0, import: 0 }; if (this.$economySub && this.$economySub.length) { const total = (take.sales || 0) + (take.export || 0) + (take.import || 0); this.$economySub.text( `Trade taxes · collected ${currencySymbol()}${groupDigits(Math.round(total))} today` ); } if (!this._taxesBuilt) { this.$economyTaxes.empty(); this.$economyTaxes.append( $("
").text( "The government lives on trade. A national sales tax falls on every purchase " + "a region makes from another region at home; an export tariff on what foreign " + "regions buy from your regions; an import tariff on the national currency your " + "regions spend buying foreign goods. A resource box left blank uses the default." ) ); const kinds = [ { id: "sales", label: "Sales tax" }, { id: "export", label: "Export tariff" }, { id: "import", label: "Import tariff" }, ]; const $table = $("
"); const $head = $(""); $head.append($("").text("Tax")); $head.append($("").text("Default")); for (const proto of RESOURCES) { $head.append($("").text(proto.name)); } $head.append($("").text("")); $table.append($("").append($head)); // One box: a themed stepper whose +/- buttons never sit on the digits, // with the percent sign as a suffix. The input keeps the `tax-input` // classes the refresh and the tests address it by. const makeField = (kind, resource) => { const $input = $("") .addClass("tax-input") .attr("data-kind", kind); if (resource) $input.addClass("tax-override").attr("data-resource", resource); const $dec = $("") .attr("data-dir", "-1").text("−"); const $inc = $("") .attr("data-dir", "1").text("+"); $input.on("input", () => this._markTaxDirty($input)); $dec.on("click", () => this._stepTaxField($input, -1)); $inc.on("click", () => this._stepTaxField($input, 1)); return $("") .append($dec, $input, $("").text("%"), $inc); }; const $body = $(""); for (const kind of kinds) { const $tr = $(""); $tr.append($("").text(kind.label)); $tr.append($("").append(makeField(kind.id, null))); for (const proto of RESOURCES) { $tr.append($("").append(makeField(kind.id, proto.id))); } const $apply = $("").text("Set"); $apply.on("click", () => this._applyTaxRow(kind.id)); $tr.append($("").append($apply)); $body.append($tr); } $table.append($body); this.$economyTaxes.append($table); this._taxesBuilt = true; } // Refresh the boxes from the snapshot. The box being typed in, and any box // the player has edited but not yet had confirmed, is left alone so the // 10 Hz snapshot stream cannot undo work in progress. const active = document.activeElement; this.$economyTaxes.find(".tax-input").each((_, element) => { const $input = $(element); const kind = $input.data("kind"); const resource = $input.data("resource"); const entry = taxes[kind] || { rate: 0, resources: {} }; const value = resource ? entry.resources[resource] : entry.rate; const text = value === undefined || value === null ? "" : (value * 100).toFixed(2); if ($input.data("dirty")) { // Release the box once the server agrees, then show its clean value. const shown = value === undefined || value === null ? null : value * 100; if (element !== active && shown !== null && Math.abs(Number($input.val()) - shown) < 0.005) { this._clearTaxDirty($input); $input.val(text); } return; } if (element === active) return; $input.val(text); }); } // Marks a tax box as holding an unsent edit so the snapshot refresh leaves it // alone; the amber outline tells the player it is not confirmed yet. _markTaxDirty($input) { $input.data("dirty", true); $input.closest(".pct-stepper").addClass("unsaved"); } _clearTaxDirty($input) { $input.data("dirty", false); $input.closest(".pct-stepper").removeClass("unsaved"); } // One click of a stepper button nudges the rate by a whole percent. _stepTaxField($input, delta) { const current = Number($input.val()); const base = Number.isFinite(current) ? current : 0; const next = Math.max(0, Math.min(100, base + delta)); $input.val(next.toFixed(2)); this._markTaxDirty($input); } // Sends every box on one tax row that has a value, the default and each // per-resource override. _applyTaxRow(kind) { const $row = this.$economyTaxes.find(`.tax-input[data-kind="${kind}"]`).closest("tr"); const send = ($input, resource) => { const raw = $input.val(); // A blank box has nothing to send (there is no "remove override" order), // so drop the edit and let the next snapshot refill it. if (raw === "" || raw === null || raw === undefined) { this._clearTaxDirty($input); return; } const percent = Number(raw); if (!Number.isFinite(percent) || percent < 0) return; const clamped = Math.max(0, Math.min(100, percent)); if (clamped !== percent) $input.val(clamped.toFixed(2)); this.onSetTaxRate(kind, clamped / 100, resource || null); }; send($row.find(".tax-input").not(".tax-override")); $row.find(".tax-override").each((_, element) => { const $input = $(element); send($input, $input.attr("data-resource")); }); } // The central bank: its interest rate, which compounds into every region's // private cash, the world commodity price index and its monthly and yearly // inflation, and the foreign reserves it has accumulated. _renderCentralBank(money) { if (!this.$economyCentralBank || !this.$economyCentralBank.length) return; const bank = money.centralBank || { rate: 0, reserves: [] }; const entries = money.resourceInflation || []; const average = (field) => entries.length ? entries.reduce((sum, entry) => sum + (entry[field] || 0), 0) / entries.length : 0; const percent = (value) => `${value >= 0 ? "+" : "−"}${(Math.abs(value) * 100).toFixed(2)}%`; const monthly = average("monthly"); const yearly = average("yearly"); if (this.$economySub && this.$economySub.length) { this.$economySub.text( `${(money.currency && money.currency.name) || "Central bank"} · ` + `Interest rate ${(bank.rate * 100).toFixed(2)}%/year · ` + `Commodity inflation ${percent(monthly)}/month, ${percent(yearly)}/year` ); } if (!this._centralBankBuilt) { this.$economyCentralBank.empty(); const $rate = $("
"); $rate.append($("").text("Interest rate (% per year)")); const $input = $(""); const $apply = $("").text("Set"); const apply = () => { const percentValue = Number($input.val()); if (!Number.isFinite(percentValue) || percentValue < 0) return; this.onSetInterestRate(percentValue / 100); }; $apply.on("click", apply); $input.on("keydown", (event) => { if (event.key === "Enter") apply(); }); $rate.append($input, $apply); this.$economyCentralBank.append($rate); this.$economyCentralBank.append( $("
").text( "Regions borrow from the bank at this rate when their cash runs out, " + "and it compounds on whatever they owe. The government may run an " + "overdraft here too." ) ); this.$economyCentralBank.append($("
")); this.$economyCentralBank.append($("
")); this.$economyCentralBank.append($("
")); const $reserves = $("
"); this.$economyCentralBank.append($reserves); this._centralBankBuilt = true; } const $input = this.$economyCentralBank.find("#cb-rate"); if ($input.length && document.activeElement !== $input.get(0)) { $input.val((bank.rate * 100).toFixed(2)); } this.$economyCentralBank.find(".central-bank-inflation").text( `Commodity inflation: ${percent(monthly)} over the month and ${percent(yearly)} ` + "over the year — the average move in the world's commodity prices." ); this._renderCommodityChart(money); this.$economyCentralBank.find(".central-bank-loans").text( `Outstanding loans: ${currencySymbol()}${groupDigits(Math.round(bank.loans || 0))}` ); const $reserves = this.$economyCentralBank.find(".central-bank-reserves"); $reserves.empty(); $reserves.append($("
").text("Foreign reserves")); if ((bank.reserves || []).length === 0) { $reserves.append($("
").text("No foreign currency held yet.")); return; } const $table = $("
"); const $body = $(""); for (const reserve of bank.reserves) { $body.append( $("") .append($("").text(`${reserve.symbol} ${reserve.name}`)) .append( $("").text( groupDigits(Math.round(reserve.amount || 0)) ) ) ); } $table.append($body); $reserves.append($table); } // The commodity price index chart and the per-commodity inflation table. The // server ships each commodity's world price already rebased to 100 in January // 2000, one point per day. The plot has a fixed ninety-day axis, so a young // game leaves its future days blank rather than stretching a short line across // the width; once the series is full it rolls, dropping the oldest day. The // axis is shaded in alternating month columns, each named once along the // bottom, so no exact date has to compete for space at the ends. _renderCommodityChart(money) { const $container = this.$economyCentralBank.find(".central-bank-prices"); if (!$container.length) return; const history = money.resourcePriceHistory || []; const inflation = new Map((money.resourceInflation || []).map((entry) => [entry.id, entry])); const series = []; const rows = []; let count = 0; for (const [id, prices] of history) { const resource = resourceById(id); if (!resource || !prices || prices.length === 0) continue; const values = prices.slice(-PRICE_INDEX_WINDOW_DAYS); count = Math.max(count, values.length); series.push({ label: resource.name, colour: resource.colour, values }); rows.push({ resource, info: inflation.get(id) || { index: 100, monthly: 0, yearly: 0 } }); } $container.empty(); $container.append($("
").text("Commodity prices")); $container.append( $("
").text( "Each commodity's world price as an index (100 in January 2000), " + "one point a day over the last ninety days." ) ); if (series.length === 0) return; // The window's first day: the chart's last point is today, so the leftmost // slot is that many days back. Slots past the newest point are future days. const lastDay = Math.floor((money.totalHours || 0) / HOURS_PER_DAY); const firstDay = lastDay - (count - 1); // A slot's calendar month, keyed year*12+month so a band breaks only when // the month really changes (and across a year boundary). const slotDate = (i) => new Date(unixTime((firstDay + i) * HOURS_PER_DAY) * 1000); const slotMonth = (i) => { const date = slotDate(i); return date.getUTCFullYear() * 12 + date.getUTCMonth(); }; // One band per month in the fixed window, named once at the bottom. A // partial month at either edge still gets a full column and its name. const xBands = []; let bandStart = 0; for (let i = 1; i <= PRICE_INDEX_WINDOW_DAYS; i++) { if (i < PRICE_INDEX_WINDOW_DAYS && slotMonth(i) === slotMonth(bandStart)) continue; xBands.push({ start: bandStart, end: i - 1, label: MONTH_NAMES[slotDate(bandStart).getUTCMonth()].slice(0, 3), }); bandStart = i; } const options = { window: PRICE_INDEX_WINDOW_DAYS, min: 0, xBands, }; // The panel scrolls, but the chart must not swallow it: size it to the // panel's width and cap it at half the visible height, so the inflation // table below stays a short scroll away. A hidden panel measures zero, so // leave the defaults there and let the next refresh size it properly. const $panel = $container.closest(".modal-body"); const width = $panel.length ? $panel.width() : 0; const height = $panel.length ? $panel.height() : 0; if (width > 0) { options.width = width; options.height = Math.min(width * 0.45, height > 0 ? height / 2 : width * 0.45); } const chart = lineChart(series, options); $container.append(chart); const percent = (value) => `${value >= 0 ? "+" : "−"}${(Math.abs(value) * 100).toFixed(2)}%`; const $table = $("
"); $table.append( $("").append( $("") .append($("").text("Commodity")) .append($("").text("Index")) .append($("").text("Monthly")) .append($("").text("Yearly")) ) ); const $body = $(""); for (const row of rows) { $body.append( $("") .append( $("") .append( $("") .css("background", row.resource.colour) ) .append(document.createTextNode(row.resource.name)) ) .append($("").text(row.info.index.toFixed(1))) .append($("").text(percent(row.info.monthly))) .append($("").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 = $("
"); const $head = $("").append( $("") .append($("").text("Currency")) .append($("").text(`1 ${mine.code || ""} buys`)) ); const $body = $(""); for (const entry of rows) { $body.append( $("") .append($("").text(`${entry.symbol} ${entry.name} (${entry.code})`)) .append($("").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 = $("") .text("◀") .prop("disabled", !older) .on("click", () => { if (!older) return; this.budgetOffset += 1; this._renderBudget({ months }); }); const $next = $("") .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, $("").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($("
").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 = $("
"); const $head = $(""); const $headRow = $(""); for (const column of columns) { const $th = $("").text(column.label); if (column.numeric) $th.addClass("numeric"); $headRow.append($th); } $head.append($headRow); const $body = $(""); 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 = $(""); const $name = $("").text(city.isCapital ? `★ ${city.name}` : city.name); $tr.append($name); $tr.append($("").text(compact(city.population))); $tr.append($("").text(`${currencySymbol()}${compact(city.gdp)}`)); $tr.append($("").text(`+${currencySymbol()}${compact(city.income)}`)); $body.append($tr); } $table.append($head, $body); $table.append( $("").append( $("") .append($("").text("TOTAL")) .append($("").text(compact(totalPopulation))) .append($("").text(`${currencySymbol()}${compact(totalGdp)}`)) .append($("").text(`+${currencySymbol()}${compact(totalIncome)}`)) ) ); this.$citiesList.append($table); } // The Resources tab: the nation's stores, what it consumes and makes, what it // imports and exports, beside the reference price and the world's supply and // demand. Energy is a daily flow, so it has no store: its row reads the // cities' demand against the power plants' output instead. _renderResources(info) { if (!this.$resourcesList || !this.$resourcesList.length) return; this.$resourcesList.empty(); // The simple economy's Resources tab is a global market: one row per // storable good with the pool, the world stock, the price and Buy/Sell // lots. There is no per-region store table and no major-traders ranking. if (this.economyModel === "simple") { this._renderMarket(info || {}); if (this.$resourceTradeList) this.$resourceTradeList.empty().addClass("hidden"); return; } const summary = info && info.summary; if (this.$resourcesSub && this.$resourcesSub.length) { this.$resourcesSub.text( "Stores, daily consumption, production and cross-nation trade, with the " + "reference price. Energy is delivered as a flow and cannot be stored." ); } if (!summary) { this.$resourcesList.append($("
").text("No resource data.")); } else { this._renderResourceStores(info, summary); } this._renderResourceLeaders(info); this._setResourceView(this.resourceView); } // The simple economy's market: the viewer's pool, the world stock and price // of each storable good, and the three buy/sell lots. Everything is priced // from the one world stock, so a purchase the treasury cannot afford or a // sale the pool cannot cover is disabled rather than sent to be refused. _renderMarket(info) { const summary = info.summary || {}; const pool = summary.stock || {}; const market = new Map((info.market || []).map((entry) => [entry.id, entry])); const budget = info.budget || 0; if (this.$resourcesSub && this.$resourcesSub.length) { this.$resourcesSub.text( `Treasury ${formatMoney(budget)} · your pool is available in every region` ); } this.$resourcesList.append( $("
").text( "The global market is shared by every nation: buying drains the world stock and " + "raises the price, selling refills it and lowers the price. Goods go into your " + "national pool, which every region can draw on." ) ); const $table = $("
"); const $head = $("").append( $("") .append($("").text("Resource")) .append($("").text("In pool")) .append($("").text("World stock")) .append($("").text("Price")) .append($("").text("Buy")) .append($("").text("Sell")) ); const $body = $(""); for (const proto of RESOURCES) { if (proto.id === "energy") continue; const id = proto.id; const stats = market.get(id) || {}; const held = pool[id] || 0; const stock = stats.stock; const price = stats.price || 0; const $row = $(""); const $name = $(""); $name.append( $("") .attr("src", `assets/${proto.icon}`) .attr("alt", proto.name) ); $name.append($("").text(` ${proto.name}`)); $row.append($name); $row.append( $("").text(formatResourceAmount(id, held)) ); $row.append( $("").text( stock === null || stock === undefined ? "—" : formatResourceAmount(id, stock) ) ); $row.append( $("").append(this._resourcePriceNode(proto, price)) ); $row.append($("").append( this._marketControls(id, "buy", held, price, budget, stock) )); $row.append($("").append( this._marketControls(id, "sell", held, price, budget, stock) )); $body.append($row); } $table.append($head, $body); this.$resourcesList.append($table); this.$resourcesList.append( $("
").text( "Selling takes goods from your pool; buying adds to it. A lot the treasury " + "cannot afford or the pool cannot cover is disabled." ) ); } // The Buy or Sell buttons for one good, one per lot. The cost/revenue is // checked against the treasury and the pool up front so a disabled button // explains itself instead of sending an order the server would refuse. _marketControls(id, side, held, price, budget, stock) { const $group = $(""); for (const lot of MARKET_LOTS) { const buying = side === "buy"; const cost = lot * price; const affordable = buying ? budget + 1e-9 >= cost && stock !== null && stock !== undefined && stock + 1e-9 >= lot : held + 1e-9 >= lot; const $button = $("") .text(compact(lot)) .prop("disabled", !affordable); $button.attr( "title", buying ? `Buy ${groupDigits(lot)} for ${formatMoney(cost)}` : `Sell ${groupDigits(lot)} for ${formatMoney(cost)}` ); if (affordable) { $button.on("click", () => this.onMarketTrade(id, side, lot)); } $group.append($button); } return $group; } _renderResourceStores(info, summary) { const market = new Map(((info && info.market) || []).map((entry) => [entry.id, entry])); const trade = summary.trade || { imports: {}, exports: {} }; // Day-over-day arrows: when the game day rolls, the values last drawn become // the baseline every cell compares against. Prices move with a lower // threshold, so a ten-percent jump doubles its arrow. const day = info && info.totalHours !== undefined ? Math.floor(info.totalHours / HOURS_PER_DAY) : 0; if (this._resourceDay !== day) { this._resourceBaseline = this._resourceLast || null; this._resourceDay = day; } const baseline = this._resourceBaseline || {}; const values = {}; const template = document.getElementById("tpl-resource-store-row"); const $table = $("
"); 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 = $(""); const $headRow = $(""); for (const column of columns) { const $th = $("").text(column.label); if (column.numeric) $th.addClass("numeric"); $headRow.append($th); } $head.append($headRow); const $body = $(""); 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( $("
").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 = $("
"); const columns = [ { label: "Resource" }, { label: "Major exporter" }, { label: "Exported /\u00a0day", numeric: true }, { label: "Major importer" }, { label: "Imported /\u00a0day", numeric: true }, ]; const $head = $(""); const $headRow = $(""); for (const column of columns) { const $th = $("").text(column.label); if (column.numeric) $th.addClass("numeric"); $headRow.append($th); } $head.append($headRow); const $body = $(""); 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 = $(""); const $name = $(""); $name.append( $("") .attr("src", `assets/${proto.icon}`) .attr("alt", proto.name) ); $name.append($("").text(` ${proto.name}`)); $tr.append($name); $tr.append($("").text(exporter ? name(exporter.civ) : "—")); $tr.append( $("").text( exporter ? formatResourceAmount(proto.id, exporter.amount) : "—" ) ); $tr.append($("").text(importer ? name(importer.civ) : "—")); $tr.append( $("").text( importer ? formatResourceAmount(proto.id, importer.amount) : "—" ) ); $body.append($tr); } $table.append($head, $body); this.$resourceTradeList.append($table); this.$resourceTradeList.append( $("
").text( "The largest cross-nation deliveries of the last day, in each resource. " + "Only trade between nations is counted, so goods bought from the global " + "market never make a nation an importer here." ) ); } // A market price reads in euro per the resource's own unit; tiny energy prices // keep enough decimals to be legible. _resourcePriceNode(proto, price) { const value = price === undefined || price === null ? 0 : price; const decimals = value >= 1 ? 0 : 4; return valueNode(value, { unit: "money", suffix: ` /\u00a0${proto.unit}`, decimals }); } _makeRow($list, proto, index, kind) { const $card = $("
"); const $info = $("
"); const $name = $("
"); const $desc = $("
").text(proto.description || ""); const $effects = $("
"); const $cost = $("
"); $info.append($name, $desc, $effects, $cost); const $actions = $("
"); const $action = $(""); $action.on("click", () => { if (kind === "government") this.onGovernment(index); else this.onFocusTechnology(index); }); $actions.append($action); $card.append($info, $actions); $list.append($card); return { proto, index, kind, $name, $effects, $cost, $action, $card }; } // The hover detail for a technology card: its description and what its next // level yields. Outstanding requirements are traced on the tree itself rather // than listed here. _technologyTip(index) { const proto = this.technologies[index]; // A repeatable's next level is what is being weighed, so its diminishing // gain is shown rather than the level already held. const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0; const level = proto.repeatable ? repeat + 1 : 1; const sections = []; if (proto.description) { sections.push({ label: "Research", cls: "tip-produce", sign: "+", suffix: "", items: [{ text: proto.description }] }); } sections.push({ label: proto.repeatable ? `Next level (${repeat + 1})` : "Effect", cls: "tip-produce", sign: "+", suffix: "", items: [{ node: effectNodes(proto.effects, level) }], }); return sections; } refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null, intelligence = null) { this.currentGovernment = currentGovernment; // A snapshot rebuilds the open panels in place; hold each scroll container // where the player left it so the panel does not jump back to the top under // them (the central bank's foreign reserves sit below the fold). const scrolls = this.$modal.find(".modal-body") .map((_, el) => ({ el, top: el.scrollTop })) .get(); if (techInfo) this.techInfo = techInfo; if (resourcesInfo) { this.resources = resourcesInfo; if (resourcesInfo.economyModel) this.economyModel = resourcesInfo.economyModel; } this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number)); if (diplomacy !== null) this.diplomacy = diplomacy; if (conflicts !== null) this.conflicts = conflicts || []; if (politics !== null) this.politics = politics; if (intelligence !== null) this.intelligence = intelligence; this.$governmentSub.text( `Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}` ); // Research has no pool: every point flows into the focused technology, so // the panel shows the rate instead of a stockpile, with the erlenmeyer icon // standing in for the word "research". this.$technologySub .empty() .append(document.createTextNode("Research: ")) .append(researchNode((researchRate || 0) * HOURS_PER_DAY, { sign: "+", decimals: 1 })) .append(document.createTextNode("/day")); const fin = finances || {}; this._treasury = budget; if (moneyInfo) this.money = moneyInfo; this._renderBudget(fin); this._renderCities(fin.cities); this._renderResources(this.resources); this._renderEconomy(this.money); for (const row of this.governmentRows) { const proto = row.proto; row.$name.text(proto.name); row.$effects.empty().append(effectNodes(proto.effects, 1)); if (row.index === currentGovernment) { row.$cost.text("Currently in power"); row.$action.text("Current government").prop("disabled", true); } else { const cost = governmentChangeCost(proto, gdp); row.$cost.text(`Adoption cost ${currencySymbol()}${groupDigits(this._quoted(cost))}`); row.$action .text(`Adopt ${currencySymbol()}${groupDigits(this._quoted(cost))}`) .prop("disabled", false); } } this.techTree.refresh({ researched: this.researched, techInfo: this.techInfo }); this._renderDiplomacy(); this._renderConflicts(); this._renderPolitics(); this._renderIntelligence(); for (const entry of scrolls) entry.el.scrollTop = entry.top; } // Lists every other nation's economy in a sortable table, with its relation // to the player and a button to declare war. `diplomacy` is // { civs, localCiv, relations, stats }, where relations is the snapshot's // [a, b, status] matrix and stats its per-civ { population, gdp, gdpPerCapita }. // The table starts sorted alphabetically by nation and every header toggles // the ordering. _renderDiplomacy() { if (!this.$diplomacyList || !this.$diplomacyList.length) return; this.$diplomacyList.empty(); const info = this.diplomacy; if (!info) return; const civs = info.civs || []; const localCiv = info.localCiv; // Which nations a connected player commands. Everything else is run by the // computer, so the table can say who is human and name the leader. const controllers = new Map(); for (const player of Object.values(info.players || {})) { if (!player || !Number.isInteger(player.civ) || player.civ < 0) continue; controllers.set(player.civ, player.name || ""); } const wars = new Set(); for (const entry of info.relations || []) { if (entry[2] !== "war") continue; if (entry[0] === localCiv) wars.add(entry[1]); else if (entry[1] === localCiv) wars.add(entry[0]); } if (this.$diplomacySub && this.$diplomacySub.length) { const atWar = wars.size; this.$diplomacySub.text( atWar === 0 ? "Your nation is at peace with every other nation." : `Your nation is at war with ${atWar} nation${atWar === 1 ? "" : "s"}.` ); } const rows = []; for (let index = 0; index < civs.length; index++) { const stats = (info.stats || [])[index] || {}; const self = index === localCiv; const human = controllers.has(index); rows.push({ index, name: civs[index].name, gdp: stats.gdp || 0, gdpPerCapita: stats.gdpPerCapita || 0, population: stats.population || 0, approval: stats.approval || 0, atWar: self ? false : wars.has(index), self, human, leader: human ? controllers.get(index) : "", }); } this._sortDiplomacyRows(rows); const columns = [ { key: "name", label: "Nation" }, { key: "control", label: "Control" }, { key: "leader", label: "Leader" }, { key: "gdp", label: "GDP", numeric: true }, { key: "gdpPerCapita", label: "GDP /\u00a0capita", numeric: true }, { key: "population", label: "Population", numeric: true }, { key: "approval", label: "Approval", numeric: true }, { key: "status", label: "Status" }, ]; const $table = $("
"); const $head = $(""); const $headRow = $(""); for (const column of columns) { const $th = $("").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( $("").text(descending ? "▼" : "▲") ); } $th.on("click", () => this._sortDiplomacyBy(column.key)); $headRow.append($th); } $head.append($headRow); const $body = $(""); for (const row of rows) { const $tr = $(""); if (row.self) $tr.addClass("local-nation"); $tr.append($("").text(row.name)); const $control = $(""); const $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($("").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($("").text(compact(row.population))); $tr.append($("").text(`${Math.round(row.approval * 100)}%`)); const $status = $(""); if (row.self) { $status.append($("").text("Your nation")); } else { $status.append( $("").text(row.atWar ? "At war" : "At peace") ); const $action = $(""); if (row.atWar) { $action.text("At war").prop("disabled", true); } else { $action.text("Declare war"); $action.on("click", () => this.onDeclareWar(row.index)); } $status.append($action); } $tr.append($status); $body.append($tr); } $table.append($head, $body); this.$diplomacyList.append($table); this._renderTreaties(info); } // The treaty controls: the compose row (type, target, payload), the offers // waiting for an answer, and the active and past treaties. The compose row // lives outside the table the snapshot refresh rebuilds, so a half-filled // offer is not wiped by the next broadcast. _renderTreaties(info) { if (!info) return; this._bindTreatyCompose(info); const name = (index) => (info.civs[index] ? info.civs[index].name : `Nation ${index + 1}`); const localCiv = info.localCiv; const labelOf = (type) => { const proto = treatyById(type); return proto ? proto.name : type; }; const $pending = $("#treaty-pending").empty(); for (const proposal of info.treatyProposals || []) { const $row = $("
"); if (proposal.to === localCiv) { $row.append($("").text( `${name(proposal.from)} proposes ${labelOf(proposal.type)}` )); const $sign = $(""); $sign.on("click", () => this.onRespondTreaty && this.onRespondTreaty(proposal.id, true)); const $reject = $(""); $reject.on("click", () => this.onRespondTreaty && this.onRespondTreaty(proposal.id, false)); $row.append($sign, $reject); } else if (proposal.from === localCiv) { $row.append($("").text( `You proposed ${labelOf(proposal.type)} to ${name(proposal.to)} — awaiting reply` )); } else { $row.append($("").text( `${name(proposal.from)} proposes ${labelOf(proposal.type)} to ${name(proposal.to)}` )); } $pending.append($row); } const $active = $("#treaty-active").empty(); const active = info.treaties || []; if (active.length === 0) $active.append($("
").text("No treaties in force.")); for (const treaty of active) { const $row = $("
"); $row.append($("").text( `${name(treaty.a)} — ${name(treaty.b)}: ${labelOf(treaty.type)} (signed ${treaty.signedDate})` )); if (treaty.a === localCiv || treaty.b === localCiv) { const $break = $(""); $break.on("click", () => this.onCancelTreaty && this.onCancelTreaty(treaty.id)); $row.append($break); } $active.append($row); } const $past = $("#treaty-past").empty(); const past = (info.treatyHistory || []).slice().reverse(); if (past.length === 0) $past.append($("
").text("No past treaties.")); for (const treaty of past) { $past.append($("
").each(function () { $(this).find(".treaty-text").text( `${name(treaty.a)} — ${name(treaty.b)}: ${labelOf(treaty.type)} (${treaty.reason} ${treaty.endedDate})` ); })); } } // Populates the compose controls once per opened panel. The target list only // needs rebuilding when the nations themselves change. _bindTreatyCompose(info) { const $type = $("#treaty-type"); if (!$type.length) return; if ($type.children().length === 0) { for (const proto of TREATIES) { $type.append($("").attr("value", proto.id).text(proto.name)); } } const signature = (info.civs || []).map((civ) => civ.name).join("|"); const $target = $("#treaty-target"); if ($target.attr("data-signature") !== signature) { $target.empty(); for (let index = 0; index < (info.civs || []).length; index++) { if (index === info.localCiv) continue; $target.append($("").attr("value", index).text(info.civs[index].name)); } $target.attr("data-signature", signature); } if (!$type.data("treatyBound")) { $type.data("treatyBound", true); $type.on("change", () => { $type.data("lastType", $type.val()); this._renderTreatyPayload(info); }); $("#treaty-propose").on("click", () => this._treatyPayloadFromForm(info)); } // Only rebuild the payload controls when the type changes, so the snapshot // refresh does not wipe a partly filled offer on every broadcast. const current = $type.val(); if ($type.data("lastType") !== current) { $type.data("lastType", current); this._renderTreatyPayload(info); } } // Rebuilds the per-type payload controls: a city picker, a money amount, a // resource basket or a checklist of giftable units. _renderTreatyPayload(info) { const type = $("#treaty-type").val(); const $payload = $("#treaty-payload").empty(); if (type === "give_city") { const $select = $(""); for (const city of info.localCities || []) { $select.append($("").attr("value", city.id).text(city.name)); } if (!(info.localCities || []).length) { $select.append($("").attr("value", "").text("No city to give")); } $payload.append($select); } else if (type === "give_money") { $payload.append( $("") ); } else if (type === "give_resources") { for (const id of STORABLE_RESOURCE_IDS) { const proto = resourceById(id); const $label = $(""); $label.append($("").text(proto ? proto.name : id)); $label.append( $("").attr("data-id", id) ); $payload.append($label); } } else if (type === "give_military") { const options = (info.localUnits || []).filter( (unit) => !((info.unitProtos || {})[unit.proto] || {}).infantry ); for (const unit of options) { const proto = (info.unitProtos || {})[unit.proto] || {}; const $label = $(""); $label.append($("").attr("value", unit.id)); $label.append($("").text(proto.name || `Unit ${unit.id}`)); $payload.append($label); } if (options.length === 0) { $payload.append($("").text("No non-infantry unit to give")); } } } // Reads the compose controls into a treaty order. Returns null when the offer // is incomplete, so an empty form sends nothing. _treatyPayloadFromForm(info) { const type = $("#treaty-type").val(); const to = Number($("#treaty-target").val()); if (!info || Number.isNaN(to) || to === info.localCiv) return null; const payload = {}; if (type === "give_city") { const city = Number($("#treaty-payload .treaty-city").val()); if (!city) return null; payload.city = city; } else if (type === "give_money") { const amount = Number($("#treaty-payload .treaty-money").val()); if (!(amount > 0)) return null; payload.amount = amount; } else if (type === "give_resources") { const resources = {}; $("#treaty-payload .treaty-resource").each((_, element) => { const amount = Number($(element).val()); if (amount > 0) resources[$(element).data("id")] = amount; }); if (Object.keys(resources).length === 0) return null; payload.resources = resources; } else if (type === "give_military") { const units = []; $("#treaty-payload .treaty-unit:checked").each((_, element) => { units.push(Number($(element).val())); }); if (units.length === 0) return null; payload.units = units; } if (this.onProposeTreaty) this.onProposeTreaty(type, to, payload); return payload; } // A nation's money figure, in that nation's own currency, with the viewer's // equivalent at the snapshot's instant rate in a discreet parenthesis beside // it. Our own figures are already in our money, so they carry no conversion. _moneyCell(value, civ, self) { const $td = $(""); const info = self ? null : currencyFor(civ); if (!info) { $td.text(`${currencySymbol()}${compact(value)}`); return $td; } $td.append( document.createTextNode(`${info.symbol}${compact(value * rateToNational(civ))}`) ); $td.append( $("").text(` (${currencySymbol()}${compact(value)})`) ); return $td; } // Clicking a header sorts by it, reversing the order when it is already the // active column. _sortDiplomacyBy(key) { if (this.diplomacySort.key === key) { this.diplomacySort.dir *= -1; } else { this.diplomacySort.key = key; this.diplomacySort.dir = 1; } this._renderDiplomacy(); } _sortDiplomacyRows(rows) { const { key, dir } = this.diplomacySort; rows.sort((a, b) => { let result; if (key === "name") result = a.name < b.name ? -1 : a.name > b.name ? 1 : 0; else if (key === "leader") { result = (a.leader || "").localeCompare(b.leader || ""); } else if (key === "control") result = (a.human ? 1 : 0) - (b.human ? 1 : 0); else if (key === "status") result = (a.atWar ? 1 : 0) - (b.atWar ? 1 : 0); else result = a[key] - b[key]; if (result === 0) result = a.index - b.index; return dir * result; }); } // Lists every ongoing war between the world's nations, whoever is fighting. // Clicking a conflict reveals the date it began and the military and civilian // dead on each side. _renderConflicts() { if (!this.$conflictsList || !this.$conflictsList.length) return; this.$conflictsList.empty(); const conflicts = this.conflicts || []; if (this.$conflictsSub && this.$conflictsSub.length) { this.$conflictsSub.text( conflicts.length === 0 ? "No ongoing conflicts." : `${conflicts.length} ongoing conflict${conflicts.length === 1 ? "" : "s"} between the world's nations.` ); } if (conflicts.length === 0) { this.$conflictsList.append( $("
").text("The world is at peace.") ); return; } for (const conflict of conflicts) this._makeConflictRow(conflict); } _makeConflictRow(conflict) { const $card = $("
"); const $info = $("
"); const $name = $("
").text( `${this._civName(conflict.a)} vs ${this._civName(conflict.b)}` ); const $desc = $("
").text(`Ongoing since ${conflict.started}`); $info.append($name, $desc); const key = `${conflict.a}:${conflict.b}`; const $detail = $("
"); if (!this.expandedConflicts.has(key)) $detail.addClass("hidden"); $detail.append( $("
").text(`Conflict started ${conflict.started}`) ); for (const side of conflict.sides || []) { $detail.append( $("
").text( `${this._civName(side.civ)} — Military: ${groupDigits(side.military || 0)} · ` + `Civilians: ${groupDigits(side.civilians || 0)}` ) ); } $card.append($info, $detail); $card.on("click", () => { if (this.expandedConflicts.has(key)) this.expandedConflicts.delete(key); else this.expandedConflicts.add(key); $detail.toggleClass("hidden"); }); this.$conflictsList.append($card); } _civName(index) { const civ = this.diplomacy && this.diplomacy.civs ? this.diplomacy.civs[index] : null; return civ ? civ.name : `Nation ${index + 1}`; } // --------------------------------------------------------- politics -- _pct(value) { return `${Math.round((value || 0) * 100)}%`; } _renderPolitics() { if (!this.$politicsList || !this.$politicsList.length) return; const info = this.politics; if (!info) return; const civs = info.civs || []; const localCiv = info.localCiv; // Build the tab's controls once. Snapshots arrive far more often than the // panel changes, so only the dynamic lists are redrawn: the wizard and the // open dropdowns keep their state instead of being torn down each refresh. if (!this._politicsBuilt || this._politicsCiv !== localCiv) { this._buildPolitics(info, civs, localCiv); this._politicsBuilt = true; this._politicsCiv = localCiv; } this._refreshPolitics(info, civs, localCiv); } _buildPolitics(info, civs, localCiv) { this.$politicsList.empty(); this._politicsWizardOpen = false; this._politicsState = { policy: { type: POLICIES[0].id, ethnicity: this._defaultEthnicity(civs, localCiv) }, opinionObserver: localCiv, }; this.$politicsMakeup = $("
"); this.$politicsMigrations = $("
"); this.$politicsList.append( this.$politicsMakeup, this.$politicsMigrations, this._buildPolicies(civs), this._buildOpinions(civs) ); } // Redraws only the lists, leaving the form controls and their state alone. _refreshPolitics(info, civs, localCiv) { const own = info.approval && info.approval[localCiv]; const ownApproval = own ? own[localCiv] : 0.5; const impact = (info.cultureImpact && info.cultureImpact[localCiv]) || 0; this.$politicsSub.text( `Government approval ${this._pct(ownApproval)} · ` + `Cultural impact ${(impact * 100).toFixed(1)}%` ); this.$politicsMakeup.empty(); this.$politicsMakeup.append($("
").text("Your people")); const makeup = (info.viewerStats && info.viewerStats.ethnicMakeup) || []; if (makeup.length > 0) this.$politicsMakeup.append(ethnicityChart(makeup, civs)); else this.$politicsMakeup.append($("
").text("No population data.")); this._fillMigrations(info, civs, localCiv); this._fillActivePolicies(info, civs, localCiv); this._syncPolicyWizard(); this._fillOpinions(); } // The Intelligence tab's sub-tabs, built once per open: the spy controls and // the propaganda campaigns. Switching only shows and hides the two views. _buildIntelligenceSubtabs() { if (!this.$intelligenceSubtabs || !this.$intelligenceSubtabs.length) return; this.$intelligenceSubtabs.empty(); const views = [ { id: "spies", label: "Spies" }, { id: "propaganda", label: "Propaganda" }, ]; for (const view of views) { const $button = $("") .attr("data-view", view.id) .text(view.label); if (view.id === this.intelligenceView) $button.addClass("active"); $button.on("click", () => this._setIntelligenceView(view.id)); this.$intelligenceSubtabs.append($button); } this._setIntelligenceView(this.intelligenceView); } _setIntelligenceView(view) { this.intelligenceView = view; if (this.$intelligenceSubtabs) { this.$intelligenceSubtabs.find(".subtab").removeClass("active"); this.$intelligenceSubtabs.find(`.subtab[data-view="${view}"]`).addClass("active"); } if (this.$intelligenceSpyView) { this.$intelligenceSpyView.toggleClass("hidden", view !== "spies"); } if (this.$intelligencePropagandaView) { this.$intelligencePropagandaView.toggleClass("hidden", view !== "propaganda"); } } // The Intelligence tab: the spy roster and the propaganda campaigns. Spies' // covert operations are unit actions and live in the unit panel, not here. // Built once per open like Politics, so its controls survive the snapshots // that arrive while the tab is open. _renderIntelligence() { if (!this.$intelligenceSpyView || !this.$intelligenceSpyView.length) return; const info = this.intelligence; if (!info) return; const civs = info.civs || []; const localCiv = info.localCiv; if (!this._intelligenceBuilt || this._intelligenceCiv !== localCiv) { this._buildIntelligence(info, civs, localCiv); this._intelligenceBuilt = true; this._intelligenceCiv = localCiv; } this._refreshIntelligence(info, civs, localCiv); } _buildIntelligence(info, civs, localCiv) { this.$intelligenceSpyView.empty(); this.$intelligencePropagandaView.empty(); this._intelligenceState = { campaign: { observer: localCiv, kind: "government", target: this._otherCiv(localCiv, civs), direction: 1, }, }; this._campaignSignature = null; this._campaignRows = new Map(); this._spySignature = null; this._spyRows = new Map(); this._reportsSignature = null; this.$intelligenceSpies = $("
"); this.$intelligenceCampaigns = $("
"); this.$intelligenceSpyView.append( this._buildSpiesCard(), this._buildReportsCard(), this._buildSatellitesCard() ); this.$intelligencePropagandaView.append( this.$intelligenceCampaigns, this._buildCampaignForm(civs) ); this._setIntelligenceView(this.intelligenceView); } _refreshIntelligence(info, civs, localCiv) { this.$intelligenceSub.text( "Your spies and the propaganda campaigns they serve." ); this._fillSpies(info, localCiv); this._fillReports(info); this._fillSatellites(info); this._fillCampaigns(info, localCiv); this._syncCampaignReadout(); this._syncCampaignForm(); } // The spy roster: one row per spy in the field, with a Locate button that // selects it and brings the map to it. Rows are only rebuilt when the roster // (or a spy's whereabouts) changes, so an arriving snapshot does not yank a // button out from under the cursor. _buildSpiesCard() { this.$intelligenceSpies.empty(); this.$intelligenceSpies.append($("
").text("Your spies")); this.$intelligenceSpyList = $("
"); this.$intelligenceSpies.append(this.$intelligenceSpyList); return this.$intelligenceSpies; } _fillSpies(info, localCiv) { if (!this.$intelligenceSpyList) return; const spies = info.spies || []; const signature = spies .map((spy) => `${spy.id}:${spy.coords[0]},${spy.coords[1]}:${spy.location}`) .join("|"); if (signature === this._spySignature) return; this._spySignature = signature; this.$intelligenceSpyList.empty(); this._spyRows = new Map(); if (spies.length === 0) { this.$intelligenceSpyList.append( $("
").text( this._hasResearched("intelligence_agencies") ? "No spies in the field. Train one in a city." : "Research Intelligence Agencies to train spies." ) ); return; } spies.forEach((spy, index) => { const $row = $("
"); $row.append($("
").text(`Spy ${index + 1} — ${spy.location}`)); const $locate = $("").text("Locate"); $locate.on("click", () => this.onLocateUnit(spy.id)); $row.append($("
").append($locate)); this.$intelligenceSpyList.append($row); this._spyRows.set(spy.id, $row); }); } // The reports the viewer's spies have brought home: city dossiers and // intercepted communications. Rebuilt only when a report id changes. _buildReportsCard() { this.$intelligenceReports = $("
"); this.$intelligenceReports.append($("
").text("Intelligence reports")); this.$intelligenceReportsList = $("
"); this.$intelligenceReports.append(this.$intelligenceReportsList); return this.$intelligenceReports; } _fillReports(info) { if (!this.$intelligenceReportsList) return; const reports = (info.reports || []).slice().reverse(); const signature = reports.map((report) => report.id).join(","); if (signature === this._reportsSignature) return; this._reportsSignature = signature; this.$intelligenceReportsList.empty(); if (reports.length === 0) { this.$intelligenceReportsList.append( $("
").text("No reports yet. Send a spy to watch a foreign city.") ); return; } for (const report of reports) this._buildReportRow(report); } _buildReportRow(report) { const owner = this._civName(report.civ); const $row = $("
"); if (report.kind === "comms") { $row.append($("
").text(`${owner} communications from ${report.capital}`)); if ((report.messages || []).length === 0) { $row.append($("
").text("No messages intercepted.")); } for (const message of report.messages || []) { const from = message.from === null ? "public" : this._civName(message.from); const to = message.to === null ? "public" : this._civName(message.to); $row.append( $("
").text(`${from} → ${to}: ${message.text}`) ); } } else { $row.append($("
").text(`${owner} city of ${report.city}`)); $row.append( $("
").text( `Population ${groupDigits(Math.round(report.population || 0))}` ) ); if ((report.buildings || []).length > 0) { $row.append( $("
").text( `Buildings: ${report.buildings.map((b) => `${b.name} ${b.level}`).join(", ")}` ) ); } if ((report.garrison || []).length > 0) { $row.append( $("
").text( `Garrison: ${report.garrison.map((g) => `${g.name} ×${g.count}`).join(", ")}` ) ); } else { $row.append($("
").text("Garrison: none seen.")); } } this.$intelligenceReportsList.append($row); } // The spy satellites in orbit, and the launch control for a nation that holds // a space launch centre. _buildSatellitesCard() { this.$intelligenceSatellites = $("
"); this.$intelligenceSatellites.append($("
").text("Spy satellites")); this.$intelligenceSatelliteStatus = $("
"); this.$intelligenceSatelliteList = $("
"); this.$intelligenceSatelliteLaunch = $("").text( "Launch spy satellite" ); this.$intelligenceSatelliteLaunch.on("click", () => { const city = this.intelligence && this.intelligence.launchCity; if (city) this.onLaunchSatellite(city.id); }); this.$intelligenceSatellites.append( this.$intelligenceSatelliteStatus, this.$intelligenceSatelliteList, $("
").append(this.$intelligenceSatelliteLaunch) ); return this.$intelligenceSatellites; } _fillSatellites(info) { if (!this.$intelligenceSatelliteList) return; const satellites = info.satellites || []; const capacity = info.spaceLaunchCapacity || 0; const totalHours = info.totalHours || 0; this.$intelligenceSatelliteList.empty(); if (satellites.length === 0) { this.$intelligenceSatelliteList.append( $("
").text( capacity > 0 ? "No satellites in orbit." : "Build a space launch centre to launch spy satellites." ) ); } else { satellites.forEach((satellite, index) => { const hours = Math.max(0, (satellite.nextRevealHour || 0) - totalHours); const days = Math.ceil(hours / 24); this.$intelligenceSatelliteList.append( $("
").text( `Satellite ${index + 1} — next sweep in ${days} day${days === 1 ? "" : "s"}` ) ); }); } this.$intelligenceSatelliteStatus.text( info.otherSatellites ? `${info.otherSatellites} foreign satellite${info.otherSatellites === 1 ? "" : "s"} in orbit.` : "" ); const can = capacity > satellites.length && !!info.launchCity; this.$intelligenceSatelliteLaunch .prop("disabled", !can) .attr( "title", can ? "Everyone will see the launch" : "No free launch pad" ); } // Whether the viewer has researched the technology with the given id. _hasResearched(id) { const index = this.technologies.findIndex((tech) => tech.id === id); return index >= 0 && this.researched.has(index); } // Campaigns the player is running or being targeted by. The rows are only // rebuilt when the set of campaigns (or a spend level) changes, so a slider // being dragged is not yanked away by an arriving snapshot; the cost text is // refreshed separately. _fillCampaigns(info, localCiv) { const campaigns = (info.campaigns || []).filter( (campaign) => campaign.civ === localCiv || campaign.observer === localCiv || campaign.target === localCiv ); this.$intelligenceCampaigns.toggleClass("hidden", campaigns.length === 0); const signature = campaigns .map((campaign) => [ campaign.id, campaign.civ, campaign.observer, campaign.targetKind, campaign.target, campaign.direction, campaign.hourlyCost, ].join(":") ) .join("|"); if (signature !== this._campaignSignature) { this._campaignSignature = signature; this.$intelligenceCampaigns.empty(); this._campaignRows = new Map(); if (campaigns.length > 0) { this.$intelligenceCampaigns.append($("
").text("Active campaigns")); for (const campaign of campaigns) this._buildCampaignRow(info, localCiv, campaign); } } for (const campaign of campaigns) { const row = this._campaignRows.get(campaign.id); if (row) row.$cost.text(this._campaignCostText(info, campaign)); } } _campaignCostText(info, campaign) { const hourly = campaign.hourlyCost || 0; const activeHours = Math.max(0, (info.totalHours || 0) - (campaign.startedHours || 0)); const spent = hourly * activeHours; const days = Math.floor(activeHours / HOURS_PER_DAY); return ( `${currencySymbol()}${groupDigits(Math.round(this._quoted(hourly) * HOURS_PER_DAY))}/day · ${currencySymbol()}${groupDigits(Math.round(spent))} spent over ` + `${days} day${days === 1 ? "" : "s"}` ); } _buildCampaignRow(info, localCiv, campaign) { const verb = campaign.direction > 0 ? "warming" : "cooling"; const target = this._civName(campaign.target); const observer = this._civName(campaign.observer); const onWhat = campaign.targetKind === "government" ? "government" : "population"; const $row = $("
"); $row.append( $("
").text( `${this._civName(campaign.civ)}: ${verb} the ${observer} people's view of the ` + `${target} ${onWhat}` ) ); const $cost = $("
"); $row.append($cost); const row = { $cost }; if (campaign.civ === localCiv) { const $slider = $("").attr({ min: 0, max: PROPAGANDA.maxHourlyCost, step: 1000, value: campaign.hourlyCost, }); const $spend = $("").text( `${currencySymbol()}${groupDigits(Math.round(this._quoted(campaign.hourlyCost) * HOURS_PER_DAY))}/day` ); $slider.on("input", () => $spend.text( `${currencySymbol()}${groupDigits(Math.round(this._quoted(Number($slider.val())) * HOURS_PER_DAY))}/day` ) ); $slider.on("change", () => { const amount = Number($slider.val()); if (amount <= 0) { // Reverting to the last known spend keeps the control honest until // the player confirms the stop. $slider.val(campaign.hourlyCost); $spend.text( `${currencySymbol()}${groupDigits(Math.round(this._quoted(campaign.hourlyCost) * HOURS_PER_DAY))}/day` ); this.onConfirm( "Stop this campaign?", `Cutting the spending to ${currencySymbol()}0 will stop the campaign.`, () => this.onCancelCampaign(campaign.id) ); } else { this.onSetCampaignSpending(campaign.id, amount); } }); const $controls = $("
").append( $("").text("Spending"), $slider, $spend ); $row.append($controls); row.$slider = $slider; row.$spend = $spend; const $cancel = $("").text("Cancel campaign"); $cancel.on("click", () => this.onCancelCampaign(campaign.id)); $row.append($("
").append($cancel)); } this.$intelligenceCampaigns.append($row); this._campaignRows.set(campaign.id, row); } // The campaign launcher, built once so its selects survive a snapshot. _buildCampaignForm(civs) { const campaign = this._intelligenceState.campaign; const $card = $("
"); $card.append($("
").text("Launch a propaganda campaign")); $card.append( $("
").text( "Spend money to warm or cool a population's view of another people or government. " + "Your cultural impact decides how strongly the message lands, and the last " + "few popularity points are the hardest to win." ) ); $card.append( $("
").text( `Costs ${currencySymbol()}${groupDigits(Math.round(this._quoted(PROPAGANDA.defaultHourlyCost) * HOURS_PER_DAY))}/day to begin; ` + "adjust the spending on an active campaign to make it land harder or softer." ) ); const $observer = this._civSelect(civs, campaign.observer); const $kind = $("").append( $("").text("their government"), $("").text("their population") ); $kind.val(campaign.kind); const $target = this._civSelect(civs, campaign.target); const $direction = $("").append( $("").text("Warm opinion"), $("").text("Cool opinion") ); $direction.val(String(campaign.direction)); const $readout = $(""); this.$campaignReadout = $readout; const sync = () => { this._intelligenceState.campaign = { observer: Number($observer.val()), kind: String($kind.val()), target: Number($target.val()), direction: Number($direction.val()), }; this._syncCampaignReadout(); this._syncCampaignForm(); }; $observer.on("change", sync); $kind.on("change", sync); $target.on("change", sync); $direction.on("change", sync); sync(); const $controls = $("
"); $controls.append(this._labelled("Observer", $observer)); $controls.append(this._labelled("Signals about", $kind)); $controls.append(this._labelled("Target", $target)); $controls.append(this._labelled("Direction", $direction)); const $start = $("").text("Start campaign"); this.$campaignStart = $start; $start.on("click", () => { const state = this._intelligenceState.campaign; this.onCampaign(state.observer, state.kind, state.target, state.direction); }); this.$campaignDuplicate = $("
"); $controls.append($("
").append($start, $readout)); $card.append($controls, this.$campaignDuplicate); return $card; } _syncCampaignReadout() { if (!this.$campaignReadout) return; const info = this.intelligence; if (!info) return; const state = this._intelligenceState.campaign; const matrix = state.kind === "government" ? info.opinions.government : info.opinions.ethnic; const row = (matrix && matrix[state.observer]) || []; const value = row[state.target]; this.$campaignReadout.text(`Current: ${this._pct(value === undefined ? 0.5 : value)}`); } // Blocks a second campaign on the same population and target. _syncCampaignForm() { if (!this.$campaignStart) return; const info = this.intelligence; if (!info) return; const state = this._intelligenceState.campaign; const duplicate = (info.campaigns || []).some( (campaign) => campaign.civ === info.localCiv && campaign.observer === state.observer && campaign.targetKind === state.kind && campaign.target === state.target ); this.$campaignDuplicate.text( duplicate ? "A campaign on this population and target is already running." : "" ); this.$campaignStart.prop("disabled", duplicate); } // The minority-policy card: the list of active policies, plus a wizard built // once so its selects stay put (and open) across snapshot refreshes. _buildPolicies(civs) { const $card = $("
"); $card.append($("
").text("Minority policies")); $card.append( $("
").text( "Any number of policies may run at once. Each sways opinions, and migration " + "policies move people in or out of the country." ) ); this.$politicsActive = $("
"); $card.append(this.$politicsActive); this.$politicsNew = $("").text( "New minority policy" ); this.$politicsWizard = $(""); this.$politicsNew.on("click", () => { this._politicsWizardOpen = !this._politicsWizardOpen; this.$politicsWizard.toggleClass("hidden", !this._politicsWizardOpen); }); const policy = this._politicsState.policy; const $type = $(""); for (const proto of POLICIES) { $type.append($("").attr("value", proto.id).text(proto.name)); } $type.val(policy.type); const $eth = this._civSelect(civs, policy.ethnicity); this.$policyType = $type; this.$policyEth = $eth; this.$policyDesc = $("
"); this.$policyTarget = $("
"); this.$policyCost = $("
"); this.$policyDuplicate = $("
"); this.$policyEnact = $("").text("Enact policy"); const sync = () => { this._politicsState.policy = { type: String($type.val()), ethnicity: Number($eth.val()), }; this._syncPolicyWizard(); }; $type.on("change", sync); $eth.on("change", sync); this.$policyEnact.on("click", () => { const state = this._politicsState.policy; this.onPolicy(state.type, state.ethnicity); }); const $controls = $("
"); $controls.append(this._labelled("Policy", $type)); $controls.append(this._labelled("Ethnicity", $eth)); $controls.append($("
").append(this.$policyEnact)); this.$politicsWizard.append( $controls, this.$policyDesc, this.$policyTarget, this.$policyCost, this.$policyDuplicate ); $card.append(this.$politicsNew, this.$politicsWizard); return $card; } // Keeps the wizard's estimates and its duplicate guard in step with the // policies actually in force. _syncPolicyWizard() { if (!this.$policyType) return; const info = this.politics; const entry = info && (info.policies || []).find((item) => item.civ === info.localCiv); const active = entry ? entry.list || [] : []; const type = String(this.$policyType.val()); const ethnicity = Number(this.$policyEth.val()); const proto = policyById(type); this.$policyDesc.text(proto ? proto.description : ""); this.$policyTarget.text(proto ? `Target: ${proto.targetEffect}` : ""); this.$policyCost.text(proto ? `Political cost: ${proto.politicalCost}` : ""); const duplicate = active.some( (policy) => policy.type === type && policy.ethnicity === ethnicity ); this.$policyDuplicate.text(duplicate ? "Already enacted for this people." : ""); this.$policyEnact.prop("disabled", duplicate); } _fillActivePolicies(info, civs, localCiv) { this.$politicsActive.empty(); const entry = (info.policies || []).find((policy) => policy.civ === localCiv); const active = entry ? entry.list || [] : []; if (active.length === 0) { this.$politicsActive.append( $("
").text("No minority policies enacted.") ); return; } for (const policy of active) { const proto = policyById(policy.type); const people = this._peopleName(civs, policy.ethnicity); const $row = $("
"); $row.append($("").text(`${proto ? proto.name : policy.type} — ${people}`)); const $revoke = $("").text("Revoke"); $revoke.on("click", () => this.onRevokePolicy(policy.id)); $row.append($revoke); this.$politicsActive.append($row); } } // Shows the last day's people movements in and out of the player's country. _fillMigrations(info, civs, localCiv) { this.$politicsMigrations.empty(); const entry = (info.migrations || []).find((flow) => flow.civ === localCiv); const inbound = entry ? entry.inbound || [] : []; const outbound = entry ? entry.outbound || [] : []; const empty = inbound.length === 0 && outbound.length === 0; this.$politicsMigrations.toggleClass("hidden", empty); if (empty) return; this.$politicsMigrations.append( $("
").text("Migration (last day)") ); const describe = (flows, sign) => { for (const [ethnicity, amount] of flows) { this.$politicsMigrations.append( $("
").text( `${sign}${groupDigits(amount)} ${this._peopleName(civs, ethnicity)}` ) ); } }; describe(inbound, "+"); describe(outbound, "−"); } _otherCiv(localCiv, civs) { const other = localCiv === 0 ? 1 : 0; return civs && other < civs.length ? other : localCiv; } // The demonym of an ethnicity, for prose. _peopleName(civs, ethnicity) { const civ = civs && civs[ethnicity]; if (!civ) return `Ethnicity ${ethnicity + 1}`; return civ.demonym ? `the ${civ.demonym}` : civ.name; } _defaultEthnicity(civs, localCiv) { if (civs.length > 1) return localCiv === 0 ? 1 : 0; return localCiv; } // A full breakdown of one population's opinions: of every government, // including its own, and of every other people. _buildOpinions(civs) { const $card = $("
"); $card.append($("
").text("Opinions by population")); $card.append( $("
").text( "How a people sees every government and every other people. Pick the people to inspect." ) ); const $observer = this._civSelect(civs, this._politicsState.opinionObserver); this.$opinionObserver = $observer; $observer.on("change", () => { this._politicsState.opinionObserver = Number($observer.val()); this._fillOpinions(); }); this.$opinionList = $("
"); const $controls = $("
"); $controls.append(this._labelled("Population", $observer)); $card.append($controls, this.$opinionList); return $card; } _fillOpinions() { if (!this.$opinionList) return; const info = this.politics; if (!info) return; const civs = info.civs || []; const observer = this._politicsState.opinionObserver; const gov = (info.opinions.government && info.opinions.government[observer]) || []; const eth = (info.opinions.ethnic && info.opinions.ethnic[observer]) || []; this.$opinionList.empty(); this.$opinionList.append($("
").text("Governments")); for (let index = 0; index < civs.length; index++) { const note = index === observer ? " (its own)" : ""; this.$opinionList.append(this._opinionRow(`${this._civName(index)}${note}`, gov[index])); } this.$opinionList.append($("
").text("Peoples")); for (let index = 0; index < civs.length; index++) { if (index === observer) continue; this.$opinionList.append(this._opinionRow(this._peopleName(civs, index), eth[index])); } } // A percentage bar for one opinion. _opinionRow(label, value) { const pct = Math.round((value === undefined ? 0.5 : value) * 100); const $fill = $("
") .css("width", `${pct}%`) .toggleClass("negative", pct < 50); const $bar = $("
").append($fill); return $("
") .append($("").text(label)) .append($bar) .append($("").text(`${pct}%`)); } _civSelect(civs, selected) { const $select = $(""); civs.forEach((civ, index) => { const label = civ.demonym ? `The ${civ.demonym}` : civ.name; const $option = $("").attr("value", index).text(label); if (index === selected) $option.prop("selected", true); $select.append($option); }); return $select; } _labelled(label, $control) { return $("") .append($("").text(label)) .append($control); } close() { this.$modal.addClass("hidden"); } }