Server-side savegames: Rust service with a shared savegame list in the menu, fallback to files
- server/: small axum service storing savegames as files (1 MB each, 500 MB total, rate limit) - docker compose: second container with a volume; nginx forwards /api/ - JS plugin: asynchronous HTTP requests polled by the game - Save/Load open a list page (name field, new, overwrite, load, delete with confirmation) - Without a reachable service the game keeps using savegame files and the browser autosave Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c5b7896609
commit
7f4324abca
17 files changed
+1390
-30
No files matched your search
@@ -0,0 +1,212 @@
|
||||
// DungeonCraft savegame service: a small HTTP API that stores savegames as files.
|
||||
//
|
||||
// GET /api/health -> "ok"
|
||||
// GET /api/saves -> list of savegames (newest first)
|
||||
// GET /api/saves/{id} -> savegame JSON
|
||||
// POST /api/saves?name=.. -> store a new savegame (body = savegame JSON), returns its info
|
||||
// PUT /api/saves/{id}?name -> overwrite a savegame
|
||||
// DELETE /api/saves/{id} -> delete a savegame
|
||||
//
|
||||
// Storage is shared by everybody (no accounts). Limits: 1 MB per savegame, 500 MB in total,
|
||||
// at most 30 write requests per minute.
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{DefaultBodyLimit, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const MAX_SAVE: usize = 1024 * 1024;
|
||||
const MAX_TOTAL: u64 = 500 * 1024 * 1024;
|
||||
const WRITES_PER_MINUTE: usize = 30;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
dir: PathBuf,
|
||||
writes: Arc<Mutex<Vec<u64>>>, // timestamps of recent writes
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct SaveInfo {
|
||||
id: String,
|
||||
name: String,
|
||||
saved_at: u64, // unix seconds
|
||||
size: usize,
|
||||
map_w: u64,
|
||||
map_h: u64,
|
||||
wave: u64,
|
||||
time: f64, // play time in seconds
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NameQuery {
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() <= 40 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
}
|
||||
|
||||
fn clean_name(n: Option<String>) -> String {
|
||||
let n: String = n.unwrap_or_default().chars().filter(|c| !c.is_control()).take(60).collect();
|
||||
if n.trim().is_empty() {
|
||||
"Savegame".into()
|
||||
} else {
|
||||
n.trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn err(code: StatusCode, msg: &str) -> Response {
|
||||
(code, msg.to_string()).into_response()
|
||||
}
|
||||
|
||||
async fn rate_ok(st: &AppState) -> bool {
|
||||
let mut w = st.writes.lock().await;
|
||||
let t = now();
|
||||
w.retain(|&x| t - x < 60);
|
||||
if w.len() >= WRITES_PER_MINUTE {
|
||||
return false;
|
||||
}
|
||||
w.push(t);
|
||||
true
|
||||
}
|
||||
|
||||
async fn total_size(dir: &PathBuf) -> u64 {
|
||||
let mut total = 0;
|
||||
if let Ok(mut rd) = tokio::fs::read_dir(dir).await {
|
||||
while let Ok(Some(e)) = rd.next_entry().await {
|
||||
if let Ok(m) = e.metadata().await {
|
||||
total += m.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
async fn list(State(st): State<AppState>) -> Response {
|
||||
let mut out: Vec<SaveInfo> = Vec::new();
|
||||
if let Ok(mut rd) = tokio::fs::read_dir(&st.dir).await {
|
||||
while let Ok(Some(e)) = rd.next_entry().await {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|x| x.to_str()) == Some("meta") {
|
||||
if let Ok(b) = tokio::fs::read(&p).await {
|
||||
if let Ok(info) = serde_json::from_slice::<SaveInfo>(&b) {
|
||||
out.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||
Json(out).into_response()
|
||||
}
|
||||
|
||||
async fn get_save(State(st): State<AppState>, Path(id): Path<String>) -> Response {
|
||||
if !valid_id(&id) {
|
||||
return err(StatusCode::BAD_REQUEST, "bad id");
|
||||
}
|
||||
match tokio::fs::read(st.dir.join(format!("{}.json", id))).await {
|
||||
Ok(b) => ([("content-type", "application/json")], b).into_response(),
|
||||
Err(_) => err(StatusCode::NOT_FOUND, "not found"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn store(st: &AppState, id: String, name: Option<String>, body: Bytes) -> Response {
|
||||
if body.len() > MAX_SAVE {
|
||||
return err(StatusCode::PAYLOAD_TOO_LARGE, "savegame too large");
|
||||
}
|
||||
if !rate_ok(st).await {
|
||||
return err(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
||||
}
|
||||
if total_size(&st.dir).await + body.len() as u64 > MAX_TOTAL {
|
||||
return err(StatusCode::INSUFFICIENT_STORAGE, "storage full");
|
||||
}
|
||||
// must be a DungeonCraft savegame; a few values are kept for the list
|
||||
let v: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return err(StatusCode::BAD_REQUEST, "not JSON"),
|
||||
};
|
||||
if v.get("format").and_then(|f| f.as_str()) != Some("dungeoncraft-save") {
|
||||
return err(StatusCode::BAD_REQUEST, "not a DungeonCraft savegame");
|
||||
}
|
||||
let game = &v["game"];
|
||||
let info = SaveInfo {
|
||||
id: id.clone(),
|
||||
name: clean_name(name),
|
||||
saved_at: now(),
|
||||
size: body.len(),
|
||||
map_w: game["map"]["w"].as_u64().unwrap_or(64),
|
||||
map_h: game["map"]["h"].as_u64().unwrap_or(64),
|
||||
wave: game["wave"].as_u64().unwrap_or(0),
|
||||
time: game["time"].as_f64().unwrap_or(0.0),
|
||||
};
|
||||
let save_path = st.dir.join(format!("{}.json", id));
|
||||
let meta_path = st.dir.join(format!("{}.meta", id));
|
||||
if tokio::fs::write(&save_path, &body).await.is_err() || tokio::fs::write(&meta_path, serde_json::to_vec(&info).unwrap_or_default()).await.is_err() {
|
||||
return err(StatusCode::INTERNAL_SERVER_ERROR, "write failed");
|
||||
}
|
||||
Json(info).into_response()
|
||||
}
|
||||
|
||||
async fn create(State(st): State<AppState>, Query(q): Query<NameQuery>, body: Bytes) -> Response {
|
||||
let id = format!("{}-{:04x}", now(), (now_nanos() % 0xffff) as u16);
|
||||
store(&st, id, q.name, body).await
|
||||
}
|
||||
|
||||
async fn overwrite(State(st): State<AppState>, Path(id): Path<String>, Query(q): Query<NameQuery>, body: Bytes) -> Response {
|
||||
if !valid_id(&id) {
|
||||
return err(StatusCode::BAD_REQUEST, "bad id");
|
||||
}
|
||||
store(&st, id, q.name, body).await
|
||||
}
|
||||
|
||||
async fn delete(State(st): State<AppState>, Path(id): Path<String>) -> Response {
|
||||
if !valid_id(&id) {
|
||||
return err(StatusCode::BAD_REQUEST, "bad id");
|
||||
}
|
||||
if !rate_ok(&st).await {
|
||||
return err(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
||||
}
|
||||
let _ = tokio::fs::remove_file(st.dir.join(format!("{}.json", id))).await;
|
||||
let _ = tokio::fs::remove_file(st.dir.join(format!("{}.meta", id))).await;
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
fn now_nanos() -> u128 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let dir = PathBuf::from(std::env::var("SAVE_DIR").unwrap_or_else(|_| "/data".into()));
|
||||
tokio::fs::create_dir_all(&dir).await.expect("save directory");
|
||||
let state = AppState { dir, writes: Arc::new(Mutex::new(Vec::new())) };
|
||||
let app = Router::new()
|
||||
.route("/api/health", get(health))
|
||||
.route("/api/saves", get(list).post(create))
|
||||
.route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete))
|
||||
.layer(DefaultBodyLimit::max(MAX_SAVE + 1024))
|
||||
.with_state(state);
|
||||
let addr = std::env::var("LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".into());
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind");
|
||||
println!("DungeonCraft savegame service on {}", addr);
|
||||
axum::serve(listener, app).await.expect("server");
|
||||
}
|
||||
Reference in new issue
Block a user