//! asm.famzheng.me — 汇编教学小游戏。 //! //! 4 个 endpoint: //! - `GET /api/health` 前端 ping 用 //! - `POST /api/players` 按 name upsert,返回 id + 已有进度 map //! - `POST /api/progress` 单关卡 upsert(stars 只能增加,code 覆盖) //! - `GET /api/leaderboard` top 50(按 total_stars desc, levels_completed desc) //! //! 静态前端 + SPA fallback 由 cube-core::base 处理。 use std::collections::HashMap; use std::sync::{Arc, Mutex}; use axum::{ extract::State, http::StatusCode, response::{IntoResponse, Json as JsonResp}, routing::{get, post}, Json, Router, }; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::json; type Db = Arc>; #[tokio::main] async fn main() -> std::io::Result<()> { cube_core::init_tracing(); let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "/data/app.db".into()); let dist = std::env::var("SIMPLEASM_DIST_DIR").unwrap_or_else(|_| "/dist".into()); let conn = Connection::open(&db_path).expect("open sqlite"); conn.execute_batch( "PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS players ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, player_id INTEGER NOT NULL, level_id INTEGER NOT NULL, stars INTEGER NOT NULL DEFAULT 0, code TEXT, completed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (player_id) REFERENCES players(id), UNIQUE (player_id, level_id) );", ) .expect("init schema"); tracing::info!(%db_path, "sqlite ready"); let db: Db = Arc::new(Mutex::new(conn)); let api = Router::new() .route("/health", get(|| async { "ok" })) .route("/players", post(create_or_get_player)) .route("/progress", post(save_progress)) .route("/leaderboard", get(leaderboard)) .with_state(db); let app = cube_core::base(dist).nest("/api", api); cube_core::serve(app, 8080).await } #[derive(Deserialize)] struct PlayerCreate { name: String, } #[derive(Deserialize)] struct ProgressSave { player_id: i64, level_id: i64, stars: i64, #[serde(default)] code: String, } #[derive(Serialize)] struct ProgressItem { stars: i64, code: String, completed: bool, } #[derive(Serialize)] struct LeaderboardRow { name: String, total_stars: i64, levels_completed: i64, } /// `POST /api/players` — 按 name upsert。返回 `{id, name, progress: {level_id: {...}}}`。 async fn create_or_get_player( State(db): State, Json(data): Json, ) -> Result { let name = data.name.trim().to_string(); if name.is_empty() { return Err(AppError::bad_request("Name cannot be empty")); } let conn = db.lock().unwrap(); let player_id: i64 = match conn .query_row( "SELECT id FROM players WHERE name = ?1", params![name], |r| r.get(0), ) .optional()? { Some(id) => id, None => { conn.execute("INSERT INTO players (name) VALUES (?1)", params![name])?; conn.last_insert_rowid() } }; let mut stmt = conn.prepare( "SELECT level_id, stars, code FROM progress WHERE player_id = ?1", )?; let mut rows = stmt.query(params![player_id])?; let mut progress: HashMap = HashMap::new(); while let Some(r) = rows.next()? { let level_id: i64 = r.get(0)?; let stars: i64 = r.get(1)?; let code: String = r.get::<_, Option>(2)?.unwrap_or_default(); progress.insert( level_id.to_string(), ProgressItem { stars, code, completed: true }, ); } Ok(JsonResp(json!({ "id": player_id, "name": name, "progress": progress, }))) } /// `POST /api/progress` — 单关 upsert。stars 取 max(old, new),code 永远覆盖。 async fn save_progress( State(db): State, Json(data): Json, ) -> Result { let conn = db.lock().unwrap(); conn.execute( "INSERT INTO progress (player_id, level_id, stars, code) VALUES (?1, ?2, ?3, ?4) ON CONFLICT (player_id, level_id) DO UPDATE SET stars = MAX(stars, excluded.stars), code = excluded.code, completed_at = CURRENT_TIMESTAMP", params![data.player_id, data.level_id, data.stars, data.code], )?; Ok(JsonResp(json!({ "success": true }))) } /// `GET /api/leaderboard` — top 50 by (total_stars desc, levels_completed desc)。 async fn leaderboard(State(db): State) -> Result { let conn = db.lock().unwrap(); let mut stmt = conn.prepare( "SELECT p.name, COALESCE(SUM(pr.stars), 0) AS total_stars, COUNT(pr.id) AS levels_completed FROM players p LEFT JOIN progress pr ON p.id = pr.player_id GROUP BY p.id ORDER BY total_stars DESC, levels_completed DESC LIMIT 50", )?; let rows = stmt .query_map([], |r| { Ok(LeaderboardRow { name: r.get(0)?, total_stars: r.get(1)?, levels_completed: r.get(2)?, }) })? .collect::, _>>()?; Ok(JsonResp(json!(rows))) } enum AppError { BadRequest(String), Db(rusqlite::Error), } impl AppError { fn bad_request(msg: impl Into) -> Self { Self::BadRequest(msg.into()) } } impl From for AppError { fn from(e: rusqlite::Error) -> Self { Self::Db(e) } } impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { match self { Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(), Self::Db(e) => { tracing::error!(error = %e, "sqlite error"); (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response() } } } }