- Tile improvements (fortifications, radar towers, coastal cannons), trenches, zone of control, support-unit capture and launch transports, with parabolic barrage arcs drawn between attacker and target. - Focused research that pours points straight into the chosen technology, with per-technology progress kept when the focus changes, plus repeatable levels. - Popularity reworked to integer points, war and pillage effects, minority policies and month-over-month arrows on the nation summary. - City progress bars, square improvement buttons, a technology tab of wide short cards with progress bars, transport cargo readout and double-click to open a city panel.
993 lines
39 KiB
JavaScript
993 lines
39 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 } from "../../../shared/data.js";
|
||
import { renderBudget } from "./budget.js";
|
||
import { effectsText, signedMoney } from "./format.js";
|
||
import { bindTabs } from "./tabs.js";
|
||
import { ethnicityChart } from "../ui/ethnicity.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.$budgetList = $("#budget-list");
|
||
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();
|
||
// Building types whose per-city budget breakdown is expanded.
|
||
this.expandedBudgetGroups = new Set();
|
||
this._lastBreakdown = {};
|
||
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.onConfirm = () => {};
|
||
this.$modal.find(".modal-close").on("click", () => this.close());
|
||
bindTabs(this.$modal, "tab-");
|
||
}
|
||
|
||
isShowing() {
|
||
return !this.$modal.hasClass("hidden");
|
||
}
|
||
|
||
show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null) {
|
||
this.governments = governments;
|
||
this.technologies = technologies;
|
||
this.diplomacy = diplomacy;
|
||
this.conflicts = conflicts || [];
|
||
this.politics = politics;
|
||
// 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.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo);
|
||
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 national budget, drawn by the shared table renderer. Building types
|
||
// expand to their per-city cost.
|
||
_renderBudget(breakdown) {
|
||
if (!this.$budgetList || !this.$budgetList.length) return;
|
||
this._lastBreakdown = breakdown || {};
|
||
renderBudget(this.$budgetList, this._lastBreakdown, {
|
||
expanded: this.expandedBudgetGroups,
|
||
onToggle: () => this._renderBudget(this._lastBreakdown),
|
||
showUnits: true,
|
||
});
|
||
}
|
||
|
||
// 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(`€${compact(city.gdp)}`));
|
||
$tr.append($("<td class='numeric income'></td>").text(`+€${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(`€${compact(totalGdp)}`))
|
||
.append($("<td class='numeric income'></td>").text(`+€${compact(totalIncome)}`))
|
||
)
|
||
);
|
||
this.$citiesList.append($table);
|
||
}
|
||
|
||
_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) {
|
||
this.currentGovernment = currentGovernment;
|
||
if (techInfo) this.techInfo = techInfo;
|
||
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: €${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 breakdown = finances || {};
|
||
const net = breakdown.net || 0;
|
||
|
||
this.$budgetSub.text(
|
||
`Treasury: €${groupDigits(Math.round(budget))} · Net: ${signedMoney(net)}`
|
||
);
|
||
|
||
this._renderBudget(breakdown);
|
||
this._renderCities(breakdown.cities);
|
||
|
||
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 €${groupDigits(Math.round(cost))}`);
|
||
row.$action.text(`Adopt €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost);
|
||
}
|
||
}
|
||
|
||
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(`€${compact(row.gdp)}`));
|
||
$tr.append($("<td class='numeric'></td>").text(`€${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 (
|
||
`€${groupDigits(hourly)}/h · €${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(
|
||
`€${groupDigits(campaign.hourlyCost)}/h`
|
||
);
|
||
$slider.on("input", () =>
|
||
$spend.text(`€${groupDigits(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(`€${groupDigits(campaign.hourlyCost)}/h`);
|
||
this.onConfirm(
|
||
"Stop this campaign?",
|
||
"Cutting the spending to €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 €${groupDigits(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");
|
||
}
|
||
}
|