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.
343 lines
13 KiB
JavaScript
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"),
|
|
});
|
|
}
|