File size: 20,942 Bytes
d32ddf2
f80d3ea
 
 
 
 
 
 
 
5181359
 
 
 
f80d3ea
 
 
 
d32ddf2
 
 
 
 
cf3fc9b
b6d76f7
1ad681c
9b5e621
5181359
1ff6f70
d32ddf2
 
 
7e2a606
5181359
 
 
 
 
 
f80d3ea
755b556
d32ddf2
7e2a606
 
5181359
 
 
 
 
7e2a606
755b556
 
5181359
 
755b556
 
5181359
 
 
 
 
755b556
 
f80d3ea
 
 
 
 
 
9b5e621
 
b6d76f7
 
9b5e621
b6d76f7
 
f80d3ea
 
5181359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4bbde9
 
 
5181359
 
 
 
f80d3ea
 
a4bbde9
5181359
d32ddf2
7e2a606
5181359
 
 
 
 
 
 
 
 
 
 
 
 
 
7e2a606
5181359
 
 
 
 
 
7e2a606
 
5181359
 
d32ddf2
5181359
 
 
 
 
 
f80d3ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d32ddf2
7e2a606
f80d3ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b5e621
f80d3ea
 
 
 
 
 
 
 
 
 
 
755b556
f80d3ea
 
755b556
f80d3ea
 
 
 
 
 
 
 
7e2a606
d32ddf2
 
 
 
 
 
 
 
cf3fc9b
f80d3ea
 
 
 
cf3fc9b
 
1ff6f70
 
 
 
 
 
 
 
22ad0a6
 
 
 
 
 
 
 
 
 
cf3fc9b
 
 
 
 
b6109dc
 
 
 
 
cf3fc9b
b6109dc
22ad0a6
 
 
b6109dc
cf3fc9b
1ff6f70
 
 
22ad0a6
 
 
 
 
 
 
 
 
1ff6f70
 
 
cf3fc9b
 
 
 
 
b6109dc
 
 
1ad681c
b6109dc
 
 
 
cf3fc9b
 
 
 
 
 
 
 
 
a4bbde9
 
 
22ad0a6
 
 
a4bbde9
 
 
1ff6f70
22ad0a6
 
 
 
 
1ff6f70
cf3fc9b
 
 
 
 
b6109dc
cf3fc9b
 
f80d3ea
 
cf3fc9b
f80d3ea
5181359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f80d3ea
 
9b5e621
 
 
 
5181359
f606a83
 
 
 
 
 
 
 
 
 
5181359
 
 
 
9b5e621
 
 
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
362
363
364
365
366
367
368
369
370
371
372
/* ============================================================
   Save / load. Because the sim is deterministic and seed-driven, a save is just
   the serialized dynamic state (engine/persist.js); the map regenerates from the
   seed on load. Two channels, by design:

     • The topbar Save / Load buttons move a save to and from a FILE — an explicit
       backup/transfer you keep on disk (download a .json, or pick one to import).
     • AutoSave keeps the CURRENT game in browser localStorage on a timer (and on
       tab-hide / unload), so the map-select "Continue" buttons (setup.js) can
       resume exactly where you left off without any manual save. Two GENERATIONS
       are kept per mode (KEY + the rotated-out KEY+'.prev'), not one overwritten
       slot: loadGame/loadOdyssey fall back to '.prev' if the primary fails to
       deserialize, so a single corrupt/interrupted write can't lose the campaign.

   Both channels branch on the mode: a skirmish is a single state (SAVE_KEY /
   serializeGame); an Odyssey is the whole galaxy (ODYSSEY_KEY / serializeGalaxy).
   A file import auto-detects which by shape (a galaxy carries `planets`).
   ============================================================ */

"use strict";

import { game } from "./session.js";
import { saveBtn, loadBtn, homeBtn } from "./dom.js";
import { serializeGame, serializeGameString, deserializeGame, serializeGalaxy, serializeGalaxyString, deserializeGalaxy } from "./engine/persist.js";
import { bootState, bootGalaxy, restartToMapSelect, pauseLoop, resumeLoop } from "./boot.js";
import { isGalaxySave, resumableMode } from "./saveShape.js";
import { showGalaxyToast } from "./overlays.js";
import { liveCompetitionFixture, forfeitLiveCompetitionMatch } from "./competition.js";
import * as sound from "./sound.js";

const SAVE_KEY = "stellarfrontier.save.v1";
const ODYSSEY_KEY = "stellarfrontier.odyssey.v1";
// The previous-generation localStorage slot for either key: KEY + PREV_SUFFIX. autoSave rotates
// the current primary into this slot before writing a fresh one (writeGeneration below), so one
// corrupt/quota-failed write — or a save that lands at a genuinely bad moment — still leaves last
// cycle's good save one fallback away (loadGame/loadOdyssey below) instead of losing the whole
// campaign, as used to happen when a single slot was overwritten every 12 seconds.
const PREV_SUFFIX = ".prev";
const AUTOSAVE_INTERVAL_MS = 12000;   // how often the current game is checkpointed to localStorage
const MAX_SAVE_BYTES = 8 * 1024 * 1024;   // reject an implausibly large import before parsing it (a real save is ≪ this)

const read = key => { try { return localStorage.getItem(key); } catch (e) { return null; } };

// True the instant EITHER generation exists — a `.prev`-only save (the primary write failed most
// recently, but last cycle's landed fine) should still offer "Continue": loadGame/loadOdyssey below
// try both generations too, so this stays consistent with what they can actually resume.
export function hasSave() { return !!read(SAVE_KEY) || !!read(SAVE_KEY + PREV_SUFFIX); }
export function hasOdysseySave() { return !!read(ODYSSEY_KEY) || !!read(ODYSSEY_KEY + PREV_SUFFIX); }

// The save-format version of each stored save (null when absent/unparseable) — used by the
// update check (version.js saveImpact) to tell the player whether a new release keeps their data.
// Prefers the primary generation; falls back to `.prev`'s version when the primary is missing or
// unparseable, so this reports exactly what loadGame/loadOdyssey would actually resume.
export function storedSaveVersions() {
  const ver = raw => { try { const o = JSON.parse(raw); return typeof o?.v === "number" ? o.v : null; } catch (e) { return null; } };
  const versionOf = key => {
    const v = ver(read(key));
    return v != null ? v : ver(read(key + PREV_SUFFIX));
  };
  return { skirmish: versionOf(SAVE_KEY), odyssey: versionOf(ODYSSEY_KEY) };
}

/* ---------- localStorage: the automatic checkpoint (resume) ---------- */

// Serialize the current game to a plain object, by mode — or null when there's
// nothing resumable (no game, a finished one, or a scripted scenario, which can't
// be saved). One place both channels agree on what "the current game" is.
function snapshot() {
  const mode = resumableMode(game);
  if (!mode) return null;
  // The JSON STRING directly (serialize*String) — autoSave writes it straight to localStorage,
  // so the fog-heavy payload is stringified once, not stringify→parse→stringify.
  return mode === "galaxy"
    ? { key: ODYSSEY_KEY, str: serializeGalaxyString(game.galaxy) }
    : { key: SAVE_KEY, str: serializeGameString(game.state) };
}

// Write a fresh generation to `key`, rotating whatever's currently there into `key + PREV_SUFFIX`
// FIRST — so loadGame/loadOdyssey below always have last cycle's good save to fall back to if
// this write lands badly (corrupt, or interrupted) or the next one does. A setItem quota throw —
// from either the rotation write or the fresh one — is met with exactly ONE retry: drop the
// `.prev` backup (freeing its space) and write the primary alone, so a nearly-full quota can never
// let the backup starve the primary out of ever being written at all.
function writeGeneration(key, str) {
  const prevKey = key + PREV_SUFFIX;
  try {
    const current = localStorage.getItem(key);
    if (current != null) localStorage.setItem(prevKey, current);
    localStorage.setItem(key, str);
    return true;
  } catch (e) {
    try {
      localStorage.removeItem(prevKey);
      localStorage.setItem(key, str);
      return true;
    } catch (e2) { return false; }
  }
}

// Checkpoint the current game to localStorage. Cheap and safe to call often. Returns
// true on a successful write, false when there's nothing to save OR the write throws
// (quota exceeded, or Safari/Firefox private mode where setItem always throws). The
// periodic caller counts consecutive failures (recordAutoSaveOutcome below) toward a
// one-time warning; "Save & Exit" checks the return value directly, because silently
// swallowing a failure there would tell the player their game was checkpointed and then
// strand them with nothing to Continue.
export function autoSave() {
  const snap = snapshot();
  if (!snap) return false;
  return writeGeneration(snap.key, snap.str);
}

// Parse + deserialize a raw localStorage string with `deserialize`, returning the live object or
// null if either step throws — corrupt JSON, an unsupported save version, or anything
// sanitizeSave/cleanEntity rejects (engine/persist.js). The shared "try it, or don't" shape
// loadGame/loadOdyssey below use for both the primary generation and its `.prev` fallback.
function tryDeserialize(raw, deserialize) {
  if (!raw) return null;
  try { return deserialize(JSON.parse(raw)); }
  catch (e) { return null; }
}

// Resume the autosaved Odyssey galaxy from localStorage (topbar-less; used by the setup "Continue
// Odyssey" button). Tries the primary generation first, then falls back to `.prev` (written by
// writeGeneration above) if the primary is missing, corrupt, or fails to deserialize — so one bad
// write doesn't strand the whole campaign the way a single overwritten slot used to.
export function loadOdyssey() {
  const primaryRaw = read(ODYSSEY_KEY), prevRaw = read(ODYSSEY_KEY + PREV_SUFFIX);
  if (!primaryRaw && !prevRaw) { flashButton(loadBtn, "No save"); return; }
  const galaxy = tryDeserialize(primaryRaw, deserializeGalaxy) || tryDeserialize(prevRaw, deserializeGalaxy);
  if (!galaxy) { flashButton(loadBtn, "Load failed"); return; }
  sound.unlockAudio();
  bootGalaxy(galaxy, { intro: false });
}

// Resume the autosaved skirmish from localStorage (setup "Continue" button). Same primary-then-
// `.prev` fallback as loadOdyssey above.
export function loadGame() {
  const primaryRaw = read(SAVE_KEY), prevRaw = read(SAVE_KEY + PREV_SUFFIX);
  if (!primaryRaw && !prevRaw) { flashButton(loadBtn, "No save"); return; }
  const state = tryDeserialize(primaryRaw, deserializeGame) || tryDeserialize(prevRaw, deserializeGame);
  if (!state) { flashButton(loadBtn, "Load failed"); return; }
  sound.unlockAudio();
  bootState(state, { intro: false });
}

/* ---------- file: the explicit backup/transfer (Save / Load buttons) ---------- */

function stamp() {
  const d = new Date();
  const p = n => String(n).padStart(2, "0");
  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}

function downloadJSON(filename, obj) {
  const blob = new Blob([JSON.stringify(obj)], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  a.click();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

// Save button — download the current game as a .json file (galaxy or skirmish).
function saveToFile() {
  try {
    if (game.galaxy) {
      downloadJSON(`stellar-frontier-odyssey-${stamp()}.json`, serializeGalaxy(game.galaxy));
    } else if (game.state) {
      downloadJSON(`stellar-frontier-skirmish-seed${game.state.seed}-${stamp()}.json`, serializeGame(game.state));
    } else { flashButton(saveBtn, "No game"); return; }
    flashButton(saveBtn, "Saved ✓");
  } catch (e) { flashButton(saveBtn, "Save failed"); }
}

// Boot a parsed save, auto-detecting the mode by shape — a galaxy carries `planets`.
function importSave(parsed) {
  sound.unlockAudio();
  if (isGalaxySave(parsed)) bootGalaxy(deserializeGalaxy(parsed), { intro: false });
  else bootState(deserializeGame(parsed), { intro: false });
}

// Load button — open a file picker and import the chosen .json save.
function loadFromFile() {
  const input = document.createElement("input");
  input.type = "file";
  input.accept = "application/json,.json";
  input.addEventListener("change", () => {
    const file = input.files && input.files[0];
    if (!file) return;
    if (file.size > MAX_SAVE_BYTES) { flashButton(loadBtn, "File too large"); return; }   // bound before we parse it
    const reader = new FileReader();
    reader.onload = () => {
      // JSON.parse first (never eval), then deserialize sanitizes the shape (engine/persist.js).
      try { importSave(JSON.parse(reader.result)); }
      catch (e) { flashButton(loadBtn, "Load failed"); }
    };
    reader.onerror = () => flashButton(loadBtn, "Load failed");
    reader.readAsText(file);
  });
  input.click();
}

function flashButton(btn, msg) {
  if (!btn) return;
  const original = btn.dataset.label || btn.textContent;
  btn.dataset.label = original;
  btn.textContent = msg;
  clearTimeout(btn._flashTimer);
  btn._flashTimer = setTimeout(() => { btn.textContent = btn.dataset.label; }, 1100);
}

// Home button — return to the menu, first asking whether to keep progress. A scenario
// can't be resumed, so it's a plain "leave?" confirm there; a skirmish/Odyssey offers
// "Save & Exit", which checkpoints to localStorage so the setup "Continue" button can
// pick it up. A lightweight modal built on the fly (Cancel / backdrop / Esc dismiss it).
function goHome() {
  const scenario = !!(game.state && game.state.scenario);
  // A live COMPETITION fixture (docs/competitions-and-elo.md Phase 4) is the third case, and it
  // changes what leaving MEANS: abandoning a gauntlet match records a forfeit — a rated loss —
  // rather than silently dropping the fixture, and the player has to be told that before it
  // happens, which is exactly what this dialog is for. It is also deliberately NOT offered a
  // "Save & Exit": game.competition isn't part of the save, so a resumed autosave would be an
  // ordinary skirmish that no longer belongs to any run, which is a worse outcome than an honest
  // forfeit.
  const fixture = liveCompetitionFixture();
  // …and a WATCHED or REPLAYED match (Phase 5) is the fourth, for the stronger reason saveShape.js's
  // resumableMode already gives: it isn't the player's game at all. This button is visible in EVERY
  // mode, so without a branch of its own the dialog fell back to the ordinary skirmish copy — which
  // PROMISED a checkpoint ("Your progress autosaves…") that resumableMode refuses to write, and
  // offered "Save & Exit" as the default-focused primary. Clicking it got false back from autoSave()
  // and fell through to a FILE download of the AI-vs-AI match: a plain skirmish save that loads back
  // handing the human full command of one entrant's army (exactly what observer.js's
  // requestExitObserverMode refuses mid-match), flashing "Saved ✓" on a Save button hud.js has
  // hidden, without leaving. So: no Save & Exit, honest copy, a plain Leave.
  const watching = game.spectateMatch;

  const overlay = document.createElement("div");
  overlay.className = "home-confirm";
  const card = document.createElement("div");
  card.className = "home-card";
  // The card is the semantic dialog: it's the focus-bounded box with the heading, body
  // text, and actions — the overlay is just the click-to-dismiss backdrop behind it.
  card.setAttribute("role", "dialog");
  card.setAttribute("aria-modal", "true");
  card.tabIndex = -1;   // focusable as a fallback landing spot if there's ever no button to focus
  const h = document.createElement("h2");
  h.id = "home-confirm-title";
  h.textContent = fixture ? "Forfeit this gauntlet match?"
    : watching ? (watching.recorded ? "Stop replaying this match?" : "Stop watching this match?")
    : scenario ? "Leave the mission?" : "Return to the menu?";
  card.setAttribute("aria-labelledby", h.id);
  const p = document.createElement("p");
  p.textContent = fixture
    ? `Leaving now records a LOSS to ${fixture.opponent} in your gauntlet, rated exactly like a match you played and lost. `
      + "A competition match can't be saved and resumed."
    : watching
      // The same two facts the spectate bar shows all match long, restated at the moment of leaving:
      // whose match this is, and that nothing is riding on it either way. A replay says the sharper
      // version — its result is already on the ladder, so re-running it records nothing by design.
      ? `${watching.aName} vs ${watching.bName} is a match between two AI entrants — you command neither seat, `
        + "so there's nothing here to save. "
        + (watching.recorded
          ? "This match is already on the ladder; re-running it records nothing."
          : "Nothing is recorded either way.")
    : scenario
      ? "A scenario can't be saved — leaving abandons this run."
      : "Your progress autosaves — Save & Exit checkpoints it now so you can Continue later.";
  const actions = document.createElement("div");
  actions.className = "home-actions";
  card.append(h, p, actions);
  overlay.appendChild(card);

  // Focus management: remember whatever had focus (the Home button, typically) so it can be
  // restored on close, and move focus into the dialog on open per WAI-ARIA dialog pattern.
  const previouslyFocused = document.activeElement;
  pauseLoop("home");   // hold the sim while the leave-confirm modal is up
  const close = () => {
    resumeLoop("home"); overlay.remove(); window.removeEventListener("keydown", onKey);
    if (previouslyFocused && previouslyFocused.focus) previouslyFocused.focus();
  };
  const onKey = e => { if (e.key === "Escape") { e.preventDefault(); close(); } };
  const act = (label, fn, cls) => {
    const b = document.createElement("button");
    b.className = "btn" + (cls ? " " + cls : "");
    b.textContent = label;
    b.addEventListener("click", () => { close(); fn(); });
    actions.appendChild(b);
  };

  // Save & Exit only leaves once the checkpoint actually lands. If localStorage is
  // unavailable (quota / private mode) we DON'T pretend it saved and exit into a lost
  // game — we fall back to a file download and keep the player in-game so nothing is lost.
  // Offered for the two modes that can actually be resumed; the other three each get their own
  // single primary below, because for them there is no checkpoint to write in the first place.
  if (!scenario && !fixture && !watching) act("Save & Exit", () => {
    if (autoSave()) restartToMapSelect();
    else saveToFile();
  }, "primary");
  if (fixture) act("Forfeit & Exit", () => { forfeitLiveCompetitionMatch(); restartToMapSelect(); }, "primary");
  // One way out of a watched match, not two: the callback its launcher parked on game.spectateMatch
  // is the same route the spectate bar's own Leave button takes (observerPanel.js), so ⌂ Home lands
  // back on the Competition screen it came from rather than inventing a second destination. The
  // fallback covers a launcher that parked none — restartToMapSelect clears spectateMatch itself.
  else if (watching) act("Leave", () => { if (watching.onLeave) watching.onLeave(); else restartToMapSelect(); }, "primary");
  else act(scenario ? "Leave" : "Exit without Saving", () => restartToMapSelect(), scenario ? "primary" : "");
  act("Cancel", () => {}, "ghost");

  overlay.addEventListener("click", e => { if (e.target === overlay) close(); });
  window.addEventListener("keydown", onKey);
  document.body.appendChild(overlay);
  (actions.querySelector("button") || card).focus();   // first actionable button, or the card itself
}

if (saveBtn) saveBtn.addEventListener("click", saveToFile);
if (loadBtn) loadBtn.addEventListener("click", loadFromFile);
if (homeBtn) homeBtn.addEventListener("click", goHome);

// Consecutive autoSave() failures the PERIODIC timer has observed — NOT the visibilitychange/
// beforeunload calls below, which each fire once at a moment the player is already leaving (a
// toast then would go unseen, and a tab-hide/close is exactly the moment a save is most likely to
// race something else anyway). After AUTOSAVE_FAIL_TOAST consecutive failures, surface it exactly
// ONCE per session: silent, repeated failure otherwise means a corrupt/full-quota/private-mode
// browser quietly strands the player with nothing to Continue, discovered only when they come back
// and "Continue" has nothing (see this file's own header). A later success resets the STREAK (a
// fresh run of failures needs AUTOSAVE_FAIL_TOAST in a row again to re-warrant concern) but never
// re-arms the one-time flag — this is a "your autosave is unreliable" notice, not a per-incident
// alert that would nag on every transient blip.
const AUTOSAVE_FAIL_TOAST = 3;
let autoSaveFailStreak = 0;
let autoSaveFailToastShown = false;

// Record one autoSave() outcome from the periodic timer; returns true exactly once — the instant
// the streak first reaches AUTOSAVE_FAIL_TOAST consecutive failures — so the caller knows to show
// the toast right then. Pure bookkeeping, no DOM: exported so the threshold/one-time behavior is
// directly testable without a real localStorage or toast-stack element.
export function recordAutoSaveOutcome(ok) {
  if (ok) { autoSaveFailStreak = 0; return false; }
  autoSaveFailStreak++;
  if (autoSaveFailStreak === AUTOSAVE_FAIL_TOAST && !autoSaveFailToastShown) {
    autoSaveFailToastShown = true;
    return true;
  }
  return false;
}

// Keep the current game checkpointed without any manual step: on a timer, whenever the
// tab is hidden (task-switch / phone lock), and on unload (tab close / refresh). These
// are the writes the setup "Continue" buttons read back. Guarded so importing this module
// under Node (to unit-test its pure logic) doesn't start a real autosave timer or touch a
// non-existent `window` — the whole block is browser-only wiring.
if (typeof window !== "undefined") {
  setInterval(() => {
    // "Nothing to save" is NOT a failure, and must never feed the failure streak. autoSave()
    // returns false for two unrelated reasons — no resumable game, or a write that actually threw
    // — and conflating them meant sitting on any menu screen for three intervals (36s) raised a
    // red "Autosave is failing" banner while localStorage was perfectly healthy. Rare before the
    // Competition screens existed (nobody idled on the splash that long); constant once a
    // tournament keeps you on a menu screen for minutes at a time, which is how this surfaced.
    // Checked here rather than inside autoSave() so its documented return contract is untouched:
    // "Save & Exit" still needs false to mean "did not save" for BOTH reasons, since either one
    // leaves the player with nothing to Continue.
    if (!resumableMode(game)) return;
    if (recordAutoSaveOutcome(autoSave())) {
      showGalaxyToast("Autosave is failing — use Save to export a file.", "bad");
    }
  }, AUTOSAVE_INTERVAL_MS);
  window.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") autoSave(); });
  window.addEventListener("beforeunload", autoSave);
}