Compare commits

...
3 Commits
11 changed files with 322 additions and 108 deletions
+18
View File
@@ -1362,6 +1362,24 @@ input:focus, select:focus { border-color: var(--accent); }
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.6);
}
/* A resource chip's tooltip: production above consumption, each row
right-aligned. One grid holds both rows so the sign and figure columns line
up; the figure column reserves the width of the largest pool figure
(99 999 999 999), so the bubble never shifts as a stock moves. */
.resource-tip {
display: grid;
grid-template-columns: auto auto auto;
justify-content: end;
column-gap: 0.35em;
row-gap: 2px;
font-variant-numeric: tabular-nums;
}
.resource-tip .rt-sign { text-align: right; font-weight: 700; }
.resource-tip .rt-figure { text-align: right; min-width: 14ch; }
.resource-tip .rt-tail { text-align: left; }
.resource-tip .rt-sign.produce, .resource-tip .rt-figure.produce { color: #6ee787; }
.resource-tip .rt-sign.consume, .resource-tip .rt-figure.consume { color: #f27272; }
/* Right-click menu for the units sharing a tile. */
.stack-menu {
position: absolute;
+18 -18
View File
@@ -1,6 +1,24 @@
// 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": "0a1c2d0",
"date": "2026-09-25",
"subject": "Guaranteed the 45-second starter build even on an unpowered map",
"body": ""
},
{
"hash": "80d2e22",
"date": "2026-09-25",
"subject": "Sped up simple construction and showed resource costs on build cards",
"body": ""
},
{
"hash": "4e11af2",
"date": "2026-09-25",
"subject": "Showed the nation's resource pool in the simple economy's top panel",
"body": "The top bar now carries a two-by-two grid of the four storable goods, each a big icon, the grouped pool and an arrow reading production against consumption (purchases excluded); hovering a chip names both daily figures. The quantity column reserves room for 99 999 999 999 so the layout never shifts as stocks grow.\n\nformatResourceAmount paired each prefix with a divisor a thousand times too large, so a million tonnes printed as 1 kt; the steps now move together as kt, Mt, Gt and Tt."
},
{
"hash": "d5305da",
"date": "2026-09-25",
@@ -42,23 +60,5 @@ export const DEVLOG = [
"date": "2026-09-24",
"subject": "Grouped the unit stack by type and let ranged units pick a target",
"body": ""
},
{
"hash": "1c6f0ae",
"date": "2026-09-24",
"subject": "Cut the per-broadcast and per-frame cost on a full-sized map",
"body": "The server sent the whole army on every snapshot and the client re-cloned\nthe whole icon layer whenever a unit moved, so a settled world with a\nstanding army drained the broadcast loop and the frame loop alike.\n\n- Ship only the units and city list that changed, keyed by a per-peer\n fingerprint and a serialised hash, and merge those deltas on the client.\n- Memoise the battlefield scan on a battle version bumped when a unit\n joins, leaves or changes side.\n- Skip the DOM writes for units whose snapshot object is unchanged, and\n cache the unit-view inner nodes.\n- Fold units, cities and tile markers onto the nearest world copy instead\n of mirroring the icon layers, which re-cloned thousands of views every\n frame; the small decoration layers still mirror, and only on a change.\n\nFrame work at full zoom-out with units moving fell from ~190ms to ~6.5ms."
},
{
"hash": "822c26f",
"date": "2026-09-23",
"subject": "Founded every nation with a standing army and a year of reserves",
"body": ""
},
{
"hash": "0d1995c",
"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."
}
];
+63 -21
View File
@@ -53,7 +53,7 @@ import { ethnicityChart } from "../ui/ethnicity.js";
import { isEconomicMode } from "../map_view/economic.js";
import { isWaterTile } from "../map_view/textures.js";
import { audio } from "../audio.js";
import { currencySymbol, currencyFor, rateToNational, moneyText } from "../currency.js";
import { currencySymbol, currencyFor, rateToNational, moneyText, formatMoney } from "../currency.js";
import { buildCard, tooltipSections, hideCardTooltip } from "../ui/card.js";
export const panelMethods = {
@@ -163,11 +163,13 @@ export const panelMethods = {
},
// A small hover tooltip anchored above a stat, fed by its data-tooltip text.
// A top-panel resource chip is the exception: its tooltip is a two-line bill,
// production above consumption, both right-aligned.
_showStatTooltip($stat) {
const text = $stat.attr("data-tooltip");
if (!text) return;
const content = this._statTooltipContent($stat);
if (content === null) return;
this._hoveredStat = $stat;
this.$statTooltip.text(text).removeClass("hidden");
this.$statTooltip.empty().append(content).removeClass("hidden");
const offset = $stat.offset() || { left: 0, top: 0 };
const width = this.$statTooltip.outerWidth() || 0;
const height = this.$statTooltip.outerHeight() || 0;
@@ -177,6 +179,50 @@ export const panelMethods = {
});
},
// The body of a stat tooltip: the resource bill for a pool chip, or the plain
// text another stat carries. Null means the stat has no tooltip.
_statTooltipContent($stat) {
if ($stat.hasClass("top-resource")) {
const proto = resourceById($stat.attr("data-resource"));
const unit = proto ? proto.unit : "";
return this._resourceTooltip(
Number($stat.attr("data-produced")) || 0,
Number($stat.attr("data-consumed")) || 0,
unit
);
}
const text = $stat.attr("data-tooltip");
return text ? document.createTextNode(text) : null;
},
// A resource's daily flows as two right-aligned rows: production first with a
// plus, consumption second with a minus. One grid holds both rows, so the sign
// and figure columns line up; the figure column reserves room for the largest
// pool figure (99 999 999 999), so the bubble never shifts width as a stock
// moves.
_resourceTooltip(produced, consumed, unit) {
const $tip = $("<div class='resource-tip'></div>");
for (const row of [
{ cls: "produce", sign: "+", amount: produced },
{ cls: "consume", sign: "−", amount: consumed },
]) {
$tip.append(
$("<span class='rt-sign'></span>").addClass(row.cls).text(row.sign),
$("<span class='rt-figure'></span>").addClass(row.cls).text(groupDigits(row.amount)),
$("<span class='rt-tail'></span>").addClass(row.cls).text(`${unit}/day`)
);
}
return $tip;
},
// Rebuilds the open stat tooltip in place, so a snapshot that changes the
// figures under the pointer does not leave a stale bubble.
_refreshStatTooltip() {
if (!this._hoveredStat || this.$statTooltip.hasClass("hidden")) return;
const content = this._statTooltipContent(this._hoveredStat);
if (content !== null) this.$statTooltip.empty().append(content);
},
_hideStatTooltip() {
this._hoveredStat = null;
this.$statTooltip.addClass("hidden");
@@ -917,11 +963,9 @@ export const panelMethods = {
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`
);
// The hover tooltip pairs the two flows; the raw figures ride on the chip
// so an open tooltip can be rebuilt in step with the next snapshot.
$chip.attr("data-produced", made).attr("data-consumed", used);
}
},
@@ -976,10 +1020,7 @@ export const panelMethods = {
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);
}
this._refreshStatTooltip();
},
// Appends the month-on-month change marker inside a stat's value: a bold
@@ -1366,8 +1407,7 @@ export const panelMethods = {
.attr("data-tooltip", parts.join(", "));
// Keep an open tooltip in step with the unit it describes across snapshots.
if (this._hoveredStat && !this.$statTooltip.hasClass("hidden")) {
const text = this._hoveredStat.attr("data-tooltip");
if (text) this.$statTooltip.text(text);
this._refreshStatTooltip();
} else {
this._hideStatTooltip();
}
@@ -1740,7 +1780,6 @@ export const panelMethods = {
const locked = !!opts.locked;
const gdp = opts.gdp || 0;
const buildHours = ECONOMY.buildingHours(gdp, improvement.buildCost) / (1 + (opts.speed || 0));
const materials = tileImprovementResourceCost(improvement);
const card = buildCard({
icon: improvement.icon,
name: improvement.name,
@@ -1750,7 +1789,6 @@ export const panelMethods = {
locked,
action: {
label: built ? "Rebuild" : "Build",
money: opts.disabled ? null : quotedBuildCost(improvement.buildCost, materials, opts.prices),
hours: hours(buildHours),
disabled: !!opts.disabled,
},
@@ -1759,7 +1797,7 @@ export const panelMethods = {
title: `Remove ${improvement.name}`,
enabled: built,
},
tip: () => this._improvementTip(improvement),
tip: () => this._improvementTip(improvement, opts.prices),
onAction: opts.onAction,
onRemove: opts.onRemove,
});
@@ -1776,8 +1814,9 @@ export const panelMethods = {
},
// The hover detail for an improvement card: what it yields, what it wears out
// each day and the materials its construction bills in.
_improvementTip(improvement) {
// each day and the materials its construction bills in. The estimated euro
// price sits here, not on the button.
_improvementTip(improvement, prices = {}) {
const produce = [];
if (improvement.resource) {
produce.push({ id: improvement.resource, amount: resourceBuildingOutputAt(improvement, 1) });
@@ -1787,12 +1826,15 @@ export const panelMethods = {
} else if (improvement.description) {
produce.push({ text: improvement.description });
}
const materials = tileImprovementResourceCost(improvement);
const bill = this._resourceItems(materials);
const price = quotedBuildCost(improvement.buildCost, materials, prices);
return tooltipSections({
produce,
produceSuffix: improvement.resource ? "/day" : "",
consume: this._improvementUpkeepItems(improvement),
consumeSuffix: "/day",
cost: this._resourceItems(tileImprovementResourceCost(improvement)),
cost: bill.length > 0 ? [{ text: `~${formatMoney(price)}` }, ...bill] : bill,
});
},
+39 -10
View File
@@ -3,7 +3,7 @@
// city's production queue, so the train tab shows both kinds of order.
import { groupDigits, hours } from "../../../shared/text_format.js";
import { ECONOMY, BUILDINGS, resourceById, RESOURCE_IDS, RESOURCE_RULES } from "../../../shared/data.js";
import { ECONOMY, BUILDINGS, resourceById, RESOURCE_IDS, RESOURCE_RULES, simpleConstructionHours } from "../../../shared/data.js";
import { constructionResourceCost, formatResourceAmount, protoUpkeep, unitFoodPerDay } from "../../../shared/resources.js";
import {
buildingBuildCost,
@@ -111,7 +111,12 @@ export class CityModal {
this.coastal = !!view.coastal;
this.constructionSpeed = view.constructionSpeed || 0;
this.economyModel = view.economyModel || this.economyModel || "hard";
this.energyAvailable = view.energyAvailable || 0;
// The simple economy's clock is set by the region's spare power, which the
// view carries inside its resources; the field is honoured too, because
// `_updateCityModal` refreshes it from a later snapshot.
this.energyAvailable = view.energyAvailable !== undefined
? view.energyAvailable
: ((view.resources && view.resources.energy) || 0);
this.testing = !!view.testing;
this.prices = view.prices || {};
this.unitProtos = view.unitProtos || {};
@@ -451,6 +456,16 @@ export class CityModal {
return 1 / (1 + (this.constructionSpeed || 0));
}
// The build time a card quotes. The simple economy keys it to the region's
// spare power, the hard economy to GDP; both then shorten with construction
// research. Mirrors the server's clock so the card and the queue agree.
_displayHours(cost, kind, gdp = this.gdp) {
const base = this.economyModel === "simple"
? simpleConstructionHours(cost, this.energyAvailable, kind)
: (kind === "unit" ? ECONOMY.trainingHours(gdp, cost) : ECONOMY.buildingHours(gdp, cost));
return base * this._constructionTimeFactor();
}
// The full price of a construction at today's market: the build cost plus its
// material bill at current prices, whether or not the city already holds the
// materials.
@@ -469,7 +484,7 @@ export class CityModal {
if (!proto.cost && proto.cost !== 0) continue;
const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index;
const locked = !!proto.locked;
const duration = ECONOMY.trainingHours(gdp, proto.cost) * this._constructionTimeFactor();
const duration = this._displayHours(proto.cost, "unit", gdp);
signature.push(`${serverIndex}:${locked ? 1 : 0}`);
this.protoNames.set(serverIndex, proto.name);
const card = buildCard({
@@ -482,7 +497,7 @@ export class CityModal {
? { label: "Locked", disabled: true }
: {
label: "Train",
money: this._quotedCost(proto.cost, proto, 0),
resources: this._resourceItems(constructionResourceCost(proto, 0, proto.cost)),
hours: hours(duration),
},
tip: () => this._unitTip(proto),
@@ -552,7 +567,16 @@ export class CityModal {
}
const cost = this._resourceItems(constructionResourceCost(proto, 0, proto.cost));
if (cost.length > 0) {
sections.push({ label: "Costs", cls: "tip-cost", sign: "−", suffix: "", items: cost });
// The money quote lives here, not on the button, so the button shows only
// the material bill it can fit.
const price = this._quotedCost(proto.cost, proto, 0);
sections.push({
label: "Costs",
cls: "tip-cost",
sign: "−",
suffix: "",
items: [{ text: `~${formatMoney(price)}` }, ...cost],
});
}
return sections;
}
@@ -769,18 +793,21 @@ export class CityModal {
// The hover detail for a building card: what it makes at the level it is
// buying, what it wears out each day there, and the materials its next order
// spends.
// spends. The estimated euro price sits here rather than on the button, whose
// meta carries only the material bill and the clock.
_buildingTip(row) {
const shownLevel = Math.max(row.level + row.pending, 1);
const effect = buildingEffectText(row.proto, shownLevel);
const upkeep = protoUpkeep(row.proto, buildingBuildCost(row.proto, shownLevel - 1));
const cost = buildingBuildCost(row.proto, row.level + row.pending);
const materials = constructionResourceCost(row.proto, row.level + row.pending, cost);
const bill = this._resourceItems(materials);
const price = this._quotedCost(cost, row.proto, row.level + row.pending);
return tooltipSections({
produce: effect && effect !== "No effect" ? [{ node: buildingEffectNodes(row.proto, shownLevel) }] : [],
consume: this._resourceItems(upkeep),
consumeSuffix: "/day",
cost: this._resourceItems(materials),
cost: bill.length > 0 ? [{ text: `~${formatMoney(price)}` }, ...bill] : bill,
});
}
@@ -830,10 +857,12 @@ export class CityModal {
.text(requirement || levelText);
row.card.$action.prop("disabled", !!requirement);
const cost = buildingBuildCost(row.proto, nextLevel);
const duration = ECONOMY.buildingHours(gdp, cost) * this._constructionTimeFactor();
const duration = this._displayHours(cost, "building", gdp);
setCardMeta(row.card.$meta, {
money: requirement ? null : this._quotedCost(cost, row.proto, nextLevel),
hours: hours(duration),
resources: requirement
? null
: this._resourceItems(constructionResourceCost(row.proto, nextLevel, cost)),
hours: requirement ? "" : hours(duration),
});
if (row.card.$remove) {
// Downgrading returns nothing, so the control is only enabled once the
+12 -8
View File
@@ -7,7 +7,6 @@
import { resourceById } from "../../../shared/data.js";
import { formatResourceAmount } from "../../../shared/resources.js";
import { groupDigits } from "../../../shared/text_format.js";
import { formatMoney } from "../currency.js";
// The materials a construction bills in, in reading order.
export const CARD_RESOURCES = ["steel", "energy", "hightech"];
@@ -122,17 +121,22 @@ export function showCardTooltip($anchor, sections) {
}
}
// Puts the action button's price and time in its meta line. The money is an
// estimate, so it is italic and prefixed with a tilde; nothing is shown when
// the work has no price (locked or already standing). Callers whose cost is not
// money pass free `text` instead, shown verbatim.
export function setCardMeta($meta, { money = null, hours = "", text = "" } = {}) {
// Puts the action button's material cost and build time in its meta line.
// `resources` is the work's material bill (an array of { id, amount }), drawn
// with the commodity icons and a minus sign; the hours follow it. A work with
// nothing to spend (locked, or already standing) shows nothing. Callers whose
// cost is not a resource bill pass free `text` instead, shown verbatim. The
// estimated euro price lives in the hover tooltip, never here, so a long bill
// cannot overflow the button.
export function setCardMeta($meta, { resources = null, hours = "", text = "" } = {}) {
$meta.empty();
if (money != null) {
$meta.append($("<span class='bt-price'></span>").text(`~${formatMoney(money)}`));
if (resources && resources.length) {
$meta.append(resourceBill(resources, { sign: "−" }));
if (hours) $meta.append(document.createTextNode(` · ${hours}`));
} else if (text) {
$meta.text(text);
} else if (hours) {
$meta.text(hours);
}
}
+9 -5
View File
@@ -42,8 +42,11 @@ Every region must start with one power plant.
(`constructionSpeedMultiplier`, `EFFECT_CONSTRUCTION_SPEED`).
- Put the tuning constants (`energyPerConstructionHour`, a minimum speed floor,
and how energy maps to hours) in the simple-economy data module.
- Define what happens when a region has no power: the work should be slow or
stalled, not free. A floor avoids a divide-by-zero and a hard lock.
- Define what happens when a region has no power: a guaranteed baseline
construction power stands in for the grid, so the work is never free, never
instant and never slower than the reference target (modern infantry in 45
game hours). Spare power above the baseline only makes a work quicker, and
the floor avoids a divide-by-zero and a hard lock.
- **Every region starts with one power plant.** Seed one working plant per city
region in the simple scenario so regions open with energy and the speed rule
has a non-zero basis.
@@ -79,9 +82,10 @@ Every region must start with one power plant.
- In simple mode, ordering a building raises it on the hourly tick with no
`"materials"` phase and no `_nearbySuppliers`/trade-graph lookups.
- Construction time scales with the region's available energy, and a
power-less region builds clearly slower than a powered one (never faster, and
never instantaneous at zero energy).
- Construction time scales with the region's available energy: spare power
above the guaranteed baseline shortens the clock, while a region at or below
the baseline (including a powerless one) is held at the reference time -- never
slower, never instantaneous.
- Every starting region has at least one working power plant in simple mode.
- Hard economy construction is untouched.
+30 -5
View File
@@ -51,11 +51,15 @@ export const SIMPLE_ECONOMY = {
// max(available, minEnergy)`, then the building/training ratio below and the
// usual approval and technology multipliers. A tighter grid raises the same
// work slower, the opposite of the hard economy's richer-nation-builds-faster.
energyPerConstructionHour: 3_000,
// The least spare power a region is credited with, so a blacked-out region
// still builds -- slowly -- instead of dividing by zero or finishing at
// once. A tenth of a solar plant's daily output.
minEnergy: 30_000_000,
energyPerConstructionHour: 1_600,
// The construction power every region is credited with even when its grid
// has nothing to spare. This is the guarantee behind the opening pace: a
// modern-infantry unit (cost 100M) always trains in 45 game hours -- 45 real
// seconds, one hour a second -- in a starter city on any map, however
// starved its grid. A region with more spare power builds the same work
// faster; none builds it slower. Sized as `100M * energyPerConstructionHour
// / 45`, so the reference work lands exactly on 45 hours at the floor.
minEnergy: (100_000_000 * 1_600) / 45,
// The hard economy builds ten times faster than it trains a unit of the same
// cost; the simple economy keeps that ratio.
buildingTimeMultiplier: 0.1,
@@ -69,6 +73,27 @@ export const SIMPLE_ECONOMY = {
},
};
// The base hours a simple-economy work of `cost` takes at `energy` kWh a day of
// spare regional power. `kind` is "unit" for training, anything else for a
// building, tile work or upgrade, which the figures in `construction` run
// faster. Energy is floored at the region's guaranteed construction power, so a
// blacked-out starter region still raises the reference unit in 45 hours and
// divides by no zero; more power than the floor only makes a work quicker.
// Shared by the server's clock and the browser's quoted times, so the two can
// never disagree.
export function simpleConstructionHours(cost, energy, kind) {
const available = Math.max(
Number.isFinite(energy) ? energy : 0,
SIMPLE_ECONOMY.construction.minEnergy
);
const base = Math.max(0, cost) *
SIMPLE_ECONOMY.construction.energyPerConstructionHour / available;
const scaled = kind === "unit"
? base
: base * SIMPLE_ECONOMY.construction.buildingTimeMultiplier;
return Math.max(SIMPLE_ECONOMY.construction.minHours, scaled);
}
// The global market every simple-economy nation buys and sells from. One shared
// world stock per storable good opens at this figure, and the price is the base
// price scaled by how far the stock has fallen from it: half the stock doubles
+11 -14
View File
@@ -25,8 +25,11 @@
// is no materials phase, no energy-buy phase and no trade-graph lookup. The
// construction or training clock is set from the spare power of the region the
// work stands in: `regionAvailableEnergy` is the region's plant output less what
// its cities, buildings and converters already draw, floored so a blacked-out
// region still builds slowly instead of dividing by zero. Money is not spent
// its cities, buildings and converters already draw. A guaranteed baseline
// covers a region whose grid has nothing to spare, so a starter city on any map
// raises a modern-infantry unit in the 45-hour reference time and no work ever
// divides by zero; spare power above the baseline only makes a work quicker.
// Money is not spent
// internally on construction (the single tax and the market cover that in
// `05-`/`06-`), so the treasury gates nothing. Every opening region is guaranteed
// at least one plant -- a fuel-free one where the hard seeding left it without --
@@ -53,6 +56,7 @@ import {
STORABLE_RESOURCE_IDS,
RESOURCE_MARKET_BASE,
SIMPLE_ECONOMY,
simpleConstructionHours,
MARKET_START_STOCK,
MARKET_LOTS,
marketPriceFor,
@@ -810,19 +814,12 @@ export const simpleEconomyMethods = {
// The base hours a work of `cost` takes in `city`'s region at today's spare
// power. `kind` is "unit" for training, anything else for a building, tile
// work or upgrade -- which the hard economy runs ten times quicker. The energy
// is floored so a blacked-out region still builds, slowly.
// work or upgrade -- which the hard economy runs ten times quicker. The
// arithmetic lives in the data module, where the guaranteed baseline keeps
// even a blacked-out starter region at the reference time, and so the browser
// quotes the same figure.
_simpleConstructionHours(city, cost, kind) {
const energy = Math.max(
this.regionAvailableEnergy(city),
SIMPLE_ECONOMY.construction.minEnergy
);
const base = Math.max(0, cost) *
SIMPLE_ECONOMY.construction.energyPerConstructionHour / energy;
const scaled = kind === "unit"
? base
: base * SIMPLE_ECONOMY.construction.buildingTimeMultiplier;
return Math.max(SIMPLE_ECONOMY.construction.minHours, scaled);
return simpleConstructionHours(cost, this.regionAvailableEnergy(city), kind);
},
// Pays a construction bill out of the nation's pool. Atomic: a bill the pool
+16 -10
View File
@@ -2090,8 +2090,8 @@ export class GameScreenTest extends TestCase {
this.assertEqual(options.length, 2, "road and railway are listed");
this.assertFalse(options.eq(0).prop("disabled"), "the road option is clickable");
this.assertFalse(options.eq(1).prop("disabled"), "the railway option is clickable");
// Each card shows the work's icon and name, while the resource bill is in
// the hover tooltip and the estimated price sits on the button.
// Each card shows the work's icon and name; the resource bill and the
// estimated price both live in the hover tooltip.
const roadCard = options.eq(0).closest(".building-tile");
this.assertTrue(
roadCard.find("img.building-icon").attr("src").endsWith("icon_road.svg"),
@@ -2107,9 +2107,9 @@ export class GameScreenTest extends TestCase {
2,
"steel and energy carry resource icons"
);
options.eq(0).trigger("mouseleave");
// The money figure is the build cost plus the material bill at today's
// market prices, not the raw build cost.
// market prices, not the raw build cost. It lives in the tooltip, so the
// button's meta stays a compact resource bill.
const roadProto = TRANSPORT_IMPROVEMENTS.find((i) => i.id === "road");
const expectedCost = quotedBuildCost(
roadProto.buildCost,
@@ -2117,9 +2117,10 @@ export class GameScreenTest extends TestCase {
marketPricesFrom(screen.snapshot)
);
this.assertTrue(
options.eq(0).find(".bt-price").text().includes(groupDigits(Math.round(expectedCost))),
`the market price is shown: ${options.eq(0).find(".bt-price").text()}`
tipCost.includes(groupDigits(Math.round(expectedCost))),
`the market price is shown in the tooltip: ${tipCost}`
);
options.eq(0).trigger("mouseleave");
options.eq(0).click();
const order = network.orders[network.orders.length - 1];
this.assertEqual(order.type, "build_improvement");
@@ -2378,15 +2379,20 @@ export class GameScreenTest extends TestCase {
screen.onState(state.snapshot(0));
screen._showTile(coords);
env.$("#tile-manage").click();
const before = env.$("#tile-manage-improvements .transport-btn").eq(0)
.find(".bt-price").text();
const quotedTip = () => {
const $btn = env.$("#tile-manage-improvements .transport-btn").eq(0);
$btn.trigger("mouseenter");
const text = env.$(".card-tooltip .tip-cost").text();
$btn.trigger("mouseleave");
return text;
};
const before = quotedTip();
// A jump in the world steel price raises the quoted price, without
// changing the amount of steel shown.
state.setResourcePrice("steel", state.getResourcePrice("steel") * 3);
screen.onState(state.snapshot(0));
screen._showTile(coords);
const after = env.$("#tile-manage-improvements .transport-btn").eq(0)
.find(".bt-price").text();
const after = quotedTip();
this.assertNotEqual(before, after, `the quote moved: ${before} -> ${after}`);
// The steel amount itself is unchanged, in the tooltip.
env.$("#tile-manage-improvements .transport-btn").eq(0).trigger("mouseenter");
+33 -5
View File
@@ -10,6 +10,7 @@ import { GameServer } from "../server/game_server.js";
import { key, parseKey } from "../shared/hex.js";
import {
SIMPLE_ECONOMY,
simpleConstructionHours,
tileImprovementById,
isResourceTileBuilding,
} from "../shared/data.js";
@@ -133,27 +134,54 @@ export class SimpleConstructionTest extends TestCase {
);
}
// The reference calibration: a modern-infantry unit (cost 100M) trains in 45
// in-game hours -- 45 real seconds -- in a starter region on any map, however
// little spare power that region has. The rate and the guaranteed baseline
// live in the shared data module so the server clock and the browser's quoted
// times derive them the same way.
test_the_reference_unit_always_trains_within_45_hours() {
const cost = 100_000_000;
const typical = simpleConstructionHours(cost, 3_500_000_000, "unit");
this.assertApprox(typical, 45, 1e-6, "a typical starter region lands on 45 hours");
this.assertApprox(
simpleConstructionHours(cost, 3_500_000_000, "building") * 10,
typical,
1e-6,
"a building of the same cost runs ten times quicker"
);
// The guarantee: no level of spare power makes the reference work slower
// than 45 hours, and none makes it instant.
const dead = simpleConstructionHours(cost, 0, "unit");
this.assertApprox(dead, 45, 1e-6, "a blacked-out region still meets the target");
this.assertApprox(simpleConstructionHours(cost, 1, "unit"), 45, 1e-6, "so does a trickle");
const huge = simpleConstructionHours(cost, 64_000_000_000, "unit");
this.assertLess(huge, 45, "a rich grid only builds faster");
this.assertGreaterOrEqual(dead, SIMPLE_ECONOMY.construction.minHours, "and never instantly");
}
test_more_regional_energy_strictly_shortens_the_clock() {
const state = simpleState();
const city = cityOf(state, 0);
const cost = 100_000_000;
state.regionAvailableEnergy = () => 100_000_000;
const floor = SIMPLE_ECONOMY.construction.minEnergy;
state.regionAvailableEnergy = () => floor;
const low = state._simpleConstructionHours(city, cost, "building");
state.regionAvailableEnergy = () => 1_000_000_000;
state.regionAvailableEnergy = () => floor * 4;
const high = state._simpleConstructionHours(city, cost, "building");
this.assertLess(high, low, "more spare power builds faster");
this.assertGreater(high, 0, "and never instant");
}
test_a_blacked_out_region_builds_slowly_but_not_at_once() {
test_a_blacked_out_region_is_capped_at_the_baseline() {
const state = simpleState();
const city = cityOf(state, 0);
const cost = 100_000_000;
const floor = SIMPLE_ECONOMY.construction.minEnergy;
state.regionAvailableEnergy = () => 0;
const dead = state._simpleConstructionHours(city, cost, "building");
state.regionAvailableEnergy = () => 100_000_000;
state.regionAvailableEnergy = () => floor * 4;
const lit = state._simpleConstructionHours(city, cost, "building");
this.assertGreater(dead, lit, "a power-less region builds clearly slower");
this.assertGreater(dead, lit, "a power-rich region builds clearly faster");
this.assertTrue(Number.isFinite(dead), "the clock is finite, not a divide-by-zero");
this.assertGreaterOrEqual(dead, SIMPLE_ECONOMY.construction.minHours, "the floor holds");
}
+73 -12
View File
@@ -2,9 +2,9 @@ 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 { GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS, BUILDINGS } from "../shared/data.js";
import { groupDigits } from "../shared/text_format.js";
import { formatResourceAmount } from "../shared/resources.js";
import { constructionResourceCost, formatResourceAmount } from "../shared/resources.js";
import { simpleState, smallState, cityOf } from "./framework/helpers.js";
import {
setCurrency,
@@ -160,6 +160,53 @@ export class SimpleUiTest extends TestCase {
}
}
// The unit and building cards show what a work spends in materials, not only
// its money quote: the steel (and high-tech) bill, drawn with the commodity
// icons on the action button's meta line.
async test_unit_and_building_cards_show_their_resource_cost() {
const env = await setupDom();
try {
const modal = new CityModal();
const unit = { ...PROTO_UNITS[0], serverIndex: 0 };
modal.show(
{ id: 1, name: "Lyon" },
{
economyModel: "simple",
resources: { energy: 3_500_000_000 },
gdp: 1.0e9,
trainable: [unit],
unitProtos: PROTO_UNITS,
buildings: BUILDINGS,
levels: {},
}
);
const unitBill = constructionResourceCost(PROTO_UNITS[0], 0, PROTO_UNITS[0].cost);
const $unitMeta = env.$("#city-train-list .bt-meta").first();
this.assertTrue(
$unitMeta.find("img[src$='icon_steel.svg']").length === 1,
"the unit's steel cost is on the card"
);
this.assertTrue(
$unitMeta.text().includes(formatResourceAmount("steel", unitBill.steel)),
`the unit's steel bill is quoted: ${$unitMeta.text()}`
);
this.assertTrue(
$unitMeta.text().includes("45"),
`the unit's simple build clock is quoted: ${$unitMeta.text()}`
);
const index = BUILDINGS.findIndex((b) => b.id === "barracks");
const $buildMeta = env.$("#city-buildings-list .building-tile").eq(index).find(".bt-meta");
this.assertTrue(
$buildMeta.find("img[src$='icon_steel.svg']").length === 1,
"the building's steel cost is on the card"
);
modal.close();
} finally {
teardownDom(env);
}
}
test_a_single_currency_book_needs_no_conversion() {
setCurrency({ code: "EUR", symbol: "€", name: "Euro" });
setCurrencyBook(
@@ -206,7 +253,14 @@ export class SimpleUiTest extends TestCase {
async test_the_top_panel_shows_the_pool_in_simple_mode() {
const env = await setupDom();
try {
const screen = { economyModel: "simple", _updateTopResources: panelMethods._updateTopResources };
const screen = {
economyModel: "simple",
_updateTopResources: panelMethods._updateTopResources,
_showStatTooltip: panelMethods._showStatTooltip,
_statTooltipContent: panelMethods._statTooltipContent,
_resourceTooltip: panelMethods._resourceTooltip,
$statTooltip: env.$("#stat-tooltip"),
};
screen._updateTopResources({
resources: {
stock: { steel: 1_000_000, food: 500_000, luxury: 500_000, hightech: 20_000 },
@@ -229,15 +283,22 @@ export class SimpleUiTest extends TestCase {
// 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"
);
// The chip's tooltip is a two-line bill: production with a plus, then
// consumption with a minus, each with its figure and unit.
screen._showStatTooltip(chips.eq(0));
const tip = env.$("#stat-tooltip .resource-tip");
const signs = tip.find(".rt-sign");
const figures = tip.find(".rt-figure");
const tails = tip.find(".rt-tail");
this.assertSize(signs, 2, "production and consumption");
this.assertTrue(signs.eq(0).hasClass("produce"), "production leads");
this.assertEqual(signs.eq(0).text(), "+");
this.assertEqual(figures.eq(0).text(), groupDigits(1_500));
this.assertEqual(tails.eq(0).text(), "t/day");
this.assertTrue(signs.eq(1).hasClass("consume"), "consumption follows");
this.assertEqual(signs.eq(1).text(), "−");
this.assertEqual(figures.eq(1).text(), groupDigits(1_000));
this.assertEqual(tails.eq(1).text(), "t/day");
} finally {
teardownDom(env);
}