diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index 12b9eea..1b4066f 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to ### Fixed +- Add the optional same-origin Axum/SQLite high-score service, browser + fetch/submit integration, and an nginx reverse-proxy example. Browser + settings retain their local-storage fallback, while the shared table is + returned by the service. - Seed the initial gameplay random stream from the startup clock so the first maximum-power launcher shot is not identical on every run. - Require an unconsumed record-148 contact before the effect-seven special diff --git a/tdkpin-rs/README.md b/tdkpin-rs/README.md index 8eb5a1e..3299251 100644 --- a/tdkpin-rs/README.md +++ b/tdkpin-rs/README.md @@ -40,9 +40,10 @@ just web-serve ``` Then open . The browser build keeps the original -640x460 presentation centered on a black page and stores settings and high -scores in browser storage. See [web/README.md](web/README.md) for the static -bundle details. +640x460 presentation centered on a black page and stores settings and a local +fallback table in browser storage unless the optional same-origin +`/api/highscores` service is deployed. See [web/README.md](web/README.md) and +[highscore-server/README.md](highscore-server/README.md) for deployment details. ## Deterministic mechanics validation @@ -88,10 +89,13 @@ box closes the program. ## Saved data -Settings and the ten-entry high-score table are stored as `save.json` in the -platform's normal per-user application-data directory. On first run, settings -are imported from an adjacent original INI (or the embedded distributed -TDKPIN.INI), and the table is imported from the original `HISCORES.DAT`. +Native settings and the ten-entry high-score table are stored as `save.json` in +the platform's normal per-user application-data directory. Browser settings +and the fallback table use `localStorage`; a deployed browser build uses the +same-origin high-score service as its shared table when available. On first +run, native settings are imported from an adjacent original INI (or the +embedded distributed TDKPIN.INI), and the table is imported from the original +`HISCORES.DAT`. ## Reconstruction status diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index d787779..58341a1 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -257,6 +257,8 @@ impl App { pub fn frame(&mut self) { #[cfg(target_arch = "wasm32")] self.assets.update(); + #[cfg(target_arch = "wasm32")] + self.update_remote_high_scores(); self.handle_global_input(); match self.screen { Screen::Loading => { @@ -557,13 +559,13 @@ impl App { } fn accept_high_score_name(&mut self) { - insert_high_score( - &mut self.saved.high_scores, - HighScore { - name: self.name.clone(), - score: self.pending_score, - }, - ); + let entry = HighScore { + name: self.name.clone(), + score: self.pending_score, + }; + insert_high_score(&mut self.saved.high_scores, entry.clone()); + #[cfg(target_arch = "wasm32")] + self.persistence.submit_high_score(&entry); self.save(); if self.game.as_ref().is_some_and(|game| game.finished) { self.take_game_and_preserve_random_seed(); @@ -1314,6 +1316,14 @@ impl App { eprintln!("could not save settings and highscores: {error}"); } } + + #[cfg(target_arch = "wasm32")] + fn update_remote_high_scores(&mut self) { + if let Some(scores) = self.persistence.take_high_scores() { + self.saved.high_scores = scores; + self.save(); + } + } } fn draw_panel(x: f32, y: f32, width: f32, height: f32) { diff --git a/tdkpin-rs/src/persistence.rs b/tdkpin-rs/src/persistence.rs index 0bc6d50..2cf375a 100644 --- a/tdkpin-rs/src/persistence.rs +++ b/tdkpin-rs/src/persistence.rs @@ -6,7 +6,7 @@ use std::{fs, path::PathBuf}; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; #[cfg(target_arch = "wasm32")] -use tdkpin_web_storage::{queue_save, take_loaded}; +use tdkpin_web_storage::{queue_high_score, queue_save, take_high_scores, take_loaded}; const ORIGINAL_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT"); const ORIGINAL_INI: &str = include_str!("../../original/TDKPIN.INI"); @@ -143,6 +143,16 @@ impl Persistence { queue_save(encoded); Ok(()) } + + pub fn take_high_scores(&self) -> Option> { + take_high_scores().and_then(|bytes| serde_json::from_slice(&bytes).ok()) + } + + pub fn submit_high_score(&self, entry: &HighScore) { + if let Ok(encoded) = serde_json::to_vec(entry) { + queue_high_score(encoded); + } + } } #[cfg(not(target_arch = "wasm32"))] diff --git a/tdkpin-rs/web/README.md b/tdkpin-rs/web/README.md index 083786e..7e50cdc 100644 --- a/tdkpin-rs/web/README.md +++ b/tdkpin-rs/web/README.md @@ -8,9 +8,13 @@ just web-serve The game is compiled for `wasm32-unknown-unknown` and loaded into a fixed 640x460 canvas centered on the black page by `index.html`. Browser settings and -high scores are saved in `localStorage`; native builds continue to use their -normal per-user save file. +a fallback copy of the high-score table are saved in `localStorage`; native +builds continue to use their normal per-user save file. The page uses Macroquad's official browser loader from the miniquad samples site. The web page must be served over HTTP rather than opened directly from a `file:` URL. + +When the same-origin `/api/highscores` endpoint is available, the browser loads +and submits the shared top-ten table there. The small Axum service and an nginx +proxy example are in [highscore-server](../highscore-server/). diff --git a/tdkpin-rs/web/storage.js b/tdkpin-rs/web/storage.js index d2255f7..f832098 100644 --- a/tdkpin-rs/web/storage.js +++ b/tdkpin-rs/web/storage.js @@ -4,7 +4,12 @@ const storageKey = "tdkpin.save.v1"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); + const highScoreApi = "/api/highscores"; let lastRevision = 0; + let lastHighScoreRevision = 0; + let highScoreRequestInFlight = false; + let highScoreGeneration = 0; + let highScoreSubmissionWarningShown = false; function browserStorage() { try { @@ -54,10 +59,81 @@ 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); + } + } + + async function flushHighScoreSubmission() { + if (highScoreRequestInFlight) { + 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; + try { + const response = await fetch(highScoreApi, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: decoder.decode(bytes), + keepalive: true, + }); + if (!response.ok) { + 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; + } catch (error) { + 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); } diff --git a/tdkpin-rs/web/tdkpin-rs.wasm b/tdkpin-rs/web/tdkpin-rs.wasm index b3fec19..9184711 100755 Binary files a/tdkpin-rs/web/tdkpin-rs.wasm and b/tdkpin-rs/web/tdkpin-rs.wasm differ diff --git a/tdkpin-rs/web_storage/src/lib.rs b/tdkpin-rs/web_storage/src/lib.rs index 6ed3335..8740231 100644 --- a/tdkpin-rs/web_storage/src/lib.rs +++ b/tdkpin-rs/web_storage/src/lib.rs @@ -7,8 +7,11 @@ use std::{ thread_local! { static LOADED_BYTES: RefCell> = const { RefCell::new(Vec::new()) }; + static LOADED_HIGH_SCORES: RefCell> = const { RefCell::new(Vec::new()) }; static PENDING_SAVE: RefCell>> = const { RefCell::new(None) }; + static PENDING_HIGH_SCORE: RefCell>> = const { RefCell::new(None) }; static SAVE_REVISION: Cell = const { Cell::new(0) }; + static HIGH_SCORE_REVISION: Cell = const { Cell::new(0) }; } pub fn take_loaded() -> Option> { @@ -21,6 +24,16 @@ pub fn queue_save(bytes: Vec) { SAVE_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1))); } +pub fn take_high_scores() -> Option> { + let bytes = LOADED_HIGH_SCORES.with(|loaded| mem::take(&mut *loaded.borrow_mut())); + (!bytes.is_empty()).then_some(bytes) +} + +pub fn queue_high_score(bytes: Vec) { + PENDING_HIGH_SCORE.with(|pending| *pending.borrow_mut() = Some(bytes)); + HIGH_SCORE_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1))); +} + #[unsafe(no_mangle)] pub extern "C" fn tdkpin_storage_crate_version() -> u32 { 1 @@ -41,6 +54,21 @@ pub extern "C" fn tdkpin_browser_storage_push(byte: u32) { #[unsafe(no_mangle)] pub extern "C" fn tdkpin_browser_storage_finish() {} +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_high_scores_clear() { + LOADED_HIGH_SCORES.with(|loaded| loaded.borrow_mut().clear()); +} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_high_scores_push(byte: u32) { + if let Ok(byte) = u8::try_from(byte) { + LOADED_HIGH_SCORES.with(|loaded| loaded.borrow_mut().push(byte)); + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_high_scores_finish() {} + #[unsafe(no_mangle)] pub extern "C" fn tdkpin_browser_storage_save_revision() -> u32 { SAVE_REVISION.with(Cell::get) @@ -71,3 +99,36 @@ pub extern "C" fn tdkpin_browser_storage_save_byte(index: u32) -> u32 { pub extern "C" fn tdkpin_browser_storage_save_ack() { PENDING_SAVE.with(|pending| *pending.borrow_mut() = None); } + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_high_score_revision() -> u32 { + HIGH_SCORE_REVISION.with(Cell::get) +} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_high_score_length() -> u32 { + PENDING_HIGH_SCORE.with(|pending| { + pending + .borrow() + .as_ref() + .map_or(0, |bytes| u32::try_from(bytes.len()).unwrap_or(u32::MAX)) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_high_score_byte(index: u32) -> u32 { + PENDING_HIGH_SCORE.with(|pending| { + pending + .borrow() + .as_ref() + .and_then(|bytes| bytes.get(usize::try_from(index).ok()?)) + .map_or(0, |byte| u32::from(*byte)) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn tdkpin_browser_high_score_ack(revision: u32) { + if HIGH_SCORE_REVISION.with(Cell::get) == revision { + PENDING_HIGH_SCORE.with(|pending| *pending.borrow_mut() = None); + } +}