Trading card city builder game?

rewrite interface in typescript by hand, as the gleam was untenable

+234 -72
-37
api/package.json
··· 1 - { 2 - "name": "cartography-api", 3 - "version": "0.0.1", 4 - "description": "Cartography API", 5 - "private": true, 6 - "directories": { 7 - "lib": "./build/dev/javascript" 8 - }, 9 - "exports": { 10 - "./coder": { 11 - "import": "./build/dev/javascript/cartography_api/coder.mjs", 12 - "types": "./build/dev/javascript/cartography_api/coder.d.mts" 13 - }, 14 - "./request": { 15 - "import": "./build/dev/javascript/cartography_api/request.mjs", 16 - "types": "./build/dev/javascript/cartography_api/request.d.mts" 17 - }, 18 - "./response": { 19 - "import": "./build/dev/javascript/cartography_api/response.mjs", 20 - "types": "./build/dev/javascript/cartography_api/response.d.mts" 21 - }, 22 - "./account": { 23 - "import": "./build/dev/javascript/cartography_api/account.mjs", 24 - "types": "./build/dev/javascript/cartography_api/account.d.mts" 25 - }, 26 - "./game_state": { 27 - "import": "./build/dev/javascript/cartography_api/game_state.mjs", 28 - "types": "./build/dev/javascript/cartography_api/game_state.d.mts" 29 - }, 30 - "./prelude": { 31 - "import": "./build/dev/javascript/prelude.mjs", 32 - "types": "./build/dev/javascript/prelude.d.mts" 33 - } 34 - }, 35 - "author": "Cameron Eldridge <cameldridge@gmail.com>", 36 - "license": "ISC" 37 - }
+1
api/src/game_state.gleam
··· 83 83 json.nullable(home_tile_id, fn(id) { json.int(id.id) }), 84 84 ), 85 85 ]) 86 + |> repr.struct("Citizen") 86 87 _ -> panic as "unreachable" 87 88 } 88 89 })
+23 -1
package-lock.json
··· 10 10 "dependencies": { 11 11 "cartography-api": "file:./api", 12 12 "core-js": "^3.38.1", 13 - "seedrandom": "^3.0.5" 13 + "json-patch": "^0.7.0", 14 + "seedrandom": "^3.0.5", 15 + "typebox": "^1.0.80" 14 16 }, 15 17 "devDependencies": { 16 18 "@eslint/js": "^9.39.2", ··· 18 20 "@sveltejs/kit": "^2.49.5", 19 21 "@sveltejs/vite-plugin-svelte": "^6.2.1", 20 22 "@tauri-apps/cli": "^2.0.0-rc.18", 23 + "@types/json-patch": "^0.0.33", 21 24 "@types/node": "^22.9.1", 22 25 "@types/seedrandom": "^3.0.8", 23 26 "concurrently": "^9.0.1", ··· 1395 1398 "version": "1.0.8", 1396 1399 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", 1397 1400 "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 1401 + "dev": true, 1402 + "license": "MIT" 1403 + }, 1404 + "node_modules/@types/json-patch": { 1405 + "version": "0.0.33", 1406 + "resolved": "https://registry.npmjs.org/@types/json-patch/-/json-patch-0.0.33.tgz", 1407 + "integrity": "sha512-XQ9hIoJCtnvTCnIV+p+SWQbY8Bt1pe+RuTkPG7lQeRCa9sPwYbhb0aYzM2paVPk0SGvV70R33rxjPjBcJ4Kdxw==", 1398 1408 "dev": true, 1399 1409 "license": "MIT" 1400 1410 }, ··· 3013 3023 "dev": true, 3014 3024 "license": "MIT" 3015 3025 }, 3026 + "node_modules/json-patch": { 3027 + "version": "0.7.0", 3028 + "resolved": "https://registry.npmjs.org/json-patch/-/json-patch-0.7.0.tgz", 3029 + "integrity": "sha512-9zaGTzsV6Hal5HVMC8kb4niXYQOOcq3tUp0P/GTw6HHZFPVwtCU2+mXE9q59MelL9uknALWnoKrUxnDpUX728g==", 3030 + "license": "BSD" 3031 + }, 3016 3032 "node_modules/json-schema-traverse": { 3017 3033 "version": "0.4.1", 3018 3034 "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", ··· 4541 4557 "engines": { 4542 4558 "node": ">= 0.8.0" 4543 4559 } 4560 + }, 4561 + "node_modules/typebox": { 4562 + "version": "1.0.80", 4563 + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.0.80.tgz", 4564 + "integrity": "sha512-ZeTgC357AxaeAw4HX+ZJRBGxpWQIUVt2FNQC+QDoF3piv4/fRmy8DU9ci8cwSkNCbWT6BIUqEXdcaU8f3ib/Iw==", 4565 + "license": "MIT" 4544 4566 }, 4545 4567 "node_modules/typescript": { 4546 4568 "version": "5.9.3",
+4 -1
package.json
··· 11 11 "@sveltejs/kit": "^2.49.5", 12 12 "@sveltejs/vite-plugin-svelte": "^6.2.1", 13 13 "@tauri-apps/cli": "^2.0.0-rc.18", 14 + "@types/json-patch": "^0.0.33", 14 15 "@types/node": "^22.9.1", 15 16 "@types/seedrandom": "^3.0.8", 16 17 "concurrently": "^9.0.1", ··· 32 33 }, 33 34 "type": "module", 34 35 "dependencies": { 36 + "cartography-api": "file:./api", 35 37 "core-js": "^3.38.1", 38 + "json-patch": "^0.7.0", 36 39 "seedrandom": "^3.0.5", 37 - "cartography-api": "file:./api" 40 + "typebox": "^1.0.80" 38 41 }, 39 42 "engines": { 40 43 "node": "22.21.1",
+1 -1
src/lib/appserver/socket/AuthEvent.ts
··· 1 - import type { Account } from "cartography-api/account"; 1 + import type { Account } from "./SocketV1Protocol"; 2 2 3 3 export class AuthEvent extends Event { 4 4 account: Account;
-10
src/lib/appserver/socket/Message.ts
··· 1 - /* eslint-disable @typescript-eslint/naming-convention -- this is a server owned field */ 2 - 3 - import type { Authenticate, DebugAddCard } from "cartography-api/request"; 4 - import type { Authenticated, PatchData } from "cartography-api/response"; 5 - 6 - export type MessageReply<T> = T extends Authenticate 7 - ? Authenticated 8 - : T extends DebugAddCard 9 - ? PatchData 10 - : void;
+3 -3
src/lib/appserver/socket/MessageEvent.ts
··· 1 - import type { Message$ } from "cartography-api/response"; 1 + import type { ResponseMessage } from "./SocketV1Protocol"; 2 2 3 3 export class MessageEvent extends Event { 4 - message: Message$; 4 + message: ResponseMessage; 5 5 6 - constructor(message: Message$) { 6 + constructor(message: ResponseMessage) { 7 7 super("message"); 8 8 this.message = message; 9 9 }
+4 -3
src/lib/appserver/socket/OneOff.svelte.ts
··· 1 - import * as Response from "cartography-api/response"; 2 1 import type { MessageEvent } from "./MessageEvent"; 3 2 import { type SocketV1 } from "./SocketV1"; 4 3 ··· 17 16 abort?.throwIfAborted(); 18 17 19 18 const handler = (event: MessageEvent) => { 20 - if (Response.id(event.message) === this.id) { 21 - resolve(Response.response(event.message) as T); 19 + if (event.message.id === this.id) { 20 + // NOTE: would be nice to do a runtime assertion here, but the mapping is currently 21 + // only defined as a type. Not hard to shift to a value, just lazy. 22 + resolve(event.message.response as T); 22 23 this.#socket.removeEventListener("message", handler); 23 24 abort?.removeEventListener("abort", onabort); 24 25 }
+20 -15
src/lib/appserver/socket/SocketV1.ts
··· 1 1 import { MessageEvent } from "./MessageEvent"; 2 2 import { AuthEvent } from "./AuthEvent"; 3 3 import { OneOff } from "./OneOff.svelte"; 4 - import { Subscription, type Channel } from "./Subscription"; 5 - import type { MessageReply } from "./Message"; 6 4 import { ReactiveEventTarget } from "$lib/ReactiveEventTarget.svelte"; 7 - import type { FieldId } from "../Field"; 8 - import { Result$isOk, Result$Ok$0 } from "cartography-api/prelude"; 9 - import * as Request from "cartography-api/request"; 10 - import * as Response from "cartography-api/response"; 5 + import Value from "typebox/value"; 6 + import { 7 + construct, 8 + Request, 9 + RequestMessage, 10 + ResponseMessage, 11 + type SocketV1Protocol, 12 + } from "./SocketV1Protocol"; 11 13 12 14 interface SocketV1EventMap { 13 15 message: MessageEvent; ··· 43 45 this.#socket.close(1003, "Only text messages are supported"); 44 46 this.dispatchEvent(new Event("error")); 45 47 } 46 - const message = Response.from_string(data); 47 - if (Result$isOk(message)) { 48 - this.dispatchEvent(new MessageEvent(Result$Ok$0(message)!)); 49 - } else { 48 + try { 49 + const message = Value.Decode(ResponseMessage, data); 50 + this.dispatchEvent(new MessageEvent(message)); 51 + } catch { 50 52 this.#socket.close(4000, "Invalid JSON received"); 51 53 this.dispatchEvent(new Event("error")); 52 54 } ··· 77 79 this.#socket.addEventListener("close", onClose); 78 80 } 79 81 80 - #sendMessage<T extends Request.Request$>(request: T, id: string = window.crypto.randomUUID()) { 81 - this.#socket.send(Request.to_string(Request.message(request, id))); 82 - return new OneOff<MessageReply<T>>(this, id); 82 + #sendMessage<T extends Request>(request: T, id: string = window.crypto.randomUUID()) { 83 + const encoded = Value.Encode(RequestMessage, { id, request }); 84 + this.#socket.send(JSON.stringify(encoded)); 85 + return new OneOff< 86 + T["#tag"] extends keyof SocketV1Protocol ? SocketV1Protocol[T["#tag"]] : never 87 + >(this, id); 83 88 } 84 89 85 90 auth(data: { id: string }) { 86 - this.#sendMessage<Request.Authenticate>(Request.authenticate(data.id) as Request.Authenticate) 91 + this.#sendMessage(construct("Authenticate", data.id)) 87 92 .reply() 88 93 .then((event) => { 89 - this.dispatchEvent(new AuthEvent(event[0])); 94 + this.dispatchEvent(new AuthEvent(event["#payload"])); 90 95 }); 91 96 } 92 97
+178
src/lib/appserver/socket/SocketV1Protocol.ts
··· 1 + /* eslint-disable @typescript-eslint/naming-convention -- TypeBox schemas are more like types than values, so can be named using type format. */ 2 + 3 + /** 4 + * This is a handwritten translation of the `cardtography_api` Gleam package's interface. 5 + * When in doubt the Gleam code is authoritative and this end should be updated. 6 + */ 7 + 8 + import Type, { type StaticDecode, type TSchema } from "typebox"; 9 + 10 + declare const __BRAND: unique symbol; 11 + type Branded<Brand, T> = T & { [__BRAND]: Brand }; 12 + 13 + function Branded<Brand, T extends TSchema>(brand: Brand, value: T) { 14 + return Type.Codec(value) 15 + .Decode((val) => val as Branded<Brand, StaticDecode<T>>) 16 + .Encode((val) => val as StaticDecode<T>); 17 + } 18 + 19 + function Struct<Tag extends string, Payload extends TSchema>(name: Tag, payload: Payload) { 20 + return Type.Object({ 21 + "#type": Type.Literal("struct"), 22 + "#tag": Type.Literal(name), 23 + "#payload": payload, 24 + }); 25 + } 26 + 27 + export function construct<Tag extends string, Payload>(tag: Tag, payload: Payload) { 28 + return { "#type": "struct", "#tag": tag, "#payload": payload } as const; 29 + } 30 + 31 + export type Struct<Tag extends string, Payload extends TSchema> = StaticDecode< 32 + typeof Struct<Tag, Payload> 33 + >; 34 + 35 + const JsonPointer = Type.String({ 36 + pattern: "^(/[^/~]*(~[01][^/~]*)*)*$", 37 + }); 38 + 39 + /** 40 + * Adapted from: https://github.com/fge/sample-json-schemas/blob/master/json-patch/json-patch.json 41 + */ 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 + }, 85 + ); 86 + 87 + export const AccountId = Branded("AccountId", Type.String()); 88 + export type AccountId = StaticDecode<typeof AccountId>; 89 + 90 + export const TileId = Branded("TileId", Type.Integer()); 91 + export type TileId = StaticDecode<typeof TileId>; 92 + 93 + export const CitizenId = Branded("CitizenId", Type.Integer()); 94 + export type CitizenId = StaticDecode<typeof CitizenId>; 95 + 96 + export const CardId = Type.Union([CitizenId, TileId]); 97 + export type CardId = StaticDecode<typeof CardId>; 98 + 99 + export const TileTypeId = Branded("TileTypeId", Type.String()); 100 + export type TileTypeId = StaticDecode<typeof TileTypeId>; 101 + 102 + export const SpeciesId = Branded("SpeciesId", Type.String()); 103 + export type SpeciesId = StaticDecode<typeof SpeciesId>; 104 + 105 + export const CardTypeId = Type.Union([TileTypeId, SpeciesId]); 106 + export type CardTypeId = StaticDecode<typeof CardTypeId>; 107 + 108 + export const Account = Type.Object({ id: AccountId }); 109 + export type Account = StaticDecode<typeof Account>; 110 + 111 + export const Tile = Type.Object({ id: TileId, tile_type_id: TileTypeId, name: Type.String() }); 112 + export type Tile = StaticDecode<typeof Tile>; 113 + 114 + export const Citizen = Type.Object({ 115 + id: CitizenId, 116 + species_id: SpeciesId, 117 + name: Type.String(), 118 + home_tile_id: Type.Union([TileId, Type.Null()]), 119 + }); 120 + export type Citizen = StaticDecode<typeof Citizen>; 121 + 122 + export const Card = Type.Union([Struct("Tile", Tile), Struct("Citizen", Citizen)]); 123 + export type Card = StaticDecode<typeof Card>; 124 + 125 + export const FieldTile = Type.Object({ id: TileId, x: Type.Integer(), y: Type.Integer() }); 126 + export type FieldTile = StaticDecode<typeof FieldTile>; 127 + 128 + export const FieldCitizen = Type.Object({ id: CitizenId, x: Type.Integer(), y: Type.Integer() }); 129 + export type FieldCitizen = StaticDecode<typeof FieldCitizen>; 130 + 131 + export const Field = Type.Object({ 132 + tiles: Type.Array(FieldTile), 133 + citizens: Type.Array(FieldCitizen), 134 + }); 135 + export type Field = StaticDecode<typeof Field>; 136 + 137 + export const GameState = Type.Object({ 138 + deck: Type.Array(Card), 139 + field: Field, 140 + }); 141 + export type GameState = StaticDecode<typeof GameState>; 142 + 143 + export const Authenticate = Struct("Authenticate", Type.String()); 144 + export type Authenticate = StaticDecode<typeof Authenticate>; 145 + 146 + export const DebugAddCard = Struct("DebugAddCard", Type.String()); 147 + export type DebugAddCard = StaticDecode<typeof DebugAddCard>; 148 + 149 + export const Request = Type.Union([Authenticate, DebugAddCard]); 150 + export type Request = StaticDecode<typeof Request>; 151 + 152 + export const Authenticated = Struct("Authenticated", Account); 153 + export type Authenticated = StaticDecode<typeof Authenticated>; 154 + 155 + export const PutState = Struct("PutState", GameState); 156 + export type PutState = StaticDecode<typeof PutState>; 157 + 158 + export const PatchState = Struct("PatchState", Type.Array(JsonPatch)); 159 + export type PatchState = StaticDecode<typeof PatchState>; 160 + 161 + export const Response = Type.Union([Authenticated, PutState, PatchState]); 162 + export type Response = StaticDecode<typeof Response>; 163 + 164 + export interface SocketV1Protocol { 165 + Authenticate: Authenticated; 166 + } 167 + 168 + export const RequestMessage = Type.Object({ 169 + id: Type.String({ format: "uuid" }), 170 + request: Request, 171 + }); 172 + export type RequestMessage = StaticDecode<typeof RequestMessage>; 173 + 174 + export const ResponseMessage = Type.Object({ 175 + id: Type.String({ format: "uuid" }), 176 + response: Response, 177 + }); 178 + export type ResponseMessage = StaticDecode<typeof ResponseMessage>;
-1
tsconfig.json
··· 1 1 { 2 2 "extends": "./.svelte-kit/tsconfig.json", 3 - "references": [{ "path": "./api" }], 4 3 "compilerOptions": { 5 4 "composite": true, 6 5 "allowJs": true,