move players and accounts into postgres

json stores are gone, they nulled out every data ref on load and wrote the null straight back on save.
shop json and the purchase commands ended up in here too.
This commit is contained in:
WiseDev 2026-08-23 08:40:13 +03:00
parent d25a6de423
commit ad5a1613e3
38 changed files with 2929 additions and 442 deletions

1458
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@ service-rpc = { path = "crates/service-rpc" }
auth-service = { path = "crates/auth-service" } auth-service = { path = "crates/auth-service" }
game-service = { path = "crates/game-service" } game-service = { path = "crates/game-service" }
gateway = { path = "crates/gateway" } gateway = { path = "crates/gateway" }
storage = { path = "crates/storage" }
tokio = { version = "1.45", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "signal", "fs"] } tokio = { version = "1.45", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "signal", "fs"] }
thiserror = "2" thiserror = "2"
@ -27,6 +28,7 @@ serde_json = "1"
rand = "0.8" rand = "0.8"
async-trait = "0.1" async-trait = "0.1"
inventory = "0.3" inventory = "0.3"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "migrate"] }
proc-macro2 = "1" proc-macro2 = "1"
quote = "1" quote = "1"
syn = { version = "2", features = ["full", "extra-traits"] } syn = { version = "2", features = ["full", "extra-traits"] }

12
config/shop.example.json Normal file
View file

@ -0,0 +1,12 @@
{
"offers": [
{ "id": 1, "give": "Gold#1000", "cost": "Diamonds#10" },
{ "id": 2, "give": "Gold#10000", "cost": "Diamonds#80" },
{ "id": 3, "give": "Diamonds#500", "cost": "Gold#20000" },
{ "id": 4, "give": "Knight#10", "cost": "Gold#500" },
{ "id": 5, "give": "Witch#4", "cost": "Gold#2000" },
{ "id": 6, "give": "chest:Silver#1", "cost": "Diamonds#5" },
{ "id": 7, "give": "chest:Gold#1", "cost": "Diamonds#20" },
{ "id": 8, "give": "chest:Magic#1", "cost": "Diamonds#230" }
]
}

View file

@ -7,6 +7,7 @@ description = "Account identity and session issuing service"
[dependencies] [dependencies]
service-rpc = { workspace = true } service-rpc = { workspace = true }
storage = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }

View file

@ -1,8 +1,8 @@
use std::path::PathBuf; use storage::DatabaseConfig;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AuthConfig { pub struct AuthConfig {
pub listen: String, pub listen: String,
pub store_path: PathBuf, pub database: Option<DatabaseConfig>,
pub min_client_build: i32, pub min_client_build: i32,
pub max_client_build: i32, pub max_client_build: i32,
pub content_fingerprint: Option<String>, pub content_fingerprint: Option<String>,
@ -12,7 +12,7 @@ impl Default for AuthConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
listen: "127.0.0.1:9401".to_owned(), listen: "127.0.0.1:9401".to_owned(),
store_path: PathBuf::from("data/accounts.json"), database: None,
min_client_build: 0, min_client_build: 0,
max_client_build: i32::MAX, max_client_build: i32::MAX,
content_fingerprint: None, content_fingerprint: None,
@ -25,9 +25,7 @@ impl AuthConfig {
let defaults = Self::default(); let defaults = Self::default();
Self { Self {
listen: env_string("SCROLL_AUTH_LISTEN").unwrap_or(defaults.listen), listen: env_string("SCROLL_AUTH_LISTEN").unwrap_or(defaults.listen),
store_path: env_string("SCROLL_AUTH_STORE") database: DatabaseConfig::from_env(),
.map(PathBuf::from)
.unwrap_or(defaults.store_path),
min_client_build: env_i32("SCROLL_MIN_CLIENT_BUILD") min_client_build: env_i32("SCROLL_MIN_CLIENT_BUILD")
.unwrap_or(defaults.min_client_build), .unwrap_or(defaults.min_client_build),
max_client_build: env_i32("SCROLL_MAX_CLIENT_BUILD") max_client_build: env_i32("SCROLL_MAX_CLIENT_BUILD")

View file

@ -4,4 +4,4 @@ pub mod store;
pub mod time; pub mod time;
pub use config::AuthConfig; pub use config::AuthConfig;
pub use service::AuthService; pub use service::AuthService;
pub use store::{Account, AccountStore}; pub use store::{generate_pass_token, Account, AccountStore};

View file

@ -4,27 +4,30 @@ use service_rpc::{
RpcService, Session, RpcService, Session,
}; };
use crate::config::AuthConfig; use crate::config::AuthConfig;
use crate::store::AccountStore; use crate::store::{unavailable, AccountStore};
use crate::time::{days_between, format_utc, unix_millis, unix_seconds}; use crate::time::{days_between, format_utc, unix_millis, unix_seconds};
pub fn missing_database() -> std::io::Error {
std::io::Error::other(
"DATABASE_URL is not set, point it at a postgres database (postgres:///scroll)",
)
}
pub const ERROR_CODE_GENERIC: i32 = 1; pub const ERROR_CODE_GENERIC: i32 = 1;
pub const ERROR_CODE_PATCH: i32 = 7; pub const ERROR_CODE_PATCH: i32 = 7;
pub const ERROR_CODE_CLIENT_TOO_OLD: i32 = 8; pub const ERROR_CODE_CLIENT_TOO_OLD: i32 = 8;
pub const ERROR_CODE_BANNED: i32 = 11; pub const ERROR_CODE_BANNED: i32 = 11;
pub struct AuthService { pub struct AuthService {
config: AuthConfig, config: AuthConfig,
store: Arc<AccountStore>, store: AccountStore,
} }
impl AuthService { impl AuthService {
pub async fn bootstrap(config: AuthConfig) -> std::io::Result<Arc<Self>> { pub async fn bootstrap(config: AuthConfig) -> std::io::Result<Arc<Self>> {
let store = Arc::new(AccountStore::open(&config.store_path).await?); let database = config.database.as_ref().ok_or_else(missing_database)?;
tracing::info!( let store = AccountStore::open(database).await?;
accounts = store.len().await, let accounts = store.count().await?;
store = %config.store_path.display(), tracing::info!(accounts, "account store ready");
"account store ready"
);
Ok(Arc::new(Self { config, store })) Ok(Arc::new(Self { config, store }))
} }
pub fn store(&self) -> &Arc<AccountStore> { pub fn store(&self) -> &AccountStore {
&self.store &self.store
} }
fn version_verdict(&self, device: &DeviceInfo) -> Option<(i32, Option<String>)> { fn version_verdict(&self, device: &DeviceInfo) -> Option<(i32, Option<String>)> {
@ -67,7 +70,7 @@ impl AuthApi for AuthService {
let stored = if account.is_zero() || pass_token.is_none() { let stored = if account.is_zero() || pass_token.is_none() {
None None
} else { } else {
self.store.get(account).await self.store.get(account).await.map_err(unavailable)?
}; };
let stored = match stored { let stored = match stored {
Some(existing) => { Some(existing) => {
@ -88,7 +91,7 @@ impl AuthApi for AuthService {
existing existing
} }
None => { None => {
let created = self.store.create().await?; let created = self.store.create().await.map_err(unavailable)?;
tracing::info!(account = %created.account_ref(), "created account"); tracing::info!(account = %created.account_ref(), "created account");
created created
} }
@ -96,7 +99,8 @@ impl AuthApi for AuthService {
let refreshed = self let refreshed = self
.store .store
.touch_session(stored.account_ref()) .touch_session(stored.account_ref())
.await? .await
.map_err(unavailable)?
.unwrap_or(stored); .unwrap_or(stored);
let now = unix_seconds(); let now = unix_seconds();
Ok(LoginOutcome::Accepted(Session { Ok(LoginOutcome::Accepted(Session {
@ -110,7 +114,12 @@ impl AuthApi for AuthService {
})) }))
} }
async fn resolve(&self, account: AccountRef) -> RpcResult<bool> { async fn resolve(&self, account: AccountRef) -> RpcResult<bool> {
Ok(self.store.get(account).await.is_some()) Ok(self
.store
.get(account)
.await
.map_err(unavailable)?
.is_some())
} }
} }
#[async_trait::async_trait] #[async_trait::async_trait]

View file

@ -1,141 +0,0 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use rand::distributions::Alphanumeric;
use rand::Rng;
use serde::{Deserialize, Serialize};
use service_rpc::AccountRef;
use tokio::sync::RwLock;
use crate::time::unix_seconds;
pub const PASS_TOKEN_LEN: usize = 40;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
pub high: i32,
pub low: i32,
pub pass_token: String,
pub created_at: i64,
pub last_seen_at: i64,
pub session_count: i32,
pub play_time_seconds: i32,
#[serde(default)]
pub banned: bool,
}
impl Account {
pub fn account_ref(&self) -> AccountRef {
AccountRef::new(self.high, self.low)
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct StoreSnapshot {
next_low: i32,
accounts: Vec<Account>,
}
pub struct AccountStore {
path: PathBuf,
state: RwLock<StoreState>,
}
#[derive(Default)]
struct StoreState {
next_low: i32,
accounts: HashMap<AccountRef, Account>,
}
impl AccountStore {
pub async fn open(path: impl AsRef<Path>) -> std::io::Result<Self> {
let path = path.as_ref().to_path_buf();
let snapshot = match tokio::fs::read(&path).await {
Ok(bytes) => serde_json::from_slice::<StoreSnapshot>(&bytes).unwrap_or_default(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => StoreSnapshot::default(),
Err(error) => return Err(error),
};
let mut accounts = HashMap::with_capacity(snapshot.accounts.len());
for account in snapshot.accounts {
accounts.insert(account.account_ref(), account);
}
Ok(Self {
path,
state: RwLock::new(StoreState {
next_low: snapshot.next_low.max(1),
accounts,
}),
})
}
pub async fn create(&self) -> std::io::Result<Account> {
let now = unix_seconds();
let account = {
let mut state = self.state.write().await;
let low = state.next_low;
state.next_low = state.next_low.wrapping_add(1).max(1);
let account = Account {
high: 0,
low,
pass_token: generate_pass_token(),
created_at: now,
last_seen_at: now,
session_count: 0,
play_time_seconds: 0,
banned: false,
};
state
.accounts
.insert(account.account_ref(), account.clone());
account
};
self.persist().await?;
Ok(account)
}
pub async fn get(&self, account: AccountRef) -> Option<Account> {
self.state.read().await.accounts.get(&account).cloned()
}
pub async fn touch_session(&self, account: AccountRef) -> std::io::Result<Option<Account>> {
let updated = {
let mut state = self.state.write().await;
match state.accounts.get_mut(&account) {
None => None,
Some(stored) => {
let now = unix_seconds();
stored.play_time_seconds = stored
.play_time_seconds
.saturating_add(((now - stored.last_seen_at).clamp(0, 3_600)) as i32);
stored.last_seen_at = now;
stored.session_count = stored.session_count.saturating_add(1);
Some(stored.clone())
}
}
};
if updated.is_some() {
self.persist().await?;
}
Ok(updated)
}
pub async fn len(&self) -> usize {
self.state.read().await.accounts.len()
}
pub async fn is_empty(&self) -> bool {
self.state.read().await.accounts.is_empty()
}
async fn persist(&self) -> std::io::Result<()> {
let snapshot = {
let state = self.state.read().await;
StoreSnapshot {
next_low: state.next_low,
accounts: state.accounts.values().cloned().collect(),
}
};
let encoded = serde_json::to_vec_pretty(&snapshot)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
if let Some(parent) = self.path.parent() {
if !parent.as_os_str().is_empty() {
tokio::fs::create_dir_all(parent).await?;
}
}
let temporary = self.path.with_extension("json.tmp");
tokio::fs::write(&temporary, &encoded).await?;
tokio::fs::rename(&temporary, &self.path).await
}
}
pub fn generate_pass_token() -> String {
rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(PASS_TOKEN_LEN)
.map(char::from)
.collect()
}

View file

@ -0,0 +1,33 @@
mod postgres;
use rand::distributions::Alphanumeric;
use rand::Rng;
use service_rpc::{AccountRef, RpcError};
use storage::StorageError;
pub use postgres::AccountStore;
pub const PASS_TOKEN_LEN: usize = 40;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Account {
pub high: i32,
pub low: i32,
pub pass_token: String,
pub created_at: i64,
pub last_seen_at: i64,
pub session_count: i32,
pub play_time_seconds: i32,
pub banned: bool,
}
impl Account {
pub fn account_ref(&self) -> AccountRef {
AccountRef::new(self.high, self.low)
}
}
pub fn unavailable(error: StorageError) -> RpcError {
RpcError::Unavailable(error.to_string())
}
pub fn generate_pass_token() -> String {
rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(PASS_TOKEN_LEN)
.map(char::from)
.collect()
}

View file

@ -0,0 +1,77 @@
use service_rpc::AccountRef;
use storage::{Database, DatabaseConfig, Result, Row};
use crate::store::{generate_pass_token, Account};
use crate::time::unix_seconds;
pub struct AccountStore {
database: Database,
}
impl AccountStore {
pub async fn open(config: &DatabaseConfig) -> Result<Self> {
let database = Database::connect(config).await?;
database.migrate().await?;
Ok(Self { database })
}
}
fn account_from_row(row: &storage::PgRow) -> Account {
Account {
high: row.get("account_high"),
low: row.get("account_low"),
pass_token: row.get("pass_token"),
created_at: row.get("created_at"),
last_seen_at: row.get("last_seen_at"),
session_count: row.get("session_count"),
play_time_seconds: row.get("play_time_seconds"),
banned: row.get("banned"),
}
}
impl AccountStore {
pub async fn create(&self) -> Result<Account> {
let now = unix_seconds();
let row = storage::sqlx::query(
"insert into accounts (
account_high, account_low, pass_token, created_at, last_seen_at,
session_count, play_time_seconds, banned
)
values (0, nextval('accounts_low_seq')::integer, $1, $2, $2, 0, 0, false)
returning *",
)
.bind(generate_pass_token())
.bind(now)
.fetch_one(self.database.pool())
.await?;
Ok(account_from_row(&row))
}
pub async fn get(&self, account: AccountRef) -> Result<Option<Account>> {
let row = storage::sqlx::query(
"select * from accounts where account_high = $1 and account_low = $2",
)
.bind(account.high)
.bind(account.low)
.fetch_optional(self.database.pool())
.await?;
Ok(row.as_ref().map(account_from_row))
}
pub async fn touch_session(&self, account: AccountRef) -> Result<Option<Account>> {
let row = storage::sqlx::query(
"update accounts
set play_time_seconds = play_time_seconds
+ least(greatest($3 - last_seen_at, 0), 3600)::integer,
last_seen_at = $3,
session_count = session_count + 1
where account_high = $1 and account_low = $2
returning *",
)
.bind(account.high)
.bind(account.low)
.bind(unix_seconds())
.fetch_optional(self.database.pool())
.await?;
Ok(row.as_ref().map(account_from_row))
}
pub async fn count(&self) -> Result<usize> {
let total: i64 = storage::sqlx::query_scalar("select count(*) from accounts")
.fetch_one(self.database.pool())
.await?;
Ok(total.max(0) as usize)
}
}

View file

@ -7,8 +7,9 @@ description = "Home and avatar state service that produces scroll lobby payloads
[dependencies] [dependencies]
titan = { workspace = true } titan = { workspace = true }
logic = { workspace = true, features = ["serde"] } logic = { workspace = true }
service-rpc = { workspace = true } service-rpc = { workspace = true }
storage = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }

View file

@ -1,5 +1,6 @@
use std::path::PathBuf; use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use storage::DatabaseConfig;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum DataSelector { pub enum DataSelector {
@ -93,9 +94,10 @@ impl Default for ChestRewardConfig {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GameConfig { pub struct GameConfig {
pub database: Option<DatabaseConfig>,
pub listen: String, pub listen: String,
pub store_path: PathBuf,
pub csv_root: Option<PathBuf>, pub csv_root: Option<PathBuf>,
pub shop_path: Option<PathBuf>,
pub starter: StarterProfile, pub starter: StarterProfile,
pub chest_reward: ChestRewardConfig, pub chest_reward: ChestRewardConfig,
pub random_seed: i32, pub random_seed: i32,
@ -103,9 +105,10 @@ pub struct GameConfig {
impl Default for GameConfig { impl Default for GameConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
database: None,
listen: "127.0.0.1:9402".to_owned(), listen: "127.0.0.1:9402".to_owned(),
store_path: PathBuf::from("data/players.json"),
csv_root: Some(PathBuf::from("assets")), csv_root: Some(PathBuf::from("assets")),
shop_path: Some(PathBuf::from("config/shop.json")),
starter: StarterProfile::default(), starter: StarterProfile::default(),
chest_reward: ChestRewardConfig::default(), chest_reward: ChestRewardConfig::default(),
random_seed: 0x5EED, random_seed: 0x5EED,
@ -123,13 +126,14 @@ impl GameConfig {
None => defaults.starter, None => defaults.starter,
}; };
Self { Self {
database: DatabaseConfig::from_env(),
listen: env_string("SCROLL_GAME_LISTEN").unwrap_or(defaults.listen), listen: env_string("SCROLL_GAME_LISTEN").unwrap_or(defaults.listen),
store_path: env_string("SCROLL_GAME_STORE")
.map(PathBuf::from)
.unwrap_or(defaults.store_path),
csv_root: env_string("SCROLL_CSV_ROOT") csv_root: env_string("SCROLL_CSV_ROOT")
.map(PathBuf::from) .map(PathBuf::from)
.or(defaults.csv_root), .or(defaults.csv_root),
shop_path: env_string("SCROLL_SHOP")
.map(PathBuf::from)
.or(defaults.shop_path),
starter, starter,
chest_reward: defaults.chest_reward, chest_reward: defaults.chest_reward,
random_seed: env_string("SCROLL_RANDOM_SEED") random_seed: env_string("SCROLL_RANDOM_SEED")

View file

@ -1,9 +1,12 @@
use logic::model::{ use logic::model::{
LogicClientAvatar, LogicClientHome, LogicCommodityStore, LogicDataSlot, LogicSpell, LogicChest, LogicClientAvatar, LogicClientHome, LogicCommodityStore, LogicDataSlot, LogicSpell,
LogicSpellCollection, LogicSpellDeck, LogicTimer, LogicSpellCollection, LogicSpellDeck, LogicTimer,
}; };
use titan::LogicLong; use titan::LogicLong;
use crate::profile::{OwnedCard, PlayerProfile}; use logic::data::RESOURCE_GOLD;
use logic::{table, LogicDataRef};
use crate::catalog::GOLD_RESOURCE_FALLBACK_INSTANCE;
use crate::store::{OwnedCard, PlayerProfile, StoredChest};
pub const COMMODITY_RESOURCES: usize = 0; pub const COMMODITY_RESOURCES: usize = 0;
fn spell_of(owned: &OwnedCard) -> LogicSpell { fn spell_of(owned: &OwnedCard) -> LogicSpell {
LogicSpell { LogicSpell {
@ -17,25 +20,53 @@ fn spell_of(owned: &OwnedCard) -> LogicSpell {
show_new_icon: false, show_new_icon: false,
} }
} }
fn chest_of(stored: &StoredChest) -> LogicChest {
LogicChest {
data: stored.chest.clone(),
unlocked: stored.unlocked,
claimed: stored.claimed,
is_new: false,
unlock_timer: (stored.remaining_ticks > 0).then_some(LogicTimer {
remaining_ticks: stored.remaining_ticks,
total_ticks: stored.total_ticks,
end_timestamp: stored.end_timestamp,
}),
chest_id: stored.chest_id,
source: stored.source,
slot_index: stored.slot_index,
}
}
fn chest_slots(profile: &PlayerProfile) -> Vec<Option<LogicChest>> {
let mut slots = vec![None; profile.chest_slot_count];
for stored in &profile.chests {
let Ok(index) = usize::try_from(stored.slot_index) else {
continue;
};
if index < slots.len() {
slots[index] = Some(chest_of(stored));
}
}
slots
}
pub fn build_home(profile: &PlayerProfile) -> LogicClientHome { pub fn build_home(profile: &PlayerProfile) -> LogicClientHome {
let deck = LogicSpellDeck::from_cards(profile.deck.iter().map(spell_of)); let deck = LogicSpellDeck::from_cards(profile.deck.iter().map(spell_of));
let collection = let collection =
LogicSpellCollection::from_spells(profile.collection.iter().map(spell_of).collect()); LogicSpellCollection::from_spells(profile.collection.iter().map(spell_of).collect());
let mut home = LogicClientHome { let mut home = LogicClientHome {
home_id: LogicLong::new(profile.account.high, profile.account.low), home_id: LogicLong::new(profile.account.high, profile.account.low),
chest_id_counter: 0, chest_id_counter: profile.chest_id_counter,
free_chest_collect_count: 1, free_chest_collect_count: profile.free_chest_collect_count,
donation_capacity_limit_timer: LogicTimer::idle(), donation_capacity_limit_timer: LogicTimer::idle(),
donated_capacity: 0, donated_capacity: 0,
decks: vec![deck], decks: vec![deck],
spell_collection: collection, spell_collection: collection,
selected_deck_index: 0, selected_deck_index: 0,
chest_slots: vec![None; profile.chest_slot_count], chest_slots: chest_slots(profile),
free_chest_timer: LogicTimer::idle(), free_chest_timer: LogicTimer::idle(),
donation_cooldown_timer: LogicTimer::idle(), donation_cooldown_timer: LogicTimer::idle(),
free_chest: None, free_chest: None,
purchased_chest: None, purchased_chest: None,
crowns_towards_crown_chest: 0, crowns_towards_crown_chest: profile.crowns_towards_crown_chest,
star_chest_timer_pending: false, star_chest_timer_pending: false,
star_chest_timer: LogicTimer::idle(), star_chest_timer: LogicTimer::idle(),
crown_chest: None, crown_chest: None,
@ -66,15 +97,22 @@ pub fn build_home(profile: &PlayerProfile) -> LogicClientHome {
} }
home home
} }
fn gold_resource(profile: &PlayerProfile) -> LogicDataRef {
if !profile.gold_resource.is_none() {
return profile.gold_resource.clone();
}
let by_name = LogicDataRef::by_name(table::RESOURCES, RESOURCE_GOLD);
if !by_name.is_none() {
return by_name;
}
LogicDataRef::of(table::RESOURCES, GOLD_RESOURCE_FALLBACK_INSTANCE)
}
pub fn build_avatar(profile: &PlayerProfile) -> LogicClientAvatar { pub fn build_avatar(profile: &PlayerProfile) -> LogicClientAvatar {
let account = LogicLong::new(profile.account.high, profile.account.low); let account = LogicLong::new(profile.account.high, profile.account.low);
let mut commodities = LogicCommodityStore::default(); let mut commodities = LogicCommodityStore::default();
commodities.set( commodities.set(
COMMODITY_RESOURCES, COMMODITY_RESOURCES,
vec![LogicDataSlot::new( vec![LogicDataSlot::new(gold_resource(profile), profile.gold)],
profile.gold_resource.clone(),
profile.gold,
)],
); );
LogicClientAvatar { LogicClientAvatar {
avatar_id: account, avatar_id: account,

View file

@ -1,19 +1,23 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use logic::data::{RESOURCE_DIAMONDS, RESOURCE_GOLD};
use logic::{ use logic::{
AvailableServerCommandMessage, CommandOutcome, EndClientTurnMessage, LogicClaimRewardCommand, chest_source, AvailableServerCommandMessage, CommandOutcome, EndClientTurnMessage,
LogicHomeMode, OutOfSyncMessage, OwnHomeDataMessage, LogicClaimRewardCommand, LogicDataRef, LogicHomeMode, LogicTimer, OutOfSyncMessage,
OwnHomeDataMessage,
}; };
use service_rpc::AccountRef; use service_rpc::AccountRef;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use crate::config::ChestRewardConfig; use crate::config::ChestRewardConfig;
use crate::home::{build_avatar, build_home}; use crate::home::{build_avatar, build_home};
use crate::profile::{OwnedCard, PlayerProfile};
use crate::rewards::RewardRoller; use crate::rewards::RewardRoller;
use crate::shop::{ShopCatalog, ShopOffer};
use crate::store::{OwnedCard, PlayerProfile, StoredChest};
pub const MAX_FAST_FORWARD_TICKS: i32 = 20 * 60 * 60; pub const MAX_FAST_FORWARD_TICKS: i32 = 20 * 60 * 60;
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct TurnResult { pub struct TurnResult {
pub claims: Vec<LogicClaimRewardCommand>, pub claims: Vec<LogicClaimRewardCommand>,
pub purchases: Vec<ShopOffer>,
pub out_of_sync: Option<OutOfSyncMessage>, pub out_of_sync: Option<OutOfSyncMessage>,
pub changed: bool, pub changed: bool,
} }
@ -40,11 +44,44 @@ impl HomeMode {
random_seed, random_seed,
) )
} }
fn purchase(
&mut self,
data: &LogicDataRef,
count: i32,
shop: &ShopCatalog,
) -> Result<ShopOffer, &'static str> {
let offer = shop
.offer_for(data)
.filter(|offer| offer.give.count == count)
.ok_or("nothing in the shop sells that")?
.clone();
if !offer.cost.is_resource() {
return Err("the price must be a resource");
}
match offer.cost.data.name() {
RESOURCE_GOLD if self.logic.spend_gold(offer.cost.count) => {}
RESOURCE_DIAMONDS if self.logic.spend_diamonds(offer.cost.count) => {}
RESOURCE_GOLD | RESOURCE_DIAMONDS => return Err("the player cannot afford it"),
_ => return Err("only gold and diamonds are accepted"),
}
if offer.give.is_resource() {
match offer.give.data.name() {
RESOURCE_GOLD => self.logic.add_gold(offer.give.count),
RESOURCE_DIAMONDS => self.logic.add_diamonds(offer.give.count),
_ => return Err("only gold and diamonds can be sold"),
}
} else if offer.give.is_card() {
self.logic
.grant_card(offer.give.data.clone(), offer.give.count);
}
Ok(offer)
}
pub fn end_client_turn( pub fn end_client_turn(
&mut self, &mut self,
turn: &EndClientTurnMessage, turn: &EndClientTurnMessage,
reward: &ChestRewardConfig, reward: &ChestRewardConfig,
roller: &RewardRoller, roller: &RewardRoller,
shop: &ShopCatalog,
) -> TurnResult { ) -> TurnResult {
let mut result = TurnResult::default(); let mut result = TurnResult::default();
let ticked = self let ticked = self
@ -74,6 +111,25 @@ impl HomeMode {
"client command rejected" "client command rejected"
); );
} }
CommandOutcome::PurchaseRequested { data, count } => {
match self.purchase(&data, count, shop) {
Ok(offer) => {
if offer.give.is_chest() {
pending.push((chest_source::PURCHASED, 0));
}
result.changed = true;
result.purchases.push(offer);
}
Err(reason) => {
tracing::warn!(
command = command.command_type(),
item = %data,
reason,
"shop purchase rejected"
);
}
}
}
CommandOutcome::Applied => result.changed = true, CommandOutcome::Applied => result.changed = true,
CommandOutcome::Ignored => {} CommandOutcome::Ignored => {}
} }
@ -131,6 +187,29 @@ impl HomeMode {
count: spell.count, count: spell.count,
}) })
.collect(); .collect();
profile.chest_id_counter = home.chest_id_counter;
profile.free_chest_collect_count = home.free_chest_collect_count;
profile.crowns_towards_crown_chest = home.crowns_towards_crown_chest;
profile.chest_slot_count = home.chest_slots.len();
profile.chests = home
.chest_slots
.iter()
.flatten()
.map(|chest| {
let timer = chest.unlock_timer.unwrap_or(LogicTimer::idle());
StoredChest {
chest: chest.data.clone(),
chest_id: chest.chest_id,
slot_index: chest.slot_index,
source: chest.source,
unlocked: chest.unlocked,
claimed: chest.claimed,
remaining_ticks: timer.remaining_ticks,
total_ticks: timer.total_ticks,
end_timestamp: timer.end_timestamp,
}
})
.collect();
} }
} }
#[derive(Default)] #[derive(Default)]

View file

@ -2,14 +2,16 @@ pub mod catalog;
pub mod config; pub mod config;
pub mod home; pub mod home;
pub mod home_mode; pub mod home_mode;
pub mod profile;
pub mod rewards; pub mod rewards;
pub mod service; pub mod service;
pub mod shop;
pub mod store;
pub mod time; pub mod time;
pub use catalog::{Catalog, ARENA_FALLBACK_INSTANCE, GOLD_RESOURCE_FALLBACK_INSTANCE}; pub use catalog::{Catalog, ARENA_FALLBACK_INSTANCE, GOLD_RESOURCE_FALLBACK_INSTANCE};
pub use config::{CardRef, ChestRewardConfig, DataSelector, GameConfig, StarterProfile}; pub use config::{CardRef, ChestRewardConfig, DataSelector, GameConfig, StarterProfile};
pub use home::{build_avatar, build_home}; pub use home::{build_avatar, build_home};
pub use home_mode::{HomeMode, HomeModeRegistry, TurnResult}; pub use home_mode::{HomeMode, HomeModeRegistry, TurnResult};
pub use profile::{PlayerProfile, ProfileStore};
pub use rewards::RewardRoller; pub use rewards::RewardRoller;
pub use service::GameService; pub use service::GameService;
pub use shop::{ShopCatalog, ShopEntry, ShopOffer};
pub use store::{PlayerProfile, ProfileStore};

View file

@ -1,150 +0,0 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use service_rpc::AccountRef;
use tokio::sync::RwLock;
use logic::LogicDataRef;
use crate::catalog::Catalog;
use crate::config::StarterProfile;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OwnedCard {
pub card: LogicDataRef,
pub level_index: i32,
pub count: i32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProfile {
pub account: AccountRef,
pub name: String,
pub name_set_by_user: bool,
pub exp_level: i32,
pub exp_points: i32,
pub gold: i32,
pub diamonds: i32,
pub free_diamonds: i32,
pub trophies: i32,
pub arena: LogicDataRef,
pub chest_slot_count: usize,
pub deck: Vec<OwnedCard>,
pub collection: Vec<OwnedCard>,
pub battle_count: i32,
pub win_count: i32,
pub lose_count: i32,
pub npc_win_count: i32,
pub npc_lose_count: i32,
pub three_crown_wins: i32,
pub tutorials_finished: bool,
pub gold_resource: LogicDataRef,
}
impl PlayerProfile {
pub fn starter(account: AccountRef, starter: &StarterProfile, catalog: &Catalog) -> Self {
let card_of = |card: &crate::config::CardRef| {
catalog.resolve_card(card).map(|resolved| OwnedCard {
card: resolved,
level_index: starter.card_level_index,
count: starter.card_count,
})
};
Self {
account,
name: format!("{}{}", starter.name_prefix, account.low),
name_set_by_user: false,
exp_level: starter.exp_level,
exp_points: starter.exp_points,
gold: starter.gold,
diamonds: starter.diamonds,
free_diamonds: starter.free_diamonds,
trophies: starter.trophies,
arena: catalog.resolve_arena(&starter.arena),
chest_slot_count: starter.chest_slot_count,
deck: starter.deck.iter().filter_map(card_of).collect(),
collection: starter
.extra_collection
.iter()
.filter_map(card_of)
.collect(),
battle_count: 0,
win_count: 0,
lose_count: 0,
npc_win_count: starter.npc_win_count,
npc_lose_count: starter.npc_lose_count,
three_crown_wins: 0,
tutorials_finished: starter.finish_tutorials,
gold_resource: catalog.resolve_resource(&starter.gold_resource),
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct StoreSnapshot {
profiles: Vec<PlayerProfile>,
}
pub struct ProfileStore {
path: PathBuf,
profiles: RwLock<HashMap<AccountRef, PlayerProfile>>,
}
impl ProfileStore {
pub async fn open(path: impl AsRef<Path>) -> std::io::Result<Self> {
let path = path.as_ref().to_path_buf();
let snapshot = match tokio::fs::read(&path).await {
Ok(bytes) => serde_json::from_slice::<StoreSnapshot>(&bytes).unwrap_or_default(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => StoreSnapshot::default(),
Err(error) => return Err(error),
};
let profiles = snapshot
.profiles
.into_iter()
.map(|profile| (profile.account, profile))
.collect();
Ok(Self {
path,
profiles: RwLock::new(profiles),
})
}
pub async fn get_or_create(
&self,
account: AccountRef,
starter: &StarterProfile,
catalog: &Catalog,
) -> std::io::Result<PlayerProfile> {
if let Some(profile) = self.profiles.read().await.get(&account) {
return Ok(profile.clone());
}
let profile = {
let mut profiles = self.profiles.write().await;
profiles
.entry(account)
.or_insert_with(|| PlayerProfile::starter(account, starter, catalog))
.clone()
};
self.persist().await?;
Ok(profile)
}
pub async fn get(&self, account: AccountRef) -> Option<PlayerProfile> {
self.profiles.read().await.get(&account).cloned()
}
pub async fn save(&self, profile: PlayerProfile) -> std::io::Result<()> {
self.profiles.write().await.insert(profile.account, profile);
self.persist().await
}
pub async fn len(&self) -> usize {
self.profiles.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.profiles.read().await.is_empty()
}
async fn persist(&self) -> std::io::Result<()> {
let snapshot = StoreSnapshot {
profiles: self.profiles.read().await.values().cloned().collect(),
};
let encoded = serde_json::to_vec_pretty(&snapshot)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
if let Some(parent) = self.path.parent() {
if !parent.as_os_str().is_empty() {
tokio::fs::create_dir_all(parent).await?;
}
}
let temporary = self.path.with_extension("json.tmp");
tokio::fs::write(&temporary, &encoded).await?;
tokio::fs::rename(&temporary, &self.path).await
}
}

View file

@ -10,22 +10,30 @@ use titan::{Message, MessageMeta, Payload};
use crate::catalog::Catalog; use crate::catalog::Catalog;
use crate::config::GameConfig; use crate::config::GameConfig;
use crate::home_mode::{available_server_command, HomeModeRegistry}; use crate::home_mode::{available_server_command, HomeModeRegistry};
use crate::profile::ProfileStore;
use crate::rewards::RewardRoller; use crate::rewards::RewardRoller;
use crate::shop::ShopCatalog;
use crate::store::{missing_database, unavailable, PlayerProfile, ProfileStore};
use crate::time::unix_seconds; use crate::time::unix_seconds;
pub struct GameService { pub struct GameService {
config: GameConfig, config: GameConfig,
profiles: Arc<ProfileStore>, profiles: ProfileStore,
catalog: Arc<Catalog>, catalog: Arc<Catalog>,
shop: Arc<ShopCatalog>,
sessions: HomeModeRegistry, sessions: HomeModeRegistry,
} }
impl GameService { impl GameService {
pub async fn bootstrap(config: GameConfig) -> std::io::Result<Arc<Self>> { pub async fn bootstrap(config: GameConfig) -> std::io::Result<Arc<Self>> {
let profiles = Arc::new(ProfileStore::open(&config.store_path).await?);
let catalog = Arc::new(Catalog::load(config.csv_root.as_deref())); let catalog = Arc::new(Catalog::load(config.csv_root.as_deref()));
let database = config.database.as_ref().ok_or_else(missing_database)?;
let profiles = ProfileStore::open(database).await?;
let profile_count = profiles.count().await?;
let shop = Arc::new(ShopCatalog::load(config.shop_path.as_deref()));
for offer in shop.describe() {
tracing::debug!(offer, "shop offer");
}
tracing::info!( tracing::info!(
profiles = profiles.len().await, profiles = profile_count,
store = %config.store_path.display(), offers = shop.offers.len(),
catalog = catalog.is_loaded(), catalog = catalog.is_loaded(),
commands = LogicCommandManager::registry().len(), commands = LogicCommandManager::registry().len(),
"profile store ready" "profile store ready"
@ -34,20 +42,31 @@ impl GameService {
config, config,
profiles, profiles,
catalog, catalog,
shop,
sessions: HomeModeRegistry::default(), sessions: HomeModeRegistry::default(),
})) }))
} }
pub fn profiles(&self) -> &Arc<ProfileStore> { pub fn profiles(&self) -> &ProfileStore {
&self.profiles &self.profiles
} }
async fn profile(&self, account: AccountRef) -> RpcResult<PlayerProfile> {
if let Some(profile) = self.profiles.load(account).await.map_err(unavailable)? {
return Ok(profile);
}
let starter = PlayerProfile::starter(account, &self.config.starter, &self.catalog);
self.profiles
.create_if_absent(starter)
.await
.map_err(unavailable)
}
pub fn catalog(&self) -> &Arc<Catalog> { pub fn catalog(&self) -> &Arc<Catalog> {
&self.catalog &self.catalog
} }
pub fn shop(&self) -> &Arc<ShopCatalog> {
&self.shop
}
async fn roller(&self, account: AccountRef) -> RpcResult<RewardRoller> { async fn roller(&self, account: AccountRef) -> RpcResult<RewardRoller> {
let profile = self let profile = self.profile(account).await?;
.profiles
.get_or_create(account, &self.config.starter, &self.catalog)
.await?;
let fallback: Vec<LogicDataRef> = profile let fallback: Vec<LogicDataRef> = profile
.deck .deck
.iter() .iter()
@ -60,12 +79,9 @@ impl GameService {
let Some(session) = self.sessions.close(account).await else { let Some(session) = self.sessions.close(account).await else {
return Ok(()); return Ok(());
}; };
let mut profile = self let mut profile = self.profile(account).await?;
.profiles
.get_or_create(account, &self.config.starter, &self.catalog)
.await?;
session.lock().await.write_back(&mut profile); session.lock().await.write_back(&mut profile);
self.profiles.save(profile).await?; self.profiles.save(&profile).await.map_err(unavailable)?;
Ok(()) Ok(())
} }
} }
@ -89,10 +105,7 @@ impl GameApi for GameService {
account: AccountRef, account: AccountRef,
kind: HomeRequestKind, kind: HomeRequestKind,
) -> RpcResult<Vec<WireMessage>> { ) -> RpcResult<Vec<WireMessage>> {
let profile = self let profile = self.profile(account).await?;
.profiles
.get_or_create(account, &self.config.starter, &self.catalog)
.await?;
let session = self let session = self
.sessions .sessions
.get_or_open(account, &profile, unix_seconds() as i32) .get_or_open(account, &profile, unix_seconds() as i32)
@ -130,10 +143,7 @@ impl GameApi for GameService {
return Ok(Vec::new()); return Ok(Vec::new());
} }
}; };
let profile = self let profile = self.profile(account).await?;
.profiles
.get_or_create(account, &self.config.starter, &self.catalog)
.await?;
let session = self let session = self
.sessions .sessions
.get_or_open(account, &profile, unix_seconds() as i32) .get_or_open(account, &profile, unix_seconds() as i32)
@ -141,7 +151,7 @@ impl GameApi for GameService {
let roller = self.roller(account).await?; let roller = self.roller(account).await?;
let result = { let result = {
let mut home = session.lock().await; let mut home = session.lock().await;
home.end_client_turn(&turn, &self.config.chest_reward, &roller) home.end_client_turn(&turn, &self.config.chest_reward, &roller, &self.shop)
}; };
tracing::debug!( tracing::debug!(
%account, %account,
@ -161,6 +171,18 @@ impl GameApi for GameService {
); );
replies.push(encode::<OutOfSyncMessage>(&out_of_sync)?); replies.push(encode::<OutOfSyncMessage>(&out_of_sync)?);
} }
for offer in &result.purchases {
tracing::info!(
%account,
offer = offer.id,
cost = %offer.cost,
give = %offer.give,
"shop purchase"
);
if let Err(error) = self.profiles.record_purchase(account, offer).await {
tracing::warn!(%account, offer = offer.id, %error, "could not record the purchase");
}
}
for claim in result.claims { for claim in result.claims {
tracing::info!( tracing::info!(
%account, %account,
@ -173,12 +195,9 @@ impl GameApi for GameService {
replies.push(encode(&available_server_command(claim))?); replies.push(encode(&available_server_command(claim))?);
} }
if result.changed { if result.changed {
let mut stored = self let mut stored = self.profile(account).await?;
.profiles
.get_or_create(account, &self.config.starter, &self.catalog)
.await?;
session.lock().await.write_back(&mut stored); session.lock().await.write_back(&mut stored);
self.profiles.save(stored).await?; self.profiles.save(&stored).await.map_err(unavailable)?;
} }
Ok(replies) Ok(replies)
} }

View file

@ -0,0 +1,73 @@
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::shop::entry::ShopEntry;
pub const DEFAULT_SHOP: &str = include_str!("default_shop.json");
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShopOffer {
pub id: i32,
pub give: ShopEntry,
pub cost: ShopEntry,
}
impl ShopOffer {
pub fn sells(&self, data: &logic::LogicDataRef) -> bool {
&self.give.data == data
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShopCatalog {
#[serde(default)]
pub offers: Vec<ShopOffer>,
}
impl ShopCatalog {
pub fn builtin() -> Self {
match serde_json::from_str(DEFAULT_SHOP) {
Ok(catalog) => catalog,
Err(error) => {
tracing::warn!(%error, "the built in shop does not resolve against the loaded tables");
Self::default()
}
}
}
pub fn load(path: Option<&Path>) -> Self {
let Some(path) = path else {
return Self::builtin();
};
match std::fs::read(path) {
Ok(bytes) => match serde_json::from_slice::<Self>(&bytes) {
Ok(catalog) => {
tracing::info!(
path = %path.display(),
offers = catalog.offers.len(),
"shop catalog loaded"
);
catalog
}
Err(error) => {
tracing::warn!(
path = %path.display(),
%error,
"unreadable shop catalog, falling back to the built in one"
);
Self::builtin()
}
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Self::builtin(),
Err(error) => {
tracing::warn!(path = %path.display(), %error, "shop catalog is unreadable");
Self::builtin()
}
}
}
pub fn offer(&self, id: i32) -> Option<&ShopOffer> {
self.offers.iter().find(|offer| offer.id == id)
}
pub fn offer_for(&self, data: &logic::LogicDataRef) -> Option<&ShopOffer> {
self.offers.iter().find(|offer| offer.sells(data))
}
pub fn describe(&self) -> Vec<String> {
self.offers
.iter()
.map(|offer| format!("{} = {} -> {}", offer.id, offer.cost, offer.give))
.collect()
}
}

View file

@ -0,0 +1,12 @@
{
"offers": [
{ "id": 1, "give": "Gold#1000", "cost": "Diamonds#10" },
{ "id": 2, "give": "Gold#10000", "cost": "Diamonds#80" },
{ "id": 3, "give": "Diamonds#500", "cost": "Gold#20000" },
{ "id": 4, "give": "Knight#10", "cost": "Gold#500" },
{ "id": 5, "give": "Witch#4", "cost": "Gold#2000" },
{ "id": 6, "give": "chest:Silver#1", "cost": "Diamonds#5" },
{ "id": 7, "give": "chest:Gold#1", "cost": "Diamonds#20" },
{ "id": 8, "give": "chest:Magic#1", "cost": "Diamonds#230" }
]
}

View file

@ -0,0 +1,158 @@
use std::fmt;
use std::str::FromStr;
use logic::{table, LogicDataRef};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub const SEPARATOR: char = '#';
pub const QUALIFIER: char = ':';
pub const LOOKUP: &[(&str, i32)] = &[
("resource", table::RESOURCES),
("card", table::SPELLS),
("chest", table::TREASURE_CHESTS),
("arena", table::ARENAS),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EntryError {
Empty,
MissingCount(String),
BadCount(String),
UnknownQualifier(String),
UnknownName(String),
}
impl fmt::Display for EntryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EntryError::Empty => write!(f, "empty entry"),
EntryError::MissingCount(entry) => {
write!(f, "`{entry}` is missing its `{SEPARATOR}count` suffix")
}
EntryError::BadCount(count) => write!(f, "`{count}` is not a count"),
EntryError::UnknownQualifier(qualifier) => {
write!(
f,
"`{qualifier}` is not a table, expected one of {}",
aliases()
)
}
EntryError::UnknownName(name) => {
write!(f, "`{name}` matches no row in {}", aliases())
}
}
}
}
impl std::error::Error for EntryError {}
fn aliases() -> String {
LOOKUP
.iter()
.map(|(alias, _)| *alias)
.collect::<Vec<_>>()
.join(", ")
}
fn table_of(alias: &str) -> Option<i32> {
LOOKUP
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(alias))
.map(|(_, index)| *index)
}
fn alias_of(table_index: i32) -> Option<&'static str> {
LOOKUP
.iter()
.find(|(_, index)| *index == table_index)
.map(|(alias, _)| *alias)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ShopEntry {
pub data: LogicDataRef,
pub count: i32,
}
impl ShopEntry {
pub fn new(data: LogicDataRef, count: i32) -> Self {
Self { data, count }
}
pub fn resolve(name: &str, count: i32) -> Result<Self, EntryError> {
let (qualifier, bare) = match name.split_once(QUALIFIER) {
Some((qualifier, bare)) => (Some(qualifier.trim()), bare.trim()),
None => (None, name.trim()),
};
if bare.is_empty() {
return Err(EntryError::Empty);
}
let data = match qualifier {
Some(qualifier) => {
let table_index = table_of(qualifier)
.ok_or_else(|| EntryError::UnknownQualifier(qualifier.to_owned()))?;
LogicDataRef::by_name(table_index, bare)
}
None => LOOKUP
.iter()
.map(|(_, table_index)| LogicDataRef::by_name(*table_index, bare))
.find(|resolved| !resolved.is_none())
.unwrap_or_default(),
};
if data.is_none() {
return Err(EntryError::UnknownName(name.to_owned()));
}
Ok(Self { data, count })
}
pub fn is_resource(&self) -> bool {
self.data.belongs_to(table::RESOURCES)
}
pub fn is_card(&self) -> bool {
self.data.belongs_to(table::SPELLS)
}
pub fn is_chest(&self) -> bool {
self.data.belongs_to(table::TREASURE_CHESTS)
}
fn ambiguous(&self) -> bool {
let name = self.data.name();
let own = self.data.table_index();
LOOKUP.iter().any(|(_, table_index)| {
Some(*table_index) != own && !LogicDataRef::by_name(*table_index, name).is_none()
})
}
}
impl FromStr for ShopEntry {
type Err = EntryError;
fn from_str(entry: &str) -> Result<Self, Self::Err> {
let entry = entry.trim();
if entry.is_empty() {
return Err(EntryError::Empty);
}
let (name, count) = entry
.rsplit_once(SEPARATOR)
.ok_or_else(|| EntryError::MissingCount(entry.to_owned()))?;
let count = count
.trim()
.parse::<i32>()
.map_err(|_| EntryError::BadCount(count.trim().to_owned()))?;
Self::resolve(name, count)
}
}
impl fmt::Display for ShopEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self
.data
.table_index()
.and_then(alias_of)
.filter(|_| self.ambiguous())
{
Some(alias) => write!(
f,
"{alias}{QUALIFIER}{}{SEPARATOR}{}",
self.data.name(),
self.count
),
None => write!(f, "{}{SEPARATOR}{}", self.data.name(), self.count),
}
}
}
impl Serialize for ShopEntry {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for ShopEntry {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let entry = String::deserialize(deserializer)?;
entry.parse().map_err(serde::de::Error::custom)
}
}

View file

@ -0,0 +1,4 @@
mod catalog;
mod entry;
pub use catalog::{ShopCatalog, ShopOffer, DEFAULT_SHOP};
pub use entry::{EntryError, ShopEntry, LOOKUP, QUALIFIER, SEPARATOR};

View file

@ -0,0 +1,16 @@
mod postgres;
mod profile;
use service_rpc::RpcError;
use storage::StorageError;
pub use postgres::ProfileStore;
pub use profile::{
OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK,
};
pub fn unavailable(error: StorageError) -> RpcError {
RpcError::Unavailable(error.to_string())
}
pub fn missing_database() -> std::io::Error {
std::io::Error::other(
"DATABASE_URL is not set, point it at a postgres database (postgres:///scroll)",
)
}

View file

@ -0,0 +1,369 @@
use logic::LogicDataRef;
use service_rpc::AccountRef;
use storage::sqlx::{Postgres, QueryBuilder};
use storage::{Database, DatabaseConfig, PgRow, Result, Row};
use crate::shop::ShopOffer;
use crate::store::{
OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK,
};
use crate::time::unix_seconds;
pub struct ProfileStore {
database: Database,
}
impl ProfileStore {
pub async fn open(config: &DatabaseConfig) -> Result<Self> {
let database = Database::connect(config).await?;
database.migrate().await?;
Ok(Self { database })
}
async fn chests(&self, account: AccountRef) -> Result<Vec<StoredChest>> {
let rows = storage::sqlx::query(
"select * from player_chests
where account_high = $1 and account_low = $2
order by slot_index",
)
.bind(account.high)
.bind(account.low)
.fetch_all(self.database.pool())
.await?;
Ok(rows.iter().map(chest_from_row).collect())
}
async fn cards(&self, account: AccountRef) -> Result<(Vec<OwnedCard>, Vec<OwnedCard>)> {
let rows = storage::sqlx::query(
"select holder, card_table, card_instance, card_name, level_index, count
from player_cards
where account_high = $1 and account_low = $2
order by holder, position",
)
.bind(account.high)
.bind(account.low)
.fetch_all(self.database.pool())
.await?;
let mut deck = Vec::new();
let mut collection = Vec::new();
for row in &rows {
let holder: i16 = row.get("holder");
let card = OwnedCard {
card: data_ref_from_row(row, "card_table", "card_instance", "card_name"),
level_index: row.get("level_index"),
count: row.get("count"),
};
if holder == CARD_HOLDER_DECK {
deck.push(card);
} else {
collection.push(card);
}
}
Ok((deck, collection))
}
}
fn data_ref_from_row(
row: &PgRow,
table_column: &str,
instance_column: &str,
name_column: &str,
) -> LogicDataRef {
let table_index: i32 = row.get(table_column);
if table_index == 0 {
return LogicDataRef::None;
}
let instance: i32 = row.get(instance_column);
let name: Option<String> = row.get(name_column);
LogicDataRef::by_name_or_instance(table_index, name.as_deref(), instance)
}
fn parts(data: &LogicDataRef) -> (i32, i32, Option<String>) {
match data.global_id() {
None => (0, 0, None),
Some(id) => {
let name = match data.name() {
"" => None,
name => Some(name.to_owned()),
};
(id.class_id, id.instance_id, name)
}
}
}
fn chest_from_row(row: &PgRow) -> StoredChest {
StoredChest {
chest: data_ref_from_row(row, "chest_table", "chest_instance", "chest_name"),
chest_id: row.get("chest_id"),
slot_index: row.get("slot_index"),
source: row.get("source"),
unlocked: row.get("unlocked"),
claimed: row.get("claimed"),
remaining_ticks: row.get("remaining_ticks"),
total_ticks: row.get("total_ticks"),
end_timestamp: row.get("end_timestamp"),
}
}
fn profile_from_row(
row: &PgRow,
deck: Vec<OwnedCard>,
collection: Vec<OwnedCard>,
chests: Vec<StoredChest>,
) -> PlayerProfile {
PlayerProfile {
account: AccountRef::new(row.get("account_high"), row.get("account_low")),
name: row.get("name"),
name_set_by_user: row.get("name_set_by_user"),
exp_level: row.get("exp_level"),
exp_points: row.get("exp_points"),
gold: row.get("gold"),
diamonds: row.get("diamonds"),
free_diamonds: row.get("free_diamonds"),
trophies: row.get("trophies"),
arena: data_ref_from_row(row, "arena_table", "arena_instance", "arena_name"),
chest_slot_count: row.get::<i32, _>("chest_slot_count").max(0) as usize,
deck,
collection,
battle_count: row.get("battle_count"),
win_count: row.get("win_count"),
lose_count: row.get("lose_count"),
npc_win_count: row.get("npc_win_count"),
npc_lose_count: row.get("npc_lose_count"),
three_crown_wins: row.get("three_crown_wins"),
tutorials_finished: row.get("tutorials_finished"),
gold_resource: data_ref_from_row(
row,
"gold_resource_table",
"gold_resource_instance",
"gold_resource_name",
),
chest_id_counter: row.get("chest_id_counter"),
free_chest_collect_count: row.get("free_chest_collect_count"),
crowns_towards_crown_chest: row.get("crowns_towards_crown_chest"),
chests,
}
}
fn bind_cards<'a>(
builder: &mut QueryBuilder<'a, Postgres>,
account: AccountRef,
holder: i16,
cards: &'a [OwnedCard],
) {
for (position, card) in cards.iter().enumerate() {
let (table_index, instance, name) = parts(&card.card);
builder.push("(");
builder.push_bind(account.high);
builder.push(", ");
builder.push_bind(account.low);
builder.push(", ");
builder.push_bind(holder);
builder.push(", ");
builder.push_bind(position as i32);
builder.push(", ");
builder.push_bind(table_index);
builder.push(", ");
builder.push_bind(instance);
builder.push(", ");
builder.push_bind(name);
builder.push(", ");
builder.push_bind(card.level_index);
builder.push(", ");
builder.push_bind(card.count);
builder.push(")");
if position + 1 < cards.len() {
builder.push(", ");
}
}
}
impl ProfileStore {
pub async fn load(&self, account: AccountRef) -> Result<Option<PlayerProfile>> {
let row = storage::sqlx::query(
"select * from players where account_high = $1 and account_low = $2",
)
.bind(account.high)
.bind(account.low)
.fetch_optional(self.database.pool())
.await?;
let Some(row) = row else {
return Ok(None);
};
let (deck, collection) = self.cards(account).await?;
let chests = self.chests(account).await?;
Ok(Some(profile_from_row(&row, deck, collection, chests)))
}
pub async fn create_if_absent(&self, profile: PlayerProfile) -> Result<PlayerProfile> {
if let Some(existing) = self.load(profile.account).await? {
return Ok(existing);
}
self.save(&profile).await?;
Ok(self.load(profile.account).await?.unwrap_or(profile))
}
pub async fn save(&self, profile: &PlayerProfile) -> Result<()> {
let account = profile.account;
let (arena_table, arena_instance, arena_name) = parts(&profile.arena);
let (gold_table, gold_instance, gold_name) = parts(&profile.gold_resource);
let mut transaction = self.database.pool().begin().await?;
storage::sqlx::query(
"insert into accounts (
account_high, account_low, pass_token, created_at, last_seen_at
)
values ($1, $2, '', $3, $3)
on conflict (account_high, account_low) do nothing",
)
.bind(account.high)
.bind(account.low)
.bind(unix_seconds())
.execute(&mut *transaction)
.await?;
storage::sqlx::query(
"insert into players (
account_high, account_low, name, name_set_by_user, exp_level, exp_points,
gold, diamonds, free_diamonds, trophies,
arena_table, arena_instance, arena_name,
gold_resource_table, gold_resource_instance, gold_resource_name,
chest_slot_count, battle_count, win_count, lose_count,
npc_win_count, npc_lose_count, three_crown_wins, tutorials_finished, updated_at,
chest_id_counter, free_chest_collect_count, crowns_towards_crown_chest
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16,
$17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)
on conflict (account_high, account_low) do update set
name = excluded.name,
name_set_by_user = excluded.name_set_by_user,
exp_level = excluded.exp_level,
exp_points = excluded.exp_points,
gold = excluded.gold,
diamonds = excluded.diamonds,
free_diamonds = excluded.free_diamonds,
trophies = excluded.trophies,
arena_table = excluded.arena_table,
arena_instance = excluded.arena_instance,
arena_name = excluded.arena_name,
gold_resource_table = excluded.gold_resource_table,
gold_resource_instance = excluded.gold_resource_instance,
gold_resource_name = excluded.gold_resource_name,
chest_slot_count = excluded.chest_slot_count,
battle_count = excluded.battle_count,
win_count = excluded.win_count,
lose_count = excluded.lose_count,
npc_win_count = excluded.npc_win_count,
npc_lose_count = excluded.npc_lose_count,
three_crown_wins = excluded.three_crown_wins,
tutorials_finished = excluded.tutorials_finished,
updated_at = excluded.updated_at,
chest_id_counter = excluded.chest_id_counter,
free_chest_collect_count = excluded.free_chest_collect_count,
crowns_towards_crown_chest = excluded.crowns_towards_crown_chest",
)
.bind(account.high)
.bind(account.low)
.bind(&profile.name)
.bind(profile.name_set_by_user)
.bind(profile.exp_level)
.bind(profile.exp_points)
.bind(profile.gold)
.bind(profile.diamonds)
.bind(profile.free_diamonds)
.bind(profile.trophies)
.bind(arena_table)
.bind(arena_instance)
.bind(arena_name)
.bind(gold_table)
.bind(gold_instance)
.bind(gold_name)
.bind(profile.chest_slot_count as i32)
.bind(profile.battle_count)
.bind(profile.win_count)
.bind(profile.lose_count)
.bind(profile.npc_win_count)
.bind(profile.npc_lose_count)
.bind(profile.three_crown_wins)
.bind(profile.tutorials_finished)
.bind(unix_seconds())
.bind(profile.chest_id_counter)
.bind(profile.free_chest_collect_count)
.bind(profile.crowns_towards_crown_chest)
.execute(&mut *transaction)
.await?;
storage::sqlx::query(
"delete from player_cards where account_high = $1 and account_low = $2",
)
.bind(account.high)
.bind(account.low)
.execute(&mut *transaction)
.await?;
for (holder, cards) in [
(CARD_HOLDER_DECK, &profile.deck),
(CARD_HOLDER_COLLECTION, &profile.collection),
] {
if cards.is_empty() {
continue;
}
let mut builder = QueryBuilder::new(
"insert into player_cards (
account_high, account_low, holder, position,
card_table, card_instance, card_name, level_index, count
) values ",
);
bind_cards(&mut builder, account, holder, cards);
builder.build().execute(&mut *transaction).await?;
}
storage::sqlx::query(
"delete from player_chests where account_high = $1 and account_low = $2",
)
.bind(account.high)
.bind(account.low)
.execute(&mut *transaction)
.await?;
for chest in &profile.chests {
let (chest_table, chest_instance, chest_name) = parts(&chest.chest);
storage::sqlx::query(
"insert into player_chests (
account_high, account_low, slot_index, chest_table, chest_instance, chest_name,
chest_id, source, unlocked, claimed, remaining_ticks, total_ticks, end_timestamp
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
)
.bind(account.high)
.bind(account.low)
.bind(chest.slot_index)
.bind(chest_table)
.bind(chest_instance)
.bind(chest_name)
.bind(chest.chest_id)
.bind(chest.source)
.bind(chest.unlocked)
.bind(chest.claimed)
.bind(chest.remaining_ticks)
.bind(chest.total_ticks)
.bind(chest.end_timestamp)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
pub async fn count(&self) -> Result<usize> {
let total: i64 = storage::sqlx::query_scalar("select count(*) from players")
.fetch_one(self.database.pool())
.await?;
Ok(total.max(0) as usize)
}
pub async fn record_purchase(&self, account: AccountRef, offer: &ShopOffer) -> Result<()> {
let (cost_table, cost_instance, _) = parts(&offer.cost.data);
let (give_table, give_instance, _) = parts(&offer.give.data);
storage::sqlx::query(
"insert into shop_purchases (
account_high, account_low, offer_id,
cost_table, cost_instance, cost_count,
give_table, give_instance, give_count, purchased_at
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
)
.bind(account.high)
.bind(account.low)
.bind(offer.id)
.bind(cost_table)
.bind(cost_instance)
.bind(offer.cost.count)
.bind(give_table)
.bind(give_instance)
.bind(offer.give.count)
.bind(unix_seconds())
.execute(self.database.pool())
.await?;
Ok(())
}
}

View file

@ -0,0 +1,94 @@
use service_rpc::AccountRef;
use logic::LogicDataRef;
use crate::catalog::Catalog;
use crate::config::StarterProfile;
pub const CARD_HOLDER_DECK: i16 = 0;
pub const CARD_HOLDER_COLLECTION: i16 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredChest {
pub chest: LogicDataRef,
pub chest_id: i32,
pub slot_index: i32,
pub source: i32,
pub unlocked: bool,
pub claimed: bool,
pub remaining_ticks: i32,
pub total_ticks: i32,
pub end_timestamp: i32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnedCard {
pub card: LogicDataRef,
pub level_index: i32,
pub count: i32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlayerProfile {
pub account: AccountRef,
pub name: String,
pub name_set_by_user: bool,
pub exp_level: i32,
pub exp_points: i32,
pub gold: i32,
pub diamonds: i32,
pub free_diamonds: i32,
pub trophies: i32,
pub arena: LogicDataRef,
pub chest_slot_count: usize,
pub deck: Vec<OwnedCard>,
pub collection: Vec<OwnedCard>,
pub battle_count: i32,
pub win_count: i32,
pub lose_count: i32,
pub npc_win_count: i32,
pub npc_lose_count: i32,
pub three_crown_wins: i32,
pub tutorials_finished: bool,
pub gold_resource: LogicDataRef,
pub chest_id_counter: i32,
pub free_chest_collect_count: i32,
pub crowns_towards_crown_chest: i32,
pub chests: Vec<StoredChest>,
}
impl PlayerProfile {
pub fn starter(account: AccountRef, starter: &StarterProfile, catalog: &Catalog) -> Self {
let card_of = |card: &crate::config::CardRef| {
catalog.resolve_card(card).map(|resolved| OwnedCard {
card: resolved,
level_index: starter.card_level_index,
count: starter.card_count,
})
};
Self {
account,
name: format!("{}{}", starter.name_prefix, account.low),
name_set_by_user: false,
exp_level: starter.exp_level,
exp_points: starter.exp_points,
gold: starter.gold,
diamonds: starter.diamonds,
free_diamonds: starter.free_diamonds,
trophies: starter.trophies,
arena: catalog.resolve_arena(&starter.arena),
chest_slot_count: starter.chest_slot_count,
deck: starter.deck.iter().filter_map(card_of).collect(),
collection: starter
.extra_collection
.iter()
.filter_map(card_of)
.collect(),
battle_count: 0,
win_count: 0,
lose_count: 0,
npc_win_count: starter.npc_win_count,
npc_lose_count: starter.npc_lose_count,
three_crown_wins: 0,
tutorials_finished: starter.finish_tutorials,
gold_resource: catalog.resolve_resource(&starter.gold_resource),
chest_id_counter: 0,
free_chest_collect_count: 1,
crowns_towards_crown_chest: 0,
chests: Vec::new(),
}
}
}

View file

@ -6,13 +6,8 @@ rust-version.workspace = true
license.workspace = true license.workspace = true
description = "Scroll game logic model and wire messages built on top of the titan engine" description = "Scroll game logic model and wire messages built on top of the titan engine"
[features]
default = []
serde = ["dep:serde"]
[dependencies] [dependencies]
titan = { workspace = true } titan = { workspace = true }
logic-derive = { workspace = true } logic-derive = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
serde = { workspace = true, optional = true }

View file

@ -14,6 +14,10 @@ pub enum CommandOutcome {
level: i32, level: i32,
gold_spent: i32, gold_spent: i32,
}, },
PurchaseRequested {
data: LogicDataRef,
count: i32,
},
Applied, Applied,
Rejected(&'static str), Rejected(&'static str),
Ignored, Ignored,

View file

@ -3,6 +3,7 @@ mod command;
mod header; mod header;
mod manager; mod manager;
mod reward; mod reward;
mod shop;
mod spells; mod spells;
mod ui; mod ui;
pub use chest::{ pub use chest::{
@ -13,15 +14,21 @@ pub use command::{CommandMeta, CommandOutcome, CommandRegistryEntry, Execute, Lo
pub use header::LogicCommandHeader; pub use header::LogicCommandHeader;
pub use manager::LogicCommandManager; pub use manager::LogicCommandManager;
pub use reward::LogicReward; pub use reward::LogicReward;
pub use shop::{
LogicBuyCardCommand, LogicBuyChestCommand, LogicBuyResourcePackCommand,
LogicShopSeedChangedCommand,
};
pub use spells::{LogicFuseSpellsCommand, LogicSortCollectionCommand}; pub use spells::{LogicFuseSpellsCommand, LogicSortCollectionCommand};
pub use ui::{ pub use ui::{
LogicHelpOpenedCommand, LogicPageOpenedCommand, LogicRefreshAchievementsCommand, LogicHelpOpenedCommand, LogicPageOpenedCommand, LogicRefreshAchievementsCommand,
LogicShopOpenedCommand, LogicShopOpenedCommand,
}; };
pub mod command_type { pub mod command_type {
pub const DIAMONDS_ADDED: i32 = 202;
pub const CLAIM_REWARD: i32 = 213; pub const CLAIM_REWARD: i32 = 213;
pub const ADD_CHEST: i32 = 214; pub const ADD_CHEST: i32 = 214;
pub const ADD_SPELLS: i32 = 216; pub const ADD_SPELLS: i32 = 216;
pub const SHOP_SEED_CHANGED: i32 = 217;
pub const SWAP_SPELLS: i32 = 500; pub const SWAP_SPELLS: i32 = 500;
pub const START_EXPLORING: i32 = 502; pub const START_EXPLORING: i32 = 502;
pub const START_REWARD_CLAIM: i32 = 503; pub const START_REWARD_CLAIM: i32 = 503;
@ -34,6 +41,9 @@ pub mod command_type {
pub const SORT_COLLECTION: i32 = 520; pub const SORT_COLLECTION: i32 = 520;
pub const MOVE_SPELL: i32 = 521; pub const MOVE_SPELL: i32 = 521;
pub const COLLECT_MULTI_WIN_CHEST: i32 = 523; pub const COLLECT_MULTI_WIN_CHEST: i32 = 523;
pub const BUY_CHEST: i32 = 528;
pub const BUY_RESOURCE_PACK: i32 = 529;
pub const BUY_CARD: i32 = 530;
pub const HELP_OPENED: i32 = 531; pub const HELP_OPENED: i32 = 531;
pub const SHOP_OPENED: i32 = 532; pub const SHOP_OPENED: i32 = 532;
pub const REFRESH_ACHIEVEMENTS: i32 = 538; pub const REFRESH_ACHIEVEMENTS: i32 = 538;

View file

@ -0,0 +1,101 @@
use logic_derive::Command;
use titan::Payload;
use crate::commands::command::{CommandOutcome, Execute};
use crate::commands::command_type;
use crate::commands::header::LogicCommandHeader;
use crate::data::LogicDataRef;
use crate::home::LogicHomeMode;
pub const WEEKDAY_MIN: i32 = 1;
pub const WEEKDAY_MAX: i32 = 7;
#[derive(Debug, Default, Payload, Command)]
#[command(id = command_type::BUY_CHEST)]
pub struct LogicBuyChestCommand {
pub header: LogicCommandHeader,
#[codec(int)]
pub unused: i32,
pub chest: LogicDataRef,
}
impl Execute for LogicBuyChestCommand {
fn execute(&self, mode: &mut LogicHomeMode) -> CommandOutcome {
if self.chest.as_treasure_chest().is_none() {
return CommandOutcome::Rejected("that is not a treasure chest");
}
if mode.is_claiming_reward() {
return CommandOutcome::Rejected("a reward claim is already in progress");
}
mode.begin_claim();
CommandOutcome::PurchaseRequested {
data: self.chest.clone(),
count: 1,
}
}
}
#[derive(Debug, Default, Payload, Command)]
#[command(id = command_type::BUY_RESOURCE_PACK)]
pub struct LogicBuyResourcePackCommand {
pub header: LogicCommandHeader,
pub pack: LogicDataRef,
}
impl Execute for LogicBuyResourcePackCommand {
fn execute(&self, _mode: &mut LogicHomeMode) -> CommandOutcome {
let Some(pack) = self.pack.as_resource_pack() else {
return CommandOutcome::Rejected("that is not a resource pack");
};
let amount = pack.amount();
if amount < 1 {
return CommandOutcome::Rejected("the resource pack is empty");
}
CommandOutcome::PurchaseRequested {
data: pack.resource(),
count: amount,
}
}
}
#[derive(Debug, Default, Payload, Command)]
#[command(id = command_type::BUY_CARD)]
pub struct LogicBuyCardCommand {
pub header: LogicCommandHeader,
#[codec(int)]
pub unused: i32,
pub card: LogicDataRef,
}
impl Execute for LogicBuyCardCommand {
fn execute(&self, _mode: &mut LogicHomeMode) -> CommandOutcome {
if self.card.as_spell().is_none() {
return CommandOutcome::Rejected("that is not a card");
}
CommandOutcome::PurchaseRequested {
data: self.card.clone(),
count: 1,
}
}
}
#[derive(Debug, Default, Payload, Command)]
#[command(id = command_type::SHOP_SEED_CHANGED)]
pub struct LogicShopSeedChangedCommand {
#[codec(vint)]
pub shop_seed: i32,
#[codec(vint)]
pub seconds_to_cycle: i32,
#[codec(vint)]
pub weekday_index: i32,
#[codec(vint)]
pub server_command_id: i32,
pub header: LogicCommandHeader,
}
impl LogicShopSeedChangedCommand {
pub fn new(shop_seed: i32, seconds_to_cycle: i32, weekday_index: i32) -> Self {
Self {
shop_seed,
seconds_to_cycle,
weekday_index: weekday_index.clamp(WEEKDAY_MIN, WEEKDAY_MAX),
server_command_id: 0,
header: LogicCommandHeader::default(),
}
}
}
impl Execute for LogicShopSeedChangedCommand {
fn execute(&self, _mode: &mut LogicHomeMode) -> CommandOutcome {
CommandOutcome::Ignored
}
}

View file

@ -5,7 +5,8 @@ use crate::data::logic_data::LogicData;
use crate::data::logic_data_tables::LogicDataTables; use crate::data::logic_data_tables::LogicDataTables;
use crate::data::tables::table; use crate::data::tables::table;
use crate::data::typed::{ use crate::data::typed::{
LogicArenaData, LogicRarityData, LogicResourceData, LogicSpellData, LogicTreasureChestData, LogicArenaData, LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData,
LogicTreasureChestData,
}; };
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub enum LogicDataRef { pub enum LogicDataRef {
@ -30,6 +31,15 @@ impl LogicDataRef {
None => LogicDataRef::None, None => LogicDataRef::None,
} }
} }
pub fn by_name_or_instance(table_index: i32, name: Option<&str>, instance_id: i32) -> Self {
if let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) {
let resolved = Self::by_name(table_index, name);
if !resolved.is_none() {
return resolved;
}
}
Self::of(table_index, instance_id)
}
pub fn spell(name: &str) -> Self { pub fn spell(name: &str) -> Self {
Self::by_name(table::SPELLS, name) Self::by_name(table::SPELLS, name)
} }
@ -96,6 +106,9 @@ impl LogicDataRef {
pub fn as_rarity(&self) -> Option<LogicRarityData> { pub fn as_rarity(&self) -> Option<LogicRarityData> {
self.typed(table::RARITIES, LogicRarityData::new) self.typed(table::RARITIES, LogicRarityData::new)
} }
pub fn as_resource_pack(&self) -> Option<LogicResourcePackData> {
self.typed(table::RESOURCE_PACKS, LogicResourcePackData::new)
}
} }
impl PartialEq for LogicDataRef { impl PartialEq for LogicDataRef {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
@ -153,66 +166,3 @@ impl Payload for LogicDataRef {
Ok(Self::from_global_id(GlobalId::new(class_id, instance_id))) Ok(Self::from_global_id(GlobalId::new(class_id, instance_id)))
} }
} }
#[cfg(feature = "serde")]
mod serde_support {
use serde::de::{self, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::LogicDataRef;
impl Serialize for LogicDataRef {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let Some(id) = self.global_id() else {
return serializer.serialize_none();
};
let mut map = serializer.serialize_map(Some(2))?;
map.serialize_entry("table", &id.class_id)?;
match self.name() {
"" => map.serialize_entry("instance", &id.instance_id)?,
name => map.serialize_entry("name", name)?,
}
map.end()
}
}
#[derive(Deserialize)]
struct Repr {
table: i32,
#[serde(default)]
name: Option<String>,
#[serde(default)]
instance: Option<i32>,
}
impl<'de> Deserialize<'de> for LogicDataRef {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct RefVisitor;
impl<'de> Visitor<'de> for RefVisitor {
type Value = LogicDataRef;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("null or a { table, name | instance } object")
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(LogicDataRef::None)
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(LogicDataRef::None)
}
fn visit_some<D: Deserializer<'de>>(
self,
deserializer: D,
) -> Result<Self::Value, D::Error> {
deserializer.deserialize_any(RefVisitor)
}
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
let repr = Repr::deserialize(de::value::MapAccessDeserializer::new(map))?;
if let Some(instance) = repr.instance {
return Ok(LogicDataRef::of(repr.table, instance));
}
let name = repr.name.ok_or_else(|| {
de::Error::custom("a data reference needs either `name` or `instance`")
})?;
Ok(LogicDataRef::by_name(repr.table, &name))
}
}
deserializer.deserialize_option(RefVisitor)
}
}
}

View file

@ -12,5 +12,6 @@ pub use logic_data_tables::{
}; };
pub use tables::{table, DataId, RESOURCE_DIAMONDS, RESOURCE_FREE_GOLD, RESOURCE_GOLD}; pub use tables::{table, DataId, RESOURCE_DIAMONDS, RESOURCE_FREE_GOLD, RESOURCE_GOLD};
pub use typed::{ pub use typed::{
LogicArenaData, LogicRarityData, LogicResourceData, LogicSpellData, LogicTreasureChestData, LogicArenaData, LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData,
LogicTreasureChestData,
}; };

View file

@ -1,4 +1,5 @@
use std::sync::Arc; use std::sync::Arc;
use crate::data::data_ref::LogicDataRef;
use crate::data::logic_data::LogicData; use crate::data::logic_data::LogicData;
macro_rules! typed_data { macro_rules! typed_data {
($name:ident) => { ($name:ident) => {
@ -40,6 +41,18 @@ typed_data!(LogicArenaData);
typed_data!(LogicResourceData); typed_data!(LogicResourceData);
typed_data!(LogicTreasureChestData); typed_data!(LogicTreasureChestData);
typed_data!(LogicRarityData); typed_data!(LogicRarityData);
typed_data!(LogicResourcePackData);
impl LogicResourcePackData {
pub fn resource(&self) -> LogicDataRef {
LogicDataRef::by_name(
crate::data::tables::table::RESOURCES,
self.string("Resource"),
)
}
pub fn amount(&self) -> i32 {
self.int("Amount")
}
}
impl LogicSpellData { impl LogicSpellData {
pub fn rarity(&self) -> &str { pub fn rarity(&self) -> &str {
self.string("Rarity") self.string("Rarity")

View file

@ -1,7 +1,8 @@
use crate::commands::{CommandOutcome, LogicCommand, LogicReward}; use crate::commands::{CommandOutcome, LogicCommand, LogicReward};
use crate::data::{LogicDataRef, LogicRarityData}; use crate::data::{LogicDataRef, LogicRarityData};
use crate::model::{ use crate::model::{
LogicChest, LogicClientAvatar, LogicClientHome, LogicSpell, LogicTimer, TICKS_PER_SECOND, ChestSource, LogicChest, LogicClientAvatar, LogicClientHome, LogicSpell, LogicTimer,
TICKS_PER_SECOND,
}; };
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogicHomeMode { pub struct LogicHomeMode {
@ -107,6 +108,53 @@ impl LogicHomeMode {
pub fn begin_claim(&mut self) { pub fn begin_claim(&mut self) {
self.claiming_reward = true; self.claiming_reward = true;
} }
pub fn diamonds(&self) -> i32 {
self.avatar.diamonds
}
pub fn add_diamonds(&mut self, amount: i32) {
self.avatar.diamonds = self.avatar.diamonds.saturating_add(amount).max(0);
}
pub fn spend_gold(&mut self, amount: i32) -> bool {
if amount < 0 || self.gold() < amount {
return false;
}
self.add_gold(-amount);
true
}
pub fn spend_diamonds(&mut self, amount: i32) -> bool {
if amount < 0 || self.avatar.diamonds < amount {
return false;
}
self.add_diamonds(-amount);
true
}
pub fn free_chest_slot(&self) -> Option<usize> {
self.home.chest_slots.iter().position(|slot| slot.is_none())
}
pub fn grant_chest(&mut self, data: LogicDataRef, source: ChestSource) -> Option<i32> {
let slot_index = self.free_chest_slot()?;
let chest_id = self.home.chest_id_counter.saturating_add(1);
self.home.chest_id_counter = chest_id;
self.home.chest_slots[slot_index] = Some(LogicChest::locked(
data,
chest_id,
slot_index as i32,
source,
));
Some(chest_id)
}
pub fn grant_card(&mut self, data: LogicDataRef, count: i32) {
self.grant_spell(&LogicSpell {
data,
level_index: 0,
create_time: 0,
count,
new_count: count,
recent_use_count: 0,
new_upgrade_available: false,
show_new_icon: true,
});
}
pub fn apply_reward(&mut self, reward: &LogicReward) { pub fn apply_reward(&mut self, reward: &LogicReward) {
for spell in reward.spells.iter().flatten() { for spell in reward.spells.iter().flatten() {
self.grant_spell(spell); self.grant_spell(spell);

View file

@ -6,16 +6,18 @@ pub mod home;
pub mod messages; pub mod messages;
pub mod model; pub mod model;
pub use commands::{ pub use commands::{
chest_source, command_type, CommandMeta, CommandOutcome, Execute, LogicClaimRewardCommand, chest_source, command_type, CommandMeta, CommandOutcome, Execute, LogicBuyCardCommand,
LogicBuyChestCommand, LogicBuyResourcePackCommand, LogicClaimRewardCommand,
LogicCollectFreeChestCommand, LogicCollectMultiWinChestCommand, LogicCommand, LogicCollectFreeChestCommand, LogicCollectMultiWinChestCommand, LogicCommand,
LogicCommandHeader, LogicCommandManager, LogicFuseSpellsCommand, LogicHelpOpenedCommand, LogicCommandHeader, LogicCommandManager, LogicFuseSpellsCommand, LogicHelpOpenedCommand,
LogicPageOpenedCommand, LogicRefreshAchievementsCommand, LogicReward, LogicShopOpenedCommand, LogicPageOpenedCommand, LogicRefreshAchievementsCommand, LogicReward, LogicShopOpenedCommand,
LogicSortCollectionCommand, LogicStartRewardClaimCommand, LogicShopSeedChangedCommand, LogicSortCollectionCommand, LogicStartRewardClaimCommand,
}; };
pub use data::{ pub use data::{
table, DataError, LogicArenaData, LogicData, LogicDataRef, LogicDataTable, table, DataError, LogicArenaData, LogicData, LogicDataRef, LogicDataTable,
LogicDataTableResource, LogicDataTables, LogicRarityData, LogicResourceData, LogicSpellData, LogicDataTableResource, LogicDataTables, LogicRarityData, LogicResourceData,
LogicTreasureChestData, DATA_TABLE_RESOURCES, TABLE_COUNT, LogicResourcePackData, LogicSpellData, LogicTreasureChestData, DATA_TABLE_RESOURCES,
TABLE_COUNT,
}; };
pub use factory::{scroll_message_registry, LogicScrollMessageFactory}; pub use factory::{scroll_message_registry, LogicScrollMessageFactory};
pub use home::LogicHomeMode; pub use home::LogicHomeMode;

13
crates/storage/Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "storage"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Postgres connection pooling and schema migrations shared by the scroll services"
[dependencies]
sqlx = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true }

View file

@ -0,0 +1,98 @@
create sequence if not exists accounts_low_seq as integer start with 1 minvalue 1;
create table if not exists accounts (
account_high integer not null,
account_low integer not null,
pass_token text not null,
created_at bigint not null,
last_seen_at bigint not null,
session_count integer not null default 0,
play_time_seconds integer not null default 0,
banned boolean not null default false,
primary key (account_high, account_low)
);
create table if not exists players (
account_high integer not null,
account_low integer not null,
name text not null,
name_set_by_user boolean not null default false,
exp_level integer not null,
exp_points integer not null,
gold integer not null,
diamonds integer not null,
free_diamonds integer not null,
trophies integer not null,
arena_table integer not null,
arena_instance integer not null,
arena_name text,
gold_resource_table integer not null,
gold_resource_instance integer not null,
gold_resource_name text,
chest_slot_count integer not null,
battle_count integer not null default 0,
win_count integer not null default 0,
lose_count integer not null default 0,
npc_win_count integer not null default 0,
npc_lose_count integer not null default 0,
three_crown_wins integer not null default 0,
tutorials_finished boolean not null default false,
chest_id_counter integer not null default 0,
free_chest_collect_count integer not null default 1,
crowns_towards_crown_chest integer not null default 0,
updated_at bigint not null default 0,
primary key (account_high, account_low),
foreign key (account_high, account_low) references accounts (account_high, account_low) on delete cascade
);
create table if not exists player_cards (
account_high integer not null,
account_low integer not null,
holder smallint not null,
position integer not null,
card_table integer not null,
card_instance integer not null,
card_name text,
level_index integer not null default 0,
count integer not null default 0,
primary key (account_high, account_low, holder, position),
foreign key (account_high, account_low) references players (account_high, account_low) on delete cascade
);
create index if not exists player_cards_account_idx
on player_cards (account_high, account_low);
create table if not exists player_chests (
account_high integer not null,
account_low integer not null,
slot_index integer not null,
chest_table integer not null,
chest_instance integer not null,
chest_name text,
chest_id integer not null,
source integer not null default 0,
unlocked boolean not null default false,
claimed boolean not null default false,
remaining_ticks integer not null default 0,
total_ticks integer not null default 0,
end_timestamp integer not null default -1,
primary key (account_high, account_low, slot_index),
foreign key (account_high, account_low) references players (account_high, account_low) on delete cascade
);
create table if not exists shop_purchases (
id bigserial primary key,
account_high integer not null,
account_low integer not null,
offer_id integer not null,
cost_table integer not null,
cost_instance integer not null,
cost_count integer not null,
give_table integer not null,
give_instance integer not null,
give_count integer not null,
purchased_at bigint not null
);
create index if not exists shop_purchases_account_idx
on shop_purchases (account_high, account_low, purchased_at desc);

View file

@ -0,0 +1,23 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, StorageError>;
#[derive(Debug, Error)]
pub enum StorageError {
#[error("database: {0}")]
Database(#[from] sqlx::Error),
#[error("migration: {0}")]
Migration(#[from] sqlx::migrate::MigrateError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Corrupt(String),
}
impl StorageError {
pub fn corrupt(message: impl Into<String>) -> Self {
Self::Corrupt(message.into())
}
}
impl From<StorageError> for std::io::Error {
fn from(error: StorageError) -> Self {
std::io::Error::other(error.to_string())
}
}

View file

@ -0,0 +1,7 @@
mod error;
mod pool;
pub use error::{Result, StorageError};
pub use pool::{Database, DatabaseConfig};
pub use sqlx;
pub use sqlx::postgres::PgRow;
pub use sqlx::{PgPool, Row};

View file

@ -0,0 +1,66 @@
use std::time::Duration;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use sqlx::{ConnectOptions, PgPool};
use crate::error::Result;
#[derive(Debug, Clone)]
pub struct DatabaseConfig {
pub url: String,
pub max_connections: u32,
pub acquire_timeout: Duration,
pub statement_log_threshold: Duration,
}
impl DatabaseConfig {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
max_connections: 8,
acquire_timeout: Duration::from_secs(5),
statement_log_threshold: Duration::from_millis(250),
}
}
pub fn from_env() -> Option<Self> {
let url = std::env::var("DATABASE_URL")
.ok()
.filter(|value| !value.trim().is_empty())?;
let mut config = Self::new(url);
if let Some(max) = std::env::var("DATABASE_MAX_CONNECTIONS")
.ok()
.and_then(|value| value.parse().ok())
{
config.max_connections = max;
}
Some(config)
}
}
#[derive(Debug, Clone)]
pub struct Database {
pool: PgPool,
}
impl Database {
pub async fn connect(config: &DatabaseConfig) -> Result<Self> {
let options: PgConnectOptions =
config.url.parse::<PgConnectOptions>()?.log_slow_statements(
tracing::log::LevelFilter::Warn,
config.statement_log_threshold,
);
let pool = PgPoolOptions::new()
.max_connections(config.max_connections)
.acquire_timeout(config.acquire_timeout)
.connect_with(options)
.await?;
Ok(Self { pool })
}
pub async fn migrate(&self) -> Result<()> {
sqlx::migrate!("./migrations").run(&self.pool).await?;
Ok(())
}
pub fn pool(&self) -> &PgPool {
&self.pool
}
pub async fn server_version(&self) -> Result<String> {
let version: String = sqlx::query_scalar("select version()")
.fetch_one(&self.pool)
.await?;
Ok(version)
}
}