The city buildings tab and the HUD's tile panel now draw the same card: an icon, name and status, a bottom-anchored button carrying the estimated price and build time, a quiet remove control and a hatched locked state, with the produce/consume/cost detail in a hover tooltip. The shared card lives in client/js/ui/card.js. The tile dock is now a summary — description, production, people and their ethnic makeup, GDP and the improvements list — with a Management button that opens a full-size panel like the city and nation panels. Its Improvements tab holds the grouped Military, Transportation and Economic cards, so Road and Railway sit with the other works. City buildings sit in a two-row horizontal scroller with the built ones first, and the city panel now matches the nation panel's size. AGENTS.md now asks for a minimal visual preview to be shown before a player-visible change is committed.
665 lines
27 KiB
JavaScript
665 lines
27 KiB
JavaScript
// The city panel: train units, raise and demolish buildings and read the
|
||
// region's budget, all in one tabbed modal. Units and buildings share the
|
||
// city's production queue, so the train tab shows both kinds of order.
|
||
|
||
import { groupDigits, hours } from "../../../shared/text_format.js";
|
||
import { ECONOMY, BUILDINGS, resourceById, RESOURCE_IDS } from "../../../shared/data.js";
|
||
import { constructionResourceCost, formatResourceAmount, protoUpkeep } from "../../../shared/resources.js";
|
||
import {
|
||
buildingBuildCost,
|
||
} from "../../../shared/rules.js";
|
||
import { buildingEffectText, signedMoney, quotedBuildCost } from "./format.js";
|
||
import { renderBudget } from "./budget.js";
|
||
import { bindTabs } from "./tabs.js";
|
||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||
import {
|
||
buildCard,
|
||
setCardMeta,
|
||
tooltipSections,
|
||
CARD_RESOURCES,
|
||
hideCardTooltip,
|
||
} from "../ui/card.js";
|
||
import { currencySymbol, formatMoney } from "../currency.js";
|
||
|
||
export class CityModal {
|
||
constructor() {
|
||
this.$modal = $("#modal-city");
|
||
this.$title = $("#city-title");
|
||
|
||
this.$trainList = $("#city-train-list");
|
||
this.$trainStatus = $("#city-train-status");
|
||
this.$trainLabel = $("#city-train-label");
|
||
this.$trainProgress = $("#city-train-progress");
|
||
this.$trainQueue = $("#city-train-queue");
|
||
|
||
this.$buildingsBudget = $("#city-buildings-budget");
|
||
this.$buildingsList = $("#city-buildings-list");
|
||
|
||
this.$peopleSub = $("#city-people-sub");
|
||
this.$peopleList = $("#city-people-list");
|
||
|
||
this.$budgetSub = $("#city-budget-sub");
|
||
this.$budgetList = $("#city-budget-list");
|
||
|
||
this.$resourcesSub = $("#city-resources-sub");
|
||
this.$resourcesList = $("#city-resources-list");
|
||
|
||
this.$economySub = $("#city-economy-sub");
|
||
this.$economyList = $("#city-economy-list");
|
||
|
||
this.cityId = 0;
|
||
this.gdp = 0;
|
||
this.coastal = false;
|
||
// Construction-technology bonus, which shortens every displayed duration.
|
||
this.constructionSpeed = 0;
|
||
// Local test game: each order also offers a free, instant button.
|
||
this.testing = false;
|
||
// Today's market prices, so build costs are quoted at current rates.
|
||
this.prices = {};
|
||
this._levels = {};
|
||
this.protoNames = new Map();
|
||
// Signature of the trainable set currently drawn, so a snapshot that did
|
||
// not change it leaves the list (and its scroll) alone.
|
||
this._trainableSignature = null;
|
||
this.buildingNames = new Map(BUILDINGS.map((b, index) => [index, b.name]));
|
||
this.buildingRows = [];
|
||
// The grid the building cards live in, and the built-first order last drawn
|
||
// so a snapshot that changes nothing does not reshuffle the cards.
|
||
this.$buildingGrid = null;
|
||
this._buildingOrderSignature = null;
|
||
// Building types whose upkeep breakdown is expanded on the budget tab.
|
||
this.expandedBudgetGroups = new Set();
|
||
// Resources whose per-building demand breakdown is expanded on the
|
||
// Resources tab.
|
||
this.expandedResources = new Set();
|
||
this._lastBreakdown = {};
|
||
|
||
this.onTrain = () => {};
|
||
this.onCancel = () => {};
|
||
this.onBuild = () => {};
|
||
this.onDemolish = () => {};
|
||
this.onFreeTrain = () => {};
|
||
this.onFreeBuild = () => {};
|
||
|
||
this.$modal.find(".modal-close").on("click", () => this.close());
|
||
bindTabs(this.$modal, "city-tab-");
|
||
}
|
||
|
||
isShowing(cityId) {
|
||
return !this.$modal.hasClass("hidden") && this.cityId === cityId;
|
||
}
|
||
|
||
// Opens the panel for `city` and lays out every tab in one pass. `view`
|
||
// carries { budget, gdp, trainable, buildings, levels, queue }.
|
||
show(city, view = {}) {
|
||
hideCardTooltip();
|
||
this.cityId = city.id;
|
||
this.gdp = view.gdp || 0;
|
||
this.coastal = !!view.coastal;
|
||
this.constructionSpeed = view.constructionSpeed || 0;
|
||
this.testing = !!view.testing;
|
||
this.prices = view.prices || {};
|
||
this.$title.text(`${city.name} — City`);
|
||
this._renderTrainList(this.gdp, view.trainable || []);
|
||
this.renderQueue(view.queue || []);
|
||
this.$trainStatus.addClass("hidden");
|
||
this.$buildingsList.empty();
|
||
this.$buildingGrid = $("<div class='building-grid'></div>");
|
||
this.$buildingsList.append(
|
||
$("<div class='building-scroller'></div>").append(this.$buildingGrid)
|
||
);
|
||
this._buildingOrderSignature = null;
|
||
this.buildingRows = (view.buildings || []).map((proto, index) =>
|
||
this._makeBuildingCard(proto, index)
|
||
);
|
||
for (const row of this.buildingRows) this.$buildingGrid.append(row.card.$card);
|
||
this.refreshBuildings(view.budget || 0, view.levels || {}, this.gdp, {});
|
||
this.$budgetSub.text(`Region net +${currencySymbol()}0/h`);
|
||
this.renderBudget(null);
|
||
this.renderPeople(
|
||
view.ethnicMakeup || [],
|
||
view.approval,
|
||
view.taxMultiplier,
|
||
view.civs || [],
|
||
view.shortage || {},
|
||
!!view.famine
|
||
);
|
||
this.renderResources(view.resources);
|
||
this.renderEconomy(view.cash || {}, view.cashValue || 0, view.currencies || [], view.debt || 0);
|
||
this.$modal.removeClass("hidden");
|
||
}
|
||
|
||
// The region's people: their ethnic makeup and their government approval,
|
||
// which decides how much tax they pay and how fast they build. `shortage`
|
||
// reports the resources they went short of over the last day and by how much;
|
||
// `famine` says whether they are starving.
|
||
renderPeople(makeup, approval, taxMultiplier, civs, shortage = {}, famine = false) {
|
||
if (!this.$peopleList || !this.$peopleList.length) return;
|
||
this.$peopleList.empty();
|
||
const value = approval === undefined || approval === null ? 0.5 : approval;
|
||
if (this.$peopleSub && this.$peopleSub.length) {
|
||
this.$peopleSub.text(`Government approval ${Math.round(value * 100)}%`);
|
||
}
|
||
if (famine) {
|
||
this.$peopleList.append(
|
||
$("<div class='desc famine-warning'></div>").text(
|
||
"Famine! The region has run out of food and its people are dying."
|
||
)
|
||
);
|
||
}
|
||
if (makeup && makeup.length > 0) {
|
||
this.$peopleList.append(ethnicityChart(makeup, civs || []));
|
||
} else {
|
||
this.$peopleList.append($("<div class='desc'></div>").text("No population data."));
|
||
}
|
||
this._renderShortages(shortage || {});
|
||
if (taxMultiplier !== undefined && taxMultiplier < 0.999) {
|
||
this.$peopleList.append(
|
||
$("<div class='desc politics-penalty'></div>").text(
|
||
`Discontent: only ${Math.round(taxMultiplier * 100)}% of taxes are collected and ` +
|
||
"construction is slowed until approval recovers."
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
// What the region's people went without over the last day: each resource they
|
||
// could not buy enough of, the amount it left them short, and the share of
|
||
// the day's need that was.
|
||
_renderShortages(shortage) {
|
||
const entries = Object.entries(shortage);
|
||
this.$peopleList.append($("<div class='subhead'></div>").text("Resource shortages"));
|
||
if (entries.length === 0) {
|
||
this.$peopleList.append(
|
||
$("<div class='desc'></div>").text(
|
||
"No shortages: the region met all of its people's and industry's needs."
|
||
)
|
||
);
|
||
return;
|
||
}
|
||
const $table = $("<table class='resources-table economy-table'></table>");
|
||
const $head = $("<thead></thead>").append(
|
||
$("<tr></tr>")
|
||
.append($("<th></th>").text("Resource"))
|
||
.append($("<th class='numeric'></th>").text("Short by"))
|
||
.append($("<th class='numeric'></th>").text("Share of need"))
|
||
);
|
||
const $body = $("<tbody></tbody>");
|
||
for (const [id, entry] of entries) {
|
||
const proto = resourceById(id);
|
||
const name = proto ? proto.name : id;
|
||
const amount = typeof entry === "number" ? entry : entry.amount;
|
||
const fraction = typeof entry === "number" ? 0 : entry.fraction;
|
||
$body.append(
|
||
$("<tr></tr>")
|
||
.append($("<td></td>").text(name))
|
||
.append(
|
||
$("<td class='numeric'></td>").text(formatResourceAmount(id, amount || 0))
|
||
)
|
||
.append(
|
||
$("<td class='numeric'></td>").text(`${Math.round((fraction || 0) * 100)}%`)
|
||
)
|
||
);
|
||
}
|
||
$table.append($head, $body);
|
||
this.$peopleList.append($table);
|
||
this.$peopleList.append(
|
||
$("<div class='desc'></div>").text(
|
||
"Shortages cut approval and slow the region's economy."
|
||
)
|
||
);
|
||
}
|
||
|
||
// The region's material reserves: how much of each resource it holds, what
|
||
// that reserve is worth at today's market, and how much its people and its
|
||
// buildings draw each day. Every resource expands into the buildings that
|
||
// demanded it and where in the region they stand. Energy is a flow, so it has
|
||
// no reserve of its own.
|
||
renderResources(resources) {
|
||
if (!this.$resourcesList || !this.$resourcesList.length) return;
|
||
this.$resourcesList.empty();
|
||
const info = resources || {};
|
||
const stock = info.stock || {};
|
||
const consumption = info.consumption || {};
|
||
const demand = info.demand || {};
|
||
const value = info.value || 0;
|
||
if (this.$resourcesSub && this.$resourcesSub.length) {
|
||
this.$resourcesSub.text(`Reserves worth ${formatMoney(value)}`);
|
||
}
|
||
this.$resourcesList.append(
|
||
$("<div class='desc'></div>").text(
|
||
"The region's private sector holds these reserves and draws them down as " +
|
||
"its people and buildings consume them. Expand a resource to see which " +
|
||
"buildings demand it. Energy is a flow -- it has no reserve."
|
||
)
|
||
);
|
||
const $table = $("<table class='resources-table economy-table'></table>");
|
||
const $head = $("<thead></thead>").append(
|
||
$("<tr></tr>")
|
||
.append($("<th></th>").text("Resource"))
|
||
.append($("<th class='numeric'></th>").text("Reserve"))
|
||
.append($("<th class='numeric'></th>").text("Consumption/day"))
|
||
.append($("<th class='numeric'></th>").text("Value"))
|
||
);
|
||
const $body = $("<tbody></tbody>");
|
||
for (const id of RESOURCE_IDS) {
|
||
const proto = resourceById(id);
|
||
const name = proto ? proto.name : id;
|
||
const storable = proto ? proto.storable : false;
|
||
const held = stock[id] || 0;
|
||
const price = this.prices[id] || 0;
|
||
const consumers = (demand[id] || []).slice().sort((a, b) => b.amount - a.amount);
|
||
const open = this.expandedResources.has(id);
|
||
const row = $("<tr></tr>");
|
||
if (consumers.length > 0) {
|
||
const $name = $("<td class='clickable'></td>");
|
||
$name.append($("<span class='resource-caret'></span>").text(open ? "▾" : "▸"));
|
||
$name.append(document.createTextNode(` ${name}`));
|
||
$name.on("click", () => {
|
||
if (this.expandedResources.has(id)) this.expandedResources.delete(id);
|
||
else this.expandedResources.add(id);
|
||
this.renderResources(resources);
|
||
});
|
||
row.append($name);
|
||
} else {
|
||
row.append($("<td></td>").text(name));
|
||
}
|
||
row.append(
|
||
$("<td class='numeric'></td>").text(storable ? formatResourceAmount(id, held) : "—")
|
||
);
|
||
row.append(
|
||
$("<td class='numeric'></td>").text(formatResourceAmount(id, consumption[id] || 0))
|
||
);
|
||
row.append(
|
||
$("<td class='numeric'></td>").text(storable ? formatMoney(held * price) : "—")
|
||
);
|
||
$body.append(row);
|
||
if (open) {
|
||
const $detail = $("<tr class='resource-demand'></tr>");
|
||
const $cell = $("<td colspan='4'></td>");
|
||
for (const consumer of consumers) {
|
||
const where = consumer.coords
|
||
? ` (${consumer.coords[0]}, ${consumer.coords[1]})`
|
||
: "";
|
||
$cell.append(
|
||
$("<div class='demand-line'></div>").text(
|
||
`${consumer.name}${where} · ${formatResourceAmount(id, consumer.amount)}/day`
|
||
)
|
||
);
|
||
}
|
||
$detail.append($cell);
|
||
$body.append($detail);
|
||
}
|
||
}
|
||
$table.append($head, $body);
|
||
this.$resourcesList.append($table);
|
||
}
|
||
|
||
// The region's private-sector cash: a basket of currencies it holds. Regions
|
||
// are the game's private economic agents, so this is their own money, not the
|
||
// government treasury.
|
||
renderEconomy(cash, cashValue, currencies = [], debt = 0) {
|
||
if (!this.$economyList || !this.$economyList.length) return;
|
||
this.$economyList.empty();
|
||
const byCode = new Map(currencies.map((entry) => [entry.code, entry]));
|
||
if (this.$economySub && this.$economySub.length) {
|
||
const owed = debt > 0
|
||
? ` · Debt: ${currencySymbol()}${groupDigits(Math.round(debt))}`
|
||
: "";
|
||
this.$economySub.text(
|
||
`Private-sector cash: ${currencySymbol()}${groupDigits(Math.round(cashValue || 0))}${owed}`
|
||
);
|
||
}
|
||
this.$economyList.append(
|
||
$("<div class='desc'></div>").text(
|
||
"This region is a private economic agent: it holds its own cash reserve, " +
|
||
"trades with its neighbours and chases profit."
|
||
)
|
||
);
|
||
const holdings = Object.entries(cash || {}).filter(([, amount]) => amount > 0);
|
||
if (holdings.length === 0) {
|
||
this.$economyList.append($("<div class='desc'></div>").text("No cash held."));
|
||
return;
|
||
}
|
||
const $table = $("<table class='resources-table economy-table'></table>");
|
||
const $head = $("<thead></thead>").append(
|
||
$("<tr></tr>")
|
||
.append($("<th></th>").text("Currency"))
|
||
.append($("<th class='numeric'></th>").text("Held"))
|
||
);
|
||
const $body = $("<tbody></tbody>");
|
||
for (const [code, amount] of holdings) {
|
||
const currency = byCode.get(code);
|
||
const name = currency ? `${currency.symbol} ${currency.name} (${code})` : code;
|
||
$body.append(
|
||
$("<tr></tr>")
|
||
.append($("<td></td>").text(name))
|
||
.append($("<td class='numeric'></td>").text(groupDigits(Math.round(amount))))
|
||
);
|
||
}
|
||
$table.append($head, $body);
|
||
this.$economyList.append($table);
|
||
}
|
||
|
||
// -------------------------------------------------------------- training --
|
||
|
||
// The fraction of the base build time a construction technology leaves, so a
|
||
// 1% level shows a 1% shorter build.
|
||
_constructionTimeFactor() {
|
||
return 1 / (1 + (this.constructionSpeed || 0));
|
||
}
|
||
|
||
// The full price of a construction at today's market: the build cost plus its
|
||
// material bill at current prices, whether or not the city already holds the
|
||
// materials.
|
||
_quotedCost(cost, proto, level) {
|
||
return quotedBuildCost(cost, constructionResourceCost(proto, level, cost), this.prices);
|
||
}
|
||
|
||
// The materials a construction spends, each with its icon, so a unit's cost
|
||
// reads in resources first and the money figure stays secondary.
|
||
_resourceCostEl(resources) {
|
||
const $wrap = $("<span class='train-resources'></span>");
|
||
for (const id of ["steel", "energy", "hightech"]) {
|
||
const amount = resources[id] || 0;
|
||
if (!(amount > 0)) continue;
|
||
const proto = resourceById(id);
|
||
$wrap.append(
|
||
$("<span class='cost-res'></span>").append(
|
||
$("<img class='cost-icon'>")
|
||
.attr("src", `assets/${proto.icon}`)
|
||
.attr("alt", proto.name)
|
||
.attr("title", proto.name),
|
||
$("<span></span>").text(formatResourceAmount(id, amount))
|
||
)
|
||
);
|
||
}
|
||
return $wrap;
|
||
}
|
||
|
||
_renderTrainList(gdp, trainableUnits) {
|
||
this.protoNames = new Map();
|
||
this.$trainList.empty();
|
||
const signature = [];
|
||
for (let index = 0; index < trainableUnits.length; index++) {
|
||
const proto = trainableUnits[index];
|
||
if (!proto.cost && proto.cost !== 0) continue;
|
||
const button = $("<button class='aero train-button'></button>");
|
||
if (proto.icon) {
|
||
button.append(
|
||
$("<img class='unit-icon' />").attr({ src: `assets/${proto.icon}`, alt: proto.name })
|
||
);
|
||
}
|
||
const duration = ECONOMY.trainingHours(gdp, proto.cost) * this._constructionTimeFactor();
|
||
button.append(
|
||
$("<span class='train-name'></span>").text(proto.name),
|
||
this._resourceCostEl(constructionResourceCost(proto, 0, proto.cost)),
|
||
$("<span class='train-price'></span>").text(
|
||
`${currencySymbol()}${groupDigits(this._quotedCost(proto.cost, proto, 0))}`
|
||
),
|
||
$("<span class='train-time'></span>").text(hours(duration))
|
||
);
|
||
const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index;
|
||
signature.push(serverIndex);
|
||
this.protoNames.set(serverIndex, proto.name);
|
||
button.on("click", () => this.onTrain(this.cityId, serverIndex));
|
||
this.$trainList.append(button);
|
||
if (this.testing) {
|
||
const free = $("<button class='aero train-button free-button'></button>").text(
|
||
`Free & instant — ${proto.name}`
|
||
);
|
||
free.on("click", () => this.onFreeTrain(this.cityId, serverIndex));
|
||
this.$trainList.append(free);
|
||
}
|
||
}
|
||
this._trainableSignature = signature.join(",");
|
||
}
|
||
|
||
// Rebuilds the trainable list only when the set changes, so a building that
|
||
// finishes while the panel is open (an airport, say) unlocks its unit without
|
||
// reopening the city and without discarding the list's scroll position.
|
||
refreshTrainList(trainableUnits) {
|
||
const signature = trainableUnits.map((proto) => proto.serverIndex).join(",");
|
||
if (signature === this._trainableSignature) return;
|
||
this._renderTrainList(this.gdp, trainableUnits);
|
||
}
|
||
|
||
// Draws the shared production queue: one row per run of contiguous identical
|
||
// entries, with the cumulated hours until each run is ready and a button to
|
||
// cancel its last entry. Cancelling refunds nothing.
|
||
renderQueue(queue) {
|
||
if (!this.$trainQueue || !this.$trainQueue.length) return;
|
||
this.$trainQueue.empty();
|
||
if (!queue || queue.length === 0) {
|
||
this.$trainQueue.append(
|
||
$("<div class='desc queue-empty'></div>").text("Nothing queued.")
|
||
);
|
||
return;
|
||
}
|
||
let cumulative = 0;
|
||
groupQueue(queue).forEach((group, groupIndex) => {
|
||
let firstReady = 0;
|
||
for (const index of group.indices) {
|
||
const entry = queue[index];
|
||
const elapsed = index === 0 ? entry.elapsedHours || 0 : 0;
|
||
// A building still gathering its materials has not started building, so
|
||
// it adds no time to the queue's readiness estimate.
|
||
const gather = entry.kind === "building" && entry.phase === "materials";
|
||
cumulative += gather ? 0 : Math.max(entry.totalHours - elapsed, 0);
|
||
if (index === group.indices[0]) firstReady = cumulative;
|
||
}
|
||
const count = group.indices.length;
|
||
const gathering = queue[group.indices[0]].kind === "building" &&
|
||
queue[group.indices[0]].phase === "materials";
|
||
const name = group.kind === "building"
|
||
? this.buildingNames.get(group.protoIndex) || "Building"
|
||
: this.protoNames.get(group.protoIndex) || "Unit";
|
||
const label = count > 1 ? `${name} ×${count}` : name;
|
||
const $row = $("<div class='row-card queue-row'></div>");
|
||
const $info = $("<div class='info'></div>");
|
||
$info.append($("<div class='name'></div>").text(`${groupIndex + 1}. ${label}`));
|
||
$info.append(
|
||
$("<div class='desc'></div>").text(
|
||
gathering
|
||
? "Gathering materials"
|
||
: count > 1
|
||
? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}`
|
||
: `Ready in ${hours(cumulative)}`
|
||
)
|
||
);
|
||
const $actions = $("<div class='actions'></div>");
|
||
const $cancel = $("<button class='aero'></button>").text(count > 1 ? "Cancel last" : "Cancel");
|
||
$cancel.on("click", () => this.onCancel(this.cityId, group.indices[group.indices.length - 1]));
|
||
$actions.append($cancel);
|
||
$row.append($info, $actions);
|
||
this.$trainQueue.append($row);
|
||
});
|
||
}
|
||
|
||
beginTraining(name, totalHours, elapsedHours = 0, verb = "Training") {
|
||
this.$trainLabel.text(`${verb} ${name}…`);
|
||
this.$trainProgress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
|
||
this.$trainStatus.removeClass("hidden");
|
||
}
|
||
|
||
setTrainingProgress(name, elapsedHours, totalHours, verb = "Training") {
|
||
const remaining = Math.max(0, Math.ceil(totalHours - elapsedHours));
|
||
this.$trainLabel.text(`${verb} ${name}… (${remaining} h left)`);
|
||
this.$trainProgress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
|
||
}
|
||
|
||
endTraining() {
|
||
this.$trainLabel.text("Training complete");
|
||
this.$trainProgress.css("width", "100%");
|
||
}
|
||
|
||
// The selected city's head of queue is a building still buying its steel and
|
||
// high-tech: show how far that gathering has got, with no hours figure since
|
||
// the construction clock has not started.
|
||
setGatheringProgress(name, fraction = 0) {
|
||
this.$trainLabel.text(`Gathering materials for ${name}…`);
|
||
this.$trainProgress.css("width", `${Math.min(100, Math.max(0, fraction) * 100)}%`);
|
||
this.$trainStatus.removeClass("hidden");
|
||
}
|
||
|
||
// ------------------------------------------------------------- buildings --
|
||
|
||
// One card per building, in the shared card idiom. The card keeps only the
|
||
// icon, name and level; the produce/consume/organisation figures live in the
|
||
// hover tooltip and the price on the action button. `row` is mutated by
|
||
// refreshBuildings, and the tooltip reads it when opened so it always quotes
|
||
// the level currently on show.
|
||
_makeBuildingCard(proto, index) {
|
||
const row = { proto, index, level: 0, pending: 0, built: false, requirement: null };
|
||
row.card = buildCard({
|
||
icon: proto.icon,
|
||
name: proto.name,
|
||
status: "Level 0",
|
||
action: { label: "▲" },
|
||
remove: { label: "▾", title: "Downgrade", enabled: false },
|
||
tip: () => this._buildingTip(row),
|
||
onAction: () => this.onBuild(this.cityId, index),
|
||
onRemove: () => this.onDemolish(this.cityId, index),
|
||
});
|
||
// In a local test game, a second button raises the next level for free.
|
||
if (this.testing) {
|
||
row.$free = $("<button class='aero bt-free free-button'></button>")
|
||
.on("click", () => this.onFreeBuild(this.cityId, index));
|
||
row.card.$card.append(row.$free);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
// The hover detail for a building card: what it makes at the level it is
|
||
// buying, what it wears out each day there, and the materials its next order
|
||
// spends.
|
||
_buildingTip(row) {
|
||
const shownLevel = Math.max(row.level + row.pending, 1);
|
||
const effect = buildingEffectText(row.proto, shownLevel);
|
||
const upkeep = protoUpkeep(row.proto, buildingBuildCost(row.proto, shownLevel - 1));
|
||
const cost = buildingBuildCost(row.proto, row.level + row.pending);
|
||
const materials = constructionResourceCost(row.proto, row.level + row.pending, cost);
|
||
return tooltipSections({
|
||
produce: effect && effect !== "No effect" ? [{ text: effect }] : [],
|
||
consume: this._resourceItems(upkeep),
|
||
consumeSuffix: "/day",
|
||
cost: this._resourceItems(materials),
|
||
});
|
||
}
|
||
|
||
// The positive entries of a resource bill, as tooltip items.
|
||
_resourceItems(resources) {
|
||
const items = [];
|
||
for (const id of CARD_RESOURCES) {
|
||
if (resources && resources[id] > 0) items.push({ id, amount: resources[id] });
|
||
}
|
||
return items;
|
||
}
|
||
|
||
// Why a building cannot be raised here, or null when it can. Ports need the
|
||
// sea and shipyards need a port, mirrored from GameState.requestBuild.
|
||
_buildRequirement(proto) {
|
||
if (proto.coastal && !this.coastal) return "Needs a coastal city";
|
||
if (proto.requiresBuilding) {
|
||
const index = BUILDINGS.findIndex((b) => b.id === proto.requiresBuilding);
|
||
if (index < 0 || (this._levels[index] || 0) <= 0) {
|
||
const name = index >= 0 ? BUILDINGS[index].name : proto.requiresBuilding;
|
||
return `Needs a ${name}`;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
refreshBuildings(budget, levels, gdp = 0, queued = {}) {
|
||
this._levels = levels || {};
|
||
this.$buildingsBudget.text(`Budget: ${formatMoney(budget)}`);
|
||
for (const row of this.buildingRows) {
|
||
const level = levels[row.index] || 0;
|
||
const pending = queued[row.index] || 0;
|
||
// The next order is priced off the level the queued ones will reach, so
|
||
// an upgrade can cost more than the completed level alone suggests.
|
||
const nextLevel = level + pending;
|
||
const built = level > 0;
|
||
const requirement = this._buildRequirement(row.proto);
|
||
row.level = level;
|
||
row.pending = pending;
|
||
row.built = built;
|
||
row.requirement = requirement;
|
||
|
||
const levelText = pending > 0 ? `Level ${level} (+${pending} queued)` : `Level ${level}`;
|
||
row.card.$card.toggleClass("built", built).toggleClass("locked", !!requirement);
|
||
row.card.$status
|
||
.toggleClass("built", built && !requirement)
|
||
.text(requirement || levelText);
|
||
row.card.$action.prop("disabled", !!requirement);
|
||
const cost = buildingBuildCost(row.proto, nextLevel);
|
||
const duration = ECONOMY.buildingHours(gdp, cost) * this._constructionTimeFactor();
|
||
setCardMeta(row.card.$meta, {
|
||
money: requirement ? null : this._quotedCost(cost, row.proto, nextLevel),
|
||
hours: hours(duration),
|
||
});
|
||
if (row.card.$remove) {
|
||
// Downgrading returns nothing, so the control is only enabled once the
|
||
// building actually stands.
|
||
row.card.$remove.prop("disabled", !built);
|
||
}
|
||
if (row.$free) {
|
||
// Free does not ignore placement rules (a port still needs the sea).
|
||
row.$free.text(`Free · ${row.proto.name}`).prop("disabled", !!requirement);
|
||
}
|
||
}
|
||
this._sortBuildingCards();
|
||
}
|
||
|
||
// Buildings already standing lead the grid, so what a city owns is always in
|
||
// view. The order is only rewritten when a building's standing changes, so the
|
||
// horizontal scroll is not disturbed on every snapshot.
|
||
_sortBuildingCards() {
|
||
const signature = this.buildingRows.map((row) => (row.built ? "1" : "0")).join("");
|
||
if (signature === this._buildingOrderSignature) return;
|
||
this._buildingOrderSignature = signature;
|
||
const ordered = this.buildingRows
|
||
.slice()
|
||
.sort((a, b) => (b.built ? 1 : 0) - (a.built ? 1 : 0));
|
||
for (const row of ordered) this.$buildingGrid.append(row.card.$card);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- budget --
|
||
|
||
renderBudget(breakdown) {
|
||
if (!this.$budgetList || !this.$budgetList.length) return;
|
||
this._lastBreakdown = breakdown || {};
|
||
const net = this._lastBreakdown.net || 0;
|
||
this.$budgetSub.text(`Region net ${signedMoney(net)}`);
|
||
renderBudget(this.$budgetList, this._lastBreakdown, {
|
||
expanded: this.expandedBudgetGroups,
|
||
onToggle: () => this.renderBudget(this._lastBreakdown),
|
||
showUnits: false,
|
||
});
|
||
}
|
||
|
||
close() {
|
||
hideCardTooltip();
|
||
this.$modal.addClass("hidden");
|
||
this.cityId = 0;
|
||
}
|
||
}
|
||
|
||
// Runs of contiguous queue entries of the same kind and type, as lists of queue
|
||
// indices. Units and buildings never merge into one row, and non-adjacent
|
||
// entries of the same type stay separate so a row only ever represents the
|
||
// things sitting together.
|
||
function groupQueue(queue) {
|
||
const groups = [];
|
||
queue.forEach((entry, index) => {
|
||
const kind = entry.kind === "building" ? "building" : "unit";
|
||
const last = groups[groups.length - 1];
|
||
if (last && last.kind === kind && last.protoIndex === entry.protoIndex) {
|
||
last.indices.push(index);
|
||
} else {
|
||
groups.push({ kind, protoIndex: entry.protoIndex, indices: [index] });
|
||
}
|
||
});
|
||
return groups;
|
||
}
|