Files
Battle-for-Tismo/client/js/modals/nation.js
T
adrien ede258d39f Added warfare depth, focused research and a reworked HUD
- 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.
2026-09-21 01:07:36 +02:00

993 lines
39 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 } 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");
}
}