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

945 lines
36 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, improvements as fmtImprovements } from "../../../shared/text_format.js";
import {
CIVILISATIONS,
TRAINABLE_UNIT_IDS,
BUILDINGS,
GOVERNMENTS,
TECHNOLOGIES,
NEWS_WAR,
ECONOMY,
EFFECT_GDP,
APPROVAL,
effectValue,
pillageSteps,
ROADS,
AIR_STRIKE,
} from "../../../shared/data.js";
import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors } from "../../../shared/rules.js";
import { dateString } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
import { ethnicityChart } from "../ui/ethnicity.js";
export const panelMethods = {
_bindHud() {
$("#city-manage").on("click", () => this._openCityModal());
$("#civ-flag").on("click", () => this._openNationModal());
$("#news-button").on("click", () => this._openNewsModal());
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.onTechnology = (index) =>
this.sendOrder({ type: "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());
// 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;
this.map.setSelectedUnits(this.selectedUnitIds);
this._hideStackMenu();
this._showUnits(units);
},
_selectedUnits() {
return this.selectedUnitIds.map((id) => this._unit(id)).filter(Boolean);
},
_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`
);
},
_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 || {};
$("#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)}`);
},
_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);
}
},
// 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._hideStatTooltip();
this._refreshBottomVisibility();
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._updatePillageButton(units);
$("#tile-info, #city-info").addClass("hidden");
$("#unit-info").removeClass("hidden");
this._refreshBottomVisibility();
},
_showUnit(unit) {
if (!unit) {
$("#unit-info").addClass("hidden");
this._hideStatTooltip();
this._refreshBottomVisibility();
return;
}
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]);
$("#tile-info, #city-info").addClass("hidden");
$("#unit-info").removeClass("hidden");
this._refreshBottomVisibility();
},
// 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 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.
_showCity(city) {
if (!city) {
$("#city-info").addClass("hidden");
$("#tile-info").addClass("hidden");
this._refreshBottomVisibility();
return;
}
this._renderTileInfo({ x: city.coords[0], y: city.coords[1] });
$("#city-name").text(city.name);
$("#unit-info").addClass("hidden");
$("#tile-info, #city-info").removeClass("hidden");
this._refreshBottomVisibility();
},
_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.map.setSelectedUnits([]);
this._hideStackMenu();
this._renderTileInfo(coords);
$("#city-info, #unit-info").addClass("hidden");
$("#tile-info").removeClass("hidden");
this._refreshBottomVisibility();
},
// 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 civ = this.civilisations[owner];
const tile = this.map.getTile(coords);
const city = (this.snapshot.cities || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
$("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`);
$("#tile-owner").text(`Owner: ${civ ? civ.name : "Unclaimed"}`);
$("#tile-terrain").text(`Terrain: ${tile ? tile.terrainType : "Unknown"}`);
$("#tile-population").text(`Population: ${groupDigits(this._population.get(k) || 0)}`);
this._renderTileEthnicity(k);
this._renderTileGdp(owner >= 0 ? this._tileGdpBreakdown(coords, owner, city, tile) : null);
const roads = this.map.roads && this.map.roads.has(k) ? [ROADS.improvementName] : [];
const improvements = city
? Array.from(new Set([...(city.improvements || []), ...roads]))
: roads;
$("#tile-improvements").text(`Improvements: ${fmtImprovements(improvements)}`);
},
// The tile's people, by ethnicity, as a small donut plus a legend with head
// counts and last-day migration arrows.
_renderTileEthnicity(k) {
const $container = $("#tile-ethnicity");
if (!$container || !$container.length) return;
$container.empty();
const entries = this._tileEthnicity.get(k) || [];
if (entries.length === 0) return;
const population = this._population.get(k) || 0;
const makeup = entries
.map(([ethnicity, share, trend]) => ({
ethnicity,
share,
count: share * population,
trend,
}))
.sort((a, b) => b.count - a.count);
$container.append($("<div class='info-title'></div>").text("People"));
$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) {
const k = `${coords.x},${coords.y}`;
const regionCity =
city || (this.snapshot.cities || []).find((c) => c.id === this._tileRegion.get(k));
const regionalModifier = regionCity ? this._cityGdpModifier(regionCity) : 0;
const nationalModifier = this._nationalGdpModifier(owner);
const factors = gdpPerCapitaFactors(tile, {
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
nextToCity: !city && 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,
});
return {
base: ECONOMY.baseGdpPerCapita,
factors,
total: gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors),
};
},
// 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;
}
$container.append(
$("<div></div>").text(`GDP/capita: €${groupDigits(Math.round(breakdown.total))}`)
);
$container.append(
$("<div class='tile-gdp-mod'></div>").text(`Base €${groupDigits(breakdown.base)}`)
);
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
? `−€${groupDigits(Math.round(Math.abs(factor.amount)))}`
: `×${factor.multiplier.toFixed(2)}`;
$container.append(
$("<div class='tile-gdp-mod'></div>").text(`${factor.label} ${value}`)
);
}
},
_refreshBottomVisibility() {
const visible =
!$("#tile-info").hasClass("hidden") ||
!$("#city-info").hasClass("hidden") ||
!$("#unit-info").hasClass("hidden");
$("#bottom-panel").toggleClass("hidden", !visible);
},
_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,
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 city = this._city(this.selectedCityId);
if (city) {
this.cityModal.refreshTrainList(this._trainableUnits(city));
const cityStats = this._cityStats(this.selectedCityId);
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)
);
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.research || 0,
this._finances(stats),
this._diplomacyInfo(),
this._conflicts,
this._politicsInfo()
);
},
_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.research || 0,
this._finances(stats),
this._diplomacyInfo(),
this._conflicts,
this._politicsInfo()
);
},
_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._news, this.civilisations);
},
_updateNewsModal() {
if (!this.snapshot || !this.newsModal.isShowing()) return;
this.newsModal.refresh(this._news, this.civilisations);
},
// 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;
}
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) || [],
};
},
};