Files
Battle-for-Tismo/client/js/game_screen/panels.js
T

1714 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 game screen's HUD: the top and bottom panels, selection and stack menus,
// hover tooltips, the pillage forecast and the modal wiring. These methods run
// on the GameScreen instance (composed in ../game_screen.js) and only read the
// snapshot and forward orders.
import { describeNews } from "../modals.js";
import { compact, groupDigits, hours, improvements as fmtImprovements } from "../../../shared/text_format.js";
import {
CIVILISATIONS,
TRAINABLE_UNIT_IDS,
BUILDINGS,
GOVERNMENTS,
TECHNOLOGIES,
NEWS_WAR,
NEWS_TECHNOLOGY,
NEWS_JOIN,
ECONOMY,
EFFECT_GDP,
APPROVAL,
effectValue,
pillageSteps,
ROADS,
TRANSPORT_IMPROVEMENTS,
transportImprovement,
TILE_IMPROVEMENTS,
AIR_STRIKE,
} from "../../../shared/data.js";
import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors, popularityDisplay } from "../../../shared/rules.js";
import { dateString, HOURS_PER_DAY, hoursInNextMonth } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
import { parseKey } from "../../../shared/hex.js";
import { ethnicityChart } from "../ui/ethnicity.js";
import { isEconomicMode } from "../map_view/economic.js";
import { isWaterTile } from "../map_view/textures.js";
import { audio } from "../audio.js";
// A tiny memo cache keyed by bucket and key, used while a per-tile economic
// figure is computed for every tile at once.
function memo(caches, bucket, key, compute) {
if (!caches) return compute();
let cache = caches[bucket];
if (!cache) {
cache = new Map();
caches[bucket] = cache;
}
if (!cache.has(key)) cache.set(key, compute());
return cache.get(key);
}
export const panelMethods = {
_bindHud() {
$("#city-manage").on("click", () => this._openCityModal());
$("#civ-flag").on("click", () => this._openNationModal());
$("#news-button").on("click", () => this._openNewsModal());
$("#map-modes-button").on("click", (event) => {
event.stopPropagation();
this.mapModesModal.toggle(this.map.mapMode);
});
this.mapModesModal.onMode = (mode) => this._setMapMode(mode);
// A click anywhere outside the picker dismisses it.
$(document).on("mousedown.mapmodes", (event) => {
if (this.mapModesModal.isShowing() &&
!$(event.target).closest("#map-modes-popover").length &&
!$(event.target).closest("#map-modes-button").length) {
this.mapModesModal.close();
}
});
this.cityModal.onTrain = (cityId, protoIndex) =>
this.sendOrder({ type: "train", city: cityId, proto: protoIndex });
this.cityModal.onCancel = (cityId, index) =>
this.sendOrder({ type: "cancel_train", city: cityId, index });
this.cityModal.onBuild = (cityId, index) =>
this.sendOrder({ type: "build", city: cityId, building: index });
this.cityModal.onDemolish = (cityId, index) =>
this.sendOrder({ type: "demolish", city: cityId, building: index });
// Testing mode adds a free, instant version of each order. The server
// ignores `free` unless the game is a local test game.
this.cityModal.onFreeTrain = (cityId, protoIndex) =>
this.sendOrder({ type: "train", city: cityId, proto: protoIndex, free: true });
this.cityModal.onFreeBuild = (cityId, index) =>
this.sendOrder({ type: "build", city: cityId, building: index, free: true });
this.nationModal.onGovernment = (index) =>
this.sendOrder({ type: "government", government: index });
this.nationModal.onFocusTechnology = (index) =>
this.sendOrder({ type: "focus_technology", technology: index });
this.nationModal.onDeclareWar = (target) =>
this.sendOrder({ type: "declare_war", target });
this.nationModal.onCampaign = (observer, targetKind, target, direction) =>
this.sendOrder({ type: "campaign", observer, targetKind, target, direction });
this.nationModal.onCancelCampaign = (campaign) =>
this.sendOrder({ type: "cancel_campaign", campaign });
this.nationModal.onSetCampaignSpending = (campaign, amount) =>
this.sendOrder({ type: "campaign_spending", campaign, amount });
this.nationModal.onConfirm = (title, message, onConfirm) =>
this.confirmModal.show(title, message, onConfirm);
this.nationModal.onPolicy = (policy, ethnicity) =>
this.sendOrder({ type: "policy", policy, ethnicity });
this.nationModal.onRevokePolicy = (policy) =>
this.sendOrder({ type: "revoke_policy", policy });
$("#unit-attack")
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
.on("mouseleave", () => this._hideStatTooltip());
$("#unit-defense")
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
.on("mouseleave", () => this._hideStatTooltip());
$("#unit-pillage")
.on("click", () => this._pillageSelected())
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
.on("mouseleave", () => this._hideStatTooltip());
$("#unit-trench").on("click", () => this._digTrenchSelected());
// Every summary stat explains itself on hover through the shared bubble.
$("#top-panel .stat")
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
.on("mouseleave", () => this._hideStatTooltip());
// A click anywhere outside the stack menu dismisses it.
$(document).on("mousedown.stackmenu", (event) => {
if (!this.$stackMenu.hasClass("hidden") && !$(event.target).closest("#stack-menu").length) {
this._hideStackMenu();
}
});
$(window).on("keydown.game", (event) => this._onKey(event));
},
// A small hover tooltip anchored above a stat, fed by its data-tooltip text.
_showStatTooltip($stat) {
const text = $stat.attr("data-tooltip");
if (!text) return;
this._hoveredStat = $stat;
this.$statTooltip.text(text).removeClass("hidden");
const offset = $stat.offset() || { left: 0, top: 0 };
const width = this.$statTooltip.outerWidth() || 0;
const height = this.$statTooltip.outerHeight() || 0;
this.$statTooltip.css({
left: `${Math.max(8, offset.left + $stat.outerWidth() / 2 - width / 2)}px`,
top: `${Math.max(8, offset.top - height - 8)}px`,
});
},
_hideStatTooltip() {
this._hoveredStat = null;
this.$statTooltip.addClass("hidden");
},
// ------------------------------------------------------- selection ------
// Selects the given unit ids (already filtered to our own), refreshing the
// map highlight and the bottom panel. A single id is a normal selection; an
// empty list clears; several ids form a stack order.
_selectUnits(ids) {
const units = [];
for (const id of ids) {
const unit = this._unit(id);
if (unit && unit.civ === this.localCiv) units.push(unit);
}
this.selectedUnitIds = units.map((unit) => unit.id);
this.selectedCityId = 0;
if (units.length === 0) {
// Clearing the selection also drops the inspected tile, so the tile panel
// closes with it. Selecting units leaves the inspected tile alone, which
// is what lets the unit panel and the tile panel show together.
this._inspectedTile = null;
$("#tile-info, #city-info").addClass("hidden");
}
this.map.setSelectedUnits(this.selectedUnitIds);
this._hideStackMenu();
this._showUnits(units);
this._updateTileHighlight();
},
_selectedUnits() {
return this.selectedUnitIds.map((id) => this._unit(id)).filter(Boolean);
},
// Outlines the tile the current selection or inspection sits on: the first
// selected unit's tile, else the selected city's, else the inspected tile.
_updateTileHighlight() {
if (this.selectedUnitIds.length > 0) {
const unit = this._unit(this.selectedUnitIds[0]);
if (unit) {
this.map.setSelectedTile({ x: unit.coords[0], y: unit.coords[1] });
return;
}
}
if (this.selectedCityId) {
const city = this._city(this.selectedCityId);
if (city) {
this.map.setSelectedTile({ x: city.coords[0], y: city.coords[1] });
return;
}
}
if (this._inspectedTile) {
this.map.setSelectedTile(this._inspectedTile);
return;
}
this.map.clearSelectedTile();
},
_openStackMenu(coords, ids, clientX, clientY) {
const units = [];
for (const id of ids) {
const unit = this._unit(id);
if (unit && unit.civ === this.localCiv) units.push(unit);
}
if (units.length === 0) return;
const $menu = this.$stackMenu.empty();
const selection = this._selectedUnits();
if (selection.length > 0) {
const $move = $("<button class='stack-item stack-move'></button>").text("Move here");
$move.on("click", () => {
this._hideStackMenu();
this._orderMove(selection, coords);
});
$menu.append($move);
}
for (const unit of units) {
const proto = this.protoUnits[unit.proto];
const $item = $("<button class='stack-item'></button>")
.text(`${proto ? proto.name : "Unit"} — ${Math.round(unit.hp)}/${unit.maxHp} HP`);
$item.on("click", () => this._selectUnits([unit.id]));
$menu.append($item);
}
const $all = $("<button class='stack-item stack-all'></button>").text(
`Select all (${units.length})`
);
$all.on("click", () => this._selectUnits(units.map((unit) => unit.id)));
$menu.append($all);
$menu.removeClass("hidden").css({ left: `${clientX}px`, top: `${clientY}px` });
},
_hideStackMenu() {
if (this.$stackMenu && this.$stackMenu.length) this.$stackMenu.addClass("hidden");
},
// The slowest member of a group sets the pace. The fallback matches the
// server's DEFAULT_UNIT_SPEED so prediction cannot disagree with the
// authoritative movement cost.
_groupSpeed(units) {
let speed = Infinity;
for (const unit of units) {
const proto = this.protoUnits[unit.proto];
const value = proto && proto.speed ? proto.speed : DEFAULT_UNIT_SPEED;
speed = Math.min(speed, value);
}
return Number.isFinite(speed) ? speed : null;
},
async _groupMovePlan(units, coords) {
const speed = units.length > 1 ? this._groupSpeed(units) : null;
const targets = [];
const routes = new Map();
// Same-type units in one stack share a route, so search once per type.
const cache = new Map();
for (const unit of units) {
const route = await this.map.planMove(unit, coords, speed, cache);
if (!route || route.length < 2) continue;
routes.set(unit.id, route);
if (!this._isMilitaryUnit(unit)) continue;
for (const point of route) {
const owner = this.map.territory.get(`${point.x},${point.y}`);
if (owner === undefined || owner < 0 || owner === unit.civ) continue;
if (this._isAtWar(unit.civ, owner)) continue;
if (!targets.includes(owner)) targets.push(owner);
}
}
return { targets, speed, routes };
},
// ------------------------------------------------------- pillage -------
// What the selected units would remove, gain and heal by pillaging the tile
// they stand on, or null when they cannot pillage.
_pillageForecast(units) {
if (units.length === 0) return null;
const coords = units[0].coords;
const sameTile = units.every((unit) => unit.coords[0] === coords[0] && unit.coords[1] === coords[1]);
if (!sameTile) return null;
const owner = this._territoryOwner.has(`${coords[0]},${coords[1]}`)
? this._territoryOwner.get(`${coords[0]},${coords[1]}`)
: -1;
if (owner < 0 || owner === this.localCiv) return null;
// A unit in combat cannot pillage.
if (this._battles.has(`${coords[0]},${coords[1]}`)) return null;
const population = this._population.get(`${coords[0]},${coords[1]}`) || 0;
const { steps, gdpMultiplier } = pillageSteps(population, units.length);
const ordered = units.slice().sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp);
const healed = ordered.map((unit, index) =>
Math.min(unit.maxHp, unit.hp + steps[index].heal) - unit.hp
);
return { population, steps, gdpMultiplier, healed };
},
_updatePillageButton(units) {
const $button = $("#unit-pillage");
const forecast = this._pillageForecast(units);
if (!forecast) {
$button.addClass("hidden").removeAttr("data-tooltip");
return;
}
const removed = forecast.population - forecast.steps[forecast.steps.length - 1].remaining;
const share = forecast.population > 0 ? (removed / forecast.population) * 100 : 0;
const heals = forecast.healed.map((hp) => `+${Math.round(hp)}`).join(", ");
$button
.removeClass("hidden")
.attr(
"data-tooltip",
`Removes ${groupDigits(Math.round(removed))} people (${share.toFixed(0)}%) · ` +
`GDP/capita ×${forecast.gdpMultiplier.toFixed(2)} · heals ${heals} HP`
);
},
// Whether the selected units may dig in where they stand: every one is
// infantry, sharing one controlled land tile with no trench yet.
_canDigTrench(units) {
if (units.length === 0) return false;
const coords = units[0].coords;
if (!units.every((u) => u.coords[0] === coords[0] && u.coords[1] === coords[1])) return false;
if (!units.every((u) => {
const proto = this.protoUnits[u.proto];
return proto && proto.infantry;
})) return false;
const k = `${coords[0]},${coords[1]}`;
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
if (owner !== this.localCiv) return false;
const tile = this.map.getTile({ x: coords[0], y: coords[1] });
if (!tile || tile.terrainClass !== "Land") return false;
return !this.map.trenches.has(k);
},
_updateTrenchButton(units) {
const $button = $("#unit-trench");
if (!$button.length) return;
$button.toggleClass("hidden", !this._canDigTrench(units));
},
_digTrenchSelected() {
const units = this._selectedUnits();
if (!this._canDigTrench(units)) return;
this.sendOrder({ type: "dig_trench", units: units.map((unit) => unit.id) });
},
_isMilitaryUnit(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.military);
},
// A unit that attacks from range instead of charging into melee: every
// aircraft and anything with a reach beyond a single tile. Mirrors
// GameState._isStrikeUnit so the browser predicts the same outcome.
_isRangedUnit(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && (proto.strike || proto.air || (proto.range || 1) > 1));
},
_isAirUnit(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.air);
},
// Whether `coords` holds a hostile military unit, the only thing a strike may
// target.
_isEnemyMilitaryAt(coords) {
return this.map.unitIdsAt(coords).some((id) => {
const data = this._unit(id);
return data && data.civ !== this.localCiv && this._isMilitaryUnit(data);
});
},
// A ground/naval battery attack: resolved where the battery stands, with a
// standing hourly target rather than a march.
_rangedAttackOrder(units, coords) {
if (units.length === 0) return false;
if (units.some((unit) => this._isAirUnit(unit))) return false;
if (!units.every((unit) => this._isRangedUnit(unit))) return false;
return this._isEnemyMilitaryAt(coords);
},
// An aircraft attack: the plane flies to the hostile unit and bombs it on
// arrival, then returns home to re-arm.
_airStrikeOrder(units, coords) {
if (units.length === 0) return false;
if (!units.every((unit) => this._isAirUnit(unit))) return false;
return this._isEnemyMilitaryAt(coords);
},
// Why an air strike cannot be issued right now, or null when it can. Blocks a
// plane that is already flying a mission, is still re-arming, or whose
// nation cannot afford the (deliberately steep) price.
_airStrikeBlocked(units) {
const snapshot = this.snapshot || {};
const totalHours = snapshot.totalHours || 0;
const budget = (snapshot.viewerStats || {}).budget || 0;
for (const unit of units) {
if (unit.strikeTarget) {
return { title: "Aircraft engaged", message: "This aircraft is already on a strike mission." };
}
if ((unit.strikeReadyHour || 0) > totalHours) {
const hours = Math.ceil(unit.strikeReadyHour - totalHours);
return {
title: "Aircraft re-arming",
message: `This aircraft cannot strike again for ${hours} more hour(s).`,
};
}
}
const cost = (AIR_STRIKE.cost || 0) * units.length;
if (cost > budget) {
return {
title: "Insufficient budget",
message: `An air strike costs €${groupDigits(cost)}, which the treasury cannot cover.`,
};
}
return null;
},
// The friendly airport an aircraft would fly home to from `coords`, mirroring
// GameState._returnAirport: its own base while that is still a friendly
// airport, otherwise the nearest friendly airport anywhere.
_airReturnAirport(unit, coords) {
const cities = (this.snapshot && this.snapshot.cities) || [];
const index = BUILDINGS.findIndex((b) => b.id === "airport");
const hasAirport = (city) =>
index >= 0 && ((this._cityBuildings.get(city.id) || {})[index] || 0) > 0;
const home = cities.find((city) => city.id === unit.homeCityId);
if (home && home.civ === this.localCiv && hasAirport(home)) return home;
let best = null;
let bestDistance = Infinity;
for (const city of cities) {
if (city.civ !== this.localCiv || !hasAirport(city)) continue;
const distance = this.map.topology.tileDistance(coords, {
x: city.coords[0],
y: city.coords[1],
});
if (distance < bestDistance) {
bestDistance = distance;
best = city;
}
}
return best;
},
// Why an air strike on `coords` is out of reach, or null when the whole round
// trip fits. Mirrors GameState._strikeReady so the browser refuses exactly
// what the server would, instead of sending an order that is dropped.
_airStrikeRangeError(units, coords) {
for (const unit of units) {
const proto = this.protoUnits[unit.proto];
if (!proto) return "Unknown aircraft.";
const from = { x: unit.coords[0], y: unit.coords[1] };
const toTarget = this.map.topology.tileDistance(from, coords);
const airport = this._airReturnAirport(unit, coords);
if (!airport) return "This aircraft has no friendly airport to return to.";
const toHome = this.map.topology.tileDistance(coords, {
x: airport.coords[0],
y: airport.coords[1],
});
const speed = proto.speed || 1;
const fuel = unit.airHours === undefined || unit.airHours === null
? proto.enduranceHours
: unit.airHours;
// The furthest it can strike: half the fuel buys the flight out, the other
// half the flight home, capped by the mission radius.
const effectiveRange = Math.floor(
Math.min(proto.missionRange || Infinity, (fuel * speed) / 2 + 1e-9)
);
const beyondMission =
toTarget > (proto.missionRange || Infinity);
const beyondFuel = (toTarget + toHome) / speed > fuel + 1e-9;
if (beyondMission || beyondFuel) {
return `That target is ${toTarget} tiles away, but this aircraft's effective range is ${effectiveRange} tiles.`;
}
}
return null;
},
// Whether `coords` is one of our own cities with an airport, the only tiles
// an aircraft may be ordered to when it is not attacking.
_isFriendlyAirport(coords) {
const city = ((this.snapshot && this.snapshot.cities) || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
if (!city || city.civ !== this.localCiv) return false;
const index = BUILDINGS.findIndex((b) => b.id === "airport");
return index >= 0 && ((this._cityBuildings.get(city.id) || {})[index] || 0) > 0;
},
_isAtWar(a, b) {
if (a < 0 || b < 0 || a === b) return false;
return this._wars.has(a < b ? `${a}:${b}` : `${b}:${a}`);
},
_civName(index) {
const civ = this.civilisations[index];
return civ ? civ.name : `Nation ${index + 1}`;
},
_civDemonym(index) {
const civ = this.civilisations[index];
return civ && civ.demonym ? civ.demonym : this._civName(index);
},
// ------------------------------------------------------- top panel -----
_updateTopPanel(state) {
const civ = this.civilisations[this.localCiv];
if (civ) {
$("#civ-name").text(civ.name);
const flag = civ.flag || (CIVILISATIONS.find((c) => c.id === civ.id) || {}).flag;
if (flag) $("#civ-flag-img").attr("src", `assets/flags/${flag}`);
}
$("#player-name").text(this.config.playerName || (this.network.players[this.network.localPlayerId] || {}).name || "");
$("#admin-label").toggle(this.network.isAdmin);
$("#stat-date").text(dateString(state.totalHours));
const stats = state.viewerStats || {};
// The nation's figures one calendar month ago, when there is one, so each
// stat can show whether it rose or fell since.
const month = ((state.monthly || [])[this.localCiv]) || null;
$("#stat-pop").text(compact(stats.population || 0));
$("#stat-gdp").text(`€${compact(stats.gdp || 0)}`);
$("#stat-gdppc").text(`€${compact(stats.gdpPerCapita || 0)}`);
$("#stat-budget").text(`€${compact(stats.budget || 0)}`);
// GDP, population and GDP/capita double the arrow past a 10% month-on-month
// move; the others only ever show one.
this._updateStatArrow("stat-pop", stats.population, month && month.population, 0.1);
this._updateStatArrow("stat-gdp", stats.gdp, month && month.gdp, 0.1);
this._updateStatArrow("stat-gdppc", stats.gdpPerCapita, month && month.gdpPerCapita, 0.1);
this._updateStatArrow("stat-budget", stats.budget, month && month.budget, null);
// The stat tooltips carry the longer-run growth: against January 2000 for
// the first year, then against the same month a year earlier.
this._updateGrowthTooltips(stats.growth || null);
this._updateBudgetTooltip(stats, state.totalHours);
const popularityPoints = stats.popularity || 0;
const popularity = popularityDisplay(popularityPoints);
this._updateApprovalStat(popularity);
// The people drift one point an hour back to neutral, so the estimate is
// simply how many points away they are; zero is already there.
const pointsAway = Math.abs(popularityPoints);
this._setStatTooltip(
"stat-approval",
pointsAway <= 0
? "Popularity is exactly neutral"
: `Neutrality in about ${pointsAway} h (~${Math.ceil(pointsAway / 24)} d) ` +
"at the current drift of one point an hour"
);
// Popularity doubles its arrow past ten percentage points.
this._updateStatArrow(
"stat-approval",
popularity,
month ? popularityDisplay(month.popularity || 0) : null,
0.1,
0.005
);
// Keep an open stat tooltip in step with the figures it describes.
if (this._hoveredStat && !this.$statTooltip.hasClass("hidden")) {
const text = this._hoveredStat.attr("data-tooltip");
if (text) this.$statTooltip.text(text);
}
},
// Appends the month-on-month change marker inside a stat's value: a bold
// green up arrow, a red down arrow, doubled once a big move occurred, or an
// equals sign when nothing changed. No baseline (January 2000) means none.
_updateStatArrow(statId, current, previous, doubleAt = null, equalEpsilon = 0.5) {
const $value = $(`#${statId}`);
if (!$value.length) return;
$value.find(".stat-arrow").remove();
if (previous === undefined || previous === null || current === undefined || current === null) {
return;
}
const diff = current - previous;
const $arrow = $("<span class='stat-arrow'></span>");
if (Math.abs(diff) <= equalEpsilon) {
$arrow.text("=").addClass("equal");
} else {
const rising = diff > 0;
const double = doubleAt !== null && previous !== 0 &&
Math.abs(diff) / Math.abs(previous) > doubleAt;
$arrow
.text(rising ? (double ? "▲▲" : "▲") : (double ? "▼▼" : "▼"))
.addClass(rising ? "up" : "down");
}
$value.append($arrow);
},
// Shows or clears a summary stat's hover tooltip. Passing no text removes it,
// so a stale bubble never lingers. Any older native title is dropped too.
_setStatTooltip(statId, text) {
const $stat = $(`#${statId}`).closest(".stat");
if (!$stat.length) return;
$stat.removeAttr("title");
if (text) $stat.attr("data-tooltip", text);
else $stat.removeAttr("data-tooltip");
},
// Puts the long-run growth figure in each headline stat's hover tooltip: the
// rise since January 2000 through the first year, and the year-on-year rise
// afterwards. The hovered datum names itself, so the text stays short.
_updateGrowthTooltips(growth) {
const scope = growth && growth.sinceStart ? "since 1 January 2000" : "year on year";
const apply = (statId, rate) => {
if (!growth || !Number.isFinite(rate)) {
this._setStatTooltip(statId, null);
return;
}
const percent = `${rate < 0 ? "−" : "+"}${(Math.abs(rate) * 100).toFixed(2)}%`;
this._setStatTooltip(statId, `${scope}: ${percent}`);
};
apply("stat-pop", growth && growth.population);
apply("stat-gdp", growth && growth.gdp);
apply("stat-gdppc", growth && growth.gdpPerCapita);
},
// Forecasts the budget a month ahead at the current income and upkeep: the
// hourly net spread over the hours of the next calendar month.
_updateBudgetTooltip(stats, totalHours) {
const net = stats.breakdown ? stats.breakdown.net : null;
if (net === null || !Number.isFinite(net)) {
this._setStatTooltip("stat-budget", null);
return;
}
const expected = net * hoursInNextMonth(totalHours || 0);
const sign = expected < 0 ? "−" : "+";
this._setStatTooltip("stat-budget", `${sign}€${compact(Math.abs(expected))}/mo`);
},
// The summary's popularity figure: white while it sits in the neutral band
// (40–60%), red when the people disapprove and green when they approve.
_updateApprovalStat(approval) {
const $approval = $("#stat-approval");
if (!$approval.length) return;
const pct = Math.round(approval * 100);
$approval.text(`${pct}%`);
$approval
.removeClass("approval-neutral approval-positive approval-negative")
.addClass(
pct < 40
? "approval-negative"
: pct > 60
? "approval-positive"
: "approval-neutral"
);
},
_updateBottomPanel() {
if (this.selectedUnitIds.length > 0) {
this._showUnits(this._selectedUnits());
} else if (this.selectedCityId) {
const city = this._city(this.selectedCityId);
if (city) this._showCity(city);
} else if (this._inspectedTile) {
// Keep the inspected tile's improvements and build button current after
// the world changes (a road may have just been built on it).
this._renderTileInfo(this._inspectedTile);
}
},
// Shows the bottom panel for one or many selected units. A stack shows its
// size and combined health, plus the pillage action when it can use it.
_showUnits(units) {
if (units.length === 0) {
$("#unit-info").addClass("hidden");
this._clearStackList();
this._updateCargo([]);
this._hideStatTooltip();
this._refreshPanelVisibility();
return;
}
if (units.length === 1) {
this._showUnit(units[0]);
return;
}
const hp = units.reduce((sum, unit) => sum + unit.hp, 0);
const maxHp = units.reduce((sum, unit) => sum + unit.maxHp, 0);
$("#unit-name").text(`${units.length} units selected`);
$("#unit-attack, #unit-defense").addClass("hidden");
$("#unit-health-fill").css("width", `${maxHp > 0 ? Math.min(100, (hp / maxHp) * 100) : 0}%`);
$("#unit-health-label").text(`${Math.round(hp)} / ${Math.round(maxHp)} HP`);
this._renderStackList(units);
this._updatePillageButton(units);
this._updateTrenchButton(units);
this._updateCargo(units);
$("#unit-info").removeClass("hidden");
this._refreshPanelVisibility();
},
// The stack panel's checklist: every member of the selected stack, ticked,
// so the player can narrow the selection to the units they want to order.
_renderStackList(units) {
const $list = $("#unit-stack-list");
if (!$list.length) return;
$list.removeClass("hidden");
const signature = units.map((unit) => unit.id).join(",");
if (signature !== this._stackListSignature) {
this._stackListSignature = signature;
$list.empty();
for (const unit of units) {
const proto = this.protoUnits[unit.proto];
const $row = $("<label class='stack-unit'></label>");
const $check = $("<input type='checkbox'>")
.attr("data-id", unit.id)
.prop("checked", true);
$check.on("change", () => {
const ids = [];
$list.find("input:checked").each((_index, element) => {
ids.push(Number($(element).attr("data-id")));
});
this._selectUnits(ids);
});
$row.append(
$check,
$("<span class='stack-unit-name'></span>").text(proto ? proto.name : "Unit"),
$("<span class='stack-unit-hp'></span>").text(
`${Math.round(unit.hp)}/${unit.maxHp}`
)
);
$list.append($row);
}
}
// Keep the hit-point readouts current even when the set did not change.
units.forEach((unit, index) => {
$list
.find(".stack-unit-hp")
.eq(index)
.text(`${Math.round(unit.hp)}/${unit.maxHp}`);
});
},
_clearStackList() {
const $list = $("#unit-stack-list");
if ($list.length) $list.addClass("hidden").empty();
this._stackListSignature = null;
},
_showUnit(unit) {
if (!unit) {
$("#unit-info").addClass("hidden");
this._clearStackList();
this._hideStatTooltip();
this._refreshPanelVisibility();
return;
}
this._clearStackList();
const proto = this.protoUnits[unit.proto];
$("#unit-name").text(this._unitLabel(unit, proto));
$("#unit-attack, #unit-defense").removeClass("hidden");
this._showUnitStats(unit, proto);
const ratio = unit.maxHp > 0 ? (unit.hp / unit.maxHp) * 100 : 0;
$("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`);
$("#unit-health-label").text(`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`);
this._updatePillageButton([unit]);
this._updateTrenchButton([unit]);
this._updateCargo([unit]);
$("#unit-info").removeClass("hidden");
this._refreshPanelVisibility();
},
// The unit's name with its current air activity, so the panel explains why a
// plane cannot strike again yet.
_unitLabel(unit, proto) {
const name = proto ? proto.name : "Unit";
if (!proto || !proto.air) return name;
const totalHours = (this.snapshot && this.snapshot.totalHours) || 0;
if (unit.strikeTarget) return `${name} — on a strike mission`;
if ((unit.strikeReadyHour || 0) > totalHours) {
const hours = Math.ceil(unit.strikeReadyHour - totalHours);
return `${name} — re-arming (${hours} h)`;
}
if (unit.airborne) return `${name} — airborne`;
return name;
},
// Attack and defense of the unit where it stands, with the hovering tooltips
// listing each factor that shaped the number.
_showUnitStats(unit, proto) {
const coords = { x: unit.coords[0], y: unit.coords[1] };
const tile = this.map.getTile(coords);
const hasCity = ((this.snapshot && this.snapshot.cities) || []).some(
(city) => city.coords[0] === coords.x && city.coords[1] === coords.y
);
const baseAttack = proto && typeof proto.attack === "number" ? proto.attack : 0;
const baseDefense = proto && typeof proto.defense === "number" ? proto.defense : 0;
$("#unit-attack")
.text(`Attack ${Math.round(baseAttack)}`)
.attr("data-tooltip", `Base ${Math.round(baseAttack)}`);
const factors = defenseFactors(tile, hasCity);
const defense = unitDefense(baseDefense, tile, hasCity);
const parts = [`Base ${Math.round(baseDefense)}`];
for (const factor of factors) {
parts.push(`${factor.label} +${Math.round(baseDefense * factor.bonus)}`);
}
$("#unit-defense")
.text(`Defense ${Math.round(defense)}`)
.attr("data-tooltip", parts.join(", "));
// Keep an open tooltip in step with the unit it describes across snapshots.
if (this._hoveredStat && !this.$statTooltip.hasClass("hidden")) {
const text = this._hoveredStat.attr("data-tooltip");
if (text) this.$statTooltip.text(text);
} else {
this._hideStatTooltip();
}
},
// A transport shows its berths and who is aboard, and any boarding still
// under way; non-transports show nothing.
_updateCargo(units) {
const $cargo = $("#unit-cargo");
if (!$cargo.length) return;
const lines = [];
for (const unit of units) {
const proto = this.protoUnits[unit.proto];
if (!proto || !proto.transportCapacity) continue;
const cargo = unit.cargo || [];
const names = cargo
.map((passenger) => {
const passengerProto = this.protoUnits[passenger.proto];
return passengerProto ? passengerProto.name : "troops";
})
.join(", ");
let text = `Cargo ${cargo.length}/${proto.transportCapacity}`;
if (names) text += ` — ${names}`;
if (unit.embarking && unit.embarking.count) {
text += ` · boarding ${unit.embarking.count} (${Math.ceil(unit.embarking.hoursLeft)} h)`;
}
lines.push(text);
}
if (lines.length === 0) {
$cargo.addClass("hidden").text("");
return;
}
$cargo.removeClass("hidden").text(lines.join(" · "));
},
// A city panel shows the tile it stands on (owner, terrain, population, GDP
// per capita, improvements) plus the city's name and its actions; the tile
// fields are the same ones the plain tile panel renders, so nothing is
// duplicated between the two.
// `keepUnits` leaves the unit panel open, so clicking a unit that stands on a
// city shows the city's tile detail without dropping the unit selection.
_showCity(city, { keepUnits = false } = {}) {
if (!city) {
$("#city-info").addClass("hidden");
$("#tile-info").addClass("hidden");
this._refreshPanelVisibility();
return;
}
this._inspectedTile = null;
this._renderTileInfo({ x: city.coords[0], y: city.coords[1] });
$("#city-name").text(city.name);
$("#city-manage").toggleClass("hidden", city.civ !== this.localCiv);
if (!keepUnits) $("#unit-info").addClass("hidden");
$("#tile-info, #city-info").removeClass("hidden");
this._refreshPanelVisibility();
this._updateTileHighlight();
},
// Selects one of the player's cities and shows its quick panel. Returns false
// when the city is unknown or someone else's. Double-clicking the city icon
// uses the same selection before opening the full city screen.
_selectCity(id) {
const city = (this.snapshot.cities || []).find((c) => c.id === id);
if (!city || city.civ !== this.localCiv) return false;
this.selectedCityId = id;
this.selectedUnitIds = [];
this.map.setSelectedUnits([]);
this._hideStackMenu();
this._showCity(city);
return true;
},
// Clicking a unit opens the panel for the tile it stands on (or the city it
// occupies), alongside the unit panel rather than replacing it.
_inspectUnitTile(unit) {
const coords = { x: unit.coords[0], y: unit.coords[1] };
const city = (this.snapshot.cities || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
if (city) {
this._showCity(city, { keepUnits: true });
return;
}
this._inspectedTile = coords;
this._renderTileInfo(coords);
$("#city-info").addClass("hidden");
$("#tile-info").removeClass("hidden");
this._refreshPanelVisibility();
this._updateTileHighlight();
},
_showTile(coords) {
// Inspecting a tile drops any city or unit selection, otherwise the next
// snapshot would immediately switch the panel back to the old selection.
this.selectedCityId = 0;
this.selectedUnitIds = [];
this._inspectedTile = { x: coords.x, y: coords.y };
this.map.setSelectedUnits([]);
this._hideStackMenu();
this._renderTileInfo(coords);
$("#city-info, #unit-info").addClass("hidden");
$("#tile-info").removeClass("hidden");
this._refreshPanelVisibility();
this._updateTileHighlight();
},
// Fills the shared tile fields without touching which section is visible.
_renderTileInfo(coords) {
const k = `${coords.x},${coords.y}`;
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
const tile = this.map.getTile(coords);
const city = (this.snapshot.cities || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
const regionCity = (this.snapshot.cities || []).find(
(c) => c.id === this._tileRegion.get(k)
);
$("#tile-desc").text(this._tileDescription(coords, owner, tile, regionCity));
$("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`);
this._renderTilePeople(k);
this._renderTileGdp(owner >= 0 ? this._tileGdpBreakdown(coords, owner, city, tile) : null);
const transport = this.map.railways.has(k)
? ["Railway"]
: this.map.roads.has(k)
? [ROADS.improvementName]
: [];
const cityImprovements = (city ? city.improvements || [] : []).filter(
(name) => name !== ROADS.improvementName && name !== "Railway"
);
// Military works and trenches are shown alongside the transport network.
const works = [];
if (this.map.trenches.has(k)) works.push("Trench");
const militaryWorks = this.map.tileImprovements.get(k);
if (militaryWorks) {
const proto = TILE_IMPROVEMENTS.find((entry) => entry.id === militaryWorks.id);
works.push(proto ? proto.name : militaryWorks.id);
}
$("#tile-improvements").text(
`Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport, ...works])))}`
);
this._renderTileWorks(coords);
this._renderCityProduction(city);
// Only a land tile the player controls can carry a new improvement. A city
// improves itself, so it never offers the build buttons.
const buildable = !city &&
owner === this.localCiv &&
owner >= 0 &&
!!tile &&
tile.terrainClass === "Land";
const current = this.map.railways.has(k)
? "railway"
: this.map.roads.has(k)
? "road"
: null;
this._renderImprovementActions(coords, buildable, current);
},
// The military improvement under construction on this tile, drawn with the
// same progress bar as a city's production.
_renderTileWorks(coords) {
const $el = $("#tile-works");
if (!$el.length) return;
$el.empty();
const queue = (this.snapshot.tileWorks || [])[this.localCiv] || [];
const work = queue.find(
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
);
if (!work) return;
const proto = TILE_IMPROVEMENTS.find((improvement) => improvement.id === work.id);
const name = proto ? proto.name : work.id;
const total = Math.max(work.totalHours || 0, 0.0001);
const elapsed = work.elapsedHours || 0;
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
const remaining = Math.max(0, Math.ceil(total - elapsed));
const $bar = $("<div class='progress city-production-bar'></div>");
$bar.append($("<div></div>").css("width", `${pct}%`));
$el.append(
$("<div class='city-production-label'></div>").text(`Building ${name} — ${remaining} h left`),
$bar
);
},
// The city panel's quick view of what is currently being produced there, so
// the player can read progress without opening the city modal. A foreign
// city's production is never shown, even when its tile is inspected.
_renderCityProduction(city) {
const $el = $("#city-production");
if (!$el.length) return;
$el.empty();
if (!city || city.civ !== this.localCiv) return;
const queue = city ? this._trainingQueue(city.id) : null;
const entry = queue && queue[0];
if (!entry) return;
const building = entry.kind === "building";
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
const name = proto ? proto.name : (building ? "Building" : "Unit");
const verb = building ? "Building" : "Training";
const total = Math.max(entry.totalHours || 0, 0.0001);
const elapsed = entry.elapsedHours || 0;
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
const remaining = Math.max(0, Math.ceil(total - elapsed));
const $bar = $("<div class='progress city-production-bar'></div>");
$bar.append($("<div></div>").css("width", `${pct}%`));
$el.append(
$("<div class='city-production-label'></div>").text(`${verb} ${name} — ${remaining} h left`),
$bar
);
},
// A square icon button for one buildable improvement: the type's icon with
// its cost beneath, and the name (plus upkeep and build time) in the tooltip.
_improvementButton(improvement, { className, costLabel, title, disabled, current }) {
const classes = `aero improvement-btn improvement-icon-btn ${className || ""}`;
const $button = $(`<button type='button' class='${classes}'></button>`)
.attr("title", title)
.prop("disabled", disabled)
.toggleClass("current", current);
$button.append(
$("<img class='improvement-icon'>")
.attr("src", `assets/${improvement.icon}`)
.attr("alt", improvement.name),
$("<span class='improvement-cost'></span>").text(costLabel)
);
return $button;
},
// One square icon button per transport and military improvement, plus a
// control to remove the one already standing on the tile. Shown only on a
// controlled land tile; an unaffordable improvement is greyed out.
_renderImprovementActions(coords, buildable, current) {
const $row = $("#tile-improvement-actions");
if (!$row.length) return;
$row.empty();
if (!buildable) return;
const budget = (this.snapshot.viewerStats || {}).budget || 0;
const testing = !!(this.snapshot && this.snapshot.testing);
for (const improvement of TRANSPORT_IMPROVEMENTS) {
const isCurrent = current === improvement.id;
const affordable = budget >= improvement.buildCost;
const title = isCurrent
? `${improvement.name} — already built on this tile`
: affordable
? `${improvement.name} — €${groupDigits(improvement.maintenance)}/tile·h upkeep`
: `${improvement.name} — not enough budget (€${groupDigits(improvement.buildCost)} needed)`;
const $button = this._improvementButton(improvement, {
className: "transport-btn",
costLabel: `€${compact(improvement.buildCost)}`,
title,
disabled: isCurrent || !affordable,
current: isCurrent,
});
$button.on("click", () => this._buildImprovement(coords, improvement.id, false));
$row.append($button);
// A local test game may raise it for free and at once.
if (testing && !isCurrent) {
const $free = this._improvementButton(improvement, {
className: "free-button",
costLabel: "free",
title: `${improvement.name} — build free and instantly (testing)`,
disabled: false,
current: false,
});
$free.on("click", () => this._buildImprovement(coords, improvement.id, true));
$row.append($free);
}
}
if (current) {
const name = transportImprovement(current) ? transportImprovement(current).name : current;
const $remove = $("<button type='button' class='aero improvement-btn remove'></button>")
.text(`Remove ${name}`)
.on("click", () => this._removeImprovement(coords));
$row.append($remove);
}
// Military tile improvements. They cost money and take production time, so
// the tooltip names the expected hours alongside the upkeep.
const military = this.map.tileImprovements.get(`${coords.x},${coords.y}`) || null;
const gdp = (this.snapshot.viewerStats || {}).gdp || 0;
const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0;
for (const improvement of TILE_IMPROVEMENTS) {
if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
const built = !!(military && military.id === improvement.id);
const affordable = budget >= improvement.buildCost;
const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed);
const title = military && !built
? `${improvement.name} — another improvement already stands here`
: affordable
? `${improvement.name} — €${groupDigits(improvement.maintenance)}/h upkeep · ${hours(buildHours)} to build`
: `${improvement.name} — not enough budget (€${groupDigits(improvement.buildCost)} needed)`;
const $button = this._improvementButton(improvement, {
className: "tile-btn",
costLabel: `€${compact(improvement.buildCost)}`,
title,
disabled: !!military || !affordable,
current: built,
});
$button.on("click", () => this._buildTileImprovement(coords, improvement.id));
$row.append($button);
}
if (military) {
const name = (TILE_IMPROVEMENTS.find((i) => i.id === military.id) || {}).name || military.id;
const $remove = $("<button type='button' class='aero improvement-btn remove'></button>")
.text(`Remove ${name}`)
.on("click", () => this._removeTileImprovement(coords));
$row.append($remove);
}
},
// Whether a land tile touches the sea, so a coastal cannon may be raised.
_tileIsCoastal(coords) {
if (!this.map.topology) return false;
for (const neighbour of this.map.topology.neighbours(coords.x, coords.y)) {
const tile = this.map.getTile(neighbour);
if (tile && tile.terrainClass === "Sea") return true;
}
return false;
},
_buildTileImprovement(coords, id) {
this.sendOrder({
type: "build_tile_improvement",
coords: [coords.x, coords.y],
improvement: id,
});
},
_removeTileImprovement(coords) {
this.sendOrder({
type: "remove_tile_improvement",
coords: [coords.x, coords.y],
});
},
_buildImprovement(coords, id, free = false) {
const order = {
type: "build_improvement",
coords: [coords.x, coords.y],
improvement: id,
};
if (free) order.free = true;
this.sendOrder(order);
},
_removeImprovement(coords) {
this.sendOrder({
type: "remove_improvement",
coords: [coords.x, coords.y],
});
},
// A short, readable label for the tile: its owner's demonym, the terrain and
// where it sits relative to the city whose region claims it.
_tileDescription(coords, owner, tile, regionCity) {
const civ = owner >= 0 ? this.civilisations[owner] : null;
const terrain = tile ? tile.terrainType.toLowerCase() : "unknown terrain";
const parts = [];
if (civ) parts.push(civ.demonym || civ.name);
parts.push(terrain);
if (regionCity) {
const sameTile =
regionCity.coords[0] === coords.x && regionCity.coords[1] === coords.y;
if (sameTile) {
parts.push(`around ${regionCity.name}`);
} else {
const direction = this._compassDirection(
{ x: regionCity.coords[0], y: regionCity.coords[1] },
coords
);
if (direction) parts.push(`${direction} of ${regionCity.name}`);
}
}
const text = parts.join(" ");
return text.charAt(0).toUpperCase() + text.slice(1);
},
// The eight-point compass direction from `origin` to `coords`, measured in
// screen space so the label matches what the player sees on the map.
_compassDirection(origin, coords) {
const topology = this.map.topology;
if (!topology) return "";
const delta = topology.pixelDelta(origin, coords);
if (delta.x === 0 && delta.y === 0) return "";
const names = [
"east", "southeast", "south", "southwest",
"west", "northwest", "north", "northeast",
];
const index = Math.round(Math.atan2(delta.y, delta.x) / (Math.PI / 4));
return names[((index % 8) + 8) % 8];
},
// The tile's people: the head count up top, then a small donut and legend of
// its ethnicities with last-day migration arrows.
_renderTilePeople(k) {
const $container = $("#tile-ethnicity");
if (!$container || !$container.length) return;
$container.empty();
const population = this._population.get(k) || 0;
$container.append($("<div class='info-title'></div>").text("People"));
$container.append(
$("<div class='tile-population'></div>").text(
`Population: ${groupDigits(population)}`
)
);
const entries = this._tileEthnicity.get(k) || [];
if (entries.length === 0) return;
const makeup = entries
.map(([ethnicity, share, trend]) => ({
ethnicity,
share,
count: share * population,
trend,
}))
.sort((a, b) => b.count - a.count);
$container.append(ethnicityChart(makeup, this.civilisations, 104));
},
// The base GDP per capita of a claimable tile plus every factor that shapes
// it, mirroring the server's getTileGdpPerCapita through the shared helper.
// The region that develops the tile supplies the building bonus; government
// and research supply the nation-wide one.
_tileGdpBreakdown(coords, owner, city, tile, caches = null) {
const k = `${coords.x},${coords.y}`;
const regionCity =
city || (this.snapshot.cities || []).find((c) => c.id === this._tileRegion.get(k));
const regionalModifier = regionCity
? memo(caches, "regional", regionCity.id, () => this._cityGdpModifier(regionCity))
: 0;
const nationalModifier = memo(caches, "national", owner, () => this._nationalGdpModifier(owner));
const factors = gdpPerCapitaFactors(tile, {
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
nextToCity: !city && memo(caches, "nextToCity", k, () => this._isNextToCity(coords, owner)),
pillagePenalty: this._tileGdpPenalties.has(k) ? this._tileGdpPenalties.get(k) : 1,
battleDeficit: this._tileBattleGdpDeficits.get(k) || 0,
regionalMultiplier: 1 + regionalModifier,
regionalLabel: regionCity ? regionCity.name : null,
nationalMultiplier: 1 + nationalModifier,
portGdp: regionCity
? memo(caches, "portGdp", regionCity.id, () => this._regionPortGdp(regionCity))
: 0,
naturalGrowth: memo(caches, "growth", k, () => this._naturalTileGdpGrowth(k)),
});
return {
base: ECONOMY.baseGdpPerCapita,
factors,
total: gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors),
};
},
// The flat GDP per capita a region's ports add to every tile it covers,
// mirroring the server: 1000 per port level for each sea tile beside the city.
_regionPortGdp(city) {
const protos = this.snapshot.buildings || [];
let bonus = 0;
for (const [index, level] of Object.entries(city.buildings || {})) {
const proto = protos[index];
if (proto && proto.mechanic === "port_gdp") {
bonus += (proto.gdpPerCapitaPerSeaTilePerLevel || 0) * level;
}
}
if (bonus === 0) return 0;
let sea = 0;
for (const neighbour of this.map.topology.neighbours(city.coords[0], city.coords[1])) {
const tile = this.map.getTile(neighbour);
if (tile && tile.terrainClass === "Sea") sea += 1;
}
return bonus * sea;
},
// The flat GDP per capita a tile has gained since January 2000, mirroring the
// server: its opening figure times the daily rate, once per day elapsed.
_naturalTileGdpGrowth(k) {
const baseline = this._gdpBaseline.get(k) || 0;
if (!baseline) return 0;
const totalHours = (this.snapshot && this.snapshot.totalHours) || 0;
const days = Math.floor(Math.max(0, totalHours) / HOURS_PER_DAY);
return baseline * ECONOMY.naturalGdpGrowthPerDay * days;
},
// -------------------------------------------------------- map modes -----
// Applied when the Map modes picker reports a choice. An economic mode needs
// its per-tile figures before the map switches to them.
_setMapMode(mode) {
if (mode === this.map.mapMode) return;
if (isEconomicMode(mode)) {
const { values, min, max } = this._computeEconomicValues(mode);
this.map.setEconomicData(mode, values, min, max);
}
this.map.setMapMode(mode);
},
// Recomputes the active economic mode's figures after a snapshot, so the map
// and its legend always show current numbers.
_refreshEconomicMode() {
const mode = this.map.mapMode;
if (!isEconomicMode(mode)) return;
const { values, min, max } = this._computeEconomicValues(mode);
this.map.setEconomicData(mode, values, min, max);
},
// One figure per land tile for an economic mode, with the range the legend
// and the colour scale need. GDP per capita mirrors the tile panel's formula;
// population is the snapshot's raw per-tile count.
_computeEconomicValues(mode) {
const values = new Map();
let min = Infinity;
let max = -Infinity;
if (!this.map || !this.map.tiles) return { values, min: 0, max: 0 };
const cities = this.snapshot.cities || [];
const cityByKey = new Map(cities.map((c) => [`${c.coords[0]},${c.coords[1]}`, c]));
const caches = mode === "gdp" ? {} : null;
for (const k in this.map.tiles) {
const tile = this.map.tiles[k];
if (isWaterTile(tile)) continue;
let value;
if (mode === "population") {
value = this._population.get(k) || 0;
} else {
const coords = parseKey(k);
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
const city = cityByKey.get(k) || null;
value = this._tileGdpBreakdown(coords, owner, city, tile, caches).total;
}
values.set(k, value);
if (value < min) min = value;
if (value > max) max = value;
}
if (!Number.isFinite(min)) return { values, min: 0, max: 0 };
return { values, min, max };
},
// The GDP bonus a single city's own buildings give.
_cityGdpModifier(city) {
let total = 0;
for (const indexStr of Object.keys(city.buildings || {})) {
const proto = BUILDINGS[Number(indexStr)];
if (!proto) continue;
const level = city.buildings[indexStr];
for (const e of proto.effects) {
if (e.stat === EFFECT_GDP) total += effectValue(e, level);
}
}
return total;
},
// The GDP bonus a nation gets from its government and researched technology.
_nationalGdpModifier(civ) {
let total = 0;
const government = GOVERNMENTS[(this.snapshot.government || [])[civ]];
if (government) {
for (const e of government.effects) {
if (e.stat === EFFECT_GDP) total += effectValue(e, 1);
}
}
for (const index of (this.snapshot.researched || [])[civ] || []) {
const tech = TECHNOLOGIES[index];
if (!tech) continue;
for (const e of tech.effects) {
if (e.stat === EFFECT_GDP) total += effectValue(e, 1);
}
}
return total;
},
_isNextToCity(coords, owner) {
for (const neighbour of this.map.topology.neighbours(coords.x, coords.y)) {
const found = (this.snapshot.cities || []).some(
(city) =>
city.civ === owner &&
city.coords[0] === neighbour.x &&
city.coords[1] === neighbour.y
);
if (found) return true;
}
return false;
},
// Lists the tile's GDP per capita and each factor behind it, so a click
// explains exactly why the figure is what it is.
_renderTileGdp(breakdown) {
const $container = $("#tile-gdp").empty();
if (!breakdown) {
$container.text("GDP/capita: —");
return;
}
const $mods = $("<div class='tile-gdp-mods'></div>");
if (!this._gdpExpanded) $mods.addClass("hidden");
$mods.append(
$("<div class='tile-gdp-mod'></div>").text(`Base €${groupDigits(breakdown.base)}`)
);
let modifierCount = 0;
for (const factor of breakdown.factors) {
// A modifier that changes nothing (×1 or ±€0) is left out.
if (factor.amount !== undefined) {
if (factor.amount === 0) continue;
} else if (factor.multiplier === 1) {
continue;
}
const value =
factor.amount !== undefined
? `${factor.amount < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(factor.amount)))}`
: `×${factor.multiplier.toFixed(2)}`;
$mods.append(
$("<div class='tile-gdp-mod'></div>").text(`${factor.label} ${value}`)
);
modifierCount += 1;
}
const $head = $("<div class='tile-gdp-head'></div>").text(
`GDP/capita: €${groupDigits(Math.round(breakdown.total))}`
);
if (modifierCount > 0) {
$head.addClass("tile-gdp-toggle");
$head.append(
$("<span class='tile-gdp-caret'></span>").text(this._gdpExpanded ? "▾" : "▸")
);
$head.on("click", () => {
this._gdpExpanded = !this._gdpExpanded;
$mods.toggleClass("hidden", !this._gdpExpanded);
$head.find(".tile-gdp-caret").text(this._gdpExpanded ? "▾" : "▸");
});
$container.append($head, $mods);
} else {
$container.append($head);
}
},
// The tile/city panel and the unit panel are independent docks, so either
// can be open without the other.
_refreshPanelVisibility() {
const tileVisible =
!$("#tile-info").hasClass("hidden") ||
!$("#city-info").hasClass("hidden");
const unitVisible = !$("#unit-info").hasClass("hidden");
$("#tile-panel").toggleClass("hidden", !tileVisible);
$("#unit-panel").toggleClass("hidden", !unitVisible);
},
_unit(id) {
return (this.snapshot.units || []).find((u) => u.id === id) || null;
},
_city(id) {
return (this.snapshot.cities || []).find((c) => c.id === id) || null;
},
// -------------------------------------------------------------- modals ---
_openCityModal() {
const city = this._city(this.selectedCityId);
if (!city) return;
this.cityModal.show({ id: city.id, name: city.name }, this._cityView(city));
this._updateCityModal();
},
// The units a city may train right now. Aircraft and warships only appear
// once the city has the installation that trains them, so the order can never
// be sent and rejected. Recomputed each snapshot so a building finished while
// the modal is open unlocks its unit without reopening the panel.
_trainableUnits(city) {
const levels = this._cityBuildings.get(city.id) || {};
const hasBuilding = (id) => {
const index = BUILDINGS.findIndex((b) => b.id === id);
return index >= 0 && (levels[index] || 0) > 0;
};
return TRAINABLE_UNIT_IDS.map((id) => {
const index = this.protoUnits.findIndex((p) => p.id === id);
return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null;
}).filter((proto) => proto && (!proto.requiresBuilding || hasBuilding(proto.requiresBuilding)));
},
_cityView(city) {
const stats = this.snapshot.viewerStats || {};
const cityStats = this._cityStats(city.id);
return {
budget: stats.budget || 0,
gdp: stats.gdp || 0,
coastal: !!city.coastal,
testing: !!(this.snapshot && this.snapshot.testing),
trainable: this._trainableUnits(city),
buildings: BUILDINGS,
levels: this._cityBuildings.get(city.id) || {},
queue: this._trainingQueue(city.id),
civs: this.civilisations,
ethnicMakeup: cityStats.ethnicMakeup || [],
approval: cityStats.approval,
// The region's tax base and approval price an upgrade's expected income.
base: cityStats.base || 0,
// Construction technology shortens the displayed build times.
constructionSpeed: stats.constructionSpeed || 0,
taxMultiplier: this._taxMultiplier(cityStats.approval),
};
},
_trainingQueue(cityId) {
return (this.snapshot.training || {})[cityId] || [];
},
_updateCityModal() {
if (!this.snapshot || !this.selectedCityId) return;
if (!this.cityModal.isShowing(this.selectedCityId)) {
this._trainingCityId = 0;
return;
}
const stats = this.snapshot.viewerStats || {};
const queue = this._trainingQueue(this.selectedCityId);
this.cityModal.renderQueue(queue);
this._updateCityProgress(queue);
const cityStats = this._cityStats(this.selectedCityId);
this.cityModal.constructionSpeed = stats.constructionSpeed || 0;
const city = this._city(this.selectedCityId);
if (city) {
this.cityModal.refreshTrainList(this._trainableUnits(city));
this.cityModal.renderPeople(
cityStats.ethnicMakeup || [],
cityStats.approval,
this._taxMultiplier(cityStats.approval),
this.civilisations
);
}
this.cityModal.refreshBuildings(
stats.budget || 0,
this._cityBuildings.get(this.selectedCityId) || {},
stats.gdp || 0,
this._queuedBuildingLevels(this.selectedCityId),
{ base: cityStats.base || 0, approval: cityStats.approval }
);
this.cityModal.renderBudget(this._cityBudget(this.selectedCityId));
},
_updateCityProgress(queue) {
const entry = queue[0];
if (!entry) {
if (this._trainingCityId === this.selectedCityId) {
this.cityModal.endTraining();
this._trainingCityId = 0;
}
return;
}
const building = entry.kind === "building";
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
const name = proto ? proto.name : (building ? "building" : "unit");
const verb = building ? "Building" : "Training";
if (this._trainingCityId !== this.selectedCityId) {
this._trainingCityId = this.selectedCityId;
this.cityModal.beginTraining(name, entry.totalHours, entry.elapsedHours, verb);
} else {
this.cityModal.setTrainingProgress(name, entry.elapsedHours, entry.totalHours, verb);
}
},
// How many levels of each building are already waiting in the city's shared
// production queue, so the buildings tab prices the next order correctly.
_queuedBuildingLevels(cityId) {
const counts = {};
for (const entry of this._trainingQueue(cityId)) {
if (entry.kind !== "building") continue;
counts[entry.protoIndex] = (counts[entry.protoIndex] || 0) + 1;
}
return counts;
},
// The selected city's regional budget, serialised alongside its stats.
_cityBudget(cityId) {
const stats = (this.snapshot.cityStats || []).find((city) => city.id === cityId);
return stats ? stats.budget : null;
},
// The selected city's serialised region stats: GDP, tax and its people.
_cityStats(cityId) {
return (this.snapshot.cityStats || []).find((city) => city.id === cityId) || {};
},
// Mirrors GameState.taxApprovalMultiplier so the People tab explains the
// penalty the server is already applying.
_taxMultiplier(approval) {
const value = approval === undefined || approval === null ? 1 : approval;
if (value >= APPROVAL.rebellionThreshold) return 1;
const floor = APPROVAL.taxFloorFraction;
return floor + (1 - floor) * (value / APPROVAL.rebellionThreshold);
},
_openNationModal() {
const stats = this.snapshot.viewerStats || {};
this.nationModal.show(
GOVERNMENTS,
stats.government || 0,
TECHNOLOGIES,
this._researched,
stats.gdp || 0,
stats.budget || 0,
stats.researchRate || 0,
this._finances(stats),
this._diplomacyInfo(),
this._conflicts,
this._politicsInfo(),
this._techInfo()
);
},
// The focused-research figures the technology tab reads from the snapshot.
_techInfo() {
const snapshot = this.snapshot || {};
const focus = (snapshot.focusTechnology || [])[this.localCiv];
const focusIndex = focus === undefined || focus === null ? -1 : focus;
// Progress is kept per technology, so a focus that was switched away from
// still shows what it had committed when it is selected again.
const byTech = (snapshot.techProgress || [])[this.localCiv] || {};
return {
focus: focusIndex,
progress: focusIndex >= 0 ? byTech[focusIndex] || 0 : 0,
progressByTech: byTech,
repeatCounts: (snapshot.repeatCounts || [])[this.localCiv] || {},
};
},
_updateNationModal() {
if (!this.snapshot || !this.nationModal.isShowing()) return;
const stats = this.snapshot.viewerStats || {};
this.nationModal.refresh(
stats.government || 0,
this._researched,
stats.gdp || 0,
stats.budget || 0,
stats.researchRate || 0,
this._finances(stats),
this._diplomacyInfo(),
this._conflicts,
this._politicsInfo(),
this._techInfo()
);
},
_diplomacyInfo() {
return {
civs: this.civilisations,
localCiv: this.localCiv,
relations: (this.snapshot && this.snapshot.diplomacy) || [],
stats: (this.snapshot && this.snapshot.civStats) || [],
};
},
// Everything the Politics tab needs from the snapshot.
_politicsInfo() {
const snapshot = this.snapshot || {};
return {
civs: this.civilisations,
localCiv: this.localCiv,
totalHours: snapshot.totalHours || 0,
approval: snapshot.approval || [],
opinions: snapshot.opinions || { ethnic: [], government: [] },
policies: snapshot.policies || [],
campaigns: snapshot.campaigns || [],
migrations: snapshot.migrations || [],
cultureImpact: snapshot.cultureImpact || [],
viewerStats: snapshot.viewerStats || {},
};
},
_openNewsModal() {
this.newsModal.show(this._newsForModal(), this.civilisations);
},
_updateNewsModal() {
if (!this.snapshot || !this.newsModal.isShowing()) return;
this.newsModal.refresh(this._newsForModal(), this.civilisations);
},
// The news list, without the repeatable-research notices: those still reach
// the player through the corner feed, but are not news events.
_newsForModal() {
return (this._news || []).filter((entry) => !entry.repeatable);
},
// Pops newly arrived news into the corner feed. The first snapshot only
// records what is already known, so history never floods the screen.
_updateFeed(news) {
if (this._seenNews === null) {
this._seenNews = new Set(news.map((entry) => entry.id));
return;
}
for (const entry of news) {
if (this._seenNews.has(entry.id)) continue;
this._seenNews.add(entry.id);
this._pushNews(entry);
}
},
_pushNews(entry) {
if (entry.type === NEWS_WAR && entry.target === this.localCiv) {
this.feed.push(`${this._civName(entry.actor)} has declared war on you!`, { alert: true });
return;
}
// A technology the viewer finishes chimes like a building or unit; someone
// else taking control of a nation is the neutral notice (Mouth_08).
if (entry.type === NEWS_TECHNOLOGY && entry.civ === this.localCiv) {
audio.play("complete");
} else if (entry.type === NEWS_JOIN && entry.civ !== this.localCiv) {
audio.play("notification");
}
this.feed.push(describeNews(entry, this.civilisations));
},
_finances(stats) {
if (!stats || !stats.breakdown) return null;
// The region figures are global, so the Cities tab can show every nation's;
// the panel filters them to the viewer.
return {
...stats.breakdown,
cities: (this.snapshot && this.snapshot.cityStats) || [],
};
},
};