Spaces:
Paused
Paused
Download saveload.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 20.9 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/saveload.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/saveload.js
-
curl -L -o saveload.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/saveload.js
20.9 kB
| /* ============================================================ | |
| 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`). | |
| ============================================================ */ | |
| ; | |
| 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); | |
| } | |