File size: 16,086 Bytes
6bd2c72
450f6c1
 
 
 
6bd2c72
 
450f6c1
 
 
 
 
 
 
6bd2c72
 
 
b88cc9c
 
 
 
6bd2c72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b88cc9c
6bd2c72
 
 
 
 
 
 
 
 
 
 
 
 
450f6c1
 
 
6bd2c72
b88cc9c
 
 
 
 
6bd2c72
 
 
450f6c1
 
090c049
 
 
 
 
 
 
 
 
450f6c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bd2c72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450f6c1
 
 
 
 
6bd2c72
 
450f6c1
b88cc9c
6bd2c72
 
 
 
 
 
 
 
 
 
 
b88cc9c
6bd2c72
 
 
450f6c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bd2c72
450f6c1
6bd2c72
 
 
b88cc9c
 
 
 
 
 
 
 
6bd2c72
b88cc9c
6bd2c72
b88cc9c
6bd2c72
b88cc9c
 
6bd2c72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450f6c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bd2c72
 
 
 
450f6c1
 
6bd2c72
 
 
 
 
 
 
 
450f6c1
 
 
6bd2c72
 
 
450f6c1
 
 
 
 
 
 
 
 
 
6bd2c72
 
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
/* ============================================================
   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.
   ============================================================ */

"use strict";

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 } : {},
    })),
  };
}