File size: 12,390 Bytes
6bd2c72
 
 
 
 
 
 
 
450f6c1
 
 
 
 
 
 
 
 
 
 
 
 
 
6bd2c72
 
 
 
 
450f6c1
 
 
 
 
6bd2c72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2904fe5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450f6c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bd2c72
 
450f6c1
6bd2c72
 
 
 
 
 
 
2904fe5
6bd2c72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450f6c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
090c049
450f6c1
 
 
 
090c049
 
450f6c1
 
 
 
 
 
 
 
 
 
 
 
 
 
090c049
 
 
 
 
 
 
450f6c1
 
 
 
 
 
 
 
 
 
6bd2c72
450f6c1
 
 
 
 
6bd2c72
 
 
 
450f6c1
6bd2c72
 
 
 
 
 
450f6c1
 
 
 
090c049
450f6c1
 
6bd2c72
 
 
 
 
cce54f5
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
/* ============================================================
   Observer Mode's UI: the topbar toggle button, the "you're spectating X" banner, and the
   live stats panel (archetype, stance, army, development, economy) for whichever world
   observer.js's observedState() currently points at. Pure view over observer.js's
   observerStats() β€” rebuilt every call, same idiom as starmap.js's renderStarmap.

   Self-wired like the other overlay buttons (starmapBtn/techChartBtn): the topbar button
   toggles Observer Mode directly, this module never needs to be told to do so.

   PHASE 5 (docs/competitions-and-elo.md) β€” the same UI now also serves a WATCHED AI-vs-AI
   exhibition match (game.spectateMatch), which differs from the Odyssey in three ways and is
   rendered accordingly:
     β€’ there is no galaxy to jump around, so the banner says what's on screen and the SPECTATE BAR
       below it carries the controls a watched match needs β€” 1x/2x/4x/8x speed, the exhibition-only
       disclosure, and the way out;
     β€’ both seats are named entrants, so the stats panel shows BOTH, by name, instead of "AI army"
       vs "Player forces" (which would name the human as a combatant in a match they aren't in);
     β€’ there is no diplomacy and no neighbour, so Stance and the Odyssey development score are
       omitted rather than shown as confident numbers β€” see observerStats' own comment.
   The Leave button calls the callback the launcher parked on game.spectateMatch.onLeave rather
   than importing boot.js/competition.js: boot.js already imports THIS module, so importing back
   would grow test/static-integrity.test.js's documented UI cycle by two more members.
   ============================================================ */

"use strict";

import { game } from "./session.js";
import { observerBtn, observerBannerEl, spectateBarEl, observerPanelEl } from "./dom.js";
import {
  observedState, observerStats, toggleObserverMode,
  SPECTATE_SPEEDS, setSpectateSpeed, isSpectatingMatch,
} from "./observer.js";
import { planetName as worldName, LORE_FACTIONS, COM } from "./data.js";
import { UNITS, BUILDINGS } from "./engine/entities.js";

function mk(tag, cls, text) {
  const el = document.createElement(tag);
  if (cls) el.className = cls;
  if (text != null) el.textContent = text;
  return el;
}

// "skiff Γ—144, bastion Γ—143, …", biggest group first β€” the composition a scientist actually
// wants to read at a glance, not insertion order.
function composition(counts, table) {
  return Object.entries(counts)
    .sort((a, b) => b[1] - a[1])
    .map(([type, n]) => `${table[type]?.name || type} Γ—${n}`)
    .join(", ") || "none";
}

// Minimize state lives as a class on observerPanelEl itself (the one stable element across
// renders β€” only its innerHTML gets wiped) rather than a JS variable, so it survives the
// innerHTML="" + full rebuild every renderPanelBody call below with no extra bookkeeping.
function minimizeToggleBtn() {
  const minimized = observerPanelEl.classList.contains("minimized");
  const btn = mk("button", "observer-min-btn", minimized ? "β–Έ" : "β–Ύ");
  btn.type = "button";
  const label = minimized ? "Expand the observation panel" : "Minimize the observation panel β€” the stats block can cover the world you're watching";
  btn.title = label;
  btn.setAttribute("aria-label", label);
  btn.addEventListener("click", e => {
    e.stopPropagation();   // this button lives inside the panel; nothing above it should react to the click
    const min = observerPanelEl.classList.toggle("minimized");
    btn.textContent = min ? "β–Έ" : "β–Ύ";
    const newLabel = min ? "Expand the observation panel" : "Minimize the observation panel β€” the stats block can cover the world you're watching";
    btn.title = newLabel;
    btn.setAttribute("aria-label", newLabel);
  });
  return btn;
}

// One "Label / value" row, the shape every entry in the panel below uses.
function statRow(label, value) {
  const row = mk("div", "observer-row");
  row.append(mk("span", "observer-label", label), mk("span", null, value));
  return row;
}

// A WATCHED match's panel: one block per SEAT, named after the entrant that holds it, because
// "AI army" vs "Player forces" would be two lies at once here (both seats are AI, and the human
// holds neither). Stance and the Odyssey development score are absent by construction β€” a
// skirmish has no diplomacy and no neighbour AI, and observerStats degrades both to null rather
// than handing this view a confident wrong number to print.
function renderSpectatePanelBody(state, s) {
  const watch = game.spectateMatch;
  // Entrant A holds owner "player", entrant B owner "ai" β€” competition.js's buildWatchConfig, the
  // same seating tools/duelCore.js's runDuelMatch uses for every simulated row.
  const nameFor = owner => (owner === "player" ? watch.aName : watch.bName);
  observerPanelEl.innerHTML = "";

  const head = mk("div", "observer-head");
  head.append(
    mk("span", "observer-ico", "πŸ‘"),
    mk("h3", null, `${watch.aName} vs ${watch.bName}`),
    mk("span", "observer-archetype", worldName(s.planetId)),
    minimizeToggleBtn(),
  );
  observerPanelEl.appendChild(head);

  const rows = mk("div", "observer-rows");
  s.seats.forEach(seat => {
    rows.appendChild(mk("div", "observer-seat-name", nameFor(seat.owner)));
    rows.appendChild(statRow("Army", composition(seat.units, UNITS)));
    const buildingCount = Object.values(seat.buildings).reduce((a, n) => a + n, 0);
    rows.appendChild(statRow("Buildings", `${buildingCount} β€” ${composition(seat.buildings, BUILDINGS)}`));
    rows.appendChild(statRow("Supply", `${seat.supplyUsed} / ${seat.supplyCap}`));
    rows.appendChild(statRow("Resources",
      Object.entries(seat.resources).map(([com, qty]) => `${COM[com]?.name || com} ${Math.floor(qty)}`).join(", ") || "none"));
  });
  observerPanelEl.appendChild(rows);
}

function renderPanelBody(state) {
  const s = observerStats(state);
  if (isSpectatingMatch()) { renderSpectatePanelBody(state, s); return; }
  observerPanelEl.innerHTML = "";

  const head = mk("div", "observer-head");
  head.append(
    mk("span", "observer-ico", LORE_FACTIONS[s.faction]?.ico || "πŸͺ"),
    mk("h3", null, worldName(s.planetId)),
    mk("span", "observer-archetype", s.archetypeName),
    minimizeToggleBtn(),
  );
  observerPanelEl.appendChild(head);

  const rows = mk("div", "observer-rows");
  if (s.stanceLabel) {
    const stanceRow = mk("div", "observer-row");
    stanceRow.append(mk("span", "observer-label", "Stance"),
      mk("span", null, `${s.stanceLabel} (${s.stance.toFixed(2)}) Β· hostility ${(s.hostility * 100).toFixed(0)}%${s.pacified ? " Β· pacified" : ""}`));
    rows.appendChild(stanceRow);
  }
  const armyRow = mk("div", "observer-row");
  armyRow.append(mk("span", "observer-label", "AI army"), mk("span", null, composition(s.units.ai || {}, UNITS)));
  rows.appendChild(armyRow);

  if (s.units.player) {
    const playerRow = mk("div", "observer-row");
    playerRow.append(mk("span", "observer-label", "Player forces"), mk("span", null, composition(s.units.player, UNITS)));
    rows.appendChild(playerRow);
  }

  const buildRow = mk("div", "observer-row");
  const buildingCount = Object.values(s.buildings.ai || {}).reduce((a, n) => a + n, 0);
  buildRow.append(mk("span", "observer-label", "AI buildings"),
    mk("span", null, `${buildingCount} total (development score ${s.development}) β€” ${composition(s.buildings.ai || {}, BUILDINGS)}`));
  rows.appendChild(buildRow);

  const supplyRow = mk("div", "observer-row");
  supplyRow.append(mk("span", "observer-label", "Supply"), mk("span", null, `${s.supplyUsed} / ${s.supplyCap}`));
  rows.appendChild(supplyRow);

  const resText = Object.entries(s.resources).map(([com, qty]) => `${COM[com]?.name || com} ${Math.floor(qty)}`).join(", ") || "none";
  const resRow = mk("div", "observer-row");
  resRow.append(mk("span", "observer-label", "AI resources"), mk("span", null, resText));
  rows.appendChild(resRow);

  observerPanelEl.appendChild(rows);
}

/* ---------- the spectate bar (a watched match only) ---------- */

// Rebuilt only when something on it actually changed β€” this runs on the HUD's ~150ms cadence, and
// tearing down a row of buttons six times a second would make them unclickable (a click landing
// between mousedown and the next rebuild would hit a detached node) and steal focus.
let spectateBarSig = null;

function renderSpectateBar() {
  const watch = game.spectateMatch;
  if (!spectateBarEl) return;
  if (!watch || !game.observerMode) {
    spectateBarEl.classList.add("hidden");
    spectateBarSig = null;
    return;
  }
  spectateBarEl.classList.remove("hidden");
  const sig = `${watch.aName}|${watch.bName}|${game.spectateSpeed}|${watch.recorded ? "replay" : "watch"}`;
  if (sig === spectateBarSig) return;
  spectateBarSig = sig;
  spectateBarEl.innerHTML = "";

  spectateBarEl.appendChild(mk("span", "spectate-vs",
    (watch.recorded ? "⟲ REPLAY · " : "") + `${watch.aName} vs ${watch.bName}`));

  const speeds = mk("div", "spectate-speeds");
  speeds.appendChild(mk("span", "spectate-speed-label", "Speed"));
  SPECTATE_SPEEDS.forEach(mult => {
    const btn = mk("button", "spectate-speed-btn" + (game.spectateSpeed === mult ? " active" : ""), `${mult}Γ—`);
    btn.type = "button";
    btn.title = `Run the match at ${mult}Γ— β€” the fixed simulation step is unchanged, only how many steps run per second`;
    btn.addEventListener("click", () => { setSpectateSpeed(mult); renderSpectateBar(); });
    speeds.appendChild(btn);
  });
  spectateBarEl.appendChild(speeds);

  // The exhibition disclosure, ON SCREEN for the whole match β€” not only on the button that started
  // it and the screen that ends it. A watched match looks exactly like a played one, so what makes
  // it different has to be visible while it's happening. A REPLAY says the sharper version: the
  // result isn't unrated because nobody counted it, it's unrated because it was ALREADY counted,
  // and it also names the result this run is expected to land on.
  spectateBarEl.appendChild(mk("span", "spectate-note", watch.recorded
    ? `Replay β€” already rated, nothing recorded Β· recorded: ${watch.recorded.winnerName
      ? `${watch.recorded.winnerName} by ${Math.abs(watch.recorded.margin)}` : "a draw"}`
    : "Exhibition β€” not rated, nothing recorded"));

  if (watch.onLeave) {
    const leave = mk("button", "spectate-leave-btn", "← Leave");
    leave.type = "button";
    leave.title = "Stop watching and return to the Competition screen";
    leave.addEventListener("click", () => watch.onLeave());
    spectateBarEl.appendChild(leave);
  }
}

export function renderObserverPanel() {
  // Still an ODYSSEY-only affordance, and deliberately so now that a second thing can be observed:
  // a watched match is ALWAYS observing (boot.js enters on launch and observer.js refuses to
  // leave β€” see requestExitObserverMode), so a toggle there would be a button that does nothing
  // half the time and hands you an AI's army the other half. Its spectate bar carries Leave
  // instead. An ordinary skirmish shows it in neither case: that would just be a fog cheat.
  observerBtn.classList.toggle("hidden", !game.galaxy);
  if (!game.observerMode) {
    observerBannerEl.classList.add("hidden");
    observerPanelEl.classList.add("hidden");
    renderSpectateBar();
    return;
  }
  const state = observedState();
  if (!state) return;

  observerBannerEl.classList.remove("hidden");
  // Deliberately SHORT for a watched match: the spectate bar sits directly beneath this strip, so a
  // banner that wraps to a second line lands underneath it. The navigation hints the Odyssey banner
  // carries are also less needed here β€” there is one world, and the bar itself shows the controls.
  observerBannerEl.textContent = game.spectateMatch
    ? `πŸ‘ ${game.spectateMatch.recorded ? "REPLAYING" : "WATCHING"} ${worldName(state.planetId)} β€” both sides are AI, you issue no orders`
    : `πŸ”­ OBSERVING ${worldName(state.planetId)} β€” Space: cycle bases Β· Galaxy map: jump anywhere Β· O/Esc: exit`;
  renderSpectateBar();

  observerPanelEl.classList.remove("hidden");
  renderPanelBody(state);
}

if (observerBtn) observerBtn.addEventListener("click", () => { toggleObserverMode(); renderObserverPanel(); });