Files
Battle-for-Tismo/client/js/modals/format.js
T
adrien 80429918c6 Made producers pay their own way and settled the world to steady prices
- The monthly budget breaks each expense into the resource bought, its quantity and the average price paid: tile improvements already did, and now every upkeep row (buildings, units, works, transport) does too.
- Private producers settle their daily wear from their own cash and borrow the shortfall from the central bank, so their cash never goes negative.
- Producers fund their own construction and upgrades on the same credit; the treasury pays only for public works, roads and railways, and no private purchase is filed as a public tile-improvement expense.
- The server settles the world until every commodity holds within 10% over a trailing seven days instead of a fixed 150, capped by --warmup (0 skips it).
- The shipped month report carries its label again, so the budget header and pager name the month.
2026-09-23 21:45:28 +02:00

200 lines
8.7 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.
// Shared formatting helpers for the modal panels.
import { groupDigits } from "../../../shared/text_format.js";
import { policyById, resourceById } from "../../../shared/data.js";
import { marketPriceIndex, resourceMarketValue } from "../../../shared/resources.js";
import { formatMoneySigned, currencySymbol } from "../currency.js";
import { researchNode } from "../ui/card.js";
// The world market's current prices, from the snapshot's market listing.
export function marketPricesFrom(snapshot) {
const prices = {};
for (const entry of (snapshot && snapshot.resourceMarket) || []) {
prices[entry.id] = entry.price;
}
return prices;
}
// A fixed money cost quoted at today's market: a cost with no material bill of
// its own is scaled by the global price index, so every euro figure drifts with
// commodity prices.
export function quotedMoney(amount, prices) {
return Math.round((amount || 0) * marketPriceIndex(prices));
}
// A construction's full price at today's market: the fixed build cost plus the
// market value of its whole material bill, ignoring anything already in store.
export function quotedBuildCost(buildCost, materials, prices) {
return Math.round((buildCost || 0) + resourceMarketValue(materials, prices));
}
export function formatEffect(effect, level) {
const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent);
switch (effect.stat) {
case "production":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% production`;
case "research":
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} research/h`;
case "culture":
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} culture/h`;
case "infantry_attack":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% infantry attack`;
case "infantry_defense":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% infantry defense`;
case "artillery_attack":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% artillery attack`;
case "construction_speed":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% construction speed`;
case "rail_speed":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% railway speed`;
case "energy_efficiency":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% electricity per fuel`;
case "steel_efficiency":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% steel per energy`;
case "food_efficiency":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% food per energy`;
case "luxury_efficiency":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% luxury per energy`;
case "hightech_efficiency":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% high-tech per energy`;
default:
return `${effect.stat} ${value.toFixed(2)}`;
}
}
export function effectsText(effects, level) {
const parts = effects.map((e) => formatEffect(e, level));
return parts.length > 0 ? parts.join(" · ") : "No effect";
}
// The effect line for a building: its stat effects, or, for a utility building
// whose benefit is a rule, a short description of that rule at `level`.
export function buildingEffectText(proto, level) {
if (proto.effects && proto.effects.length > 0) return effectsText(proto.effects, level);
switch (proto.mechanic) {
case "parallel_training":
return `${1 + level} parallel training line${level === 0 ? "" : "s"}`;
case "parallel_building":
return `${1 + level} parallel building line${level === 0 ? "" : "s"}`;
case "civilian_shelter": {
const saved = Math.round((proto.civilianProtectionPerLevel || 0) * level * 100);
return `−${saved}% civilian bombing losses · very hard to destroy`;
}
case "air_defense":
return "Fires one volley at hostile aircraft over the city";
case "port_production": {
const per = (proto.productionPerSeaTilePerLevel || 0) * level;
const food = (proto.foodPerSeaTilePerLevel || 0) * level * 100;
const parts = [`+${groupDigits(per)} production per person per adjacent sea tile to the region`];
if (food > 0) parts.push(`+${food.toFixed(1)}% food output per adjacent sea tile`);
return parts.join(" · ");
}
case "air_immigration":
return "Immigration from every other city with an airport";
default:
return "No effect";
}
}
// The effect list as DOM, so a research effect can carry the erlenmeyer icon in
// place of the word "research". Every other effect reads as its plain text.
export function effectNodes(effects, level) {
const $frag = $("<span class='effect-text'></span>");
(effects || []).forEach((effect, index) => {
if (index > 0) $frag.append(document.createTextNode(" · "));
if (effect.stat === "research") {
const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent);
$frag.append(researchNode(value, { sign: value >= 0 ? "+" : "", decimals: 2 }));
$frag.append(document.createTextNode("/h"));
} else {
$frag.append(document.createTextNode(formatEffect(effect, level)));
}
});
return $frag;
}
// The effect line for a building as DOM: a stats building shows its effects with
// the research icon, a utility building its rule text.
export function buildingEffectNodes(proto, level) {
if (proto.effects && proto.effects.length > 0) return effectNodes(proto.effects, level);
return document.createTextNode(buildingEffectText(proto, level));
}
export const SOURCE_LABELS = { building: "building", government: "government", technology: "research" };
export function signedMoney(value) {
return `${formatMoneySigned(value)}/h`;
}
// A signed money amount with no period suffix, for the monthly budget report.
export function money(value) {
return formatMoneySigned(value);
}
// A per-unit price. Ordinary figures round to the nearest unit of currency;
// sub-unit prices (a kilowatt-hour, say) keep significant digits so the row
// does not read as free.
export function unitMoney(value) {
const amount = Number(value) || 0;
if (amount >= 100) return `${currencySymbol()}${groupDigits(Math.round(amount))}`;
if (amount >= 1) return `${currencySymbol()}${amount.toFixed(1)}`;
return `${currencySymbol()}${amount.toPrecision(3)}`;
}
export function percent(value) {
return `${value < 0 ? "−" : "+"}${(Math.abs(value) * 100).toFixed(1)}%`;
}
// Renders one news entry as text. Government actions name the specific actors
// doing them — "French troops", "French scientists" — rather than the nation as
// a whole, and the victim or target is named in the people's form ("the
// Vietnamese").
export function describeNews(entry, civs) {
const name = (index) => (civs[index] ? civs[index].name : `Nation ${index + 1}`);
// The attributive form of a nation, for putting before an actor noun. It
// falls back to the demonym, which doubles as an adjective for most of them;
// the few person-nouns (New Zealander, New Hampshirite) carry an explicit one.
const adjective = (index) => {
const civ = civs[index];
if (!civ) return `Nation ${index + 1}`;
return civ.adjective || civ.demonym || civ.name;
};
const people = (index) => {
const civ = civs[index];
return civ && civ.demonym ? `the ${civ.demonym}` : name(index);
};
switch (entry.type) {
case "war":
return `${adjective(entry.actor)} leaders have declared war on ${name(entry.target)}`;
case "capture":
return `${adjective(entry.to)} troops have captured ${entry.city} from ${people(entry.from)}`;
case "policy": {
const policy = policyById(entry.policy);
const policyName = policy ? policy.name : entry.policy;
const who =
civs[entry.ethnicity] && civs[entry.ethnicity].demonym
? `the ${civs[entry.ethnicity].demonym}`
: name(entry.ethnicity);
return `${adjective(entry.civ)} lawmakers have enacted ${policyName} for ${who}`;
}
case "technology":
return entry.repeatable
? `${adjective(entry.civ)} scientists have improved ${entry.name}`
: `${adjective(entry.civ)} scientists have discovered ${entry.name}`;
case "join":
return `${entry.player} took control of ${name(entry.civ)}`;
case "tariff": {
const rate = `${(entry.rate * 100).toFixed(1)}%`;
const resource = entry.resource
? (resourceById(entry.resource) ? resourceById(entry.resource).name : entry.resource)
: "all resources";
const kind = entry.kind === "export" ? "export tariff" : "import tariff";
return `${adjective(entry.civ)} leaders set the ${kind} on ${resource} to ${rate}`;
}
case "famine":
return `${people(entry.civ)} are starving in ${entry.region}: a famine has begun`;
default:
return entry.type;
}
}