feat(highscore-server): store a crazy-mode flag per score

The game is getting a crazy mode that drops extra balls, and scores
reached with it should still enter the shared table but be visibly
labelled. The service therefore has to persist and return that label.

API:
- POST /api/highscores accepts an optional "crazy" boolean. Missing
  means false, so cached clients from before crazy mode keep working.
- GET responses include "crazy" on every entry. Older clients ignore
  unknown fields (serde default behaviour), so the new response shape is
  safe for them too.

Database:
- New databases get a `crazy INTEGER NOT NULL DEFAULT 0 CHECK (crazy IN
  (0, 1))` column.
- Existing databases are migrated on open: if pragma_table_info shows no
  `crazy` column, ALTER TABLE adds it with the same definition. SQLite
  fills existing rows with the default, so every historic score stays a
  normal score. No data is rewritten and the migration is idempotent.

Ordering and the top-ten pruning are unchanged; the flag is not part of
the ranking.

Test Plan:
- cargo test --manifest-path highscore-server/Cargo.toml
  - pre_crazy_mode_database_is_migrated_in_place: builds the old schema
    with a row, opens it, submits a crazy score, checks both rows come
    back with the right flag, and reopens the migrated file.
  - api_accepts_crazy_flag_and_defaults_it_for_old_clients: one request
    without the field, one with "crazy": true.
- Deploy: restart the service against the production database and check
  GET /api/highscores lists the old entries with "crazy": false.
This commit is contained in:
ddidderr committed 2026-10-10 11:13:37 +02:00
1 parent 139e641005
commit 1a216dc795
3 files changed
+115 -6

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ and this project adheres to
- Crazy mode: Ctrl+Shift+0 drops ten extra balls onto the playfield. They bounce - 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 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. 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 ## [1.2.1] - 2026-09-01
+6 -2
View File
@@ -8,8 +8,12 @@ GET /api/highscores
POST /api/highscores POST /api/highscores
``` ```
The POST body is JSON with a 21-character maximum name and a `u32` score. The The POST body is JSON with a 21-character maximum name, a `u32` score, and an
response is the canonical top-ten JSON array. 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 A newly created database starts with the original distributed demo table. An
existing database, including an existing empty database, is left unchanged. existing database, including an existing empty database, is left unchanged.
+106 -4
View File
@@ -36,6 +36,10 @@ const DEFAULT_HIGH_SCORES: &[(&str, u32)] = &[
pub struct HighScore { pub struct HighScore {
pub name: String, pub name: String,
pub score: u32, 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)] #[derive(Debug)]
@@ -53,6 +57,21 @@ impl From<rusqlite::Error> for StoreError {
#[derive(Clone)] #[derive(Clone)]
pub struct HighScoreStore(Arc<Mutex<Connection>>); pub struct HighScoreStore(Arc<Mutex<Connection>>);
/// 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 { impl HighScoreStore {
/// Open or create a SQLite-backed high-score store. /// Open or create a SQLite-backed high-score store.
/// ///
@@ -83,11 +102,13 @@ impl HighScoreStore {
"CREATE TABLE IF NOT EXISTS high_scores ( "CREATE TABLE IF NOT EXISTS high_scores (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT NOT NULL, 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 CREATE INDEX IF NOT EXISTS high_scores_order
ON high_scores (score DESC, id ASC);", ON high_scores (score DESC, id ASC);",
)?; )?;
add_crazy_column(&connection)?;
if seed_defaults { if seed_defaults {
let transaction = connection.transaction()?; let transaction = connection.transaction()?;
for &(name, score) in DEFAULT_HIGH_SCORES { for &(name, score) in DEFAULT_HIGH_SCORES {
@@ -109,7 +130,7 @@ impl HighScoreStore {
pub fn list(&self) -> Result<Vec<HighScore>, StoreError> { pub fn list(&self) -> Result<Vec<HighScore>, StoreError> {
let connection = self.0.lock().map_err(|_| StoreError::Poisoned)?; let connection = self.0.lock().map_err(|_| StoreError::Poisoned)?;
let mut statement = connection.prepare( let mut statement = connection.prepare(
"SELECT name, score "SELECT name, score, crazy
FROM high_scores FROM high_scores
ORDER BY score DESC, id ASC ORDER BY score DESC, id ASC
LIMIT ?1", LIMIT ?1",
@@ -123,6 +144,7 @@ impl HighScoreStore {
Ok(HighScore { Ok(HighScore {
name: row.get(0)?, name: row.get(0)?,
score, score,
crazy: row.get(2)?,
}) })
})? })?
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
@@ -139,8 +161,8 @@ impl HighScoreStore {
let mut connection = self.0.lock().map_err(|_| StoreError::Poisoned)?; let mut connection = self.0.lock().map_err(|_| StoreError::Poisoned)?;
let transaction = connection.transaction()?; let transaction = connection.transaction()?;
transaction.execute( transaction.execute(
"INSERT INTO high_scores (name, score) VALUES (?1, ?2)", "INSERT INTO high_scores (name, score, crazy) VALUES (?1, ?2, ?3)",
params![entry.name, i64::from(entry.score)], params![entry.name, i64::from(entry.score), entry.crazy],
)?; )?;
transaction.execute( transaction.execute(
"DELETE FROM high_scores "DELETE FROM high_scores
@@ -205,6 +227,8 @@ where
struct SubmitRequest { struct SubmitRequest {
name: String, name: String,
score: u32, score: u32,
#[serde(default)]
crazy: bool,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -247,6 +271,7 @@ fn validate_request(request: &SubmitRequest) -> Result<HighScore, &'static str>
Ok(HighScore { Ok(HighScore {
name: name.to_owned(), name: name.to_owned(),
score: request.score, score: request.score,
crazy: request.crazy,
}) })
} }
@@ -418,11 +443,88 @@ mod tests {
.map(|&(name, score)| HighScore { .map(|&(name, score)| HighScore {
name: name.to_owned(), name: name.to_owned(),
score, score,
crazy: false,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(scores, expected); 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::<Vec<_>>(),
[("Crazy", true), ("Old client", false)]
);
}
#[test] #[test]
fn existing_database_is_not_seeded() { fn existing_database_is_not_seeded() {
let directory = tempdir().expect("temporary directory should exist"); let directory = tempdir().expect("temporary directory should exist");