Trading card city builder game?

end to end implement the first few revived socket request/responses

eldridge.cam 4a5f896d 7a2a06e0

Waiting for spindle ...
+458 -654
+1
Justfile
··· 65 65 66 66 [group: "release"] 67 67 build: 68 + cd server && gleam build 68 69 npx svelte-kit sync 69 70 npx vite build 70 71
api/src/account.gleam api/src/cartography_api/account.gleam
+30
api/src/cartography_api/field.gleam
··· 1 + import cartography_api/game_state 2 + import gleam/dynamic/decode 3 + import gleam/json.{type Json} 4 + 5 + pub type Field { 6 + Field(id: game_state.FieldId, name: String) 7 + } 8 + 9 + pub fn to_json(field: Field) -> Json { 10 + json.object([ 11 + #("id", json.int(field.id.id)), 12 + #("name", json.string(field.name)), 13 + ]) 14 + } 15 + 16 + pub fn to_string(field: Field) -> String { 17 + field 18 + |> to_json() 19 + |> json.to_string() 20 + } 21 + 22 + pub fn decoder() -> decode.Decoder(Field) { 23 + use id <- decode.field("id", decode.map(decode.int, game_state.FieldId)) 24 + use name <- decode.field("name", decode.string) 25 + decode.success(Field(id:, name:)) 26 + } 27 + 28 + pub fn from_string(string: String) -> Result(Field, json.DecodeError) { 29 + json.parse(string, decoder()) 30 + }
+82
api/src/cartography_api/request.gleam
··· 1 + import cartography_api/game_state.{type FieldId} 2 + import cartography_api/internal/repr 3 + import gleam/dynamic/decode 4 + import gleam/json 5 + import youid/uuid.{type Uuid} 6 + 7 + pub type Message { 8 + Message(id: Uuid, request: Request) 9 + } 10 + 11 + pub fn message(request: Request, id: String) -> Message { 12 + let assert Ok(id) = uuid.from_string(id) 13 + Message(id:, request:) 14 + } 15 + 16 + /// A request is sent from the client to the server. 17 + pub type Request { 18 + Authenticate(auth_token: String) 19 + ListFields 20 + WatchField(field_id: FieldId) 21 + Unsubscribe 22 + DebugAddCard(card_id: String) 23 + } 24 + 25 + pub fn to_json(message: Message) -> json.Json { 26 + let Message(id, request) = message 27 + let request = case request { 28 + Authenticate(auth_token) -> 29 + json.string(auth_token) 30 + |> repr.struct("Authenticate") 31 + ListFields -> repr.struct(json.null(), "ListFields") 32 + WatchField(field_id) -> 33 + json.int(field_id.id) 34 + |> repr.struct("WatchField") 35 + Unsubscribe -> repr.struct(json.null(), "Unsubscribe") 36 + DebugAddCard(card_id) -> 37 + json.string(card_id) 38 + |> repr.struct("DebugAddCard") 39 + } 40 + json.object([#("id", json.string(uuid.to_string(id))), #("request", request)]) 41 + } 42 + 43 + pub fn to_string(message: Message) -> String { 44 + message 45 + |> to_json() 46 + |> json.to_string() 47 + } 48 + 49 + pub fn decoder() { 50 + use id <- decode.field("id", repr.uuid()) 51 + use request <- decode.field("request", { 52 + use tag <- repr.struct_tag(Unsubscribe) 53 + case tag { 54 + "Authenticate" -> { 55 + use payload <- repr.struct_payload(decode.string) 56 + decode.success(Authenticate(payload)) 57 + } 58 + "ListFields" -> { 59 + decode.success(ListFields) 60 + } 61 + "WatchField" -> { 62 + use payload <- repr.struct_payload(decode.int) 63 + decode.success(WatchField(game_state.FieldId(payload))) 64 + } 65 + "Unsubscribe" -> { 66 + decode.success(Unsubscribe) 67 + } 68 + "DebugAddCard" -> { 69 + use payload <- repr.struct_payload(decode.string) 70 + decode.success(DebugAddCard(payload)) 71 + } 72 + _ -> { 73 + decode.failure(Unsubscribe, "valid #tag") 74 + } 75 + } 76 + }) 77 + decode.success(Message(id:, request:)) 78 + } 79 + 80 + pub fn from_string(string: String) -> Result(Message, json.DecodeError) { 81 + json.parse(string, decoder()) 82 + }
+96
api/src/cartography_api/response.gleam
··· 1 + import cartography_api/account.{type Account} 2 + import cartography_api/field.{type Field} 3 + import cartography_api/game_state.{type GameState} 4 + import cartography_api/internal/repr 5 + import gleam/dynamic/decode 6 + import gleam/json 7 + import gleam/list 8 + import squirtle.{type Patch} 9 + import youid/uuid.{type Uuid} 10 + 11 + pub type Message { 12 + Message(nonce: Int, id: Uuid, response: Response) 13 + } 14 + 15 + pub fn message(response: Response, id: Uuid, nonce: Int) -> Message { 16 + Message(nonce:, id:, response:) 17 + } 18 + 19 + /// A response is sent from the server to the client. 20 + /// 21 + /// A response does not necessarily respond to something, it might just be a pushed notification. 22 + pub type Response { 23 + Authenticated(Account) 24 + Fields(List(Field)) 25 + PutData(GameState) 26 + PatchData(List(Patch)) 27 + } 28 + 29 + pub fn to_json(message: Message) -> json.Json { 30 + let Message(nonce, id, response) = message 31 + let response = case response { 32 + Authenticated(account) -> 33 + account.to_json(account) 34 + |> repr.struct("Authenticated") 35 + Fields(fields) -> 36 + fields 37 + |> json.array(field.to_json) 38 + |> repr.struct("Fields") 39 + PutData(game_state) -> 40 + game_state.to_json(game_state) 41 + |> repr.struct("PutData") 42 + PatchData(patches) -> 43 + patches 44 + |> list.map(squirtle.patch_to_json_value) 45 + |> list.map(squirtle.json_value_to_gleam_json) 46 + |> json.preprocessed_array() 47 + |> repr.struct("PatchData") 48 + } 49 + json.object([ 50 + #("nonce", json.int(nonce)), 51 + #("id", json.string(uuid.to_string(id))), 52 + #("response", response), 53 + ]) 54 + } 55 + 56 + pub fn to_string(message: Message) -> String { 57 + message 58 + |> to_json() 59 + |> json.to_string() 60 + } 61 + 62 + pub fn decoder() { 63 + use nonce <- decode.field("nonce", decode.int) 64 + use id <- decode.field("id", repr.uuid()) 65 + use response <- decode.field("response", { 66 + use tag <- repr.struct_tag(PatchData([])) 67 + case tag { 68 + "Authenticated" -> { 69 + use payload <- repr.struct_payload(account.decoder()) 70 + decode.success(Authenticated(payload)) 71 + } 72 + "PutData" -> { 73 + use payload <- repr.struct_payload(game_state.decoder()) 74 + decode.success(PutData(payload)) 75 + } 76 + "Fields" -> { 77 + use payload <- repr.struct_payload(decode.list(field.decoder())) 78 + decode.success(Fields(payload)) 79 + } 80 + "PatchData" -> { 81 + use payload <- repr.struct_payload( 82 + decode.list(squirtle.patch_decoder()), 83 + ) 84 + decode.success(PatchData(payload)) 85 + } 86 + _ -> { 87 + decode.failure(PatchData([]), "valid #tag") 88 + } 89 + } 90 + }) 91 + decode.success(Message(nonce:, id:, response:)) 92 + } 93 + 94 + pub fn from_string(string: String) -> Result(Message, json.DecodeError) { 95 + json.parse(string, decoder()) 96 + }
+1 -1
api/src/game_state.gleam api/src/cartography_api/game_state.gleam
··· 1 + import cartography_api/internal/repr 1 2 import gleam/dynamic/decode 2 3 import gleam/json.{type Json} 3 4 import gleam/list 4 5 import gleam/option.{type Option} 5 - import repr 6 6 import squirtle 7 7 8 8 /// Defines a shared game state data model, which the server manages on behalf of a client
api/src/repr.gleam api/src/cartography_api/internal/repr.gleam
-83
api/src/request.gleam
··· 1 - import game_state.{type FieldId} 2 - import gleam/dynamic/decode 3 - import gleam/json 4 - import repr 5 - import youid/uuid.{type Uuid} 6 - 7 - pub opaque type Message { 8 - Message(id: Uuid, request: Request) 9 - } 10 - 11 - pub fn message(request: Request, id: String) -> Message { 12 - let assert Ok(id) = uuid.from_string(id) 13 - Message(id:, request:) 14 - } 15 - 16 - pub fn id(message: Message) -> String { 17 - uuid.to_string(message.id) 18 - } 19 - 20 - pub fn request(message: Message) -> Request { 21 - message.request 22 - } 23 - 24 - /// A request is sent from the client to the server. 25 - pub opaque type Request { 26 - Authenticate(auth_token: String) 27 - WatchField(field_id: FieldId) 28 - Unsubscribe 29 - DebugAddCard(card_id: String) 30 - } 31 - 32 - pub fn to_json(message: Message) -> json.Json { 33 - let Message(id, request) = message 34 - let request = case request { 35 - Authenticate(auth_token) -> 36 - json.string(auth_token) 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") 42 - DebugAddCard(card_id) -> 43 - json.string(card_id) 44 - |> repr.struct("DebugAddCard") 45 - } 46 - json.object([#("id", json.string(uuid.to_string(id))), #("request", request)]) 47 - } 48 - 49 - pub fn to_string(message: Message) -> String { 50 - message 51 - |> to_json() 52 - |> json.to_string() 53 - } 54 - 55 - pub fn from_string(string: String) -> Result(Message, json.DecodeError) { 56 - json.parse(string, { 57 - use id <- decode.field("id", repr.uuid()) 58 - use request <- decode.field("request", { 59 - use tag <- repr.struct_tag(Unsubscribe) 60 - case tag { 61 - "Authenticate" -> { 62 - use payload <- repr.struct_payload(decode.string) 63 - decode.success(Authenticate(payload)) 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 - } 72 - "DebugAddCard" -> { 73 - use payload <- repr.struct_payload(decode.string) 74 - decode.success(DebugAddCard(payload)) 75 - } 76 - _ -> { 77 - decode.failure(Unsubscribe, "valid #tag") 78 - } 79 - } 80 - }) 81 - decode.success(Message(id:, request:)) 82 - }) 83 - }
-105
api/src/response.gleam
··· 1 - import account.{type Account} 2 - import game_state.{type GameState} 3 - import gleam/dynamic/decode 4 - import gleam/json 5 - import gleam/list 6 - import repr 7 - import squirtle.{type Patch} 8 - import youid/uuid.{type Uuid} 9 - 10 - pub opaque type Message { 11 - Message(nonce: Int, id: Uuid, response: Response) 12 - } 13 - 14 - pub fn message(response: Response, id: String, nonce: Int) -> Message { 15 - let assert Ok(id) = uuid.from_string(id) 16 - Message(nonce:, id:, response:) 17 - } 18 - 19 - pub fn id(message: Message) -> String { 20 - uuid.to_string(message.id) 21 - } 22 - 23 - pub fn response(message: Message) -> Response { 24 - message.response 25 - } 26 - 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. 30 - pub opaque type Response { 31 - Authenticated(Account) 32 - PutData(GameState) 33 - PatchData(List(Patch)) 34 - } 35 - 36 - pub fn to_json(message: Message) -> json.Json { 37 - let Message(nonce, id, response) = message 38 - let response = case response { 39 - Authenticated(account) -> 40 - account.to_json(account) 41 - |> repr.struct("Authenticated") 42 - PutData(game_state) -> 43 - game_state.to_json(game_state) 44 - |> repr.struct("PutData") 45 - PatchData(patches) -> 46 - patches 47 - |> list.map(squirtle.patch_to_json_value) 48 - |> list.map(squirtle.json_value_to_gleam_json) 49 - |> json.preprocessed_array() 50 - |> repr.struct("PatchData") 51 - } 52 - json.object([ 53 - #("nonce", json.int(nonce)), 54 - #("id", json.string(uuid.to_string(id))), 55 - #("response", response), 56 - ]) 57 - } 58 - 59 - pub fn to_string(message: Message) -> String { 60 - message 61 - |> to_json() 62 - |> json.to_string() 63 - } 64 - 65 - pub fn from_string(string: String) -> Result(Message, json.DecodeError) { 66 - json.parse(string, { 67 - use nonce <- decode.field("nonce", decode.int) 68 - use id <- decode.field("id", repr.uuid()) 69 - use response <- decode.field("response", { 70 - use tag <- repr.struct_tag(PatchData([])) 71 - case tag { 72 - "Authenticated" -> { 73 - use payload <- repr.struct_payload(account.decoder()) 74 - decode.success(Authenticated(payload)) 75 - } 76 - "PutData" -> { 77 - use payload <- repr.struct_payload(game_state.decoder()) 78 - decode.success(PutData(payload)) 79 - } 80 - "PatchData" -> { 81 - use payload <- repr.struct_payload( 82 - decode.list(squirtle.patch_decoder()), 83 - ) 84 - decode.success(PatchData(payload)) 85 - } 86 - _ -> { 87 - decode.failure(PatchData([]), "valid #tag") 88 - } 89 - } 90 - }) 91 - decode.success(Message(nonce:, id:, response:)) 92 - }) 93 - } 94 - 95 - pub fn authenticated(account: Account) -> Response { 96 - Authenticated(account) 97 - } 98 - 99 - pub fn put_data(game_state: GameState) -> Response { 100 - PutData(game_state) 101 - } 102 - 103 - pub fn patch_data(patches: List(Patch)) -> Response { 104 - PatchData(patches) 105 - }
-17
server/src/dto/channel.gleam
··· 1 - import gleam/dynamic/decode 2 - 3 - pub type Channel { 4 - Deck 5 - } 6 - 7 - pub fn decode_channel() { 8 - use name <- decode.then( 9 - decode.one_of(decode.field("channel", decode.string, decode.success), or: [ 10 - decode.subfield(["channel", "topic"], decode.string, decode.success), 11 - ]), 12 - ) 13 - case name { 14 - "deck" -> decode.success(Deck) 15 - _ -> decode.failure(Deck, "valid channel") 16 - } 17 - }
-36
server/src/dto/input_action.gleam
··· 1 - import dto/channel 2 - import gleam/dynamic/decode 3 - 4 - pub type InputAction { 5 - Auth(String) 6 - GetFields 7 - GetField(Int) 8 - Subscribe(channel.Channel) 9 - Unsubscribe 10 - } 11 - 12 - fn decode_empty(into: InputAction) { 13 - use _ <- decode.map(decode.dict( 14 - decode.failure(Nil, "empty object"), 15 - decode.failure(Nil, "empty object"), 16 - )) 17 - into 18 - } 19 - 20 - pub fn decoder(message_type: String) { 21 - case message_type { 22 - "auth" -> { 23 - use id <- decode.field("id", decode.string) 24 - decode.success(Auth(id)) 25 - } 26 - "get_fields" -> decode_empty(GetFields) 27 - "get_field" -> { 28 - use field_id <- decode.field("field_id", decode.int) 29 - decode.success(GetField(field_id)) 30 - } 31 - "subscribe" -> decode.map(channel.decode_channel(), Subscribe) 32 - "unsubscribe" -> decode_empty(Unsubscribe) 33 - "deck" -> decode_empty(GetFields) 34 - _ -> decode.failure(GetFields, "known message type") 35 - } 36 - }
-13
server/src/dto/input_message.gleam
··· 1 - import dto/input_action 2 - import gleam/dynamic/decode 3 - 4 - pub type InputMessage { 5 - InputMessage(data: input_action.InputAction, id: String) 6 - } 7 - 8 - pub fn decoder() { 9 - use message_type <- decode.field("type", decode.string) 10 - use id <- decode.field("id", decode.string) 11 - use data <- decode.field("data", input_action.decoder(message_type)) 12 - decode.success(InputMessage(data, id)) 13 - }
-58
server/src/dto/output_action.gleam
··· 1 - import gleam/json 2 - import models/account 3 - import models/field 4 - import models/field_tile 5 - 6 - pub type OutputAction { 7 - Account(account.Account) 8 - Fields(List(field.Field)) 9 - FieldWithTiles(field: field.Field, field_tiles: List(field_tile.FieldTile)) 10 - Field(field: field.Field) 11 - CardAccount(card_id: Int) 12 - FieldTile(field_tile.FieldTile) 13 - FieldTileStub(card_id: Int) 14 - } 15 - 16 - pub fn to_json(message: OutputAction) -> #(String, json.Json) { 17 - case message { 18 - Account(acc) -> #( 19 - "account", 20 - json.object([#("account", account.to_json(acc))]), 21 - ) 22 - Fields(fields) -> #( 23 - "fields", 24 - json.object([#("fields", json.array(fields, field.to_json))]), 25 - ) 26 - FieldWithTiles(field_data, field_tiles) -> #( 27 - "field", 28 - json.object([ 29 - #("field", field.to_json(field_data)), 30 - #("field_tiles", json.array(field_tiles, field_tile.to_json)), 31 - ]), 32 - ) 33 - Field(field_data) -> #( 34 - "field", 35 - json.object([ 36 - #("field", field.to_json(field_data)), 37 - ]), 38 - ) 39 - CardAccount(card_id) -> #( 40 - "card_account", 41 - json.object([ 42 - #("card", json.object([#("id", json.int(card_id))])), 43 - ]), 44 - ) 45 - FieldTileStub(card_id) -> #( 46 - "field_tile", 47 - json.object([ 48 - #("field_tile", json.object([#("id", json.int(card_id))])), 49 - ]), 50 - ) 51 - FieldTile(field_tile) -> #( 52 - "field_tile", 53 - json.object([ 54 - #("field_tile", field_tile.to_json(field_tile)), 55 - ]), 56 - ) 57 - } 58 - }
-24
server/src/dto/output_message.gleam
··· 1 - import dto/output_action 2 - import gleam/json 3 - import mist 4 - 5 - pub type OutputMessage { 6 - OutputMessage(data: output_action.OutputAction, id: String) 7 - } 8 - 9 - pub fn to_json(message: OutputMessage) -> json.Json { 10 - let #(msg_type, data) = output_action.to_json(message.data) 11 - json.object([ 12 - #("type", json.string(msg_type)), 13 - #("data", data), 14 - #("id", json.string(message.id)), 15 - ]) 16 - } 17 - 18 - pub fn send(message: OutputMessage, conn: mist.WebsocketConnection) { 19 - let message = 20 - message 21 - |> to_json() 22 - |> json.to_string() 23 - mist.send_text_frame(conn, message) 24 - }
+10 -7
server/src/handlers/auth_handler.gleam server/src/handlers/authenticate_handler.gleam
··· 1 + import cartography_api/account 2 + import cartography_api/response 1 3 import db/rows 2 4 import db/sql 3 - import dto/output_action 4 - import dto/output_message 5 5 import gleam/option 6 6 import gleam/result 7 7 import gleam/string 8 8 import mist 9 - import models/account 10 9 import websocket/state 10 + import youid/uuid 11 11 12 12 pub fn handle( 13 13 st: state.State, 14 14 conn: mist.WebsocketConnection, 15 - message_id: String, 15 + message_id: uuid.Uuid, 16 16 account_id: String, 17 17 ) -> Result(mist.Next(state.State, _msg), String) { 18 18 { ··· 28 28 } 29 29 } 30 30 31 + let message = 32 + account.Account(id: account_id) 33 + |> response.Authenticated() 34 + |> response.message(message_id, 0) 35 + |> response.to_string() 31 36 use _ <- result.map( 32 - output_action.Account(account.Account(id: account_id)) 33 - |> output_message.OutputMessage(id: message_id) 34 - |> output_message.send(conn) 37 + mist.send_text_frame(conn, message) 35 38 |> result.map_error(rows.HandlerError), 36 39 ) 37 40 state.authenticate(st, account_id)
-40
server/src/handlers/get_field_handler.gleam
··· 1 - import db/rows 2 - import db/sql 3 - import dto/output_action 4 - import dto/output_message 5 - import gleam/dynamic/decode 6 - import gleam/json 7 - import gleam/result 8 - import gleam/string 9 - import mist 10 - import models/field 11 - import models/field_tile 12 - import websocket/state 13 - 14 - pub fn handle( 15 - st: state.State, 16 - conn: mist.WebsocketConnection, 17 - message_id: String, 18 - field_id: Int, 19 - ) { 20 - { 21 - let assert Ok(result) = 22 - sql.get_field_and_tiles_by_id(state.db_connection(st), field_id) 23 - use sql.GetFieldAndTilesByIdRow(field_json, field_tiles_json) <- rows.one( 24 - result, 25 - ) 26 - let assert Ok(field_data) = json.parse(field_json, field.from_json()) 27 - let assert Ok(field_tiles) = 28 - json.parse(field_tiles_json, decode.list(field_tile.from_json())) 29 - use _ <- result.map( 30 - output_action.FieldWithTiles(field: field_data, field_tiles:) 31 - |> output_message.OutputMessage(id: message_id) 32 - |> output_message.send(conn) 33 - |> result.map_error(rows.HandlerError), 34 - ) 35 - 36 - Ok(mist.continue(st)) 37 - } 38 - |> result.map_error(string.inspect) 39 - |> result.flatten() 40 - }
-25
server/src/handlers/get_fields_handler.gleam
··· 1 - import db/sql 2 - import dto/output_action 3 - import dto/output_message 4 - import gleam/list 5 - import mist 6 - import models/field 7 - import websocket/state 8 - 9 - pub fn handle( 10 - st: state.State, 11 - conn: mist.WebsocketConnection, 12 - message_id: String, 13 - ) -> Result(mist.Next(state.State, _msg), String) { 14 - use account_id <- state.account_id(st) 15 - let assert Ok(result) = 16 - sql.list_fields_for_account(state.db_connection(st), account_id) 17 - let assert Ok(_) = 18 - result.rows 19 - |> list.map(field.from_list_fields_for_account) 20 - |> output_action.Fields() 21 - |> output_message.OutputMessage(id: message_id) 22 - |> output_message.send(conn) 23 - 24 - Ok(mist.continue(st)) 25 - }
+33
server/src/handlers/list_fields_handler.gleam
··· 1 + import cartography_api/field 2 + import cartography_api/game_state 3 + import cartography_api/response 4 + import db/sql 5 + import gleam/list 6 + import gleam/result 7 + import gleam/string 8 + import mist 9 + import websocket/state 10 + import youid/uuid 11 + 12 + pub fn handle( 13 + st: state.State, 14 + conn: mist.WebsocketConnection, 15 + message_id: uuid.Uuid, 16 + ) -> Result(mist.Next(state.State, _msg), String) { 17 + use account_id <- state.account_id(st) 18 + { 19 + let assert Ok(result) = 20 + sql.list_fields_for_account(state.db_connection(st), account_id) 21 + let message = 22 + result.rows 23 + |> list.map(fn(row) { 24 + field.Field(id: game_state.FieldId(row.id), name: row.name) 25 + }) 26 + |> response.Fields() 27 + |> response.message(message_id, 0) 28 + |> response.to_string() 29 + use _ <- result.try(mist.send_text_frame(conn, message)) 30 + Ok(mist.continue(st)) 31 + } 32 + |> result.map_error(string.inspect) 33 + }
-22
server/src/handlers/subscribe_handler.gleam
··· 1 - import dto/channel 2 - import gleam/result 3 - import mist 4 - import websocket/state 5 - 6 - pub fn handle( 7 - st: state.State, 8 - _conn: mist.WebsocketConnection, 9 - message_id: String, 10 - channel: channel.Channel, 11 - ) -> Result(mist.Next(state.State, _msg), String) { 12 - use _account_id <- state.account_id(st) 13 - 14 - use unsubscribe <- result.try(case channel { 15 - channel.Deck -> todo 16 - }) 17 - 18 - st 19 - |> state.add_listener(message_id, unsubscribe) 20 - |> mist.continue() 21 - |> Ok() 22 - }
-13
server/src/handlers/unsubscribe_handler.gleam
··· 1 - import mist 2 - import websocket/state 3 - 4 - pub fn handle( 5 - st: state.State, 6 - _conn: mist.WebsocketConnection, 7 - message_id: String, 8 - ) -> Result(mist.Next(state.State, _msg), String) { 9 - st 10 - |> state.remove_listener(message_id) 11 - |> mist.continue() 12 - |> Ok() 13 - }
+20 -21
server/src/websocket/handler.gleam
··· 1 - import dto/input_action 2 - import dto/input_message 3 - import gleam/http/request 4 - import handlers/auth_handler 5 - import handlers/get_field_handler 6 - import handlers/get_fields_handler 7 - import handlers/subscribe_handler 8 - import handlers/unsubscribe_handler 1 + import cartography_api/request 2 + import gleam/http/request as http 3 + import handlers/authenticate_handler 4 + import handlers/list_fields_handler 9 5 import json_websocket 10 6 import mist.{type WebsocketConnection} 11 7 import palabres ··· 14 10 15 11 fn handle_message( 16 12 state: state.State, 17 - message: input_message.InputMessage, 13 + message: request.Message, 18 14 conn: WebsocketConnection, 19 15 ) -> mist.Next(state.State, _msg) { 20 16 let response = { 21 - case message.data { 22 - input_action.Auth(id) -> auth_handler.handle(state, conn, message.id, id) 23 - input_action.GetFields -> 24 - get_fields_handler.handle(state, conn, message.id) 25 - input_action.GetField(field_id) -> 26 - get_field_handler.handle(state, conn, message.id, field_id) 27 - input_action.Subscribe(channel) -> 28 - subscribe_handler.handle(state, conn, message.id, channel) 29 - input_action.Unsubscribe -> 30 - unsubscribe_handler.handle(state, conn, message.id) 17 + case message.request { 18 + request.Authenticate(id) -> 19 + authenticate_handler.handle(state, conn, message.id, id) 20 + request.ListFields -> list_fields_handler.handle(state, conn, message.id) 21 + request.WatchField(_) -> { 22 + todo 23 + } 24 + request.DebugAddCard(_) -> { 25 + todo 26 + } 27 + request.Unsubscribe -> { 28 + todo 29 + } 31 30 } 32 31 } 33 32 case response { ··· 41 40 } 42 41 } 43 42 44 - pub fn start(request: request.Request(mist.Connection), context: Context) { 43 + pub fn start(request: http.Request(mist.Connection), context: Context) { 45 44 state.new(context) 46 45 |> json_websocket.new() 47 - |> json_websocket.message(input_message.decoder(), handle_message) 46 + |> json_websocket.message(request.decoder(), handle_message) 48 47 |> json_websocket.start(request) 49 48 }
+8 -15
src/lib/appserver/provideSocket.svelte.ts
··· 1 - import { getContext, setContext } from "svelte"; 1 + import { createContext } from "svelte"; 2 2 import { PUBLIC_SERVER_WS_URL } from "$env/static/public"; 3 - import { SocketV1 } from "./socket/SocketV1"; 4 - 5 - const SOCKET = Symbol("Socket"); 3 + import { SocketV1 } from "./socket/SocketV1.svelte"; 6 4 7 5 interface SocketContext { 8 6 get socket(): SocketV1; 9 7 } 10 8 11 - export function getSocket(): SocketV1 { 12 - const context = getContext(SOCKET) as SocketContext; 13 - return context.socket; 14 - } 9 + const [getSocket, setSocket] = createContext<SocketContext>(); 10 + 11 + export { getSocket }; 15 12 16 13 export function provideSocket() { 17 - let socket: SocketV1; 14 + const context = $state<{ socket: SocketV1 }>({ socket: undefined! }); 18 15 19 16 $effect.pre(() => { 20 - socket = new SocketV1(`${PUBLIC_SERVER_WS_URL}/websocket`); 17 + const socket = (context.socket = new SocketV1(`${PUBLIC_SERVER_WS_URL}/websocket`)); 21 18 22 19 socket.addEventListener("open", () => { 23 20 socket.auth({ id: "foxfriends" }); ··· 26 23 return () => socket.close(); 27 24 }); 28 25 29 - setContext(SOCKET, { 30 - get socket() { 31 - return socket; 32 - }, 33 - } satisfies SocketContext); 26 + setSocket(context); 34 27 }
+8 -7
src/lib/appserver/socket/MessageStream.svelte.ts
··· 1 1 import type { MessageEvent } from "./MessageEvent"; 2 - import { type SocketV1 } from "./SocketV1"; 2 + import { type SocketV1 } from "./SocketV1.svelte"; 3 + import type { OnceType, StreamType } from "./SocketV1Protocol"; 3 4 4 5 export class MessageStream<T> extends EventTarget { 5 6 #socket: SocketV1; ··· 12 13 } 13 14 14 15 async reply(abort?: AbortSignal) { 15 - return new Promise<T>((resolve, reject) => { 16 + return new Promise<OnceType<T>>((resolve, reject) => { 16 17 abort?.throwIfAborted(); 17 18 18 19 const handler = (event: MessageEvent) => { 19 20 if (event.message.id === this.id) { 20 21 // NOTE: would be nice to do a runtime assertion here, but the mapping is currently 21 22 // only defined as a type. Not hard to shift to a value, just lazy. 22 - resolve(event.message.response as T); 23 + resolve(event.message.response as OnceType<T>); 23 24 this.#socket.removeEventListener("message", handler); 24 25 abort?.removeEventListener("abort", onabort); 25 26 } ··· 35 36 }); 36 37 } 37 38 38 - replies(callback: (message: T) => void) { 39 + replies(callback: (message: StreamType<T>) => void) { 39 40 const handler = (event: MessageEvent) => { 40 41 if (event.message.id === this.id) { 41 42 // NOTE: would be nice to do a runtime assertion here, but the mapping is currently 42 43 // only defined as a type. Not hard to shift to a value, just lazy. 43 - callback(event.message.response as T); 44 + callback(event.message.response as StreamType<T>); 44 45 } 45 46 }; 46 47 ··· 52 53 }; 53 54 } 54 55 55 - $then(callback: (event: T) => void): void { 56 + $then(callback: (event: OnceType<T>) => void): void { 56 57 $effect(() => { 57 58 const abort = new AbortController(); 58 59 ··· 70 71 }); 71 72 } 72 73 73 - $subscribe(callback: (event: T) => void): void { 74 + $subscribe(callback: (event: StreamType<T>) => void): void { 74 75 $effect(() => { 75 76 const subscription = this.replies(callback); 76 77 return () => subscription.unsubscribe();
+25 -4
src/lib/appserver/socket/SocketV1.ts src/lib/appserver/socket/SocketV1.svelte.ts
··· 4 4 import { ReactiveEventTarget } from "$lib/ReactiveEventTarget.svelte"; 5 5 import Value from "typebox/value"; 6 6 import { 7 + Account, 7 8 construct, 9 + Field, 8 10 FieldId, 11 + GameState, 9 12 Request, 10 13 RequestMessage, 11 14 ResponseMessage, 12 15 type SocketV1Protocol, 13 16 } from "./SocketV1Protocol"; 17 + import jsonpatch from "json-patch"; 14 18 15 19 interface SocketV1EventMap { 16 20 message: MessageEvent; ··· 25 29 #socket: WebSocket; 26 30 #url: string; 27 31 32 + account: Account | undefined = $state(); 33 + 28 34 constructor(url: string) { 29 35 super(); 30 36 this.#url = url; ··· 47 53 this.dispatchEvent(new Event("error")); 48 54 } 49 55 try { 50 - const message = Value.Decode(ResponseMessage, data); 56 + const message = Value.Decode(ResponseMessage, JSON.parse(data)); 51 57 this.dispatchEvent(new MessageEvent(message)); 52 - } catch { 58 + } catch (error) { 53 59 this.#socket.close(4000, "Invalid JSON received"); 54 60 this.dispatchEvent(new Event("error")); 55 61 } ··· 92 98 this.#sendMessage(construct("Authenticate", data.id)) 93 99 .reply() 94 100 .then((event) => { 101 + this.account = event["#payload"]; 95 102 this.dispatchEvent(new AuthEvent(event["#payload"])); 96 103 }); 97 104 } 98 105 99 - $watchField(data: { id: FieldId }) { 100 - this.#sendMessage(construct("WatchField", data.id)).$subscribe(() => {}); 106 + async listFields(): Promise<Field[]> { 107 + const event = await this.#sendMessage(construct("ListFields", null)).reply(); 108 + return event["#payload"]; 109 + } 110 + 111 + $watchField(data: { id: FieldId }, subscriber: (gameState: GameState | undefined) => void) { 112 + 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"]; 118 + gameState = jsonpatch.apply(gameState, patches); 119 + } 120 + subscriber(gameState); 121 + }); 101 122 } 102 123 103 124 unsubscribe(id: string) {
+33 -6
src/lib/appserver/socket/SocketV1Protocol.ts
··· 11 11 declare const __BRAND: unique symbol; 12 12 type Branded<Brand, T> = T & { [__BRAND]: Brand }; 13 13 14 - function Branded<Brand, T extends TSchema>(brand: Brand, value: T) { 14 + function Branded<Brand extends string, T extends TSchema>(brand: Brand, value: T) { 15 15 return Type.Codec(value) 16 16 .Decode((val) => val as Branded<Brand, StaticDecode<T>>) 17 17 .Encode((val) => val as StaticDecode<T>); ··· 125 125 export type FieldCitizen = StaticDecode<typeof FieldCitizen>; 126 126 127 127 export const Field = Type.Object({ 128 + id: FieldId, 129 + name: Type.String(), 130 + }); 131 + export type Field = StaticDecode<typeof Field>; 132 + 133 + export const GameStateField = Type.Object({ 128 134 tiles: Type.Array(FieldTile), 129 135 citizens: Type.Array(FieldCitizen), 130 136 }); 131 - export type Field = StaticDecode<typeof Field>; 137 + export type GameStateField = StaticDecode<typeof GameStateField>; 132 138 133 139 export const GameState = Type.Object({ 134 140 deck: Type.Array(Card), 135 - field: Field, 141 + field: GameStateField, 136 142 }); 137 143 export type GameState = StaticDecode<typeof GameState>; 138 144 139 145 export const Authenticate = Struct("Authenticate", Type.String()); 140 146 export type Authenticate = StaticDecode<typeof Authenticate>; 141 147 148 + export const ListFields = Struct("ListFields", Type.Null()); 149 + export type ListFields = StaticDecode<typeof ListFields>; 150 + 142 151 export const WatchField = Struct("WatchField", FieldId); 143 152 export type WatchField = StaticDecode<typeof WatchField>; 144 153 ··· 148 157 export const DebugAddCard = Struct("DebugAddCard", Type.String()); 149 158 export type DebugAddCard = StaticDecode<typeof DebugAddCard>; 150 159 151 - export const Request = Type.Union([Authenticate, WatchField, Unsubscribe, DebugAddCard]); 160 + export const Request = Type.Union([ 161 + Authenticate, 162 + ListFields, 163 + WatchField, 164 + Unsubscribe, 165 + DebugAddCard, 166 + ]); 152 167 export type Request = StaticDecode<typeof Request>; 153 168 154 169 export const Authenticated = Struct("Authenticated", Account); 155 170 export type Authenticated = StaticDecode<typeof Authenticated>; 171 + 172 + export const Fields = Struct("Fields", Type.Array(Field)); 173 + export type Fields = StaticDecode<typeof Fields>; 156 174 157 175 export const PutState = Struct("PutState", GameState); 158 176 export type PutState = StaticDecode<typeof PutState>; ··· 160 178 export const PatchState = Struct("PatchState", Type.Array(JsonPatch)); 161 179 export type PatchState = StaticDecode<typeof PatchState>; 162 180 163 - export const Response = Type.Union([Authenticated, PutState, PatchState]); 181 + export const Response = Type.Union([Authenticated, Fields, PutState, PatchState]); 164 182 export type Response = StaticDecode<typeof Response>; 165 183 184 + export type Once<T> = Branded<"Once", T>; 185 + export type Stream<T> = Branded<"Stream", T>; 186 + 187 + export type OnceType<T> = T extends Once<infer R> ? R : never; 188 + export type StreamType<T> = T extends Stream<infer R> ? R : never; 189 + 166 190 export interface SocketV1Protocol { 167 - Authenticate: Authenticated; 191 + Authenticate: Once<Authenticated>; 192 + ListFields: Once<Fields>; 193 + WatchField: Stream<PutState | PatchState>; 168 194 } 169 195 170 196 export const RequestMessage = Type.Object({ ··· 175 201 176 202 export const ResponseMessage = Type.Object({ 177 203 id: Type.String({ format: "uuid" }), 204 + nonce: Type.Integer(), 178 205 response: Response, 179 206 }); 180 207 export type ResponseMessage = StaticDecode<typeof ResponseMessage>;
+10 -74
src/routes/(socket)/play/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { type Field } from "$lib/appserver/Field"; 3 - import DragTile from "$lib/components/DragTile.svelte"; 4 - import DragWindow from "$lib/components/DragWindow.svelte"; 5 - import GridLines from "$lib/components/GridLines.svelte"; 6 - import { getOverworldState, provideOverworldState } from "./components/provideOverworld.svelte"; 7 - import { getFieldState, provideFieldState } from "./components/provideFieldState.svelte"; 2 + import { getSocket } from "$lib/appserver/provideSocket.svelte"; 3 + import PlayPage from "./components/PlayPage.svelte"; 8 4 9 - provideOverworldState(); 10 - const setFieldState = provideFieldState(); 11 - 12 - const { fields } = $derived.by(getOverworldState); 13 - const { field, fieldTiles } = $derived.by(getFieldState); 14 - 15 - async function viewField(field: Field) { 16 - setFieldState.fieldId = field.id; 17 - } 5 + const { socket } = $derived.by(getSocket); 18 6 </script> 19 7 20 - <main class="void" role="application"> 21 - <DragWindow> 22 - <GridLines /> 23 - 24 - {#if field} 25 - {#each fieldTiles.values() as fieldTile (fieldTile.tile_id)} 26 - <DragTile 27 - x={fieldTile.grid_x ?? 0} 28 - y={fieldTile.grid_y ?? 0} 29 - loose={fieldTile.grid_x === undefined || fieldTile.grid_y === undefined} 30 - > 31 - <div class="field-label"> 32 - {#if field.name} 33 - {field.name} 34 - {:else} 35 - Field {field.id} 36 - {/if} 37 - </div> 38 - </DragTile> 39 - {/each} 40 - {:else} 41 - {#each fields.values() as field, i (field.id)} 42 - <DragTile x={i} y={0} onClick={() => viewField(field)}> 43 - <div class="field-label"> 44 - {#if field.name} 45 - {field.name} 46 - {:else} 47 - Field {field.id} 48 - {/if} 49 - </div> 50 - </DragTile> 51 - {/each} 52 - {/if} 53 - </DragWindow> 54 - </main> 55 - 56 - <style> 57 - main { 58 - position: absolute; 59 - inset: 0; 60 - width: 100vw; 61 - height: 100vh; 62 - } 63 - 64 - .void { 65 - background: black; 66 - --grid-lines-color: white; 67 - } 68 - 69 - .field-label { 70 - display: flex; 71 - align-items: center; 72 - justify-content: center; 73 - width: 100%; 74 - height: 100%; 75 - font-weight: 600; 76 - border: 1px solid rgb(0 0 0 / 0.12); 77 - } 78 - </style> 8 + {#if socket.account} 9 + {#key socket.account.id} 10 + <PlayPage /> 11 + {/key} 12 + {:else} 13 + <div>Logging in</div> 14 + {/if}
+23
src/routes/(socket)/play/components/FieldView.svelte
··· 1 + <script lang="ts"> 2 + import { FieldTile, GameState, TileId } from "$lib/appserver/socket/SocketV1Protocol"; 3 + import DragTile from "$lib/components/DragTile.svelte"; 4 + 5 + type RuntimeTile = FieldTile | { id: TileId; x: number | undefined; y: number | undefined }; 6 + 7 + const { gameState }: { gameState: GameState } = $props(); 8 + 9 + const uncommittedTiles: { id: TileId; x: number | undefined; y: number | undefined }[] = $state( 10 + [], 11 + ); 12 + const fieldTiles = $derived<RuntimeTile[]>([...gameState.field.tiles, ...uncommittedTiles]); 13 + </script> 14 + 15 + {#each fieldTiles as fieldTile (fieldTile.id)} 16 + <DragTile 17 + x={fieldTile.x ?? 0} 18 + y={fieldTile.y ?? 0} 19 + loose={fieldTile.x === undefined || fieldTile.y === undefined} 20 + > 21 + {fieldTile.id} 22 + </DragTile> 23 + {/each}
+78
src/routes/(socket)/play/components/PlayPage.svelte
··· 1 + <script lang="ts"> 2 + import { getSocket } from "$lib/appserver/provideSocket.svelte"; 3 + import type { FieldId, GameState } from "$lib/appserver/socket/SocketV1Protocol"; 4 + import DragTile from "$lib/components/DragTile.svelte"; 5 + import DragWindow from "$lib/components/DragWindow.svelte"; 6 + import GridLines from "$lib/components/GridLines.svelte"; 7 + import FieldView from "./FieldView.svelte"; 8 + 9 + const { socket } = $derived.by(getSocket); 10 + 11 + const fields = $derived(socket.listFields()); 12 + 13 + let fieldId: FieldId | undefined = $state(); 14 + let gameState: GameState | undefined = $state(); 15 + 16 + $effect(() => { 17 + if (fieldId) { 18 + socket.$watchField({ id: fieldId }, (state) => (gameState = state)); 19 + } 20 + }); 21 + </script> 22 + 23 + <main class="void" role="application"> 24 + <DragWindow> 25 + <GridLines /> 26 + 27 + {#if fieldId === undefined} 28 + {#await fields} 29 + <div>Loading</div> 30 + {:then fields} 31 + {#each fields as field, i (field.id)} 32 + <DragTile x={i} y={0} onClick={() => (fieldId = field.id)}> 33 + <div class="field-label"> 34 + {#if field.name} 35 + {field.name} 36 + {:else} 37 + Field {field.id} 38 + {/if} 39 + </div> 40 + </DragTile> 41 + {:else} 42 + <div>No fields</div> 43 + {/each} 44 + {:catch error} 45 + <div>{error}</div> 46 + {/await} 47 + {:else if gameState} 48 + <FieldView {gameState} /> 49 + {:else} 50 + <div>Loading</div> 51 + {/if} 52 + </DragWindow> 53 + </main> 54 + 55 + <style> 56 + main { 57 + position: absolute; 58 + inset: 0; 59 + width: 100vw; 60 + height: 100vh; 61 + } 62 + 63 + .void { 64 + background: black; 65 + --grid-lines-color: white; 66 + color: white; 67 + } 68 + 69 + .field-label { 70 + display: flex; 71 + align-items: center; 72 + justify-content: center; 73 + width: 100%; 74 + height: 100%; 75 + font-weight: 600; 76 + border: 1px solid rgb(0 0 0 / 0.12); 77 + } 78 + </style>
-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 - }
-43
src/routes/(socket)/play/components/provideOverworld.svelte.ts
··· 1 - import type { Field, FieldId } from "$lib/appserver/Field"; 2 - import { getSocket } from "$lib/appserver/provideSocket.svelte"; 3 - import { getContext, setContext } from "svelte"; 4 - import { SvelteMap } from "svelte/reactivity"; 5 - 6 - const OVERWORLD = Symbol("Overworld"); 7 - 8 - interface Overworld { 9 - get fields(): SvelteMap<FieldId, Field>; 10 - } 11 - 12 - export function getOverworldState() { 13 - const overworld = getContext(OVERWORLD) as Overworld; 14 - return { ...overworld }; 15 - } 16 - 17 - export function provideOverworldState() { 18 - const socket = getSocket(); 19 - 20 - let fields = $state(new SvelteMap<FieldId, Field>()); 21 - 22 - socket.$on("auth", () => { 23 - // const subscription = socket.subscribe("fields"); 24 - 25 - socket.getFields().$then(({ data }) => { 26 - fields = new SvelteMap(data.fields.map((field) => [field.id, field])); 27 - }); 28 - 29 - // subscription.$on("next", ({ message }) => { 30 - // fields.set(message.data.field.id, message.data.field); 31 - // }); 32 - 33 - return () => { 34 - // subscription.unsubscribe(); 35 - }; 36 - }); 37 - 38 - setContext(OVERWORLD, { 39 - get fields() { 40 - return fields; 41 - }, 42 - }); 43 - }