Files
Battle-for-Tismo/client/js/modals/city.js
T
adrien 49613f2a1c Made training gather materials, fixed the trade graph, and banked money gifts
Unit orders now gather steel and high-tech over the days before training, exactly as buildings do, and clicking a queued order reveals what it is gathering, its reserved budget and the soldiers it will draw.

The trade graph cached a producer-less node list under a stamp that already reflected the producers' stores, so every seeded mine stayed invisible to buyers and rare-good prices settled at a huge multiple of base; the storage nodes are now re-read after production.

Money gifts draw the giver's central-bank reserves in the denomination first, buy the rest from the issuing bank at the market rate, convert into the receiver's currency, and cannot buy from a bank their nation is at war with.

Pruned the finished design briefs (intelligence, stacks, air movement, politics performance, economy balance, training UI and FIXME) and refreshed the index and cross-references.
2026-09-24 21:20:47 +02:00

805 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The 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, RESOURCE_RULES } from "../../../shared/data.js";
import { constructionResourceCost, formatResourceAmount, protoUpkeep, unitFoodPerDay } from "../../../shared/resources.js";
import {
buildingBuildCost,
} from "../../../shared/rules.js";
import { buildingEffectNodes, 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,
resourceBill,
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();
// Unit protos by server index, so a queued unit's material bill and
// population draw can be read in the queue detail.
this.unitProtos = {};
// The queue row whose detail is expanded, keyed by its run so a snapshot
// rebuild keeps it open, and the queue last drawn so a click can redraw.
this._expandedQueue = null;
this._lastQueue = null;
// 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.unitProtos = view.unitProtos || {};
// A different city's queue keys have different meanings, so collapse any
// open detail when the panel is reopened.
this._expandedQueue = null;
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);
}
_renderTrainList(gdp, trainableUnits) {
this.protoNames = new Map();
this.$trainList.empty();
const $grid = $("<div class='building-grid'></div>");
this.$trainList.append($grid);
const signature = [];
for (let index = 0; index < trainableUnits.length; index++) {
const proto = trainableUnits[index];
if (!proto.cost && proto.cost !== 0) continue;
const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index;
const locked = !!proto.locked;
const duration = ECONOMY.trainingHours(gdp, proto.cost) * this._constructionTimeFactor();
signature.push(`${serverIndex}:${locked ? 1 : 0}`);
this.protoNames.set(serverIndex, proto.name);
const card = buildCard({
icon: proto.icon,
name: proto.name,
locked,
status: locked ? proto.requiresLabel : this._unitStatus(proto),
note: this._unitNote(proto),
action: locked
? { label: "Locked", disabled: true }
: {
label: "Train",
money: this._quotedCost(proto.cost, proto, 0),
hours: hours(duration),
},
tip: () => this._unitTip(proto),
onAction: locked ? null : () => this.onTrain(this.cityId, serverIndex),
});
// In a local test game, a second button trains the unit for free.
if (this.testing && !locked) {
const $free = $("<button class='aero bt-free free-button'></button>")
.text(`Free · ${proto.name}`)
.on("click", () => this.onFreeTrain(this.cityId, serverIndex));
card.$card.append($free);
}
$grid.append(card.$card);
}
this._trainableSignature = signature.join(",");
}
// The unit's one-line summary on its card: its fighting figures for a combat
// unit, what it carries or sees otherwise.
_unitStatus(proto) {
if (proto.military) return `Attack ${proto.attack} · Defense ${proto.defense}`;
if (proto.transportCapacity) return `Carries ${proto.transportCapacity} ${proto.transportKind}`;
if (proto.vision) return `Vision ${proto.vision}`;
return "Civilian";
}
// The materials and food a unit wears out standing each day. Energy is left
// out: it is fed to the grid through the city it stands in, not billed to the
// unit. Aircraft and ships eat nothing, so only their materials show.
_unitUpkeepItems(proto) {
const upkeep = protoUpkeep(proto, proto.cost);
return [
{ id: "steel", amount: upkeep.steel },
{ id: "food", amount: unitFoodPerDay(proto) },
{ id: "hightech", amount: upkeep.hightech },
].filter((item) => item.amount > 0);
}
// The unit's daily maintenance, paid in materials and food rather than money,
// drawn as the same icon bill a building's consumes uses.
_unitNote(proto) {
const items = this._unitUpkeepItems(proto);
if (items.length === 0) return "";
return resourceBill(items, { sign: "−", suffix: "/day" });
}
// The hover detail for a unit card: what it is worth in a fight, what it
// wears out each day and the materials training it spends.
_unitTip(proto) {
const stats = [];
if (proto.military) {
stats.push(`Attack ${proto.attack}`, `Defense ${proto.defense}`);
if (proto.range > 1) stats.push(`Range ${proto.range}`);
}
if (proto.vision) stats.push(`Vision ${proto.vision}`);
if (proto.transportCapacity) {
stats.push(`Carries ${proto.transportCapacity} ${proto.transportKind}`);
}
if (proto.air) stats.push(`Endurance ${proto.enduranceHours}h`);
if (stats.length === 0) stats.push("Civilian");
const sections = [
{ label: "Unit", cls: "tip-produce", sign: "+", suffix: "", items: stats.map((text) => ({ text })) },
];
const consume = this._unitUpkeepItems(proto);
if (consume.length > 0) {
sections.push({ label: "Upkeep", cls: "tip-consume", sign: "−", suffix: "/day", items: consume });
}
const cost = this._resourceItems(constructionResourceCost(proto, 0, proto.cost));
if (cost.length > 0) {
sections.push({ label: "Costs", cls: "tip-cost", sign: "−", suffix: "", items: cost });
}
return sections;
}
// 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}:${proto.locked ? 1 : 0}`)
.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. Clicking an order opens
// its detail -- the steel and high-tech it is gathering, its reserved budget
// and, for a unit, the soldiers it will draw from the region.
renderQueue(queue) {
if (!this.$trainQueue || !this.$trainQueue.length) return;
this._lastQueue = queue || null;
this.$trainQueue.empty();
if (!queue || queue.length === 0) {
this._expandedQueue = null;
this.$trainQueue.append(
$("<div class='desc queue-empty'></div>").text("Nothing queued.")
);
return;
}
let cumulative = 0;
groupQueue(queue).forEach((group, groupIndex) => {
const first = group.indices[0];
const entry = queue[first];
// An order still gathering its materials has not started building or
// training, so it adds no time to the queue's readiness estimate.
const gather = entry.phase === "materials";
let firstReady = 0;
for (const index of group.indices) {
const at = queue[index];
const elapsed = index === 0 ? at.elapsedHours || 0 : 0;
cumulative += gather ? 0 : Math.max(at.totalHours - elapsed, 0);
if (index === first) firstReady = cumulative;
}
const count = group.indices.length;
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 expanded = this._isExpanded(group, first);
const $row = $("<div class='row-card queue-row expandable'></div>");
if (expanded) $row.addClass("expanded");
const $info = $("<div class='info'></div>");
$info.append($("<div class='name'></div>").text(`${groupIndex + 1}. ${label}`));
$info.append(
$("<div class='desc'></div>").text(
gather
? "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", (event) => {
event.stopPropagation();
this.onCancel(this.cityId, group.indices[group.indices.length - 1]);
});
$actions.append($cancel);
$row.append($info, $actions);
$row.on("click", () => this._toggleQueueDetail(group, first));
this.$trainQueue.append($row);
if (expanded) this.$trainQueue.append(this._queueDetail(entry, name));
});
}
// Whether the run starting at `first` is the one whose detail is open.
_isExpanded(group, first) {
const open = this._expandedQueue;
return !!open && open.kind === group.kind &&
open.protoIndex === group.protoIndex && open.first === first;
}
// Opens or closes a queue row's detail, then redraws from the last queue.
_toggleQueueDetail(group, first) {
if (this._isExpanded(group, first)) this._expandedQueue = null;
else this._expandedQueue = { kind: group.kind, protoIndex: group.protoIndex, first };
this.renderQueue(this._lastQueue);
}
// What an order is using while it waits: its gathered materials with a bar
// each, the budget reserved for them and, for a unit, the soldiers the region
// will give up.
_queueDetail(entry, name) {
const $detail = $("<div class='queue-detail'></div>");
if (entry.phase === "materials") {
$detail.append(
$("<div class='city-production-label'></div>").text(`Gathering materials for ${name}`)
);
for (const id of ["steel", "hightech"]) {
const needed = (entry.needed && entry.needed[id]) || 0;
if (!(needed > 0)) continue;
const bought = Math.min((entry.bought && entry.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}%`));
$detail.append(
$("<div class='site-material-label'></div>").text(
`${resourceById(id).name} — ${formatResourceAmount(id, bought)} / ${formatResourceAmount(id, needed)}`
),
$bar
);
}
if (entry.budget > 0) {
$detail.append(
$("<div class='site-material-label'></div>").text(`Budget ${formatMoney(entry.budget)}`)
);
}
const soldiers = this._soldiersFor(entry);
if (soldiers > 0) {
$detail.append(
$("<div class='site-material-label'></div>").text(
`${groupDigits(soldiers)} soldiers will be drawn from the region`
)
);
}
$detail.append($("<span class='gather-spinner' title='Gathering materials'></span>"));
} else {
const verb = entry.kind === "building" ? "Building" : "Training";
const total = Math.max(entry.totalHours || 0, 0.0001);
const elapsed = entry.elapsedHours || 0;
const remaining = Math.max(0, Math.ceil(total - elapsed));
const $bar = $("<div class='progress city-production-bar'></div>");
$bar.append($("<div></div>").css("width", `${Math.max(0, Math.min(100, (elapsed / total) * 100))}%`));
$detail.append(
$("<div class='city-production-label'></div>").text(`${verb} ${name} — ${remaining} h left`),
$bar
);
}
return $detail;
}
// The people a unit order will draw when it completes, matching the model's
// own default for a type that does not state one.
_soldiersFor(entry) {
if (entry.kind !== "unit") return 0;
const proto = this.unitProtos && this.unitProtos[entry.protoIndex];
const population = proto && proto.population;
return population || RESOURCE_RULES.unitPopulation;
}
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" ? [{ node: buildingEffectNodes(row.proto, shownLevel) }] : [],
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;
}