Trading card city builder game?

filling in the server functionality that was lost

eldridge.cam f716475b fee7ac0c

verified
+352 -70
+34
.sqlx/query-4c464e0454d614dba55463a0332d09c67b56585eea7018d885a7bdc7a62400e5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT tile_id AS \"id\", grid_x AS \"x\", grid_y AS \"y\"\n FROM field_tiles\n WHERE field_id = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int8" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "x", 14 + "type_info": "Int4" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "y", 19 + "type_info": "Int4" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Int8" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "4c464e0454d614dba55463a0332d09c67b56585eea7018d885a7bdc7a62400e5" 34 + }
+22
.sqlx/query-d7a61570b34e911183eec8a3fd0b0c6ad64ef81a6b0581e5116aa3e0c1a52f2c.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT name FROM fields WHERE id = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "name", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Int8" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "d7a61570b34e911183eec8a3fd0b0c6ad64ef81a6b0581e5116aa3e0c1a52f2c" 22 + }
+34
.sqlx/query-f4901c3731fabc8b0fc60129740902ce8fe74b73b088cbb632b34a3c7cf402ea.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT citizen_id AS \"id\", grid_x AS \"x\", grid_y AS \"y\"\n FROM field_citizens\n WHERE field_id = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int8" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "x", 14 + "type_info": "Int4" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "y", 19 + "type_info": "Int4" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Int8" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "f4901c3731fabc8b0fc60129740902ce8fe74b73b088cbb632b34a3c7cf402ea" 34 + }
+50 -6
Cargo.lock
··· 147 147 "derive_more", 148 148 "futures", 149 149 "futures-rx", 150 + "json-patch", 150 151 "kameo", 151 152 "rmp-serde", 152 153 "serde", ··· 804 805 ] 805 806 806 807 [[package]] 808 + name = "json-patch" 809 + version = "4.1.0" 810 + source = "registry+https://github.com/rust-lang/crates.io-index" 811 + checksum = "f300e415e2134745ef75f04562dd0145405c2f7fd92065db029ac4b16b57fe90" 812 + dependencies = [ 813 + "jsonptr", 814 + "serde", 815 + "serde_json", 816 + "thiserror 1.0.69", 817 + "utoipa", 818 + ] 819 + 820 + [[package]] 821 + name = "jsonptr" 822 + version = "0.7.1" 823 + source = "registry+https://github.com/rust-lang/crates.io-index" 824 + checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" 825 + dependencies = [ 826 + "serde", 827 + "serde_json", 828 + ] 829 + 830 + [[package]] 807 831 name = "kameo" 808 832 version = "0.19.2" 809 833 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1446 1470 "serde_json", 1447 1471 "sha2", 1448 1472 "smallvec", 1449 - "thiserror", 1473 + "thiserror 2.0.18", 1450 1474 "tokio", 1451 1475 "tokio-stream", 1452 1476 "tracing", ··· 1528 1552 "smallvec", 1529 1553 "sqlx-core", 1530 1554 "stringprep", 1531 - "thiserror", 1555 + "thiserror 2.0.18", 1532 1556 "tracing", 1533 1557 "whoami", 1534 1558 ] ··· 1565 1589 "smallvec", 1566 1590 "sqlx-core", 1567 1591 "stringprep", 1568 - "thiserror", 1592 + "thiserror 2.0.18", 1569 1593 "tracing", 1570 1594 "whoami", 1571 1595 ] ··· 1589 1613 "serde", 1590 1614 "serde_urlencoded", 1591 1615 "sqlx-core", 1592 - "thiserror", 1616 + "thiserror 2.0.18", 1593 1617 "tracing", 1594 1618 "url", 1595 1619 ] ··· 1647 1671 1648 1672 [[package]] 1649 1673 name = "thiserror" 1674 + version = "1.0.69" 1675 + source = "registry+https://github.com/rust-lang/crates.io-index" 1676 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 1677 + dependencies = [ 1678 + "thiserror-impl 1.0.69", 1679 + ] 1680 + 1681 + [[package]] 1682 + name = "thiserror" 1650 1683 version = "2.0.18" 1651 1684 source = "registry+https://github.com/rust-lang/crates.io-index" 1652 1685 checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 1653 1686 dependencies = [ 1654 - "thiserror-impl", 1687 + "thiserror-impl 2.0.18", 1688 + ] 1689 + 1690 + [[package]] 1691 + name = "thiserror-impl" 1692 + version = "1.0.69" 1693 + source = "registry+https://github.com/rust-lang/crates.io-index" 1694 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 1695 + dependencies = [ 1696 + "proc-macro2", 1697 + "quote", 1698 + "syn", 1655 1699 ] 1656 1700 1657 1701 [[package]] ··· 1827 1871 "log", 1828 1872 "rand 0.9.2", 1829 1873 "sha1", 1830 - "thiserror", 1874 + "thiserror 2.0.18", 1831 1875 "utf-8", 1832 1876 ] 1833 1877
+1
Cargo.toml
··· 16 16 derive_more = { version = "2.1.1", features = ["error", "display"] } 17 17 futures = "0.3.31" 18 18 futures-rx = "0.2.1" 19 + json-patch = { version = "4.1.0", features = ["utoipa"] } 19 20 kameo = "0.19.2" 20 21 rmp-serde = "1.3.1" 21 22 serde = { version = "1.0.228", features = ["derive"] }
+2 -2
app/src/lib/appserver/socket/SocketV1.svelte.ts
··· 110 110 $watchField(data: { id: FieldId }, subscriber: (gameState: GameState | undefined) => void) { 111 111 let gameState: GameState | undefined = undefined; 112 112 this.#sendMessage({ type: "WatchField", data: data.id }).$subscribe((response) => { 113 - if (response.type === "PutState") { 113 + if (response.type === "PutFieldState") { 114 114 gameState = response.data; 115 - } else if (response.type === "PatchState") { 115 + } else if (response.type === "PatchFieldState") { 116 116 const patches = response.data; 117 117 gameState = jsonpatch.apply(gameState, patches); 118 118 }
+8 -7
app/src/lib/appserver/socket/SocketV1Protocol.ts
··· 162 162 export const FieldList = Type.Object({ type: Type.Literal("FieldList"), data: Type.Array(Field) }); 163 163 export type FieldList = StaticDecode<typeof FieldList>; 164 164 165 - export const PutState = Type.Object({ type: Type.Literal("PutState"), data: GameState }); 166 - export type PutState = StaticDecode<typeof PutState>; 165 + export const PutFieldState = Type.Object({ type: Type.Literal("PutFieldState"), data: GameState }); 166 + export type PutFieldState = StaticDecode<typeof PutFieldState>; 167 167 168 - export const PatchState = Type.Object({ 169 - type: Type.Literal("PatchState"), 168 + export const PatchFieldState = Type.Object({ 169 + type: Type.Literal("PatchFieldState"), 170 170 data: Type.Array(JsonPatch), 171 171 }); 172 - export type PatchState = StaticDecode<typeof PatchState>; 172 + export type PatchFieldState = StaticDecode<typeof PatchFieldState>; 173 173 174 - export const Response = Type.Union([Authenticated, FieldList, PutState, PatchState]); 174 + export const Response = Type.Union([Authenticated, FieldList, PutFieldState, PatchFieldState]); 175 175 export type Response = StaticDecode<typeof Response>; 176 176 177 177 export type Once<T> = Branded<"Once", T>; ··· 183 183 export interface SocketV1Protocol { 184 184 Authenticate: Once<Authenticated>; 185 185 ListFields: Once<FieldList>; 186 - WatchField: Stream<PutState | PatchState>; 186 + WatchField: Stream<PutFieldState | PatchFieldState>; 187 + Unsubscribe: never; 187 188 } 188 189 189 190 function ProtocolV1Message<T extends TSchema>(data: T) {
+95
src/actor/field_state/mod.rs
··· 1 + use super::player_socket::Response; 2 + use super::Unsubscribe; 3 + use kameo::{prelude::Message, Actor}; 4 + use serde::{Deserialize, Serialize}; 5 + use sqlx::PgPool; 6 + use tokio::sync::mpsc::UnboundedSender; 7 + 8 + #[derive(Serialize, Deserialize, Clone, Debug)] 9 + pub struct FieldTile { 10 + pub id: i64, 11 + pub x: i32, 12 + pub y: i32, 13 + } 14 + 15 + #[derive(Serialize, Deserialize, Clone, Debug)] 16 + pub struct FieldCitizen { 17 + pub id: i64, 18 + pub x: i32, 19 + pub y: i32, 20 + } 21 + 22 + #[derive(Serialize, Deserialize, Clone, Debug)] 23 + pub struct FieldState { 24 + pub name: String, 25 + pub tiles: Vec<FieldTile>, 26 + pub citizens: Vec<FieldCitizen>, 27 + } 28 + 29 + #[derive(Actor)] 30 + #[expect(dead_code)] 31 + pub struct FieldWatcher { 32 + state: FieldState, 33 + field_id: i64, 34 + db: PgPool, 35 + tx: UnboundedSender<Response>, 36 + } 37 + 38 + impl FieldWatcher { 39 + pub async fn build( 40 + db: PgPool, 41 + tx: UnboundedSender<Response>, 42 + field_id: i64, 43 + ) -> anyhow::Result<Self> { 44 + let mut conn = db.acquire().await?; 45 + 46 + let field = sqlx::query!("SELECT name FROM fields WHERE id = $1", field_id) 47 + .fetch_one(&mut *conn) 48 + .await?; 49 + let tiles = sqlx::query_as!( 50 + FieldTile, 51 + r#" 52 + SELECT tile_id AS "id", grid_x AS "x", grid_y AS "y" 53 + FROM field_tiles 54 + WHERE field_id = $1 55 + "#, 56 + field_id 57 + ) 58 + .fetch_all(&mut *conn) 59 + .await?; 60 + let citizens = sqlx::query_as!( 61 + FieldCitizen, 62 + r#" 63 + SELECT citizen_id AS "id", grid_x AS "x", grid_y AS "y" 64 + FROM field_citizens 65 + WHERE field_id = $1 66 + "#, 67 + field_id 68 + ) 69 + .fetch_all(&mut *conn) 70 + .await?; 71 + let state = FieldState { 72 + name: field.name, 73 + tiles, 74 + citizens, 75 + }; 76 + tx.send(Response::PutFieldState(state.clone()))?; 77 + Ok(Self { 78 + state, 79 + db, 80 + tx, 81 + field_id, 82 + }) 83 + } 84 + } 85 + 86 + impl Message<Unsubscribe> for FieldWatcher { 87 + type Reply = (); 88 + async fn handle( 89 + &mut self, 90 + _msg: Unsubscribe, 91 + ctx: &mut kameo::prelude::Context<Self, Self::Reply>, 92 + ) -> Self::Reply { 93 + ctx.stop(); 94 + } 95 + }
+3
src/actor/mod.rs
··· 1 + pub mod field_state; 1 2 pub mod player_socket; 3 + 4 + pub struct Unsubscribe;
+66 -51
src/actor/player_socket/mod.rs
··· 1 + use super::field_state::FieldState; 2 + use crate::actor::Unsubscribe; 3 + use crate::api::ws::ProtocolV1Message; 1 4 use crate::dto::{Account, Field}; 5 + use futures::Stream; 6 + use json_patch::Patch; 7 + use kameo::prelude::*; 2 8 use serde::{Deserialize, Serialize}; 9 + use sqlx::PgPool; 10 + use std::collections::HashMap; 11 + use tokio::sync::mpsc::{unbounded_channel, UnboundedSender}; 12 + use tokio_stream::wrappers::UnboundedReceiverStream; 13 + use uuid::Uuid; 14 + 15 + mod authenticate; 16 + mod list_fields; 17 + mod unsubscribe; 18 + mod watch_field; 3 19 4 20 #[derive(Serialize, Deserialize, Clone, Debug)] 5 21 #[serde(tag = "type", content = "data")] 6 22 pub enum Request { 7 23 Authenticate(String), 8 24 ListFields, 25 + WatchField(i64), 26 + Unsubscribe, 9 27 } 10 28 11 29 #[derive(Serialize, Deserialize, Clone, Debug)] ··· 13 31 pub enum Response { 14 32 Authenticated(Account), 15 33 FieldList(Vec<Field>), 34 + PutFieldState(FieldState), 35 + PatchFieldState(Vec<Patch>), 16 36 } 17 37 18 - pub use server::PlayerSocket; 38 + #[derive(Actor)] 39 + pub struct PlayerSocket { 40 + db: PgPool, 41 + account_id: Option<String>, 42 + subscriptions: HashMap<Uuid, Recipient<Unsubscribe>>, 43 + } 19 44 20 - mod server { 21 - use super::{Request, Response}; 22 - use futures::Stream; 23 - use kameo::prelude::*; 24 - use sqlx::PgPool; 25 - use tokio::sync::mpsc::{unbounded_channel, UnboundedSender}; 26 - use tokio_stream::wrappers::UnboundedReceiverStream; 45 + impl PlayerSocket { 46 + pub fn build(db: PgPool) -> Self { 47 + Self { 48 + db, 49 + account_id: None, 50 + subscriptions: HashMap::default(), 51 + } 52 + } 27 53 28 - mod authenticate; 29 - mod list_fields; 30 - 31 - #[derive(Actor)] 32 - pub struct PlayerSocket { 33 - pub(super) db: PgPool, 34 - pub(super) account_id: Option<String>, 54 + pub async fn push( 55 + actor: ActorRef<Self>, 56 + request: ProtocolV1Message<Request>, 57 + ) -> Result<impl Stream<Item = Response>, SendError<PlayerSocketMessage>> { 58 + let (tx, rx) = unbounded_channel(); 59 + actor.tell(PlayerSocketMessage { tx, request }).await?; 60 + Ok(UnboundedReceiverStream::new(rx)) 35 61 } 36 62 37 - impl PlayerSocket { 38 - pub fn build(db: PgPool) -> Self { 39 - Self { 40 - db, 41 - account_id: None, 42 - } 43 - } 44 - 45 - pub async fn push( 46 - actor: ActorRef<Self>, 47 - request: Request, 48 - ) -> Result<impl Stream<Item = Response>, SendError<PlayerSocketMessage>> { 49 - let (tx, rx) = unbounded_channel(); 50 - actor.tell(PlayerSocketMessage { tx, request }).await?; 51 - Ok(UnboundedReceiverStream::new(rx)) 52 - } 63 + fn require_authentication(&self) -> anyhow::Result<&str> { 64 + self.account_id 65 + .as_deref() 66 + .ok_or_else(|| anyhow::anyhow!("authentication required")) 53 67 } 68 + } 54 69 55 - pub struct PlayerSocketMessage { 56 - tx: UnboundedSender<Response>, 57 - request: Request, 58 - } 70 + pub struct PlayerSocketMessage { 71 + tx: UnboundedSender<Response>, 72 + request: ProtocolV1Message<Request>, 73 + } 59 74 60 - impl Message<PlayerSocketMessage> for PlayerSocket { 61 - type Reply = (); 75 + impl Message<PlayerSocketMessage> for PlayerSocket { 76 + type Reply = (); 62 77 63 - async fn handle( 64 - &mut self, 65 - PlayerSocketMessage { tx, request }: PlayerSocketMessage, 66 - ctx: &mut Context<Self, Self::Reply>, 67 - ) -> Self::Reply { 68 - let result = match request { 69 - Request::Authenticate(account_id) => self.authenticate(tx, account_id).await, 70 - Request::ListFields if self.account_id.is_some() => self.list_fields(tx).await, 71 - _ => Err(anyhow::anyhow!("authentication required")), 72 - }; 73 - if let Err(error) = result { 74 - tracing::error!("error handling player socket message: {}", error); 75 - ctx.stop() 76 - } 78 + async fn handle( 79 + &mut self, 80 + PlayerSocketMessage { tx, request }: PlayerSocketMessage, 81 + ctx: &mut Context<Self, Self::Reply>, 82 + ) -> Self::Reply { 83 + let result = match request.data { 84 + Request::Authenticate(account_id) => self.authenticate(tx, account_id).await, 85 + Request::ListFields => self.list_fields(tx).await, 86 + Request::WatchField(field_id) => self.watch_field(tx, request.id, field_id).await, 87 + Request::Unsubscribe => self.unsubscribe(request.id).await, 88 + }; 89 + if let Err(error) = result { 90 + tracing::error!("error handling player socket message: {}", error); 91 + ctx.stop() 77 92 } 78 93 } 79 94 }
src/actor/player_socket/server/authenticate.rs src/actor/player_socket/authenticate.rs
+1 -1
src/actor/player_socket/server/list_fields.rs src/actor/player_socket/list_fields.rs
··· 11 11 let field_list = sqlx::query_as!( 12 12 Field, 13 13 "SELECT id, name FROM fields WHERE account_id = $1", 14 - self.account_id.as_ref().unwrap() 14 + self.require_authentication()?, 15 15 ) 16 16 .fetch_all(&mut *conn) 17 17 .await?;
+12
src/actor/player_socket/unsubscribe.rs
··· 1 + use super::super::Unsubscribe; 2 + use super::PlayerSocket; 3 + use uuid::Uuid; 4 + 5 + impl PlayerSocket { 6 + pub(super) async fn unsubscribe(&mut self, message_id: Uuid) -> anyhow::Result<()> { 7 + if let Some(sub) = self.subscriptions.remove(&message_id) { 8 + sub.tell(Unsubscribe).await?; 9 + } 10 + Ok(()) 11 + } 12 + }
+20
src/actor/player_socket/watch_field.rs
··· 1 + use super::super::field_state::FieldWatcher; 2 + use super::super::Unsubscribe; 3 + use super::{PlayerSocket, Response}; 4 + use kameo::actor::Spawn; 5 + use tokio::sync::mpsc::UnboundedSender; 6 + use uuid::Uuid; 7 + 8 + impl PlayerSocket { 9 + pub(super) async fn watch_field( 10 + &mut self, 11 + tx: UnboundedSender<Response>, 12 + message_id: Uuid, 13 + field_id: i64, 14 + ) -> anyhow::Result<()> { 15 + let actor = FieldWatcher::spawn(FieldWatcher::build(self.db.clone(), tx, field_id).await?); 16 + let unsubscriber = actor.recipient::<Unsubscribe>(); 17 + self.subscriptions.insert(message_id, unsubscriber); 18 + Ok(()) 19 + } 20 + }
+3 -2
src/api/ws.rs
··· 93 93 }) 94 94 .filter_map({ 95 95 let actor = actor.clone(); 96 - move |ProtocolV1Message { id, data }| { 96 + move |message| { 97 97 let actor = actor.clone(); 98 + let id = message.id; 98 99 async move { 99 100 Some( 100 - PlayerSocket::push(actor, data) 101 + PlayerSocket::push(actor, message) 101 102 .await 102 103 .ok()? 103 104 .map(move |data| ProtocolV1Message { id, data }),
+1 -1
src/main.rs
··· 5 5 6 6 use std::net::IpAddr; 7 7 8 - use axum::{Extension, Json, response::Html}; 8 + use axum::{response::Html, Extension, Json}; 9 9 use utoipa::OpenApi; 10 10 use utoipa_scalar::Scalar; 11 11