Rolled the commodity index over ninety days and gave the market an equilibrium

The central bank now charts one point a day for a rolling ninety days, shipped rebased to 100, with alternating month columns named once along the bottom so no two dates collide; short games leave future days blank and the axis floor is pinned at 0 with the 100 baseline labelled. The market no longer clamps a price to 0.5-1.8x base: the day's supply and demand set an equilibrium (base * demand/supply) and the price eases toward it, so a sustained shortage lifts a good well past its old band while a glut cheapens it, and the price settles instead of compounding to infinity. The nation modal holds each scroll position across a snapshot rebuild so the foreign reserves below the fold stay put.
This commit is contained in:
2026-09-23 18:54:07 +02:00
parent 7dbbe8696f
commit 31683be27d
13 changed files with 353 additions and 78 deletions
+6
View File
@@ -1580,6 +1580,12 @@ input:focus, select:focus { border-color: var(--accent); }
stroke-dasharray: 3 3;
}
.line-chart-label { fill: var(--muted); font-size: 9px; }
.line-chart-baseline-label { fill: rgba(255, 255, 255, 0.6); }
.line-chart-month { fill: var(--text); font-size: 9px; }
/* Alternating month columns behind the index lines, so a month is named once
at the bottom rather than a date at each end. */
.line-chart-band-even { fill: rgba(255, 255, 255, 0.03); }
.line-chart-band-odd { fill: rgba(255, 255, 255, 0.1); }
.inflation-table { margin-top: 4px; }
.inflation-swatch {
display: inline-block;
+6 -6
View File
@@ -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": "7dbbe86",
"date": "2026-09-23",
"subject": "Gave the tax boxes a stepper and kept edits from the snapshot",
"body": ""
},
{
"hash": "965f0bf",
"date": "2026-09-23",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-22",
"subject": "Showed idle trade routes and flagged migrant flows on the economic maps",
"body": "The delivery graph now ships every route the viewer's own storage nodes could trade over, not just the legs that ran, and keeps exports as well as imports so goods leaving the nation appear. The map draws the idle routes grey beneath the active white legs, with a key entry for them.\n\nThe population map replaces its bare flow arrows with the arrows plus a disc textured with the origin nation's flag drifting from city to city, mirroring the merchandise that rides the trade graph."
},
{
"hash": "aa726ff",
"date": "2026-09-22",
"subject": "Made queued buildings gather their materials and rise ten times faster",
"body": "City building orders now keep their production-queue slot but open in a materials phase: the order reserves the market value of its steel and high-tech, buys from the nation's own stores and reachable suppliers once a day, and only joins a construction line once the bill is settled, so a city with no supplier simply waits. The queue, the city panel and the quick view show the gathering progress instead of a stalled bar.\n\nBuildings and tile improvements now take a tenth of the old time; unit training is untouched. Any material money left over is returned when the work completes."
}
];
+4 -2
View File
@@ -1944,10 +1944,12 @@ export const panelMethods = {
takeTax: stats.taxTake || { sales: 0, export: 0, import: 0 },
// The world commodity price move of the last day, for the central bank.
commodityInflation: stats.commodityInflation || 0,
// The monthly world-price series and each commodity's index and
// monthly/yearly inflation, for the central bank's chart.
// The daily world-price index series, each commodity's index and
// monthly/yearly inflation, for the central bank's chart. The clock dates
// the chart's rolling window.
resourcePriceHistory: (this.snapshot && this.snapshot.resourcePriceHistory) || [],
resourceInflation: (this.snapshot && this.snapshot.resourceInflation) || [],
totalHours: (this.snapshot && this.snapshot.totalHours) || 0,
cities: (this.snapshot && this.snapshot.cityStats) || [],
};
},
+49 -22
View File
@@ -14,6 +14,7 @@ import {
RESOURCES,
RESOURCE_IDS,
resourceById,
PRICE_INDEX_WINDOW_DAYS,
} from "../../../shared/data.js";
import { formatResourceAmount } from "../../../shared/resources.js";
import { renderMonthlyBudget } from "./budget.js";
@@ -26,11 +27,7 @@ import { lineChart } from "../ui/line_chart.js";
import { trendArrow } from "../ui/trend.js";
import { currencySymbol, currencyFor, rateToNational } from "../currency.js";
import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
import { MONTH_NAMES } from "../../../shared/game_clock.js";
// The central bank charts a rolling window of the last two years, so its
// monthly commodity index stays 24 points instead of growing with the game.
const PRICE_INDEX_WINDOW_MONTHS = 24;
import { MONTH_NAMES, unixTime } from "../../../shared/game_clock.js";
// Each research theme borrows the building-card icon of its field, so a
// technology card reads with the same visual grammar as a building.
@@ -810,10 +807,13 @@ export class NationModal {
$reserves.append($table);
}
// The commodity price index chart and the per-commodity inflation table. Each
// series is a commodity's world price rebased to 100 in January 2000, with the
// live point appended so the current month reads to today. Only the last two
// years are plotted, so the window rolls instead of the chart growing.
// The commodity price index chart and the per-commodity inflation table. The
// server ships each commodity's world price already rebased to 100 in January
// 2000, one point per day. The plot has a fixed ninety-day axis, so a young
// game leaves its future days blank rather than stretching a short line across
// the width; once the series is full it rolls, dropping the oldest day. The
// axis is shaded in alternating month columns, each named once along the
// bottom, so no exact date has to compete for space at the ends.
_renderCommodityChart(money) {
const $container = this.$economyCentralBank.find(".central-bank-prices");
if (!$container.length) return;
@@ -821,32 +821,52 @@ export class NationModal {
const inflation = new Map((money.resourceInflation || []).map((entry) => [entry.id, entry]));
const series = [];
const rows = [];
// The month the plotted window starts in, as an offset from January 2000.
let windowStart = 0;
let count = 0;
for (const [id, prices] of history) {
const resource = resourceById(id);
if (!resource || !prices || prices.length === 0) continue;
const base = prices[0] > 0 ? prices[0] : 1;
const values = prices.map((price) => (price / base) * 100);
const info = inflation.get(id) || { index: 100, monthly: 0, yearly: 0 };
if (Math.abs(info.index - values[values.length - 1]) > 1e-6) values.push(info.index);
windowStart = Math.max(windowStart, values.length - PRICE_INDEX_WINDOW_MONTHS);
const values = prices.slice(-PRICE_INDEX_WINDOW_DAYS);
count = Math.max(count, values.length);
series.push({ label: resource.name, colour: resource.colour, values });
rows.push({ resource, info });
rows.push({ resource, info: inflation.get(id) || { index: 100, monthly: 0, yearly: 0 } });
}
for (const entry of series) entry.values = entry.values.slice(windowStart);
$container.empty();
$container.append($("<div class='subhead'></div>").text("Commodity prices"));
$container.append(
$("<div class='desc'></div>").text(
"Each commodity's world price as an index, 100 in January 2000."
"Each commodity's world price as an index (100 in January 2000), " +
"one point a day over the last ninety days."
)
);
if (series.length === 0) return;
// The window's first day: the chart's last point is today, so the leftmost
// slot is that many days back. Slots past the newest point are future days.
const lastDay = Math.floor((money.totalHours || 0) / HOURS_PER_DAY);
const firstDay = lastDay - (count - 1);
// A slot's calendar month, keyed year*12+month so a band breaks only when
// the month really changes (and across a year boundary).
const slotDate = (i) => new Date(unixTime((firstDay + i) * HOURS_PER_DAY) * 1000);
const slotMonth = (i) => {
const date = slotDate(i);
return date.getUTCFullYear() * 12 + date.getUTCMonth();
};
// One band per month in the fixed window, named once at the bottom. A
// partial month at either edge still gets a full column and its name.
const xBands = [];
let bandStart = 0;
for (let i = 1; i <= PRICE_INDEX_WINDOW_DAYS; i++) {
if (i < PRICE_INDEX_WINDOW_DAYS && slotMonth(i) === slotMonth(bandStart)) continue;
xBands.push({
start: bandStart,
end: i - 1,
label: MONTH_NAMES[slotDate(bandStart).getUTCMonth()].slice(0, 3),
});
bandStart = i;
}
const options = {
xLabel: (i) =>
`${MONTH_NAMES[(windowStart + i) % 12].slice(0, 3)} ` +
`${2000 + Math.floor((windowStart + i) / 12)}`,
window: PRICE_INDEX_WINDOW_DAYS,
min: 0,
xBands,
};
// The panel scrolls, but the chart must not swallow it: size it to the
// panel's width and cap it at half the visible height, so the inflation
@@ -1321,6 +1341,12 @@ export class NationModal {
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
this.currentGovernment = currentGovernment;
// A snapshot rebuilds the open panels in place; hold each scroll container
// where the player left it so the panel does not jump back to the top under
// them (the central bank's foreign reserves sit below the fold).
const scrolls = this.$modal.find(".modal-body")
.map((_, el) => ({ el, top: el.scrollTop }))
.get();
if (techInfo) this.techInfo = techInfo;
if (resourcesInfo) this.resources = resourcesInfo;
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
@@ -1417,6 +1443,7 @@ export class NationModal {
this._renderDiplomacy();
this._renderConflicts();
this._renderPolitics();
for (const entry of scrolls) entry.el.scrollTop = entry.top;
}
// Lists every other nation's economy in a sortable table, with its relation
+65 -4
View File
@@ -6,6 +6,12 @@ const SVG_NS = "http://www.w3.org/2000/svg";
// Builds the chart. `series` is [{ label, colour, values }] with values oldest
// first; every series shares one x axis. `options.baseline` draws the reference
// line (100 for a price index), and `options.xLabel(i)` labels the ends.
// `options.window` fixes the number of x slots: the plot spans that many values
// so a short series sits at the left of a fixed window instead of stretching to
// fill it, leaving slots the game has not reached yet blank.
// `options.xBands` is [{ start, end, label }] in slot indices. Each band is
// shaded, alternating lighter and darker, and the label is centred below it, so
// months read as tinted columns named once instead of a date at each end.
export function lineChart(series, options = {}) {
const width = options.width || 320;
const height = options.height || 150;
@@ -27,6 +33,9 @@ export function lineChart(series, options = {}) {
if (value > max) max = value;
}
}
// A caller may pin the floor (the commodity index always bottoms out at 0);
// otherwise the axis fits the data around the baseline.
if (options.min !== undefined) min = options.min;
if (max - min < 1e-6) {
min -= 1;
max += 1;
@@ -34,8 +43,37 @@ export function lineChart(series, options = {}) {
const span = max - min;
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
const x = (i) => pad.left + (count <= 1 ? plotW / 2 : (i / (count - 1)) * plotW);
// The x slots: the fixed window when given, otherwise one per value so a
// caller that does not care about future slots keeps filling the plot.
const slots = options.window && options.window > 1 ? options.window : count;
const x = (i) => pad.left + (slots <= 1 ? plotW / 2 : (i / (slots - 1)) * plotW);
const y = (value) => pad.top + (1 - (value - min) / span) * plotH;
// A band covers its slots plus half a slot on each side, so neighbouring
// bands meet exactly and the edge bands reach the plot's edges.
const half = slots > 1 ? plotW / (slots - 1) / 2 : 0;
const band = (a, b) => ({
left: Math.max(pad.left, x(a) - half),
right: Math.min(width - pad.right, x(b) + half),
});
// The month bands sit behind everything else so the lines read over them.
const bands = options.xBands || [];
bands.forEach((entry, i) => {
const { left, right } = band(entry.start, entry.end);
if (right - left <= 0) return;
const rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", String(left));
rect.setAttribute("y", String(pad.top));
rect.setAttribute("width", String(right - left));
rect.setAttribute("height", String(plotH));
// The fill here is a fallback for a page whose stylesheet has not been
// refreshed; the classes let the theme override it.
rect.setAttribute("fill", i % 2 === 0
? "rgba(255, 255, 255, 0.03)"
: "rgba(255, 255, 255, 0.1)");
rect.setAttribute("class", i % 2 === 0 ? "line-chart-band-even" : "line-chart-band-odd");
svg.appendChild(rect);
});
const baseLine = document.createElementNS(SVG_NS, "line");
baseLine.setAttribute("x1", String(pad.left));
@@ -45,12 +83,22 @@ export function lineChart(series, options = {}) {
baseLine.setAttribute("class", "line-chart-baseline");
svg.appendChild(baseLine);
for (const [value, anchor] of [[max, "start"], [min, "end"]]) {
// The axis always names its floor and ceiling; the baseline gets its own
// label when it sits strictly between them (a price index reads its 100).
const ticks = [[max, "start", false], [min, "end", false]];
if (baseline > min && baseline < max) ticks.push([baseline, "start", true]);
for (const [value, anchor, isBaseline] of ticks) {
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("x", String(pad.left - 5));
label.setAttribute("y", String(y(value) + (anchor === "start" ? 3 : 0)));
label.setAttribute("text-anchor", "end");
label.setAttribute("class", "line-chart-label");
label.setAttribute(
"class",
isBaseline ? "line-chart-label line-chart-baseline-label" : "line-chart-label"
);
// Fallback fill so the baseline label still reads if the stylesheet is a
// cached older copy without the class.
if (isBaseline) label.setAttribute("fill", "rgba(255, 255, 255, 0.6)");
label.textContent = String(Math.round(value));
svg.appendChild(label);
}
@@ -68,7 +116,20 @@ export function lineChart(series, options = {}) {
svg.appendChild(polyline);
}
if (options.xLabel) {
if (bands.length > 0) {
for (const entry of bands) {
const { left, right } = band(entry.start, entry.end);
if (right - left < 24) continue;
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("x", String((left + right) / 2));
label.setAttribute("y", String(height - 6));
label.setAttribute("text-anchor", "middle");
label.setAttribute("fill", "#e8eef7");
label.setAttribute("class", "line-chart-month");
label.textContent = entry.label;
svg.appendChild(label);
}
} else if (options.xLabel) {
for (const i of [0, count - 1]) {
if (count <= 1 && i !== 0) continue;
const label = document.createElementNS(SVG_NS, "text");
+14 -5
View File
@@ -131,16 +131,25 @@ export const RESOURCE_MARKET_BASE = {
hightech: 13_600, // per unit
};
// How strongly a day's world-wide imbalance moves the price, and how far it may
// drift from the base. The market is a smoothing device, not a real auction:
// it lets a shortage raise the cost of imports and a glut cheapen them.
// How the world market finds its price each day. The market is a smoothing
// device, not a real auction: the day's imbalance sets an equilibrium, the
// price that clears supply against demand, and the price eases toward it. There
// is no price band, so a sustained shortage lifts a good well past its base
// while a glut cheapens it, but the equilibrium keeps the price anchored: it
// settles rather than compounding without bound. `maxRatio` bounds how sharp a
// shortage the ratio may read, so a rounding-error supply cannot run away, and
// is also the ratio a day with no supply at all is treated as.
export const RESOURCE_MARKET = {
base: RESOURCE_MARKET_BASE,
adjustment: 0.2,
minMultiplier: 0.5,
maxMultiplier: 1.8,
maxRatio: 10,
};
// The central bank charts a rolling window of the world commodity price index:
// one sample per day for this many days. The model keeps that many daily
// samples and the browser draws the same window.
export const PRICE_INDEX_WINDOW_DAYS = 90;
// A region whose food store runs dry and cannot feed its people is in famine:
// the whole world is told the day it begins, and the region loses this share of
// its people every day until food comes back.
+37 -10
View File
@@ -16,6 +16,7 @@ import {
RESOURCE_RULES,
RESOURCE_MARKET_BASE,
RESOURCE_TRADE_RADIUS,
PRICE_INDEX_WINDOW_DAYS,
MIDDLEMAN,
FAMINE,
tileImprovementById,
@@ -93,11 +94,21 @@ export const resourceMethods = {
this.resourceMarketStats.set(id, { supply: 0, demand: 0, price: RESOURCE_MARKET_BASE[id] });
}
// The world price of every commodity at the start of each month, oldest
// first, so the central bank can chart an index from 100 in January 2000 and
// read month-on-month and year-on-year inflation. The first sample is the
// opening price, the index's base.
this.resourcePriceHistory = new Map();
for (const id of RESOURCE_IDS) this.resourcePriceHistory.set(id, [RESOURCE_MARKET_BASE[id]]);
// first, so the central bank can read month-on-month and year-on-year
// inflation. The first sample is the opening price, the index's base.
this.resourceMonthlyPrices = new Map();
for (const id of RESOURCE_IDS) {
this.resourceMonthlyPrices.set(id, [RESOURCE_MARKET_BASE[id]]);
}
// One world price per day, newest last, for the central bank's index chart.
// The series rolls: once it holds a full window the oldest day drops as a
// new one is sampled, so it never grows with the game. The chart is drawn
// against this fixed window, so days the game has not reached yet are left
// blank rather than stretched across the plot.
this.resourceDailyPrices = new Map();
for (const id of RESOURCE_IDS) {
this.resourceDailyPrices.set(id, [RESOURCE_MARKET_BASE[id]]);
}
this.resourceLinks = [];
this._resourceShortages = new Map();
// The viewer-keyed delivery graph is rebuilt when the resource day rolls
@@ -508,13 +519,27 @@ export const resourceMethods = {
},
// Snapshots every commodity's world price at the start of a new month. Called
// from the monthly rollover, so the series holds one opening price per month.
// from the monthly rollover, so the series holds one opening price per month
// for the month-on-month and year-on-year inflation readings.
_sampleResourcePrices() {
if (!this.resourcePriceHistory) return;
if (!this.resourceMonthlyPrices) return;
for (const id of RESOURCE_IDS) {
const series = this.resourcePriceHistory.get(id) || [];
const series = this.resourceMonthlyPrices.get(id) || [];
series.push(this.getResourcePrice(id));
this.resourcePriceHistory.set(id, series);
this.resourceMonthlyPrices.set(id, series);
}
},
// Adds today's world prices to the daily series the central bank charts, and
// drops the oldest once the rolling window is full. Called at the end of every
// daily resource tick, after the day's prices have been set.
_sampleDailyResourcePrices() {
if (!this.resourceDailyPrices) return;
for (const id of RESOURCE_IDS) {
const series = this.resourceDailyPrices.get(id) || [];
series.push(this.getResourcePrice(id));
while (series.length > PRICE_INDEX_WINDOW_DAYS) series.shift();
this.resourceDailyPrices.set(id, series);
}
},
@@ -524,7 +549,7 @@ export const resourceMethods = {
// a year earlier; through 2000 there is no earlier year, so the year's first
// month stands in and yearly reads as growth since the start of the game.
resourceInflation(id) {
const series = this.resourcePriceHistory ? this.resourcePriceHistory.get(id) : null;
const series = this.resourceMonthlyPrices ? this.resourceMonthlyPrices.get(id) : null;
const price = this.getResourcePrice(id);
if (!series || series.length === 0) {
return { index: 100, monthly: 0, yearly: 0 };
@@ -1609,6 +1634,8 @@ export const resourceMethods = {
stats.price = price;
}
this._recordCommodityInflation(market, previousPrices);
// Today's prices join the central bank's daily index series.
this._sampleDailyResourcePrices();
this._applyResourceShortages(ledger);
this._pruneResourceStock();
+14 -9
View File
@@ -98,8 +98,8 @@ export const serializationMethods = {
// The global resource market: today's price and the supply and demand
// that moved it, for the resource panel.
resourceMarket: this._serializeResourceMarket(),
// The monthly world-price series per commodity and the inflation each one
// shows, for the central bank's index chart and its monthly/yearly rates.
// The daily world-price index series per commodity, for the central
// bank's chart, and the monthly/yearly inflation each one shows.
resourcePriceHistory: this._serializeResourcePriceHistory(),
resourceInflation: this._serializeResourceInflation(),
// Every nation's imports and exports of each resource over the last day.
@@ -654,14 +654,19 @@ export const serializationMethods = {
});
},
// The opening world price of every commodity at the start of each month,
// [id, [price, ...]], oldest first. World-wide, so it travels once.
// The daily world-price index of every commodity, [id, [index, ...]], oldest
// first, 100 at January 2000's opening price. The daily series rolls, so the
// array never exceeds the chart window; world-wide, so it travels once.
_serializeResourcePriceHistory() {
return RESOURCE_IDS.map((id) => [
id,
(this.resourcePriceHistory ? this.resourcePriceHistory.get(id) || [] : [])
.map((price) => roundTo(price, 3)),
]);
return RESOURCE_IDS.map((id) => {
const series = this.resourceDailyPrices ? this.resourceDailyPrices.get(id) || [] : [];
const monthly = this.resourceMonthlyPrices ? this.resourceMonthlyPrices.get(id) || [] : [];
const base = monthly[0] > 0 ? monthly[0] : (series[0] > 0 ? series[0] : 1);
return [
id,
series.map((price) => roundTo((price / base) * 100, 3)),
];
});
},
// Each commodity's index (100 in January 2000) and its month-on-month and
+11 -9
View File
@@ -235,15 +235,17 @@ export function producerEnergyPerDay(id, outputPerDay) {
// ----------------------------------------------------------------- market --
// Moves a price toward what the day's world-wide supply and demand imply. A
// glut pushes it down, a shortage up, and the clamp keeps the market sane. A
// zero-supply day is treated as the sharpest shortage.
// Eases a price toward the equilibrium the day's world-wide supply and demand
// imply: the price that clears supply against demand is `base * demand/supply`,
// and the price travels a fixed share of the way there each day. A sustained
// shortage therefore lifts a good above its base and a glut cheapens it, but the
// price settles at the equilibrium instead of compounding without bound, so no
// price band is needed to keep it sane. A day with no supply at all is treated as
// the sharpest shortage the ratio may read, and the ratio is capped so a
// rounding-error supply cannot run the equilibrium away.
export function nextMarketPrice(basePrice, price, supply, demand) {
if (!(demand > 0)) return basePrice;
const ratio = supply > 0 ? demand / supply : RESOURCE_MARKET.maxMultiplier;
const pressure = (ratio - 1) * RESOURCE_MARKET.adjustment;
const moved = price * (1 + pressure);
const min = basePrice * RESOURCE_MARKET.minMultiplier;
const max = basePrice * RESOURCE_MARKET.maxMultiplier;
return Math.max(min, Math.min(max, moved));
const ratio = supply > 0 ? demand / supply : RESOURCE_MARKET.maxRatio;
const equilibrium = basePrice * Math.min(ratio, RESOURCE_MARKET.maxRatio);
return price + (equilibrium - price) * RESOURCE_MARKET.adjustment;
}
+30 -2
View File
@@ -1,6 +1,6 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { RESOURCE_IDS, RESOURCE_MARKET_BASE } from "../shared/data.js";
import { RESOURCE_IDS, RESOURCE_MARKET_BASE, PRICE_INDEX_WINDOW_DAYS } from "../shared/data.js";
export class InflationHistoryTest extends TestCase {
test_the_index_starts_at_one_hundred() {
@@ -13,6 +13,34 @@ export class InflationHistoryTest extends TestCase {
}
}
test_the_daily_series_rolls_over_the_window() {
const state = smallState();
const base = RESOURCE_MARKET_BASE.steel;
// One sample a day for longer than the window, each 1% dearer than the last.
for (let day = 1; day <= PRICE_INDEX_WINDOW_DAYS + 5; day++) {
state.setResourcePrice("steel", base * (1 + 0.01 * day));
state._sampleDailyResourcePrices();
}
const series = state.resourceDailyPrices.get("steel");
this.assertSize(series, PRICE_INDEX_WINDOW_DAYS, "the daily series holds the window");
this.assertApprox(series[0], base * 1.06, 1e-6, "the oldest five days have rolled off");
this.assertApprox(series[series.length - 1], base * 1.95, 1e-6, "the newest day is today");
}
test_the_snapshot_ships_daily_indices_rebased_on_the_opening_price() {
const state = smallState();
const base = RESOURCE_MARKET_BASE.food;
state.setResourcePrice("food", base * 1.25);
state._sampleDailyResourcePrices();
const shared = state.serializeShared();
const food = shared.resourcePriceHistory.find(([id]) => id === "food");
this.assertSize(food[1], 2, "the opening price and today's point");
this.assertApprox(food[1][0], 100, 1e-6, "the series opens at the index base");
this.assertApprox(food[1][1], 125, 1e-6, "today's point carries the move");
const inflation = shared.resourceInflation.find((entry) => entry.id === "food");
this.assertApprox(inflation.index, 125, 1e-6, "the table's live index agrees");
}
test_monthly_inflation_compares_with_last_month() {
const state = smallState();
const base = RESOURCE_MARKET_BASE.steel;
@@ -48,7 +76,7 @@ export class InflationHistoryTest extends TestCase {
this.assertSize(shared.resourceInflation, RESOURCE_IDS.length);
for (const [id, prices] of shared.resourcePriceHistory) {
this.assertTrue(RESOURCE_IDS.includes(id));
this.assertSize(prices, 1, "one opening price at the start");
this.assertSize(prices, 1, "one opening index at the start");
}
const steel = shared.resourceInflation.find((entry) => entry.id === "steel");
this.assertApprox(steel.index, 100, 1e-9);
+33
View File
@@ -28,6 +28,39 @@ export class LineChartTest extends TestCase {
}
}
async test_chart_shades_and_names_month_bands() {
const env = await setupDom();
try {
const svg = lineChart(
[{ values: [100, 110] }],
{
width: 400,
height: 160,
window: 4,
min: 0,
xBands: [
{ start: 0, end: 1, label: "Jan" },
{ start: 2, end: 3, label: "Feb" },
],
}
);
this.assertSize(
svg.querySelectorAll(".line-chart-band-even, .line-chart-band-odd"),
2,
"one shaded column per month band"
);
const labels = svg.querySelectorAll(".line-chart-month");
this.assertSize(labels, 2, "each month is named once");
this.assertEqual(labels[0].textContent, "Jan");
this.assertEqual(labels[1].textContent, "Feb");
const axisLabels = [...svg.querySelectorAll(".line-chart-label")].map((el) => el.textContent);
this.assertTrue(axisLabels.includes("0"), "a pinned floor puts zero at the bottom");
this.assertTrue(axisLabels.includes("100"), "the baseline is labelled 100");
} finally {
teardownDom(env);
}
}
async test_chart_defaults_to_a_small_size() {
const env = await setupDom();
try {
+68 -9
View File
@@ -1121,13 +1121,13 @@ export class NationModalTest extends TestCase {
}
}
async test_central_bank_chart_rolls_a_two_year_window() {
async test_central_bank_chart_rolls_a_ninety_day_window() {
const env = await setupDom();
try {
const modal = new NationModal();
// Thirty monthly openings, so six must fall off the twenty-four month
// window. The live index matches the last sample, so no point is appended.
const energy = Array.from({ length: 30 }, (_, i) => 100 + i * 10);
// One hundred and twenty daily points, so thirty fall off the ninety-day
// window. The clock is day 120, so the window's first day is day 31.
const energy = Array.from({ length: 120 }, (_, i) => 100 + i);
const money = {
viewerCiv: 0,
currency: { code: "FRF", symbol: "₣", name: "French Franc" },
@@ -1144,8 +1144,9 @@ export class NationModalTest extends TestCase {
currencyHoldings: null,
privateCash: 0,
cities: [],
totalHours: 120 * 24,
resourcePriceHistory: [["energy", energy]],
resourceInflation: [{ id: "energy", index: 390, monthly: 0.03, yearly: 0.25 }],
resourceInflation: [{ id: "energy", index: 219, monthly: 0.03, yearly: 0.25 }],
};
const resourcesInfo = {
summary: {
@@ -1163,10 +1164,68 @@ export class NationModalTest extends TestCase {
env.$("#modal-nation .tab[data-tab=economy]").click();
env.$("#economy-subtabs .subtab[data-view=centralbank]").click();
const points = env.$("#economy-central-bank .line-chart-line").attr("points") || "";
this.assertEqual(points.trim().split(/\s+/).length, 24, "the window holds 24 months");
const labels = env.$("#economy-central-bank .line-chart-label");
this.assertEqual(labels.eq(-2).text(), "Jul 2000", "the window starts two years back");
this.assertEqual(labels.eq(-1).text(), "Jun 2002", "the window ends at the latest month");
this.assertEqual(points.trim().split(/\s+/).length, 90, "the window holds 90 days");
const months = env.$("#economy-central-bank .line-chart-month");
this.assertEqual(months.length, 3, "one label per month in the window");
this.assertEqual(months.eq(0).text(), "Feb", "the window starts in February");
this.assertEqual(months.eq(1).text(), "Mar");
this.assertEqual(months.eq(2).text(), "Apr", "and ends in April");
} finally {
teardownDom(env);
}
}
async test_central_bank_chart_leaves_future_days_blank() {
const env = await setupDom();
try {
const modal = new NationModal();
// Ten daily points: the line must cover ten of the ninety slots, with the
// rest of the fixed axis left for days the game has not reached.
const energy = Array.from({ length: 10 }, (_, i) => 100 + i);
const money = {
viewerCiv: 0,
currency: { code: "FRF", symbol: "₣", name: "French Franc" },
currencies: [{ civ: 0, code: "FRF", symbol: "₣", name: "French Franc", value: 1 }],
exchangeRates: [{ civ: 0, code: "FRF", symbol: "₣", name: "French Franc", rate: 1 }],
centralBank: { rate: 0.02, reserves: [], loans: 0 },
taxes: {
sales: { rate: 0.2, resources: {} },
export: { rate: 0.05, resources: {} },
import: { rate: 0.05, resources: {} },
},
takeTax: { sales: 0, export: 0, import: 0 },
commodityInflation: 0,
currencyHoldings: null,
privateCash: 0,
cities: [],
totalHours: 9 * 24,
resourcePriceHistory: [["energy", energy]],
resourceInflation: [{ id: "energy", index: 109, monthly: 0.01, yearly: 0.09 }],
};
const resourcesInfo = {
summary: {
stock: {}, need: {}, production: {}, prices: {},
consumed: { steel: 0, food: 0, luxury: 0, hightech: 0, energy: 0 },
external: { buy: {}, sell: {} },
},
market: [],
trade: [],
};
modal.show(
GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6,
{ months: [], cities: [] }, null, [], null, null, resourcesInfo, money
);
env.$("#modal-nation .tab[data-tab=economy]").click();
env.$("#economy-subtabs .subtab[data-view=centralbank]").click();
const chart = env.$("#economy-central-bank .line-chart").get(0);
const viewBox = (chart.getAttribute("viewBox") || "").split(/\s+/);
const points = env.$("#economy-central-bank .line-chart-line").attr("points") || "";
const last = points.trim().split(/\s+/).pop().split(",");
const plotW = Number(viewBox[2]) - 40 - 10;
this.assertApprox(
Number(last[0]), 40 + (9 / 89) * plotW, 0.5,
"ten points sit on the first ten of the ninety fixed slots, not stretched"
);
} finally {
teardownDom(env);
}
+16
View File
@@ -4,6 +4,7 @@ import { key } from "../shared/hex.js";
import {
RESOURCE_RULES,
RESOURCE_MARKET_BASE,
RESOURCE_MARKET,
RESOURCE_BUILDINGS,
EFFECT_RESEARCH,
technologyCost,
@@ -95,6 +96,21 @@ export class ResourceRulesTest extends TestCase {
this.assertGreater(scarce, base);
this.assertLess(glut, base);
}
test_market_price_settles_at_its_equilibrium_without_a_clamp() {
const base = RESOURCE_MARKET_BASE.steel;
// A persistent shortage of half the supply settles at twice the base price:
// a price above the old 1.8x band, reached without a clamp and without
// compounding away to infinity.
let price = base;
for (let day = 0; day < 200; day++) price = nextMarketPrice(base, price, 10, 20);
this.assertApprox(price, base * 2, base * 1e-6, "the price eases to base * demand/supply");
// A day with no supply at all reads as the sharpest shortage the ratio may
// express, so the price settles at that multiple rather than running away.
let scarce = base;
for (let day = 0; day < 200; day++) scarce = nextMarketPrice(base, scarce, 0, 10);
this.assertApprox(scarce, base * RESOURCE_MARKET.maxRatio, base * 1e-6);
}
}
export class ResourceModelTest extends TestCase {