Guaranteed the 45-second starter build even on an unpowered map

This commit is contained in:
2026-09-25 15:29:19 +02:00
parent 80d2e22a59
commit 0a1c2d0c58
5 changed files with 64 additions and 45 deletions
+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": "80d2e22",
"date": "2026-09-25",
"subject": "Sped up simple construction and showed resource costs on build cards",
"body": ""
},
{
"hash": "4e11af2",
"date": "2026-09-25",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"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": ""
}
];
+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.
+13 -10
View File
@@ -51,14 +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.
// Tuned so a modern-infantry unit (cost 100M) trains in 45 in-game hours --
// 45 real seconds, one hour a second -- in a starter region holding the
// typical ~3.5e9 kWh a day of spare power.
energyPerConstructionHour: 1_600,
// 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,
// 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,
@@ -75,9 +76,11 @@ 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 so a blacked-out region still builds -- slowly --
// rather than dividing by zero or finishing at once. Shared by the server's
// clock and the browser's quoted times, so the two can never disagree.
// 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,
+9 -5
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 --
@@ -811,9 +814,10 @@ 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. The arithmetic
// lives in the data module so the browser quotes the same figure.
// 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) {
return simpleConstructionHours(cost, this.regionAvailableEnergy(city), kind);
},
+27 -19
View File
@@ -134,46 +134,54 @@ export class SimpleConstructionTest extends TestCase {
);
}
// The reference calibration: a modern-infantry unit (cost 100M) trains in
// about 45 in-game hours -- 45 real seconds -- in a starter region with the
// typical spare power. The rate lives in the shared data module so the server
// clock and the browser's quoted times derive it the same way.
test_the_reference_unit_trains_in_45_hours_in_a_starter_region() {
const energy = 3_500_000_000;
const hours = simpleConstructionHours(100_000_000, energy, "unit");
this.assertApprox(hours, 45, 1, "modern infantry is about 45 hours at starter power");
// 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(100_000_000, energy, "building") * 10,
hours,
0.5,
simpleConstructionHours(cost, 3_500_000_000, "building") * 10,
typical,
1e-6,
"a building of the same cost runs ten times quicker"
);
const dead = simpleConstructionHours(100_000_000, 0, "unit");
this.assertGreater(dead, hours, "a blacked-out region builds slower");
this.assertGreaterOrEqual(dead, SIMPLE_ECONOMY.construction.minHours, "but not instantly");
// 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");
}