Trading card city builder game?

add socket messages for watching deck

eldridge.cam 008a37ce e9ced0a6

verified
+439 -84
+34
.sqlx/query-240ff3ffa4ed17228f5086f5bddf96b8d4bf1b084ff64e30eec76393f9d85af9.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, tile_type_id, name FROM tiles WHERE id = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int8" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "tile_type_id", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "name", 19 + "type_info": "Text" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Int8" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "240ff3ffa4ed17228f5086f5bddf96b8d4bf1b084ff64e30eec76393f9d85af9" 34 + }
+39
.sqlx/query-6ef359bb0fbc129554338b1da2351226e560c969d8a4ec60c850a365a929d36f.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, species_id, name\n FROM citizens\n INNER JOIN card_accounts ON card_accounts.card_id = citizens.id\n WHERE card_accounts.account_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": "species_id", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "name", 19 + "type_info": "Text" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + { 25 + "Custom": { 26 + "name": "citext", 27 + "kind": "Simple" 28 + } 29 + } 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + false, 35 + false 36 + ] 37 + }, 38 + "hash": "6ef359bb0fbc129554338b1da2351226e560c969d8a4ec60c850a365a929d36f" 39 + }
+44
.sqlx/query-874cf1a7157ea7e92f38f15821d0bd2c0b87498ca3a7c2e5075da8ba21ac7598.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT cards.card_type_id, card_types.class AS \"class: CardClass\"\n FROM cards\n INNER JOIN card_types ON card_types.id = cards.card_type_id\n INNER JOIN card_accounts ON card_accounts.card_id = cards.id\n WHERE cards.id = $1 AND card_accounts.account_id = $2\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "card_type_id", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "class: CardClass", 14 + "type_info": { 15 + "Custom": { 16 + "name": "card_class", 17 + "kind": { 18 + "Enum": [ 19 + "tile", 20 + "citizen" 21 + ] 22 + } 23 + } 24 + } 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Int8", 30 + { 31 + "Custom": { 32 + "name": "citext", 33 + "kind": "Simple" 34 + } 35 + } 36 + ] 37 + }, 38 + "nullable": [ 39 + false, 40 + false 41 + ] 42 + }, 43 + "hash": "874cf1a7157ea7e92f38f15821d0bd2c0b87498ca3a7c2e5075da8ba21ac7598" 44 + }
+39
.sqlx/query-a2bf75bad5e8c991ad1018f31d66d23e04235e2098c1412d266b0020bb2bdbd6.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, tile_type_id, name\n FROM tiles\n INNER JOIN card_accounts ON card_accounts.card_id = tiles.id\n WHERE card_accounts.account_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": "tile_type_id", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "name", 19 + "type_info": "Text" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + { 25 + "Custom": { 26 + "name": "citext", 27 + "kind": "Simple" 28 + } 29 + } 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + false, 35 + false 36 + ] 37 + }, 38 + "hash": "a2bf75bad5e8c991ad1018f31d66d23e04235e2098c1412d266b0020bb2bdbd6" 39 + }
+28
.sqlx/query-c6d8c3e06b08f41bdca9bed9d9a4ec85afa461106431b0a71f7a6bf4a8233d32.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT name FROM fields WHERE id = $1 AND account_id = $2", 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 + "Custom": { 17 + "name": "citext", 18 + "kind": "Simple" 19 + } 20 + } 21 + ] 22 + }, 23 + "nullable": [ 24 + false 25 + ] 26 + }, 27 + "hash": "c6d8c3e06b08f41bdca9bed9d9a4ec85afa461106431b0a71f7a6bf4a8233d32" 28 + }
+34
.sqlx/query-ca7449b8b88332d81b20055a751ed8b2ebaf6d109927342d472a4a032603449c.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, species_id, name FROM citizens WHERE id = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int8" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "species_id", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "name", 19 + "type_info": "Text" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Int8" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "ca7449b8b88332d81b20055a751ed8b2ebaf6d109927342d472a4a032603449c" 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 - }
-28
.sqlx/query-f407778375bbf66b6a52e9436d5ba8dea93f5456ef42d5c102f3c9d273ba853e.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT id, card_type_id\n FROM cards\n INNER JOIN pack_contents ON pack_contents.card_id = cards.id\n WHERE pack_contents.pack_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": "card_type_id", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Int8" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "f407778375bbf66b6a52e9436d5ba8dea93f5456ef42d5c102f3c9d273ba853e" 28 - }
+170
packages/cartography/src/actor/deck_state/mod.rs
··· 1 + use super::player_socket::Response; 2 + use super::{AddCardToDeck, Unsubscribe}; 3 + use crate::bus::{Bus, BusExt}; 4 + use crate::db::CardClass; 5 + use kameo::prelude::*; 6 + use serde::{Deserialize, Serialize}; 7 + use sqlx::PgPool; 8 + use tokio::sync::mpsc::UnboundedSender; 9 + 10 + #[derive(PartialEq, Clone, Debug, Serialize, Deserialize)] 11 + pub struct Tile { 12 + pub id: i64, 13 + pub tile_type_id: String, 14 + pub name: String, 15 + } 16 + 17 + #[derive(PartialEq, Clone, Debug, Serialize, Deserialize)] 18 + pub struct Citizen { 19 + pub id: i64, 20 + pub species_id: String, 21 + pub name: String, 22 + } 23 + 24 + #[derive(Serialize, Deserialize, Clone, Debug)] 25 + pub struct DeckState { 26 + pub tiles: Vec<Tile>, 27 + pub citizens: Vec<Citizen>, 28 + } 29 + 30 + pub struct DeckWatcher { 31 + state: DeckState, 32 + account_id: String, 33 + db: PgPool, 34 + tx: UnboundedSender<Response>, 35 + } 36 + 37 + impl Actor for DeckWatcher { 38 + type Args = (PgPool, UnboundedSender<Response>, ActorRef<Bus>, String); 39 + type Error = anyhow::Error; 40 + 41 + async fn on_start( 42 + (db, tx, bus, account_id): Self::Args, 43 + actor_ref: ActorRef<Self>, 44 + ) -> Result<Self, Self::Error> { 45 + let mut conn = db.acquire().await?; 46 + 47 + bus.listen::<AddCardToDeck, _>(&actor_ref).await?; 48 + 49 + let tiles = sqlx::query_as!( 50 + Tile, 51 + r#" 52 + SELECT id, tile_type_id, name 53 + FROM tiles 54 + INNER JOIN card_accounts ON card_accounts.card_id = tiles.id 55 + WHERE card_accounts.account_id = $1 56 + "#, 57 + account_id 58 + ) 59 + .fetch_all(&mut *conn) 60 + .await?; 61 + let citizens = sqlx::query_as!( 62 + Citizen, 63 + r#" 64 + SELECT id, species_id, name 65 + FROM citizens 66 + INNER JOIN card_accounts ON card_accounts.card_id = citizens.id 67 + WHERE card_accounts.account_id = $1 68 + "#, 69 + account_id 70 + ) 71 + .fetch_all(&mut *conn) 72 + .await?; 73 + let state = DeckState { tiles, citizens }; 74 + 75 + tx.send(Response::PutDeckState(state.clone()))?; 76 + 77 + Ok(Self { 78 + state, 79 + db, 80 + tx, 81 + account_id, 82 + }) 83 + } 84 + } 85 + 86 + impl DeckWatcher { 87 + async fn send_patch(&self, previous: &serde_json::Value) -> anyhow::Result<()> { 88 + let next = serde_json::to_value(&self.state).unwrap(); 89 + let patch = json_patch::diff(previous, &next); 90 + self.tx.send(Response::PatchState(patch))?; 91 + Ok(()) 92 + } 93 + 94 + async fn add_card(&mut self, card_id: i64) -> anyhow::Result<()> { 95 + let mut conn = self.db.acquire().await?; 96 + let card = sqlx::query!( 97 + r#" 98 + SELECT cards.card_type_id, card_types.class AS "class: CardClass" 99 + FROM cards 100 + INNER JOIN card_types ON card_types.id = cards.card_type_id 101 + INNER JOIN card_accounts ON card_accounts.card_id = cards.id 102 + WHERE cards.id = $1 AND card_accounts.account_id = $2 103 + "#, 104 + card_id, 105 + self.account_id 106 + ) 107 + .fetch_one(&mut *conn) 108 + .await?; 109 + 110 + let previous = serde_json::to_value(&self.state).unwrap(); 111 + match card.class { 112 + CardClass::Tile => { 113 + let tile = sqlx::query_as!( 114 + Tile, 115 + "SELECT id, tile_type_id, name FROM tiles WHERE id = $1", 116 + card_id 117 + ) 118 + .fetch_one(&mut *conn) 119 + .await?; 120 + self.state.tiles.retain(|tile| tile.id != card_id); 121 + self.state.tiles.push(tile); 122 + } 123 + CardClass::Citizen => { 124 + let citizen = sqlx::query_as!( 125 + Citizen, 126 + "SELECT id, species_id, name FROM citizens WHERE id = $1", 127 + card_id 128 + ) 129 + .fetch_one(&mut *conn) 130 + .await?; 131 + self.state.citizens.retain(|citizen| citizen.id != card_id); 132 + self.state.citizens.push(citizen); 133 + } 134 + } 135 + self.send_patch(&previous).await?; 136 + Ok(()) 137 + } 138 + } 139 + 140 + impl Message<AddCardToDeck> for DeckWatcher { 141 + type Reply = (); 142 + 143 + async fn handle( 144 + &mut self, 145 + msg: AddCardToDeck, 146 + _ctx: &mut Context<Self, Self::Reply>, 147 + ) -> Self::Reply { 148 + #[expect( 149 + clippy::collapsible_if, 150 + reason = "confusing use of side effects when collapsed" 151 + )] 152 + if msg.account_id == self.account_id { 153 + if let Err(error) = self.add_card(msg.card_id).await { 154 + tracing::error!("{}", error); 155 + panic!("aborting deck watcher: {}", error); 156 + } 157 + } 158 + } 159 + } 160 + 161 + impl Message<Unsubscribe> for DeckWatcher { 162 + type Reply = (); 163 + async fn handle( 164 + &mut self, 165 + _msg: Unsubscribe, 166 + ctx: &mut kameo::prelude::Context<Self, Self::Reply>, 167 + ) -> Self::Reply { 168 + ctx.stop(); 169 + } 170 + }
+8 -3
packages/cartography/src/actor/field_state/mod.rs
··· 40 40 db: PgPool, 41 41 tx: UnboundedSender<Response>, 42 42 field_id: i64, 43 + account_id: &str, 43 44 ) -> anyhow::Result<Self> { 44 45 let mut conn = db.acquire().await?; 45 46 46 - let field = sqlx::query!("SELECT name FROM fields WHERE id = $1", field_id) 47 - .fetch_one(&mut *conn) 48 - .await?; 47 + let field = sqlx::query!( 48 + "SELECT name FROM fields WHERE id = $1 AND account_id = $2", 49 + field_id, 50 + account_id 51 + ) 52 + .fetch_one(&mut *conn) 53 + .await?; 49 54 let tiles = sqlx::query_as!( 50 55 FieldTile, 51 56 r#"
+1 -1
packages/cartography/src/actor/mod.rs
··· 1 + pub mod deck_state; 1 2 pub mod field_state; 2 3 pub mod player_socket; 3 4 ··· 5 6 pub struct Unsubscribe; 6 7 7 8 #[derive(Clone, Debug)] 8 - #[expect(dead_code, reason = "stub for later")] 9 9 pub struct AddCardToDeck { 10 10 pub account_id: String, 11 11 pub card_id: i64,
+10 -3
packages/cartography/src/actor/player_socket/mod.rs
··· 1 1 use super::field_state::FieldState; 2 - use crate::actor::Unsubscribe; 2 + use crate::actor::{Unsubscribe, deck_state::DeckState}; 3 3 use crate::api::ws::ProtocolV1Message; 4 + use crate::bus::Bus; 4 5 use crate::dto::Account; 5 6 use futures::Stream; 6 7 use json_patch::Patch; ··· 14 15 15 16 mod authenticate; 16 17 mod unsubscribe; 18 + mod watch_deck; 17 19 mod watch_field; 18 20 19 21 #[derive(Serialize, Deserialize, Clone, Debug)] 20 22 #[serde(tag = "type", content = "data")] 21 23 pub enum Request { 22 24 Authenticate(String), 25 + WatchDeck, 23 26 WatchField(i64), 24 27 Unsubscribe, 25 28 } ··· 29 32 pub enum Response { 30 33 Authenticated(Account), 31 34 PutFieldState(FieldState), 32 - PatchFieldState(Vec<Patch>), 35 + PutDeckState(DeckState), 36 + PatchState(Patch), 33 37 } 34 38 35 39 #[derive(Actor)] 36 40 pub struct PlayerSocket { 37 41 db: PgPool, 42 + bus: ActorRef<Bus>, 38 43 account_id: Option<String>, 39 44 subscriptions: HashMap<Uuid, Recipient<Unsubscribe>>, 40 45 } 41 46 42 47 impl PlayerSocket { 43 - pub fn build(db: PgPool) -> Self { 48 + pub fn build(db: PgPool, bus: ActorRef<Bus>) -> Self { 44 49 Self { 45 50 db, 51 + bus, 46 52 account_id: None, 47 53 subscriptions: HashMap::default(), 48 54 } ··· 80 86 let result = match request.data { 81 87 Request::Authenticate(account_id) => self.authenticate(tx, account_id).await, 82 88 Request::WatchField(field_id) => self.watch_field(tx, request.id, field_id).await, 89 + Request::WatchDeck => self.watch_deck(tx, request.id).await, 83 90 Request::Unsubscribe => self.unsubscribe(request.id).await, 84 91 }; 85 92 if let Err(error) = result {
+21
packages/cartography/src/actor/player_socket/watch_deck.rs
··· 1 + use super::super::Unsubscribe; 2 + use super::super::deck_state::DeckWatcher; 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_deck( 10 + &mut self, 11 + tx: UnboundedSender<Response>, 12 + message_id: Uuid, 13 + ) -> anyhow::Result<()> { 14 + let account_id = self.require_authentication()?; 15 + let actor = 16 + DeckWatcher::spawn((self.db.clone(), tx, self.bus.clone(), account_id.to_owned())); 17 + let unsubscriber = actor.recipient::<Unsubscribe>(); 18 + self.subscriptions.insert(message_id, unsubscriber); 19 + Ok(()) 20 + } 21 + }
+4 -2
packages/cartography/src/actor/player_socket/watch_field.rs
··· 12 12 message_id: Uuid, 13 13 field_id: i64, 14 14 ) -> anyhow::Result<()> { 15 - let _account_id = self.require_authentication()?; // TODO: only allow watching your own fields 16 - let actor = FieldWatcher::spawn(FieldWatcher::build(self.db.clone(), tx, field_id).await?); 15 + let account_id = self.require_authentication()?; 16 + let actor = FieldWatcher::spawn( 17 + FieldWatcher::build(self.db.clone(), tx, field_id, account_id).await?, 18 + ); 17 19 let unsubscriber = actor.recipient::<Unsubscribe>(); 18 20 self.subscriptions.insert(message_id, unsubscriber); 19 21 Ok(())
+7 -2
packages/cartography/src/api/ws.rs
··· 1 1 use crate::actor::player_socket::{PlayerSocket, Request, Response}; 2 + use crate::bus::Bus; 2 3 use axum::Extension; 3 4 use axum::extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade}; 4 5 use futures::StreamExt; ··· 47 48 Closed(#[error(not(source))] CloseFrame), 48 49 } 49 50 50 - pub async fn v1(ws: WebSocketUpgrade, db: Extension<PgPool>) -> axum::response::Response { 51 + pub async fn v1( 52 + ws: WebSocketUpgrade, 53 + db: Extension<PgPool>, 54 + bus: Extension<ActorRef<Bus>>, 55 + ) -> axum::response::Response { 51 56 let ws = ws.protocols([JSON_PROTOCOL, MESSAGEPACK_PROTOCOL]); 52 57 let protocol = ws 53 58 .selected_protocol() ··· 60 65 let (ws_sender, ws_receiver) = socket.split(); 61 66 futures::pin_mut!(ws_sender); 62 67 63 - let actor = PlayerSocket::spawn(PlayerSocket::build((*db).clone())); 68 + let actor = PlayerSocket::spawn(PlayerSocket::build((*db).clone(), (*bus).clone())); 64 69 let result = ws_receiver 65 70 .filter_map(|msg| async move { msg.ok() }) 66 71 .map(|msg| match msg {
-1
packages/cartography/src/bus.rs
··· 79 79 pub struct Listen<T: Any + Send + Sync>(Recipient<T>); 80 80 pub struct Notify<T: Any + Send + Sync + Clone>(T); 81 81 82 - #[cfg_attr(not(test), expect(dead_code))] 83 82 pub trait BusExt { 84 83 async fn listen<T: Any + Send + Sync, A: Actor + Message<T>>( 85 84 &self,
-22
packages/cartography/src/dto/mod.rs
··· 69 69 pub id: i64, 70 70 pub card_type_id: String, 71 71 } 72 - 73 - #[derive(PartialEq, Clone, Debug, Serialize, Deserialize, ToSchema)] 74 - #[serde(tag = "class")] 75 - #[expect(dead_code)] 76 - pub enum CardData { 77 - #[serde(rename = "Tile")] 78 - Tile(Tile), 79 - #[serde(rename = "Citizen")] 80 - Citizen(Citizen), 81 - } 82 - 83 - #[derive(PartialEq, Clone, Debug, Serialize, Deserialize, ToSchema)] 84 - pub struct Tile { 85 - pub tile_type_id: String, 86 - pub name: String, 87 - } 88 - 89 - #[derive(PartialEq, Clone, Debug, Serialize, Deserialize, ToSchema)] 90 - pub struct Citizen { 91 - pub species_id: String, 92 - pub name: String, 93 - }