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"); + } +}