Files
Battle-for-Tismo/client/js/modals/nation.js
T
adrien 91dfedf7ec Gave regions their own economies and removed the global market
- Each nation has a currency and each region a private-sector cash basket, borrowing from its central bank at the bank's rate; the government may always pay, going into central-bank debt when its budget runs red.
- Resources are bought from reachable stores only: the region itself first, then same-nation regions, then foreign ones, paid for out of private cash or, for government orders, the treasury. Drawn national stores are paid to their owning region.
- A grid in deficit imports power from a reachable surplus grid instead of a market fallback; downgrading a building no longer refunds anything.
- The monthly budget lists each resource's economic value, expands to a resource-to-source tree and keeps a cash figure beside it.
- Regions report what their people went short of over the day; starved research buildings and power-starved works raise a private daily notice.
- The city panel gains a Resources tab: reserves per resource, their market value and the private sector's daily consumption.
2026-09-22 06:41:58 +02:00

1691 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The nation panel: government, technology, budget, diplomacy and conflicts.
import { compact, groupDigits } from "../../../shared/text_format.js";
import {
governmentChangeCost,
prerequisitesMet,
} from "../../../shared/rules.js";
import {
POLICIES,
PROPAGANDA,
policyById,
TECH_THEMES,
technologyCost,
RESOURCES,
RESOURCE_IDS,
resourceById,
} from "../../../shared/data.js";
import { formatResourceAmount } from "../../../shared/resources.js";
import { renderMonthlyBudget } from "./budget.js";
import { effectsText, money, quotedMoney } from "./format.js";
import { bindTabs } from "./tabs.js";
import { ethnicityChart } from "../ui/ethnicity.js";
import { currencySymbol } from "../currency.js";
export class NationModal {
constructor() {
this.$modal = $("#modal-nation");
this.$governmentSub = $("#government-sub");
this.$governmentList = $("#government-list");
this.$technologySub = $("#technology-sub");
this.$technologyList = $("#technology-list");
this.$budgetSub = $("#budget-sub");
this.$budgetMonths = $("#budget-months");
this.$budgetList = $("#budget-list");
this.$economySub = $("#economy-sub");
this.$economySubtabs = $("#economy-subtabs");
this.$economySummary = $("#economy-summary");
this.$economyConsumption = $("#economy-consumption");
this.$economyCurrency = $("#economy-currency");
this.$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;
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";
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.onConfirm = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
bindTabs(this.$modal, "tab-");
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
// Today's world market prices, from the Resources tab's data. Used to quote
// the money costs that carry no material bill of their own.
_prices() {
if (this.resources && this.resources.summary && this.resources.summary.prices) {
return this.resources.summary.prices;
}
const prices = {};
for (const entry of (this.resources && this.resources.market) || []) {
prices[entry.id] = entry.price;
}
return prices;
}
// A fixed money cost quoted at today's market.
_quoted(amount) {
return quotedMoney(amount, this._prices());
}
show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
this.governments = governments;
this.technologies = technologies;
this.diplomacy = diplomacy;
this.conflicts = conflicts || [];
this.politics = politics;
this.resources = resourcesInfo;
this.money = moneyInfo;
// Rebuild the rows from scratch: reopening the panel must not stack a
// second set of buttons on top of the previous one, or the stale rows keep
// their old labels and disabled state and appear unresponsive.
this.$governmentList.empty();
this.$technologyList.empty();
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology"));
this._buildTechnologyThemes();
this._buildResourceSubtabs();
this._buildEconomySubtabs();
this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo, resourcesInfo, moneyInfo);
this.$modal.removeClass("hidden");
}
// The technology tab's theme sub-tabs, built once per open. Switching a tab
// only shows and hides the rows that are already there.
_buildTechnologyThemes() {
const $themes = $("#technology-themes");
if (!$themes.length) return;
$themes.empty();
for (const theme of TECH_THEMES) {
const $button = $("<button type='button' class='subtab'></button>").text(theme.name);
if (theme.id === this.activeTheme) $button.addClass("active");
$button.on("click", () => {
this.activeTheme = theme.id;
$themes.find(".subtab").removeClass("active");
$button.addClass("active");
this._applyTechnologyFilter();
});
$themes.append($button);
}
}
_applyTechnologyFilter() {
for (const row of this.technologyRows) {
if (row.$card) row.$card.toggle(row.proto.theme === this.activeTheme);
}
}
// The Resources tab's sub-tabs: the nation's own stores and trade, and the
// world ranking of each commodity's major exporters and importers. Both views
// are rendered together; switching only shows and hides them.
_buildResourceSubtabs() {
const $tabs = $("#resources-subtabs");
if (!$tabs.length) return;
$tabs.empty();
const views = [
{ id: "stores", label: "Stores & trade" },
{ id: "leaders", label: "Major traders" },
];
for (const view of views) {
const $button = $("<button type='button' class='subtab'></button>")
.attr("data-view", view.id)
.text(view.label);
if (view.id === this.resourceView) $button.addClass("active");
$button.on("click", () => this._setResourceView(view.id));
$tabs.append($button);
}
}
_setResourceView(view) {
this.resourceView = view;
const $tabs = $("#resources-subtabs");
$tabs.find(".subtab").removeClass("active");
$tabs.find(`.subtab[data-view="${view}"]`).addClass("active");
if (this.$resourcesList) this.$resourcesList.toggleClass("hidden", view !== "stores");
if (this.$resourceTradeList) this.$resourceTradeList.toggleClass("hidden", view !== "leaders");
}
// The Economy tab's sub-tabs: the private sector, the day's consumption and
// trade, the viewer's own currency and where it is held, the central bank and
// the exchange-rate table.
_buildEconomySubtabs() {
if (!this.$economySubtabs || !this.$economySubtabs.length) return;
this.$economySubtabs.empty();
const currency = (this.money && this.money.currency) || null;
const views = [
{ id: "summary", label: "Private sector" },
{ id: "consumption", label: "Consumption & trade" },
{ id: "currency", label: currency ? `${currency.symbol} ${currency.name}` : "Currency" },
{ id: "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.$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 === "centralbank") this._renderCentralBank(this.money);
else if (this.economyView === "rates") this._renderExchangeRates(this.money);
else this._renderPrivateSector(this.money);
}
// The day's resource consumption and the trade that crossed the nation's
// border: what each neighbour and the world market sold us, and what we sold
// them, with the money it moved.
_renderConsumption() {
if (!this.$economyConsumption || !this.$economyConsumption.length) return;
if (this.$economySub && this.$economySub.length) {
this.$economySub.text("Daily consumption and cross-border trade");
}
this.$economyConsumption.empty();
const summary = this.resources && this.resources.summary;
if (!summary) {
this.$economyConsumption.append(
$("<div class='desc'></div>").text("No resource data yet.")
);
return;
}
const consumed = summary.consumed || {};
const external = summary.external || { buy: {}, sell: {} };
const $table = $("<table class='resources-table economy-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Resource"))
.append($("<th class='numeric'></th>").text("Consumed / day"))
.append($("<th class='numeric'></th>").text("Bought abroad"))
.append($("<th class='numeric'></th>").text("Spent"))
.append($("<th class='numeric'></th>").text("Sold abroad"))
.append($("<th class='numeric'></th>").text("Earned"))
);
const $body = $("<tbody></tbody>");
let totalSpent = 0;
let totalEarned = 0;
for (const id of RESOURCE_IDS) {
const proto = resourceById(id);
const name = proto ? proto.name : id;
const buy = (external.buy && external.buy[id]) || { qty: 0, value: 0 };
const sell = (external.sell && external.sell[id]) || { qty: 0, value: 0 };
totalSpent += buy.value || 0;
totalEarned += sell.value || 0;
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(name))
.append(
$("<td class='numeric'></td>").text(
formatResourceAmount(id, consumed[id] || 0)
)
)
.append($("<td class='numeric'></td>").text(
buy.qty > 0 ? formatResourceAmount(id, buy.qty) : "—"
))
.append($("<td class='numeric'></td>").text(
buy.value > 0 ? this._amount(buy.value) : "—"
))
.append($("<td class='numeric'></td>").text(
sell.qty > 0 ? formatResourceAmount(id, sell.qty) : "—"
))
.append($("<td class='numeric'></td>").text(
sell.value > 0 ? this._amount(sell.value) : "—"
))
);
}
$table.append($head, $body);
$table.append(
$("<tfoot></tfoot>").append(
$("<tr class='budget-total'></tr>")
.append($("<td colspan='3'></td>").text("Trade balance"))
.append($("<td class='numeric'></td>").text(this._amount(totalSpent)))
.append($("<td></td>"))
.append($("<td class='numeric'></td>").text(this._amount(totalEarned)))
)
);
this.$economyConsumption.append($table);
this.$economyConsumption.append(
$("<div class='desc'></div>").text(
"Bought and sold counts only trade across the border — foreign " +
"regions reached over the trade network. Neighbours at home are not " +
"a foreign sale."
)
);
}
// Where every unit of the viewer's currency is stored: the treasury, the
// nation's regions, and the foreign regions and central banks that hold it.
_renderCurrencyHoldings(money) {
if (!this.$economyCurrency || !this.$economyCurrency.length) return;
const holdings = money.currencyHoldings;
const currency = money.currency || { name: "Currency", symbol: "¤", code: "" };
if (this.$economySub && this.$economySub.length) {
this.$economySub.text(
`Holdings of the ${currency.name} (${currency.code})`
);
}
this.$economyCurrency.empty();
if (!holdings) {
this.$economyCurrency.append(
$("<div class='desc'></div>").text("No currency data yet.")
);
return;
}
const $table = $("<table class='resources-table economy-table'></table>");
const $body = $("<tbody></tbody>");
$body.append(
$("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text("Treasury")
)
);
$body.append(
$("<tr></tr>")
.append($("<td></td>").text("Government"))
.append($("<td class='numeric'></td>").text(this._amount(holdings.treasury || 0)))
);
if (holdings.treasuryDebt > 0) {
$body.append(
$("<tr class='expense-detail'></tr>")
.append($("<td></td>").text("Overdraft (debt)"))
.append($("<td class='numeric'></td>").text(`−${this._amount(holdings.treasuryDebt)}`))
);
}
$body.append(
$("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text(`Your regions (${(holdings.own || []).length})`)
)
);
for (const region of holdings.own || []) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(region.name))
.append($("<td class='numeric'></td>").text(this._amount(region.amount)))
);
}
if ((holdings.foreign || []).length > 0) {
$body.append(
$("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text("Held by foreign regions")
)
);
for (const region of holdings.foreign) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(`${region.name} (${region.civName})`))
.append($("<td class='numeric'></td>").text(this._amount(region.amount)))
);
}
}
if ((holdings.banks || []).length > 0) {
$body.append(
$("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text("Central-bank reserves")
)
);
for (const bank of holdings.banks) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(`Central bank of ${bank.name}`))
.append($("<td class='numeric'></td>").text(this._amount(bank.amount)))
);
}
}
$table.append($body);
const $foot = $("<tfoot></tfoot>").append(
$("<tr class='budget-total'></tr>")
.append($("<td></td>").text("Total in circulation"))
.append($("<td class='numeric'></td>").text(this._amount(holdings.total || 0)))
);
$table.append($foot);
this.$economyCurrency.append($table);
this.$economyCurrency.append(
$("<div class='desc'></div>").text(
"Foreign holdings are what exports earned; a foreign central bank " +
"holds your money as a reserve."
)
);
}
_amount(value) {
return `${currencySymbol()}${groupDigits(Math.round(value || 0))}`;
}
// The aggregate private-sector cash the nation's regions hold, and each
// region's own reserve.
_renderPrivateSector(money) {
if (!this.$economySummary || !this.$economySummary.length) return;
const cash = money.privateCash || 0;
if (this.$economySub && this.$economySub.length) {
this.$economySub.text(
`Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash))} · ` +
`${(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) return;
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);
}
// The central bank: its interest rate, which compounds into every region's
// private cash, and the foreign reserves it has accumulated.
_renderCentralBank(money) {
if (!this.$economyCentralBank || !this.$economyCentralBank.length) return;
const bank = money.centralBank || { rate: 0, reserves: [] };
if (this.$economySub && this.$economySub.length) {
this.$economySub.text(
`${(money.currency && money.currency.name) || "Central bank"} · ` +
`Interest rate ${(bank.rate * 100).toFixed(2)}%/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 percent = Number($input.val());
if (!Number.isFinite(percent) || percent < 0) return;
this.onSetInterestRate(percent / 100);
};
$apply.on("click", apply);
$input.on("keydown", (event) => {
if (event.key === "Enter") apply();
});
$rate.append($input, $apply);
this.$economyCentralBank.append($rate);
this.$economyCentralBank.append(
$("<div class='desc'></div>").text(
"Regions borrow from the bank at this rate when their cash runs out, " +
"and it compounds on whatever they owe. The government may run an " +
"overdraft here too."
)
);
this.$economyCentralBank.append($("<div class='central-bank-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-loans").text(
`Outstanding loans: ${currencySymbol()}${groupDigits(Math.round(bank.loans || 0))}`
);
const $reserves = this.$economyCentralBank.find(".central-bank-reserves");
$reserves.empty();
$reserves.append($("<div class='subhead'></div>").text("Foreign reserves"));
if ((bank.reserves || []).length === 0) {
$reserves.append($("<div class='desc'></div>").text("No foreign currency held yet."));
return;
}
const $table = $("<table class='resources-table economy-table'></table>");
const $body = $("<tbody></tbody>");
for (const reserve of bank.reserves) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(`${reserve.symbol} ${reserve.name}`))
.append(
$("<td class='numeric'></td>").text(
groupDigits(Math.round(reserve.amount || 0))
)
)
);
}
$table.append($body);
$reserves.append($table);
}
// The exchange rate between the viewer's currency and every other.
_renderExchangeRates(money) {
if (!this.$economyRates || !this.$economyRates.length) return;
const mine = money.currency || { name: "Currency" };
if (this.$economySub && this.$economySub.length) {
this.$economySub.text(`Exchange rates against the ${mine.name}`);
}
this.$economyRates.empty();
const rows = money.exchangeRates || [];
const $table = $("<table class='resources-table economy-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Currency"))
.append($("<th class='numeric'></th>").text(`1 ${mine.code || ""} buys`))
);
const $body = $("<tbody></tbody>");
for (const entry of rows) {
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(`${entry.symbol} ${entry.name} (${entry.code})`))
.append($("<td class='numeric'></td>").text(entry.rate.toFixed(4)))
);
}
$table.append($head, $body);
this.$economyRates.append($table);
}
// The national budget: the selected month's actual income and expenses, with
// a pager back through the stored monthly digests. The treasury stays on the
// subtitle so the table is purely the month's flows.
_renderBudget(finances) {
if (!this.$budgetList || !this.$budgetList.length) return;
const months = (finances && finances.months) || [];
this._budgetMonths = months;
const maxOffset = Math.max(0, months.length - 1);
if (this.budgetOffset > maxOffset) this.budgetOffset = maxOffset;
if (this.budgetOffset < 0) this.budgetOffset = 0;
const index = months.length - 1 - this.budgetOffset;
const report = index >= 0 ? months[index] : null;
this._renderBudgetMonthsNav(months, index);
if (this.$budgetSub && this.$budgetSub.length) {
const net = report ? ` · ${report.label}: ${money(report.net)}` : "";
this.$budgetSub.text(`Treasury: ${currencySymbol()}${groupDigits(Math.round(this._treasury))}${net}`);
}
renderMonthlyBudget(this.$budgetList, report, {
expanded: this.expandedBudgetCategories,
onToggle: () => this._renderBudget({ months }),
});
}
_renderBudgetMonthsNav(months, index) {
if (!this.$budgetMonths || !this.$budgetMonths.length) return;
this.$budgetMonths.empty();
if (months.length === 0) return;
const older = this.budgetOffset < months.length - 1;
const newer = this.budgetOffset > 0;
const $prev = $("<button type='button' class='budget-month-nav'></button>")
.text("◀")
.prop("disabled", !older)
.on("click", () => {
if (!older) return;
this.budgetOffset += 1;
this._renderBudget({ months });
});
const $next = $("<button type='button' class='budget-month-nav'></button>")
.text("▶")
.prop("disabled", !newer)
.on("click", () => {
if (!newer) return;
this.budgetOffset -= 1;
this._renderBudget({ months });
});
const label = months[index] ? months[index].label : "";
this.$budgetMonths.append(
$prev,
$("<span class='budget-month-label'></span>").text(label),
$next
);
}
// The nation's regions: each city's population, GDP and the tax it collects.
_renderCities(cities) {
if (!this.$citiesList || !this.$citiesList.length) return;
this.$citiesList.empty();
const localCiv = this.diplomacy ? this.diplomacy.localCiv : -1;
const rows = (cities || []).filter((city) => city.civ === localCiv);
if (this.$citiesSub && this.$citiesSub.length) {
this.$citiesSub.text(
rows.length === 0
? "No cities."
: `${rows.length} region${rows.length === 1 ? "" : "s"} collecting taxes independently.`
);
}
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: "Tax / h", numeric: true },
];
const $table = $("<table class='diplomacy-table budget-table'></table>");
const $head = $("<thead></thead>");
const $headRow = $("<tr></tr>");
for (const column of columns) {
const $th = $("<th></th>").text(column.label);
if (column.numeric) $th.addClass("numeric");
$headRow.append($th);
}
$head.append($headRow);
const $body = $("<tbody></tbody>");
let totalPopulation = 0;
let totalGdp = 0;
let totalIncome = 0;
for (const city of rows) {
totalPopulation += city.population;
totalGdp += city.gdp;
totalIncome += city.income;
const $tr = $("<tr></tr>");
const $name = $("<td class='name'></td>").text(city.isCapital ? `★ ${city.name}` : city.name);
$tr.append($name);
$tr.append($("<td class='numeric'></td>").text(compact(city.population)));
$tr.append($("<td class='numeric'></td>").text(`${currencySymbol()}${compact(city.gdp)}`));
$tr.append($("<td class='numeric income'></td>").text(`+${currencySymbol()}${compact(city.income)}`));
$body.append($tr);
}
$table.append($head, $body);
$table.append(
$("<tfoot></tfoot>").append(
$("<tr class='budget-total'></tr>")
.append($("<td></td>").text("TOTAL"))
.append($("<td class='numeric'></td>").text(compact(totalPopulation)))
.append($("<td class='numeric'></td>").text(`${currencySymbol()}${compact(totalGdp)}`))
.append($("<td class='numeric income'></td>").text(`+${currencySymbol()}${compact(totalIncome)}`))
)
);
this.$citiesList.append($table);
}
// The Resources tab: the nation's stores, what it consumes and makes, what it
// imports and exports, beside the reference price and the world's supply and
// demand. Energy is a daily flow, so it has no store: its row reads the
// cities' demand against the power plants' output instead.
_renderResources(info) {
if (!this.$resourcesList || !this.$resourcesList.length) return;
this.$resourcesList.empty();
const summary = info && info.summary;
if (this.$resourcesSub && this.$resourcesSub.length) {
this.$resourcesSub.text(
"Stores, daily consumption, production and cross-nation trade, with the " +
"reference price. Energy is delivered as a flow and cannot be stored."
);
}
if (!summary) {
this.$resourcesList.append($("<div class='desc'></div>").text("No resource data."));
} else {
this._renderResourceStores(info, summary);
}
this._renderResourceLeaders(info);
this._setResourceView(this.resourceView);
}
_renderResourceStores(info, summary) {
const market = new Map(((info && info.market) || []).map((entry) => [entry.id, entry]));
const trade = summary.trade || { imports: {}, exports: {} };
const $table = $("<table class='diplomacy-table budget-table resources-table'></table>");
const columns = [
{ label: "Resource" },
{ label: "Stored", numeric: true },
{ label: "Consumed / day", numeric: true },
{ label: "Made / day", numeric: true },
{ label: "Exported / day", numeric: true },
{ label: "Imported / day", numeric: true },
{ label: "Market price", numeric: true },
{ label: "World supply / day", numeric: true },
{ label: "World demand / day", numeric: true },
];
const $head = $("<thead></thead>");
const $headRow = $("<tr></tr>");
for (const column of columns) {
const $th = $("<th></th>").text(column.label);
if (column.numeric) $th.addClass("numeric");
$headRow.append($th);
}
$head.append($headRow);
const $body = $("<tbody></tbody>");
for (const proto of RESOURCES) {
const energy = proto.id === "energy";
const stats = market.get(proto.id) || {};
const stored = energy ? "—" : formatResourceAmount(proto.id, summary.stock[proto.id] || 0);
const consumed = energy
? formatResourceAmount("energy", (summary.consumed && summary.consumed.energy) || 0)
: formatResourceAmount(proto.id, (summary.consumed && summary.consumed[proto.id]) || 0);
const made = energy
? formatResourceAmount("energy", summary.energy ? summary.energy.supply : 0)
: formatResourceAmount(proto.id, summary.production[proto.id] || 0);
const exported = formatResourceAmount(
proto.id,
(trade.exports && trade.exports[proto.id]) || 0
);
const imported = formatResourceAmount(
proto.id,
(trade.imports && trade.imports[proto.id]) || 0
);
const $tr = $("<tr></tr>");
const $name = $("<td class='name'></td>");
$name.append(
$("<img class='resource-icon' width='18' height='18'>")
.attr("src", `assets/${proto.icon}`)
.attr("alt", proto.name)
);
$name.append($("<span></span>").text(` ${proto.name}`));
$tr.append($name);
$tr.append($("<td class='numeric'></td>").text(stored));
$tr.append($("<td class='numeric'></td>").text(consumed));
$tr.append($("<td class='numeric'></td>").text(made));
$tr.append($("<td class='numeric'></td>").text(exported));
$tr.append($("<td class='numeric'></td>").text(imported));
$tr.append($("<td class='numeric'></td>").text(this._resourcePriceText(proto, stats.price)));
$tr.append(
$("<td class='numeric'></td>").text(
formatResourceAmount(proto.id, Math.max(0, stats.supply || 0))
)
);
$tr.append(
$("<td class='numeric'></td>").text(
formatResourceAmount(proto.id, Math.max(0, stats.demand || 0))
)
);
$body.append($tr);
}
$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 Major traders sub-tab: for every commodity, the nation that exported the
// most over the last day and the one that imported the most.
_renderResourceLeaders(info) {
if (!this.$resourceTradeList || !this.$resourceTradeList.length) return;
this.$resourceTradeList.empty();
const trade = (info && info.trade) || [];
const civs = (info && info.civs) || [];
const name = (civ) => (civs[civ] ? civs[civ].name : `Nation ${civ + 1}`);
const $table = $("<table class='diplomacy-table budget-table resources-table'></table>");
const columns = [
{ label: "Resource" },
{ label: "Major exporter" },
{ label: "Exported / day", numeric: true },
{ label: "Major importer" },
{ label: "Imported / day", numeric: true },
];
const $head = $("<thead></thead>");
const $headRow = $("<tr></tr>");
for (const column of columns) {
const $th = $("<th></th>").text(column.label);
if (column.numeric) $th.addClass("numeric");
$headRow.append($th);
}
$head.append($headRow);
const $body = $("<tbody></tbody>");
for (const proto of RESOURCES) {
const best = (kind) => {
let winner = null;
for (const entry of trade) {
const amount = (entry[kind] && entry[kind][proto.id]) || 0;
if (amount > 0 && (!winner || amount > winner.amount)) {
winner = { civ: entry.civ, amount };
}
}
return winner;
};
const exporter = best("exports");
const importer = best("imports");
const $tr = $("<tr></tr>");
const $name = $("<td class='name'></td>");
$name.append(
$("<img class='resource-icon' width='18' height='18'>")
.attr("src", `assets/${proto.icon}`)
.attr("alt", proto.name)
);
$name.append($("<span></span>").text(` ${proto.name}`));
$tr.append($name);
$tr.append($("<td></td>").text(exporter ? name(exporter.civ) : "—"));
$tr.append(
$("<td class='numeric'></td>").text(
exporter ? formatResourceAmount(proto.id, exporter.amount) : "—"
)
);
$tr.append($("<td></td>").text(importer ? name(importer.civ) : "—"));
$tr.append(
$("<td class='numeric'></td>").text(
importer ? formatResourceAmount(proto.id, importer.amount) : "—"
)
);
$body.append($tr);
}
$table.append($head, $body);
this.$resourceTradeList.append($table);
this.$resourceTradeList.append(
$("<div class='desc'></div>").text(
"The largest cross-nation deliveries of the last day, in each resource. " +
"Only trade between nations is counted, so goods bought from the global " +
"market never make a nation an importer here."
)
);
}
// A market price reads in euro per the resource's own unit; tiny energy prices
// keep enough decimals to be legible.
_resourcePriceText(proto, price) {
const value = price === undefined || price === null ? 0 : price;
const text = value >= 1000
? groupDigits(Math.round(value))
: value >= 1
? value.toFixed(0)
: value.toFixed(4);
return `${currencySymbol()}${text} / ${proto.unit}`;
}
_makeRow($list, proto, index, kind) {
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>");
const $desc = $("<div class='desc'></div>").text(proto.description || "");
const $effects = $("<div class='effects'></div>");
const $cost = $("<div class='cost'></div>");
$info.append($name, $desc, $effects, $cost);
const $actions = $("<div class='actions'></div>");
const $action = $("<button class='aero'></button>");
$action.on("click", () => {
if (kind === "government") this.onGovernment(index);
else this.onFocusTechnology(index);
});
$actions.append($action);
$card.append($info, $actions);
$list.append($card);
return { proto, index, kind, $name, $effects, $cost, $action, $card };
}
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
this.currentGovernment = currentGovernment;
if (techInfo) this.techInfo = techInfo;
if (resourcesInfo) this.resources = resourcesInfo;
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
if (diplomacy !== null) this.diplomacy = diplomacy;
if (conflicts !== null) this.conflicts = conflicts || [];
if (politics !== null) this.politics = politics;
this.$governmentSub.text(
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}`
);
// Research has no pool: every point flows into the focused technology, so
// the panel shows the rate instead of a stockpile.
this.$technologySub.text(`Research: +${(researchRate || 0).toFixed(1)}/h`);
const fin = finances || {};
this._treasury = budget;
if (moneyInfo) this.money = moneyInfo;
this._renderBudget(fin);
this._renderCities(fin.cities);
this._renderResources(this.resources);
this._renderEconomy(this.money);
for (const row of this.governmentRows) {
const proto = row.proto;
row.$name.text(proto.name);
row.$effects.text(effectsText(proto.effects, 1));
if (row.index === currentGovernment) {
row.$cost.text("Currently in power");
row.$action.text("Current government").prop("disabled", true);
} else {
const cost = governmentChangeCost(proto, gdp);
row.$cost.text(`Adoption cost ${currencySymbol()}${groupDigits(this._quoted(cost))}`);
row.$action
.text(`Adopt ${currencySymbol()}${groupDigits(this._quoted(cost))}`)
.prop("disabled", false);
}
}
const repeatCounts = this.techInfo.repeatCounts || {};
for (const row of this.technologyRows) {
const proto = row.proto;
const repeat = repeatCounts[proto.id] || 0;
const focused = this.techInfo.focus === row.index;
row.$name.text(repeat > 0 ? `${proto.name} — Level ${repeat}` : proto.name);
// Show the effect of the next level, so a repeatable's diminishing gain
// is clear before it is chosen.
row.$effects.text(effectsText(proto.effects, repeat > 0 ? repeat : 1));
if (this.researched.has(row.index) && !proto.repeatable) {
row.$cost.text("Researched");
row.$action.text("Researched").prop("disabled", true);
} else if (!prerequisitesMet(proto, this.researched, this.technologies)) {
const names = proto.prerequisites
.map((id) => {
const found = this.technologies.find((t) => t.id === id);
return found ? found.name : id;
})
.join(", ");
row.$cost.text(`Requires: ${names}`);
row.$action.text("Locked").prop("disabled", true);
} else if (focused) {
// A focused technology fills a progress bar, with the committed and
// total points to its right.
const cost = technologyCost(proto, repeat);
const progress = this.techInfo.progress || 0;
const pct = cost > 0 ? Math.max(0, Math.min(100, (progress / cost) * 100)) : 0;
row.$cost.empty().append(
$("<div class='tech-progress-track'></div>").append(
$("<div class='tech-progress-fill'></div>").css("width", `${pct}%`)
),
$("<span class='tech-progress-text'></span>").text(
`${groupDigits(Math.round(progress))} / ${groupDigits(Math.round(cost))}`
)
);
row.$action.text("Focused").prop("disabled", true);
} else {
const cost = technologyCost(proto, repeat);
row.$cost.text(`Cost ${groupDigits(Math.round(cost))} research points`);
row.$action.text("Focus").prop("disabled", false);
}
}
this._applyTechnologyFilter();
this._renderDiplomacy();
this._renderConflicts();
this._renderPolitics();
}
// Lists every other nation's economy in a sortable table, with its relation
// to the player and a button to declare war. `diplomacy` is
// { civs, localCiv, relations, stats }, where relations is the snapshot's
// [a, b, status] matrix and stats its per-civ { population, gdp, gdpPerCapita }.
// The table starts sorted alphabetically by nation and every header toggles
// the ordering.
_renderDiplomacy() {
if (!this.$diplomacyList || !this.$diplomacyList.length) return;
this.$diplomacyList.empty();
const info = this.diplomacy;
if (!info) return;
const civs = info.civs || [];
const localCiv = info.localCiv;
const wars = new Set();
for (const entry of info.relations || []) {
if (entry[2] !== "war") continue;
if (entry[0] === localCiv) wars.add(entry[1]);
else if (entry[1] === localCiv) wars.add(entry[0]);
}
if (this.$diplomacySub && this.$diplomacySub.length) {
const atWar = wars.size;
this.$diplomacySub.text(
atWar === 0
? "Your nation is at peace with every other nation."
: `Your nation is at war with ${atWar} nation${atWar === 1 ? "" : "s"}.`
);
}
const rows = [];
for (let index = 0; index < civs.length; index++) {
const stats = (info.stats || [])[index] || {};
const self = index === localCiv;
rows.push({
index,
name: civs[index].name,
gdp: stats.gdp || 0,
gdpPerCapita: stats.gdpPerCapita || 0,
population: stats.population || 0,
approval: stats.approval || 0,
atWar: self ? false : wars.has(index),
self,
});
}
this._sortDiplomacyRows(rows);
const columns = [
{ key: "name", label: "Nation" },
{ key: "gdp", label: "GDP", numeric: true },
{ key: "gdpPerCapita", label: "GDP / capita", numeric: true },
{ key: "population", label: "Population", numeric: true },
{ key: "approval", label: "Approval", numeric: true },
{ key: "status", label: "Status" },
];
const $table = $("<table class='diplomacy-table'></table>");
const $head = $("<thead></thead>");
const $headRow = $("<tr></tr>");
for (const column of columns) {
const $th = $("<th></th>").attr("data-sort", column.key).text(column.label);
if (column.numeric) $th.addClass("numeric");
if (this.diplomacySort.key === column.key) {
const descending = this.diplomacySort.dir < 0;
$th.addClass(descending ? "sorted-desc" : "sorted-asc");
$th.append(
$("<span class='sort-arrow'></span>").text(descending ? "▼" : "▲")
);
}
$th.on("click", () => this._sortDiplomacyBy(column.key));
$headRow.append($th);
}
$head.append($headRow);
const $body = $("<tbody></tbody>");
for (const row of rows) {
const $tr = $("<tr></tr>");
if (row.self) $tr.addClass("local-nation");
$tr.append($("<td class='name'></td>").text(row.name));
$tr.append($("<td class='numeric'></td>").text(`${currencySymbol()}${compact(row.gdp)}`));
$tr.append($("<td class='numeric'></td>").text(`${currencySymbol()}${compact(row.gdpPerCapita)}`));
$tr.append($("<td class='numeric'></td>").text(compact(row.population)));
$tr.append($("<td class='numeric'></td>").text(`${Math.round(row.approval * 100)}%`));
const $status = $("<td class='status'></td>");
if (row.self) {
$status.append($("<span class='relation'></span>").text("Your nation"));
} else {
$status.append(
$("<span class='relation'></span>").text(row.atWar ? "At war" : "At peace")
);
const $action = $("<button class='aero'></button>");
if (row.atWar) {
$action.text("At war").prop("disabled", true);
} else {
$action.text("Declare war");
$action.on("click", () => this.onDeclareWar(row.index));
}
$status.append($action);
}
$tr.append($status);
$body.append($tr);
}
$table.append($head, $body);
this.$diplomacyList.append($table);
}
// Clicking a header sorts by it, reversing the order when it is already the
// active column.
_sortDiplomacyBy(key) {
if (this.diplomacySort.key === key) {
this.diplomacySort.dir *= -1;
} else {
this.diplomacySort.key = key;
this.diplomacySort.dir = 1;
}
this._renderDiplomacy();
}
_sortDiplomacyRows(rows) {
const { key, dir } = this.diplomacySort;
rows.sort((a, b) => {
let result;
if (key === "name") result = a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
else if (key === "status") result = (a.atWar ? 1 : 0) - (b.atWar ? 1 : 0);
else result = a[key] - b[key];
if (result === 0) result = a.index - b.index;
return dir * result;
});
}
// Lists every ongoing war between the world's nations, whoever is fighting.
// Clicking a conflict reveals the date it began and the military and civilian
// dead on each side.
_renderConflicts() {
if (!this.$conflictsList || !this.$conflictsList.length) return;
this.$conflictsList.empty();
const conflicts = this.conflicts || [];
if (this.$conflictsSub && this.$conflictsSub.length) {
this.$conflictsSub.text(
conflicts.length === 0
? "No ongoing conflicts."
: `${conflicts.length} ongoing conflict${conflicts.length === 1 ? "" : "s"} between the world's nations.`
);
}
if (conflicts.length === 0) {
this.$conflictsList.append(
$("<div class='desc'></div>").text("The world is at peace.")
);
return;
}
for (const conflict of conflicts) this._makeConflictRow(conflict);
}
_makeConflictRow(conflict) {
const $card = $("<div class='row-card conflict-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>").text(
`${this._civName(conflict.a)} vs ${this._civName(conflict.b)}`
);
const $desc = $("<div class='desc'></div>").text(`Ongoing since ${conflict.started}`);
$info.append($name, $desc);
const key = `${conflict.a}:${conflict.b}`;
const $detail = $("<div class='conflict-detail'></div>");
if (!this.expandedConflicts.has(key)) $detail.addClass("hidden");
$detail.append(
$("<div class='conflict-line'></div>").text(`Conflict started ${conflict.started}`)
);
for (const side of conflict.sides || []) {
$detail.append(
$("<div class='conflict-side'></div>").text(
`${this._civName(side.civ)} — Military: ${groupDigits(side.military || 0)} · ` +
`Civilians: ${groupDigits(side.civilians || 0)}`
)
);
}
$card.append($info, $detail);
$card.on("click", () => {
if (this.expandedConflicts.has(key)) this.expandedConflicts.delete(key);
else this.expandedConflicts.add(key);
$detail.toggleClass("hidden");
});
this.$conflictsList.append($card);
}
_civName(index) {
const civ = this.diplomacy && this.diplomacy.civs ? this.diplomacy.civs[index] : null;
return civ ? civ.name : `Nation ${index + 1}`;
}
// --------------------------------------------------------- politics --
_pct(value) {
return `${Math.round((value || 0) * 100)}%`;
}
_renderPolitics() {
if (!this.$politicsList || !this.$politicsList.length) return;
const info = this.politics;
if (!info) return;
const civs = info.civs || [];
const localCiv = info.localCiv;
// Build the tab's controls once. Snapshots arrive far more often than the
// panel changes, so only the dynamic lists are redrawn: the wizard and the
// open dropdowns keep their state instead of being torn down each refresh.
if (!this._politicsBuilt || this._politicsCiv !== localCiv) {
this._buildPolitics(info, civs, localCiv);
this._politicsBuilt = true;
this._politicsCiv = localCiv;
}
this._refreshPolitics(info, civs, localCiv);
}
_buildPolitics(info, civs, localCiv) {
this.$politicsList.empty();
this._politicsWizardOpen = false;
this._politicsState = {
campaign: {
observer: localCiv,
kind: "government",
target: this._otherCiv(localCiv, civs),
direction: 1,
},
policy: { type: POLICIES[0].id, ethnicity: this._defaultEthnicity(civs, localCiv) },
opinionObserver: localCiv,
};
this._campaignSignature = null;
this._campaignRows = new Map();
this.$politicsMakeup = $("<div class='politics-card'></div>");
this.$politicsCampaigns = $("<div class='politics-card'></div>");
this.$politicsMigrations = $("<div class='politics-card'></div>");
this.$politicsList.append(
this.$politicsMakeup,
this.$politicsCampaigns,
this._buildCampaignForm(civs),
this.$politicsMigrations,
this._buildPolicies(civs),
this._buildOpinions(civs)
);
}
// Redraws only the lists, leaving the form controls and their state alone.
_refreshPolitics(info, civs, localCiv) {
const own = info.approval && info.approval[localCiv];
const ownApproval = own ? own[localCiv] : 0.5;
const impact = (info.cultureImpact && info.cultureImpact[localCiv]) || 0;
this.$politicsSub.text(
`Government approval ${this._pct(ownApproval)} · ` +
`Cultural impact ${(impact * 100).toFixed(1)}%`
);
this.$politicsMakeup.empty();
this.$politicsMakeup.append($("<div class='name'></div>").text("Your people"));
const makeup = (info.viewerStats && info.viewerStats.ethnicMakeup) || [];
if (makeup.length > 0) this.$politicsMakeup.append(ethnicityChart(makeup, civs));
else this.$politicsMakeup.append($("<div class='desc'></div>").text("No population data."));
this._fillCampaigns(info, localCiv);
this._syncCampaignReadout();
this._syncCampaignForm();
this._fillMigrations(info, civs, localCiv);
this._fillActivePolicies(info, civs, localCiv);
this._syncPolicyWizard();
this._fillOpinions();
}
// Campaigns the player is running or being targeted by. The rows are only
// rebuilt when the set of campaigns (or a spend level) changes, so a slider
// being dragged is not yanked away by an arriving snapshot; the cost text is
// refreshed separately.
_fillCampaigns(info, localCiv) {
const campaigns = (info.campaigns || []).filter(
(campaign) =>
campaign.civ === localCiv ||
campaign.observer === localCiv ||
campaign.target === localCiv
);
this.$politicsCampaigns.toggleClass("hidden", campaigns.length === 0);
const signature = campaigns
.map((campaign) =>
[
campaign.id,
campaign.civ,
campaign.observer,
campaign.targetKind,
campaign.target,
campaign.direction,
campaign.hourlyCost,
].join(":")
)
.join("|");
if (signature !== this._campaignSignature) {
this._campaignSignature = signature;
this.$politicsCampaigns.empty();
this._campaignRows = new Map();
if (campaigns.length > 0) {
this.$politicsCampaigns.append($("<div class='name'></div>").text("Active campaigns"));
for (const campaign of campaigns) this._buildCampaignRow(info, localCiv, campaign);
}
}
for (const campaign of campaigns) {
const row = this._campaignRows.get(campaign.id);
if (row) row.$cost.text(this._campaignCostText(info, campaign));
}
}
_campaignCostText(info, campaign) {
const hourly = campaign.hourlyCost || 0;
const activeHours = Math.max(0, (info.totalHours || 0) - (campaign.startedHours || 0));
const spent = hourly * activeHours;
const days = Math.floor(activeHours / 24);
return (
`${currencySymbol()}${groupDigits(this._quoted(hourly))}/h · ${currencySymbol()}${groupDigits(Math.round(spent))} spent over ` +
`${days} day${days === 1 ? "" : "s"}`
);
}
_buildCampaignRow(info, localCiv, campaign) {
const verb = campaign.direction > 0 ? "warming" : "cooling";
const target = this._civName(campaign.target);
const observer = this._civName(campaign.observer);
const onWhat = campaign.targetKind === "government" ? "government" : "population";
const $row = $("<div class='politics-row'></div>");
$row.append(
$("<div></div>").text(
`${this._civName(campaign.civ)}: ${verb} the ${observer} people's view of the ` +
`${target} ${onWhat}`
)
);
const $cost = $("<div class='politics-cost'></div>");
$row.append($cost);
const row = { $cost };
if (campaign.civ === localCiv) {
const $slider = $("<input type='range' class='politics-slider'>").attr({
min: 0,
max: PROPAGANDA.maxHourlyCost,
step: 1000,
value: campaign.hourlyCost,
});
const $spend = $("<span class='politics-readout'></span>").text(
`${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`
);
$slider.on("input", () =>
$spend.text(`${currencySymbol()}${groupDigits(this._quoted(Number($slider.val())))}/h`)
);
$slider.on("change", () => {
const amount = Number($slider.val());
if (amount <= 0) {
// Reverting to the last known spend keeps the control honest until
// the player confirms the stop.
$slider.val(campaign.hourlyCost);
$spend.text(`${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`);
this.onConfirm(
"Stop this campaign?",
`Cutting the spending to ${currencySymbol()}0 will stop the campaign.`,
() => this.onCancelCampaign(campaign.id)
);
} else {
this.onSetCampaignSpending(campaign.id, amount);
}
});
const $controls = $("<div class='politics-spend'></div>").append(
$("<span class='politics-label'></span>").text("Spending"),
$slider,
$spend
);
$row.append($controls);
row.$slider = $slider;
row.$spend = $spend;
const $cancel = $("<button class='aero'></button>").text("Cancel campaign");
$cancel.on("click", () => this.onCancelCampaign(campaign.id));
$row.append($("<div class='politics-row-actions'></div>").append($cancel));
}
this.$politicsCampaigns.append($row);
this._campaignRows.set(campaign.id, row);
}
// The campaign launcher, built once so its selects survive a snapshot.
_buildCampaignForm(civs) {
const campaign = this._politicsState.campaign;
const $card = $("<div class='politics-card'></div>");
$card.append($("<div class='name'></div>").text("Launch a propaganda campaign"));
$card.append(
$("<div class='desc'></div>").text(
"Spend money to warm or cool a population's view of another people or government. " +
"Your cultural impact decides how strongly the message lands, and the last " +
"few popularity points are the hardest to win."
)
);
$card.append(
$("<div class='desc politics-cost'></div>").text(
`Costs ${currencySymbol()}${groupDigits(this._quoted(PROPAGANDA.defaultHourlyCost))}/h to begin; ` +
"adjust the spending on an active campaign to make it land harder or softer."
)
);
const $observer = this._civSelect(civs, campaign.observer);
const $kind = $("<select class='politics-select'></select>").append(
$("<option value='government'></option>").text("their government"),
$("<option value='ethnicity'></option>").text("their population")
);
$kind.val(campaign.kind);
const $target = this._civSelect(civs, campaign.target);
const $direction = $("<select class='politics-select'></select>").append(
$("<option value='1'></option>").text("Warm opinion"),
$("<option value='-1'></option>").text("Cool opinion")
);
$direction.val(String(campaign.direction));
const $readout = $("<span class='politics-readout'></span>");
this.$campaignReadout = $readout;
const sync = () => {
this._politicsState.campaign = {
observer: Number($observer.val()),
kind: String($kind.val()),
target: Number($target.val()),
direction: Number($direction.val()),
};
this._syncCampaignReadout();
this._syncCampaignForm();
};
$observer.on("change", sync);
$kind.on("change", sync);
$target.on("change", sync);
$direction.on("change", sync);
sync();
const $controls = $("<div class='politics-controls'></div>");
$controls.append(this._labelled("Observer", $observer));
$controls.append(this._labelled("Signals about", $kind));
$controls.append(this._labelled("Target", $target));
$controls.append(this._labelled("Direction", $direction));
const $start = $("<button class='aero'></button>").text("Start campaign");
this.$campaignStart = $start;
$start.on("click", () => {
const state = this._politicsState.campaign;
this.onCampaign(state.observer, state.kind, state.target, state.direction);
});
this.$campaignDuplicate = $("<div class='politics-estimate politics-penalty'></div>");
$controls.append($("<div class='politics-field'></div>").append($start, $readout));
$card.append($controls, this.$campaignDuplicate);
return $card;
}
_syncCampaignReadout() {
if (!this.$campaignReadout) return;
const info = this.politics;
if (!info) return;
const state = this._politicsState.campaign;
const matrix =
state.kind === "government" ? info.opinions.government : info.opinions.ethnic;
const row = (matrix && matrix[state.observer]) || [];
const value = row[state.target];
this.$campaignReadout.text(`Current: ${this._pct(value === undefined ? 0.5 : value)}`);
}
// Blocks a second campaign on the same population and target.
_syncCampaignForm() {
if (!this.$campaignStart) return;
const info = this.politics;
if (!info) return;
const state = this._politicsState.campaign;
const duplicate = (info.campaigns || []).some(
(campaign) =>
campaign.civ === info.localCiv &&
campaign.observer === state.observer &&
campaign.targetKind === state.kind &&
campaign.target === state.target
);
this.$campaignDuplicate.text(
duplicate ? "A campaign on this population and target is already running." : ""
);
this.$campaignStart.prop("disabled", duplicate);
}
// The minority-policy card: the list of active policies, plus a wizard built
// once so its selects stay put (and open) across snapshot refreshes.
_buildPolicies(civs) {
const $card = $("<div class='politics-card'></div>");
$card.append($("<div class='name'></div>").text("Minority policies"));
$card.append(
$("<div class='desc'></div>").text(
"Any number of policies may run at once. Each sways opinions, and migration " +
"policies move people in or out of the country."
)
);
this.$politicsActive = $("<div></div>");
$card.append(this.$politicsActive);
this.$politicsNew = $("<button class='aero politics-new'></button>").text(
"New minority policy"
);
this.$politicsWizard = $("<div class='politics-wizard hidden'></div>");
this.$politicsNew.on("click", () => {
this._politicsWizardOpen = !this._politicsWizardOpen;
this.$politicsWizard.toggleClass("hidden", !this._politicsWizardOpen);
});
const policy = this._politicsState.policy;
const $type = $("<select class='politics-select'></select>");
for (const proto of POLICIES) {
$type.append($("<option></option>").attr("value", proto.id).text(proto.name));
}
$type.val(policy.type);
const $eth = this._civSelect(civs, policy.ethnicity);
this.$policyType = $type;
this.$policyEth = $eth;
this.$policyTarget = $("<div class='politics-estimate'></div>");
this.$policyCost = $("<div class='politics-estimate politics-cost'></div>");
this.$policyDuplicate = $("<div class='politics-estimate politics-penalty'></div>");
this.$policyEnact = $("<button class='aero'></button>").text("Enact policy");
const sync = () => {
this._politicsState.policy = {
type: String($type.val()),
ethnicity: Number($eth.val()),
};
this._syncPolicyWizard();
};
$type.on("change", sync);
$eth.on("change", sync);
this.$policyEnact.on("click", () => {
const state = this._politicsState.policy;
this.onPolicy(state.type, state.ethnicity);
});
const $controls = $("<div class='politics-controls'></div>");
$controls.append(this._labelled("Policy", $type));
$controls.append(this._labelled("Ethnicity", $eth));
$controls.append($("<div class='politics-field'></div>").append(this.$policyEnact));
this.$politicsWizard.append(
$controls,
this.$policyTarget,
this.$policyCost,
this.$policyDuplicate
);
$card.append(this.$politicsNew, this.$politicsWizard);
return $card;
}
// Keeps the wizard's estimates and its duplicate guard in step with the
// policies actually in force.
_syncPolicyWizard() {
if (!this.$policyType) return;
const info = this.politics;
const entry = info && (info.policies || []).find((item) => item.civ === info.localCiv);
const active = entry ? entry.list || [] : [];
const type = String(this.$policyType.val());
const ethnicity = Number(this.$policyEth.val());
const proto = policyById(type);
this.$policyTarget.text(proto ? `Target: ${proto.targetEffect}` : "");
this.$policyCost.text(proto ? `Political cost: ${proto.politicalCost}` : "");
const duplicate = active.some(
(policy) => policy.type === type && policy.ethnicity === ethnicity
);
this.$policyDuplicate.text(duplicate ? "Already enacted for this people." : "");
this.$policyEnact.prop("disabled", duplicate);
}
_fillActivePolicies(info, civs, localCiv) {
this.$politicsActive.empty();
const entry = (info.policies || []).find((policy) => policy.civ === localCiv);
const active = entry ? entry.list || [] : [];
if (active.length === 0) {
this.$politicsActive.append(
$("<div class='desc'></div>").text("No minority policies enacted.")
);
return;
}
for (const policy of active) {
const proto = policyById(policy.type);
const people = this._peopleName(civs, policy.ethnicity);
const $row = $("<div class='politics-row politics-policy-row'></div>");
$row.append($("<span></span>").text(`${proto ? proto.name : policy.type} — ${people}`));
const $revoke = $("<button class='aero'></button>").text("Revoke");
$revoke.on("click", () => this.onRevokePolicy(policy.id));
$row.append($revoke);
this.$politicsActive.append($row);
}
}
// Shows the last day's people movements in and out of the player's country.
_fillMigrations(info, civs, localCiv) {
this.$politicsMigrations.empty();
const entry = (info.migrations || []).find((flow) => flow.civ === localCiv);
const inbound = entry ? entry.inbound || [] : [];
const outbound = entry ? entry.outbound || [] : [];
const empty = inbound.length === 0 && outbound.length === 0;
this.$politicsMigrations.toggleClass("hidden", empty);
if (empty) return;
this.$politicsMigrations.append(
$("<div class='name'></div>").text("Migration (last day)")
);
const describe = (flows, sign) => {
for (const [ethnicity, amount] of flows) {
this.$politicsMigrations.append(
$("<div class='politics-row'></div>").text(
`${sign}${groupDigits(amount)} ${this._peopleName(civs, ethnicity)}`
)
);
}
};
describe(inbound, "+");
describe(outbound, "−");
}
_otherCiv(localCiv, civs) {
const other = localCiv === 0 ? 1 : 0;
return civs && other < civs.length ? other : localCiv;
}
// The demonym of an ethnicity, for prose.
_peopleName(civs, ethnicity) {
const civ = civs && civs[ethnicity];
if (!civ) return `Ethnicity ${ethnicity + 1}`;
return civ.demonym ? `the ${civ.demonym}` : civ.name;
}
_defaultEthnicity(civs, localCiv) {
if (civs.length > 1) return localCiv === 0 ? 1 : 0;
return localCiv;
}
// A full breakdown of one population's opinions: of every government,
// including its own, and of every other people.
_buildOpinions(civs) {
const $card = $("<div class='politics-card'></div>");
$card.append($("<div class='name'></div>").text("Opinions by population"));
$card.append(
$("<div class='desc'></div>").text(
"How a people sees every government and every other people. Pick the people to inspect."
)
);
const $observer = this._civSelect(civs, this._politicsState.opinionObserver);
this.$opinionObserver = $observer;
$observer.on("change", () => {
this._politicsState.opinionObserver = Number($observer.val());
this._fillOpinions();
});
this.$opinionList = $("<div class='opinion-list'></div>");
const $controls = $("<div class='politics-controls'></div>");
$controls.append(this._labelled("Population", $observer));
$card.append($controls, this.$opinionList);
return $card;
}
_fillOpinions() {
if (!this.$opinionList) return;
const info = this.politics;
if (!info) return;
const civs = info.civs || [];
const observer = this._politicsState.opinionObserver;
const gov = (info.opinions.government && info.opinions.government[observer]) || [];
const eth = (info.opinions.ethnic && info.opinions.ethnic[observer]) || [];
this.$opinionList.empty();
this.$opinionList.append($("<div class='opinion-heading'></div>").text("Governments"));
for (let index = 0; index < civs.length; index++) {
const note = index === observer ? " (its own)" : "";
this.$opinionList.append(this._opinionRow(`${this._civName(index)}${note}`, gov[index]));
}
this.$opinionList.append($("<div class='opinion-heading'></div>").text("Peoples"));
for (let index = 0; index < civs.length; index++) {
if (index === observer) continue;
this.$opinionList.append(this._opinionRow(this._peopleName(civs, index), eth[index]));
}
}
// A percentage bar for one opinion.
_opinionRow(label, value) {
const pct = Math.round((value === undefined ? 0.5 : value) * 100);
const $fill = $("<div class='opinion-fill'></div>")
.css("width", `${pct}%`)
.toggleClass("negative", pct < 50);
const $bar = $("<div class='opinion-bar'></div>").append($fill);
return $("<div class='opinion-row'></div>")
.append($("<span class='opinion-name'></span>").text(label))
.append($bar)
.append($("<span class='opinion-pct'></span>").text(`${pct}%`));
}
_civSelect(civs, selected) {
const $select = $("<select class='politics-select'></select>");
civs.forEach((civ, index) => {
const label = civ.demonym ? `The ${civ.demonym}` : civ.name;
const $option = $("<option></option>").attr("value", index).text(label);
if (index === selected) $option.prop("selected", true);
$select.append($option);
});
return $select;
}
_labelled(label, $control) {
return $("<label class='politics-field'></label>")
.append($("<span class='politics-label'></span>").text(label))
.append($control);
}
close() {
this.$modal.addClass("hidden");
}
}