2087 lines
84 KiB
JavaScript
2087 lines
84 KiB
JavaScript
// The nation panel: government, technology, budget, diplomacy and conflicts.
|
||
|
||
import { compact, groupDigits } from "../../../shared/text_format.js";
|
||
import {
|
||
governmentChangeCost,
|
||
prerequisitesMet,
|
||
} from "../../../shared/rules.js";
|
||
import {
|
||
POLICIES,
|
||
PROPAGANDA,
|
||
policyById,
|
||
TECH_THEMES,
|
||
technologyCost,
|
||
RESOURCES,
|
||
RESOURCE_IDS,
|
||
resourceById,
|
||
} from "../../../shared/data.js";
|
||
import { formatResourceAmount } from "../../../shared/resources.js";
|
||
import { renderMonthlyBudget } from "./budget.js";
|
||
import { effectNodes, money, quotedMoney } from "./format.js";
|
||
import { bindTabs } from "./tabs.js";
|
||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||
import { buildCard, researchNode, setCardMeta } from "../ui/card.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 } from "../currency.js";
|
||
import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
|
||
import { MONTH_NAMES } from "../../../shared/game_clock.js";
|
||
|
||
// The central bank charts a rolling window of the last two years, so its
|
||
// monthly commodity index stays 24 points instead of growing with the game.
|
||
const PRICE_INDEX_WINDOW_MONTHS = 24;
|
||
|
||
// Each research theme borrows the building-card icon of its field, so a
|
||
// technology card reads with the same visual grammar as a building.
|
||
const TECH_THEME_ICONS = {
|
||
science: "icon_university.svg",
|
||
economy: "icon_bank.svg",
|
||
society: "icon_museum.svg",
|
||
military: "icon_fortifications.svg",
|
||
};
|
||
|
||
export class NationModal {
|
||
constructor() {
|
||
this.$modal = $("#modal-nation");
|
||
this.$governmentSub = $("#government-sub");
|
||
this.$governmentList = $("#government-list");
|
||
this.$technologySub = $("#technology-sub");
|
||
this.$technologyList = $("#technology-list");
|
||
this.$budgetSub = $("#budget-sub");
|
||
this.$budgetMonths = $("#budget-months");
|
||
this.$budgetList = $("#budget-list");
|
||
this.$economySub = $("#economy-sub");
|
||
this.$economySubtabs = $("#economy-subtabs");
|
||
this.$economySummary = $("#economy-summary");
|
||
this.$economyConsumption = $("#economy-consumption");
|
||
this.$economyCurrency = $("#economy-currency");
|
||
this.$economyTaxes = $("#economy-taxes");
|
||
this.$economyCentralBank = $("#economy-central-bank");
|
||
this.$economyRates = $("#economy-rates");
|
||
// Which Economy sub-tab is open: the private sector, the trade table, the
|
||
// viewer's own currency, the central bank or the exchange rates.
|
||
this.economyView = "summary";
|
||
// The central-bank interest input is built once and only its value updated,
|
||
// so typing is not interrupted by the snapshot refresh.
|
||
this._centralBankBuilt = false;
|
||
// The tax controls are likewise built once; only their values refresh.
|
||
this._taxesBuilt = false;
|
||
this.$resourcesSub = $("#resources-sub");
|
||
this.$resourcesList = $("#resources-list");
|
||
this.$resourceTradeList = $("#resource-trade-list");
|
||
// Which Resources sub-tab is open: the nation's own stores and trade, or
|
||
// the world ranking of each commodity's major exporter and importer.
|
||
this.resourceView = "stores";
|
||
// Day-over-day baseline and the latest values for the Resources tab arrows.
|
||
this._resourceDay = null;
|
||
this._resourceBaseline = null;
|
||
this._resourceLast = null;
|
||
this.$diplomacySub = $("#diplomacy-sub");
|
||
this.$diplomacyList = $("#diplomacy-list");
|
||
this.$citiesSub = $("#cities-sub");
|
||
this.$citiesList = $("#cities-list");
|
||
this.$conflictsSub = $("#conflicts-sub");
|
||
this.$conflictsList = $("#conflicts-list");
|
||
this.$politicsSub = $("#politics-sub");
|
||
this.$politicsList = $("#politics-list");
|
||
this.governments = [];
|
||
this.technologies = [];
|
||
this.currentGovernment = 0;
|
||
this.researched = new Set();
|
||
this.governmentRows = [];
|
||
this.technologyRows = [];
|
||
// Focused research: which theme tab is open and the current focus figures.
|
||
this.activeTheme = TECH_THEMES[0].id;
|
||
this.techInfo = { focus: -1, progress: 0, repeatCounts: {} };
|
||
this.diplomacy = null;
|
||
this.diplomacySort = { key: "name", dir: 1 };
|
||
this.conflicts = [];
|
||
// Conflict cards the player expanded, keyed "a:b". Snapshot refreshes
|
||
// rebuild the list, so the open/closed state has to live outside the DOM.
|
||
this.expandedConflicts = new Set();
|
||
// How many months back the budget tab is paging: 0 is the current month.
|
||
this.budgetOffset = 0;
|
||
this._treasury = 0;
|
||
this._budgetMonths = [];
|
||
// Expense categories whose per-resource purchase detail is expanded.
|
||
this.expandedBudgetCategories = new Set();
|
||
this.politics = null;
|
||
// The Politics tab is built once and only its lists are refreshed, so the
|
||
// wizard and dropdowns survive the snapshots that arrive while it is open.
|
||
this._politicsBuilt = false;
|
||
this._politicsCiv = -1;
|
||
this._politicsState = null;
|
||
this._politicsWizardOpen = false;
|
||
this.onGovernment = () => {};
|
||
this.onFocusTechnology = () => {};
|
||
this.onDeclareWar = () => {};
|
||
this.onCampaign = () => {};
|
||
this.onCancelCampaign = () => {};
|
||
this.onSetCampaignSpending = () => {};
|
||
this.onPolicy = () => {};
|
||
this.onRevokePolicy = () => {};
|
||
this.onSetInterestRate = () => {};
|
||
this.onSetTaxRate = () => {};
|
||
this.onConfirm = () => {};
|
||
this.$modal.find(".modal-close").on("click", () => this.close());
|
||
bindTabs(this.$modal, "tab-");
|
||
}
|
||
|
||
isShowing() {
|
||
return !this.$modal.hasClass("hidden");
|
||
}
|
||
|
||
// Today's world market prices, from the Resources tab's data. Used to quote
|
||
// the money costs that carry no material bill of their own.
|
||
_prices() {
|
||
if (this.resources && this.resources.summary && this.resources.summary.prices) {
|
||
return this.resources.summary.prices;
|
||
}
|
||
const prices = {};
|
||
for (const entry of (this.resources && this.resources.market) || []) {
|
||
prices[entry.id] = entry.price;
|
||
}
|
||
return prices;
|
||
}
|
||
|
||
// A fixed money cost quoted at today's market.
|
||
_quoted(amount) {
|
||
return quotedMoney(amount, this._prices());
|
||
}
|
||
|
||
show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
|
||
this.governments = governments;
|
||
this.technologies = technologies;
|
||
this.diplomacy = diplomacy;
|
||
this.conflicts = conflicts || [];
|
||
this.politics = politics;
|
||
this.resources = resourcesInfo;
|
||
this.money = moneyInfo;
|
||
// Rebuild the rows from scratch: reopening the panel must not stack a
|
||
// second set of buttons on top of the previous one, or the stale rows keep
|
||
// their old labels and disabled state and appear unresponsive.
|
||
this.$governmentList.empty();
|
||
this.$technologyList.empty();
|
||
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
|
||
this.technologyRows = technologies.map((proto, index) => this._makeTechnologyCard(proto, index));
|
||
for (const row of this.technologyRows) this.$technologyList.append(row.card.$card);
|
||
this._buildTechnologyThemes();
|
||
this._buildResourceSubtabs();
|
||
this._buildEconomySubtabs();
|
||
this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo, resourcesInfo, moneyInfo);
|
||
this.$modal.removeClass("hidden");
|
||
}
|
||
|
||
// The technology tab's theme sub-tabs, built once per open. Switching a tab
|
||
// only shows and hides the rows that are already there.
|
||
_buildTechnologyThemes() {
|
||
const $themes = $("#technology-themes");
|
||
if (!$themes.length) return;
|
||
$themes.empty();
|
||
for (const theme of TECH_THEMES) {
|
||
const $button = $("<button type='button' class='subtab'></button>").text(theme.name);
|
||
if (theme.id === this.activeTheme) $button.addClass("active");
|
||
$button.on("click", () => {
|
||
this.activeTheme = theme.id;
|
||
$themes.find(".subtab").removeClass("active");
|
||
$button.addClass("active");
|
||
this._applyTechnologyFilter();
|
||
});
|
||
$themes.append($button);
|
||
}
|
||
}
|
||
|
||
_applyTechnologyFilter() {
|
||
for (const row of this.technologyRows) {
|
||
if (row.$card) row.$card.toggle(row.proto.theme === this.activeTheme);
|
||
}
|
||
}
|
||
|
||
// The Resources tab's sub-tabs: the nation's own stores and trade, and the
|
||
// world ranking of each commodity's major exporters and importers. Both views
|
||
// are rendered together; switching only shows and hides them.
|
||
_buildResourceSubtabs() {
|
||
const $tabs = $("#resources-subtabs");
|
||
if (!$tabs.length) return;
|
||
$tabs.empty();
|
||
const views = [
|
||
{ id: "stores", label: "Stores & trade" },
|
||
{ id: "leaders", label: "Major traders" },
|
||
];
|
||
for (const view of views) {
|
||
const $button = $("<button type='button' class='subtab'></button>")
|
||
.attr("data-view", view.id)
|
||
.text(view.label);
|
||
if (view.id === this.resourceView) $button.addClass("active");
|
||
$button.on("click", () => this._setResourceView(view.id));
|
||
$tabs.append($button);
|
||
}
|
||
}
|
||
|
||
_setResourceView(view) {
|
||
this.resourceView = view;
|
||
const $tabs = $("#resources-subtabs");
|
||
$tabs.find(".subtab").removeClass("active");
|
||
$tabs.find(`.subtab[data-view="${view}"]`).addClass("active");
|
||
if (this.$resourcesList) this.$resourcesList.toggleClass("hidden", view !== "stores");
|
||
if (this.$resourceTradeList) this.$resourceTradeList.toggleClass("hidden", view !== "leaders");
|
||
}
|
||
|
||
// The Economy tab's sub-tabs: the private sector, the day's consumption and
|
||
// trade, the viewer's own currency and where it is held, the central bank and
|
||
// the exchange-rate table.
|
||
_buildEconomySubtabs() {
|
||
if (!this.$economySubtabs || !this.$economySubtabs.length) return;
|
||
this.$economySubtabs.empty();
|
||
const currency = (this.money && this.money.currency) || null;
|
||
const views = [
|
||
{ id: "summary", label: "Private sector" },
|
||
{ id: "consumption", label: "Consumption & trade" },
|
||
{ id: "currency", label: currency ? `${currency.symbol} ${currency.name}` : "Currency" },
|
||
{ id: "taxes", label: "Taxes" },
|
||
{ id: "centralbank", label: "Central bank" },
|
||
{ id: "rates", label: "Exchange rates" },
|
||
];
|
||
for (const view of views) {
|
||
const $button = $("<button type='button' class='subtab'></button>")
|
||
.attr("data-view", view.id)
|
||
.text(view.label);
|
||
if (view.id === this.economyView) $button.addClass("active");
|
||
$button.on("click", () => this._setEconomyView(view.id));
|
||
this.$economySubtabs.append($button);
|
||
}
|
||
this._setEconomyView(this.economyView);
|
||
}
|
||
|
||
_setEconomyView(view) {
|
||
this.economyView = view;
|
||
if (this.$economySubtabs) {
|
||
this.$economySubtabs.find(".subtab").removeClass("active");
|
||
this.$economySubtabs.find(`.subtab[data-view="${view}"]`).addClass("active");
|
||
}
|
||
if (this.$economySummary) this.$economySummary.toggleClass("hidden", view !== "summary");
|
||
if (this.$economyConsumption) {
|
||
this.$economyConsumption.toggleClass("hidden", view !== "consumption");
|
||
}
|
||
if (this.$economyCurrency) this.$economyCurrency.toggleClass("hidden", view !== "currency");
|
||
if (this.$economyTaxes) this.$economyTaxes.toggleClass("hidden", view !== "taxes");
|
||
if (this.$economyCentralBank) {
|
||
this.$economyCentralBank.toggleClass("hidden", view !== "centralbank");
|
||
}
|
||
if (this.$economyRates) this.$economyRates.toggleClass("hidden", view !== "rates");
|
||
// Render the view that just became visible.
|
||
this._renderEconomy(this.money);
|
||
}
|
||
|
||
_renderEconomy(money) {
|
||
this.money = money || this.money || {};
|
||
if (this.economyView === "consumption") this._renderConsumption();
|
||
else if (this.economyView === "currency") this._renderCurrencyHoldings(this.money);
|
||
else if (this.economyView === "taxes") this._renderTaxes(this.money);
|
||
else if (this.economyView === "centralbank") this._renderCentralBank(this.money);
|
||
else if (this.economyView === "rates") this._renderExchangeRates(this.money);
|
||
else this._renderPrivateSector(this.money);
|
||
}
|
||
|
||
// The day's resource consumption and the trade that crossed the nation's
|
||
// border: what each neighbour and the world market sold us, and what we sold
|
||
// them, with the money it moved.
|
||
_renderConsumption() {
|
||
if (!this.$economyConsumption || !this.$economyConsumption.length) return;
|
||
if (this.$economySub && this.$economySub.length) {
|
||
this.$economySub.text("Daily consumption and cross-border trade");
|
||
}
|
||
this.$economyConsumption.empty();
|
||
const summary = this.resources && this.resources.summary;
|
||
if (!summary) {
|
||
this.$economyConsumption.append(
|
||
$("<div class='desc'></div>").text("No resource data yet.")
|
||
);
|
||
return;
|
||
}
|
||
const consumed = summary.consumed || {};
|
||
const external = summary.external || { buy: {}, sell: {} };
|
||
const $table = $("<table class='resources-table economy-table'></table>");
|
||
const $head = $("<thead></thead>").append(
|
||
$("<tr></tr>")
|
||
.append($("<th></th>").text("Resource"))
|
||
.append($("<th class='numeric'></th>").text("Consumed /\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));
|
||
const $body = $("<tbody></tbody>");
|
||
for (const kind of kinds) {
|
||
const $tr = $("<tr></tr>");
|
||
$tr.append($("<td class='name'></td>").text(kind.label));
|
||
$tr.append(
|
||
$("<td class='numeric'></td>").append(
|
||
$("<input type='number' min='0' max='100' step='1'>")
|
||
.addClass("tax-input")
|
||
.attr("data-kind", kind.id)
|
||
)
|
||
);
|
||
for (const proto of RESOURCES) {
|
||
$tr.append(
|
||
$("<td class='numeric'></td>").append(
|
||
$("<input type='number' min='0' max='100' step='1'>")
|
||
.addClass("tax-input tax-override")
|
||
.attr("data-kind", kind.id)
|
||
.attr("data-resource", proto.id)
|
||
)
|
||
);
|
||
}
|
||
const $apply = $("<button type='button' class='aero tax-apply'></button>").text("Set");
|
||
$apply.on("click", () => this._applyTaxRow(kind.id));
|
||
$tr.append($("<td></td>").append($apply));
|
||
$body.append($tr);
|
||
}
|
||
$table.append($body);
|
||
this.$economyTaxes.append($table);
|
||
this._taxesBuilt = true;
|
||
}
|
||
// Refresh the boxes, leaving the one being typed in alone.
|
||
const active = document.activeElement;
|
||
this.$economyTaxes.find(".tax-input").each((_, element) => {
|
||
if (element === active) return;
|
||
const $input = $(element);
|
||
const kind = $input.data("kind");
|
||
const resource = $input.data("resource");
|
||
const entry = taxes[kind] || { rate: 0, resources: {} };
|
||
const value = resource ? entry.resources[resource] : entry.rate;
|
||
$input.val(value === undefined || value === null ? "" : (value * 100).toFixed(2));
|
||
});
|
||
}
|
||
|
||
// Sends every box on one tax row that has a value, the default and each
|
||
// per-resource override.
|
||
_applyTaxRow(kind) {
|
||
const $row = this.$economyTaxes.find(`.tax-input[data-kind="${kind}"]`).closest("tr");
|
||
const send = ($input, resource) => {
|
||
const raw = $input.val();
|
||
if (raw === "" || raw === null || raw === undefined) return;
|
||
const percent = Number(raw);
|
||
if (!Number.isFinite(percent) || percent < 0) return;
|
||
this.onSetTaxRate(kind, percent / 100, resource || null);
|
||
};
|
||
send($row.find(".tax-input").not(".tax-override"));
|
||
$row.find(".tax-override").each((_, element) => {
|
||
const $input = $(element);
|
||
send($input, $input.attr("data-resource"));
|
||
});
|
||
}
|
||
|
||
// The central bank: its interest rate, which compounds into every region's
|
||
// private cash, 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. Each
|
||
// series is a commodity's world price rebased to 100 in January 2000, with the
|
||
// live point appended so the current month reads to today. Only the last two
|
||
// years are plotted, so the window rolls instead of the chart growing.
|
||
_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 = [];
|
||
// The month the plotted window starts in, as an offset from January 2000.
|
||
let windowStart = 0;
|
||
for (const [id, prices] of history) {
|
||
const resource = resourceById(id);
|
||
if (!resource || !prices || prices.length === 0) continue;
|
||
const base = prices[0] > 0 ? prices[0] : 1;
|
||
const values = prices.map((price) => (price / base) * 100);
|
||
const info = inflation.get(id) || { index: 100, monthly: 0, yearly: 0 };
|
||
if (Math.abs(info.index - values[values.length - 1]) > 1e-6) values.push(info.index);
|
||
windowStart = Math.max(windowStart, values.length - PRICE_INDEX_WINDOW_MONTHS);
|
||
series.push({ label: resource.name, colour: resource.colour, values });
|
||
rows.push({ resource, info });
|
||
}
|
||
for (const entry of series) entry.values = entry.values.slice(windowStart);
|
||
$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."
|
||
)
|
||
);
|
||
if (series.length === 0) return;
|
||
const options = {
|
||
xLabel: (i) =>
|
||
`${MONTH_NAMES[(windowStart + i) % 12].slice(0, 3)} ` +
|
||
`${2000 + Math.floor((windowStart + i) / 12)}`,
|
||
};
|
||
// 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();
|
||
const summary = info && info.summary;
|
||
if (this.$resourcesSub && this.$resourcesSub.length) {
|
||
this.$resourcesSub.text(
|
||
"Stores, daily consumption, production and cross-nation trade, with the " +
|
||
"reference price. Energy is delivered as a flow and cannot be stored."
|
||
);
|
||
}
|
||
if (!summary) {
|
||
this.$resourcesList.append($("<div class='desc'></div>").text("No resource data."));
|
||
} else {
|
||
this._renderResourceStores(info, summary);
|
||
}
|
||
this._renderResourceLeaders(info);
|
||
this._setResourceView(this.resourceView);
|
||
}
|
||
|
||
_renderResourceStores(info, summary) {
|
||
const market = new Map(((info && info.market) || []).map((entry) => [entry.id, entry]));
|
||
const trade = summary.trade || { imports: {}, exports: {} };
|
||
// Day-over-day arrows: when the game day rolls, the values last drawn become
|
||
// the baseline every cell compares against. Prices move with a lower
|
||
// threshold, so a ten-percent jump doubles its arrow.
|
||
const day = info && info.totalHours !== undefined
|
||
? Math.floor(info.totalHours / HOURS_PER_DAY)
|
||
: 0;
|
||
if (this._resourceDay !== day) {
|
||
this._resourceBaseline = this._resourceLast || null;
|
||
this._resourceDay = day;
|
||
}
|
||
const baseline = this._resourceBaseline || {};
|
||
const values = {};
|
||
const 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 };
|
||
}
|
||
|
||
// One technology card in the shared building-card idiom: the theme icon and
|
||
// name up top with the research state below, the description and effect in
|
||
// the hover tooltip, and a Focus button carrying the research cost. A focused
|
||
// technology fills the card's progress bar instead.
|
||
_makeTechnologyCard(proto, index) {
|
||
const row = { proto, index, repeat: 0 };
|
||
const card = buildCard({
|
||
icon: TECH_THEME_ICONS[proto.theme] || "icon_university.svg",
|
||
name: proto.name,
|
||
status: "",
|
||
action: { label: "Focus" },
|
||
progress: true,
|
||
tip: () => this._technologyTip(row),
|
||
onAction: () => this.onFocusTechnology(index),
|
||
});
|
||
row.card = card;
|
||
row.$card = card.$card;
|
||
row.$name = card.$card.find(".bt-name");
|
||
row.$status = card.$status;
|
||
row.$action = card.$action;
|
||
row.$label = card.$label;
|
||
row.$meta = card.$meta;
|
||
row.$fill = card.$progressFill;
|
||
return row;
|
||
}
|
||
|
||
// The hover detail for a technology card: its description, what its next
|
||
// level yields, and what it still needs before it can be focused.
|
||
_technologyTip(row) {
|
||
const proto = row.proto;
|
||
// A repeatable's next level is what is being weighed, so its diminishing
|
||
// gain is shown rather than the level already held.
|
||
const level = proto.repeatable ? row.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 (${row.repeat + 1})` : "Effect",
|
||
cls: "tip-produce",
|
||
sign: "+",
|
||
suffix: "",
|
||
items: [{ node: effectNodes(proto.effects, level) }],
|
||
});
|
||
if (
|
||
row.$action &&
|
||
row.$action.prop("disabled") &&
|
||
!this.researched.has(row.index) &&
|
||
!prerequisitesMet(proto, this.researched, this.technologies)
|
||
) {
|
||
const names = (proto.prerequisites || [])
|
||
.map((id) => {
|
||
const found = this.technologies.find((t) => t.id === id);
|
||
return found ? found.name : id;
|
||
})
|
||
.join(", ");
|
||
sections.push({ label: "Requires", cls: "tip-cost", sign: "−", suffix: "", items: [{ text: names }] });
|
||
}
|
||
return sections;
|
||
}
|
||
|
||
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
|
||
this.currentGovernment = currentGovernment;
|
||
if (techInfo) this.techInfo = techInfo;
|
||
if (resourcesInfo) this.resources = resourcesInfo;
|
||
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
|
||
if (diplomacy !== null) this.diplomacy = diplomacy;
|
||
if (conflicts !== null) this.conflicts = conflicts || [];
|
||
if (politics !== null) this.politics = politics;
|
||
this.$governmentSub.text(
|
||
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}`
|
||
);
|
||
// Research has no pool: every point flows into the focused technology, so
|
||
// the panel shows the rate instead of a stockpile, with the erlenmeyer icon
|
||
// standing in for the word "research".
|
||
this.$technologySub
|
||
.empty()
|
||
.append(document.createTextNode("Research: "))
|
||
.append(researchNode(researchRate || 0, { sign: "+", decimals: 1 }))
|
||
.append(document.createTextNode("/h"));
|
||
|
||
const fin = finances || {};
|
||
this._treasury = budget;
|
||
|
||
if (moneyInfo) this.money = moneyInfo;
|
||
this._renderBudget(fin);
|
||
this._renderCities(fin.cities);
|
||
this._renderResources(this.resources);
|
||
this._renderEconomy(this.money);
|
||
|
||
for (const row of this.governmentRows) {
|
||
const proto = row.proto;
|
||
row.$name.text(proto.name);
|
||
row.$effects.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);
|
||
}
|
||
}
|
||
|
||
const repeatCounts = this.techInfo.repeatCounts || {};
|
||
for (const row of this.technologyRows) {
|
||
const proto = row.proto;
|
||
const repeat = repeatCounts[proto.id] || 0;
|
||
const focused = this.techInfo.focus === row.index;
|
||
row.repeat = repeat;
|
||
row.$name.text(proto.name);
|
||
row.$fill.css("width", "0%");
|
||
row.$action.removeClass("progressing");
|
||
row.$card.removeClass("built locked");
|
||
row.$status.removeClass("built");
|
||
row.$action.prop("disabled", false);
|
||
|
||
if (this.researched.has(row.index) && !proto.repeatable) {
|
||
row.$status.text("Researched").addClass("built");
|
||
row.$card.addClass("built");
|
||
row.$label.text("Researched");
|
||
row.$action.prop("disabled", true);
|
||
setCardMeta(row.$meta, {});
|
||
} else if (!prerequisitesMet(proto, this.researched, this.technologies)) {
|
||
row.$status.text("Locked");
|
||
row.$card.addClass("locked");
|
||
row.$label.text("Locked");
|
||
row.$action.prop("disabled", true);
|
||
setCardMeta(row.$meta, {});
|
||
} else if (focused) {
|
||
// A focused technology fills the card's progress bar, with the committed
|
||
// and total points on the button.
|
||
const cost = technologyCost(proto, repeat);
|
||
const progress = this.techInfo.progress || 0;
|
||
const pct = cost > 0 ? Math.max(0, Math.min(100, (progress / cost) * 100)) : 0;
|
||
row.$status.text(repeat > 0 ? `Level ${repeat} · Researching` : "Researching").addClass("built");
|
||
row.$card.addClass("built");
|
||
row.$label.text("Focused");
|
||
row.$action.prop("disabled", true).addClass("progressing");
|
||
row.$fill.css("width", `${pct}%`);
|
||
setCardMeta(row.$meta, {});
|
||
row.$meta
|
||
.append(researchNode(progress))
|
||
.append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`));
|
||
} else {
|
||
const cost = technologyCost(proto, repeat);
|
||
row.$status.text(repeat > 0 ? `Level ${repeat}` : "Not researched");
|
||
row.$label.text("Focus");
|
||
setCardMeta(row.$meta, {});
|
||
row.$meta.append(researchNode(cost));
|
||
}
|
||
}
|
||
this._applyTechnologyFilter();
|
||
|
||
this._renderDiplomacy();
|
||
this._renderConflicts();
|
||
this._renderPolitics();
|
||
}
|
||
|
||
// Lists every other nation's economy in a sortable table, with its relation
|
||
// to the player and a button to declare war. `diplomacy` is
|
||
// { civs, localCiv, relations, stats }, where relations is the snapshot's
|
||
// [a, b, status] matrix and stats its per-civ { population, gdp, gdpPerCapita }.
|
||
// The table starts sorted alphabetically by nation and every header toggles
|
||
// the ordering.
|
||
_renderDiplomacy() {
|
||
if (!this.$diplomacyList || !this.$diplomacyList.length) return;
|
||
this.$diplomacyList.empty();
|
||
const info = this.diplomacy;
|
||
if (!info) return;
|
||
const civs = info.civs || [];
|
||
const localCiv = info.localCiv;
|
||
const wars = new Set();
|
||
for (const entry of info.relations || []) {
|
||
if (entry[2] !== "war") continue;
|
||
if (entry[0] === localCiv) wars.add(entry[1]);
|
||
else if (entry[1] === localCiv) wars.add(entry[0]);
|
||
}
|
||
if (this.$diplomacySub && this.$diplomacySub.length) {
|
||
const atWar = wars.size;
|
||
this.$diplomacySub.text(
|
||
atWar === 0
|
||
? "Your nation is at peace with every other nation."
|
||
: `Your nation is at war with ${atWar} nation${atWar === 1 ? "" : "s"}.`
|
||
);
|
||
}
|
||
|
||
const rows = [];
|
||
for (let index = 0; index < civs.length; index++) {
|
||
const stats = (info.stats || [])[index] || {};
|
||
const self = index === localCiv;
|
||
rows.push({
|
||
index,
|
||
name: civs[index].name,
|
||
gdp: stats.gdp || 0,
|
||
gdpPerCapita: stats.gdpPerCapita || 0,
|
||
population: stats.population || 0,
|
||
approval: stats.approval || 0,
|
||
atWar: self ? false : wars.has(index),
|
||
self,
|
||
});
|
||
}
|
||
this._sortDiplomacyRows(rows);
|
||
|
||
const columns = [
|
||
{ key: "name", label: "Nation" },
|
||
{ key: "gdp", label: "GDP", numeric: true },
|
||
{ key: "gdpPerCapita", label: "GDP /\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));
|
||
$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);
|
||
}
|
||
|
||
// 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 === "status") result = (a.atWar ? 1 : 0) - (b.atWar ? 1 : 0);
|
||
else result = a[key] - b[key];
|
||
if (result === 0) result = a.index - b.index;
|
||
return dir * result;
|
||
});
|
||
}
|
||
|
||
// Lists every ongoing war between the world's nations, whoever is fighting.
|
||
// Clicking a conflict reveals the date it began and the military and civilian
|
||
// dead on each side.
|
||
_renderConflicts() {
|
||
if (!this.$conflictsList || !this.$conflictsList.length) return;
|
||
this.$conflictsList.empty();
|
||
const conflicts = this.conflicts || [];
|
||
if (this.$conflictsSub && this.$conflictsSub.length) {
|
||
this.$conflictsSub.text(
|
||
conflicts.length === 0
|
||
? "No ongoing conflicts."
|
||
: `${conflicts.length} ongoing conflict${conflicts.length === 1 ? "" : "s"} between the world's nations.`
|
||
);
|
||
}
|
||
if (conflicts.length === 0) {
|
||
this.$conflictsList.append(
|
||
$("<div class='desc'></div>").text("The world is at peace.")
|
||
);
|
||
return;
|
||
}
|
||
for (const conflict of conflicts) this._makeConflictRow(conflict);
|
||
}
|
||
|
||
_makeConflictRow(conflict) {
|
||
const $card = $("<div class='row-card conflict-card'></div>");
|
||
const $info = $("<div class='info'></div>");
|
||
const $name = $("<div class='name'></div>").text(
|
||
`${this._civName(conflict.a)} vs ${this._civName(conflict.b)}`
|
||
);
|
||
const $desc = $("<div class='desc'></div>").text(`Ongoing since ${conflict.started}`);
|
||
$info.append($name, $desc);
|
||
|
||
const key = `${conflict.a}:${conflict.b}`;
|
||
const $detail = $("<div class='conflict-detail'></div>");
|
||
if (!this.expandedConflicts.has(key)) $detail.addClass("hidden");
|
||
$detail.append(
|
||
$("<div class='conflict-line'></div>").text(`Conflict started ${conflict.started}`)
|
||
);
|
||
for (const side of conflict.sides || []) {
|
||
$detail.append(
|
||
$("<div class='conflict-side'></div>").text(
|
||
`${this._civName(side.civ)} — Military: ${groupDigits(side.military || 0)} · ` +
|
||
`Civilians: ${groupDigits(side.civilians || 0)}`
|
||
)
|
||
);
|
||
}
|
||
|
||
$card.append($info, $detail);
|
||
$card.on("click", () => {
|
||
if (this.expandedConflicts.has(key)) this.expandedConflicts.delete(key);
|
||
else this.expandedConflicts.add(key);
|
||
$detail.toggleClass("hidden");
|
||
});
|
||
this.$conflictsList.append($card);
|
||
}
|
||
|
||
_civName(index) {
|
||
const civ = this.diplomacy && this.diplomacy.civs ? this.diplomacy.civs[index] : null;
|
||
return civ ? civ.name : `Nation ${index + 1}`;
|
||
}
|
||
|
||
// --------------------------------------------------------- politics --
|
||
|
||
_pct(value) {
|
||
return `${Math.round((value || 0) * 100)}%`;
|
||
}
|
||
|
||
_renderPolitics() {
|
||
if (!this.$politicsList || !this.$politicsList.length) return;
|
||
const info = this.politics;
|
||
if (!info) return;
|
||
const civs = info.civs || [];
|
||
const localCiv = info.localCiv;
|
||
// Build the tab's controls once. Snapshots arrive far more often than the
|
||
// panel changes, so only the dynamic lists are redrawn: the wizard and the
|
||
// open dropdowns keep their state instead of being torn down each refresh.
|
||
if (!this._politicsBuilt || this._politicsCiv !== localCiv) {
|
||
this._buildPolitics(info, civs, localCiv);
|
||
this._politicsBuilt = true;
|
||
this._politicsCiv = localCiv;
|
||
}
|
||
this._refreshPolitics(info, civs, localCiv);
|
||
}
|
||
|
||
_buildPolitics(info, civs, localCiv) {
|
||
this.$politicsList.empty();
|
||
this._politicsWizardOpen = false;
|
||
this._politicsState = {
|
||
campaign: {
|
||
observer: localCiv,
|
||
kind: "government",
|
||
target: this._otherCiv(localCiv, civs),
|
||
direction: 1,
|
||
},
|
||
policy: { type: POLICIES[0].id, ethnicity: this._defaultEthnicity(civs, localCiv) },
|
||
opinionObserver: localCiv,
|
||
};
|
||
this._campaignSignature = null;
|
||
this._campaignRows = new Map();
|
||
this.$politicsMakeup = $("<div class='politics-card'></div>");
|
||
this.$politicsCampaigns = $("<div class='politics-card'></div>");
|
||
this.$politicsMigrations = $("<div class='politics-card'></div>");
|
||
this.$politicsList.append(
|
||
this.$politicsMakeup,
|
||
this.$politicsCampaigns,
|
||
this._buildCampaignForm(civs),
|
||
this.$politicsMigrations,
|
||
this._buildPolicies(civs),
|
||
this._buildOpinions(civs)
|
||
);
|
||
}
|
||
|
||
// Redraws only the lists, leaving the form controls and their state alone.
|
||
_refreshPolitics(info, civs, localCiv) {
|
||
const own = info.approval && info.approval[localCiv];
|
||
const ownApproval = own ? own[localCiv] : 0.5;
|
||
const impact = (info.cultureImpact && info.cultureImpact[localCiv]) || 0;
|
||
this.$politicsSub.text(
|
||
`Government approval ${this._pct(ownApproval)} · ` +
|
||
`Cultural impact ${(impact * 100).toFixed(1)}%`
|
||
);
|
||
|
||
this.$politicsMakeup.empty();
|
||
this.$politicsMakeup.append($("<div class='name'></div>").text("Your people"));
|
||
const makeup = (info.viewerStats && info.viewerStats.ethnicMakeup) || [];
|
||
if (makeup.length > 0) this.$politicsMakeup.append(ethnicityChart(makeup, civs));
|
||
else this.$politicsMakeup.append($("<div class='desc'></div>").text("No population data."));
|
||
|
||
this._fillCampaigns(info, localCiv);
|
||
this._syncCampaignReadout();
|
||
this._syncCampaignForm();
|
||
this._fillMigrations(info, civs, localCiv);
|
||
this._fillActivePolicies(info, civs, localCiv);
|
||
this._syncPolicyWizard();
|
||
this._fillOpinions();
|
||
}
|
||
|
||
// Campaigns the player is running or being targeted by. The rows are only
|
||
// rebuilt when the set of campaigns (or a spend level) changes, so a slider
|
||
// being dragged is not yanked away by an arriving snapshot; the cost text is
|
||
// refreshed separately.
|
||
_fillCampaigns(info, localCiv) {
|
||
const campaigns = (info.campaigns || []).filter(
|
||
(campaign) =>
|
||
campaign.civ === localCiv ||
|
||
campaign.observer === localCiv ||
|
||
campaign.target === localCiv
|
||
);
|
||
this.$politicsCampaigns.toggleClass("hidden", campaigns.length === 0);
|
||
const signature = campaigns
|
||
.map((campaign) =>
|
||
[
|
||
campaign.id,
|
||
campaign.civ,
|
||
campaign.observer,
|
||
campaign.targetKind,
|
||
campaign.target,
|
||
campaign.direction,
|
||
campaign.hourlyCost,
|
||
].join(":")
|
||
)
|
||
.join("|");
|
||
if (signature !== this._campaignSignature) {
|
||
this._campaignSignature = signature;
|
||
this.$politicsCampaigns.empty();
|
||
this._campaignRows = new Map();
|
||
if (campaigns.length > 0) {
|
||
this.$politicsCampaigns.append($("<div class='name'></div>").text("Active campaigns"));
|
||
for (const campaign of campaigns) this._buildCampaignRow(info, localCiv, campaign);
|
||
}
|
||
}
|
||
for (const campaign of campaigns) {
|
||
const row = this._campaignRows.get(campaign.id);
|
||
if (row) row.$cost.text(this._campaignCostText(info, campaign));
|
||
}
|
||
}
|
||
|
||
_campaignCostText(info, campaign) {
|
||
const hourly = campaign.hourlyCost || 0;
|
||
const activeHours = Math.max(0, (info.totalHours || 0) - (campaign.startedHours || 0));
|
||
const spent = hourly * activeHours;
|
||
const days = Math.floor(activeHours / 24);
|
||
return (
|
||
`${currencySymbol()}${groupDigits(this._quoted(hourly))}/h · ${currencySymbol()}${groupDigits(Math.round(spent))} spent over ` +
|
||
`${days} day${days === 1 ? "" : "s"}`
|
||
);
|
||
}
|
||
|
||
_buildCampaignRow(info, localCiv, campaign) {
|
||
const verb = campaign.direction > 0 ? "warming" : "cooling";
|
||
const target = this._civName(campaign.target);
|
||
const observer = this._civName(campaign.observer);
|
||
const onWhat = campaign.targetKind === "government" ? "government" : "population";
|
||
const $row = $("<div class='politics-row'></div>");
|
||
$row.append(
|
||
$("<div></div>").text(
|
||
`${this._civName(campaign.civ)}: ${verb} the ${observer} people's view of the ` +
|
||
`${target} ${onWhat}`
|
||
)
|
||
);
|
||
const $cost = $("<div class='politics-cost'></div>");
|
||
$row.append($cost);
|
||
const row = { $cost };
|
||
if (campaign.civ === localCiv) {
|
||
const $slider = $("<input type='range' class='politics-slider'>").attr({
|
||
min: 0,
|
||
max: PROPAGANDA.maxHourlyCost,
|
||
step: 1000,
|
||
value: campaign.hourlyCost,
|
||
});
|
||
const $spend = $("<span class='politics-readout'></span>").text(
|
||
`${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`
|
||
);
|
||
$slider.on("input", () =>
|
||
$spend.text(`${currencySymbol()}${groupDigits(this._quoted(Number($slider.val())))}/h`)
|
||
);
|
||
$slider.on("change", () => {
|
||
const amount = Number($slider.val());
|
||
if (amount <= 0) {
|
||
// Reverting to the last known spend keeps the control honest until
|
||
// the player confirms the stop.
|
||
$slider.val(campaign.hourlyCost);
|
||
$spend.text(`${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`);
|
||
this.onConfirm(
|
||
"Stop this campaign?",
|
||
`Cutting the spending to ${currencySymbol()}0 will stop the campaign.`,
|
||
() => this.onCancelCampaign(campaign.id)
|
||
);
|
||
} else {
|
||
this.onSetCampaignSpending(campaign.id, amount);
|
||
}
|
||
});
|
||
const $controls = $("<div class='politics-spend'></div>").append(
|
||
$("<span class='politics-label'></span>").text("Spending"),
|
||
$slider,
|
||
$spend
|
||
);
|
||
$row.append($controls);
|
||
row.$slider = $slider;
|
||
row.$spend = $spend;
|
||
const $cancel = $("<button class='aero'></button>").text("Cancel campaign");
|
||
$cancel.on("click", () => this.onCancelCampaign(campaign.id));
|
||
$row.append($("<div class='politics-row-actions'></div>").append($cancel));
|
||
}
|
||
this.$politicsCampaigns.append($row);
|
||
this._campaignRows.set(campaign.id, row);
|
||
}
|
||
|
||
// The campaign launcher, built once so its selects survive a snapshot.
|
||
_buildCampaignForm(civs) {
|
||
const campaign = this._politicsState.campaign;
|
||
const $card = $("<div class='politics-card'></div>");
|
||
$card.append($("<div class='name'></div>").text("Launch a propaganda campaign"));
|
||
$card.append(
|
||
$("<div class='desc'></div>").text(
|
||
"Spend money to warm or cool a population's view of another people or government. " +
|
||
"Your cultural impact decides how strongly the message lands, and the last " +
|
||
"few popularity points are the hardest to win."
|
||
)
|
||
);
|
||
$card.append(
|
||
$("<div class='desc politics-cost'></div>").text(
|
||
`Costs ${currencySymbol()}${groupDigits(this._quoted(PROPAGANDA.defaultHourlyCost))}/h to begin; ` +
|
||
"adjust the spending on an active campaign to make it land harder or softer."
|
||
)
|
||
);
|
||
const $observer = this._civSelect(civs, campaign.observer);
|
||
const $kind = $("<select class='politics-select'></select>").append(
|
||
$("<option value='government'></option>").text("their government"),
|
||
$("<option value='ethnicity'></option>").text("their population")
|
||
);
|
||
$kind.val(campaign.kind);
|
||
const $target = this._civSelect(civs, campaign.target);
|
||
const $direction = $("<select class='politics-select'></select>").append(
|
||
$("<option value='1'></option>").text("Warm opinion"),
|
||
$("<option value='-1'></option>").text("Cool opinion")
|
||
);
|
||
$direction.val(String(campaign.direction));
|
||
const $readout = $("<span class='politics-readout'></span>");
|
||
this.$campaignReadout = $readout;
|
||
const sync = () => {
|
||
this._politicsState.campaign = {
|
||
observer: Number($observer.val()),
|
||
kind: String($kind.val()),
|
||
target: Number($target.val()),
|
||
direction: Number($direction.val()),
|
||
};
|
||
this._syncCampaignReadout();
|
||
this._syncCampaignForm();
|
||
};
|
||
$observer.on("change", sync);
|
||
$kind.on("change", sync);
|
||
$target.on("change", sync);
|
||
$direction.on("change", sync);
|
||
sync();
|
||
|
||
const $controls = $("<div class='politics-controls'></div>");
|
||
$controls.append(this._labelled("Observer", $observer));
|
||
$controls.append(this._labelled("Signals about", $kind));
|
||
$controls.append(this._labelled("Target", $target));
|
||
$controls.append(this._labelled("Direction", $direction));
|
||
const $start = $("<button class='aero'></button>").text("Start campaign");
|
||
this.$campaignStart = $start;
|
||
$start.on("click", () => {
|
||
const state = this._politicsState.campaign;
|
||
this.onCampaign(state.observer, state.kind, state.target, state.direction);
|
||
});
|
||
this.$campaignDuplicate = $("<div class='politics-estimate politics-penalty'></div>");
|
||
$controls.append($("<div class='politics-field'></div>").append($start, $readout));
|
||
$card.append($controls, this.$campaignDuplicate);
|
||
return $card;
|
||
}
|
||
|
||
_syncCampaignReadout() {
|
||
if (!this.$campaignReadout) return;
|
||
const info = this.politics;
|
||
if (!info) return;
|
||
const state = this._politicsState.campaign;
|
||
const matrix =
|
||
state.kind === "government" ? info.opinions.government : info.opinions.ethnic;
|
||
const row = (matrix && matrix[state.observer]) || [];
|
||
const value = row[state.target];
|
||
this.$campaignReadout.text(`Current: ${this._pct(value === undefined ? 0.5 : value)}`);
|
||
}
|
||
|
||
// Blocks a second campaign on the same population and target.
|
||
_syncCampaignForm() {
|
||
if (!this.$campaignStart) return;
|
||
const info = this.politics;
|
||
if (!info) return;
|
||
const state = this._politicsState.campaign;
|
||
const duplicate = (info.campaigns || []).some(
|
||
(campaign) =>
|
||
campaign.civ === info.localCiv &&
|
||
campaign.observer === state.observer &&
|
||
campaign.targetKind === state.kind &&
|
||
campaign.target === state.target
|
||
);
|
||
this.$campaignDuplicate.text(
|
||
duplicate ? "A campaign on this population and target is already running." : ""
|
||
);
|
||
this.$campaignStart.prop("disabled", duplicate);
|
||
}
|
||
|
||
// The minority-policy card: the list of active policies, plus a wizard built
|
||
// once so its selects stay put (and open) across snapshot refreshes.
|
||
_buildPolicies(civs) {
|
||
const $card = $("<div class='politics-card'></div>");
|
||
$card.append($("<div class='name'></div>").text("Minority policies"));
|
||
$card.append(
|
||
$("<div class='desc'></div>").text(
|
||
"Any number of policies may run at once. Each sways opinions, and migration " +
|
||
"policies move people in or out of the country."
|
||
)
|
||
);
|
||
this.$politicsActive = $("<div></div>");
|
||
$card.append(this.$politicsActive);
|
||
|
||
this.$politicsNew = $("<button class='aero politics-new'></button>").text(
|
||
"New minority policy"
|
||
);
|
||
this.$politicsWizard = $("<div class='politics-wizard hidden'></div>");
|
||
this.$politicsNew.on("click", () => {
|
||
this._politicsWizardOpen = !this._politicsWizardOpen;
|
||
this.$politicsWizard.toggleClass("hidden", !this._politicsWizardOpen);
|
||
});
|
||
|
||
const policy = this._politicsState.policy;
|
||
const $type = $("<select class='politics-select'></select>");
|
||
for (const proto of POLICIES) {
|
||
$type.append($("<option></option>").attr("value", proto.id).text(proto.name));
|
||
}
|
||
$type.val(policy.type);
|
||
const $eth = this._civSelect(civs, policy.ethnicity);
|
||
this.$policyType = $type;
|
||
this.$policyEth = $eth;
|
||
this.$policyTarget = $("<div class='politics-estimate'></div>");
|
||
this.$policyCost = $("<div class='politics-estimate politics-cost'></div>");
|
||
this.$policyDuplicate = $("<div class='politics-estimate politics-penalty'></div>");
|
||
this.$policyEnact = $("<button class='aero'></button>").text("Enact policy");
|
||
const sync = () => {
|
||
this._politicsState.policy = {
|
||
type: String($type.val()),
|
||
ethnicity: Number($eth.val()),
|
||
};
|
||
this._syncPolicyWizard();
|
||
};
|
||
$type.on("change", sync);
|
||
$eth.on("change", sync);
|
||
this.$policyEnact.on("click", () => {
|
||
const state = this._politicsState.policy;
|
||
this.onPolicy(state.type, state.ethnicity);
|
||
});
|
||
|
||
const $controls = $("<div class='politics-controls'></div>");
|
||
$controls.append(this._labelled("Policy", $type));
|
||
$controls.append(this._labelled("Ethnicity", $eth));
|
||
$controls.append($("<div class='politics-field'></div>").append(this.$policyEnact));
|
||
this.$politicsWizard.append(
|
||
$controls,
|
||
this.$policyTarget,
|
||
this.$policyCost,
|
||
this.$policyDuplicate
|
||
);
|
||
$card.append(this.$politicsNew, this.$politicsWizard);
|
||
return $card;
|
||
}
|
||
|
||
// Keeps the wizard's estimates and its duplicate guard in step with the
|
||
// policies actually in force.
|
||
_syncPolicyWizard() {
|
||
if (!this.$policyType) return;
|
||
const info = this.politics;
|
||
const entry = info && (info.policies || []).find((item) => item.civ === info.localCiv);
|
||
const active = entry ? entry.list || [] : [];
|
||
const type = String(this.$policyType.val());
|
||
const ethnicity = Number(this.$policyEth.val());
|
||
const proto = policyById(type);
|
||
this.$policyTarget.text(proto ? `Target: ${proto.targetEffect}` : "");
|
||
this.$policyCost.text(proto ? `Political cost: ${proto.politicalCost}` : "");
|
||
const duplicate = active.some(
|
||
(policy) => policy.type === type && policy.ethnicity === ethnicity
|
||
);
|
||
this.$policyDuplicate.text(duplicate ? "Already enacted for this people." : "");
|
||
this.$policyEnact.prop("disabled", duplicate);
|
||
}
|
||
|
||
_fillActivePolicies(info, civs, localCiv) {
|
||
this.$politicsActive.empty();
|
||
const entry = (info.policies || []).find((policy) => policy.civ === localCiv);
|
||
const active = entry ? entry.list || [] : [];
|
||
if (active.length === 0) {
|
||
this.$politicsActive.append(
|
||
$("<div class='desc'></div>").text("No minority policies enacted.")
|
||
);
|
||
return;
|
||
}
|
||
for (const policy of active) {
|
||
const proto = policyById(policy.type);
|
||
const people = this._peopleName(civs, policy.ethnicity);
|
||
const $row = $("<div class='politics-row politics-policy-row'></div>");
|
||
$row.append($("<span></span>").text(`${proto ? proto.name : policy.type} — ${people}`));
|
||
const $revoke = $("<button class='aero'></button>").text("Revoke");
|
||
$revoke.on("click", () => this.onRevokePolicy(policy.id));
|
||
$row.append($revoke);
|
||
this.$politicsActive.append($row);
|
||
}
|
||
}
|
||
|
||
// Shows the last day's people movements in and out of the player's country.
|
||
_fillMigrations(info, civs, localCiv) {
|
||
this.$politicsMigrations.empty();
|
||
const entry = (info.migrations || []).find((flow) => flow.civ === localCiv);
|
||
const inbound = entry ? entry.inbound || [] : [];
|
||
const outbound = entry ? entry.outbound || [] : [];
|
||
const empty = inbound.length === 0 && outbound.length === 0;
|
||
this.$politicsMigrations.toggleClass("hidden", empty);
|
||
if (empty) return;
|
||
this.$politicsMigrations.append(
|
||
$("<div class='name'></div>").text("Migration (last day)")
|
||
);
|
||
const describe = (flows, sign) => {
|
||
for (const [ethnicity, amount] of flows) {
|
||
this.$politicsMigrations.append(
|
||
$("<div class='politics-row'></div>").text(
|
||
`${sign}${groupDigits(amount)} ${this._peopleName(civs, ethnicity)}`
|
||
)
|
||
);
|
||
}
|
||
};
|
||
describe(inbound, "+");
|
||
describe(outbound, "−");
|
||
}
|
||
|
||
_otherCiv(localCiv, civs) {
|
||
const other = localCiv === 0 ? 1 : 0;
|
||
return civs && other < civs.length ? other : localCiv;
|
||
}
|
||
|
||
// The demonym of an ethnicity, for prose.
|
||
_peopleName(civs, ethnicity) {
|
||
const civ = civs && civs[ethnicity];
|
||
if (!civ) return `Ethnicity ${ethnicity + 1}`;
|
||
return civ.demonym ? `the ${civ.demonym}` : civ.name;
|
||
}
|
||
|
||
_defaultEthnicity(civs, localCiv) {
|
||
if (civs.length > 1) return localCiv === 0 ? 1 : 0;
|
||
return localCiv;
|
||
}
|
||
|
||
// A full breakdown of one population's opinions: of every government,
|
||
// including its own, and of every other people.
|
||
_buildOpinions(civs) {
|
||
const $card = $("<div class='politics-card'></div>");
|
||
$card.append($("<div class='name'></div>").text("Opinions by population"));
|
||
$card.append(
|
||
$("<div class='desc'></div>").text(
|
||
"How a people sees every government and every other people. Pick the people to inspect."
|
||
)
|
||
);
|
||
const $observer = this._civSelect(civs, this._politicsState.opinionObserver);
|
||
this.$opinionObserver = $observer;
|
||
$observer.on("change", () => {
|
||
this._politicsState.opinionObserver = Number($observer.val());
|
||
this._fillOpinions();
|
||
});
|
||
this.$opinionList = $("<div class='opinion-list'></div>");
|
||
const $controls = $("<div class='politics-controls'></div>");
|
||
$controls.append(this._labelled("Population", $observer));
|
||
$card.append($controls, this.$opinionList);
|
||
return $card;
|
||
}
|
||
|
||
_fillOpinions() {
|
||
if (!this.$opinionList) return;
|
||
const info = this.politics;
|
||
if (!info) return;
|
||
const civs = info.civs || [];
|
||
const observer = this._politicsState.opinionObserver;
|
||
const gov = (info.opinions.government && info.opinions.government[observer]) || [];
|
||
const eth = (info.opinions.ethnic && info.opinions.ethnic[observer]) || [];
|
||
this.$opinionList.empty();
|
||
this.$opinionList.append($("<div class='opinion-heading'></div>").text("Governments"));
|
||
for (let index = 0; index < civs.length; index++) {
|
||
const note = index === observer ? " (its own)" : "";
|
||
this.$opinionList.append(this._opinionRow(`${this._civName(index)}${note}`, gov[index]));
|
||
}
|
||
this.$opinionList.append($("<div class='opinion-heading'></div>").text("Peoples"));
|
||
for (let index = 0; index < civs.length; index++) {
|
||
if (index === observer) continue;
|
||
this.$opinionList.append(this._opinionRow(this._peopleName(civs, index), eth[index]));
|
||
}
|
||
}
|
||
|
||
// A percentage bar for one opinion.
|
||
_opinionRow(label, value) {
|
||
const pct = Math.round((value === undefined ? 0.5 : value) * 100);
|
||
const $fill = $("<div class='opinion-fill'></div>")
|
||
.css("width", `${pct}%`)
|
||
.toggleClass("negative", pct < 50);
|
||
const $bar = $("<div class='opinion-bar'></div>").append($fill);
|
||
return $("<div class='opinion-row'></div>")
|
||
.append($("<span class='opinion-name'></span>").text(label))
|
||
.append($bar)
|
||
.append($("<span class='opinion-pct'></span>").text(`${pct}%`));
|
||
}
|
||
|
||
_civSelect(civs, selected) {
|
||
const $select = $("<select class='politics-select'></select>");
|
||
civs.forEach((civ, index) => {
|
||
const label = civ.demonym ? `The ${civ.demonym}` : civ.name;
|
||
const $option = $("<option></option>").attr("value", index).text(label);
|
||
if (index === selected) $option.prop("selected", true);
|
||
$select.append($option);
|
||
});
|
||
return $select;
|
||
}
|
||
|
||
_labelled(label, $control) {
|
||
return $("<label class='politics-field'></label>")
|
||
.append($("<span class='politics-label'></span>").text(label))
|
||
.append($control);
|
||
}
|
||
|
||
close() {
|
||
this.$modal.addClass("hidden");
|
||
}
|
||
}
|