Trading card city builder game?

adjusting the protocol some more

+147 -240
+16 -16
api/src/request.gleam
··· 1 + import game_state.{type FieldId} 1 2 import gleam/dynamic/decode 2 3 import gleam/json 3 4 import repr ··· 21 22 } 22 23 23 24 /// A request is sent from the client to the server. 24 - /// 25 - /// A response does not necessarily respond to something, it might just be a pushed notification. 26 25 pub opaque type Request { 27 26 Authenticate(auth_token: String) 27 + WatchField(field_id: FieldId) 28 + Unsubscribe 28 29 DebugAddCard(card_id: String) 29 30 } 30 31 ··· 34 35 Authenticate(auth_token) -> 35 36 json.string(auth_token) 36 37 |> repr.struct("Authenticate") 38 + WatchField(field_id) -> 39 + json.int(field_id.id) 40 + |> repr.struct("WatchField") 41 + Unsubscribe -> repr.struct(json.null(), "Unsubscribe") 37 42 DebugAddCard(card_id) -> 38 43 json.string(card_id) 39 44 |> repr.struct("DebugAddCard") ··· 51 56 json.parse(string, { 52 57 use id <- decode.field("id", repr.uuid()) 53 58 use request <- decode.field("request", { 54 - use tag <- repr.struct_tag(Authenticate("")) 59 + use tag <- repr.struct_tag(Unsubscribe) 55 60 case tag { 56 61 "Authenticate" -> { 57 62 use payload <- repr.struct_payload(decode.string) 58 63 decode.success(Authenticate(payload)) 59 64 } 65 + "WatchField" -> { 66 + use payload <- repr.struct_payload(decode.int) 67 + decode.success(WatchField(game_state.FieldId(payload))) 68 + } 69 + "Unsubscribe" -> { 70 + decode.success(Unsubscribe) 71 + } 60 72 "DebugAddCard" -> { 61 73 use payload <- repr.struct_payload(decode.string) 62 74 decode.success(DebugAddCard(payload)) 63 75 } 64 76 _ -> { 65 - decode.failure(Authenticate(""), "valid #tag") 77 + decode.failure(Unsubscribe, "valid #tag") 66 78 } 67 79 } 68 80 }) 69 81 decode.success(Message(id:, request:)) 70 82 }) 71 83 } 72 - 73 - pub type Error { 74 - InvalidTag 75 - } 76 - 77 - pub fn authenticate(auth_token: String) -> Request { 78 - Authenticate(auth_token) 79 - } 80 - 81 - pub fn debug_add_card(card_id: String) -> Request { 82 - DebugAddCard(card_id) 83 - }
+9 -5
api/src/response.gleam
··· 8 8 import youid/uuid.{type Uuid} 9 9 10 10 pub opaque type Message { 11 - Message(id: Uuid, response: Response) 11 + Message(nonce: Int, id: Uuid, response: Response) 12 12 } 13 13 14 - pub fn message(response: Response, id: String) -> Message { 14 + pub fn message(response: Response, id: String, nonce: Int) -> Message { 15 15 let assert Ok(id) = uuid.from_string(id) 16 - Message(id:, response:) 16 + Message(nonce:, id:, response:) 17 17 } 18 18 19 19 pub fn id(message: Message) -> String { ··· 25 25 } 26 26 27 27 /// A response is sent from the server to the client. 28 + /// 29 + /// A response does not necessarily respond to something, it might just be a pushed notification. 28 30 pub opaque type Response { 29 31 Authenticated(Account) 30 32 PutData(GameState) ··· 32 34 } 33 35 34 36 pub fn to_json(message: Message) -> json.Json { 35 - let Message(id, response) = message 37 + let Message(nonce, id, response) = message 36 38 let response = case response { 37 39 Authenticated(account) -> 38 40 account.to_json(account) ··· 48 50 |> repr.struct("PatchData") 49 51 } 50 52 json.object([ 53 + #("nonce", json.int(nonce)), 51 54 #("id", json.string(uuid.to_string(id))), 52 55 #("response", response), 53 56 ]) ··· 61 64 62 65 pub fn from_string(string: String) -> Result(Message, json.DecodeError) { 63 66 json.parse(string, { 67 + use nonce <- decode.field("nonce", decode.int) 64 68 use id <- decode.field("id", repr.uuid()) 65 69 use response <- decode.field("response", { 66 70 use tag <- repr.struct_tag(PatchData([])) ··· 84 88 } 85 89 } 86 90 }) 87 - decode.success(Message(id:, response:)) 91 + decode.success(Message(nonce:, id:, response:)) 88 92 }) 89 93 } 90 94
-6
src/lib/appserver/Account.ts
··· 1 - export interface Account { 2 - id: AccountId; 3 - } 4 - 5 - declare const __brand: unique symbol; 6 - export type AccountId = string & { [__brand]: "AccountId" };
-10
src/lib/appserver/Card.ts
··· 1 - import type { CardType } from "$lib/data/cards"; 2 - 3 - export interface Card { 4 - id: CardId; 5 - // eslint-disable-next-line @typescript-eslint/naming-convention -- this is a server owned field 6 - card_type_id: CardType; // TODO: is this to be linked to database records? or to code... 7 - } 8 - 9 - declare const __brand: unique symbol; 10 - export type CardId = string & { [__brand]: "CardId" };
-15
src/lib/appserver/Field.ts
··· 1 - /* eslint-disable @typescript-eslint/naming-convention */ 2 - export interface Field { 3 - id: FieldId; 4 - name: string; 5 - account_id: string; 6 - } 7 - 8 - declare const __brand: unique symbol; 9 - export type FieldId = number & { [__brand]: "FieldId" }; 10 - 11 - export type FieldIdString = `${number}` & { [__brand]: "FieldId" }; 12 - 13 - export function parseFieldId(string: FieldIdString): FieldId { 14 - return Number.parseInt(string) as FieldId; 15 - }
-16
src/lib/appserver/FieldTile.ts
··· 1 - import type { AccountId } from "./Account"; 2 - import type { CardId } from "./Card"; 3 - import type { FieldId } from "./Field"; 4 - 5 - export interface FieldTile { 6 - // eslint-disable-next-line @typescript-eslint/naming-convention -- this is a server owned field 7 - account_id: AccountId; 8 - // eslint-disable-next-line @typescript-eslint/naming-convention -- this is a server owned field 9 - tile_id: CardId; 10 - // eslint-disable-next-line @typescript-eslint/naming-convention -- this is a server owned field 11 - field_id?: FieldId; 12 - // eslint-disable-next-line @typescript-eslint/naming-convention -- this is a server owned field 13 - grid_x?: number; 14 - // eslint-disable-next-line @typescript-eslint/naming-convention -- this is a server owned field 15 - grid_y?: number; 16 - }
-8
src/lib/appserver/socket/NextEvent.ts
··· 1 - export class NextEvent<T> extends Event { 2 - message: T; 3 - 4 - constructor(message: T) { 5 - super("next"); 6 - this.message = message; 7 - } 8 - }
+25 -1
src/lib/appserver/socket/OneOff.svelte.ts src/lib/appserver/socket/MessageStream.svelte.ts
··· 1 1 import type { MessageEvent } from "./MessageEvent"; 2 2 import { type SocketV1 } from "./SocketV1"; 3 3 4 - export class OneOff<T> extends EventTarget { 4 + export class MessageStream<T> extends EventTarget { 5 5 #socket: SocketV1; 6 6 id: string; 7 7 ··· 35 35 }); 36 36 } 37 37 38 + replies(callback: (message: T) => void) { 39 + const handler = (event: MessageEvent) => { 40 + if (event.message.id === this.id) { 41 + // NOTE: would be nice to do a runtime assertion here, but the mapping is currently 42 + // only defined as a type. Not hard to shift to a value, just lazy. 43 + callback(event.message.response as T); 44 + } 45 + }; 46 + 47 + return { 48 + unsubscribe: () => { 49 + this.#socket.unsubscribe(this.id); 50 + this.#socket.removeEventListener("message", handler); 51 + }, 52 + }; 53 + } 54 + 38 55 $then(callback: (event: T) => void): void { 39 56 $effect(() => { 40 57 const abort = new AbortController(); ··· 50 67 ); 51 68 52 69 return () => abort.abort("unmounted"); 70 + }); 71 + } 72 + 73 + $subscribe(callback: (event: T) => void): void { 74 + $effect(() => { 75 + const subscription = this.replies(callback); 76 + return () => subscription.unsubscribe(); 53 77 }); 54 78 } 55 79 }
+11 -2
src/lib/appserver/socket/SocketV1.ts
··· 1 1 import { MessageEvent } from "./MessageEvent"; 2 2 import { AuthEvent } from "./AuthEvent"; 3 - import { OneOff } from "./OneOff.svelte"; 3 + import { MessageStream } from "./MessageStream.svelte"; 4 4 import { ReactiveEventTarget } from "$lib/ReactiveEventTarget.svelte"; 5 5 import Value from "typebox/value"; 6 6 import { 7 7 construct, 8 + FieldId, 8 9 Request, 9 10 RequestMessage, 10 11 ResponseMessage, ··· 82 83 #sendMessage<T extends Request>(request: T, id: string = window.crypto.randomUUID()) { 83 84 const encoded = Value.Encode(RequestMessage, { id, request }); 84 85 this.#socket.send(JSON.stringify(encoded)); 85 - return new OneOff< 86 + return new MessageStream< 86 87 T["#tag"] extends keyof SocketV1Protocol ? SocketV1Protocol[T["#tag"]] : never 87 88 >(this, id); 88 89 } ··· 93 94 .then((event) => { 94 95 this.dispatchEvent(new AuthEvent(event["#payload"])); 95 96 }); 97 + } 98 + 99 + $watchField(data: { id: FieldId }) { 100 + this.#sendMessage(construct("WatchField", data.id)).$subscribe(() => {}); 101 + } 102 + 103 + unsubscribe(id: string) { 104 + this.#sendMessage(construct("Unsubscribe", null), id); 96 105 } 97 106 98 107 close(code?: number, reason?: string) {
+46 -44
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 type { OpPatch } from "json-patch"; 8 9 import Type, { type StaticDecode, type TSchema } from "typebox"; 9 10 10 11 declare const __BRAND: unique symbol; ··· 39 40 /** 40 41 * Adapted from: https://github.com/fge/sample-json-schemas/blob/master/json-patch/json-patch.json 41 42 */ 42 - export const JsonPatch = Type.Array( 43 - Type.Intersect( 44 - [ 45 - Type.Object({ path: JsonPointer }, { description: "Members common to all operations" }), 46 - Type.Union([ 47 - Type.Object( 48 - { op: Type.Literal("add"), value: Type.Unknown() }, 49 - { description: "add operation. Value can be any JSON value." }, 50 - ), 51 - Type.Object( 52 - { op: Type.Literal("remove") }, 53 - { description: "remove operation. Only a path is specified." }, 54 - ), 55 - Type.Object( 56 - { op: Type.Literal("replace"), value: Type.Unknown() }, 57 - { description: "replace operation. Value can be any JSON value." }, 58 - ), 59 - Type.Object( 60 - { op: Type.Literal("move"), from: JsonPointer }, 61 - { description: 'move operation. "from" is a JSON Pointer.' }, 62 - ), 63 - Type.Object( 64 - { op: Type.Literal("copy"), from: JsonPointer }, 65 - { description: 'copy operation. "from" is a JSON Pointer.' }, 66 - ), 67 - Type.Object( 68 - { op: Type.Literal("test"), value: Type.Unknown() }, 69 - { description: "test operation. Value can be any JSON value." }, 70 - ), 71 - Type.Object( 72 - { op: Type.Literal("_get"), value: Type.Unknown() }, 73 - { description: "_get operation. Value can be any JSON value." }, 74 - ), 75 - ]), 76 - ], 77 - { description: "one JSON Patch operation" }, 78 - ), 79 - { 80 - title: "JSON Patch", 81 - description: "A JSON Schema describing a JSON Patch", 82 - $schema: "http://json-schema.org/draft-04/schema#", 83 - notes: ["Only required members are accounted for, other members are ignored"], 84 - }, 43 + const JsonPatchT = Type.Intersect( 44 + [ 45 + Type.Object({ path: JsonPointer }, { description: "Members common to all operations" }), 46 + Type.Union([ 47 + Type.Object( 48 + { op: Type.Literal("add"), value: Type.Unknown() }, 49 + { description: "add operation. Value can be any JSON value." }, 50 + ), 51 + Type.Object( 52 + { op: Type.Literal("remove") }, 53 + { description: "remove operation. Only a path is specified." }, 54 + ), 55 + Type.Object( 56 + { op: Type.Literal("replace"), value: Type.Unknown() }, 57 + { description: "replace operation. Value can be any JSON value." }, 58 + ), 59 + Type.Object( 60 + { op: Type.Literal("move"), from: JsonPointer }, 61 + { description: 'move operation. "from" is a JSON Pointer.' }, 62 + ), 63 + Type.Object( 64 + { op: Type.Literal("copy"), from: JsonPointer }, 65 + { description: 'copy operation. "from" is a JSON Pointer.' }, 66 + ), 67 + Type.Object( 68 + { op: Type.Literal("test"), value: Type.Unknown() }, 69 + { description: "test operation. Value can be any JSON value." }, 70 + ), 71 + ]), 72 + ], 73 + { description: "one JSON Patch operation" }, 85 74 ); 75 + 76 + export const JsonPatch = Type.Codec(JsonPatchT) 77 + .Decode((value) => value as unknown as OpPatch) 78 + .Encode((value) => value as StaticDecode<typeof JsonPatchT>); 86 79 87 80 export const AccountId = Branded("AccountId", Type.String()); 88 81 export type AccountId = StaticDecode<typeof AccountId>; ··· 102 95 export const SpeciesId = Branded("SpeciesId", Type.String()); 103 96 export type SpeciesId = StaticDecode<typeof SpeciesId>; 104 97 98 + export const FieldId = Branded("FieldId", Type.Integer()); 99 + export type FieldId = StaticDecode<typeof FieldId>; 100 + 105 101 export const CardTypeId = Type.Union([TileTypeId, SpeciesId]); 106 102 export type CardTypeId = StaticDecode<typeof CardTypeId>; 107 103 ··· 143 139 export const Authenticate = Struct("Authenticate", Type.String()); 144 140 export type Authenticate = StaticDecode<typeof Authenticate>; 145 141 142 + export const WatchField = Struct("WatchField", FieldId); 143 + export type WatchField = StaticDecode<typeof WatchField>; 144 + 145 + export const Unsubscribe = Struct("Unsubscribe", Type.Null()); 146 + export type Unsubscribe = StaticDecode<typeof Unsubscribe>; 147 + 146 148 export const DebugAddCard = Struct("DebugAddCard", Type.String()); 147 149 export type DebugAddCard = StaticDecode<typeof DebugAddCard>; 148 150 149 - export const Request = Type.Union([Authenticate, DebugAddCard]); 151 + export const Request = Type.Union([Authenticate, WatchField, Unsubscribe, DebugAddCard]); 150 152 export type Request = StaticDecode<typeof Request>; 151 153 152 154 export const Authenticated = Struct("Authenticated", Account);
-51
src/lib/appserver/socket/Subscription.ts
··· 1 - import { ReactiveEventTarget } from "$lib/ReactiveEventTarget.svelte"; 2 - import type { FieldId } from "../Field"; 3 - import type { CardMessage, FieldTileMessage, FieldMessage } from "./Message"; 4 - import { MessageEvent } from "./MessageEvent"; 5 - import { NextEvent } from "./NextEvent"; 6 - import { type SocketV1 } from "./SocketV1"; 7 - 8 - interface SubscriptionEventMap<C extends Channel> { 9 - next: NextEvent<SubscriptionMessageMap[Topic<C>]>; 10 - } 11 - 12 - // eslint-disable-next-line @typescript-eslint/naming-convention -- this is a server owned field 13 - export type Channel = "deck" | "fields" | { topic: "field_tiles"; field_id: FieldId }; 14 - 15 - export type Topic<C extends Channel> = C extends string 16 - ? C 17 - : C extends { topic: infer T } 18 - ? T 19 - : never; 20 - 21 - export interface SubscriptionMessageMap { 22 - fields: FieldMessage; 23 - deck: CardMessage; 24 - // eslint-disable-next-line @typescript-eslint/naming-convention -- this is a server owned field 25 - field_tiles: FieldTileMessage; 26 - } 27 - 28 - export class Subscription<C extends Channel> extends ReactiveEventTarget<SubscriptionEventMap<C>> { 29 - #socket: SocketV1; 30 - #handler: (event: MessageEvent) => void; 31 - id: string; 32 - 33 - constructor(socket: SocketV1, id: string) { 34 - super(); 35 - this.#socket = socket; 36 - this.id = id; 37 - 38 - this.#handler = (event: MessageEvent) => { 39 - if (event.message.id === this.id) { 40 - this.dispatchEvent(new NextEvent(event.message as SubscriptionMessageMap[Topic<C>])); 41 - } 42 - }; 43 - 44 - this.#socket.addEventListener("message", this.#handler); 45 - } 46 - 47 - unsubscribe() { 48 - this.#socket.unsubscribe(this.id); 49 - this.#socket.removeEventListener("message", this.#handler); 50 - } 51 - }
-66
src/routes/(socket)/play/components/provideFieldState.svelte.ts
··· 1 - import type { CardId } from "$lib/appserver/Card"; 2 - import type { Field, FieldId } from "$lib/appserver/Field"; 3 - import type { FieldTile } from "$lib/appserver/FieldTile"; 4 - import { getSocket } from "$lib/appserver/provideSocket.svelte"; 5 - import { getContext, setContext } from "svelte"; 6 - import { SvelteMap } from "svelte/reactivity"; 7 - 8 - const FIELD_STATE = Symbol("Field State"); 9 - 10 - interface FieldState { 11 - get fieldId(): FieldId | undefined; 12 - get field(): Field | undefined; 13 - get fieldTiles(): FieldTile[]; 14 - } 15 - 16 - export function getFieldState() { 17 - const state = getContext(FIELD_STATE) as FieldState; 18 - return { ...state }; 19 - } 20 - 21 - export function provideFieldState() { 22 - const socket = getSocket(); 23 - 24 - let fieldId: FieldId | undefined = $state(); 25 - let field: Field | undefined = $state(); 26 - let fieldTiles = $state(new SvelteMap<CardId, FieldTile>()); 27 - 28 - socket.$on("auth", () => { 29 - $effect(() => { 30 - if (fieldId) { 31 - // const subscription = socket.subscribe({ topic: "field_tiles", field_id: fieldId }); 32 - 33 - socket.getField(fieldId).$then(({ data }) => { 34 - field = data.field; 35 - fieldTiles = new SvelteMap(data.field_tiles.map((tile) => [tile.tile_id, tile])); 36 - }); 37 - 38 - // subscription.$on("next", ({ message }) => { 39 - // fieldTiles.set(message.data.field_card.card_id, message.data.field_card); 40 - // }); 41 - 42 - return () => { 43 - // subscription.unsubscribe(); 44 - }; 45 - } 46 - }); 47 - }); 48 - 49 - setContext(FIELD_STATE, { 50 - get fieldId() { 51 - return fieldId; 52 - }, 53 - get field() { 54 - return field; 55 - }, 56 - get fieldTiles() { 57 - return fieldTiles; 58 - }, 59 - }); 60 - 61 - return { 62 - set fieldId(value: FieldId | undefined) { 63 - fieldId = value; 64 - }, 65 - }; 66 - }
+40
src/routes/(socket)/play/components/provideGameState.svelte.ts
··· 1 + import type { GameState, FieldId } from "$lib/appserver/socket/SocketV1Protocol"; 2 + import { getSocket } from "$lib/appserver/provideSocket.svelte"; 3 + import jsonpatch from "json-patch"; 4 + import { getContext, setContext } from "svelte"; 5 + 6 + const GAME_STATE = Symbol("GameState"); 7 + 8 + export function getGameState(): { gameState: GameState } { 9 + const state = getContext(GAME_STATE) as { gameState: GameState }; 10 + return { ...state }; 11 + } 12 + 13 + export function provideGameState() { 14 + const socket = getSocket(); 15 + 16 + let fieldId: FieldId | undefined = $state(); 17 + let gameState: GameState | undefined = $state(); 18 + 19 + socket.$on("auth", () => { 20 + $effect(() => { 21 + if (fieldId) { 22 + socket.$watchField({ id: fieldId }); 23 + socket.$on("message", (event) => { 24 + if (event.message.response["#tag"] === "PutState") { 25 + gameState = event.message.response["#payload"]; 26 + } else if (event.message.response["#tag"] === "PatchState") { 27 + const patches = event.message.response["#payload"]; 28 + gameState = jsonpatch.apply(gameState, patches); 29 + } 30 + }); 31 + } 32 + }); 33 + }); 34 + 35 + setContext(GAME_STATE, { 36 + get gameState() { 37 + return gameState; 38 + }, 39 + }); 40 + }