229 lines
10 KiB
JavaScript
229 lines
10 KiB
JavaScript
// Shared formatting helpers for the modal panels.
|
||
|
||
import { groupDigits } from "../../../shared/text_format.js";
|
||
import { HOURS_PER_DAY } from "../../../shared/game_clock.js";
|
||
import { policyById, resourceById, treatyById } 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 * HOURS_PER_DAY).toFixed(2)} research/day`;
|
||
case "culture":
|
||
return `${value >= 0 ? "+" : ""}${(value * HOURS_PER_DAY).toFixed(2)} culture/day`;
|
||
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 on hostile aircraft and missiles within two tiles";
|
||
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 * HOURS_PER_DAY, { sign: value >= 0 ? "+" : "", decimals: 2 }));
|
||
$frag.append(document.createTextNode("/day"));
|
||
} 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" };
|
||
|
||
// A signed money rate, given per hour by the model and shown per day.
|
||
export function signedMoney(value) {
|
||
return `${formatMoneySigned(value * HOURS_PER_DAY)}/day`;
|
||
}
|
||
|
||
// 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`;
|
||
case "sabotage": {
|
||
const base = `Saboteurs have crippled production in ${entry.region}`;
|
||
if (entry.suspect === null || entry.suspect === undefined) {
|
||
return `${base} — no one has been blamed`;
|
||
}
|
||
return `${base} — suspicion falls on ${name(entry.suspect)}`;
|
||
}
|
||
case "satellite":
|
||
return `${adjective(entry.civ)} rocket has placed a spy satellite in orbit`;
|
||
case "enrichment":
|
||
return `Intelligence agencies warn that ${name(entry.civ)} has begun enriching uranium`;
|
||
case "treaty": {
|
||
const proto = treatyById(entry.treaty);
|
||
const label = proto ? proto.name : entry.treaty;
|
||
switch (entry.stage) {
|
||
case "proposed":
|
||
return `${name(entry.from)} proposes ${label} to ${name(entry.to)}`;
|
||
case "signed":
|
||
return `${name(entry.from)} and ${name(entry.to)} sign ${label}`;
|
||
case "rejected":
|
||
return `${name(entry.to)} rejects ${name(entry.from)}'s ${label} offer`;
|
||
case "broken":
|
||
return `${name(entry.from)} and ${name(entry.to)} break their ${label}`;
|
||
default:
|
||
return label;
|
||
}
|
||
}
|
||
default:
|
||
return entry.type;
|
||
}
|
||
}
|