use crate::{accounts::Accounts, platform::PlatformKind, storage::Storage, Result};
use std::sync::Arc;
const XP_PREFIX: &str = "foukoapi:econ:xp:";
const COINS_PREFIX: &str = "foukoapi:econ:coins:";
const TITLE_PREFIX: &str = "foukoapi:econ:title:";
const NAME_PREFIX: &str = "foukoapi:econ:name:";
const COLOR_PREFIX: &str = "foukoapi:econ:color:";
const COOLDOWN_PREFIX: &str = "foukoapi:econ:cd:";
const ACH_PREFIX: &str = "foukoapi:econ:ach:";
pub const XP_PER_COIN: u64 = 10;
#[derive(Clone)]
pub struct Economy {
storage: Arc<dyn Storage>,
accounts: Accounts,
lock: Arc<tokio::sync::Mutex<()>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct XpGain {
pub xp: u64,
pub coins_minted: u64,
}
#[derive(Debug, Clone)]
pub struct Wallet {
pub xp: u64,
pub coins: u64,
pub level: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Metric {
Xp,
Coins,
}
#[derive(Debug, Clone)]
pub struct RankedPlayer {
pub primary: String,
pub value: u64,
pub position: usize,
}
impl Economy {
pub fn new(accounts: Accounts) -> Self {
Self {
storage: accounts.storage_ref().clone(),
accounts,
lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
async fn primary(&self, platform: PlatformKind, user_id: &str) -> String {
let me = format!("{platform}:{user_id}");
let primary = self
.accounts
.primary_for(platform, user_id)
.await
.unwrap_or_else(|_| me.clone());
if primary != me {
if let Err(e) = self.merge_identity(&me, &primary).await {
tracing::error!(from = %me, into = %primary, error = %e, "economy: identity merge failed");
}
}
primary
}
async fn merge_identity(&self, from: &str, into: &str) -> Result<()> {
let _guard = self.lock.lock().await;
let from_xp = self.read(&format!("{XP_PREFIX}{from}")).await;
let from_coins = self.read(&format!("{COINS_PREFIX}{from}")).await;
if from_xp == 0 && from_coins == 0 {
return Ok(()); }
tracing::info!(from = %from, into = %into, "economy: merging linked identity");
if from_xp > 0 {
let into_key = format!("{XP_PREFIX}{into}");
let total = self.read(&into_key).await.saturating_add(from_xp);
self.storage.set(&into_key, &total.to_string()).await?;
self.storage.del(&format!("{XP_PREFIX}{from}")).await?;
}
if from_coins > 0 {
let into_key = format!("{COINS_PREFIX}{into}");
let total = self.read(&into_key).await.saturating_add(from_coins);
self.storage.set(&into_key, &total.to_string()).await?;
self.storage.del(&format!("{COINS_PREFIX}{from}")).await?;
}
for prefix in [TITLE_PREFIX, COLOR_PREFIX, NAME_PREFIX] {
let from_key = format!("{prefix}{from}");
if let Ok(Some(v)) = self.storage.get(&from_key).await {
let into_key = format!("{prefix}{into}");
if matches!(self.storage.get(&into_key).await, Ok(None)) {
self.storage.set(&into_key, &v).await?;
}
self.storage.del(&from_key).await?;
}
}
let from_key = format!("{ACH_PREFIX}{from}");
if let Ok(Some(theirs)) = self.storage.get(&from_key).await {
let into_key = format!("{ACH_PREFIX}{into}");
let mut merged: Vec<String> = self
.storage
.get(&into_key)
.await
.ok()
.flatten()
.map(|blob| blob.lines().map(str::to_owned).collect())
.unwrap_or_default();
for a in theirs.lines() {
if !a.is_empty() && !merged.iter().any(|m| m == a) {
merged.push(a.to_owned());
}
}
self.storage.set(&into_key, &merged.join("\n")).await?;
self.storage.del(&from_key).await?;
}
Ok(())
}
pub async fn wallet(&self, platform: PlatformKind, user_id: &str) -> Wallet {
let primary = self.primary(platform, user_id).await;
self.wallet_of(&primary).await
}
pub async fn wallet_of(&self, primary: &str) -> Wallet {
let xp = self.read(&format!("{XP_PREFIX}{primary}")).await;
let coins = self.read(&format!("{COINS_PREFIX}{primary}")).await;
Wallet {
xp,
coins,
level: Self::level_for(xp),
}
}
pub async fn coins(&self, platform: PlatformKind, user_id: &str) -> u64 {
let primary = self.primary(platform, user_id).await;
self.read(&format!("{COINS_PREFIX}{primary}")).await
}
pub async fn add_xp(
&self,
platform: PlatformKind,
user_id: &str,
amount: u64,
) -> Result<XpGain> {
let primary = self.primary(platform, user_id).await;
let _guard = self.lock.lock().await;
let key = format!("{XP_PREFIX}{primary}");
let before = self.read(&key).await;
if amount == 0 {
return Ok(XpGain {
xp: before,
coins_minted: 0,
});
}
let after = before.saturating_add(amount);
let minted = (after / XP_PER_COIN).saturating_sub(before / XP_PER_COIN);
if minted > 0 {
self.add_coins_locked(&primary, minted as i64).await?;
}
self.storage.set(&key, &after.to_string()).await?;
Ok(XpGain {
xp: after,
coins_minted: minted,
})
}
pub async fn add_coins(
&self,
platform: PlatformKind,
user_id: &str,
delta: i64,
) -> Result<u64> {
let primary = self.primary(platform, user_id).await;
let _guard = self.lock.lock().await;
self.add_coins_locked(&primary, delta).await
}
pub async fn transfer(
&self,
from: (PlatformKind, &str),
to: (PlatformKind, &str),
amount: u64,
) -> Result<u64> {
let sender = self.primary(from.0, from.1).await;
let recipient = self.primary(to.0, to.1).await;
if sender == recipient {
return Err(crate::Error::Other("can't send coins to yourself".into()));
}
let _guard = self.lock.lock().await;
let balance = self.read(&format!("{COINS_PREFIX}{sender}")).await;
if balance < amount {
return Err(crate::Error::Other("not enough coins".into()));
}
let remaining = self.add_coins_locked(&sender, -(amount as i64)).await?;
if let Err(e) = self.add_coins_locked(&recipient, amount as i64).await {
if let Err(refund_err) = self.add_coins_locked(&sender, amount as i64).await {
tracing::error!(
sender = %sender,
amount,
error = %refund_err,
"transfer refund failed - coins lost"
);
}
return Err(e);
}
Ok(remaining)
}
async fn add_coins_locked(&self, primary: &str, delta: i64) -> Result<u64> {
let key = format!("{COINS_PREFIX}{primary}");
let current = self.read(&key).await as i64;
let next = (current + delta).max(0) as u64;
self.storage.set(&key, &next.to_string()).await?;
Ok(next)
}
pub fn level_for(xp: u64) -> u32 {
let mut level: u32 = 0;
loop {
let next = (level as u64 + 1) * (level as u64 + 2) * 10;
if xp < next || level > 1000 {
return level;
}
level += 1;
}
}
pub fn level_bounds(level: u32) -> (u64, u64) {
let l = level as u64;
let lower = l * (l + 1) * 10;
let upper = (l + 1) * (l + 2) * 10;
(lower, upper)
}
pub async fn cooldown_remaining(
&self,
platform: PlatformKind,
user_id: &str,
action: &str,
window_secs: i64,
) -> i64 {
let primary = self.primary(platform, user_id).await;
let last = self
.read(&format!("{COOLDOWN_PREFIX}{action}:{primary}"))
.await as i64;
(window_secs - (now() - last)).max(0)
}
pub async fn touch_cooldown(
&self,
platform: PlatformKind,
user_id: &str,
action: &str,
) -> Result<()> {
let primary = self.primary(platform, user_id).await;
self.storage
.set(
&format!("{COOLDOWN_PREFIX}{action}:{primary}"),
&now().to_string(),
)
.await
}
pub async fn title(&self, platform: PlatformKind, user_id: &str) -> Option<String> {
let primary = self.primary(platform, user_id).await;
self.storage
.get(&format!("{TITLE_PREFIX}{primary}"))
.await
.ok()
.flatten()
.filter(|s| !s.is_empty())
}
pub async fn set_title(
&self,
platform: PlatformKind,
user_id: &str,
title: &str,
) -> Result<()> {
let primary = self.primary(platform, user_id).await;
self.storage
.set(&format!("{TITLE_PREFIX}{primary}"), title)
.await
}
pub async fn set_display_name(
&self,
platform: PlatformKind,
user_id: &str,
name: &str,
) -> Result<()> {
let primary = self.primary(platform, user_id).await;
self.storage
.set(&format!("{NAME_PREFIX}{primary}"), name)
.await
}
pub async fn display_name_of(&self, primary: &str) -> Option<String> {
self.storage
.get(&format!("{NAME_PREFIX}{primary}"))
.await
.ok()
.flatten()
.filter(|s| !s.is_empty())
}
pub async fn color(&self, platform: PlatformKind, user_id: &str) -> Option<u32> {
let primary = self.primary(platform, user_id).await;
self.storage
.get(&format!("{COLOR_PREFIX}{primary}"))
.await
.ok()
.flatten()
.and_then(|s| u32::from_str_radix(s.trim_start_matches('#'), 16).ok())
}
pub async fn set_color(&self, platform: PlatformKind, user_id: &str, rgb: u32) -> Result<()> {
let primary = self.primary(platform, user_id).await;
self.storage
.set(&format!("{COLOR_PREFIX}{primary}"), &format!("{rgb:06X}"))
.await
}
pub async fn grant_achievement(
&self,
platform: PlatformKind,
user_id: &str,
id: &str,
) -> Result<bool> {
let primary = self.primary(platform, user_id).await;
let _guard = self.lock.lock().await;
let key = format!("{ACH_PREFIX}{primary}");
let mut owned = self.read_list(&key).await;
if owned.iter().any(|a| a == id) {
return Ok(false);
}
owned.push(id.to_owned());
self.storage.set(&key, &owned.join("\n")).await?;
Ok(true)
}
pub async fn has_achievement(&self, platform: PlatformKind, user_id: &str, id: &str) -> bool {
let primary = self.primary(platform, user_id).await;
self.read_list(&format!("{ACH_PREFIX}{primary}"))
.await
.iter()
.any(|a| a == id)
}
pub async fn achievements(&self, platform: PlatformKind, user_id: &str) -> Vec<String> {
let primary = self.primary(platform, user_id).await;
self.read_list(&format!("{ACH_PREFIX}{primary}")).await
}
async fn read_list(&self, key: &str) -> Vec<String> {
self.storage
.get(key)
.await
.ok()
.flatten()
.map(|blob| {
blob.lines()
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
pub async fn leaderboard(&self, metric: Metric, limit: usize) -> Vec<RankedPlayer> {
let mut ranked = self.ranked_all(metric).await;
ranked.truncate(limit);
ranked
}
pub async fn rank_of(
&self,
platform: PlatformKind,
user_id: &str,
metric: Metric,
) -> Option<RankedPlayer> {
let primary = self.primary(platform, user_id).await;
self.ranked_all(metric)
.await
.into_iter()
.find(|r| r.primary == primary)
}
pub async fn player_count(&self) -> usize {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for (key, _) in self
.storage
.list_prefix(XP_PREFIX)
.await
.unwrap_or_default()
{
if let Some(p) = key.strip_prefix(XP_PREFIX) {
seen.insert(p.to_owned());
}
}
for (key, _) in self
.storage
.list_prefix(COINS_PREFIX)
.await
.unwrap_or_default()
{
if let Some(p) = key.strip_prefix(COINS_PREFIX) {
seen.insert(p.to_owned());
}
}
seen.len()
}
async fn ranked_all(&self, metric: Metric) -> Vec<RankedPlayer> {
let prefix = match metric {
Metric::Xp => XP_PREFIX,
Metric::Coins => COINS_PREFIX,
};
let mut rows: Vec<(String, u64)> = self
.storage
.list_prefix(prefix)
.await
.unwrap_or_default()
.into_iter()
.filter_map(|(key, value)| {
let primary = key.strip_prefix(prefix)?.to_owned();
let value = value.parse().unwrap_or(0);
Some((primary, value))
})
.collect();
rows.sort_by_key(|row| std::cmp::Reverse(row.1));
rows.into_iter()
.enumerate()
.map(|(position, (primary, value))| RankedPlayer {
primary,
value,
position,
})
.collect()
}
async fn read(&self, key: &str) -> u64 {
self.storage
.get(key)
.await
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{storage::MemoryStorage, PlatformKind};
fn econ() -> Economy {
Economy::new(Accounts::new(MemoryStorage::new()))
}
#[test]
fn level_curve() {
assert_eq!(Economy::level_for(0), 0);
assert_eq!(Economy::level_for(19), 0);
assert_eq!(Economy::level_for(20), 1);
assert_eq!(Economy::level_for(60), 2);
assert_eq!(Economy::level_for(119), 2);
assert_eq!(Economy::level_for(120), 3);
}
#[test]
fn level_bounds_bracket_the_xp() {
for xp in [0u64, 5, 25, 61, 200, 999] {
let level = Economy::level_for(xp);
let (lower, upper) = Economy::level_bounds(level);
assert!(lower <= xp, "lower {lower} should be <= xp {xp}");
assert!(xp < upper, "xp {xp} should be < upper {upper}");
}
}
#[tokio::test]
async fn xp_mints_coins_across_boundaries() {
let e = econ();
let d = PlatformKind::Discord;
let gain = e.add_xp(d, "u1", 25).await.unwrap();
assert_eq!(gain.xp, 25);
assert_eq!(gain.coins_minted, 2);
let w = e.wallet(d, "u1").await;
assert_eq!(w.xp, 25);
assert_eq!(w.coins, 2);
}
#[tokio::test]
async fn concurrent_grants_do_not_lose_updates() {
let e = econ();
let d = PlatformKind::Discord;
let mut handles = Vec::new();
for _ in 0..20 {
let e = e.clone();
handles.push(tokio::spawn(async move {
e.add_xp(d, "u", 5).await.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
let w = e.wallet(d, "u").await;
assert_eq!(w.xp, 100);
assert_eq!(w.coins, 10);
}
#[tokio::test]
async fn concurrent_transfers_cannot_overdraw() {
let e = econ();
let d = PlatformKind::Discord;
e.add_coins(d, "rich", 100).await.unwrap();
let a = {
let e = e.clone();
tokio::spawn(async move { e.transfer((d, "rich"), (d, "p1"), 100).await })
};
let b = {
let e = e.clone();
tokio::spawn(async move { e.transfer((d, "rich"), (d, "p2"), 100).await })
};
let (ra, rb) = (a.await.unwrap(), b.await.unwrap());
assert!(ra.is_ok() != rb.is_ok());
let total = e.coins(d, "rich").await + e.coins(d, "p1").await + e.coins(d, "p2").await;
assert_eq!(total, 100);
}
#[tokio::test]
async fn transfer_moves_coins_and_refuses_overdraft() {
let e = econ();
let d = PlatformKind::Discord;
e.add_coins(d, "rich", 100).await.unwrap();
let left = e.transfer((d, "rich"), (d, "poor"), 30).await.unwrap();
assert_eq!(left, 70);
assert_eq!(e.coins(d, "poor").await, 30);
assert!(e.transfer((d, "poor"), (d, "rich"), 999).await.is_err());
}
#[tokio::test]
async fn leaderboard_orders_by_value() {
let e = econ();
let d = PlatformKind::Discord;
e.add_xp(d, "a", 100).await.unwrap();
e.add_xp(d, "b", 300).await.unwrap();
e.add_xp(d, "c", 200).await.unwrap();
let top = e.leaderboard(Metric::Xp, 10).await;
assert_eq!(top[0].primary, "discord:b");
assert_eq!(top[1].primary, "discord:c");
assert_eq!(top[2].primary, "discord:a");
assert_eq!(top[0].position, 0);
}
#[tokio::test]
async fn rank_of_reports_position() {
let e = econ();
let d = PlatformKind::Discord;
e.add_xp(d, "a", 100).await.unwrap();
e.add_xp(d, "b", 300).await.unwrap();
let rank = e.rank_of(d, "a", Metric::Xp).await.unwrap();
assert_eq!(rank.position, 1); assert!(e.rank_of(d, "ghost", Metric::Xp).await.is_none());
}
#[tokio::test]
async fn linking_merges_previously_earned_balances() {
let accounts = Accounts::new(crate::storage::MemoryStorage::new());
let e = Economy::new(accounts.clone());
let d = PlatformKind::Discord;
let t = PlatformKind::Telegram;
e.add_xp(d, "42", 50).await.unwrap();
assert_eq!(e.wallet_of("discord:42").await.xp, 50);
let code = accounts.start_link(t, "777").await.unwrap();
accounts.redeem_link(&code, d, "42").await.unwrap();
accounts.set_primary(d, "42", "telegram:777").await.unwrap();
e.add_xp(d, "42", 1).await.unwrap();
let w = e.wallet(t, "777").await;
assert_eq!(w.xp, 51);
assert_eq!(e.wallet_of("discord:42").await.xp, 0);
let top = e.leaderboard(Metric::Xp, 10).await;
assert_eq!(top.len(), 1);
assert_eq!(top[0].primary, "telegram:777");
}
#[tokio::test]
async fn cooldown_tracks_a_window() {
let e = econ();
let d = PlatformKind::Discord;
assert_eq!(e.cooldown_remaining(d, "u", "daily", 60).await, 0);
e.touch_cooldown(d, "u", "daily").await.unwrap();
assert!(e.cooldown_remaining(d, "u", "daily", 60).await > 0);
assert_eq!(e.cooldown_remaining(d, "u", "daily", 0).await, 0);
}
#[tokio::test]
async fn cosmetics_persist() {
let e = econ();
let d = PlatformKind::Discord;
assert!(e.title(d, "u").await.is_none());
e.set_title(d, "u", "Legend").await.unwrap();
assert_eq!(e.title(d, "u").await.as_deref(), Some("Legend"));
e.set_color(d, "u", 0x00C2A8).await.unwrap();
assert_eq!(e.color(d, "u").await, Some(0x00C2A8));
}
#[tokio::test]
async fn achievements_grant_once() {
let e = econ();
let d = PlatformKind::Discord;
assert!(!e.has_achievement(d, "u", "first_daily").await);
assert!(e.grant_achievement(d, "u", "first_daily").await.unwrap()); assert!(!e.grant_achievement(d, "u", "first_daily").await.unwrap()); assert!(e.has_achievement(d, "u", "first_daily").await);
e.grant_achievement(d, "u", "high_roller").await.unwrap();
assert_eq!(
e.achievements(d, "u").await,
vec!["first_daily".to_owned(), "high_roller".to_owned()]
);
}
}