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