Trading card city builder game?

build out more fully some api interface

+341 -35
+2
Justfile
··· 48 48 [group: "dev"] 49 49 get: 50 50 npm install 51 + cd server && gleam deps download 52 + cd api && gleam deps download 51 53 52 54 [group: "docker"] 53 55 up: && migrate
+1 -1
api/gleam.toml
··· 20 20 [dependencies] 21 21 gleam_stdlib = ">= 0.44.0 and < 2.0.0" 22 22 gleam_json = ">= 3.1.0 and < 4.0.0" 23 - glepack = { git = "https://github.com/Lemorz56/glepack", ref = "main" } 23 + squirtle = ">= 1.4.0 and < 2.0.0" 24 24 25 25 [dev-dependencies] 26 26 gleeunit = ">= 1.0.0 and < 2.0.0"
+2 -2
api/manifest.toml
··· 5 5 { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 6 6 { name = "gleam_stdlib", version = "0.68.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F7FAEBD8EF260664E86A46C8DBA23508D1D11BB3BCC6EE1B89B3BC3E5C83FF1E" }, 7 7 { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 8 - { name = "glepack", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "git", repo = "https://github.com/Lemorz56/glepack", commit = "7a7df549845ccec8f92c757adbfb9c76e6c70692" }, 8 + { name = "squirtle", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "squirtle", source = "hex", outer_checksum = "B4DCF7ED3E829E1DB6163F3E18677EF0862DF3F4CE076D98628EBB8D9BC974BC" }, 9 9 ] 10 10 11 11 [requirements] 12 12 gleam_json = { version = ">= 3.1.0 and < 4.0.0" } 13 13 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 14 14 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 15 - glepack = { git = "https://github.com/Lemorz56/glepack", ref = "main" } 15 + squirtle = { version = ">= 1.4.0 and < 2.0.0" }
+227
api/src/game_state.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/json.{type Json} 3 + import gleam/list 4 + import gleam/option.{type Option} 5 + import repr 6 + import squirtle 7 + 8 + /// Defines a shared game state data model, which the server manages on behalf of a client 9 + /// as a definitive source of truth. 10 + pub type GameState { 11 + GameState(deck: Deck, field: Field) 12 + } 13 + 14 + pub type CardId { 15 + TileId(id: Int) 16 + CitizenId(id: Int) 17 + } 18 + 19 + pub type CardTypeId { 20 + TileTypeId(id: String) 21 + SpeciesId(id: String) 22 + } 23 + 24 + pub type FieldId { 25 + FieldId(id: Int) 26 + } 27 + 28 + pub type Deck { 29 + Deck(cards: List(Card)) 30 + } 31 + 32 + pub type Card { 33 + Tile(id: CardId, tile_type_id: CardTypeId, name: String) 34 + Citizen( 35 + id: CardId, 36 + species_id: CardTypeId, 37 + name: String, 38 + home_tile_id: Option(CardId), 39 + ) 40 + } 41 + 42 + pub type Field { 43 + Field(id: FieldId, tiles: List(FieldTile), citizens: List(FieldCitizen)) 44 + } 45 + 46 + pub type FieldTile { 47 + FieldTile(id: CardId, x: Int, y: Int) 48 + } 49 + 50 + pub type FieldCitizen { 51 + FieldCitizen(id: CardId, x: Int, y: Int) 52 + } 53 + 54 + pub fn to_json(game_state: GameState) -> Json { 55 + json.object([ 56 + #( 57 + "deck", 58 + json.object([ 59 + #( 60 + "cards", 61 + game_state.deck.cards 62 + |> list.map(fn(card) { 63 + case card { 64 + Tile(TileId(id), TileTypeId(tile_type_id), name) -> 65 + json.object([ 66 + #("id", json.int(id)), 67 + #("tile_type_id", json.string(tile_type_id)), 68 + #("name", json.string(name)), 69 + ]) 70 + |> repr.struct("Tile") 71 + Citizen( 72 + CitizenId(id), 73 + SpeciesId(species_id), 74 + name, 75 + home_tile_id, 76 + ) -> 77 + json.object([ 78 + #("id", json.int(id)), 79 + #("species_id", json.string(species_id)), 80 + #("name", json.string(name)), 81 + #( 82 + "home_tile_id", 83 + json.nullable(home_tile_id, fn(id) { json.int(id.id) }), 84 + ), 85 + ]) 86 + _ -> panic as "unreachable" 87 + } 88 + }) 89 + |> json.preprocessed_array(), 90 + ), 91 + ]), 92 + ), 93 + #( 94 + "field", 95 + json.object([ 96 + #("id", json.int(game_state.field.id.id)), 97 + #( 98 + "tiles", 99 + game_state.field.tiles 100 + |> list.map(fn(tile) { 101 + json.object([ 102 + #("id", json.int(tile.id.id)), 103 + #("x", json.int(tile.x)), 104 + #("y", json.int(tile.y)), 105 + ]) 106 + }) 107 + |> json.preprocessed_array(), 108 + ), 109 + #( 110 + "citizens", 111 + game_state.field.citizens 112 + |> list.map(fn(citizen) { 113 + json.object([ 114 + #("id", json.int(citizen.id.id)), 115 + #("x", json.int(citizen.x)), 116 + #("y", json.int(citizen.y)), 117 + ]) 118 + }) 119 + |> json.preprocessed_array(), 120 + ), 121 + ]), 122 + ), 123 + ]) 124 + } 125 + 126 + pub fn to_string(game_state: GameState) -> String { 127 + game_state 128 + |> to_json() 129 + |> json.to_string() 130 + } 131 + 132 + pub fn decoder() -> decode.Decoder(GameState) { 133 + use deck <- decode.field("deck", { 134 + use cards <- decode.field( 135 + "cards", 136 + decode.list({ 137 + use tag <- repr.struct_tag(Tile( 138 + id: TileId(0), 139 + tile_type_id: TileTypeId(""), 140 + name: "", 141 + )) 142 + case tag { 143 + "Tile" -> { 144 + use id <- decode.field("id", decode.map(decode.int, TileId)) 145 + use tile_type_id <- decode.field( 146 + "tile_type_id", 147 + decode.map(decode.string, TileTypeId), 148 + ) 149 + use name <- decode.field("name", decode.string) 150 + decode.success(Tile(id:, tile_type_id:, name:)) 151 + } 152 + "Citizen" -> { 153 + use id <- decode.field("id", decode.map(decode.int, CitizenId)) 154 + use species_id <- decode.field( 155 + "species_id", 156 + decode.map(decode.string, SpeciesId), 157 + ) 158 + use name <- decode.field("name", decode.string) 159 + use home_tile_id <- decode.field( 160 + "home_tile_id", 161 + decode.optional(decode.map(decode.int, TileId)), 162 + ) 163 + decode.success(Citizen(id:, species_id:, name:, home_tile_id:)) 164 + } 165 + _ -> { 166 + decode.failure( 167 + Tile(id: TileId(0), tile_type_id: TileTypeId(""), name: ""), 168 + "card", 169 + ) 170 + } 171 + } 172 + }), 173 + ) 174 + decode.success(Deck(cards:)) 175 + }) 176 + use field <- decode.field("field", { 177 + use id <- decode.field("id", decode.map(decode.int, FieldId)) 178 + use tiles <- decode.field( 179 + "tiles", 180 + decode.list({ 181 + use id <- decode.field("id", decode.map(decode.int, TileId)) 182 + use x <- decode.field("x", decode.int) 183 + use y <- decode.field("y", decode.int) 184 + decode.success(FieldTile(id:, x:, y:)) 185 + }), 186 + ) 187 + use citizens <- decode.field( 188 + "citizens", 189 + decode.list({ 190 + use id <- decode.field("id", decode.map(decode.int, CitizenId)) 191 + use x <- decode.field("x", decode.int) 192 + use y <- decode.field("y", decode.int) 193 + decode.success(FieldCitizen(id:, x:, y:)) 194 + }), 195 + ) 196 + decode.success(Field(id:, tiles:, citizens:)) 197 + }) 198 + decode.success(GameState(deck:, field:)) 199 + } 200 + 201 + pub fn diff(previous: GameState, next: GameState) -> List(squirtle.Patch) { 202 + let assert Ok(previous) = 203 + previous 204 + |> to_string() 205 + |> squirtle.json_value_parse() 206 + let assert Ok(next) = 207 + next 208 + |> to_string() 209 + |> squirtle.json_value_parse() 210 + squirtle.diff(previous, next) 211 + } 212 + 213 + pub fn patch( 214 + previous: GameState, 215 + patches: List(squirtle.Patch), 216 + ) -> Result(GameState, List(decode.DecodeError)) { 217 + let assert Ok(previous) = 218 + previous 219 + |> to_string() 220 + |> squirtle.json_value_parse() 221 + let assert Ok(next) = 222 + // NOTE: this one might fail if the patches are bad, should handle that better, 223 + // but realistically if there are bad patches, it means we have lost the server. 224 + previous 225 + |> squirtle.patch(patches) 226 + squirtle.json_value_decode(next, decoder()) 227 + }
+30
api/src/repr.gleam
··· 1 + import gleam/dynamic/decode.{type Decoder} 2 + import gleam/json.{type Json} 3 + 4 + pub fn struct(payload: Json, tag: String) -> Json { 5 + json.object([ 6 + #("#type", json.string("struct")), 7 + #("#tag", json.string(tag)), 8 + #("#payload", payload), 9 + ]) 10 + } 11 + 12 + pub fn struct_tag(fallback: t, cb: fn(String) -> Decoder(t)) -> Decoder(t) { 13 + use ty <- decode.field("#type", decode.string) 14 + case ty { 15 + "struct" -> { 16 + use tag <- decode.field("#tag", decode.string) 17 + cb(tag) 18 + } 19 + _ -> { 20 + decode.failure(fallback, "struct") 21 + } 22 + } 23 + } 24 + 25 + pub fn struct_payload( 26 + decoder: Decoder(f), 27 + cb: fn(f) -> Decoder(t), 28 + ) -> Decoder(t) { 29 + decode.field("#payload", decoder, cb) 30 + }
+21 -31
api/src/request.gleam
··· 1 1 import gleam/dynamic/decode 2 2 import gleam/json 3 + import repr 3 4 5 + /// A request is sent from the client to the server. 6 + /// 7 + /// A response does not necessarily respond to something, it might just be a pushed notification. 4 8 pub opaque type Request { 5 9 Authenticate(auth_token: String) 6 10 DebugAddCard(card_id: String) 7 11 } 8 12 9 - pub fn to_text(request: Request) -> String { 13 + pub fn to_string(request: Request) -> String { 10 14 json.to_string(case request { 11 15 Authenticate(auth_token) -> 12 - json.object([ 13 - #("#type", json.string("struct")), 14 - #("#tag", json.string("Authenticate")), 15 - #("#payload", json.string(auth_token)), 16 - ]) 16 + json.string(auth_token) 17 + |> repr.struct("Authenticate") 17 18 DebugAddCard(card_id) -> 18 - json.object([ 19 - #("#type", json.string("struct")), 20 - #("#tag", json.string("DebugAddCard")), 21 - #("#payload", json.string(card_id)), 22 - ]) 19 + json.string(card_id) 20 + |> repr.struct("DebugAddCard") 23 21 }) 24 22 } 25 23 26 - pub fn from_text(text: String) -> Result(Request, json.DecodeError) { 27 - json.parse(text, { 28 - use ty <- decode.field("#type", decode.string) 29 - case ty { 30 - "struct" -> { 31 - use tag <- decode.field("#tag", decode.string) 32 - case tag { 33 - "Authenticate" -> { 34 - use payload <- decode.field("#payload", decode.string) 35 - decode.success(Authenticate(payload)) 36 - } 37 - "DebugAddCard" -> { 38 - use payload <- decode.field("#payload", decode.string) 39 - decode.success(DebugAddCard(payload)) 40 - } 41 - _ -> { 42 - decode.failure(Authenticate(""), "valid #tag") 43 - } 44 - } 24 + pub fn from_string(string: String) -> Result(Request, json.DecodeError) { 25 + json.parse(string, { 26 + use tag <- repr.struct_tag(Authenticate("")) 27 + case tag { 28 + "Authenticate" -> { 29 + use payload <- repr.struct_payload(decode.string) 30 + decode.success(Authenticate(payload)) 31 + } 32 + "DebugAddCard" -> { 33 + use payload <- repr.struct_payload(decode.string) 34 + decode.success(DebugAddCard(payload)) 45 35 } 46 36 _ -> { 47 - decode.failure(Authenticate(""), "#type == 'struct'") 37 + decode.failure(Authenticate(""), "valid #tag") 48 38 } 49 39 } 50 40 })
+54
api/src/response.gleam
··· 1 + import game_state.{type GameState} 2 + import gleam/dynamic/decode 3 + import gleam/json 4 + import gleam/list 5 + import repr 6 + import squirtle.{type Patch} 1 7 8 + /// A response is sent from the server to the client. 9 + pub opaque type Response { 10 + PutData(GameState) 11 + PatchData(List(Patch)) 12 + } 13 + 14 + pub fn to_string(response: Response) -> String { 15 + json.to_string(case response { 16 + PutData(game_state) -> 17 + game_state.to_json(game_state) 18 + |> repr.struct("PutData") 19 + PatchData(patches) -> 20 + patches 21 + |> list.map(squirtle.patch_to_json_value) 22 + |> list.map(squirtle.json_value_to_gleam_json) 23 + |> json.preprocessed_array() 24 + |> repr.struct("PatchData") 25 + }) 26 + } 27 + 28 + pub fn from_string(string: String) -> Result(Response, json.DecodeError) { 29 + json.parse(string, { 30 + use tag <- repr.struct_tag(PatchData([])) 31 + case tag { 32 + "PutData" -> { 33 + use payload <- repr.struct_payload(game_state.decoder()) 34 + decode.success(PutData(payload)) 35 + } 36 + "PatchData" -> { 37 + use payload <- repr.struct_payload( 38 + decode.list(squirtle.patch_decoder()), 39 + ) 40 + decode.success(PatchData(payload)) 41 + } 42 + _ -> { 43 + decode.failure(PatchData([]), "valid #tag") 44 + } 45 + } 46 + }) 47 + } 48 + 49 + pub fn put_data(game_state: GameState) -> Response { 50 + PutData(game_state) 51 + } 52 + 53 + pub fn patch_data(patches: List(Patch)) -> Response { 54 + PatchData(patches) 55 + }
+1
server/gleam.toml
··· 25 25 palabres = ">= 1.0.3 and < 2.0.0" 26 26 squirrel = ">= 4.6.0 and < 5.0.0" 27 27 cartography_api = { path = "../api" } 28 + squirtle = ">= 1.4.0 and < 2.0.0" 28 29 29 30 [dev-dependencies] 30 31 gleeunit = ">= 1.0.0 and < 2.0.0"
+3 -1
server/manifest.toml
··· 4 4 packages = [ 5 5 { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 6 { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, 7 - { name = "cartography_api", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "glepack"], source = "local", path = "../api" }, 7 + { name = "cartography_api", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "squirtle"], source = "local", path = "../api" }, 8 8 { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, 9 9 { name = "eval", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "eval", source = "hex", outer_checksum = "264DAF4B49DF807F303CA4A4E4EBC012070429E40BE384C58FE094C4958F9BDA" }, 10 10 { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, ··· 40 40 { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, 41 41 { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, 42 42 { name = "squirrel", version = "4.6.0", build_tools = ["gleam"], requirements = ["argv", "envoy", "eval", "filepath", "glam", "gleam_community_ansi", "gleam_crypto", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "glexer", "justin", "mug", "non_empty_list", "pog", "simplifile", "term_size", "tom", "tote", "youid"], otp_app = "squirrel", source = "hex", outer_checksum = "0ED10A868BDD1A5D4B68D99CD1C72DC3F23C6E36E16D33454C5F0C31BAC9CB1E" }, 43 + { name = "squirtle", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "squirtle", source = "hex", outer_checksum = "B4DCF7ED3E829E1DB6163F3E18677EF0862DF3F4CE076D98628EBB8D9BC974BC" }, 43 44 { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 44 45 { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 45 46 { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, ··· 61 62 palabres = { version = ">= 1.0.3 and < 2.0.0" } 62 63 pog = { version = ">= 4.1.0 and < 5.0.0" } 63 64 squirrel = { version = ">= 4.6.0 and < 5.0.0" } 65 + squirtle = { version = ">= 1.4.0 and < 2.0.0" }