Files
Battle-for-Tismo/shared/rules.js
T
adrien d73ea4735d Grew the economy at a constant rate and forecast the budget
GDP per capita had no natural growth, so a nation's economy only moved
when population, buildings or war did. The summary stats also offered
inconsistent hover text: a native browser tooltip for popularity and none
at all for the budget.

- Every land tile now gains a constant 0.01% of the GDP per capita it
  opened with, once a day. Because the amount is fixed the relative growth
  rate tapers over the years. Each tile's opening figure is snapshotted
  once and shipped to the browser, so the tile panel and the GDP-per-capita
  map mode stay in step with the server.
- The population, GDP and GDP/capita tooltips show the relative growth:
  against 1 January 2000 through the first year, then year on year. The
  monthly captures now keep a rolling thirteen months for the baseline.
- Every summary stat explains itself through the same custom bubble.
  Popularity's native title moved onto it, and Budget forecasts the next
  calendar month at the current hourly net, as a compact figure such as
  "+€3.28B/mo" (hoursInNextMonth follows the real calendar).
- The tile GDP breakdown shows gains with a plus sign (natural growth,
  ports) rather than the old minus.

Added tests for the constant daily gain, the falling relative rate, the
first-year and year-on-year baselines, the snapshot baseline, the budget
forecast and the calendar month length.
2026-09-21 12:27:11 +02:00

184 lines
6.9 KiB
JavaScript

// Catalogue maths shared by the server (which charges costs) and the browser
// (which displays and disables buttons) so the numbers always agree.
import { effectValue, CITY_DEFENSE_BONUS, ECONOMY, POPULARITY, TRENCH } from "./data.js";
// The popularity scale shared by the server (which simulates it) and the
// browser (which shows it). Opinions live on the 0..1 scale; popularity is the
// same figure in integer points centred on neutral 0, and what the player sees
// is a logistic of those points.
export function popularityPoints(opinion) {
return Math.round((opinion - 0.5) / POPULARITY.opinionPerPoint);
}
export function popularityDisplay(points) {
return 1 / (1 + Math.exp(-POPULARITY.logisticScale * points));
}
// The hourly pace at which a population at `points` returns to neutral: one
// point an hour, so the estimate is simply the distance left.
export function hoursToNeutral(points) {
return Math.abs(points);
}
// Fixed cost of raising a building to `level` (0-indexed: the cost of the next
// level). It grows only with the level, never with the nation's GDP.
export function buildingBuildCost(proto, level) {
return proto.baseCost * Math.pow(proto.costAlpha, Math.max(level, 0));
}
export function buildingRefund(proto, level) {
return buildingBuildCost(proto, level - 1) * proto.refundFraction;
}
// Recurring upkeep of a building kept at `level`, as a fraction of the cost of
// that level. Like the build cost it follows the level alone, never the GDP.
export const BUILDING_UPKEEP_FRACTION = 0.01;
export function buildingUpkeep(proto, level) {
if (level <= 0) return 0;
return buildingBuildCost(proto, level - 1) * BUILDING_UPKEEP_FRACTION;
}
export function buildingEffectValue(proto, stat, level) {
let total = 0;
for (const e of proto.effects) {
if (e.stat === stat) total += effectValue(e, level);
}
return total;
}
export function governmentChangeCost(proto, gdp) {
return proto.baseCost + proto.costGdpFraction * Math.max(gdp, 0);
}
// Combined effect totals for a catalogue entry whose effects do not scale
// (governments and technologies are always applied at level 1).
function effectsTotal(proto) {
const totals = {};
for (const e of proto.effects) {
totals[e.stat] = (totals[e.stat] || 0) + effectValue(e, 1);
}
return totals;
}
export function governmentEffects(proto) {
return effectsTotal(proto);
}
export function technologyEffects(proto) {
return effectsTotal(proto);
}
// True when every prerequisite technology is in the researched set. The set
// holds catalogue indices, so each prerequisite id is first resolved against
// the catalogue.
export function prerequisitesMet(technology, researchedSet, catalogue) {
for (const id of technology.prerequisites) {
const index = catalogue ? catalogue.findIndex((t) => t.id === id) : -1;
if (index < 0 || !researchedSet.has(index)) return false;
}
return true;
}
// Every defense modifier of the tile a unit stands on, in display order. The
// tile carries its own terrain bonus and a city adds another; each entry is a
// fraction of the unit's base defense. Shared by the server (which fights with
// them) and the browser (which shows them) so the numbers always agree.
export function defenseFactors(tile, hasCity) {
const factors = [];
if (tile && tile.defenseBonus) {
factors.push({ label: tile.terrainType, bonus: tile.defenseBonus });
}
if (hasCity) factors.push({ label: "City", bonus: CITY_DEFENSE_BONUS });
return factors;
}
// Multiplier applied to a unit's base defense for the tile it occupies.
export function defenseMultiplier(tile, hasCity) {
let total = 0;
for (const factor of defenseFactors(tile, hasCity)) total += factor.bonus;
return 1 + total;
}
// A unit's defense on a tile, before any role bonus (the attacker/defender
// distinction is a combat rule, not part of the stat shown to the player).
export function unitDefense(baseDefense, tile, hasCity) {
return baseDefense * defenseMultiplier(tile, hasCity);
}
// Every GDP-per-capita modifier of a tile, in display order. `tile` supplies the
// terrain multiplier and its name; `cityKind` is "capital", "city" or null and
// `nextToCity` is true when the tile borders one of its owner's cities. The
// pillage penalty and battle deficit are the lasting marks of war. Shared by the
// server (which derives each tile's GDP) and the browser (which explains it), so
// the numbers always agree.
export function gdpPerCapitaFactors(tile, {
cityKind = null,
nextToCity = false,
pillagePenalty = 1,
battleDeficit = 0,
trench = false,
regionalMultiplier = 1,
regionalLabel = null,
nationalMultiplier = 1,
portGdp = 0,
naturalGrowth = 0,
} = {}) {
const factors = [];
const terrainMultiplier = tile ? tile.gdpMultiplier || 0 : 0;
factors.push({ label: tile ? tile.terrainType : "Unknown", multiplier: terrainMultiplier });
if (cityKind === "capital") {
factors.push({ label: "Capital", multiplier: ECONOMY.capitalTileGdpMultiplier });
} else if (cityKind === "city") {
factors.push({ label: "City", multiplier: ECONOMY.cityTileGdpMultiplier });
} else if (nextToCity) {
factors.push({ label: "Near city", multiplier: 1 + ECONOMY.citySurroundingGdpBonus });
}
if (pillagePenalty !== 1) {
factors.push({ label: "Pillaging", multiplier: pillagePenalty });
}
if (trench) {
factors.push({ label: "Trenches", multiplier: TRENCH.gdpMultiplier });
}
// City improvements only shape the region their city controls, so a tile's
// GDP per capita carries its region's multiplier plus the nation-wide one
// from government and research. Both are factors on the per-capita figure,
// never an adjustment to the final GDP. They are applied before the absolute
// war damage below, so a scar stays proportional to the tile's productivity.
if (regionalMultiplier !== 1) {
factors.push({
label: regionalLabel ? `${regionalLabel} improvements` : "Regional improvements",
multiplier: regionalMultiplier,
});
}
if (nationalMultiplier !== 1) {
factors.push({ label: "National modifiers", multiplier: nationalMultiplier });
}
// A warm-water port adds a flat per-capita figure to its whole region, scaled
// by the sea tiles beside the city, before war damage is taken off.
if (portGdp > 0) {
factors.push({ label: "Port", amount: portGdp });
}
// Natural growth is a flat per-capita gain added on top of the tile's
// productivity, growing by a constant amount each day.
if (naturalGrowth > 0) {
factors.push({ label: "Natural growth", amount: naturalGrowth });
}
if (battleDeficit > 0) {
factors.push({ label: "Battle damage", amount: -battleDeficit });
}
return factors;
}
// Applies the `gdpPerCapitaFactors` list to `base`, in order, never falling
// below zero.
export function gdpPerCapitaFromFactors(base, factors) {
let total = base;
for (const factor of factors) {
if (factor.amount !== undefined) total += factor.amount;
else total *= factor.multiplier;
}
return total > 0 ? total : 0;
}