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:
1 parent
139e641005
commit
1a216dc795
3 files changed
+115
-6
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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<rusqlite::Error> for StoreError {
|
||||
#[derive(Clone)]
|
||||
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 {
|
||||
/// 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<Vec<HighScore>, 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::<Result<Vec<_>, _>>()?;
|
||||
@@ -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<HighScore, &'static str>
|
||||
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::<Vec<_>>();
|
||||
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]
|
||||
fn existing_database_is_not_seeded() {
|
||||
let directory = tempdir().expect("temporary directory should exist");
|
||||
|
||||
Reference in new issue
Block a user