The simple economy pays for propaganda out of the nation's culture stock and lapses a campaign it can no longer fund, but the tab quoted money and never named the store. Give culture the clapperboard icon as a non-tradeable resource like research, read the stock and the campaigns' daily drain out on the Propaganda tab, and price every campaign figure in culture when the simple economy runs.
2985 lines
121 KiB
JavaScript
2985 lines
121 KiB
JavaScript
// 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, marketPricesFrom, quotedBuildCost } 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,
|
||
NEWS_FAMINE,
|
||
NEWS_SABOTAGE,
|
||
NEWS_SATELLITE,
|
||
NEWS_ENRICHMENT,
|
||
NEWS_TREATY,
|
||
TREATY_PROPOSED,
|
||
TREATY_ALLIANCE,
|
||
treatyById,
|
||
ECONOMY,
|
||
EFFECT_PRODUCTION,
|
||
APPROVAL,
|
||
POPULARITY,
|
||
effectValue,
|
||
pillageSteps,
|
||
ROADS,
|
||
TRANSPORT_IMPROVEMENTS,
|
||
transportImprovement,
|
||
TILE_IMPROVEMENTS,
|
||
RESOURCE_BUILDINGS,
|
||
resourceById,
|
||
RESOURCES,
|
||
STORABLE_RESOURCE_IDS,
|
||
RESOURCE_RULES,
|
||
resourceBuildingOutputAt,
|
||
tileImprovementById,
|
||
SPY_OPERATIONS,
|
||
SPY_SABOTAGE,
|
||
SPY_CITY,
|
||
SPY_COMMS,
|
||
BUILDING_MECHANIC,
|
||
} from "../../../shared/data.js";
|
||
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
|
||
import { formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
|
||
import { dateString, hoursInNextMonth } from "../../../shared/game_clock.js";
|
||
import { DEFAULT_UNIT_SPEED, HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
|
||
import { flightDistanceTiles } 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";
|
||
import { currencySymbol, currencyFor, rateToNational, moneyText, formatMoney } from "../currency.js";
|
||
import { buildCard, tooltipSections, hideCardTooltip } from "../ui/card.js";
|
||
|
||
export const panelMethods = {
|
||
_bindHud() {
|
||
$("#city-manage").on("click", () => this._openCityModal());
|
||
$("#tile-manage").on("click", () => this._openTileManageModal());
|
||
$("#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.onUnlockTechnology = (index) =>
|
||
this.sendOrder({ type: "unlock_technology", technology: index, free: true });
|
||
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.onLocateUnit = (id) => this._locateUnit(id);
|
||
this.nationModal.onLaunchSatellite = (cityId) =>
|
||
this.sendOrder({ type: "launch_satellite", city: cityId });
|
||
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 });
|
||
this.nationModal.onSetInterestRate = (rate) =>
|
||
this.sendOrder({ type: "set_interest_rate", rate });
|
||
this.nationModal.onSetTaxRate = (kind, rate, resource) =>
|
||
this.sendOrder({ type: "set_tax_rate", kind, rate, resource });
|
||
// The simple economy's market: one lot of one good, bought or sold at the
|
||
// world price. The server re-validates the lot, the funds and the stock.
|
||
this.nationModal.onMarketTrade = (resource, side, lot) =>
|
||
this.sendOrder({ type: "market_trade", resource, side, lot });
|
||
this.nationModal.onProposeTreaty = (type, to, payload) =>
|
||
this.sendOrder({ type: "propose_treaty", treaty: type, to, payload });
|
||
this.nationModal.onRespondTreaty = (proposal, accept) =>
|
||
this.sendOrder({ type: "respond_treaty", proposal, accept });
|
||
this.nationModal.onCancelTreaty = (treatyId) =>
|
||
this.sendOrder({ type: "cancel_treaty", treatyId });
|
||
|
||
$("#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());
|
||
$("#unit-nuclear").on("click", () => this._toggleNuclearPayload());
|
||
$("#unit-attack-order").on("click", () => this._toggleTargeting());
|
||
|
||
// 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());
|
||
// The pool chips do too, and they are rebuilt on some snapshots, so the
|
||
// handler is delegated rather than bound to the elements themselves.
|
||
$("#top-panel")
|
||
.on("mouseenter", ".top-resource", (event) => this._showStatTooltip($(event.currentTarget)))
|
||
.on("mouseleave", ".top-resource", () => 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.
|
||
// A top-panel resource chip is the exception: its tooltip is a two-line bill,
|
||
// production above consumption, both right-aligned.
|
||
_showStatTooltip($stat) {
|
||
const content = this._statTooltipContent($stat);
|
||
if (content === null) return;
|
||
this._hoveredStat = $stat;
|
||
this.$statTooltip.empty().append(content).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`,
|
||
});
|
||
},
|
||
|
||
// The body of a stat tooltip: the resource bill for a pool chip, or the plain
|
||
// text another stat carries. Null means the stat has no tooltip.
|
||
_statTooltipContent($stat) {
|
||
if ($stat.hasClass("top-resource")) {
|
||
const proto = resourceById($stat.attr("data-resource"));
|
||
const unit = proto ? proto.unit : "";
|
||
return this._resourceTooltip(
|
||
Number($stat.attr("data-produced")) || 0,
|
||
Number($stat.attr("data-consumed")) || 0,
|
||
unit
|
||
);
|
||
}
|
||
const text = $stat.attr("data-tooltip");
|
||
return text ? document.createTextNode(text) : null;
|
||
},
|
||
|
||
// A resource's daily flows as two right-aligned rows: production first with a
|
||
// plus, consumption second with a minus. One grid holds both rows, so the sign
|
||
// and figure columns line up; the figure column reserves room for the largest
|
||
// pool figure (99 999 999 999), so the bubble never shifts width as a stock
|
||
// moves.
|
||
_resourceTooltip(produced, consumed, unit) {
|
||
const $tip = $("<div class='resource-tip'></div>");
|
||
for (const row of [
|
||
{ cls: "produce", sign: "+", amount: produced },
|
||
{ cls: "consume", sign: "−", amount: consumed },
|
||
]) {
|
||
$tip.append(
|
||
$("<span class='rt-sign'></span>").addClass(row.cls).text(row.sign),
|
||
$("<span class='rt-figure'></span>").addClass(row.cls).text(groupDigits(row.amount)),
|
||
$("<span class='rt-tail'></span>").addClass(row.cls).text(`${unit}/day`)
|
||
);
|
||
}
|
||
return $tip;
|
||
},
|
||
|
||
// Rebuilds the open stat tooltip in place, so a snapshot that changes the
|
||
// figures under the pointer does not leave a stale bubble.
|
||
_refreshStatTooltip() {
|
||
if (!this._hoveredStat || this.$statTooltip.hasClass("hidden")) return;
|
||
const content = this._statTooltipContent(this._hoveredStat);
|
||
if (content !== null) this.$statTooltip.empty().append(content);
|
||
},
|
||
|
||
_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);
|
||
}
|
||
// A new selection is not the stack that was picking a target, so leave
|
||
// target mode rather than firing at the wrong tile.
|
||
if (this._targeting) this._endTargeting();
|
||
this.selectedUnitIds = units.map((unit) => unit.id);
|
||
// The panel keeps showing the whole stack the selection sits on, so a unit
|
||
// the player unticks remains as a card to tick back on.
|
||
this._stackMembers = this._stackForSelection(units);
|
||
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();
|
||
},
|
||
|
||
// The ids of the whole friendly stack `units` stands on, so the panel can keep
|
||
// the deselected cards around. Units spread across tiles (or a lone unit) are
|
||
// just themselves.
|
||
_stackForSelection(units) {
|
||
if (units.length === 0) return [];
|
||
const coords = units[0].coords;
|
||
const sameTile = units.every(
|
||
(unit) => unit.coords[0] === coords[0] && unit.coords[1] === coords[1]
|
||
);
|
||
if (!sameTile) return units.map((unit) => unit.id);
|
||
const ids = this.map.stackIds(units[0].id).filter((id) => {
|
||
const data = this._unit(id);
|
||
return data && data.civ === this.localCiv;
|
||
});
|
||
return ids.length > 1 ? ids : units.map((unit) => unit.id);
|
||
},
|
||
|
||
_stackMemberUnits() {
|
||
return (this._stackMembers || []).map((id) => this._unit(id)).filter(Boolean);
|
||
},
|
||
|
||
_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;
|
||
// A flight path is world pixels, not tiles, and an aircraft crosses
|
||
// foreign land without declaring war; only ground marches name targets.
|
||
if (this._isAirUnit(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 (this._isAllied(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, productionMultiplier } = 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, productionMultiplier, 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)}%) · ` +
|
||
`production ×${forecast.productionMultiplier.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) });
|
||
},
|
||
|
||
// A missile warhead is conventional unless the player arms it otherwise. The
|
||
// toggle only shows for a nuclear power with missiles selected.
|
||
_updateNuclearButton(units) {
|
||
const $button = $("#unit-nuclear");
|
||
if (!$button.length) return;
|
||
const missiles = units.length > 0 && units.every((unit) => this._isMissileUnit(unit));
|
||
const armed = missiles && this._hasTechnology("nuclear_weapons");
|
||
$button.toggleClass("hidden", !armed);
|
||
$button.toggleClass("active", armed && !!this._nuclearPayload);
|
||
},
|
||
|
||
_toggleNuclearPayload() {
|
||
this._nuclearPayload = !this._nuclearPayload;
|
||
const units = this._selectedUnits();
|
||
this._updateNuclearButton(units);
|
||
},
|
||
|
||
// The covert operations a spy may run, as buttons in the unit panel. Built
|
||
// once from the shared catalogue; disabled until each action is wired end to
|
||
// end (see INTELLIGENCE.md), so the player sees where they will live.
|
||
_buildSpyActionButtons() {
|
||
const $actions = $("#unit-spy-actions");
|
||
if (!$actions.length || $actions.data("built")) return;
|
||
$actions.data("built", true);
|
||
for (const operation of SPY_OPERATIONS) {
|
||
const $button = $("<button class='aero'></button>")
|
||
.attr("data-operation", operation.id)
|
||
.attr("title", "Not yet available")
|
||
.text(operation.name)
|
||
.prop("disabled", true);
|
||
$button.on("click", () => this._runSpyOperation(operation.id));
|
||
$actions.append($button);
|
||
}
|
||
// A saboteur may try to pin the act on another nation. It is a bluff either
|
||
// way: the victim cannot tell a true blame from a false flag.
|
||
const $frame = $("<select class='spy-frame' id='unit-sabotage-frame'></select>")
|
||
.attr("title", "Pin the sabotage on another nation");
|
||
$frame.append($("<option value=''></option>").text("Blame no one"));
|
||
this.civilisations.forEach((civ, index) => {
|
||
if (index === this.localCiv) return;
|
||
$frame.append($("<option></option>").attr("value", index).text(civ.name));
|
||
});
|
||
$actions.append($frame);
|
||
},
|
||
|
||
// Spy actions show only for a stack of spies. Each is enabled when the spy is
|
||
// in position; city and comms spying then pick a target on the map.
|
||
_updateSpyActions(units) {
|
||
const $actions = $("#unit-spy-actions");
|
||
if (!$actions.length) return;
|
||
this._buildSpyActionButtons();
|
||
const spies = units.length > 0 && units.every((unit) => this._isSpy(unit));
|
||
$actions.toggleClass("hidden", !spies);
|
||
if (!spies) return;
|
||
const armed = this._targeting ? this._targeting.operation : null;
|
||
for (const operation of SPY_OPERATIONS) {
|
||
const $button = $actions.find(`[data-operation="${operation.id}"]`);
|
||
if (!$button.length) continue;
|
||
let enabled = false;
|
||
let hint = "Not yet available";
|
||
if (operation.id === SPY_SABOTAGE) {
|
||
enabled = this._canSabotage(units);
|
||
hint = enabled ? operation.description : "Move the spy onto foreign land";
|
||
} else if (operation.id === SPY_CITY) {
|
||
enabled = this._spyActionTarget(SPY_CITY, units);
|
||
hint = enabled ? operation.description : "Move the spy beside a foreign city";
|
||
} else if (operation.id === SPY_COMMS) {
|
||
enabled = this._spyActionTarget(SPY_COMMS, units);
|
||
hint = enabled ? operation.description : "Move the spy beside a foreign capital";
|
||
}
|
||
const active = armed === operation.id;
|
||
$button
|
||
.prop("disabled", !enabled)
|
||
.attr("title", hint)
|
||
.toggleClass("active", active)
|
||
.text(active ? `${operation.name} — pick a target` : operation.name);
|
||
}
|
||
},
|
||
|
||
_runSpyOperation(operation) {
|
||
if (operation === SPY_SABOTAGE) this._sabotageSelected();
|
||
else if (operation === SPY_CITY || operation === SPY_COMMS) this._toggleSpyOperation(operation);
|
||
},
|
||
|
||
// Whether the selected stack may sabotage the tile it stands on: all spies,
|
||
// sharing one foreign land tile. Mirrors GameState.canSabotage.
|
||
_canSabotage(units) {
|
||
if (units.length === 0 || !units.every((unit) => this._isSpy(unit))) return false;
|
||
const coords = units[0].coords;
|
||
if (!units.every((unit) => unit.coords[0] === coords[0] && unit.coords[1] === coords[1])) {
|
||
return false;
|
||
}
|
||
const k = `${coords[0]},${coords[1]}`;
|
||
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : null;
|
||
if (owner === null || owner === undefined || owner === this.localCiv) return false;
|
||
const tile = this.map.getTile({ x: coords[0], y: coords[1] });
|
||
return !!tile && tile.terrainClass === "Land";
|
||
},
|
||
|
||
_sabotageSelected() {
|
||
const units = this._selectedUnits();
|
||
if (!this._canSabotage(units)) return;
|
||
const raw = $("#unit-sabotage-frame").val();
|
||
const frame = raw === "" || raw === null || raw === undefined ? null : Number(raw);
|
||
const order = { type: "sabotage", units: units.map((unit) => unit.id) };
|
||
if (Number.isInteger(frame) && frame >= 0) order.frame = frame;
|
||
this.sendOrder(order);
|
||
},
|
||
|
||
// The city standing on `coords`, from the snapshot.
|
||
_cityAtCoords(coords) {
|
||
return (
|
||
(this.snapshot.cities || []).find(
|
||
(city) => city.coords[0] === coords.x && city.coords[1] === coords.y
|
||
) || null
|
||
);
|
||
},
|
||
|
||
// Mirrors GameState.canCitySpy: a foreign city within one tile of the spy.
|
||
_canCitySpy(units, coords) {
|
||
const city = this._cityAtCoords(coords);
|
||
if (!city || city.civ === this.localCiv) return false;
|
||
const from = units[0].coords;
|
||
return this.map.topology.tileDistance({ x: from[0], y: from[1] }, coords) <= 1;
|
||
},
|
||
|
||
// Mirrors GameState.canCommsSpy: a foreign capital within one tile.
|
||
_canCommsSpy(units, coords) {
|
||
const city = this._cityAtCoords(coords);
|
||
if (!city || !city.isCapital || city.civ === this.localCiv) return false;
|
||
const from = units[0].coords;
|
||
return this.map.topology.tileDistance({ x: from[0], y: from[1] }, coords) <= 1;
|
||
},
|
||
|
||
// Whether any tile the spy can watch satisfies the operation's target.
|
||
_spyActionTarget(operation, units) {
|
||
if (units.length === 0) return false;
|
||
const from = units[0].coords;
|
||
const centre = { x: from[0], y: from[1] };
|
||
const candidates = [centre, ...this.map.topology.neighbours(centre.x, centre.y)];
|
||
return candidates.some((coords) =>
|
||
operation === SPY_CITY
|
||
? this._canCitySpy(units, coords)
|
||
: this._canCommsSpy(units, coords)
|
||
);
|
||
},
|
||
|
||
// Arms target-picking for a spy operation that points at a neighbouring city.
|
||
_toggleSpyOperation(operation) {
|
||
const units = this._selectedUnits();
|
||
if (units.length === 0 || !units.every((unit) => this._isSpy(unit))) return;
|
||
if (this._targeting && this._targeting.operation === operation) {
|
||
this._endTargeting();
|
||
return;
|
||
}
|
||
if (!this._spyActionTarget(operation, units)) return;
|
||
const ids = units.map((unit) => unit.id);
|
||
const coords = units[0].coords;
|
||
this._targeting = { ids, operation };
|
||
this.map.beginTargeting({
|
||
unitIds: ids,
|
||
origin: { x: coords[0], y: coords[1] },
|
||
range: 1,
|
||
globalRange: false,
|
||
});
|
||
this._updateSpyActions(units);
|
||
},
|
||
|
||
// The Attack action, offered only when every selected unit is the same type
|
||
// and that type strikes at range. It enters target-picking mode rather than
|
||
// firing straight away.
|
||
_updateAttackButton(units) {
|
||
const $button = $("#unit-attack-order");
|
||
if (!$button.length) return;
|
||
const singleType = units.length > 0 &&
|
||
units.every((unit) => unit.proto === units[0].proto) &&
|
||
this._isRangedUnit(units[0]);
|
||
const active = singleType && !!this._targeting;
|
||
$button.toggleClass("hidden", !singleType);
|
||
$button.toggleClass("active", active);
|
||
$button.text(active ? "Attack — pick a target" : "Attack");
|
||
},
|
||
|
||
// Starts or cancels target-picking for the current ranged stack.
|
||
_toggleTargeting() {
|
||
if (this._targeting) {
|
||
this._endTargeting();
|
||
return;
|
||
}
|
||
const units = this._selectedUnits();
|
||
if (units.length === 0) return;
|
||
if (!units.every((unit) => unit.proto === units[0].proto)) return;
|
||
if (!this._isRangedUnit(units[0])) return;
|
||
const proto = this.protoUnits[units[0].proto];
|
||
const ids = units.map((unit) => unit.id);
|
||
this._targeting = { ids, operation: "attack" };
|
||
this.map.beginTargeting({
|
||
unitIds: ids,
|
||
origin: { x: units[0].coords[0], y: units[0].coords[1] },
|
||
range: proto.missionRange || proto.range || 1,
|
||
globalRange: !!proto.globalRange,
|
||
});
|
||
this._updateAttackButton(units);
|
||
},
|
||
|
||
_endTargeting() {
|
||
if (!this._targeting) return;
|
||
this._targeting = null;
|
||
this.map.clearTargeting();
|
||
this._updateAttackButton(this._selectedUnits());
|
||
this._updateSpyActions(this._selectedUnits());
|
||
},
|
||
|
||
// A left-click in target-picking mode. A ranged attack fires when the tile
|
||
// holds a hostile military unit, or -- for an aircraft -- a hostile city; a
|
||
// spy operation files its report or opens its bombardment when the tile holds
|
||
// the city it wants.
|
||
_chooseTarget(coords) {
|
||
const targeting = this._targeting;
|
||
if (!targeting) return;
|
||
const units = targeting.ids.map((id) => this._unit(id)).filter(Boolean);
|
||
this._endTargeting();
|
||
if (units.length === 0) return;
|
||
if (targeting.operation === SPY_CITY) {
|
||
if (this._canCitySpy(units, coords)) {
|
||
this.sendOrder({
|
||
type: "spy_city",
|
||
units: units.map((unit) => unit.id),
|
||
coords: [coords.x, coords.y],
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
if (targeting.operation === SPY_COMMS) {
|
||
if (this._canCommsSpy(units, coords)) {
|
||
this.sendOrder({
|
||
type: "spy_comms",
|
||
units: units.map((unit) => unit.id),
|
||
coords: [coords.x, coords.y],
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
if (!this._isEnemyMilitaryAt(coords) && !this._airStrikeOrder(units, coords)) return;
|
||
this._orderMove(units, coords, "attack");
|
||
},
|
||
|
||
_isMissileUnit(unit) {
|
||
const proto = this.protoUnits[unit.proto];
|
||
return !!(proto && proto.missile);
|
||
},
|
||
|
||
_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 target of a ranged
|
||
// strike.
|
||
_isEnemyMilitaryAt(coords) {
|
||
return this.map.unitIdsAt(coords).some((id) => {
|
||
const data = this._unit(id);
|
||
return data && data.civ !== this.localCiv && this._isMilitaryUnit(data);
|
||
});
|
||
},
|
||
|
||
// A city that is not ours, at `coords`. A city known to the player but hidden
|
||
// under the fog is still a legitimate target, so this reads the snapshot
|
||
// rather than the visible unit layer.
|
||
_isEnemyCityAt(coords) {
|
||
return this._cityCivAt(coords) >= 0;
|
||
},
|
||
|
||
// The hostiles a strike on `coords` would fall on: the enemy military unit
|
||
// there, else the non-friendly city. -1 when there is nothing to attack.
|
||
_attackTargetCiv(coords) {
|
||
const hostile = this.map.unitIdsAt(coords)
|
||
.map((id) => this._unit(id))
|
||
.find((data) => data && data.civ !== this.localCiv && this._isMilitaryUnit(data));
|
||
if (hostile) return hostile.civ;
|
||
return this._cityCivAt(coords);
|
||
},
|
||
|
||
_cityCivAt(coords) {
|
||
const city = ((this.snapshot && this.snapshot.cities) || []).find(
|
||
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
|
||
);
|
||
return city && city.civ !== this.localCiv ? city.civ : -1;
|
||
},
|
||
|
||
// 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 or city and bombs
|
||
// it on arrival, then returns home to re-arm. A city is a valid target even
|
||
// when it is under the fog and even when its garrison is not visible.
|
||
_airStrikeOrder(units, coords) {
|
||
if (units.length === 0) return false;
|
||
if (!units.every((unit) => this._isAirUnit(unit))) return false;
|
||
return this._isEnemyMilitaryAt(coords) || this._isEnemyCityAt(coords);
|
||
},
|
||
|
||
// Why an air strike cannot be issued right now, or null when it can. Blocks a
|
||
// plane that is already flying a mission or is still re-arming, and a nation
|
||
// whose stores cannot cover the munitions the sortie burns.
|
||
_airStrikeBlocked(units) {
|
||
const snapshot = this.snapshot || {};
|
||
const totalHours = snapshot.totalHours || 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).`,
|
||
};
|
||
}
|
||
}
|
||
// A sortie is paid for in munitions, not money (GameState.requestAirStrike):
|
||
// every aircraft spends steel and high-tech from the nation's stores.
|
||
const per = RESOURCE_RULES.combatConsumption.airStrike || {};
|
||
const neededSteel = (per.steel || 0) * units.length;
|
||
const neededTech = (per.hightech || 0) * units.length;
|
||
const stock = (((snapshot.viewerStats || {}).resources || {}).stock) || {};
|
||
if (neededSteel > (stock.steel || 0) + 1e-6 || neededTech > (stock.hightech || 0) + 1e-6) {
|
||
return {
|
||
title: "Insufficient munitions",
|
||
message: "The nation's stores cannot cover this air strike's steel and high-tech.",
|
||
};
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// The friendly airport an aircraft would fly home to from `coords`, mirroring
|
||
// GameState._returnAirport: the nearest one to where it is heading, so a
|
||
// rebased plane is not sent back across the map to its original base.
|
||
_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;
|
||
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 home = { 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;
|
||
// Fuel is spent in straight-line hours: out to the target, then on to the
|
||
// nearest friendly airport. Mirrors GameState._canStrikeAndReturn, so the
|
||
// browser refuses exactly what the server would.
|
||
const maxStep = this.map._maxStepLength || 1;
|
||
const hours =
|
||
flightDistanceTiles(from, coords, this.map.topology, maxStep) / speed +
|
||
flightDistanceTiles(coords, home, this.map.topology, maxStep) / speed;
|
||
// 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 = hours > 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}`);
|
||
},
|
||
|
||
// Whether two nations have a standing alliance. Read from the snapshot's
|
||
// treaties, which the client mirrors on every state. An ally's land may be
|
||
// crossed without a war declaration.
|
||
_isAllied(a, b) {
|
||
if (a < 0 || b < 0 || a === b) return false;
|
||
return ((this.snapshot && this.snapshot.treaties) || []).some(
|
||
(treaty) =>
|
||
treaty.type === TREATY_ALLIANCE &&
|
||
((treaty.a === a && treaty.b === b) || (treaty.a === b && treaty.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 -----
|
||
|
||
// The nation's resource pool, shown only in the simple economy: one icon and
|
||
// its grouped amount per storable good, no label or unit. An arrow beside the
|
||
// amount says whether the pool is filling or draining -- production against
|
||
// consumption only, so market purchases do not move it -- and the chip's
|
||
// tooltip spells both figures out. The hard economy keeps its per-region
|
||
// stores and shows nothing here.
|
||
_updateTopResources(stats) {
|
||
const $list = $("#top-resources");
|
||
if (!$list.length) return;
|
||
const simple = this.economyModel === "simple";
|
||
$list.toggleClass("hidden", !simple);
|
||
if (!simple) {
|
||
$list.empty();
|
||
return;
|
||
}
|
||
const summary = stats.resources || {};
|
||
const stock = summary.stock || {};
|
||
const production = summary.production || {};
|
||
const consumed = summary.consumed || {};
|
||
for (const id of STORABLE_RESOURCE_IDS) {
|
||
const proto = resourceById(id);
|
||
if (!proto) continue;
|
||
// Reuse the chip across snapshots so a hovered tooltip is not anchored to
|
||
// a node the next broadcast throws away.
|
||
let $chip = $list.children(`.top-resource[data-resource='${id}']`);
|
||
if (!$chip.length) {
|
||
$chip = $("<span class='top-resource'></span>")
|
||
.attr("data-resource", id)
|
||
.append(
|
||
$("<img>")
|
||
.attr("src", `assets/${proto.icon}`)
|
||
.attr("alt", proto.name)
|
||
)
|
||
.append($("<span class='top-resource-value'></span>"));
|
||
$list.append($chip);
|
||
}
|
||
const made = production[id] || 0;
|
||
const used = consumed[id] || 0;
|
||
const $value = $chip.find(".top-resource-value").text(groupDigits(stock[id] || 0));
|
||
$chip.find(".stat-arrow").remove();
|
||
const net = made - used;
|
||
const $arrow = $("<span class='stat-arrow'></span>");
|
||
if (Math.abs(net) <= 0.5) $arrow.text("=").addClass("equal");
|
||
else $arrow.text(net > 0 ? "▲" : "▼").addClass(net > 0 ? "up" : "down");
|
||
$value.after($arrow);
|
||
// The hover tooltip pairs the two flows; the raw figures ride on the chip
|
||
// so an open tooltip can be rebuilt in step with the next snapshot.
|
||
$chip.attr("data-produced", made).attr("data-consumed", used);
|
||
}
|
||
},
|
||
|
||
_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 || {};
|
||
this._updateTopResources(stats);
|
||
// 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(`${currencySymbol()}${compact(stats.gdp || 0)}`);
|
||
$("#stat-gdppc").text(`${currencySymbol()}${compact(stats.gdpPerCapita || 0)}`);
|
||
$("#stat-budget").text(`${currencySymbol()}${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 `POPULARITY.driftPerHour` points back to neutral every
|
||
// hour, so the estimate is how many points away they are spread over that
|
||
// daily rate; zero is already there.
|
||
const driftPerDay = POPULARITY.driftPerHour * HOURS_PER_DAY;
|
||
const pointsAway = Math.abs(popularityPoints);
|
||
this._setStatTooltip(
|
||
"stat-approval",
|
||
pointsAway <= 0
|
||
? "Popularity is exactly neutral"
|
||
: `Neutrality in about ${Math.ceil(pointsAway / driftPerDay)} d ` +
|
||
`at the current drift of ${driftPerDay} points a day`
|
||
);
|
||
// 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.
|
||
this._refreshStatTooltip();
|
||
},
|
||
|
||
// 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}${currencySymbol()}${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._stackMemberUnits().length > 1) {
|
||
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 the selected units. When they share a stack the
|
||
// panel lists every unit of it (so deselecting one keeps its card around) and
|
||
// reports the combined health of the ticked ones.
|
||
_showUnits(units) {
|
||
const members = this._stackMemberUnits();
|
||
if (members.length > 1) {
|
||
const counted = units.length > 0 ? units : members;
|
||
const hp = counted.reduce((sum, unit) => sum + unit.hp, 0);
|
||
const maxHp = counted.reduce((sum, unit) => sum + unit.maxHp, 0);
|
||
if (units.length === 1) {
|
||
$("#unit-name").text(this._unitLabel(units[0], this.protoUnits[units[0].proto]));
|
||
} else if (units.length === 0) {
|
||
$("#unit-name").text("No units selected");
|
||
} else {
|
||
$("#unit-name").text(`${units.length} units selected`);
|
||
}
|
||
const single = this._singleProto(units);
|
||
if (single) {
|
||
$("#unit-attack, #unit-defense").removeClass("hidden");
|
||
this._showUnitStats(units[0], single);
|
||
} else {
|
||
$("#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(members);
|
||
this._updatePillageButton(units);
|
||
this._updateTrenchButton(units);
|
||
this._updateNuclearButton(units);
|
||
this._updateAttackButton(units);
|
||
this._updateSpyActions(units);
|
||
this._updateCargo(units);
|
||
$("#unit-info").removeClass("hidden");
|
||
this._refreshPanelVisibility();
|
||
// The deck was built while the panel was still hidden, so its cards had
|
||
// no measurable width and the overlap was skipped. Lay them out again now
|
||
// that they are actually on screen.
|
||
this._layoutStackDecks();
|
||
return;
|
||
}
|
||
if (units.length === 0) {
|
||
$("#unit-info").addClass("hidden");
|
||
this._clearStackList();
|
||
this._updateCargo([]);
|
||
this._hideStatTooltip();
|
||
this._refreshPanelVisibility();
|
||
return;
|
||
}
|
||
this._showUnit(units[0]);
|
||
},
|
||
|
||
// The one proto every unit shares, or null when the selection is mixed.
|
||
_singleProto(units) {
|
||
if (units.length === 0) return null;
|
||
if (!units.every((unit) => unit.proto === units[0].proto)) return null;
|
||
return this.protoUnits[units[0].proto] || null;
|
||
},
|
||
|
||
// The stack panel, grouped one unit type per line. A type line opens with a
|
||
// tri-state tick (all / some / none) that selects or excludes the whole type,
|
||
// then the type icon and count; clicking the line elsewhere spreads its cards
|
||
// out. Each card is an opaque icon toggle with its own healthbar, and the
|
||
// ticked ones sort to the front of their type's list.
|
||
_renderStackList(units) {
|
||
const $list = $("#unit-stack-list");
|
||
if (!$list.length) return;
|
||
$list.removeClass("hidden");
|
||
const active = new Set(this.selectedUnitIds);
|
||
const signature = units.map((unit) => unit.id).join(",") +
|
||
"|" + this.selectedUnitIds.join(",");
|
||
if (signature !== this._stackListSignature) {
|
||
this._stackListSignature = signature;
|
||
$list.empty();
|
||
const groups = new Map();
|
||
for (const unit of units) {
|
||
if (!groups.has(unit.proto)) groups.set(unit.proto, []);
|
||
groups.get(unit.proto).push(unit);
|
||
}
|
||
for (const [protoIndex, members] of groups) {
|
||
const proto = this.protoUnits[protoIndex];
|
||
const name = proto ? proto.name : "Unit";
|
||
const $row = $("<div class='stack-type-row'></div>");
|
||
|
||
const activeCount = members.filter((unit) => active.has(unit.id)).length;
|
||
const state = activeCount === 0 ? "none" : activeCount === members.length ? "all" : "some";
|
||
const $check = $("<button type='button'></button>")
|
||
.addClass(`stack-group-check ${state}`)
|
||
.text(state === "all" ? "✓" : state === "some" ? "–" : "")
|
||
.attr("title", state === "all" ? `Exclude all ${name}` : `Select all ${name}`);
|
||
$check.on("click", (event) => {
|
||
event.stopPropagation();
|
||
$row.find(".stack-unit-tile").toggleClass("active", state !== "all");
|
||
this._stackSelectionChanged();
|
||
});
|
||
$row.append($check);
|
||
|
||
const $label = $("<div class='stack-type-label'></div>");
|
||
if (proto) {
|
||
$label.append(
|
||
$("<img class='stack-type-icon'>")
|
||
.attr("src", `assets/${proto.icon}`)
|
||
.attr("alt", name)
|
||
.attr("title", name)
|
||
);
|
||
}
|
||
$label.append($("<span class='stack-type-count'></span>").text(`×${members.length}`));
|
||
$row.append($label);
|
||
|
||
const $cards = $("<div class='stack-type-units'></div>");
|
||
// Ticked units move to the front of their type's list.
|
||
const ordered = members
|
||
.slice()
|
||
.sort((a, b) => (active.has(b.id) ? 1 : 0) - (active.has(a.id) ? 1 : 0));
|
||
for (const unit of ordered) {
|
||
const $card = $("<button type='button'></button>")
|
||
.addClass(active.has(unit.id) ? "stack-unit-tile active" : "stack-unit-tile")
|
||
.attr("data-id", unit.id)
|
||
.attr("title", `${name} — ${Math.round(unit.hp)}/${unit.maxHp}`);
|
||
if (proto) {
|
||
$card.append(
|
||
$("<img class='stack-unit-icon'>").attr("src", `assets/${proto.icon}`).attr("alt", name)
|
||
);
|
||
}
|
||
$card.append(
|
||
$("<span class='stack-unit-bar'></span>").append(
|
||
$("<span class='stack-unit-fill'></span>").css("width", `${this._healthPercent(unit)}%`)
|
||
)
|
||
);
|
||
$card.on("click", () => {
|
||
$card.toggleClass("active");
|
||
this._stackSelectionChanged();
|
||
});
|
||
$cards.append($card);
|
||
}
|
||
$row.append($cards);
|
||
|
||
$row.append($("<span class='stack-type-caret'></span>").text("▸"));
|
||
$row.on("click", (event) => {
|
||
if ($(event.target).closest(".stack-unit-tile").length) return;
|
||
if ($(event.target).closest(".stack-group-check").length) return;
|
||
$row.toggleClass("expanded");
|
||
this._layoutStackDecks();
|
||
});
|
||
$list.append($row);
|
||
}
|
||
this._layoutStackDecks();
|
||
}
|
||
// Keep the healthbars and tooltips current even when the set did not change.
|
||
for (const unit of units) {
|
||
const proto = this.protoUnits[unit.proto];
|
||
const name = proto ? proto.name : "Unit";
|
||
$list
|
||
.find(`.stack-unit-tile[data-id='${unit.id}']`)
|
||
.attr("title", `${name} — ${Math.round(unit.hp)}/${unit.maxHp}`)
|
||
.find(".stack-unit-fill")
|
||
.css("width", `${this._healthPercent(unit)}%`);
|
||
}
|
||
},
|
||
|
||
_healthPercent(unit) {
|
||
if (!unit || !unit.maxHp) return 0;
|
||
return Math.max(0, Math.min(100, (unit.hp / unit.maxHp) * 100));
|
||
},
|
||
|
||
// Overlaps a type's cards by just enough to keep them inside the row; cards
|
||
// that already fit are left side by side. An expanded group is left alone
|
||
// (its cards wrap instead).
|
||
_layoutStackDecks() {
|
||
if (typeof document === "undefined") return;
|
||
const rows = document.querySelectorAll("#unit-stack-list .stack-type-row");
|
||
for (const row of rows) this._layoutStackDeck(row);
|
||
},
|
||
|
||
_layoutStackDeck(row) {
|
||
if (row.classList.contains("expanded")) {
|
||
for (const card of row.querySelectorAll(".stack-unit-tile")) card.style.zIndex = "";
|
||
return;
|
||
}
|
||
const units = row.querySelector(".stack-type-units");
|
||
const cards = units ? Array.from(units.children) : [];
|
||
if (cards.length < 2) return;
|
||
for (let i = 0; i < cards.length; i++) {
|
||
cards[i].style.marginLeft = "";
|
||
// Cards to the left sit on top, so the deck reads left-to-right.
|
||
cards[i].style.zIndex = String(cards.length - i);
|
||
}
|
||
const cardW = cards[0].getBoundingClientRect().width;
|
||
const avail = units.getBoundingClientRect().width;
|
||
const needed = cards.length * cardW;
|
||
if (cardW <= 0 || avail <= 0 || needed <= avail) return;
|
||
const overlap = (needed - avail) / (cards.length - 1);
|
||
for (let i = 1; i < cards.length; i++) cards[i].style.marginLeft = `${-overlap}px`;
|
||
},
|
||
|
||
// Reads the ticked cards back into the selection without dropping the rest of
|
||
// the stack, so unticking a unit leaves its card in place (and moves it to the
|
||
// back of its type's list).
|
||
_stackSelectionChanged() {
|
||
const $list = $("#unit-stack-list");
|
||
const members = [];
|
||
const active = [];
|
||
$list.find(".stack-unit-tile").each((_index, element) => {
|
||
const id = Number($(element).attr("data-id"));
|
||
members.push(id);
|
||
if ($(element).hasClass("active")) active.push(id);
|
||
});
|
||
this._stackMembers = members;
|
||
this.selectedUnitIds = active;
|
||
this.map.setSelectedUnits(active);
|
||
this._hideStackMenu();
|
||
this._updateTileHighlight();
|
||
this._stackListSignature = null;
|
||
this._showUnits(this._selectedUnits());
|
||
},
|
||
|
||
_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._updateNuclearButton([unit]);
|
||
this._updateAttackButton([unit]);
|
||
this._updateSpyActions([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")) {
|
||
this._refreshStatTooltip();
|
||
} 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) {
|
||
// Keep the city id selected as well as the units, so the city panel's
|
||
// button still knows which city to open.
|
||
this.selectedCityId = city.id;
|
||
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._renderTileProduction(
|
||
this._tileResourceProduction(coords, owner, city, tile),
|
||
owner
|
||
);
|
||
this._renderRegionGdp(owner >= 0 ? regionCity : 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 = tileImprovementById(militaryWorks.id);
|
||
const level = militaryWorks.level || 0;
|
||
works.push(
|
||
(proto ? proto.name : militaryWorks.id) + (level > 0 ? ` (level ${level})` : "")
|
||
);
|
||
}
|
||
$("#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 cards.
|
||
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;
|
||
// The summary only points at the management panel; the building cards live
|
||
// there, so they never overflow the small dock.
|
||
$("#tile-manage").toggleClass("hidden", !buildable);
|
||
if (this.tileModal.isShowing(coords)) {
|
||
this.tileModal.setSummary(this._tileManageSummary(coords));
|
||
this._renderImprovementActions(coords, buildable, current);
|
||
} else if (this.tileModal.isOpen()) {
|
||
// The inspected tile changed out from under an open panel.
|
||
this.tileModal.close();
|
||
}
|
||
},
|
||
|
||
// Opens the management panel for the inspected tile and fills it with the
|
||
// buildable works, which the HUD summary has no room for.
|
||
_openTileManageModal() {
|
||
const coords = this._inspectedTile;
|
||
if (!coords) return;
|
||
const summary = this._tileManageSummary(coords);
|
||
this.tileModal.show(coords, summary);
|
||
this._renderTileInfo(coords);
|
||
},
|
||
|
||
// The title and one-line summary shown atop the management panel.
|
||
_tileManageSummary(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)
|
||
);
|
||
const improvements = Array.from(
|
||
new Set([
|
||
...(this.map.railways.has(k) ? ["Railway"] : this.map.roads.has(k) ? [ROADS.improvementName] : []),
|
||
...(city ? (city.improvements || []).filter(
|
||
(name) => name !== ROADS.improvementName && name !== "Railway"
|
||
) : []),
|
||
])
|
||
);
|
||
return {
|
||
title: this._tileDescription(coords, owner, tile, regionCity),
|
||
sub: `Tile (${coords.x}, ${coords.y})` +
|
||
(improvements.length ? ` · ${fmtImprovements(improvements)}` : " · No improvements yet"),
|
||
};
|
||
},
|
||
|
||
// The construction site on this tile. A site gathering materials shows a
|
||
// spinner and a bought/needed bar per material; once everything is bought it
|
||
// builds with the same progress bar as a city's production. A stalled site
|
||
// raises its budget on its own, so the only control is cancelling the work;
|
||
// the warning "!" is kept for a site no supplier can reach at all.
|
||
_renderTileWorks(coords) {
|
||
const $el = $("#tile-works");
|
||
if (!$el.length) return;
|
||
$el.empty();
|
||
const sites = (this.snapshot.constructionSites || [])[this.localCiv] || [];
|
||
const site = sites.find(
|
||
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
|
||
);
|
||
if (site) {
|
||
const proto = tileImprovementById(site.id);
|
||
const name = proto ? proto.name : site.id;
|
||
const gathering = site.kind === "upgrade"
|
||
? `Gathering materials to upgrade ${name}`
|
||
: `Gathering materials for ${name}`;
|
||
const building = site.kind === "upgrade"
|
||
? `Upgrading ${name} to level ${site.targetLevel} — `
|
||
: `Building ${name} — `;
|
||
if (site.phase === "materials") {
|
||
const unreachable = site.unreachable || [];
|
||
const $label = $("<div class='city-production-label'></div>").text(gathering);
|
||
if (unreachable.length) {
|
||
$label.prepend($("<span class='site-warning' title='No supplier in reach'>!</span> "));
|
||
}
|
||
$el.append($label);
|
||
for (const id of ["steel", "hightech"]) {
|
||
const needed = site.needed[id] || 0;
|
||
if (!(needed > 0)) continue;
|
||
const bought = Math.min(site.bought[id] || 0, needed);
|
||
const pct = Math.max(0, Math.min(100, (bought / needed) * 100));
|
||
const $bar = $("<div class='progress city-production-bar site-gather'></div>");
|
||
$bar.append($("<div></div>").css("width", `${pct}%`));
|
||
$el.append(
|
||
$("<div class='site-material-label'></div>").text(
|
||
`${resourceById(id).name} — ${Math.round(bought)}/${Math.round(needed)}`
|
||
),
|
||
$bar
|
||
);
|
||
}
|
||
if (unreachable.length) {
|
||
const names = unreachable.map((id) => resourceById(id).name).join(", ");
|
||
$el.append(
|
||
$("<div class='site-material-label stalled'></div>").text(
|
||
`No supplier can reach this site for ${names}. ` +
|
||
"It stays stalled until the trade network does."
|
||
)
|
||
);
|
||
} else if (site.stalled) {
|
||
// The budget climbs on its own; the player is only told why the work
|
||
// is waiting, with no button to press.
|
||
$el.append(
|
||
$("<div class='site-material-label stalled'></div>").text(
|
||
"The market is above the budgeted prices. " +
|
||
"The budget is raised to meet them, then the materials are bought."
|
||
)
|
||
);
|
||
} else {
|
||
$el.append($("<span class='gather-spinner' title='Gathering materials'></span>"));
|
||
}
|
||
$el.append(
|
||
$("<div class='site-material-label'></div>").text(
|
||
site.private
|
||
? "Private work — the owner's cash and credit buy the materials"
|
||
: `Budget ${currencySymbol()}${compact(site.budget || 0)}`
|
||
)
|
||
);
|
||
$el.append(
|
||
$("<div class='site-actions'></div>").append(this._cancelSiteButton(coords))
|
||
);
|
||
} else {
|
||
const total = Math.max(site.totalHours || 0, 0.0001);
|
||
const elapsed = site.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}${remaining} h left`),
|
||
$bar
|
||
);
|
||
// Once building has begun the work can still be called off; only the
|
||
// materials already bought and the time spent are lost.
|
||
$el.append(
|
||
$("<div class='site-actions'></div>").append(this._cancelSiteButton(coords))
|
||
);
|
||
}
|
||
}
|
||
// A production building upgrades itself: show the work its agent has begun.
|
||
const stats = this.snapshot.viewerStats || {};
|
||
const building = (stats.productionBuildings || []).find(
|
||
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
|
||
);
|
||
if (!site && building && building.upgrading) {
|
||
$el.append(
|
||
$("<div class='city-production-label'></div>").text(
|
||
`Upgrading ${building.name} to level ${(building.level || 0) + 1}` +
|
||
` — ${building.upgradeDaysLeft || 0} days left`
|
||
)
|
||
);
|
||
}
|
||
// A finished work may still hold a maintenance reserve that pays its own
|
||
// upkeep until it runs dry.
|
||
const works = this.map.tileImprovements.get(`${coords.x},${coords.y}`);
|
||
if (!site && works && works.cash > 0) {
|
||
$el.append(
|
||
$("<div class='city-production-label'></div>").text(
|
||
`Maintenance reserve ${currencySymbol()}${compact(works.cash)}`
|
||
)
|
||
);
|
||
}
|
||
},
|
||
|
||
// 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");
|
||
if (entry.phase === "materials") {
|
||
const $bar = $("<div class='progress city-production-bar'></div>");
|
||
$bar.append($("<div></div>").css("width", `${this._gatherFraction(entry) * 100}%`));
|
||
$el.append(
|
||
$("<div class='city-production-label'></div>").text(`Gathering materials for ${name}`),
|
||
$bar
|
||
);
|
||
return;
|
||
}
|
||
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
|
||
);
|
||
},
|
||
|
||
// One card per buildable work, in the shared card idiom. The price and build
|
||
// time sit on the button; the output, upkeep and material bill are in the
|
||
// hover tooltip.
|
||
_improvementCard(improvement, opts) {
|
||
const built = !!opts.built;
|
||
const locked = !!opts.locked;
|
||
const gdp = opts.gdp || 0;
|
||
const buildHours = ECONOMY.buildingHours(gdp, improvement.buildCost) / (1 + (opts.speed || 0));
|
||
const card = buildCard({
|
||
icon: improvement.icon,
|
||
name: improvement.name,
|
||
status: locked ? `Requires ${opts.lockedName}` : (built ? "Built" : "Not built"),
|
||
statusBuilt: built,
|
||
built,
|
||
locked,
|
||
action: {
|
||
label: built ? "Rebuild" : "Build",
|
||
hours: hours(buildHours),
|
||
disabled: !!opts.disabled,
|
||
},
|
||
remove: {
|
||
label: "✕",
|
||
title: `Remove ${improvement.name}`,
|
||
enabled: built,
|
||
},
|
||
tip: () => this._improvementTip(improvement, opts.prices),
|
||
onAction: opts.onAction,
|
||
onRemove: opts.onRemove,
|
||
});
|
||
card.$action.addClass("improvement-btn").addClass(opts.className || "");
|
||
if (card.$remove) card.$remove.addClass("improvement-btn remove");
|
||
// A local test game may raise transport for free and at once.
|
||
if (opts.free && !built) {
|
||
const $free = $("<button class='aero bt-free free-button'></button>")
|
||
.text(`Free · ${improvement.name}`)
|
||
.on("click", opts.onFree);
|
||
card.$card.append($free);
|
||
}
|
||
return card.$card;
|
||
},
|
||
|
||
// The hover detail for an improvement card: what it yields, what it wears out
|
||
// each day and the materials its construction bills in. The estimated euro
|
||
// price sits here, not on the button.
|
||
_improvementTip(improvement, prices = {}) {
|
||
const produce = [];
|
||
if (improvement.resource) {
|
||
produce.push({ id: improvement.resource, amount: resourceBuildingOutputAt(improvement, 1) });
|
||
} else if (transportImprovement(improvement.id)) {
|
||
const faster = Math.round((1 / improvement.movementCostMultiplier - 1) * 100);
|
||
produce.push({ text: `+${faster}% movement on this tile` });
|
||
} else if (improvement.description) {
|
||
produce.push({ text: improvement.description });
|
||
}
|
||
const materials = tileImprovementResourceCost(improvement);
|
||
const bill = this._resourceItems(materials);
|
||
const price = quotedBuildCost(improvement.buildCost, materials, prices);
|
||
// A resource producer's reference text sits above its output; the military
|
||
// works carry theirs as the produce line.
|
||
const desc = improvement.resource ? improvement.description || "" : "";
|
||
return tooltipSections({
|
||
desc,
|
||
produce,
|
||
produceSuffix: improvement.resource ? "/day" : "",
|
||
consume: this._improvementUpkeepItems(improvement),
|
||
consumeSuffix: "/day",
|
||
cost: bill.length > 0 ? [{ text: `~${formatMoney(price)}` }, ...bill] : bill,
|
||
});
|
||
},
|
||
|
||
// The daily upkeep of a work as tooltip items. Transport wears per tile rather
|
||
// than as a share of its cost.
|
||
_improvementUpkeepItems(improvement) {
|
||
if (transportImprovement(improvement.id)) {
|
||
const steel = RESOURCE_RULES.transportUpkeepSteel[improvement.id] || 0;
|
||
return steel > 0 ? [{ id: "steel", amount: steel }] : [];
|
||
}
|
||
return this._resourceItems(protoUpkeep(improvement, improvement.buildCost || 0));
|
||
},
|
||
|
||
// The positive entries of a resource bill, as tooltip items.
|
||
_resourceItems(resources) {
|
||
const items = [];
|
||
for (const id of ["steel", "energy", "hightech"]) {
|
||
if (resources && resources[id] > 0) items.push({ id, amount: resources[id] });
|
||
}
|
||
return items;
|
||
},
|
||
|
||
// One card per improvement, grouped under a category heading: military first,
|
||
// then transportation, then economic (resource producers). Drawn into the
|
||
// management panel, which has the room the HUD summary does not. Money never
|
||
// disables a card: a nation funds any order by borrowing from its central
|
||
// bank, so what it cannot yet pay for simply becomes debt. While a work is
|
||
// under way every card is hidden, and the only control left is the cancel
|
||
// drawn with the works progress.
|
||
_renderImprovementActions(coords, buildable, current) {
|
||
const $row = $("#tile-manage-improvements");
|
||
if (!$row.length) return;
|
||
hideCardTooltip();
|
||
$row.empty();
|
||
if (!buildable) return;
|
||
const testing = !!this.testing;
|
||
const gdp = (this.snapshot.viewerStats || {}).gdp || 0;
|
||
const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0;
|
||
const prices = marketPricesFrom(this.snapshot);
|
||
const standing = this.map.tileImprovements.get(`${coords.x},${coords.y}`) || null;
|
||
// A work already under way on this tile blocks another order; the player may
|
||
// still cancel it from here.
|
||
const pending = ((this.snapshot.constructionSites || [])[this.localCiv] || []).some(
|
||
(site) => site.coords[0] === coords.x && site.coords[1] === coords.y
|
||
);
|
||
if (pending) return;
|
||
const shared = { coords, prices, gdp, speed, standing };
|
||
|
||
// Military tile works. A tile that already carries a work offers none of
|
||
// them, only the removal control on the one that stands.
|
||
const military = [];
|
||
for (const improvement of TILE_IMPROVEMENTS) {
|
||
if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
|
||
military.push(this._improvementCard(improvement, {
|
||
...shared,
|
||
className: "tile-btn",
|
||
built: !!(standing && standing.id === improvement.id),
|
||
disabled: !!standing,
|
||
onAction: () => this._buildTileImprovement(coords, improvement.id),
|
||
onRemove: () => this._removeTileImprovement(coords),
|
||
}));
|
||
}
|
||
|
||
// Transportation: roads and railways, bought outright and laid at once. A
|
||
// second one may replace the first, so only the one already down is greyed.
|
||
const transport = [];
|
||
for (const improvement of TRANSPORT_IMPROVEMENTS) {
|
||
const isCurrent = current === improvement.id;
|
||
transport.push(this._improvementCard(improvement, {
|
||
...shared,
|
||
className: "transport-btn",
|
||
built: isCurrent,
|
||
disabled: isCurrent,
|
||
free: testing,
|
||
onAction: () => this._buildImprovement(coords, improvement.id, false),
|
||
onFree: () => this._buildImprovement(coords, improvement.id, true),
|
||
onRemove: () => this._removeImprovement(coords),
|
||
}));
|
||
}
|
||
|
||
// Economic: resource producers. They live on their own land, take
|
||
// production time and feed the delivery graph; only one may stand on a
|
||
// tile. Some are locked behind a technology (the fusion plant).
|
||
const economic = [];
|
||
for (const producer of RESOURCE_BUILDINGS) {
|
||
if (producer.coastal && !this._tileIsCoastal(coords)) continue;
|
||
const techIndex = producer.requiresTechnology
|
||
? TECHNOLOGIES.findIndex((tech) => tech.id === producer.requiresTechnology)
|
||
: -1;
|
||
const locked = techIndex >= 0 && !this._researched.has(techIndex);
|
||
economic.push(this._improvementCard(producer, {
|
||
...shared,
|
||
className: "resource-btn",
|
||
built: !!(standing && standing.id === producer.id),
|
||
disabled: !!standing || locked,
|
||
locked,
|
||
lockedName: techIndex >= 0 ? TECHNOLOGIES[techIndex].name : "",
|
||
onAction: () => this._buildTileImprovement(coords, producer.id),
|
||
onRemove: () => this._removeTileImprovement(coords),
|
||
}));
|
||
}
|
||
|
||
for (const group of [
|
||
{ label: "Military", cards: military },
|
||
{ label: "Transportation", cards: transport },
|
||
{ label: "Economic", cards: economic },
|
||
]) {
|
||
if (group.cards.length === 0) continue;
|
||
const $group = $("<div class='improvement-group'></div>");
|
||
$group.append($("<div class='improvement-category'></div>").text(group.label));
|
||
const $cards = $("<div class='improvement-row'></div>");
|
||
for (const card of group.cards) $cards.append(card);
|
||
$group.append($cards);
|
||
$row.append($group);
|
||
}
|
||
},
|
||
|
||
// 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],
|
||
});
|
||
},
|
||
|
||
// The one control a tile under construction still offers, wherever its
|
||
// progress is drawn. Cancelling loses the money and materials already sunk
|
||
// into the work and raises nothing.
|
||
_cancelSiteButton(coords) {
|
||
return $("<button type='button' class='aero'></button>")
|
||
.text("Cancel work")
|
||
.on("click", () => this._cancelSite(coords));
|
||
},
|
||
|
||
_cancelSite(coords) {
|
||
this.sendOrder({
|
||
type: "cancel_site",
|
||
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 resources a tile produces in a day, each amount already carrying the
|
||
// tile's production modifiers, plus the factor list behind it. Food comes from
|
||
// the people on the tile; every other resource from the work standing there.
|
||
_tileResourceProduction(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 factors = this._productionFactors(coords, owner, city, tile, regionCity);
|
||
const multiplier = productionMultiplier(factors);
|
||
const amounts = { energy: 0, steel: 0, food: 0, luxury: 0, hightech: 0 };
|
||
const population = this._population.get(k) || 0;
|
||
let foodMultiplier = 1;
|
||
if (population > 0 && tile) {
|
||
foodMultiplier = regionCity ? this._regionFoodMultiplier(regionCity) : 1;
|
||
const factor = RESOURCE_RULES.tileFoodFactor[tile.terrainType] ?? 1;
|
||
amounts.food = foodNeedPerDay(population) * factor * foodMultiplier * multiplier;
|
||
}
|
||
const improvement = this.map.tileImprovements.get(k);
|
||
const proto = improvement ? tileImprovementById(improvement.id) : null;
|
||
if (proto && proto.resource && proto.outputPerDay) {
|
||
amounts[proto.resource] += resourceBuildingOutputAt(proto, improvement.level) * multiplier;
|
||
}
|
||
return { amounts, factors, foodMultiplier };
|
||
},
|
||
|
||
// The production modifiers of one tile: terrain, settlement, war and the
|
||
// region's and nation's own bonuses. Mirrors the server's `productionFactors`
|
||
// inputs so the tile panel and the simulation explain the same figure.
|
||
_productionFactors(coords, owner, city, tile, regionCity) {
|
||
const k = `${coords.x},${coords.y}`;
|
||
return productionFactors(tile, {
|
||
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
|
||
nextToCity: !city && this._isNextToCity(coords, owner),
|
||
pillagePenalty: this._tileProductionPenalties.has(k) ? this._tileProductionPenalties.get(k) : 1,
|
||
sabotaged: this._tileSabotage.has(k),
|
||
battleDeficit: this._tileBattleProductionDeficits.get(k) || 0,
|
||
trench: this.map.trenches.has(k),
|
||
regionalMultiplier: 1 + (regionCity ? this._cityProductionModifier(regionCity) : 0),
|
||
regionalLabel: regionCity ? regionCity.name : null,
|
||
nationalMultiplier: 1 + this._nationalProductionModifier(owner),
|
||
});
|
||
},
|
||
|
||
// The extra food a region's ports bring in, mirroring the server: 5% per
|
||
// adjacent sea tile per port level.
|
||
_regionFoodMultiplier(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_production") {
|
||
bonus += (proto.foodPerSeaTilePerLevel || 0) * level;
|
||
}
|
||
}
|
||
if (bonus === 0) return 1;
|
||
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 1 + bonus * sea;
|
||
},
|
||
|
||
// -------------------------------------------------------- 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 is the region's, so every tile of
|
||
// a region shares one colour; population is the 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 gdpPerCapita = new Map();
|
||
if (mode === "gdp") {
|
||
for (const city of this.snapshot.cities || []) {
|
||
const stats = this._cityStats(city.id);
|
||
const population = stats.population || 0;
|
||
gdpPerCapita.set(city.id, population > 0 ? (stats.gdp || 0) / population : 0);
|
||
}
|
||
}
|
||
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 {
|
||
value = gdpPerCapita.get(this._tileRegion.get(k)) || 0;
|
||
}
|
||
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 production bonus a single city's own buildings give.
|
||
_cityProductionModifier(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_PRODUCTION) total += effectValue(e, level);
|
||
}
|
||
}
|
||
return total;
|
||
},
|
||
|
||
// The production bonus a nation gets from its government and researched
|
||
// technology.
|
||
_nationalProductionModifier(civ) {
|
||
let total = 0;
|
||
const government = GOVERNMENTS[(this.snapshot.government || [])[civ]];
|
||
if (government) {
|
||
for (const e of government.effects) {
|
||
if (e.stat === EFFECT_PRODUCTION) 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_PRODUCTION) 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;
|
||
},
|
||
|
||
// The tile panel's headline: what the tile actually produces, one row per
|
||
// resource (nothing is shown for a resource the tile does not make), with the
|
||
// modifiers that shape it in a hover tooltip.
|
||
_renderTileProduction(production, owner = -1) {
|
||
const $container = $("#tile-production").empty();
|
||
const rows = [];
|
||
for (const resource of RESOURCES) {
|
||
const amount = production.amounts[resource.id] || 0;
|
||
if (!(amount > 0)) continue;
|
||
const notes = [];
|
||
for (const factor of production.factors) {
|
||
if (factor.multiplier !== undefined) {
|
||
if (factor.multiplier === 1) continue;
|
||
notes.push(`${factor.label}: ×${factor.multiplier.toFixed(2)}`);
|
||
} else if (factor.amount) {
|
||
const sign = factor.amount < 0 ? "−" : "+";
|
||
notes.push(`${factor.label}: ${sign}${moneyText(Math.abs(factor.amount), owner)}`);
|
||
}
|
||
}
|
||
if (resource.id === "food" && production.foodMultiplier !== 1) {
|
||
notes.push(`Port food: ×${production.foodMultiplier.toFixed(2)}`);
|
||
}
|
||
const $row = $("<div class='tile-prod-row'></div>");
|
||
$row.append(
|
||
$("<img class='resource-icon' width='16' height='16'>")
|
||
.attr("src", `assets/${resource.icon}`)
|
||
.attr("alt", resource.name),
|
||
$("<span class='tile-prod-name'></span>").text(resource.name),
|
||
$("<span class='tile-prod-value'></span>").text(
|
||
`${formatResourceAmount(resource.id, amount)}/day`
|
||
)
|
||
);
|
||
if (notes.length > 0) $row.attr("title", notes.join("\n"));
|
||
rows.push($row);
|
||
}
|
||
if (rows.length === 0) {
|
||
$container.append($("<div class='tile-prod-empty'></div>").text("No production"));
|
||
return;
|
||
}
|
||
for (const $row of rows) $container.append($row);
|
||
},
|
||
|
||
// The only place GDP per capita is shown: the region the tile belongs to. A
|
||
// tile never gets a per-tile figure of its own.
|
||
_renderRegionGdp(regionCity) {
|
||
const $container = $("#tile-gdp").empty();
|
||
if (!regionCity) {
|
||
$container.text("Region GDP/capita: —");
|
||
return;
|
||
}
|
||
const stats = this._cityStats(regionCity.id);
|
||
const population = stats.population || 0;
|
||
const perCapita = population > 0 ? (stats.gdp || 0) / population : 0;
|
||
const $head = $("<div class='tile-gdp-head'></div>").text("Region GDP/capita: ");
|
||
const info = regionCity.civ === this.localCiv ? null : currencyFor(regionCity.civ);
|
||
if (info) {
|
||
$head.append(
|
||
document.createTextNode(
|
||
`${info.symbol}${groupDigits(Math.round(perCapita * rateToNational(regionCity.civ)))}`
|
||
)
|
||
);
|
||
$head.append(
|
||
$("<span class='conversion'></span>").text(
|
||
` (${currencySymbol()}${groupDigits(Math.round(perCapita))})`
|
||
)
|
||
);
|
||
} else {
|
||
$head.append(
|
||
document.createTextNode(`${currencySymbol()}${groupDigits(Math.round(perCapita))}`)
|
||
);
|
||
}
|
||
$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);
|
||
if (!tileVisible) hideCardTooltip();
|
||
},
|
||
|
||
_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. A unit whose building or research is missing is
|
||
// still listed, marked `locked` with the requirement, so the player can see
|
||
// what a city could build and what it needs; only the unlocked ones take an
|
||
// order. Recomputed each snapshot so a building or technology finished while
|
||
// the modal is open flips its card 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);
|
||
if (index < 0) return null;
|
||
const proto = this.protoUnits[index];
|
||
const requirement = this._unitRequirement(proto, hasBuilding);
|
||
return {
|
||
...proto,
|
||
serverIndex: index,
|
||
locked: !!requirement,
|
||
requiresLabel: requirement,
|
||
};
|
||
}).filter(Boolean);
|
||
},
|
||
|
||
// The first unmet requirement on a trainable unit, as a short label ("Requires
|
||
// Airport"), or an empty string when nothing blocks it.
|
||
_unitRequirement(proto, hasBuilding) {
|
||
if (proto.requiresBuilding && !hasBuilding(proto.requiresBuilding)) {
|
||
const building = BUILDINGS.find((entry) => entry.id === proto.requiresBuilding);
|
||
return `Requires ${building ? building.name : proto.requiresBuilding}`;
|
||
}
|
||
const technologies = Array.isArray(proto.requiresTechnologies)
|
||
? proto.requiresTechnologies
|
||
: proto.requiresTechnology
|
||
? [proto.requiresTechnology]
|
||
: [];
|
||
const missing = technologies.find((id) => !this._hasTechnology(id));
|
||
if (missing) {
|
||
const tech = TECHNOLOGIES.find((entry) => entry.id === missing);
|
||
return `Requires ${tech ? tech.name : missing}`;
|
||
}
|
||
return "";
|
||
},
|
||
|
||
// Whether the nation the viewer controls has researched a technology, by id.
|
||
_hasTechnology(id) {
|
||
const index = TECHNOLOGIES.findIndex((tech) => tech.id === id);
|
||
return index >= 0 && this._researched.has(index);
|
||
},
|
||
|
||
_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.testing,
|
||
trainable: this._trainableUnits(city),
|
||
unitProtos: this.protoUnits,
|
||
buildings: BUILDINGS,
|
||
levels: this._cityBuildings.get(city.id) || {},
|
||
queue: this._trainingQueue(city.id),
|
||
civs: this.civilisations,
|
||
ethnicMakeup: cityStats.ethnicMakeup || [],
|
||
// The region's private-sector cash basket and the currencies it names.
|
||
cash: cityStats.cash || {},
|
||
cashValue: cityStats.cashValue || 0,
|
||
debt: cityStats.debt || 0,
|
||
currencies: stats.currencies || [],
|
||
approval: cityStats.approval,
|
||
// What the region's people went short of over the last day, and whether
|
||
// they are starving.
|
||
shortage: cityStats.shortage || {},
|
||
famine: !!cityStats.famine,
|
||
// The region's reserves, their value and its daily consumption.
|
||
resources: cityStats.resources || null,
|
||
// The trade taxes the region generated over the last day.
|
||
taxes: cityStats.taxes || null,
|
||
// Construction technology shortens the displayed build times.
|
||
constructionSpeed: stats.constructionSpeed || 0,
|
||
taxMultiplier: this._taxMultiplier(cityStats.approval),
|
||
// Today's market prices, so build costs are quoted at current rates.
|
||
prices: marketPricesFrom(this.snapshot),
|
||
// Which economy model the panel renders for: the simple model drops the
|
||
// materials phase and shows the region's spare power instead.
|
||
economyModel: this.economyModel,
|
||
};
|
||
},
|
||
|
||
_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 cityStats = this._cityStats(this.selectedCityId);
|
||
const queue = this._trainingQueue(this.selectedCityId);
|
||
this.cityModal.renderQueue(queue);
|
||
// The simple economy builds off the region's spare power, so the progress
|
||
// line names it; the hard economy has no such figure and ignores the field.
|
||
this.cityModal.energyAvailable = (cityStats.resources && cityStats.resources.energy) || 0;
|
||
this.cityModal.constructionSpeed = stats.constructionSpeed || 0;
|
||
this.cityModal.economyModel = this.economyModel;
|
||
this.cityModal.prices = marketPricesFrom(this.snapshot);
|
||
this._updateCityProgress(queue);
|
||
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,
|
||
cityStats.shortage || {},
|
||
!!cityStats.famine
|
||
);
|
||
this.cityModal.renderResources(cityStats.resources || null);
|
||
}
|
||
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));
|
||
this.cityModal.renderEconomy(
|
||
cityStats.cash || {},
|
||
cityStats.cashValue || 0,
|
||
stats.currencies || [],
|
||
cityStats.debt || 0
|
||
);
|
||
},
|
||
|
||
_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");
|
||
// An order -- building or unit -- spends its first days buying its steel and
|
||
// high-tech, so show that gathering rather than a clock that has not started.
|
||
if (entry.phase === "materials") {
|
||
this._trainingCityId = this.selectedCityId;
|
||
this.cityModal.setGatheringProgress(name, this._gatherFraction(entry));
|
||
return;
|
||
}
|
||
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 far an order has got through gathering its material bill, as the
|
||
// average of each storable material's share bought. 1 when nothing is needed.
|
||
_gatherFraction(entry) {
|
||
let sum = 0;
|
||
let count = 0;
|
||
for (const id of ["steel", "hightech"]) {
|
||
const need = (entry.needed && entry.needed[id]) || 0;
|
||
if (!(need > 0)) continue;
|
||
sum += Math.min(1, ((entry.bought && entry.bought[id]) || 0) / need);
|
||
count += 1;
|
||
}
|
||
return count > 0 ? sum / count : 1;
|
||
},
|
||
|
||
// 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(),
|
||
this._resourcesInfo(),
|
||
this._moneyInfo(),
|
||
this._intelligenceInfo()
|
||
);
|
||
},
|
||
|
||
// The money the Economy tab needs: the viewer's currency, everyone else's,
|
||
// the exchange rates, the central bank and the aggregate private-sector cash.
|
||
_moneyInfo() {
|
||
const stats = (this.snapshot && this.snapshot.viewerStats) || {};
|
||
return {
|
||
viewerCiv: this.localCiv,
|
||
currency: stats.currency || null,
|
||
currencies: stats.currencies || [],
|
||
exchangeRates: stats.exchangeRates || [],
|
||
centralBank: stats.centralBank || { rate: 0, reserves: [] },
|
||
privateCash: stats.privateCash || 0,
|
||
// Production buildings are private-sector agents of their own, held apart
|
||
// from the regions in the Economy tab.
|
||
privateBuildingCash: stats.privateBuildingCash || 0,
|
||
productionBuildings: stats.productionBuildings || [],
|
||
// Where the viewer's own currency sits: treasury, regions, foreign banks.
|
||
currencyHoldings: stats.currencyHoldings || null,
|
||
// The viewer's trade-tax rates and the day's take, for the Taxes sub-tab.
|
||
taxes: stats.taxes || null,
|
||
takeTax: stats.taxTake || { sales: 0, export: 0, import: 0 },
|
||
// The world commodity price move of the last day, for the central bank.
|
||
commodityInflation: stats.commodityInflation || 0,
|
||
// The daily world-price index series, each commodity's index and
|
||
// monthly/yearly inflation, for the central bank's chart. The clock dates
|
||
// the chart's rolling window.
|
||
resourcePriceHistory: (this.snapshot && this.snapshot.resourcePriceHistory) || [],
|
||
resourceInflation: (this.snapshot && this.snapshot.resourceInflation) || [],
|
||
totalHours: (this.snapshot && this.snapshot.totalHours) || 0,
|
||
cities: (this.snapshot && this.snapshot.cityStats) || [],
|
||
// The economy model the Economy tab renders for: the simple model keeps
|
||
// the euro and drops the taxes, central bank and FX sub-tabs.
|
||
economyModel: this.economyModel,
|
||
// The treasury, so the simple summary can show it beside the tax income.
|
||
budget: stats.budget || 0,
|
||
};
|
||
},
|
||
|
||
// Everything the Resources tab needs: the nation's own stores, needs and
|
||
// trade from the viewer stats, plus the world market's prices, the world's
|
||
// supply and demand, and every nation's import/export ledger. In the simple
|
||
// economy the same block feeds the market table, so it also carries the
|
||
// economy model, the treasury the buy controls draw on, and the pool figures.
|
||
_resourcesInfo() {
|
||
const stats = (this.snapshot && this.snapshot.viewerStats) || {};
|
||
return {
|
||
summary: stats.resources || null,
|
||
market: (this.snapshot && this.snapshot.resourceMarket) || [],
|
||
trade: (this.snapshot && this.snapshot.resourceTrade) || [],
|
||
civs: this.civilisations,
|
||
// The clock, so the table can reset its day-over-day arrow baseline.
|
||
totalHours: (this.snapshot && this.snapshot.totalHours) || 0,
|
||
economyModel: this.economyModel,
|
||
// The treasury a market purchase is paid from.
|
||
budget: stats.budget || 0,
|
||
};
|
||
},
|
||
|
||
// 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] || {};
|
||
// The buildings the viewer's cities hold, so a technology gated behind one
|
||
// (nuclear weapons behind the uranium enrichment centre) shows as locked.
|
||
const buildings = new Set();
|
||
for (const levels of this._cityBuildings.values()) {
|
||
for (const index of Object.keys(levels)) {
|
||
if ((levels[index] || 0) > 0) {
|
||
const proto = BUILDINGS[Number(index)];
|
||
if (proto) buildings.add(proto.id);
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
focus: focusIndex,
|
||
progress: focusIndex >= 0 ? byTech[focusIndex] || 0 : 0,
|
||
progressByTech: byTech,
|
||
repeatCounts: (snapshot.repeatCounts || [])[this.localCiv] || {},
|
||
buildings,
|
||
// In a local testing game the technology cards gain an instant-unlock
|
||
// button, like the free build and train buttons.
|
||
testing: !!this.testing,
|
||
};
|
||
},
|
||
|
||
_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(),
|
||
this._resourcesInfo(),
|
||
this._moneyInfo(),
|
||
this._intelligenceInfo()
|
||
);
|
||
},
|
||
|
||
_diplomacyInfo() {
|
||
const snapshot = this.snapshot || {};
|
||
const stats = snapshot.viewerStats || {};
|
||
return {
|
||
civs: this.civilisations,
|
||
localCiv: this.localCiv,
|
||
relations: (snapshot.diplomacy) || [],
|
||
stats: snapshot.civStats || [],
|
||
// Who is behind each nation: the network's player table, keyed by peer id,
|
||
// each entry naming the nation they claimed. A nation with no player is AI.
|
||
players: (this.network && this.network.players) || {},
|
||
// Treaties: offers awaiting an answer, the agreements in force and the
|
||
// ones that ended.
|
||
treaties: snapshot.treaties || [],
|
||
treatyProposals: snapshot.treatyProposals || [],
|
||
treatyHistory: snapshot.treatyHistory || [],
|
||
// What the viewer may offer: its own cities and non-infantry units, plus
|
||
// the stores it can hand over.
|
||
localCities: (snapshot.cities || []).filter((city) => city.civ === this.localCiv),
|
||
localUnits: (snapshot.units || []).filter((unit) => unit.civ === this.localCiv),
|
||
unitProtos: this.protoUnits,
|
||
resources: stats.resources || null,
|
||
};
|
||
},
|
||
|
||
// 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 || {},
|
||
};
|
||
},
|
||
|
||
// Everything the Intelligence tab needs from the snapshot: the viewer's spies
|
||
// and where they stand, the reports they have brought home, the satellites in
|
||
// orbit, the propaganda campaigns (moved off Politics) and the opinion
|
||
// matrices their readout quotes. A spy's covert operations are unit actions,
|
||
// wired in the unit panel rather than here.
|
||
_intelligenceInfo() {
|
||
const snapshot = this.snapshot || {};
|
||
const localCiv = this.localCiv;
|
||
const satellites = snapshot.satellites || [];
|
||
const own = satellites.filter((satellite) => satellite.civ === localCiv);
|
||
return {
|
||
civs: this.civilisations,
|
||
localCiv,
|
||
totalHours: snapshot.totalHours || 0,
|
||
campaigns: snapshot.campaigns || [],
|
||
// The nation's culture stock, which the simple economy spends on the
|
||
// campaigns, and the model that decides which currency they are paid in.
|
||
culture: snapshot.culture || [],
|
||
economyModel: this.economyModel,
|
||
opinions: snapshot.opinions || { ethnic: [], government: [] },
|
||
spies: (snapshot.units || [])
|
||
.filter((unit) => unit.civ === localCiv && this._isSpy(unit))
|
||
.map((unit) => ({
|
||
id: unit.id,
|
||
coords: unit.coords,
|
||
location: this._unitLocation(unit.coords, localCiv),
|
||
})),
|
||
reports: snapshot.spyReports || [],
|
||
satellites: own,
|
||
otherSatellites: satellites.filter((satellite) => satellite.civ !== localCiv).length,
|
||
spaceLaunchCapacity: this._spaceLaunchCapacity(),
|
||
launchCity: this._launchCity(),
|
||
};
|
||
},
|
||
|
||
// Total levels of the viewer's space launch centres, and the city it would
|
||
// launch from. Mirrors GameState._spaceLaunchCapacity. Only the viewer's own
|
||
// cities count, though `_cityBuildings` carries every nation's.
|
||
_spaceLaunchCapacity() {
|
||
let total = 0;
|
||
for (const city of this.snapshot.cities || []) {
|
||
if (city.civ !== this.localCiv) continue;
|
||
const levels = this._cityBuildings.get(city.id) || {};
|
||
for (const [index, level] of Object.entries(levels)) {
|
||
if ((level || 0) <= 0) continue;
|
||
const proto = BUILDINGS[Number(index)];
|
||
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) total += level;
|
||
}
|
||
}
|
||
return total;
|
||
},
|
||
|
||
_launchCity() {
|
||
for (const city of this.snapshot.cities || []) {
|
||
if (city.civ !== this.localCiv) continue;
|
||
const levels = this._cityBuildings.get(city.id) || {};
|
||
for (const [index, level] of Object.entries(levels)) {
|
||
if ((level || 0) <= 0) continue;
|
||
const proto = BUILDINGS[Number(index)];
|
||
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) return city;
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
_isSpy(unit) {
|
||
const proto = this.protoUnits[unit.proto];
|
||
return !!(proto && proto.spy);
|
||
},
|
||
|
||
// A short, player-facing description of where a unit stands: the city it is
|
||
// in, its own territory, a foreign nation, or unclaimed land.
|
||
_unitLocation(coords, localCiv) {
|
||
const city = (this.snapshot.cities || []).find(
|
||
(entry) => entry.coords[0] === coords[0] && entry.coords[1] === coords[1]
|
||
);
|
||
if (city) return `at ${city.name}`;
|
||
const k = `${coords[0]},${coords[1]}`;
|
||
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : null;
|
||
if (owner === null || owner === undefined) return "in unclaimed territory";
|
||
if (owner === localCiv) return "in home territory";
|
||
return `in ${this._civName(owner)}`;
|
||
},
|
||
|
||
// Brings the map to one of the viewer's units and selects it, for the
|
||
// Intelligence tab's spy roster.
|
||
_locateUnit(id) {
|
||
const unit = this._unit(id);
|
||
if (!unit || unit.civ !== this.localCiv) return;
|
||
this.map.centerOn({ x: unit.coords[0], y: unit.coords[1] });
|
||
this._selectUnits([unit.id]);
|
||
this.nationModal.close();
|
||
},
|
||
|
||
_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;
|
||
}
|
||
// An offer addressed to the player stays put and can be clicked to review it.
|
||
if (entry.type === NEWS_TREATY && entry.stage === TREATY_PROPOSED && entry.to === this.localCiv) {
|
||
const proto = treatyById(entry.treaty);
|
||
const label = proto ? proto.name : entry.treaty;
|
||
audio.play("notification");
|
||
const $line = this.feed.push(
|
||
`${this._civName(entry.from)} proposes ${label} — click to review`,
|
||
{ alert: true, ttl: -1 }
|
||
);
|
||
if ($line) {
|
||
$line.addClass("feed-clickable");
|
||
$line.on("click", () => this._openTreatyProposal(entry));
|
||
}
|
||
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_FAMINE) {
|
||
// A famine concerns everyone, so it raises the alert notice regardless of
|
||
// whose region it strikes.
|
||
audio.play("notification");
|
||
this.feed.push(describeNews(entry, this.civilisations), { alert: true });
|
||
return;
|
||
} else if (entry.type === NEWS_SABOTAGE && entry.civ === this.localCiv) {
|
||
// The victim of a sabotage is warned; the culprit may or may not be named.
|
||
audio.play("notification");
|
||
this.feed.push(describeNews(entry, this.civilisations), { alert: true });
|
||
return;
|
||
} else if (entry.type === NEWS_SATELLITE) {
|
||
// Every launch is public knowledge.
|
||
audio.play("notification");
|
||
this.feed.push(describeNews(entry, this.civilisations), { alert: true });
|
||
return;
|
||
} else if (entry.type === NEWS_ENRICHMENT) {
|
||
// A secret the world's intelligence services have caught.
|
||
audio.play("notification");
|
||
this.feed.push(describeNews(entry, this.civilisations), { alert: true });
|
||
return;
|
||
} else if (entry.type === NEWS_JOIN && entry.civ !== this.localCiv) {
|
||
audio.play("notification");
|
||
}
|
||
this.feed.push(describeNews(entry, this.civilisations));
|
||
},
|
||
|
||
// Opens the terms of an incoming treaty offer with Sign and Reject buttons.
|
||
// The proposal is looked up by the parties it names, so a click on the feed
|
||
// line reaches the live record the server shipped.
|
||
_openTreatyProposal(entry) {
|
||
const proposals = (this.snapshot && this.snapshot.treatyProposals) || [];
|
||
const sameTerms = (p) =>
|
||
p.from === entry.from && p.to === this.localCiv && p.type === entry.treaty;
|
||
const proposal = proposals.find(
|
||
(p) => sameTerms(p) && (entry.totalHours === undefined || p.proposedHours === entry.totalHours)
|
||
) || proposals.find(sameTerms);
|
||
if (!proposal) return;
|
||
const proto = treatyById(proposal.type);
|
||
const label = proto ? proto.name : proposal.type;
|
||
const terms = this._treatyTermsText(proposal);
|
||
const message = `${proto ? proto.description : ""}${terms ? ` - ${terms}` : ""}`;
|
||
this.confirmModal.show(
|
||
`${this._civName(proposal.from)} proposes ${label}`,
|
||
message,
|
||
() => this.sendOrder({ type: "respond_treaty", proposal: proposal.id, accept: true }),
|
||
() => this.sendOrder({ type: "respond_treaty", proposal: proposal.id, accept: false }),
|
||
"Sign",
|
||
"Reject"
|
||
);
|
||
},
|
||
|
||
// A plain-text summary of what a treaty hands over, for the offer dialog.
|
||
_treatyTermsText(treaty) {
|
||
const payload = treaty.payload || {};
|
||
if (payload.city !== undefined) {
|
||
const city = (this.snapshot && this.snapshot.cities || []).find((c) => c.id === payload.city);
|
||
return `gives ${city ? city.name : "a city"}`;
|
||
}
|
||
if (payload.units) return `gives ${payload.units.length} unit(s)`;
|
||
if (payload.resources) {
|
||
const parts = Object.entries(payload.resources).map(([id, amount]) => {
|
||
const proto = resourceById(id);
|
||
return `${compact(amount)} ${proto ? proto.name : id}`;
|
||
});
|
||
return `gives ${parts.join(", ")}`;
|
||
}
|
||
if (payload.amount !== undefined) return `gives ${compact(payload.amount)} money`;
|
||
return "";
|
||
},
|
||
|
||
// Pops the viewer's own daily resource-shortage notices into the corner feed.
|
||
// Each building, region and resource is announced once per game day.
|
||
_updateResourceAlerts(stats) {
|
||
const alerts = (stats && stats.resourceAlerts) || [];
|
||
if (this._seenAlerts === null) {
|
||
this._seenAlerts = new Set(alerts.map((alert) => this._alertSignature(alert)));
|
||
return;
|
||
}
|
||
for (const alert of alerts) {
|
||
const signature = this._alertSignature(alert);
|
||
if (this._seenAlerts.has(signature)) continue;
|
||
this._seenAlerts.add(signature);
|
||
const proto = resourceById(alert.resource);
|
||
const name = proto ? proto.name : alert.resource;
|
||
this.feed.push(
|
||
`${alert.building} in ${alert.region} is missing ${name}`, { alert: true }
|
||
);
|
||
}
|
||
},
|
||
|
||
_alertSignature(alert) {
|
||
return `${alert.day || 0}|${alert.building}|${alert.region}|${alert.resource}`;
|
||
},
|
||
|
||
_finances(stats) {
|
||
if (!stats) return null;
|
||
// The month reports page back through the stored budget digests; the region
|
||
// figures are global, so the Cities tab can show every nation's, and the
|
||
// panel filters them to the viewer.
|
||
return {
|
||
months: stats.budgetMonths || [],
|
||
cities: (this.snapshot && this.snapshot.cityStats) || [],
|
||
};
|
||
},
|
||
};
|