From 4ed3fe276b96ee538f55eed69e358bc600170c26 Mon Sep 17 00:00:00 2001
From: prescientmoon <git@moonythm.dev>
Date: Tue, 20 Aug 2024 19:24:32 +0200
Subject: [PATCH] Generalize code to more scoring systems

---
 schema.sql                |  15 +-
 src/arcaea/achievement.rs |  33 ++--
 src/arcaea/mod.rs         |   1 +
 src/arcaea/play.rs        | 366 +++++++++++++++++++++++---------------
 src/arcaea/rating.rs      |  23 +++
 src/arcaea/score.rs       |  50 ++++--
 src/commands/chart.rs     |  57 +++---
 src/commands/score.rs     |  36 ++--
 src/commands/stats.rs     |  12 +-
 src/main.rs               |   8 +
 10 files changed, 375 insertions(+), 226 deletions(-)
 create mode 100644 src/arcaea/rating.rs

diff --git a/schema.sql b/schema.sql
index c4fc7d3..3b3d512 100644
--- a/schema.sql
+++ b/schema.sql
@@ -1,10 +1,10 @@
 # {{{ users
-# }}}
 create table IF NOT EXISTS users (
     id INTEGER NOT NULL PRIMARY KEY,
     discord_id TEXT UNIQUE NOT NULL,
     is_pookie BOOL NOT NULL DEFAULT 0
 );
+# }}}
 # {{{ songs
 CREATE TABLE IF NOT EXISTS songs (
     id INTEGER NOT NULL PRIMARY KEY,
@@ -55,5 +55,18 @@ CREATE TABLE IF NOT EXISTS plays (
     FOREIGN KEY (user_id) REFERENCES users(id)
 );
 # }}}
+# {{{ scores
+CREATE TABLE IF NOT EXISTS scores (
+   id INTEGER NOT NULL PRIMARY KEY,
+   play_id INTEGER NOT NULL,
+
+   score INTEGER NOT NULL,
+   creation_ptt INTEGER,
+   scoring_system NOT NULL CHECK (scoring_system IN ('standard', 'ex')),
+
+   FOREIGN KEY (play_id) REFERENCES plays(id),
+   UNIQUE(play_id, scoring_system)
+)
+# }}}
 
 insert into users(discord_id) values (385759924917108740);
diff --git a/src/arcaea/achievement.rs b/src/arcaea/achievement.rs
index 0680097..21bc90e 100644
--- a/src/arcaea/achievement.rs
+++ b/src/arcaea/achievement.rs
@@ -125,10 +125,11 @@ impl GoalStats {
 		let plays = get_best_plays(
 			&ctx.db,
 			&ctx.song_cache,
-			user,
+			user.id,
 			scoring_system,
 			0,
 			usize::MAX,
+			None,
 		)
 		.await??;
 
@@ -150,22 +151,20 @@ impl GoalStats {
 		.count as usize;
 		// }}}
 		// {{{ Peak ptt
-		let peak_ptt = {
-			let record = query!(
-				"
-        SELECT 
-          max(creation_ptt) as standard,
-          max(creation_zeta_ptt) as ex 
-        FROM plays
-      "
-			)
-			.fetch_one(&ctx.db)
-			.await?;
-			match scoring_system {
-				ScoringSystem::Standard => record.standard,
-				ScoringSystem::EX => record.ex,
-			}
-		}
+		let peak_ptt = query!(
+			"
+        SELECT s.creation_ptt
+        FROM plays p
+        JOIN scores s ON s.play_id = p.id
+        WHERE user_id = ?
+        AND scoring_system = ?
+      ",
+			user.id,
+			ScoringSystem::SCORING_SYSTEM_DB_STRINGS[scoring_system.to_index()]
+		)
+		.fetch_one(&ctx.db)
+		.await?
+		.creation_ptt
 		.ok_or_else(|| "No ptt history data found")? as u32;
 		// }}}
 		// {{{ Peak PM relay
diff --git a/src/arcaea/mod.rs b/src/arcaea/mod.rs
index 50dea45..f6f249f 100644
--- a/src/arcaea/mod.rs
+++ b/src/arcaea/mod.rs
@@ -2,4 +2,5 @@ pub mod achievement;
 pub mod chart;
 pub mod jacket;
 pub mod play;
+pub mod rating;
 pub mod score;
diff --git a/src/arcaea/play.rs b/src/arcaea/play.rs
index 4421d89..9e59236 100644
--- a/src/arcaea/play.rs
+++ b/src/arcaea/play.rs
@@ -1,41 +1,42 @@
-use std::str::FromStr;
+use std::array;
 
+use chrono::NaiveDateTime;
+use chrono::Utc;
 use num::traits::Euclid;
+use num::CheckedDiv;
+use num::Rational32;
+use num::Zero;
 use poise::serenity_prelude::{
 	Attachment, AttachmentId, CreateAttachment, CreateEmbed, CreateEmbedAuthor, Timestamp,
 };
-use sqlx::{query, query_as, SqlitePool};
+use sqlx::query_as;
+use sqlx::{query, SqlitePool};
 
 use crate::arcaea::chart::{Chart, Song};
 use crate::context::{Error, UserContext};
 use crate::user::User;
 
 use super::chart::SongCache;
+use super::rating::{rating_as_fixed, rating_as_float};
 use super::score::{Score, ScoringSystem};
 
 // {{{ Create play
 #[derive(Debug, Clone)]
 pub struct CreatePlay {
-	chart_id: u32,
 	discord_attachment_id: Option<AttachmentId>,
 
-	// Actual score data
+	// Scoring details
 	score: Score,
-	zeta_score: Score,
-
-	// Optional score details
 	max_recall: Option<u32>,
 	far_notes: Option<u32>,
 }
 
 impl CreatePlay {
 	#[inline]
-	pub fn new(score: Score, chart: &Chart) -> Self {
+	pub fn new(score: Score) -> Self {
 		Self {
-			chart_id: chart.id,
 			discord_attachment_id: None,
 			score,
-			zeta_score: score.to_zeta(chart.note_count as u32),
 			max_recall: None,
 			far_notes: None,
 		}
@@ -60,24 +61,22 @@ impl CreatePlay {
 	}
 
 	// {{{ Save
-	pub async fn save(self, ctx: &UserContext, user: &User) -> Result<Play, Error> {
+	pub async fn save(self, ctx: &UserContext, user: &User, chart: &Chart) -> Result<Play, Error> {
 		let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64);
 
 		// {{{ Save current data to play
 		let play = sqlx::query!(
 			"
         INSERT INTO plays(
-        user_id,chart_id,discord_attachment_id,
-        score,zeta_score,max_recall,far_notes
+            user_id,chart_id,discord_attachment_id,
+            max_recall,far_notes
         )
-        VALUES(?,?,?,?,?,?,?)
+        VALUES(?,?,?,?,?)
         RETURNING id, created_at
       ",
 			user.id,
-			self.chart_id,
+			chart.id,
 			attachment_id,
-			self.score.0,
-			self.zeta_score.0,
 			self.max_recall,
 			self.far_notes
 		)
@@ -85,93 +84,88 @@ impl CreatePlay {
 		.await?;
 		// }}}
 		// {{{ Update creation ptt data
-		let creation_ptt = get_best_plays(
-			&ctx.db,
-			&ctx.song_cache,
-			user,
-			ScoringSystem::Standard,
-			30,
-			30,
-		)
-		.await?
-		.ok()
-		.map(|plays| compute_b30_ptt(ScoringSystem::Standard, &plays));
-
-		let creation_zeta_ptt =
-			get_best_plays(&ctx.db, &ctx.song_cache, user, ScoringSystem::EX, 30, 30)
+		let scores = ScoreCollection::from_standard_score(self.score, chart);
+		for system in ScoringSystem::SCORING_SYSTEMS {
+			let i = system.to_index();
+			let plays = get_best_plays(&ctx.db, &ctx.song_cache, user.id, system, 30, 30, None)
 				.await?
-				.ok()
-				.map(|plays| compute_b30_ptt(ScoringSystem::EX, &plays));
+				.ok();
+
+			let creation_ptt: Option<_> = try { rating_as_fixed(compute_b30_ptt(system, &plays?)) };
+
+			query!(
+				"
+          INSERT INTO scores(play_id, score, creation_ptt, scoring_system)
+          VALUES (?,?,?,?)
+        ",
+				play.id,
+				scores.0[i].0,
+				creation_ptt,
+				ScoringSystem::SCORING_SYSTEM_DB_STRINGS[i]
+			)
+			.execute(&ctx.db)
+			.await?;
+		}
 
-		query!(
-			"
-        UPDATE plays 
-        SET 
-          creation_ptt=?,
-          creation_zeta_ptt=?
-        WHERE 
-          id=?
-      ",
-			creation_ptt,
-			creation_zeta_ptt,
-			play.id
-		)
-		.execute(&ctx.db)
-		.await?;
 		// }}}
 
 		Ok(Play {
 			id: play.id as u32,
 			created_at: play.created_at,
-			chart_id: self.chart_id,
+			chart_id: chart.id,
 			user_id: user.id,
-			discord_attachment_id: self.discord_attachment_id,
-			score: self.score,
-			zeta_score: self.zeta_score,
+			scores,
 			max_recall: self.max_recall,
 			far_notes: self.far_notes,
-			creation_ptt,
-			creation_zeta_ptt,
 		})
 	}
 	// }}}
 }
 // }}}
 // {{{ DbPlay
-/// Version of `Play` matching the format sqlx expects
-#[derive(Debug, Clone, sqlx::FromRow)]
+/// Construct a `Play` from a sqlite return record.
+#[macro_export]
+macro_rules! play_from_db_record {
+	($chart:expr, $record:expr) => {{
+		use crate::arcaea::play::{Play, ScoreCollection};
+		use crate::arcaea::score::Score;
+		Play {
+			id: $record.id as u32,
+			chart_id: $record.chart_id as u32,
+			user_id: $record.user_id as u32,
+			scores: ScoreCollection::from_standard_score(Score($record.score as u32), $chart),
+			max_recall: $record.max_recall.map(|r| r as u32),
+			far_notes: $record.far_notes.map(|r| r as u32),
+			created_at: $record.created_at,
+		}
+	}};
+}
+
+/// Typed version of the input to the macro above.
+/// Useful when using the non-macro version of the sqlx functions.
+#[derive(Debug, sqlx::FromRow)]
 pub struct DbPlay {
 	pub id: i64,
 	pub chart_id: i64,
 	pub user_id: i64,
-	pub discord_attachment_id: Option<String>,
-	pub score: i64,
-	pub zeta_score: i64,
+	pub created_at: chrono::NaiveDateTime,
+
+	// Score details
 	pub max_recall: Option<i64>,
 	pub far_notes: Option<i64>,
-	pub created_at: chrono::NaiveDateTime,
-	pub creation_ptt: Option<i64>,
-	pub creation_zeta_ptt: Option<i64>,
+	pub score: i64,
 }
 
-impl DbPlay {
-	#[inline]
-	pub fn into_play(self) -> Play {
-		Play {
-			id: self.id as u32,
-			chart_id: self.chart_id as u32,
-			user_id: self.user_id as u32,
-			score: Score(self.score as u32),
-			zeta_score: Score(self.zeta_score as u32),
-			max_recall: self.max_recall.map(|r| r as u32),
-			far_notes: self.far_notes.map(|r| r as u32),
-			created_at: self.created_at,
-			discord_attachment_id: self
-				.discord_attachment_id
-				.and_then(|s| AttachmentId::from_str(&s).ok()),
-			creation_ptt: self.creation_ptt.map(|r| r as i32),
-			creation_zeta_ptt: self.creation_zeta_ptt.map(|r| r as i32),
-		}
+// }}}
+// {{{ Score data
+#[derive(Debug, Clone, Copy)]
+pub struct ScoreCollection([Score; ScoringSystem::SCORING_SYSTEMS.len()]);
+
+impl ScoreCollection {
+	pub fn from_standard_score(score: Score, chart: &Chart) -> Self {
+		ScoreCollection(array::from_fn(|i| {
+			score.convert_to(ScoringSystem::SCORING_SYSTEMS[i], chart)
+		}))
 	}
 }
 // }}}
@@ -179,48 +173,38 @@ impl DbPlay {
 #[derive(Debug, Clone)]
 pub struct Play {
 	pub id: u32,
+	#[allow(unused)]
 	pub chart_id: u32,
 	pub user_id: u32,
-
-	#[allow(unused)]
-	pub discord_attachment_id: Option<AttachmentId>,
-
-	// Actual score data
-	pub score: Score,
-	pub zeta_score: Score,
-
-	// Optional score details
-	pub max_recall: Option<u32>,
-	pub far_notes: Option<u32>,
-
-	// Creation data
 	pub created_at: chrono::NaiveDateTime,
 
-	#[allow(dead_code)]
-	pub creation_ptt: Option<i32>,
-	#[allow(dead_code)]
-	pub creation_zeta_ptt: Option<i32>,
+	// Score details
+	pub max_recall: Option<u32>,
+	pub far_notes: Option<u32>,
+	pub scores: ScoreCollection,
 }
 
 impl Play {
 	// {{{ Query the underlying score
 	#[inline]
 	pub fn score(&self, system: ScoringSystem) -> Score {
-		match system {
-			ScoringSystem::Standard => self.score,
-			ScoringSystem::EX => self.zeta_score,
-		}
+		self.scores.0[system.to_index()]
 	}
 
 	#[inline]
-	pub fn play_rating(&self, system: ScoringSystem, chart_constant: u32) -> i32 {
+	pub fn play_rating(&self, system: ScoringSystem, chart_constant: u32) -> Rational32 {
 		self.score(system).play_rating(chart_constant)
 	}
+
+	#[inline]
+	pub fn play_rating_f32(&self, system: ScoringSystem, chart_constant: u32) -> f32 {
+		rating_as_float(self.score(system).play_rating(chart_constant))
+	}
 	// }}}
 	// {{{ Play => distribution
 	pub fn distribution(&self, note_count: u32) -> Option<(u32, u32, u32, u32)> {
 		if let Some(fars) = self.far_notes {
-			let (_, shinies, units) = self.score.analyse(note_count);
+			let (_, shinies, units) = self.score(ScoringSystem::Standard).analyse(note_count);
 			let (pures, rem) = units.checked_sub(fars)?.div_rem_euclid(&2);
 			if rem == 1 {
 				println!("The impossible happened: got an invalid amount of far notes!");
@@ -237,8 +221,8 @@ impl Play {
 	// }}}
 	// {{{ Play => status
 	#[inline]
-	pub fn status(&self, chart: &Chart) -> Option<String> {
-		let score = self.score.0;
+	pub fn status(&self, scoring_system: ScoringSystem, chart: &Chart) -> Option<String> {
+		let score = self.score(scoring_system).0;
 		if score >= 10_000_000 {
 			if score > chart.note_count + 10_000_000 {
 				return None;
@@ -266,8 +250,8 @@ impl Play {
 	}
 
 	#[inline]
-	pub fn short_status(&self, chart: &Chart) -> Option<char> {
-		let score = self.score.0;
+	pub fn short_status(&self, scoring_system: ScoringSystem, chart: &Chart) -> Option<char> {
+		let score = self.score(scoring_system).0;
 		if score >= 10_000_000 {
 			let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?;
 			if non_max_pures == 0 {
@@ -298,14 +282,18 @@ impl Play {
 		author: Option<&poise::serenity_prelude::User>,
 	) -> Result<(CreateEmbed, Option<CreateAttachment>), Error> {
 		// {{{ Get previously best score
-		let prev_play = query_as!(
-			DbPlay,
+		let prev_play = query!(
 			"
-        SELECT * FROM plays
-        WHERE user_id=?
-        AND chart_id=?
-        AND created_at<?
-        ORDER BY score DESC
+        SELECT 
+          p.id, p.chart_id, p.user_id, p.created_at,
+          p.max_recall, p.far_notes, s.score
+        FROM plays p
+        JOIN scores s ON s.play_id = p.id
+        WHERE s.scoring_system='standard'
+        AND p.user_id=?
+        AND p.chart_id=?
+        AND p.created_at<?
+        ORDER BY s.score DESC
         LIMIT 1
     ",
 			user.id,
@@ -320,13 +308,18 @@ impl Play {
 				song.title, chart.difficulty
 			)
 		})?
-		.map(|p| p.into_play());
+		.map(|p| play_from_db_record!(chart, p));
 
-		let prev_score = prev_play.as_ref().map(|p| p.score);
-		let prev_zeta_score = prev_play.as_ref().map(|p| p.zeta_score);
+		let prev_score = prev_play.as_ref().map(|p| p.score(ScoringSystem::Standard));
+		let prev_zeta_score = prev_play.as_ref().map(|p| p.score(ScoringSystem::EX));
 		// }}}
 
-		let attachement_name = format!("{:?}-{:?}-{:?}.png", song.id, self.score.0, index);
+		let attachement_name = format!(
+			"{:?}-{:?}-{:?}.png",
+			song.id,
+			self.score(ScoringSystem::Standard).0,
+			index
+		);
 		let icon_attachement = match chart.cached_jacket.as_ref() {
 			Some(jacket) => Some(CreateAttachment::bytes(jacket.raw, &attachement_name)),
 			None => None,
@@ -337,30 +330,46 @@ impl Play {
 				"{} [{:?} {}]",
 				&song.title, chart.difficulty, chart.level
 			))
-			.field("Score", self.score.display_with_diff(prev_score)?, true)
 			.field(
-				"Rating",
-				self.score.display_play_rating(prev_score, chart)?,
+				"Score",
+				self.score(ScoringSystem::Standard)
+					.display_with_diff(prev_score)?,
+				true,
+			)
+			.field(
+				"Rating",
+				self.score(ScoringSystem::Standard)
+					.display_play_rating(prev_score, chart)?,
+				true,
+			)
+			.field(
+				"Grade",
+				format!("{}", self.score(ScoringSystem::Standard).grade()),
 				true,
 			)
-			.field("Grade", format!("{}", self.score.grade()), true)
 			.field(
 				"ξ-Score",
-				self.zeta_score.display_with_diff(prev_zeta_score)?,
+				self.score(ScoringSystem::EX)
+					.display_with_diff(prev_zeta_score)?,
 				true,
 			)
 			// {{{ ξ-Rating
 			.field(
 				"ξ-Rating",
-				self.zeta_score
+				self.score(ScoringSystem::EX)
 					.display_play_rating(prev_zeta_score, chart)?,
 				true,
 			)
 			// }}}
-			.field("ξ-Grade", format!("{}", self.zeta_score.grade()), true)
+			.field(
+				"ξ-Grade",
+				format!("{}", self.score(ScoringSystem::EX).grade()),
+				true,
+			)
 			.field(
 				"Status",
-				self.status(chart).unwrap_or("-".to_string()),
+				self.status(ScoringSystem::Standard, chart)
+					.unwrap_or("-".to_string()),
 				true,
 			)
 			.field(
@@ -402,24 +411,33 @@ pub type PlayCollection<'a> = Vec<(Play, &'a Song, &'a Chart)>;
 pub async fn get_best_plays<'a>(
 	db: &SqlitePool,
 	song_cache: &'a SongCache,
-	user: &User,
+	user_id: u32,
 	scoring_system: ScoringSystem,
 	min_amount: usize,
 	max_amount: usize,
+	before: Option<NaiveDateTime>,
 ) -> Result<Result<PlayCollection<'a>, String>, Error> {
 	// {{{ DB data fetching
 	let plays: Vec<DbPlay> = query_as(
 		"
-        SELECT id, chart_id, user_id,
-        created_at, MAX(score) as score, zeta_score,
-        creation_ptt, creation_zeta_ptt, far_notes, max_recall, discord_attachment_id
-        FROM plays p
-        WHERE user_id = ?
-        GROUP BY chart_id
-        ORDER BY score DESC
+      SELECT 
+        p.id, p.chart_id, p.user_id, p.created_at,
+        p.max_recall, p.far_notes, s.score,
+        MAX(s.score) as _cscore 
+        -- ^ This is only here to make sqlite pick the correct row for the bare columns
+      FROM plays p
+      JOIN scores s ON s.play_id = p.id
+      JOIN scores cs ON cs.play_id = p.id
+      WHERE s.scoring_system='standard'
+      AND cs.scoring_system=?
+      AND p.user_id=?
+      AND p.created_at<=?
+      GROUP BY p.chart_id
     ",
 	)
-	.bind(user.id)
+	.bind(ScoringSystem::SCORING_SYSTEM_DB_STRINGS[scoring_system.to_index()])
+	.bind(user_id)
+	.bind(before.unwrap_or_else(|| Utc::now().naive_utc()))
 	.fetch_all(db)
 	.await?;
 	// }}}
@@ -437,8 +455,8 @@ pub async fn get_best_plays<'a>(
 	let mut plays: Vec<(Play, &Song, &Chart)> = plays
 		.into_iter()
 		.map(|play| {
-			let play = play.into_play();
-			let (song, chart) = song_cache.lookup_chart(play.chart_id)?;
+			let (song, chart) = song_cache.lookup_chart(play.chart_id as u32)?;
+			let play = play_from_db_record!(chart, play);
 			Ok((play, song, chart))
 		})
 		.collect::<Result<Vec<_>, Error>>()?;
@@ -451,12 +469,78 @@ pub async fn get_best_plays<'a>(
 }
 
 #[inline]
-pub fn compute_b30_ptt(scoring_system: ScoringSystem, plays: &PlayCollection<'_>) -> i32 {
+pub fn compute_b30_ptt(scoring_system: ScoringSystem, plays: &PlayCollection<'_>) -> Rational32 {
 	plays
 		.iter()
 		.map(|(play, _, chart)| play.play_rating(scoring_system, chart.chart_constant))
-		.sum::<i32>()
-		.checked_div(plays.len() as i32)
-		.unwrap_or(0)
+		.sum::<Rational32>()
+		.checked_div(&Rational32::from_integer(plays.len() as i32))
+		.unwrap_or(Rational32::zero())
+}
+// }}}
+// {{{ Maintenance functions
+pub async fn generate_missing_scores(ctx: &UserContext) -> Result<(), Error> {
+	let plays = query!(
+		"
+      SELECT 
+        p.id, p.chart_id, p.user_id, p.created_at,
+        p.max_recall, p.far_notes, s.score
+      FROM plays p
+      JOIN scores s ON s.play_id = p.id
+      WHERE s.scoring_system='standard'
+      ORDER BY p.created_at ASC
+    "
+	)
+	// Can't use the stream based version because of db locking...
+	.fetch_all(&ctx.db)
+	.await?;
+
+	let mut i = 0;
+
+	for play in plays {
+		let (_, chart) = ctx.song_cache.lookup_chart(play.chart_id as u32)?;
+		let play = play_from_db_record!(chart, play);
+
+		for system in ScoringSystem::SCORING_SYSTEMS {
+			let i = system.to_index();
+			let plays = get_best_plays(
+				&ctx.db,
+				&ctx.song_cache,
+				play.user_id,
+				system,
+				30,
+				30,
+				Some(play.created_at),
+			)
+			.await?
+			.ok();
+
+			let creation_ptt: Option<_> = try { rating_as_fixed(compute_b30_ptt(system, &plays?)) };
+			let raw_score = play.scores.0[i].0;
+
+			query!(
+				"
+	        INSERT INTO scores(play_id, score, creation_ptt, scoring_system)
+	        VALUES ($1, $2, $3, $4)
+          ON CONFLICT(play_id, scoring_system)
+            DO UPDATE SET
+              score=$2, creation_ptt=$3
+            WHERE play_id = $1
+            AND scoring_system = $4
+
+	      ",
+				play.id,
+				raw_score,
+				creation_ptt,
+				ScoringSystem::SCORING_SYSTEM_DB_STRINGS[i],
+			)
+			.execute(&ctx.db)
+			.await?;
+		}
+
+		i += 1;
+		println!("Processed {i} plays");
+	}
+	Ok(())
 }
 // }}}
diff --git a/src/arcaea/rating.rs b/src/arcaea/rating.rs
new file mode 100644
index 0000000..149d197
--- /dev/null
+++ b/src/arcaea/rating.rs
@@ -0,0 +1,23 @@
+use num::Rational32;
+
+pub type Rating = Rational32;
+
+/// Saves a rating rational as an integer where it's multiplied by 100.
+#[inline]
+pub fn rating_as_fixed(rating: Rating) -> i32 {
+	(rating * Rational32::from_integer(100))
+		.round()
+		.to_integer()
+}
+
+/// Saves a rating rational as a float with precision 2.
+#[inline]
+pub fn rating_as_float(rating: Rating) -> f32 {
+	rating_as_fixed(rating) as f32 / 100.0
+}
+
+/// The pseudo-inverse of `rating_as_fixed`.
+#[inline]
+pub fn rating_from_fixed(fixed: i32) -> Rating {
+	Rating::new(fixed, 100)
+}
diff --git a/src/arcaea/score.rs b/src/arcaea/score.rs
index 5e49143..ef9cd2d 100644
--- a/src/arcaea/score.rs
+++ b/src/arcaea/score.rs
@@ -1,10 +1,13 @@
 use std::fmt::{Display, Write};
 
-use num::Rational64;
+use num::{Rational32, Rational64};
 
 use crate::context::Error;
 
-use super::chart::Chart;
+use super::{
+	chart::Chart,
+	rating::{rating_as_float, rating_from_fixed, Rating},
+};
 
 // {{{ Scoring system
 #[derive(Debug, Clone, Copy, poise::ChoiceParameter)]
@@ -15,6 +18,18 @@ pub enum ScoringSystem {
 	EX,
 }
 
+impl ScoringSystem {
+	pub const SCORING_SYSTEMS: [Self; 2] = [Self::Standard, Self::EX];
+
+	/// Values used inside sqlite
+	pub const SCORING_SYSTEM_DB_STRINGS: [&'static str; 2] = ["standard", "ex"];
+
+	#[inline]
+	pub fn to_index(self) -> usize {
+		self as usize
+	}
+}
+
 impl Default for ScoringSystem {
 	fn default() -> Self {
 		Self::Standard
@@ -125,32 +140,39 @@ impl Score {
 		)
 	}
 	// }}}
+	// {{{ Scoring system conversion
+	/// Convert a standard score to any other scoring system. The output might be
+	/// nonsense if the given score is not using the standard system.
+	#[inline]
+	pub fn convert_to(self, scoring_system: ScoringSystem, chart: &Chart) -> Self {
+		match scoring_system {
+			ScoringSystem::Standard => self,
+			ScoringSystem::EX => self.to_zeta(chart.note_count),
+		}
+	}
+	// }}}
 	// {{{ Score => Play rating
 	#[inline]
-	pub fn play_rating(self, chart_constant: u32) -> i32 {
-		chart_constant as i32
+	pub fn play_rating(self, chart_constant: u32) -> Rating {
+		rating_from_fixed(chart_constant as i32)
 			+ if self.0 >= 10_000_000 {
-				200
+				Rational32::from_integer(2)
 			} else if self.0 >= 9_800_000 {
-				100 + (self.0 as i32 - 9_800_000) / 2_000
+				Rational32::from_integer(1)
+					+ Rational32::new(self.0 as i32 - 9_800_000, 200_000).reduced()
 			} else {
-				(self.0 as i32 - 9_500_000) / 3_000
+				Rational32::new(self.0 as i32 - 9_500_000, 300_000).reduced()
 			}
 	}
 
-	#[inline]
-	pub fn play_rating_f32(self, chart_constant: u32) -> f32 {
-		(self.play_rating(chart_constant)) as f32 / 100.0
-	}
-
 	pub fn display_play_rating(self, prev: Option<Self>, chart: &Chart) -> Result<String, Error> {
 		let mut buffer = String::with_capacity(14);
 
-		let play_rating = self.play_rating_f32(chart.chart_constant);
+		let play_rating = rating_as_float(self.play_rating(chart.chart_constant));
 		write!(buffer, "{:.2}", play_rating)?;
 
 		if let Some(prev) = prev {
-			let prev_play_rating = prev.play_rating_f32(chart.chart_constant);
+			let prev_play_rating = rating_as_float(prev.play_rating(chart.chart_constant));
 
 			if play_rating >= prev_play_rating {
 				write!(buffer, " (+{:.2})", play_rating - prev_play_rating)?;
diff --git a/src/commands/chart.rs b/src/commands/chart.rs
index 7375274..71a8a29 100644
--- a/src/commands/chart.rs
+++ b/src/commands/chart.rs
@@ -4,7 +4,7 @@ use sqlx::query;
 use crate::{
 	arcaea::chart::Side,
 	context::{Context, Error},
-	get_user,
+	get_user, play_from_db_record,
 	recognition::fuzzy_song_name::guess_song_and_chart,
 };
 use std::io::Cursor;
@@ -20,13 +20,9 @@ use plotters::{
 	style::{IntoFont, TextStyle, BLUE, WHITE},
 };
 use poise::CreateReply;
-use sqlx::query_as;
 
 use crate::{
-	arcaea::{
-		play::DbPlay,
-		score::{Score, ScoringSystem},
-	},
+	arcaea::score::{Score, ScoringSystem},
 	user::discord_it_to_discord_user,
 };
 
@@ -121,13 +117,18 @@ async fn best(
 	let user = get_user!(&ctx);
 
 	let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
-	let play = query_as!(
-		DbPlay,
+	let play = query!(
 		"
-        SELECT * FROM plays
-        WHERE user_id=?
-        AND chart_id=?
-        ORDER BY score DESC
+      SELECT 
+        p.id, p.chart_id, p.user_id, p.created_at,
+        p.max_recall, p.far_notes, s.score
+      FROM plays p
+      JOIN scores s ON s.play_id = p.id
+      WHERE s.scoring_system='standard'
+      AND p.user_id=?
+      AND p.chart_id=?
+      ORDER BY s.score DESC
+      LIMIT 1
     ",
 		user.id,
 		chart.id
@@ -139,8 +140,8 @@ async fn best(
 			"Could not find any scores for {} [{:?}]",
 			song.title, chart.difficulty
 		)
-	})?
-	.into_play();
+	})?;
+	let play = play_from_db_record!(chart, play);
 
 	let (embed, attachment) = play
 		.to_embed(
@@ -176,13 +177,17 @@ async fn plot(
 	let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
 
 	// SAFETY: we limit the amount of plotted plays to 1000.
-	let plays = query_as!(
-		DbPlay,
+	let plays = query!(
 		"
-      SELECT * FROM plays
-      WHERE user_id=?
-      AND chart_id=?
-      ORDER BY created_at ASC
+      SELECT 
+        p.id, p.chart_id, p.user_id, p.created_at,
+        p.max_recall, p.far_notes, s.score
+      FROM plays p
+      JOIN scores s ON s.play_id = p.id
+      WHERE s.scoring_system='standard'
+      AND p.user_id=?
+      AND p.chart_id=?
+      ORDER BY s.score DESC
       LIMIT 1000
     ",
 		user.id,
@@ -204,7 +209,7 @@ async fn plot(
 	let max_time = plays.iter().map(|p| p.created_at).max().unwrap();
 	let mut min_score = plays
 		.iter()
-		.map(|p| p.clone().into_play().score(scoring_system))
+		.map(|p| play_from_db_record!(chart, p).score(scoring_system))
 		.min()
 		.unwrap()
 		.0 as i64;
@@ -228,7 +233,7 @@ async fn plot(
 	{
 		let root = BitMapBackend::with_buffer(&mut buffer, (width, height)).into_drawing_area();
 
-		let mut chart = ChartBuilder::on(&root)
+		let mut chart_buider = ChartBuilder::on(&root)
 			.margin(25)
 			.caption(
 				format!("{} [{:?}]", song.title, chart.difficulty),
@@ -241,7 +246,7 @@ async fn plot(
 				min_score..max_score,
 			)?;
 
-		chart
+		chart_buider
 			.configure_mesh()
 			.light_line_style(WHITE)
 			.y_label_formatter(&|s| format!("{}", Score(*s as u32)))
@@ -261,7 +266,7 @@ async fn plot(
 			.map(|play| {
 				(
 					play.created_at.and_utc().timestamp_millis(),
-					play.into_play().score(scoring_system),
+					play_from_db_record!(chart, play).score(scoring_system),
 				)
 			})
 			.collect();
@@ -269,12 +274,12 @@ async fn plot(
 		points.sort();
 		points.dedup();
 
-		chart.draw_series(LineSeries::new(
+		chart_buider.draw_series(LineSeries::new(
 			points.iter().map(|(t, s)| (*t, s.0 as i64)),
 			&BLUE,
 		))?;
 
-		chart.draw_series(points.iter().map(|(t, s)| {
+		chart_buider.draw_series(points.iter().map(|(t, s)| {
 			Circle::new((*t, s.0 as i64), 3, plotters::style::Color::filled(&BLUE))
 		}))?;
 		root.present()?;
diff --git a/src/commands/score.rs b/src/commands/score.rs
index 62f5fb1..efa6b23 100644
--- a/src/commands/score.rs
+++ b/src/commands/score.rs
@@ -1,11 +1,11 @@
 use std::time::Instant;
 
-use crate::arcaea::play::{CreatePlay, Play};
+use crate::arcaea::play::CreatePlay;
 use crate::arcaea::score::Score;
 use crate::context::{Context, Error};
 use crate::recognition::recognize::{ImageAnalyzer, ScoreKind};
 use crate::user::{discord_it_to_discord_user, User};
-use crate::{edit_reply, get_user, timed};
+use crate::{edit_reply, get_user, play_from_db_record, timed};
 use image::DynamicImage;
 use poise::serenity_prelude::futures::future::join_all;
 use poise::serenity_prelude::CreateMessage;
@@ -117,11 +117,11 @@ pub async fn magic(
 			let maybe_fars =
 				Score::resolve_distibution_ambiguities(score, note_distribution, chart.note_count);
 
-			let play = CreatePlay::new(score, &chart)
+			let play = CreatePlay::new(score)
 				.with_attachment(file)
 				.with_fars(maybe_fars)
 				.with_max_recall(max_recall)
-				.save(&ctx.data(), &user)
+				.save(&ctx.data(), &user, &chart)
 				.await?;
 			// }}}
 			// }}}
@@ -220,12 +220,16 @@ pub async fn show(
 		let res = query!(
 			"
         SELECT 
-          p.id,p.chart_id,p.user_id,p.score,p.zeta_score,
-          p.max_recall,p.created_at,p.far_notes,
+          p.id, p.chart_id, p.user_id, p.created_at,
+          p.max_recall, p.far_notes, s.score,
           u.discord_id
-        FROM plays p 
+        FROM plays p
+        JOIN scores s ON s.play_id = p.id
         JOIN users u ON p.user_id = u.id
-        WHERE p.id=?
+        WHERE s.scoring_system='standard'
+        AND p.id=?
+        ORDER BY s.score DESC
+        LIMIT 1
       ",
 			id
 		)
@@ -233,24 +237,12 @@ pub async fn show(
 		.await
 		.map_err(|_| format!("Could not find play with id {}", id))?;
 
-		let play = Play {
-			id: res.id as u32,
-			chart_id: res.chart_id as u32,
-			user_id: res.user_id as u32,
-			score: Score(res.score as u32),
-			zeta_score: Score(res.zeta_score as u32),
-			max_recall: res.max_recall.map(|r| r as u32),
-			far_notes: res.far_notes.map(|r| r as u32),
-			created_at: res.created_at,
-			discord_attachment_id: None,
-			creation_ptt: None,
-			creation_zeta_ptt: None,
-		};
+		let (song, chart) = ctx.data().song_cache.lookup_chart(res.chart_id as u32)?;
+		let play = play_from_db_record!(chart, res);
 
 		let author = discord_it_to_discord_user(&ctx, &res.discord_id).await?;
 		let user = User::by_id(&ctx.data().db, play.user_id).await?;
 
-		let (song, chart) = ctx.data().song_cache.lookup_chart(play.chart_id)?;
 		let (embed, attachment) = play
 			.to_embed(&ctx.data().db, &user, song, chart, i, Some(&author))
 			.await?;
diff --git a/src/commands/stats.rs b/src/commands/stats.rs
index f84af03..3cbc6da 100644
--- a/src/commands/stats.rs
+++ b/src/commands/stats.rs
@@ -13,6 +13,7 @@ use crate::{
 		chart::Level,
 		jacket::BITMAP_IMAGE_SIZE,
 		play::{compute_b30_ptt, get_best_plays},
+		rating::rating_as_float,
 		score::ScoringSystem,
 	},
 	assert_is_pookie,
@@ -55,14 +56,15 @@ async fn best_plays(
 		get_best_plays(
 			&user_ctx.db,
 			&user_ctx.song_cache,
-			&user,
+			user.id,
 			scoring_system,
 			if require_full {
 				grid_size.0 * grid_size.1
 			} else {
 				grid_size.0 * (grid_size.1.max(1) - 1) + 1
 			} as usize,
-			(grid_size.0 * grid_size.1) as usize
+			(grid_size.0 * grid_size.1) as usize,
+			None
 		)
 		.await?
 	);
@@ -287,7 +289,7 @@ async fn best_plays(
 		// }}}
 		// {{{ Display status text
 		with_font(&EXO_FONT, |faces| {
-			let status = play.short_status(chart).ok_or_else(|| {
+			let status = play.short_status(scoring_system, chart).ok_or_else(|| {
 				format!(
 					"Could not get status for score {}",
 					play.score(scoring_system)
@@ -379,7 +381,7 @@ async fn best_plays(
 				style,
 				&format!(
 					"{:.2}",
-					play.play_rating(scoring_system, chart.chart_constant) as f32 / 100.0
+					play.play_rating_f32(scoring_system, chart.chart_constant)
 				),
 			)?;
 
@@ -415,7 +417,7 @@ async fn best_plays(
 		.attachment(CreateAttachment::bytes(out_buffer, "b30.png"))
 		.content(format!(
 			"Your ptt is {:.2}",
-			compute_b30_ptt(scoring_system, &plays) as f32 / 100.0
+			rating_as_float(compute_b30_ptt(scoring_system, &plays))
 		));
 	ctx.send(reply).await?;
 
diff --git a/src/main.rs b/src/main.rs
index 445882d..d93124e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5,6 +5,7 @@
 #![feature(async_closure)]
 #![feature(try_blocks)]
 #![feature(thread_local)]
+#![feature(generic_arg_infer)]
 
 mod arcaea;
 mod assets;
@@ -18,6 +19,7 @@ mod time;
 mod transform;
 mod user;
 
+use arcaea::play::generate_missing_scores;
 use assets::get_data_dir;
 use context::{Error, UserContext};
 use poise::serenity_prelude::{self as serenity};
@@ -89,6 +91,12 @@ async fn main() {
 				poise::builtins::register_globally(ctx, &framework.options().commands).await?;
 				let ctx = UserContext::new(pool).await?;
 
+				if var("SHIMMERING_REGEN_SCORES").unwrap_or_default() == "1" {
+					timed!("generate_missing_scores", {
+						generate_missing_scores(&ctx).await?;
+					});
+				}
+
 				Ok(ctx)
 			})
 		})