feat(workspace): full server + sync implementation, all tests green
- solitaire_server: Axum auth, sync push/pull, leaderboard, daily challenge, account deletion, JWT middleware, rate limiting via tower_governor, SQLite migrations, health endpoint - solitaire_server: expose build_test_router (no rate limiting) so integration tests work without a peer IP in oneshot requests - solitaire_sync: SyncPayload, merge logic, shared API types - solitaire_data: SyncProvider trait, LocalOnlyProvider, SolitaireServerClient, auth_tokens keyring integration, blanket Box<dyn SyncProvider> impl - solitaire_data/settings: derive Default on SyncBackend (clippy fix) - .sqlx/: offline query cache so server compiles without a live DB - sqlx: removed non-existent "offline" feature flag - keyring v2: fixed Entry::new() returning Result<Entry> - sqlx 0.8: all SQLite TEXT columns wrapped in Option<T> - Integration tests: max_connections(1) on in-memory pool so all connections share the same schema All 191 tests pass; cargo clippy -D warnings clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+111
-3
@@ -1,17 +1,125 @@
|
||||
//! Shared API types and merge logic for Solitaire Quest.
|
||||
//!
|
||||
//! This crate is the contract between the game client (`solitaire_data`) and
|
||||
//! the sync server (`solitaire_server`). Changing any public type here is a
|
||||
//! breaking change on both sides — version carefully.
|
||||
//!
|
||||
//! **No Bevy. No network. No file I/O.** Only `serde`, `uuid`, and `chrono`.
|
||||
|
||||
pub mod achievements;
|
||||
pub mod merge;
|
||||
pub mod progress;
|
||||
pub mod stats;
|
||||
|
||||
pub use achievements::AchievementRecord;
|
||||
pub use merge::merge;
|
||||
pub use progress::{level_for_xp, PlayerProgress};
|
||||
pub use stats::StatsSnapshot;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Payload sent from client to server (and returned after server merge).
|
||||
/// Full fields are added in Phase 8 (Sync System).
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync wire types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full sync payload sent from the client to the server and returned after
|
||||
/// server-side merge. Contains all data needed to reconcile two instances.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SyncPayload {
|
||||
/// Identifies the owning player. Must match the authenticated user.
|
||||
pub user_id: Uuid,
|
||||
/// Cumulative game statistics.
|
||||
pub stats: StatsSnapshot,
|
||||
/// Per-achievement unlock records.
|
||||
pub achievements: Vec<AchievementRecord>,
|
||||
/// XP, level, cosmetic unlocks, and daily/weekly progress.
|
||||
pub progress: PlayerProgress,
|
||||
/// Wall-clock time of the last local modification.
|
||||
pub last_modified: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Response returned by the sync server after merging.
|
||||
/// Response returned by the sync server after a pull or push operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SyncResponse {
|
||||
/// The merged payload that the client should save locally.
|
||||
pub merged: SyncPayload,
|
||||
/// The server's current wall-clock time (useful for clock-skew detection).
|
||||
pub server_time: DateTime<Utc>,
|
||||
/// Fields where local and remote values differed and could not be merged
|
||||
/// deterministically. Returned for display purposes — data is never
|
||||
/// silently discarded.
|
||||
pub conflicts: Vec<ConflictReport>,
|
||||
}
|
||||
|
||||
/// Describes a single field where local and remote values diverged in a way
|
||||
/// that the merge function could not resolve automatically.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConflictReport {
|
||||
/// Dot-separated field path, e.g. `"win_streak_current"`.
|
||||
pub field: String,
|
||||
/// Human-readable representation of the local value.
|
||||
pub local_value: String,
|
||||
/// Human-readable representation of the remote value.
|
||||
pub remote_value: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daily challenge / leaderboard types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Describes today's daily challenge, returned by `GET /api/daily-challenge`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChallengeGoal {
|
||||
/// Date this challenge applies to, formatted as `"YYYY-MM-DD"`.
|
||||
pub date: String,
|
||||
/// Deterministic RNG seed for this date's deal — identical for all players.
|
||||
pub seed: u64,
|
||||
/// Human-readable description of the goal, e.g. "Win in under 5 minutes".
|
||||
pub description: String,
|
||||
/// Optional target score required to complete the challenge.
|
||||
pub target_score: Option<i32>,
|
||||
/// Optional maximum allowed time in seconds to complete the challenge.
|
||||
pub max_time_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// A single row from the server leaderboard, returned by `GET /api/leaderboard`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LeaderboardEntry {
|
||||
/// Display name chosen by the player at opt-in time.
|
||||
pub display_name: String,
|
||||
/// The player's best single-game score.
|
||||
pub best_score: Option<i32>,
|
||||
/// The player's fastest win time in seconds.
|
||||
pub best_time_secs: Option<u64>,
|
||||
/// When this entry was last recorded.
|
||||
pub recorded_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Errors returned by the sync server in `application/json` error bodies.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
/// The request could not be authenticated (missing or invalid JWT).
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
/// The supplied credentials were incorrect.
|
||||
#[error("invalid credentials")]
|
||||
InvalidCredentials,
|
||||
/// A username that was requested for registration is already taken.
|
||||
#[error("username already taken")]
|
||||
UsernameTaken,
|
||||
/// The request payload was too large (> 1 MB).
|
||||
#[error("payload too large")]
|
||||
PayloadTooLarge,
|
||||
/// The request body could not be parsed.
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
/// An unexpected server-side error occurred.
|
||||
#[error("internal server error")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user