SpaceCities / server /session.js
Claude
T-012: port the single-player client onto session + loopback
42b40d2 unverified
Raw History Blame Contribute Delete
14.5 kB
/* ============================================================
A session owns one match: the engine state, and the ONLY path anything
outside engine/ has to mutate it. ADR-0003 (server authority) + ADR-0004
(single-player rides the same seam) — this is the object both the
in-process loopback transport (net/loopback.js, Phase 1) and, later, a
real per-connection server handler (Phase 3) wrap.
PHASE 1 SCOPE. submitCommand resolves ids and applies the matching
engine/commands.js issue* call — it does NOT validate ownership or fog.
There is exactly one trusted local client in Phase 1 (loopback), so that
validation has nothing to defend against yet; Phase 2's net/commandCodec.js
adds it as a layer IN FRONT of this file, not a rewrite of it (see
net/commandShapes.js's own header for the fuller version of this argument).
What this file DOES do even now, deliberately: drop a dead id gracefully
rather than throw (a real race, not an attack) and reject a malformed or
unknown command shape (a client bug, not a security event) — both are
ordinary robustness, independent of who's allowed to do what.
TICKING. A session does not own a loop (no requestAnimationFrame, no
setInterval) — tick(dt) advances the sim by exactly one step and returns.
The CALLER decides the cadence: boot.js's engine/loop.js accumulator in
the browser, a plain interval for a headless server, or a tight while-loop
for a bench/test. This mirrors how engine/sim.js's own tick(state, dt) has
always worked — ticked externally, never self-driving.
aiSeats lets a session drive MORE than the engine's own built-in "ai" seat
(engine/sim.js's tick already calls runAI for owner "ai" internally) —
e.g. aiSeats:["player"] reproduces tools/selfplay.js's tickSelfPlay for a
fully headless AI-vs-AI match (T-010's own exit criterion), the same
mechanism Phase 6's MCP-agent-vs-AI matches and Phase 3's server-hosted
matches will both reuse.
============================================================ */
"use strict";
import { createGameState } from "../engine/state.js";
import { tick } from "../engine/sim.js";
import { runAI } from "../engine/ai.js";
import {
issueMove, issueGather, issueServiceBuilding, issueFerryFreighter, issueRepair,
issueSetHomeBase, issueSetAILogistics, issueSetCollectPoint, issueSetLogiPriority,
issueAttack, issueAttackMove, issueEscort, issueHoldFormation, issueBuild,
issueAssistBuild, issueStop, issueRecycle, issueCancelRecycle, issueHold,
issuePatrol, issueScout, issueSetRally,
} from "../engine/commands.js";
import { queueProduction, cancelProduction, researchUpgrade } from "../engine/production.js";
import { researchTech, cancelResearch } from "../engine/techtree.js";
import { lightFuse } from "../engine/bomb.js";
export const REJECT = Object.freeze({
MALFORMED: "malformed",
UNKNOWN_TYPE: "unknown-type",
EMPTY: "empty-selection",
NO_TARGET: "no-target",
TOO_MANY: "too-many",
});
const MAX_BATCH = 16;
const ok = result => (result === undefined ? { ok: true } : { ok: true, result });
const err = code => ({ ok: false, code });
/* ---------- id resolution: existence only, no ownership/fog (Phase 2's job) ----------
Same EMPTY-vs-MALFORMED split as the Phase 2 codec this feeds into (dossier 02
resolveOwn): an id that isn't even a non-empty string is a client bug (MALFORMED);
ids that are well-formed but resolve to nothing (a unit that died between the
player clicking and this command applying) is a legitimate race (EMPTY), not a bug. */
function resolveIds(state, ids, pick) {
if (!Array.isArray(ids) || ids.length === 0) return err(REJECT.EMPTY);
if (ids.length > 400) return err(REJECT.TOO_MANY);
const out = [];
for (const id of ids) {
if (typeof id !== "string" || !id) return err(REJECT.MALFORMED);
const e = pick(state, id);
if (e) out.push(e);
}
return out.length ? ok(out) : err(REJECT.EMPTY);
}
const pickUnit = (state, id) => state.units.get(id) || null;
const pickEntity = (state, id) => state.units.get(id) || state.buildings.get(id) || null;
const isStr = v => typeof v === "string" && v.length > 0;
const isNum = v => typeof v === "number" && Number.isFinite(v);
function resolveBuilding(state, id) {
if (!isStr(id)) return err(REJECT.MALFORMED);
const b = state.buildings.get(id);
return b ? ok(b) : err(REJECT.NO_TARGET);
}
function resolveNode(state, id) {
if (!isStr(id)) return err(REJECT.MALFORMED);
const n = state.map.nodes.find(n => n.id === id);
return n ? ok(n) : err(REJECT.NO_TARGET);
}
/* ---------- the dispatch table: WireCommand.t -> (state, cmd) -> CommandResult ----------
Each handler resolves ITS OWN ids/targets, then delegates to the same engine/
function the single-player client calls today — no game rule is reimplemented
here (affordability, prereqs, placement all stay in engine/, exactly as ADR-0006
for Phase 2's codec documents). */
const HANDLERS = {
move(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
if (!isNum(cmd.x) || !isNum(cmd.y)) return err(REJECT.MALFORMED);
issueMove(units.result, cmd.x, cmd.y, !!cmd.q, cmd.f);
return ok();
},
attackMove(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
if (!isNum(cmd.x) || !isNum(cmd.y)) return err(REJECT.MALFORMED);
issueAttackMove(units.result, cmd.x, cmd.y, !!cmd.q, cmd.f);
return ok();
},
holdFormation(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
issueHoldFormation(units.result, cmd.s, cmd.l);
return ok();
},
patrol(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
if (!Array.isArray(cmd.pts) || cmd.pts.length === 0 || cmd.pts.length > 32) return err(REJECT.MALFORMED);
issuePatrol(units.result, cmd.pts);
return ok();
},
stop(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
issueStop(units.result);
return ok();
},
hold(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
issueHold(units.result);
return ok();
},
scout(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
issueScout(units.result);
return ok();
},
attack(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
const target = resolveEntityId(state, cmd.target);
if (!target.ok) return target;
issueAttack(units.result, target.result.id, !!cmd.q);
return ok();
},
escort(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
const target = resolveEntityId(state, cmd.target);
if (!target.ok) return target;
issueEscort(units.result, target.result.id, !!cmd.q);
return ok();
},
repair(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
const target = resolveEntityId(state, cmd.target);
if (!target.ok) return target;
issueRepair(units.result, target.result.id, !!cmd.q);
return ok();
},
gather(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
const node = resolveNode(state, cmd.node);
if (!node.ok) return node;
issueGather(units.result, node.result.id, !!cmd.q);
return ok();
},
service(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
const target = resolveBuilding(state, cmd.target);
if (!target.ok) return target;
issueServiceBuilding(units.result, target.result.id, !!cmd.q);
return ok();
},
ferry(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
const target = resolveEntityId(state, cmd.target);
if (!target.ok) return target;
issueFerryFreighter(units.result, target.result.id, !!cmd.q);
return ok();
},
setHomeBase(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
const target = resolveBuilding(state, cmd.target);
if (!target.ok) return target;
issueSetHomeBase(units.result, target.result.id);
return ok();
},
assistBuild(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
const target = resolveBuilding(state, cmd.target);
if (!target.ok) return target;
issueAssistBuild(units.result, target.result.id, target.result.type, !!cmd.q);
return ok();
},
build(state, cmd) {
if (!isStr(cmd.worker) || !isStr(cmd.b) || !isNum(cmd.x) || !isNum(cmd.y)) return err(REJECT.MALFORMED);
const buildingId = issueBuild(state, cmd.worker, cmd.b, cmd.x, cmd.y);
return buildingId ? ok({ buildingId }) : err(REJECT.NO_TARGET);
},
recycle(state, cmd) {
const entities = resolveIds(state, cmd.ids, pickEntity);
if (!entities.ok) return entities;
issueRecycle(entities.result);
return ok();
},
cancelRecycle(state, cmd) {
const entities = resolveIds(state, cmd.ids, pickEntity);
if (!entities.ok) return entities;
issueCancelRecycle(entities.result);
return ok();
},
setAILogistics(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
if (typeof cmd.on !== "boolean") return err(REJECT.MALFORMED);
issueSetAILogistics(units.result, cmd.on, state);
return ok();
},
setCollectPoint(state, cmd) {
const units = resolveIds(state, cmd.ids, pickUnit);
if (!units.ok) return units;
if (typeof cmd.on !== "boolean") return err(REJECT.MALFORMED);
issueSetCollectPoint(units.result, cmd.on);
return ok();
},
setLogiPriority(state, cmd) {
if (!isStr(cmd.building)) return err(REJECT.MALFORMED);
issueSetLogiPriority(state, cmd.building, cmd.p);
return ok();
},
setRally(state, cmd) {
const building = resolveBuilding(state, cmd.building);
if (!building.ok) return building;
if (!isNum(cmd.x) || !isNum(cmd.y)) return err(REJECT.MALFORMED);
issueSetRally(building.result, cmd.x, cmd.y, cmd.node ?? null);
return ok();
},
queueProduction(state, cmd) {
const building = resolveBuilding(state, cmd.building);
if (!building.ok) return building;
if (!isStr(cmd.u)) return err(REJECT.MALFORMED);
queueProduction(state, building.result.id, cmd.u, !!cmd.alt);
return ok();
},
cancelProduction(state, cmd) {
const building = resolveBuilding(state, cmd.building);
if (!building.ok) return building;
if (!isNum(cmd.i)) return err(REJECT.MALFORMED);
cancelProduction(state, building.result.id, cmd.i);
return ok();
},
researchUpgrade(state, cmd) {
const building = resolveBuilding(state, cmd.building);
if (!building.ok) return building;
if (!isStr(cmd.up)) return err(REJECT.MALFORMED);
researchUpgrade(state, building.result.id, cmd.up);
return ok();
},
researchTech(state, cmd) {
const building = resolveBuilding(state, cmd.building);
if (!building.ok) return building;
if (!isStr(cmd.tech)) return err(REJECT.MALFORMED);
researchTech(state, building.result.id, cmd.tech);
return ok();
},
cancelResearch(state, cmd) {
const building = resolveBuilding(state, cmd.building);
if (!building.ok) return building;
if (!isNum(cmd.i)) return err(REJECT.MALFORMED);
cancelResearch(state, building.result.id, cmd.i);
return ok();
},
lightFuse(state, cmd) {
if (!isStr(cmd.unit)) return err(REJECT.MALFORMED);
const bomb = state.units.get(cmd.unit);
if (!bomb) return err(REJECT.NO_TARGET);
lightFuse(state, bomb);
return ok();
},
batch(state, cmd) {
if (!Array.isArray(cmd.c) || cmd.c.length === 0 || cmd.c.length > MAX_BATCH) return err(REJECT.MALFORMED);
for (const sub of cmd.c) {
if (sub && sub.t === "batch") return err(REJECT.MALFORMED); // no nesting
const r = applyCommand(state, sub);
if (!r.ok) return r; // all-or-nothing: the client's disambiguation was built on stale state
}
return ok();
},
};
function resolveEntityId(state, id) {
if (!isStr(id)) return err(REJECT.MALFORMED);
const e = pickEntity(state, id);
return e ? ok(e) : err(REJECT.NO_TARGET);
}
/**
* Apply one WireCommand (net/commandShapes.js) to `state`. Exported standalone
* (not just as a session method) so Phase 2's net/commandCodec.js can call it
* directly once it has already done its own ownership/fog validation, without
* needing a whole session object just to reach the dispatch table.
* @param {State} state
* @param {WireCommand} cmd
* @returns {CommandResult}
*/
export function applyCommand(state, cmd) {
if (!cmd || typeof cmd !== "object" || typeof cmd.t !== "string") return err(REJECT.MALFORMED);
const handler = HANDLERS[cmd.t];
if (!handler) return err(REJECT.UNKNOWN_TYPE);
return handler(state, cmd);
}
/**
* @param {Object} opts - everything engine/state.js's createGameState takes,
* plus:
* @param {string[]} [opts.aiSeats] - extra owners to drive via runAI(state, dt, owner)
* on every tick, beyond the engine's own built-in "ai" seat. Empty for an
* ordinary human-vs-AI skirmish; ["player"] for a fully headless AI-vs-AI
* match (T-010's exit criterion, tools/selfplay.js's own pattern).
* @param {State} [opts.state] - wrap this ALREADY-BUILT state instead of building a fresh one
* from the rest of opts (which are ignored when this is given). For boot.js's loaded-game
* path (T-012): a deserialized save is reconstructed by engine/persist.js, not by
* createGameState(gameOpts) — there is no seed/rng to rebuild it from — so the session has to
* be able to wrap whatever state the caller already has in hand.
*/
export function createSession(opts = {}) {
const { aiSeats = [], state: providedState, ...gameOpts } = opts;
const state = providedState || createGameState(gameOpts);
return {
getState() {
return state;
},
tick(dt) {
for (const owner of aiSeats) runAI(state, dt, owner);
tick(state, dt);
},
submitCommand(cmd) {
return applyCommand(state, cmd);
},
};
}