Spaces:
Paused
Paused
Download server/session.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 14.5 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/server/session.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities/server/session.js
-
curl -L -o session.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/server/session.js
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. | |
| ============================================================ */ | |
| ; | |
| 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); | |
| }, | |
| }; | |
| } | |