diff --git a/client/css/style.css b/client/css/style.css index 3b24723..4c2c04e 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -772,6 +772,27 @@ input:focus, select:focus { border-color: var(--accent); } .player-name { color: var(--muted); } .admin-label { color: #ffcf7a; font-size: 12px; } +/* The nation's resource pool, simple economy only: a two-by-two grid of the + storable goods, each a big icon and its grouped amount with no label. The + amount is pushed to the right edge and reserves room for the largest figure + the pool can reach (99 999 999 999: eleven digits and three separators), so + the columns never resize as the stocks grow. */ +.top-resources { display: grid; grid-template-columns: repeat(2, auto); gap: 6px 20px; margin-left: 4px; } +.top-resource { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-variant-numeric: tabular-nums; + padding: 3px 8px 3px 6px; + border: 1px solid rgba(150, 190, 240, 0.22); + border-radius: 7px; +} +.top-resource img { width: 24px; height: 24px; display: block; } +.top-resource-value { flex: 1; min-width: 14ch; text-align: right; } +/* The chip's flex gap already spaces the arrow, so drop the stat arrow's own. */ +.top-resource .stat-arrow { margin-left: 0; } + .top-stats { display: flex; gap: 16px; margin-left: auto; } .stat { display: flex; flex-direction: column; align-items: flex-end; } .stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); } diff --git a/client/index.html b/client/index.html index 60efc45..76b8b1c 100644 --- a/client/index.html +++ b/client/index.html @@ -125,6 +125,7 @@
Budget¤0
Popularity—
+
60 FPS
diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index 24924ff..b51ca60 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -1,6 +1,12 @@ // Generated by scripts/generate-devlog.js from `git log`; do not edit. // The pre-commit hook refreshes it so the main menu shows the latest commits. export const DEVLOG = [ + { + "hash": "d5305da", + "date": "2026-09-25", + "subject": "Gave the world a simple economy: one pool per nation and a global market", + "body": "The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days.\n\nMoney is euro only: a per-inhabitant daily tax funds a treasury spent in a global market, where buy and sell lots trade against the world stock and move its price. The hard economy keeps its per-nation currencies, central banks and exchange rates.\n\nSimple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy." + }, { "hash": "49613f2", "date": "2026-09-24", @@ -54,11 +60,5 @@ export const DEVLOG = [ "date": "2026-09-23", "subject": "Stopped a tile-work rebuild from discarding every cached trade walk", "body": "The trade graph's cache stamp mixed in the tile-improvement version, which a mine, mill or plant rebuilt in place bumps many times a day, so every city's sea-lane flood was thrown away and rebuilt. The stamp now keys on the transport network, the territory and the storage-node set alone: over ten days the rebuilds fall from hundreds to the 45 the world needs, and the daily resource tick from about 700 ms to about 150 ms.\n\nThe snapshot path is memoised against the counters that actually move it -- the per-civ headline population and GDP, the per-city building and per-nation government/research modifiers, the port and food bonuses, the road and railway tile counts and the ethnic make-up rebuild only when their version changes; the flattened visible set is cached alongside explored; and the HUD upkeep sums without building the labelled budget breakdown. DELTA_COLLECTIONS had drifted from the snapshot's key names (it listed gdpBaseline while the state ships productionBaseline, and tileEthnicity while the version is ethnicity), so the static baseline was re-sent as JSON on every broadcast; the collection/version pairs are now explicit.\n\nTogether a broadcast falls from roughly 60 ms to 18 ms and the bytes for fifty of them from 57 MB to 24 MB, and 200 in-game hours simulate in about a third of the time. regionTiles caches the per-city owned lists behind the daily economy walks, and supplier reserves read a region's headcount without deriving its GDP; getCityEconomy itself stays uncached outside a snapshot so a simulation read still reflects a direct population edit at once." - }, - { - "hash": "8042991", - "date": "2026-09-23", - "subject": "Made producers pay their own way and settled the world to steady prices", - "body": "- 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.\n- Private producers settle their daily wear from their own cash and borrow the shortfall from the central bank, so their cash never goes negative.\n- 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.\n- 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).\n- The shipped month report carries its label again, so the budget header and pager name the month." } ]; diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index a2672d0..c3eed39 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -34,6 +34,7 @@ import { RESOURCE_BUILDINGS, resourceById, RESOURCES, + STORABLE_RESOURCE_IDS, RESOURCE_RULES, resourceBuildingOutputAt, tileImprovementById, @@ -145,6 +146,11 @@ export const panelMethods = { $("#top-panel .stat") .on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget))) .on("mouseleave", () => this._hideStatTooltip()); + // The pool chips do too, and they are rebuilt on some snapshots, so the + // handler is delegated rather than bound to the elements themselves. + $("#top-panel") + .on("mouseenter", ".top-resource", (event) => this._showStatTooltip($(event.currentTarget))) + .on("mouseleave", ".top-resource", () => this._hideStatTooltip()); // A click anywhere outside the stack menu dismisses it. $(document).on("mousedown.stackmenu", (event) => { @@ -866,6 +872,59 @@ export const panelMethods = { // ------------------------------------------------------- top panel ----- + // The nation's resource pool, shown only in the simple economy: one icon and + // its grouped amount per storable good, no label or unit. An arrow beside the + // amount says whether the pool is filling or draining -- production against + // consumption only, so market purchases do not move it -- and the chip's + // tooltip spells both figures out. The hard economy keeps its per-region + // stores and shows nothing here. + _updateTopResources(stats) { + const $list = $("#top-resources"); + if (!$list.length) return; + const simple = this.economyModel === "simple"; + $list.toggleClass("hidden", !simple); + if (!simple) { + $list.empty(); + return; + } + const summary = stats.resources || {}; + const stock = summary.stock || {}; + const production = summary.production || {}; + const consumed = summary.consumed || {}; + for (const id of STORABLE_RESOURCE_IDS) { + const proto = resourceById(id); + if (!proto) continue; + // Reuse the chip across snapshots so a hovered tooltip is not anchored to + // a node the next broadcast throws away. + let $chip = $list.children(`.top-resource[data-resource='${id}']`); + if (!$chip.length) { + $chip = $("") + .attr("data-resource", id) + .append( + $("") + .attr("src", `assets/${proto.icon}`) + .attr("alt", proto.name) + ) + .append($("")); + $list.append($chip); + } + const made = production[id] || 0; + const used = consumed[id] || 0; + const $value = $chip.find(".top-resource-value").text(groupDigits(stock[id] || 0)); + $chip.find(".stat-arrow").remove(); + const net = made - used; + const $arrow = $(""); + if (Math.abs(net) <= 0.5) $arrow.text("=").addClass("equal"); + else $arrow.text(net > 0 ? "▲" : "▼").addClass(net > 0 ? "up" : "down"); + $value.after($arrow); + $chip.attr( + "data-tooltip", + `Produced ${formatResourceAmount(id, made)}/day · ` + + `Consumed ${formatResourceAmount(id, used)}/day` + ); + } + }, + _updateTopPanel(state) { const civ = this.civilisations[this.localCiv]; if (civ) { @@ -877,6 +936,7 @@ export const panelMethods = { $("#admin-label").toggle(this.network.isAdmin); $("#stat-date").text(dateString(state.totalHours)); const stats = state.viewerStats || {}; + this._updateTopResources(stats); // The nation's figures one calendar month ago, when there is one, so each // stat can show whether it rose or fell since. const month = ((state.monthly || [])[this.localCiv]) || null; diff --git a/shared/resources.js b/shared/resources.js index 580c544..f46b64d 100644 --- a/shared/resources.js +++ b/shared/resources.js @@ -27,21 +27,16 @@ export function formatEnergy(kwh) { } // A material in its own friendly unit: tonnes step up through kt and Mt, -// carats through kct, high-tech through ku. +// carats through kct, high-tech through ku. The prefix and the divisor move +// together, so a million tonnes reads as 1 Mt rather than 1 kt. export function formatResourceAmount(id, value) { if (id === "energy") return formatEnergy(value); const amount = Math.max(0, value); - let unit = resourceById(id) ? resourceById(id).unit : ""; - let scaled = amount; - let prefix = ""; - if (amount >= 1e9) { - scaled = amount / 1e9; - prefix = "M"; - } else if (amount >= 1e6) { - scaled = amount / 1e6; - prefix = "k"; - } - return `${trim(scaled)} ${prefix}${unit}`; + const unit = resourceById(id) ? resourceById(id).unit : ""; + if (amount >= 1e12) return `${trim(amount / 1e12)} T${unit}`; + if (amount >= 1e9) return `${trim(amount / 1e9)} G${unit}`; + if (amount >= 1e6) return `${trim(amount / 1e6)} M${unit}`; + return `${trim(amount)} ${unit}`; } function trim(value) { diff --git a/tests/resources_test.js b/tests/resources_test.js index da98308..17c5579 100644 --- a/tests/resources_test.js +++ b/tests/resources_test.js @@ -50,8 +50,9 @@ export class ResourceRulesTest extends TestCase { test_material_units_step_up() { this.assertEqual(formatResourceAmount("steel", 250), "250 t"); - this.assertTrue(formatResourceAmount("steel", 2_500_000).includes("kt")); - this.assertTrue(formatResourceAmount("steel", 2_500_000_000).includes("Mt")); + this.assertEqual(formatResourceAmount("steel", 2_500), "2\u00a0500 t"); + this.assertEqual(formatResourceAmount("steel", 2_500_000), "2.5 Mt"); + this.assertEqual(formatResourceAmount("steel", 2_500_000_000), "2.5 Gt"); this.assertTrue(formatResourceAmount("luxury", 2500).includes("ct")); } diff --git a/tests/simple_ui_test.js b/tests/simple_ui_test.js index d2e8b5b..ebab923 100644 --- a/tests/simple_ui_test.js +++ b/tests/simple_ui_test.js @@ -1,7 +1,10 @@ import { TestCase } from "./framework/test_case.js"; import { setupDom, teardownDom } from "./framework/dom.js"; import { CityModal, NationModal } from "../client/js/modals.js"; +import { panelMethods } from "../client/js/game_screen/panels.js"; import { GOVERNMENTS, TECHNOLOGIES } from "../shared/data.js"; +import { groupDigits } from "../shared/text_format.js"; +import { formatResourceAmount } from "../shared/resources.js"; import { simpleState, smallState, cityOf } from "./framework/helpers.js"; import { setCurrency, @@ -195,4 +198,60 @@ export class SimpleUiTest extends TestCase { this.assertNotNull(entry, "the city has stats"); this.assertEqual(entry.resources.energy, undefined, "the hard city has no power field"); } + + // The top panel gains one chip per storable good in simple mode, each an icon + // and the full grouped pool. An arrow reads production against consumption, + // and the chip's tooltip spells both out. The hard economy keeps its + // per-region stores and shows nothing here. + async test_the_top_panel_shows_the_pool_in_simple_mode() { + const env = await setupDom(); + try { + const screen = { economyModel: "simple", _updateTopResources: panelMethods._updateTopResources }; + screen._updateTopResources({ + resources: { + stock: { steel: 1_000_000, food: 500_000, luxury: 500_000, hightech: 20_000 }, + production: { steel: 1_500, food: 500, luxury: 0, hightech: 0 }, + consumed: { steel: 1_000, food: 600, luxury: 10, hightech: 5 }, + }, + }); + const chips = env.$("#top-resources .top-resource"); + this.assertSize(chips, 4, "one chip per storable good"); + this.assertEqual( + chips.eq(0).find(".top-resource-value").text(), + groupDigits(1_000_000), + "the steel pool is grouped in full, not abbreviated" + ); + this.assertTrue( + chips.eq(0).find("img").attr("src").includes("icon_steel"), + "the chip leads with the resource's icon" + ); + // Steel makes 1 500 and uses 1 000, so the pool is filling; food makes + // less than it eats, so it is draining. + this.assertTrue(chips.eq(0).find(".stat-arrow").hasClass("up"), "a growing pool arrow up"); + this.assertTrue(chips.eq(1).find(".stat-arrow").hasClass("down"), "a draining pool arrow down"); + const tooltip = chips.eq(0).attr("data-tooltip"); + this.assertTrue( + tooltip.includes(`Produced ${formatResourceAmount("steel", 1_500)}/day`), + "the tooltip names production" + ); + this.assertTrue( + tooltip.includes(`Consumed ${formatResourceAmount("steel", 1_000)}/day`), + "the tooltip names consumption" + ); + } finally { + teardownDom(env); + } + } + + async test_the_top_panel_hides_the_pool_in_hard_mode() { + const env = await setupDom(); + try { + const screen = { economyModel: "hard", _updateTopResources: panelMethods._updateTopResources }; + screen._updateTopResources({ resources: { stock: { steel: 1 } } }); + this.assertTrue(env.$("#top-resources").hasClass("hidden"), "hard mode shows no pool"); + this.assertSize(env.$("#top-resources .top-resource"), 0, "no chips are left behind"); + } finally { + teardownDom(env); + } + } }