Founded every nation with a standing army and a year of reserves

This commit is contained in:
2026-09-23 22:41:44 +02:00
parent 0d1995c0bb
commit 822c26f616
7 changed files with 269 additions and 11 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": "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."
},
{
"hash": "8042991",
"date": "2026-09-23",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-23",
"subject": "Rolled the central bank's commodity chart over the last two years",
"body": "The index is still 100 at January 2000's prices, but the chart now plots only the last twenty-four monthly samples and its end labels follow the window, so the graph stops growing as the game runs. A test covers the 24-month window and its end labels."
},
{
"hash": "2d36685",
"date": "2026-09-22",
"subject": "Moved tile works into a management panel and put every building on one card",
"body": "The city buildings tab and the HUD's tile panel now draw the same card: an icon, name and status, a bottom-anchored button carrying the estimated price and build time, a quiet remove control and a hatched locked state, with the produce/consume/cost detail in a hover tooltip. The shared card lives in client/js/ui/card.js.\n\nThe tile dock is now a summary — description, production, people and their ethnic makeup, GDP and the improvements list — with a Management button that opens a full-size panel like the city and nation panels. Its Improvements tab holds the grouped Military, Transportation and Economic cards, so Road and Railway sit with the other works. City buildings sit in a two-row horizontal scroller with the built ones first, and the city panel now matches the nation panel's size.\n\nAGENTS.md now asks for a minimal visual preview to be shown before a player-visible change is committed."
}
];
+4
View File
@@ -120,6 +120,10 @@ export class GameServer extends EventEmitter {
this.settledDays = warmupDays > 0
? this.state.warmUpToStability({ maxDays: warmupDays })
: 0;
// Lay out every nation's opening forces and reserves only after the settle,
// so the warm-up cannot spend the year of stores the game is meant to open
// with. This is the state the first player joins.
this.state.applyStartingScenario();
this._configured = true;
for (const peerId of this.network.getPeerIds()) {
this._assignCiv(peerId, this.peerNames.get(peerId) || "");
+3 -1
View File
@@ -52,6 +52,7 @@ import { industryMethods } from "./game_state/industry.js";
import { budgetMethods } from "./game_state/budget.js";
import { moneyMethods } from "./game_state/money.js";
import { taxMethods } from "./game_state/taxes.js";
import { scenarioMethods } from "./game_state/scenario.js";
// Shared empty result for `unitsAt`, so an empty tile does not allocate.
const EMPTY_UNITS = [];
@@ -547,5 +548,6 @@ Object.assign(
industryMethods,
budgetMethods,
moneyMethods,
taxMethods
taxMethods,
scenarioMethods
);
+104
View File
@@ -0,0 +1,104 @@
// The opening order of battle and the reserves every civilisation starts a game
// with. The scenario is applied by the server once the world has been settled,
// not during world generation, so the economy's warm-up cannot spend the
// opening stores: a player joining the server sees exactly these figures.
import { STORABLE_RESOURCE_IDS } from "../data.js";
// The forces a nation is founded with. Infantry and artillery stand in every
// city, the launches and battleships of a city's port are launched onto its
// adjacent water, and the capital fields the air force.
export const STARTING_FORCES = {
infantryPerCity: 15,
artilleryPerCity: 5,
launchesPerPort: 5,
battleshipsPerPort: 2,
fightersPerCapital: 5,
bombersPerCapital: 5,
};
// Every region opens holding a year of what it consumes. Energy is a flow with
// no store, so only the four storable goods are stocked.
export const STARTING_RESERVE_DAYS = 365;
export const scenarioMethods = {
// Applies the whole opening scenario to a settled world. Any units the
// generated world seeded are cleared first, so the order of battle is exactly
// the one described here and never picks up a stray starting unit.
applyStartingScenario() {
this._clearStartingForces();
this._seedStartingBuildings();
this._seedStartingUnits();
this._seedStartingReserves();
this._emitChanged();
},
_clearStartingForces() {
for (const unit of this.units.slice()) this._destroyUnit(unit);
},
// Every city raises a barracks; the capital also an airport.
_seedStartingBuildings() {
const barracks = this.buildingIndexById("barracks");
const airport = this.buildingIndexById("airport");
for (const city of this.cities) {
if (barracks >= 0) {
city.buildings[barracks] = Math.max(1, city.buildings[barracks] || 0);
}
if (airport >= 0 && city.isCapital) {
city.buildings[airport] = Math.max(1, city.buildings[airport] || 0);
}
}
this._clearTileGdpCache();
this._touchModifiers();
},
_seedStartingUnits() {
const protoById = (id) => this.protoUnits.find((unit) => unit.id === id);
const infantry = protoById("modern_infantry");
const artillery = protoById("artillery");
const launch = protoById("launch");
const battleship = protoById("battleship");
const fighter = protoById("jet_fighter");
const bomber = protoById("bomber");
for (const city of this.cities) {
for (let i = 0; i < STARTING_FORCES.infantryPerCity; i++) {
this._spawnUnit(city.coords, city.civ, infantry, city.id);
}
for (let i = 0; i < STARTING_FORCES.artilleryPerCity; i++) {
this._spawnUnit(city.coords, city.civ, artillery, city.id);
}
if (!this.hasCityBuilding(city, "port")) continue;
for (let i = 0; i < STARTING_FORCES.launchesPerPort; i++) {
this._spawnUnit(this._spawnCoordsFor(city, launch), city.civ, launch, city.id);
}
for (let i = 0; i < STARTING_FORCES.battleshipsPerPort; i++) {
this._spawnUnit(this._spawnCoordsFor(city, battleship), city.civ, battleship, city.id);
}
}
for (const city of this.cities) {
if (!city.isCapital) continue;
for (let i = 0; i < STARTING_FORCES.fightersPerCapital; i++) {
this._spawnUnit(city.coords, city.civ, fighter, city.id);
}
for (let i = 0; i < STARTING_FORCES.bombersPerCapital; i++) {
this._spawnUnit(city.coords, city.civ, bomber, city.id);
}
}
},
// Fills every region's store with a year of its daily consumption, so a fresh
// nation can fight and build for a long while before its industry and its
// trade routes must carry it.
_seedStartingReserves() {
for (const city of this.cities) {
const consumption = this.getRegionResourceInfo(city).consumption;
const stock = this.getCityResourceStock(city);
for (const id of STORABLE_RESOURCE_IDS) {
stock[id] = (consumption[id] || 0) * STARTING_RESERVE_DAYS;
}
}
},
};
+17 -1
View File
@@ -2,8 +2,24 @@ import { TestCase } from "./framework/test_case.js";
import { GameServer } from "../server/game_server.js";
import { CIVILISATIONS } from "../shared/data.js";
import { adjacentLand, SEED, grantBuilding, smallConfig } from "./framework/helpers.js";
import { STARTING_FORCES } from "../shared/game_state/scenario.js";
import { key } from "../shared/hex.js";
// What the opening order of battle should total on the fixture's world.
function expectedStartingUnits(state) {
let total = 0;
for (const city of state.cities) {
total += STARTING_FORCES.infantryPerCity + STARTING_FORCES.artilleryPerCity;
if (state.hasCityBuilding(city, "port")) {
total += STARTING_FORCES.launchesPerPort + STARTING_FORCES.battleshipsPerPort;
}
if (city.isCapital) {
total += STARTING_FORCES.fightersPerCapital + STARTING_FORCES.bombersPerCapital;
}
}
return total;
}
function fakeNetwork() {
return {
peers: new Set(),
@@ -42,7 +58,7 @@ export class GameServerTest extends TestCase {
this.assertTrue(server.isConfigured());
this.assertNotNull(server.state);
this.assertEqual(server.hostCiv, 0);
this.assertSize(server.state.units, 2);
this.assertSize(server.state.units, expectedStartingUnits(server.state));
this.assertSize(server.state.civilisations, 2);
}
+117
View File
@@ -0,0 +1,117 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import {
STARTING_FORCES,
STARTING_RESERVE_DAYS,
} from "../shared/game_state/scenario.js";
import { STORABLE_RESOURCE_IDS } from "../shared/data.js";
function unitsWithProto(state, protoId) {
const index = state.protoUnits.findIndex((unit) => unit.id === protoId);
return state.units.filter((unit) => unit.proto === index);
}
function countAt(state, protoId, coords) {
return unitsWithProto(state, protoId).filter(
(unit) => unit.coords.x === coords.x && unit.coords.y === coords.y
).length;
}
export class ScenarioTest extends TestCase {
test_every_city_opens_with_a_barracks_and_the_capital_an_airport() {
const state = smallState();
state.applyStartingScenario();
const barracks = state.buildingIndexById("barracks");
const airport = state.buildingIndexById("airport");
for (const city of state.cities) {
this.assertEqual(state.getCityBuildingLevel(city, barracks), 1, "the city barracks");
this.assertEqual(
state.getCityBuildingLevel(city, airport),
city.isCapital ? 1 : 0,
`the airport in ${city.name}`
);
}
}
test_every_city_opens_with_its_infantry_and_artillery() {
const state = smallState();
state.applyStartingScenario();
for (const city of state.cities) {
this.assertEqual(
countAt(state, "modern_infantry", city.coords),
STARTING_FORCES.infantryPerCity,
`infantry in ${city.name}`
);
this.assertEqual(
countAt(state, "artillery", city.coords),
STARTING_FORCES.artilleryPerCity,
`artillery in ${city.name}`
);
}
}
test_every_port_launches_its_flotilla_onto_the_water() {
const state = smallState();
state.applyStartingScenario();
for (let civ = 0; civ < state.civilisations.length; civ++) {
const ports = state.cities.filter(
(city) => city.civ === civ && state.hasCityBuilding(city, "port")
).length;
const launches = unitsWithProto(state, "launch").filter((unit) => unit.civ === civ);
const battleships = unitsWithProto(state, "battleship").filter((unit) => unit.civ === civ);
this.assertEqual(launches.length, STARTING_FORCES.launchesPerPort * ports, `launches of civ ${civ}`);
this.assertEqual(
battleships.length,
STARTING_FORCES.battleshipsPerPort * ports,
`battleships of civ ${civ}`
);
for (const unit of launches.concat(battleships)) {
const tile = state.tiles[`${unit.coords.x},${unit.coords.y}`];
this.assertEqual(tile && tile.terrainClass, "Sea", "the warship floats");
}
}
}
test_the_capital_fields_the_air_force() {
const state = smallState();
state.applyStartingScenario();
for (const city of state.cities) {
if (!city.isCapital) continue;
this.assertEqual(
countAt(state, "jet_fighter", city.coords),
STARTING_FORCES.fightersPerCapital,
`fighters in ${city.name}`
);
this.assertEqual(
countAt(state, "bomber", city.coords),
STARTING_FORCES.bombersPerCapital,
`bombers in ${city.name}`
);
}
}
test_each_region_opens_with_a_year_of_its_consumption() {
const state = smallState();
state.applyStartingScenario();
for (const city of state.cities) {
const consumption = state.getRegionResourceInfo(city).consumption;
const stock = state.getCityResourceStock(city);
for (const id of STORABLE_RESOURCE_IDS) {
this.assertApprox(
stock[id],
(consumption[id] || 0) * STARTING_RESERVE_DAYS,
1e-6,
`${id} in ${city.name}`
);
}
}
}
test_the_scenario_replaces_any_starting_units() {
const state = smallState();
state.applyStartingScenario();
const once = state.units.length;
state.applyStartingScenario();
this.assertEqual(state.units.length, once, "re-applying does not stack a second army");
}
}
+18 -3
View File
@@ -98,12 +98,17 @@ export class TestingModeTest extends TestCase {
server.peerCiv.set(1, 0);
const city = server.state.cities.find((c) => c.civ === 0);
const barracks = server.state.protoBuildings.findIndex((b) => b.id === "barracks");
const levelBefore = server.state.getCityBuildingLevel(city, barracks);
const budgetBefore = server.state.getBudget(0);
this.assertTrue(
server.handleOrder(1, { type: "build", city: city.id, building: barracks, free: true }),
"the order is accepted"
);
this.assertEqual(server.state.getCityBuildingLevel(city, barracks), 1, "the building went up");
this.assertEqual(
server.state.getCityBuildingLevel(city, barracks),
levelBefore + 1,
"the building went up"
);
this.assertEqual(server.state.getBudget(0), budgetBefore, "it was free");
this.assertTrue(server.state.testing, "the snapshot flag is set");
}
@@ -113,12 +118,17 @@ export class TestingModeTest extends TestCase {
server.peerCiv.set(1, 0);
const city = server.state.cities.find((c) => c.civ === 0);
const barracks = server.state.protoBuildings.findIndex((b) => b.id === "barracks");
const levelBefore = server.state.getCityBuildingLevel(city, barracks);
const budgetBefore = server.state.getBudget(0);
this.assertTrue(
server.handleOrder(1, { type: "build", city: city.id, building: barracks, free: true }),
"the order is still processed"
);
this.assertEqual(server.state.getCityBuildingLevel(city, barracks), 0, "it only went into the queue");
this.assertEqual(
server.state.getCityBuildingLevel(city, barracks),
levelBefore,
"it only went into the queue"
);
this.assertLess(server.state.getBudget(0), budgetBefore, "the normal cost was charged");
this.assertFalse(server.state.testing, "the testing flag stays off");
}
@@ -129,12 +139,17 @@ export class TestingModeTest extends TestCase {
server.peerCiv.set(1, 0);
const city = server.state.cities.find((c) => c.civ === 0);
const barracks = server.state.protoBuildings.findIndex((b) => b.id === "barracks");
const levelBefore = server.state.getCityBuildingLevel(city, barracks);
const budgetBefore = server.state.getBudget(0);
this.assertTrue(
server.handleOrder(1, { type: "build", city: city.id, building: barracks, free: true }),
"the order is accepted"
);
this.assertEqual(server.state.getCityBuildingLevel(city, barracks), 1, "the building went up");
this.assertEqual(
server.state.getCityBuildingLevel(city, barracks),
levelBefore + 1,
"the building went up"
);
this.assertEqual(server.state.getBudget(0), budgetBefore, "it was free");
this.assertFalse(server.state.testing, "the game itself stays a normal game");
}