336 lines
11 KiB
JavaScript
336 lines
11 KiB
JavaScript
// Authoritative game server, ported from scripts/server/game_server.gd. Owns
|
|
// the GameState, turns player orders into state changes and pushes snapshots
|
|
// back to every connected client. The `network` adapter supplies getPeerIds()
|
|
// and send(peerId, message); everything else is transport-agnostic.
|
|
|
|
import { EventEmitter } from "node:events";
|
|
import { GameState } from "../shared/game_state.js";
|
|
import { LoginManager } from "../shared/login_manager.js";
|
|
import { CIVILISATIONS } from "../shared/data.js";
|
|
import { dateString } from "../shared/game_clock.js";
|
|
|
|
export class GameServer extends EventEmitter {
|
|
constructor(network) {
|
|
super();
|
|
this.network = network;
|
|
this.state = null;
|
|
this.hostCiv = 0;
|
|
this.peerCiv = new Map();
|
|
this.login = new LoginManager();
|
|
this.adminName = "";
|
|
this.peerNames = new Map();
|
|
this.identityCiv = new Map();
|
|
this._configured = false;
|
|
this._stateDirty = false;
|
|
}
|
|
|
|
isConfigured() {
|
|
return this._configured;
|
|
}
|
|
|
|
gameList() {
|
|
if (!this._configured || !this.state) return [];
|
|
return [this.gameSummary()];
|
|
}
|
|
|
|
gameSummary() {
|
|
return {
|
|
id: this.state.seed,
|
|
name: `Game ${this.state.seed}`,
|
|
seed: this.state.seed,
|
|
players: this.peerNames.size,
|
|
civs: this.state.civilisations.map((c) => c.name),
|
|
date: dateString(this.state.totalHours),
|
|
};
|
|
}
|
|
|
|
authenticate(peerId, playerName, password) {
|
|
const name = String(playerName || "").trim();
|
|
if (name.length === 0) return { ok: false, reason: "Enter a player name." };
|
|
if (String(password || "").length === 0) return { ok: false, reason: "Enter a password." };
|
|
if (Array.from(this.peerNames.values()).includes(name)) {
|
|
return { ok: false, reason: `${name} is already playing.` };
|
|
}
|
|
if (!this.login.login(name, password)) {
|
|
return { ok: false, reason: `Wrong password for ${name}.` };
|
|
}
|
|
const isAdmin = this.adminName.length === 0;
|
|
if (isAdmin) this.adminName = name;
|
|
this.peerNames.set(peerId, name);
|
|
return { ok: true, name, admin: name === this.adminName };
|
|
}
|
|
|
|
configureGame(setup) {
|
|
if (this._configured) return;
|
|
this.hostCiv = Number(setup.player_civ || 0);
|
|
this.state = new GameState();
|
|
this.state.configure(this._loadCivs(setup.civilisations || []), Number(setup.seed || 0));
|
|
this.state.onChanged(() => {
|
|
this._stateDirty = true;
|
|
});
|
|
this._configured = true;
|
|
for (const peerId of this.network.getPeerIds()) {
|
|
this._assignCiv(peerId, this.peerNames.get(peerId) || "");
|
|
}
|
|
this._broadcastState();
|
|
this.emit("game_configured");
|
|
}
|
|
|
|
_loadCivs(ids) {
|
|
const civs = [];
|
|
for (const id of ids) {
|
|
const civ = CIVILISATIONS.find((c) => c.id === id);
|
|
if (civ) civs.push(civ);
|
|
}
|
|
// An empty list means the project's full roster.
|
|
return civs.length === 0 ? CIVILISATIONS.slice() : civs;
|
|
}
|
|
|
|
handleOrder(peerId, order) {
|
|
if (!this._configured) return false;
|
|
const civ = this.peerCiv.has(peerId) ? this.peerCiv.get(peerId) : -1;
|
|
if (civ < 0) return false;
|
|
const type = String(order.t || order.type || "");
|
|
switch (type) {
|
|
case "move":
|
|
return this._handleMove(civ, order);
|
|
case "attack":
|
|
return this._handleAttack(civ, order);
|
|
case "schedule":
|
|
return this._handleSchedule(civ, order);
|
|
case "pillage":
|
|
return this._handlePillage(civ, order);
|
|
case "train":
|
|
return this._handleTrain(civ, order);
|
|
case "cancel_train":
|
|
return this._handleCancelTrain(civ, order);
|
|
case "build":
|
|
return this._handleBuild(civ, order);
|
|
case "demolish":
|
|
return this._handleDemolish(civ, order);
|
|
case "government":
|
|
return this._handleGovernment(civ, order);
|
|
case "technology":
|
|
return this._handleTechnology(civ, order);
|
|
case "declare_war":
|
|
return this._handleDeclareWar(civ, order);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
_handleMove(civ, order) {
|
|
const ids = Array.isArray(order.units) ? order.units : [order.unit];
|
|
const units = [];
|
|
for (const id of ids) {
|
|
const unit = this.state.findUnit(Number(id));
|
|
if (!unit || unit.civ !== civ) return false;
|
|
units.push(unit.id);
|
|
}
|
|
if (units.length === 0) return false;
|
|
return this.state.requestGroupMove(units, coordsOf(order.coords));
|
|
}
|
|
|
|
_handleAttack(civ, order) {
|
|
const units = this._ownUnitIds(civ, order);
|
|
if (units.length === 0) return false;
|
|
return this.state.requestAttack(units, coordsOf(order.coords));
|
|
}
|
|
|
|
_handleSchedule(civ, order) {
|
|
const units = this._ownUnitIds(civ, order);
|
|
if (units.length === 0) return false;
|
|
const goals = (Array.isArray(order.goals) ? order.goals : [])
|
|
.filter((goal) => Array.isArray(goal) && goal.length >= 2)
|
|
.map(coordsOf);
|
|
return this.state.requestScheduledMove(units, goals, !!order.append);
|
|
}
|
|
|
|
_ownUnitIds(civ, order) {
|
|
const ids = Array.isArray(order.units) ? order.units : [order.unit];
|
|
const units = [];
|
|
for (const id of ids) {
|
|
const unit = this.state.findUnit(Number(id));
|
|
if (!unit || unit.civ !== civ) continue;
|
|
units.push(unit.id);
|
|
}
|
|
return units;
|
|
}
|
|
|
|
_handlePillage(civ, order) {
|
|
const ids = Array.isArray(order.units) ? order.units : [order.unit];
|
|
const units = [];
|
|
for (const id of ids) {
|
|
const unit = this.state.findUnit(Number(id));
|
|
if (!unit || unit.civ !== civ) return false;
|
|
units.push(unit.id);
|
|
}
|
|
if (units.length === 0) return false;
|
|
return this.state.requestPillage(units);
|
|
}
|
|
|
|
_handleTrain(civ, order) {
|
|
const city = this.state.findCity(Number(order.city));
|
|
if (!city || city.civ !== civ) return false;
|
|
return this.state.requestTrain(city.id, Number(order.proto));
|
|
}
|
|
|
|
_handleCancelTrain(civ, order) {
|
|
const city = this.state.findCity(Number(order.city));
|
|
if (!city || city.civ !== civ) return false;
|
|
return this.state.cancelTrain(city.id, Number(order.index));
|
|
}
|
|
|
|
_handleBuild(civ, order) {
|
|
const city = this.state.findCity(Number(order.city));
|
|
if (!city || city.civ !== civ) return false;
|
|
return this.state.requestBuild(city.id, Number(order.building));
|
|
}
|
|
|
|
_handleDemolish(civ, order) {
|
|
const city = this.state.findCity(Number(order.city));
|
|
if (!city || city.civ !== civ) return false;
|
|
return this.state.requestDemolish(city.id, Number(order.building));
|
|
}
|
|
|
|
_handleGovernment(civ, order) {
|
|
return this.state.requestSetGovernment(civ, Number(order.government));
|
|
}
|
|
|
|
_handleTechnology(civ, order) {
|
|
return this.state.requestResearchTechnology(civ, Number(order.technology));
|
|
}
|
|
|
|
_handleDeclareWar(civ, order) {
|
|
const target = Number(order.target !== undefined ? order.target : order.civ);
|
|
return this.state.requestDeclareWar(civ, target);
|
|
}
|
|
|
|
onPeerJoined(peerId, playerName = "") {
|
|
if (!this._configured) return;
|
|
// A returning player keeps the nation they claimed before.
|
|
if (this._reclaimIdentity(peerId, playerName)) {
|
|
this.network.send(peerId, { t: "game_state", state: this.state.snapshot(this.peerCiv.get(peerId)) });
|
|
return;
|
|
}
|
|
// Otherwise offer every nation no one has claimed yet and let the player
|
|
// pick. They stay a spectator (civ -1) until they answer.
|
|
this.peerCiv.set(peerId, -1);
|
|
const options = this.availableCivs();
|
|
this.network.send(peerId, { t: "choose_civ", civs: options });
|
|
if (options.length === 0) {
|
|
this.network.send(peerId, { t: "game_state", state: this.state.snapshot(-1) });
|
|
}
|
|
}
|
|
|
|
onPeerLeft(peerId) {
|
|
this.peerCiv.delete(peerId);
|
|
this.peerNames.delete(peerId);
|
|
}
|
|
|
|
// Indices of the civilisations no connected peer has claimed.
|
|
availableCivs() {
|
|
if (!this._configured || !this.state) return [];
|
|
const used = new Set(this.peerCiv.values());
|
|
const options = [];
|
|
for (let i = 0; i < this.state.civilisations.length; i++) {
|
|
if (used.has(i)) continue;
|
|
const civ = this.state.civilisations[i];
|
|
options.push({ index: i, id: civ.id, name: civ.name });
|
|
}
|
|
return options;
|
|
}
|
|
|
|
// Claims one of the offered civilisations for a joining peer. Rejects an
|
|
// index that is out of range or already taken by someone else.
|
|
claimCiv(peerId, index) {
|
|
if (!this._configured || !this.state) return false;
|
|
const choice = Number(index);
|
|
if (!Number.isInteger(choice) || choice < 0 || choice >= this.state.civilisations.length) {
|
|
return false;
|
|
}
|
|
if (Array.from(this.peerCiv.values()).includes(choice)) return false;
|
|
this.peerCiv.set(peerId, choice);
|
|
const name = this.peerNames.get(peerId);
|
|
if (name) this.identityCiv.set(name, choice);
|
|
this.network.send(peerId, { t: "civ_assigned", civ: choice });
|
|
this._broadcastState();
|
|
return true;
|
|
}
|
|
|
|
// Re-attaches a known player to the nation they held before, as long as no
|
|
// one else has claimed it in the meantime.
|
|
_reclaimIdentity(peerId, playerName) {
|
|
if (!playerName || !this.identityCiv.has(playerName)) return false;
|
|
const choice = this.identityCiv.get(playerName);
|
|
if (choice < 0 || choice >= this.state.civilisations.length) return false;
|
|
if (Array.from(this.peerCiv.values()).includes(choice)) return false;
|
|
this.peerCiv.set(peerId, choice);
|
|
this.network.send(peerId, { t: "civ_assigned", civ: choice });
|
|
return true;
|
|
}
|
|
|
|
_assignCiv(peerId, playerName = "") {
|
|
if (this._configured === false || this.state == null) return;
|
|
const used = Array.from(this.peerCiv.values());
|
|
let choice = -1;
|
|
if (playerName && this.identityCiv.has(playerName)) choice = this.identityCiv.get(playerName);
|
|
if (choice < 0 && peerId === this._adminPeerId()) choice = this.hostCiv;
|
|
if (choice < 0 || used.includes(choice)) {
|
|
choice = -1;
|
|
for (let i = 0; i < this.state.civilisations.length; i++) {
|
|
if (!used.includes(i)) {
|
|
choice = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
this.peerCiv.set(peerId, choice);
|
|
if (playerName && choice >= 0) this.identityCiv.set(playerName, choice);
|
|
}
|
|
|
|
_adminPeerId() {
|
|
for (const [peerId, name] of this.peerNames) {
|
|
if (name === this.adminName) return peerId;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
tickHour() {
|
|
if (!this._configured || !this.state) return;
|
|
this.state.tickHour();
|
|
}
|
|
|
|
advanceMovement(hours) {
|
|
if (!this._configured || !this.state) return;
|
|
this.state.advanceMovement(hours);
|
|
}
|
|
|
|
flushIfDirty() {
|
|
if (!this._stateDirty) return;
|
|
this._stateDirty = false;
|
|
this._broadcastState();
|
|
}
|
|
|
|
_broadcastState() {
|
|
if (!this.state) return;
|
|
const shared = this.state.serializeShared();
|
|
const statsCache = new Map();
|
|
for (const peerId of this.network.getPeerIds()) {
|
|
const civ = this.peerCiv.has(peerId) ? this.peerCiv.get(peerId) : -1;
|
|
if (!statsCache.has(civ)) statsCache.set(civ, this.state.viewerStats(civ));
|
|
this.network.send(peerId, {
|
|
t: "game_state",
|
|
state: this.state.viewerSnapshot(shared, civ, statsCache.get(civ)),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function coordsOf(value) {
|
|
if (Array.isArray(value) && value.length >= 2) {
|
|
return { x: Number(value[0]), y: Number(value[1]) };
|
|
}
|
|
return { x: -1, y: -1 };
|
|
}
|