Spaces:
Paused
Paused
File size: 14,460 Bytes
a14579a 42b40d2 a14579a 42b40d2 a14579a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 | /* ============================================================
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);
},
};
}
|