Spaces:
Paused
Paused
Download observer.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 16.1 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/observer.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/observer.js
-
curl -L -o observer.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/observer.js
16.1 kB
| /* ============================================================ | |
| Observer Mode: a free-look, read-only spectator over the Odyssey galaxy β and, since | |
| docs/competitions-and-elo.md Phase 5, over a WATCHED AI-vs-AI exhibition match too. Toggle it | |
| on (O) to reveal fog on whichever world you're looking at, jump the camera to ANY world or base | |
| β yours or a neighbour's β for free, and read a live stats panel (observerPanel.js) instead of | |
| issuing orders. | |
| WHERE IT'S OFFERED, and why that list is exactly two entries. Observer Mode reveals fog and | |
| costs nothing, so it is only ever offered for a game the human is NOT playing competitively: | |
| the Odyssey (a play-forever sandbox with no opponent to cheat) and a spectated match between two | |
| AI entrants (game.spectateMatch, boot.js's startSpectatedMatch). Offering it in an ordinary | |
| player-vs-AI skirmish would just be a fog cheat, so enterObserverMode below refuses that case β | |
| the SAME early return that used to say "Odyssey only", widened by exactly one term. | |
| Deliberately does not touch `game.state`, `game.galaxy.activeId`, or the real input camera: | |
| it only changes what gets DRAWN (boot.js's render callback swaps in observedState() + the | |
| camera below while active) and reads state that's already simulating regardless of what's | |
| on screen (stepGalaxy advances every world the galaxy is actually running, active or | |
| backgrounded β see engine/galaxy.js). A world the galaxy ISN'T running yet (a dormant one: | |
| only a seeded few are alive from turn one) is shown as a throwaway preview instead of being | |
| woken β see spectateWorld. So exiting always resumes normal play exactly where it was left, | |
| and nothing here can affect the deterministic sim. | |
| input.js delegates its mouse/wheel/keydown handlers to the small helpers below while | |
| game.observerMode is on, instead of running its own selection/order logic against the wrong | |
| world. | |
| ============================================================ */ | |
| ; | |
| import { game } from "./session.js"; | |
| import { createCamera, zoomAt, dragCamera, panCamera, clampCamera } from "./camera.js"; | |
| import { canvas } from "./dom.js"; | |
| import { archetypeFor } from "./engine/aiArchetypes.js"; | |
| import { hostility, stanceLabel, aiDevelopment } from "./engine/diplomacy.js"; | |
| import { supplyUsed, supplyCap } from "./engine/supply.js"; | |
| import { previewPlanet } from "./engine/galaxy.js"; | |
| const DOUBLE_PRESS_MS = 400; // repeated Space within this window cycles bases, same feel as centerOnBase | |
| // A small duplicate of input.js's own PAN_KEYS (not imported β input.js imports THIS module to | |
| // delegate its handlers here while observing, so importing back the other way would cycle). | |
| const PAN_KEYS = { | |
| arrowleft: [-1, 0], arrowright: [1, 0], d: [1, 0], | |
| arrowup: [0, -1], w: [0, -1], | |
| arrowdown: [0, 1], s: [0, 1], | |
| }; | |
| // The world currently being spectated: the real active world's state when observerMode is | |
| // off (so every other module can keep reading this without an `if (game.observerMode)` of its | |
| // own), else whichever world spectateWorld() last pointed at. A watched skirmish has exactly one | |
| // world and no galaxy, so it falls through to game.state β which is already what this returned | |
| // for a galaxy-less session before Phase 5, hence no branch of its own here. | |
| export function observedState() { | |
| if (game.observerMode && game.galaxy) { | |
| // A world the galaxy is really simulating wins; a DORMANT one falls back to the pristine | |
| // preview spectateWorld built for it (see there), and only then to the real active world. | |
| return game.galaxy.planets.get(game.spectateId) || game.spectatePreview || game.state; | |
| } | |
| return game.state; | |
| } | |
| /* ---------- spectate speed (docs/competitions-and-elo.md Phase 5) ---------- */ | |
| // The speed ladder a watched or replayed match offers. Powers of two so each rung is an obvious | |
| // doubling, and capped at 8x because engine/loop.js's MAX_SUBSTEPS bounds how much sim a frame can | |
| // carry: at a 60 Hz display 8x needs ~1.3 of the 5 substeps a frame allows, which leaves real | |
| // headroom. (That number was ~2.7 when a spectated match ran at the ordinary 20 Hz sim rate; Phase | |
| // 5's replay work moved it to tools/selfplay.js's own 10 Hz step β the step every recorded row was | |
| // simulated at β which halves the substeps a given speed needs. 16x would still be the wrong rung | |
| // to add: it would sit near the cap on a 30 fps machine and quietly under-deliver.) Past the cap | |
| // the loop degrades to slow motion rather than spiralling (see its own comment) β a bound worth | |
| // staying inside, not one worth riding. | |
| export const SPECTATE_SPEEDS = [1, 2, 4, 8]; | |
| // Any input -> a real rung, or 1x. Deliberately NOT a nearest-rung round: an off-ladder value is a | |
| // caller bug or a stale persisted number, and silently promoting 6 to 8 would run the match faster | |
| // than anything on screen claims. 1x is the honest fallback. | |
| export function clampSpectateSpeed(v) { | |
| return SPECTATE_SPEEDS.includes(v) ? v : 1; | |
| } | |
| // The next rung up, wrapping 8x back to 1x β the "cycle speed" affordance, so one control can walk | |
| // the whole ladder. | |
| export function nextSpectateSpeed(v) { | |
| const i = SPECTATE_SPEEDS.indexOf(clampSpectateSpeed(v)); | |
| return SPECTATE_SPEEDS[(i + 1) % SPECTATE_SPEEDS.length]; | |
| } | |
| // Set the live multiplier boot.js's loop reads (session.js game.spectateSpeed). Always clamped, so | |
| // a bogus value can never reach engine/loop.js at all. | |
| export function setSpectateSpeed(v) { | |
| game.spectateSpeed = clampSpectateSpeed(v); | |
| } | |
| // Whether a spectate-only affordance (the speed control, the Leave button, the two-seat stats | |
| // panel) applies right now: a watched exhibition match, as opposed to the Odyssey. | |
| export function isSpectatingMatch() { | |
| return !!game.spectateMatch; | |
| } | |
| // Any completed Command-Center-type building on `state`, regardless of owner β the whole point | |
| // being to also land on a neighbour's capital, not just your own. Sorted by id for a stable | |
| // cycling order (matches input.js's own player-only centerOnBase precedent). | |
| export function findBases(state) { | |
| return [...state.buildings.values()] | |
| .filter(b => b.type === "command" && !b.constructing) | |
| .sort((a, b) => (a.id < b.id ? -1 : 1)); | |
| } | |
| // Where to point the camera at a world we've just started spectating: its first base (any | |
| // owner), else the map's own landing-zone marker, else createCamera's plain map-center default. | |
| function initialFocus(state) { | |
| const bases = findBases(state); | |
| if (bases.length) return { x: bases[0].x, y: bases[0].y }; | |
| const at = state.map.bases && (state.map.bases.player || state.map.bases.ai); | |
| return at ? { x: at.x, y: at.y } : null; | |
| } | |
| function pointCameraAt(state, at) { | |
| if (!game.observerCamera) return; | |
| if (at) { game.observerCamera.x = at.x; game.observerCamera.y = at.y; } | |
| clampCamera(game.observerCamera, state.map, canvas.clientWidth, canvas.clientHeight); | |
| } | |
| let obsBaseCycle = 0, lastObsBaseAt = -Infinity; | |
| export function enterObserverMode() { | |
| // The one gate (see this file's header): an Odyssey, or a match the human is not playing. | |
| // A plain skirmish falls through both terms and stays un-observable β that would be a fog cheat. | |
| if (!game.galaxy && !game.spectateMatch) return; | |
| if (!game.state && !game.galaxy) return; // nothing to look at (belt-and-braces: spectateMatch always implies a state) | |
| if (game.observerMode) return; | |
| if (game.input) game.input.cancelGesture(); // don't let a drag armed just before this resolve into a real order | |
| game.observerMode = true; | |
| game.spectateId = game.galaxy ? game.galaxy.activeId : game.state.planetId; | |
| game.spectatePreview = null; // entry always lands on a world that really is simulating (see spectateWorld) | |
| const real = game.input ? game.input.getCamera() : null; | |
| // Start exactly where normal play was looking β entering shouldn't jar the view β and only | |
| // recenter once actual observer navigation (Space / a starmap jump) asks for somewhere else. | |
| game.observerCamera = real ? { x: real.x, y: real.y, zoom: real.zoom } : createCamera(observedState().map); | |
| obsBaseCycle = 0; lastObsBaseAt = -Infinity; | |
| } | |
| export function exitObserverMode() { | |
| if (!game.observerMode) return; | |
| game.observerMode = false; | |
| game.spectateId = null; | |
| game.spectatePreview = null; | |
| game.observerCamera = null; | |
| } | |
| // The PLAYER-facing "stop observing" (the O key, Esc, the topbar button) β as opposed to | |
| // exitObserverMode above, which is the unconditional teardown boot.js runs while leaving a game. | |
| // Refused for a watched match, and the distinction is the whole point: in the Odyssey, leaving | |
| // Observer Mode returns you to commanding your own world. In an AI-vs-AI exhibition there is | |
| // nothing to return TO β the human commands neither seat β and dropping the delegation would hand | |
| // input.js's ordinary selection/order path an army belonging to one of the two entrants, mid-match. | |
| // So a watched match is observed for as long as it lasts; the spectate bar's Leave button (which | |
| // ends the match and returns to the Competition screen) is the way out. | |
| // Returns whether Observer Mode was actually left. | |
| export function requestExitObserverMode() { | |
| if (game.spectateMatch) return false; | |
| exitObserverMode(); | |
| return true; | |
| } | |
| export function toggleObserverMode() { | |
| if (game.observerMode) requestExitObserverMode(); else enterObserverMode(); | |
| } | |
| // Free jump to any world's camera β no fuel, no canJumpTo gate, no real galaxy.activeId | |
| // change. A no-op unless observerMode is actually on and `id` is a world of this galaxy at all. | |
| // | |
| // "Any world" includes a DORMANT one (engine/galaxy.js BACKGROUND_WORLDS: only a seeded few | |
| // simulate from turn one). There's no live state to look at there, so previewPlanet builds the | |
| // pristine world the player WOULD arrive at and that's what gets shown β read-only, never ticked | |
| // and never registered on the galaxy, so spectating still can't wake a world or otherwise touch | |
| // the sim (this module's header comment). Nothing is happening on a dormant world, and a frozen, | |
| // untouched world is exactly how that reads. | |
| export function spectateWorld(id) { | |
| if (!game.observerMode || !game.galaxy || !game.galaxy.worlds.includes(id)) return; | |
| game.spectateId = id; | |
| game.spectatePreview = game.galaxy.planets.has(id) ? null : previewPlanet(game.galaxy, id); | |
| obsBaseCycle = 0; lastObsBaseAt = -Infinity; | |
| const state = observedState(); | |
| pointCameraAt(state, initialFocus(state)); | |
| } | |
| // Space, while observing: cycle every completed Command Center on the spectated world, any | |
| // owner β repeated presses within DOUBLE_PRESS_MS advance to the next one, same feel as | |
| // input.js's own centerOnBase. | |
| export function cycleObserverBase() { | |
| const state = observedState(); | |
| if (!state) return; | |
| const bases = findBases(state); | |
| const now = performance.now(); | |
| const cycling = bases.length > 0 && now - lastObsBaseAt < DOUBLE_PRESS_MS; | |
| lastObsBaseAt = now; | |
| if (!bases.length) { obsBaseCycle = 0; pointCameraAt(state, initialFocus(state)); return; } | |
| obsBaseCycle = cycling ? (obsBaseCycle + 1) % bases.length : 0; | |
| const at = bases[obsBaseCycle]; | |
| pointCameraAt(state, { x: at.x, y: at.y }); | |
| } | |
| /* ---------- camera input, delegated to from input.js while observerMode is on ---------- */ | |
| // `heldKeys` is input.js's own live Set (tickCamera calls this with it directly) β observer.js | |
| // doesn't track keyboard state of its own, it just reads whichever keys are currently down. | |
| export function tickObserverCamera(dt, heldKeys) { | |
| const state = observedState(); | |
| if (!state || !game.observerCamera) return; | |
| let dx = 0, dy = 0; | |
| for (const key of heldKeys) { | |
| const dir = PAN_KEYS[key]; | |
| if (dir) { dx += dir[0]; dy += dir[1]; } | |
| } | |
| panCamera(game.observerCamera, state.map, canvas.clientWidth, canvas.clientHeight, dx, dy, dt); | |
| } | |
| export function observerWheelZoom(clientX, clientY, rect, factor) { | |
| const state = observedState(); | |
| if (!state || !game.observerCamera) return; | |
| zoomAt(game.observerCamera, state.map, rect.width, rect.height, clientX - rect.left, clientY - rect.top, factor); | |
| } | |
| let dragAnchor = null; | |
| export function observerDragStart(clientX, clientY) { dragAnchor = { x: clientX, y: clientY }; } | |
| export function observerDragMove(clientX, clientY) { | |
| if (!dragAnchor) return; | |
| const state = observedState(); | |
| if (!state || !game.observerCamera) return; | |
| const dsx = clientX - dragAnchor.x, dsy = clientY - dragAnchor.y; | |
| dragAnchor = { x: clientX, y: clientY }; | |
| dragCamera(game.observerCamera, state.map, canvas.clientWidth, canvas.clientHeight, dsx, dsy); | |
| } | |
| export function observerDragEnd() { dragAnchor = null; } | |
| /* ---------- stats panel data (observerPanel.js formats/displays this) ---------- */ | |
| // Live unit/building tallies for `state`, grouped by owner then type β the panel's "what does | |
| // this archetype actually field" readout. Plain counts, computed fresh every call (state.units/ | |
| // buildings are already live Maps β nothing here is cached or sampled over time; that's the | |
| // analytics/history layer this intentionally doesn't attempt yet). | |
| function tally(map, keyOf) { | |
| const byOwner = {}; | |
| for (const e of map.values()) { | |
| const owner = e.owner; | |
| (byOwner[owner] ||= {}); | |
| const k = keyOf(e); | |
| byOwner[owner][k] = (byOwner[owner][k] || 0) + 1; | |
| } | |
| return byOwner; | |
| } | |
| // A snapshot of everything the panel shows for `state` β pure data, no DOM, so it's directly | |
| // testable and reusable if a future analytics view wants the same numbers on a timer. | |
| // | |
| // TWO AUDIENCES, ONE FUNCTION. In the Odyssey there is a player and ONE neighbour AI, so the | |
| // owner-"ai" fields below (supply/resources/development) are "what the neighbour has" and every | |
| // one of them means exactly what it always did. In a spectated duel (Phase 5) both seats are AI | |
| // entrants and neither is "the AI", so: | |
| // β’ `seats` carries the same economy per owner, off state.owners β the honest shape for a | |
| // two-entrant match, and simply ignored by the Odyssey panel; | |
| // β’ `development` degrades to NULL without diplomacy. aiDevelopment (engine/diplomacy.js) does | |
| // not need state.diplomacy to run β it counts owner "ai"'s finished economic buildings plus | |
| // its researched techs, both present on any skirmish state β but it is the Odyssey's own | |
| // development-curve metric for the one neighbour, and reporting it for one seat of a two-seat | |
| // duel would be a confident number about half the match. Same reasoning docs/competitions-and | |
| // -elo.md Β§6 gives for keeping ailab.js's Odyssey-shaped score() out of competitions entirely. | |
| // Every galaxy world carries diplomacy (engine/galaxy.js's spawnPlanet, and persist.js's | |
| // cleanDiplomacy on load), so gating on it leaves the Odyssey panel byte-identical. | |
| export function observerStats(state) { | |
| const archetype = archetypeFor(state.planetId); | |
| const dip = state.diplomacy; | |
| const ai = state.players.ai; | |
| const units = tally(state.units, u => u.type); | |
| const buildings = tally(state.buildings, b => b.type); | |
| return { | |
| planetId: state.planetId, | |
| archetypeName: archetype.name, | |
| faction: archetype.faction, | |
| stance: dip ? dip.stance : null, | |
| stanceLabel: dip ? stanceLabel(dip.stance) : null, | |
| hostility: dip ? hostility(state) : null, | |
| pacified: !!(dip && dip.pacified), | |
| units, | |
| buildings, | |
| development: dip ? aiDevelopment(state) : null, | |
| supplyUsed: supplyUsed(state, "ai"), | |
| supplyCap: supplyCap(state, "ai"), | |
| resources: ai ? { ...ai.resources } : {}, | |
| // Per-seat, in the state's own canonical owner order (engine/state.js's state.owners), so a | |
| // two-entrant match reads as two entrants rather than as "the AI" plus "the player". | |
| seats: (state.owners || []).map(owner => ({ | |
| owner, | |
| units: units[owner] || {}, | |
| buildings: buildings[owner] || {}, | |
| supplyUsed: supplyUsed(state, owner), | |
| supplyCap: supplyCap(state, owner), | |
| resources: state.players[owner] ? { ...state.players[owner].resources } : {}, | |
| })), | |
| }; | |
| } | |