diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index c92d43f..ed7ea5f 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to - Crazy mode: Ctrl+Shift+0 drops ten extra balls onto the playfield. They bounce and score off walls, bumpers, and flippers, replace the main ball when it drains, and keep the game's scores off the online high-score table. +- The high-score service stores a crazy-mode flag per entry; it adds the column + to existing databases on startup and treats submissions from older clients as + normal scores. ## [1.2.1] - 2026-09-01 diff --git a/tdkpin-rs/highscore-server/README.md b/tdkpin-rs/highscore-server/README.md index 46740cd..7a45ce4 100644 --- a/tdkpin-rs/highscore-server/README.md +++ b/tdkpin-rs/highscore-server/README.md @@ -8,8 +8,12 @@ GET /api/highscores POST /api/highscores ``` -The POST body is JSON with a 21-character maximum name and a `u32` score. The -response is the canonical top-ten JSON array. +The POST body is JSON with a 21-character maximum name, a `u32` score, and an +optional `crazy` boolean (default `false`) marking a crazy-mode score. The +response is the canonical top-ten JSON array, each entry carrying `crazy`. + +Databases created before crazy mode gain the `crazy` column automatically on +startup; existing rows become normal scores. A newly created database starts with the original distributed demo table. An existing database, including an existing empty database, is left unchanged. diff --git a/tdkpin-rs/highscore-server/src/lib.rs b/tdkpin-rs/highscore-server/src/lib.rs index 7cc3248..ac0c5a4 100644 --- a/tdkpin-rs/highscore-server/src/lib.rs +++ b/tdkpin-rs/highscore-server/src/lib.rs @@ -36,6 +36,10 @@ const DEFAULT_HIGH_SCORES: &[(&str, u32)] = &[ pub struct HighScore { pub name: String, pub score: u32, + /// The score was reached in crazy mode. Clients that predate crazy mode + /// neither send nor read it. + #[serde(default)] + pub crazy: bool, } #[derive(Debug)] @@ -53,6 +57,21 @@ impl From for StoreError { #[derive(Clone)] pub struct HighScoreStore(Arc>); +/// Databases created before crazy mode lack the `crazy` column. Adding it +/// with a default keeps every existing row as a normal score. +fn add_crazy_column(connection: &Connection) -> Result<(), rusqlite::Error> { + let has_column = connection + .prepare("SELECT 1 FROM pragma_table_info('high_scores') WHERE name = 'crazy'")? + .exists([])?; + if !has_column { + connection.execute_batch( + "ALTER TABLE high_scores + ADD COLUMN crazy INTEGER NOT NULL DEFAULT 0 CHECK (crazy IN (0, 1));", + )?; + } + Ok(()) +} + impl HighScoreStore { /// Open or create a SQLite-backed high-score store. /// @@ -83,11 +102,13 @@ impl HighScoreStore { "CREATE TABLE IF NOT EXISTS high_scores ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, - score INTEGER NOT NULL CHECK (score >= 0 AND score <= 4294967295) + score INTEGER NOT NULL CHECK (score >= 0 AND score <= 4294967295), + crazy INTEGER NOT NULL DEFAULT 0 CHECK (crazy IN (0, 1)) ); CREATE INDEX IF NOT EXISTS high_scores_order ON high_scores (score DESC, id ASC);", )?; + add_crazy_column(&connection)?; if seed_defaults { let transaction = connection.transaction()?; for &(name, score) in DEFAULT_HIGH_SCORES { @@ -109,7 +130,7 @@ impl HighScoreStore { pub fn list(&self) -> Result, StoreError> { let connection = self.0.lock().map_err(|_| StoreError::Poisoned)?; let mut statement = connection.prepare( - "SELECT name, score + "SELECT name, score, crazy FROM high_scores ORDER BY score DESC, id ASC LIMIT ?1", @@ -123,6 +144,7 @@ impl HighScoreStore { Ok(HighScore { name: row.get(0)?, score, + crazy: row.get(2)?, }) })? .collect::, _>>()?; @@ -139,8 +161,8 @@ impl HighScoreStore { let mut connection = self.0.lock().map_err(|_| StoreError::Poisoned)?; let transaction = connection.transaction()?; transaction.execute( - "INSERT INTO high_scores (name, score) VALUES (?1, ?2)", - params![entry.name, i64::from(entry.score)], + "INSERT INTO high_scores (name, score, crazy) VALUES (?1, ?2, ?3)", + params![entry.name, i64::from(entry.score), entry.crazy], )?; transaction.execute( "DELETE FROM high_scores @@ -205,6 +227,8 @@ where struct SubmitRequest { name: String, score: u32, + #[serde(default)] + crazy: bool, } #[derive(Debug, Serialize)] @@ -247,6 +271,7 @@ fn validate_request(request: &SubmitRequest) -> Result Ok(HighScore { name: name.to_owned(), score: request.score, + crazy: request.crazy, }) } @@ -418,11 +443,88 @@ mod tests { .map(|&(name, score)| HighScore { name: name.to_owned(), score, + crazy: false, }) .collect::>(); assert_eq!(scores, expected); } + #[test] + fn pre_crazy_mode_database_is_migrated_in_place() { + let directory = tempdir().expect("temporary directory should exist"); + let database = directory.path().join("highscores.sqlite3"); + { + let connection = Connection::open(&database).expect("database should open"); + connection + .execute_batch( + "CREATE TABLE high_scores ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + score INTEGER NOT NULL CHECK (score >= 0 AND score <= 4294967295) + ); + INSERT INTO high_scores (name, score) VALUES ('Old', 500);", + ) + .expect("legacy schema should be created"); + } + + let store = HighScoreStore::open(&database).expect("legacy database should open"); + let scores = store + .submit(&HighScore { + name: "New".to_owned(), + score: 900, + crazy: true, + }) + .expect("submission should succeed"); + assert_eq!( + scores, + [ + HighScore { + name: "New".to_owned(), + score: 900, + crazy: true, + }, + HighScore { + name: "Old".to_owned(), + score: 500, + crazy: false, + }, + ] + ); + drop(store); + HighScoreStore::open(&database).expect("migrated database should reopen"); + } + + #[tokio::test] + async fn api_accepts_crazy_flag_and_defaults_it_for_old_clients() { + let app = router(HighScoreStore::open_in_memory().expect("database should open")); + assert_eq!(submit(&app, "Old client", 10).await, StatusCode::CREATED); + let status = request( + &app, + Request::post("/api/highscores") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&serde_json::json!({ + "name": "Crazy", + "score": 20, + "crazy": true, + })) + .expect("request JSON should encode"), + )) + .expect("request should build"), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + + let scores = list(&app).await; + assert_eq!( + scores + .iter() + .map(|entry| (entry.name.as_str(), entry.crazy)) + .collect::>(), + [("Crazy", true), ("Old client", false)] + ); + } + #[test] fn existing_database_is_not_seeded() { let directory = tempdir().expect("temporary directory should exist");