2856 lines
114 KiB
JavaScript
2856 lines
114 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,
|
||
effectValue,
|
||
pillageSteps,
|
||
ROADS,
|
||
TRANSPORT_IMPROVEMENTS,
|
||
transportImprovement,
|
||
TILE_IMPROVEMENTS,
|
||
RESOURCE_BUILDINGS,
|
||
resourceById,
|
||
RESOURCES,
|
||
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 } 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 } 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 });
|
||
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());
|
||
|
||
// A click anywhere outside the stack menu dismisses it.
|
||
$(document).on("mousedown.stackmenu", (event) => {
|
||
if (!this.$stackMenu.hasClass("hidden") && !$(event.target).closest("#stack-menu").length) {
|
||
this._hideStackMenu();
|
||
}
|
||
});
|
||
|
||
$(window).on("keydown.game", (event) => this._onKey(event));
|
||
},
|
||
|
||
// A small hover tooltip anchored above a stat, fed by its data-tooltip text.
|
||
_showStatTooltip($stat) {
|
||
const text = $stat.attr("data-tooltip");
|
||
if (!text) return;
|
||
this._hoveredStat = $stat;
|
||
this.$statTooltip.text(text).removeClass("hidden");
|
||
const offset = $stat.offset() || { left: 0, top: 0 };
|
||
const width = this.$statTooltip.outerWidth() || 0;
|
||
const height = this.$statTooltip.outerHeight() || 0;
|
||
this.$statTooltip.css({
|
||
left: `${Math.max(8, offset.left + $stat.outerWidth() / 2 - width / 2)}px`,
|
||
top: `${Math.max(8, offset.top - height - 8)}px`,
|
||
});
|
||
},
|
||
|
||
_hideStatTooltip() {
|
||
this._hoveredStat = null;
|
||
this.$statTooltip.addClass("hidden");
|
||
},
|
||
|
||
// ------------------------------------------------------- selection ------
|
||
|
||
// Selects the given unit ids (already filtered to our own), refreshing the
|
||
// map highlight and the bottom panel. A single id is a normal selection; an
|
||
// empty list clears; several ids form a stack order.
|
||
_selectUnits(ids) {
|
||
const units = [];
|
||
for (const id of ids) {
|
||
const unit = this._unit(id);
|
||
if (unit && unit.civ === this.localCiv) units.push(unit);
|
||
}
|
||
// 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
|
||
? "Cripple this tile's production for a week (spends the spies)"
|
||
: "Move the spy onto foreign land";
|
||
} else if (operation.id === SPY_CITY) {
|
||
enabled = this._spyActionTarget(SPY_CITY, units);
|
||
hint = enabled
|
||
? "Read a neighbouring foreign city (spends the spies)"
|
||
: "Move the spy beside a foreign city";
|
||
} else if (operation.id === SPY_COMMS) {
|
||
enabled = this._spyActionTarget(SPY_COMMS, units);
|
||
hint = enabled
|
||
? "Intercept a neighbouring capital's messages (spends the spies)"
|
||
: "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 -----
|
||
|
||
_updateTopPanel(state) {
|
||
const civ = this.civilisations[this.localCiv];
|
||
if (civ) {
|
||
$("#civ-name").text(civ.name);
|
||
const flag = civ.flag || (CIVILISATIONS.find((c) => c.id === civ.id) || {}).flag;
|
||
if (flag) $("#civ-flag-img").attr("src", `assets/flags/${flag}`);
|
||
}
|
||
$("#player-name").text(this.config.playerName || (this.network.players[this.network.localPlayerId] || {}).name || "");
|
||
$("#admin-label").toggle(this.network.isAdmin);
|
||
$("#stat-date").text(dateString(state.totalHours));
|
||
const stats = state.viewerStats || {};
|
||
// The nation's figures one calendar month ago, when there is one, so each
|
||
// stat can show whether it rose or fell since.
|
||
const month = ((state.monthly || [])[this.localCiv]) || null;
|
||
$("#stat-pop").text(compact(stats.population || 0));
|
||
$("#stat-gdp").text(`${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 one point an hour back to neutral, so the estimate is
|
||
// simply how many points away they are; zero is already there.
|
||
const pointsAway = Math.abs(popularityPoints);
|
||
this._setStatTooltip(
|
||
"stat-approval",
|
||
pointsAway <= 0
|
||
? "Popularity is exactly neutral"
|
||
: `Neutrality in about ${pointsAway} h (~${Math.ceil(pointsAway / 24)} d) ` +
|
||
"at the current drift of one point an hour"
|
||
);
|
||
// Popularity doubles its arrow past ten percentage points.
|
||
this._updateStatArrow(
|
||
"stat-approval",
|
||
popularity,
|
||
month ? popularityDisplay(month.popularity || 0) : null,
|
||
0.1,
|
||
0.005
|
||
);
|
||
// Keep an open stat tooltip in step with the figures it describes.
|
||
if (this._hoveredStat && !this.$statTooltip.hasClass("hidden")) {
|
||
const text = this._hoveredStat.attr("data-tooltip");
|
||
if (text) this.$statTooltip.text(text);
|
||
}
|
||
},
|
||
|
||
// Appends the month-on-month change marker inside a stat's value: a bold
|
||
// green up arrow, a red down arrow, doubled once a big move occurred, or an
|
||
// equals sign when nothing changed. No baseline (January 2000) means none.
|
||
_updateStatArrow(statId, current, previous, doubleAt = null, equalEpsilon = 0.5) {
|
||
const $value = $(`#${statId}`);
|
||
if (!$value.length) return;
|
||
$value.find(".stat-arrow").remove();
|
||
if (previous === undefined || previous === null || current === undefined || current === null) {
|
||
return;
|
||
}
|
||
const diff = current - previous;
|
||
const $arrow = $("<span class='stat-arrow'></span>");
|
||
if (Math.abs(diff) <= equalEpsilon) {
|
||
$arrow.text("=").addClass("equal");
|
||
} else {
|
||
const rising = diff > 0;
|
||
const double = doubleAt !== null && previous !== 0 &&
|
||
Math.abs(diff) / Math.abs(previous) > doubleAt;
|
||
$arrow
|
||
.text(rising ? (double ? "▲▲" : "▲") : (double ? "▼▼" : "▼"))
|
||
.addClass(rising ? "up" : "down");
|
||
}
|
||
$value.append($arrow);
|
||
},
|
||
|
||
// Shows or clears a summary stat's hover tooltip. Passing no text removes it,
|
||
// so a stale bubble never lingers. Any older native title is dropped too.
|
||
_setStatTooltip(statId, text) {
|
||
const $stat = $(`#${statId}`).closest(".stat");
|
||
if (!$stat.length) return;
|
||
$stat.removeAttr("title");
|
||
if (text) $stat.attr("data-tooltip", text);
|
||
else $stat.removeAttr("data-tooltip");
|
||
},
|
||
|
||
// Puts the long-run growth figure in each headline stat's hover tooltip: the
|
||
// rise since January 2000 through the first year, and the year-on-year rise
|
||
// afterwards. The hovered datum names itself, so the text stays short.
|
||
_updateGrowthTooltips(growth) {
|
||
const scope = growth && growth.sinceStart ? "since 1 January 2000" : "year on year";
|
||
const apply = (statId, rate) => {
|
||
if (!growth || !Number.isFinite(rate)) {
|
||
this._setStatTooltip(statId, null);
|
||
return;
|
||
}
|
||
const percent = `${rate < 0 ? "−" : "+"}${(Math.abs(rate) * 100).toFixed(2)}%`;
|
||
this._setStatTooltip(statId, `${scope}: ${percent}`);
|
||
};
|
||
apply("stat-pop", growth && growth.population);
|
||
apply("stat-gdp", growth && growth.gdp);
|
||
apply("stat-gdppc", growth && growth.gdpPerCapita);
|
||
},
|
||
|
||
// Forecasts the budget a month ahead at the current income and upkeep: the
|
||
// hourly net spread over the hours of the next calendar month.
|
||
_updateBudgetTooltip(stats, totalHours) {
|
||
const net = stats.breakdown ? stats.breakdown.net : null;
|
||
if (net === null || !Number.isFinite(net)) {
|
||
this._setStatTooltip("stat-budget", null);
|
||
return;
|
||
}
|
||
const expected = net * hoursInNextMonth(totalHours || 0);
|
||
const sign = expected < 0 ? "−" : "+";
|
||
this._setStatTooltip("stat-budget", `${sign}${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")) {
|
||
const text = this._hoveredStat.attr("data-tooltip");
|
||
if (text) this.$statTooltip.text(text);
|
||
} else {
|
||
this._hideStatTooltip();
|
||
}
|
||
},
|
||
|
||
// A transport shows its berths and who is aboard, and any boarding still
|
||
// under way; non-transports show nothing.
|
||
_updateCargo(units) {
|
||
const $cargo = $("#unit-cargo");
|
||
if (!$cargo.length) return;
|
||
const lines = [];
|
||
for (const unit of units) {
|
||
const proto = this.protoUnits[unit.proto];
|
||
if (!proto || !proto.transportCapacity) continue;
|
||
const cargo = unit.cargo || [];
|
||
const names = cargo
|
||
.map((passenger) => {
|
||
const passengerProto = this.protoUnits[passenger.proto];
|
||
return passengerProto ? passengerProto.name : "troops";
|
||
})
|
||
.join(", ");
|
||
let text = `Cargo ${cargo.length}/${proto.transportCapacity}`;
|
||
if (names) text += ` — ${names}`;
|
||
if (unit.embarking && unit.embarking.count) {
|
||
text += ` · boarding ${unit.embarking.count} (${Math.ceil(unit.embarking.hoursLeft)} h)`;
|
||
}
|
||
lines.push(text);
|
||
}
|
||
if (lines.length === 0) {
|
||
$cargo.addClass("hidden").text("");
|
||
return;
|
||
}
|
||
$cargo.removeClass("hidden").text(lines.join(" · "));
|
||
},
|
||
|
||
// A city panel shows the tile it stands on (owner, terrain, population, GDP
|
||
// per capita, improvements) plus the city's name and its actions; the tile
|
||
// fields are the same ones the plain tile panel renders, so nothing is
|
||
// duplicated between the two.
|
||
// `keepUnits` leaves the unit panel open, so clicking a unit that stands on a
|
||
// city shows the city's tile detail without dropping the unit selection.
|
||
_showCity(city, { keepUnits = false } = {}) {
|
||
if (!city) {
|
||
$("#city-info").addClass("hidden");
|
||
$("#tile-info").addClass("hidden");
|
||
this._refreshPanelVisibility();
|
||
return;
|
||
}
|
||
this._inspectedTile = null;
|
||
this._renderTileInfo({ x: city.coords[0], y: city.coords[1] });
|
||
$("#city-name").text(city.name);
|
||
$("#city-manage").toggleClass("hidden", city.civ !== this.localCiv);
|
||
if (!keepUnits) $("#unit-info").addClass("hidden");
|
||
$("#tile-info, #city-info").removeClass("hidden");
|
||
this._refreshPanelVisibility();
|
||
this._updateTileHighlight();
|
||
},
|
||
|
||
// Selects one of the player's cities and shows its quick panel. Returns false
|
||
// when the city is unknown or someone else's. Double-clicking the city icon
|
||
// uses the same selection before opening the full city screen.
|
||
_selectCity(id) {
|
||
const city = (this.snapshot.cities || []).find((c) => c.id === id);
|
||
if (!city || city.civ !== this.localCiv) return false;
|
||
this.selectedCityId = id;
|
||
this.selectedUnitIds = [];
|
||
this.map.setSelectedUnits([]);
|
||
this._hideStackMenu();
|
||
this._showCity(city);
|
||
return true;
|
||
},
|
||
|
||
// Clicking a unit opens the panel for the tile it stands on (or the city it
|
||
// occupies), alongside the unit panel rather than replacing it.
|
||
_inspectUnitTile(unit) {
|
||
const coords = { x: unit.coords[0], y: unit.coords[1] };
|
||
const city = (this.snapshot.cities || []).find(
|
||
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
|
||
);
|
||
if (city) {
|
||
// 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 rises 1% a day, doubling each day, until the materials can be 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 (building && 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 materials = tileImprovementResourceCost(improvement);
|
||
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",
|
||
money: opts.disabled ? null : quotedBuildCost(improvement.buildCost, materials, opts.prices),
|
||
hours: hours(buildHours),
|
||
disabled: !!opts.disabled,
|
||
},
|
||
remove: {
|
||
label: "✕",
|
||
title: `Remove ${improvement.name}`,
|
||
enabled: built,
|
||
},
|
||
tip: () => this._improvementTip(improvement),
|
||
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.
|
||
_improvementTip(improvement) {
|
||
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 });
|
||
}
|
||
return tooltipSections({
|
||
produce,
|
||
produceSuffix: improvement.resource ? "/day" : "",
|
||
consume: this._improvementUpkeepItems(improvement),
|
||
consumeSuffix: "/day",
|
||
cost: this._resourceItems(tileImprovementResourceCost(improvement)),
|
||
});
|
||
},
|
||
|
||
// 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),
|
||
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),
|
||
};
|
||
},
|
||
|
||
_trainingQueue(cityId) {
|
||
return (this.snapshot.training || {})[cityId] || [];
|
||
},
|
||
|
||
_updateCityModal() {
|
||
if (!this.snapshot || !this.selectedCityId) return;
|
||
if (!this.cityModal.isShowing(this.selectedCityId)) {
|
||
this._trainingCityId = 0;
|
||
return;
|
||
}
|
||
const stats = this.snapshot.viewerStats || {};
|
||
const queue = this._trainingQueue(this.selectedCityId);
|
||
this.cityModal.renderQueue(queue);
|
||
this._updateCityProgress(queue);
|
||
const cityStats = this._cityStats(this.selectedCityId);
|
||
this.cityModal.constructionSpeed = stats.constructionSpeed || 0;
|
||
this.cityModal.prices = marketPricesFrom(this.snapshot);
|
||
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");
|
||
// A building order spends its first days buying its steel and high-tech, so
|
||
// show that gathering rather than a construction clock that has not started.
|
||
if (building && 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 a building 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) || [],
|
||
};
|
||
},
|
||
|
||
// 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.
|
||
_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,
|
||
};
|
||
},
|
||
|
||
// 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 || [],
|
||
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) || [],
|
||
};
|
||
},
|
||
};
|