diff --git a/AGENTS.md b/AGENTS.md index 4013936..4a33bfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,9 @@ There is no linter, formatter, or CI. Do not invent commands beyond these. the isolated element and each of its states, not a whole mocked-up screen — open it in a browser and show it to the user. Only commit once the user has seen and approved the preview. +- Whenever an HTML page is shown, open it in the user's Firefox directly + (`firefox `); never render a screenshot and never just print the path + for them to find. - Keep the examples small and self-contained: link the stylesheet and copy in the few assets they need. A short gallery of one small example per visual change is the right size. Do not add the examples to the repository. diff --git a/client/css/style.css b/client/css/style.css index 998cb23..e90bd7f 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -743,6 +743,14 @@ input:focus, select:focus { border-color: var(--accent); } .stat-arrow.up { color: #6ee787; } .stat-arrow.down { color: #f27272; } .stat-arrow.equal { color: var(--muted); } +/* The unified variable display: a unit (currency symbol or commodity icon), a + space-grouped number, then a suffix (a resource's unit or a power of ten). + A flow carries its sign and reads green as income, red as a cost. */ +.value { white-space: nowrap; } +.value .value-icon { width: 13px; height: 13px; vertical-align: -2px; } +.value .value-number { font-variant-numeric: tabular-nums; } +.value .value-number.positive { color: #8fe3a8; } +.value .value-number.negative { color: #ff9a9a; } /* Approval reads white in the neutral 40–60% band, red when the people disapprove and green when they approve. */ .stat-value.approval-neutral { color: var(--text); } diff --git a/client/index.html b/client/index.html index c2e87dc..c4da401 100644 --- a/client/index.html +++ b/client/index.html @@ -395,6 +395,26 @@ + + + diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index 5a249a7..fd6554b 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": "35c46ab", + "date": "2026-09-23", + "subject": "Halved the test suite's runtime and pruned trivial tests", + "body": "" + }, { "hash": "aed6ba9", "date": "2026-09-23", @@ -54,11 +60,5 @@ export const DEVLOG = [ "date": "2026-09-22", "subject": "Turned tile improvements into construction sites with their own budgets", "body": "Every road, railway and tile work now rises as an economic construction site instead of a queue entry. A site gathers its materials over days from reachable regions, refusing any supplier quoting above the price it was budgeted at (a 1% tolerance); a day that buys nothing stalls the site, raising a red mark and letting the nation raise the budget to current prices or cancel it. The upfront budget covers the build cost plus the market value of its steel and high-tech, energy stays a grid draw, and any money left over stays with the finished work as a maintenance reserve.\n\nThat reserve now pays the work's own daily steel and high-tech wear before the treasury is billed, so a well funded work spares the state until it runs dry; the spent money is paid into the nation's regions. Building upgrades run through the same gather-build-maintain machinery, sharing the site's kind flag and target level. The legacy tile-work queue and the market building are gone.\n\nBundles the in-progress chart sizing for the central bank panel." - }, - { - "hash": "6f73e2c", - "date": "2026-09-22", - "subject": "Reworked the world around a fixed sea share and settled coasts", - "body": "World generation now aims at a fixed share of the ice-free tiles (60%) instead of a minimum continent count, bisecting the water level for it rather than sweeping every step, and still joins the ocean into one body. Ice and tundra are the outermost rows only, scaled down on small maps so the fixtures keep usable land.\n\nCities score their site, coastal plains best and landlocked worst, avoiding tundra and desert. Every nation that holds a coast gets a port city on each island it owns, and any landmass left empty is settled by the nearest nation, so no land starts unowned. A coastal city opens with a port already built.\n\nBundles the in-progress work in the tree: the commodity monthly price series and inflation the central bank's chart reads, and the rolling city-to-city migration graph the population map draws as flow arrows." } ]; diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js index 2bd9659..07ca2c0 100644 --- a/client/js/modals/nation.js +++ b/client/js/modals/nation.js @@ -20,6 +20,7 @@ import { renderMonthlyBudget } from "./budget.js"; import { effectsText, money, quotedMoney } from "./format.js"; import { bindTabs } from "./tabs.js"; import { ethnicityChart } from "../ui/ethnicity.js"; +import { resourceNode, valueNode } from "../ui/value.js"; import { lineChart } from "../ui/line_chart.js"; import { trendArrow } from "../ui/trend.js"; import { currencySymbol, currencyFor, rateToNational } from "../currency.js"; @@ -294,7 +295,7 @@ export class NationModal { const $head = $("").append( $("") .append($("").text("Resource")) - .append($("").text("Consumed / day")) + .append($("").text("Consumed /\u00a0day")) .append($("").text("Bought abroad")) .append($("").text("Spent")) .append($("").text("Sold abroad")) @@ -520,7 +521,7 @@ export class NationModal { const $head = $("").append( $("") .append($("").text("Production building")) - .append($("").text("Output / day")) + .append($("").text("Output /\u00a0day")) .append($("").text("Level")) .append($("").text("Private cash")) ); @@ -934,7 +935,7 @@ export class NationModal { { key: "name", label: "City" }, { key: "population", label: "Population", numeric: true }, { key: "gdp", label: "GDP", numeric: true }, - { key: "income", label: "Taxes / h", numeric: true }, + { key: "income", label: "Taxes /\u00a0h", numeric: true }, ]; const $table = $("
"); const $head = $(""); @@ -1012,24 +1013,18 @@ export class NationModal { } const baseline = this._resourceBaseline || {}; const values = {}; - const cell = ($tr, key, value, text, options = {}) => { - values[key] = value; - const $td = $("").text(text); - const arrow = trendArrow(value, baseline[key], options); - if (arrow) $td.append(arrow); - $tr.append($td); - }; + const template = document.getElementById("tpl-resource-store-row"); const $table = $("
"); const columns = [ { label: "Resource" }, { label: "Stored", numeric: true }, - { label: "Consumed / day", numeric: true }, - { label: "Made / day", numeric: true }, - { label: "Exported / day", numeric: true }, - { label: "Imported / day", numeric: true }, + { label: "Consumed /\u00a0day", numeric: true }, + { label: "Made /\u00a0day", numeric: true }, + { label: "Exported /\u00a0day", numeric: true }, + { label: "Imported /\u00a0day", numeric: true }, { label: "Market price", numeric: true }, - { label: "World supply / day", numeric: true }, - { label: "World demand / day", numeric: true }, + { label: "World supply /\u00a0day", numeric: true }, + { label: "World demand /\u00a0day", numeric: true }, ]; const $head = $(""); const $headRow = $(""); @@ -1056,30 +1051,37 @@ export class NationModal { const priceValue = stats.price || 0; const supplyValue = Math.max(0, stats.supply || 0); const demandValue = Math.max(0, stats.demand || 0); - const $tr = $(""); - const $name = $(""); - $name.append( - $("") - .attr("src", `assets/${proto.icon}`) - .attr("alt", proto.name) - ); - $name.append($("").text(` ${proto.name}`)); - $tr.append($name); + const $row = $(template.content.cloneNode(true).querySelector("tr")); + const $cells = $row.children("td"); + $cells.eq(0).find(".resource-icon") + .attr("src", `assets/${proto.icon}`) + .attr("alt", proto.name); + $cells.eq(0).find("span").text(proto.name); + let column = 1; + const cell = (key, value, $content, options = {}) => { + values[key] = value; + const $td = $cells.eq(column); + column += 1; + $td.append($content); + const arrow = trendArrow(value, baseline[key], options); + if (arrow) $td.append(arrow); + }; if (energy) { - $tr.append($("").text("—")); + $cells.eq(column).text("—"); + column += 1; } else { - cell($tr, `${id}:stored`, storedValue, formatResourceAmount(id, storedValue)); + cell(`${id}:stored`, storedValue, resourceNode(id, storedValue)); } - cell($tr, `${id}:consumed`, consumedValue, formatResourceAmount(id, consumedValue)); - cell($tr, `${id}:made`, madeValue, formatResourceAmount(id, madeValue)); - cell($tr, `${id}:exported`, exportedValue, formatResourceAmount(id, exportedValue)); - cell($tr, `${id}:imported`, importedValue, formatResourceAmount(id, importedValue)); - cell($tr, `${id}:price`, priceValue, this._resourcePriceText(proto, priceValue), { + cell(`${id}:consumed`, consumedValue, resourceNode(id, consumedValue)); + cell(`${id}:made`, madeValue, resourceNode(id, madeValue)); + cell(`${id}:exported`, exportedValue, resourceNode(id, exportedValue)); + cell(`${id}:imported`, importedValue, resourceNode(id, importedValue)); + cell(`${id}:price`, priceValue, this._resourcePriceNode(proto, priceValue), { doubleAt: 0.1, }); - cell($tr, `${id}:supply`, supplyValue, formatResourceAmount(id, supplyValue)); - cell($tr, `${id}:demand`, demandValue, formatResourceAmount(id, demandValue)); - $body.append($tr); + cell(`${id}:supply`, supplyValue, resourceNode(id, supplyValue)); + cell(`${id}:demand`, demandValue, resourceNode(id, demandValue)); + $body.append($row); } this._resourceLast = values; $table.append($head, $body); @@ -1107,9 +1109,9 @@ export class NationModal { const columns = [ { label: "Resource" }, { label: "Major exporter" }, - { label: "Exported / day", numeric: true }, + { label: "Exported /\u00a0day", numeric: true }, { label: "Major importer" }, - { label: "Imported / day", numeric: true }, + { label: "Imported /\u00a0day", numeric: true }, ]; const $head = $(""); const $headRow = $(""); @@ -1169,14 +1171,10 @@ export class NationModal { // A market price reads in euro per the resource's own unit; tiny energy prices // keep enough decimals to be legible. - _resourcePriceText(proto, price) { + _resourcePriceNode(proto, price) { const value = price === undefined || price === null ? 0 : price; - const text = value >= 1000 - ? groupDigits(Math.round(value)) - : value >= 1 - ? value.toFixed(0) - : value.toFixed(4); - return `${currencySymbol()}${text} / ${proto.unit}`; + const decimals = value >= 1 ? 0 : 4; + return valueNode(value, { unit: "money", suffix: ` /\u00a0${proto.unit}`, decimals }); } _makeRow($list, proto, index, kind) { @@ -1336,7 +1334,7 @@ export class NationModal { const columns = [ { key: "name", label: "Nation" }, { key: "gdp", label: "GDP", numeric: true }, - { key: "gdpPerCapita", label: "GDP / capita", numeric: true }, + { key: "gdpPerCapita", label: "GDP /\u00a0capita", numeric: true }, { key: "population", label: "Population", numeric: true }, { key: "approval", label: "Approval", numeric: true }, { key: "status", label: "Status" }, diff --git a/client/js/ui/value.js b/client/js/ui/value.js new file mode 100644 index 0000000..f938cb8 --- /dev/null +++ b/client/js/ui/value.js @@ -0,0 +1,82 @@ +// The unified variable display. Every figure the player sees is assembled here +// so it reads the same way everywhere: +// [unit] a currency symbol or a commodity icon, +// [number] space-grouped thousands, carrying an explicit sign and a colour +// only when the figure is a flow (a change, income, a cost), +// [suffix] the resource's own unit, or a power-of-ten marker for a stat. +// Stocks (population, GDP, stored goods) are unsigned and uncoloured; deltas +// (budget lines, trade) are signed and green or red. + +import { formatNumber, splitCompact } from "../../../shared/text_format.js"; +import { formatResourceAmount } from "../../../shared/resources.js"; +import { resourceById } from "../../../shared/data.js"; +import { currencySymbol } from "../currency.js"; + +// The leading unit of a value: the currency symbol, a commodity icon, or a +// plain unit string. Returns a node, or null when the figure has no unit. +function unitNode(unit) { + if (unit === "money") return document.createTextNode(currencySymbol()); + const proto = unit && resourceById(unit); + if (proto) { + return $("") + .attr("src", `assets/${proto.icon}`) + .attr("alt", proto.name); + } + if (unit) return document.createTextNode(unit); + return null; +} + +// A value as text, for titles, tooltips and anywhere a node cannot go. The unit +// here can only be a symbol or string, never an icon. +export function valueText(amount, { + unit = null, + signed = false, + suffix = "", + decimals = 0, + compact = false, +} = {}) { + const symbol = unit === "money" ? currencySymbol() : unit && resourceById(unit) ? "" : unit || ""; + const parts = compact + ? splitCompact(amount) + : { number: formatNumber(amount, { decimals, signed }), suffix: "" }; + return `${symbol}${parts.number}${suffix || parts.suffix}`; +} + +// A value as a DOM node. `unit` is "money", a resource id (drawn as its icon), +// or a plain string; `signed` marks a flow so the number gains a sign and a +// colour; `suffix` is appended after the number, or, when `compact` is set, +// taken from the power-of-ten split of the figure. +export function valueNode(amount, { + unit = null, + signed = false, + suffix = "", + decimals = 0, + compact = false, +} = {}) { + const $span = $(""); + const head = unitNode(unit); + if (head) $span.append(head); + const parts = compact + ? splitCompact(amount) + : { number: formatNumber(amount, { decimals, signed }), suffix: "" }; + const $number = $("").text(parts.number); + if (signed && !compact) { + $number.addClass(amount < 0 ? "negative" : amount > 0 ? "positive" : "neutral"); + } + $span.append($number); + const tail = suffix || parts.suffix; + if (tail) $span.append(document.createTextNode(tail)); + return $span; +} + +// One commodity amount in its own unit. Energy steps through the named units it +// is metered in; the storable materials read their base unit, grouped, with the +// icon shown only when the surrounding row does not already name the resource. +export function resourceNode(id, amount, { icon = false } = {}) { + if (id === "energy") { + return $("").text(formatResourceAmount(id, amount)); + } + const proto = resourceById(id); + const suffix = proto ? `\u00a0${proto.unit}` : ""; + return valueNode(amount, { unit: icon ? id : null, suffix }); +} diff --git a/shared/text_format.js b/shared/text_format.js index 061b858..0c04ad8 100644 --- a/shared/text_format.js +++ b/shared/text_format.js @@ -16,6 +16,31 @@ export function groupDigits(value) { return (truncated < 0 ? "-" : "") + grouped; } +// The canonical number for a variable display: digits grouped in threes with +// non-breaking spaces and, when `signed`, an explicit plus or minus. Callers +// pass an unsigned figure for a stock; a flow passes `signed` so it reads as a +// movement. This is what every money, resource and stat figure renders through. +export function formatNumber(value, { decimals = 0, signed = false } = {}) { + const amount = Number(value); + const safe = Number.isFinite(amount) ? amount : 0; + const negative = safe < 0; + const sign = signed ? (negative ? "\u2212" : "+") : (negative ? "\u2212" : ""); + const fixed = Math.abs(safe).toFixed(decimals); + const [whole, fraction] = fixed.split("."); + const grouped = groupDigits(Number(whole)); + return sign + (fraction ? `${grouped}.${fraction}` : grouped); +} + +// Splits a compact figure into its mantissa and power-of-ten suffix, so the +// suffix can sit apart from the number in the unified display. `-1.5M` becomes +// the number `-1.5` and the suffix `M`. +export function splitCompact(value) { + const text = compact(value); + const match = /^(-?[0-9.]+)([KMBT]?)$/.exec(text); + if (!match) return { number: text, suffix: "" }; + return { number: match[1].replace("-", "\u2212"), suffix: match[2] }; +} + function threeSignificant(value) { const digits = Math.floor(Math.log(value) / Math.log(10)) + 1; const decimals = Math.max(0, 3 - digits); diff --git a/tests/text_format_test.js b/tests/text_format_test.js index ef07463..d6e58a0 100644 --- a/tests/text_format_test.js +++ b/tests/text_format_test.js @@ -1,5 +1,5 @@ import { TestCase } from "./framework/test_case.js"; -import { groupDigits, compact, hours, improvements } from "../shared/text_format.js"; +import { groupDigits, compact, formatNumber, splitCompact, hours, improvements } from "../shared/text_format.js"; export class TextFormatTest extends TestCase { test_number_and_list_formatting() { @@ -23,4 +23,22 @@ export class TextFormatTest extends TestCase { this.assertEqual(improvements([]), "None"); this.assertEqual(improvements(["Eiffel Tower", "Louvre"]), "Eiffel Tower, Louvre"); } + + test_the_canonical_number_carries_a_sign_and_decimals() { + this.assertEqual(formatNumber(0), "0"); + this.assertEqual(formatNumber(1234), "1\u00a0234"); + this.assertEqual(formatNumber(1234567), "1\u00a0234\u00a0567"); + this.assertEqual(formatNumber(-1234), "\u22121\u00a0234", "a stock still shows its sign"); + this.assertEqual(formatNumber(1234, { signed: true }), "+1\u00a0234"); + this.assertEqual(formatNumber(-1234, { signed: true }), "\u22121\u00a0234"); + this.assertEqual(formatNumber(1.5, { decimals: 2 }), "1.50"); + this.assertEqual(formatNumber(1234.567, { decimals: 2 }), "1\u00a0234.57"); + } + + test_a_compact_figure_splits_into_number_and_suffix() { + this.assertEqual(splitCompact(999), { number: "999", suffix: "" }); + this.assertEqual(splitCompact(1234), { number: "1.23", suffix: "K" }); + this.assertEqual(splitCompact(1234567), { number: "1.23", suffix: "M" }); + this.assertEqual(splitCompact(-2500), { number: "\u22122.5", suffix: "K" }); + } } diff --git a/tests/value_test.js b/tests/value_test.js new file mode 100644 index 0000000..0e30543 --- /dev/null +++ b/tests/value_test.js @@ -0,0 +1,42 @@ +import { TestCase } from "./framework/test_case.js"; +import { setupDom, teardownDom } from "./framework/dom.js"; +import { valueText, valueNode, resourceNode } from "../client/js/ui/value.js"; +import { setCurrency } from "../client/js/currency.js"; + +export class ValueTest extends TestCase { + async test_a_value_reads_unit_first_then_a_signed_number() { + const env = await setupDom(); + try { + setCurrency({ symbol: "€" }); + this.assertEqual(valueText(1234, { unit: "money" }), "€1\u00a0234"); + this.assertEqual( + valueText(-1234, { unit: "money", signed: true }), + "€\u22121\u00a0234", + "a cost is signed and reads the symbol first" + ); + this.assertEqual(valueText(1500000, { compact: true }), "1.5M"); + + const $positive = valueNode(1234, { unit: "money", signed: true }); + this.assertEqual($positive.text(), "€+1\u00a0234"); + this.assertTrue($positive.find(".value-number").hasClass("positive")); + const $negative = valueNode(-1234, { unit: "money", signed: true }); + this.assertTrue($negative.find(".value-number").hasClass("negative")); + const $stock = valueNode(1234, { unit: "money" }); + this.assertFalse($stock.find(".value-number").hasClass("positive"), "a stock is uncoloured"); + } finally { + teardownDom(env); + } + } + + async test_a_resource_reads_its_icon_and_unit() { + const env = await setupDom(); + try { + const plain = resourceNode("steel", 1234); + this.assertEqual(plain.text(), "1\u00a0234\u00a0t", "the material keeps its unit"); + const withIcon = resourceNode("steel", 1234, { icon: true }); + this.assertEqual(withIcon.find("img").length, 1, "the icon leads the value"); + } finally { + teardownDom(env); + } + } +}