Change the high-score endpoint URL in storage.js from "/api/highscores" to "./api/highscores". When serving the web build from a subpath rather than the domain root, an absolute path sends fetch requests to the domain root instead of the nested application path. Using a relative URL ensures requests resolve relative to the active document path while still working when hosted at root. Test Plan: - `node --check tdkpin-rs/web/storage.js` -- passed - `npx prettier --check tdkpin-rs/web/storage.js` -- passed - `just test` -- passed (138 game tests, 3 highscore-server tests) - `just clippy` -- passed - `git diff --cached --check` -- passed
194 lines
5.6 KiB
JavaScript
194 lines
5.6 KiB
JavaScript
"use strict";
|
|
|
|
(function registerStoragePlugin() {
|
|
const storageKey = "tdkpin.save.v1";
|
|
const encoder = new TextEncoder();
|
|
const decoder = new TextDecoder();
|
|
const highScoreApi = "./api/highscores";
|
|
const highScoreRetryInitialDelayMs = 250;
|
|
const highScoreRetryMaxDelayMs = 30_000;
|
|
let lastRevision = 0;
|
|
let lastHighScoreRevision = 0;
|
|
let highScoreRequestInFlight = false;
|
|
let highScoreGeneration = 0;
|
|
let highScoreSubmissionWarningShown = false;
|
|
let highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
|
|
let highScoreRetryAt = 0;
|
|
|
|
function browserStorage() {
|
|
try {
|
|
return window.localStorage;
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function sendSavedDataToRust() {
|
|
wasm_exports.tdkpin_browser_storage_clear();
|
|
const storage = browserStorage();
|
|
let saved = null;
|
|
try {
|
|
saved = storage?.getItem(storageKey);
|
|
} catch (_error) {
|
|
saved = null;
|
|
}
|
|
if (saved !== null && saved !== undefined) {
|
|
for (const byte of encoder.encode(saved)) {
|
|
wasm_exports.tdkpin_browser_storage_push(byte);
|
|
}
|
|
}
|
|
wasm_exports.tdkpin_browser_storage_finish();
|
|
}
|
|
|
|
function flushRustSave() {
|
|
const revision = wasm_exports.tdkpin_browser_storage_save_revision();
|
|
if (revision === lastRevision) {
|
|
return;
|
|
}
|
|
|
|
const bytes = new Uint8Array(
|
|
wasm_exports.tdkpin_browser_storage_save_length(),
|
|
);
|
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
bytes[index] = wasm_exports.tdkpin_browser_storage_save_byte(index);
|
|
}
|
|
|
|
const storage = browserStorage();
|
|
try {
|
|
storage?.setItem(storageKey, decoder.decode(bytes));
|
|
} catch (_error) {
|
|
// Private browsing or a full quota should not stop the game loop.
|
|
}
|
|
wasm_exports.tdkpin_browser_storage_save_ack();
|
|
lastRevision = revision;
|
|
}
|
|
|
|
function deliverHighScores(json) {
|
|
wasm_exports.tdkpin_browser_high_scores_clear();
|
|
for (const byte of encoder.encode(json)) {
|
|
wasm_exports.tdkpin_browser_high_scores_push(byte);
|
|
}
|
|
wasm_exports.tdkpin_browser_high_scores_finish();
|
|
}
|
|
|
|
async function fetchHighScores() {
|
|
const generation = ++highScoreGeneration;
|
|
try {
|
|
const response = await fetch(highScoreApi, {
|
|
cache: "no-store",
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`high-score request failed (${response.status})`);
|
|
}
|
|
const json = await response.text();
|
|
if (generation === highScoreGeneration) {
|
|
deliverHighScores(json);
|
|
}
|
|
} catch (error) {
|
|
console.warn("shared high scores unavailable; using local scores", error);
|
|
}
|
|
}
|
|
|
|
function retryAfterDelay(response) {
|
|
if (response.status !== 429 && response.status !== 503) {
|
|
return null;
|
|
}
|
|
const value = response.headers.get("Retry-After")?.trim();
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
const seconds = Number(value);
|
|
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
const delay = seconds * 1000;
|
|
return Number.isFinite(delay) ? delay : null;
|
|
}
|
|
|
|
const timestamp = Date.parse(value);
|
|
return Number.isFinite(timestamp)
|
|
? Math.max(0, timestamp - Date.now())
|
|
: null;
|
|
}
|
|
|
|
function scheduleHighScoreRetry(retryAfterMs) {
|
|
const jitteredDelay = highScoreRetryDelayMs * (0.5 + Math.random());
|
|
const delay =
|
|
retryAfterMs === null ? jitteredDelay : retryAfterMs + jitteredDelay;
|
|
highScoreRetryAt = Date.now() + delay;
|
|
highScoreRetryDelayMs = Math.min(
|
|
highScoreRetryDelayMs * 2,
|
|
highScoreRetryMaxDelayMs,
|
|
);
|
|
}
|
|
|
|
function resetHighScoreRetry() {
|
|
highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
|
|
highScoreRetryAt = 0;
|
|
}
|
|
|
|
async function flushHighScoreSubmission() {
|
|
if (highScoreRequestInFlight) {
|
|
return;
|
|
}
|
|
if (Date.now() < highScoreRetryAt) {
|
|
return;
|
|
}
|
|
const revision = wasm_exports.tdkpin_browser_high_score_revision();
|
|
if (revision === lastHighScoreRevision) {
|
|
return;
|
|
}
|
|
const bytes = new Uint8Array(
|
|
wasm_exports.tdkpin_browser_high_score_length(),
|
|
);
|
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
bytes[index] = wasm_exports.tdkpin_browser_high_score_byte(index);
|
|
}
|
|
|
|
highScoreRequestInFlight = true;
|
|
let retryAfterMs = null;
|
|
try {
|
|
const response = await fetch(highScoreApi, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: decoder.decode(bytes),
|
|
keepalive: true,
|
|
});
|
|
if (!response.ok) {
|
|
retryAfterMs = retryAfterDelay(response);
|
|
throw new Error(`high-score submission failed (${response.status})`);
|
|
}
|
|
const json = await response.text();
|
|
highScoreGeneration += 1;
|
|
deliverHighScores(json);
|
|
wasm_exports.tdkpin_browser_high_score_ack(revision);
|
|
lastHighScoreRevision = revision;
|
|
highScoreSubmissionWarningShown = false;
|
|
resetHighScoreRetry();
|
|
} catch (error) {
|
|
scheduleHighScoreRetry(retryAfterMs);
|
|
if (!highScoreSubmissionWarningShown) {
|
|
console.warn("shared high-score submission failed; will retry", error);
|
|
highScoreSubmissionWarningShown = true;
|
|
}
|
|
} finally {
|
|
highScoreRequestInFlight = false;
|
|
}
|
|
}
|
|
|
|
function onInit() {
|
|
sendSavedDataToRust();
|
|
lastRevision = wasm_exports.tdkpin_browser_storage_save_revision();
|
|
void fetchHighScores();
|
|
window.setInterval(flushRustSave, 50);
|
|
window.setInterval(() => void flushHighScoreSubmission(), 50);
|
|
window.addEventListener("beforeunload", flushRustSave);
|
|
}
|
|
|
|
miniquad_add_plugin({
|
|
name: "tdkpin_storage",
|
|
on_init: onInit,
|
|
version: 1,
|
|
});
|
|
})();
|