Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
649 lines
25 KiB
Rust
649 lines
25 KiB
Rust
use anyhow::Result;
|
|
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
|
|
use sqlx::SqlitePool;
|
|
use std::path::Path;
|
|
use std::str::FromStr;
|
|
|
|
/// Schema migrations, applied in order. Never edit a released entry —
|
|
/// append a new one instead.
|
|
const MIGRATIONS: &[&str] = &[
|
|
// 1: initial schema
|
|
r#"
|
|
CREATE TABLE users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
password_hash TEXT NOT NULL,
|
|
email TEXT,
|
|
role TEXT NOT NULL DEFAULT 'player',
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
created_at TEXT NOT NULL,
|
|
last_login TEXT
|
|
);
|
|
CREATE TABLE groups (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
color TEXT NOT NULL DEFAULT '#7c5cff'
|
|
);
|
|
CREATE TABLE user_groups (
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (user_id, group_id)
|
|
);
|
|
CREATE TABLE instances (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
icon_url TEXT,
|
|
banner_url TEXT,
|
|
mc_version TEXT NOT NULL,
|
|
loader TEXT NOT NULL DEFAULT 'vanilla',
|
|
loader_version TEXT,
|
|
source_kind TEXT NOT NULL DEFAULT 'vanilla',
|
|
source_label TEXT NOT NULL DEFAULT '',
|
|
source_ref TEXT NOT NULL DEFAULT '{}',
|
|
visibility TEXT NOT NULL DEFAULT 'public',
|
|
allowed_groups TEXT NOT NULL DEFAULT '[]',
|
|
memory_min INTEGER NOT NULL DEFAULT 1024,
|
|
memory_max INTEGER NOT NULL DEFAULT 4096,
|
|
jvm_args TEXT NOT NULL DEFAULT '',
|
|
server TEXT,
|
|
featured INTEGER NOT NULL DEFAULT 0,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
sort INTEGER NOT NULL DEFAULT 0,
|
|
revision INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE instance_files (
|
|
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
|
path TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
sha1 TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
origin TEXT NOT NULL,
|
|
note TEXT,
|
|
PRIMARY KEY (instance_id, path)
|
|
);
|
|
CREATE TABLE kv (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
CREATE TABLE events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
instance_id TEXT,
|
|
username TEXT,
|
|
kind TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX events_created ON events(created_at);
|
|
"#,
|
|
// 2: Yggdrasil auth server (UUIDs, skins, capes, sessions) and game
|
|
// server integration (plugin/mod tracking)
|
|
r#"
|
|
ALTER TABLE users ADD COLUMN uuid TEXT NOT NULL DEFAULT '';
|
|
ALTER TABLE users ADD COLUMN skin_hash TEXT;
|
|
ALTER TABLE users ADD COLUMN skin_model TEXT NOT NULL DEFAULT 'classic';
|
|
ALTER TABLE users ADD COLUMN cape_id INTEGER;
|
|
ALTER TABLE users ADD COLUMN status_reason TEXT;
|
|
|
|
CREATE TABLE capes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
hash TEXT NOT NULL,
|
|
visibility TEXT NOT NULL DEFAULT 'public',
|
|
allowed_groups TEXT NOT NULL DEFAULT '[]',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE ygg_tokens (
|
|
access_token TEXT PRIMARY KEY,
|
|
client_token TEXT NOT NULL,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX ygg_tokens_user ON ygg_tokens(user_id);
|
|
CREATE TABLE ygg_sessions (
|
|
server_id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
ip TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE player_keys (
|
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
private_pem TEXT NOT NULL,
|
|
public_pem TEXT NOT NULL,
|
|
signature_v1 TEXT NOT NULL,
|
|
signature_v2 TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
refreshed_after TEXT NOT NULL
|
|
);
|
|
CREATE TABLE launcher_sessions (
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
ip TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX launcher_sessions_user ON launcher_sessions(user_id, created_at);
|
|
|
|
CREATE TABLE game_servers (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
token_hint TEXT NOT NULL,
|
|
access TEXT NOT NULL DEFAULT 'all',
|
|
allowed_groups TEXT NOT NULL DEFAULT '[]',
|
|
require_launcher INTEGER NOT NULL DEFAULT 0,
|
|
software TEXT,
|
|
mc_version TEXT,
|
|
plugin_version TEXT,
|
|
online_mode INTEGER,
|
|
max_players INTEGER NOT NULL DEFAULT 0,
|
|
online_count INTEGER NOT NULL DEFAULT 0,
|
|
tps REAL,
|
|
last_seen TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE server_online (
|
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
uuid TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
joined_at TEXT NOT NULL,
|
|
PRIMARY KEY (server_id, uuid)
|
|
);
|
|
CREATE TABLE player_stats (
|
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
uuid TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
playtime_secs INTEGER NOT NULL DEFAULT 0,
|
|
joins INTEGER NOT NULL DEFAULT 0,
|
|
deaths INTEGER NOT NULL DEFAULT 0,
|
|
player_kills INTEGER NOT NULL DEFAULT 0,
|
|
mob_kills INTEGER NOT NULL DEFAULT 0,
|
|
blocks_broken INTEGER NOT NULL DEFAULT 0,
|
|
blocks_placed INTEGER NOT NULL DEFAULT 0,
|
|
messages INTEGER NOT NULL DEFAULT 0,
|
|
first_seen TEXT NOT NULL,
|
|
last_seen TEXT NOT NULL,
|
|
PRIMARY KEY (server_id, uuid)
|
|
);
|
|
CREATE INDEX player_stats_uuid ON player_stats(uuid);
|
|
CREATE TABLE server_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
uuid TEXT,
|
|
name TEXT,
|
|
kind TEXT NOT NULL,
|
|
detail TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX server_events_server ON server_events(server_id, id);
|
|
CREATE INDEX server_events_uuid ON server_events(uuid, id);
|
|
"#,
|
|
// 3: idempotent server stat batches (retained until the server is deleted).
|
|
r#"
|
|
CREATE TABLE server_sync_receipts (
|
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
batch_id TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (server_id, batch_id)
|
|
);
|
|
"#,
|
|
// 4: permanent player identity, reserved historical names and audit data.
|
|
r#"
|
|
ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0;
|
|
ALTER TABLE events ADD COLUMN uuid TEXT;
|
|
ALTER TABLE events ADD COLUMN detail TEXT;
|
|
ALTER TABLE events ADD COLUMN source TEXT NOT NULL DEFAULT 'launcher';
|
|
CREATE INDEX events_uuid ON events(uuid, id);
|
|
CREATE TABLE reserved_usernames (
|
|
name TEXT PRIMARY KEY COLLATE NOCASE,
|
|
uuid TEXT NOT NULL
|
|
);
|
|
INSERT INTO reserved_usernames SELECT username, uuid FROM users WHERE uuid <> '';
|
|
CREATE TRIGGER immutable_player_uuid BEFORE UPDATE OF uuid ON users
|
|
WHEN OLD.uuid <> '' AND NEW.uuid <> OLD.uuid
|
|
BEGIN SELECT RAISE(ABORT, 'player UUID is immutable'); END;
|
|
CREATE TRIGGER reserve_name_insert BEFORE INSERT ON users
|
|
WHEN EXISTS(SELECT 1 FROM reserved_usernames WHERE name = NEW.username AND uuid <> NEW.uuid)
|
|
BEGIN SELECT RAISE(ABORT, 'username reserved'); END;
|
|
CREATE TRIGGER reserve_name_update BEFORE UPDATE OF username ON users
|
|
WHEN EXISTS(SELECT 1 FROM reserved_usernames WHERE name = NEW.username AND uuid <> NEW.uuid)
|
|
BEGIN SELECT RAISE(ABORT, 'username reserved'); END;
|
|
CREATE TRIGGER remember_name_insert AFTER INSERT ON users
|
|
WHEN NEW.uuid <> ''
|
|
BEGIN INSERT OR IGNORE INTO reserved_usernames VALUES (NEW.username, NEW.uuid); END;
|
|
CREATE TRIGGER remember_name_update AFTER UPDATE OF username, uuid ON users
|
|
WHEN NEW.uuid <> ''
|
|
BEGIN INSERT OR IGNORE INTO reserved_usernames VALUES (NEW.username, NEW.uuid); END;
|
|
"#,
|
|
// 5: instance logo url and flexible launcher sessions.
|
|
r#"
|
|
ALTER TABLE instances ADD COLUMN logo_url TEXT;
|
|
CREATE TABLE launcher_sessions_new (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
ip TEXT NOT NULL,
|
|
username TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
INSERT INTO launcher_sessions_new (user_id, ip, created_at)
|
|
SELECT user_id, ip, created_at FROM launcher_sessions;
|
|
DROP TABLE launcher_sessions;
|
|
ALTER TABLE launcher_sessions_new RENAME TO launcher_sessions;
|
|
CREATE INDEX launcher_sessions_user ON launcher_sessions(user_id, created_at);
|
|
CREATE INDEX launcher_sessions_ip ON launcher_sessions(ip, created_at);
|
|
"#,
|
|
// 6: Leveling, Quests, Achievements, Guilds, Land Claims, Friends, Profiles & Social Posts
|
|
r#"
|
|
CREATE TABLE user_levels (
|
|
uuid TEXT PRIMARY KEY,
|
|
global_xp INTEGER NOT NULL DEFAULT 0,
|
|
global_level INTEGER NOT NULL DEFAULT 1,
|
|
title TEXT,
|
|
badges TEXT NOT NULL DEFAULT '[]',
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE server_levels (
|
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
uuid TEXT NOT NULL,
|
|
server_xp INTEGER NOT NULL DEFAULT 0,
|
|
server_level INTEGER NOT NULL DEFAULT 1,
|
|
rank_name TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (server_id, uuid)
|
|
);
|
|
|
|
CREATE TABLE level_rewards (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
level_type TEXT NOT NULL,
|
|
server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
level_req INTEGER NOT NULL,
|
|
reward_type TEXT NOT NULL,
|
|
reward_name TEXT NOT NULL,
|
|
reward_data TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE quests (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
period TEXT NOT NULL,
|
|
category TEXT NOT NULL,
|
|
target_stat TEXT NOT NULL,
|
|
target_count INTEGER NOT NULL,
|
|
xp_reward INTEGER NOT NULL,
|
|
icon TEXT NOT NULL DEFAULT 'star',
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE user_quests (
|
|
user_uuid TEXT NOT NULL,
|
|
quest_id TEXT NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
|
|
period_key TEXT NOT NULL,
|
|
progress INTEGER NOT NULL DEFAULT 0,
|
|
completed INTEGER NOT NULL DEFAULT 0,
|
|
claimed INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (user_uuid, quest_id, period_key)
|
|
);
|
|
CREATE INDEX user_quests_uuid ON user_quests(user_uuid, period_key);
|
|
|
|
CREATE TABLE achievements (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
category TEXT NOT NULL DEFAULT 'general',
|
|
icon_frame TEXT NOT NULL DEFAULT 'task',
|
|
icon_item TEXT NOT NULL DEFAULT 'diamond',
|
|
icon_bg TEXT NOT NULL DEFAULT 'stone',
|
|
icon_border TEXT NOT NULL DEFAULT 'gold',
|
|
requirement_type TEXT NOT NULL DEFAULT 'stat',
|
|
requirement_key TEXT NOT NULL,
|
|
requirement_value INTEGER NOT NULL DEFAULT 1,
|
|
xp_reward INTEGER NOT NULL DEFAULT 100,
|
|
secret INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE user_achievements (
|
|
user_uuid TEXT NOT NULL,
|
|
achievement_id TEXT NOT NULL REFERENCES achievements(id) ON DELETE CASCADE,
|
|
unlocked_at TEXT NOT NULL,
|
|
PRIMARY KEY (user_uuid, achievement_id)
|
|
);
|
|
CREATE INDEX user_achievements_uuid ON user_achievements(user_uuid);
|
|
|
|
CREATE TABLE guilds (
|
|
id TEXT PRIMARY KEY,
|
|
instance_id TEXT NOT NULL,
|
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
tag TEXT NOT NULL COLLATE NOCASE,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
motd TEXT NOT NULL DEFAULT '',
|
|
leader_uuid TEXT NOT NULL,
|
|
icon_url TEXT,
|
|
banner_url TEXT,
|
|
level INTEGER NOT NULL DEFAULT 1,
|
|
xp INTEGER NOT NULL DEFAULT 0,
|
|
max_claims INTEGER NOT NULL DEFAULT 16,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX guilds_instance ON guilds(instance_id);
|
|
|
|
CREATE TABLE guild_members (
|
|
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
|
uuid TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'member',
|
|
joined_at TEXT NOT NULL,
|
|
PRIMARY KEY (guild_id, uuid)
|
|
);
|
|
CREATE UNIQUE INDEX guild_member_unique ON guild_members(uuid);
|
|
|
|
CREATE TABLE guild_roles (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
can_invite INTEGER NOT NULL DEFAULT 0,
|
|
can_kick INTEGER NOT NULL DEFAULT 0,
|
|
can_claim INTEGER NOT NULL DEFAULT 0,
|
|
can_post INTEGER NOT NULL DEFAULT 1,
|
|
can_manage INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE guild_posts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
|
author_uuid TEXT NOT NULL,
|
|
author_name TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX guild_posts_guild ON guild_posts(guild_id, id DESC);
|
|
|
|
CREATE TABLE guild_claims (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
|
server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
dimension TEXT NOT NULL DEFAULT 'minecraft:overworld',
|
|
chunk_x INTEGER NOT NULL,
|
|
chunk_z INTEGER NOT NULL,
|
|
claimed_by_uuid TEXT NOT NULL,
|
|
claimed_at TEXT NOT NULL,
|
|
UNIQUE (server_id, dimension, chunk_x, chunk_z)
|
|
);
|
|
CREATE INDEX guild_claims_guild ON guild_claims(guild_id);
|
|
CREATE INDEX guild_claims_loc ON guild_claims(server_id, dimension, chunk_x, chunk_z);
|
|
|
|
CREATE TABLE friendships (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_uuid TEXT NOT NULL,
|
|
friend_uuid TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
action_uuid TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE (user_uuid, friend_uuid)
|
|
);
|
|
CREATE INDEX friendships_user ON friendships(user_uuid);
|
|
CREATE INDEX friendships_friend ON friendships(friend_uuid);
|
|
|
|
CREATE TABLE direct_messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
sender_uuid TEXT NOT NULL,
|
|
sender_name TEXT NOT NULL,
|
|
recipient_uuid TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
is_read INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX dm_conversation ON direct_messages(sender_uuid, recipient_uuid, id);
|
|
CREATE INDEX dm_recipient ON direct_messages(recipient_uuid, is_read);
|
|
|
|
CREATE TABLE game_invites (
|
|
id TEXT PRIMARY KEY,
|
|
sender_uuid TEXT NOT NULL,
|
|
sender_name TEXT NOT NULL,
|
|
recipient_uuid TEXT NOT NULL,
|
|
instance_id TEXT NOT NULL,
|
|
server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX game_invites_recipient ON game_invites(recipient_uuid, status);
|
|
|
|
CREATE TABLE user_profiles (
|
|
uuid TEXT PRIMARY KEY,
|
|
bio TEXT NOT NULL DEFAULT '',
|
|
banner_url TEXT,
|
|
custom_badge TEXT,
|
|
featured_achievement_id TEXT,
|
|
social_links TEXT NOT NULL DEFAULT '{}',
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE user_posts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_uuid TEXT NOT NULL,
|
|
author_name TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
image_url TEXT,
|
|
likes_count INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX user_posts_user ON user_posts(user_uuid, id DESC);
|
|
"#,
|
|
r#"
|
|
CREATE TABLE IF NOT EXISTS server_economy (
|
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
uuid TEXT NOT NULL,
|
|
username TEXT NOT NULL,
|
|
balance REAL NOT NULL DEFAULT 1000.0,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (server_id, uuid)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS economy_user ON server_economy(uuid);
|
|
CREATE INDEX IF NOT EXISTS economy_bal ON server_economy(server_id, balance DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS economy_transactions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
from_uuid TEXT NOT NULL,
|
|
from_name TEXT NOT NULL,
|
|
to_uuid TEXT NOT NULL,
|
|
to_name TEXT NOT NULL,
|
|
amount REAL NOT NULL,
|
|
description TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS econ_tx_user ON economy_transactions(server_id, from_uuid, to_uuid);
|
|
|
|
CREATE TABLE IF NOT EXISTS server_market (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
seller_uuid TEXT NOT NULL,
|
|
seller_name TEXT NOT NULL,
|
|
item_id TEXT NOT NULL,
|
|
item_name TEXT NOT NULL,
|
|
amount INTEGER NOT NULL DEFAULT 1,
|
|
price REAL NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS market_srv ON server_market(server_id, id DESC);
|
|
"#,
|
|
r#"
|
|
ALTER TABLE game_servers ADD COLUMN instance_id TEXT NOT NULL DEFAULT '';
|
|
DROP INDEX guild_member_unique;
|
|
CREATE INDEX guild_member_uuid ON guild_members(uuid);
|
|
CREATE TRIGGER guild_members_instance_unique BEFORE INSERT ON guild_members
|
|
WHEN EXISTS (SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id
|
|
WHERE gm.uuid = NEW.uuid AND gm.guild_id <> NEW.guild_id
|
|
AND g.instance_id = (SELECT instance_id FROM guilds WHERE id = NEW.guild_id))
|
|
BEGIN SELECT RAISE(ABORT, 'Already in a guild for this instance'); END;
|
|
CREATE TRIGGER achievement_xp AFTER INSERT ON user_achievements BEGIN
|
|
INSERT INTO user_levels(uuid, global_xp, updated_at)
|
|
VALUES(NEW.user_uuid, (SELECT MAX(0, xp_reward) FROM achievements WHERE id = NEW.achievement_id), NEW.unlocked_at)
|
|
ON CONFLICT(uuid) DO UPDATE SET global_xp = global_xp + excluded.global_xp, updated_at = excluded.updated_at;
|
|
END;
|
|
INSERT INTO user_levels(uuid, global_xp, updated_at)
|
|
SELECT ua.user_uuid, SUM(MAX(0, a.xp_reward)), MAX(ua.unlocked_at) FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id GROUP BY ua.user_uuid
|
|
ON CONFLICT(uuid) DO UPDATE SET global_xp = global_xp + excluded.global_xp;
|
|
CREATE TABLE granted_rewards (uuid TEXT NOT NULL, reward_id INTEGER NOT NULL REFERENCES level_rewards(id) ON DELETE CASCADE,
|
|
granted_at TEXT NOT NULL, PRIMARY KEY (uuid, reward_id));
|
|
ALTER TABLE server_market ADD COLUMN item_data TEXT;
|
|
CREATE TABLE economy_operations (server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
operation_id TEXT NOT NULL, response TEXT NOT NULL, PRIMARY KEY(server_id, operation_id));
|
|
"#,
|
|
r#"
|
|
CREATE TABLE user_post_likes (
|
|
post_id INTEGER NOT NULL REFERENCES user_posts(id) ON DELETE CASCADE,
|
|
user_uuid TEXT NOT NULL,
|
|
PRIMARY KEY (post_id, user_uuid)
|
|
);
|
|
"#,
|
|
r#"
|
|
ALTER TABLE game_servers ADD COLUMN map_url TEXT NOT NULL DEFAULT '';
|
|
"#,
|
|
r#"
|
|
CREATE TABLE account_connections (
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
provider TEXT NOT NULL,
|
|
provider_id TEXT NOT NULL,
|
|
display_name TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (provider, provider_id),
|
|
UNIQUE (user_id, provider)
|
|
);
|
|
CREATE TABLE oauth_attempts (
|
|
state TEXT PRIMARY KEY,
|
|
kind TEXT NOT NULL,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
result TEXT,
|
|
consumed_at TEXT
|
|
);
|
|
CREATE TABLE password_resets (
|
|
token_hash TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
expires_at TEXT NOT NULL,
|
|
used_at TEXT
|
|
);
|
|
CREATE INDEX password_resets_user ON password_resets(user_id);
|
|
"#,
|
|
r#"
|
|
CREATE TABLE guild_wallets (
|
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
|
balance REAL NOT NULL DEFAULT 0 CHECK(balance >= 0),
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY(server_id,guild_id)
|
|
);
|
|
CREATE TABLE guild_wallet_transactions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
|
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
|
actor_uuid TEXT NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
amount REAL NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX guild_wallet_transactions_recent ON guild_wallet_transactions(guild_id,server_id,id DESC);
|
|
"#,
|
|
r#"
|
|
ALTER TABLE game_servers ADD COLUMN live_map_enabled INTEGER NOT NULL DEFAULT 0;
|
|
"#,
|
|
];
|
|
|
|
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
|
std::fs::create_dir_all(data_dir)?;
|
|
let url = format!("sqlite://{}", data_dir.join("panel.db").display());
|
|
let opts = SqliteConnectOptions::from_str(&url)?
|
|
.create_if_missing(true)
|
|
.journal_mode(SqliteJournalMode::Wal)
|
|
.synchronous(SqliteSynchronous::Normal)
|
|
.foreign_keys(true);
|
|
let pool = SqlitePoolOptions::new().max_connections(8).connect_with(opts).await?;
|
|
migrate(&pool).await?;
|
|
Ok(pool)
|
|
}
|
|
|
|
pub async fn connect_memory() -> Result<SqlitePool> {
|
|
let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true);
|
|
let pool = SqlitePoolOptions::new().max_connections(1).connect_with(opts).await?;
|
|
migrate(&pool).await?;
|
|
Ok(pool)
|
|
}
|
|
|
|
async fn migrate(pool: &SqlitePool) -> Result<()> {
|
|
let current: i64 = sqlx::query_scalar("PRAGMA user_version").fetch_one(pool).await?;
|
|
for (i, sql) in MIGRATIONS.iter().enumerate() {
|
|
let version = i as i64 + 1;
|
|
if version <= current {
|
|
continue;
|
|
}
|
|
let mut tx = pool.begin().await?;
|
|
sqlx::raw_sql(sql).execute(&mut *tx).await?;
|
|
sqlx::raw_sql(&format!("PRAGMA user_version = {version}")).execute(&mut *tx).await?;
|
|
tx.commit().await?;
|
|
tracing::info!("applied database migration {version}");
|
|
}
|
|
backfill_uuids(pool).await?;
|
|
crate::seed::seed_quests_and_achievements(pool).await?;
|
|
if current < 9 {
|
|
crate::seed::upgrade_seeded_quest_targets(pool).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Accounts created before the auth server existed get the offline-mode UUID
|
|
/// for their name — the one offline servers already knew them by.
|
|
async fn backfill_uuids(pool: &SqlitePool) -> Result<()> {
|
|
let missing: Vec<(i64, String)> = sqlx::query_as("SELECT id, username FROM users WHERE uuid = ''").fetch_all(pool).await?;
|
|
for (id, name) in missing {
|
|
sqlx::query("UPDATE users SET uuid = ? WHERE id = ?").bind(scopenet_shared::offline_uuid(&name)).bind(id).execute(pool).await?;
|
|
}
|
|
if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sqlite_master WHERE name = 'users_uuid'").fetch_one(pool).await? == 0 {
|
|
sqlx::raw_sql("CREATE UNIQUE INDEX users_uuid ON users(uuid)").execute(pool).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn now() -> String {
|
|
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn identity_migration_preserves_existing_player_data_keys() {
|
|
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
|
|
sqlx::raw_sql(MIGRATIONS[0]).execute(&pool).await.unwrap();
|
|
sqlx::raw_sql(MIGRATIONS[1]).execute(&pool).await.unwrap();
|
|
let existing = scopenet_shared::offline_uuid("LegacyPlayer");
|
|
sqlx::query("INSERT INTO users(username,password_hash,created_at,uuid) VALUES('LegacyPlayer','test',?,?)")
|
|
.bind(now())
|
|
.bind(&existing)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::raw_sql("PRAGMA user_version=2").execute(&pool).await.unwrap();
|
|
migrate(&pool).await.unwrap();
|
|
let uuid: String = sqlx::query_scalar("SELECT uuid FROM users WHERE username='LegacyPlayer'").fetch_one(&pool).await.unwrap();
|
|
assert_eq!(uuid, existing);
|
|
sqlx::query("UPDATE users SET username='RenamedPlayer' WHERE uuid=?").bind(&existing).execute(&pool).await.unwrap();
|
|
let uuid: String = sqlx::query_scalar("SELECT uuid FROM users WHERE username='RenamedPlayer'").fetch_one(&pool).await.unwrap();
|
|
assert_eq!(uuid, existing);
|
|
let owner: String =
|
|
sqlx::query_scalar("SELECT uuid FROM reserved_usernames WHERE name='legacyplayer'").fetch_one(&pool).await.unwrap();
|
|
assert_eq!(owner, existing);
|
|
}
|
|
}
|