Trading card city builder game?

connecting svelte client to rust backend

eldridge.cam 632919cc b3ea0b27

verified
+147 -69
+1
.gitignore
··· 11 11 .eslintcache 12 12 /target 13 13 /app/.svelte-kit 14 + /app/src/lib/appserver/types
+42
Cargo.lock
··· 155 155 "tokio", 156 156 "tokio-stream", 157 157 "tracing", 158 + "ts-rs", 158 159 "utoipa", 159 160 "uuid", 160 161 ] ··· 1645 1646 ] 1646 1647 1647 1648 [[package]] 1649 + name = "termcolor" 1650 + version = "1.4.1" 1651 + source = "registry+https://github.com/rust-lang/crates.io-index" 1652 + checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" 1653 + dependencies = [ 1654 + "winapi-util", 1655 + ] 1656 + 1657 + [[package]] 1648 1658 name = "thiserror" 1649 1659 version = "2.0.18" 1650 1660 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1811 1821 checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 1812 1822 dependencies = [ 1813 1823 "once_cell", 1824 + ] 1825 + 1826 + [[package]] 1827 + name = "ts-rs" 1828 + version = "12.0.1" 1829 + source = "registry+https://github.com/rust-lang/crates.io-index" 1830 + checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" 1831 + dependencies = [ 1832 + "thiserror", 1833 + "ts-rs-macros", 1834 + "uuid", 1835 + ] 1836 + 1837 + [[package]] 1838 + name = "ts-rs-macros" 1839 + version = "12.0.1" 1840 + source = "registry+https://github.com/rust-lang/crates.io-index" 1841 + checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" 1842 + dependencies = [ 1843 + "proc-macro2", 1844 + "quote", 1845 + "syn", 1846 + "termcolor", 1814 1847 ] 1815 1848 1816 1849 [[package]] ··· 2020 2053 dependencies = [ 2021 2054 "libredox", 2022 2055 "wasite", 2056 + ] 2057 + 2058 + [[package]] 2059 + name = "winapi-util" 2060 + version = "0.1.11" 2061 + source = "registry+https://github.com/rust-lang/crates.io-index" 2062 + checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 2063 + dependencies = [ 2064 + "windows-sys 0.61.2", 2023 2065 ] 2024 2066 2025 2067 [[package]]
+1
Cargo.toml
··· 20 20 tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } 21 21 tokio-stream = { version = "0.1.18", features = ["sync"] } 22 22 tracing = "0.1.44" 23 + ts-rs = { version = "12.0.1", features = ["uuid-impl"] } 23 24 utoipa = "5.4.0" 24 25 uuid = { version = "1.20.0", features = ["serde", "v7"] }
+9 -1
Justfile
··· 7 7 [group: "dev"] 8 8 fix: fmt 9 9 10 + export TS_RS_EXPORT_DIR := "app/src/lib/appserver/types" 11 + 10 12 export CONCURRENTLY_KILL_OTHERS := "true" 11 13 export CONCURRENTLY_PAD_PREFIX := "true" 12 14 export CONCURRENTLY_PREFIX_COLORS := "auto" ··· 19 21 shadow_database_name := if SHADOW_DATABASE_URL != "" { file_stem(SHADOW_DATABASE_URL) } else { "" } 20 22 21 23 [group: "run"] 22 - dev: up 24 + dev: up generate 23 25 npx concurrently -n "server,client" \ 24 26 "cargo run" \ 25 27 "cd app && npx vite dev" ··· 61 63 cargo check 62 64 cd app && npx svelte-kit sync 63 65 cd app && npx svelte-check 66 + 67 + [group: "dev"] 68 + generate: 69 + cargo test export_bindings 70 + cd app && npx svelte-kit sync 71 + cd app && npx prettier --write ../{{TS_RS_EXPORT_DIR}} 64 72 65 73 [group: "dev"] 66 74 fmt:
+2 -2
app/src/lib/appserver/socket/MessageStream.svelte.ts
··· 20 20 if (event.message.id === this.id) { 21 21 // NOTE: would be nice to do a runtime assertion here, but the mapping is currently 22 22 // only defined as a type. Not hard to shift to a value, just lazy. 23 - resolve(event.message.response as OnceType<T>); 23 + resolve(event.message as OnceType<T>); 24 24 this.#socket.removeEventListener("message", handler); 25 25 abort?.removeEventListener("abort", onabort); 26 26 } ··· 41 41 if (event.message.id === this.id) { 42 42 // NOTE: would be nice to do a runtime assertion here, but the mapping is currently 43 43 // only defined as a type. Not hard to shift to a value, just lazy. 44 - callback(event.message.response as StreamType<T>); 44 + callback(event.message as StreamType<T>); 45 45 } 46 46 }; 47 47
+13 -14
app/src/lib/appserver/socket/SocketV1.svelte.ts
··· 5 5 import Value from "typebox/value"; 6 6 import { 7 7 Account, 8 - construct, 9 8 Field, 10 9 FieldId, 11 10 GameState, ··· 87 86 } 88 87 89 88 #sendMessage<T extends Request>(request: T, id: string = window.crypto.randomUUID()) { 90 - const encoded = Value.Encode(RequestMessage, { id, request }); 89 + const encoded = Value.Encode(RequestMessage, { id, ...request }); 91 90 this.#socket.send(JSON.stringify(encoded)); 92 91 return new MessageStream< 93 - T["#tag"] extends keyof SocketV1Protocol ? SocketV1Protocol[T["#tag"]] : never 92 + T["type"] extends keyof SocketV1Protocol ? SocketV1Protocol[T["type"]] : never 94 93 >(this, id); 95 94 } 96 95 97 96 auth(data: { id: string }) { 98 - this.#sendMessage(construct("Authenticate", data.id)) 97 + this.#sendMessage({ type: "Authenticate", data: data.id }) 99 98 .reply() 100 99 .then((event) => { 101 - this.account = event["#payload"]; 102 - this.dispatchEvent(new AuthEvent(event["#payload"])); 100 + this.account = event.data; 101 + this.dispatchEvent(new AuthEvent(event.data)); 103 102 }); 104 103 } 105 104 106 105 async listFields(): Promise<Field[]> { 107 - const event = await this.#sendMessage(construct("ListFields", null)).reply(); 108 - return event["#payload"]; 106 + const event = await this.#sendMessage({ type: "ListFields" }).reply(); 107 + return event.data; 109 108 } 110 109 111 110 $watchField(data: { id: FieldId }, subscriber: (gameState: GameState | undefined) => void) { 112 111 let gameState: GameState | undefined = undefined; 113 - this.#sendMessage(construct("WatchField", data.id)).$subscribe((response) => { 114 - if (response["#tag"] === "PutState") { 115 - gameState = response["#payload"]; 116 - } else if (response["#tag"] === "PatchState") { 117 - const patches = response["#payload"]; 112 + this.#sendMessage({ type: "WatchField", data: data.id }).$subscribe((response) => { 113 + if (response.type === "PutState") { 114 + gameState = response.data; 115 + } else if (response.type === "PatchState") { 116 + const patches = response.data; 118 117 gameState = jsonpatch.apply(gameState, patches); 119 118 } 120 119 subscriber(gameState); ··· 122 121 } 123 122 124 123 unsubscribe(id: string) { 125 - this.#sendMessage(construct("Unsubscribe", null), id); 124 + this.#sendMessage({ type: "Unsubscribe" }, id); 126 125 } 127 126 128 127 close(code?: number, reason?: string) {
+18 -37
app/src/lib/appserver/socket/SocketV1Protocol.ts
··· 17 17 .Encode((val) => val as StaticDecode<T>); 18 18 } 19 19 20 - function Struct<Tag extends string, Payload extends TSchema>(name: Tag, payload: Payload) { 21 - return Type.Object({ 22 - "#type": Type.Literal("struct"), 23 - "#tag": Type.Literal(name), 24 - "#payload": payload, 25 - }); 26 - } 27 - 28 - export function construct<Tag extends string, Payload>(tag: Tag, payload: Payload) { 29 - return { "#type": "struct", "#tag": tag, "#payload": payload } as const; 30 - } 31 - 32 - export type Struct<Tag extends string, Payload extends TSchema> = StaticDecode< 33 - typeof Struct<Tag, Payload> 34 - >; 35 - 36 20 const JsonPointer = Type.String({ 37 21 pattern: "^(/[^/~]*(~[01][^/~]*)*)*$", 38 22 }); ··· 142 126 }); 143 127 export type GameState = StaticDecode<typeof GameState>; 144 128 145 - export const Authenticate = Struct("Authenticate", Type.String()); 129 + export const Authenticate = Type.Object({ type: Type.Literal("Authenticate"), data: Type.String() }); 146 130 export type Authenticate = StaticDecode<typeof Authenticate>; 147 131 148 - export const ListFields = Struct("ListFields", Type.Null()); 132 + export const ListFields = Type.Object({ type: Type.Literal("ListFields") }); 149 133 export type ListFields = StaticDecode<typeof ListFields>; 150 134 151 - export const WatchField = Struct("WatchField", FieldId); 135 + export const WatchField = Type.Object({ type: Type.Literal("WatchField"), data: FieldId }); 152 136 export type WatchField = StaticDecode<typeof WatchField>; 153 137 154 - export const Unsubscribe = Struct("Unsubscribe", Type.Null()); 138 + export const Unsubscribe = Type.Object({ type: Type.Literal("Unsubscribe") }); 155 139 export type Unsubscribe = StaticDecode<typeof Unsubscribe>; 156 140 157 - export const DebugAddCard = Struct("DebugAddCard", Type.String()); 141 + export const DebugAddCard = Type.Object({ type: Type.Literal("DebugAddCard"), data: Type.String() }); 158 142 export type DebugAddCard = StaticDecode<typeof DebugAddCard>; 159 143 160 144 export const Request = Type.Union([ ··· 166 150 ]); 167 151 export type Request = StaticDecode<typeof Request>; 168 152 169 - export const Authenticated = Struct("Authenticated", Account); 153 + export const Authenticated = Type.Object({ type: Type.Literal("Authenticated"), data: Account }); 170 154 export type Authenticated = StaticDecode<typeof Authenticated>; 171 155 172 - export const Fields = Struct("Fields", Type.Array(Field)); 173 - export type Fields = StaticDecode<typeof Fields>; 156 + export const FieldList = Type.Object({ type: Type.Literal("FieldList"), data: Type.Array(Field) }); 157 + export type FieldList = StaticDecode<typeof FieldList>; 174 158 175 - export const PutState = Struct("PutState", GameState); 159 + export const PutState = Type.Object({ type: Type.Literal("PutState"), data: GameState }); 176 160 export type PutState = StaticDecode<typeof PutState>; 177 161 178 - export const PatchState = Struct("PatchState", Type.Array(JsonPatch)); 162 + export const PatchState = Type.Object({ type: Type.Literal("PatchState"), data: Type.Array(JsonPatch) }); 179 163 export type PatchState = StaticDecode<typeof PatchState>; 180 164 181 - export const Response = Type.Union([Authenticated, Fields, PutState, PatchState]); 165 + export const Response = Type.Union([Authenticated, FieldList, PutState, PatchState]); 182 166 export type Response = StaticDecode<typeof Response>; 183 167 184 168 export type Once<T> = Branded<"Once", T>; ··· 189 173 190 174 export interface SocketV1Protocol { 191 175 Authenticate: Once<Authenticated>; 192 - ListFields: Once<Fields>; 176 + ListFields: Once<FieldList>; 193 177 WatchField: Stream<PutState | PatchState>; 194 178 } 195 179 196 - export const RequestMessage = Type.Object({ 197 - id: Type.String({ format: "uuid" }), 198 - request: Request, 199 - }); 180 + function ProtocolV1Message<T extends TSchema>(data: T) { 181 + return Type.Intersect([Type.Object({ id: Type.String({ format: "uuid" }) }), data]); 182 + } 183 + 184 + export const RequestMessage = ProtocolV1Message(Request); 200 185 export type RequestMessage = StaticDecode<typeof RequestMessage>; 201 186 202 - export const ResponseMessage = Type.Object({ 203 - id: Type.String({ format: "uuid" }), 204 - nonce: Type.Integer(), 205 - response: Response, 206 - }); 187 + export const ResponseMessage = ProtocolV1Message(Response); 207 188 export type ResponseMessage = StaticDecode<typeof ResponseMessage>;
+16 -5
src/actor/player_socket/mod.rs
··· 1 + use crate::dto::{Account, Field}; 1 2 use serde::{Deserialize, Serialize}; 3 + use ts_rs::TS; 2 4 3 - #[derive(Serialize, Deserialize, Clone, Debug)] 5 + #[derive(Serialize, Deserialize, Clone, Debug, TS)] 6 + #[ts(export)] 4 7 #[serde(tag = "type", content = "data")] 5 8 pub enum Request { 6 9 Authenticate(String), 10 + ListFields, 7 11 } 8 12 9 - #[derive(Serialize, Deserialize, Clone, Debug)] 13 + #[derive(Serialize, Deserialize, Clone, Debug, TS)] 14 + #[ts(export)] 10 15 #[serde(tag = "type", content = "data")] 11 16 pub enum Response { 12 - Authenticated(String), 17 + Authenticated(Account), 18 + FieldList(Vec<Field>), 13 19 } 14 20 15 21 pub use server::PlayerSocket; 16 22 17 23 mod server { 18 - mod authenticate; 19 - 20 24 use super::{Request, Response}; 21 25 use futures::Stream; 22 26 use kameo::prelude::*; 23 27 use sqlx::PgPool; 24 28 use tokio::sync::mpsc::{unbounded_channel, UnboundedSender}; 25 29 use tokio_stream::wrappers::UnboundedReceiverStream; 30 + 31 + mod authenticate; 32 + mod list_fields; 26 33 27 34 #[derive(Actor)] 28 35 pub struct PlayerSocket { ··· 63 70 ) -> Self::Reply { 64 71 let result = match request { 65 72 Request::Authenticate(account_id) => self.authenticate(tx, account_id).await, 73 + Request::ListFields if self.account_id.is_some() => { 74 + self.list_fields(tx).await 75 + } 76 + _ => Err(anyhow::anyhow!("authentication required")) 66 77 }; 67 78 if let Err(error) = result { 68 79 tracing::error!("error handling player socket message: {}", error);
+2 -4
src/actor/player_socket/server/authenticate.rs
··· 1 1 use super::{PlayerSocket, Response}; 2 + use crate::dto::Account; 2 3 use tokio::sync::mpsc::UnboundedSender; 3 4 4 5 impl PlayerSocket { ··· 7 8 tx: UnboundedSender<Response>, 8 9 account_id: String, 9 10 ) -> anyhow::Result<()> { 10 - struct Account { 11 - id: String, 12 - } 13 11 let mut conn = self.db.begin().await?; 14 12 let account = sqlx::query_as!(Account, "SELECT id FROM accounts WHERE id = $1", account_id) 15 13 .fetch_optional(&mut *conn) ··· 28 26 }; 29 27 conn.commit().await?; 30 28 self.account_id = Some(account.id.clone()); 31 - tx.send(Response::Authenticated(account.id))?; 29 + tx.send(Response::Authenticated(account))?; 32 30 Ok(()) 33 31 } 34 32 }
+22
src/actor/player_socket/server/list_fields.rs
··· 1 + use super::{PlayerSocket, Response}; 2 + use crate::dto::Field; 3 + use tokio::sync::mpsc::UnboundedSender; 4 + 5 + impl PlayerSocket { 6 + pub(super) async fn list_fields( 7 + &mut self, 8 + tx: UnboundedSender<Response>, 9 + ) -> anyhow::Result<()> { 10 + let mut conn = self.db.begin().await?; 11 + let field_list = sqlx::query_as!( 12 + Field, 13 + "SELECT id, name FROM fields WHERE account_id = $1", 14 + self.account_id.as_ref().unwrap() 15 + ) 16 + .fetch_all(&mut *conn) 17 + .await?; 18 + conn.commit().await?; 19 + tx.send(Response::FieldList(field_list))?; 20 + Ok(()) 21 + } 22 + }
+3 -1
src/api/ws.rs
··· 6 6 use serde::{Deserialize, Serialize}; 7 7 use sqlx::PgPool; 8 8 use tracing::Instrument; 9 + use ts_rs::TS; 9 10 use uuid::Uuid; 10 11 11 - #[derive(Serialize, Deserialize, Clone, Debug)] 12 + #[derive(Serialize, Deserialize, Clone, Debug, TS)] 13 + #[ts(export)] 12 14 pub struct ProtocolV1Message<T> { 13 15 pub id: Uuid, 14 16 #[serde(flatten)]
+3 -2
src/db.rs
··· 1 1 use serde::{Deserialize, Serialize}; 2 + use ts_rs::TS; 2 3 use utoipa::ToSchema; 3 4 4 5 #[derive(sqlx::Type)] 5 6 #[sqlx(type_name = "card_class", rename_all = "lowercase")] 6 - #[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy, Debug, ToSchema)] 7 + #[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy, Debug, ToSchema, TS)] 7 8 pub enum CardClass { 8 9 Tile, 9 10 Citizen, ··· 11 12 12 13 #[derive(sqlx::Type)] 13 14 #[sqlx(type_name = "tile_category", rename_all = "lowercase")] 14 - #[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy, Debug, ToSchema)] 15 + #[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy, Debug, ToSchema, TS)] 15 16 pub enum TileCategory { 16 17 Residential, 17 18 Production,
+15 -3
src/dto/mod.rs
··· 1 1 use crate::db::TileCategory; 2 2 use serde::{Deserialize, Serialize}; 3 + use ts_rs::TS; 3 4 use utoipa::ToSchema; 4 5 5 - #[derive(Serialize, Deserialize, PartialEq, Clone, ToSchema)] 6 + #[derive(Serialize, Deserialize, PartialEq, Clone, Debug, ToSchema, TS)] 7 + pub struct Account { 8 + pub id: String, 9 + } 10 + 11 + #[derive(Serialize, Deserialize, PartialEq, Clone, Debug, ToSchema, TS)] 12 + pub struct Field { 13 + pub id: i64, 14 + pub name: String, 15 + } 16 + 17 + #[derive(Serialize, Deserialize, PartialEq, Clone, Debug, ToSchema, TS)] 6 18 #[serde(tag = "class")] 7 19 pub enum CardType { 8 20 Tile(TileType), 9 21 Citizen(Species), 10 22 } 11 23 12 - #[derive(Serialize, Deserialize, PartialEq, Clone, ToSchema)] 24 + #[derive(Serialize, Deserialize, PartialEq, Clone, Debug, ToSchema, TS)] 13 25 pub struct TileType { 14 26 pub id: String, 15 27 pub card_set_id: String, ··· 18 30 pub employs: i32, 19 31 } 20 32 21 - #[derive(Serialize, Deserialize, PartialEq, Clone, ToSchema)] 33 + #[derive(Serialize, Deserialize, PartialEq, Clone, Debug, ToSchema, TS)] 22 34 pub struct Species { 23 35 pub id: String, 24 36 pub card_set_id: String,