Files
adrien d5305da449 Gave the world a simple economy: one pool per nation and a global market
The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days.

Money is euro only: a per-inhabitant daily tax funds a treasury spent in a global market, where buy and sell lots trade against the world stock and move its price. The hard economy keeps its per-nation currencies, central banks and exchange rates.

Simple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy.
2026-09-25 14:58:49 +02:00

343 lines
13 KiB
JavaScript

// Node entry point: serves the client and shared modules as static files and
// hosts the authoritative game over a WebSocket. Run with:
//
// node server/server.js [--port 27015] [--bind 127.0.0.1] [--economy simple|hard]
// [--warmup days (hard economy only)]
//
// The browser client connects to ws(s)://<host>/ws on the same origin. The
// wiring is exposed through startServer() so the test suite can boot the same
// server on an ephemeral port.
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { dirname, extname, join, normalize, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { WebSocketServer } from "./websocket.js";
import { GameServer } from "./game_server.js";
import { SECONDS_PER_HOUR } from "../shared/game_clock.js";
import { DEFAULT_ECONOMY_MODEL, normalizeEconomyModel } from "../shared/data.js";
const ROOT = normalize(join(dirname(fileURLToPath(import.meta.url)), ".."));
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".wav": "audio/wav",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".flac": "audio/flac",
".ttf": "font/ttf",
".woff": "font/woff",
".woff2": "font/woff2",
".ico": "image/x-icon",
};
const TICK_MS = 50;
// Movement still simulates every tick, but snapshots are broadcast at most this
// often. A full snapshot is hundreds of KiB, and sending one per 50ms tick made
// every client parse, allocate and repaint at 20Hz; the browser dead-reckons
// movement between snapshots, so a lower rate is invisible in play.
const SNAPSHOT_INTERVAL_S = 0.1;
async function serveStatic(request, response) {
try {
const url = new URL(request.url, "http://localhost");
const pathname = decodeURIComponent(url.pathname);
if (pathname === "/") {
response.writeHead(302, { Location: "/client/index.html" });
response.end();
return;
}
const safe = normalize(pathname).replace(/^(\.\.[/\\])+/, "");
const filePath = join(ROOT, safe);
if (!filePath.startsWith(ROOT + sep) && filePath !== ROOT) {
response.writeHead(403).end("Forbidden");
return;
}
const info = await stat(filePath).catch(() => null);
if (!info || !info.isFile()) {
response.writeHead(404, { "Content-Type": "text/plain" }).end("Not found");
return;
}
const body = await readFile(filePath);
response.writeHead(200, {
"Content-Type": MIME[extname(filePath).toLowerCase()] || "application/octet-stream",
"Cache-Control": "no-cache",
});
response.end(body);
} catch {
response.writeHead(500, { "Content-Type": "text/plain" }).end("Server error");
}
}
// Whether a peer address is the local machine. Handles the IPv4-mapped IPv6
// form Node reports for a v4 socket ("::ffff:127.0.0.1") as well as plain v4/v6.
export function isLoopback(address) {
if (!address) return false;
const value = String(address).replace(/^::ffff:/, "");
return value === "::1" || value === "localhost" || value.startsWith("127.");
}
// Whether a connection came from a page served on the local machine. The socket
// has to be loopback and the browser's Origin header (which page scripts cannot
// forge) has to name a loopback host, so a proxied remote client whose socket
// looks local is still treated as remote.
export function isLocalRequest(remoteAddress, origin) {
if (!isLoopback(remoteAddress)) return false;
let host;
try {
host = new URL(String(origin)).hostname;
} catch {
return false;
}
return isLoopback(host.replace(/^\[|\]$/g, ""));
}
// Builds the whole server without listening. `listen()` resolves once the port
// is bound and yields the bound address. A game is configured here, before any
// player connects: the server owns one persistent world and clients only join
// it (`seed`, `testing` and `civilisations` exist for tests and operators).
export function startServer({
port = 27015,
bind = "127.0.0.1",
log = console.log,
seed = 0,
testing = false,
civilisations = [],
mapConfig = null,
warmupDays = 0,
// Whether `warmupDays` was asked for explicitly (the launcher's flag or the
// `WARMUP` env var rather than a default), so the simple model can say it is
// ignoring the request instead of dropping it in silence.
warmupExplicit = false,
economyModel = "simple",
} = {}) {
const httpServer = createServer(serveStatic);
const wsServer = new WebSocketServer(httpServer, { path: "/ws" });
const connections = new Map();
const network = {
getPeerIds() {
return Array.from(connections.keys());
},
send(peerId, message) {
const connection = connections.get(peerId);
if (connection) connection.send(JSON.stringify(message));
},
};
const gameServer = new GameServer(network);
// The settle is a hard-economy concept; the simple model opens on its authored
// scenario. Resolve the model here the same way `configure` will, so the log
// matches the game that is actually built.
const simpleEconomy = normalizeEconomyModel(economyModel) === DEFAULT_ECONOMY_MODEL;
if (simpleEconomy) {
if (warmupExplicit) {
log("the simple economy needs no warm-up; ignoring --warmup.");
}
} else if (warmupDays > 0) {
log(`settling the world until commodity prices hold steady (up to ${warmupDays} days)...`);
}
const settledDays = gameServer.configureGame({
seed, player_civ: 0, civilisations, testing, mapConfig, warmupDays, economyModel,
});
if (!simpleEconomy && warmupDays > 0) log(`world settled at day ${settledDays}`);
function broadcastPlayers() {
const players = {};
for (const [peerId, connection] of connections) {
if (!connection.name) continue;
players[peerId] = {
id: peerId,
name: connection.name,
admin: connection.name === gameServer.adminName,
civ: gameServer.peerCiv.has(peerId) ? gameServer.peerCiv.get(peerId) : -1,
};
}
const message = JSON.stringify({ t: "players", players });
for (const connection of connections.values()) connection.send(message);
}
function handleMessage(connection, message) {
switch (message.t) {
case "request_games":
connection.send(JSON.stringify({ t: "game_list", games: gameServer.gameList() }));
break;
case "login": {
const result = gameServer.authenticate(connection.id, message.name, message.password);
if (!result.ok) {
connection.send(JSON.stringify({ t: "login_rejected", reason: result.reason }));
connection.close(1000);
return;
}
connection.name = result.name;
connection.send(JSON.stringify({
t: "assigned",
playerId: connection.id,
admin: result.admin,
name: result.name,
}));
gameServer.onPeerJoined(connection.id, result.name);
// Broadcast after the join, so a reclaimed nation shows its player
// immediately in everyone's diplomacy tab.
broadcastPlayers();
break;
}
case "claim_civ":
gameServer.claimCiv(connection.id, message.civ);
broadcastPlayers();
break;
case "chat": {
// Relay a short line of chat. `to` names a nation index for a private
// line (delivered to that nation and echoed to the sender); without it
// the line goes to every signed-in player. The line is recorded in the
// game state so a spy can intercept it later. Empty lines and anonymous
// senders are dropped.
if (!connection.name) break;
const text = String(message.text || "").trim().slice(0, 240);
if (text.length === 0) break;
const fromCiv = gameServer.peerCiv.has(connection.id)
? gameServer.peerCiv.get(connection.id)
: -1;
const toCiv = Number.isInteger(message.to) && message.to >= 0 ? message.to : null;
const chat = JSON.stringify({
t: "chat",
name: connection.name,
civ: fromCiv,
to: toCiv,
text,
});
for (const peer of connections.values()) {
if (!peer.name) continue;
if (toCiv === null) {
peer.send(chat);
} else {
const peerCiv = gameServer.peerCiv.has(peer.id)
? gameServer.peerCiv.get(peer.id)
: -1;
if (peer.id === connection.id || peerCiv === toCiv) peer.send(chat);
}
}
if (gameServer.state) gameServer.state.recordChat(fromCiv, toCiv, text);
break;
}
case "order": {
const order = message.order || {};
// The free shortcut is only honoured for a page on the local machine,
// whatever the game's testing flag says.
if (order.free && !isLocalRequest(connection.remoteAddress, connection.origin)) {
order.free = false;
}
gameServer.enqueueOrder(connection.id, order);
break;
}
default:
break;
}
}
wsServer.on("connection", (connection) => {
connections.set(connection.id, connection);
connection.name = "";
gameServer.setPeerLocal(
connection.id,
isLocalRequest(connection.remoteAddress, connection.origin)
);
connection.on("message", (raw) => {
let message;
try {
message = JSON.parse(raw);
} catch {
return;
}
handleMessage(connection, message);
});
connection.on("close", () => {
connections.delete(connection.id);
gameServer.onPeerLeft(connection.id);
broadcastPlayers();
});
});
// Fixed-step loop: movement advances every tick and the clock strikes an hour
// every SECONDS_PER_HOUR real seconds. The state is only broadcast once per
// SNAPSHOT_INTERVAL_S however often it changed, so a game with moving units
// does not flood clients with full snapshots. Exposed as `step` so tests can
// drive the clock deterministically.
let hourAccumulator = 0;
let snapshotAccumulator = 0;
function step(seconds) {
// Drain queued player orders first, under a small time budget, so a burst
// from several players never runs every path search in one tick.
gameServer.processOrders();
gameServer.advanceMovement(seconds / SECONDS_PER_HOUR);
hourAccumulator += seconds;
while (hourAccumulator >= SECONDS_PER_HOUR) {
hourAccumulator -= SECONDS_PER_HOUR;
gameServer.tickHour();
}
snapshotAccumulator += seconds;
if (snapshotAccumulator >= SNAPSHOT_INTERVAL_S) {
snapshotAccumulator = 0;
gameServer.flushIfDirty();
}
}
const timer = setInterval(() => step(TICK_MS / 1000), TICK_MS);
const listen = new Promise((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(port, bind, () => {
const address = httpServer.address();
log(`Battle for 'Tismo listening on http://${bind}:${address.port}/ (ws://${bind}:${address.port}/ws)`);
resolve(address);
});
});
function close() {
clearInterval(timer);
for (const connection of connections.values()) connection.close(1001);
connections.clear();
httpServer.close();
}
return { httpServer, wsServer, gameServer, network, connections, listen, step, close };
}
function argValue(name, fallback) {
const index = process.argv.indexOf(name);
if (index >= 0 && index + 1 < process.argv.length) return process.argv[index + 1];
return fallback;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
// Settle the world before serving, so the hard game opens with its economy
// established. The settle stops early once commodity prices hold steady over
// a seven-day window, and this value caps the days it may run. `--warmup 0`
// skips the settle. A `null` means the operator asked for it explicitly
// (flag or `WARMUP`), which the simple model reports and ignores; the plain
// default 150 is not "explicit".
const warmupArg = argValue("--warmup", process.env.WARMUP ?? null);
startServer({
port: Number(argValue("--port", process.env.PORT || 27015)),
bind: argValue("--bind", process.env.BIND || "127.0.0.1"),
seed: Number(argValue("--seed", process.env.SEED || 0)) | 0,
// Free, instant orders for local play; remote peers still have their `free`
// flag stripped in the order handler.
testing: process.argv.includes("--testing"),
warmupDays: warmupArg == null ? 150 : Number(warmupArg) | 0,
warmupExplicit: warmupArg != null,
// Which economy model to run: "simple" (the default) or "hard", the
// original per-region simulation. `configure` coerces an unknown value to
// the default.
economyModel: argValue("--economy", process.env.ECONOMY || "simple"),
});
}