Spaces:
Paused
Paused
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);
}
|