Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
63 kB · 1651 lines
Rust
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652//! The sqlite file behind everything server-side: OAuth state, OAuth//! sessions (which hold tokens — this file is a credential store first and a//! database second), and the account rows `/api/session` answers from.//!//! rusqlite is synchronous. Every operation runs the closure on a blocking//! thread over one mutex-serialized connection: the futures come out `Send`//! (the atrium store traits require it), no runtime worker parks on file//! I/O, and a single process writing a WAL database needs nothing cleverer.
use std::collections::HashSet;use std::path::Path;use std::sync::{Arc, Mutex};
use rusqlite::Connection;
#[derive(Debug)]pub enum DbError { Sqlite(rusqlite::Error), /// The blocking task was cancelled — only happens at shutdown. Cancelled,}
impl std::fmt::Display for DbError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DbError::Sqlite(e) => write!(f, "sqlite: {e}"), DbError::Cancelled => write!(f, "database task cancelled"), } }}
impl std::error::Error for DbError {}
#[derive(Clone)]pub struct Db(Arc<Mutex<Connection>>);
/// One row of the matches table. Status vocabulary lives in matches/mod.rs;/// here it is just text.#[derive(Clone, Debug)]pub struct MatchRow { pub id: String, pub owner_did: String, pub status: String, pub token: String, pub task_arn: Option<String>, pub task_ip: Option<String>, pub created_at: i64, /// The scenario path the match launched with. None on rows that predate /// the column. pub scenario: Option<String>, /// When the match became playable, and when it was decided. Both None on /// rows that predate the columns, and `ended_at` is None while a match is /// still being fought. pub started_at: Option<i64>, pub ended_at: Option<i64>,}
/// One seat in a match. `did` is NULL for a bot; `handle` is the display/// name the seat launched under — for a bot that is the site's own handle,/// and for a human it is what verified at launch time, kept as-was rather/// than re-resolved: it names who was fought, not who they are now.#[derive(Clone, Debug, PartialEq, serde::Serialize)]pub struct MatchPlayer { pub slot: String, pub control: String, pub did: Option<String>, pub handle: Option<String>, /// The team this seat fights on, when a lobby said something other than /// the scenario did. `None` is not "no team": it is "nobody overrode /// it", and the scenario's own `team_<faction>` stands — see /// `routes::lobby_seats_to_players`, which is where the two are resolved /// into the one number the manifest carries. pub team: Option<i32>,}
/// A player seen across the table, for the challenge screen's recent list.#[derive(Clone, Debug, serde::Serialize)]pub struct Opponent { pub did: String, pub handle: Option<String>, /// When the latest match against them started, unix seconds. #[serde(rename = "lastMatchAt")] pub last_match_at: i64,}
/// Adds a column unless the table already has it. Table and column names are/// literals in this file, never input.fn add_column( conn: &Connection, table: &str, column: &str, declaration: &str,) -> Result<(), rusqlite::Error> { let mut statement = conn.prepare(&format!("PRAGMA table_info({table})"))?; let mut columns = statement.query_map([], |row| row.get::<_, String>(1))?; if columns.any(|name| name.as_deref() == Ok(column)) { return Ok(()); } conn.execute_batch(&format!( "ALTER TABLE {table} ADD COLUMN {column} {declaration}" ))}
fn row_to_match(r: &rusqlite::Row<'_>) -> Result<MatchRow, rusqlite::Error> { Ok(MatchRow { id: r.get(0)?, owner_did: r.get(1)?, status: r.get(2)?, token: r.get(3)?, task_arn: r.get(4)?, task_ip: r.get(5)?, created_at: r.get(6)?, scenario: r.get(7)?, started_at: r.get(8)?, ended_at: r.get(9)?, })}
/// One player's attempt at one daily challenge.////// `did` and `day` are stored and read back but nothing acts on them yet: they/// are what a per-player history and a day's board are built from, which is/// the next half of this epic.#[derive(Debug, Clone)]#[allow(dead_code, reason = "did and day are for the board and the profile")]pub struct DailyAttempt { pub slug: String, pub did: String, pub match_id: String, /// The UTC day the attempt was started. pub day: String, /// None while the match is still running, or if it never produced a /// result — though in that case the row is deleted rather than kept. pub score: Option<i64>,}
fn daily_attempt_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<DailyAttempt> { Ok(DailyAttempt { slug: row.get(0)?, did: row.get(1)?, match_id: row.get(2)?, day: row.get(3)?, score: row.get(4)?, })}
impl Db { pub fn open(path: &Path) -> Result<Db, String> { if let Some(parent) = path.parent() && !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent) .map_err(|e| format!("cannot create {}: {e}", parent.display()))?; }
let conn = Connection::open(path).map_err(|e| format!("cannot open {}: {e}", path.display()))?;
// Tokens at rest: nobody but the service user gets to read them. let mut permissions = std::fs::metadata(path) .map_err(|e| format!("cannot stat {}: {e}", path.display()))? .permissions(); std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o600); std::fs::set_permissions(path, permissions) .map_err(|e| format!("cannot chmod {}: {e}", path.display()))?;
conn.execute_batch( "PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS oauth_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS oauth_session ( did TEXT PRIMARY KEY, value TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS account ( did TEXT PRIMARY KEY, handle TEXT, verified_at INTEGER, checked_at INTEGER ); CREATE TABLE IF NOT EXISTS matches ( id TEXT PRIMARY KEY, owner_did TEXT NOT NULL, status TEXT NOT NULL, token TEXT NOT NULL, task_arn TEXT, task_ip TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS match_players ( match_id TEXT NOT NULL, slot TEXT NOT NULL, control TEXT NOT NULL, did TEXT, handle TEXT, PRIMARY KEY (match_id, slot) ); CREATE INDEX IF NOT EXISTS match_players_by_did ON match_players (did) WHERE did IS NOT NULL;
-- One attempt at a daily challenge, per player per challenge. -- The primary key is the rule: there is no second row to write, -- so a second attempt fails at the database rather than at a -- check somebody can forget to make. -- -- A row is written when the match launches and carries the -- player through it. `score` is null while the match runs, and -- the row is *deleted* if the match ends without a result - -- an attempt lost to a crash is not a score, and burning it -- would charge the player for our own failure. CREATE TABLE IF NOT EXISTS daily_attempt ( slug TEXT NOT NULL, did TEXT NOT NULL, match_id TEXT NOT NULL, day TEXT NOT NULL, score INTEGER, bv_start INTEGER, bv_left INTEGER, foe_bv_start INTEGER, foe_bv_left INTEGER, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), PRIMARY KEY (slug, did) ); CREATE UNIQUE INDEX IF NOT EXISTS daily_attempt_by_match ON daily_attempt (match_id);", ) .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?;
// The batch above only creates missing tables, so a database that // predates a column keeps it missing. sqlite has no ADD COLUMN IF NOT // EXISTS; asking the table what it has is the portable substitute. add_column(&conn, "account", "checked_at", "INTEGER") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; add_column(&conn, "account", "default_camo", "TEXT") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; add_column(&conn, "match_players", "team", "INTEGER") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; add_column(&conn, "matches", "scenario", "TEXT") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; // When the match became playable and when it was decided. Stamped by // set_match_status, so nothing has to remember to do it. add_column(&conn, "matches", "started_at", "INTEGER") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; add_column(&conn, "matches", "ended_at", "INTEGER") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?;
Ok(Db(Arc::new(Mutex::new(conn)))) }
/// Runs `f` against the connection on a blocking thread. pub async fn call<T, F>(&self, f: F) -> Result<T, DbError> where T: Send + 'static, F: FnOnce(&Connection) -> Result<T, rusqlite::Error> + Send + 'static, { let db = self.0.clone(); tokio::task::spawn_blocking(move || { let conn = db.lock().expect("db mutex poisoned"); f(&conn).map_err(DbError::Sqlite) }) .await .map_err(|_| DbError::Cancelled)? }
/// The one writer of the account row, used by sign-in and by every later /// re-check. `handle` is the *verified* handle — None means verification /// failed and the UI shows the DID as unverified, so a name that stops /// verifying is demoted by the same path that promoted it. /// /// `verified_at` is when the handle last checked out; `checked_at` is /// when we last looked, however it went. They differ for exactly the /// account that is failing verification, which is the one that still /// needs scheduling for another look. pub async fn record_handle_check( &self, did: &str, handle: Option<&str>, ) -> Result<(), DbError> { let did = did.to_owned(); let handle = handle.map(str::to_owned); self.call(move |conn| { conn.execute( "INSERT INTO account (did, handle, verified_at, checked_at) VALUES (?1, ?2, CASE WHEN ?2 IS NULL THEN NULL ELSE unixepoch() END, unixepoch()) ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, verified_at = excluded.verified_at, checked_at = excluded.checked_at", (did, handle), ) .map(|_| ()) }) .await }
/// Takes the right to re-verify this account's handle, if it has not been /// looked at since `stale_before`. True means the caller got it and /// nobody else will until the next interval; false means the row is /// fresh, or another request claimed it first. /// /// The claim is the `checked_at` bump itself, which is why it is written /// before the work rather than after: a compare-and-swap in the shared /// store needs no coordination between requests, or between instances if /// there is ever more than one. The cost of writing it early is that a /// crash mid-check delays the re-check by one interval, and a delayed /// handle refresh is not worth a lock to avoid. pub async fn claim_handle_check(&self, did: &str, stale_before: i64) -> Result<bool, DbError> { let did = did.to_owned(); self.call(move |conn| { conn.execute( "UPDATE account SET checked_at = unixepoch() WHERE did = ?1 AND (checked_at IS NULL OR checked_at < ?2)", (did, stale_before), ) .map(|rows| rows == 1) }) .await }
/// `None` — no such account; `Some(None)` — account with no verified /// handle. pub async fn account_handle(&self, did: &str) -> Result<Option<Option<String>>, DbError> { let did = did.to_owned(); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row("SELECT handle FROM account WHERE did = ?1", [did], |row| { row.get::<_, Option<String>>(0) }) .optional() }) .await }
/// The camo a match uses when the player does not pick one, as the record /// key of a `blue.lance.camo` in their own repository. /// /// A site preference and not player content, which is why it is here and /// not in their repo: the camo itself is theirs and lives in their /// account, and this is only lance.blue remembering which one to reach for /// first. It is a key and not a copy, so a camo renamed, redrawn or /// deleted needs nothing done here — a key that no longer resolves is read /// as "no default", which is the same as never having set one. pub async fn default_camo(&self, did: &str) -> Result<Option<String>, DbError> { let did = did.to_owned(); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( "SELECT default_camo FROM account WHERE did = ?1", [did], |row| row.get::<_, Option<String>>(0), ) .optional() .map(Option::flatten) }) .await }
/// Sets it, or clears it with None. /// /// Upserts, because the account row is created by the handle check and a /// player can reach this before one has ever run. pub async fn set_default_camo(&self, did: &str, rkey: Option<&str>) -> Result<(), DbError> { let did = did.to_owned(); let rkey = rkey.map(str::to_owned); self.call(move |conn| { conn.execute( "INSERT INTO account (did, default_camo) VALUES (?1, ?2) ON CONFLICT(did) DO UPDATE SET default_camo = ?2", rusqlite::params![did, rkey], ) .map(|_| ()) }) .await }
/// Whether a stored OAuth session still exists for the DID. The cookie /// alone is not a session: this row going away is how sign-out and a /// wiped database actually sign someone out. pub async fn has_oauth_session(&self, did: &str) -> Result<bool, DbError> { let did = did.to_owned(); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row("SELECT 1 FROM oauth_session WHERE did = ?1", [did], |_| { Ok(()) }) .optional() .map(|row| row.is_some()) }) .await }
pub async fn insert_match(&self, row: &MatchRow) -> Result<(), DbError> { let row = row.clone(); self.call(move |conn| { conn.execute( "INSERT INTO matches (id, owner_did, status, token, scenario) VALUES (?1, ?2, ?3, ?4, ?5)", (row.id, row.owner_did, row.status, row.token, row.scenario), ) .map(|_| ()) }) .await }
/// Writes a match's seats, all at once at launch. They never change /// afterwards: who was in a match is a fact about the launch. pub async fn insert_match_players( &self, match_id: &str, players: &[MatchPlayer], ) -> Result<(), DbError> { let match_id = match_id.to_owned(); let players = players.to_vec(); self.call(move |conn| { let mut statement = conn.prepare( "INSERT INTO match_players (match_id, slot, control, did, handle, team) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", )?; for p in &players { statement.execute((&match_id, &p.slot, &p.control, &p.did, &p.handle, &p.team))?; } Ok(()) }) .await }
/// Claims one seat for a lobby that fills in one slot at a time, rather /// than the whole slate at once the way `insert_match_players` does for /// a launch. Writes `player`'s row only if `player.slot` has no occupant /// yet, and — when `player.did` is `Some` — only if that DID does not /// already hold a *different* slot in the same match; a bot's `did` is /// always `None`, so several bot seats never trip that second check. /// /// This is deliberately not a blind "INSERT OR REPLACE": that would make /// the *last* claim of a contested slot win, and a lobby seat is meant /// to be first-write-wins. Both conditions are evaluated by the same /// `INSERT ... WHERE NOT EXISTS`, so there is no gap between "is this /// slot free" and "take it" for a second, concurrent call to land in — /// unlike a `match_players` read followed by a separate write, which /// would race even though every `Db` call funnels through one connection, /// because the read and the write would be two separate lock /// acquisitions with room for another caller's write in between. /// /// Returns whether this call's row is the one that landed. pub async fn claim_match_player( &self, match_id: &str, player: &MatchPlayer, ) -> Result<bool, DbError> { let match_id = match_id.to_owned(); let player = player.clone(); self.call(move |conn| { let changed = conn.execute( "INSERT INTO match_players (match_id, slot, control, did, handle, team) SELECT ?1, ?2, ?3, ?4, ?5, ?6 WHERE NOT EXISTS ( SELECT 1 FROM match_players WHERE match_id = ?1 AND slot = ?2 ) AND ( ?4 IS NULL OR NOT EXISTS ( SELECT 1 FROM match_players WHERE match_id = ?1 AND did = ?4 ) )", ( &match_id, &player.slot, &player.control, &player.did, &player.handle, &player.team, ), )?; Ok(changed == 1) }) .await }
/// Sets (or clears) one seat's team override. Clearing — `None` — is /// not "this seat has no team": it hands the question back to the /// scenario file, which is where the answer came from before a lobby /// had an opinion. /// /// Returns whether a row was actually written, so a caller can tell a /// change from a request naming a slot nobody holds. pub async fn set_match_player_team( &self, match_id: &str, slot: &str, team: Option<i32>, ) -> Result<bool, DbError> { let (match_id, slot) = (match_id.to_owned(), slot.to_owned()); self.call(move |conn| { let changed = conn.execute( "UPDATE match_players SET team = ?3 WHERE match_id = ?1 AND slot = ?2", (match_id, slot, team), )?; Ok(changed == 1) }) .await }
/// Hands a lobby to a different owner. Lobby rows only: a launched /// match's owner is a fact about who launched it. pub async fn set_match_owner(&self, match_id: &str, owner_did: &str) -> Result<(), DbError> { let (match_id, owner_did) = (match_id.to_owned(), owner_did.to_owned()); self.call(move |conn| { conn.execute( "UPDATE matches SET owner_did = ?2, updated_at = unixepoch() WHERE id = ?1 AND status = 'lobby'", (match_id, owner_did), ) .map(|_| ()) }) .await }
/// Removes one seat. The only way a claimed seat disappears in this /// slice: a lobby's scenario changes out from under it and the slot the /// seat was in no longer exists in the new scenario. There is no /// "leave the lobby" action yet. pub async fn delete_match_player(&self, match_id: &str, slot: &str) -> Result<(), DbError> { let (match_id, slot) = (match_id.to_owned(), slot.to_owned()); self.call(move |conn| { conn.execute( "DELETE FROM match_players WHERE match_id = ?1 AND slot = ?2", (match_id, slot), ) .map(|_| ()) }) .await }
/// The seats of one match, in slot order. pub async fn match_players(&self, match_id: &str) -> Result<Vec<MatchPlayer>, DbError> { let match_id = match_id.to_owned(); self.call(move |conn| { let mut statement = conn.prepare( "SELECT slot, control, did, handle, team FROM match_players WHERE match_id = ?1 ORDER BY rowid", )?; let rows = statement.query_map([match_id], |r| { Ok(MatchPlayer { slot: r.get(0)?, control: r.get(1)?, did: r.get(2)?, handle: r.get(3)?, team: r.get(4)?, }) })?; rows.collect() }) .await }
/// Whether this DID holds a seat in this match. The proxy's question: /// matches that predate the seats table have only their owner row, so /// callers check the owner column first. pub async fn is_match_player(&self, match_id: &str, did: &str) -> Result<bool, DbError> { let (match_id, did) = (match_id.to_owned(), did.to_owned()); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( "SELECT 1 FROM match_players WHERE match_id = ?1 AND did = ?2", (match_id, did), |_| Ok(()), ) .optional() .map(|row| row.is_some()) }) .await }
/// Whether this DID fights in this match — holds a human seat, not just /// a view of it. What routing keys on: the container's client for a /// fighter lives at `/play/<did>`. pub async fn is_human_player(&self, match_id: &str, did: &str) -> Result<bool, DbError> { let (match_id, did) = (match_id.to_owned(), did.to_owned()); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( "SELECT 1 FROM match_players WHERE match_id = ?1 AND did = ?2 AND control = 'human'", (match_id, did), |_| Ok(()), ) .optional() .map(|row| row.is_some()) }) .await }
/// Everyone this DID has shared a match with, latest first. Bots have no /// DID and drop out on their own; the handle is the one from the most /// recent match, which is the name they were last seen under — sqlite's /// documented bare-column-with-max() behavior is what makes `other.handle` /// come from the row that won the MAX. pub async fn recent_opponents(&self, did: &str, limit: u32) -> Result<Vec<Opponent>, DbError> { let did = did.to_owned(); self.call(move |conn| { let mut statement = conn.prepare( "SELECT other.did, other.handle, MAX(m.created_at) AS last FROM match_players mine JOIN match_players other ON other.match_id = mine.match_id JOIN matches m ON m.id = mine.match_id WHERE mine.did = ?1 AND other.did IS NOT NULL AND other.did != ?1 GROUP BY other.did ORDER BY last DESC LIMIT ?2", )?; let rows = statement.query_map((did, limit), |r| { Ok(Opponent { did: r.get(0)?, handle: r.get(1)?, last_match_at: r.get(2)?, }) })?; rows.collect() }) .await }
/// Everyone this DID has ever shared a match with, unordered and /// uncapped — recent_opponents' join, minus the ordering and limit that /// make sense for a "recent" strip but not for a full exclusion set. /// /// Used to leave already-faced opponents out of suggestions: a followed /// account already fought is a re-sort of the recent-opponents strip, /// not new discovery. pub async fn all_opponent_dids(&self, did: &str) -> Result<HashSet<String>, DbError> { let did = did.to_owned(); self.call(move |conn| { let mut statement = conn.prepare( "SELECT DISTINCT other.did FROM match_players mine JOIN match_players other ON other.match_id = mine.match_id WHERE mine.did = ?1 AND other.did IS NOT NULL AND other.did != ?1", )?; let rows = statement.query_map([&did], |r| r.get::<_, String>(0))?; rows.collect() }) .await }
/// Which of `dids` have ever held a seat in any match, anywhere — "has /// played lance.blue before," scoped to a candidate list rather than the /// whole table: match_players has no upper bound, and every caller of /// this only ever wants an answer about a bounded follow-list page. pub async fn played_dids(&self, dids: &[String]) -> Result<HashSet<String>, DbError> { if dids.is_empty() { return Ok(HashSet::new()); } let dids = dids.to_vec(); self.call(move |conn| { let placeholders = vec!["?"; dids.len()].join(","); let sql = format!("SELECT DISTINCT did FROM match_players WHERE did IN ({placeholders})"); let mut statement = conn.prepare(&sql)?; let rows = statement.query_map(rusqlite::params_from_iter(dids.iter()), |r| { r.get::<_, String>(0) })?; rows.collect() }) .await }
pub async fn get_match(&self, id: &str) -> Result<Option<MatchRow>, DbError> { let id = id.to_owned(); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( "SELECT id, owner_did, status, token, task_arn, task_ip, created_at, scenario, started_at, ended_at FROM matches WHERE id = ?1", [id], row_to_match, ) .optional() }) .await }
pub async fn set_match_status(&self, id: &str, status: &str) -> Result<(), DbError> { let (id, status) = (id.to_owned(), status.to_owned()); self.call(move |conn| { // The two timestamps are stamped here rather than by the // callers, because every path that ends a match goes through this // one statement and none of them would remember. First write // wins: a status re-asserted must not move the clock. conn.execute( "UPDATE matches SET status = ?2, updated_at = unixepoch(), started_at = CASE WHEN started_at IS NULL AND ?2 IN ('running', 'ready') THEN unixepoch() ELSE started_at END, ended_at = CASE WHEN ended_at IS NULL AND ?2 IN ('over', 'failed') THEN unixepoch() ELSE ended_at END WHERE id = ?1", (id, status), ) .map(|_| ()) }) .await }
/// How many players have taken on a challenge. Attempts, not finishers: /// a match still running counts, because the card's figure is "how many /// people have tried this" and a player mid-match has. pub async fn daily_attempt_count(&self, slug: &str) -> Result<i64, DbError> { let slug = slug.to_owned(); self.call(move |conn| { conn.query_row( "SELECT COUNT(*) FROM daily_attempt WHERE slug = ?1", [slug], |row| row.get(0), ) }) .await }
/// This player's attempt at this challenge, if they have used it. pub async fn daily_attempt( &self, slug: &str, did: &str, ) -> Result<Option<DailyAttempt>, DbError> { let (slug, did) = (slug.to_owned(), did.to_owned()); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( "SELECT slug, did, match_id, day, score FROM daily_attempt WHERE slug = ?1 AND did = ?2", (slug, did), daily_attempt_row, ) .optional() }) .await }
/// The attempt a match belongs to, if it is one. Used when a match ends, /// to decide whether there is a score to write or an attempt to release. pub async fn daily_attempt_for_match( &self, match_id: &str, ) -> Result<Option<DailyAttempt>, DbError> { let match_id = match_id.to_owned(); self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( "SELECT slug, did, match_id, day, score FROM daily_attempt WHERE match_id = ?1", [match_id], daily_attempt_row, ) .optional() }) .await }
/// Take the player's one attempt at a challenge. /// /// False means they had already used it. The primary key is what decides /// that, not a read followed by a write: two launches racing each other /// would both pass a check and only one can insert. pub async fn take_daily_attempt( &self, slug: &str, did: &str, match_id: &str, day: &str, ) -> Result<bool, DbError> { let (slug, did, match_id, day) = ( slug.to_owned(), did.to_owned(), match_id.to_owned(), day.to_owned(), ); self.call(move |conn| { let changed = conn.execute( "INSERT OR IGNORE INTO daily_attempt (slug, did, match_id, day) VALUES (?1, ?2, ?3, ?4)", (slug, did, match_id, day), )?; Ok(changed > 0) }) .await }
/// Point a claimed attempt at the match it was spent on, replacing the /// placeholder the claim was made against. pub async fn bind_daily_attempt( &self, slug: &str, did: &str, match_id: &str, ) -> Result<(), DbError> { let (slug, did, match_id) = (slug.to_owned(), did.to_owned(), match_id.to_owned()); self.call(move |conn| { conn.execute( "UPDATE daily_attempt SET match_id = ?3, updated_at = unixepoch() WHERE slug = ?1 AND did = ?2", (slug, did, match_id), ) .map(|_| ()) }) .await }
/// Write the score for a finished attempt. pub async fn score_daily_attempt( &self, match_id: &str, score: i64, mine: (i64, i64), theirs: (i64, i64), ) -> Result<(), DbError> { let match_id = match_id.to_owned(); self.call(move |conn| { conn.execute( "UPDATE daily_attempt SET score = ?2, bv_start = ?3, bv_left = ?4, foe_bv_start = ?5, foe_bv_left = ?6, updated_at = unixepoch() WHERE match_id = ?1", (match_id, score, mine.0, mine.1, theirs.0, theirs.1), ) .map(|_| ()) }) .await }
/// Give an attempt back, because the match it was spent on produced no /// result. Deleting the row is what makes the attempt available again, /// and it is a no-op for a match that was never a daily attempt. /// /// Only an unscored attempt is released: a late duplicate `error` event /// after a match that already scored must not take the score away. pub async fn release_daily_attempt(&self, match_id: &str) -> Result<bool, DbError> { let match_id = match_id.to_owned(); self.call(move |conn| { let changed = conn.execute( "DELETE FROM daily_attempt WHERE match_id = ?1 AND score IS NULL", [match_id], )?; Ok(changed > 0) }) .await }
pub async fn set_match_task(&self, id: &str, task_arn: &str) -> Result<(), DbError> { let (id, task_arn) = (id.to_owned(), task_arn.to_owned()); self.call(move |conn| { conn.execute( "UPDATE matches SET task_arn = ?2, updated_at = unixepoch() WHERE id = ?1", (id, task_arn), ) .map(|_| ()) }) .await }
pub async fn set_match_ip(&self, id: &str, task_ip: &str) -> Result<(), DbError> { let (id, task_ip) = (id.to_owned(), task_ip.to_owned()); self.call(move |conn| { conn.execute( "UPDATE matches SET task_ip = ?2, updated_at = unixepoch() WHERE id = ?1", (id, task_ip), ) .map(|_| ()) }) .await }
/// The one write a `lobby`-status row takes while it is still a lobby: /// swapping which scenario it is parked on. `matches::lobby::Lobbies` /// calls this only after checking the path against the catalog itself. pub async fn set_match_scenario(&self, id: &str, scenario: &str) -> Result<(), DbError> { let (id, scenario) = (id.to_owned(), scenario.to_owned()); self.call(move |conn| { conn.execute( "UPDATE matches SET scenario = ?2, updated_at = unixepoch() WHERE id = ?1", (id, scenario), ) .map(|_| ()) }) .await }
/// The atomic guard against two callers deploying the same lobby at /// once: flips the row from `lobby` to `deploying` only if it is still /// `lobby` when this runs. `routes.rs`'s `deploy_lobby` calls this /// before ever calling `Matches::start`, so a second caller — a second /// click, a second browser — loses here rather than after a second /// match has already been minted. Same shape as `claim_match_player`'s /// `WHERE NOT EXISTS`: one write decides the race, with no gap between /// "is this still a lobby" and "claim it" for a second caller to land /// in. /// /// Returns whether this call's write is the one that landed. pub async fn claim_deploy(&self, id: &str) -> Result<bool, DbError> { let id = id.to_owned(); self.call(move |conn| { let changed = conn.execute( "UPDATE matches SET status = 'deploying', updated_at = unixepoch() WHERE id = ?1 AND status = 'lobby'", [id], )?; Ok(changed == 1) }) .await }
/// Removes a lobby's own row and its seats, once it has resolved into a /// real match and there is nothing left to keep it around for. There is /// no foreign key or `ON DELETE CASCADE` between `matches` and /// `match_players` (see the `CREATE TABLE` statements above), so both /// are deleted here explicitly, in the same call. pub async fn delete_lobby(&self, id: &str) -> Result<(), DbError> { let id = id.to_owned(); self.call(move |conn| { conn.execute("DELETE FROM match_players WHERE match_id = ?1", [&id])?; conn.execute("DELETE FROM matches WHERE id = ?1", [&id])?; Ok(()) }) .await }
/// Matches this DID is in — as the launcher, or holding a seat someone /// else's launch gave them. The seat is what puts an invited player's /// match in their own list, which is how they find it to join it. /// /// `lobby`-status rows are excluded: they are not a match yet, and this /// is the query "Your matches" reads. pub async fn list_matches( &self, owner_did: &str, limit: u32, ) -> Result<Vec<MatchRow>, DbError> { let owner_did = owner_did.to_owned(); self.call(move |conn| { let mut statement = conn.prepare( "SELECT id, owner_did, status, token, task_arn, task_ip, created_at, scenario, started_at, ended_at FROM matches WHERE status != 'lobby' AND (owner_did = ?1 OR id IN (SELECT match_id FROM match_players WHERE did = ?1)) ORDER BY created_at DESC LIMIT ?2", )?; let rows = statement.query_map((owner_did, limit), row_to_match)?; rows.collect() }) .await }
/// How many matches this account has been in, by our own record of them. /// /// The same predicate `list_matches` uses, counted rather than listed: /// the figure a player's card carries does not need the rows. Lobbies are /// excluded there and here — a lobby somebody opened and left is not a /// match anybody played. pub async fn match_count(&self, did: &str) -> Result<i64, DbError> { let did = did.to_owned(); self.call(move |conn| { conn.query_row( "SELECT COUNT(*) FROM matches WHERE status != 'lobby' AND (owner_did = ?1 OR id IN (SELECT match_id FROM match_players WHERE did = ?1))", [did], |row| row.get(0), ) }) .await }
pub async fn delete_oauth_session(&self, did: &str) -> Result<(), DbError> { let did = did.to_owned(); self.call(move |conn| { conn.execute("DELETE FROM oauth_session WHERE did = ?1", [did]) .map(|_| ()) }) .await }}
#[cfg(test)]mod tests { use super::*;
async fn temp_db() -> (tempfile::TempDir, Db) { let dir = tempfile::tempdir().unwrap(); let db = Db::open(&dir.path().join("test.sqlite")).unwrap(); (dir, db) }
async fn checked_at(db: &Db, did: &str) -> Option<i64> { let did = did.to_owned(); db.call(move |conn| { conn.query_row( "SELECT checked_at FROM account WHERE did = ?1", [did], |r| r.get(0), ) }) .await .unwrap() }
#[tokio::test] async fn account_round_trip() { let (_dir, db) = temp_db().await;
assert_eq!(db.account_handle("did:plc:abc").await.unwrap(), None);
db.record_handle_check("did:plc:abc", Some("someone.example")) .await .unwrap(); assert_eq!( db.account_handle("did:plc:abc").await.unwrap(), Some(Some("someone.example".to_owned())) );
// A later check that fails verification demotes the handle. db.record_handle_check("did:plc:abc", None).await.unwrap(); assert_eq!(db.account_handle("did:plc:abc").await.unwrap(), Some(None)); }
async fn seed_seats(db: &Db, match_id: &str, dids: &[&str]) { db.insert_match(&MatchRow { id: match_id.into(), owner_did: dids[0].into(), status: "over".into(), token: format!("tok-{match_id}"), task_arn: None, task_ip: None, created_at: 0, scenario: None, started_at: None, ended_at: None, }) .await .unwrap(); let players = dids .iter() .enumerate() .map(|(i, did)| MatchPlayer { slot: format!("Slot{i}"), control: "human".into(), did: Some((*did).to_owned()), handle: None, team: None, }) .collect::<Vec<_>>(); db.insert_match_players(match_id, &players).await.unwrap(); }
/// A `lobby`-status row is not a match yet, so "Your matches" must not /// list it — even when the caller owns it. #[tokio::test] async fn list_matches_excludes_lobby_status() { let (_dir, db) = temp_db().await; seed_seats(&db, "m1", &["did:plc:abc"]).await; // status "over" db.insert_match(&MatchRow { id: "m2".into(), owner_did: "did:plc:abc".into(), status: "lobby".into(), token: "tok-m2".into(), task_arn: None, task_ip: None, created_at: 0, scenario: Some("TrainingScenarios/1-FirstRun.mms".into()), started_at: None, ended_at: None, }) .await .unwrap();
let listed = db.list_matches("did:plc:abc", 20).await.unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].id, "m1"); }
#[tokio::test] async fn set_match_scenario_updates_the_row() { let (_dir, db) = temp_db().await; db.insert_match(&MatchRow { id: "m1".into(), owner_did: "did:plc:abc".into(), status: "lobby".into(), token: "tok".into(), task_arn: None, task_ip: None, created_at: 0, scenario: Some("TrainingScenarios/1-FirstRun.mms".into()), started_at: None, ended_at: None, }) .await .unwrap();
db.set_match_scenario("m1", "TrainingScenarios/2-FinalExam.mms") .await .unwrap();
let row = db.get_match("m1").await.unwrap().unwrap(); assert_eq!( row.scenario.as_deref(), Some("TrainingScenarios/2-FinalExam.mms") ); }
fn seat(slot: &str, control: &str, did: Option<&str>) -> MatchPlayer { MatchPlayer { slot: slot.into(), control: control.into(), did: did.map(str::to_owned), handle: None, team: None, } }
async fn lobby_row(db: &Db, id: &str) { db.insert_match(&MatchRow { id: id.into(), owner_did: "did:plc:owner".into(), status: "lobby".into(), token: format!("tok-{id}"), task_arn: None, task_ip: None, created_at: 0, scenario: Some("TrainingScenarios/1-FirstRun.mms".into()), started_at: None, ended_at: None, }) .await .unwrap(); }
#[tokio::test] async fn claim_match_player_takes_an_empty_slot() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await;
let claimed = db .claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:abc"))) .await .unwrap();
assert!(claimed); let seats = db.match_players("m1").await.unwrap(); assert_eq!(seats.len(), 1); assert_eq!(seats[0].slot, "TraineeA"); assert_eq!(seats[0].did.as_deref(), Some("did:plc:abc")); }
#[tokio::test] async fn claim_match_player_refuses_an_occupied_slot() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; db.claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:abc"))) .await .unwrap();
// A different DID trying the same slot loses, even though the slot // has no relation to theirs. let claimed = db .claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:rival"))) .await .unwrap();
assert!(!claimed); let seats = db.match_players("m1").await.unwrap(); assert_eq!(seats.len(), 1); assert_eq!(seats[0].did.as_deref(), Some("did:plc:abc")); }
#[tokio::test] async fn claim_match_player_refuses_a_second_slot_for_the_same_did() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; db.claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:abc"))) .await .unwrap();
let claimed = db .claim_match_player("m1", &seat("TraineeB", "human", Some("did:plc:abc"))) .await .unwrap();
assert!(!claimed, "one DID must not hold two seats in one lobby"); assert_eq!(db.match_players("m1").await.unwrap().len(), 1); }
/// Bots have no DID, so the "one seat per DID" guard must not catch /// them — several bot seats in one lobby is ordinary, not a race. #[tokio::test] async fn claim_match_player_allows_several_bot_seats() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await;
assert!( db.claim_match_player("m1", &seat("TraineeA", "bot", None)) .await .unwrap() ); assert!( db.claim_match_player("m1", &seat("TraineeB", "bot", None)) .await .unwrap() ); assert_eq!(db.match_players("m1").await.unwrap().len(), 2); }
/// The concurrency claim the module doc makes: two callers racing for /// the same slot cannot both win. Spawned as real concurrent tokio /// tasks (not sequential awaits) so this exercises the actual race — /// both tasks reach `claim_match_player` before either's write lands — /// rather than just asserting the second of two sequential calls loses, /// which a check-then-write implementation could also pass by accident. /// /// Repeated: this test's own multi-threaded runtime tends to run the /// blocking `spawn_blocking` work in submission order, so a single /// iteration would not distinguish "atomic" from "lucky ordering". Every /// iteration still must land on exactly one winner and one row — the /// invariant this test exists to prove, whichever task happens to win. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn claim_match_player_is_first_write_wins_under_real_concurrency() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await;
for _ in 0..25 { db.call(|conn| conn.execute("DELETE FROM match_players", [])) .await .unwrap(); let db_a = db.clone(); let db_b = db.clone(); let a = tokio::spawn(async move { db_a.claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:a"))) .await .unwrap() }); let b = tokio::spawn(async move { db_b.claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:b"))) .await .unwrap() }); let (a_won, b_won) = tokio::join!(a, b); let (a_won, b_won) = (a_won.unwrap(), b_won.unwrap()); assert_ne!(a_won, b_won, "exactly one of two racing claims must win"); assert_eq!(db.match_players("m1").await.unwrap().len(), 1); } }
#[tokio::test] async fn delete_match_player_removes_the_seat() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; db.claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:abc"))) .await .unwrap();
db.delete_match_player("m1", "TraineeA").await.unwrap();
assert_eq!(db.match_players("m1").await.unwrap(), Vec::new()); }
#[tokio::test] async fn claim_deploy_flips_a_lobby_to_deploying() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await;
assert!(db.claim_deploy("m1").await.unwrap());
let row = db.get_match("m1").await.unwrap().unwrap(); assert_eq!(row.status, "deploying"); }
/// A row already past `lobby` — mid-deploy, or any other status — is not /// a second chance to claim it. Only the caller that finds it still at /// `lobby` wins. #[tokio::test] async fn claim_deploy_refuses_a_row_that_is_not_a_lobby() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; assert!(db.claim_deploy("m1").await.unwrap());
assert!(!db.claim_deploy("m1").await.unwrap()); let row = db.get_match("m1").await.unwrap().unwrap(); assert_eq!( row.status, "deploying", "still stuck where the first call left it" ); }
/// The concurrency claim the doc comment makes: two callers racing to /// deploy the same lobby cannot both win. Real concurrent tokio tasks, /// not sequential awaits — the same reasoning as /// `claim_match_player_is_first_write_wins_under_real_concurrency` /// above: a check-then-write implementation could also pass a single /// sequential test by accident. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn claim_deploy_is_first_write_wins_under_real_concurrency() { let (_dir, db) = temp_db().await;
for i in 0..25 { let id = format!("m{i}"); lobby_row(&db, &id).await; let (db_a, db_b) = (db.clone(), db.clone()); let (id_a, id_b) = (id.clone(), id.clone()); let a = tokio::spawn(async move { db_a.claim_deploy(&id_a).await.unwrap() }); let b = tokio::spawn(async move { db_b.claim_deploy(&id_b).await.unwrap() }); let (a_won, b_won) = tokio::join!(a, b); let (a_won, b_won) = (a_won.unwrap(), b_won.unwrap()); assert_ne!(a_won, b_won, "exactly one of two racing deploys must win"); assert_eq!( db.get_match(&id).await.unwrap().unwrap().status, "deploying" ); } }
#[tokio::test] async fn delete_lobby_removes_the_row_and_its_seats() { let (_dir, db) = temp_db().await; lobby_row(&db, "m1").await; db.claim_match_player("m1", &seat("TraineeA", "human", Some("did:plc:abc"))) .await .unwrap();
db.delete_lobby("m1").await.unwrap();
assert!(db.get_match("m1").await.unwrap().is_none()); assert_eq!(db.match_players("m1").await.unwrap(), Vec::new()); }
#[tokio::test] async fn all_opponent_dids_is_unordered_and_uncapped() { let (_dir, db) = temp_db().await; seed_seats(&db, "m1", &["did:plc:abc", "did:plc:rival"]).await; seed_seats(&db, "m2", &["did:plc:abc", "did:plc:other"]).await;
let opponents = db.all_opponent_dids("did:plc:abc").await.unwrap(); assert_eq!( opponents, HashSet::from(["did:plc:rival".to_owned(), "did:plc:other".to_owned()]) ); // Self is never its own opponent. assert!(!opponents.contains("did:plc:abc")); }
#[tokio::test] async fn played_dids_answers_only_for_the_candidates_given() { let (_dir, db) = temp_db().await; seed_seats(&db, "m1", &["did:plc:abc", "did:plc:rival"]).await;
assert_eq!( db.played_dids(&["did:plc:rival".to_owned(), "did:plc:nobody".to_owned()]) .await .unwrap(), HashSet::from(["did:plc:rival".to_owned()]) ); assert_eq!(db.played_dids(&[]).await.unwrap(), HashSet::new()); }
#[tokio::test] async fn a_check_records_when_it_happened_either_way() { let (_dir, db) = temp_db().await;
db.record_handle_check("did:plc:abc", Some("someone.example")) .await .unwrap(); assert!(checked_at(&db, "did:plc:abc").await.is_some());
// The failing case is the one that matters: without a clock of its // own an unverified account could never be scheduled for another // look, so it could never be promoted back. db.record_handle_check("did:plc:abc", None).await.unwrap(); assert!(checked_at(&db, "did:plc:abc").await.is_some()); }
/// Backdates the last check to the given epoch second. async fn backdate_check(db: &Db, did: &str, to: i64) { let did = did.to_owned(); db.call(move |conn| { conn.execute( "UPDATE account SET checked_at = ?2 WHERE did = ?1", (did, to), ) .map(|_| ()) }) .await .unwrap(); }
#[tokio::test] async fn only_one_claim_per_interval() { let (_dir, db) = temp_db().await;
// No account row at all: nothing to claim. assert!(!db.claim_handle_check("did:plc:abc", 2).await.unwrap());
db.record_handle_check("did:plc:abc", Some("someone.example")) .await .unwrap(); assert!( !db.claim_handle_check("did:plc:abc", 2).await.unwrap(), "just checked, so not stale" );
// Stale: the first caller takes it, and the bump it writes is what // shuts the second one out. backdate_check(&db, "did:plc:abc", 1).await; assert!(db.claim_handle_check("did:plc:abc", 2).await.unwrap()); assert!(!db.claim_handle_check("did:plc:abc", 2).await.unwrap()); }
/// The account row is written by the handle check, and a player can set a /// default before one has ever run for them. #[tokio::test] async fn a_default_camo_can_be_set_before_the_account_row_exists() { let (_dir, db) = temp_db().await; assert_eq!(db.default_camo("did:plc:abc").await.unwrap(), None);
db.set_default_camo("did:plc:abc", Some("3mshoxhvtgt2f")) .await .unwrap(); assert_eq!( db.default_camo("did:plc:abc").await.unwrap(), Some("3mshoxhvtgt2f".to_owned()) );
// And a later handle check must not wipe it. db.record_handle_check("did:plc:abc", Some("someone.example")) .await .unwrap(); assert_eq!( db.default_camo("did:plc:abc").await.unwrap(), Some("3mshoxhvtgt2f".to_owned()) );
db.set_default_camo("did:plc:abc", None).await.unwrap(); assert_eq!(db.default_camo("did:plc:abc").await.unwrap(), None); assert_eq!( db.account_handle("did:plc:abc").await.unwrap(), Some(Some("someone.example".to_owned())), "clearing the default leaves the rest of the row alone" ); }
#[tokio::test] async fn a_database_without_checked_at_gains_it() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.sqlite"); { let conn = Connection::open(&path).unwrap(); conn.execute_batch( "CREATE TABLE account ( did TEXT PRIMARY KEY, handle TEXT, verified_at INTEGER); INSERT INTO account (did, handle, verified_at) VALUES ('did:plc:abc', 'someone.example', 1);", ) .unwrap(); }
let db = Db::open(&path).unwrap(); assert_eq!( db.account_handle("did:plc:abc").await.unwrap(), Some(Some("someone.example".to_owned())), "the existing row survives the migration" ); assert_eq!( db.default_camo("did:plc:abc").await.unwrap(), None, "a row that predates the column has no default, not an error" ); // Never looked at under the new rules, so due for a check now. assert_eq!(checked_at(&db, "did:plc:abc").await, None); assert!(db.claim_handle_check("did:plc:abc", 0).await.unwrap());
// Opening again must not try to add the column a second time. drop(db); assert!(Db::open(&path).is_ok()); }
#[tokio::test] async fn oauth_session_presence() { let (_dir, db) = temp_db().await;
assert!(!db.has_oauth_session("did:plc:abc").await.unwrap()); db.call(|conn| { conn.execute( "INSERT INTO oauth_session (did, value) VALUES ('did:plc:abc', '{}')", [], ) .map(|_| ()) }) .await .unwrap(); assert!(db.has_oauth_session("did:plc:abc").await.unwrap());
db.delete_oauth_session("did:plc:abc").await.unwrap(); assert!(!db.has_oauth_session("did:plc:abc").await.unwrap()); }
#[tokio::test] async fn file_is_private_and_survives_reopen() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.sqlite");
let db = Db::open(&path).unwrap(); db.record_handle_check("did:plc:abc", Some("someone.example")) .await .unwrap(); drop(db);
let mode = std::os::unix::fs::PermissionsExt::mode( &std::fs::metadata(&path).unwrap().permissions(), ); assert_eq!(mode & 0o777, 0o600);
let db = Db::open(&path).unwrap(); assert_eq!( db.account_handle("did:plc:abc").await.unwrap(), Some(Some("someone.example".to_owned())) ); }
// --- the daily challenge's one attempt -------------------------------- // // The rule the whole feature rests on: one attempt per player per // challenge, and an attempt only spent on a match that produced a result.
#[tokio::test] async fn a_player_gets_one_attempt_at_a_challenge() { let (_dir, db) = temp_db().await; assert!( db.take_daily_attempt("parity", "did:plc:a", "m1", "2026-08-15") .await .unwrap() ); assert!( !db.take_daily_attempt("parity", "did:plc:a", "m2", "2026-08-15") .await .unwrap(), "a second attempt at the same challenge was allowed" ); assert_eq!(db.daily_attempt_count("parity").await.unwrap(), 1); }
#[tokio::test] async fn the_limit_is_per_challenge_and_per_player() { let (_dir, db) = temp_db().await; assert!( db.take_daily_attempt("parity", "did:plc:a", "m1", "2026-08-15") .await .unwrap() ); // Another player, same challenge. assert!( db.take_daily_attempt("parity", "did:plc:b", "m2", "2026-08-15") .await .unwrap() ); // Same player, another challenge. assert!( db.take_daily_attempt("sinkhole", "did:plc:a", "m3", "2026-08-16") .await .unwrap() ); assert_eq!(db.daily_attempt_count("parity").await.unwrap(), 2); assert_eq!(db.daily_attempt_count("sinkhole").await.unwrap(), 1); }
#[tokio::test] async fn a_match_that_produced_no_result_gives_the_attempt_back() { let (_dir, db) = temp_db().await; db.take_daily_attempt("parity", "did:plc:a", "m1", "2026-08-15") .await .unwrap(); assert!(db.release_daily_attempt("m1").await.unwrap()); assert!( db.daily_attempt("parity", "did:plc:a") .await .unwrap() .is_none(), "the attempt was not released" ); // And the player may take it on again. assert!( db.take_daily_attempt("parity", "did:plc:a", "m2", "2026-08-15") .await .unwrap() ); }
#[tokio::test] async fn a_scored_attempt_is_never_released() { // A duplicate `error` event arriving after a match scored must not // take the score away and hand back an attempt that was played. let (_dir, db) = temp_db().await; db.take_daily_attempt("parity", "did:plc:a", "m1", "2026-08-15") .await .unwrap(); db.score_daily_attempt("m1", 750_000, (6633, 4000), (6633, 900)) .await .unwrap(); assert!(!db.release_daily_attempt("m1").await.unwrap()); let attempt = db.daily_attempt("parity", "did:plc:a").await.unwrap(); assert_eq!(attempt.map(|a| a.score), Some(Some(750_000))); }
#[tokio::test] async fn an_attempt_is_bound_to_the_match_it_was_spent_on() { // The claim happens before the launch, against a placeholder, so the // result has to be able to find the attempt afterwards. let (_dir, db) = temp_db().await; db.take_daily_attempt( "parity", "did:plc:a", "pending:parity:did:plc:a", "2026-08-15", ) .await .unwrap(); db.bind_daily_attempt("parity", "did:plc:a", "match-7") .await .unwrap(); let found = db.daily_attempt_for_match("match-7").await.unwrap(); assert_eq!(found.map(|a| a.slug), Some("parity".to_owned())); }
#[tokio::test] async fn releasing_a_match_that_was_never_an_attempt_does_nothing() { let (_dir, db) = temp_db().await; assert!(!db.release_daily_attempt("some-other-match").await.unwrap()); }}