From d73ea4735ddb2c8f99ac3b0a265e6c1634da1b32 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Mon, 21 Sep 2026 12:27:11 +0200 Subject: [PATCH] Grew the economy at a constant rate and forecast the budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/js/game_screen.js | 10 +++ client/js/game_screen/panels.js | 74 ++++++++++++++++++-- server/game_server.js | 1 + shared/data/economy.js | 4 ++ shared/game_clock.js | 10 +++ shared/game_state.js | 9 +++ shared/game_state/economy.js | 28 ++++++++ shared/game_state/monthly.js | 42 +++++++++++- shared/game_state/serialization.js | 23 +++++++ shared/rules.js | 6 ++ tests/game_clock_test.js | 8 ++- tests/game_screen_test.js | 106 ++++++++++++++++++++++++++++- tests/game_server_test.js | 1 + tests/growth_test.js | 96 ++++++++++++++++++++++++++ 14 files changed, 409 insertions(+), 9 deletions(-) create mode 100644 tests/growth_test.js diff --git a/client/js/game_screen.js b/client/js/game_screen.js index 9a30c45..055087e 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -31,8 +31,10 @@ export class GameScreen { this._trainingCityId = 0; this._cityBuildings = new Map(); this._population = new Map(); + this._gdpBaseline = new Map(); this._tileEthnicity = new Map(); this._populationVersion = undefined; + this._gdpBaselineVersion = undefined; this._ethnicityVersion = undefined; this._territoryVersion = undefined; this._regionVersion = undefined; @@ -158,6 +160,14 @@ export class GameScreen { this._populationVersion = versionOf("population"); this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]])); } + // The opening GDP per capita of every land tile never changes, so it is + // built once and kept across the deltas that omit it. + if (versionChanged("gdpBaseline", this._gdpBaselineVersion)) { + this._gdpBaselineVersion = versionOf("gdpBaseline"); + this._gdpBaseline = new Map( + (state.gdpBaseline || []).map((e) => [`${e[0]},${e[1]}`, e[2]]) + ); + } if (versionChanged("ethnicity", this._ethnicityVersion)) { this._ethnicityVersion = versionOf("ethnicity"); this._tileEthnicity = new Map( diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index b439675..cff27d5 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -24,7 +24,7 @@ import { AIR_STRIKE, } from "../../../shared/data.js"; import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors, popularityDisplay } from "../../../shared/rules.js"; -import { dateString } from "../../../shared/game_clock.js"; +import { dateString, HOURS_PER_DAY, hoursInNextMonth } from "../../../shared/game_clock.js"; import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js"; import { parseKey } from "../../../shared/hex.js"; import { ethnicityChart } from "../ui/ethnicity.js"; @@ -108,6 +108,11 @@ export const panelMethods = { .on("mouseleave", () => this._hideStatTooltip()); $("#unit-trench").on("click", () => this._digTrenchSelected()); + // Every summary stat explains itself on hover through the shared bubble. + $("#top-panel .stat") + .on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget))) + .on("mouseleave", () => this._hideStatTooltip()); + // A click anywhere outside the stack menu dismisses it. $(document).on("mousedown.stackmenu", (event) => { if (!this.$stackMenu.hasClass("hidden") && !$(event.target).closest("#stack-menu").length) { @@ -520,14 +525,18 @@ export const panelMethods = { this._updateStatArrow("stat-gdp", stats.gdp, month && month.gdp, 0.1); this._updateStatArrow("stat-gdppc", stats.gdpPerCapita, month && month.gdpPerCapita, 0.1); this._updateStatArrow("stat-budget", stats.budget, month && month.budget, null); + // The stat tooltips carry the longer-run growth: against January 2000 for + // the first year, then against the same month a year earlier. + this._updateGrowthTooltips(stats.growth || null); + this._updateBudgetTooltip(stats, state.totalHours); const popularityPoints = stats.popularity || 0; const popularity = popularityDisplay(popularityPoints); this._updateApprovalStat(popularity); // The people drift one point an hour back to neutral, so the estimate is // simply how many points away they are; zero is already there. const pointsAway = Math.abs(popularityPoints); - $("#stat-approval").attr( - "title", + this._setStatTooltip( + "stat-approval", pointsAway <= 0 ? "Popularity is exactly neutral" : `Neutrality in about ${pointsAway} h (~${Math.ceil(pointsAway / 24)} d) ` + @@ -541,6 +550,11 @@ export const panelMethods = { 0.1, 0.005 ); + // Keep an open stat tooltip in step with the figures it describes. + if (this._hoveredStat && !this.$statTooltip.hasClass("hidden")) { + const text = this._hoveredStat.attr("data-tooltip"); + if (text) this.$statTooltip.text(text); + } }, // Appends the month-on-month change marker inside a stat's value: a bold @@ -568,6 +582,47 @@ export const panelMethods = { $value.append($arrow); }, + // Shows or clears a summary stat's hover tooltip. Passing no text removes it, + // so a stale bubble never lingers. Any older native title is dropped too. + _setStatTooltip(statId, text) { + const $stat = $(`#${statId}`).closest(".stat"); + if (!$stat.length) return; + $stat.removeAttr("title"); + if (text) $stat.attr("data-tooltip", text); + else $stat.removeAttr("data-tooltip"); + }, + + // Puts the long-run growth figure in each headline stat's hover tooltip: the + // rise since January 2000 through the first year, and the year-on-year rise + // afterwards. The hovered datum names itself, so the text stays short. + _updateGrowthTooltips(growth) { + const scope = growth && growth.sinceStart ? "since 1 January 2000" : "year on year"; + const apply = (statId, rate) => { + if (!growth || !Number.isFinite(rate)) { + this._setStatTooltip(statId, null); + return; + } + const percent = `${rate < 0 ? "−" : "+"}${(Math.abs(rate) * 100).toFixed(2)}%`; + this._setStatTooltip(statId, `${scope}: ${percent}`); + }; + apply("stat-pop", growth && growth.population); + apply("stat-gdp", growth && growth.gdp); + apply("stat-gdppc", growth && growth.gdpPerCapita); + }, + + // Forecasts the budget a month ahead at the current income and upkeep: the + // hourly net spread over the hours of the next calendar month. + _updateBudgetTooltip(stats, totalHours) { + const net = stats.breakdown ? stats.breakdown.net : null; + if (net === null || !Number.isFinite(net)) { + this._setStatTooltip("stat-budget", null); + return; + } + const expected = net * hoursInNextMonth(totalHours || 0); + const sign = expected < 0 ? "−" : "+"; + this._setStatTooltip("stat-budget", `${sign}€${compact(Math.abs(expected))}/mo`); + }, + // The summary's popularity figure: white while it sits in the neutral band // (40–60%), red when the people disapprove and green when they approve. _updateApprovalStat(approval) { @@ -1178,6 +1233,7 @@ export const panelMethods = { portGdp: regionCity ? memo(caches, "portGdp", regionCity.id, () => this._regionPortGdp(regionCity)) : 0, + naturalGrowth: memo(caches, "growth", k, () => this._naturalTileGdpGrowth(k)), }); return { base: ECONOMY.baseGdpPerCapita, @@ -1206,6 +1262,16 @@ export const panelMethods = { return bonus * sea; }, + // The flat GDP per capita a tile has gained since January 2000, mirroring the + // server: its opening figure times the daily rate, once per day elapsed. + _naturalTileGdpGrowth(k) { + const baseline = this._gdpBaseline.get(k) || 0; + if (!baseline) return 0; + const totalHours = (this.snapshot && this.snapshot.totalHours) || 0; + const days = Math.floor(Math.max(0, totalHours) / HOURS_PER_DAY); + return baseline * ECONOMY.naturalGdpGrowthPerDay * days; + }, + // -------------------------------------------------------- map modes ----- // Applied when the Map modes picker reports a choice. An economic mode needs @@ -1328,7 +1394,7 @@ export const panelMethods = { } const value = factor.amount !== undefined - ? `−€${groupDigits(Math.round(Math.abs(factor.amount)))}` + ? `${factor.amount < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(factor.amount)))}` : `×${factor.multiplier.toFixed(2)}`; $mods.append( $("
").text(`${factor.label} ${value}`) diff --git a/server/game_server.js b/server/game_server.js index 3d8f53f..42a6093 100644 --- a/server/game_server.js +++ b/server/game_server.js @@ -17,6 +17,7 @@ const DELTA_COLLECTIONS = [ "territory", "regions", "population", + "gdpBaseline", "tileEthnicity", "cityStats", "visible", diff --git a/shared/data/economy.js b/shared/data/economy.js index 9c95bd4..2c7cac1 100644 --- a/shared/data/economy.js +++ b/shared/data/economy.js @@ -22,6 +22,10 @@ export const ECONOMY = { startingBudgetFraction: 0.1, startingBudget: 1_000_000_000, basePopulationGrowthRate: 0.005, + // Natural growth of GDP per capita: every day a tile gains this share of the + // GDP per capita it opened the game with. The amount is constant, so the + // relative growth rate naturally tapers off as the years pass. + naturalGdpGrowthPerDay: 0.0001, productionCapacity(gdp) { if (this.gdpPerProductionPoint <= 0) return 0; diff --git a/shared/game_clock.js b/shared/game_clock.js index 4dc7710..9c22a0c 100644 --- a/shared/game_clock.js +++ b/shared/game_clock.js @@ -25,3 +25,13 @@ export function dateString(totalHours) { const hour = date.getUTCHours(); return `${day} ${month} ${year}, ${String(hour).padStart(2, "0")}:00`; } + +// How many in-game hours there are from `totalHours` to the same moment one +// calendar month later. Months differ in length, so a forecast that speaks of +// "the next month" should not assume a fixed 30 days. +export function hoursInNextMonth(totalHours) { + const date = new Date(unixTime(totalHours) * 1000); + const later = new Date(date.getTime()); + later.setUTCMonth(later.getUTCMonth() + 1); + return Math.round((later.getTime() - date.getTime()) / (UNIX_SECONDS_PER_HOUR * 1000)); +} diff --git a/shared/game_state.js b/shared/game_state.js index 3421150..74dcbdf 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -177,6 +177,11 @@ export class GameState { this._populationVersion = 0; this._ethnicityVersion = 0; this._visibleVersion = 0; + // Each land tile's opening GDP per capita, the constant natural growth is + // measured from. Static once the world exists. + this._gdpBaselineVersion = 0; + this._serializedGdpBaselineCache = null; + this.tileGdpBaseline = new Map(); // Approval is expensive to weight over every tile and is read far more // often than politics changes, so both the per-city and per-nation results // are memoised against the politics version (and cleared on territory @@ -271,6 +276,7 @@ export class GameState { this._revealAll(); this._refreshVisibility(); this._initMonthly(); + this._initNaturalGdpGrowth(); this._visibilityDirty = false; this._emitChanged(); } @@ -328,6 +334,9 @@ export class GameState { tickHour() { this.totalHours += 1; + // Natural growth is a whole number of days, so the cached per-capita figures + // only go stale when a new day begins. + if (this.totalHours % HOURS_PER_DAY === 0) this._gdpPerCapitaCache.clear(); this._tickMonthly(); this._tickTraining(); this._tickAirUnits(); diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index 34fd726..fefbf2f 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -84,12 +84,40 @@ export const economyMethods = { regionalLabel: region ? region.name : null, nationalMultiplier: 1 + (national[EFFECT_GDP] || 0), portGdp: this._regionPortGdp(region), + naturalGrowth: this._naturalTileGdpGrowth(k), }); const perCapita = gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors); this._gdpPerCapitaCache.set(k, perCapita); return perCapita; }, + // Snapshots every land tile's opening GDP per capita, so natural growth has a + // constant to build on. The world and its economy must exist by now. + _initNaturalGdpGrowth() { + this.tileGdpBaseline = new Map(); + if (ECONOMY.naturalGdpGrowthPerDay <= 0) return; + for (const k in this.tiles) { + const coords = parseKey(k); + if (!this._isLand(coords)) continue; + const perCapita = this.getTileGdpPerCapita(coords); + if (perCapita > 0) this.tileGdpBaseline.set(k, perCapita); + } + this._gdpPerCapitaCache.clear(); + }, + + // The flat amount a tile's GDP per capita has gained since January 2000: its + // opening figure times the daily rate, once per day elapsed. A constant, so + // the relative growth rate falls as the tile grows richer. + _naturalTileGdpGrowth(k) { + const baseline = this.tileGdpBaseline ? this.tileGdpBaseline.get(k) : 0; + if (!baseline) return 0; + return baseline * ECONOMY.naturalGdpGrowthPerDay * this._gdpGrowthDays(); + }, + + _gdpGrowthDays() { + return Math.floor(Math.max(0, this.totalHours) / HOURS_PER_DAY); + }, + // The flat GDP per capita a region's ports add to every tile it covers: // 1000 per level for each sea tile beside the city, summed over its ports. _regionPortGdp(region) { diff --git a/shared/game_state/monthly.js b/shared/game_state/monthly.js index 3aaa965..fdd5898 100644 --- a/shared/game_state/monthly.js +++ b/shared/game_state/monthly.js @@ -1,8 +1,10 @@ -// Month-over-month tracking for the nation summary bar. Once a calendar month +// Monthly and annual tracking for the nation summary bar. Once a calendar month // rolls over, the nation figures captured at the start of the month that just // ended become the baseline the current month is compared against, so every -// indicator can show whether it rose or fell since "the past month". January -// 2000 has no baseline, so it carries no change indicator. +// indicator can show whether it rose or fell since "the past month". The same +// captures are kept for a rolling year, so a headline figure can be compared +// with the same month a year earlier. January 2000 has neither a past month nor +// a past year, so it carries no change indicator and measures from the start. import { unixTime } from "../game_clock.js"; @@ -19,6 +21,10 @@ export const monthlyMethods = { this._monthIndexNow = this._monthIndexOf(this.totalHours); this._currentMonthStats = this._captureMonthlyStats(); this.monthlyStats = null; + // The figures at the start of every month, so any month can be compared to + // the same month a year earlier. A rolling 13 months is enough to always + // hold the twelve-months-ago baseline. + this._monthStartHistory = [{ index: this._monthIndexNow, stats: this._currentMonthStats }]; }, // Called every hour; only a calendar-month change does any work. @@ -30,6 +36,36 @@ export const monthlyMethods = { this.monthlyStats = this._currentMonthStats; this._currentMonthStats = this._captureMonthlyStats(); this._monthIndexNow = index; + this._monthStartHistory.push({ index, stats: this._currentMonthStats }); + if (this._monthStartHistory.length > 13) this._monthStartHistory.shift(); + }, + + // The relative growth of a nation's headline figures against the same month a + // year earlier. Through 2000 there is no earlier year, so January 2000 stands + // in and the figure is growth since the start of the game. + _growthRates(civ) { + const history = this._monthStartHistory || []; + if (history.length === 0) return null; + const target = this._monthIndexNow - 12; + let baseline = null; + for (const entry of history) { + if (entry.index === target) { + baseline = entry; + break; + } + } + const sinceStart = baseline === null; + if (!baseline) baseline = history[0]; + const was = (baseline.stats || [])[civ] || null; + if (!was || was.population <= 0) return null; + const now = this._civHeadline(civ); + const ratio = (current, previous) => (previous > 0 ? current / previous - 1 : 0); + return { + sinceStart, + population: ratio(now.population, was.population), + gdp: ratio(now.gdp, was.gdp), + gdpPerCapita: ratio(now.gdpPerCapita, was.gdpPerCapita), + }; }, // One record per civilisation of the indicators the summary bar compares. diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index 72005f1..467c461 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -47,6 +47,9 @@ export const serializationMethods = { // tile's GDP modifiers to the city that develops it. regions: this._serializeRegions(), population: this._serializePopulation(), + // Every land tile's opening GDP per capita, the constant natural growth + // is measured from. Static, so it travels once and is then delta-dropped. + gdpBaseline: this._serializeGdpBaseline(), // Sparse per-tile marks of war, so the browser can explain a tile's // GDP per capita without shipping the whole breakdown for every tile. tileGdpPenalties: this._serializeTileValues(this.tileGdpPenalty), @@ -108,6 +111,7 @@ export const serializationMethods = { warfare: this._tileImprovementVersion, cityStats: hashCityStats(cityStats), visible: this._visibleVersion, + gdpBaseline: this._gdpBaselineVersion, }, }; }, @@ -150,6 +154,9 @@ export const serializationMethods = { cultureImpact: this.getCulturalImpact(viewerCiv), ethnicMakeup: this.getCivEthnicMakeup(viewerCiv), breakdown: this.budgetBreakdown(viewerCiv), + // How far the headline figures have grown, against January 2000 in the + // first year and year-on-year afterwards. + growth: this._growthRates(viewerCiv), }; }, @@ -287,6 +294,22 @@ export const serializationMethods = { return result; }, + // A sparse [x, y, value] list of every land tile's opening GDP per capita. It + // never changes, so it is memoised and the delta wire drops it after the first + // snapshot a peer receives. + _serializeGdpBaseline() { + if (this._serializedGdpBaselineCache) return this._serializedGdpBaselineCache; + const result = []; + if (this.tileGdpBaseline) { + for (const [k, value] of this.tileGdpBaseline) { + const coords = parseKey(k); + result.push([coords.x, coords.y, Math.round(value)]); + } + } + this._serializedGdpBaselineCache = result; + return result; + }, + // A sparse [x, y, value] list for a per-tile map, used for the war marks. _serializeTileValues(values) { const result = []; diff --git a/shared/rules.js b/shared/rules.js index 603002a..e940fd2 100644 --- a/shared/rules.js +++ b/shared/rules.js @@ -123,6 +123,7 @@ export function gdpPerCapitaFactors(tile, { regionalLabel = null, nationalMultiplier = 1, portGdp = 0, + naturalGrowth = 0, } = {}) { const factors = []; const terrainMultiplier = tile ? tile.gdpMultiplier || 0 : 0; @@ -159,6 +160,11 @@ export function gdpPerCapitaFactors(tile, { 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 }); } diff --git a/tests/game_clock_test.js b/tests/game_clock_test.js index b896747..8929c47 100644 --- a/tests/game_clock_test.js +++ b/tests/game_clock_test.js @@ -1,5 +1,5 @@ import { TestCase } from "./framework/test_case.js"; -import { dateString, unixTime, START_UNIX_TIME, UNIX_SECONDS_PER_HOUR } from "../shared/game_clock.js"; +import { dateString, unixTime, hoursInNextMonth, START_UNIX_TIME, UNIX_SECONDS_PER_HOUR } from "../shared/game_clock.js"; export class GameClockTest extends TestCase { test_starts_in_the_year_2000() { @@ -22,4 +22,10 @@ export class GameClockTest extends TestCase { this.assertEqual(unixTime(0), START_UNIX_TIME); this.assertEqual(unixTime(5), START_UNIX_TIME + 5 * UNIX_SECONDS_PER_HOUR); } + + test_the_next_month_follows_the_real_calendar() { + // January has 31 days, and February 2000 has 29. + this.assertEqual(hoursInNextMonth(0), 31 * 24); + this.assertEqual(hoursInNextMonth(31 * 24), 29 * 24); + } } diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index e66095e..b827dc6 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -1,7 +1,10 @@ import { TestCase } from "./framework/test_case.js"; import { setupDom, teardownDom } from "./framework/dom.js"; import { GameScreen } from "../client/js/game_screen.js"; -import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES } from "../shared/data.js"; +import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, ECONOMY } from "../shared/data.js"; +import { HOURS_PER_DAY } from "../shared/game_state/constants.js"; +import { hoursInNextMonth } from "../shared/game_clock.js"; +import { compact } from "../shared/text_format.js"; import { parseKey } from "../shared/hex.js"; import { smallState, adjacentLand, grantBuilding } from "./framework/helpers.js"; @@ -52,10 +55,12 @@ export class GameScreenTest extends TestCase { const territory = screen.map.territory; const regions = screen.map.regions; const ethnicity = screen._tileEthnicity; + const baseline = screen._gdpBaseline; // A second snapshot with no state change carries the same versions, so // none of the big per-tile maps should be rebuilt. screen.onState(state.snapshot(0)); this.assert(population === screen._population, "population map is reused"); + this.assert(baseline === screen._gdpBaseline, "the growth baseline is reused"); this.assert(territory === screen.map.territory, "territory map is reused"); this.assert(regions === screen.map.regions, "region map is reused"); this.assert(ethnicity === screen._tileEthnicity, "ethnicity map is reused"); @@ -639,6 +644,18 @@ export class GameScreenTest extends TestCase { this.assertFalse(gdpText.includes("Battle damage"), "a zero modifier is hidden"); this.assertTrue(gdpText.includes("City"), "a real modifier is still shown"); + screen._renderTileGdp({ + base: 10000, + factors: [ + { label: "Natural growth", amount: 150 }, + { label: "Battle damage", amount: -50 }, + ], + total: 10100, + }); + const amountText = env.$("#tile-gdp").text(); + this.assertTrue(amountText.includes("Natural growth +€150"), "a gain shows a plus"); + this.assertTrue(amountText.includes("Battle damage −€50"), "a loss keeps its minus"); + const unit = state.units.find((u) => u.civ === 0); screen.map.onUnitSelected(unit.id); this.assertFalse(env.$("#unit-info").hasClass("hidden")); @@ -1602,6 +1619,93 @@ export class GameScreenTest extends TestCase { teardownDom(env); } } + + async test_stat_tooltips_show_the_long_run_growth() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), makeConfig()); + screen.enter(); + const state = smallState(); + const snapshot = state.snapshot(0); + snapshot.viewerStats.growth = { sinceStart: true, population: 0.0123, gdp: 0.02, gdpPerCapita: 0.01 }; + screen.onState(snapshot); + const tip = (id) => env.$(`#${id}`).closest(".stat").attr("data-tooltip"); + this.assertEqual(tip("stat-pop"), "since 1 January 2000: +1.23%"); + this.assertEqual(tip("stat-gdp"), "since 1 January 2000: +2.00%"); + this.assertEqual(tip("stat-gdppc"), "since 1 January 2000: +1.00%"); + // A year on, the wording and the baseline both change. + snapshot.viewerStats.growth = { sinceStart: false, population: -0.005, gdp: 0.0, gdpPerCapita: 0.004 }; + screen.onState(snapshot); + this.assertEqual(tip("stat-pop"), "year on year: −0.50%"); + this.assertEqual(tip("stat-gdp"), "year on year: +0.00%"); + // A snapshot without a figure leaves no stale tooltip behind. + snapshot.viewerStats.growth = null; + screen.onState(snapshot); + this.assertNull(tip("stat-pop"), "no growth means no tooltip"); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_every_summary_stat_uses_the_same_custom_tooltip() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), makeConfig()); + screen.enter(); + const state = smallState(); + const snapshot = state.snapshot(0); + screen.onState(snapshot); + const stats = snapshot.viewerStats; + // The budget forecasts the next calendar month at the current hourly net. + const expected = stats.breakdown.net * hoursInNextMonth(snapshot.totalHours); + const sign = expected < 0 ? "−" : "+"; + this.assertEqual( + env.$("#stat-budget").closest(".stat").attr("data-tooltip"), + `${sign}€${compact(Math.abs(expected))}/mo` + ); + // Popularity speaks through the same bubble, not a native title. + const $approval = env.$("#stat-approval").closest(".stat"); + this.assertNotNull($approval.attr("data-tooltip"), "popularity has a custom tooltip"); + this.assertNull($approval.attr("title"), "popularity has no native title"); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_the_tile_gdp_breakdown_includes_natural_growth() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), makeConfig()); + screen.enter(); + const state = smallState(); + state.totalHours = 10 * HOURS_PER_DAY; + screen.onState(state.snapshot(0)); + const k = state.tileGdpBaseline.keys().next().value; + const coords = parseKey(k); + const shipped = Math.round(state.tileGdpBaseline.get(k)); + const expected = shipped * ECONOMY.naturalGdpGrowthPerDay * 10; + this.assertApprox( + screen._naturalTileGdpGrowth(k), + expected, + 1e-6, + "the browser mirrors the server's growth" + ); + const breakdown = screen._tileGdpBreakdown( + coords, + 0, + state.cityAt(coords), + screen.map.getTile(coords) + ); + const factor = breakdown.factors.find((f) => f.label === "Natural growth"); + this.assertNotNull(factor, "the breakdown names the growth"); + this.assertApprox(factor.amount, expected, 1e-6, "and adds it to the total"); + screen.leave(); + } finally { + teardownDom(env); + } + } } function findEnemyTile(state) { diff --git a/tests/game_server_test.js b/tests/game_server_test.js index 5cefd34..02bd44d 100644 --- a/tests/game_server_test.js +++ b/tests/game_server_test.js @@ -70,6 +70,7 @@ export class GameServerTest extends TestCase { this.assertNull(delta.territory, "unchanged territory is left out"); this.assertNull(delta.regions, "unchanged regions are left out"); this.assertNull(delta.population, "unchanged population is left out"); + this.assertNull(delta.gdpBaseline, "the static growth baseline is left out"); this.assertNull(delta.tileEthnicity, "unchanged ethnicity is left out"); this.assertNull(delta.cityStats, "unchanged city stats are left out"); this.assertNull(delta.visible, "unchanged visibility is left out"); diff --git a/tests/growth_test.js b/tests/growth_test.js new file mode 100644 index 0000000..5b12613 --- /dev/null +++ b/tests/growth_test.js @@ -0,0 +1,96 @@ +import { TestCase } from "./framework/test_case.js"; +import { smallState } from "./framework/helpers.js"; +import { ECONOMY } from "../shared/data.js"; +import { HOURS_PER_DAY } from "../shared/game_state/constants.js"; +import { parseKey } from "../shared/hex.js"; + +// Natural GDP-per-capita growth: a constant daily amount measured from each +// tile's opening figure, so the relative growth rate tapers over the years. +export class GdpGrowthTest extends TestCase { + firstBaseline(state) { + const k = state.tileGdpBaseline.keys().next().value; + return { k, baseline: state.tileGdpBaseline.get(k), coords: parseKey(k) }; + } + + test_a_day_adds_a_constant_share_of_the_opening_figure() { + const state = smallState(); + const { k, baseline, coords } = this.firstBaseline(state); + this.assertTrue(baseline > 0, "the tile has an opening GDP per capita"); + const opening = state.getTileGdpPerCapita(coords); + this.assertApprox(opening, baseline, 1e-9, "day zero is the baseline"); + for (let i = 0; i < HOURS_PER_DAY; i++) state.tickHour(); + const dayOne = state.getTileGdpPerCapita(coords); + const step = baseline * ECONOMY.naturalGdpGrowthPerDay; + this.assertApprox(dayOne - opening, step, 1e-6, "one day adds one step"); + // Thirty more days add exactly thirty more steps: the amount is constant. + state.totalHours = 31 * HOURS_PER_DAY; + state._gdpPerCapitaCache.clear(); + const dayThirtyOne = state.getTileGdpPerCapita(coords); + this.assertApprox(dayThirtyOne - dayOne, 30 * step, 1e-6, "the daily amount never grows"); + this.assertEqual(k, state.tileGdpBaseline.keys().next().value, "the baseline never moves"); + } + + test_the_relative_growth_rate_falls_as_the_tile_grows() { + const state = smallState(); + const { baseline, coords } = this.firstBaseline(state); + const step = baseline * ECONOMY.naturalGdpGrowthPerDay; + const earlyRate = step / baseline; + state.totalHours = 366 * HOURS_PER_DAY; + state._gdpPerCapitaCache.clear(); + const value = state.getTileGdpPerCapita(coords); + // The same constant step, a year later, is a smaller share of a bigger + // figure: the relative rate has fallen. + const laterRate = step / (value - step); + this.assertTrue(value > baseline, "the tile is richer than it began"); + this.assertTrue(laterRate < earlyRate, "the same gain is now a smaller share"); + } + + test_growth_is_measured_from_january_2000_in_the_first_year() { + const state = smallState(); + const growth = state.viewerStats(0).growth; + this.assertNotNull(growth, "the viewer carries growth figures"); + this.assertTrue(growth.sinceStart, "the first year measures from January 2000"); + this.assertApprox(growth.gdpPerCapita, 0, 1e-9, "nothing has grown yet"); + for (let i = 0; i < HOURS_PER_DAY; i++) state.tickHour(); + const after = state.viewerStats(0).growth; + this.assertTrue(after.sinceStart, "still the first year"); + this.assertTrue(after.gdpPerCapita > 0, "a day of growth is reported"); + this.assertTrue(after.gdp > after.gdpPerCapita, "GDP also carries population growth"); + } + + test_growth_becomes_year_on_year_after_the_first_year() { + const state = smallState(); + const current = state._monthIndexOf(state.totalHours); + // A year on, the same month last year is the baseline. Halve it so the + // comparison has an unmistakable answer. + const past = state._captureMonthlyStats(); + for (const entry of past) { + entry.population = Math.floor(entry.population / 2); + entry.gdp = entry.gdp / 2; + entry.gdpPerCapita = entry.gdpPerCapita / 2; + } + state._monthIndexNow = current; + state._monthStartHistory = [ + { index: current - 13, stats: past }, + { index: current - 12, stats: past }, + { index: current, stats: state._captureMonthlyStats() }, + ]; + const growth = state._growthRates(0); + this.assertFalse(growth.sinceStart, "with a year of history it is year on year"); + this.assertApprox(growth.population, 1, 1e-6, "the population doubled"); + this.assertApprox(growth.gdp, 1, 1e-6, "the GDP doubled"); + } + + test_the_snapshot_ships_the_growth_baseline_once() { + const state = smallState(); + const first = state.snapshot(0); + this.assertSize( + first.gdpBaseline, + state.tileGdpBaseline.size, + "every baseline tile travels" + ); + this.assertNotNull(first.viewerStats.growth, "the viewer's growth travels too"); + // The version never moves, so the delta wire drops the baseline afterwards. + this.assertEqual(first.versions.gdpBaseline, 0, "the baseline version is static"); + } +}