Trading card city builder game?

convert list fields to REST API instead of websocket message

eldridge.cam 2b275fe7 0aef7ed5

verified
Waiting for spindle ...
+140 -77
+1
Justfile
··· 61 61 cargo check 62 62 cd app && npx svelte-kit sync 63 63 cd app && npx svelte-check 64 + cd app && npx eslint . 64 65 65 66 [group: "dev"] 66 67 generate:
+15
app/package-lock.json
··· 9 9 "version": "0.1.0", 10 10 "dependencies": { 11 11 "@sentry/browser": "^10.38.0", 12 + "@sveltestack/svelte-query": "^1.6.0", 12 13 "cartography-api": "file:./api", 13 14 "core-js": "^3.38.1", 14 15 "json-patch": "^0.7.0", ··· 1261 1262 "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", 1262 1263 "svelte": "^5.0.0", 1263 1264 "vite": "^6.3.0 || ^7.0.0" 1265 + } 1266 + }, 1267 + "node_modules/@sveltestack/svelte-query": { 1268 + "version": "1.6.0", 1269 + "resolved": "https://registry.npmjs.org/@sveltestack/svelte-query/-/svelte-query-1.6.0.tgz", 1270 + "integrity": "sha512-C0wWuh6av1zu3Pzwrg6EQmX3BhDZQ4gMAdYu6Tfv4bjbEZTB00uEDz52z92IZdONh+iUKuyo0xRZ2e16k2Xifg==", 1271 + "license": "MIT", 1272 + "peerDependencies": { 1273 + "broadcast-channel": "^4.5.0" 1274 + }, 1275 + "peerDependenciesMeta": { 1276 + "broadcast-channel": { 1277 + "optional": true 1278 + } 1264 1279 } 1265 1280 }, 1266 1281 "node_modules/@types/cookie": {
+1
app/package.json
··· 33 33 "type": "module", 34 34 "dependencies": { 35 35 "@sentry/browser": "^10.38.0", 36 + "@sveltestack/svelte-query": "^1.6.0", 36 37 "cartography-api": "file:./api", 37 38 "core-js": "^3.38.1", 38 39 "json-patch": "^0.7.0",
+13
app/src/lib/appserver/dto/Field.ts
··· 1 + import { Branded } from "$lib/types"; 2 + import Type, { type StaticDecode } from "typebox"; 3 + 4 + // eslint-disable-next-line @typescript-eslint/naming-convention -- TypeBox types are named like types */ 5 + export const FieldId = Branded("FieldId", Type.Integer()); 6 + export type FieldId = StaticDecode<typeof FieldId>; 7 + 8 + // eslint-disable-next-line @typescript-eslint/naming-convention -- TypeBox types are named like types */ 9 + export const Field = Type.Object({ 10 + id: FieldId, 11 + name: Type.String(), 12 + }); 13 + export type Field = StaticDecode<typeof Field>;
+1 -7
app/src/lib/appserver/socket/SocketV1.svelte.ts
··· 5 5 import Value from "typebox/value"; 6 6 import { 7 7 Account, 8 - Field, 9 - FieldId, 10 8 GameState, 11 9 Request, 12 10 RequestMessage, ··· 14 12 type SocketV1Protocol, 15 13 } from "./SocketV1Protocol"; 16 14 import jsonpatch from "json-patch"; 15 + import type { FieldId } from "../dto/Field"; 17 16 18 17 interface SocketV1EventMap { 19 18 message: MessageEvent; ··· 100 99 this.account = event.data; 101 100 this.dispatchEvent(new AuthEvent(event.data)); 102 101 }); 103 - } 104 - 105 - async listFields(): Promise<Field[]> { 106 - const event = await this.#sendMessage({ type: "ListFields" }).reply(); 107 - return event.data; 108 102 } 109 103 110 104 $watchField(data: { id: FieldId }, subscriber: (gameState: GameState | undefined) => void) {
+4 -33
app/src/lib/appserver/socket/SocketV1Protocol.ts
··· 5 5 * When in doubt the Gleam code is authoritative and this end should be updated. 6 6 */ 7 7 8 + import { Branded } from "$lib/types"; 8 9 import type { OpPatch } from "json-patch"; 9 10 import Type, { type StaticDecode, type TSchema } from "typebox"; 10 - 11 - declare const __BRAND: unique symbol; 12 - type Branded<Brand, T> = T & { [__BRAND]: Brand }; 13 - 14 - function Branded<Brand extends string, T extends TSchema>(brand: Brand, value: T) { 15 - return Type.Codec(value) 16 - .Decode((val) => val as Branded<Brand, StaticDecode<T>>) 17 - .Encode((val) => val as StaticDecode<T>); 18 - } 11 + import { FieldId } from "../dto/Field"; 19 12 20 13 const JsonPointer = Type.String({ 21 14 pattern: "^(/[^/~]*(~[01][^/~]*)*)*$", ··· 79 72 export const SpeciesId = Branded("SpeciesId", Type.String()); 80 73 export type SpeciesId = StaticDecode<typeof SpeciesId>; 81 74 82 - export const FieldId = Branded("FieldId", Type.Integer()); 83 - export type FieldId = StaticDecode<typeof FieldId>; 84 - 85 75 export const CardTypeId = Type.Union([TileTypeId, SpeciesId]); 86 76 export type CardTypeId = StaticDecode<typeof CardTypeId>; 87 77 ··· 105 95 export const FieldCitizen = Type.Object({ id: CitizenId, x: Type.Integer(), y: Type.Integer() }); 106 96 export type FieldCitizen = StaticDecode<typeof FieldCitizen>; 107 97 108 - export const Field = Type.Object({ 109 - id: FieldId, 110 - name: Type.String(), 111 - }); 112 - export type Field = StaticDecode<typeof Field>; 113 - 114 98 export const GameStateField = Type.Object({ 115 99 tiles: Type.Array(FieldTile), 116 100 citizens: Type.Array(FieldCitizen), ··· 132 116 }); 133 117 export type Authenticate = StaticDecode<typeof Authenticate>; 134 118 135 - export const ListFields = Type.Object({ type: Type.Literal("ListFields") }); 136 - export type ListFields = StaticDecode<typeof ListFields>; 137 - 138 119 export const WatchField = Type.Object({ type: Type.Literal("WatchField"), data: FieldId }); 139 120 export type WatchField = StaticDecode<typeof WatchField>; 140 121 ··· 147 128 }); 148 129 export type DebugAddCard = StaticDecode<typeof DebugAddCard>; 149 130 150 - export const Request = Type.Union([ 151 - Authenticate, 152 - ListFields, 153 - WatchField, 154 - Unsubscribe, 155 - DebugAddCard, 156 - ]); 131 + export const Request = Type.Union([Authenticate, WatchField, Unsubscribe, DebugAddCard]); 157 132 export type Request = StaticDecode<typeof Request>; 158 133 159 134 export const Authenticated = Type.Object({ type: Type.Literal("Authenticated"), data: Account }); 160 135 export type Authenticated = StaticDecode<typeof Authenticated>; 161 136 162 - export const FieldList = Type.Object({ type: Type.Literal("FieldList"), data: Type.Array(Field) }); 163 - export type FieldList = StaticDecode<typeof FieldList>; 164 - 165 137 export const PutFieldState = Type.Object({ type: Type.Literal("PutFieldState"), data: GameState }); 166 138 export type PutFieldState = StaticDecode<typeof PutFieldState>; 167 139 ··· 171 143 }); 172 144 export type PatchFieldState = StaticDecode<typeof PatchFieldState>; 173 145 174 - export const Response = Type.Union([Authenticated, FieldList, PutFieldState, PatchFieldState]); 146 + export const Response = Type.Union([Authenticated, PutFieldState, PatchFieldState]); 175 147 export type Response = StaticDecode<typeof Response>; 176 148 177 149 export type Once<T> = Branded<"Once", T>; ··· 182 154 183 155 export interface SocketV1Protocol { 184 156 Authenticate: Once<Authenticated>; 185 - ListFields: Once<FieldList>; 186 157 WatchField: Stream<PutFieldState | PatchFieldState>; 187 158 Unsubscribe: never; 188 159 }
+12
app/src/lib/types.ts
··· 1 + import Type, { type StaticDecode, type TSchema } from "typebox"; 2 + 3 + declare const __BRAND: unique symbol; 4 + export type Branded<Brand, T> = T & { [__BRAND]: Brand }; 5 + 6 + // eslint-disable-next-line @typescript-eslint/naming-convention -- TypeBox types are named like types */ 7 + export function Branded<Brand extends string, T extends TSchema>(brand: Brand, value: T) { 8 + return Type.Codec(value) 9 + .Decode((val) => val as Branded<Brand, StaticDecode<T>>) 10 + .Encode((val) => val as StaticDecode<T>); 11 + } 12 + 1 13 export interface Geography { 2 14 biome: string; 3 15 origin: { x: number; y: number };
+16 -9
app/src/routes/(socket)/play/components/PlayPage.svelte
··· 1 1 <script lang="ts"> 2 + import { PUBLIC_SERVER_URL } from "$env/static/public"; 3 + import type { Field, FieldId } from "$lib/appserver/dto/Field"; 4 + import type { GameState } from "$lib/appserver/socket/SocketV1Protocol"; 2 5 import { getSocket } from "$lib/appserver/provideSocket.svelte"; 3 - import type { FieldId, GameState } from "$lib/appserver/socket/SocketV1Protocol"; 4 6 import DragTile from "$lib/components/DragTile.svelte"; 5 7 import DragWindow from "$lib/components/DragWindow.svelte"; 6 8 import GridLines from "$lib/components/GridLines.svelte"; 7 9 import FieldView from "./FieldView.svelte"; 10 + import { useQuery } from "@sveltestack/svelte-query"; 8 11 9 12 const { socket } = $derived.by(getSocket); 10 13 11 - const fields = $derived(socket.listFields()); 14 + const fields = useQuery(["fields"], async () => { 15 + const response = await fetch(`${PUBLIC_SERVER_URL}/api/v1/players/foxfriends/fields`); 16 + // TODO: type-safe API client 17 + return response.json() as Promise<{ fields: Field[] }>; 18 + }); 12 19 13 20 let fieldId: FieldId | undefined = $state(); 14 21 let gameState: GameState | undefined = $state(); ··· 25 32 <GridLines /> 26 33 27 34 {#if fieldId === undefined} 28 - {#await fields} 29 - <div>Loading</div> 30 - {:then fields} 31 - {#each fields as field, i (field.id)} 35 + {#if $fields.status === "error"} 36 + <div>{$fields.error}</div> 37 + {:else if $fields.status === "success"} 38 + {#each $fields.data.fields as field, i (field.id)} 32 39 <DragTile x={i} y={0} onClick={() => (fieldId = field.id)}> 33 40 <div class="field-label"> 34 41 {#if field.name} ··· 41 48 {:else} 42 49 <div>No fields</div> 43 50 {/each} 44 - {:catch error} 45 - <div>{error}</div> 46 - {/await} 51 + {:else} 52 + <div>Loading</div> 53 + {/if} 47 54 {:else if gameState} 48 55 <FieldView {gameState} /> 49 56 {:else}
+11 -1
app/src/routes/+layout.svelte
··· 4 4 import type { Snippet } from "svelte"; 5 5 import Package from "../../package.json"; 6 6 import { PUBLIC_SENTRY_DSN, PUBLIC_ENV } from "$env/static/public"; 7 + import { QueryClient, QueryClientProvider } from "@sveltestack/svelte-query"; 7 8 8 9 if (PUBLIC_SENTRY_DSN) { 9 10 Sentry.init({ ··· 13 14 }); 14 15 } 15 16 17 + const queryClient = new QueryClient({ 18 + defaultOptions: { 19 + queries: { retry: false, refetchInterval: false }, 20 + mutations: { retry: false }, 21 + }, 22 + }); 23 + 16 24 const { children }: { children: Snippet } = $props(); 17 25 </script> 18 26 19 - {@render children()} 27 + <QueryClientProvider client={queryClient}> 28 + {@render children()} 29 + </QueryClientProvider>
-22
src/actor/player_socket/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.require_authentication()?, 15 - ) 16 - .fetch_all(&mut *conn) 17 - .await?; 18 - conn.commit().await?; 19 - tx.send(Response::FieldList(field_list))?; 20 - Ok(()) 21 - } 22 - }
+1 -5
src/actor/player_socket/mod.rs
··· 1 1 use super::field_state::FieldState; 2 2 use crate::actor::Unsubscribe; 3 3 use crate::api::ws::ProtocolV1Message; 4 - use crate::dto::{Account, Field}; 4 + use crate::dto::Account; 5 5 use futures::Stream; 6 6 use json_patch::Patch; 7 7 use kameo::prelude::*; ··· 13 13 use uuid::Uuid; 14 14 15 15 mod authenticate; 16 - mod list_fields; 17 16 mod unsubscribe; 18 17 mod watch_field; 19 18 ··· 21 20 #[serde(tag = "type", content = "data")] 22 21 pub enum Request { 23 22 Authenticate(String), 24 - ListFields, 25 23 WatchField(i64), 26 24 Unsubscribe, 27 25 } ··· 30 28 #[serde(tag = "type", content = "data")] 31 29 pub enum Response { 32 30 Authenticated(Account), 33 - FieldList(Vec<Field>), 34 31 PutFieldState(FieldState), 35 32 PatchFieldState(Vec<Patch>), 36 33 } ··· 82 79 ) -> Self::Reply { 83 80 let result = match request.data { 84 81 Request::Authenticate(account_id) => self.authenticate(tx, account_id).await, 85 - Request::ListFields => self.list_fields(tx).await, 86 82 Request::WatchField(field_id) => self.watch_field(tx, request.id, field_id).await, 87 83 Request::Unsubscribe => self.unsubscribe(request.id).await, 88 84 };
+1
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 15 16 let actor = FieldWatcher::spawn(FieldWatcher::build(self.db.clone(), tx, field_id).await?); 16 17 let unsubscriber = actor.recipient::<Unsubscribe>(); 17 18 self.subscriptions.insert(message_id, unsubscriber);
+59
src/api/list_fields.rs
··· 1 + use axum::extract::Path; 2 + use axum::http::StatusCode; 3 + use axum::response::{IntoResponse, Response}; 4 + use axum::Json; 5 + use serde::{Deserialize, Serialize}; 6 + use utoipa::ToSchema; 7 + 8 + use crate::dto::*; 9 + 10 + #[derive(Serialize, Deserialize, ToSchema)] 11 + pub struct ListFieldsResponse { 12 + fields: Vec<Field>, 13 + } 14 + 15 + #[derive(Serialize, Deserialize, ToSchema)] 16 + #[serde(untagged)] 17 + pub enum AccountIdOrMe { 18 + #[serde(rename = "@me")] 19 + Me, 20 + AccountId(String), 21 + } 22 + 23 + #[utoipa::path( 24 + get, 25 + path = "/api/v1/players/{player_id}/fields", 26 + description = "List player fields.", 27 + tag = "Player", 28 + responses( 29 + (status = OK, description = "Successfully listed all fields.", body = ListFieldsResponse), 30 + ), 31 + params( 32 + ("player_id" = AccountIdOrMe, Path, description = "The ID of the player whose fields to list.") 33 + ) 34 + )] 35 + pub async fn list_fields( 36 + db: axum::Extension<sqlx::PgPool>, 37 + Path(player_id): Path<AccountIdOrMe>, 38 + ) -> Result<Json<ListFieldsResponse>, Response> { 39 + let AccountIdOrMe::AccountId(account_id) = player_id else { 40 + return Err(( 41 + StatusCode::INTERNAL_SERVER_ERROR, 42 + "unimplemented".to_owned(), 43 + ) 44 + .into_response()); 45 + }; 46 + let mut conn = db 47 + .acquire() 48 + .await 49 + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())?; 50 + let fields = sqlx::query_as!( 51 + Field, 52 + "SELECT id, name FROM fields WHERE account_id = $1", 53 + account_id, 54 + ) 55 + .fetch_all(&mut *conn) 56 + .await 57 + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())?; 58 + Ok(Json(ListFieldsResponse { fields })) 59 + }
+1
src/api/mod.rs
··· 1 1 pub mod list_card_types; 2 + pub mod list_fields; 2 3 pub mod ws;
+4
src/main.rs
··· 38 38 "/api/v1/cardtypes", 39 39 axum::routing::get(api::list_card_types::list_card_types), 40 40 ) 41 + .route( 42 + "/api/v1/players/{player_id}/fields", 43 + axum::routing::get(api::list_fields::list_fields), 44 + ) 41 45 .route("/play/ws", axum::routing::any(api::ws::v1)) 42 46 .route( 43 47 "/api/openapi.json",