From 9ecf92f15cd012884e0dafeefc2a75469925b89b Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 8 Sep 2026 17:25:53 -0400 Subject: [PATCH] Add grid, lobby, presence, and API key submodules --- pnpm-lock.yaml | 307 +++ spacetime-api-keys-ts/.npmrc | 1 + spacetime-api-keys-ts/LICENSE.txt | 759 +++++++ spacetime-api-keys-ts/README.md | 198 ++ spacetime-api-keys-ts/example/.env.example | 5 + spacetime-api-keys-ts/example/.npmrc | 1 + spacetime-api-keys-ts/example/README.md | 198 ++ spacetime-api-keys-ts/example/package.json | 28 + .../example/public/index.html | 239 +++ .../example/public/styles.css | 1100 +++++++++++ .../example/scripts/test-model.ts | 117 ++ .../example/scripts/test-share-key.ts | 15 + spacetime-api-keys-ts/example/server.ts | 99 + .../example/spacetimedb/.npmrc | 1 + .../example/spacetimedb/package.json | 21 + .../example/spacetimedb/src/http.ts | 61 + .../example/spacetimedb/src/index.ts | 950 +++++++++ .../example/spacetimedb/src/schema.ts | 105 + .../example/spacetimedb/tsconfig.json | 15 + spacetime-api-keys-ts/example/src/app.ts | 1185 +++++++++++ spacetime-api-keys-ts/example/src/model.ts | 384 ++++ .../app/apiKeys/add_admin_identity_reducer.ts | 15 + .../app/apiKeys/api_key_usage_admin_table.ts | 22 + .../app/apiKeys/api_keys_admin_table.ts | 31 + .../create_api_key_for_subject_procedure.ts | 25 + .../app/apiKeys/create_api_key_procedure.ts | 24 + .../app/apiKeys/my_api_keys_table.ts | 31 + .../apiKeys/remove_admin_identity_reducer.ts | 15 + .../revoke_api_key_for_subject_reducer.ts | 16 + .../app/apiKeys/revoke_api_key_reducer.ts | 15 + .../app/apiKeys/rotate_api_key_procedure.ts | 22 + .../apiKeys/sweep_api_key_usage_reducer.ts | 16 + .../src/module_bindings/app/apiKeys/types.ts | 111 ++ .../src/module_bindings/app/build_reducer.ts | 18 + .../src/module_bindings/app/clear_reducer.ts | 16 + .../app/clear_world_events_reducer.ts | 13 + .../module_bindings/app/colony_cells_table.ts | 20 + .../app/colony_entities_table.ts | 24 + .../module_bindings/app/colony_grid_table.ts | 26 + .../app/create_access_key_procedure.ts | 24 + .../app/ensure_world_procedure.ts | 19 + .../src/module_bindings/app/grid/types.ts | 69 + .../example/src/module_bindings/app/index.ts | 324 +++ .../app/my_access_keys_table.ts | 31 + .../src/module_bindings/app/plant_reducer.ts | 17 + .../app/presence_entry_table.ts | 24 + .../app/presence_heartbeat_reducer.ts | 21 + .../app/presence_leave_reducer.ts | 15 + .../app/reset_world_reducer.ts | 13 + .../app/revoke_access_key_reducer.ts | 15 + .../app/rotate_access_key_procedure.ts | 22 + .../module_bindings/app/terraform_reducer.ts | 17 + .../example/src/module_bindings/app/types.ts | 159 ++ .../module_bindings/app/types/procedures.ts | 19 + .../src/module_bindings/app/types/reducers.ts | 30 + .../module_bindings/app/unbuild_reducer.ts | 16 + .../module_bindings/app/world_event_table.ts | 22 + .../src/module_bindings/app/world_table.ts | 19 + .../example/src/share-key.ts | 16 + spacetime-api-keys-ts/example/tsconfig.json | 16 + spacetime-api-keys-ts/package.json | 68 + spacetime-api-keys-ts/scripts/test.ts | 30 + spacetime-api-keys-ts/src/index.ts | 14 + spacetime-api-keys-ts/src/keys.ts | 79 + spacetime-api-keys-ts/src/submodule.ts | 33 + spacetime-api-keys-ts/src/submodule/auth.ts | 19 + .../src/submodule/install.ts | 9 + .../src/submodule/operations.ts | 696 +++++++ spacetime-api-keys-ts/src/submodule/schema.ts | 130 ++ spacetime-api-keys-ts/tsconfig.json | 15 + spacetime-grid-ts/.npmrc | 1 + spacetime-grid-ts/LICENSE.txt | 759 +++++++ spacetime-grid-ts/README.md | 233 +++ spacetime-grid-ts/example/.env.example | 30 + spacetime-grid-ts/example/.npmrc | 1 + spacetime-grid-ts/example/README.md | 167 ++ spacetime-grid-ts/example/package.json | 29 + .../example/public/hex-geometry.js | 111 ++ spacetime-grid-ts/example/public/index.html | 172 ++ spacetime-grid-ts/example/public/styles.css | 508 +++++ spacetime-grid-ts/example/public/ui.js | 1028 ++++++++++ .../example/scripts/test-hex-geometry.mjs | 60 + spacetime-grid-ts/example/server.ts | 195 ++ spacetime-grid-ts/example/spacetimedb/.npmrc | 1 + .../example/spacetimedb/package.json | 20 + .../example/spacetimedb/src/auth-adapter.ts | 181 ++ .../example/spacetimedb/src/index.ts | 930 +++++++++ .../example/spacetimedb/src/schema.ts | 130 ++ .../example/spacetimedb/src/views.ts | 235 +++ .../example/spacetimedb/tsconfig.json | 14 + spacetime-grid-ts/example/src/app.ts | 536 +++++ .../app/actor_directory_table.ts | 24 + .../app/ai_take_turn_procedure.ts | 20 + .../app/attack_unit_procedure.ts | 17 + .../app/auth/get_auth_public_key_procedure.ts | 19 + .../app/auth/link_connection_reducer.ts | 15 + .../app/auth/list_my_sessions_procedure.ts | 19 + .../app/auth/my_auth_user_table.ts | 21 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/auth/rateLimit/consume_procedure.ts | 24 + .../auth/rateLimit/rate_limit_config_table.ts | 17 + .../auth/rateLimit/reset_buckets_reducer.ts | 15 + .../app/auth/rateLimit/run_sweep_procedure.ts | 16 + .../app/auth/rateLimit/types.ts | 56 + .../auth/rateLimit/update_config_reducer.ts | 15 + .../app/auth/revoke_my_session_reducer.ts | 15 + .../app/auth/revoke_session_reducer.ts | 15 + .../app/auth/set_auth_config_reducer.ts | 23 + .../src/module_bindings/app/auth/types.ts | 137 ++ .../app/auth/unlink_connection_reducer.ts | 13 + .../app/auth/update_profile_reducer.ts | 16 + .../app/auth/whoami_procedure.ts | 19 + .../app/create_match_procedure.ts | 20 + .../module_bindings/app/end_turn_procedure.ts | 16 + .../app/get_auth_public_key_procedure.ts | 19 + .../app/get_cells_in_range_procedure.ts | 23 + .../src/module_bindings/app/grid/types.ts | 69 + .../example/src/module_bindings/app/index.ts | 360 ++++ .../app/join_match_procedure.ts | 16 + .../app/link_connection_reducer.ts | 15 + .../app/list_my_sessions_procedure.ts | 19 + .../app/lobby_open_matches_table.ts | 17 + .../app/move_unit_procedure.ts | 22 + .../module_bindings/app/my_auth_user_table.ts | 21 + .../app/my_cell_states_table.ts | 20 + .../app/my_grid_entities_table.ts | 24 + .../src/module_bindings/app/my_grids_table.ts | 26 + .../app/my_match_participants_table.ts | 20 + .../module_bindings/app/my_matches_table.ts | 28 + .../app/my_player_units_table.ts | 22 + .../module_bindings/app/npc_actor_table.ts | 18 + .../app/revoke_my_session_reducer.ts | 15 + .../app/revoke_session_reducer.ts | 15 + .../app/set_auth_config_reducer.ts | 23 + .../example/src/module_bindings/app/types.ts | 276 +++ .../module_bindings/app/types/procedures.ts | 40 + .../src/module_bindings/app/types/reducers.ts | 22 + .../module_bindings/app/unit_type_table.ts | 21 + .../app/unlink_connection_reducer.ts | 13 + .../app/update_profile_reducer.ts | 16 + .../module_bindings/app/whoami_procedure.ts | 19 + spacetime-grid-ts/example/tsconfig.json | 14 + spacetime-grid-ts/package.json | 72 + spacetime-grid-ts/scripts/test-pathfind.ts | 228 +++ spacetime-grid-ts/src/index.ts | 38 + spacetime-grid-ts/src/math/coords.ts | 17 + spacetime-grid-ts/src/math/distance.ts | 29 + spacetime-grid-ts/src/math/index.ts | 22 + spacetime-grid-ts/src/math/neighbors.ts | 42 + spacetime-grid-ts/src/math/pathfind.ts | 189 ++ spacetime-grid-ts/src/procedures.ts | 535 +++++ spacetime-grid-ts/src/rows.ts | 74 + spacetime-grid-ts/src/submodule.ts | 6 + spacetime-grid-ts/src/submodule/install.ts | 6 + spacetime-grid-ts/src/submodule/schema.ts | 46 + spacetime-grid-ts/tsconfig.json | 14 + spacetime-lobby-ts/.npmrc | 1 + spacetime-lobby-ts/LICENSE.txt | 759 +++++++ spacetime-lobby-ts/README.md | 179 ++ spacetime-lobby-ts/example/.env.example | 4 + spacetime-lobby-ts/example/.npmrc | 1 + spacetime-lobby-ts/example/README.md | 167 ++ spacetime-lobby-ts/example/package.json | 29 + spacetime-lobby-ts/example/public/index.html | 132 ++ spacetime-lobby-ts/example/public/styles.css | 1480 ++++++++++++++ .../example/scripts/test-model.ts | 181 ++ spacetime-lobby-ts/example/server.ts | 49 + spacetime-lobby-ts/example/spacetimedb/.npmrc | 1 + .../example/spacetimedb/package.json | 19 + .../example/spacetimedb/src/catalog.ts | 210 ++ .../example/spacetimedb/src/index.ts | 835 ++++++++ .../example/spacetimedb/src/schema.ts | 242 +++ .../example/spacetimedb/src/views.ts | 254 +++ .../example/spacetimedb/tsconfig.json | 22 + spacetime-lobby-ts/example/src/app.ts | 1074 ++++++++++ spacetime-lobby-ts/example/src/model.ts | 300 +++ .../module_bindings/advance_duel_reducer.ts | 15 + .../choose_maneuver_reducer.ts | 22 + .../module_bindings/fallback_to_ai_reducer.ts | 13 + .../src/module_bindings/find_duel_reducer.ts | 13 + .../example/src/module_bindings/index.ts | 422 ++++ .../module_bindings/join_duel_room_reducer.ts | 15 + .../src/module_bindings/leave_duel_reducer.ts | 15 + .../lobby/add_admin_identity_reducer.ts | 15 + .../lobby/cancel_ticket_reducer.ts | 15 + .../lobby/close_room_reducer.ts | 15 + .../lobby/expire_tickets_reducer.ts | 15 + .../lobby/get_lobby_status_procedure.ts | 15 + .../lobby/join_queue_reducer.ts | 18 + .../lobby/join_ranked_queue_reducer.ts | 19 + .../lobby/join_room_reducer.ts | 15 + .../lobby/leave_room_reducer.ts | 15 + .../lobby/lobby_admin_match_results_table.ts | 26 + .../lobby/lobby_admin_room_seats_table.ts | 30 + .../lobby/lobby_admin_rooms_table.ts | 28 + .../lobby/lobby_admin_tickets_table.ts | 34 + .../lobby/lobby_queue_summary_table.ts | 18 + .../lobby/lobby_ranked_leaderboard_table.ts | 21 + .../lobby/my_lobby_ratings_table.ts | 21 + .../lobby/my_lobby_room_seats_table.ts | 30 + .../lobby/my_lobby_rooms_table.ts | 28 + .../lobby/my_lobby_tickets_table.ts | 34 + .../lobby/remove_admin_identity_reducer.ts | 15 + .../lobby/set_rating_reducer.ts | 17 + .../src/module_bindings/lobby/types.ts | 182 ++ .../lobby/update_config_reducer.ts | 16 + .../lobby_queue_summary_table.ts | 18 + .../lobby_ranked_leaderboard_table.ts | 21 + .../module_bindings/maneuver_catalog_table.ts | 34 + .../my_duel_combatants_table.ts | 35 + .../my_duel_maneuvers_table.ts | 27 + .../my_duel_round_logs_table.ts | 19 + .../src/module_bindings/my_duels_table.ts | 26 + .../module_bindings/my_lobby_ratings_table.ts | 21 + .../my_lobby_room_seats_table.ts | 30 + .../module_bindings/my_lobby_rooms_table.ts | 28 + .../module_bindings/my_lobby_tickets_table.ts | 34 + .../src/module_bindings/my_profile_table.ts | 25 + .../src/module_bindings/players_table.ts | 25 + .../module_bindings/queue_again_reducer.ts | 15 + .../module_bindings/select_ship_reducer.ts | 21 + .../set_display_name_reducer.ts | 15 + .../src/module_bindings/ship_catalog_table.ts | 31 + .../example/src/module_bindings/types.ts | 272 +++ .../src/module_bindings/types/procedures.ts | 10 + .../src/module_bindings/types/reducers.ts | 28 + spacetime-lobby-ts/example/tsconfig.json | 22 + spacetime-lobby-ts/package.json | 65 + spacetime-lobby-ts/scripts/test.ts | 36 + spacetime-lobby-ts/src/index.ts | 25 + spacetime-lobby-ts/src/keys.ts | 3 + spacetime-lobby-ts/src/matchmaking.ts | 67 + spacetime-lobby-ts/src/submodule.ts | 56 + spacetime-lobby-ts/src/submodule/install.ts | 22 + .../src/submodule/operations.ts | 814 ++++++++ spacetime-lobby-ts/src/submodule/schema.ts | 270 +++ spacetime-lobby-ts/src/submodule/views.ts | 256 +++ spacetime-lobby-ts/tsconfig.json | 22 + spacetime-presence-ts/.npmrc | 1 + spacetime-presence-ts/LICENSE.txt | 759 +++++++ spacetime-presence-ts/README.md | 159 ++ spacetime-presence-ts/example/.env.example | 26 + spacetime-presence-ts/example/.npmrc | 1 + spacetime-presence-ts/example/README.md | 184 ++ spacetime-presence-ts/example/package.json | 29 + .../example/public/chat-model.js | 170 ++ .../example/public/chat-state.js | 27 + spacetime-presence-ts/example/public/chat.css | 1398 +++++++++++++ .../example/public/index.html | 560 ++++++ .../example/public/styles.css | 1160 +++++++++++ spacetime-presence-ts/example/public/ui.js | 1753 +++++++++++++++++ .../example/scripts/test-ui-model.mjs | 111 ++ spacetime-presence-ts/example/server.ts | 205 ++ .../example/spacetimedb/.npmrc | 1 + .../example/spacetimedb/package.json | 21 + .../example/spacetimedb/src/chat-policy.ts | 32 + .../example/spacetimedb/src/domain.ts | 331 ++++ .../example/spacetimedb/src/index.ts | 1212 ++++++++++++ .../example/spacetimedb/src/model.ts | 192 ++ .../example/spacetimedb/src/schema.ts | 60 + .../example/spacetimedb/src/views.ts | 376 ++++ .../example/spacetimedb/tsconfig.json | 15 + spacetime-presence-ts/example/src/app.ts | 812 ++++++++ .../app/auth/get_auth_public_key_procedure.ts | 19 + .../app/auth/link_connection_reducer.ts | 15 + .../app/auth/list_my_sessions_procedure.ts | 19 + .../app/auth/my_auth_user_table.ts | 21 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/auth/rateLimit/consume_procedure.ts | 24 + .../auth/rateLimit/rate_limit_config_table.ts | 17 + .../auth/rateLimit/reset_buckets_reducer.ts | 15 + .../app/auth/rateLimit/run_sweep_procedure.ts | 16 + .../app/auth/rateLimit/types.ts | 56 + .../auth/rateLimit/update_config_reducer.ts | 15 + .../app/auth/revoke_my_session_reducer.ts | 15 + .../app/auth/revoke_session_reducer.ts | 15 + .../app/auth/set_auth_config_reducer.ts | 23 + .../src/module_bindings/app/auth/types.ts | 137 ++ .../app/auth/unlink_connection_reducer.ts | 13 + .../app/auth/update_profile_reducer.ts | 16 + .../app/auth/whoami_procedure.ts | 19 + .../app/create_room_reducer.ts | 18 + .../app/create_server_reducer.ts | 15 + .../app/delete_message_reducer.ts | 15 + .../app/delete_room_reducer.ts | 15 + .../app/delete_server_reducer.ts | 15 + .../app/delete_thread_message_reducer.ts | 15 + .../app/edit_message_reducer.ts | 16 + .../app/edit_thread_message_reducer.ts | 16 + .../src/module_bindings/app/files/types.ts | 32 + .../app/get_attachment_file_procedure.ts | 20 + .../app/get_auth_public_key_procedure.ts | 19 + .../module_bindings/app/heartbeat_reducer.ts | 13 + .../example/src/module_bindings/app/index.ts | 488 +++++ .../module_bindings/app/join_room_reducer.ts | 15 + .../app/join_server_reducer.ts | 15 + .../module_bindings/app/leave_room_reducer.ts | 15 + .../app/leave_server_reducer.ts | 15 + .../app/link_connection_reducer.ts | 15 + .../app/list_my_sessions_procedure.ts | 19 + .../app/mark_room_read_reducer.ts | 15 + .../module_bindings/app/my_auth_user_table.ts | 21 + .../app/my_chat_users_table.ts | 27 + .../app/my_message_threads_table.ts | 20 + .../app/my_presence_entries_table.ts | 24 + .../app/my_rate_limit_status_table.ts | 19 + .../app/my_room_attachments_table.ts | 27 + .../app/my_room_members_table.ts | 19 + .../app/my_room_message_reactions_table.ts | 19 + .../app/my_room_messages_table.ts | 23 + .../app/my_room_read_cursors_table.ts | 19 + .../src/module_bindings/app/my_rooms_table.ts | 24 + .../app/my_server_members_table.ts | 19 + .../module_bindings/app/my_servers_table.ts | 18 + .../app/my_thread_messages_table.ts | 20 + .../app/pin_message_reducer.ts | 15 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/rateLimit/consume_procedure.ts | 24 + .../app/rateLimit/rate_limit_config_table.ts | 17 + .../app/rateLimit/reset_buckets_reducer.ts | 15 + .../app/rateLimit/run_sweep_procedure.ts | 16 + .../module_bindings/app/rateLimit/types.ts | 56 + .../app/rateLimit/update_config_reducer.ts | 15 + .../app/rename_room_reducer.ts | 16 + .../app/rename_server_reducer.ts | 16 + .../app/revoke_my_session_reducer.ts | 15 + .../app/revoke_session_reducer.ts | 15 + .../app/search_messages_procedure.ts | 21 + .../app/send_message_reducer.ts | 24 + .../app/send_thread_message_reducer.ts | 16 + .../app/set_auth_config_reducer.ts | 23 + .../app/set_display_name_reducer.ts | 15 + .../app/set_room_category_reducer.ts | 16 + .../app/set_room_privacy_reducer.ts | 16 + .../module_bindings/app/set_status_reducer.ts | 21 + .../app/start_typing_reducer.ts | 15 + .../app/stop_typing_reducer.ts | 15 + .../app/toggle_reaction_reducer.ts | 16 + .../example/src/module_bindings/app/types.ts | 296 +++ .../module_bindings/app/types/procedures.ts | 25 + .../src/module_bindings/app/types/reducers.ts | 76 + .../app/unlink_connection_reducer.ts | 13 + .../app/unpin_message_reducer.ts | 15 + .../app/update_profile_reducer.ts | 16 + .../module_bindings/app/whoami_procedure.ts | 19 + spacetime-presence-ts/example/tsconfig.json | 15 + spacetime-presence-ts/package.json | 68 + spacetime-presence-ts/scripts/test.ts | 226 +++ spacetime-presence-ts/spacetimedb/.npmrc | 1 + .../spacetimedb/package.json | 19 + .../spacetimedb/src/index.ts | 2 + .../spacetimedb/tsconfig.json | 12 + spacetime-presence-ts/src/index.ts | 34 + spacetime-presence-ts/src/presence.ts | 300 +++ spacetime-presence-ts/src/submodule.ts | 11 + spacetime-presence-ts/src/submodule/index.ts | 243 +++ .../src/submodule/install.ts | 33 + spacetime-presence-ts/src/tables.ts | 60 + spacetime-presence-ts/tsconfig.json | 15 + 362 files changed, 39340 insertions(+) create mode 100644 spacetime-api-keys-ts/.npmrc create mode 100644 spacetime-api-keys-ts/LICENSE.txt create mode 100644 spacetime-api-keys-ts/README.md create mode 100644 spacetime-api-keys-ts/example/.env.example create mode 100644 spacetime-api-keys-ts/example/.npmrc create mode 100644 spacetime-api-keys-ts/example/README.md create mode 100644 spacetime-api-keys-ts/example/package.json create mode 100644 spacetime-api-keys-ts/example/public/index.html create mode 100644 spacetime-api-keys-ts/example/public/styles.css create mode 100644 spacetime-api-keys-ts/example/scripts/test-model.ts create mode 100644 spacetime-api-keys-ts/example/scripts/test-share-key.ts create mode 100644 spacetime-api-keys-ts/example/server.ts create mode 100644 spacetime-api-keys-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-api-keys-ts/example/spacetimedb/package.json create mode 100644 spacetime-api-keys-ts/example/spacetimedb/src/http.ts create mode 100644 spacetime-api-keys-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-api-keys-ts/example/spacetimedb/src/schema.ts create mode 100644 spacetime-api-keys-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-api-keys-ts/example/src/app.ts create mode 100644 spacetime-api-keys-ts/example/src/model.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/add_admin_identity_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_key_usage_admin_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_keys_admin_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_for_subject_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/my_api_keys_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/remove_admin_identity_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_for_subject_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/rotate_api_key_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/sweep_api_key_usage_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/types.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/build_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/clear_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/clear_world_events_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/colony_cells_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/colony_entities_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/colony_grid_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/create_access_key_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/ensure_world_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/grid/types.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/my_access_keys_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/plant_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/presence_entry_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/presence_heartbeat_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/presence_leave_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/reset_world_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/revoke_access_key_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/rotate_access_key_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/terraform_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/unbuild_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/world_event_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/world_table.ts create mode 100644 spacetime-api-keys-ts/example/src/share-key.ts create mode 100644 spacetime-api-keys-ts/example/tsconfig.json create mode 100644 spacetime-api-keys-ts/package.json create mode 100644 spacetime-api-keys-ts/scripts/test.ts create mode 100644 spacetime-api-keys-ts/src/index.ts create mode 100644 spacetime-api-keys-ts/src/keys.ts create mode 100644 spacetime-api-keys-ts/src/submodule.ts create mode 100644 spacetime-api-keys-ts/src/submodule/auth.ts create mode 100644 spacetime-api-keys-ts/src/submodule/install.ts create mode 100644 spacetime-api-keys-ts/src/submodule/operations.ts create mode 100644 spacetime-api-keys-ts/src/submodule/schema.ts create mode 100644 spacetime-api-keys-ts/tsconfig.json create mode 100644 spacetime-grid-ts/.npmrc create mode 100644 spacetime-grid-ts/LICENSE.txt create mode 100644 spacetime-grid-ts/README.md create mode 100644 spacetime-grid-ts/example/.env.example create mode 100644 spacetime-grid-ts/example/.npmrc create mode 100644 spacetime-grid-ts/example/README.md create mode 100644 spacetime-grid-ts/example/package.json create mode 100644 spacetime-grid-ts/example/public/hex-geometry.js create mode 100644 spacetime-grid-ts/example/public/index.html create mode 100644 spacetime-grid-ts/example/public/styles.css create mode 100644 spacetime-grid-ts/example/public/ui.js create mode 100644 spacetime-grid-ts/example/scripts/test-hex-geometry.mjs create mode 100644 spacetime-grid-ts/example/server.ts create mode 100644 spacetime-grid-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-grid-ts/example/spacetimedb/package.json create mode 100644 spacetime-grid-ts/example/spacetimedb/src/auth-adapter.ts create mode 100644 spacetime-grid-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-grid-ts/example/spacetimedb/src/schema.ts create mode 100644 spacetime-grid-ts/example/spacetimedb/src/views.ts create mode 100644 spacetime-grid-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-grid-ts/example/src/app.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/actor_directory_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/ai_take_turn_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/attack_unit_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/types.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/types.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/whoami_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/create_match_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/end_turn_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/get_cells_in_range_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/grid/types.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/join_match_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/link_connection_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/lobby_open_matches_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/move_unit_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_auth_user_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_cell_states_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_grid_entities_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_grids_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_match_participants_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_matches_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_player_units_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/npc_actor_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/revoke_session_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/set_auth_config_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/unit_type_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/unlink_connection_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/update_profile_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/whoami_procedure.ts create mode 100644 spacetime-grid-ts/example/tsconfig.json create mode 100644 spacetime-grid-ts/package.json create mode 100644 spacetime-grid-ts/scripts/test-pathfind.ts create mode 100644 spacetime-grid-ts/src/index.ts create mode 100644 spacetime-grid-ts/src/math/coords.ts create mode 100644 spacetime-grid-ts/src/math/distance.ts create mode 100644 spacetime-grid-ts/src/math/index.ts create mode 100644 spacetime-grid-ts/src/math/neighbors.ts create mode 100644 spacetime-grid-ts/src/math/pathfind.ts create mode 100644 spacetime-grid-ts/src/procedures.ts create mode 100644 spacetime-grid-ts/src/rows.ts create mode 100644 spacetime-grid-ts/src/submodule.ts create mode 100644 spacetime-grid-ts/src/submodule/install.ts create mode 100644 spacetime-grid-ts/src/submodule/schema.ts create mode 100644 spacetime-grid-ts/tsconfig.json create mode 100644 spacetime-lobby-ts/.npmrc create mode 100644 spacetime-lobby-ts/LICENSE.txt create mode 100644 spacetime-lobby-ts/README.md create mode 100644 spacetime-lobby-ts/example/.env.example create mode 100644 spacetime-lobby-ts/example/.npmrc create mode 100644 spacetime-lobby-ts/example/README.md create mode 100644 spacetime-lobby-ts/example/package.json create mode 100644 spacetime-lobby-ts/example/public/index.html create mode 100644 spacetime-lobby-ts/example/public/styles.css create mode 100644 spacetime-lobby-ts/example/scripts/test-model.ts create mode 100644 spacetime-lobby-ts/example/server.ts create mode 100644 spacetime-lobby-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-lobby-ts/example/spacetimedb/package.json create mode 100644 spacetime-lobby-ts/example/spacetimedb/src/catalog.ts create mode 100644 spacetime-lobby-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-lobby-ts/example/spacetimedb/src/schema.ts create mode 100644 spacetime-lobby-ts/example/spacetimedb/src/views.ts create mode 100644 spacetime-lobby-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-lobby-ts/example/src/app.ts create mode 100644 spacetime-lobby-ts/example/src/model.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/advance_duel_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/choose_maneuver_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/fallback_to_ai_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/find_duel_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/index.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/join_duel_room_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/leave_duel_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/add_admin_identity_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/cancel_ticket_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/close_room_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/expire_tickets_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/get_lobby_status_procedure.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/join_queue_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/join_ranked_queue_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/join_room_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/leave_room_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_match_results_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_room_seats_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_rooms_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_tickets_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_queue_summary_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_ranked_leaderboard_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_ratings_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_room_seats_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_rooms_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_tickets_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/remove_admin_identity_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/set_rating_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/types.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/update_config_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby_queue_summary_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby_ranked_leaderboard_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/maneuver_catalog_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_duel_combatants_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_duel_maneuvers_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_duel_round_logs_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_duels_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_lobby_ratings_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_lobby_room_seats_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_lobby_rooms_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_lobby_tickets_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_profile_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/players_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/queue_again_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/select_ship_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/set_display_name_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/ship_catalog_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/types.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/types/procedures.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/types/reducers.ts create mode 100644 spacetime-lobby-ts/example/tsconfig.json create mode 100644 spacetime-lobby-ts/package.json create mode 100644 spacetime-lobby-ts/scripts/test.ts create mode 100644 spacetime-lobby-ts/src/index.ts create mode 100644 spacetime-lobby-ts/src/keys.ts create mode 100644 spacetime-lobby-ts/src/matchmaking.ts create mode 100644 spacetime-lobby-ts/src/submodule.ts create mode 100644 spacetime-lobby-ts/src/submodule/install.ts create mode 100644 spacetime-lobby-ts/src/submodule/operations.ts create mode 100644 spacetime-lobby-ts/src/submodule/schema.ts create mode 100644 spacetime-lobby-ts/src/submodule/views.ts create mode 100644 spacetime-lobby-ts/tsconfig.json create mode 100644 spacetime-presence-ts/.npmrc create mode 100644 spacetime-presence-ts/LICENSE.txt create mode 100644 spacetime-presence-ts/README.md create mode 100644 spacetime-presence-ts/example/.env.example create mode 100644 spacetime-presence-ts/example/.npmrc create mode 100644 spacetime-presence-ts/example/README.md create mode 100644 spacetime-presence-ts/example/package.json create mode 100644 spacetime-presence-ts/example/public/chat-model.js create mode 100644 spacetime-presence-ts/example/public/chat-state.js create mode 100644 spacetime-presence-ts/example/public/chat.css create mode 100644 spacetime-presence-ts/example/public/index.html create mode 100644 spacetime-presence-ts/example/public/styles.css create mode 100644 spacetime-presence-ts/example/public/ui.js create mode 100644 spacetime-presence-ts/example/scripts/test-ui-model.mjs create mode 100644 spacetime-presence-ts/example/server.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-presence-ts/example/spacetimedb/package.json create mode 100644 spacetime-presence-ts/example/spacetimedb/src/chat-policy.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/src/domain.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/src/model.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/src/schema.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/src/views.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-presence-ts/example/src/app.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/types.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/types.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/whoami_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/create_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/create_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/delete_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/delete_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/delete_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/delete_thread_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/edit_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/edit_thread_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/files/types.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/get_attachment_file_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/heartbeat_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/join_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/join_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/leave_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/leave_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/link_connection_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/mark_room_read_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_auth_user_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_chat_users_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_message_threads_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_presence_entries_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_rate_limit_status_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_room_attachments_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_room_members_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_room_message_reactions_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_room_messages_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_room_read_cursors_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_rooms_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_server_members_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_servers_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_thread_messages_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/pin_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/consume_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/types.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/update_config_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rename_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rename_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/revoke_session_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/search_messages_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/send_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/send_thread_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/set_auth_config_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/set_display_name_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/set_room_category_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/set_room_privacy_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/set_status_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/start_typing_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/stop_typing_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/toggle_reaction_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/unlink_connection_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/unpin_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/update_profile_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/whoami_procedure.ts create mode 100644 spacetime-presence-ts/example/tsconfig.json create mode 100644 spacetime-presence-ts/package.json create mode 100644 spacetime-presence-ts/scripts/test.ts create mode 100644 spacetime-presence-ts/spacetimedb/.npmrc create mode 100644 spacetime-presence-ts/spacetimedb/package.json create mode 100644 spacetime-presence-ts/spacetimedb/src/index.ts create mode 100644 spacetime-presence-ts/spacetimedb/tsconfig.json create mode 100644 spacetime-presence-ts/src/index.ts create mode 100644 spacetime-presence-ts/src/presence.ts create mode 100644 spacetime-presence-ts/src/submodule.ts create mode 100644 spacetime-presence-ts/src/submodule/index.ts create mode 100644 spacetime-presence-ts/src/submodule/install.ts create mode 100644 spacetime-presence-ts/src/tables.ts create mode 100644 spacetime-presence-ts/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8227dd71554..b511fa250c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -436,6 +436,81 @@ importers: specifier: ^5.9.3 version: 5.9.3 + spacetime-api-keys-ts: + dependencies: + '@spacetimedb/crypto': + specifier: workspace:^ + version: link:../spacetime-crypto-ts + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-api-keys-ts/example: + dependencies: + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-api-keys-ts/example/spacetimedb: + dependencies: + '@spacetimedb/api-keys': + specifier: workspace:* + version: link:../.. + '@spacetimedb/grid': + specifier: workspace:* + version: link:../../../spacetime-grid-ts + '@spacetimedb/presence': + specifier: workspace:* + version: link:../../../spacetime-presence-ts + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + spacetime-auth-ts: dependencies: '@noble/curves': @@ -718,6 +793,238 @@ importers: specifier: ^5.9.3 version: 5.9.3 + spacetime-grid-ts: + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-grid-ts/example: + dependencies: + '@spacetimedb/submodule-shared': + specifier: workspace:* + version: link:../../spacetime-submodule-shared-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-grid-ts/example/spacetimedb: + dependencies: + '@spacetimedb/auth': + specifier: workspace:* + version: link:../../../spacetime-auth-ts + '@spacetimedb/grid': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-lobby-ts: + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-lobby-ts/example: + dependencies: + '@spacetimedb/submodule-shared': + specifier: workspace:* + version: link:../../spacetime-submodule-shared-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-lobby-ts/example/spacetimedb: + dependencies: + '@spacetimedb/lobby': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-presence-ts: + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-presence-ts/example: + dependencies: + '@spacetimedb/submodule-shared': + specifier: workspace:* + version: link:../../spacetime-submodule-shared-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-presence-ts/example/spacetimedb: + dependencies: + '@spacetimedb/auth': + specifier: workspace:* + version: link:../../../spacetime-auth-ts + '@spacetimedb/files': + specifier: workspace:* + version: link:../../../spacetime-files-ts + '@spacetimedb/presence': + specifier: workspace:* + version: link:../.. + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../../../spacetime-rate-limit-ts + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-presence-ts/spacetimedb: + dependencies: + '@spacetimedb/presence': + specifier: workspace:* + version: link:.. + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + spacetime-rate-limit-ts: devDependencies: '@types/node': diff --git a/spacetime-api-keys-ts/.npmrc b/spacetime-api-keys-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-api-keys-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-api-keys-ts/LICENSE.txt b/spacetime-api-keys-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-api-keys-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-api-keys-ts/README.md b/spacetime-api-keys-ts/README.md new file mode 100644 index 00000000000..024affd10de --- /dev/null +++ b/spacetime-api-keys-ts/README.md @@ -0,0 +1,198 @@ +# @spacetimedb/api-keys + +Reusable SpacetimeDB submodule for server-to-server API keys. + +The submodule owns API key lifecycle state: key creation, hashed secret storage, +verification, scope checks, revocation, rotation, usage audit rows, and +admin-gated views. Host apps own what scopes mean. + +## Install + +```bash +npm install @spacetimedb/api-keys spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +## Usage + +### Integrate into an application + +Register the submodule in the host schema and install its private state from the +host lifecycle hook: + +```ts +import { schema, SenderError, t } from 'spacetimedb/server'; +import * as apiKeys from '@spacetimedb/api-keys/submodule'; + +const spacetimedb = schema({ + apiKeys, +}); + +export const init = spacetimedb.init(ctx => { + apiKeys.installApiKeys(ctx.as.apiKeys); +}); + +export default spacetimedb; +``` + +The root package exports a standalone `init`. The `./submodule` entrypoint +leaves lifecycle ownership with the host module. + +Next, wrap `verifyApiKey` in the host operation that needs bearer-token access; +the submodule validates key material and scopes, while the host decides what a +scope authorizes. The complete +[Colony host module](./example/spacetimedb/) +shows scoped HTTP routes and one-time key delivery. + +Verification runs through a host wrapper so the application can apply request +limits and derive the action being authorized. + +## API + +`create_api_key` creates a key for the caller's SpacetimeDB identity and returns +the raw key once. Only the hash and lookup prefix are stored. + +```ts +const result = await conn.procedures.createApiKey({ + name: 'Deploy Bot', + scopesJson: JSON.stringify(['files:read', 'files:write']), + metadataJson: JSON.stringify({ environment: 'prod' }), + expiresInSeconds: 60 * 60 * 24 * 90, + keyPrefix: 'stdb_live', +}); + +console.log(result.key); +``` + +`create_api_key_for_subject` lets a submodule admin create service keys for a +specific owner subject. + +Key lifecycle operations: + +- `rotate_api_key({ keyId, expiresInSeconds, keyPrefix })` replaces a + caller-owned key and returns the new raw key once. +- `revoke_api_key({ keyId })` revokes a caller-owned key. +- `revoke_api_key_for_subject({ keyId, ownerSubject })` is the administrative + equivalent. +- `sweep_api_key_usage({ maxAgeSeconds, maxRows })` removes a bounded audit + batch. +- `createApiKey`, `rotateApiKey`, `revokeApiKey`, and `verifyApiKey` are host + helper functions for host applications. +- `add_admin_identity({ identity })` and `remove_admin_identity({ identity })` + manage the administrator allowlist. + +Each owner may have up to 50 active, unexpired keys. Expiration may be set up to +10 years from creation. + +## Verify in a host app + +Host applications can use the transactional helper directly: + +```ts +const result = apiKeys.verifyApiKey(ctx.as.apiKeys, { + key: bearerToken, + requiredScope: 'files:write', + action: 'upload_file', +}); + +if (!result.allowed) { + throw new SenderError(`unauthorized:${result.reason}`); +} +``` + +Scope matching supports exact scopes, `*`, and prefix wildcards like +`files:*`. + +After verification, keep the authorized mutation in the same host transaction: + +```ts +export const upload_with_api_key = spacetimedb.procedure( + { apiKey: t.string(), path: t.string(), bytes: t.array(t.u8()) }, + t.u64(), + (ctx, args) => + ctx.withTx(tx => { + const access = apiKeys.verifyApiKey(tx.as.apiKeys, { + key: args.apiKey, + requiredScope: 'files:write', + action: 'upload_file', + }); + if (!access.allowed || !access.ownerSubject) { + throw new SenderError('api_key.unauthorized'); + } + return writeAuthorizedFile( + tx, + access.ownerSubject, + args.path, + args.bytes + ); + }) +); +``` + +The generated client calls the host wrapper: + +```ts +const fileId = await conn.procedures.uploadWithApiKey({ + apiKey, + path: '/reports/latest.json', + bytes, +}); +``` + +`writeAuthorizedFile` represents the host application's protected mutation. It +uses the verified subject from the key record as its owner. + +Package entrypoints: + +- `@spacetimedb/api-keys` supports a standalone API-key database. +- `@spacetimedb/api-keys/submodule` supplies the submodule namespace, + helpers, operations, and views for host applications. + +## Tables and views + +Private tables: + +- `api_key`: key hash, prefix, owner, scopes, status, expiration, timestamps. +- `api_key_admin_identity`: submodule admins. +- `api_key_usage`: audit rows for verification, creation, rotation, and + revocation. + +Public views: + +- `my_api_keys`: up to 500 current-identity key summaries, with no hashes or raw keys. +- `api_keys_admin`: up to 200 recent key summaries for submodule admins. +- `api_key_usage_admin`: recent usage/audit rows for submodule admins. + +`sweep_api_key_usage` lets an admin delete up to 1,000 audit rows older than a +chosen age. Schedule it from the host according to the application's retention +policy. + +## Security model + +- Raw keys are returned once. Persistent state contains the hash and lookup + prefix. +- Stored hashes are SHA-256 of high-entropy random keys. +- A short key prefix is stored for lookup and display. +- Public views expose safe summaries and omit key hashes and raw secrets. +- The submodule validates scopes as strings; the host app defines their meaning. +- Audit rows cover recognized keys, including expired, revoked, and + scope-denied keys. Malformed and unknown input is rejected before audit + storage. + +## Testing + +```bash +pnpm test +pnpm run typecheck +``` + +The example module exercises issuance, verification, rotation, revocation, and +admin views. + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-api-keys-ts/example/.env.example b/spacetime-api-keys-ts/example/.env.example new file mode 100644 index 00000000000..81decb08050 --- /dev/null +++ b/spacetime-api-keys-ts/example/.env.example @@ -0,0 +1,5 @@ +HOST=127.0.0.1 +PORT=8798 +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +SPACETIMEDB_DB_NAME=spacetime-api-keys-example diff --git a/spacetime-api-keys-ts/example/.npmrc b/spacetime-api-keys-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-api-keys-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-api-keys-ts/example/README.md b/spacetime-api-keys-ts/example/README.md new file mode 100644 index 00000000000..356b22cea42 --- /dev/null +++ b/spacetime-api-keys-ts/example/README.md @@ -0,0 +1,198 @@ +# Colony API-key example + +Colony is a shared map editor built with +[`@spacetimedb/api-keys`](../). Each SpacetimeDB identity owns a colony and +can issue scoped bearer links that allow another browser to view or modify it. + +## What this demonstrates + +- Mounting the API Keys submodule under the `apiKeys` namespace. +- Creating, rotating, validating, and revoking scoped bearer keys. +- Validating keys in native SpacetimeDB HTTP handlers. +- Composing the API Keys, Grid, and Presence submodules. +- Giving owners native reducer access while routing key holders through HTTP. +- Recording allowed and rejected holder actions in an audit-style world event log. +- Returning a raw key only at creation or rotation time. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity for publishing the example. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-api-keys-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open . Create a share link, copy it while it is visible, +and open it in a private/incognito window to test holder permissions. + +`build:module:fresh` deletes and recreates only the local +`spacetime-api-keys-example` database. Use `pnpm run build:module` to preserve existing +local rows. + +## Use in your project + +This workspace tests the submodule source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/api-keys spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application), then wrap key +verification in the host routes or procedures that define your scopes. The +colony, grid, and presence features are application-specific demonstration code. + +## Configuration + +| Variable | Default | Purpose | +| --------------------- | ---------------------------- | ------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8798` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | Upstream module HTTP endpoint. | +| `SPACETIMEDB_DB_NAME` | `spacetime-api-keys-example` | Published database name. | + +The publish scripts target the SpacetimeDB server registered as `local`. If that +registration resolves to a different endpoint than `STDB_URI` and `STDB_HTTP`, +the browser and proxy may use a different database from the published module. + +## Roles and scopes + +The example combines four scopes into a small set of roles: + +| Scope | Allows | +| ------------------ | ---------------------------- | +| `colony:view` | Load the colony snapshot. | +| `colony:terraform` | Change cell terrain. | +| `colony:build` | Place and remove structures. | +| `colony:plant` | Place natural objects. | + +The owner may reset the colony, clear its event log, and manage access keys. A key +holder can perform only the actions represented by the key's current scopes. Key +validation also checks status and expiry. Rotation and revocation preserve the +share URL format. + +## HTTP routes + +The module exposes these native routes: + +```text +GET /api/colony/snapshot +POST /api/colony/terraform +POST /api/colony/build +POST /api/colony/unbuild +POST /api/colony/plant +POST /api/colony/clear +``` + +Holder requests send the raw key as `Authorization: Bearer `. The Node server +forwards `/api/colony/*` to the module's HTTP router. Owner actions use native +reducers authenticated by the owner's SpacetimeDB identity. + +## Key handling + +- The module stores a hash and safe key metadata. Raw keys are recoverable only + from creation and rotation responses. +- Creation and rotation return the raw key once. The owner UI displays a copyable + link for that response and keeps the raw secret out of persistent browser storage. +- Existing keys can be rotated or revoked, but their original link cannot be + reconstructed. This is intentional. +- Share links place the key in the URL fragment (`#key=...`), which browsers do + not include in the initial HTTP request. The browser reads the fragment and sends + the key only in the authorization header for colony API calls. +- Anyone with a share link has its permissions until the key expires, is rotated, + or is revoked. Treat the link as a secret. + +## Architecture and visibility + +```text +Owner browser -> SpacetimeDB reducers/procedures -> colony state + key management + +Holder browser -> Node /api/colony proxy -> module HTTP handler + -> API-key validation + scope check + -> colony mutation + +Both browsers -> SpacetimeDB subscriptions -> realtime colony and presence state +``` + +Colony world data is readable by colony identifier in this demonstration; write +authority is the behavior under test. The `my_access_keys` view is owner-scoped +and contains metadata only. Do not copy this public-read model into an application +where the resource itself must be confidential. + +## Security and deployment boundaries + +- Never log the `Authorization` header, raw creation response, pasted key, or full + share URL. +- Prefer short expirations and narrow scopes for real integrations. +- Treat XSS prevention as part of key security because holder credentials exist in + browser memory and the URL fragment while the page is open. +- The world-event rows provide UI feedback. Compliance auditing requires a + tamper-resistant external trail. +- The included Express proxy is for local development. Production needs TLS, + explicit network binding, request-size limits, origin policy, structured secret + redaction, and process supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a release smoke test: + +1. Create one key for each role and capture each link when shown. +2. Confirm each holder can load the snapshot and perform only its allowed actions. +3. Confirm a disallowed action returns an authorization failure, records a + rejected world event, and leaves the grid unchanged. +4. Rotate a key and verify the previous link is rejected and the replacement is accepted. +5. Revoke the new key and verify subsequent snapshot and mutation requests fail. +6. Reload the owner and confirm no raw key can be recovered or copied from the key + list. + +## Troubleshooting + +- **The holder opens the owner's colony instead:** use the complete `#key=...` + link and verify no extension or redirect strips the URL fragment. +- **Every holder action is rejected:** inspect the key's scopes and status, then + confirm the proxy and browser target the same published database. +- **Realtime state is stale:** confirm the WebSocket endpoint in + `STDB_URI` is reachable independently of the HTTP proxy. +- **A browser identity fails after a fresh publish:** reload once so the + client can discard the rejected development token. + +## Important files + +- `spacetimedb/src/index.ts` - colony schema, submodule mounts, views, reducers, + and HTTP handlers. +- `server.ts` - static server and colony-route proxy. +- `src/app.ts` - owner/holder modes, key handling, subscriptions, and UI logic. +- `public/index.html` - colony interface. +- `public/styles.css` - colony presentation. diff --git a/spacetime-api-keys-ts/example/package.json b/spacetime-api-keys-ts/example/package.json new file mode 100644 index 00000000000..169843eae5d --- /dev/null +++ b/spacetime-api-keys-ts/example/package.json @@ -0,0 +1,28 @@ +{ + "name": "spacetime-api-keys-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", + "check": "tsc --noEmit", + "test": "tsx scripts/test-model.ts && tsx scripts/test-share-key.ts", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run spacetime:generate && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-api-keys-ts/example/public/index.html b/spacetime-api-keys-ts/example/public/index.html new file mode 100644 index 00000000000..d08da6b7ed9 --- /dev/null +++ b/spacetime-api-keys-ts/example/public/index.html @@ -0,0 +1,239 @@ + + + + + + + + Colony + + + +
+ +
+
+
+
+
+
+ + +
+
+ +

Colony

+
+ +
+ + +
+ + + + + +
+ + +
+ + 100% + + +
+ + +
+
+
+ + + + + + + + + + + + + + +
+
+

Access removed

+

+ The owner revoked this share link. Colony access is unavailable. +

+
+
+ + + + diff --git a/spacetime-api-keys-ts/example/public/styles.css b/spacetime-api-keys-ts/example/public/styles.css new file mode 100644 index 00000000000..b1d06cdc6d4 --- /dev/null +++ b/spacetime-api-keys-ts/example/public/styles.css @@ -0,0 +1,1100 @@ +:root { + color-scheme: dark; + --bg: #0a0605; + --panel: #17100d; + --line: #3a251c; + --line-soft: #281812; + --text: #f6ece6; + --muted: #b08c7d; + --rust: #e07a4a; + --cyan: #59c6d6; + --green: #74c56a; + --yellow: #f0c05a; + --red: #f07676; + --sky: #8fb7d6; + --button: #eadfd7; + --button-text: #1a0f0a; +} + +* { + box-sizing: border-box; +} +html, +body { + height: 100%; +} +body { + margin: 0; + overflow: hidden; + background: var(--bg); + color: var(--text); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +button, +input, +select { + font: inherit; +} +button { + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(40, 24, 18, 0.9); + color: var(--text); + font-weight: 800; + cursor: pointer; + transition: + transform 90ms ease, + border-color 120ms ease, + background 120ms ease, + opacity 120ms ease; +} +button:hover:not(:disabled) { + border-color: var(--rust); + background: rgba(56, 32, 22, 0.95); +} +button:not(:disabled):active { + transform: translateY(1px); +} +button.primary { + border-color: transparent; + background: var(--button); + color: var(--button-text); +} +button:disabled { + cursor: not-allowed; + opacity: 0.38; +} +input { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: #120a07; + color: var(--text); + padding: 9px 11px; + outline: none; +} +input:focus { + border-color: var(--rust); +} +h1, +h2, +p { + margin: 0; +} +.eyebrow { + color: #c58f76; + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.14em; + text-transform: uppercase; +} +.sub { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +/* ============ the world IS the screen ============ */ +#gridViewport { + position: fixed; + inset: 0; + overflow: hidden; + touch-action: none; + user-select: none; + cursor: crosshair; + background: radial-gradient( + ellipse 130% 100% at 50% 26%, + #241009 0%, + #150a06 55%, + #0a0504 100% + ); +} +#gridViewport.panning { + cursor: grabbing; +} +#gridViewport::after { + content: ''; + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; + box-shadow: inset 0 0 260px 50px rgba(6, 3, 2, 0.92); +} + +.grid-stage { + position: absolute; + left: 0; + top: 0; + transform-origin: 0 0; + will-change: transform; + z-index: 1; +} +.world-grid { + --cols: 12; + --tile: 78px; + position: relative; + display: grid; + grid-template-columns: repeat(var(--cols), var(--tile)); + grid-auto-rows: var(--tile); + gap: 0; + padding: 26px; + border-radius: 18px; + background: #1a0d08; + box-shadow: + 0 40px 120px rgba(0, 0, 0, 0.6), + inset 0 0 0 1px rgba(200, 130, 90, 0.07); +} + +.tile { + position: relative; + border: 0; + padding: 0; + overflow: hidden; + background: #6b3b28; + box-shadow: + inset 0 0 0 1px rgba(0, 0, 0, 0.26), + inset 0 0 0 1.5px rgba(230, 150, 110, 0.04); + transition: filter 110ms ease; +} +.tile::before { + content: ''; + position: absolute; + inset: 0; +} +.tile:hover { + filter: brightness(1.18); + z-index: 2; +} + +/* Mars surface terrain */ +.tile.regolith { + background: #7a4229; +} +.tile.regolith::before { + background: + radial-gradient( + circle at 26% 30%, + rgba(240, 180, 140, 0.18), + transparent 18% + ), + radial-gradient(circle at 70% 62%, rgba(90, 40, 20, 0.3), transparent 22%); +} +.tile.rock { + background: #4a4038; +} +.tile.rock::before { + background: + radial-gradient( + circle at 34% 32%, + rgba(255, 240, 220, 0.16), + transparent 20% + ), + radial-gradient(circle at 68% 70%, rgba(0, 0, 0, 0.34), transparent 26%), + linear-gradient( + 125deg, + transparent 46%, + rgba(0, 0, 0, 0.24) 48%, + transparent 52% + ); +} +.tile.grass { + background: #3f6b31; +} +.tile.grass::before { + background: + radial-gradient( + circle at 24% 28%, + rgba(150, 220, 120, 0.28), + transparent 16% + ), + radial-gradient( + circle at 60% 44%, + rgba(120, 190, 95, 0.24), + transparent 18% + ), + radial-gradient( + circle at 44% 74%, + rgba(140, 210, 110, 0.22), + transparent 16% + ); +} +.tile.water { + background: #1c6a86; +} +.tile.water::before { + background: + repeating-linear-gradient( + 58deg, + rgba(150, 225, 240, 0.22) 0 2px, + transparent 2px 9px + ), + radial-gradient( + ellipse at 50% 40%, + rgba(175, 235, 245, 0.3), + transparent 60% + ); +} +.tile.soil { + background: #4a3320; +} +.tile.soil::before { + background: + repeating-linear-gradient( + 90deg, + rgba(0, 0, 0, 0.16) 0 2px, + transparent 2px 13px + ), + radial-gradient( + circle at 30% 30%, + rgba(125, 82, 46, 0.35), + transparent 24% + ), + radial-gradient(circle at 68% 66%, rgba(28, 16, 8, 0.4), transparent 26%); +} + +.tile.flash-allow { + animation: flashAllow 0.5s ease; +} +.tile.flash-deny { + animation: flashDeny 0.6s ease; +} +@keyframes flashAllow { + 0%, + 100% { + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.26); + } + 30% { + box-shadow: + inset 0 0 0 3px var(--green), + inset 0 0 26px rgba(116, 197, 106, 0.5); + } +} +@keyframes flashDeny { + 0%, + 100% { + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.26); + } + 18%, + 58% { + box-shadow: + inset 0 0 0 3px var(--red), + inset 0 0 26px rgba(240, 118, 118, 0.55); + } +} + +/* structures + nature */ +.entity { + position: absolute; + left: 50%; + top: 50%; + width: 56%; + height: 56%; + transform: translate(-50%, -50%); + filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.55)); + z-index: 2; +} +/* Chunky 2.5D objects: top-lit, sitting on the tile with a footprint + shadow. */ +/* Biodome: an isometric gridded glass shell (drawn as inline SVG). */ +.entity.dome { + width: 82%; + height: 78%; + background: none; + box-shadow: none; + filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5)); +} +.entity.dome svg { + width: 100%; + height: 100%; + display: block; + overflow: visible; +} + +/* Placed objects drawn as inline SVG (see ENTITY_SVG in app.ts). */ +.entity svg { + width: 100%; + height: 100%; + display: block; + overflow: visible; +} +.entity.pod { + width: 62%; + height: 66%; + background: none; + box-shadow: none; + filter: none; +} +.entity.solar { + width: 66%; + height: 62%; + background: none; + box-shadow: none; + filter: none; +} +.entity.boulder { + width: 60%; + height: 54%; + background: none; + box-shadow: none; + filter: none; +} +.entity.road { + width: 100%; + height: 100%; + transform: none; + left: 0; + top: 0; + background: none; + box-shadow: none; +} +.entity.road .road-center { + position: absolute; + left: 50%; + top: 50%; + width: 38%; + height: 38%; + transform: translate(-50%, -50%); + background: #4a4f56; + border-radius: 3px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.28); +} +.entity.road .road-arm { + position: absolute; + background: #4a4f56; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18); +} +.entity.road .road-arm.n { + left: 50%; + top: 0; + width: 38%; + height: 52%; + transform: translateX(-50%); +} +.entity.road .road-arm.s { + left: 50%; + bottom: 0; + width: 38%; + height: 52%; + transform: translateX(-50%); +} +.entity.road .road-arm.e { + right: 0; + top: 50%; + width: 52%; + height: 38%; + transform: translateY(-50%); +} +.entity.road .road-arm.w { + left: 0; + top: 50%; + width: 52%; + height: 38%; + transform: translateY(-50%); +} +.entity.road .road-arm.n::after, +.entity.road .road-arm.s::after { + content: ''; + position: absolute; + left: 50%; + top: 12%; + bottom: 12%; + width: 2px; + transform: translateX(-50%); + background: repeating-linear-gradient( + rgba(240, 220, 180, 0.4) 0 5px, + transparent 5px 11px + ); +} +.entity.road .road-arm.e::after, +.entity.road .road-arm.w::after { + content: ''; + position: absolute; + top: 50%; + left: 12%; + right: 12%; + height: 2px; + transform: translateY(-50%); + background: repeating-linear-gradient( + 90deg, + rgba(240, 220, 180, 0.4) 0 5px, + transparent 5px 11px + ); +} +.entity.tree { + width: 62%; + height: 68%; + background: none; + box-shadow: none; + filter: none; +} +.entity.shrub { + width: 44%; + height: 40%; + background: radial-gradient(circle at 50% 40%, #a6d98f, #4f8f45); + border-radius: 50% 50% 46% 46%; +} + +.deny-pop { + position: absolute; + left: 50%; + top: 36%; + transform: translate(-50%, -50%); + z-index: 6; + pointer-events: none; + color: #ffd2d0; + background: rgba(40, 10, 12, 0.92); + border: 1px solid rgba(240, 118, 118, 0.7); + border-radius: 7px; + padding: 5px 8px; + font: + 900 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.07em; + white-space: nowrap; + animation: denyPop 1s ease-out forwards; +} +@keyframes denyPop { + 0% { + opacity: 0; + transform: translate(-50%, -10%) scale(0.8); + } + 16% { + opacity: 1; + transform: translate(-50%, -110%) scale(1); + } + 100% { + opacity: 0; + transform: translate(-50%, -210%) scale(0.95); + } +} + +/* live cursors, positioned in world space so they pan/zoom with the map */ +#cursorLayer { + position: absolute; + inset: 0; + z-index: 5; + pointer-events: none; +} +.cursor { + position: absolute; + transform: translate(-2px, -2px); + transition: + left 90ms linear, + top 90ms linear; +} +.cursor svg { + width: 20px; + height: 20px; + filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.6)); + display: block; +} +.cursor .tag { + position: absolute; + left: 15px; + top: 14px; + white-space: nowrap; + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + padding: 3px 6px; + border-radius: 6px; + color: #120a07; +} + +/* ============ floating HUD ============ */ +.hud { + position: fixed; + z-index: 10; +} +.hud-tl { + left: 16px; + top: 16px; + display: grid; + gap: 10px; + justify-items: start; +} +.hud-tr { + right: 16px; + top: 16px; + display: flex; + align-items: center; + gap: 8px; +} +.hud-bl { + left: 16px; + bottom: 16px; +} +.hud-bottom { + left: 50%; + bottom: 16px; + transform: translateX(-50%); + max-width: calc(100vw - 32px); +} + +.glass { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(20, 11, 7, 0.86); + backdrop-filter: blur(8px); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5); +} + +.title-tag { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 12px; +} +.title-tag .mark { + width: 22px; + height: 22px; + color: var(--rust); +} +.title-tag h1 { + margin: 0; + font-size: 14px; + line-height: 1; + letter-spacing: 0.02em; +} + +.role-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 12px; +} +.role-banner[hidden] { + display: none; +} +.role-banner .swatch { + width: 10px; + height: 10px; + border-radius: 999px; + box-shadow: 0 0 8px currentColor; +} +.role-banner b { + color: var(--text); +} +.role-banner.view-only { + color: var(--yellow); +} + +.chip { + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 8px 12px; + background: rgba(20, 11, 7, 0.86); + backdrop-filter: blur(8px); + color: #e8cbbc; + font: + 800 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.04em; + white-space: nowrap; +} +.chip .dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 8px currentColor; +} +#connChip[hidden] { + display: none; +} +#connChip { + color: var(--yellow); +} + +.icon-btn { + height: 36px; + min-width: 36px; + padding: 0 12px; + display: inline-flex; + align-items: center; + gap: 7px; + border-radius: 999px; +} +.icon-btn .count { + color: var(--rust); +} + +.popover { + position: fixed; + right: 16px; + top: 62px; + z-index: 12; + width: 320px; + max-height: calc(100vh - 190px); + overflow: auto; + padding: 14px; + display: grid; + gap: 14px; +} +.popover.left { + left: 16px; + right: auto; +} +.popover[hidden], +.drawer[hidden] { + display: none; +} +.pop-head { + display: grid; + gap: 3px; +} +.pop-head h2 { + font-size: 15px; +} + +/* share panel */ +.role-grid { + display: grid; + gap: 6px; +} +.role-opt { + display: grid; + gap: 3px; + text-align: left; + padding: 9px 11px; + border: 1px solid var(--line-soft); + border-radius: 9px; + background: #140b07; + color: var(--text); +} +.role-opt.on { + border-color: var(--rust); + background: #20120b; +} +.role-opt b { + font-size: 12.5px; +} +.role-opt small { + color: var(--muted); + font-size: 10.5px; +} +.key-list { + display: grid; + gap: 8px; +} +.key-empty { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} +.key-card { + border: 1px solid var(--line-soft); + border-radius: 10px; + background: #140b07; + padding: 10px; + display: grid; + gap: 8px; +} +.key-card.revoked { + opacity: 0.5; +} +.key-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.key-top b { + font-size: 13px; +} +.key-role { + color: var(--rust); + font: + 800 9px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.key-meta { + color: var(--muted); + font: + 700 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} +.key-actions { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 6px; +} +.key-actions button { + height: 30px; + font-size: 11px; +} +.link-box { + border: 1px solid var(--line); + border-radius: 9px; + padding: 10px; + background: #120a07; + display: grid; + gap: 8px; +} +.link-box[hidden] { + display: none; +} +.link-box .lh { + color: var(--muted); + font: + 600 11px/1.4 Inter, + sans-serif; +} +.link-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 7px; +} +.link-code { + min-width: 0; + font: + 700 10.5px/1.45 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + color: #ffd9c2; + background: #180d08; + border: 1px solid var(--line-soft); + border-radius: 7px; + padding: 8px 9px; + word-break: break-all; + max-height: 56px; + overflow: auto; +} + +/* roster */ +.you-editor { + display: flex; + gap: 8px; + align-items: center; +} +.you-editor #myNameInput { + flex: 1; +} +.you-editor input[type='color'] { + width: 38px; + height: 38px; + padding: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: #120a07; + cursor: pointer; + flex: none; +} +.you-editor input[type='color']::-webkit-color-swatch-wrapper { + padding: 3px; +} +.you-editor input[type='color']::-webkit-color-swatch { + border: none; + border-radius: 5px; +} +.roster { + display: grid; + gap: 7px; +} +.roster-row { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 9px; + border: 1px solid var(--line-soft); + border-radius: 9px; + background: #140b07; +} +.roster-row .swatch { + width: 11px; + height: 11px; + border-radius: 999px; + box-shadow: 0 0 8px currentColor; +} +.roster-row b { + font-size: 12.5px; +} +.roster-row small { + color: var(--muted); + font: + 700 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + margin-left: auto; +} +.roster-empty { + color: var(--muted); + font-size: 12px; +} + +/* toolbar */ +.toolbar { + display: flex; + align-items: stretch; + gap: 10px; + padding: 10px 12px; +} +.tool-group { + display: flex; + align-items: center; + gap: 6px; +} +.tool-group + .tool-group { + border-left: 1px solid var(--line); + padding-left: 10px; +} +.tool { + position: relative; + width: 52px; + padding: 7px 5px 6px; + border-radius: 10px; + border: 1px solid var(--line-soft); + background: #140b07; + color: #d8c3b6; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + cursor: pointer; +} +.tool .swatch { + width: 22px; + height: 22px; + border-radius: 5px; + border: 1px solid rgba(255, 255, 255, 0.12); +} +.tool .glyph { + width: 22px; + height: 22px; + display: grid; + place-items: center; +} +.tool .glyph svg { + width: 20px; + height: 20px; +} +.tool .name { + font: + 800 8.5px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.02em; + color: var(--muted); + white-space: nowrap; +} +.tool.on { + border-color: var(--rust); + background: #23130c; + color: var(--text); + box-shadow: 0 0 0 1px rgba(224, 122, 74, 0.4); +} +.tool.on .name { + color: #f0cdb8; +} +.tool:disabled { + cursor: not-allowed; + opacity: 0.3; +} +.toolbar-empty { + color: var(--muted); + font: + 800 11px/1.3 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + padding: 10px 16px; +} + +/* log drawer */ +.drawer { + position: fixed; + right: 16px; + top: 64px; + bottom: 118px; + z-index: 12; + width: 320px; + padding: 14px; + display: grid; + grid-template-rows: auto 1fr auto; + gap: 12px; +} +.drawer h2 { + font-size: 15px; +} +.feed { + display: grid; + gap: 4px; + overflow: auto; + align-content: start; +} +.feed-empty { + color: var(--muted); + font-size: 12px; +} +.event { + border: 1px solid var(--line-soft); + border-radius: 7px; + padding: 6px 9px; + background: #140b07; + display: grid; + gap: 1px; +} +.event.denied { + background: #1c0b0a; +} +.event.denied b { + color: #ffb3ae; +} +.event b { + font-size: 11.5px; + font-weight: 700; +} +.event small { + color: var(--muted); + font: + 700 9px/1.25 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} +.drawer-foot { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; +} +.drawer-foot button { + height: 32px; + font-size: 11.5px; +} + +/* view controls */ +.view-controls { + display: flex; + align-items: center; + gap: 6px; + padding: 5px; + border-radius: 999px; +} +.view-controls button { + width: 30px; + height: 28px; + padding: 0; + border-radius: 999px; +} +.view-controls button.fit { + width: auto; + padding: 0 11px; +} +.zoom-label { + min-width: 42px; + text-align: center; + color: #f0d3c1; + font: + 800 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +.toast { + position: fixed; + left: 50%; + top: 16px; + transform: translateX(-50%); + z-index: 40; + min-width: min(440px, calc(100vw - 28px)); + border: 1px solid var(--line); + border-radius: 9px; + background: rgba(20, 11, 7, 0.96); + color: #ffe6d6; + padding: 11px 14px; + display: none; + align-items: center; + justify-content: center; + font: + 800 12px/1.2 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + text-align: center; + backdrop-filter: blur(8px); +} +.toast.show { + display: flex; +} +.toast.error { + border-color: #823837; + background: rgba(36, 16, 16, 0.97); + color: #ffb8b3; +} + +/* access-removed overlay (holder) */ +.overlay { + position: fixed; + inset: 0; + z-index: 60; + display: none; + align-items: center; + justify-content: center; + background: rgba(8, 4, 3, 0.86); + backdrop-filter: blur(6px); +} +.overlay.show { + display: flex; +} +.overlay .card { + max-width: 360px; + text-align: center; + padding: 26px; + display: grid; + gap: 10px; +} +.overlay .card h2 { + font-size: 18px; +} + +@media (max-width: 720px) { + .popover { + width: calc(100vw - 32px); + } + .drawer { + width: calc(100vw - 32px); + } +} diff --git a/spacetime-api-keys-ts/example/scripts/test-model.ts b/spacetime-api-keys-ts/example/scripts/test-model.ts new file mode 100644 index 00000000000..d52ab7ce82a --- /dev/null +++ b/spacetime-api-keys-ts/example/scripts/test-model.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { + SCOPE_BUILD, + SCOPE_PLANT, + SCOPE_TERRAFORM, + clamp, + colorFor, + paintLinePoints, + parsePresencePayload, + parseScopes, + permissionsFor, + roleLabel, + safePresenceColor, + safePresenceCoordinate, + safePresenceText, + stepDirection, + toolAllowedFor, + worldPixelSize, +} from '../src/model.ts'; + +assert.equal(safePresenceColor('#59C6D6'), '#59c6d6'); +assert.equal(safePresenceColor('" onload="alert(1)', '#59c6d6'), '#59c6d6'); +assert.equal(safePresenceColor('red;position:fixed'), ''); + +assert.equal(safePresenceText(' Settler ', 'Someone', 64), 'Settler'); +assert.equal(safePresenceText('', 'Someone', 64), 'Someone'); +assert.equal(safePresenceText('abcdef', 'Someone', 4), 'abcd'); + +assert.equal(safePresenceCoordinate(4.5, -1, 13), 4.5); +assert.equal(safePresenceCoordinate(Infinity, -1, 13), 0); +assert.equal(safePresenceCoordinate(99, -1, 13), 13); + +assert.deepEqual(parseScopes('["colony:view", 7]'), ['colony:view', '7']); +assert.deepEqual(parseScopes('{"scope":"colony:view"}'), []); +assert.deepEqual(parseScopes('invalid'), []); +assert.equal(roleLabel([]), 'Viewer'); +assert.equal(roleLabel([SCOPE_TERRAFORM]), 'Terraformer'); +assert.equal(roleLabel([SCOPE_BUILD]), 'Builder'); +assert.equal(roleLabel([SCOPE_PLANT]), 'Planter'); +assert.equal( + roleLabel([SCOPE_TERRAFORM, SCOPE_BUILD, SCOPE_PLANT]), + 'Collaborator' +); + +assert.deepEqual(permissionsFor('holder', [SCOPE_BUILD]), { + terraform: false, + build: true, + plant: false, +}); +assert.deepEqual(permissionsFor('owner', []), { + terraform: true, + build: true, + plant: true, +}); +assert.equal( + toolAllowedFor('holder', [SCOPE_BUILD], { + id: 'dome', + group: 'structure', + label: 'Dome', + }), + true +); +assert.equal( + toolAllowedFor('holder', [SCOPE_BUILD], { + id: 'tree', + group: 'nature', + label: 'Tree', + }), + false +); + +assert.match(colorFor('subject'), /^#[0-9a-f]{6}$/); +assert.equal(colorFor('subject'), colorFor('subject')); +assert.deepEqual(parsePresencePayload(), { + name: 'Someone', + role: '', + color: '', + cx: 0, + cy: 0, + onGrid: false, +}); +assert.deepEqual( + parsePresencePayload( + JSON.stringify({ + name: ' Settler ', + role: 'Builder', + color: '#59C6D6', + cx: 99, + cy: 4.5, + onGrid: true, + }) + ), + { + name: 'Settler', + role: 'Builder', + color: '#59c6d6', + cx: 13, + cy: 4.5, + onGrid: true, + } +); + +assert.equal(clamp(9, 0, 5), 5); +assert.deepEqual(worldPixelSize(2, 3), { width: 208, height: 286 }); +assert.equal(stepDirection(1, 1, 1, 0), 'n'); +assert.equal(stepDirection(1, 1, 2, 1), 'e'); +assert.equal(stepDirection(1, 1, 1, 2), 's'); +assert.equal(stepDirection(1, 1, 0, 1), 'w'); +assert.equal(stepDirection(1, 1, 3, 3), ''); +assert.deepEqual(paintLinePoints({ x: 0, y: 0 }, { x: 2, y: 2 }), [ + { x: 1, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 1 }, + { x: 2, y: 2 }, +]); + +console.log('api-keys model tests passed'); diff --git a/spacetime-api-keys-ts/example/scripts/test-share-key.ts b/spacetime-api-keys-ts/example/scripts/test-share-key.ts new file mode 100644 index 00000000000..240a9e440e6 --- /dev/null +++ b/spacetime-api-keys-ts/example/scripts/test-share-key.ts @@ -0,0 +1,15 @@ +import * as assert from 'node:assert/strict'; +import { parseShareKey, shareKeyFromHash } from '../src/share-key'; + +assert.equal(parseShareKey('raw-key-value'), 'raw-key-value'); +assert.equal( + parseShareKey('http://127.0.0.1:8798/#key=shared%20key'), + 'shared key' +); +assert.equal(parseShareKey('http://127.0.0.1:8798/?key=query-secret'), null); +assert.equal(parseShareKey('http://127.0.0.1:8798/'), null); +assert.equal(parseShareKey(' '), null); +assert.equal(shareKeyFromHash('#key=abc123'), 'abc123'); +assert.equal(shareKeyFromHash('?key=query-secret'), null); + +console.log('API key share-link tests passed'); diff --git a/spacetime-api-keys-ts/example/server.ts b/spacetime-api-keys-ts/example/server.ts new file mode 100644 index 00000000000..1c5c84c62c5 --- /dev/null +++ b/spacetime-api-keys-ts/example/server.ts @@ -0,0 +1,99 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) process.env[key] = value; + } +} + +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8798', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-api-keys-example'; + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + spacetimeUri: STDB_URI, + databaseName: DB_NAME, + }); +}); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, databaseName: DB_NAME }); +}); + +app.use('/api/colony', async (req: Request, res: Response) => { + const fullPath = `/api/colony${req.url}`; + const qIdx = fullPath.indexOf('?'); + const subpath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); + const query = qIdx < 0 ? '' : fullPath.slice(qIdx); + const upstreamUrl = `${STDB_HTTP}/v1/database/${DB_NAME}/route${subpath}${query}`; + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') headers[key] = value; + else if (Array.isArray(value)) headers[key] = value.join(', '); + } + delete headers.host; + delete headers['content-length']; + + const init: RequestInit = { + method: req.method, + headers, + redirect: 'manual', + }; + if (req.method !== 'GET' && req.method !== 'HEAD') { + headers['content-type'] = 'application/json'; + init.body = JSON.stringify(req.body ?? {}); + } + + try { + const upstream = await fetch(upstreamUrl, init); + res.status(upstream.status); + upstream.headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if ( + lower === 'transfer-encoding' || + lower === 'content-encoding' || + lower === 'content-length' + ) + return; + res.setHeader(key, value); + }); + res.send(Buffer.from(await upstream.arrayBuffer())); + } catch (err) { + res.status(502).json({ + ok: false, + error: 'stdb_route_unreachable', + detail: err instanceof Error ? err.message : String(err), + }); + } +}); + +app.use(express.static(path.join(__dirname, 'public'))); + +app.listen(PORT, HOST, () => { + console.log(`Colony running at http://${HOST}:${PORT}`); + console.log(` STDB ws -> ${STDB_URI}`); + console.log(` STDB http-> ${STDB_HTTP}`); + console.log(` Database -> ${DB_NAME}`); +}); diff --git a/spacetime-api-keys-ts/example/spacetimedb/.npmrc b/spacetime-api-keys-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-api-keys-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-api-keys-ts/example/spacetimedb/package.json b/spacetime-api-keys-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..41bf800908c --- /dev/null +++ b/spacetime-api-keys-ts/example/spacetimedb/package.json @@ -0,0 +1,21 @@ +{ + "name": "spacetime-api-keys-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-api-keys-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-api-keys-example" + }, + "dependencies": { + "@spacetimedb/api-keys": "workspace:*", + "@spacetimedb/grid": "workspace:*", + "@spacetimedb/presence": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-api-keys-ts/example/spacetimedb/src/http.ts b/spacetime-api-keys-ts/example/spacetimedb/src/http.ts new file mode 100644 index 00000000000..2bc168fd2d3 --- /dev/null +++ b/spacetime-api-keys-ts/example/spacetimedb/src/http.ts @@ -0,0 +1,61 @@ +import { SenderError, SyncResponse, type Request } from 'spacetimedb/server'; + +export function jsonResponse(body: unknown, status = 200): SyncResponse { + return new SyncResponse( + JSON.stringify(body, (_key, value) => + typeof value === 'bigint' ? value.toString() : value + ), + { + status, + headers: { 'content-type': 'application/json' }, + } + ); +} + +export function errorResponse( + error: string, + status: number, + extra: Record = {} +): SyncResponse { + return jsonResponse({ ok: false, error, ...extra }, status); +} + +export function readBearer(req: Request): string | undefined { + const header = req.headers.get('authorization') ?? ''; + if (!header.toLowerCase().startsWith('bearer ')) return undefined; + const token = header.slice(7).trim(); + return token.length > 0 ? token : undefined; +} + +export function safeJson(req: Request): unknown { + try { + return req.json(); + } catch { + throw new SenderError('world.invalid_json'); + } +} + +export function asObject(value: unknown): Record { + if (value === null || Array.isArray(value) || typeof value !== 'object') { + throw new SenderError('world.invalid_json'); + } + return value as Record; +} + +export function asI32(value: unknown, field: string): number { + if (!Number.isInteger(value)) throw new SenderError(`world.invalid_${field}`); + return value as number; +} + +export function asOptionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +export function asString(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new SenderError(`world.invalid_${field}`); + } + const out = value.trim(); + if (!out) throw new SenderError(`world.invalid_${field}`); + return out; +} diff --git a/spacetime-api-keys-ts/example/spacetimedb/src/index.ts b/spacetime-api-keys-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..53634d58288 --- /dev/null +++ b/spacetime-api-keys-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,950 @@ +import { + Router, + Range, + SenderError, + t, + type Infer, + type Request, + type SyncResponse, +} from 'spacetimedb/server'; +import { ScheduleAt } from 'spacetimedb'; +import * as apiKeys from '@spacetimedb/api-keys/submodule'; +import * as gridSubmodule from '@spacetimedb/grid/submodule'; +import { + GRID_KIND_SQUARE, + GRID_MODE_OWNER, + GRID_ORIENTATION_FLAT, +} from '@spacetimedb/grid/submodule'; +import { + installPresenceConfig, + removePresence, + runPresenceSweep, + upsertPresence, +} from '@spacetimedb/presence'; +import { + accessKeySummary, + colonySweepTick, + spacetimedb, + type HttpCtx, + type ReadCtx, + type Tx, +} from './schema'; +import { + asI32, + asObject, + asOptionalString, + asString, + errorResponse, + jsonResponse, + readBearer, + safeJson, +} from './http'; + +const COLONY_WIDTH = 12; +const COLONY_HEIGHT = 8; +const EVENT_RETAIN = 120; +const PRESENCE_TTL_SECONDS = 35; +const PRESENCE_SCOPE_MAX = 128; +const PRESENCE_NAME_MAX = 64; +const PRESENCE_ROLE_MAX = 32; +const PRESENCE_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; +const PRESENCE_ROLES = new Set([ + 'Owner', + 'Collaborator', + 'Terraformer', + 'Builder', + 'Planter', + 'Viewer', + 'Editor', +]); +const SWEEP_INTERVAL_MICROS = 10n * 1_000_000n; + +// Scopes a share key can carry. view is read; the three edit scopes are the +// granular powers a share link can grant. +const SCOPE_VIEW = 'colony:view'; +const SCOPE_TERRAFORM = 'colony:terraform'; +const SCOPE_BUILD = 'colony:build'; +const SCOPE_PLANT = 'colony:plant'; + +export default spacetimedb; + +type ApiKeyCreateResult = Infer; + +// Surface terrain. regolith is the default (no row); the rest are stored. +const DEFAULT_TERRAIN = 'regolith'; +const terrainCost: Record = { + rock: 0, + grass: 1, + water: 1, + soil: 1, +}; +const validTerrain = new Set([DEFAULT_TERRAIN, ...Object.keys(terrainCost)]); + +// Placeable objects. Structures are gated by colony:build, nature by +// colony:plant, so a build-only and a plant-only key are visibly different. +const STRUCTURE_KINDS = new Set(['dome', 'pod', 'solar', 'road']); +const NATURE_KINDS = new Set(['tree', 'shrub', 'boulder']); + +export const init = spacetimedb.init(ctx => { + apiKeys.installApiKeys(ctx.as.apiKeys); + gridSubmodule.installGrid(ctx.as.grid); + installPresenceConfig(ctx, { + defaultTtlSeconds: PRESENCE_TTL_SECONDS, + sweepBatch: 500, + }); + ctx.db.colonySweepTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval(SWEEP_INTERVAL_MICROS), + }); +}); + +function senderSubject(ctx: { sender: unknown }): string { + const sender = ctx.sender as { toHexString?: () => string }; + return typeof sender?.toHexString === 'function' + ? sender.toHexString() + : String(ctx.sender); +} + +function assertInBounds(x: number, y: number): void { + if (x < 0 || y < 0 || x >= COLONY_WIDTH || y >= COLONY_HEIGHT) { + throw new SenderError(`world.out_of_bounds:${x},${y}`); + } +} + +function ensureWorldTx(tx: Tx, ownerSubject: string) { + const existing = tx.db.world.ownerSubject.find(ownerSubject); + if (existing) return existing; + + const grid = tx.db.grid.grid.insert({ + id: 0n, + ownerUserId: ownerSubject, + name: 'Colony Grid', + kind: GRID_KIND_SQUARE, + orientation: GRID_ORIENTATION_FLAT, + width: COLONY_WIDTH, + height: COLONY_HEIGHT, + defaultCost: 1, + connectivity: 4, + mode: GRID_MODE_OWNER, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + + const seedCells = [ + [2, 1, 'grass'], + [3, 1, 'grass'], + [2, 2, 'grass'], + [8, 2, 'water'], + [9, 2, 'water'], + [10, 5, 'rock'], + [10, 6, 'rock'], + [3, 6, 'rock'], + ] as const; + for (const [x, y, terrain] of seedCells) { + tx.db.grid.cellState.insert({ + id: 0n, + gridId: grid.id, + x, + y, + cost: terrainCost[terrain], + terrain, + }); + } + + tx.db.grid.gridEntity.insert({ + id: 0n, + gridId: grid.id, + ownerUserId: ownerSubject, + x: 5, + y: 3, + kind: 'dome', + blocksMovement: false, + label: 'Landing Dome', + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + + const row = tx.db.world.insert({ + ownerSubject, + gridId: grid.id, + name: 'Colony', + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + insertEvent( + tx, + ownerSubject, + '', + 'colony.created', + true, + 'created', + 'Colony founded' + ); + return row; +} + +function deleteWorldTx(tx: Tx, ownerSubject: string): void { + const existing = tx.db.world.ownerSubject.find(ownerSubject); + if (!existing) return; + for (const row of [...tx.db.grid.cellState.gridId.filter(existing.gridId)]) + tx.db.grid.cellState.delete(row); + for (const row of [...tx.db.grid.gridEntity.gridId.filter(existing.gridId)]) + tx.db.grid.gridEntity.delete(row); + for (const row of [...tx.db.grid.entityPath.gridId.filter(existing.gridId)]) + tx.db.grid.entityPath.delete(row); + const grid = tx.db.grid.grid.id.find(existing.gridId); + if (grid) tx.db.grid.grid.delete(grid); + tx.db.world.delete(existing); +} + +function insertEvent( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + action: string, + allowed: boolean, + reason: string, + message: string +): void { + tx.db.worldEvent.insert({ + eventId: 0n, + ownerSubject, + keyPrefix, + action, + allowed, + reason, + message, + createdAt: tx.timestamp, + }); + + const rows = [...tx.db.worldEvent.ownerSubject.filter(ownerSubject)]; + if (rows.length <= EVENT_RETAIN) return; + rows.sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch as bigint; + const bv = b.createdAt.microsSinceUnixEpoch as bigint; + return av < bv ? -1 : av > bv ? 1 : 0; + }); + for (const row of rows.slice(0, rows.length - EVENT_RETAIN)) + tx.db.worldEvent.delete(row); +} + +function findCell(tx: Tx, gridId: bigint, x: number, y: number) { + for (const row of tx.db.grid.cellState.gridId.filter(gridId)) { + if (row.x === x && row.y === y) return row; + } + return undefined; +} + +function findEntityAt(tx: Tx, gridId: bigint, x: number, y: number) { + for (const row of tx.db.grid.gridEntity.gridId.filter(gridId)) { + if (row.x === x && row.y === y) return row; + } + return undefined; +} + +function upsertTerrain( + tx: Tx, + gridId: bigint, + x: number, + y: number, + terrain: string +): void { + assertInBounds(x, y); + if (!validTerrain.has(terrain)) + throw new SenderError(`world.invalid_terrain:${terrain}`); + const existing = findCell(tx, gridId, x, y); + // Regolith is the bare surface, so painting it clears the cell row. + if (terrain === DEFAULT_TERRAIN) { + if (existing) tx.db.grid.cellState.delete(existing); + return; + } + const cost = terrainCost[terrain]; + if (existing) tx.db.grid.cellState.id.update({ ...existing, cost, terrain }); + else tx.db.grid.cellState.insert({ id: 0n, gridId, x, y, cost, terrain }); +} + +// Core mutations shared by the owner reducers and the scoped +// HTTP routes. keyPrefix is '' for native owner edits, or the key prefix +// for share-key edits (for the activity feed). + +function doTerraform( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + x: number, + y: number, + terrain: string +) { + const w = ensureWorldTx(tx, ownerSubject); + upsertTerrain(tx, w.gridId, x, y, terrain); + insertEvent( + tx, + ownerSubject, + keyPrefix, + 'terraform', + true, + 'allowed', + `Terraformed ${terrain} at ${x},${y}` + ); + return { x, y, terrain }; +} + +function mergeRoadMask(a: string, b: string): string { + const out: string[] = []; + for (const ch of a + b) + if ('nesw'.includes(ch) && !out.includes(ch)) out.push(ch); + return out.join(''); +} + +function doBuild( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + x: number, + y: number, + kind: string, + label?: string +) { + assertInBounds(x, y); + if (!STRUCTURE_KINDS.has(kind)) + throw new SenderError(`world.invalid_kind:${kind}`); + const w = ensureWorldTx(tx, ownerSubject); + const occupant = findEntityAt(tx, w.gridId, x, y); + if (occupant) { + // Dragging a road into an existing road merges the connection into it (so + // adjacent existing roads link when you drag between them); anything else + // on the cell blocks the build. + if (kind === 'road' && occupant.kind === 'road') { + const merged = mergeRoadMask(occupant.label ?? '', label ?? ''); + if (merged !== (occupant.label ?? '')) { + tx.db.grid.gridEntity.id.update({ + ...occupant, + label: merged, + updatedAt: tx.timestamp, + }); + } + return { entityId: occupant.id, x, y, kind }; + } + throw new SenderError(`world.cell_occupied:${x},${y}`); + } + // Roads carry a connection mask (which sides link) in label, computed as you + // draw; other structures store their kind. + const entity = tx.db.grid.gridEntity.insert({ + id: 0n, + gridId: w.gridId, + ownerUserId: ownerSubject, + x, + y, + kind, + blocksMovement: false, + label: label ?? kind, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + insertEvent( + tx, + ownerSubject, + keyPrefix, + 'build', + true, + 'allowed', + `Built ${kind} at ${x},${y}` + ); + return { entityId: entity.id, x, y, kind }; +} + +function doUnbuild( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + x: number, + y: number +) { + assertInBounds(x, y); + const w = ensureWorldTx(tx, ownerSubject); + const entity = findEntityAt(tx, w.gridId, x, y); + if (!entity || !STRUCTURE_KINDS.has(entity.kind)) + throw new SenderError(`world.nothing_to_remove:${x},${y}`); + tx.db.grid.gridEntity.delete(entity); + insertEvent( + tx, + ownerSubject, + keyPrefix, + 'unbuild', + true, + 'allowed', + `Removed ${entity.kind} at ${x},${y}` + ); + return { x, y }; +} + +function doPlant( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + x: number, + y: number, + kind: string +) { + assertInBounds(x, y); + if (!NATURE_KINDS.has(kind)) + throw new SenderError(`world.invalid_kind:${kind}`); + const w = ensureWorldTx(tx, ownerSubject); + if (findEntityAt(tx, w.gridId, x, y)) + throw new SenderError(`world.cell_occupied:${x},${y}`); + const entity = tx.db.grid.gridEntity.insert({ + id: 0n, + gridId: w.gridId, + ownerUserId: ownerSubject, + x, + y, + kind, + blocksMovement: false, + label: kind, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + insertEvent( + tx, + ownerSubject, + keyPrefix, + 'plant', + true, + 'allowed', + `Planted ${kind} at ${x},${y}` + ); + return { entityId: entity.id, x, y, kind }; +} + +function doClear( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + x: number, + y: number +) { + assertInBounds(x, y); + const w = ensureWorldTx(tx, ownerSubject); + const entity = findEntityAt(tx, w.gridId, x, y); + if (!entity || !NATURE_KINDS.has(entity.kind)) + throw new SenderError(`world.nothing_to_clear:${x},${y}`); + tx.db.grid.gridEntity.delete(entity); + insertEvent( + tx, + ownerSubject, + keyPrefix, + 'clear', + true, + 'allowed', + `Cleared ${entity.kind} at ${x},${y}` + ); + return { x, y }; +} + +function readWorldSnapshot(tx: Tx, ownerSubject: string) { + const w = ensureWorldTx(tx, ownerSubject); + return { + world: w, + grid: tx.db.grid.grid.id.find(w.gridId), + cells: [...tx.db.grid.cellState.gridId.filter(w.gridId)], + entities: [...tx.db.grid.gridEntity.gridId.filter(w.gridId)], + }; +} + +function mirrorAccessKey(tx: Tx, row: ApiKeyCreateResult): void { + const summary = { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + name: row.name, + scopesJson: row.scopesJson, + metadataJson: row.metadataJson, + status: row.status, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + lastUsedAt: undefined, + revokedAt: undefined, + }; + const existing = tx.db.accessKeySummary.keyId.find(summary.keyId); + if (existing) tx.db.accessKeySummary.keyId.update(summary); + else tx.db.accessKeySummary.insert(summary); +} + +function verifyRequest( + tx: Tx, + req: Request, + requiredScope: string, + action: string +) { + const key = readBearer(req); + if (!key) + return { allowed: false, reason: 'missing_bearer', status: 401 } as const; + const result = apiKeys.verifyApiKey(tx.as.apiKeys, { + key, + requiredScope, + action, + }); + if (!result.allowed) { + if (result.ownerSubject) { + insertEvent( + tx, + result.ownerSubject, + result.prefix ?? '', + action, + false, + result.reason, + `${action} denied: ${result.reason}` + ); + } + return { + allowed: false, + reason: result.reason, + status: result.reason === 'scope_denied' ? 403 : 401, + } as const; + } + if (!result.ownerSubject) { + return { allowed: false, reason: 'missing_owner', status: 401 } as const; + } + return { + allowed: true, + keyPrefix: result.prefix ?? '', + ownerSubject: result.ownerSubject, + scopesJson: result.scopesJson ?? '[]', + } as const; +} + +function handleAuthedWorldAction( + ctx: HttpCtx, + req: Request, + requiredScope: string, + action: string, + fn: ( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + scopesJson: string + ) => unknown +): SyncResponse { + try { + const out = ctx.withTx((tx: Tx) => { + const auth = verifyRequest(tx, req, requiredScope, action); + if (!auth.allowed) return { error: auth.reason, status: auth.status }; + return { + ok: true, + value: fn( + tx, + auth.ownerSubject, + auth.keyPrefix, + auth.scopesJson ?? '[]' + ), + }; + }); + if (out?.error) return errorResponse(out.error, out.status); + return jsonResponse({ ok: true, result: out?.value ?? null }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return errorResponse( + message, + message.startsWith('world.invalid') ? 400 : 500 + ); + } +} + +export const ensure_world = spacetimedb.procedure( + {}, + t.object('EnsureWorldResult', { ownerSubject: t.string(), gridId: t.u64() }), + ctx => + ctx.withTx(tx => { + const w = ensureWorldTx(tx, senderSubject(ctx)); + return { ownerSubject: w.ownerSubject, gridId: w.gridId }; + }) +); + +export const reset_world = spacetimedb.reducer({}, ctx => { + const ownerSubject = senderSubject(ctx); + deleteWorldTx(ctx, ownerSubject); + ensureWorldTx(ctx, ownerSubject); + insertEvent( + ctx, + ownerSubject, + '', + 'colony.reset', + true, + 'reset', + 'Colony reset' + ); +}); + +export const clear_world_events = spacetimedb.reducer({}, ctx => { + const ownerSubject = senderSubject(ctx); + for (const row of [...ctx.db.worldEvent.ownerSubject.filter(ownerSubject)]) { + ctx.db.worldEvent.delete(row); + } +}); + +export const terraform = spacetimedb.reducer( + { x: t.i32(), y: t.i32(), terrain: t.string() }, + (ctx, args) => { + doTerraform(ctx, senderSubject(ctx), '', args.x, args.y, args.terrain); + } +); + +export const build = spacetimedb.reducer( + { x: t.i32(), y: t.i32(), kind: t.string(), label: t.option(t.string()) }, + (ctx, args) => { + doBuild(ctx, senderSubject(ctx), '', args.x, args.y, args.kind, args.label); + } +); + +export const unbuild = spacetimedb.reducer( + { x: t.i32(), y: t.i32() }, + (ctx, args) => { + doUnbuild(ctx, senderSubject(ctx), '', args.x, args.y); + } +); + +export const plant = spacetimedb.reducer( + { x: t.i32(), y: t.i32(), kind: t.option(t.string()) }, + (ctx, args) => { + doPlant(ctx, senderSubject(ctx), '', args.x, args.y, args.kind ?? 'tree'); + } +); + +export const clear = spacetimedb.reducer( + { x: t.i32(), y: t.i32() }, + (ctx, args) => { + doClear(ctx, senderSubject(ctx), '', args.x, args.y); + } +); + +// Presence includes the live roster and mouse cursors. Subject is derived from +// the caller identity to prevent spoofing. Scope is the colony id, so presence is +// per-colony. cx/cy are fractional tile coordinates. + +export const presence_heartbeat = spacetimedb.reducer( + { + scope: t.string(), + name: t.string(), + role: t.string(), + color: t.string(), + cx: t.f64(), + cy: t.f64(), + onGrid: t.bool(), + }, + (ctx, args) => { + const scope = args.scope.trim(); + const name = args.name.trim(); + const role = args.role.trim(); + if (!scope || scope.length > PRESENCE_SCOPE_MAX) { + throw new SenderError('presence.invalid_scope'); + } + if (!name || name.length > PRESENCE_NAME_MAX) { + throw new SenderError('presence.invalid_name'); + } + if (!role || role.length > PRESENCE_ROLE_MAX || !PRESENCE_ROLES.has(role)) { + throw new SenderError('presence.invalid_role'); + } + if (!PRESENCE_COLOR_PATTERN.test(args.color)) { + throw new SenderError('presence.invalid_color'); + } + if ( + !Number.isFinite(args.cx) || + !Number.isFinite(args.cy) || + args.cx < -1 || + args.cx > COLONY_WIDTH + 1 || + args.cy < -1 || + args.cy > COLONY_HEIGHT + 1 + ) { + throw new SenderError('presence.invalid_cursor'); + } + upsertPresence(ctx, { + scope, + subject: senderSubject(ctx), + status: 'online', + activity: role, + payloadJson: JSON.stringify({ + name, + role, + color: args.color.toLowerCase(), + cx: args.cx, + cy: args.cy, + onGrid: args.onGrid, + }), + ttlSeconds: PRESENCE_TTL_SECONDS, + }); + } +); + +export const presence_leave = spacetimedb.reducer( + { scope: t.string() }, + (ctx, args) => { + removePresence(ctx, args.scope.trim(), senderSubject(ctx)); + } +); + +export const colony_sweep = spacetimedb.reducer( + { onSchedule: colonySweepTick }, + { arg: colonySweepTick.rowType }, + ctx => { + runPresenceSweep( + ctx, + ctx.db.presenceEntry.expiresAt.filter( + new Range(undefined, { tag: 'included', value: ctx.timestamp }) + ) + ); + } +); + +// Reads. world, world_event, and presence_entry are public tables the +// client subscribes to with a WHERE on the colony id. The grid submodule's +// tables are reached through these public projection views, filtered by +// grid_id. A holder learns the colony id (owner subject) from verifyApiKey, +// then the grid id from the world row. + +function allGridIds(ctx: ReadCtx): Set { + const ids = new Set(); + for (const w of ctx.db.world.iter()) ids.add(w.gridId); + return ids; +} + +export const colonyGrid = spacetimedb.view( + { name: 'colony_grid', public: true }, + t.array(gridSubmodule.grid.rowType), + ctx => { + const out = []; + for (const id of allGridIds(ctx)) { + const g = ctx.db.grid.grid.id.find(id); + if (g) out.push(g); + } + return out; + } +); + +export const colonyCells = spacetimedb.view( + { name: 'colony_cells', public: true }, + t.array(gridSubmodule.cellState.rowType), + ctx => { + const out = []; + for (const id of allGridIds(ctx)) { + for (const c of ctx.db.grid.cellState.gridId.filter(id)) out.push(c); + } + return out; + } +); + +export const colonyEntities = spacetimedb.view( + { name: 'colony_entities', public: true }, + t.array(gridSubmodule.gridEntity.rowType), + ctx => { + const out = []; + for (const id of allGridIds(ctx)) { + for (const e of ctx.db.grid.gridEntity.gridId.filter(id)) out.push(e); + } + return out; + } +); + +export const myAccessKeys = spacetimedb.view( + { name: 'my_access_keys', public: true }, + t.array(accessKeySummary.rowType), + ctx => { + const subject = senderSubject(ctx); + return [...ctx.db.accessKeySummary.ownerSubject.filter(subject)]; + } +); + +export const create_access_key = spacetimedb.procedure( + { + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + expiresInSeconds: t.option(t.u32()), + keyPrefix: t.option(t.string()), + }, + apiKeys.apiKeyCreateResult, + (ctx, args) => + ctx.withTx(tx => { + const result = apiKeys.createApiKey(tx.as.apiKeys, { + ownerSubject: senderSubject(ctx), + name: args.name, + scopesJson: args.scopesJson, + metadataJson: args.metadataJson, + expiresInSeconds: args.expiresInSeconds, + keyPrefix: args.keyPrefix, + }); + mirrorAccessKey(tx, result); + return result; + }) +); + +export const rotate_access_key = spacetimedb.procedure( + { + keyId: t.string(), + expiresInSeconds: t.option(t.u32()), + keyPrefix: t.option(t.string()), + }, + apiKeys.apiKeyCreateResult, + (ctx, args) => + ctx.withTx(tx => { + const result = apiKeys.rotateApiKey(tx.as.apiKeys, { + keyId: args.keyId, + ownerSubject: senderSubject(ctx), + expiresInSeconds: args.expiresInSeconds, + keyPrefix: args.keyPrefix, + }); + mirrorAccessKey(tx, result); + return result; + }) +); + +export const revoke_access_key = spacetimedb.reducer( + { keyId: t.string() }, + (ctx, args) => { + const ownerSubject = senderSubject(ctx); + apiKeys.revokeApiKey(ctx.as.apiKeys, { keyId: args.keyId, ownerSubject }); + const row = ctx.db.accessKeySummary.keyId.find(args.keyId); + if (!row || row.ownerSubject !== ownerSubject) return; + ctx.db.accessKeySummary.keyId.update({ + ...row, + status: apiKeys.ApiKeyStatus.Revoked, + revokedAt: ctx.timestamp, + }); + } +); + +// Scoped HTTP routes. A share-key holder calls these with the key as a +// bearer token; verifyApiKey checks the scope and resolves the colony owner. + +export const colonySnapshot = spacetimedb.httpHandler((ctx, req) => + handleAuthedWorldAction( + ctx, + req, + SCOPE_VIEW, + 'snapshot', + (tx, ownerSubject, keyPrefix, scopesJson) => { + const snapshot = readWorldSnapshot(tx, ownerSubject); + insertEvent( + tx, + ownerSubject, + keyPrefix, + 'snapshot', + true, + 'allowed', + 'Snapshot read' + ); + // The holder learns which world it is (owner subject + grid id) and what + // this key can do (scopes) in one call, so it can subscribe and enable + // only the allowed tools. + return { ...snapshot, ownerSubject, scopesJson }; + } + ) +); + +export const colonyTerraform = spacetimedb.httpHandler((ctx, req) => + handleAuthedWorldAction( + ctx, + req, + SCOPE_TERRAFORM, + 'terraform', + (tx, ownerSubject, keyPrefix) => { + const body = asObject(safeJson(req)); + return doTerraform( + tx, + ownerSubject, + keyPrefix, + asI32(body.x, 'x'), + asI32(body.y, 'y'), + asString(body.terrain, 'terrain') + ); + } + ) +); + +export const colonyBuild = spacetimedb.httpHandler((ctx, req) => + handleAuthedWorldAction( + ctx, + req, + SCOPE_BUILD, + 'build', + (tx, ownerSubject, keyPrefix) => { + const body = asObject(safeJson(req)); + return doBuild( + tx, + ownerSubject, + keyPrefix, + asI32(body.x, 'x'), + asI32(body.y, 'y'), + asString(body.kind, 'kind'), + asOptionalString(body.label) + ); + } + ) +); + +export const colonyUnbuild = spacetimedb.httpHandler((ctx, req) => + handleAuthedWorldAction( + ctx, + req, + SCOPE_BUILD, + 'unbuild', + (tx, ownerSubject, keyPrefix) => { + const body = asObject(safeJson(req)); + return doUnbuild( + tx, + ownerSubject, + keyPrefix, + asI32(body.x, 'x'), + asI32(body.y, 'y') + ); + } + ) +); + +export const colonyPlant = spacetimedb.httpHandler((ctx, req) => + handleAuthedWorldAction( + ctx, + req, + SCOPE_PLANT, + 'plant', + (tx, ownerSubject, keyPrefix) => { + const body = asObject(safeJson(req)); + return doPlant( + tx, + ownerSubject, + keyPrefix, + asI32(body.x, 'x'), + asI32(body.y, 'y'), + asOptionalString(body.kind) ?? 'tree' + ); + } + ) +); + +export const colonyClear = spacetimedb.httpHandler((ctx, req) => + handleAuthedWorldAction( + ctx, + req, + SCOPE_PLANT, + 'clear', + (tx, ownerSubject, keyPrefix) => { + const body = asObject(safeJson(req)); + return doClear( + tx, + ownerSubject, + keyPrefix, + asI32(body.x, 'x'), + asI32(body.y, 'y') + ); + } + ) +); + +export const router = spacetimedb.httpRouter( + new Router() + .get('/api/colony/snapshot', colonySnapshot) + .post('/api/colony/terraform', colonyTerraform) + .post('/api/colony/build', colonyBuild) + .post('/api/colony/unbuild', colonyUnbuild) + .post('/api/colony/plant', colonyPlant) + .post('/api/colony/clear', colonyClear) +); diff --git a/spacetime-api-keys-ts/example/spacetimedb/src/schema.ts b/spacetime-api-keys-ts/example/spacetimedb/src/schema.ts new file mode 100644 index 00000000000..7e838eba697 --- /dev/null +++ b/spacetime-api-keys-ts/example/spacetimedb/src/schema.ts @@ -0,0 +1,105 @@ +import * as apiKeys from '@spacetimedb/api-keys/submodule'; +import * as grid from '@spacetimedb/grid/submodule'; +import { + schema, + table, + t, + type HandlerContext, + type InferSchema, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; + +export const world = table( + { name: 'world', public: true }, + { + ownerSubject: t.string().primaryKey(), + gridId: t.u64().index(), + name: t.string(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +export const worldEvent = table( + { name: 'world_event', public: true }, + { + eventId: t.u64().primaryKey().autoInc(), + ownerSubject: t.string().index(), + keyPrefix: t.string(), + action: t.string().index(), + allowed: t.bool().index(), + reason: t.string(), + message: t.string(), + createdAt: t.timestamp().index(), + } +); + +export const accessKeySummary = table( + { name: 'access_key_summary', public: false }, + { + keyId: t.string().primaryKey(), + prefix: t.string(), + ownerSubject: t.string().index(), + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + status: apiKeys.apiKeyStatus.index(), + createdAt: t.timestamp().index(), + expiresAt: t.option(t.timestamp()), + lastUsedAt: t.option(t.timestamp()), + revokedAt: t.option(t.timestamp()), + } +); + +// The public presence table supplies the colony roster and cursors. +export const presenceEntry = table( + { name: 'presence_entry', public: true }, + { + key: t.string().primaryKey(), + scope: t.string().index(), + subject: t.string().index(), + status: t.string().index(), + activity: t.option(t.string()), + payloadJson: t.option(t.string()), + joinedAt: t.timestamp().index(), + lastSeenAt: t.timestamp().index(), + expiresAt: t.timestamp().index(), + updatedAt: t.timestamp(), + } +); + +export const presenceConfig = table( + { name: 'presence_config', public: false }, + { + singleton: t.bool().primaryKey(), + defaultTtlSeconds: t.u32(), + sweepBatch: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const colonySweepTick = table( + { name: 'colony_sweep_tick' }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +export const spacetimedb = schema({ + apiKeys, + grid, + world, + worldEvent, + accessKeySummary, + presenceEntry, + presenceConfig, + colonySweepTick, +}); + +export type Schema = InferSchema; +export type Tx = ReducerCtx | TransactionCtx; +export type ReadCtx = Tx | ViewCtx; +export type HttpCtx = HandlerContext; diff --git a/spacetime-api-keys-ts/example/spacetimedb/tsconfig.json b/spacetime-api-keys-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..f004a6cbc79 --- /dev/null +++ b/spacetime-api-keys-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-api-keys-ts/example/src/app.ts b/spacetime-api-keys-ts/example/src/app.ts new file mode 100644 index 00000000000..b8756107d52 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/app.ts @@ -0,0 +1,1185 @@ +import { + DbConnection, + tables, + type ErrorContext, +} from './module_bindings/app/index.ts'; +import { parseShareKey, shareKeyFromHash } from './share-key'; + +import { + WIDTH, + HEIGHT, + TOKEN_PREFIX, + NAME_KEY, + COLOR_KEY, + TILE_SIZE, + PAD, + MIN_ZOOM, + MAX_ZOOM, + ZOOM_STEP, + HEARTBEAT_MS, + KEEPALIVE_MS, + SCOPE_VIEW, + SCOPE_TERRAFORM, + SCOPE_BUILD, + SCOPE_PLANT, + ROLES, + STRUCT_GLYPH, + TOOLS, + REMOVE_GLYPH, + CLIENT_NATURE, + TREE_SVGS, + BOULDER_SVGS, + variantIndex, + ENTITY_SVG, + clamp, + colorFor, + paintLinePoints, + parsePresencePayload, + parseScopes, + permissionsFor, + roleLabel, + safePresenceColor, + stepDirection, + toolAllowedFor, + worldPixelSize, + type AccessMode, + type ServerConfig, + type Grid, + type CellState, + type GridEntity, + type WorldEvent, + type PresenceEntry, + type ApiKeySummary, + type ToolGroup, + type Tool, +} from './model'; + +let conn: DbConnection | null = null; +let config: ServerConfig | null = null; +let identityHex = ''; + +let mode: AccessMode = 'owner'; +let holderKey = ''; +let colonyId = ''; +let gridId = 0n; +let myScopes: string[] = []; +let selectedTool = 'soil'; +let subscribed = false; +// The last road placed in the current stroke, so roads connect along the +// direction you draw. This keeps connections intentional. +let lastRoad: { x: number; y: number } | null = null; + +let reconnectTimer: number | null = null; +let controlsWired = false; +let viewScale = 1; +let viewX = 0; +let viewY = 0; +let viewInitialized = false; +let lastGridWidth = 0; +let lastGridHeight = 0; +let isPanning = false; +let panStartClientX = 0; +let panStartClientY = 0; +let panStartViewX = 0; +let panStartViewY = 0; +let panMoved = false; +// Left-drag paints the current tool across tiles (draw roads, brush terrain). +let isPainting = false; +let paintRemove = false; +let lastPaintTile: { x: number; y: number } | null = null; + +let myName = ''; +let myColor = ''; +let lastBeatAt = 0; +let beatTimer: number | null = null; +let cursor = { cx: 0, cy: 0, onGrid: false }; +let keepaliveTimer: number | null = null; + +function $(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) throw new Error(`missing #${id}`); + return el; +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function toast(message: string, kind: 'ok' | 'error' = 'ok'): void { + const el = $('toast'); + el.textContent = message; + el.className = `toast show ${kind === 'error' ? 'error' : ''}`; + window.setTimeout(() => el.classList.remove('show'), 2800); +} + +function tokenKey(): string { + return `${TOKEN_PREFIX}.${config?.spacetimeUri ?? 'unknown'}.${config?.databaseName ?? 'unknown'}`; +} + +function requireConn(): DbConnection { + if (!conn) throw new Error('stdb.disconnected'); + return conn; +} + +function grids(): Grid[] { + return [...requireConn().db.colonyGrid.iter()].filter(g => g.id === gridId); +} +function cells(): CellState[] { + return [...requireConn().db.colonyCells.iter()].filter( + c => c.gridId === gridId + ); +} +function entities(): GridEntity[] { + return [...requireConn().db.colonyEntities.iter()].filter( + e => e.gridId === gridId + ); +} +function events(): WorldEvent[] { + return [...requireConn().db.worldEvent.iter()].filter( + e => e.ownerSubject === colonyId + ); +} +function presenceRows(): PresenceEntry[] { + return [...requireConn().db.presenceEntry.iter()].filter( + p => p.scope === colonyId + ); +} +function apiKeyRows(): ApiKeySummary[] { + return [...requireConn().db.myAccessKeys.iter()]; +} + +function myRole(): string { + return mode === 'owner' ? 'Owner' : roleLabel(myScopes); +} + +function canTerraform(): boolean { + return permissionsFor(mode, myScopes).terraform; +} +function canBuild(): boolean { + return permissionsFor(mode, myScopes).build; +} +function canPlant(): boolean { + return permissionsFor(mode, myScopes).plant; +} +function toolAllowed(tool: Tool): boolean { + return toolAllowedFor(mode, myScopes, tool); +} + +function loadName(): string { + const stored = localStorage.getItem(NAME_KEY); + if (stored) return stored; + const suffix = + identityHex.slice(-4) || Math.floor(Math.random() * 9000 + 1000).toString(); + return `Settler-${suffix}`; +} +function loadColor(): string { + return safePresenceColor( + localStorage.getItem(COLOR_KEY), + colorFor(identityHex) + ); +} + +async function loadServerConfig(): Promise { + const res = await fetch('/api/config'); + if (!res.ok) throw new Error(`/api/config returned ${res.status}`); + return (await res.json()) as ServerConfig; +} + +function connect(cfg: ServerConfig): Promise { + return new Promise((resolve, reject) => { + let retriedWithoutToken = false; + const start = (token: string | undefined) => { + let builder = DbConnection.builder() + .withUri(cfg.spacetimeUri) + .withDatabaseName(cfg.databaseName); + if (token) builder = builder.withToken(token); + builder + .onConnect((connection, identity, nextToken) => { + conn = connection; + identityHex = + typeof identity.toHexString === 'function' + ? identity.toHexString() + : String(identity); + if (nextToken) localStorage.setItem(tokenKey(), nextToken); + resolve(connection); + }) + .onDisconnect((_ctx, err) => { + conn = null; + subscribed = false; + setStatus(err?.message ?? 'Disconnected'); + scheduleReconnect(); + }) + .onConnectError((_ctx, err) => { + if (token && !retriedWithoutToken) { + retriedWithoutToken = true; + localStorage.removeItem(tokenKey()); + start(undefined); + return; + } + reject(err); + }) + .build(); + }; + start(localStorage.getItem(tokenKey()) ?? undefined); + }); +} + +function setStatus(message: string): void { + const el = $('connChip'); + const connected = message === 'Connected'; + el.dataset.state = connected ? 'connected' : 'connecting'; + el.hidden = connected; + el.innerHTML = `${escapeHtml(connected ? '' : message.toLowerCase())}`; +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + main().catch(err => { + console.error(err); + setStatus(err instanceof Error ? err.message : String(err)); + scheduleReconnect(); + }); + }, 2000); +} + +// Subscriptions. Owner and holder subscribe to the same colony by id; +// only the id differs. Reads are public-by-colony; writes are gated. + +function subscribeToTables(): void { + if (subscribed) return; + subscribed = true; + const connection = requireConn(); + connection + .subscriptionBuilder() + .onApplied(() => renderWorld()) + .onError((ctx: ErrorContext) => + console.error('subscription error', ctx.event) + ) + .subscribe([ + tables.world.where(row => row.ownerSubject.eq(colonyId)), + tables.worldEvent.where(row => row.ownerSubject.eq(colonyId)), + tables.colonyGrid.where(row => row.id.eq(gridId)), + tables.colonyCells.where(row => row.gridId.eq(gridId)), + tables.colonyEntities.where(row => row.gridId.eq(gridId)), + tables.presenceEntry.where(row => row.scope.eq(colonyId)), + ...(mode === 'owner' ? [tables.myAccessKeys] : []), + ]); +} + +function registerRowCallbacks(): void { + const connection = requireConn(); + connection.db.world.onInsert(() => renderWorld()); + connection.db.world.onUpdate(() => renderWorld()); + connection.db.world.onDelete(() => renderWorld()); + connection.db.colonyGrid.onInsert(() => renderWorld()); + connection.db.colonyGrid.onUpdate(() => renderWorld()); + connection.db.colonyGrid.onDelete(() => renderWorld()); + connection.db.colonyCells.onInsert(() => renderWorld()); + connection.db.colonyCells.onUpdate(() => renderWorld()); + connection.db.colonyCells.onDelete(() => renderWorld()); + connection.db.colonyEntities.onInsert(() => renderWorld()); + connection.db.colonyEntities.onUpdate(() => renderWorld()); + connection.db.colonyEntities.onDelete(() => renderWorld()); + connection.db.worldEvent.onInsert(() => renderWorld()); + connection.db.worldEvent.onUpdate(() => renderWorld()); + connection.db.worldEvent.onDelete(() => renderWorld()); + connection.db.myAccessKeys.onInsert(() => renderWorld()); + connection.db.myAccessKeys.onUpdate(() => renderWorld()); + connection.db.myAccessKeys.onDelete(() => renderWorld()); + connection.db.presenceEntry.onInsert(() => renderPresence()); + connection.db.presenceEntry.onUpdate(() => renderPresence()); + connection.db.presenceEntry.onDelete(() => renderPresence()); +} + +function terrainFor(x: number, y: number): string { + return cells().find(c => c.x === x && c.y === y)?.terrain ?? 'regolith'; +} +function entityAt(x: number, y: number): GridEntity | undefined { + return entities().find(e => e.x === x && e.y === y); +} +function applyViewportTransform(): void { + $('gridStage').style.transform = + `translate(${viewX}px, ${viewY}px) scale(${viewScale})`; + $('zoomLabel').textContent = `${Math.round(viewScale * 100)}%`; +} + +function resetViewport( + width = grids()[0]?.width ?? WIDTH, + height = grids()[0]?.height ?? HEIGHT +): void { + const bounds = $('gridViewport').getBoundingClientRect(); + const world = worldPixelSize(width, height); + const topMargin = 76; + const bottomMargin = 116; + const sideMargin = 28; + const fitScale = Math.min( + (bounds.width - sideMargin * 2) / world.width, + (bounds.height - topMargin - bottomMargin) / world.height + ); + viewScale = clamp(Math.min(2.0, fitScale), MIN_ZOOM, MAX_ZOOM); + viewX = (bounds.width - world.width * viewScale) / 2; + viewY = + topMargin + + Math.max( + 0, + (bounds.height - topMargin - bottomMargin - world.height * viewScale) / 2 + ); + viewInitialized = true; + applyViewportTransform(); +} + +function zoomViewport( + nextScale: number, + clientX?: number, + clientY?: number +): void { + const rect = $('gridViewport').getBoundingClientRect(); + const anchorX = (clientX ?? rect.left + rect.width / 2) - rect.left; + const anchorY = (clientY ?? rect.top + rect.height / 2) - rect.top; + const clamped = clamp(nextScale, MIN_ZOOM, MAX_ZOOM); + const worldX = (anchorX - viewX) / viewScale; + const worldY = (anchorY - viewY) / viewScale; + viewScale = clamped; + viewX = anchorX - worldX * viewScale; + viewY = anchorY - worldY * viewScale; + applyViewportTransform(); +} + +function pointerToTile( + clientX: number, + clientY: number +): { cx: number; cy: number; onGrid: boolean } { + const rect = $('gridViewport').getBoundingClientRect(); + const worldX = (clientX - rect.left - viewX) / viewScale; + const worldY = (clientY - rect.top - viewY) / viewScale; + const cx = (worldX - PAD) / TILE_SIZE; + const cy = (worldY - PAD) / TILE_SIZE; + const width = grids()[0]?.width ?? WIDTH; + const height = grids()[0]?.height ?? HEIGHT; + const onGrid = cx >= 0 && cy >= 0 && cx < width && cy < height; + return { cx, cy, onGrid }; +} + +function tileAt(x: number, y: number): HTMLElement | null { + return $('worldGrid').querySelector( + `.tile[data-x="${x}"][data-y="${y}"]` + ); +} + +function flashTile( + x: number, + y: number, + kind: 'allow' | 'deny', + denyText = 'DENIED' +): void { + const tile = tileAt(x, y); + if (!tile) return; + const cls = kind === 'allow' ? 'flash-allow' : 'flash-deny'; + tile.classList.remove('flash-allow', 'flash-deny'); + void tile.offsetWidth; + tile.classList.add(cls); + window.setTimeout(() => tile.classList.remove(cls), 620); + if (kind === 'deny') { + const pop = document.createElement('span'); + pop.className = 'deny-pop'; + pop.textContent = denyText; + tile.appendChild(pop); + window.setTimeout(() => pop.remove(), 1000); + } +} + +// A road renders exactly the arms in its own stored mask. Connections are +// written to both roads when they are drawn/dragged together, so there is no +// mirror guessing: a road only links where you explicitly drew a link. +function entitySpan(entity: GridEntity): string { + if (entity.kind === 'road') { + const own = entity.label ?? ''; + const arms = ['n', 's', 'e', 'w'].filter(d => own.includes(d)); + return `${arms.map(a => ``).join('')}`; + } + if (entity.kind === 'tree') + return `${TREE_SVGS[variantIndex(entity.x, entity.y, TREE_SVGS.length)]}`; + if (entity.kind === 'boulder') + return `${BOULDER_SVGS[variantIndex(entity.x, entity.y, BOULDER_SVGS.length)]}`; + const svg = ENTITY_SVG[entity.kind]; + if (svg) + return `${svg}`; + return ``; +} + +function renderGrid(): void { + const grid = grids()[0]; + const width = grid?.width ?? WIDTH; + const height = grid?.height ?? HEIGHT; + const worldGrid = $('worldGrid'); + worldGrid.style.setProperty('--cols', String(width)); + worldGrid.style.setProperty('--tile', `${TILE_SIZE}px`); + const entityRows = entities(); + let html = ''; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const terrain = terrainFor(x, y); + const entity = entityRows.find(e => e.x === x && e.y === y); + html += ``; + } + } + worldGrid.innerHTML = html; + // Placement is handled on the viewport (pointer down + drag), not per-tile, + // so a click-drag can paint/draw a path across tiles. + if ( + !viewInitialized || + width !== lastGridWidth || + height !== lastGridHeight + ) { + lastGridWidth = width; + lastGridHeight = height; + resetViewport(width, height); + } else { + applyViewportTransform(); + } +} + +function renderToolbar(): void { + if (mode === 'holder' && !canTerraform() && !canBuild() && !canPlant()) { + $('toolbar').innerHTML = + 'View only. This key cannot change the colony.'; + return; + } + const groups: Array<{ key: ToolGroup; tools: Tool[] }> = [ + { key: 'surface', tools: TOOLS.filter(t => t.group === 'surface') }, + { key: 'structure', tools: TOOLS.filter(t => t.group === 'structure') }, + { key: 'nature', tools: TOOLS.filter(t => t.group === 'nature') }, + { key: 'remove', tools: TOOLS.filter(t => t.group === 'remove') }, + ]; + let html = ''; + for (const g of groups) { + const inner = g.tools + .map(tool => { + const allowed = toolAllowed(tool); + const face = + tool.group === 'surface' + ? `` + : tool.group === 'remove' + ? `${REMOVE_GLYPH}` + : `${STRUCT_GLYPH[tool.kind ?? ''] ?? tool.label}`; + return ``; + }) + .join(''); + html += `
${inner}
`; + } + const bar = $('toolbar'); + bar.innerHTML = html; + bar.querySelectorAll('[data-tool]').forEach(btn => { + btn.addEventListener('click', () => { + selectedTool = btn.dataset.tool ?? selectedTool; + lastRoad = null; + renderToolbar(); + }); + }); +} + +function renderRoleBanner(): void { + const banner = $('roleBanner'); + if (mode === 'owner') { + banner.hidden = true; + return; + } + banner.hidden = false; + const role = myRole(); + const viewOnly = role === 'Viewer'; + banner.className = `role-banner glass ${viewOnly ? 'view-only' : ''}`; + const swatch = document.createElement('span'); + swatch.className = 'swatch'; + swatch.style.color = myColor; + const roleName = document.createElement('b'); + roleName.textContent = role; + banner.replaceChildren(swatch, 'Joined as ', roleName); + if (viewOnly) banner.append(' (view only)'); +} + +function renderFeed(): void { + const ordered = events().sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch, + bv = b.createdAt.microsSinceUnixEpoch; + return av > bv ? -1 : av < bv ? 1 : 0; + }); + $('eventFeed').innerHTML = ordered.length + ? ordered + .map( + row => ` +
+ ${escapeHtml(row.message)} + ${escapeHtml(row.action)} | ${row.allowed ? 'allowed' : escapeHtml(row.reason)}${row.keyPrefix ? ` | ${escapeHtml(row.keyPrefix)}` : ''} +
` + ) + .join('') + : '

Nothing yet. Start building.

'; +} + +function renderKeys(): void { + if (mode !== 'owner') return; + const keys = apiKeyRows() + .filter(k => k.ownerSubject === colonyId) + .sort((a, b) => a.name.localeCompare(b.name)); + const active = keys.filter(k => k.status.tag === 'Active'); + if (!active.length) { + $('keyList').innerHTML = + '

No share links yet. Pick a role above and create one.

'; + return; + } + $('keyList').innerHTML = active + .map(key => { + const scopes = parseScopes(key.scopesJson); + return ` +
+
+ ${escapeHtml(key.name)} + ${escapeHtml(roleLabel(scopes))} +
+
${escapeHtml(key.prefix)}
+
+ + +
+
`; + }) + .join(''); + $('keyList') + .querySelectorAll('[data-rotate]') + .forEach(b => + b.addEventListener('click', () => void rotateKey(b.dataset.rotate ?? '')) + ); + $('keyList') + .querySelectorAll('[data-revoke]') + .forEach(b => + b.addEventListener('click', () => void revokeKey(b.dataset.revoke ?? '')) + ); +} + +function renderWorld(): void { + renderGrid(); + renderToolbar(); + renderRoleBanner(); + renderFeed(); + renderKeys(); + renderPresence(); +} + +function renderPresence(): void { + const people = presenceRows(); + const rosterHtml = people.length + ? people + .map(p => { + const payload = parsePresencePayload(p.payloadJson); + const isMe = p.subject === identityHex; + const color = safePresenceColor(payload.color, colorFor(p.subject)); + return `
${escapeHtml(payload.name || 'Someone')}${isMe ? ' (you)' : ''}${escapeHtml(payload.role || '')}
`; + }) + .join('') + : '

Nobody here yet.

'; + $('roster').innerHTML = rosterHtml; + $('rosterCount').textContent = String(people.length || 1); + + // live cursors (world-space, so they pan/zoom with the map) + const layer = $('cursorLayer'); + let html = ''; + for (const p of people) { + if (p.subject === identityHex) continue; + const payload = parsePresencePayload(p.payloadJson); + if (!payload.onGrid) continue; + const color = safePresenceColor(payload.color, colorFor(p.subject)); + const left = PAD + payload.cx * TILE_SIZE; + const top = PAD + payload.cy * TILE_SIZE; + html += `
+ + ${escapeHtml(payload.name || 'Someone')} +
`; + } + layer.innerHTML = html; +} + +async function applyTool(x: number, y: number): Promise { + const tool = TOOLS.find(t => t.id === selectedTool); + if (!tool) return; + if (tool.group === 'remove') { + await removeAt(x, y); + return; + } + if (!toolAllowed(tool)) { + flashTile(x, y, 'deny', 'NO ACCESS'); + toast( + `This key cannot ${tool.group === 'surface' ? 'terraform' : tool.group === 'structure' ? 'build' : 'plant'} here.`, + 'error' + ); + return; + } + + try { + if (tool.group === 'surface') { + await mutate('terraform', { x, y, terrain: tool.kind }); + lastRoad = null; + } else if (tool.group === 'structure') { + if (tool.kind === 'road') { + // Link the new tile and the preceding tile in this stroke, on both + // sides. Nothing else (perpendicular neighbours) is touched. + const prev = lastRoad; + const toPrev = prev ? stepDirection(x, y, prev.x, prev.y) : ''; + lastRoad = { x, y }; + await mutate('build', { x, y, kind: 'road', label: toPrev }); + if (prev && toPrev) { + await mutate('build', { + x: prev.x, + y: prev.y, + kind: 'road', + label: stepDirection(prev.x, prev.y, x, y), + }); + } + } else { + await mutate('build', { x, y, kind: tool.kind }); + lastRoad = null; + } + } else { + await mutate('plant', { x, y, kind: tool.kind }); + lastRoad = null; + } + flashTile(x, y, 'allow'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const denied = /scope|denied|forbidden|401|403/i.test(message); + const occupied = /occupied|nothing/i.test(message); + flashTile( + x, + y, + 'deny', + denied ? 'NO ACCESS' : occupied ? 'BLOCKED' : 'FAILED' + ); + if (denied) toast('This key cannot do that here.', 'error'); + else if (!occupied) toast(message, 'error'); + } +} + +async function removeAt(x: number, y: number): Promise { + lastRoad = null; + const ent = entityAt(x, y); + try { + if (ent && CLIENT_NATURE.has(ent.kind)) { + if (!canPlant()) throw new Error('scope_denied'); + await mutate('clear', { x, y }); + } else if (ent) { + if (!canBuild()) throw new Error('scope_denied'); + await mutate('unbuild', { x, y }); + } else { + if (!canTerraform()) throw new Error('scope_denied'); + await mutate('terraform', { x, y, terrain: 'regolith' }); + } + flashTile(x, y, 'allow'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const denied = /scope|denied|forbidden|401|403/i.test(message); + flashTile(x, y, 'deny', denied ? 'NO ACCESS' : 'FAILED'); + if (denied) toast('This key cannot remove that here.', 'error'); + } +} + +// Owner mutates via native reducers; holder via scoped HTTP routes. +function requiredNumber(body: Record, field: string): number { + const value = body[field]; + if (typeof value !== 'number' || !Number.isFinite(value)) + throw new Error(`invalid_${field}`); + return value; +} + +function requiredString(body: Record, field: string): string { + const value = body[field]; + if (typeof value !== 'string') throw new Error(`invalid_${field}`); + return value; +} + +async function mutate( + action: string, + body: Record +): Promise { + if (mode === 'owner') { + const r = requireConn().reducers; + const x = requiredNumber(body, 'x'); + const y = requiredNumber(body, 'y'); + if (action === 'terraform') + r.terraform({ x, y, terrain: requiredString(body, 'terrain') }); + else if (action === 'build') { + const label = body.label; + if (label !== undefined && typeof label !== 'string') + throw new Error('invalid_label'); + r.build({ x, y, kind: requiredString(body, 'kind'), label }); + } else if (action === 'plant') + r.plant({ x, y, kind: requiredString(body, 'kind') }); + else if (action === 'unbuild') r.unbuild({ x, y }); + else if (action === 'clear') r.clear({ x, y }); + else throw new Error(`unknown_action:${action}`); + return; + } + await colonyRequest(`/api/colony/${action}`, body); +} + +async function colonyRequest(path: string, body?: unknown): Promise { + const res = await fetch(path, { + method: body === undefined ? 'GET' : 'POST', + headers: { + authorization: `Bearer ${holderKey}`, + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const error = + data && typeof data === 'object' && 'error' in data + ? String((data as { error: unknown }).error) + : `http_${res.status}`; + if (/revoked|expired|unknown_key|invalid_key/i.test(error)) + showAccessRemoved(error); + throw new Error(error); + } + return data; +} + +let selectedRole = 'collaborator'; + +function renderRoleGrid(): void { + $('roleGrid').innerHTML = ROLES.map( + role => ` + ` + ).join(''); + $('roleGrid') + .querySelectorAll('[data-role]') + .forEach(b => { + b.addEventListener('click', () => { + selectedRole = b.dataset.role ?? selectedRole; + renderRoleGrid(); + }); + }); +} + +function shareLink(secret: string): string { + return `${location.origin}${location.pathname}#key=${encodeURIComponent(secret)}`; +} + +async function createKey(): Promise { + const role = ROLES.find(r => r.id === selectedRole) ?? ROLES[0]; + const input = document.getElementById( + 'keyNameInput' + ) as HTMLInputElement | null; + const name = input?.value.trim() || role.name; + try { + const result = await requireConn().procedures.createAccessKey({ + name, + scopesJson: JSON.stringify(role.scopes), + metadataJson: JSON.stringify({ role: role.id }), + expiresInSeconds: undefined, + keyPrefix: undefined, + }); + if (input) input.value = ''; + showLink(name, result.key); + toast(`${name} link created`); + renderKeys(); + } catch (err) { + toast(err instanceof Error ? err.message : String(err), 'error'); + } +} + +function showLink(label: string, secret: string): void { + const box = $('linkBox'); + const link = shareLink(secret); + box.hidden = false; + box.innerHTML = ` +
${escapeHtml(label)} link. Anyone with it gets this access. Copy it now.
+ `; + box + .querySelector('#copyFreshLink') + ?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(link); + toast('Link copied'); + } catch { + toast('Copy failed. Select the text manually.', 'error'); + } + }); +} + +async function rotateKey(keyId: string): Promise { + if (!keyId) return; + try { + const result = await requireConn().procedures.rotateAccessKey({ + keyId, + expiresInSeconds: undefined, + keyPrefix: undefined, + }); + showLink(result.name ?? 'New', result.key); + toast('Replacement link issued. Previous link revoked.'); + renderKeys(); + } catch (err) { + toast(err instanceof Error ? err.message : String(err), 'error'); + } +} + +async function revokeKey(keyId: string): Promise { + if (!keyId) return; + requireConn().reducers.revokeAccessKey({ keyId }); + toast('Access revoked'); +} + +function showAccessRemoved(reason: string): void { + const overlay = $('accessOverlay'); + const expired = /expired/i.test(reason); + $('overlayTitle').textContent = expired ? 'Link expired' : 'Access removed'; + $('overlayText').textContent = expired + ? 'This share link has expired. Ask the owner for a new one.' + : 'The owner revoked this share link. Colony access is unavailable.'; + overlay.classList.add('show'); + if (keepaliveTimer) { + window.clearInterval(keepaliveTimer); + keepaliveTimer = null; + } +} + +function sendBeat(): void { + if (!conn || !colonyId) return; + lastBeatAt = Date.now(); + try { + requireConn().reducers.presenceHeartbeat({ + scope: colonyId, + name: myName, + role: myRole(), + color: myColor, + cx: cursor.cx, + cy: cursor.cy, + onGrid: cursor.onGrid, + }); + } catch { + /* connection churn, keepalive will retry */ + } +} + +function queueBeat(): void { + const now = Date.now(); + const wait = HEARTBEAT_MS - (now - lastBeatAt); + if (wait <= 0) { + sendBeat(); + return; + } + if (beatTimer) return; + beatTimer = window.setTimeout(() => { + beatTimer = null; + sendBeat(); + }, wait); +} + +function startPresence(): void { + sendBeat(); + if (keepaliveTimer) window.clearInterval(keepaliveTimer); + keepaliveTimer = window.setInterval(() => { + if (mode === 'holder') void reverify(); + sendBeat(); + }, KEEPALIVE_MS); + window.addEventListener('beforeunload', () => { + try { + requireConn().reducers.presenceLeave({ scope: colonyId }); + } catch { + /* best-effort disconnect cleanup */ + } + }); +} + +async function reverify(): Promise { + if (mode !== 'holder') return; + try { + await colonyRequest('/api/colony/snapshot'); + } catch { + /* colonyRequest already shows the overlay on revoke/expire */ + } +} + +function tileFromEvent( + clientX: number, + clientY: number +): { x: number; y: number } | null { + const t = pointerToTile(clientX, clientY); + if (!t.onGrid) return null; + return { x: Math.floor(t.cx), y: Math.floor(t.cy) }; +} +function applyAtTile(x: number, y: number, remove: boolean): void { + if (remove) void removeAt(x, y); + else void applyTool(x, y); +} + +// Fill in every tile along a drag so a fast drag never leaves gaps (and roads +// chain tile-by-tile). Walks orthogonally so each step is adjacent to the last. + +function toggle(id: string, others: string[]): void { + const panel = $(id); + const open = panel.hidden; + for (const o of others) $(o).hidden = true; + panel.hidden = !open; +} + +// Use the URL fragment so the bearer key is not sent in the initial HTTP request. +function joinColony(): void { + const raw = ($('joinInput') as HTMLInputElement).value.trim(); + if (!raw) { + toast('Paste a share link or key first', 'error'); + return; + } + const key = parseShareKey(raw); + if (!key) { + toast('Use a raw key or a share link with #key=...', 'error'); + return; + } + location.href = shareLink(key); +} + +function registerUiHandlers(): void { + if (controlsWired) return; + controlsWired = true; + + const viewport = $('gridViewport'); + $('zoomOut').addEventListener('click', () => + zoomViewport(viewScale / ZOOM_STEP) + ); + $('zoomIn').addEventListener('click', () => + zoomViewport(viewScale * ZOOM_STEP) + ); + $('resetView').addEventListener('click', () => resetViewport()); + viewport.addEventListener( + 'wheel', + event => { + event.preventDefault(); + zoomViewport( + viewScale * (event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP), + event.clientX, + event.clientY + ); + }, + { passive: false } + ); + viewport.addEventListener('contextmenu', event => event.preventDefault()); + viewport.addEventListener('mousedown', event => { + if (event.button === 1) event.preventDefault(); + }); + viewport.addEventListener('pointerdown', event => { + const touch = event.pointerType === 'touch'; + // Middle / right / touch drag pans. Left mouse paints the current tool. + const wantsPan = touch || event.button === 1 || event.button === 2; + if (wantsPan) { + if (!touch) event.preventDefault(); + isPanning = true; + panMoved = false; + panStartClientX = event.clientX; + panStartClientY = event.clientY; + panStartViewX = viewX; + panStartViewY = viewY; + viewport.classList.add('panning'); + viewport.setPointerCapture(event.pointerId); + return; + } + if (event.button !== 0) return; + const tile = tileFromEvent(event.clientX, event.clientY); + if (!tile) return; + isPainting = true; + paintRemove = event.ctrlKey || event.metaKey; + lastPaintTile = tile; + applyAtTile(tile.x, tile.y, paintRemove); + viewport.setPointerCapture(event.pointerId); + }); + viewport.addEventListener('pointermove', event => { + if (isPanning) { + const dx = event.clientX - panStartClientX, + dy = event.clientY - panStartClientY; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) panMoved = true; + viewX = panStartViewX + dx; + viewY = panStartViewY + dy; + applyViewportTransform(); + } else if (isPainting) { + const tile = tileFromEvent(event.clientX, event.clientY); + if ( + tile && + (!lastPaintTile || + tile.x !== lastPaintTile.x || + tile.y !== lastPaintTile.y) + ) { + if (lastPaintTile) { + for (const point of paintLinePoints(lastPaintTile, tile)) { + applyAtTile(point.x, point.y, paintRemove); + } + } else applyAtTile(tile.x, tile.y, paintRemove); + lastPaintTile = tile; + } + } + const t = pointerToTile(event.clientX, event.clientY); + cursor = { cx: t.cx, cy: t.cy, onGrid: t.onGrid }; + queueBeat(); + }); + viewport.addEventListener('pointerleave', () => { + cursor = { ...cursor, onGrid: false }; + sendBeat(); + }); + const endStroke = (event: PointerEvent): void => { + if (isPanning) { + isPanning = false; + viewport.classList.remove('panning'); + if (event.pointerType === 'touch' && !panMoved) { + const tile = tileFromEvent(event.clientX, event.clientY); + if (tile) applyAtTile(tile.x, tile.y, false); + } + } + // End the stroke: roads only chain within a single continuous drag, so a + // A separate click beside the road starts a distinct stroke. + if (isPainting) { + isPainting = false; + lastPaintTile = null; + lastRoad = null; + } + if (viewport.hasPointerCapture(event.pointerId)) + viewport.releasePointerCapture(event.pointerId); + }; + viewport.addEventListener('pointerup', endStroke); + viewport.addEventListener('pointercancel', endStroke); + + $('rosterBtn').addEventListener('click', () => + toggle('rosterPanel', ['sharePanel', 'joinPanel', 'logDrawer']) + ); + $('joinBtn').addEventListener('click', () => + toggle('joinPanel', ['sharePanel', 'rosterPanel', 'logDrawer']) + ); + $('shareBtn').addEventListener('click', () => + toggle('sharePanel', ['rosterPanel', 'joinPanel', 'logDrawer']) + ); + $('logBtn').addEventListener('click', () => + toggle('logDrawer', ['rosterPanel', 'sharePanel', 'joinPanel']) + ); + const colorInput = $('myColorInput') as HTMLInputElement; + const nameInput = $('myNameInput') as HTMLInputElement; + colorInput.value = /^#[0-9a-fA-F]{6}$/.test(myColor) ? myColor : '#59c6d6'; + nameInput.value = myName; + colorInput.addEventListener('input', () => { + myColor = colorInput.value; + localStorage.setItem(COLOR_KEY, myColor); + sendBeat(); + renderPresence(); + renderRoleBanner(); + }); + nameInput.addEventListener('input', () => { + myName = nameInput.value.trim() || `Settler-${identityHex.slice(-4)}`; + localStorage.setItem(NAME_KEY, myName); + sendBeat(); + renderPresence(); + }); + $('createKeyBtn').addEventListener('click', () => void createKey()); + $('joinSubmit').addEventListener('click', () => joinColony()); + ($('joinInput') as HTMLInputElement).addEventListener('keydown', e => { + if (e.key === 'Enter') joinColony(); + }); + $('resetWorld').addEventListener('click', () => { + requireConn().reducers.resetWorld({}); + toast('Colony reset'); + }); + $('clearEvents').addEventListener('click', () => { + requireConn().reducers.clearWorldEvents({}); + toast('Log cleared'); + }); + + document.addEventListener('pointerdown', event => { + const t = event.target as HTMLElement; + if (!t.closest('#sharePanel, #shareBtn')) $('sharePanel').hidden = true; + if (!t.closest('#rosterPanel, #rosterBtn')) $('rosterPanel').hidden = true; + if (!t.closest('#joinPanel, #joinBtn')) $('joinPanel').hidden = true; + if (!t.closest('#logDrawer, #logBtn')) $('logDrawer').hidden = true; + }); + document.addEventListener('keydown', event => { + if (event.key === 'Escape') { + $('sharePanel').hidden = true; + $('rosterPanel').hidden = true; + $('joinPanel').hidden = true; + $('logDrawer').hidden = true; + } + }); +} + +function applyModeChrome(): void { + $('shareBtn').hidden = mode !== 'owner'; + $('drawerFoot').style.display = mode === 'owner' ? '' : 'none'; + if (mode === 'holder') { + $('sharePanel').hidden = true; + } +} + +async function main(): Promise { + setStatus('Connecting'); + config = await loadServerConfig(); + const connection = await connect(config); + conn = connection; + myName = loadName(); + myColor = loadColor(); + + const urlKey = shareKeyFromHash(location.hash); + if (urlKey) { + mode = 'holder'; + holderKey = urlKey; + setStatus('Opening colony'); + // Snapshot resolves which colony, its grid, and this key's scopes at once. + const data = (await colonyRequest('/api/colony/snapshot')) as { + result?: { + ownerSubject?: unknown; + world?: { ownerSubject?: unknown; gridId?: string | number | bigint }; + grid?: { id?: string | number | bigint }; + scopesJson?: unknown; + }; + }; + const result = data.result ?? {}; + colonyId = String(result.ownerSubject ?? result.world?.ownerSubject ?? ''); + gridId = BigInt(result.world?.gridId ?? result.grid?.id ?? 0); + myScopes = parseScopes( + typeof result.scopesJson === 'string' ? result.scopesJson : '[]' + ); + if (!colonyId) throw new Error('could not resolve colony'); + } else { + mode = 'owner'; + setStatus('Preparing colony'); + const r = await requireConn().procedures.ensureWorld({}); + colonyId = String(r.ownerSubject); + gridId = BigInt(r.gridId); + myScopes = [SCOPE_VIEW, SCOPE_TERRAFORM, SCOPE_BUILD, SCOPE_PLANT]; + } + + if (mode === 'holder' && !toolAllowed(TOOLS[0]) && !canBuild() && !canPlant()) + selectedTool = 'regolith'; + else if (mode === 'holder') { + if (canTerraform()) selectedTool = 'soil'; + else if (canBuild()) selectedTool = 'dome'; + else if (canPlant()) selectedTool = 'tree'; + } + + setStatus('Connected'); + applyModeChrome(); + registerRowCallbacks(); + subscribeToTables(); + registerUiHandlers(); + renderRoleGrid(); + renderWorld(); + startPresence(); +} + +main().catch(err => { + console.error(err); + setStatus(err instanceof Error ? err.message : String(err)); + toast(err instanceof Error ? err.message : String(err), 'error'); +}); diff --git a/spacetime-api-keys-ts/example/src/model.ts b/spacetime-api-keys-ts/example/src/model.ts new file mode 100644 index 00000000000..355eaac64a7 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/model.ts @@ -0,0 +1,384 @@ +export type TimestampLike = { microsSinceUnixEpoch: bigint }; +export type EnumTag = { tag: T }; + +export type ServerConfig = { + spacetimeUri: string; + databaseName: string; +}; + +export type World = { + ownerSubject: string; + gridId: bigint; + name: string; + updatedAt: TimestampLike; +}; +export type Grid = { id: bigint; width: number; height: number }; +export type CellState = { + id: bigint; + gridId: bigint; + x: number; + y: number; + cost: number; + terrain?: string; +}; +export type GridEntity = { + id: bigint; + gridId: bigint; + ownerUserId: string; + x: number; + y: number; + kind: string; + label?: string; +}; +export type WorldEvent = { + eventId: bigint; + ownerSubject: string; + keyPrefix: string; + action: string; + allowed: boolean; + reason: string; + message: string; + createdAt: TimestampLike; +}; +export type PresenceEntry = { + key: string; + scope: string; + subject: string; + status: string; + payloadJson?: string; + lastSeenAt: TimestampLike; +}; +export type ApiKeySummary = { + keyId: string; + prefix: string; + ownerSubject: string; + name: string; + scopesJson: string; + metadataJson?: string; + status: EnumTag<'Active' | 'Revoked'>; + createdAt: TimestampLike; + expiresAt?: TimestampLike; + lastUsedAt?: TimestampLike; + revokedAt?: TimestampLike; +}; + +export const WIDTH = 12; +export const HEIGHT = 8; +export const TOKEN_PREFIX = 'colony.stdb-token'; +export const NAME_KEY = 'colony.name'; +export const COLOR_KEY = 'colony.color'; +export const TILE_SIZE = 78; +export const PAD = 26; +export const MIN_ZOOM = 0.5; +export const MAX_ZOOM = 2.6; +export const ZOOM_STEP = 1.18; +export const HEARTBEAT_MS = 80; +export const KEEPALIVE_MS = 9000; +export const PRESENCE_NAME_MAX = 64; +export const PRESENCE_ROLE_MAX = 32; + +export function safePresenceColor(value: unknown, fallback = ''): string { + return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) + ? value.toLowerCase() + : fallback; +} + +export function safePresenceText( + value: unknown, + fallback: string, + maxLength: number +): string { + if (typeof value !== 'string') return fallback; + const text = value.trim(); + return text ? text.slice(0, maxLength) : fallback; +} + +export function safePresenceCoordinate( + value: unknown, + min: number, + max: number +): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.min(max, Math.max(min, value)) + : 0; +} + +export const SCOPE_VIEW = 'colony:view'; +export const SCOPE_TERRAFORM = 'colony:terraform'; +export const SCOPE_BUILD = 'colony:build'; +export const SCOPE_PLANT = 'colony:plant'; + +export type AccessMode = 'owner' | 'holder'; + +export function parseScopes(json: string): string[] { + try { + const parsed: unknown = JSON.parse(json); + return Array.isArray(parsed) ? parsed.map(String) : []; + } catch { + return []; + } +} + +export function hasScope(scopes: string[], scope: string): boolean { + return ( + scopes.includes('*') || + scopes.includes('colony:*') || + scopes.includes(scope) + ); +} + +export function roleLabel(scopes: string[]): string { + const canTerraform = hasScope(scopes, SCOPE_TERRAFORM); + const canBuild = hasScope(scopes, SCOPE_BUILD); + const canPlant = hasScope(scopes, SCOPE_PLANT); + if (canTerraform && canBuild && canPlant) return 'Collaborator'; + if (canTerraform && !canBuild && !canPlant) return 'Terraformer'; + if (canBuild && !canTerraform && !canPlant) return 'Builder'; + if (canPlant && !canTerraform && !canBuild) return 'Planter'; + if (!canTerraform && !canBuild && !canPlant) return 'Viewer'; + return 'Editor'; +} + +export function permissionsFor(mode: AccessMode, scopes: string[]) { + const owner = mode === 'owner'; + return { + terraform: owner || hasScope(scopes, SCOPE_TERRAFORM), + build: owner || hasScope(scopes, SCOPE_BUILD), + plant: owner || hasScope(scopes, SCOPE_PLANT), + }; +} + +// Share roles: a small, honest set that maps straight onto scopes. +export const ROLES: Array<{ + id: string; + name: string; + scopes: string[]; + blurb: string; +}> = [ + { + id: 'viewer', + name: 'Viewer', + scopes: [SCOPE_VIEW], + blurb: 'Can look around, cannot change anything.', + }, + { + id: 'terraformer', + name: 'Terraformer', + scopes: [SCOPE_VIEW, SCOPE_TERRAFORM], + blurb: 'Can reshape the surface.', + }, + { + id: 'builder', + name: 'Builder', + scopes: [SCOPE_VIEW, SCOPE_BUILD], + blurb: 'Can place structures and roads.', + }, + { + id: 'planter', + name: 'Planter', + scopes: [SCOPE_VIEW, SCOPE_PLANT], + blurb: 'Can plant and clear greenery.', + }, + { + id: 'collaborator', + name: 'Collaborator', + scopes: [SCOPE_VIEW, SCOPE_TERRAFORM, SCOPE_BUILD, SCOPE_PLANT], + blurb: 'Can do everything.', + }, +]; + +export type ToolGroup = 'surface' | 'structure' | 'nature' | 'remove'; +export type Tool = { + id: string; + group: ToolGroup; + kind?: string; + label: string; +}; + +export function toolAllowedFor( + mode: AccessMode, + scopes: string[], + tool: Tool +): boolean { + const permissions = permissionsFor(mode, scopes); + if (tool.group === 'surface') return permissions.terraform; + if (tool.group === 'structure') return permissions.build; + if (tool.group === 'nature') return permissions.plant; + return permissions.build || permissions.plant; +} + +export const STRUCT_GLYPH: Record = { + dome: '', + pod: '', + solar: + '', + road: '', + tree: '', + shrub: + '', + boulder: + '', +}; +export const TOOLS: Tool[] = [ + { id: 'regolith', group: 'surface', kind: 'regolith', label: 'Bare' }, + { id: 'rock', group: 'surface', kind: 'rock', label: 'Rock' }, + { id: 'grass', group: 'surface', kind: 'grass', label: 'Grass' }, + { id: 'water', group: 'surface', kind: 'water', label: 'Water' }, + { id: 'soil', group: 'surface', kind: 'soil', label: 'Soil' }, + { id: 'dome', group: 'structure', kind: 'dome', label: 'Dome' }, + { id: 'pod', group: 'structure', kind: 'pod', label: 'Pod' }, + { id: 'solar', group: 'structure', kind: 'solar', label: 'Solar' }, + { id: 'road', group: 'structure', kind: 'road', label: 'Road' }, + { id: 'tree', group: 'nature', kind: 'tree', label: 'Tree' }, + { id: 'boulder', group: 'nature', kind: 'boulder', label: 'Boulder' }, + { id: 'remove', group: 'remove', label: 'Remove' }, +]; +export const REMOVE_GLYPH = + ''; +export const CLIENT_NATURE = new Set(['tree', 'shrub', 'boulder']); + +// A biodome drawn in 3/4 isometric perspective: a gridded glass shell (latitude +// rings + meridians + top cap) on a short cylindrical base. +export const DOME_SVG = ``; + +// Solid isometric objects, each sitting on a ground shadow. +export const POD_SVG = ``; + +export const SOLAR_SVG = ``; + +// A few tree and boulder variants for natural variety; the variant is chosen +// from the tile position so it is stable across renders and identical for +// everyone viewing the colony. +export const TREE_SVGS = [ + ``, + ``, + ``, +]; + +export const BOULDER_SVGS = [ + ``, + ``, + ``, +]; + +export function variantIndex(x: number, y: number, n: number): number { + const h = ((x * 73856093) ^ (y * 19349663)) >>> 0; + return h % n; +} + +const PRESENCE_PALETTE = [ + '#e0714a', + '#59c6d6', + '#74c56a', + '#f0c05a', + '#c58fe0', + '#f07676', + '#8fb7d6', + '#e08fc0', + '#7ad6b0', + '#d6b84a', + '#9b8cff', + '#5ad1a0', +]; + +export function colorFor(subject: string): string { + let hash = 0; + for (let index = 0; index < subject.length; index++) { + hash = (hash * 31 + subject.charCodeAt(index)) >>> 0; + } + return PRESENCE_PALETTE[hash % PRESENCE_PALETTE.length]; +} + +export type PresencePayload = { + name: string; + role: string; + color: string; + cx: number; + cy: number; + onGrid: boolean; +}; + +export function parsePresencePayload(json?: string): PresencePayload { + const fallback: PresencePayload = { + name: 'Someone', + role: '', + color: '', + cx: 0, + cy: 0, + onGrid: false, + }; + if (!json) return fallback; + try { + const parsed: unknown = JSON.parse(json); + if (typeof parsed !== 'object' || parsed === null) return fallback; + const payload = parsed as Record; + return { + name: safePresenceText(payload.name, 'Someone', PRESENCE_NAME_MAX), + role: safePresenceText(payload.role, '', PRESENCE_ROLE_MAX), + color: safePresenceColor(payload.color), + cx: safePresenceCoordinate(payload.cx, -1, WIDTH + 1), + cy: safePresenceCoordinate(payload.cy, -1, HEIGHT + 1), + onGrid: payload.onGrid === true, + }; + } catch { + return fallback; + } +} + +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function worldPixelSize(width: number, height: number) { + return { + width: width * TILE_SIZE + 2 * PAD, + height: height * TILE_SIZE + 2 * PAD, + }; +} + +export function stepDirection( + fromX: number, + fromY: number, + toX: number, + toY: number +): string { + if (toX === fromX && toY === fromY - 1) return 'n'; + if (toX === fromX + 1 && toY === fromY) return 'e'; + if (toX === fromX && toY === fromY + 1) return 's'; + if (toX === fromX - 1 && toY === fromY) return 'w'; + return ''; +} + +export function paintLinePoints( + from: { x: number; y: number }, + to: { x: number; y: number } +): Array<{ x: number; y: number }> { + const points: Array<{ x: number; y: number }> = []; + let { x, y } = from; + let guard = 0; + while ((x !== to.x || y !== to.y) && guard++ < 256) { + if (x !== to.x) x += Math.sign(to.x - x); + else y += Math.sign(to.y - y); + points.push({ x, y }); + } + return points; +} + +export const ENTITY_SVG: Record = { + dome: DOME_SVG, + pod: POD_SVG, + solar: SOLAR_SVG, +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/add_admin_identity_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/add_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/add_admin_identity_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_key_usage_admin_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_key_usage_admin_table.ts new file mode 100644 index 00000000000..13ca42e538d --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_key_usage_admin_table.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + usageId: __t.u64().name("usage_id"), + keyId: __t.string().name("key_id"), + prefix: __t.string(), + ownerSubject: __t.string().name("owner_subject"), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + usedAt: __t.timestamp().name("used_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_keys_admin_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_keys_admin_table.ts new file mode 100644 index 00000000000..478edd2b013 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_keys_admin_table.ts @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + ApiKeyStatus, +} from "./types"; + + +export default __t.row({ + keyId: __t.string().name("key_id"), + prefix: __t.string(), + ownerSubject: __t.string().name("owner_subject"), + name: __t.string(), + scopesJson: __t.string().name("scopes_json"), + metadataJson: __t.option(__t.string()).name("metadata_json"), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp().name("created_at"), + expiresAt: __t.option(__t.timestamp()).name("expires_at"), + lastUsedAt: __t.option(__t.timestamp()).name("last_used_at"), + revokedAt: __t.option(__t.timestamp()).name("revoked_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_for_subject_procedure.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_for_subject_procedure.ts new file mode 100644 index 00000000000..b3ca01ada61 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_for_subject_procedure.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_procedure.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_procedure.ts new file mode 100644 index 00000000000..2324c883938 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_procedure.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/my_api_keys_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/my_api_keys_table.ts new file mode 100644 index 00000000000..478edd2b013 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/my_api_keys_table.ts @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + ApiKeyStatus, +} from "./types"; + + +export default __t.row({ + keyId: __t.string().name("key_id"), + prefix: __t.string(), + ownerSubject: __t.string().name("owner_subject"), + name: __t.string(), + scopesJson: __t.string().name("scopes_json"), + metadataJson: __t.option(__t.string()).name("metadata_json"), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp().name("created_at"), + expiresAt: __t.option(__t.timestamp()).name("expires_at"), + lastUsedAt: __t.option(__t.timestamp()).name("last_used_at"), + revokedAt: __t.option(__t.timestamp()).name("revoked_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/remove_admin_identity_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/remove_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/remove_admin_identity_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_for_subject_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_for_subject_reducer.ts new file mode 100644 index 00000000000..8088a2a4a47 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_for_subject_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + keyId: __t.string(), + ownerSubject: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_reducer.ts new file mode 100644 index 00000000000..74c389667b3 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + keyId: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/rotate_api_key_procedure.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/rotate_api_key_procedure.ts new file mode 100644 index 00000000000..8261e1ed9bc --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/rotate_api_key_procedure.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + keyId: __t.string(), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/sweep_api_key_usage_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/sweep_api_key_usage_reducer.ts new file mode 100644 index 00000000000..77e9dcb6473 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/sweep_api_key_usage_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + maxAgeSeconds: __t.u32(), + maxRows: __t.u32(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/types.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/types.ts new file mode 100644 index 00000000000..0fbfafcd592 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/types.ts @@ -0,0 +1,111 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const ApiKey = __t.object("ApiKey", { + keyId: __t.string(), + prefix: __t.string(), + hash: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + createdAtOrder: __t.i64(), + expiresAt: __t.option(__t.timestamp()), + lastUsedAt: __t.option(__t.timestamp()), + revokedAt: __t.option(__t.timestamp()), +}); +export type ApiKey = __Infer; + +export const ApiKeyAdminIdentity = __t.object("ApiKeyAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type ApiKeyAdminIdentity = __Infer; + +export const ApiKeyCreateResult = __t.object("ApiKeyCreateResult", { + keyId: __t.string(), + key: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + expiresAt: __t.option(__t.timestamp()), +}); +export type ApiKeyCreateResult = __Infer; + +// The tagged union or sum type for the algebraic type `ApiKeyStatus`. +export const ApiKeyStatus = __t.enum("ApiKeyStatus", { + Active: __t.unit(), + Revoked: __t.unit(), +}); +export type ApiKeyStatus = __Infer; + +export const ApiKeySummary = __t.object("ApiKeySummary", { + keyId: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + expiresAt: __t.option(__t.timestamp()), + lastUsedAt: __t.option(__t.timestamp()), + revokedAt: __t.option(__t.timestamp()), +}); +export type ApiKeySummary = __Infer; + +export const ApiKeyUsage = __t.object("ApiKeyUsage", { + usageId: __t.u64(), + keyId: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + usedAt: __t.timestamp(), + usedAtOrder: __t.i64(), +}); +export type ApiKeyUsage = __Infer; + +export const ApiKeyUsageAdmin = __t.object("ApiKeyUsageAdmin", {}); +export type ApiKeyUsageAdmin = __Infer; + +export const ApiKeyUsageSummary = __t.object("ApiKeyUsageSummary", { + usageId: __t.u64(), + keyId: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + usedAt: __t.timestamp(), +}); +export type ApiKeyUsageSummary = __Infer; + +export const ApiKeysAdmin = __t.object("ApiKeysAdmin", {}); +export type ApiKeysAdmin = __Infer; + +export const MyApiKeys = __t.object("MyApiKeys", {}); +export type MyApiKeys = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/build_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/build_reducer.ts new file mode 100644 index 00000000000..2b94f0ff71d --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/build_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + label: __t.option(__t.string()), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/clear_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/clear_reducer.ts new file mode 100644 index 00000000000..c061193b961 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/clear_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + x: __t.i32(), + y: __t.i32(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/clear_world_events_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/clear_world_events_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/clear_world_events_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/colony_cells_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/colony_cells_table.ts new file mode 100644 index 00000000000..9f1a3bfc694 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/colony_cells_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + gridId: __t.u64().name("grid_id"), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/colony_entities_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/colony_entities_table.ts new file mode 100644 index 00000000000..1b75cfb7ea0 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/colony_entities_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + gridId: __t.u64().name("grid_id"), + ownerUserId: __t.string().name("owner_user_id"), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool().name("blocks_movement"), + label: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/colony_grid_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/colony_grid_table.ts new file mode 100644 index 00000000000..ba03c161ea6 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/colony_grid_table.ts @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + ownerUserId: __t.string().name("owner_user_id"), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32().name("default_cost"), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/create_access_key_procedure.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/create_access_key_procedure.ts new file mode 100644 index 00000000000..2324c883938 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/create_access_key_procedure.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/ensure_world_procedure.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/ensure_world_procedure.ts new file mode 100644 index 00000000000..dd16b86efa5 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/ensure_world_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + EnsureWorldResult, +} from "./types"; + +export const params = { +}; +export const returnType = EnsureWorldResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/grid/types.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/grid/types.ts new file mode 100644 index 00000000000..48f7c6524bd --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/grid/types.ts @@ -0,0 +1,69 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const CellState = __t.object("CellState", { + id: __t.u64(), + gridId: __t.u64(), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); +export type CellState = __Infer; + +export const EntityPath = __t.object("EntityPath", { + entityId: __t.u64(), + gridId: __t.u64(), + get cells() { + return __t.array(PathCell); + }, + cost: __t.i32(), + computedAt: __t.timestamp(), +}); +export type EntityPath = __Infer; + +export const Grid = __t.object("Grid", { + id: __t.u64(), + ownerUserId: __t.string(), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32(), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Grid = __Infer; + +export const GridEntity = __t.object("GridEntity", { + id: __t.u64(), + gridId: __t.u64(), + ownerUserId: __t.string(), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool(), + label: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridEntity = __Infer; + +export const PathCell = __t.object("PathCell", { + x: __t.i32(), + y: __t.i32(), +}); +export type PathCell = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/index.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..d4342f01cbe --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,324 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import BuildReducer from "./build_reducer"; +import ClearReducer from "./clear_reducer"; +import ClearWorldEventsReducer from "./clear_world_events_reducer"; +import PlantReducer from "./plant_reducer"; +import PresenceHeartbeatReducer from "./presence_heartbeat_reducer"; +import PresenceLeaveReducer from "./presence_leave_reducer"; +import ResetWorldReducer from "./reset_world_reducer"; +import RevokeAccessKeyReducer from "./revoke_access_key_reducer"; +import TerraformReducer from "./terraform_reducer"; +import UnbuildReducer from "./unbuild_reducer"; + +// Import all procedure arg schemas +import * as CreateAccessKeyProcedure from "./create_access_key_procedure"; +import * as EnsureWorldProcedure from "./ensure_world_procedure"; +import * as RotateAccessKeyProcedure from "./rotate_access_key_procedure"; + +// Import all table schema definitions +import ColonyCellsRow from "./colony_cells_table"; +import ColonyEntitiesRow from "./colony_entities_table"; +import ColonyGridRow from "./colony_grid_table"; +import MyAccessKeysRow from "./my_access_keys_table"; +import PresenceEntryRow from "./presence_entry_table"; +import WorldRow from "./world_table"; +import WorldEventRow from "./world_event_table"; + +// Import namespace table schema definitions +import ApiKeys_ApiKeyUsageAdminRow from "./apiKeys/api_key_usage_admin_table"; +import ApiKeys_ApiKeysAdminRow from "./apiKeys/api_keys_admin_table"; +import ApiKeys_MyApiKeysRow from "./apiKeys/my_api_keys_table"; + +// Import namespace reducer arg schemas +import ApiKeys_AddAdminIdentityReducer from "./apiKeys/add_admin_identity_reducer"; +import ApiKeys_RemoveAdminIdentityReducer from "./apiKeys/remove_admin_identity_reducer"; +import ApiKeys_RevokeApiKeyReducer from "./apiKeys/revoke_api_key_reducer"; +import ApiKeys_RevokeApiKeyForSubjectReducer from "./apiKeys/revoke_api_key_for_subject_reducer"; +import ApiKeys_SweepApiKeyUsageReducer from "./apiKeys/sweep_api_key_usage_reducer"; + +// Import namespace procedure arg schemas +import * as ApiKeys_CreateApiKeyProcedure from "./apiKeys/create_api_key_procedure"; +import * as ApiKeys_CreateApiKeyForSubjectProcedure from "./apiKeys/create_api_key_for_subject_procedure"; +import * as ApiKeys_RotateApiKeyProcedure from "./apiKeys/rotate_api_key_procedure"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + presenceEntry: __table({ + name: 'presence_entry', + indexes: [ + { accessor: 'expiresAt', name: 'presence_entry_expires_at_idx_btree', algorithm: 'btree', columns: [ + 'expiresAt', + ] }, + { accessor: 'joinedAt', name: 'presence_entry_joined_at_idx_btree', algorithm: 'btree', columns: [ + 'joinedAt', + ] }, + { accessor: 'key', name: 'presence_entry_key_idx_btree', algorithm: 'btree', columns: [ + 'key', + ] }, + { accessor: 'lastSeenAt', name: 'presence_entry_last_seen_at_idx_btree', algorithm: 'btree', columns: [ + 'lastSeenAt', + ] }, + { accessor: 'scope', name: 'presence_entry_scope_idx_btree', algorithm: 'btree', columns: [ + 'scope', + ] }, + { accessor: 'status', name: 'presence_entry_status_idx_btree', algorithm: 'btree', columns: [ + 'status', + ] }, + { accessor: 'subject', name: 'presence_entry_subject_idx_btree', algorithm: 'btree', columns: [ + 'subject', + ] }, + ], + constraints: [ + { name: 'presence_entry_key_key', constraint: 'unique', columns: ['key'] }, + ], + }, PresenceEntryRow), + world: __table({ + name: 'world', + indexes: [ + { accessor: 'gridId', name: 'world_grid_id_idx_btree', algorithm: 'btree', columns: [ + 'gridId', + ] }, + { accessor: 'ownerSubject', name: 'world_owner_subject_idx_btree', algorithm: 'btree', columns: [ + 'ownerSubject', + ] }, + ], + constraints: [ + { name: 'world_owner_subject_key', constraint: 'unique', columns: ['ownerSubject'] }, + ], + }, WorldRow), + worldEvent: __table({ + name: 'world_event', + indexes: [ + { accessor: 'action', name: 'world_event_action_idx_btree', algorithm: 'btree', columns: [ + 'action', + ] }, + { accessor: 'allowed', name: 'world_event_allowed_idx_btree', algorithm: 'btree', columns: [ + 'allowed', + ] }, + { accessor: 'createdAt', name: 'world_event_created_at_idx_btree', algorithm: 'btree', columns: [ + 'createdAt', + ] }, + { accessor: 'eventId', name: 'world_event_event_id_idx_btree', algorithm: 'btree', columns: [ + 'eventId', + ] }, + { accessor: 'ownerSubject', name: 'world_event_owner_subject_idx_btree', algorithm: 'btree', columns: [ + 'ownerSubject', + ] }, + ], + constraints: [ + { name: 'world_event_event_id_key', constraint: 'unique', columns: ['eventId'] }, + ], + }, WorldEventRow), + colonyCells: __table({ + name: 'colony_cells', + indexes: [ + ], + constraints: [ + ], + }, ColonyCellsRow), + colonyEntities: __table({ + name: 'colony_entities', + indexes: [ + ], + constraints: [ + ], + }, ColonyEntitiesRow), + colonyGrid: __table({ + name: 'colony_grid', + indexes: [ + ], + constraints: [ + ], + }, ColonyGridRow), + myAccessKeys: __table({ + name: 'my_access_keys', + indexes: [ + ], + constraints: [ + ], + }, MyAccessKeysRow), + "apiKeys.api_key_usage_admin": __table({ + name: 'apiKeys.api_key_usage_admin', + indexes: [ + ], + constraints: [ + ], + }, ApiKeys_ApiKeyUsageAdminRow), + "apiKeys.api_keys_admin": __table({ + name: 'apiKeys.api_keys_admin', + indexes: [ + ], + constraints: [ + ], + }, ApiKeys_ApiKeysAdminRow), + "apiKeys.my_api_keys": __table({ + name: 'apiKeys.my_api_keys', + indexes: [ + ], + constraints: [ + ], + }, ApiKeys_MyApiKeysRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("build", BuildReducer), + __reducerSchema("clear", ClearReducer), + __reducerSchema("clear_world_events", ClearWorldEventsReducer), + __reducerSchema("plant", PlantReducer), + __reducerSchema("presence_heartbeat", PresenceHeartbeatReducer), + __reducerSchema("presence_leave", PresenceLeaveReducer), + __reducerSchema("reset_world", ResetWorldReducer), + __reducerSchema("revoke_access_key", RevokeAccessKeyReducer), + __reducerSchema("terraform", TerraformReducer), + __reducerSchema("unbuild", UnbuildReducer), + __reducerSchema("apiKeys.add_admin_identity", ApiKeys_AddAdminIdentityReducer), + __reducerSchema("apiKeys.remove_admin_identity", ApiKeys_RemoveAdminIdentityReducer), + __reducerSchema("apiKeys.revoke_api_key", ApiKeys_RevokeApiKeyReducer), + __reducerSchema("apiKeys.revoke_api_key_for_subject", ApiKeys_RevokeApiKeyForSubjectReducer), + __reducerSchema("apiKeys.sweep_api_key_usage", ApiKeys_SweepApiKeyUsageReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("create_access_key", CreateAccessKeyProcedure.params, CreateAccessKeyProcedure.returnType), + __procedureSchema("ensure_world", EnsureWorldProcedure.params, EnsureWorldProcedure.returnType), + __procedureSchema("rotate_access_key", RotateAccessKeyProcedure.params, RotateAccessKeyProcedure.returnType), + __procedureSchema("apiKeys.create_api_key", ApiKeys_CreateApiKeyProcedure.params, ApiKeys_CreateApiKeyProcedure.returnType), + __procedureSchema("apiKeys.create_api_key_for_subject", ApiKeys_CreateApiKeyForSubjectProcedure.params, ApiKeys_CreateApiKeyForSubjectProcedure.returnType), + __procedureSchema("apiKeys.rotate_api_key", ApiKeys_RotateApiKeyProcedure.params, ApiKeys_RotateApiKeyProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + presenceEntry: __qb.presenceEntry, + world: __qb.world, + worldEvent: __qb.worldEvent, + colonyCells: __qb.colonyCells, + colonyEntities: __qb.colonyEntities, + colonyGrid: __qb.colonyGrid, + myAccessKeys: __qb.myAccessKeys, + apiKeys: { + apiKeyUsageAdmin: __qb["apiKeys.api_key_usage_admin"], + apiKeysAdmin: __qb["apiKeys.api_keys_admin"], + myApiKeys: __qb["apiKeys.my_api_keys"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + build: __reducerAccessors.build, + clear: __reducerAccessors.clear, + clearWorldEvents: __reducerAccessors.clearWorldEvents, + plant: __reducerAccessors.plant, + presenceHeartbeat: __reducerAccessors.presenceHeartbeat, + presenceLeave: __reducerAccessors.presenceLeave, + resetWorld: __reducerAccessors.resetWorld, + revokeAccessKey: __reducerAccessors.revokeAccessKey, + terraform: __reducerAccessors.terraform, + unbuild: __reducerAccessors.unbuild, + apiKeys: { + addAdminIdentity: __reducerAccessors["apiKeys.addAdminIdentity"], + removeAdminIdentity: __reducerAccessors["apiKeys.removeAdminIdentity"], + revokeApiKey: __reducerAccessors["apiKeys.revokeApiKey"], + revokeApiKeyForSubject: __reducerAccessors["apiKeys.revokeApiKeyForSubject"], + sweepApiKeyUsage: __reducerAccessors["apiKeys.sweepApiKeyUsage"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + createAccessKey: __procedureAccessors.createAccessKey, + ensureWorld: __procedureAccessors.ensureWorld, + rotateAccessKey: __procedureAccessors.rotateAccessKey, + apiKeys: { + createApiKey: __procedureAccessors["apiKeys.createApiKey"], + createApiKeyForSubject: __procedureAccessors["apiKeys.createApiKeyForSubject"], + rotateApiKey: __procedureAccessors["apiKeys.rotateApiKey"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/my_access_keys_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/my_access_keys_table.ts new file mode 100644 index 00000000000..63686ec3ca6 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/my_access_keys_table.ts @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + ApiKeyStatus, +} from "./types"; + + +export default __t.row({ + keyId: __t.string().primaryKey().name("key_id"), + prefix: __t.string(), + ownerSubject: __t.string().name("owner_subject"), + name: __t.string(), + scopesJson: __t.string().name("scopes_json"), + metadataJson: __t.option(__t.string()).name("metadata_json"), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp().name("created_at"), + expiresAt: __t.option(__t.timestamp()).name("expires_at"), + lastUsedAt: __t.option(__t.timestamp()).name("last_used_at"), + revokedAt: __t.option(__t.timestamp()).name("revoked_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/plant_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/plant_reducer.ts new file mode 100644 index 00000000000..2b4c9c3b0f5 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/plant_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + x: __t.i32(), + y: __t.i32(), + kind: __t.option(__t.string()), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/presence_entry_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/presence_entry_table.ts new file mode 100644 index 00000000000..70af5d56d15 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/presence_entry_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + key: __t.string().primaryKey(), + scope: __t.string(), + subject: __t.string(), + status: __t.string(), + activity: __t.option(__t.string()), + payloadJson: __t.option(__t.string()).name("payload_json"), + joinedAt: __t.timestamp().name("joined_at"), + lastSeenAt: __t.timestamp().name("last_seen_at"), + expiresAt: __t.timestamp().name("expires_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/presence_heartbeat_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/presence_heartbeat_reducer.ts new file mode 100644 index 00000000000..46b19979f5d --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/presence_heartbeat_reducer.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + scope: __t.string(), + name: __t.string(), + role: __t.string(), + color: __t.string(), + cx: __t.f64(), + cy: __t.f64(), + onGrid: __t.bool(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/presence_leave_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/presence_leave_reducer.ts new file mode 100644 index 00000000000..7a16fc253db --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/presence_leave_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + scope: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/reset_world_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/reset_world_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/reset_world_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/revoke_access_key_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/revoke_access_key_reducer.ts new file mode 100644 index 00000000000..74c389667b3 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/revoke_access_key_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + keyId: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/rotate_access_key_procedure.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/rotate_access_key_procedure.ts new file mode 100644 index 00000000000..8261e1ed9bc --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/rotate_access_key_procedure.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + keyId: __t.string(), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/terraform_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/terraform_reducer.ts new file mode 100644 index 00000000000..fea18233964 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/terraform_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + x: __t.i32(), + y: __t.i32(), + terrain: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/types.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..54619126f70 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,159 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AccessKeySummary = __t.object("AccessKeySummary", { + keyId: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + expiresAt: __t.option(__t.timestamp()), + lastUsedAt: __t.option(__t.timestamp()), + revokedAt: __t.option(__t.timestamp()), +}); +export type AccessKeySummary = __Infer; + +export const ApiKeyCreateResult = __t.object("ApiKeyCreateResult", { + keyId: __t.string(), + key: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + expiresAt: __t.option(__t.timestamp()), +}); +export type ApiKeyCreateResult = __Infer; + +// The tagged union or sum type for the algebraic type `ApiKeyStatus`. +export const ApiKeyStatus = __t.enum("ApiKeyStatus", { + Active: __t.unit(), + Revoked: __t.unit(), +}); +export type ApiKeyStatus = __Infer; + +export const CellState = __t.object("CellState", { + id: __t.u64(), + gridId: __t.u64(), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); +export type CellState = __Infer; + +export const ColonyCells = __t.object("ColonyCells", {}); +export type ColonyCells = __Infer; + +export const ColonyEntities = __t.object("ColonyEntities", {}); +export type ColonyEntities = __Infer; + +export const ColonyGrid = __t.object("ColonyGrid", {}); +export type ColonyGrid = __Infer; + +export const ColonySweepTick = __t.object("ColonySweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type ColonySweepTick = __Infer; + +export const EnsureWorldResult = __t.object("EnsureWorldResult", { + ownerSubject: __t.string(), + gridId: __t.u64(), +}); +export type EnsureWorldResult = __Infer; + +export const Grid = __t.object("Grid", { + id: __t.u64(), + ownerUserId: __t.string(), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32(), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Grid = __Infer; + +export const GridEntity = __t.object("GridEntity", { + id: __t.u64(), + gridId: __t.u64(), + ownerUserId: __t.string(), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool(), + label: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridEntity = __Infer; + +export const MyAccessKeys = __t.object("MyAccessKeys", {}); +export type MyAccessKeys = __Infer; + +export const PresenceConfig = __t.object("PresenceConfig", { + singleton: __t.bool(), + defaultTtlSeconds: __t.u32(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type PresenceConfig = __Infer; + +export const PresenceEntry = __t.object("PresenceEntry", { + key: __t.string(), + scope: __t.string(), + subject: __t.string(), + status: __t.string(), + activity: __t.option(__t.string()), + payloadJson: __t.option(__t.string()), + joinedAt: __t.timestamp(), + lastSeenAt: __t.timestamp(), + expiresAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type PresenceEntry = __Infer; + +export const World = __t.object("World", { + ownerSubject: __t.string(), + gridId: __t.u64(), + name: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type World = __Infer; + +export const WorldEvent = __t.object("WorldEvent", { + eventId: __t.u64(), + ownerSubject: __t.string(), + keyPrefix: __t.string(), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + message: __t.string(), + createdAt: __t.timestamp(), +}); +export type WorldEvent = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..4655da2c690 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/types/procedures.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as CreateAccessKeyProcedure from "../create_access_key_procedure"; +import * as EnsureWorldProcedure from "../ensure_world_procedure"; +import * as RotateAccessKeyProcedure from "../rotate_access_key_procedure"; + +export type CreateAccessKeyArgs = __Infer; +export type CreateAccessKeyResult = __Infer; +export type EnsureWorldArgs = __Infer; +export type EnsureWorldResult = __Infer; +export type RotateAccessKeyArgs = __Infer; +export type RotateAccessKeyResult = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..b44ba89c968 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import BuildReducer from "../build_reducer"; +import ClearReducer from "../clear_reducer"; +import ClearWorldEventsReducer from "../clear_world_events_reducer"; +import PlantReducer from "../plant_reducer"; +import PresenceHeartbeatReducer from "../presence_heartbeat_reducer"; +import PresenceLeaveReducer from "../presence_leave_reducer"; +import ResetWorldReducer from "../reset_world_reducer"; +import RevokeAccessKeyReducer from "../revoke_access_key_reducer"; +import TerraformReducer from "../terraform_reducer"; +import UnbuildReducer from "../unbuild_reducer"; + +export type BuildParams = __Infer; +export type ClearParams = __Infer; +export type ClearWorldEventsParams = __Infer; +export type PlantParams = __Infer; +export type PresenceHeartbeatParams = __Infer; +export type PresenceLeaveParams = __Infer; +export type ResetWorldParams = __Infer; +export type RevokeAccessKeyParams = __Infer; +export type TerraformParams = __Infer; +export type UnbuildParams = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/unbuild_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/unbuild_reducer.ts new file mode 100644 index 00000000000..c061193b961 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/unbuild_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + x: __t.i32(), + y: __t.i32(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/world_event_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/world_event_table.ts new file mode 100644 index 00000000000..ec04549e0ef --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/world_event_table.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + eventId: __t.u64().primaryKey().name("event_id"), + ownerSubject: __t.string().name("owner_subject"), + keyPrefix: __t.string().name("key_prefix"), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + message: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/world_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/world_table.ts new file mode 100644 index 00000000000..7a4304b505b --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/world_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + ownerSubject: __t.string().primaryKey().name("owner_subject"), + gridId: __t.u64().name("grid_id"), + name: __t.string(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/share-key.ts b/spacetime-api-keys-ts/example/src/share-key.ts new file mode 100644 index 00000000000..7e7c66c3e99 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/share-key.ts @@ -0,0 +1,16 @@ +export function shareKeyFromHash(hash: string): string | null { + if (!hash.startsWith('#')) return null; + const key = new URLSearchParams(hash.slice(1)).get('key')?.trim(); + return key || null; +} + +export function parseShareKey(raw: string): string | null { + const value = raw.trim(); + if (!value) return null; + + try { + return shareKeyFromHash(new URL(value).hash); + } catch { + return value; + } +} diff --git a/spacetime-api-keys-ts/example/tsconfig.json b/spacetime-api-keys-ts/example/tsconfig.json new file mode 100644 index 00000000000..ebdadf9f78c --- /dev/null +++ b/spacetime-api-keys-ts/example/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-api-keys-ts/package.json b/spacetime-api-keys-ts/package.json new file mode 100644 index 00000000000..2cfcc048f8f --- /dev/null +++ b/spacetime-api-keys-ts/package.json @@ -0,0 +1,68 @@ +{ + "name": "@spacetimedb/api-keys", + "description": "API key issuance, verification, rotation, and usage tracking for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-api-keys-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-api-keys-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "api-keys", + "authentication", + "typescript" + ], + "scripts": { + "build": "spacetime build", + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts", + "spacetime:generate": "spacetime generate --lang typescript --out-dir ts-codegen", + "publish:module": "spacetime publish", + "publish:local": "spacetime publish --server local --yes spacetime-api-keys", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-api-keys" + }, + "dependencies": { + "@spacetimedb/crypto": "workspace:^" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^22.10.2", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-api-keys-ts/scripts/test.ts b/spacetime-api-keys-ts/scripts/test.ts new file mode 100644 index 00000000000..788a14fedad --- /dev/null +++ b/spacetime-api-keys-ts/scripts/test.ts @@ -0,0 +1,30 @@ +import * as assert from 'node:assert/strict'; +import { + base64Url, + extractLookupPrefix, + hashApiKey, + matchesApiKeyHash, + hasScope, +} from '../src/keys.ts'; + +assert.equal(base64Url(new Uint8Array([102, 111, 111])), 'Zm9v'); +assert.equal(base64Url(new Uint8Array([255, 255, 255])), '____'); +assert.equal( + extractLookupPrefix('stdb_live_abcdefghijklmnop'), + 'stdb_live_abcdefghij' +); +assert.equal(extractLookupPrefix('missing-secret'), undefined); + +const key = 'stdb_live_abcdefghijklmnop'; +const hash = hashApiKey(key); +assert.match(hash, /^[0-9a-f]{64}$/); +assert.equal(matchesApiKeyHash(key, hash), true); +assert.equal(matchesApiKeyHash(`${key}x`, hash), false); +assert.equal(matchesApiKeyHash(key, 'not-hex'), false); + +assert.equal(hasScope('["files:*","jobs:read"]', 'files:write'), true); +assert.equal(hasScope('["files:*","jobs:read"]', 'jobs:read'), true); +assert.equal(hasScope('["files:*","jobs:read"]', 'admin:write'), false); +assert.equal(hasScope('not-json', 'files:read'), false); + +console.log('api-keys tests passed'); diff --git a/spacetime-api-keys-ts/src/index.ts b/spacetime-api-keys-ts/src/index.ts new file mode 100644 index 00000000000..d10b7a64098 --- /dev/null +++ b/spacetime-api-keys-ts/src/index.ts @@ -0,0 +1,14 @@ +export { default, init } from './submodule/schema'; +export { + add_admin_identity, + apiKeyUsageAdmin, + apiKeysAdmin, + create_api_key, + create_api_key_for_subject, + myApiKeys, + remove_admin_identity, + revoke_api_key, + revoke_api_key_for_subject, + rotate_api_key, + sweep_api_key_usage, +} from './submodule/operations'; diff --git a/spacetime-api-keys-ts/src/keys.ts b/spacetime-api-keys-ts/src/keys.ts new file mode 100644 index 00000000000..72db80e14c7 --- /dev/null +++ b/spacetime-api-keys-ts/src/keys.ts @@ -0,0 +1,79 @@ +import { + bytesToHex, + hexToBytes, + sha256, + timingSafeEqual, +} from '@spacetimedb/crypto'; + +export const LOOKUP_SECRET_CHARS = 10; +const textEncoder = new TextEncoder(); + +export function base64Url(bytes: Uint8Array): string { + const alphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + let out = ''; + let i = 0; + for (; i + 2 < bytes.length; i += 3) { + out += alphabet[bytes[i] >> 2]; + out += alphabet[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += alphabet[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; + out += alphabet[bytes[i + 2] & 63]; + } + if (i < bytes.length) { + out += alphabet[bytes[i] >> 2]; + if (i + 1 === bytes.length) { + out += alphabet[(bytes[i] & 3) << 4]; + } else { + out += alphabet[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += alphabet[(bytes[i + 1] & 15) << 2]; + } + } + return out; +} + +export function extractLookupPrefix(key: string): string | undefined { + const trimmed = key.trim(); + const lastUnderscore = trimmed.lastIndexOf('_'); + if (lastUnderscore <= 0) return undefined; + const keyPrefix = trimmed.slice(0, lastUnderscore); + const secret = trimmed.slice(lastUnderscore + 1); + if (secret.length < LOOKUP_SECRET_CHARS) return undefined; + return `${keyPrefix}_${secret.slice(0, LOOKUP_SECRET_CHARS)}`; +} + +export function hashApiKey(key: string): string { + return bytesToHex(sha256(textEncoder.encode(key))); +} + +export function matchesApiKeyHash(key: string, expectedHex: string): boolean { + try { + return timingSafeEqual( + hexToBytes(expectedHex), + sha256(textEncoder.encode(key)) + ); + } catch { + return false; + } +} + +export function hasScope( + scopesJson: string, + requiredScope: string | undefined +): boolean { + if (requiredScope === undefined || requiredScope.trim() === '') return true; + const required = requiredScope.trim(); + let scopes: unknown; + try { + scopes = JSON.parse(scopesJson); + } catch { + return false; + } + if (!Array.isArray(scopes)) return false; + return scopes.some( + scope => + typeof scope === 'string' && + (scope === '*' || + scope === required || + (scope.endsWith(':*') && required.startsWith(scope.slice(0, -1)))) + ); +} diff --git a/spacetime-api-keys-ts/src/submodule.ts b/spacetime-api-keys-ts/src/submodule.ts new file mode 100644 index 00000000000..3b095be7921 --- /dev/null +++ b/spacetime-api-keys-ts/src/submodule.ts @@ -0,0 +1,33 @@ +export { default } from './submodule/schema'; +export { + ApiKeyStatus, + apiKey, + apiKeyAdminIdentity, + apiKeyCreateResult, + apiKeyStatus, + apiKeySummary, + apiKeyUsage, + apiKeyVerifyResult, + t, +} from './submodule/schema'; +export { installApiKeys } from './submodule/install'; +export { + add_admin_identity, + apiKeyUsageAdmin, + apiKeysAdmin, + createApiKey, + create_api_key, + create_api_key_for_subject, + myApiKeys, + remove_admin_identity, + revokeApiKey, + revoke_api_key, + revoke_api_key_for_subject, + rotateApiKey, + rotate_api_key, + sweep_api_key_usage, + verifyApiKey, + type ApiKeyVerifyResult, + type CreateApiKeyArgs, + type VerifyApiKeyArgs, +} from './submodule/operations'; diff --git a/spacetime-api-keys-ts/src/submodule/auth.ts b/spacetime-api-keys-ts/src/submodule/auth.ts new file mode 100644 index 00000000000..6837339b855 --- /dev/null +++ b/spacetime-api-keys-ts/src/submodule/auth.ts @@ -0,0 +1,19 @@ +import type { + ReducerModuleCtx, + TransactionModuleCtx, + ViewModuleCtx, +} from './schema'; +import { SenderError } from './schema'; + +type AdminCtx = ReducerModuleCtx | TransactionModuleCtx | ViewModuleCtx; + +export function isAdmin(ctx: AdminCtx, sender = ctx.sender): boolean { + return ctx.db.apiKeyAdminIdentity.identity.find(sender) != null; +} + +export function requireAdmin( + ctx: ReducerModuleCtx | TransactionModuleCtx, + sender = ctx.sender +): void { + if (!isAdmin(ctx, sender)) throw new SenderError('api_keys.not_authorized'); +} diff --git a/spacetime-api-keys-ts/src/submodule/install.ts b/spacetime-api-keys-ts/src/submodule/install.ts new file mode 100644 index 00000000000..f48d6aafdaa --- /dev/null +++ b/spacetime-api-keys-ts/src/submodule/install.ts @@ -0,0 +1,9 @@ +import type { ReducerModuleCtx } from './schema'; + +export function installApiKeys(ctx: ReducerModuleCtx) { + if (ctx.db.apiKeyAdminIdentity.identity.find(ctx.sender) != null) return; + ctx.db.apiKeyAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-api-keys-ts/src/submodule/operations.ts b/spacetime-api-keys-ts/src/submodule/operations.ts new file mode 100644 index 00000000000..b955e46f55b --- /dev/null +++ b/spacetime-api-keys-ts/src/submodule/operations.ts @@ -0,0 +1,696 @@ +import { Timestamp } from 'spacetimedb'; +import { Range, type Infer } from 'spacetimedb/server'; +import { + ApiKeyStatus, + apiKey, + apiKeyCreateResult, + apiKeySummary, + apiKeyUsageSummary, + spacetimedb, + t, + SenderError, + type ReducerModuleCtx, + type ViewModuleCtx, + type WriteCtx, +} from './schema'; +import { isAdmin, requireAdmin } from './auth'; +import { sha256 } from '@spacetimedb/crypto'; +import { + base64Url, + extractLookupPrefix, + hashApiKey, + matchesApiKeyHash, + hasScope, + LOOKUP_SECRET_CHARS, +} from '../keys'; + +const DEFAULT_KEY_PREFIX = 'stdb_live'; +const MAX_NAME_LENGTH = 120; +const MAX_OWNER_SUBJECT_LENGTH = 256; +const MAX_SCOPE_LENGTH = 128; +const MAX_SCOPES = 128; +const MAX_METADATA_JSON_LENGTH = 8192; +const MAX_RAW_KEY_LENGTH = 128; +const MAX_ACTIVE_KEYS_PER_OWNER = 50; +const MAX_EXPIRATION_SECONDS = 60 * 60 * 24 * 365 * 10; +const MAX_USAGE_SWEEP_ROWS = 1000; +const ONE_SECOND_MICROS = 1_000_000n; + +const textEncoder = new TextEncoder(); + +type ApiKeyRow = Infer; + +export type CreateApiKeyArgs = { + ownerSubject: string; + name: string; + scopesJson: string; + metadataJson?: string | undefined; + expiresInSeconds?: number | undefined; + keyPrefix?: string | undefined; +}; + +export type VerifyApiKeyArgs = { + key: string; + requiredScope?: string | undefined; + action?: string | undefined; +}; + +export type ApiKeyVerifyResult = { + allowed: boolean; + reason: string; + keyId: string | undefined; + prefix: string | undefined; + ownerSubject: string | undefined; + scopesJson: string | undefined; + metadataJson: string | undefined; +}; + +function throwSenderError(message: string): never { + throw new SenderError(message); +} + +function takeRows(rows: Iterable, limit: number): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +function uniqueSecret(ctx: WriteCtx): string { + const material = [ + ctx.newUuidV7().toString(), + ctx.newUuidV7().toString(), + ctx.timestamp.microsSinceUnixEpoch.toString(), + ].join(':'); + // The key is `${keyPrefix}_${secret}` and lookup splits on the last '_', so + // the secret must not contain '_' or the prefix lookup lands mid-secret and + // verification fails. base64url can emit '_', so fold it to '-'. + return base64Url(sha256(textEncoder.encode(material))).replace(/_/g, '-'); +} + +function normalizeKeyPrefix(prefix: string | undefined): string { + const value = (prefix ?? DEFAULT_KEY_PREFIX).trim(); + if (!/^[A-Za-z][A-Za-z0-9_]{1,31}$/.test(value)) { + throwSenderError('api_keys.invalid_key_prefix'); + } + return value; +} + +function generateRawKey( + ctx: WriteCtx, + keyPrefix: string +): { key: string; prefix: string } { + const secret = uniqueSecret(ctx); + const key = `${keyPrefix}_${secret}`; + const prefix = `${keyPrefix}_${secret.slice(0, LOOKUP_SECRET_CHARS)}`; + return { key, prefix }; +} + +function normalizeName(name: string): string { + const value = name.trim().replace(/\s+/g, ' '); + if (value.length === 0 || value.length > MAX_NAME_LENGTH) { + throwSenderError('api_keys.invalid_name'); + } + return value; +} + +function normalizeOwnerSubject(ownerSubject: string): string { + const value = ownerSubject.trim(); + if (value.length === 0 || value.length > MAX_OWNER_SUBJECT_LENGTH) { + throwSenderError('api_keys.invalid_owner_subject'); + } + return value; +} + +function normalizeAction( + action: string | undefined, + requiredScope: string | undefined +): string { + const value = (action ?? requiredScope ?? 'verify').trim(); + if (value.length === 0 || value.length > MAX_SCOPE_LENGTH) { + throwSenderError('api_keys.invalid_action'); + } + return value; +} + +function normalizeRequiredScope( + requiredScope: string | undefined +): string | undefined { + if (requiredScope === undefined) return undefined; + const value = requiredScope.trim(); + if ( + value.length === 0 || + value.length > MAX_SCOPE_LENGTH || + !/^[A-Za-z0-9:_*.-]+$/.test(value) + ) { + throwSenderError('api_keys.invalid_required_scope'); + } + return value; +} + +function normalizeScopesJson(scopesJson: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(scopesJson); + } catch { + throwSenderError('api_keys.invalid_scopes_json'); + } + if (!Array.isArray(parsed)) throwSenderError('api_keys.invalid_scopes_json'); + if (parsed.length === 0 || parsed.length > MAX_SCOPES) { + throwSenderError('api_keys.invalid_scopes_json'); + } + const seen = new Set(); + const scopes: string[] = []; + for (const raw of parsed) { + if (typeof raw !== 'string') + throwSenderError('api_keys.invalid_scopes_json'); + const scope = raw.trim(); + if (scope.length === 0 || scope.length > MAX_SCOPE_LENGTH) { + throwSenderError('api_keys.invalid_scopes_json'); + } + if (!/^[A-Za-z0-9:_*.-]+$/.test(scope)) { + throwSenderError('api_keys.invalid_scopes_json'); + } + if (!seen.has(scope)) { + seen.add(scope); + scopes.push(scope); + } + } + return JSON.stringify(scopes); +} + +function normalizeMetadataJson( + metadataJson: string | undefined +): string | undefined { + if (metadataJson === undefined) return undefined; + const value = metadataJson.trim(); + if (value.length === 0) return undefined; + if (value.length > MAX_METADATA_JSON_LENGTH) + throwSenderError('api_keys.invalid_metadata_json'); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throwSenderError('api_keys.invalid_metadata_json'); + } + if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') { + throwSenderError('api_keys.invalid_metadata_json'); + } + return JSON.stringify(parsed); +} + +function senderSubject(sender: unknown): string { + if ( + sender && + typeof (sender as { toHexString?: unknown }).toHexString === 'function' + ) { + return (sender as { toHexString: () => string }).toHexString(); + } + return String(sender); +} + +function expiresAtFromSeconds( + ctx: WriteCtx, + expiresInSeconds: number | undefined +): Timestamp | undefined { + if (expiresInSeconds === undefined) return undefined; + if ( + !Number.isInteger(expiresInSeconds) || + expiresInSeconds <= 0 || + expiresInSeconds > MAX_EXPIRATION_SECONDS + ) { + throwSenderError('api_keys.invalid_expires_in_seconds'); + } + return new Timestamp( + (ctx.timestamp.microsSinceUnixEpoch as bigint) + + BigInt(expiresInSeconds) * ONE_SECOND_MICROS + ); +} + +function isExpired(row: ApiKeyRow, now: Timestamp): boolean { + return ( + row.expiresAt !== undefined && + (row.expiresAt.microsSinceUnixEpoch as bigint) <= + (now.microsSinceUnixEpoch as bigint) + ); +} + +function toSummary(row: ApiKeyRow) { + return { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + name: row.name, + scopesJson: row.scopesJson, + metadataJson: row.metadataJson, + status: row.status, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + lastUsedAt: row.lastUsedAt, + revokedAt: row.revokedAt, + }; +} + +function toCreateResult(row: ApiKeyRow, key: string) { + return { + keyId: row.keyId, + key, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + name: row.name, + scopesJson: row.scopesJson, + metadataJson: row.metadataJson, + status: row.status, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + }; +} + +function recordUsage( + ctx: WriteCtx, + args: { + keyId?: string | undefined; + prefix?: string | undefined; + ownerSubject?: string | undefined; + action: string; + allowed: boolean; + reason: string; + } +): void { + ctx.db.apiKeyUsage.insert({ + usageId: 0n, + keyId: args.keyId ?? '', + prefix: args.prefix ?? '', + ownerSubject: args.ownerSubject ?? '', + action: args.action, + allowed: args.allowed, + reason: args.reason, + usedAt: ctx.timestamp, + usedAtOrder: -ctx.timestamp.microsSinceUnixEpoch, + }); +} + +export function createApiKey(ctx: WriteCtx, args: CreateApiKeyArgs) { + const ownerSubject = normalizeOwnerSubject(args.ownerSubject); + const name = normalizeName(args.name); + const scopesJson = normalizeScopesJson(args.scopesJson); + const metadataJson = normalizeMetadataJson(args.metadataJson); + const expiresAt = expiresAtFromSeconds(ctx, args.expiresInSeconds); + const keyPrefix = normalizeKeyPrefix(args.keyPrefix); + let activeKeys = 0; + for (const row of ctx.db.apiKey.ownerSubject.filter(ownerSubject)) { + if (row.status.tag === 'Active' && !isExpired(row, ctx.timestamp)) { + activeKeys++; + if (activeKeys >= MAX_ACTIVE_KEYS_PER_OWNER) { + throwSenderError('api_keys.active_key_limit_reached'); + } + } + } + const { key, prefix } = generateRawKey(ctx, keyPrefix); + const keyId = `ak_${ctx.newUuidV7().toString()}`; + const row = ctx.db.apiKey.insert({ + keyId, + prefix, + hash: hashApiKey(key), + ownerSubject, + name, + scopesJson, + metadataJson, + status: ApiKeyStatus.Active, + createdAt: ctx.timestamp, + createdAtOrder: -ctx.timestamp.microsSinceUnixEpoch, + expiresAt, + lastUsedAt: undefined, + revokedAt: undefined, + }); + recordUsage(ctx, { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action: 'create', + allowed: true, + reason: 'created', + }); + return toCreateResult(row, key); +} + +function denied( + ctx: WriteCtx, + args: { + keyId?: string | undefined; + prefix?: string | undefined; + ownerSubject?: string | undefined; + action: string; + reason: string; + record?: boolean | undefined; + } +): ApiKeyVerifyResult { + if (args.record !== false) recordUsage(ctx, { ...args, allowed: false }); + return { + allowed: false, + reason: args.reason, + keyId: undefined, + prefix: args.prefix, + ownerSubject: undefined, + scopesJson: undefined, + metadataJson: undefined, + }; +} + +export function verifyApiKey( + ctx: WriteCtx, + args: VerifyApiKeyArgs +): ApiKeyVerifyResult { + const requiredScope = normalizeRequiredScope(args.requiredScope); + const action = normalizeAction(args.action, requiredScope); + const key = args.key.trim(); + if (key.length === 0 || key.length > MAX_RAW_KEY_LENGTH) { + return denied(ctx, { action, reason: 'invalid_key', record: false }); + } + const prefix = extractLookupPrefix(key); + if (prefix === undefined) { + return denied(ctx, { action, reason: 'invalid_key', record: false }); + } + const row = ctx.db.apiKey.prefix.find(prefix); + if (!row) { + return denied(ctx, { + prefix, + action, + reason: 'unknown_key', + record: false, + }); + } + if (!matchesApiKeyHash(key, row.hash)) { + return denied(ctx, { + prefix, + action, + reason: 'invalid_key', + record: false, + }); + } + if (row.status.tag !== 'Active') { + return denied(ctx, { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action, + reason: 'revoked', + }); + } + if (isExpired(row, ctx.timestamp)) { + return denied(ctx, { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action, + reason: 'expired', + }); + } + if (!hasScope(row.scopesJson, requiredScope)) { + return denied(ctx, { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action, + reason: 'scope_denied', + }); + } + ctx.db.apiKey.keyId.update({ + ...row, + lastUsedAt: ctx.timestamp, + }); + recordUsage(ctx, { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action, + allowed: true, + reason: 'allowed', + }); + return { + allowed: true, + reason: 'allowed', + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + scopesJson: row.scopesJson, + metadataJson: row.metadataJson, + }; +} + +function canManageKey( + ctx: ReducerModuleCtx | WriteCtx, + row: ApiKeyRow, + subject: string +): boolean { + return row.ownerSubject === subject || isAdmin(ctx, ctx.sender); +} + +export function revokeApiKey( + ctx: WriteCtx, + args: { keyId: string; ownerSubject?: string | undefined } +): void { + const keyId = args.keyId.trim(); + if (!keyId) throwSenderError('api_keys.invalid_key_id'); + const row = ctx.db.apiKey.keyId.find(keyId); + if (!row) throwSenderError('api_keys.not_found'); + const subject = normalizeOwnerSubject( + args.ownerSubject ?? senderSubject(ctx.sender) + ); + if (!canManageKey(ctx, row, subject)) + throwSenderError('api_keys.not_authorized'); + if (row.status.tag === 'Revoked') return; + ctx.db.apiKey.keyId.update({ + ...row, + status: ApiKeyStatus.Revoked, + revokedAt: ctx.timestamp, + }); + recordUsage(ctx, { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action: 'revoke', + allowed: true, + reason: 'revoked', + }); +} + +export function rotateApiKey( + ctx: WriteCtx, + args: { + keyId: string; + ownerSubject?: string | undefined; + expiresInSeconds?: number | undefined; + keyPrefix?: string | undefined; + } +) { + const keyId = args.keyId.trim(); + if (!keyId) throwSenderError('api_keys.invalid_key_id'); + const row = ctx.db.apiKey.keyId.find(keyId); + if (!row) throwSenderError('api_keys.not_found'); + const subject = normalizeOwnerSubject( + args.ownerSubject ?? senderSubject(ctx.sender) + ); + if (!canManageKey(ctx, row, subject)) + throwSenderError('api_keys.not_authorized'); + const keyPrefix = normalizeKeyPrefix(args.keyPrefix); + const { key, prefix } = generateRawKey(ctx, keyPrefix); + const expiresAt = + args.expiresInSeconds === undefined + ? row.expiresAt + : expiresAtFromSeconds(ctx, args.expiresInSeconds); + const updated = { + ...row, + prefix, + hash: hashApiKey(key), + status: ApiKeyStatus.Active, + expiresAt, + lastUsedAt: undefined, + revokedAt: undefined, + }; + ctx.db.apiKey.keyId.update(updated); + recordUsage(ctx, { + keyId: row.keyId, + prefix, + ownerSubject: row.ownerSubject, + action: 'rotate', + allowed: true, + reason: 'rotated', + }); + return toCreateResult(updated, key); +} + +export const create_api_key = spacetimedb.procedure( + { + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + expiresInSeconds: t.option(t.u32()), + keyPrefix: t.option(t.string()), + }, + apiKeyCreateResult, + (ctx, args) => + ctx.withTx(tx => + createApiKey(tx, { + ownerSubject: senderSubject(ctx.sender), + name: args.name, + scopesJson: args.scopesJson, + metadataJson: args.metadataJson, + expiresInSeconds: args.expiresInSeconds, + keyPrefix: args.keyPrefix, + }) + ) +); + +export const create_api_key_for_subject = spacetimedb.procedure( + { + ownerSubject: t.string(), + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + expiresInSeconds: t.option(t.u32()), + keyPrefix: t.option(t.string()), + }, + apiKeyCreateResult, + (ctx, args) => + ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + return createApiKey(tx, args); + }) +); + +export const rotate_api_key = spacetimedb.procedure( + { + keyId: t.string(), + expiresInSeconds: t.option(t.u32()), + keyPrefix: t.option(t.string()), + }, + apiKeyCreateResult, + (ctx, args) => + ctx.withTx(tx => + rotateApiKey(tx, { + keyId: args.keyId, + ownerSubject: senderSubject(ctx.sender), + expiresInSeconds: args.expiresInSeconds, + keyPrefix: args.keyPrefix, + }) + ) +); + +export const revoke_api_key = spacetimedb.reducer( + { keyId: t.string() }, + (ctx, args) => { + revokeApiKey(ctx, { + keyId: args.keyId, + ownerSubject: senderSubject(ctx.sender), + }); + } +); + +export const revoke_api_key_for_subject = spacetimedb.reducer( + { keyId: t.string(), ownerSubject: t.string() }, + (ctx, args) => { + requireAdmin(ctx); + revokeApiKey(ctx, args); + } +); + +export const add_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, args) => { + requireAdmin(ctx); + if (ctx.db.apiKeyAdminIdentity.identity.find(args.identity) == null) { + ctx.db.apiKeyAdminIdentity.insert({ + identity: args.identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + } +); + +export const remove_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, args) => { + requireAdmin(ctx); + const row = ctx.db.apiKeyAdminIdentity.identity.find(args.identity); + if (!row) return; + if (ctx.db.apiKeyAdminIdentity.count() <= 1n) { + throwSenderError('api_keys.cannot_remove_last_admin'); + } + ctx.db.apiKeyAdminIdentity.delete(row); + } +); + +export const sweep_api_key_usage = spacetimedb.reducer( + { maxAgeSeconds: t.u32(), maxRows: t.u32() }, + (ctx, args) => { + requireAdmin(ctx); + if ( + args.maxAgeSeconds < 3600 || + args.maxAgeSeconds > MAX_EXPIRATION_SECONDS || + args.maxRows < 1 || + args.maxRows > MAX_USAGE_SWEEP_ROWS + ) { + throwSenderError('api_keys.invalid_sweep_args'); + } + const cutoff = new Timestamp( + (ctx.timestamp.microsSinceUnixEpoch as bigint) - + BigInt(args.maxAgeSeconds) * ONE_SECOND_MICROS + ); + let examined = 0; + for (const row of ctx.db.apiKeyUsage.usedAt.filter( + new Range(undefined, { tag: 'included', value: cutoff }) + )) { + if (examined >= args.maxRows) break; + examined++; + ctx.db.apiKeyUsage.usageId.delete(row.usageId); + } + } +); + +export const myApiKeys = spacetimedb.view( + { name: 'my_api_keys', public: true }, + t.array(apiKeySummary), + ctx => { + const subject = senderSubject(ctx.sender); + return takeRows(ctx.db.apiKey.ownerSubject.filter(subject), 500).map( + toSummary + ); + } +); + +export const apiKeysAdmin = spacetimedb.view( + { name: 'api_keys_admin', public: true }, + t.array(apiKeySummary), + (ctx: ViewModuleCtx) => { + if (!isAdmin(ctx)) return []; + const rows = takeRows( + ctx.db.apiKey.createdAtOrder.filter(new Range()), + 200 + ); + return rows.map(toSummary); + } +); + +export const apiKeyUsageAdmin = spacetimedb.view( + { name: 'api_key_usage_admin', public: true }, + t.array(apiKeyUsageSummary), + (ctx: ViewModuleCtx) => { + if (!isAdmin(ctx)) return []; + return takeRows( + ctx.db.apiKeyUsage.usedAtOrder.filter(new Range()), + 100 + ).map(row => ({ + usageId: row.usageId, + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action: row.action, + allowed: row.allowed, + reason: row.reason, + usedAt: row.usedAt, + })); + } +); diff --git a/spacetime-api-keys-ts/src/submodule/schema.ts b/spacetime-api-keys-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..d17acc78537 --- /dev/null +++ b/spacetime-api-keys-ts/src/submodule/schema.ts @@ -0,0 +1,130 @@ +import { + SenderError, + schema, + table, + t, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { installApiKeys } from './install'; + +export const apiKeyStatus = t.enum('ApiKeyStatus', ['Active', 'Revoked']); + +export const ApiKeyStatus = { + Active: { tag: 'Active' as const }, + Revoked: { tag: 'Revoked' as const }, +}; + +export const apiKey = table( + { name: 'api_key', public: false }, + { + keyId: t.string().primaryKey(), + prefix: t.string().unique(), + hash: t.string(), + ownerSubject: t.string().index(), + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + status: apiKeyStatus.index(), + createdAt: t.timestamp().index(), + createdAtOrder: t.i64().index(), + expiresAt: t.option(t.timestamp()), + lastUsedAt: t.option(t.timestamp()), + revokedAt: t.option(t.timestamp()), + } +); + +export const apiKeyAdminIdentity = table( + { name: 'api_key_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +export const apiKeyUsage = table( + { name: 'api_key_usage', public: false }, + { + usageId: t.u64().primaryKey().autoInc(), + keyId: t.string().index(), + prefix: t.string(), + ownerSubject: t.string().index(), + action: t.string().index(), + allowed: t.bool().index(), + reason: t.string().index(), + usedAt: t.timestamp().index(), + usedAtOrder: t.i64().index(), + } +); + +export const apiKeySummary = t.object('ApiKeySummary', { + keyId: t.string(), + prefix: t.string(), + ownerSubject: t.string(), + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + status: apiKeyStatus, + createdAt: t.timestamp(), + expiresAt: t.option(t.timestamp()), + lastUsedAt: t.option(t.timestamp()), + revokedAt: t.option(t.timestamp()), +}); + +export const apiKeyCreateResult = t.object('ApiKeyCreateResult', { + keyId: t.string(), + key: t.string(), + prefix: t.string(), + ownerSubject: t.string(), + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + status: apiKeyStatus, + createdAt: t.timestamp(), + expiresAt: t.option(t.timestamp()), +}); + +export const apiKeyUsageSummary = t.object('ApiKeyUsageSummary', { + usageId: t.u64(), + keyId: t.string(), + prefix: t.string(), + ownerSubject: t.string(), + action: t.string(), + allowed: t.bool(), + reason: t.string(), + usedAt: t.timestamp(), +}); + +export const apiKeyVerifyResult = t.object('ApiKeyVerifyResult', { + allowed: t.bool(), + reason: t.string(), + keyId: t.option(t.string()), + prefix: t.option(t.string()), + ownerSubject: t.option(t.string()), + scopesJson: t.option(t.string()), + metadataJson: t.option(t.string()), +}); + +export const spacetimedb = schema({ + apiKey, + apiKeyAdminIdentity, + apiKeyUsage, +}); + +export const init = spacetimedb.init(ctx => { + installApiKeys(ctx); +}); + +export default spacetimedb; + +export type Schema = InferSchema; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx; +export type ViewModuleCtx = ViewCtx; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; + +export { SenderError, t }; diff --git a/spacetime-api-keys-ts/tsconfig.json b/spacetime-api-keys-ts/tsconfig.json new file mode 100644 index 00000000000..b2b4ecdcdda --- /dev/null +++ b/spacetime-api-keys-ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-grid-ts/.npmrc b/spacetime-grid-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-grid-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-grid-ts/LICENSE.txt b/spacetime-grid-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-grid-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-grid-ts/README.md b/spacetime-grid-ts/README.md new file mode 100644 index 00000000000..2cc52b20dbe --- /dev/null +++ b/spacetime-grid-ts/README.md @@ -0,0 +1,233 @@ +# @spacetimedb/grid + +Square and hex grids for SpacetimeDB modules, with sparse cell costs, owned or +collaborative entities, A\* pathfinding, and Dijkstra movement ranges inside the +gameplay transaction. + +--- + +## Install + +```bash +npm install @spacetimedb/grid spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +A grid is a row plus its associated `cellState`, `gridEntity`, and `entityPath` +rows. Everything is regular SpacetimeDB state, so clients subscribe to grid +changes like any other table. + +## Usage + +### Integrate into an application + +Register the Grid namespace, initialize it from the host lifecycle hook, and wrap +its helpers with the application's ownership rules: + +```ts +import { schema, t } from 'spacetimedb/server'; +import * as grid from '@spacetimedb/grid/submodule'; + +const spacetimedb = schema({ grid }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + grid.installGrid(ctx.as.grid); +}); + +export const create_player_grid = spacetimedb.procedure( + grid.createGridParams, + t.u64(), + (ctx, args) => grid.createGrid(ctx.as.grid, args, ctx.sender.toHexString()) +); +``` + +The submodule treats owner values as opaque strings. Host operations must map +the authenticated caller to that string before calling helpers such as +`createGrid` or `moveEntity`. See the +[Grid Tactics host module](./example/spacetimedb/) +for an authenticated boundary and scoped views. + +The generated client calls the host procedure, then subscribes to the host's +caller-scoped grid views: + +```ts +import { tables } from './module_bindings'; + +const gridId = await conn.procedures.createPlayerGrid({ + name: 'Arena', + kind: 'square', + orientation: 'flat', + width: 32, + height: 32, + defaultCost: 1, + connectivity: 4, + mode: 'owner', +}); + +conn.subscriptionBuilder().subscribe([tables.myGrids]); +``` + +### Standalone table builders + +```ts +import { + gridRow, + cellStateRow, + gridEntityRow, + entityPathRow, +} from '@spacetimedb/grid/rows'; +``` + +### `grid` + +| Field | Type | Notes | +| ------------------ | ----------------- | --------------------------------------------------------------------------------------- | +| `id` | `u64` PK auto-inc | | +| `ownerUserId` | `string` indexed | Opaque identity, application user ID, or host-defined actor ID | +| `name` | `string` | | +| `kind` | `string` | `GRID_KIND_SQUARE` or `GRID_KIND_HEX` | +| `orientation` | `string` | `GRID_ORIENTATION_FLAT` or `GRID_ORIENTATION_POINTY` (ignored when `kind === 'square'`) | +| `width` / `height` | `i32` | Up to 1024 each | +| `defaultCost` | `i32` | Per-cell traversal cost when no sparse row exists | +| `connectivity` | `i32` | Square: `4` or `8`. Hex: always `6`. | +| `mode` | `string` | `GRID_MODE_OWNER` (creator-only mutation) or `GRID_MODE_COLLABORATIVE` | + +### `cellState` + +Sparse cell state stores rows for non-default cells. `cost <= 0` blocks the +cell. Rows are indexed by `gridId`. + +### `gridEntity` + +Entities placed on the grid. Each has `ownerUserId`, `kind` (user-defined +string), and `blocksMovement` for pathfinding. Movement uses `ownerUserId` for +authorization. + +### `entityPath` + +The last path written for an entity, with one row per `entityId`. Consumers call +`computePath` after cost-map changes to refresh the snapshot. + +Helper types `pathCell`, `pathResult`, and `reachableCell` are exported for use in your own procedure signatures. + +## Constants + +| Constant | Value | +| ------------------------- | ----------------- | +| `GRID_KIND_SQUARE` | `'square'` | +| `GRID_KIND_HEX` | `'hex'` | +| `GRID_ORIENTATION_FLAT` | `'flat'` | +| `GRID_ORIENTATION_POINTY` | `'pointy'` | +| `GRID_MODE_OWNER` | `'owner'` | +| `GRID_MODE_COLLABORATIVE` | `'collaborative'` | + +## API + +Each `*Impl` takes `(ctx, args, owner)`. Wrap them with thin reducers in your module that supply `owner` however your auth scheme works. + +Package entrypoints: + +- `@spacetimedb/grid/submodule` supplies the submodule tables and helpers. +- `@spacetimedb/grid` exports the lower-level rows, procedures, and math + helpers. +- `@spacetimedb/grid/procedures` exports operation parameters, + implementations, and result types. +- `@spacetimedb/grid/rows` exports lower-level row builders. +- `@spacetimedb/grid/math` exports standalone pathfinding primitives. + +### `createGrid` + +- Args: `name`, `kind`, `orientation`, `width`, `height`, `defaultCost`, `connectivity`, `mode`. +- Returns: `bigint` (the grid `id`). +- Validates kind / orientation / mode / dimensions (1-1024) / `defaultCost >= 1` / square connectivity in {4, 8}. Hex `connectivity` is forced to 6 regardless of input. + +### `deleteGrid` + +- Args: `gridId`. +- Cascades: deletes all `cellState`, `gridEntity`, and `entityPath` rows for the grid. +- Owner-mode-gated (collaborative mode allows any caller). + +### `setCellCost` + +- Args: `gridId`, `x`, `y`, `cost`, `terrain`. +- Upserts the sparse row. Setting `cost === grid.defaultCost` with empty terrain + removes the sparse override. +- `cost <= 0` blocks the cell for pathfinding. + +### `paintCells` + +- Args: `gridId`, `cells: PaintCell[]` (`{ x, y, cost, terrain}`). +- Batched `setCellCost` for editor brushes / map import. + +### `placeEntity` + +- Args: `gridId`, `x`, `y`, `kind`, `blocksMovement`, `label`. +- Returns: `bigint` (the entity `id`). +- Entity `ownerUserId` is set to the host-supplied `owner` value. + +### `moveEntity` + +- Args: `entityId`, `toX`, `toY`. +- Entity-owner-gated (independent of grid mode). +- Rejects with `grid.move_not_adjacent` unless `(toX, toY)` is in the entity's current neighbor set for the grid's `kind` and `connectivity`. + +For multi-step movement, drive sequential `moveEntity` calls from `computePath` results, or compute a path with `storeFor` and replay cells client-side. + +### `computePath` + +- Args: `gridId`, `startX`, `startY`, `endX`, `endY`, `storeFor` (entity id), `maxExpansions` (default 50,000). +- Returns: `PathResult { found, cells: PathCell[], cost, expanded }`. +- A\* over the live cost map (sparse `cellState` + entities with `blocksMovement`), using a kind-aware distance heuristic. +- When `storeFor` is set and a path is found, writes / overwrites the `entityPath` row for that entity. + +### `cellsInRange` + +- Args: `gridId`, `originX`, `originY`, `maxCost`. +- Returns: `{ cells: ReachableCell[] }`. +- Dijkstra flood-fill from origin out to `maxCost`. Useful for movement-range overlays, area-of-effect previews, line-of-sight gates. + +## Errors + +All `SenderError` with stable codes: + +- `grid.invalid_kind` / `grid.invalid_orientation` / `grid.invalid_mode` / `grid.invalid_connectivity` +- `grid.invalid_dimensions:x` - outside 1-1024 +- `grid.invalid_default_cost:` - `defaultCost < 1` +- `grid.not_found:` - missing grid +- `grid.not_owner:` - caller lacks grid ownership when `mode === 'owner'` +- `grid.entity_not_found:` / `grid.entity_not_owner:` +- `grid.out_of_bounds:,` +- `grid.move_not_adjacent` - destination not in current neighbor set + +## Math helpers + +`@spacetimedb/grid/math` exports the pathfinding primitives directly so you can run them off the live tables (e.g. for client-side preview or precomputed analysis): + +- `neighbors(kind, coord, connectivity)` +- `distance(kind, a, b, connectivity)` - kind-aware heuristic +- `findPathAstar({ start, goal, cost, neighbors, heuristic, maxExpansions })` +- `dijkstra({ start, cost, neighbors, maxCost })` +- `coordKey(coord)` - stable string key for `Map` + +Types: `Coord`, `GridKind`, `Connectivity`. + +## Testing + +```bash +pnpm test +pnpm run typecheck +``` + +Build the +[example host module](./example/spacetimedb/) +to verify the +submodule schema, procedures, and generated bindings. + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-grid-ts/example/.env.example b/spacetime-grid-ts/example/.env.example new file mode 100644 index 00000000000..e5ee1f01136 --- /dev/null +++ b/spacetime-grid-ts/example/.env.example @@ -0,0 +1,30 @@ +# Copy to .env. The example server loads this on startup and bootstraps auth. + +# ---------------- Static server ---------------- +HOST=127.0.0.1 +PORT=8793 + +# ---------------- SpacetimeDB ---------------- +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +SPACETIMEDB_DB_NAME=spacetime-grid-example +STDB_SERVER=http://127.0.0.1:3000 + +# ---------------- Auth ---------------- +AUTH_ISSUER_URL=http://localhost:8793 +AUTH_BASE_URL=http://localhost:8793 +AUTH_COOKIE_NAME=stdb_auth +AUTH_SESSION_TTL_SECONDS=604800 + +# Optional. Leave blank to have the module generate an ES256 keypair on first startup bootstrap. +# Use \n escapes if putting a PEM on one line. +AUTH_ES256_PRIVATE_KEY_PEM= + +# OAuth (optional). Without these the corresponding buttons are disabled. +# Redirect URI to register with each provider: +# http://localhost:8793/auth/google/callback +# http://localhost:8793/auth/github/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/spacetime-grid-ts/example/.npmrc b/spacetime-grid-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-grid-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-grid-ts/example/README.md b/spacetime-grid-ts/example/README.md new file mode 100644 index 00000000000..5114167d8d9 --- /dev/null +++ b/spacetime-grid-ts/example/README.md @@ -0,0 +1,167 @@ +# Grid tactics example + +This example is a turn-based hex-grid tactics game built with +[`@spacetimedb/grid`](../). The Grid submodule owns grids, cell +state, and entity positions; the host module owns matches, participants, unit +statistics, turns, and combat rules. + +## What this demonstrates + +- Mounting the Grid and Auth submodules in one host module. +- Authenticated match membership and caller-scoped subscriptions. +- Hex-grid pathfinding with `computePath`. +- Movement and attack ranges with `cellsInRange`. +- Layering application rules over submodule-owned spatial state. +- Human-versus-human matchmaking and a solo match against the built-in Xeno + Garrison actor. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity. A fresh publish seeds it as the initial auth + administrator. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-grid-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open , create an account, and deploy a solo match. For the +human-versus-human flow, open a private/incognito window, create a second account, +and join the open match. + +`build:module:fresh` deletes and recreates only the local `spacetime-grid-example` +database. Use `pnpm run build:module` when existing matches must be preserved. + +## Use in your project + +This workspace tests the submodule source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/grid spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Add Auth or +Rate Limit only if your application needs them. The match, account, and tactics +rules are host-owned example code. + +## Configuration + +| Variable | Default | Purpose | +| ----------------------------------- | ------------------------ | ------------------------------------------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8793` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | HTTP endpoint used by the auth proxy. | +| `STDB_SERVER` | `STDB_HTTP` | CLI target used during startup auth configuration. | +| `SPACETIMEDB_DB_NAME` | `spacetime-grid-example` | Published database name. | +| `AUTH_ISSUER_URL` / `AUTH_BASE_URL` | `http://localhost:8793` | JWT issuer and browser-visible auth origin. | +| `AUTH_COOKIE_NAME` | `stdb_auth` | Session-cookie name. | +| `AUTH_SESSION_TTL_SECONDS` | `604800` | Session lifetime in seconds. | +| OAuth client variables | empty | Enables Google or GitHub when both values for that provider are present. | + +The development server calls `set_auth_config` automatically on startup as the +logged-in CLI identity. Restart it after changing auth or OAuth values. + +## Gameplay and authority + +1. A signed-in user creates a human or solo match. +2. Participants and initial units are created by module reducers. +3. The active player selects a unit and requests a legal move or attack. +4. The host module checks membership, turn ownership, path/range, occupancy, and + unit state before changing Grid-owned positions or combat state. +5. Subscriptions update each participant's UI. + +The browser may calculate highlights for responsiveness, but reducer validation is +authoritative. A custom client must not be able to move an opponent's unit, cross +blocked cells, exceed movement range, attack outside range, or act out of turn. + +## Architecture and visibility + +```text +Browser -> /auth/* proxy -> Auth submodule HTTP handlers +Browser -> linked SpacetimeDB connection + -> my_matches / my_match_participants + -> match-scoped my_player_units / my_grid_entities / my_cell_states + +Host match rules -> Grid submodule tables and helpers +``` + +The browser first subscribes to caller-scoped match views. It creates a second, +match-filtered subscription only for the selected match. Public catalogs and the +open-match lobby are shared; private match state is restricted by +the linked authenticated user and participation checks. + +## Security and deployment boundaries + +- Match reducers derive the acting user from the linked auth session and never + accept a browser-provided owner as authority. +- A fresh publish seeds only the publisher as auth administrator. +- Passwords, OAuth secrets, signing keys, cookies, `.env`, and development tokens + must not be committed or logged. +- Solo actors are server-owned game actors, not privileged browser identities. +- The included Express process is for local development. Production needs TLS, + explicit binding, origin/host policy, durable signing keys, and supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a release smoke test: + +1. Complete signup, reload-based session refresh, and logout. +2. Play a solo match through movement, attack, end-turn, and terminal match state. +3. Join a human match with a second account and verify realtime state in both + browsers. +4. Attempt out-of-turn, out-of-range, blocked, occupied, and opponent-unit actions + and confirm each rejection leaves state unchanged. +5. Confirm a third account cannot subscribe to or mutate a private match. + +## Troubleshooting + +- **The server exits at startup:** verify the database exists and the CLI identity + is its owner or an auth administrator. +- **No matches appear after login:** check that `link_connection` succeeded before + the caller-scoped subscriptions were created. +- **OAuth redirects incorrectly:** make `AUTH_ISSUER_URL` match the exact origin + registered with the provider. +- **The app and publish target disagree:** ensure all STDB endpoints refer to the + same server registered as `local`. + +## Important files + +- `spacetimedb/src/index.ts` - submodule mounts, auth integration, match schema, + scoped views, and game rules. +- `src/app.ts` - auth/session linking, subscriptions, and interaction bridge. +- `server.ts` - auth bootstrap, static serving, and same-origin proxy. +- `public/index.html` - tactics interface. +- `public/ui.js` - game rendering and interaction handling. +- `public/styles.css` - tactics presentation. diff --git a/spacetime-grid-ts/example/package.json b/spacetime-grid-ts/example/package.json new file mode 100644 index 00000000000..5c2cd23f8e1 --- /dev/null +++ b/spacetime-grid-ts/example/package.json @@ -0,0 +1,29 @@ +{ + "name": "spacetime-grid-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", + "check": "tsc --noEmit", + "test": "node scripts/test-hex-geometry.mjs", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run spacetime:generate && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/submodule-shared": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-grid-ts/example/public/hex-geometry.js b/spacetime-grid-ts/example/public/hex-geometry.js new file mode 100644 index 00000000000..83265e85414 --- /dev/null +++ b/spacetime-grid-ts/example/public/hex-geometry.js @@ -0,0 +1,111 @@ +export const HEX_SIZE = 26; +export const HEX_H = Math.sqrt(3) * HEX_SIZE; +export const COL_STEP = 1.5 * HEX_SIZE; +export const GRID_RADIUS = 5; +export const HEX_MIN_X = -HEX_SIZE; +export const HEX_MAX_X = COL_STEP * 2 * GRID_RADIUS + HEX_SIZE; +export const HEX_MIN_Y = HEX_H * (GRID_RADIUS / 2) - HEX_H / 2; +export const HEX_MAX_Y = + HEX_H * (2 * GRID_RADIUS + GRID_RADIUS / 2) + HEX_H / 2; +export const PAD = 12; +export const PAD_LEFT = -HEX_MIN_X + PAD; +export const PAD_TOP = -HEX_MIN_Y + PAD; + +export function cellKey(x, y) { + return `${x},${y}`; +} + +// Keep this formula aligned with the Grid submodule's server distance rule. +export function axialHexDistance(ax, ay, bx, by) { + return ( + (Math.abs(ax - bx) + Math.abs(ax + ay - bx - by) + Math.abs(ay - by)) / 2 + ); +} + +export function isInHexShape(q, r) { + const center = GRID_RADIUS; + return ( + (Math.abs(q - center) + + Math.abs(r - center) + + Math.abs(q + r - center * 2)) / + 2 <= + GRID_RADIUS + ); +} + +export function cellsWithinHexDistance(gridW, gridH, ox, oy, range) { + const cells = new Set(); + for (let r = 0; r < gridH; r++) { + for (let q = 0; q < gridW; q++) { + if (isInHexShape(q, r) && axialHexDistance(ox, oy, q, r) <= range) { + cells.add(cellKey(q, r)); + } + } + } + return cells; +} + +// Flat-top axial coordinates. +export function hexCenter(q, r) { + return { + cx: COL_STEP * q + PAD_LEFT, + cy: HEX_H * (q / 2 + r) + PAD_TOP, + }; +} + +export function hexCorners(cx, cy, size) { + const points = []; + for (let index = 0; index < 6; index++) { + const angle = (Math.PI / 3) * index; + points.push([cx + size * Math.cos(angle), cy + size * Math.sin(angle)]); + } + return points; +} + +export function pixelToHex(px, py, gridW, gridH) { + let closest = null; + let closestDistance = Infinity; + for (let y = 0; y < gridH; y++) { + for (let x = 0; x < gridW; x++) { + if (!isInHexShape(x, y)) continue; + const { cx, cy } = hexCenter(x, y); + const distance = (cx - px) ** 2 + (cy - py) ** 2; + if (distance < closestDistance) { + closestDistance = distance; + closest = { x, y }; + } + } + } + return closestDistance <= HEX_SIZE * HEX_SIZE ? closest : null; +} + +export function samplePathPixels(path, progress) { + if (path.length === 0) return { x: 0, y: 0 }; + if (path.length === 1 || progress <= 0) return { ...path[0] }; + if (progress >= 1) return { ...path[path.length - 1] }; + + const segmentLengths = []; + let totalLength = 0; + for (let index = 1; index < path.length; index++) { + const length = Math.hypot( + path[index].x - path[index - 1].x, + path[index].y - path[index - 1].y + ); + segmentLengths.push(length); + totalLength += length; + } + + let targetLength = progress * totalLength; + for (let index = 0; index < segmentLengths.length; index++) { + const segmentLength = segmentLengths[index]; + if (targetLength <= segmentLength) { + const ratio = segmentLength === 0 ? 0 : targetLength / segmentLength; + return { + x: path[index].x + (path[index + 1].x - path[index].x) * ratio, + y: path[index].y + (path[index + 1].y - path[index].y) * ratio, + }; + } + targetLength -= segmentLength; + } + return { ...path[path.length - 1] }; +} diff --git a/spacetime-grid-ts/example/public/index.html b/spacetime-grid-ts/example/public/index.html new file mode 100644 index 00000000000..aa7f884bc71 --- /dev/null +++ b/spacetime-grid-ts/example/public/index.html @@ -0,0 +1,172 @@ + + + + + + + SpacetimeDB Grid + + + + +
+
+
+ + + + + +
+ +
+ +
+ + + + + diff --git a/spacetime-grid-ts/example/public/styles.css b/spacetime-grid-ts/example/public/styles.css new file mode 100644 index 00000000000..6b5f95f18b8 --- /dev/null +++ b/spacetime-grid-ts/example/public/styles.css @@ -0,0 +1,508 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&family=Source+Code+Pro:wght@400;500;600&display=swap'); + +:root { + --font-inter: 'Inter', sans-serif; + --font-ibm: 'IBM Plex Mono', monospace; + --font-mono: 'Source Code Pro', monospace; + + --color-green: #4cf490; + --color-green-20: #4cf49033; + --color-blue: #02befa; + --color-orange: #ff9e9e; + --color-yellow: #fbdc8e; + --color-red: #ff4c4c; + --color-white: #d7d8d9; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} + +* { + box-sizing: border-box; +} +[hidden] { + display: none !important; +} +html, +body { + margin: 0; + height: 100%; +} +body { + font-family: var(--font-inter); + color: var(--color-n1); + background: var(--color-shade7); + overflow: hidden; +} + +/* Authentication shell */ +.auth-shell { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 50; + background: + radial-gradient( + ellipse 80% 50% at 50% 0%, + var(--color-green-20), + transparent 60% + ), + var(--color-shade7); +} +/* ============================================================ + Buttons (spacetime-web canonical) + ============================================================ */ +.btn { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 8px 16px; + border-radius: 4px; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; + border: 1px solid transparent; + background: var(--color-shade7); + color: var(--color-n2); + cursor: pointer; + transition: + background 0.2s, + border-color 0.2s, + color 0.2s; +} +.btn:hover:not(:disabled) { + background: var(--color-shade4); +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.btn.primary { + background: var(--color-n3); + border: 2px solid var(--color-n3); + color: var(--color-n8); +} +.btn.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); +} +.btn.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); + color: var(--color-n8); +} +.btn.primary:disabled { + background-color: var(--color-n4); + border-color: var(--color-n4); +} +.btn.danger { + background: transparent; + color: var(--color-orange); + border-color: #5a2222; +} +.btn.danger:hover:not(:disabled) { + background: rgba(255, 158, 158, 0.08); + border-color: var(--color-orange); +} +.btn.small { + font-size: 12px; + padding: 4px 12px; + height: 26px; +} + +/* ============================================================ + App shell (signed-in view) + ============================================================ */ +.shell { + width: min(1440px, calc(100% - 32px)); + margin: 14px auto; + height: calc(100dvh - 28px); + display: flex; + flex-direction: column; + gap: 14px; +} + +.topnav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + padding: 9px 12px; + box-shadow: inset 0 1px 0 #26435166; + flex-shrink: 0; +} +.brand { + display: inline-flex; + align-items: center; + gap: 10px; +} +.brand-wordmark { + display: block; + height: 28px; +} +.brand-sub { + padding: 2px 7px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 10px; + color: #9cb1cb; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.topnav-actions { + display: flex; + align-items: center; + gap: 8px; +} +.user-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 10px 4px 4px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 11px; + color: #c4d0e0; +} +.user-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--color-blue); + color: var(--color-n8); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 11px; + text-transform: uppercase; +} + +/* ============================================================ + Lobby + ============================================================ */ +.lobby { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 14px; + overflow-y: auto; +} +.panel { + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + padding: 18px; + display: flex; + flex-direction: column; + gap: 12px; +} +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; +} +.create-actions { + display: flex; + gap: 8px; +} +.mode-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + font-family: var(--font-mono); + background: rgba(132, 169, 255, 0.12); + color: #84a9ff; + border: 1px solid rgba(132, 169, 255, 0.3); +} +.panel-head h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--color-n1); +} +.panel-sub { + margin: 0; + font-size: 13px; + color: var(--color-n4); +} +.match-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.match-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade6); +} +.match-meta { + display: flex; + flex-direction: column; + gap: 2px; + font-family: var(--font-ibm); + font-size: 12px; +} +.match-meta .id { + color: var(--color-blue); +} +.match-meta .players { + color: var(--color-n3); +} +.match-status { + font-family: var(--font-ibm); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid #2a4250; + color: #9cb1cb; +} +.match-status.waiting { + border-color: #5b5737; + color: var(--color-yellow); +} +.match-status.active { + border-color: #31684c; + color: var(--color-green); +} +.match-status.ended { + border-color: #4a3940; + color: var(--color-orange); +} +.lobby-empty { + text-align: center; + padding: 40px; + color: var(--color-n4); + font-style: italic; +} + +/* ============================================================ + Match view (canvas + sidebar) + ============================================================ */ +.match-view { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 1fr 320px; + gap: 14px; +} +.board-panel { + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; +} +.board-canvas-wrap { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; + background: var(--color-shade7); + border-radius: var(--radius-sm); +} +#board-canvas { + display: block; +} + +.info-panel { + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; +} +.turn-banner { + padding: 10px 12px; + border-radius: var(--radius-sm); + font-family: var(--font-ibm); + font-size: 12px; + text-align: center; + background: var(--color-shade5); + border: 1px solid var(--color-shade1); +} +.turn-banner.mine { + background: rgba(76, 244, 144, 0.08); + border-color: #31684c; + color: var(--color-green); +} +.turn-banner.theirs { + background: rgba(255, 158, 158, 0.05); + border-color: #4a3940; + color: var(--color-orange); +} +.turn-banner.waiting { + color: var(--color-yellow); + border-color: #5b5737; +} + +.selected-unit { + padding: 12px; + border: 1px solid var(--color-shade1); + border-radius: var(--radius-sm); + background: var(--color-shade6); + display: flex; + flex-direction: column; + gap: 6px; +} +.selected-unit .row { + display: flex; + justify-content: space-between; + font-family: var(--font-ibm); + font-size: 12px; +} +.selected-unit .row .label { + color: var(--color-n4); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 10px; +} +.selected-unit .row .value { + color: var(--color-n1); +} +.hint { + font-size: 12px; + color: var(--color-n4); + font-style: italic; +} +.unit-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.unit-list .unit-pill { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 10px; + border: 1px solid var(--color-shade1); + border-radius: var(--radius-sm); + font-family: var(--font-ibm); + font-size: 11px; +} +.unit-list .unit-pill.mine { + border-color: rgba(2, 190, 250, 0.4); +} +.unit-list .unit-pill.enemy { + border-color: rgba(255, 76, 76, 0.4); +} + +/* ============================================================ + Win/lose modal + ============================================================ */ +.backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: none; + align-items: center; + justify-content: center; + z-index: 100; +} +.backdrop.open { + display: flex; +} +.modal { + background: var(--color-shade5); + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + padding: 24px; + min-width: 320px; + text-align: center; + display: flex; + flex-direction: column; + gap: 12px; +} +.modal h2 { + margin: 0; + font-size: 22px; +} +.modal h2.win { + color: var(--color-green); +} +.modal h2.lose { + color: var(--color-orange); +} +.modal p { + margin: 0; + color: var(--color-n3); +} +.modal .actions { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 6px; +} + +/* ============================================================ + Toast + ============================================================ */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + padding: 10px 16px; + background: var(--color-shade5); + border: 1px solid var(--color-shade1); + border-radius: var(--radius-sm); + font-size: 13px; + opacity: 0; + transition: opacity 0.2s; + z-index: 200; + pointer-events: none; +} +.toast.show { + opacity: 1; +} +.toast.err { + border-color: #5a2222; + background: #2a1212; +} +.toast.ok { + border-color: #1f5a32; + background: #102a17; +} diff --git a/spacetime-grid-ts/example/public/ui.js b/spacetime-grid-ts/example/public/ui.js new file mode 100644 index 00000000000..f1caddbb080 --- /dev/null +++ b/spacetime-grid-ts/example/public/ui.js @@ -0,0 +1,1028 @@ +import { + HEX_MAX_X, + HEX_MAX_Y, + HEX_MIN_X, + HEX_MIN_Y, + HEX_SIZE, + PAD, + axialHexDistance, + cellKey, + cellsWithinHexDistance, + hexCenter, + hexCorners, + isInHexShape, + pixelToHex, + samplePathPixels, +} from './hex-geometry.js'; + +const $ = id => document.getElementById(id); +let toastTimer = null; +function toast(kind, msg) { + const el = $('toast'); + el.textContent = msg; + el.className = `toast ${kind} show`; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + el.classList.remove('show'); + }, 3000); +} + +$('btn-logout').addEventListener('click', () => window.auth?.logout()); + +let state = null; +let selectedUnitId = null; +let reachableCache = null; // { entityId, cells: Set<"q,r"> } for Dijkstra reachability +let attackableCache = null; // Set<"q,r"> within the selected unit's attack range +// Active move animations: entityId → { pathPx: [(x,y)…], startMs, durationMs }. +// While present, drawBoard renders the unit at the interpolated pixel +// position while the data x/y already contains the destination. +const animatingUnits = new Map(); +const MS_PER_HEX = 110; // tune for snappiness +let rafScheduled = false; +function scheduleAnimFrame() { + const hasWork = () => + animatingUnits.size > 0 || + attackFlashes.length > 0 || + visualHp.size > 0 || + ghostUnits.size > 0 || + damageNumbers.length > 0; + if (rafScheduled || !hasWork()) return; + rafScheduled = true; + requestAnimationFrame(() => { + rafScheduled = false; + const now = performance.now(); + for (const [id, a] of animatingUnits) { + if (now >= a.startMs + a.durationMs) animatingUnits.delete(id); + } + for (let i = attackFlashes.length - 1; i >= 0; i--) { + if (now >= attackFlashes[i].startMs + attackFlashes[i].durationMs) { + attackFlashes.splice(i, 1); + } + } + for (let i = damageNumbers.length - 1; i >= 0; i--) { + if (now >= damageNumbers[i].startMs + damageNumbers[i].durationMs) { + damageNumbers.splice(i, 1); + } + } + renderMatch(); + if (hasWork()) scheduleAnimFrame(); + }); +} +function showAuth() { + $('auth-shell').hidden = false; + $('shell').hidden = true; +} +function showShell() { + $('auth-shell').hidden = true; + $('shell').hidden = false; +} +function showLobby() { + $('lobby').hidden = false; + $('match-view').hidden = true; +} +function showMatchView() { + $('lobby').hidden = true; + $('match-view').hidden = false; +} + +window.addEventListener('grid:ready', () => { + /* initial render kicked by auth:state */ +}); + +window.addEventListener('grid:auth', e => { + const u = e.detail.user; + if (u) { + showShell(); + $('user-name').textContent = u.name || u.email; + $('user-avatar').textContent = (u.name || u.email) + .slice(0, 1) + .toUpperCase(); + } else { + showAuth(); + } +}); + +window.addEventListener('grid:state', e => { + state = e.detail; + renderAll(); +}); + +// Visual overrides applied while attack animations are playing. +// Keep the data-state HP/death hidden until the flash completes, +// so the player sees: move → pause → flash → HP drop / death. +const visualHp = new Map(); // entityId → preferred HP (overrides data) +const ghostUnits = new Map(); // entityId → { x, y, ownerUserId, typeId, currentHp }; rendered during removal animations +const attackFlashes = []; // [{ from, to, startMs, durationMs }] +const damageNumbers = []; // [{ x, y, dmg, killed, startMs, durationMs }] +const FLASH_MS = 220; +const DAMAGE_FLOAT_MS = 950; + +function spawnDamageNumber(px, py, dmg, killed, atMs) { + damageNumbers.push({ + x: px, + y: py - HEX_SIZE * 0.4, + dmg, + killed, + startMs: atMs ?? performance.now(), + durationMs: DAMAGE_FLOAT_MS, + }); + scheduleAnimFrame(); +} + +// Snapshot a target's current visual state, push an attack flash + a +// floating damage number, then release the HP/ghost override after +// FLASH_MS so the data-state HP/death is shown only after the flash +// plays. Used by both player + AI attacks. +function flashAttack(attackerId, target, dmg, killed) { + visualHp.set(target.entityId, target.currentHp); + ghostUnits.set(target.entityId, { + entityId: target.entityId, + x: target.x, + y: target.y, + ownerUserId: target.ownerUserId, + typeId: target.typeId, + currentHp: target.currentHp, + }); + const startMs = performance.now(); + attackFlashes.push({ + attackerId, + targetX: target.x, + targetY: target.y, + startMs, + durationMs: FLASH_MS, + }); + const { cx, cy } = hexCenter(target.x, target.y); + spawnDamageNumber(cx, cy, dmg, killed, startMs + FLASH_MS / 2); + scheduleAnimFrame(); + setTimeout(() => { + visualHp.delete(target.entityId); + ghostUnits.delete(target.entityId); + renderMatch(); + }, FLASH_MS); +} + +// Schedule each AI-turn event and release its visual override. The event +// list arrives in execution order. +window.addEventListener('grid:ai-events', e => { + const events = e.detail?.events ?? []; + let cursorMs = performance.now(); + const PAUSE_MS = 80; + + for (const ev of events) { + let moveEndMs = cursorMs; + + if (Array.isArray(ev.movePath) && ev.movePath.length >= 2) { + const pathPx = ev.movePath.map(c => { + const { cx, cy } = hexCenter(c.x, c.y); + return { x: cx, y: cy }; + }); + const hops = pathPx.length - 1; + const durationMs = Math.max(180, hops * MS_PER_HEX); + animatingUnits.set(ev.entityId, { + pathPx, + startMs: cursorMs, + durationMs, + }); + moveEndMs = cursorMs + durationMs; + cursorMs = moveEndMs + PAUSE_MS; + } + + // Preserve the target's pre-attack visual until the flash fires. A defeated + // target may already be absent from state, so render it as a temporary ghost. + if (ev.attack) { + const a = ev.attack; + visualHp.set(a.targetId, a.targetPreHp); + if (a.killed) { + ghostUnits.set(a.targetId, { + entityId: a.targetId, + x: a.targetX, + y: a.targetY, + ownerUserId: a.targetOwner, + typeId: a.targetTypeId, + currentHp: a.targetPreHp, + }); + } + const attackStartMs = cursorMs; + attackFlashes.push({ + attackerId: ev.entityId, + targetX: a.targetX, + targetY: a.targetY, + startMs: attackStartMs, + durationMs: FLASH_MS, + }); + const { cx, cy } = hexCenter(a.targetX, a.targetY); + spawnDamageNumber( + cx, + cy, + a.damage, + a.killed, + attackStartMs + FLASH_MS / 2 + ); + setTimeout( + () => { + visualHp.delete(a.targetId); + ghostUnits.delete(a.targetId); + renderMatch(); + }, + attackStartMs - performance.now() + FLASH_MS + ); + cursorMs += FLASH_MS + PAUSE_MS; + } + } + + if ( + animatingUnits.size > 0 || + attackFlashes.length > 0 || + ghostUnits.size > 0 + ) { + renderMatch(); + scheduleAnimFrame(); + } +}); + +showAuth(); + +function renderAll() { + if (!state) return; + renderLobby(); + if (state.activeMatchId !== null && state.activeMatch) { + showMatchView(); + renderMatch(); + } else { + showLobby(); + } +} + +function statusKey(s) { + return s?.tag ? s.tag.toLowerCase() : 'unknown'; +} + +function seatsForMatch(matchId) { + const seats = {}; + for (const p of state.participants ?? []) { + if (p.matchId === matchId) seats[p.seatIdx] = p.userId; + } + return seats; +} +function actorById(id) { + return id ? (state.actors ?? []).find(a => a.actorId === id) : null; +} +function displayName(actor, fallbackId) { + if (!actor) return fallbackId ? fallbackId.slice(0, 8) : 'Unknown'; + return actor.name || actor.actorId.slice(0, 8); +} + +function textSpan(className, text) { + const span = document.createElement('span'); + span.className = className; + span.textContent = text; + return span; +} + +function hint(text) { + const div = document.createElement('div'); + div.className = 'hint'; + div.textContent = text; + return div; +} + +function infoRow(label, value) { + const row = document.createElement('div'); + row.className = 'row'; + row.appendChild(textSpan('label', label)); + row.appendChild(textSpan('value', value)); + return row; +} + +function renderLobby() { + const list = $('match-list'); + const my = state.myUserId; + const myMatches = state.matches ?? []; + const openOther = (state.openMatches ?? []).filter( + o => !myMatches.some(m => m.matchId === o.matchId) + ); + if (myMatches.length === 0 && openOther.length === 0) { + const empty = document.createElement('div'); + empty.className = 'lobby-empty'; + empty.textContent = 'No missions in this sector. Deploy one to begin.'; + list.replaceChildren(empty); + return; + } + list.replaceChildren(); + + for (const o of openOther) { + const row = document.createElement('div'); + row.className = 'match-row'; + const meta = document.createElement('div'); + meta.className = 'match-meta'; + const hostName = displayName(actorById(o.hostUserId), o.hostUserId); + meta.appendChild(textSpan('id', `#${o.matchId}`)); + meta.appendChild(textSpan('players', `${hostName} waiting for opponent`)); + const status = document.createElement('span'); + status.className = 'match-status waiting'; + status.textContent = 'waiting'; + const actions = document.createElement('div'); + actions.style.display = 'flex'; + actions.style.gap = '8px'; + actions.style.alignItems = 'center'; + actions.appendChild(status); + const btn = document.createElement('button'); + btn.className = 'btn small primary'; + btn.textContent = 'Join'; + btn.addEventListener('click', async () => { + try { + await window.grid.joinMatch(o.matchId); + window.grid.setActiveMatch(o.matchId); + } catch (err) { + toast('err', err.message ?? String(err)); + } + }); + actions.appendChild(btn); + row.appendChild(meta); + row.appendChild(actions); + list.appendChild(row); + } + + for (const m of myMatches) { + const row = document.createElement('div'); + row.className = 'match-row'; + const meta = document.createElement('div'); + meta.className = 'match-meta'; + const seats = seatsForMatch(m.matchId); + const hostUid = seats[0]; + const oppUid = seats[1]; + const isVsAi = oppUid === window.grid.aiBotUserId; + const hostName = displayName(actorById(hostUid), hostUid); + const oppName = isVsAi + ? 'Alien Hive' + : displayName(actorById(oppUid), oppUid); + meta.appendChild(textSpan('id', `#${m.matchId}`)); + const players = textSpan('players', `${hostName} vs ${oppName}`); + if (isVsAi) { + players.append(' '); + players.appendChild(textSpan('mode-badge ai', 'SOLO')); + } + meta.appendChild(players); + const status = document.createElement('span'); + status.className = `match-status ${statusKey(m.status)}`; + status.textContent = statusKey(m.status); + const actions = document.createElement('div'); + actions.style.display = 'flex'; + actions.style.gap = '8px'; + actions.style.alignItems = 'center'; + actions.appendChild(status); + + const inMatch = Object.values(seats).includes(my); + if (m.status.tag === 'Waiting' && !inMatch) { + const btn = document.createElement('button'); + btn.className = 'btn small primary'; + btn.textContent = 'Join'; + btn.addEventListener('click', async () => { + try { + await window.grid.joinMatch(m.matchId); + window.grid.setActiveMatch(m.matchId); + } catch (err) { + toast('err', err.message ?? String(err)); + } + }); + actions.appendChild(btn); + } else if (m.status.tag === 'Active' && inMatch) { + const btn = document.createElement('button'); + btn.className = 'btn small primary'; + btn.textContent = 'Resume'; + btn.addEventListener('click', () => + window.grid.setActiveMatch(m.matchId) + ); + actions.appendChild(btn); + } else if (m.status.tag === 'Waiting' && inMatch) { + const btn = document.createElement('button'); + btn.className = 'btn small'; + btn.textContent = 'View'; + btn.addEventListener('click', () => + window.grid.setActiveMatch(m.matchId) + ); + actions.appendChild(btn); + } else { + const btn = document.createElement('button'); + btn.className = 'btn small'; + btn.textContent = 'Spectate'; + btn.addEventListener('click', () => + window.grid.setActiveMatch(m.matchId) + ); + actions.appendChild(btn); + } + row.appendChild(meta); + row.appendChild(actions); + list.appendChild(row); + } +} + +function renderMatch() { + const m = state.activeMatch; + const grid = state.activeGrid; + if (!m || !grid) return; + const my = state.myUserId; + const seats = seatsForMatch(m.matchId); + const mySeatIdx = Object.entries(seats).find(([, uid]) => uid === my)?.[0]; + const isHost = mySeatIdx === '0'; + const myTurn = + m.status.tag === 'Active' && + mySeatIdx !== undefined && + Number(mySeatIdx) === m.currentSeatIdx; + + const isVsAi = seats[1] === window.grid.aiBotUserId; + $('match-title').textContent = + `Sector ${m.matchId}${isVsAi ? ' · solo' : ''}`; + $('match-subtitle').textContent = `, turn ${m.turnNumber}`; + + const banner = $('turn-banner'); + if (m.status.tag === 'Waiting') { + banner.className = 'turn-banner waiting'; + banner.textContent = isHost + ? 'Waiting for an opponent to join…' + : 'Match is waiting for a host'; + } else if (m.status.tag === 'Ended') { + banner.className = 'turn-banner'; + banner.textContent = + m.winnerUserId === my ? 'You won this match.' : 'Match ended.'; + } else if (myTurn) { + banner.className = 'turn-banner mine'; + banner.textContent = 'Your turn'; + } else { + banner.className = 'turn-banner theirs'; + banner.textContent = isVsAi ? 'Aliens advancing…' : "Opponent's turn"; + } + + $('btn-end-turn').disabled = !myTurn; + + const my0 = []; + const en = []; + for (const u of state.units) { + const type = state.unitTypes.find(t => t.typeId === u.typeId); + const entity = state.entities.find(e => e.id === u.entityId); + const item = { u, type, entity }; + (u.ownerUserId === my ? my0 : en).push(item); + } + function pill(item) { + const div = document.createElement('div'); + div.className = `unit-pill ${item.u.ownerUserId === my ? 'mine' : 'enemy'}`; + const left = document.createElement('span'); + left.textContent = `${item.type?.glyph ?? '?'} ${item.type?.name ?? item.u.typeId} (${item.entity?.x},${item.entity?.y})`; + const right = document.createElement('span'); + right.textContent = `${item.u.currentHp}/${item.type?.hp ?? '?'} HP`; + div.appendChild(left); + div.appendChild(right); + return div; + } + const myList = $('my-units'); + const enList = $('enemy-units'); + myList.replaceChildren(); + enList.replaceChildren(); + for (const x of my0) myList.appendChild(pill(x)); + for (const x of en) enList.appendChild(pill(x)); + if (my0.length === 0) myList.appendChild(hint('no units')); + if (en.length === 0) enList.appendChild(hint('no units')); + + const selUnit = + selectedUnitId !== null + ? state.units.find(u => u.entityId === selectedUnitId) + : null; + const selEntity = selUnit + ? state.entities.find(e => e.id === selUnit.entityId) + : null; + const selType = selUnit + ? state.unitTypes.find(t => t.typeId === selUnit.typeId) + : null; + const sel = $('selected-unit-info'); + if (selUnit && selType && selEntity) { + const card = document.createElement('div'); + card.className = 'selected-unit'; + card.appendChild(infoRow('Unit', `${selType.glyph} ${selType.name}`)); + card.appendChild(infoRow('Position', `(${selEntity.x}, ${selEntity.y})`)); + card.appendChild(infoRow('HP', `${selUnit.currentHp} / ${selType.hp}`)); + card.appendChild( + infoRow( + 'Movement', + `${selType.movement} (used: ${selUnit.hasMoved ? 'yes' : 'no'})` + ) + ); + card.appendChild( + infoRow( + 'Attack', + `${selType.attackDmg} dmg · range ${selType.attackRange} (used: ${selUnit.hasAttacked ? 'yes' : 'no'})` + ) + ); + sel.replaceChildren(card); + } else { + sel.replaceChildren(hint('click one of your units to select')); + } + + drawBoard(grid, state.entities, state.cells, state.units, m, my); + + if (m.status.tag === 'Ended') { + $('end-title').textContent = m.winnerUserId === my ? 'Victory!' : 'Defeat'; + $('end-title').className = m.winnerUserId === my ? 'win' : 'lose'; + const winner = actorById(m.winnerUserId); + const winnerName = winner + ? winner.name || m.winnerUserId.slice(0, 8) + : 'someone'; + $('end-body').textContent = + m.winnerUserId === my + ? `You secured the sector.` + : `${winnerName} overran the outpost.`; + $('end-backdrop').classList.add('open'); + } else { + $('end-backdrop').classList.remove('open'); + } +} + +$('end-back-to-lobby').addEventListener('click', () => { + $('end-backdrop').classList.remove('open'); + window.grid.setActiveMatch(null); +}); +$('btn-leave-match').addEventListener('click', () => { + selectedUnitId = null; + reachableCache = null; + attackableCache = null; + window.grid.setActiveMatch(null); +}); +$('btn-create-match').addEventListener('click', async () => { + try { + const r = await window.grid.createMatch(false); + window.grid.setActiveMatch(r.matchId); + } catch (err) { + toast('err', err.message ?? String(err)); + } +}); +$('btn-create-match-ai').addEventListener('click', async () => { + try { + const r = await window.grid.createMatch(true); + window.grid.setActiveMatch(r.matchId); + } catch (err) { + toast('err', err.message ?? String(err)); + } +}); +$('btn-end-turn').addEventListener('click', async () => { + if (!state?.activeMatchId) return; + try { + selectedUnitId = null; + reachableCache = null; + attackableCache = null; + await window.grid.endTurn(state.activeMatchId); + } catch (err) { + toast('err', err.message ?? String(err)); + } +}); + +function drawBoard(grid, entities, cells, units, match, myUserId) { + const canvas = $('board-canvas'); + // Canvas only needs to hold the hex-shape bounding box (computed at + // module load from HEX_RADIUS). Out-of-hex axial cells aren't drawn. + const width = Math.ceil(HEX_MAX_X - HEX_MIN_X + 2 * PAD); + const height = Math.ceil(HEX_MAX_Y - HEX_MIN_Y + 2 * PAD); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = + getComputedStyle(document.body).getPropertyValue('--color-shade7').trim() || + '#0b1114'; + ctx.fillRect(0, 0, width, height); + const starSeed = (grid.width * 31 + grid.height) | 0; + let s = starSeed; + for (let i = 0; i < 60; i++) { + s = (s * 1664525 + 1013904223) >>> 0; + const sx = ((s % 1000) / 1000) * width; + s = (s * 1664525 + 1013904223) >>> 0; + const sy = ((s % 1000) / 1000) * height; + s = (s * 1664525 + 1013904223) >>> 0; + const a = 0.12 + ((s % 1000) / 1000) * 0.25; + ctx.fillStyle = `rgba(160, 180, 200, ${a})`; + ctx.fillRect(sx, sy, 1, 1); + } + + const cellMap = new Map(cells.map(c => [cellKey(c.x, c.y), c])); + const reachable = reachableCache?.cells ?? null; + + for (let y = 0; y < grid.height; y++) { + for (let x = 0; x < grid.width; x++) { + if (!isInHexShape(x, y)) continue; + const { cx, cy } = hexCenter(x, y); + const corners = hexCorners(cx, cy, HEX_SIZE - 1); + const cell = cellMap.get(cellKey(x, y)); + let fill = '#0e161a'; // shade6 regolith (default) + let stroke = '#162d38'; // shade1 + if (cell && cell.terrain === 'crater') { + fill = '#080c0e'; + stroke = '#3a1a22'; + } + const isReachable = reachable && reachable.has(cellKey(x, y)); + const isAttackable = + attackableCache && attackableCache.has(cellKey(x, y)); + ctx.beginPath(); + ctx.moveTo(corners[0][0], corners[0][1]); + for (let i = 1; i < 6; i++) ctx.lineTo(corners[i][0], corners[i][1]); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + if (isAttackable) { + ctx.fillStyle = 'rgba(255, 76, 76, 0.28)'; + ctx.fill(); + ctx.strokeStyle = '#ff4c4c'; + ctx.lineWidth = 1.5; + } else if (isReachable) { + ctx.fillStyle = 'rgba(2, 190, 250, 0.20)'; + ctx.fill(); + ctx.strokeStyle = '#02befa'; + ctx.lineWidth = 1.5; + } else { + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + } + ctx.stroke(); + } + } + + const drawUnit = (u, posX, posY, hpForBar) => { + const type = state.unitTypes.find(t => t.typeId === u.typeId); + const isMine = u.ownerUserId === myUserId; + const color = isMine ? '#02befa' : '#4cf490'; + ctx.beginPath(); + ctx.arc(posX, posY, HEX_SIZE * 0.55, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = u.entityId === selectedUnitId ? '#fbdc8e' : '#000a'; + ctx.lineWidth = u.entityId === selectedUnitId ? 3 : 2; + ctx.stroke(); + ctx.fillStyle = '#060606'; + ctx.font = 'bold 14px "Source Code Pro", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(type?.glyph ?? '?', posX, posY); + const hpPct = type ? hpForBar / type.hp : 0; + const barW = HEX_SIZE * 0.9; + ctx.fillStyle = '#000a'; + ctx.fillRect(posX - barW / 2, posY + HEX_SIZE * 0.6, barW, 4); + ctx.fillStyle = + hpPct > 0.5 ? '#4cf490' : hpPct > 0.25 ? '#fbdc8e' : '#ff4c4c'; + ctx.fillRect(posX - barW / 2, posY + HEX_SIZE * 0.6, barW * hpPct, 4); + if (isMine && u.hasMoved && u.hasAttacked) { + ctx.fillStyle = 'rgba(0,0,0,0.4)'; + ctx.beginPath(); + ctx.arc(posX, posY, HEX_SIZE * 0.55, 0, Math.PI * 2); + ctx.fill(); + } + }; + + for (const u of units) { + // Skip if this unit is being ghost-rendered (its data state may + // be stale during the animation; the ghost below supplies the visual). + if (ghostUnits.has(u.entityId)) continue; + const ent = entities.find(e => e.id === u.entityId); + if (!ent) continue; + // Use the interpolated pixel position while a unit is moving. Its + // data x/y already contains the destination. + let cx, cy; + const anim = animatingUnits.get(u.entityId); + if (anim) { + const t = Math.min( + 1, + (performance.now() - anim.startMs) / anim.durationMs + ); + const p = samplePathPixels(anim.pathPx, t); + cx = p.x; + cy = p.y; + } else { + const c = hexCenter(ent.x, ent.y); + cx = c.cx; + cy = c.cy; + } + // Use visual HP override if pending attack hasn't fired yet. + const hp = visualHp.has(u.entityId) + ? visualHp.get(u.entityId) + : u.currentHp; + drawUnit(u, cx, cy, hp); + } + + for (const g of ghostUnits.values()) { + const { cx, cy } = hexCenter(g.x, g.y); + drawUnit( + { + entityId: g.entityId, + ownerUserId: g.ownerUserId, + typeId: g.typeId, + hasMoved: true, + hasAttacked: true, + }, + cx, + cy, + visualHp.has(g.entityId) ? visualHp.get(g.entityId) : g.currentHp + ); + } + + for (const f of attackFlashes) { + const now = performance.now(); + const t = Math.min(1, Math.max(0, (now - f.startMs) / f.durationMs)); + if (t <= 0 || t >= 1) continue; + let fx, fy; + const aAnim = animatingUnits.get(f.attackerId); + if (aAnim) { + const at = Math.min(1, (now - aAnim.startMs) / aAnim.durationMs); + const p = samplePathPixels(aAnim.pathPx, at); + fx = p.x; + fy = p.y; + } else { + const aEnt = entities.find(e => e.id === f.attackerId); + if (!aEnt) continue; + const p = hexCenter(aEnt.x, aEnt.y); + fx = p.cx; + fy = p.cy; + } + const tEnd = hexCenter(f.targetX, f.targetY); + const alpha = 0.9 * (1 - t); + ctx.strokeStyle = `rgba(255, 76, 76, ${alpha})`; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(fx, fy); + ctx.lineTo(tEnd.cx, tEnd.cy); + ctx.stroke(); + ctx.strokeStyle = `rgba(255, 156, 61, ${alpha})`; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(tEnd.cx, tEnd.cy, HEX_SIZE * (0.4 + t * 0.6), 0, Math.PI * 2); + ctx.stroke(); + } + + const nowMs = performance.now(); + for (const d of damageNumbers) { + const t = (nowMs - d.startMs) / d.durationMs; + if (t < 0 || t > 1) continue; + let alpha; + if (t < 0.15) alpha = t / 0.15; + else if (t > 0.7) alpha = (1 - t) / 0.3; + else alpha = 1; + alpha = Math.max(0, Math.min(1, alpha)); + const drift = HEX_SIZE * 1.1 * (1 - Math.pow(1 - t, 2)); + const px = d.x; + const py = d.y - drift; + const text = d.killed ? `−${d.dmg} KO` : `−${d.dmg}`; + ctx.font = d.killed + ? 'bold 18px "Source Code Pro", monospace' + : 'bold 15px "Source Code Pro", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + // Dark stroke first so the number stays readable over any hex. + ctx.strokeStyle = `rgba(0, 0, 0, ${alpha * 0.85})`; + ctx.lineWidth = 3; + ctx.strokeText(text, px, py); + ctx.fillStyle = d.killed + ? `rgba(255, 76, 76, ${alpha})` + : `rgba(255, 156, 61, ${alpha})`; + ctx.fillText(text, px, py); + } +} + +$('board-canvas').addEventListener('click', async e => { + if (!state?.activeMatch || !state.activeGrid) return; + const m = state.activeMatch; + const grid = state.activeGrid; + const seats = seatsForMatch(m.matchId); + const myTurn = + m.status.tag === 'Active' && seats[m.currentSeatIdx] === state.myUserId; + if (!myTurn) return; + + const rect = e.target.getBoundingClientRect(); + const px = e.clientX - rect.left; + const py = e.clientY - rect.top; + const hex = pixelToHex(px, py, grid.width, grid.height); + if (!hex) return; + + const entityAtHex = state.entities.find( + e2 => e2.x === hex.x && e2.y === hex.y + ); + const unitAtHex = entityAtHex + ? state.units.find(u => u.entityId === entityAtHex.id) + : null; + + if (unitAtHex && unitAtHex.ownerUserId === state.myUserId) { + selectedUnitId = unitAtHex.entityId; + const type = state.unitTypes.find(t => t.typeId === unitAtHex.typeId); + let reachCells = [{ x: hex.x, y: hex.y, cost: 0 }]; + if (type && !unitAtHex.hasMoved) { + try { + const r = await window.grid.getCellsInRange( + grid.id, + hex.x, + hex.y, + type.movement + ); + reachCells = r; + } catch { + reachCells = [{ x: hex.x, y: hex.y, cost: 0 }]; + } + } + reachableCache = { + entityId: unitAtHex.entityId, + cells: new Set(reachCells.map(c => cellKey(c.x, c.y))), + }; + // Influence range = "movement zone with attack range tacked on the + // outside". For each reachable cell, fan out by attackRange. Strip + // the cells that are already in the movement zone so cyan shows the + // movement halo and red shows only the OUTER attack ring. + if (type && !unitAtHex.hasAttacked) { + const influence = new Set(); + for (const r of reachCells) { + const ring = cellsWithinHexDistance( + grid.width, + grid.height, + r.x, + r.y, + type.attackRange + ); + for (const k of ring) influence.add(k); + } + for (const k of reachableCache.cells) influence.delete(k); + attackableCache = influence; + } else { + attackableCache = null; + } + renderMatch(); + return; + } + + if (unitAtHex && selectedUnitId !== null) { + const attacker = state.units.find(u => u.entityId === selectedUnitId); + const attackerEnt = attacker + ? state.entities.find(e => e.id === selectedUnitId) + : null; + const type = attacker + ? state.unitTypes.find(t => t.typeId === attacker.typeId) + : null; + if (!attacker || !attackerEnt || !type) return; + if (attacker.hasAttacked) { + toast('err', 'this unit has already attacked'); + return; + } + + const targetId = unitAtHex.entityId; + const attackerId = selectedUnitId; + const targetX = hex.x, + targetY = hex.y; + + const fromHere = axialHexDistance( + attackerEnt.x, + attackerEnt.y, + targetX, + targetY + ); + if (fromHere <= type.attackRange) { + selectedUnitId = null; + reachableCache = null; + attackableCache = null; + // Snapshot the target so its HP/death is held until the flash fires. + const targetEnt = state.entities.find(en => en.id === targetId); + if (targetEnt) { + const dmg = type.attackDmg; + const killed = unitAtHex.currentHp - dmg <= 0; + flashAttack( + attackerId, + { + entityId: targetId, + x: targetEnt.x, + y: targetEnt.y, + ownerUserId: unitAtHex.ownerUserId, + typeId: unitAtHex.typeId, + currentHp: unitAtHex.currentHp, + }, + dmg, + killed + ); + } + renderMatch(); + try { + await window.grid.attackUnit(attackerId, targetId); + } catch (err) { + toast('err', err.message ?? String(err)); + } + return; + } + + // Need to move first. Pick the CHEAPEST reachable cell that puts the + // target within attackRange. Has to be one cellsInRange returned. + if ( + attacker.hasMoved || + !reachableCache || + reachableCache.entityId !== attackerId + ) { + toast('err', 'target out of range'); + return; + } + let bestStep = null; + for (const k of reachableCache.cells) { + const [sx, sy] = k.split(',').map(Number); + if (axialHexDistance(sx, sy, targetX, targetY) <= type.attackRange) { + if ( + bestStep === null || + axialHexDistance(attackerEnt.x, attackerEnt.y, sx, sy) < + axialHexDistance( + attackerEnt.x, + attackerEnt.y, + bestStep.x, + bestStep.y + ) + ) { + bestStep = { x: sx, y: sy }; + } + } + } + if (!bestStep) { + toast('err', 'target out of range'); + return; + } + + selectedUnitId = null; + reachableCache = null; + attackableCache = null; + renderMatch(); + try { + const { path } = await window.grid.moveUnit( + attackerId, + bestStep.x, + bestStep.y + ); + const pathPx = path.map(c => { + const { cx, cy } = hexCenter(c.x, c.y); + return { x: cx, y: cy }; + }); + if (pathPx.length >= 2) { + const hops = pathPx.length - 1; + const durationMs = Math.max(180, hops * MS_PER_HEX); + animatingUnits.set(attackerId, { + pathPx, + startMs: performance.now(), + durationMs, + }); + scheduleAnimFrame(); + // Hold the attack until the move animation completes so the unit + // is visibly adjacent before the HP drop on the target. + await new Promise(r => setTimeout(r, durationMs)); + } + // Snapshot target's current visual state before the RPC, then + // flash + release after FLASH_MS. Re-fetch from state in case + // anything changed during the move animation. + const targetEnt = state.entities.find(en => en.id === targetId); + const targetUnit = state.units.find(u => u.entityId === targetId); + if (targetEnt && targetUnit) { + const dmg = type.attackDmg; + const killed = targetUnit.currentHp - dmg <= 0; + flashAttack( + attackerId, + { + entityId: targetId, + x: targetEnt.x, + y: targetEnt.y, + ownerUserId: targetUnit.ownerUserId, + typeId: targetUnit.typeId, + currentHp: targetUnit.currentHp, + }, + dmg, + killed + ); + } + await window.grid.attackUnit(attackerId, targetId); + } catch (err) { + toast('err', err.message ?? String(err)); + } + return; + } + + if ( + selectedUnitId !== null && + reachableCache?.entityId === selectedUnitId && + reachableCache.cells.has(cellKey(hex.x, hex.y)) + ) { + const movingId = selectedUnitId; + selectedUnitId = null; + reachableCache = null; + attackableCache = null; + renderMatch(); + try { + const { path } = await window.grid.moveUnit(movingId, hex.x, hex.y); + const pathPx = path.map(c => { + const { cx, cy } = hexCenter(c.x, c.y); + return { x: cx, y: cy }; + }); + // path includes the origin cell; need >= 2 points to animate. + if (pathPx.length >= 2) { + const hops = pathPx.length - 1; + animatingUnits.set(movingId, { + pathPx, + startMs: performance.now(), + durationMs: Math.max(180, hops * MS_PER_HEX), + }); + scheduleAnimFrame(); + } + } catch (err) { + toast('err', err.message ?? String(err)); + } + } +}); diff --git a/spacetime-grid-ts/example/scripts/test-hex-geometry.mjs b/spacetime-grid-ts/example/scripts/test-hex-geometry.mjs new file mode 100644 index 00000000000..b8ddf646ed9 --- /dev/null +++ b/spacetime-grid-ts/example/scripts/test-hex-geometry.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { + GRID_RADIUS, + HEX_SIZE, + axialHexDistance, + cellKey, + cellsWithinHexDistance, + hexCenter, + hexCorners, + isInHexShape, + pixelToHex, + samplePathPixels, +} from '../public/hex-geometry.js'; + +assert.equal(axialHexDistance(0, 0, 0, 0), 0); +assert.equal(axialHexDistance(0, 0, 2, -1), 2); +assert.equal(axialHexDistance(2, -1, 0, 0), 2); + +assert.equal(isInHexShape(GRID_RADIUS, GRID_RADIUS), true); +assert.equal(isInHexShape(GRID_RADIUS, 0), true); +assert.equal(isInHexShape(-1, GRID_RADIUS), false); +assert.equal(isInHexShape(GRID_RADIUS * 2, GRID_RADIUS * 2), false); + +const nearby = cellsWithinHexDistance( + GRID_RADIUS * 2 + 1, + GRID_RADIUS * 2 + 1, + GRID_RADIUS, + GRID_RADIUS, + 1 +); +assert.equal(nearby.size, 7); +assert.equal(nearby.has(cellKey(GRID_RADIUS + 1, GRID_RADIUS)), true); + +const center = hexCenter(GRID_RADIUS, GRID_RADIUS); +assert.deepEqual( + pixelToHex(center.cx, center.cy, GRID_RADIUS * 2 + 1, GRID_RADIUS * 2 + 1), + { x: GRID_RADIUS, y: GRID_RADIUS } +); +assert.equal(pixelToHex(-HEX_SIZE * 10, -HEX_SIZE * 10, 11, 11), null); + +const corners = hexCorners(center.cx, center.cy, HEX_SIZE); +assert.equal(corners.length, 6); +for (const [x, y] of corners) { + assert.ok( + Math.abs(Math.hypot(x - center.cx, y - center.cy) - HEX_SIZE) < 1e-9 + ); +} + +const path = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 30 }, +]; +assert.deepEqual(samplePathPixels([], 0.5), { x: 0, y: 0 }); +assert.deepEqual(samplePathPixels(path, 0), path[0]); +assert.deepEqual(samplePathPixels(path, 0.25), { x: 10, y: 0 }); +assert.deepEqual(samplePathPixels(path, 0.5), { x: 10, y: 10 }); +assert.deepEqual(samplePathPixels(path, 1), path[2]); + +console.log('grid hex geometry tests passed'); diff --git a/spacetime-grid-ts/example/server.ts b/spacetime-grid-ts/example/server.ts new file mode 100644 index 00000000000..1cd5b6b02f5 --- /dev/null +++ b/spacetime-grid-ts/example/server.ts @@ -0,0 +1,195 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const SPA_HTML = readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8'); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +// Shared/root env supplies secrets; example-local env supplies app defaults. +// Blank placeholders in the example .env should not erase shared secrets. +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8793', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-grid-example'; +const AUTH_ISSUER_URL = + process.env.AUTH_ISSUER_URL ?? `http://localhost:${PORT}`; +const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? AUTH_ISSUER_URL; +const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME ?? 'stdb_auth'; +const AUTH_SESSION_TTL_SECONDS = Number.parseInt( + process.env.AUTH_SESSION_TTL_SECONDS ?? `${60 * 60 * 24 * 7}`, + 10 +); +if ( + !Number.isInteger(AUTH_SESSION_TTL_SECONDS) || + AUTH_SESSION_TTL_SECONDS <= 0 +) { + throw new Error('AUTH_SESSION_TTL_SECONDS must be a positive integer'); +} +const GOOGLE_OAUTH_ENABLED = Boolean( + process.env.GOOGLE_CLIENT_ID?.trim() && + process.env.GOOGLE_CLIENT_SECRET?.trim() +); +const GITHUB_OAUTH_ENABLED = Boolean( + process.env.GITHUB_CLIENT_ID?.trim() && + process.env.GITHUB_CLIENT_SECRET?.trim() +); +const STDB_SERVER = process.env.STDB_SERVER ?? STDB_HTTP; +const SPACETIME_BIN = 'spacetime'; + +function configuredValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function configuredPem(value: string | undefined): string | undefined { + return configuredValue(value)?.replace(/\\n/g, '\n'); +} + +const opt = (value: string | undefined) => + value === undefined ? JSON.stringify([1, []]) : JSON.stringify([0, value]); + +function configureAuthFromEnv(): void { + const args = [ + JSON.stringify(AUTH_ISSUER_URL), + opt(AUTH_BASE_URL), + opt(AUTH_COOKIE_NAME), + JSON.stringify([0, AUTH_SESSION_TTL_SECONDS]), + opt(configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM)), + opt(configuredValue(process.env.GOOGLE_CLIENT_ID)), + opt(configuredValue(process.env.GOOGLE_CLIENT_SECRET)), + opt(configuredValue(process.env.GITHUB_CLIENT_ID)), + opt(configuredValue(process.env.GITHUB_CLIENT_SECRET)), + ]; + + const result = spawnSync( + SPACETIME_BIN, + ['call', '--server', STDB_SERVER, DB_NAME, 'set_auth_config', ...args], + { stdio: 'inherit', shell: false } + ); + if (result.status !== 0) { + throw new Error(`auth config bootstrap failed (exit ${result.status})`); + } +} + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +// Register this before the /auth proxy so reset links reach the SPA. +app.get('/auth/password/reset', (_req: Request, res: Response) => { + res.type('html').send(SPA_HTML); +}); + +app.use('/auth', async (req, res) => { + const fullPath = `/auth${req.url}`; + const qIdx = fullPath.indexOf('?'); + const subpath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); + const query = qIdx < 0 ? '' : fullPath.slice(qIdx); + const upstreamUrl = `${STDB_HTTP}/v1/database/${DB_NAME}/route${subpath}${query}`; + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string') headers[k] = v; + else if (Array.isArray(v)) headers[k] = v.join(', '); + } + delete headers.host; + delete headers['content-length']; + + const init: RequestInit = { method: req.method, headers, redirect: 'manual' }; + if (req.method !== 'GET' && req.method !== 'HEAD') { + init.body = JSON.stringify(req.body); + headers['content-type'] = 'application/json'; + } + + try { + const upstream = await fetch(upstreamUrl, init); + res.status(upstream.status); + upstream.headers.forEach((val, key) => { + const lower = key.toLowerCase(); + if ( + lower === 'transfer-encoding' || + lower === 'content-encoding' || + lower === 'content-length' + ) + return; + res.setHeader(key, val); + }); + const buf = Buffer.from(await upstream.arrayBuffer()); + res.send(buf); + } catch (err) { + res + .status(502) + .json({ error: 'upstream_unreachable', detail: (err as Error).message }); + } +}); + +app.use('/assets', express.static(exampleUiAssetsDir)); +app.use(express.static(PUBLIC_DIR)); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, databaseName: DB_NAME }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + spacetimeUri: STDB_URI, + databaseName: DB_NAME, + auth: { + issuerUrl: AUTH_ISSUER_URL, + baseUrl: AUTH_BASE_URL, + cookieName: AUTH_COOKIE_NAME, + sessionTtlSeconds: AUTH_SESSION_TTL_SECONDS, + hasEs256PrivateKeyPem: Boolean( + configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM) + ), + }, + oauth: { + google: GOOGLE_OAUTH_ENABLED, + github: GITHUB_OAUTH_ENABLED, + }, + }); +}); + +try { + console.log(`[auth] bootstrapping env config via ${SPACETIME_BIN}`); + configureAuthFromEnv(); + console.log(`[auth] bootstrapped env config issuer=${AUTH_ISSUER_URL}`); +} catch (err) { + console.error( + `[auth] env config bootstrap failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[auth] is the SpacetimeDB host running and the grid example module published?' + ); + process.exit(1); +} + +app.listen(PORT, HOST, () => { + console.log(`Grid example running at http://${HOST}:${PORT}`); + console.log(` STDB ws -> ${STDB_URI}`); + console.log(` STDB http-> ${STDB_HTTP} (proxying /auth/*)`); + console.log(` Database -> ${DB_NAME}`); +}); diff --git a/spacetime-grid-ts/example/spacetimedb/.npmrc b/spacetime-grid-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-grid-ts/example/spacetimedb/package.json b/spacetime-grid-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..fcd1a07f78f --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/package.json @@ -0,0 +1,20 @@ +{ + "name": "spacetime-grid-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-grid-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-grid-example" + }, + "dependencies": { + "@spacetimedb/auth": "workspace:*", + "@spacetimedb/grid": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-grid-ts/example/spacetimedb/src/auth-adapter.ts b/spacetime-grid-ts/example/spacetimedb/src/auth-adapter.ts new file mode 100644 index 00000000000..aefa062282c --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/src/auth-adapter.ts @@ -0,0 +1,181 @@ +import { Router, t } from 'spacetimedb/server'; +import { Timestamp } from 'spacetimedb'; +import * as auth from '@spacetimedb/auth/submodule'; +import { + setAuthConfigParams, + getPublicKeyPemParams, + linkConnectionParams, + unlinkConnectionParams, + updateProfileParams, + revokeSessionParams, + listMySessionsParams, + revokeMySessionParams, + passwordSignupHandler, + passwordLoginHandler, + meHandler, + logoutHandler, + refreshHandler, + googleStartHandler, + googleCallbackHandler, + githubStartHandler, + githubCallbackHandler, + makeForgotPasswordHandler, + resetPasswordHandler, + makeEmailVerifyRequestHandler, + makeEmailVerifyHandler, +} from '@spacetimedb/auth/submodule'; + +import { consoleSendMail, spacetimedb } from './schema'; + +export const set_auth_config = spacetimedb.reducer( + setAuthConfigParams, + (ctx, args) => { + auth.set_auth_config(ctx.as.auth, args); + } +); + +export const get_auth_public_key = spacetimedb.procedure( + getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + (ctx, args) => + auth.get_auth_public_key(ctx.as.auth, args) as { + publicKeyPem: string; + keyId: string; + issuerUrl: string; + } +); + +export const link_connection = spacetimedb.reducer( + linkConnectionParams, + (ctx, args) => { + auth.link_connection(ctx.as.auth, args); + } +); + +export const unlink_connection = spacetimedb.reducer( + unlinkConnectionParams, + (ctx, args) => { + auth.unlink_connection(ctx.as.auth, args); + } +); + +export const update_profile = spacetimedb.reducer( + updateProfileParams, + (ctx, args) => { + auth.update_profile(ctx.as.auth, args); + } +); + +export const revoke_session = spacetimedb.reducer( + revokeSessionParams, + (ctx, args) => { + auth.revoke_session(ctx.as.auth, args); + } +); + +export const list_my_sessions = spacetimedb.procedure( + listMySessionsParams, + t.object('MySessions', { + sessions: t.array( + t.object('MySession', { + sessionId: t.string(), + expiresAt: t.timestamp(), + createdAt: t.timestamp(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + isCurrent: t.bool(), + }) + ), + }), + (ctx, args) => + auth.list_my_sessions(ctx.as.auth, args) as { + sessions: Array<{ + sessionId: string; + expiresAt: Timestamp; + createdAt: Timestamp; + ipAddress: string | undefined; + userAgent: string | undefined; + isCurrent: boolean; + }>; + } +); + +export const revoke_my_session = spacetimedb.reducer( + revokeMySessionParams, + (ctx, args) => { + auth.revoke_my_session(ctx.as.auth, args); + } +); + +const forgotHandler = makeForgotPasswordHandler({ + sendMail: consoleSendMail, + appName: 'Grid', +}); +const verifyRequestHandler = makeEmailVerifyRequestHandler({ + sendMail: consoleSendMail, + appName: 'Grid', +}); +const verifyHandler = makeEmailVerifyHandler({ + successRedirect: '/?verified=1', +}); + +export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => + passwordSignupHandler(ctx.as.auth, req) +); +export const authPasswordLogin = spacetimedb.httpHandler((ctx, req) => + passwordLoginHandler(ctx.as.auth, req) +); +export const authMe = spacetimedb.httpHandler((ctx, req) => + meHandler(ctx.as.auth, req) +); +export const authLogout = spacetimedb.httpHandler((ctx, req) => + logoutHandler(ctx.as.auth, req) +); +export const authRefresh = spacetimedb.httpHandler((ctx, req) => + refreshHandler(ctx.as.auth, req) +); +export const authGoogleStart = spacetimedb.httpHandler((ctx, req) => + googleStartHandler(ctx.as.auth, req) +); +export const authGoogleCallback = spacetimedb.httpHandler((ctx, req) => + googleCallbackHandler(ctx.as.auth, req) +); +export const authGithubStart = spacetimedb.httpHandler((ctx, req) => + githubStartHandler(ctx.as.auth, req) +); +export const authGithubCallback = spacetimedb.httpHandler((ctx, req) => + githubCallbackHandler(ctx.as.auth, req) +); +export const authPasswordForgot = spacetimedb.httpHandler((ctx, req) => + forgotHandler(ctx.as.auth, req) +); +export const authPasswordReset = spacetimedb.httpHandler((ctx, req) => + resetPasswordHandler(ctx.as.auth, req) +); +export const authEmailVerifyRequest = spacetimedb.httpHandler((ctx, req) => + verifyRequestHandler(ctx.as.auth, req) +); +export const authEmailVerify = spacetimedb.httpHandler((ctx, req) => + verifyHandler(ctx.as.auth, req) +); + +export const router = spacetimedb.httpRouter( + new Router() + .post('/auth/password/signup', authPasswordSignup) + .post('/auth/password/login', authPasswordLogin) + .post('/auth/session/refresh', authRefresh) + .get('/auth/me', authMe) + .post('/auth/logout', authLogout) + .get('/auth/google/start', authGoogleStart) + .get('/auth/google/callback', authGoogleCallback) + .get('/auth/github/start', authGithubStart) + .get('/auth/github/callback', authGithubCallback) + .post('/auth/password/forgot', authPasswordForgot) + .post('/auth/password/reset', authPasswordReset) + .post('/auth/email/verify-request', authEmailVerifyRequest) + .get('/auth/email/verify', authEmailVerify) +); diff --git a/spacetime-grid-ts/example/spacetimedb/src/index.ts b/spacetime-grid-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..c252eed07a3 --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,930 @@ +import { t, SenderError, type ProcedureCtx } from 'spacetimedb/server'; +import { Timestamp, type Identity } from 'spacetimedb'; +import * as auth from '@spacetimedb/auth/submodule'; +import * as gridSubmodule from '@spacetimedb/grid/submodule'; +import { getCallerUserId } from '@spacetimedb/auth/submodule'; +import { + GRID_KIND_HEX, + GRID_ORIENTATION_FLAT, + GRID_MODE_COLLABORATIVE, + computePath, + cellsInRange, +} from '@spacetimedb/grid'; +import { distance } from '@spacetimedb/grid/math'; + +import { + MatchStatus, + AI_BOT_USER_ID, + AI_BOT_NAME, + spacetimedb, + type Schema, + type WriteCtx, +} from './schema'; +export { default } from './schema'; +export * from './auth-adapter'; + +function throwSenderError(msg: string): never { + throw new SenderError(msg); +} + +function requireUserId(ctx: ProcedureCtx): string { + const userId = getCallerUserId(ctx.as.auth); + if (!userId) throwSenderError('grid.not_authenticated'); + return userId; +} + +export * from './views'; + +export const init = spacetimedb.init(ctx => { + auth.installAuth(ctx.as.auth); + gridSubmodule.installGrid(ctx.as.grid); + + const types = [ + { + typeId: 'marine', + name: 'Marine', + movement: 3, + attackRange: 1, + attackDmg: 3, + hp: 10, + glyph: 'M', + }, + { + typeId: 'titan', + name: 'Titan', + movement: 4, + attackRange: 1, + attackDmg: 5, + hp: 14, + glyph: 'T', + }, + { + typeId: 'drone', + name: 'Drone', + movement: 6, + attackRange: 2, + attackDmg: 2, + hp: 6, + glyph: 'D', + }, + ]; + for (const u of types) { + if (!ctx.db.unitType.typeId.find(u.typeId)) ctx.db.unitType.insert(u); + } + // Seed the AI opponent as an NPC actor. Lives outside auth_user so it + // can't be impersonated and shows up in actor_directory as an Npc, not a User. + if (!ctx.db.npcActor.actorId.find(AI_BOT_USER_ID)) { + ctx.db.npcActor.insert({ + actorId: AI_BOT_USER_ID, + name: AI_BOT_NAME, + image: undefined, + createdAt: ctx.timestamp, + }); + } +}); + +export const whoami = spacetimedb.procedure( + {}, + t.object('WhoAmI', { + userId: t.option(t.string()), + senderIdentityHex: t.string(), + }), + ctx => { + const userId = getCallerUserId(ctx.as.auth); + return { + userId: userId ?? undefined, + senderIdentityHex: (ctx.sender as Identity).toHexString(), + }; + } +); + +// The playable area is a HEXAGON of radius R centered at axial (R, R). +// The grid submodule allocates a (2R+1) x (2R+1) rectangle because its bounds +// checker uses rectangular coordinates. Cells outside the playable hex are +// impassable, which keeps A* and Dijkstra inside the SpacetimeDB-logo shape. +const GRID_RADIUS = 5; +const GRID_DIAMETER = 2 * GRID_RADIUS + 1; // 11 +const DEFAULT_COST = 1; + +function isInHexShape(q: number, r: number): boolean { + const cx = GRID_RADIUS, + cy = GRID_RADIUS; + return ( + (Math.abs(q - cx) + Math.abs(r - cy) + Math.abs(q + r - (cx + cy))) / 2 <= + GRID_RADIUS + ); +} + +const PLAYER_SPAWNS = [ + { x: GRID_RADIUS, y: 0, typeId: 'marine' }, + { x: GRID_RADIUS - 1, y: 1, typeId: 'titan' }, + { x: GRID_RADIUS + 1, y: 0, typeId: 'drone' }, +]; +const OPPONENT_SPAWNS = [ + { x: GRID_RADIUS, y: 2 * GRID_RADIUS, typeId: 'marine' }, + { x: GRID_RADIUS + 1, y: 2 * GRID_RADIUS - 1, typeId: 'titan' }, + { x: GRID_RADIUS - 1, y: 2 * GRID_RADIUS, typeId: 'drone' }, +]; + +// Deterministic-ish terrain seed (uses match createdAt micros). Cheap PRNG. +function rng(seed: bigint) { + let state = seed === 0n ? 1n : seed; + const UINT32_MASK = 0xffffffffn; + return (): number => { + state = (state * 1103515245n + 12345n) & UINT32_MASK; + return Number(state) / Number(UINT32_MASK); + }; +} + +export const create_match = spacetimedb.procedure( + { vsAi: t.bool() }, + t.object('CreateMatchResult', { matchId: t.u64(), gridId: t.u64() }), + (ctx, args) => { + const userId = requireUserId(ctx); + return ctx.withTx(tx => { + // 1. Create the grid (collaborative so both players can move units via our own reducers). + const gridRowInserted = tx.db.grid.grid.insert({ + id: 0n, + ownerUserId: userId, + name: `Match by ${userId.slice(0, 8)}`, + kind: GRID_KIND_HEX, + orientation: GRID_ORIENTATION_FLAT, + width: GRID_DIAMETER, + height: GRID_DIAMETER, + defaultCost: DEFAULT_COST, + connectivity: 6, + mode: GRID_MODE_COLLABORATIVE, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + + // 2. Seed terrain. Cells outside the hex shape are 'void' + impassable + // so neither A* nor Dijkstra crosses them. Spawn cells stay clear. + const spawnKeys = new Set( + [...PLAYER_SPAWNS, ...OPPONENT_SPAWNS].map(s => `${s.x},${s.y}`) + ); + const seedMicros = ctx.timestamp.microsSinceUnixEpoch as bigint; + const rand = rng(seedMicros); + for (let y = 0; y < GRID_DIAMETER; y++) { + for (let x = 0; x < GRID_DIAMETER; x++) { + if (!isInHexShape(x, y)) { + tx.db.grid.cellState.insert({ + id: 0n, + gridId: gridRowInserted.id, + x, + y, + cost: -1, + terrain: 'void', + }); + continue; + } + if (spawnKeys.has(`${x},${y}`)) continue; // keep spawns clear + // Single tactical-obstacle type: impassable crater. Movement is + // either 1 (regolith) or blocked - no slow terrain to remember. + if (rand() < 0.14) { + tx.db.grid.cellState.insert({ + id: 0n, + gridId: gridRowInserted.id, + x, + y, + cost: -1, + terrain: 'crater', + }); + } + } + } + + // 3. Create the match row. vs-AI starts active immediately; vs-human + // waits for someone to call join_match. + const matchInserted = tx.db.match.insert({ + matchId: 0n, + status: args.vsAi ? MatchStatus.Active : MatchStatus.Waiting, + currentSeatIdx: 0, + turnNumber: 1, + winnerUserId: undefined, + gridId: gridRowInserted.id, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + + insertParticipant(tx, matchInserted.matchId, userId, 0, 0, ctx.timestamp); + placeStartingUnits( + tx, + ctx.timestamp, + matchInserted.matchId, + gridRowInserted.id, + userId, + PLAYER_SPAWNS + ); + + if (args.vsAi) { + insertParticipant( + tx, + matchInserted.matchId, + AI_BOT_USER_ID, + 1, + 1, + ctx.timestamp + ); + placeStartingUnits( + tx, + ctx.timestamp, + matchInserted.matchId, + gridRowInserted.id, + AI_BOT_USER_ID, + OPPONENT_SPAWNS + ); + } + + return { matchId: matchInserted.matchId, gridId: gridRowInserted.id }; + }); + } +); + +export const join_match = spacetimedb.procedure( + { matchId: t.u64() }, + t.unit(), + (ctx, { matchId }) => { + const userId = requireUserId(ctx); + ctx.withTx(tx => { + const m = tx.db.match.matchId.find(matchId); + if (!m) throwSenderError(`grid.match_not_found:${matchId}`); + if (m.status.tag !== 'Waiting') + throwSenderError(`grid.match_not_joinable:${m.status.tag}`); + for (const p of tx.db.matchParticipant.matchId.filter(matchId)) { + if (p.userId === userId) throwSenderError(`grid.match_self_join`); + } + + insertParticipant(tx, m.matchId, userId, 1, 1, ctx.timestamp); + placeStartingUnits( + tx, + ctx.timestamp, + m.matchId, + m.gridId, + userId, + OPPONENT_SPAWNS + ); + + tx.db.match.matchId.update({ + ...m, + status: MatchStatus.Active, + updatedAt: ctx.timestamp, + }); + }); + return {}; + } +); + +export const end_turn = spacetimedb.procedure( + { matchId: t.u64() }, + t.unit(), + (ctx, { matchId }) => { + const userId = requireUserId(ctx); + ctx.withTx(tx => { + const m = tx.db.match.matchId.find(matchId); + if (!m) throwSenderError(`grid.match_not_found:${matchId}`); + if (m.status.tag !== 'Active') + throwSenderError(`grid.match_not_active:${m.status.tag}`); + const seats = participantsBySeat(tx, matchId); + if (userIdAt(seats, m.currentSeatIdx) !== userId) + throwSenderError(`grid.not_your_turn`); + + // Two-seat rotation. Generalizes via (currentSeatIdx + 1) % seats.size. + const nextIdx = (m.currentSeatIdx + 1) % seats.size; + const nextUserId = userIdAt(seats, nextIdx); + for (const u of tx.db.playerUnit.matchId.filter(matchId)) { + if (u.ownerUserId === nextUserId) { + tx.db.playerUnit.entityId.update({ + ...u, + hasMoved: false, + hasAttacked: false, + }); + } + } + tx.db.match.matchId.update({ + ...m, + currentSeatIdx: nextIdx, + turnNumber: nextIdx === 0 ? m.turnNumber + 1 : m.turnNumber, + updatedAt: ctx.timestamp, + }); + }); + return {}; + } +); + +function insertParticipant( + tx: WriteCtx, + matchId: bigint, + userId: string, + seatIdx: number, + team: number, + timestamp: Timestamp +): void { + tx.db.matchParticipant.insert({ + id: 0n, + matchId, + userId, + seatIdx, + team, + joinedAt: timestamp, + }); +} + +function participantsBySeat( + tx: WriteCtx, + matchId: bigint +): Map { + const seats = new Map(); + for (const p of tx.db.matchParticipant.matchId.filter(matchId)) { + seats.set(p.seatIdx, p.userId); + } + return seats; +} + +function userIdAt( + seats: Map, + seatIdx: number +): string | undefined { + return seats.get(seatIdx); +} + +function participantTeams(tx: WriteCtx, matchId: bigint): Map { + const teams = new Map(); + for (const p of tx.db.matchParticipant.matchId.filter(matchId)) { + teams.set(p.userId, p.team); + } + return teams; +} + +function placeStartingUnits( + tx: WriteCtx, + timestamp: Timestamp, + matchId: bigint, + gridId: bigint, + ownerUserId: string, + spawns: Array<{ x: number; y: number; typeId: string }> +): void { + for (const s of spawns) { + const type = tx.db.unitType.typeId.find(s.typeId); + if (!type) throwSenderError(`grid.unknown_unit_type:${s.typeId}`); + const entity = tx.db.grid.gridEntity.insert({ + id: 0n, + gridId, + ownerUserId, + x: s.x, + y: s.y, + kind: s.typeId, + blocksMovement: true, + label: undefined, + createdAt: timestamp, + updatedAt: timestamp, + }); + tx.db.playerUnit.insert({ + entityId: entity.id, + matchId, + ownerUserId, + typeId: s.typeId, + currentHp: type.hp, + hasMoved: false, + hasAttacked: false, + createdAt: timestamp, + }); + } +} + +export const move_unit = spacetimedb.procedure( + { entityId: t.u64(), toX: t.i32(), toY: t.i32() }, + t.object('MoveUnitResult', { + // The exact A* path from start to end, including both endpoints. The client + // animates the unit along this path to the destination. + path: t.array(t.object('MoveStep', { x: t.i32(), y: t.i32() })), + }), + (ctx, args) => { + const userId = requireUserId(ctx); + + // Validate ownership and turn state, then capture coordinates for pathfinding. + // computePath opens its own transaction, so run it after this transaction. + let entityX = 0, + entityY = 0; + let gridId = 0n; + let typeMovement = 0; + ctx.withTx(tx => { + const unit = tx.db.playerUnit.entityId.find(args.entityId); + if (!unit) throwSenderError(`grid.unit_not_found:${args.entityId}`); + if (unit.ownerUserId !== userId) throwSenderError(`grid.not_unit_owner`); + if (unit.hasMoved) throwSenderError(`grid.already_moved`); + + const m = tx.db.match.matchId.find(unit.matchId); + if (!m) throwSenderError(`grid.match_not_found:${unit.matchId}`); + if (m.status.tag !== 'Active') + throwSenderError(`grid.match_not_active:${m.status.tag}`); + const seats = participantsBySeat(tx, unit.matchId); + if (userIdAt(seats, m.currentSeatIdx) !== userId) + throwSenderError(`grid.not_your_turn`); + + const entity = tx.db.grid.gridEntity.id.find(args.entityId); + if (!entity) throwSenderError(`grid.entity_not_found:${args.entityId}`); + + const type = tx.db.unitType.typeId.find(unit.typeId); + if (!type) throwSenderError(`grid.unknown_unit_type:${unit.typeId}`); + + entityX = entity.x; + entityY = entity.y; + gridId = entity.gridId; + typeMovement = type.movement; + }); + + const path = computePath( + ctx.as.grid, + { + gridId, + startX: entityX, + startY: entityY, + endX: args.toX, + endY: args.toY, + storeFor: undefined, + maxExpansions: undefined, + }, + userId + ) as { + found: boolean; + cells: Array<{ x: number; y: number }>; + cost: number; + }; + if (!path.found) + throwSenderError(`grid.no_path_to:${args.toX},${args.toY}`); + if (path.cost > typeMovement) + throwSenderError(`grid.move_too_far:${path.cost}>${typeMovement}`); + + // Apply the move. Procedure serialization bounds TOCTOU, and the transaction + // verifies hasMoved immediately before mutation. + ctx.withTx(tx => { + const unit = tx.db.playerUnit.entityId.find(args.entityId); + const entity = tx.db.grid.gridEntity.id.find(args.entityId); + if (!unit || !entity) throwSenderError(`grid.unit_vanished`); + if (unit.hasMoved) throwSenderError(`grid.already_moved`); + tx.db.grid.gridEntity.id.update({ + ...entity, + x: args.toX, + y: args.toY, + updatedAt: ctx.timestamp, + }); + tx.db.playerUnit.entityId.update({ ...unit, hasMoved: true }); + }); + return { path: path.cells }; + } +); + +export const attack_unit = spacetimedb.procedure( + { attackerId: t.u64(), targetId: t.u64() }, + t.unit(), + (ctx, args) => { + const userId = requireUserId(ctx); + ctx.withTx(tx => { + const attacker = tx.db.playerUnit.entityId.find(args.attackerId); + const target = tx.db.playerUnit.entityId.find(args.targetId); + if (!attacker) throwSenderError(`grid.unit_not_found:${args.attackerId}`); + if (!target) throwSenderError(`grid.unit_not_found:${args.targetId}`); + if (attacker.ownerUserId !== userId) + throwSenderError(`grid.not_unit_owner`); + if (attacker.hasAttacked) throwSenderError(`grid.already_attacked`); + if (target.ownerUserId === userId) + throwSenderError(`grid.cant_attack_self`); + if (attacker.matchId !== target.matchId) + throwSenderError(`grid.cross_match_attack`); + + const m = tx.db.match.matchId.find(attacker.matchId); + if (!m) throwSenderError(`grid.match_not_found:${attacker.matchId}`); + if (m.status.tag !== 'Active') throwSenderError(`grid.match_not_active`); + const seats = participantsBySeat(tx, attacker.matchId); + if (userIdAt(seats, m.currentSeatIdx) !== userId) + throwSenderError(`grid.not_your_turn`); + + const attackerEntity = tx.db.grid.gridEntity.id.find(args.attackerId); + const targetEntity = tx.db.grid.gridEntity.id.find(args.targetId); + if (!attackerEntity || !targetEntity) + throwSenderError(`grid.entity_missing`); + + const type = tx.db.unitType.typeId.find(attacker.typeId); + if (!type) throwSenderError(`grid.unknown_unit_type:${attacker.typeId}`); + + const dist = distance( + 'hex', + { x: attackerEntity.x, y: attackerEntity.y }, + { x: targetEntity.x, y: targetEntity.y } + ); + if (dist > type.attackRange) { + throwSenderError( + `grid.target_out_of_range:${dist}>${type.attackRange}` + ); + } + + const newHp = target.currentHp - type.attackDmg; + tx.db.playerUnit.entityId.update({ ...attacker, hasAttacked: true }); + + if (newHp <= 0) { + tx.db.playerUnit.delete(target); + tx.db.grid.gridEntity.delete(targetEntity); + + const teamByUser = participantTeams(tx, attacker.matchId); + const myTeam = teamByUser.get(userId); + const remaining = [ + ...tx.db.playerUnit.matchId.filter(attacker.matchId), + ].filter(u => teamByUser.get(u.ownerUserId) !== myTeam); + if (remaining.length === 0) { + tx.db.match.matchId.update({ + ...m, + status: MatchStatus.Ended, + winnerUserId: userId, + updatedAt: ctx.timestamp, + }); + } + } else { + tx.db.playerUnit.entityId.update({ ...target, currentHp: newHp }); + } + }); + return {}; + } +); + +// AI opponent. Triggered by the client after end_turn flips to AI's turn. +// Greedy heuristic: for each AI unit (highest-damage first), attack the +// lowest-HP enemy in range; otherwise move toward the nearest enemy and +// attack after the move if newly in range. Then flips the turn back. + +type AiUnit = { + entityId: bigint; + typeId: string; + currentHp: number; + x: number; + y: number; + hasMoved: boolean; + hasAttacked: boolean; +}; +type EnemyUnit = { + entityId: bigint; + ownerUserId: string; + currentHp: number; + x: number; + y: number; +}; +type UnitTypeSnap = { + movement: number; + attackRange: number; + attackDmg: number; + hp: number; +}; + +export const ai_take_turn = spacetimedb.procedure( + { matchId: t.u64() }, + t.object('AiTakeTurnResult', { + // One event per acting unit, in execution order. Each event may have a + // movePath (the A* path the unit walked) and/or an attack (with target + // snapshot so the client can ghost-render the victim until the attack + // visually fires after the move animation). Lets the client sequence: + // move animation -> brief pause -> attack flash -> target HP drop / death. + events: t.array( + t.object('AiTurnEvent', { + entityId: t.u64(), + movePath: t.option( + t.array(t.object('AiPathStep', { x: t.i32(), y: t.i32() })) + ), + attack: t.option( + t.object('AiAttackInfo', { + targetId: t.u64(), + damage: t.i32(), + killed: t.bool(), + // Target snapshot AT the moment of attack (so the client knows where + // to draw the ghost while it's pending and what HP to show). + targetX: t.i32(), + targetY: t.i32(), + targetOwner: t.string(), + targetTypeId: t.string(), + targetPreHp: t.i32(), + }) + ), + }) + ), + }), + (ctx, args) => { + // Any signed-in human in the match can trigger the AI to play. The + // procedure validates the AI turn before acting, so invalid calls are no-ops. + requireUserId(ctx); + type AttackInfo = { + targetId: bigint; + damage: number; + killed: boolean; + targetX: number; + targetY: number; + targetOwner: string; + targetTypeId: string; + targetPreHp: number; + }; + // Fields are `| undefined` (not optional) because spacetimedb's t.option + // serializer requires them to be present in the runtime shape. + type TurnEvent = { + entityId: bigint; + movePath: Array<{ x: number; y: number }> | undefined; + attack: AttackInfo | undefined; + }; + const events: TurnEvent[] = []; + + let gridId = 0n; + const aiUnits: AiUnit[] = []; + const enemyUnits: EnemyUnit[] = []; + const typeIdx = new Map(); + ctx.withTx(tx => { + const m = tx.db.match.matchId.find(args.matchId); + if (!m) throwSenderError(`grid.match_not_found:${args.matchId}`); + if (m.status.tag !== 'Active') + throwSenderError(`grid.match_not_active:${m.status.tag}`); + const seats = participantsBySeat(tx, args.matchId); + if (userIdAt(seats, m.currentSeatIdx) !== AI_BOT_USER_ID) + throwSenderError(`grid.not_ai_turn`); + gridId = m.gridId; + for (const u of tx.db.playerUnit.matchId.filter(args.matchId)) { + const e = tx.db.grid.gridEntity.id.find(u.entityId); + if (!e) continue; + if (u.ownerUserId === AI_BOT_USER_ID) { + aiUnits.push({ + entityId: u.entityId, + typeId: u.typeId, + currentHp: u.currentHp, + x: e.x, + y: e.y, + hasMoved: u.hasMoved, + hasAttacked: u.hasAttacked, + }); + } else { + enemyUnits.push({ + entityId: u.entityId, + ownerUserId: u.ownerUserId, + currentHp: u.currentHp, + x: e.x, + y: e.y, + }); + } + } + for (const t of tx.db.unitType.iter()) { + typeIdx.set(t.typeId, { + movement: t.movement, + attackRange: t.attackRange, + attackDmg: t.attackDmg, + hp: t.hp, + }); + } + }); + + const hexDist = (ax: number, ay: number, bx: number, by: number) => + distance('hex', { x: ax, y: ay }, { x: bx, y: by }); + + // Apply one attack atomically. Returns { info, ended } where `info` is + // the snapshot needed to animate the attack on the client. A null `info` + // means no attack fired, and `ended` indicates the match concluded. + const tryAttack = ( + aiUnit: AiUnit, + target: EnemyUnit, + dmg: number + ): { info: AttackInfo | null; ended: boolean } => { + let ended = false; + let info: AttackInfo | null = null; + ctx.withTx(tx => { + const attacker = tx.db.playerUnit.entityId.find(aiUnit.entityId); + const tgt = tx.db.playerUnit.entityId.find(target.entityId); + if (!attacker || !tgt || attacker.hasAttacked) return; + const newHp = tgt.currentHp - dmg; + const tEnt = tx.db.grid.gridEntity.id.find(target.entityId); + // Capture target snapshot BEFORE the delete/update so the client + // can ghost-render it during the move animation. + info = { + targetId: target.entityId, + damage: dmg, + killed: newHp <= 0, + targetX: tEnt ? tEnt.x : target.x, + targetY: tEnt ? tEnt.y : target.y, + targetOwner: tgt.ownerUserId, + targetTypeId: tgt.typeId, + targetPreHp: tgt.currentHp, + }; + tx.db.playerUnit.entityId.update({ ...attacker, hasAttacked: true }); + if (newHp <= 0) { + tx.db.playerUnit.delete(tgt); + if (tEnt) tx.db.grid.gridEntity.delete(tEnt); + const teamByUser = participantTeams(tx, args.matchId); + const aiTeam = teamByUser.get(AI_BOT_USER_ID); + const remaining = [ + ...tx.db.playerUnit.matchId.filter(args.matchId), + ].filter(u => teamByUser.get(u.ownerUserId) !== aiTeam); + if (remaining.length === 0) { + const mm = tx.db.match.matchId.find(args.matchId); + if (mm) { + tx.db.match.matchId.update({ + ...mm, + status: MatchStatus.Ended, + winnerUserId: AI_BOT_USER_ID, + updatedAt: ctx.timestamp, + }); + ended = true; + } + } + } else { + tx.db.playerUnit.entityId.update({ ...tgt, currentHp: newHp }); + } + }); + // Mirror in our local snapshot for subsequent units' planning. + aiUnit.hasAttacked = true; + target.currentHp -= dmg; + if (target.currentHp <= 0) { + const idx = enemyUnits.indexOf(target); + if (idx >= 0) enemyUnits.splice(idx, 1); + } + return { info, ended }; + }; + + // Best target in attack range: lowest currentHp (greedy lethal-first). + const pickTarget = (aiUnit: AiUnit, range: number): EnemyUnit | null => { + const inRange = enemyUnits + .filter(e => e.currentHp > 0) + .filter(e => hexDist(aiUnit.x, aiUnit.y, e.x, e.y) <= range); + if (inRange.length === 0) return null; + inRange.sort((a, b) => a.currentHp - b.currentHp); + return inRange[0]; + }; + + // High-damage units act first so the kills land before the chip-damage. + aiUnits.sort( + (a, b) => + (typeIdx.get(b.typeId)?.attackDmg ?? 0) - + (typeIdx.get(a.typeId)?.attackDmg ?? 0) + ); + + for (const aiUnit of aiUnits) { + const type = typeIdx.get(aiUnit.typeId); + if (!type) continue; + + const evt: TurnEvent = { + entityId: aiUnit.entityId, + movePath: undefined, + attack: undefined, + }; + + if (!aiUnit.hasAttacked) { + const target = pickTarget(aiUnit, type.attackRange); + if (target) { + const r = tryAttack(aiUnit, target, type.attackDmg); + if (r.info) evt.attack = r.info; + if (r.ended) { + events.push(evt); + return { events }; + } + events.push(evt); + continue; // already attacked; skip move this turn + } + } + + if (!aiUnit.hasMoved && enemyUnits.length > 0) { + const cells = ( + cellsInRange( + ctx.as.grid, + { + gridId, + originX: aiUnit.x, + originY: aiUnit.y, + maxCost: type.movement, + }, + AI_BOT_USER_ID + ) as { cells: Array<{ x: number; y: number; cost: number }> } + ).cells; + + // Avoid stepping onto a tile occupied by another known unit (defensive; + // blocksMovement on entities should already prevent this). + const blocked = new Set(); + for (const u of aiUnits) + if (u.entityId !== aiUnit.entityId) blocked.add(`${u.x},${u.y}`); + for (const e of enemyUnits) blocked.add(`${e.x},${e.y}`); + + const movable = cells.filter( + c => c.cost > 0 && !blocked.has(`${c.x},${c.y}`) + ); + if (movable.length > 0) { + let best = movable[0]; + let bestScore = Infinity; + for (const c of movable) { + const closest = Math.min( + ...enemyUnits.map(e => hexDist(c.x, c.y, e.x, e.y)) + ); + // Primary: closeness to enemy. Tiebreak: prefer cheaper paths. + const score = closest * 100 + c.cost; + if (score < bestScore) { + bestScore = score; + best = c; + } + } + // Capture the A* path BEFORE the move so the client can animate it. + const fromX = aiUnit.x, + fromY = aiUnit.y; + const pathRes = computePath( + ctx.as.grid, + { + gridId, + startX: fromX, + startY: fromY, + endX: best.x, + endY: best.y, + storeFor: undefined, + maxExpansions: undefined, + }, + AI_BOT_USER_ID + ) as { + found: boolean; + cells: Array<{ x: number; y: number }>; + cost: number; + }; + ctx.withTx(tx => { + const u = tx.db.playerUnit.entityId.find(aiUnit.entityId); + const e = tx.db.grid.gridEntity.id.find(aiUnit.entityId); + if (!u || !e || u.hasMoved) return; + tx.db.grid.gridEntity.id.update({ + ...e, + x: best.x, + y: best.y, + updatedAt: ctx.timestamp, + }); + tx.db.playerUnit.entityId.update({ ...u, hasMoved: true }); + }); + aiUnit.x = best.x; + aiUnit.y = best.y; + aiUnit.hasMoved = true; + evt.movePath = + pathRes.found && pathRes.cells.length >= 2 + ? pathRes.cells + : [ + { x: fromX, y: fromY }, + { x: best.x, y: best.y }, + ]; + + if (!aiUnit.hasAttacked) { + const target = pickTarget(aiUnit, type.attackRange); + if (target) { + const r = tryAttack(aiUnit, target, type.attackDmg); + if (r.info) evt.attack = r.info; + if (r.ended) { + events.push(evt); + return { events }; + } + } + } + } + } + + if (evt.movePath || evt.attack) events.push(evt); + } + + ctx.withTx(tx => { + const m = tx.db.match.matchId.find(args.matchId); + if (!m || m.status.tag !== 'Active') return; + const seats = participantsBySeat(tx, args.matchId); + const nextIdx = (m.currentSeatIdx + 1) % seats.size; + const nextUserId = userIdAt(seats, nextIdx); + for (const u of tx.db.playerUnit.matchId.filter(args.matchId)) { + if (u.ownerUserId === nextUserId) { + tx.db.playerUnit.entityId.update({ + ...u, + hasMoved: false, + hasAttacked: false, + }); + } + } + tx.db.match.matchId.update({ + ...m, + currentSeatIdx: nextIdx, + turnNumber: nextIdx === 0 ? m.turnNumber + 1 : m.turnNumber, + updatedAt: ctx.timestamp, + }); + }); + + return { events }; + } +); + +// Query helpers exposed as procedures (so the client can preview +// movement range / paths without subscribing to entity_path). + +export const get_cells_in_range = spacetimedb.procedure( + { gridId: t.u64(), originX: t.i32(), originY: t.i32(), maxCost: t.i32() }, + t.object('CellsInRangeResult', { + cells: t.array( + t.object('ReachableCell', { + x: t.i32(), + y: t.i32(), + cost: t.i32(), + }) + ), + }), + (ctx, args) => { + const userId = requireUserId(ctx); + return cellsInRange(ctx.as.grid, args, userId) as { + cells: Array<{ x: number; y: number; cost: number }>; + }; + } +); diff --git a/spacetime-grid-ts/example/spacetimedb/src/schema.ts b/spacetime-grid-ts/example/spacetimedb/src/schema.ts new file mode 100644 index 00000000000..8545a0abdc4 --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/src/schema.ts @@ -0,0 +1,130 @@ +import { + schema, + t, + table, + type InferSchema, + type TransactionCtx, +} from 'spacetimedb/server'; +import * as auth from '@spacetimedb/auth/submodule'; +import * as gridSubmodule from '@spacetimedb/grid/submodule'; +import { type SendMailFn, type MailParams } from '@spacetimedb/auth/submodule'; + +export const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => { + console.log( + `[mail] to=${params.to} subject=${params.subject}\n${params.text}` + ); +}; + +export const authUserViewRow = t.object('GridAuthUser', { + userId: t.string(), + email: t.string(), + emailVerified: t.bool(), + name: t.option(t.string()), + image: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}); + +// Static unit catalog. Seeded once in init. +export const unitType = table( + { name: 'unit_type', public: true }, + { + typeId: t.string().primaryKey(), + name: t.string(), + movement: t.i32(), + attackRange: t.i32(), + attackDmg: t.i32(), + hp: t.i32(), + glyph: t.string(), + } +); + +// Match lifecycle. Waiting = seats not yet filled. Active = playing. +// Ended = a side has won (winnerUserId is set). +export const matchStatus = t.enum('MatchStatus', [ + 'Waiting', + 'Active', + 'Ended', +]); +export const MatchStatus = { + Waiting: { tag: 'Waiting' as const }, + Active: { tag: 'Active' as const }, + Ended: { tag: 'Ended' as const }, +}; + +// One row per match. Participant userIds live in match_participant, not here. +export const match = table( + { name: 'match', public: false }, + { + matchId: t.u64().primaryKey().autoInc(), + status: matchStatus.index(), + currentSeatIdx: t.i32(), + turnNumber: t.i32(), + winnerUserId: t.option(t.string()), + gridId: t.u64().index(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +// Seats in a match. matchId+userId pair via two indexes - no host/opponent +// asymmetry, naturally extends past 2 seats, and a player's matches are +// reachable through matchParticipant.userId in O(log n). team groups allies +// for win-check semantics; in free-for-all each seat is its own team. +export const matchParticipant = table( + { name: 'match_participant', public: false }, + { + id: t.u64().primaryKey().autoInc(), + matchId: t.u64().index(), + userId: t.string().index(), + seatIdx: t.i32(), + team: t.i32(), + joinedAt: t.timestamp(), + } +); + +// Non-player actors include the built-in AI. Their actorId shares the +// auth_user.userId namespace for ownership columns. A separate table keeps +// NPCs out of authentication and user-directory rows. +export const npcActor = table( + { name: 'npc_actor', public: true }, + { + actorId: t.string().primaryKey(), + name: t.string(), + image: t.option(t.string()), + createdAt: t.timestamp(), + } +); + +export const AI_BOT_USER_ID = 'ai-bot-001'; +export const AI_BOT_NAME = 'Xeno Garrison'; + +// Combat state per unit. The grid submodule owns positional state +// (gridEntity.x/y); this row holds the game-specific layer on top. +export const playerUnit = table( + { name: 'player_unit', public: false }, + { + entityId: t.u64().primaryKey(), // FK into grid_entity.id + matchId: t.u64().index(), + ownerUserId: t.string().index(), + typeId: t.string().index(), + currentHp: t.i32(), + hasMoved: t.bool(), + hasAttacked: t.bool(), + createdAt: t.timestamp(), + } +); + +export const spacetimedb = schema({ + auth, + grid: gridSubmodule, + unitType, + match, + matchParticipant, + npcActor, + playerUnit, +}); +export default spacetimedb; + +export type Schema = InferSchema; +export type WriteCtx = TransactionCtx; diff --git a/spacetime-grid-ts/example/spacetimedb/src/views.ts b/spacetime-grid-ts/example/spacetimedb/src/views.ts new file mode 100644 index 00000000000..04e87d5270d --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/src/views.ts @@ -0,0 +1,235 @@ +import { t, type ViewCtx } from 'spacetimedb/server'; +import * as gridSubmodule from '@spacetimedb/grid/submodule'; + +import { + authUserViewRow, + MatchStatus, + match, + matchParticipant, + playerUnit, + spacetimedb, + type Schema, +} from './schema'; +export { default } from './schema'; + +export const myAuthUser = spacetimedb.view( + { name: 'my_auth_user', public: true }, + t.array(authUserViewRow), + ctx => { + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return []; + const row = ctx.db.auth.authUser.userId.find(binding.userId); + return row ? [row] : []; + } +); + +// Per-match scoping. Caller sees matches they participate in, the grid +// state for those matches, and other seats' participant rows so the lobby +// can render opponent names. Other matches are invisible. +function callerUserId(ctx: ViewCtx): string | undefined { + return ctx.db.auth.authConnectionBinding.stdbIdentity.find(ctx.sender) + ?.userId; +} + +function myMatchAndGridIds(ctx: ViewCtx): { + matchIds: Set; + gridIds: Set; +} { + const matchIds = new Set(); + const gridIds = new Set(); + const uid = callerUserId(ctx); + if (!uid) return { matchIds, gridIds }; + for (const p of ctx.db.matchParticipant.userId.filter(uid)) { + if (matchIds.has(p.matchId)) continue; + matchIds.add(p.matchId); + const m = ctx.db.match.matchId.find(p.matchId); + if (m) gridIds.add(m.gridId); + } + return { matchIds, gridIds }; +} + +export const myMatches = spacetimedb.view( + { name: 'my_matches', public: true }, + t.array(match.rowType), + ctx => { + const { matchIds } = myMatchAndGridIds(ctx); + if (matchIds.size === 0) return []; + const out = []; + for (const id of matchIds) { + const m = ctx.db.match.matchId.find(id); + if (m) out.push(m); + } + return out; + } +); +export const myGrids = spacetimedb.view( + { name: 'my_grids', public: true }, + t.array(gridSubmodule.grid.rowType), + ctx => { + const { gridIds } = myMatchAndGridIds(ctx); + if (gridIds.size === 0) return []; + const out = []; + for (const id of gridIds) { + const g = ctx.db.grid.grid.id.find(id); + if (g) out.push(g); + } + return out; + } +); + +export const myCellStates = spacetimedb.view( + { name: 'my_cell_states', public: true }, + t.array(gridSubmodule.cellState.rowType), + ctx => { + const { gridIds } = myMatchAndGridIds(ctx); + if (gridIds.size === 0) return []; + const out = []; + for (const gid of gridIds) { + for (const c of ctx.db.grid.cellState.gridId.filter(gid)) out.push(c); + } + return out; + } +); + +export const myGridEntities = spacetimedb.view( + { name: 'my_grid_entities', public: true }, + t.array(gridSubmodule.gridEntity.rowType), + ctx => { + const { gridIds } = myMatchAndGridIds(ctx); + if (gridIds.size === 0) return []; + const out = []; + for (const gid of gridIds) { + for (const e of ctx.db.grid.gridEntity.gridId.filter(gid)) out.push(e); + } + return out; + } +); + +export const myPlayerUnits = spacetimedb.view( + { name: 'my_player_units', public: true }, + t.array(playerUnit.rowType), + ctx => { + const { matchIds } = myMatchAndGridIds(ctx); + if (matchIds.size === 0) return []; + const out = []; + for (const mid of matchIds) { + for (const u of ctx.db.playerUnit.matchId.filter(mid)) out.push(u); + } + return out; + } +); + +export const myMatchParticipants = spacetimedb.view( + { name: 'my_match_participants', public: true }, + t.array(matchParticipant.rowType), + ctx => { + const { matchIds } = myMatchAndGridIds(ctx); + if (matchIds.size === 0) return []; + const out = []; + for (const mid of matchIds) { + for (const p of ctx.db.matchParticipant.matchId.filter(mid)) out.push(p); + } + return out; + } +); + +// Discriminator for actor_directory rows. User = real auth_user; Npc = +// npc_actor row (built-in opponents). +const actorKind = t.enum('ActorKind', ['User', 'Npc']); +const ActorKind = { + User: { tag: 'User' as const }, + Npc: { tag: 'Npc' as const }, +}; + +function visibleActorIds(ctx: ViewCtx): Set { + const ids = new Set(); + const uid = callerUserId(ctx); + if (!uid) return ids; + ids.add(uid); + + for (const ownSeat of ctx.db.matchParticipant.userId.filter(uid)) { + for (const seat of ctx.db.matchParticipant.matchId.filter( + ownSeat.matchId + )) { + ids.add(seat.userId); + } + } + + for (const pending of ctx.db.match.status.filter(MatchStatus.Waiting)) { + for (const seat of ctx.db.matchParticipant.matchId.filter( + pending.matchId + )) { + if (seat.seatIdx === 0) ids.add(seat.userId); + } + } + return ids; +} + +// Safe profile fields for actors the caller can currently encounter: the +// caller, participants in their matches, and hosts of joinable matches. +export const actorDirectory = spacetimedb.view( + { name: 'actor_directory', public: true }, + t.array( + t.object('ActorDirectoryRow', { + actorId: t.string(), + name: t.option(t.string()), + image: t.option(t.string()), + kind: actorKind, + }) + ), + ctx => { + const out = []; + for (const actorId of visibleActorIds(ctx)) { + const user = ctx.db.auth.authUser.userId.find(actorId); + if (user) { + out.push({ + actorId: user.userId, + name: user.name, + image: user.image, + kind: ActorKind.User, + }); + continue; + } + const npc = ctx.db.npcActor.actorId.find(actorId); + if (npc) { + out.push({ + actorId: npc.actorId, + name: npc.name, + image: npc.image, + kind: ActorKind.Npc, + }); + } + } + return out; + } +); + +// Public discovery of joinable matches. Anyone signed in can see Waiting +// matches and call join_match. +export const lobbyOpenMatches = spacetimedb.view( + { name: 'lobby_open_matches', public: true }, + t.array( + t.object('LobbyOpenMatch', { + matchId: t.u64(), + hostUserId: t.string(), + createdAt: t.timestamp(), + }) + ), + ctx => { + const out = []; + for (const m of ctx.db.match.status.filter(MatchStatus.Waiting)) { + const seat0 = [...ctx.db.matchParticipant.matchId.filter(m.matchId)].find( + p => p.seatIdx === 0 + ); + if (!seat0) continue; + out.push({ + matchId: m.matchId, + hostUserId: seat0.userId, + createdAt: m.createdAt, + }); + } + return out; + } +); diff --git a/spacetime-grid-ts/example/spacetimedb/tsconfig.json b/spacetime-grid-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..c18065b7cb8 --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-grid-ts/example/src/app.ts b/spacetime-grid-ts/example/src/app.ts new file mode 100644 index 00000000000..d6c30b86ac3 --- /dev/null +++ b/spacetime-grid-ts/example/src/app.ts @@ -0,0 +1,536 @@ +import { + authUrlState, + clearAuthResultParams, + mountAuthPanel, +} from '@spacetimedb/submodule-shared'; +import '@spacetimedb/submodule-shared/styles.css'; +import { + DbConnection, + tables, + type ErrorContext, + type SubscriptionHandle, +} from './module_bindings/app'; +interface AuthUser { + userId: string; + email: string; + emailVerified: boolean; + name?: string; + image?: string; +} +interface AuthMe { + user: AuthUser; + sessionExpiresAt: number; +} + +interface ReachableCell { + x: number; + y: number; + cost: number; +} + +declare global { + interface Window { + auth?: { + signup: (args: { + email: string; + password: string; + name?: string; + }) => Promise; + login: (args: { email: string; password: string }) => Promise; + logout: () => Promise; + oauthStart: (provider: 'google' | 'github') => void; + forgotPassword: (email: string) => Promise; + requestEmailVerify: () => Promise; + }; + grid?: { + createMatch: ( + vsAi: boolean + ) => Promise<{ matchId: bigint; gridId: bigint }>; + joinMatch: (matchId: bigint) => Promise; + setActiveMatch: (matchId: bigint | null) => void; + moveUnit: ( + entityId: bigint, + toX: number, + toY: number + ) => Promise<{ path: Array<{ x: number; y: number }> }>; + attackUnit: (attackerId: bigint, targetId: bigint) => Promise; + endTurn: (matchId: bigint) => Promise; + getCellsInRange: ( + gridId: bigint, + originX: number, + originY: number, + maxCost: number + ) => Promise; + aiBotUserId: string; + }; + } +} + +type ConnState = 'idle' | 'connecting' | 'connected' | 'error'; + +let currentConn: DbConnection | null = null; +let globalSub: SubscriptionHandle | null = null; +let matchSub: SubscriptionHandle | null = null; +let activeMatchId: bigint | null = null; +type ServerConfig = { + spacetimeUri: string; + databaseName: string; + oauth?: { + google?: boolean; + github?: boolean; + }; +}; + +let serverCfg: ServerConfig | null = null; + +let currentUser: AuthUser | null = null; +let currentExp: number | undefined; + +let reconnectAttempt = 0; +let reconnectTimer: ReturnType | null = null; +const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000]; + +function emitAppEvent(name: string, detail: unknown): void { + window.dispatchEvent(new CustomEvent(name, { detail })); +} +function emitConnectionState(state: ConnState, detail?: string): void { + emitAppEvent('grid:conn', { state, detail }); +} +function emitAuthState(): void { + emitAppEvent('grid:auth', { + user: currentUser, + sessionExpiresAt: currentExp, + }); +} +function emitGridState(): void { + const myUserId = currentUser?.userId; + if (!currentConn) { + emitAppEvent('grid:state', { + myUserId, + matches: [], + activeMatchId, + activeMatch: null, + activeGrid: null, + units: [], + entities: [], + cells: [], + unitTypes: [], + actors: [], + openMatches: [], + }); + return; + } + const c = currentConn; + const matchList = [...c.db.myMatches.iter()].sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch as bigint; + const bv = b.createdAt.microsSinceUnixEpoch as bigint; + return av < bv ? 1 : av > bv ? -1 : 0; + }); + const activeMatch = + activeMatchId !== null + ? (matchList.find(m => m.matchId === activeMatchId) ?? null) + : null; + const activeGrid = activeMatch + ? ([...c.db.myGrids.iter()].find(g => g.id === activeMatch.gridId) ?? null) + : null; + const activeUnits = + activeMatchId !== null + ? [...c.db.myPlayerUnits.iter()].filter(u => u.matchId === activeMatchId) + : []; + const activeEntities = activeGrid + ? [...c.db.myGridEntities.iter()].filter(e => e.gridId === activeGrid.id) + : []; + const activeCells = activeGrid + ? [...c.db.myCellStates.iter()].filter(c2 => c2.gridId === activeGrid.id) + : []; + emitAppEvent('grid:state', { + myUserId, + matches: matchList, + participants: [...c.db.myMatchParticipants.iter()], + activeMatchId, + activeMatch, + activeGrid, + units: activeUnits, + entities: activeEntities, + cells: activeCells, + unitTypes: [...c.db.unitType.iter()], + actors: [...c.db.actorDirectory.iter()], + openMatches: [...c.db.lobbyOpenMatches.iter()], + }); +} + +function requireConn(): DbConnection { + if (!currentConn) throw new Error('STDB not connected'); + return currentConn; +} + +async function callJson(path: string, body?: unknown): Promise { + const r = await fetch(path, { + method: body !== undefined ? 'POST' : 'GET', + headers: body !== undefined ? { 'content-type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'same-origin', + }); + let data: unknown = null; + try { + data = await r.json(); + } catch { + /* empty */ + } + if (!r.ok) { + const err = + data && typeof data === 'object' && 'error' in data + ? String((data as { error: unknown }).error) + : `http_${r.status}`; + throw new Error(err); + } + return data as T; +} + +async function loadServerConfig(): Promise { + const res = await fetch('/api/config', { credentials: 'same-origin' }); + if (!res.ok) throw new Error(`/api/config returned ${res.status}`); + const cfg = (await res.json()) as ServerConfig; + authPanel.setProviders({ + google: Boolean(cfg.oauth?.google), + github: Boolean(cfg.oauth?.github), + }); + return cfg; +} + +function connect(uri: string, databaseName: string): Promise { + return new Promise((resolve, reject) => { + DbConnection.builder() + .withUri(uri) + .withDatabaseName(databaseName) + .onConnect(c => resolve(c)) + .onDisconnect((_ctx, err) => { + emitConnectionState('error', err?.message ?? 'disconnected'); + currentConn = null; + globalSub = null; + matchSub = null; + if (currentUser) scheduleReconnect(); + }) + .onConnectError((_ctx, err) => { + emitConnectionState('error', err?.message ?? 'connect failed'); + reject(err); + }) + .build(); + }); +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + const delay = + RECONNECT_DELAYS_MS[ + Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1) + ]; + console.warn( + `STDB reconnect in ${delay}ms (attempt ${reconnectAttempt + 1})` + ); + reconnectTimer = setTimeout(async () => { + reconnectTimer = null; + reconnectAttempt++; + if (!currentUser) return; + try { + const r = await callJson<{ + user: AuthUser; + token: string; + sessionExpiresAt: number; + }>('/auth/session/refresh', {}); + await bindSession(r.token, r.user, r.sessionExpiresAt); + reconnectAttempt = 0; + } catch (err) { + console.error('Reconnect failed:', err); + scheduleReconnect(); + } + }, delay); +} + +function setActiveMatch(matchId: bigint | null): void { + if (activeMatchId === matchId) return; + activeMatchId = matchId; + + if (matchSub) { + matchSub.unsubscribe(); + matchSub = null; + } + + if (matchId === null || !currentConn) { + emitGridState(); + return; + } + + const m = currentConn + ? [...currentConn.db.myMatches.iter()].find(x => x.matchId === matchId) + : undefined; + if (!m) { + emitGridState(); + return; + } + + // Per-active-match subscription for the grid entities and cell states in + // this match's grid, plus the player_unit rows for this match. The match + // row + unit_type rows + auth_user rows are already in the global sub. + matchSub = currentConn + .subscriptionBuilder() + .onApplied(() => emitGridState()) + .onError((ctx: ErrorContext) => console.error('match sub error', ctx.event)) + .subscribe([ + tables.myPlayerUnits.where(row => row.matchId.eq(matchId)), + tables.myGridEntities.where(row => row.gridId.eq(m.gridId)), + tables.myCellStates.where(row => row.gridId.eq(m.gridId)), + tables.myGrids.where(row => row.id.eq(m.gridId)), + ]); +} + +function registerRowCallbacks(connection: DbConnection): void { + const tableAccessors = [ + connection.db.myMatches, + connection.db.myMatchParticipants, + connection.db.myPlayerUnits, + connection.db.unitType, + connection.db.myGrids, + connection.db.myGridEntities, + connection.db.myCellStates, + connection.db.actorDirectory, + connection.db.lobbyOpenMatches, + ]; + for (const t of tableAccessors) { + t.onInsert(() => emitGridState()); + t.onUpdate(() => emitGridState()); + t.onDelete(() => emitGridState()); + } +} + +function subscribeToTables(connection: DbConnection): SubscriptionHandle { + return connection + .subscriptionBuilder() + .onApplied(() => emitGridState()) + .onError((ctx: ErrorContext) => + console.error('global sub error', ctx.event) + ) + .subscribe([ + tables.myMatches, + tables.myMatchParticipants, + tables.unitType, + tables.actorDirectory, + tables.lobbyOpenMatches, + ]); +} + +async function bindSession( + token: string, + user: AuthUser, + exp: number +): Promise { + currentUser = user; + currentExp = exp; + + if (!serverCfg) serverCfg = await loadServerConfig(); + + if (!currentConn) { + emitConnectionState('connecting'); + try { + const conn = await connect( + serverCfg.spacetimeUri, + serverCfg.databaseName + ); + currentConn = conn; + reconnectAttempt = 0; + emitConnectionState('connected'); + + emitGridState(); + + registerRowCallbacks(conn); + + globalSub = subscribeToTables(conn); + + // Re-open per-match subscription if a match was active before reconnect. + const previousActive = activeMatchId; + activeMatchId = null; + matchSub = null; + if (previousActive !== null) setActiveMatch(previousActive); + } catch (err) { + emitConnectionState( + 'error', + err instanceof Error ? err.message : String(err) + ); + return; + } + } + + try { + await currentConn.reducers.linkConnection({ sessionToken: token }); + } catch (err) { + console.warn('link_connection failed', err); + } + + emitAuthState(); + emitGridState(); +} + +async function restoreSession(): Promise { + try { + const r = await callJson<{ + user: AuthUser; + token: string; + sessionExpiresAt: number; + }>('/auth/session/refresh', {}); + await bindSession(r.token, r.user, r.sessionExpiresAt); + return true; + } catch { + return false; + } +} + +async function signup(args: { + email: string; + password: string; + name?: string; +}): Promise { + const r = await callJson<{ token: string }>('/auth/password/signup', args); + const me = await callJson('/auth/me'); + await bindSession(r.token, me.user, me.sessionExpiresAt); +} +async function login(args: { email: string; password: string }): Promise { + const r = await callJson<{ token: string }>('/auth/password/login', args); + const me = await callJson('/auth/me'); + await bindSession(r.token, me.user, me.sessionExpiresAt); +} +async function logout(): Promise { + globalSub?.unsubscribe(); + globalSub = null; + matchSub?.unsubscribe(); + matchSub = null; + if (currentConn) { + try { + await currentConn.reducers.unlinkConnection({}); + } catch { + /* ignore */ + } + } + try { + await callJson('/auth/logout', {}); + } catch { + /* ignore */ + } + currentUser = null; + currentExp = undefined; + activeMatchId = null; + emitAuthState(); + emitGridState(); +} +function oauthStart(provider: 'google' | 'github'): void { + window.location.href = `/auth/${provider}/start?redirectTo=/`; +} +async function forgotPassword(email: string): Promise { + await callJson('/auth/password/forgot', { email }); +} +async function resetPassword( + token: string, + newPassword: string +): Promise { + await callJson('/auth/password/reset', { token, newPassword }); +} +async function requestEmailVerify(): Promise { + await callJson('/auth/email/verify-request', {}); +} + +const authResult = authUrlState(window.location); +const authPanelRoot = document.getElementById('auth-panel'); +if (!authPanelRoot) throw new Error('missing_auth_panel'); +const authPanel = mountAuthPanel(authPanelRoot, { + productName: 'Grid', + actions: { + login, + signup, + forgotPassword, + resetPassword, + oauthStart, + }, + initialMode: authResult.mode, + resetToken: authResult.resetToken, +}); +if (authResult.oauthError) { + authPanel.showMessage('error', `OAuth: ${authResult.oauthError}`); +} +if (authResult.verified) { + authPanel.showMessage('success', 'Email verified.'); +} +clearAuthResultParams(window.location, window.history); + +async function main(): Promise { + window.auth = { + signup, + login, + logout, + oauthStart, + forgotPassword, + requestEmailVerify, + }; + + window.grid = { + aiBotUserId: 'ai-bot-001', + createMatch: async vsAi => requireConn().procedures.createMatch({ vsAi }), + joinMatch: async matchId => { + await requireConn().procedures.joinMatch({ matchId }); + }, + setActiveMatch, + moveUnit: async (entityId, toX, toY) => { + const r = await requireConn().procedures.moveUnit({ entityId, toX, toY }); + return { path: r.path }; + }, + attackUnit: async (attackerId, targetId) => { + await requireConn().procedures.attackUnit({ attackerId, targetId }); + }, + endTurn: async matchId => { + await requireConn().procedures.endTurn({ matchId }); + // If the new active seat belongs to the built-in AI, prod it to play. + // Pause briefly so the player can see the turn flip in the UI. + const conn = currentConn; + const m = conn + ? [...conn.db.myMatches.iter()].find(x => x.matchId === matchId) + : undefined; + const seatUserId = + conn && m + ? [...conn.db.myMatchParticipants.iter()].find( + p => p.matchId === matchId && p.seatIdx === m.currentSeatIdx + )?.userId + : undefined; + if (m && m.status.tag === 'Active' && seatUserId === 'ai-bot-001') { + await new Promise(r => setTimeout(r, 400)); + try { + const result = await requireConn().procedures.aiTakeTurn({ matchId }); + // Hand the events to the renderer so it can sequence: + // move animation → pause → attack flash → target HP drop / death. + emitAppEvent('grid:ai-events', { events: result.events }); + } catch (err) { + console.error('ai_take_turn failed:', err); + } + } + }, + getCellsInRange: async (gridId, originX, originY, maxCost) => { + const r = await requireConn().procedures.getCellsInRange({ + gridId, + originX, + originY, + maxCost, + }); + return r.cells; + }, + }; + + emitConnectionState('idle'); + serverCfg = await loadServerConfig(); + await restoreSession(); + emitAppEvent('grid:ready', {}); +} + +main().catch(err => { + console.error(err); + emitConnectionState( + 'error', + err instanceof Error ? err.message : String(err) + ); +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/actor_directory_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/actor_directory_table.ts new file mode 100644 index 00000000000..a6151ec4368 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/actor_directory_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + ActorKind, +} from "./types"; + + +export default __t.row({ + actorId: __t.string().name("actor_id"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + get kind() { + return ActorKind; + }, +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/ai_take_turn_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/ai_take_turn_procedure.ts new file mode 100644 index 00000000000..8f540212e08 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/ai_take_turn_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AiTakeTurnResult, +} from "./types"; + +export const params = { + matchId: __t.u64(), +}; +export const returnType = AiTakeTurnResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/attack_unit_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/attack_unit_procedure.ts new file mode 100644 index 00000000000..6416ac0c492 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/attack_unit_procedure.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + attackerId: __t.u64(), + targetId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionToken: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts new file mode 100644 index 00000000000..6573c3fe132 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + userId: __t.string().primaryKey().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/types.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/types.ts @@ -0,0 +1,56 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/types.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/types.ts new file mode 100644 index 00000000000..8df99e3d1b4 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/types.ts @@ -0,0 +1,137 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AuthAccount = __t.object("AuthAccount", { + accountId: __t.string(), + userId: __t.string(), + providerId: __t.string(), + providerAccountId: __t.string(), + passwordHash: __t.option(__t.string()), + accessToken: __t.option(__t.string()), + refreshToken: __t.option(__t.string()), + accessTokenExpiresAt: __t.option(__t.timestamp()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthAccount = __Infer; + +export const AuthAdminIdentity = __t.object("AuthAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type AuthAdminIdentity = __Infer; + +export const AuthConfig = __t.object("AuthConfig", { + singleton: __t.bool(), + issuerUrl: __t.string(), + baseUrl: __t.string(), + cookieName: __t.string(), + sessionTtlSeconds: __t.u64(), + es256PrivateKeyPem: __t.string(), + es256PublicKeyPem: __t.string(), + keyId: __t.string(), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type AuthConfig = __Infer; + +export const AuthConnectionBinding = __t.object("AuthConnectionBinding", { + stdbIdentity: __t.identity(), + userId: __t.string(), + linkedAt: __t.timestamp(), +}); +export type AuthConnectionBinding = __Infer; + +export const AuthOauthState = __t.object("AuthOauthState", { + state: __t.string(), + provider: __t.string(), + codeVerifier: __t.string(), + redirectTo: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthOauthState = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const AuthSession = __t.object("AuthSession", { + sessionId: __t.string(), + userId: __t.string(), + token: __t.string(), + expiresAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type AuthSession = __Infer; + +export const AuthSweeperTick = __t.object("AuthSweeperTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type AuthSweeperTick = __Infer; + +export const AuthUser = __t.object("AuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthUser = __Infer; + +export const AuthVerification = __t.object("AuthVerification", { + verificationId: __t.string(), + identifier: __t.string(), + value: __t.string(), + purpose: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthVerification = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/whoami_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/whoami_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/create_match_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/create_match_procedure.ts new file mode 100644 index 00000000000..19e91eb52ec --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/create_match_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + CreateMatchResult, +} from "./types"; + +export const params = { + vsAi: __t.bool(), +}; +export const returnType = CreateMatchResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/end_turn_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/end_turn_procedure.ts new file mode 100644 index 00000000000..c1f25720383 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/end_turn_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + matchId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/get_cells_in_range_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/get_cells_in_range_procedure.ts new file mode 100644 index 00000000000..486c44aee70 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/get_cells_in_range_procedure.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + CellsInRangeResult, +} from "./types"; + +export const params = { + gridId: __t.u64(), + originX: __t.i32(), + originY: __t.i32(), + maxCost: __t.i32(), +}; +export const returnType = CellsInRangeResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/grid/types.ts b/spacetime-grid-ts/example/src/module_bindings/app/grid/types.ts new file mode 100644 index 00000000000..48f7c6524bd --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/grid/types.ts @@ -0,0 +1,69 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const CellState = __t.object("CellState", { + id: __t.u64(), + gridId: __t.u64(), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); +export type CellState = __Infer; + +export const EntityPath = __t.object("EntityPath", { + entityId: __t.u64(), + gridId: __t.u64(), + get cells() { + return __t.array(PathCell); + }, + cost: __t.i32(), + computedAt: __t.timestamp(), +}); +export type EntityPath = __Infer; + +export const Grid = __t.object("Grid", { + id: __t.u64(), + ownerUserId: __t.string(), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32(), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Grid = __Infer; + +export const GridEntity = __t.object("GridEntity", { + id: __t.u64(), + gridId: __t.u64(), + ownerUserId: __t.string(), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool(), + label: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridEntity = __Infer; + +export const PathCell = __t.object("PathCell", { + x: __t.i32(), + y: __t.i32(), +}); +export type PathCell = __Infer; + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/index.ts b/spacetime-grid-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..05a46c2eea7 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,360 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import LinkConnectionReducer from "./link_connection_reducer"; +import RevokeMySessionReducer from "./revoke_my_session_reducer"; +import RevokeSessionReducer from "./revoke_session_reducer"; +import SetAuthConfigReducer from "./set_auth_config_reducer"; +import UnlinkConnectionReducer from "./unlink_connection_reducer"; +import UpdateProfileReducer from "./update_profile_reducer"; + +// Import all procedure arg schemas +import * as AiTakeTurnProcedure from "./ai_take_turn_procedure"; +import * as AttackUnitProcedure from "./attack_unit_procedure"; +import * as CreateMatchProcedure from "./create_match_procedure"; +import * as EndTurnProcedure from "./end_turn_procedure"; +import * as GetAuthPublicKeyProcedure from "./get_auth_public_key_procedure"; +import * as GetCellsInRangeProcedure from "./get_cells_in_range_procedure"; +import * as JoinMatchProcedure from "./join_match_procedure"; +import * as ListMySessionsProcedure from "./list_my_sessions_procedure"; +import * as MoveUnitProcedure from "./move_unit_procedure"; +import * as WhoamiProcedure from "./whoami_procedure"; + +// Import all table schema definitions +import ActorDirectoryRow from "./actor_directory_table"; +import LobbyOpenMatchesRow from "./lobby_open_matches_table"; +import MyAuthUserRow from "./my_auth_user_table"; +import MyCellStatesRow from "./my_cell_states_table"; +import MyGridEntitiesRow from "./my_grid_entities_table"; +import MyGridsRow from "./my_grids_table"; +import MyMatchParticipantsRow from "./my_match_participants_table"; +import MyMatchesRow from "./my_matches_table"; +import MyPlayerUnitsRow from "./my_player_units_table"; +import NpcActorRow from "./npc_actor_table"; +import UnitTypeRow from "./unit_type_table"; + +// Import namespace table schema definitions +import AuthRateLimit_RateLimitConfigRow from "./auth/rateLimit/rate_limit_config_table"; +import Auth_MyAuthUserRow from "./auth/my_auth_user_table"; +import AuthRateLimit_AdminRateLimitBucketsRow from "./auth/rateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import Auth_LinkConnectionReducer from "./auth/link_connection_reducer"; +import Auth_RevokeMySessionReducer from "./auth/revoke_my_session_reducer"; +import Auth_RevokeSessionReducer from "./auth/revoke_session_reducer"; +import Auth_SetAuthConfigReducer from "./auth/set_auth_config_reducer"; +import Auth_UnlinkConnectionReducer from "./auth/unlink_connection_reducer"; +import Auth_UpdateProfileReducer from "./auth/update_profile_reducer"; +import AuthRateLimit_AddRateLimitAdminReducer from "./auth/rateLimit/add_rate_limit_admin_reducer"; +import AuthRateLimit_ResetBucketsReducer from "./auth/rateLimit/reset_buckets_reducer"; +import AuthRateLimit_UpdateConfigReducer from "./auth/rateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Auth_GetAuthPublicKeyProcedure from "./auth/get_auth_public_key_procedure"; +import * as Auth_ListMySessionsProcedure from "./auth/list_my_sessions_procedure"; +import * as Auth_WhoamiProcedure from "./auth/whoami_procedure"; +import * as AuthRateLimit_ConsumeProcedure from "./auth/rateLimit/consume_procedure"; +import * as AuthRateLimit_RunSweepProcedure from "./auth/rateLimit/run_sweep_procedure"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + npcActor: __table({ + name: 'npc_actor', + indexes: [ + { accessor: 'actorId', name: 'npc_actor_actor_id_idx_btree', algorithm: 'btree', columns: [ + 'actorId', + ] }, + ], + constraints: [ + { name: 'npc_actor_actor_id_key', constraint: 'unique', columns: ['actorId'] }, + ], + }, NpcActorRow), + unitType: __table({ + name: 'unit_type', + indexes: [ + { accessor: 'typeId', name: 'unit_type_type_id_idx_btree', algorithm: 'btree', columns: [ + 'typeId', + ] }, + ], + constraints: [ + { name: 'unit_type_type_id_key', constraint: 'unique', columns: ['typeId'] }, + ], + }, UnitTypeRow), + actorDirectory: __table({ + name: 'actor_directory', + indexes: [ + ], + constraints: [ + ], + }, ActorDirectoryRow), + lobbyOpenMatches: __table({ + name: 'lobby_open_matches', + indexes: [ + ], + constraints: [ + ], + }, LobbyOpenMatchesRow), + myAuthUser: __table({ + name: 'my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, MyAuthUserRow), + myCellStates: __table({ + name: 'my_cell_states', + indexes: [ + ], + constraints: [ + ], + }, MyCellStatesRow), + myGridEntities: __table({ + name: 'my_grid_entities', + indexes: [ + ], + constraints: [ + ], + }, MyGridEntitiesRow), + myGrids: __table({ + name: 'my_grids', + indexes: [ + ], + constraints: [ + ], + }, MyGridsRow), + myMatchParticipants: __table({ + name: 'my_match_participants', + indexes: [ + ], + constraints: [ + ], + }, MyMatchParticipantsRow), + myMatches: __table({ + name: 'my_matches', + indexes: [ + ], + constraints: [ + ], + }, MyMatchesRow), + myPlayerUnits: __table({ + name: 'my_player_units', + indexes: [ + ], + constraints: [ + ], + }, MyPlayerUnitsRow), + "auth.rateLimit.rate_limit_config": __table({ + name: 'auth.rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, AuthRateLimit_RateLimitConfigRow), + "auth.my_auth_user": __table({ + name: 'auth.my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, Auth_MyAuthUserRow), + "auth.rateLimit.admin_rate_limit_buckets": __table({ + name: 'auth.rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, AuthRateLimit_AdminRateLimitBucketsRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("link_connection", LinkConnectionReducer), + __reducerSchema("revoke_my_session", RevokeMySessionReducer), + __reducerSchema("revoke_session", RevokeSessionReducer), + __reducerSchema("set_auth_config", SetAuthConfigReducer), + __reducerSchema("unlink_connection", UnlinkConnectionReducer), + __reducerSchema("update_profile", UpdateProfileReducer), + __reducerSchema("auth.link_connection", Auth_LinkConnectionReducer), + __reducerSchema("auth.revoke_my_session", Auth_RevokeMySessionReducer), + __reducerSchema("auth.revoke_session", Auth_RevokeSessionReducer), + __reducerSchema("auth.set_auth_config", Auth_SetAuthConfigReducer), + __reducerSchema("auth.unlink_connection", Auth_UnlinkConnectionReducer), + __reducerSchema("auth.update_profile", Auth_UpdateProfileReducer), + __reducerSchema("auth.rateLimit.add_rate_limit_admin", AuthRateLimit_AddRateLimitAdminReducer), + __reducerSchema("auth.rateLimit.reset_buckets", AuthRateLimit_ResetBucketsReducer), + __reducerSchema("auth.rateLimit.update_config", AuthRateLimit_UpdateConfigReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("ai_take_turn", AiTakeTurnProcedure.params, AiTakeTurnProcedure.returnType), + __procedureSchema("attack_unit", AttackUnitProcedure.params, AttackUnitProcedure.returnType), + __procedureSchema("create_match", CreateMatchProcedure.params, CreateMatchProcedure.returnType), + __procedureSchema("end_turn", EndTurnProcedure.params, EndTurnProcedure.returnType), + __procedureSchema("get_auth_public_key", GetAuthPublicKeyProcedure.params, GetAuthPublicKeyProcedure.returnType), + __procedureSchema("get_cells_in_range", GetCellsInRangeProcedure.params, GetCellsInRangeProcedure.returnType), + __procedureSchema("join_match", JoinMatchProcedure.params, JoinMatchProcedure.returnType), + __procedureSchema("list_my_sessions", ListMySessionsProcedure.params, ListMySessionsProcedure.returnType), + __procedureSchema("move_unit", MoveUnitProcedure.params, MoveUnitProcedure.returnType), + __procedureSchema("whoami", WhoamiProcedure.params, WhoamiProcedure.returnType), + __procedureSchema("auth.get_auth_public_key", Auth_GetAuthPublicKeyProcedure.params, Auth_GetAuthPublicKeyProcedure.returnType), + __procedureSchema("auth.list_my_sessions", Auth_ListMySessionsProcedure.params, Auth_ListMySessionsProcedure.returnType), + __procedureSchema("auth.whoami", Auth_WhoamiProcedure.params, Auth_WhoamiProcedure.returnType), + __procedureSchema("auth.rateLimit.consume", AuthRateLimit_ConsumeProcedure.params, AuthRateLimit_ConsumeProcedure.returnType), + __procedureSchema("auth.rateLimit.run_sweep", AuthRateLimit_RunSweepProcedure.params, AuthRateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + npcActor: __qb.npcActor, + unitType: __qb.unitType, + actorDirectory: __qb.actorDirectory, + lobbyOpenMatches: __qb.lobbyOpenMatches, + myAuthUser: __qb.myAuthUser, + myCellStates: __qb.myCellStates, + myGridEntities: __qb.myGridEntities, + myGrids: __qb.myGrids, + myMatchParticipants: __qb.myMatchParticipants, + myMatches: __qb.myMatches, + myPlayerUnits: __qb.myPlayerUnits, + auth: { + myAuthUser: __qb["auth.my_auth_user"], + rateLimit: { + rateLimitConfig: __qb["auth.rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["auth.rateLimit.admin_rate_limit_buckets"], + }, + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + linkConnection: __reducerAccessors.linkConnection, + revokeMySession: __reducerAccessors.revokeMySession, + revokeSession: __reducerAccessors.revokeSession, + setAuthConfig: __reducerAccessors.setAuthConfig, + unlinkConnection: __reducerAccessors.unlinkConnection, + updateProfile: __reducerAccessors.updateProfile, + auth: { + linkConnection: __reducerAccessors["auth.linkConnection"], + revokeMySession: __reducerAccessors["auth.revokeMySession"], + revokeSession: __reducerAccessors["auth.revokeSession"], + setAuthConfig: __reducerAccessors["auth.setAuthConfig"], + unlinkConnection: __reducerAccessors["auth.unlinkConnection"], + updateProfile: __reducerAccessors["auth.updateProfile"], + rateLimit: { + addRateLimitAdmin: __reducerAccessors["auth.rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["auth.rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["auth.rateLimit.updateConfig"], + }, + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + aiTakeTurn: __procedureAccessors.aiTakeTurn, + attackUnit: __procedureAccessors.attackUnit, + createMatch: __procedureAccessors.createMatch, + endTurn: __procedureAccessors.endTurn, + getAuthPublicKey: __procedureAccessors.getAuthPublicKey, + getCellsInRange: __procedureAccessors.getCellsInRange, + joinMatch: __procedureAccessors.joinMatch, + listMySessions: __procedureAccessors.listMySessions, + moveUnit: __procedureAccessors.moveUnit, + whoami: __procedureAccessors.whoami, + auth: { + getAuthPublicKey: __procedureAccessors["auth.getAuthPublicKey"], + listMySessions: __procedureAccessors["auth.listMySessions"], + whoami: __procedureAccessors["auth.whoami"], + rateLimit: { + consume: __procedureAccessors["auth.rateLimit.consume"], + runSweep: __procedureAccessors["auth.rateLimit.runSweep"], + }, + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/join_match_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/join_match_procedure.ts new file mode 100644 index 00000000000..c1f25720383 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/join_match_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + matchId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/link_connection_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/link_connection_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionToken: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/lobby_open_matches_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/lobby_open_matches_table.ts new file mode 100644 index 00000000000..96bdadeae1c --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/lobby_open_matches_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + matchId: __t.u64().name("match_id"), + hostUserId: __t.string().name("host_user_id"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/move_unit_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/move_unit_procedure.ts new file mode 100644 index 00000000000..75f2e273fb6 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/move_unit_procedure.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MoveUnitResult, +} from "./types"; + +export const params = { + entityId: __t.u64(), + toX: __t.i32(), + toY: __t.i32(), +}; +export const returnType = MoveUnitResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_auth_user_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_auth_user_table.ts new file mode 100644 index 00000000000..5966b094061 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + userId: __t.string().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_cell_states_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_cell_states_table.ts new file mode 100644 index 00000000000..9f1a3bfc694 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_cell_states_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + gridId: __t.u64().name("grid_id"), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_grid_entities_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_grid_entities_table.ts new file mode 100644 index 00000000000..1b75cfb7ea0 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_grid_entities_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + gridId: __t.u64().name("grid_id"), + ownerUserId: __t.string().name("owner_user_id"), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool().name("blocks_movement"), + label: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_grids_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_grids_table.ts new file mode 100644 index 00000000000..ba03c161ea6 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_grids_table.ts @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + ownerUserId: __t.string().name("owner_user_id"), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32().name("default_cost"), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_match_participants_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_match_participants_table.ts new file mode 100644 index 00000000000..2a912a235b9 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_match_participants_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + matchId: __t.u64().name("match_id"), + userId: __t.string().name("user_id"), + seatIdx: __t.i32().name("seat_idx"), + team: __t.i32(), + joinedAt: __t.timestamp().name("joined_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_matches_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_matches_table.ts new file mode 100644 index 00000000000..ca03504bff9 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_matches_table.ts @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + MatchStatus, +} from "./types"; + + +export default __t.row({ + matchId: __t.u64().primaryKey().name("match_id"), + get status() { + return MatchStatus; + }, + currentSeatIdx: __t.i32().name("current_seat_idx"), + turnNumber: __t.i32().name("turn_number"), + winnerUserId: __t.option(__t.string()).name("winner_user_id"), + gridId: __t.u64().name("grid_id"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_player_units_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_player_units_table.ts new file mode 100644 index 00000000000..fd18c44eb92 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_player_units_table.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + matchId: __t.u64().name("match_id"), + ownerUserId: __t.string().name("owner_user_id"), + typeId: __t.string().name("type_id"), + currentHp: __t.i32().name("current_hp"), + hasMoved: __t.bool().name("has_moved"), + hasAttacked: __t.bool().name("has_attacked"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/npc_actor_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/npc_actor_table.ts new file mode 100644 index 00000000000..c4dd4173c26 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/npc_actor_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + actorId: __t.string().primaryKey().name("actor_id"), + name: __t.string(), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/revoke_session_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/revoke_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/set_auth_config_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/set_auth_config_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/types.ts b/spacetime-grid-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..4727e25df0a --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,276 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const ActorDirectory = __t.object("ActorDirectory", {}); +export type ActorDirectory = __Infer; + +export const ActorDirectoryRow = __t.object("ActorDirectoryRow", { + actorId: __t.string(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + get kind() { + return ActorKind; + }, +}); +export type ActorDirectoryRow = __Infer; + +// The tagged union or sum type for the algebraic type `ActorKind`. +export const ActorKind = __t.enum("ActorKind", { + User: __t.unit(), + Npc: __t.unit(), +}); +export type ActorKind = __Infer; + +export const AiAttackInfo = __t.object("AiAttackInfo", { + targetId: __t.u64(), + damage: __t.i32(), + killed: __t.bool(), + targetX: __t.i32(), + targetY: __t.i32(), + targetOwner: __t.string(), + targetTypeId: __t.string(), + targetPreHp: __t.i32(), +}); +export type AiAttackInfo = __Infer; + +export const AiPathStep = __t.object("AiPathStep", { + x: __t.i32(), + y: __t.i32(), +}); +export type AiPathStep = __Infer; + +export const AiTakeTurnResult = __t.object("AiTakeTurnResult", { + get events() { + return __t.array(AiTurnEvent); + }, +}); +export type AiTakeTurnResult = __Infer; + +export const AiTurnEvent = __t.object("AiTurnEvent", { + entityId: __t.u64(), + get movePath() { + return __t.option(__t.array(AiPathStep)); + }, + get attack() { + return __t.option(AiAttackInfo); + }, +}); +export type AiTurnEvent = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const CellState = __t.object("CellState", { + id: __t.u64(), + gridId: __t.u64(), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); +export type CellState = __Infer; + +export const CellsInRangeResult = __t.object("CellsInRangeResult", { + get cells() { + return __t.array(ReachableCell); + }, +}); +export type CellsInRangeResult = __Infer; + +export const CreateMatchResult = __t.object("CreateMatchResult", { + matchId: __t.u64(), + gridId: __t.u64(), +}); +export type CreateMatchResult = __Infer; + +export const Grid = __t.object("Grid", { + id: __t.u64(), + ownerUserId: __t.string(), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32(), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Grid = __Infer; + +export const GridAuthUser = __t.object("GridAuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridAuthUser = __Infer; + +export const GridEntity = __t.object("GridEntity", { + id: __t.u64(), + gridId: __t.u64(), + ownerUserId: __t.string(), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool(), + label: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridEntity = __Infer; + +export const LobbyOpenMatch = __t.object("LobbyOpenMatch", { + matchId: __t.u64(), + hostUserId: __t.string(), + createdAt: __t.timestamp(), +}); +export type LobbyOpenMatch = __Infer; + +export const LobbyOpenMatches = __t.object("LobbyOpenMatches", {}); +export type LobbyOpenMatches = __Infer; + +export const Match = __t.object("Match", { + matchId: __t.u64(), + get status() { + return MatchStatus; + }, + currentSeatIdx: __t.i32(), + turnNumber: __t.i32(), + winnerUserId: __t.option(__t.string()), + gridId: __t.u64(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Match = __Infer; + +export const MatchParticipant = __t.object("MatchParticipant", { + id: __t.u64(), + matchId: __t.u64(), + userId: __t.string(), + seatIdx: __t.i32(), + team: __t.i32(), + joinedAt: __t.timestamp(), +}); +export type MatchParticipant = __Infer; + +// The tagged union or sum type for the algebraic type `MatchStatus`. +export const MatchStatus = __t.enum("MatchStatus", { + Waiting: __t.unit(), + Active: __t.unit(), + Ended: __t.unit(), +}); +export type MatchStatus = __Infer; + +export const MoveStep = __t.object("MoveStep", { + x: __t.i32(), + y: __t.i32(), +}); +export type MoveStep = __Infer; + +export const MoveUnitResult = __t.object("MoveUnitResult", { + get path() { + return __t.array(MoveStep); + }, +}); +export type MoveUnitResult = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MyCellStates = __t.object("MyCellStates", {}); +export type MyCellStates = __Infer; + +export const MyGridEntities = __t.object("MyGridEntities", {}); +export type MyGridEntities = __Infer; + +export const MyGrids = __t.object("MyGrids", {}); +export type MyGrids = __Infer; + +export const MyMatchParticipants = __t.object("MyMatchParticipants", {}); +export type MyMatchParticipants = __Infer; + +export const MyMatches = __t.object("MyMatches", {}); +export type MyMatches = __Infer; + +export const MyPlayerUnits = __t.object("MyPlayerUnits", {}); +export type MyPlayerUnits = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const NpcActor = __t.object("NpcActor", { + actorId: __t.string(), + name: __t.string(), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type NpcActor = __Infer; + +export const PlayerUnit = __t.object("PlayerUnit", { + entityId: __t.u64(), + matchId: __t.u64(), + ownerUserId: __t.string(), + typeId: __t.string(), + currentHp: __t.i32(), + hasMoved: __t.bool(), + hasAttacked: __t.bool(), + createdAt: __t.timestamp(), +}); +export type PlayerUnit = __Infer; + +export const ReachableCell = __t.object("ReachableCell", { + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), +}); +export type ReachableCell = __Infer; + +export const UnitType = __t.object("UnitType", { + typeId: __t.string(), + name: __t.string(), + movement: __t.i32(), + attackRange: __t.i32(), + attackDmg: __t.i32(), + hp: __t.i32(), + glyph: __t.string(), +}); +export type UnitType = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-grid-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..ec53ed8c107 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/types/procedures.ts @@ -0,0 +1,40 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as AiTakeTurnProcedure from "../ai_take_turn_procedure"; +import * as AttackUnitProcedure from "../attack_unit_procedure"; +import * as CreateMatchProcedure from "../create_match_procedure"; +import * as EndTurnProcedure from "../end_turn_procedure"; +import * as GetAuthPublicKeyProcedure from "../get_auth_public_key_procedure"; +import * as GetCellsInRangeProcedure from "../get_cells_in_range_procedure"; +import * as JoinMatchProcedure from "../join_match_procedure"; +import * as ListMySessionsProcedure from "../list_my_sessions_procedure"; +import * as MoveUnitProcedure from "../move_unit_procedure"; +import * as WhoamiProcedure from "../whoami_procedure"; + +export type AiTakeTurnArgs = __Infer; +export type AiTakeTurnResult = __Infer; +export type AttackUnitArgs = __Infer; +export type AttackUnitResult = __Infer; +export type CreateMatchArgs = __Infer; +export type CreateMatchResult = __Infer; +export type EndTurnArgs = __Infer; +export type EndTurnResult = __Infer; +export type GetAuthPublicKeyArgs = __Infer; +export type GetAuthPublicKeyResult = __Infer; +export type GetCellsInRangeArgs = __Infer; +export type GetCellsInRangeResult = __Infer; +export type JoinMatchArgs = __Infer; +export type JoinMatchResult = __Infer; +export type ListMySessionsArgs = __Infer; +export type ListMySessionsResult = __Infer; +export type MoveUnitArgs = __Infer; +export type MoveUnitResult = __Infer; +export type WhoamiArgs = __Infer; +export type WhoamiResult = __Infer; + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-grid-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..db11ee2a71f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import LinkConnectionReducer from "../link_connection_reducer"; +import RevokeMySessionReducer from "../revoke_my_session_reducer"; +import RevokeSessionReducer from "../revoke_session_reducer"; +import SetAuthConfigReducer from "../set_auth_config_reducer"; +import UnlinkConnectionReducer from "../unlink_connection_reducer"; +import UpdateProfileReducer from "../update_profile_reducer"; + +export type LinkConnectionParams = __Infer; +export type RevokeMySessionParams = __Infer; +export type RevokeSessionParams = __Infer; +export type SetAuthConfigParams = __Infer; +export type UnlinkConnectionParams = __Infer; +export type UpdateProfileParams = __Infer; + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/unit_type_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/unit_type_table.ts new file mode 100644 index 00000000000..7cdd49c6677 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/unit_type_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + typeId: __t.string().primaryKey().name("type_id"), + name: __t.string(), + movement: __t.i32(), + attackRange: __t.i32().name("attack_range"), + attackDmg: __t.i32().name("attack_dmg"), + hp: __t.i32(), + glyph: __t.string(), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/unlink_connection_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/update_profile_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/whoami_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/whoami_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-grid-ts/example/tsconfig.json b/spacetime-grid-ts/example/tsconfig.json new file mode 100644 index 00000000000..9b159ac1913 --- /dev/null +++ b/spacetime-grid-ts/example/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-grid-ts/package.json b/spacetime-grid-ts/package.json new file mode 100644 index 00000000000..f94423ce5ed --- /dev/null +++ b/spacetime-grid-ts/package.json @@ -0,0 +1,72 @@ +{ + "name": "@spacetimedb/grid", + "description": "Square and hex grid storage, pathfinding, range, and entity movement for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./rows": { + "types": "./src/rows.ts", + "default": "./src/rows.ts" + }, + "./procedures": { + "types": "./src/procedures.ts", + "default": "./src/procedures.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + }, + "./math": { + "types": "./src/math/index.ts", + "default": "./src/math/index.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-grid-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-grid-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "grid", + "pathfinding", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test-pathfind.ts" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^22.10.2", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-grid-ts/scripts/test-pathfind.ts b/spacetime-grid-ts/scripts/test-pathfind.ts new file mode 100644 index 00000000000..3c05c52f49c --- /dev/null +++ b/spacetime-grid-ts/scripts/test-pathfind.ts @@ -0,0 +1,228 @@ +// Minimal sanity tests for pathfind.ts. Run: pnpm exec tsx scripts/test-pathfind.ts + +import { + type Coord, + neighbors, + distance, + findPathAstar, + dijkstra, + coordKey, +} from '../src/math/index.ts'; + +let pass = 0; +let fail = 0; +function check(name: string, cond: boolean, extra?: string): void { + if (cond) { + pass++; + process.stdout.write(` ${name} OK\n`); + } else { + fail++; + process.stdout.write(` ${name} FAIL ${extra ?? ''}\n`); + } +} + +// Helper: build a sparse obstacle map from a 2D character grid. +// '.' = open (cost 1) +// '#' = wall (cost -1) +// digits = explicit cost +function buildGrid(rows: string[]) { + const h = rows.length; + const w = rows[0].length; + const obstacles = new Map(); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const ch = rows[y][x]; + if (ch === '#') obstacles.set(coordKey({ x, y }), -1); + else if (ch >= '2' && ch <= '9') + obstacles.set(coordKey({ x, y }), Number(ch)); + } + } + const cost = (c: Coord): number => obstacles.get(coordKey(c)) ?? 1; + const inBounds = (c: Coord) => c.x >= 0 && c.y >= 0 && c.x < w && c.y < h; + const nb = (c: Coord) => neighbors('square', c, 4).filter(inBounds); + return { w, h, cost, neighbors: nb }; +} + +// A* test cases +process.stdout.write('A* tests\n'); + +// 1. Trivial: start == goal +{ + const g = buildGrid(['..']); + const r = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 0, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + }); + check( + 'start equals goal -> found, 1 cell, 0 cost', + r.found && r.cells.length === 1 && r.cost === 0 + ); +} + +// 2. Straight line, no obstacles +{ + const g = buildGrid(['.....']); + const r = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 4, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + heuristic: (a, b) => distance('square', a, b, 4), + }); + check( + '5-cell straight line -> cost 4, 5 cells', + r.found && r.cost === 4 && r.cells.length === 5 + ); +} + +// 3. Around a wall +{ + const g = buildGrid(['.....', '...#.', '...#.', '.....']); + const r = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 4, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + heuristic: (a, b) => distance('square', a, b, 4), + }); + check('straight path around no obstacle -> cost 4', r.found && r.cost === 4); +} + +// 4. Goal walled off +{ + const g = buildGrid(['...#.', '...#.', '...#.', '...#.']); + const r = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 4, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + heuristic: (a, b) => distance('square', a, b, 4), + }); + check('goal unreachable -> found:false', !r.found); +} + +// 5. Forced detour +{ + const g = buildGrid(['..#..', '..#..', '..#..', '.....']); + const r = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 4, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + heuristic: (a, b) => distance('square', a, b, 4), + }); + // 0,0 -> 0,3 -> 4,3 -> 4,0 = 3 + 4 + 3 = 10 + check( + 'forced detour around vertical wall -> cost 10', + r.found && r.cost === 10, + r.found ? `(got ${r.cost})` : '' + ); +} + +// 6. Weighted terrain (swamp cost=3) +{ + const g = buildGrid(['.....', '.333.', '.333.', '.....']); + const straight = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 4, y: 3 }, + cost: g.cost, + neighbors: g.neighbors, + heuristic: (a, b) => distance('square', a, b, 4), + }); + // Cheapest is around the swamp via edges. + check('weighted terrain finds path', straight.found); + // 0,0 -> 4,0 -> 4,3 = cost 4 + 3 = 7 (all open). + check( + 'weighted terrain picks cheap route (cost 7)', + straight.found && straight.cost === 7, + straight.found ? `(got ${straight.cost})` : '' + ); +} + +// 7. Hex grid: 3 cells apart in axial coords +{ + const cost = (_c: Coord) => 1; + const nb = (c: Coord) => neighbors('hex', c); + const r = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 3, y: 0 }, + cost, + neighbors: nb, + heuristic: (a, b) => distance('hex', a, b), + }); + check( + 'hex 3 cells apart -> cost 3, 4 cells', + r.found && r.cost === 3 && r.cells.length === 4 + ); +} + +// Dijkstra test cases +process.stdout.write('\nDijkstra tests\n'); + +// 8. 5x5 open grid, range = 2 -> 13 cells (manhattan disk). +{ + const g = buildGrid(['.....', '.....', '.....', '.....', '.....']); + const r = dijkstra({ + start: { x: 2, y: 2 }, + cost: g.cost, + neighbors: g.neighbors, + maxCost: 2, + }); + check( + 'dijkstra radius 2 on open 5x5 -> 13 reachable', + r.size === 13, + `(got ${r.size})` + ); +} + +// 9. Range 0 returns only the start. +{ + const g = buildGrid(['.']); + const r = dijkstra({ + start: { x: 0, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + maxCost: 0, + }); + check('dijkstra range 0 -> 1 reachable (start only)', r.size === 1); +} + +// 10. Reachable record carries cost + parent. +{ + const g = buildGrid(['...', '...', '...']); + const r = dijkstra({ + start: { x: 0, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + maxCost: 4, + }); + const corner = r.get(coordKey({ x: 2, y: 2 })); + check('dijkstra: corner record exists', corner !== undefined); + check( + 'dijkstra: corner cost == 4', + corner !== undefined && corner.cost === 4, + `(got ${corner?.cost})` + ); +} + +// 11. Wall reduces reachable set. +{ + const g = buildGrid(['...', '###', '...']); + const r = dijkstra({ + start: { x: 0, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + maxCost: 100, + }); + // Only the top row is reachable. + check( + 'dijkstra: wall blocks lower half -> 3 reachable', + r.size === 3, + `(got ${r.size})` + ); +} + +process.stdout.write(`\n${pass} pass, ${fail} fail\n`); +if (fail > 0) process.exit(1); diff --git a/spacetime-grid-ts/src/index.ts b/spacetime-grid-ts/src/index.ts new file mode 100644 index 00000000000..a4638b1f4d1 --- /dev/null +++ b/spacetime-grid-ts/src/index.ts @@ -0,0 +1,38 @@ +export { + gridRow, + cellStateRow, + gridEntityRow, + entityPathRow, + pathCell, + pathResult, + reachableCell, + GRID_KIND_SQUARE, + GRID_KIND_HEX, + GRID_ORIENTATION_FLAT, + GRID_ORIENTATION_POINTY, + GRID_MODE_OWNER, + GRID_MODE_COLLABORATIVE, +} from './rows.ts'; + +export { + createGridParams, + createGrid, + deleteGridParams, + deleteGrid, + setCellCostParams, + setCellCost, + paintCellsParams, + paintCells, + placeEntityParams, + placeEntity, + moveEntityParams, + moveEntity, + computePathParams, + computePathReturn, + computePath, + cellsInRangeParams, + cellsInRangeReturn, + cellsInRange, +} from './procedures.ts'; + +export * from './math/index.ts'; diff --git a/spacetime-grid-ts/src/math/coords.ts b/spacetime-grid-ts/src/math/coords.ts new file mode 100644 index 00000000000..5abc161c753 --- /dev/null +++ b/spacetime-grid-ts/src/math/coords.ts @@ -0,0 +1,17 @@ +export type Coord = { x: number; y: number }; +export type GridKind = 'square' | 'hex'; +export type HexOrientation = 'flat' | 'pointy'; +export type Connectivity = 4 | 8; + +export function coordKey(c: Coord): string { + return `${c.x},${c.y}`; +} + +export function parseCoordKey(key: string): Coord { + const i = key.indexOf(','); + return { x: Number(key.slice(0, i)), y: Number(key.slice(i + 1)) }; +} + +export function coordsEqual(a: Coord, b: Coord): boolean { + return a.x === b.x && a.y === b.y; +} diff --git a/spacetime-grid-ts/src/math/distance.ts b/spacetime-grid-ts/src/math/distance.ts new file mode 100644 index 00000000000..c96137d27ec --- /dev/null +++ b/spacetime-grid-ts/src/math/distance.ts @@ -0,0 +1,29 @@ +import type { Coord, GridKind, Connectivity } from './coords.ts'; + +export function manhattan(a: Coord, b: Coord): number { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} + +export function chebyshev(a: Coord, b: Coord): number { + return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)); +} + +// Hex axial distance. (q,r) stored as (x,y). +export function hexDistance(a: Coord, b: Coord): number { + return ( + (Math.abs(a.x - b.x) + + Math.abs(a.x + a.y - b.x - b.y) + + Math.abs(a.y - b.y)) / + 2 + ); +} + +export function distance( + kind: GridKind, + a: Coord, + b: Coord, + connectivity: Connectivity = 4 +): number { + if (kind === 'hex') return hexDistance(a, b); + return connectivity === 8 ? chebyshev(a, b) : manhattan(a, b); +} diff --git a/spacetime-grid-ts/src/math/index.ts b/spacetime-grid-ts/src/math/index.ts new file mode 100644 index 00000000000..515b43b8471 --- /dev/null +++ b/spacetime-grid-ts/src/math/index.ts @@ -0,0 +1,22 @@ +export { + type Coord, + type GridKind, + type HexOrientation, + type Connectivity, + coordKey, + parseCoordKey, + coordsEqual, +} from './coords.ts'; + +export { neighbors } from './neighbors.ts'; + +export { manhattan, chebyshev, hexDistance, distance } from './distance.ts'; + +export { + type PathResult, + type PathfindOpts, + type DijkstraOpts, + type DijkstraNode, + findPathAstar, + dijkstra, +} from './pathfind.ts'; diff --git a/spacetime-grid-ts/src/math/neighbors.ts b/spacetime-grid-ts/src/math/neighbors.ts new file mode 100644 index 00000000000..f8fe20c2ec2 --- /dev/null +++ b/spacetime-grid-ts/src/math/neighbors.ts @@ -0,0 +1,42 @@ +import type { Coord, Connectivity, GridKind } from './coords.ts'; + +const SQUARE_4: ReadonlyArray = [ + [0, -1], + [1, 0], + [0, 1], + [-1, 0], +]; + +const SQUARE_8: ReadonlyArray = [ + [0, -1], + [1, -1], + [1, 0], + [1, 1], + [0, 1], + [-1, 1], + [-1, 0], + [-1, -1], +]; + +const HEX_AXIAL: ReadonlyArray = [ + [1, 0], + [1, -1], + [0, -1], + [-1, 0], + [-1, 1], + [0, 1], +]; + +export function neighbors( + kind: GridKind, + c: Coord, + connectivity: Connectivity = 4 +): Coord[] { + const offsets = + kind === 'hex' ? HEX_AXIAL : connectivity === 8 ? SQUARE_8 : SQUARE_4; + const out: Coord[] = []; + for (const [dx, dy] of offsets) { + out.push({ x: c.x + dx, y: c.y + dy }); + } + return out; +} diff --git a/spacetime-grid-ts/src/math/pathfind.ts b/spacetime-grid-ts/src/math/pathfind.ts new file mode 100644 index 00000000000..ca973e4929f --- /dev/null +++ b/spacetime-grid-ts/src/math/pathfind.ts @@ -0,0 +1,189 @@ +// Pathfinding over an abstract graph. Caller injects `cost` and `neighbors` +// so this module knows nothing about STDB or grid kind. cost <= 0 = blocked. + +import type { Coord } from './coords.ts'; +import { coordKey } from './coords.ts'; + +export type PathResult = + | { found: true; cells: Coord[]; cost: number; expanded: number } + | { found: false; expanded: number }; + +export interface PathfindOpts { + start: Coord; + goal: Coord; + cost: (c: Coord) => number; + neighbors: (c: Coord) => Coord[]; + heuristic?: (c: Coord, goal: Coord) => number; + maxExpansions?: number; +} + +const DEFAULT_MAX_EXPANSIONS = 100_000; + +// Binary min-heap. Each item carries its priority `key`, avoiding an +// external decrease-key. Duplicate pushes allow stale pops to be skipped. +interface HeapEntry { + key: number; + node: Coord; + gScore: number; +} + +class MinHeap { + private items: HeapEntry[] = []; + + get size(): number { + return this.items.length; + } + + push(e: HeapEntry): void { + this.items.push(e); + this.siftUp(this.items.length - 1); + } + + pop(): HeapEntry | undefined { + const n = this.items.length; + if (n === 0) return undefined; + const top = this.items[0]; + const last = this.items.pop()!; + if (n > 1) { + this.items[0] = last; + this.siftDown(0); + } + return top; + } + + private siftUp(i: number): void { + const a = this.items; + while (i > 0) { + const p = (i - 1) >> 1; + if (a[p].key <= a[i].key) break; + [a[p], a[i]] = [a[i], a[p]]; + i = p; + } + } + + private siftDown(i: number): void { + const a = this.items; + const n = a.length; + while (true) { + const l = i * 2 + 1; + const r = l + 1; + let s = i; + if (l < n && a[l].key < a[s].key) s = l; + if (r < n && a[r].key < a[s].key) s = r; + if (s === i) break; + [a[s], a[i]] = [a[i], a[s]]; + i = s; + } + } +} + +export function findPathAstar(opts: PathfindOpts): PathResult { + const { start, goal, cost, neighbors } = opts; + const heuristic = opts.heuristic ?? (() => 0); + const cap = opts.maxExpansions ?? DEFAULT_MAX_EXPANSIONS; + + const startKey = coordKey(start); + const goalKey = coordKey(goal); + + if (startKey === goalKey) { + return { found: true, cells: [start], cost: 0, expanded: 0 }; + } + + const gScore = new Map(); + const parent = new Map(); + const open = new MinHeap(); + + gScore.set(startKey, 0); + open.push({ key: heuristic(start, goal), node: start, gScore: 0 }); + + let expanded = 0; + while (open.size > 0) { + if (expanded >= cap) break; + const cur = open.pop()!; + const curKey = coordKey(cur.node); + + // Skip stale heap entries (a better path landed after this one was pushed). + if (cur.gScore > (gScore.get(curKey) ?? Infinity)) continue; + expanded++; + + if (curKey === goalKey) { + const path: Coord[] = []; + let n: Coord | undefined = cur.node; + while (n !== undefined) { + path.unshift(n); + n = parent.get(coordKey(n)); + } + return { found: true, cells: path, cost: cur.gScore, expanded }; + } + + for (const nb of neighbors(cur.node)) { + const stepCost = cost(nb); + if (stepCost <= 0) continue; + const tentativeG = cur.gScore + stepCost; + const nbKey = coordKey(nb); + if (tentativeG < (gScore.get(nbKey) ?? Infinity)) { + gScore.set(nbKey, tentativeG); + parent.set(nbKey, cur.node); + open.push({ + key: tentativeG + heuristic(nb, goal), + node: nb, + gScore: tentativeG, + }); + } + } + } + + return { found: false, expanded }; +} + +export interface DijkstraOpts { + start: Coord; + cost: (c: Coord) => number; + neighbors: (c: Coord) => Coord[]; + maxCost: number; + maxExpansions?: number; +} + +export interface DijkstraNode { + cell: Coord; + cost: number; + from: Coord | null; +} + +// Returns all cells reachable from `start` within `maxCost`, keyed by coordKey. +export function dijkstra(opts: DijkstraOpts): Map { + const { start, cost, neighbors, maxCost } = opts; + const cap = opts.maxExpansions ?? DEFAULT_MAX_EXPANSIONS; + + const result = new Map(); + const open = new MinHeap(); + + result.set(coordKey(start), { cell: start, cost: 0, from: null }); + open.push({ key: 0, node: start, gScore: 0 }); + + let expanded = 0; + while (open.size > 0) { + if (expanded >= cap) break; + const cur = open.pop()!; + const curKey = coordKey(cur.node); + const known = result.get(curKey); + if (!known || cur.gScore > known.cost) continue; + if (cur.gScore > maxCost) break; + expanded++; + + for (const nb of neighbors(cur.node)) { + const stepCost = cost(nb); + if (stepCost <= 0) continue; + const tentative = cur.gScore + stepCost; + if (tentative > maxCost) continue; + const nbKey = coordKey(nb); + const existing = result.get(nbKey); + if (!existing || tentative < existing.cost) { + result.set(nbKey, { cell: nb, cost: tentative, from: cur.node }); + open.push({ key: tentative, node: nb, gScore: tentative }); + } + } + } + + return result; +} diff --git a/spacetime-grid-ts/src/procedures.ts b/spacetime-grid-ts/src/procedures.ts new file mode 100644 index 00000000000..9a874ed0d84 --- /dev/null +++ b/spacetime-grid-ts/src/procedures.ts @@ -0,0 +1,535 @@ +// Owner is passed explicitly so the submodule is identity-scheme-agnostic. +import type { Timestamp } from 'spacetimedb'; +import { + t, + SenderError, + type Infer, + type InferTypeOfParams, +} from 'spacetimedb/server'; +import { + gridRow, + pathResult, + reachableCell, + GRID_KIND_SQUARE, + GRID_KIND_HEX, + GRID_ORIENTATION_FLAT, + GRID_ORIENTATION_POINTY, + GRID_MODE_OWNER, + GRID_MODE_COLLABORATIVE, +} from './rows.ts'; +import { + type Coord, + type GridKind, + type Connectivity, + coordKey, + neighbors, + distance, + findPathAstar, + dijkstra, +} from './math/index.ts'; +import type { + ProcedureModuleCtx, + TransactionModuleCtx, +} from './submodule/schema.ts'; + +type GridRow = Infer; + +const VALID_KINDS = new Set([GRID_KIND_SQUARE, GRID_KIND_HEX]); +const VALID_ORIENTATIONS = new Set([ + GRID_ORIENTATION_FLAT, + GRID_ORIENTATION_POINTY, +]); +const VALID_MODES = new Set([GRID_MODE_OWNER, GRID_MODE_COLLABORATIVE]); +const VALID_CONNECTIVITY = new Set([4, 8]); + +const GRID_MAX_DIM = 1024; +const PATH_DEFAULT_MAX_EXPANSIONS = 50_000; +const PATH_MAX_EXPANSIONS = 50_000; +const MAX_RESULT_CELLS = 5_000; +const MAX_PAINT_CELLS = 1_000; +const MAX_NAME_LENGTH = 128; +const MAX_KIND_LENGTH = 64; +const MAX_LABEL_LENGTH = 256; +const MAX_TERRAIN_LENGTH = 64; + +export const createGridParams = { + name: t.string(), + kind: t.string(), + orientation: t.string(), + width: t.i32(), + height: t.i32(), + defaultCost: t.i32(), + connectivity: t.i32(), + mode: t.string(), +}; + +export function createGrid( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +): bigint { + if ( + typeof args.name !== 'string' || + args.name.length === 0 || + args.name.length > MAX_NAME_LENGTH + ) { + throw new SenderError('grid.invalid_name'); + } + if (!VALID_KINDS.has(args.kind)) { + throw new SenderError(`grid.invalid_kind:${args.kind}`); + } + if (!VALID_ORIENTATIONS.has(args.orientation)) { + throw new SenderError(`grid.invalid_orientation:${args.orientation}`); + } + if (!VALID_MODES.has(args.mode)) { + throw new SenderError(`grid.invalid_mode:${args.mode}`); + } + if ( + args.width < 1 || + args.width > GRID_MAX_DIM || + args.height < 1 || + args.height > GRID_MAX_DIM + ) { + throw new SenderError( + `grid.invalid_dimensions:${args.width}x${args.height}` + ); + } + if (args.defaultCost < 1) { + throw new SenderError(`grid.invalid_default_cost:${args.defaultCost}`); + } + if ( + args.kind === GRID_KIND_SQUARE && + !VALID_CONNECTIVITY.has(args.connectivity) + ) { + throw new SenderError(`grid.invalid_connectivity:${args.connectivity}`); + } + return ctx.withTx(tx => { + const row = tx.db.grid.insert({ + id: 0n, + ownerUserId: owner, + name: args.name, + kind: args.kind, + orientation: args.orientation, + width: args.width, + height: args.height, + defaultCost: args.defaultCost, + connectivity: args.kind === GRID_KIND_HEX ? 6 : args.connectivity, + mode: args.mode, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + return row.id; + }); +} + +// delete_grid cascades cell_state, grid_entity, entity_path. +export const deleteGridParams = { + gridId: t.u64(), +}; + +export function deleteGrid( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +): void { + ctx.withTx(tx => { + const grid = requireGridForMutation(tx, args.gridId, owner); + for (const c of [...tx.db.cellState.gridId.filter(grid.id)]) + tx.db.cellState.delete(c); + for (const e of [...tx.db.gridEntity.gridId.filter(grid.id)]) + tx.db.gridEntity.delete(e); + for (const p of [...tx.db.entityPath.gridId.filter(grid.id)]) + tx.db.entityPath.delete(p); + tx.db.grid.delete(grid); + }); +} + +// cost<=0 blocks, cost==defaultCost removes the sparse row. +export const setCellCostParams = { + gridId: t.u64(), + x: t.i32(), + y: t.i32(), + cost: t.i32(), + terrain: t.option(t.string()), +}; + +export function setCellCost( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +): void { + if ((args.terrain?.length ?? 0) > MAX_TERRAIN_LENGTH) { + throw new SenderError('grid.invalid_terrain'); + } + ctx.withTx(tx => { + const grid = requireGridForMutation(tx, args.gridId, owner); + assertInBounds(grid, { x: args.x, y: args.y }); + upsertCellState(tx, grid, args.x, args.y, args.cost, args.terrain); + }); +} + +export const paintCellsParams = { + gridId: t.u64(), + cells: t.array( + t.object('PaintCell', { + x: t.i32(), + y: t.i32(), + cost: t.i32(), + terrain: t.option(t.string()), + }) + ), +}; + +export function paintCells( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +): void { + if (!Array.isArray(args.cells) || args.cells.length > MAX_PAINT_CELLS) { + throw new SenderError('grid.too_many_cells'); + } + ctx.withTx(tx => { + const grid = requireGridForMutation(tx, args.gridId, owner); + for (const c of args.cells) { + if ((c.terrain?.length ?? 0) > MAX_TERRAIN_LENGTH) { + throw new SenderError('grid.invalid_terrain'); + } + assertInBounds(grid, { x: c.x, y: c.y }); + upsertCellState(tx, grid, c.x, c.y, c.cost, c.terrain); + } + }); +} + +export const placeEntityParams = { + gridId: t.u64(), + x: t.i32(), + y: t.i32(), + kind: t.string(), + blocksMovement: t.bool(), + label: t.option(t.string()), +}; + +export function placeEntity( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +): bigint { + if ( + typeof args.kind !== 'string' || + args.kind.length === 0 || + args.kind.length > MAX_KIND_LENGTH + ) { + throw new SenderError('grid.invalid_entity_kind'); + } + if ((args.label?.length ?? 0) > MAX_LABEL_LENGTH) { + throw new SenderError('grid.invalid_entity_label'); + } + return ctx.withTx(tx => { + const grid = requireGridForMutation(tx, args.gridId, owner); + assertInBounds(grid, { x: args.x, y: args.y }); + const row = tx.db.gridEntity.insert({ + id: 0n, + gridId: grid.id, + ownerUserId: owner, + x: args.x, + y: args.y, + kind: args.kind, + blocksMovement: args.blocksMovement, + label: args.label, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + return row.id; + }); +} + +export const moveEntityParams = { + entityId: t.u64(), + toX: t.i32(), + toY: t.i32(), +}; + +export function moveEntity( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +): void { + ctx.withTx(tx => { + const ent = tx.db.gridEntity.id.find(args.entityId); + if (!ent) throw new SenderError(`grid.entity_not_found:${args.entityId}`); + if (ent.ownerUserId !== owner) + throw new SenderError(`grid.entity_not_owner:${args.entityId}`); + const grid = tx.db.grid.id.find(ent.gridId); + if (!grid) throw new SenderError(`grid.not_found:${ent.gridId}`); + assertInBounds(grid, { x: args.toX, y: args.toY }); + + const neighborSet = neighbors( + grid.kind as GridKind, + { x: ent.x, y: ent.y }, + grid.connectivity as Connectivity + ); + const reachable = neighborSet.some( + n => n.x === args.toX && n.y === args.toY + ); + if (!reachable) throw new SenderError(`grid.move_not_adjacent`); + + tx.db.gridEntity.id.update({ + ...ent, + x: args.toX, + y: args.toY, + updatedAt: ctx.timestamp, + }); + }); +} + +// A* over the current cost map; optionally writes entity_path. +export const computePathParams = { + gridId: t.u64(), + startX: t.i32(), + startY: t.i32(), + endX: t.i32(), + endY: t.i32(), + storeFor: t.option(t.u64()), + maxExpansions: t.option(t.i32()), +}; + +export const computePathReturn = pathResult; + +export function computePath( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +) { + return ctx.withTx(tx => { + const grid = requireGridForAccess(tx, args.gridId, owner); + assertInBounds(grid, { x: args.startX, y: args.startY }); + assertInBounds(grid, { x: args.endX, y: args.endY }); + + const costMap = buildCostMap(tx, grid); + const requestedCap = Number( + args.maxExpansions ?? PATH_DEFAULT_MAX_EXPANSIONS + ); + if ( + !Number.isInteger(requestedCap) || + requestedCap < 1 || + requestedCap > PATH_MAX_EXPANSIONS + ) { + throw new SenderError('grid.invalid_max_expansions'); + } + const cap = requestedCap; + + const neighborsFn = (c: Coord) => + filterInBounds( + grid, + neighbors(grid.kind as GridKind, c, grid.connectivity as Connectivity) + ); + + const costFn = (c: Coord) => { + const v = costMap.get(coordKey(c)); + return v === undefined ? grid.defaultCost : v; + }; + + const result = findPathAstar({ + start: { x: args.startX, y: args.startY }, + goal: { x: args.endX, y: args.endY }, + cost: costFn, + neighbors: neighborsFn, + heuristic: (a, b) => + distance( + grid.kind as GridKind, + a, + b, + grid.connectivity as Connectivity + ), + maxExpansions: cap, + }); + + if (result.found && args.storeFor !== undefined && args.storeFor !== null) { + const entity = tx.db.gridEntity.id.find(args.storeFor); + if (!entity) + throw new SenderError(`grid.entity_not_found:${args.storeFor}`); + if (entity.gridId !== grid.id) + throw new SenderError('grid.entity_grid_mismatch'); + if (entity.ownerUserId !== owner) + throw new SenderError(`grid.entity_not_owner:${args.storeFor}`); + if (result.cells.length > MAX_RESULT_CELLS) + throw new SenderError('grid.path_too_long'); + writeEntityPath( + tx, + ctx.timestamp, + args.storeFor, + grid.id, + result.cells, + result.cost + ); + } + + if (result.found) { + if (result.cells.length > MAX_RESULT_CELLS) + throw new SenderError('grid.path_too_long'); + return { + found: true, + cells: result.cells, + cost: result.cost, + expanded: result.expanded, + }; + } + return { found: false, cells: [], cost: 0, expanded: result.expanded }; + }); +} + +export const cellsInRangeParams = { + gridId: t.u64(), + originX: t.i32(), + originY: t.i32(), + maxCost: t.i32(), +}; + +export const cellsInRangeReturn = t.object('CellsInRangeResult', { + cells: t.array(reachableCell), +}); + +export function cellsInRange( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +) { + return ctx.withTx(tx => { + const grid = requireGridForAccess(tx, args.gridId, owner); + assertInBounds(grid, { x: args.originX, y: args.originY }); + + const costMap = buildCostMap(tx, grid); + const neighborsFn = (c: Coord) => + filterInBounds( + grid, + neighbors(grid.kind as GridKind, c, grid.connectivity as Connectivity) + ); + const costFn = (c: Coord) => { + const v = costMap.get(coordKey(c)); + return v === undefined ? grid.defaultCost : v; + }; + + const reached = dijkstra({ + start: { x: args.originX, y: args.originY }, + cost: costFn, + neighbors: neighborsFn, + maxCost: args.maxCost, + maxExpansions: PATH_MAX_EXPANSIONS, + }); + + if (reached.size > MAX_RESULT_CELLS) + throw new SenderError('grid.range_too_large'); + + const cells: Array<{ x: number; y: number; cost: number }> = []; + for (const node of reached.values()) { + cells.push({ x: node.cell.x, y: node.cell.y, cost: node.cost }); + } + return { cells }; + }); +} + +function requireGridForMutation( + tx: TransactionModuleCtx, + gridId: bigint, + owner: string +): GridRow { + const grid = tx.db.grid.id.find(gridId); + if (!grid) throw new SenderError(`grid.not_found:${gridId}`); + if (grid.mode === GRID_MODE_OWNER && grid.ownerUserId !== owner) { + throw new SenderError(`grid.not_owner:${gridId}`); + } + return grid; +} + +function requireGridForAccess( + tx: TransactionModuleCtx, + gridId: bigint, + owner: string +): GridRow { + const grid = tx.db.grid.id.find(gridId); + if (!grid) throw new SenderError(`grid.not_found:${gridId}`); + if (grid.mode === GRID_MODE_OWNER && grid.ownerUserId !== owner) { + throw new SenderError(`grid.not_owner:${gridId}`); + } + return grid; +} + +function assertInBounds(grid: GridRow, c: Coord): void { + if (c.x < 0 || c.y < 0 || c.x >= grid.width || c.y >= grid.height) { + throw new SenderError(`grid.out_of_bounds:${c.x},${c.y}`); + } +} + +function filterInBounds(grid: GridRow, list: Coord[]): Coord[] { + return list.filter( + c => c.x >= 0 && c.y >= 0 && c.x < grid.width && c.y < grid.height + ); +} + +function upsertCellState( + tx: TransactionModuleCtx, + grid: GridRow, + x: number, + y: number, + cost: number, + terrain: string | undefined +): void { + let existing = null; + for (const c of tx.db.cellState.gridId.filter(grid.id)) { + if (c.x === x && c.y === y) { + existing = c; + break; + } + } + if ( + cost === grid.defaultCost && + (terrain === undefined || terrain === null) + ) { + if (existing) tx.db.cellState.delete(existing); + return; + } + if (existing) { + tx.db.cellState.id.update({ ...existing, cost, terrain }); + return; + } + tx.db.cellState.insert({ id: 0n, gridId: grid.id, x, y, cost, terrain }); +} + +function buildCostMap( + tx: TransactionModuleCtx, + grid: GridRow +): Map { + const map = new Map(); + for (const c of tx.db.cellState.gridId.filter(grid.id)) { + map.set(coordKey({ x: c.x, y: c.y }), c.cost); + } + for (const e of tx.db.gridEntity.gridId.filter(grid.id)) { + if (!e.blocksMovement) continue; + const k = coordKey({ x: e.x, y: e.y }); + if (!map.has(k)) map.set(k, -1); + } + return map; +} + +function writeEntityPath( + tx: TransactionModuleCtx, + timestamp: Timestamp, + entityId: bigint, + gridId: bigint, + cells: Coord[], + cost: number +): void { + const existing = tx.db.entityPath.entityId.find(entityId); + const row = { + entityId, + gridId, + cells: cells.map(c => ({ x: c.x, y: c.y })), + cost, + computedAt: timestamp, + }; + if (existing) { + tx.db.entityPath.entityId.update(row); + } else { + tx.db.entityPath.insert(row); + } +} diff --git a/spacetime-grid-ts/src/rows.ts b/spacetime-grid-ts/src/rows.ts new file mode 100644 index 00000000000..a9c9425012e --- /dev/null +++ b/spacetime-grid-ts/src/rows.ts @@ -0,0 +1,74 @@ +// ownerUserId is opaque and may contain an identity, application user ID, or +// host-defined actor ID. Owner mode restricts access to that value. +import { t } from 'spacetimedb/server'; + +export const GRID_KIND_SQUARE = 'square'; +export const GRID_KIND_HEX = 'hex'; + +export const GRID_ORIENTATION_FLAT = 'flat'; +export const GRID_ORIENTATION_POINTY = 'pointy'; + +export const GRID_MODE_OWNER = 'owner'; +export const GRID_MODE_COLLABORATIVE = 'collaborative'; + +export const gridRow = { + id: t.u64().primaryKey().autoInc(), + ownerUserId: t.string().index(), + name: t.string(), + kind: t.string(), // 'square' | 'hex' + orientation: t.string(), // 'flat' | 'pointy' (ignored for square) + width: t.i32(), + height: t.i32(), + defaultCost: t.i32(), + connectivity: t.i32(), // square only: 4 | 8. Hex is always 6. + mode: t.string(), // 'owner' | 'collaborative' + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +// Sparse: only non-default cells have a row. cost<=0 means blocked. +export const cellStateRow = { + id: t.u64().primaryKey().autoInc(), + gridId: t.u64().index(), + x: t.i32(), + y: t.i32(), + cost: t.i32(), + terrain: t.option(t.string()), +}; + +export const gridEntityRow = { + id: t.u64().primaryKey().autoInc(), + gridId: t.u64().index(), + ownerUserId: t.string().index(), + x: t.i32(), + y: t.i32(), + kind: t.string(), // user-defined: 'player', 'npc', 'item', ... + blocksMovement: t.bool(), + label: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +// Not auto-invalidated; consumers re-call compute_path for fresh routes. +export const entityPathRow = { + entityId: t.u64().primaryKey(), + gridId: t.u64().index(), + cells: t.array(t.object('PathCell', { x: t.i32(), y: t.i32() })), + cost: t.i32(), + computedAt: t.timestamp(), +}; + +export const pathCell = t.object('PathCell', { x: t.i32(), y: t.i32() }); + +export const pathResult = t.object('PathResult', { + found: t.bool(), + cells: t.array(pathCell), + cost: t.i32(), + expanded: t.i32(), +}); + +export const reachableCell = t.object('ReachableCell', { + x: t.i32(), + y: t.i32(), + cost: t.i32(), +}); diff --git a/spacetime-grid-ts/src/submodule.ts b/spacetime-grid-ts/src/submodule.ts new file mode 100644 index 00000000000..881d273564f --- /dev/null +++ b/spacetime-grid-ts/src/submodule.ts @@ -0,0 +1,6 @@ +export { default } from './submodule/schema.ts'; +export { cellState, entityPath, grid, gridEntity } from './submodule/schema.ts'; +export { installGrid } from './submodule/install.ts'; +export * from './rows.ts'; +export * from './procedures.ts'; +export * from './math/index.ts'; diff --git a/spacetime-grid-ts/src/submodule/install.ts b/spacetime-grid-ts/src/submodule/install.ts new file mode 100644 index 00000000000..78101da7d80 --- /dev/null +++ b/spacetime-grid-ts/src/submodule/install.ts @@ -0,0 +1,6 @@ +import type { ReducerModuleCtx } from './schema.ts'; + +export function installGrid(_ctx: ReducerModuleCtx) { + // Grid owns only persistent map/entity tables. Host modules decide auth, + // ownership, and any seed data they want layered on top. +} diff --git a/spacetime-grid-ts/src/submodule/schema.ts b/spacetime-grid-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..27aad079e68 --- /dev/null +++ b/spacetime-grid-ts/src/submodule/schema.ts @@ -0,0 +1,46 @@ +import { + schema, + table, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { + cellStateRow, + entityPathRow, + gridEntityRow, + gridRow, +} from '../rows.ts'; + +export const grid = table({ name: 'grid', public: false }, gridRow); + +export const cellState = table( + { name: 'cell_state', public: false }, + cellStateRow +); + +export const gridEntity = table( + { name: 'grid_entity', public: false }, + gridEntityRow +); + +export const entityPath = table( + { name: 'entity_path', public: false }, + entityPathRow +); + +export const spacetimedb = schema({ + grid, + cellState, + gridEntity, + entityPath, +}); +export default spacetimedb; + +export type Schema = InferSchema; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx; +export type ViewModuleCtx = ViewCtx; diff --git a/spacetime-grid-ts/tsconfig.json b/spacetime-grid-ts/tsconfig.json new file mode 100644 index 00000000000..e6a8236bbab --- /dev/null +++ b/spacetime-grid-ts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"] +} diff --git a/spacetime-lobby-ts/.npmrc b/spacetime-lobby-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-lobby-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-lobby-ts/LICENSE.txt b/spacetime-lobby-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-lobby-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-lobby-ts/README.md b/spacetime-lobby-ts/README.md new file mode 100644 index 00000000000..49e5ef1c517 --- /dev/null +++ b/spacetime-lobby-ts/README.md @@ -0,0 +1,179 @@ +# @spacetimedb/lobby + +SpacetimeDB lobby and matchmaking submodule. + +This package provides queue tickets, deterministic same-pool matchmaking, +ranked two-player results with Elo ratings, rooms, seats, lifecycle state, +admin observability, and submodule helpers for host modules. Host applications +define parties, backfill, and product-specific match rules. + +## Install + +```bash +npm install @spacetimedb/lobby spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +## Usage + +### Integrate into an application + +For a host application, register the namespace and keep the lifecycle hook in the +host module: + +```ts +import { schema } from 'spacetimedb/server'; +import * as lobby from '@spacetimedb/lobby/submodule'; + +const spacetimedb = schema({ lobby }); + +export const init = spacetimedb.init(ctx => { + lobby.installLobby(ctx.as.lobby); +}); + +export default spacetimedb; +``` + +Host modules can call submodule helpers with an explicit subject after they have +validated auth or mapped the SpacetimeDB identity to an application user ID: + +```ts +lobby.joinQueue(ctx.as.lobby, { + pool: 'duel', + subject: userId, + matchSize: 2, + attributesJson: JSON.stringify({ region: 'iad' }), +}); +``` + +Public submodule reducers derive the subject from `ctx.sender.toHexString()`. +Host wrappers can map the caller to an application user ID. +See the +[Starclash host module](./example/spacetimedb/) +for profile mapping, matchmaking, match results, and caller-scoped views. + +After generating bindings, a client joins through the host operation and reads +match state through subscriptions: + +```ts +import { tables } from './module_bindings'; + +await conn.reducers.findDuel({}); + +conn + .subscriptionBuilder() + .subscribe([ + tables.myLobbyTickets, + tables.myLobbyRooms, + tables.myLobbyRoomSeats, + ]); +``` + +`findDuel` is the example's product-facing wrapper. A host can instead expose +its own reducer around `lobby.joinQueue(ctx.as.lobby, ...)`. + +### Publish Lobby as the database + +The root entrypoint includes the standalone lifecycle hook for databases +dedicated to Lobby: + +```ts +export { default, init } from '@spacetimedb/lobby'; +export { + join_queue, + join_ranked_queue, + cancel_ticket, + myLobbyTickets, + myLobbyRooms, + lobbyQueueSummary, + lobbyRankedLeaderboard, +} from '@spacetimedb/lobby'; +``` + +## API + +Reducers: + +- `join_queue({ pool, matchSize, attributesJson, ttlSeconds})` +- `join_ranked_queue({ pool, matchSize, attributesJson, ttlSeconds, ratingPool })` +- `cancel_ticket({ ticketId })` +- `join_room({ roomId })` +- `leave_room({ roomId })` +- `close_room({ roomId })` +- `expire_tickets({ limit })` (admin only, up to 1,000 rows) +- `set_rating({ pool, subject, rating })` (admin only) +- `update_config({ defaultTicketTtlSeconds, maxMatchSize })` (admin only) +- `add_admin_identity({ identity })` (admin only) +- `remove_admin_identity({ identity })` (admin only) + +Procedure: + +- `get_lobby_status()` returns a JSON string. + +Views: + +- `my_lobby_tickets` +- `my_lobby_ratings` +- `my_lobby_rooms` +- `my_lobby_room_seats` +- `lobby_queue_summary` +- `lobby_ranked_leaderboard` +- `lobby_admin_tickets` +- `lobby_admin_rooms` +- `lobby_admin_room_seats` +- `lobby_admin_match_results` + +Host helper API: + +- Queue lifecycle: `joinQueue`, `joinRankedQueue`, and `cancelTicket`. +- Room lifecycle: `joinRoom`, `leaveRoom`, and `closeRoom`. +- Ranking: `reportMatchResult`. + +Submodule administrator operations include `set_rating`, `expire_tickets`, and +`update_config`. + +Package entrypoints: + +- `@spacetimedb/lobby` can run as a standalone Lobby database. +- `@spacetimedb/lobby/submodule` supplies the submodule namespace and host + helpers. + +## Matching + +Matching is deterministic: + +- tickets match only within the same `pool` +- tickets match only with the same `matchSize` +- an indexed `(pool, status, createdAt)` scan selects the oldest eligible tickets +- matched tickets create one room and one reserved seat per ticket +- rooms become `Active` when every reserved seat joins + +`attributesJson` stores host-defined matching metadata. Host wrappers interpret +the metadata when applying product-specific rules. + +Ranked queues use a 1,000 starting rating and a widening rating band: 100 +points initially, 50 more for each 10 seconds waited, capped at 800. A host +host reports a two-player result through `reportMatchResult` after validating +its game-specific completion rules. Results are idempotent per room and update +both players with Elo K=32. The room must be active. Result reporting is a host +helper and is absent from the generic client-callable API. + +Any participant may close a room through `close_room`. Hosts that need stricter +completion rules should expose their own reducer and call `closeRoom` after +validating the game state. + +## Testing + +```bash +pnpm test +pnpm run typecheck +pnpm run build +``` + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-lobby-ts/example/.env.example b/spacetime-lobby-ts/example/.env.example new file mode 100644 index 00000000000..12d8c0dacda --- /dev/null +++ b/spacetime-lobby-ts/example/.env.example @@ -0,0 +1,4 @@ +HOST=127.0.0.1 +PORT=8797 +STDB_URI=ws://127.0.0.1:3000 +SPACETIMEDB_DB_NAME=spacetime-lobby-example diff --git a/spacetime-lobby-ts/example/.npmrc b/spacetime-lobby-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-lobby-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-lobby-ts/example/README.md b/spacetime-lobby-ts/example/README.md new file mode 100644 index 00000000000..defb0b3fd3d --- /dev/null +++ b/spacetime-lobby-ts/example/README.md @@ -0,0 +1,167 @@ +# Starclash lobby example + +Starclash is a ranked one-on-one spaceship duel built with +[`@spacetimedb/lobby`](../). The Lobby submodule owns queue tickets, +rooms, seats, and ratings; the host module owns ship selection, duel state, +maneuvers, combat resolution, and round logs. + +## What this demonstrates + +- Mounting the Lobby submodule in a host game module. +- Ranked queue matching, room joining, rematches, and rating updates. +- Falling back from a public queue to a server-controlled AI opponent. +- Keeping submodule matchmaking state separate from application game state. +- Caller-scoped ticket, room, seat, rating, duel, and maneuver views. +- Driving a realtime UI entirely from SpacetimeDB subscriptions. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity for publishing the example. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-lobby-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open in two independent browser profiles or one normal +and one private/incognito window. Choose ships and queue both pilots. Separate +profiles are important because ordinary tabs share the same persisted development +SpacetimeDB identity. + +`build:module:fresh` deletes and recreates only the local `spacetime-lobby-example` +database. Use `pnpm run build:module` when existing ratings and duel history must +be preserved. + +## Use in your project + +This workspace tests the submodule source in this repository. Consumer +applications install the published release: + +```bash +npm install @spacetimedb/lobby spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the +subject-mapping and result-reporting boundaries; ships, maneuvers, and duel +simulation are application-specific demonstration code. + +## Configuration + +| Variable | Default | Purpose | +| --------------------- | ------------------------- | ------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8797` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `SPACETIMEDB_DB_NAME` | `spacetime-lobby-example` | Published database name. | + +The Node process serves static files, `GET /api/health`, and browser-safe +`GET /api/config`. Matchmaking and combat calls travel directly to SpacetimeDB. + +## Match and duel lifecycle + +1. A player sets a display name and ship class. +2. `find_duel` joins the ranked public pool through the Lobby submodule. +3. Once two compatible tickets are matched, both subjects join the resulting + room and the host module creates duel state. +4. Each pilot chooses a maneuver. The module resolves the round only when the + required choices exist, then records combat changes and a round log. +5. A completed or abandoned duel reports its result to Lobby and closes the room. +6. The submodule updates ratings; players can queue again. + +The fallback action cancels the player's public ticket and creates a match in an +AI-specific pool with a server-controlled subject. + +## Visibility and authority + +The browser subscribes to public catalogs and leaderboard summaries plus +caller-scoped views including `my_lobby_tickets`, `my_lobby_rooms`, +`my_lobby_room_seats`, `my_lobby_ratings`, `my_duels`, and +`my_duel_maneuvers`. Scoped views derive their subject from `ctx.sender`. +The public display-name roster returns at most 1,000 pilots. + +Reducers repeat the membership, room, turn, and combat checks. A player cannot +choose for the opponent, resolve an unrelated room, or read another room merely +by changing a client query. + +This example uses anonymous SpacetimeDB identities. Display names are profile +metadata. Applications that need verified accounts can register Auth. + +## Security and deployment boundaries + +- Persisted browser tokens are development identity credentials. Do not log or + commit them. +- Matchmaking and combat inputs are untrusted even when generated by the bundled + UI; validate all state transitions in reducers. +- Rating changes should be reported once for each terminal room. Production game + logic should include durable idempotency and abuse controls. +- Anonymous identities suit this demo. Account recovery, moderation, purchases, + and durable competitive identity require application authentication. +- The included Express process is a local static server. Production needs TLS, + explicit binding, origin policy, asset hardening, and supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a release smoke test: + +1. Queue two independent identities and verify exactly one room with two seats. +2. Join both seats, play several rounds, and confirm both browsers receive the same + combat state and logs. +3. Finish a duel and verify room closure, winner state, and rating changes happen + once. +4. Exercise leave/abandon and queue-again behavior. +5. Exercise AI fallback and complete a solo duel. +6. Attempt to submit a maneuver or room action from an unrelated third identity + and confirm the module rejects it. + +## Troubleshooting + +- **Both windows appear as one pilot:** use independent browser profiles or an + incognito window to give each player a separate SpacetimeDB token. +- **A ticket never matches:** confirm both pilots selected the public duel pool and + inspect `lobby_queue_summary` for queued tickets. +- **The page connects to stale state:** verify `STDB_URI` targets the server + registered as `local` by the publish scripts. +- **State disappears after republishing:** `build:module:fresh` + deletes all local rows, including ratings. + +## Important files + +- `spacetimedb/src/index.ts` - Lobby registration, scoped views, matchmaking, combat, + ratings, and AI fallback. +- `spacetimedb/src/catalog.ts` - ship and maneuver definitions used to seed the + public catalogs. +- `src/app.ts` - browser identity, subscriptions, rendering, and controls. +- `server.ts` - static development server and browser-safe configuration. +- `public/index.html` - Starclash interface. +- `public/styles.css` - Starclash presentation. diff --git a/spacetime-lobby-ts/example/package.json b/spacetime-lobby-ts/example/package.json new file mode 100644 index 00000000000..05b2c54293d --- /dev/null +++ b/spacetime-lobby-ts/example/package.json @@ -0,0 +1,29 @@ +{ + "name": "spacetime-lobby-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", + "check": "tsc --noEmit", + "test": "tsx scripts/test-model.ts", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run spacetime:generate && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/submodule-shared": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-lobby-ts/example/public/index.html b/spacetime-lobby-ts/example/public/index.html new file mode 100644 index 00000000000..7a220d3272f --- /dev/null +++ b/spacetime-lobby-ts/example/public/index.html @@ -0,0 +1,132 @@ + + + + + + + + Starclash + + + +
+
+
+
+
+
+

Starclash

+

Pick your ship and find a match.

+
+
+ + +
+
+
+ Ranked rating + 1000 +
+ 0W 0L +
+ +
+ Top Pilots +
+
+
+ +
+
+ +
+
+
+
+

Searching for Rival

+ +
+
+
+ +
+
+
+ +
+
+ +
+ + + +
+
+
+
+ + +
+
+ Confirm forfeit +

Leave this duel?

+
+

+ Forfeiting ends the match, gives your opponent the win, and returns + you to the hangar. +

+
+ + +
+
+
+ +
Connecting to SpacetimeDB.
+ +
+ Built on + SpacetimeDB +
+
+ + + + diff --git a/spacetime-lobby-ts/example/public/styles.css b/spacetime-lobby-ts/example/public/styles.css new file mode 100644 index 00000000000..6e4e3329483 --- /dev/null +++ b/spacetime-lobby-ts/example/public/styles.css @@ -0,0 +1,1480 @@ +:root { + color-scheme: dark; + --bg: #090a0d; + --surface: #10151a; + --surface-2: #141b22; + --line: #2b3f47; + --line-strong: #3e6571; + --text: #f2f6f4; + --muted: #91a3a3; + --cyan: #18c5d9; + --green: #6bd17b; + --yellow: #f3c969; + --red: #ee6f6f; + --violet: #b998ff; + --button: #d7dedb; + --button-text: #090d10; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient( + circle at 50% -8%, + rgba(24, 197, 217, 0.08), + transparent 42% + ), + radial-gradient( + circle at 88% 108%, + rgba(185, 152, 255, 0.06), + transparent 40% + ), + var(--bg); + background-attachment: fixed; + color: var(--text); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + letter-spacing: 0; +} + +button, +input { + font: inherit; +} + +button { + height: 36px; + border: 1px solid var(--line-strong); + border-radius: 6px; + background: #14222a; + color: var(--text); + font-weight: 800; + cursor: pointer; +} + +button:hover:not(:disabled) { + border-color: var(--cyan); + background: #17313a; +} + +button.primary { + border-color: transparent; + background: var(--button); + color: var(--button-text); +} + +button.primary:hover:not(:disabled) { + background: #eef4ef; + color: var(--button-text); +} + +button.danger { + border-color: #7d3838; + background: #241010; + color: #ffb8b3; +} + +button.danger:hover:not(:disabled) { + border-color: var(--red); + background: #361313; +} + +button:disabled { + opacity: 0.42; + cursor: default; +} + +input { + width: 100%; + height: 38px; + border: 1px solid var(--line); + border-radius: 6px; + background: #090f12; + color: var(--text); + padding: 0 11px; + outline: none; +} + +input:focus { + border-color: var(--cyan); +} + +h1, +h2, +h3, +p { + margin: 0; +} +h1 { + font-size: 22px; + line-height: 1.1; +} +h2 { + font-size: 17px; + line-height: 1.2; +} +h3 { + font-size: 20px; + line-height: 1.1; +} +p, +small { + color: var(--muted); +} + +.app { + width: min(1280px, calc(100vw - 28px)); + margin: 0 auto; + padding: 28px 0 18px; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.shell { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 14px; + margin-top: 14px; + align-items: start; + flex: 1; +} + +.screen { + display: none; +} + +.screen.active { + display: grid; + gap: 14px; +} + +.setup-screen { + width: min(980px, 100%); + margin: 0 auto; + grid-template-columns: 1fr; + align-items: start; +} + +.waiting-screen { + width: min(620px, 100%); + margin: 0 auto; + align-content: center; +} + +.duel-screen { + width: min(1280px, 100%); + margin: 0 auto; + grid-template-columns: minmax(0, 1fr); + align-items: start; +} + +.panel { + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + padding: 16px; +} + +.launch-card { + --ship: var(--cyan); + padding: 28px; + display: grid; + grid-template-columns: minmax(280px, 0.7fr) minmax(360px, 1fr); + gap: 24px; + align-items: start; +} + +.launch-hero { + display: grid; + gap: 8px; +} + +.launch-hero h1 { + font-size: 42px; + line-height: 0.96; +} + +.launch-hero p { + max-width: 520px; + font-size: 16px; +} + +.launch-controls { + display: grid; + gap: 16px; +} + +.ship-carousel { + border: 1px solid var(--line); + border-radius: 8px; + background: #0a1014; + padding: 16px; + display: grid; + gap: 14px; +} + +.ship-roster { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.ship-chip { + height: auto; + min-height: 66px; + padding: 9px 4px 7px; + border: 1px solid var(--line); + border-radius: 8px; + background: #0a1014; + display: grid; + gap: 7px; + justify-items: center; + align-content: center; + color: var(--muted); + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.ship-chip:hover:not(.active) { + border-color: var(--line-strong); + background: #0c141a; +} + +.ship-chip.active { + border-color: var(--chip); + color: var(--text); + background: color-mix(in srgb, var(--chip) 12%, #0a1014); + box-shadow: 0 0 16px color-mix(in srgb, var(--chip) 22%, transparent); +} + +.chip-ship { + width: 22px; + height: 22px; + background: var(--chip); + filter: drop-shadow(0 0 6px var(--chip)); + clip-path: polygon(50% 0, 100% 100%, 50% 78%, 0 100%); +} + +.ship-chip:not(.active) .chip-ship { + opacity: 0.68; +} +.ship-chip.bulwark .chip-ship { + clip-path: polygon(50% 0, 94% 42%, 78% 100%, 50% 82%, 22% 100%, 6% 42%); +} +.ship-chip.interceptor .chip-ship { + width: 16px; + clip-path: polygon(50% 0, 96% 100%, 50% 78%, 4% 100%); +} +.ship-chip.phantom .chip-ship { + clip-path: polygon(50% 0, 92% 58%, 70% 100%, 50% 72%, 30% 100%, 8% 58%); +} +.ship-chip.artillery .chip-ship { + clip-path: polygon(50% 0, 76% 30%, 100% 100%, 50% 82%, 0 100%, 24% 30%); +} + +.ship-preview { + position: relative; + min-height: 232px; + border: 1px solid var(--line-strong); + border-radius: 10px; + overflow: hidden; + background: + radial-gradient( + circle at 50% 44%, + color-mix(in srgb, var(--ship) 22%, transparent), + transparent 56% + ), + #070d11; +} + +.ship-preview::after { + content: ''; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(120, 170, 190, 0.1) 1px, transparent 1px), + linear-gradient(90deg, rgba(120, 170, 190, 0.1) 1px, transparent 1px); + background-size: 30px 30px; + -webkit-mask: radial-gradient(circle at 50% 50%, #000 38%, transparent 74%); + mask: radial-gradient(circle at 50% 50%, #000 38%, transparent 74%); +} + +.ship-preview::before { + content: ''; + position: absolute; + left: 50%; + top: 60px; + z-index: 1; + width: 54px; + height: 112px; + clip-path: polygon(50% 0, 100% 100%, 50% 78%, 0 100%); + background: var(--cyan); + filter: drop-shadow(0 0 18px var(--ship)); + animation: ship-float 3.6s ease-in-out infinite; +} + +@keyframes ship-float { + 0%, + 100% { + transform: translateX(-50%) translateY(0); + } + 50% { + transform: translateX(-50%) translateY(-9px); + } +} + +.ship-preview.bulwark::before { + width: 72px; + background: var(--green); + clip-path: polygon(50% 0, 94% 42%, 78% 100%, 50% 82%, 22% 100%, 6% 42%); +} + +.ship-preview.interceptor::before { + width: 38px; + background: var(--yellow); + clip-path: polygon(50% 0, 96% 100%, 50% 78%, 4% 100%); +} + +.ship-preview.phantom::before { + width: 66px; + background: var(--violet); + clip-path: polygon(50% 0, 92% 58%, 70% 100%, 50% 72%, 30% 100%, 8% 58%); +} + +.ship-preview.artillery::before { + width: 78px; + background: var(--red); + clip-path: polygon(50% 0, 76% 30%, 100% 100%, 50% 82%, 0 100%, 24% 30%); +} + +.ship-details { + display: grid; + gap: 5px; +} + +.ship-details p { + max-width: 560px; +} + +.ship-stat-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.ship-stat { + border: 1px solid var(--line); + border-radius: 6px; + background: #090f12; + padding: 9px; + display: grid; + gap: 6px; +} + +.ship-stat-label { + display: flex; + justify-content: space-between; + gap: 8px; + color: var(--muted); + font: + 800 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +.ship-stat-track { + height: 8px; + border-radius: 999px; + background: #05090b; + overflow: hidden; + border: 1px solid #24383f; +} + +.ship-stat-track i { + display: block; + height: 100%; + background: var(--ship); + transition: + width 220ms ease, + background 220ms ease; +} + +.ranked-card { + border: 1px solid var(--line); + border-radius: 8px; + background: #0a1014; + padding: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.ranked-card strong { + display: block; + font-size: 24px; + line-height: 1; +} + +.ranked-card span, +.ranked-card small, +.leaderboard-row span { + color: var(--muted); + font: + 800 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + text-transform: uppercase; +} + +.leaderboard-section { + display: grid; + gap: 10px; + margin-top: 4px; + padding-top: 18px; + border-top: 1px solid var(--line); +} + +.leaderboard { + border: 1px solid var(--line); + border-radius: 8px; + background: #0a1014; + overflow: hidden; + max-height: 212px; + overflow-y: auto; +} + +.leaderboard-empty { + margin: 0; + padding: 12px; + color: var(--muted); + font-size: 13px; +} + +.leaderboard-row { + min-height: 40px; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 8px 11px; + border-bottom: 1px solid var(--line); +} + +.leaderboard-row:last-child { + border-bottom: 0; +} + +.leaderboard-row.me { + background: color-mix(in srgb, var(--cyan) 12%, transparent); +} + +.lb-rank { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border-radius: 999px; + border: 1px solid var(--line-strong); + background: #11181d; + font: + 800 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +.lb-rank.rank-1 { + background: var(--yellow); + border-color: var(--yellow); + color: #1b1407; +} +.lb-rank.rank-2 { + background: #c7d2d8; + border-color: #c7d2d8; + color: #0b0f12; +} +.lb-rank.rank-3 { + background: #cf9a63; + border-color: #cf9a63; + color: #1a0f06; +} + +.leaderboard-row strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + color: var(--text); +} + +.leaderboard-row em { + font-style: normal; + font: + 800 13px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + color: var(--text); +} + +.lb-you { + margin-left: 5px; + color: var(--muted); + font-weight: 600; +} + +.panel.compact { + padding: 14px; +} +.panel > p { + margin-top: 6px; +} + +.eyebrow, +label { + display: block; + color: var(--muted); + font: + 800 11px/1.2 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + text-transform: uppercase; +} + +.field { + display: grid; + gap: 8px; +} + +.inline { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; +} + +.queue-button { + width: 100%; + height: 50px; + font-size: 16px; + letter-spacing: 0.04em; + box-shadow: 0 0 26px color-mix(in srgb, var(--ship) 28%, transparent); + transition: box-shadow 220ms ease; +} + +.arena { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 16px; + background: #0f1518; +} + +/* Fixed-height slot shared by the round indicator and the win/lose result, + so the outcome swaps in without shifting the page. */ +.arena-head { + display: flex; + align-items: center; + justify-content: center; + min-height: 34px; +} + +#duelStatus { + color: var(--muted); + font: + 800 12px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +#duelStatus[hidden] { + display: none; +} + +#duelStatus.win, +#duelStatus.lose, +#duelStatus.abandoned { + font: + 900 26px/1 Inter, + ui-sans-serif, + system-ui, + sans-serif; + letter-spacing: 0.04em; +} + +#duelStatus.win { + color: var(--green); + text-shadow: 0 0 22px rgba(107, 209, 123, 0.4); +} +#duelStatus.lose { + color: var(--red); + text-shadow: 0 0 22px rgba(238, 111, 111, 0.35); +} +#duelStatus.abandoned { + color: var(--muted); +} +#duelStatus.show { + animation: outcome-in 0.4s ease; +} + +.combatants { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-content: stretch; +} + +.combatant { + position: relative; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-2); + padding: 14px; + display: flex; + flex-direction: column; + gap: 13px; +} + +.combatant.mine { + border-color: #4d8c5c; +} + +.ship-visual { + min-height: 156px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: #0b1114; + position: relative; + overflow: hidden; +} + +.ship-visual::before { + content: ''; + position: absolute; + left: 50%; + top: 32px; + width: 44px; + height: 86px; + transform: translateX(-50%); + clip-path: polygon(50% 0, 100% 100%, 50% 78%, 0 100%); + background: var(--cyan); +} + +.ship-visual.bulwark::before { + background: var(--green); + width: 58px; + clip-path: polygon(50% 0, 94% 42%, 78% 100%, 50% 82%, 22% 100%, 6% 42%); +} + +.ship-visual.interceptor::before { + background: var(--yellow); + width: 32px; + clip-path: polygon(50% 0, 96% 100%, 50% 78%, 4% 100%); +} + +.ship-visual.phantom::before { + background: var(--violet); + width: 54px; + clip-path: polygon(50% 0, 92% 58%, 70% 100%, 50% 72%, 30% 100%, 8% 58%); +} + +.ship-visual.artillery::before { + background: var(--red); + width: 62px; + clip-path: polygon(50% 0, 76% 30%, 100% 100%, 50% 82%, 0 100%, 24% 30%); +} + +.combatant p { + margin-top: 3px; +} + +.ship-abilities { + min-height: 68px; + display: grid; + gap: 7px; +} + +.ability-card.active { + border-color: var(--slot, var(--cyan)); + box-shadow: + inset 0 0 0 1px + color-mix(in srgb, var(--slot, var(--cyan)) 45%, transparent), + 0 0 18px color-mix(in srgb, var(--slot, var(--cyan)) 24%, transparent); +} + +.bar-block { + display: grid; + gap: 6px; + margin-top: auto; +} + +.bar-label { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 13px; +} + +.bar { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: #070b0d; + border: 1px solid #24383f; +} + +.bar i { + display: block; + height: 100%; + background: var(--green); + transition: + width 450ms cubic-bezier(0.4, 0, 0.2, 1), + background 200ms ease; +} + +.bar.shield i { + background: var(--cyan); +} + +.combatant.low .bar:not(.shield) i { + background: var(--red); +} + +/* --- combat animation layer --- */ +.ship-visual { + transition: + transform 170ms ease, + opacity 400ms ease, + filter 400ms ease; +} + +.combatant.attacking.mine .ship-visual { + transform: translateX(16px); +} +.combatant.attacking:not(.mine) .ship-visual { + transform: translateX(-16px); +} + +.pops { + position: absolute; + left: 14px; + right: 14px; + top: 14px; + height: 156px; + pointer-events: none; + overflow: visible; + z-index: 4; +} + +.pop { + position: absolute; + left: 50%; + top: 42%; + transform: translate(-50%, -50%); + font: + 900 26px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.8); + animation: pop-rise 900ms ease-out forwards; + will-change: transform, opacity; +} + +.pop.hit { + color: var(--red); +} +.pop.shield { + color: var(--cyan); + font-size: 22px; +} +.pop.evade { + color: var(--muted); + font-size: 19px; + letter-spacing: 0.06em; +} +.pop.crit { + color: var(--yellow); + font-size: 36px; + text-shadow: + 0 0 18px rgba(243, 201, 105, 0.6), + 0 2px 12px rgba(0, 0, 0, 0.8); +} + +@keyframes pop-rise { + 0% { + opacity: 0; + transform: translate(-50%, -30%) scale(0.8); + } + 16% { + opacity: 1; + transform: translate(calc(-50% + var(--pop-x, 0px)), -62%) scale(1.06); + } + 100% { + opacity: 0; + transform: translate(calc(-50% + var(--pop-x, 0px)), -150%) scale(0.95); + } +} + +.combatant.shake { + animation: ship-shake 0.42s ease; +} +.combatant.shake-hard { + animation: ship-shake-hard 0.46s ease; +} + +@keyframes ship-shake { + 10%, + 90% { + transform: translateX(-2px); + } + 30%, + 70% { + transform: translateX(4px); + } + 50% { + transform: translateX(-4px); + } +} + +@keyframes ship-shake-hard { + 10%, + 90% { + transform: translate(-4px, 1px); + } + 30%, + 70% { + transform: translate(7px, -1px); + } + 50% { + transform: translate(-8px, 1px); + } +} + +.combatant.flash-hit .ship-visual { + animation: flash-hit 0.36s ease; +} +.combatant.flash-crit .ship-visual { + animation: flash-crit 0.42s ease; +} +.combatant.flash-shield .ship-visual { + animation: flash-shield 0.36s ease; +} + +@keyframes flash-hit { + 35% { + box-shadow: + inset 0 0 0 2px var(--red), + 0 0 26px rgba(238, 111, 111, 0.45); + } +} +@keyframes flash-crit { + 35% { + box-shadow: + inset 0 0 0 2px var(--yellow), + 0 0 34px rgba(243, 201, 105, 0.6); + } +} +@keyframes flash-shield { + 35% { + box-shadow: + inset 0 0 0 2px var(--cyan), + 0 0 26px rgba(24, 197, 217, 0.5); + } +} + +.combatant.dead .ship-visual { + opacity: 0.3; + filter: grayscale(0.85); +} + +.combatant.winner { + border-color: var(--yellow); +} +.combatant.winner .ship-visual { + animation: winner-pulse 1.5s ease-in-out infinite; +} +.combatant.loser { + opacity: 0.55; +} +.combatant.loser .ship-visual { + animation: loser-out 0.6s ease forwards; +} + +@keyframes winner-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(243, 201, 105, 0); + } + 50% { + box-shadow: 0 0 26px 2px rgba(243, 201, 105, 0.35); + } +} + +@keyframes loser-out { + to { + opacity: 0.25; + transform: scale(0.92) rotate(-2deg); + filter: grayscale(1); + } +} + +@keyframes outcome-in { + from { + opacity: 0; + transform: translateY(-6px) scale(0.96); + } + to { + opacity: 1; + transform: none; + } +} + +.actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 180px)); + justify-content: center; + gap: 8px; +} + +.actions button { + width: 100%; + min-height: 44px; +} + +.maneuver-actions { + display: grid; + gap: 8px; +} + +.maneuver-actions[hidden] { + display: none; +} + +.maneuver-head { + display: flex; + align-items: center; + justify-content: center; + min-height: 14px; +} + +.maneuver-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.maneuver-button { + height: auto; + min-height: 96px; + padding: 11px 12px; + display: grid; + align-content: start; + gap: 8px; + text-align: left; + border: 1px solid var(--line); + border-radius: 8px; + background: + radial-gradient( + 130% 90% at 50% -20%, + color-mix(in srgb, var(--slot, var(--cyan)) 14%, transparent), + transparent 62% + ), + #0a1014; + transition: + transform 120ms ease, + border-color 160ms ease, + box-shadow 180ms ease; +} + +.maneuver-button:hover:not(:disabled) { + transform: translateY(-2px); + border-color: color-mix( + in srgb, + var(--slot, var(--cyan)) 55%, + var(--line-strong) + ); + background: + radial-gradient( + 130% 90% at 50% -20%, + color-mix(in srgb, var(--slot, var(--cyan)) 20%, transparent), + transparent 62% + ), + #0c1620; +} + +.maneuver-button.active { + border-color: var(--slot, var(--cyan)); + opacity: 1; + box-shadow: + inset 0 0 0 1px + color-mix(in srgb, var(--slot, var(--cyan)) 45%, transparent), + 0 0 22px color-mix(in srgb, var(--slot, var(--cyan)) 26%, transparent); +} + +.maneuver-button.dimmed { + opacity: 0.42; +} + +.maneuver-top { + display: flex; + align-items: center; + gap: 7px; +} + +.maneuver-icon { + width: 18px; + height: 18px; + display: inline-flex; + color: var(--slot, var(--cyan)); +} + +.maneuver-icon svg { + width: 18px; + height: 18px; +} + +.maneuver-slot { + color: var(--slot, var(--cyan)); + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.maneuver-lock { + margin-left: auto; + color: var(--slot, var(--cyan)); + font-weight: 900; +} + +.maneuver-button strong { + font-size: 14px; + color: var(--text); +} + +.maneuver-fx { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.fx { + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + font-style: normal; + padding: 3px 6px; + border-radius: 999px; + border: 1px solid var(--line); + color: var(--muted); + white-space: nowrap; +} + +.fx-buff { + color: color-mix(in srgb, var(--slot, var(--cyan)) 78%, var(--text)); + border-color: color-mix(in srgb, var(--slot, var(--cyan)) 38%, transparent); + background: color-mix(in srgb, var(--slot, var(--cyan)) 10%, transparent); +} + +.fx-cost { + color: #ffb3ae; + border-color: rgba(238, 111, 111, 0.38); + background: rgba(238, 111, 111, 0.08); +} + +.maneuver-head .eyebrow.locked { + color: var(--green); +} + +.ship-abilities { + display: grid; + gap: 8px; +} + +.ability-cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.ability-card { + padding: 10px; + display: grid; + align-content: start; + gap: 7px; + border: 1px solid var(--line); + border-radius: 8px; + background: + radial-gradient( + 130% 90% at 50% -20%, + color-mix(in srgb, var(--slot, var(--cyan)) 12%, transparent), + transparent 62% + ), + #0a1014; +} + +.ability-card strong { + font-size: 13px; + color: var(--text); +} + +.ability-cards.compact .ability-card { + cursor: help; +} + +.tooltip { + position: fixed; + z-index: 50; + max-width: 250px; + pointer-events: none; + display: grid; + gap: 7px; + padding: 10px 11px; + border: 1px solid + color-mix(in srgb, var(--slot, var(--line-strong)) 55%, var(--line-strong)); + border-radius: 8px; + background: #0c141a; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6); + transform: translateY(4px); + transition: transform 120ms ease; +} + +.tooltip[hidden] { + display: none; +} +.tooltip.show { + transform: none; +} + +.tooltip .tip-head { + display: flex; + align-items: baseline; + gap: 8px; +} + +.tooltip .tip-slot { + color: var(--slot, var(--cyan)); + font: + 800 9px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.tooltip .tip-name { + font-size: 13px; + font-weight: 800; + color: var(--text); +} + +.tooltip .tip-fx { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.tooltip .tip-desc { + color: var(--muted); + font-size: 12px; + line-height: 1.3; +} + +.confirm-dialog { + width: min(420px, calc(100vw - 32px)); + border: 1px solid var(--line-strong); + border-radius: 8px; + background: var(--surface); + color: var(--text); + padding: 0; + box-shadow: 0 24px 90px rgba(0, 0, 0, 0.62); +} + +.confirm-dialog::backdrop { + background: rgba(0, 0, 0, 0.68); +} + +.confirm-card { + display: grid; + gap: 14px; + padding: 18px; +} + +.confirm-card p { + line-height: 1.45; +} + +.confirm-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.waiting-card { + min-height: clamp(390px, calc(100vh - 220px), 560px); + display: grid; + place-items: center; + text-align: center; +} + +.waiting-card > div { + display: grid; + gap: 14px; + justify-items: center; +} + +.waiting-card h1 { + margin: 0; +} + +.waiting-card h1::after { + content: ''; + display: inline-block; + width: 1.2em; + text-align: left; + animation: loading-dots 1.2s steps(4, end) infinite; +} + +.waiting-card p { + max-width: 460px; + margin: 0; +} + +#cancelSearch { + margin-top: 10px; + height: 38px; + padding: 0 26px; + background: transparent; + border-color: var(--line); + color: var(--muted); +} + +#cancelSearch:hover:not(:disabled) { + color: var(--text); + border-color: var(--cyan); + background: #14222a; +} + +.scanner { + width: 104px; + height: 104px; + border: 1px solid #2f6470; + border-radius: 999px; + position: relative; + animation: scanner-pulse 1.6s ease-in-out infinite; +} + +.scanner::before { + content: ''; + position: absolute; + left: 50%; + top: 24px; + width: 18px; + height: 56px; + transform: translateX(-50%); + background: var(--cyan); + clip-path: polygon(50% 0, 100% 100%, 50% 78%, 0 100%); +} + +.scanner::after { + content: ''; + position: absolute; + inset: 11px; + border: 1px solid rgba(24, 197, 217, 0.26); + border-radius: 999px; + animation: scanner-spin 2.2s linear infinite; + clip-path: polygon(50% 0, 100% 0, 100% 38%, 50% 50%); +} + +@keyframes scanner-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(24, 197, 217, 0.18); + } + 50% { + box-shadow: 0 0 0 10px rgba(24, 197, 217, 0); + } +} + +@keyframes scanner-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes loading-dots { + 0% { + content: ''; + } + 25% { + content: '.'; + } + 50% { + content: '..'; + } + 75%, + 100% { + content: '...'; + } +} + +.empty { + color: var(--muted); + border: 1px dashed var(--line-strong); + border-radius: 8px; + padding: 14px; + text-align: center; +} + +.empty.wide { + grid-column: 1 / -1; + min-height: 250px; + display: grid; + place-items: center; +} + +.toast { + display: none; + position: fixed; + top: 24px; + left: 50%; + z-index: 20; + transform: translateX(-50%); + min-height: 42px; + width: min(420px, calc(100vw - 32px)); + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + background: #0a1014; + color: #d8e5e2; + padding: 0 14px; + font: + 800 12px/1.2 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +.toast.error { + display: flex; + border-color: #823837; + background: #241010; + color: #ffb8b3; +} + +.page-foot { + display: flex; + align-items: center; + justify-content: center; + gap: 11px; + width: 100%; + margin-left: auto; + margin-right: auto; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid rgba(120, 170, 190, 0.1); +} + +.page-foot .by { + color: #7e9cad; + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.page-foot img { + height: 28px; + width: auto; + opacity: 0.9; +} + +@media (max-width: 1180px) { + .setup-screen, + .duel-screen { + grid-template-columns: 1fr; + } +} + +@media (max-width: 860px) { + .app { + width: min(100vw - 20px, 720px); + padding-top: 10px; + } + + .topbar { + align-items: flex-start; + flex-direction: column; + } + + .launch-card { + grid-template-columns: 1fr; + } + + .ship-preview { + min-height: 196px; + } + + .combatants { + grid-template-columns: 1fr; + } + + .maneuver-grid, + .ability-cards { + grid-template-columns: 1fr; + } +} + +@media (max-width: 540px) { + .inline { + grid-template-columns: 1fr; + } +} diff --git a/spacetime-lobby-ts/example/scripts/test-model.ts b/spacetime-lobby-ts/example/scripts/test-model.ts new file mode 100644 index 00000000000..baac9dd550c --- /dev/null +++ b/spacetime-lobby-ts/example/scripts/test-model.ts @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import { + selectActiveRoom, + selectHighlightedManeuver, + selectLatestDuel, + selectLatestTicket, + selectLobbyScreen, + type Combatant, + type Duel, + type DuelManeuver, + type LobbyRoom, + type LobbyTicket, +} from '../src/model'; + +const timestamp = (value: bigint) => ({ microsSinceUnixEpoch: value }); +const ticket = ( + ticketId: string, + createdAt: bigint, + status: string, + roomId?: bigint +): LobbyTicket => ({ + ticketId, + pool: 'spaceship_duel', + status: { tag: status }, + roomId, + createdAt: timestamp(createdAt), +}); +const duel = ( + roomId: bigint, + updatedAt: bigint, + status: string, + round = 0 +): Duel => ({ + roomId, + status: { tag: status }, + round, + updatedAt: timestamp(updatedAt), +}); +const room = (roomId: bigint): LobbyRoom => ({ + roomId, + pool: 'spaceship_duel', + status: { tag: 'Ready' }, + capacity: 2, + createdAt: timestamp(roomId), +}); + +const olderTicket = ticket('older', 1n, 'Cancelled'); +const currentTicket = ticket('current', 2n, 'Matched', 20n); +assert.equal(selectLatestTicket([currentTicket, olderTicket]), currentTicket); +assert.equal(selectLatestTicket([]), undefined); + +const unrelatedActiveDuel = duel(10n, 3n, 'Active'); +const ticketDuel = duel(20n, 1n, 'Complete'); +assert.equal( + selectLatestDuel([unrelatedActiveDuel, ticketDuel], currentTicket), + ticketDuel +); +assert.equal( + selectLatestDuel([ticketDuel, unrelatedActiveDuel], undefined), + unrelatedActiveDuel +); +assert.equal(selectLatestDuel([ticketDuel], undefined), undefined); + +const rooms = [room(10n), room(20n)]; +assert.equal( + selectActiveRoom(rooms, unrelatedActiveDuel, currentTicket), + rooms[0] +); +assert.equal(selectActiveRoom(rooms, ticketDuel, currentTicket), rooms[1]); +assert.equal(selectActiveRoom(rooms, undefined, undefined), undefined); + +assert.equal( + selectLobbyScreen({ + screenOverride: 'setup', + ticket: ticket('queued', 3n, 'Queued'), + duel: unrelatedActiveDuel, + room: rooms[0], + playedRoomId: '10', + }), + 'setupScreen' +); +assert.equal( + selectLobbyScreen({ + screenOverride: null, + ticket: ticket('queued', 3n, 'Queued'), + duel: undefined, + room: undefined, + playedRoomId: null, + }), + 'waitingScreen' +); +assert.equal( + selectLobbyScreen({ + screenOverride: null, + ticket: currentTicket, + duel: ticketDuel, + room: rooms[1], + playedRoomId: '20', + }), + 'duelScreen' +); +assert.equal( + selectLobbyScreen({ + screenOverride: null, + ticket: currentTicket, + duel: ticketDuel, + room: rooms[1], + playedRoomId: null, + }), + 'setupScreen' +); +assert.equal( + selectLobbyScreen({ + screenOverride: null, + ticket: undefined, + duel: undefined, + room: rooms[0], + playedRoomId: null, + }), + 'duelScreen' +); +assert.equal( + selectLobbyScreen({ + screenOverride: null, + ticket: undefined, + duel: undefined, + room: undefined, + playedRoomId: null, + }), + 'setupScreen' +); + +const combatant: Combatant = { + roomId: 10n, + subject: 'pilot', + displayName: 'Pilot', + shipClass: { tag: 'Interceptor' }, + hull: 100, + maxHull: 100, + shields: 50, + maxShields: 50, + attack: 20, + defense: 10, + speed: 8, + critBps: 500, + dodgeBps: 1000, +}; +const choice = ( + round: number, + slot: 'Primary' | 'Defensive' +): DuelManeuver => ({ + choiceId: `${round}:${slot}`, + roomId: 10n, + round, + subject: 'pilot', + slot: { tag: slot }, + maneuverId: slot.toLowerCase(), +}); +const choices = [choice(2, 'Defensive'), choice(1, 'Primary')]; +const unrelatedChoice = { + ...choice(99, 'Primary'), + choiceId: 'unrelated', + roomId: 99n, +}; +assert.equal( + selectHighlightedManeuver(duel(10n, 5n, 'Active', 1), combatant, [ + unrelatedChoice, + ...choices, + ]), + choices[0] +); +assert.equal( + selectHighlightedManeuver(duel(10n, 5n, 'Complete', 2), combatant, choices), + choices[0] +); +assert.equal( + selectHighlightedManeuver(duel(10n, 5n, 'Configuring'), combatant, choices), + undefined +); + +console.log('lobby model tests passed'); diff --git a/spacetime-lobby-ts/example/server.ts b/spacetime-lobby-ts/example/server.ts new file mode 100644 index 00000000000..c78b99727ca --- /dev/null +++ b/spacetime-lobby-ts/example/server.ts @@ -0,0 +1,49 @@ +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import * as dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + const parsed = dotenv.parse(readFileSync(pathname)) as Record; + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) process.env[key] = value; + } +} + +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8797', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-lobby-example'; + +const app = express(); +app.use(express.json({ limit: '128kb' })); +app.use('/assets', express.static(exampleUiAssetsDir)); +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, databaseName: DB_NAME }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ spacetimeUri: STDB_URI, databaseName: DB_NAME }); +}); + +app.listen(PORT, HOST, () => { + process.stdout.write( + `\nspacetime-lobby-example listening on http://${HOST}:${PORT}\n` + ); + process.stdout.write(` database: ${STDB_URI}/${DB_NAME}\n\n`); +}); diff --git a/spacetime-lobby-ts/example/spacetimedb/.npmrc b/spacetime-lobby-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-lobby-ts/example/spacetimedb/package.json b/spacetime-lobby-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..e6a98fd7766 --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-lobby-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-lobby-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-lobby-example" + }, + "dependencies": { + "@spacetimedb/lobby": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-lobby-ts/example/spacetimedb/src/catalog.ts b/spacetime-lobby-ts/example/spacetimedb/src/catalog.ts new file mode 100644 index 00000000000..59a2630cc38 --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/src/catalog.ts @@ -0,0 +1,210 @@ +import { ManeuverSlot, ShipClass } from './schema'; + +export const SHIP_CATALOG = [ + { + shipId: 'Bulwark', + shipClass: ShipClass.Bulwark, + role: 'Heavy defender', + description: + 'High hull and shields. Wins by absorbing punishment and grinding the opponent down.', + hull: 160, + shields: 80, + attack: 18, + defense: 7, + speed: 3, + critBps: 500, + dodgeBps: 400, + }, + { + shipId: 'Interceptor', + shipClass: ShipClass.Interceptor, + role: 'Fast attack', + description: + 'High damage and speed. Fragile, but dangerous if it can end the duel quickly.', + hull: 95, + shields: 45, + attack: 30, + defense: 2, + speed: 8, + critBps: 1400, + dodgeBps: 900, + }, + { + shipId: 'Phantom', + shipClass: ShipClass.Phantom, + role: 'Evasion striker', + description: + 'Dodge and crit focused. Swingy, slippery, and built around high-risk turns.', + hull: 110, + shields: 50, + attack: 22, + defense: 3, + speed: 7, + critBps: 1200, + dodgeBps: 2200, + }, + { + shipId: 'Artillery', + shipClass: ShipClass.Artillery, + role: 'Burst cannon', + description: + 'Slow, heavy burst damage. Weak shields, but every shot can change the duel.', + hull: 105, + shields: 35, + attack: 38, + defense: 1, + speed: 2, + critBps: 900, + dodgeBps: 300, + }, +]; + +export const MANEUVER_CATALOG = [ + { + shipClass: ShipClass.Bulwark, + slot: ManeuverSlot.Primary, + name: 'Cannon Volley', + description: 'Reliable pressure with no tradeoff.', + damageBps: 10000, + defenseBps: 0, + shieldRestore: 0, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 0, + }, + { + shipClass: ShipClass.Bulwark, + slot: ManeuverSlot.Defensive, + name: 'Fortify', + description: 'Restore shields and absorb the next hit.', + damageBps: 5500, + defenseBps: 4500, + shieldRestore: 18, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 0, + }, + { + shipClass: ShipClass.Bulwark, + slot: ManeuverSlot.Risky, + name: 'Ramming Burn', + description: 'Spend shields for a heavy strike.', + damageBps: 15000, + defenseBps: -1500, + shieldRestore: 0, + selfShieldCost: 12, + critBonusBps: 0, + dodgeBonusBps: -300, + }, + { + shipClass: ShipClass.Interceptor, + slot: ManeuverSlot.Primary, + name: 'Pulse Lasers', + description: 'Fast, clean damage.', + damageBps: 10500, + defenseBps: 0, + shieldRestore: 0, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 0, + }, + { + shipClass: ShipClass.Interceptor, + slot: ManeuverSlot.Defensive, + name: 'Evasive Roll', + description: 'Light damage while boosting dodge.', + damageBps: 6500, + defenseBps: 0, + shieldRestore: 6, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 2500, + }, + { + shipClass: ShipClass.Interceptor, + slot: ManeuverSlot.Risky, + name: 'Afterburn Strike', + description: 'High crit attack with shield burn.', + damageBps: 13000, + defenseBps: 0, + shieldRestore: 0, + selfShieldCost: 10, + critBonusBps: 1800, + dodgeBonusBps: -500, + }, + { + shipClass: ShipClass.Phantom, + slot: ManeuverSlot.Primary, + name: 'Phase Lance', + description: 'A balanced strike with evasive drift.', + damageBps: 9500, + defenseBps: 0, + shieldRestore: 0, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 600, + }, + { + shipClass: ShipClass.Phantom, + slot: ManeuverSlot.Defensive, + name: 'Cloak', + description: 'Low damage, huge dodge, and a small shield refresh.', + damageBps: 4500, + defenseBps: 0, + shieldRestore: 4, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 4200, + }, + { + shipClass: ShipClass.Phantom, + slot: ManeuverSlot.Risky, + name: 'Ambush', + description: 'Crit-heavy attack that lowers defenses.', + damageBps: 12500, + defenseBps: -1000, + shieldRestore: 0, + selfShieldCost: 0, + critBonusBps: 2600, + dodgeBonusBps: 0, + }, + { + shipClass: ShipClass.Artillery, + slot: ManeuverSlot.Primary, + name: 'Rail Shot', + description: 'Slow but punishing direct fire.', + damageBps: 11500, + defenseBps: 0, + shieldRestore: 0, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 0, + }, + { + shipClass: ShipClass.Artillery, + slot: ManeuverSlot.Defensive, + name: 'Brace', + description: 'Hold position, restore shields, reduce incoming damage.', + damageBps: 6500, + defenseBps: 3500, + shieldRestore: 10, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 0, + }, + { + shipClass: ShipClass.Artillery, + slot: ManeuverSlot.Risky, + name: 'Overload Cannon', + description: 'Massive shot that drains shields and hurts evasiveness.', + damageBps: 16500, + defenseBps: 0, + shieldRestore: 0, + selfShieldCost: 16, + critBonusBps: 900, + dodgeBonusBps: -400, + }, +].map(row => ({ + ...row, + maneuverId: `${row.shipClass.tag}:${row.slot.tag}`, +})); diff --git a/spacetime-lobby-ts/example/spacetimedb/src/index.ts b/spacetime-lobby-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..afdcce078cd --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,835 @@ +import { SenderError, t } from 'spacetimedb/server'; +import * as lobby from '@spacetimedb/lobby/submodule'; + +import { + DUEL_POOL, + AI_DUEL_POOL_PREFIX, + RATING_POOL, + MATCH_SIZE, + DISPLAY_NAME_MAX, + shipClass, + ShipClass, + maneuverSlot, + ManeuverSlot, + DuelStatus, + spacetimedb, + type WriteCtx, + type CombatantRow, + type ManeuverRow, + type DuelRow, + type ShipClassValue, + type ManeuverSlotValue, +} from './schema'; +import { MANEUVER_CATALOG, SHIP_CATALOG } from './catalog'; +export { default } from './schema'; +export * from './views'; + +function fail(message: string): never { + throw new SenderError(`duel.${message}`); +} + +function subjectFor(ctx: { sender: { toHexString(): string } }): string { + return ctx.sender.toHexString(); +} + +function displaySubject(subject: string): string { + return `Pilot ${subject.slice(0, 6).toUpperCase()}`; +} + +function aiSubjectFor(subject: string): string { + return `ai:${subject}`; +} + +function aiPoolFor(subject: string): string { + return `${AI_DUEL_POOL_PREFIX}:${subject.slice(0, 32)}`; +} + +function isDuelPool(pool: string): boolean { + return pool === DUEL_POOL || pool.startsWith(`${AI_DUEL_POOL_PREFIX}:`); +} + +function normalizeDisplayName(value: string): string { + const out = value.trim().replace(/\s+/g, ' '); + if (!out) fail('invalid_display_name'); + return out.slice(0, DISPLAY_NAME_MAX); +} + +function combatantId(roomId: bigint, subject: string): string { + return `${roomId.toString()}:${subject}`; +} + +function choiceId(roomId: bigint, round: number, subject: string): string { + return `${roomId.toString()}:${round}:${subject}`; +} + +function maneuverId(ship: ShipClassValue, slot: ManeuverSlotValue): string { + return `${ship.tag}:${slot.tag}`; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function shipStats(ctx: WriteCtx, cls: ShipClassValue) { + const row = ctx.db.shipCatalog.shipId.find(cls.tag); + if (!row) fail('ship_catalog_missing'); + return row; +} + +function maneuverFor( + ctx: WriteCtx, + ship: ShipClassValue, + slot: ManeuverSlotValue +) { + const row = ctx.db.maneuverCatalog.maneuverId.find(maneuverId(ship, slot)); + if (!row) fail('maneuver_missing'); + return row; +} + +function choiceFor( + ctx: WriteCtx, + roomId: bigint, + round: number, + subject: string +) { + return ctx.db.duelManeuver.choiceId.find(choiceId(roomId, round, subject)); +} + +function upsertManeuverChoice( + ctx: WriteCtx, + roomId: bigint, + round: number, + subject: string, + slot: ManeuverSlotValue, + ship: ShipClassValue +) { + const id = choiceId(roomId, round, subject); + const maneuver = maneuverFor(ctx, ship, slot); + const row = { + choiceId: id, + roomId, + round, + subject, + slot, + maneuverId: maneuver.maneuverId, + chosenAt: ctx.timestamp, + }; + if (ctx.db.duelManeuver.choiceId.find(id)) + ctx.db.duelManeuver.choiceId.update(row); + else ctx.db.duelManeuver.insert(row); + return row; +} + +function seedCatalog(ctx: WriteCtx): void { + for (const row of SHIP_CATALOG) { + if (ctx.db.shipCatalog.shipId.find(row.shipId)) + ctx.db.shipCatalog.shipId.update(row); + else ctx.db.shipCatalog.insert(row); + } + for (const row of MANEUVER_CATALOG) { + if (ctx.db.maneuverCatalog.maneuverId.find(row.maneuverId)) + ctx.db.maneuverCatalog.maneuverId.update(row); + else ctx.db.maneuverCatalog.insert(row); + } +} + +function ensurePilot(ctx: WriteCtx, subject: string) { + const existing = ctx.db.pilot.subject.find(subject); + if (existing) return existing; + const row = { + subject, + displayName: displaySubject(subject), + shipClass: ShipClass.Interceptor, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }; + ctx.db.pilot.insert(row); + return row; +} + +function ensureAiPilot(ctx: WriteCtx, subject: string) { + const existing = ensurePilot(ctx, subject); + const seed = hashSeed(subject); + const ship = [ + ShipClass.Bulwark, + ShipClass.Interceptor, + ShipClass.Phantom, + ShipClass.Artillery, + ][seed % 4]; + const displayName = 'Arena AI'; + const next = { + ...existing, + displayName, + shipClass: ship, + updatedAt: ctx.timestamp, + }; + ctx.db.pilot.subject.update(next); + return next; +} + +function seatsForRoom(ctx: WriteCtx, roomId: bigint) { + return [...ctx.db.lobby.lobbyRoomSeat.byRoom.filter(roomId)]; +} + +function hasSeat(ctx: WriteCtx, roomId: bigint, subject: string): boolean { + return [...ctx.db.lobby.lobbyRoomSeat.bySubject.filter(subject)].some( + seat => seat.roomId === roomId + ); +} + +function roomFor(ctx: WriteCtx, roomId: bigint) { + return ctx.db.lobby.lobbyRoom.roomId.find(roomId); +} + +function log( + ctx: WriteCtx, + roomId: bigint, + round: number, + message: string +): void { + ctx.db.duelRoundLog.insert({ + logId: 0n, + roomId, + round, + message, + createdAt: ctx.timestamp, + }); +} + +function ensureCombatant(ctx: WriteCtx, roomId: bigint, subject: string) { + const id = combatantId(roomId, subject); + const existing = ctx.db.duelCombatant.combatantId.find(id); + if (existing) return existing; + const p = ensurePilot(ctx, subject); + const stats = shipStats(ctx, p.shipClass); + const row = { + combatantId: id, + roomId, + subject, + displayName: p.displayName, + shipClass: p.shipClass, + hull: stats.hull, + maxHull: stats.hull, + shields: stats.shields, + maxShields: stats.shields, + attack: stats.attack, + defense: stats.defense, + speed: stats.speed, + critBps: stats.critBps, + dodgeBps: stats.dodgeBps, + updatedAt: ctx.timestamp, + }; + ctx.db.duelCombatant.insert(row); + return row; +} + +function ensureDuelForRoom(ctx: WriteCtx, roomId: bigint) { + const existing = ctx.db.duel.roomId.find(roomId); + if (existing) return existing; + const room = roomFor(ctx, roomId); + if (!room || !isDuelPool(room.pool)) fail('room_not_found'); + const seats = seatsForRoom(ctx, roomId); + if (seats.length < MATCH_SIZE) fail('room_not_ready'); + const row = { + roomId, + status: DuelStatus.Configuring, + round: 0, + winnerSubject: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }; + ctx.db.duel.insert(row); + for (const seat of seats.slice(0, MATCH_SIZE)) { + ensureCombatant(ctx, roomId, seat.subject); + } + log(ctx, roomId, 0, 'Match found. Pilots are docking into the arena.'); + return row; +} + +function refreshDuelStatus(ctx: WriteCtx, roomId: bigint) { + const current = ensureDuelForRoom(ctx, roomId); + if (current.status.tag !== DuelStatus.Configuring.tag) return current; + const seats = seatsForRoom(ctx, roomId); + const allJoined = + seats.length >= MATCH_SIZE && + seats.every(seat => seat.status.tag === lobby.SeatStatus.Joined.tag); + if (!allJoined) return current; + const updated = { + ...current, + status: DuelStatus.Active, + updatedAt: ctx.timestamp, + }; + ctx.db.duel.roomId.update(updated); + log(ctx, roomId, 0, 'Both pilots joined. Duel is live.'); + return updated; +} + +function hashSeed(input: string): number { + let h = 2166136261; + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function roll(seed: string): number { + let x = hashSeed(seed) || 1; + x ^= x << 13; + x ^= x >>> 17; + x ^= x << 5; + return (x >>> 0) % 10_000; +} + +function requireMapped( + values: Map, + key: string, + reason: string +): T { + const value = values.get(key); + if (value === undefined) fail(reason); + return value; +} + +function applyDamage(target: CombatantRow, amount: number): CombatantRow { + let remaining = Math.max(0, Math.floor(amount)); + const shieldDamage = Math.min(target.shields, remaining); + remaining -= shieldDamage; + return { + ...target, + shields: target.shields - shieldDamage, + hull: Math.max(0, target.hull - remaining), + }; +} + +function applyManeuverSetup( + ctx: WriteCtx, + roomId: bigint, + round: number, + combatant: CombatantRow, + maneuver: ManeuverRow +): CombatantRow { + let next = combatant; + if (maneuver.selfShieldCost > 0) { + const cost = Math.min(next.shields, maneuver.selfShieldCost); + next = { ...next, shields: next.shields - cost }; + if (cost > 0) + log( + ctx, + roomId, + round, + `${next.displayName} burns ${cost} shields to power ${maneuver.name}.` + ); + } + if (maneuver.shieldRestore > 0 && next.shields < next.maxShields) { + const restored = Math.min( + maneuver.shieldRestore, + next.maxShields - next.shields + ); + next = { ...next, shields: next.shields + restored }; + if (restored > 0) + log( + ctx, + roomId, + round, + `${next.displayName} restores ${restored} shields with ${maneuver.name}.` + ); + } + return next; +} + +function attackOnce( + ctx: WriteCtx, + roomId: bigint, + round: number, + attacker: CombatantRow, + defender: CombatantRow, + attackerMove: ManeuverRow, + defenderMove: ManeuverRow +): CombatantRow { + if (attacker.hull <= 0 || defender.hull <= 0) return defender; + const prefix = `${roomId.toString()}:${round}:${attacker.subject}:${defender.subject}`; + const dodgeBps = clamp( + defender.dodgeBps + defenderMove.dodgeBonusBps, + 0, + 9000 + ); + if (roll(`${prefix}:dodge`) < dodgeBps) { + log( + ctx, + roomId, + round, + `${defender.displayName}'s ${defender.shipClass.tag} evades ${attackerMove.name}.` + ); + return defender; + } + const critBps = clamp(attacker.critBps + attackerMove.critBonusBps, 0, 9000); + const crit = roll(`${prefix}:crit`) < critBps; + const baseDamage = Math.max(1, attacker.attack - defender.defense); + const attackDamage = Math.max( + 1, + Math.floor((baseDamage * Math.max(0, attackerMove.damageBps)) / 10_000) + ); + const defenseBps = clamp(defenderMove.defenseBps, -5000, 8500); + const mitigated = Math.max( + 1, + Math.floor((attackDamage * (10_000 - defenseBps)) / 10_000) + ); + const damage = crit ? Math.floor(mitigated * 1.75) : mitigated; + const updated = applyDamage(defender, damage); + log( + ctx, + roomId, + round, + `${attacker.displayName} uses ${attackerMove.name} on ${defender.displayName} for ${damage}${crit ? ' critical' : ''} damage.` + ); + return updated; +} + +function aiSlotFor( + roomId: bigint, + round: number, + subject: string +): ManeuverSlotValue { + const slots = [ + ManeuverSlot.Primary, + ManeuverSlot.Defensive, + ManeuverSlot.Risky, + ]; + return slots[ + hashSeed(`${roomId.toString()}:${round}:${subject}:ai-move`) % slots.length + ]; +} + +function ensureAiChoices( + ctx: WriteCtx, + roomId: bigint, + round: number, + combatants: CombatantRow[] +): void { + for (const combatant of combatants) { + if (!combatant.subject.startsWith('ai:')) continue; + if (choiceFor(ctx, roomId, round, combatant.subject)) continue; + upsertManeuverChoice( + ctx, + roomId, + round, + combatant.subject, + aiSlotFor(roomId, round, combatant.subject), + combatant.shipClass + ); + } +} + +function completeDuel( + ctx: WriteCtx, + d: DuelRow, + round: number, + winnerSubject: string, + reporterSubject: string +): void { + ctx.db.duel.roomId.update({ + ...d, + status: DuelStatus.Complete, + round, + winnerSubject, + updatedAt: ctx.timestamp, + }); + const winner = ctx.db.duelCombatant.combatantId.find( + combatantId(d.roomId, winnerSubject) + ); + log( + ctx, + d.roomId, + round, + `${winner?.displayName ?? 'A pilot'} wins the duel.` + ); + lobby.reportMatchResult(ctx.as.lobby, { + roomId: d.roomId, + subject: reporterSubject, + winnerSubject, + }); + lobby.closeRoom(ctx.as.lobby, { roomId: d.roomId, subject: reporterSubject }); +} + +function maybeResolveRound( + ctx: WriteCtx, + roomId: bigint, + reporterSubject: string +): void { + const d = refreshDuelStatus(ctx, roomId); + if ( + d.status.tag === DuelStatus.Complete.tag || + d.status.tag === DuelStatus.Abandoned.tag + ) + return; + if (d.status.tag !== DuelStatus.Active.tag) return; + const combatants = sortedCombatants(ctx, roomId); + if (combatants.length < MATCH_SIZE) fail('combatants_missing'); + const round = d.round + 1; + ensureAiChoices(ctx, roomId, round, combatants); + const moves = new Map(); + for (const combatant of combatants) { + const choice = choiceFor(ctx, roomId, round, combatant.subject); + if (!choice) return; + const move = ctx.db.maneuverCatalog.maneuverId.find(choice.maneuverId); + if (!move) fail('maneuver_missing'); + moves.set(combatant.subject, move); + } + + log( + ctx, + roomId, + round, + `Round ${round}. ${combatants.map(c => `${c.displayName}: ${requireMapped(moves, c.subject, 'maneuver_missing').name}`).join(' | ')}` + ); + const next = new Map(); + for (const combatant of combatants) { + next.set( + combatant.subject, + applyManeuverSetup( + ctx, + roomId, + round, + combatant, + requireMapped(moves, combatant.subject, 'maneuver_missing') + ) + ); + } + const ordered = [...next.values()].sort((a, b) => { + if (a.speed !== b.speed) return b.speed - a.speed; + return a.subject.localeCompare(b.subject); + }); + + const firstInitial = ordered[0]; + const secondInitial = ordered[1]; + if (!firstInitial || !secondInitial) fail('combatants_missing'); + let first = firstInitial; + let second = secondInitial; + second = attackOnce( + ctx, + roomId, + round, + first, + second, + requireMapped(moves, first.subject, 'maneuver_missing'), + requireMapped(moves, second.subject, 'maneuver_missing') + ); + next.set(second.subject, second); + if (second.hull > 0) { + first = requireMapped(next, first.subject, 'combatants_missing'); + second = requireMapped(next, second.subject, 'combatants_missing'); + first = attackOnce( + ctx, + roomId, + round, + second, + first, + requireMapped(moves, second.subject, 'maneuver_missing'), + requireMapped(moves, first.subject, 'maneuver_missing') + ); + next.set(first.subject, first); + } + + const updatedCombatants = [...next.values()]; + for (const combatant of updatedCombatants) { + ctx.db.duelCombatant.combatantId.update({ + ...combatant, + updatedAt: ctx.timestamp, + }); + } + const alive = updatedCombatants.filter(c => c.hull > 0); + if (alive.length === 1) { + completeDuel(ctx, d, round, alive[0].subject, reporterSubject); + } else if (alive.length === 0) { + const winner = + updatedCombatants[0].hull >= updatedCombatants[1].hull + ? updatedCombatants[0] + : updatedCombatants[1]; + ctx.db.duel.roomId.update({ + ...d, + status: DuelStatus.Complete, + round, + winnerSubject: winner.subject, + updatedAt: ctx.timestamp, + }); + log( + ctx, + roomId, + round, + `${winner.displayName} wins by emergency adjudication.` + ); + lobby.reportMatchResult(ctx.as.lobby, { + roomId, + subject: reporterSubject, + winnerSubject: winner.subject, + }); + lobby.closeRoom(ctx.as.lobby, { roomId, subject: reporterSubject }); + } else { + ctx.db.duel.roomId.update({ ...d, round, updatedAt: ctx.timestamp }); + } +} + +function sortedCombatants(ctx: WriteCtx, roomId: bigint) { + return [...ctx.db.duelCombatant.byRoom.filter(roomId)].sort((a, b) => { + if (a.speed !== b.speed) return b.speed - a.speed; + return a.subject.localeCompare(b.subject); + }); +} + +export const set_display_name = spacetimedb.reducer( + { displayName: t.string() }, + (ctx, args) => { + const subject = subjectFor(ctx); + const existing = ensurePilot(ctx, subject); + const displayName = normalizeDisplayName(args.displayName); + ctx.db.pilot.subject.update({ + ...existing, + displayName, + updatedAt: ctx.timestamp, + }); + for (const combatant of [ + ...ctx.db.duelCombatant.bySubject.filter(subject), + ]) { + const d = ctx.db.duel.roomId.find(combatant.roomId); + if (d && d.status.tag === DuelStatus.Configuring.tag) { + ctx.db.duelCombatant.combatantId.update({ + ...combatant, + displayName, + updatedAt: ctx.timestamp, + }); + } + } + } +); + +export const select_ship = spacetimedb.reducer({ shipClass }, (ctx, args) => { + const subject = subjectFor(ctx); + const existing = ensurePilot(ctx, subject); + ctx.db.pilot.subject.update({ + ...existing, + shipClass: args.shipClass, + updatedAt: ctx.timestamp, + }); + for (const combatant of [...ctx.db.duelCombatant.bySubject.filter(subject)]) { + const d = ctx.db.duel.roomId.find(combatant.roomId); + if (!d || d.status.tag !== DuelStatus.Configuring.tag) continue; + const stats = shipStats(ctx, args.shipClass); + ctx.db.duelCombatant.combatantId.update({ + ...combatant, + shipClass: args.shipClass, + hull: stats.hull, + maxHull: stats.hull, + shields: stats.shields, + maxShields: stats.shields, + attack: stats.attack, + defense: stats.defense, + speed: stats.speed, + critBps: stats.critBps, + dodgeBps: stats.dodgeBps, + updatedAt: ctx.timestamp, + }); + } +}); + +export const find_duel = spacetimedb.reducer({}, ctx => { + const subject = subjectFor(ctx); + const p = ensurePilot(ctx, subject); + const result = lobby.joinRankedQueue(ctx.as.lobby, { + pool: DUEL_POOL, + subject, + matchSize: MATCH_SIZE, + ratingPool: RATING_POOL, + attributesJson: JSON.stringify({ shipClass: p.shipClass.tag }), + ttlSeconds: 120, + }); + if (result.roomId !== undefined) ensureDuelForRoom(ctx, result.roomId); +}); + +export const fallback_to_ai = spacetimedb.reducer({}, ctx => { + const subject = subjectFor(ctx); + const p = ensurePilot(ctx, subject); + const publicTickets = [ + ...ctx.db.lobby.lobbyQueueTicket.bySubject.filter(subject), + ].filter( + ticket => + ticket.pool === DUEL_POOL && + ticket.status.tag === lobby.TicketStatus.Queued.tag + ); + for (const ticket of publicTickets) { + lobby.cancelTicket(ctx.as.lobby, { ticketId: ticket.ticketId, subject }); + } + + const aiSubject = aiSubjectFor(subject); + const aiPilot = ensureAiPilot(ctx, aiSubject); + const pool = aiPoolFor(subject); + lobby.joinRankedQueue(ctx.as.lobby, { + pool, + subject, + matchSize: MATCH_SIZE, + ratingPool: RATING_POOL, + attributesJson: JSON.stringify({ + shipClass: p.shipClass.tag, + fallback: 'human', + }), + ttlSeconds: 120, + }); + const result = lobby.joinRankedQueue(ctx.as.lobby, { + pool, + subject: aiSubject, + matchSize: MATCH_SIZE, + ratingPool: RATING_POOL, + attributesJson: JSON.stringify({ + shipClass: aiPilot.shipClass.tag, + fallback: 'ai', + }), + ttlSeconds: 120, + }); + if (result.roomId === undefined) fail('ai_match_failed'); + lobby.joinRoom(ctx.as.lobby, { roomId: result.roomId, subject }); + lobby.joinRoom(ctx.as.lobby, { roomId: result.roomId, subject: aiSubject }); + ensureDuelForRoom(ctx, result.roomId); + refreshDuelStatus(ctx, result.roomId); + log( + ctx, + result.roomId, + 0, + 'No rival found. Arena AI accepted the challenge.' + ); +}); + +export const join_duel_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, args) => { + const subject = subjectFor(ctx); + ensurePilot(ctx, subject); + lobby.joinRoom(ctx.as.lobby, { roomId: args.roomId, subject }); + refreshDuelStatus(ctx, args.roomId); + } +); + +export const choose_maneuver = spacetimedb.reducer( + { roomId: t.u64(), slot: maneuverSlot }, + (ctx, args) => { + const subject = subjectFor(ctx); + if (!hasSeat(ctx, args.roomId, subject)) fail('not_in_room'); + const d = refreshDuelStatus(ctx, args.roomId); + if (d.status.tag === DuelStatus.Complete.tag) return; + if (d.status.tag === DuelStatus.Abandoned.tag) fail('duel_abandoned'); + if (d.status.tag !== DuelStatus.Active.tag) fail('duel_not_ready'); + const round = d.round + 1; + const combatant = ctx.db.duelCombatant.combatantId.find( + combatantId(args.roomId, subject) + ); + if (!combatant) fail('combatant_missing'); + upsertManeuverChoice( + ctx, + args.roomId, + round, + subject, + args.slot, + combatant.shipClass + ); + maybeResolveRound(ctx, args.roomId, subject); + } +); + +export const advance_duel = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, args) => { + const subject = subjectFor(ctx); + const combatant = ctx.db.duelCombatant.combatantId.find( + combatantId(args.roomId, subject) + ); + if (!combatant) fail('combatant_missing'); + upsertManeuverChoice( + ctx, + args.roomId, + (ctx.db.duel.roomId.find(args.roomId)?.round ?? 0) + 1, + subject, + ManeuverSlot.Primary, + combatant.shipClass + ); + maybeResolveRound(ctx, args.roomId, subject); + } +); + +export const leave_duel = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, args) => { + const subject = subjectFor(ctx); + const d = ctx.db.duel.roomId.find(args.roomId); + if (!d || d.status.tag === DuelStatus.Complete.tag) return; + if (!hasSeat(ctx, args.roomId, subject)) fail('not_in_room'); + const opponent = seatsForRoom(ctx, args.roomId).find( + seat => seat.subject !== subject + ); + if (!opponent) { + lobby.leaveRoom(ctx.as.lobby, { roomId: args.roomId, subject }); + ctx.db.duel.roomId.update({ + ...d, + status: DuelStatus.Abandoned, + updatedAt: ctx.timestamp, + }); + log(ctx, args.roomId, d.round, 'A pilot left. Duel abandoned.'); + return; + } + const winner = ctx.db.duelCombatant.combatantId.find( + combatantId(args.roomId, opponent.subject) + ); + const loser = ctx.db.duelCombatant.combatantId.find( + combatantId(args.roomId, subject) + ); + ctx.db.duel.roomId.update({ + ...d, + status: DuelStatus.Complete, + winnerSubject: opponent.subject, + updatedAt: ctx.timestamp, + }); + log( + ctx, + args.roomId, + d.round, + `${loser?.displayName ?? 'A pilot'} forfeits. ${winner?.displayName ?? 'Opponent'} wins.` + ); + lobby.reportMatchResult(ctx.as.lobby, { + roomId: args.roomId, + subject, + winnerSubject: opponent.subject, + }); + lobby.closeRoom(ctx.as.lobby, { roomId: args.roomId, subject }); + } +); + +export const queue_again = spacetimedb.reducer( + { roomId: t.option(t.u64()) }, + (ctx, args) => { + const subject = subjectFor(ctx); + ensurePilot(ctx, subject); + if (args.roomId !== undefined && hasSeat(ctx, args.roomId, subject)) { + const d = ctx.db.duel.roomId.find(args.roomId); + if (d && d.status.tag !== DuelStatus.Complete.tag) { + ctx.db.duel.roomId.update({ + ...d, + status: DuelStatus.Abandoned, + updatedAt: ctx.timestamp, + }); + } + lobby.closeRoom(ctx.as.lobby, { roomId: args.roomId, subject }); + } + const p = ensurePilot(ctx, subject); + lobby.joinRankedQueue(ctx.as.lobby, { + pool: DUEL_POOL, + subject, + matchSize: MATCH_SIZE, + ratingPool: RATING_POOL, + attributesJson: JSON.stringify({ shipClass: p.shipClass.tag }), + ttlSeconds: 120, + }); + } +); + +export const init = spacetimedb.init(ctx => { + lobby.installLobby(ctx.as.lobby); + seedCatalog(ctx); +}); diff --git a/spacetime-lobby-ts/example/spacetimedb/src/schema.ts b/spacetime-lobby-ts/example/spacetimedb/src/schema.ts new file mode 100644 index 00000000000..dc8767ea217 --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/src/schema.ts @@ -0,0 +1,242 @@ +import { + schema, + table, + t, + type Infer, + type InferSchema, + type ReducerCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import * as lobby from '@spacetimedb/lobby/submodule'; + +export const DUEL_POOL = 'spaceship_duel'; +export const AI_DUEL_POOL_PREFIX = 'spaceship_duel_ai'; +export const RATING_POOL = DUEL_POOL; +export const MATCH_SIZE = 2; +export const DISPLAY_NAME_MAX = 32; + +export const shipClass = t.enum('ShipClass', [ + 'Bulwark', + 'Interceptor', + 'Phantom', + 'Artillery', +]); +export const ShipClass = { + Bulwark: { tag: 'Bulwark' as const }, + Interceptor: { tag: 'Interceptor' as const }, + Phantom: { tag: 'Phantom' as const }, + Artillery: { tag: 'Artillery' as const }, +}; + +export const maneuverSlot = t.enum('ManeuverSlot', [ + 'Primary', + 'Defensive', + 'Risky', +]); +export const ManeuverSlot = { + Primary: { tag: 'Primary' as const }, + Defensive: { tag: 'Defensive' as const }, + Risky: { tag: 'Risky' as const }, +}; + +export const duelStatus = t.enum('DuelStatus', [ + 'Configuring', + 'Active', + 'Complete', + 'Abandoned', +]); +export const DuelStatus = { + Configuring: { tag: 'Configuring' as const }, + Active: { tag: 'Active' as const }, + Complete: { tag: 'Complete' as const }, + Abandoned: { tag: 'Abandoned' as const }, +}; + +export const pilot = table( + { + name: 'pilot', + public: false, + indexes: [ + { accessor: 'byUpdatedAt', algorithm: 'btree', columns: ['updatedAt'] }, + ], + }, + { + subject: t.string().primaryKey(), + displayName: t.string(), + shipClass, + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +export const shipCatalog = table( + { + name: 'ship_catalog', + public: true, + indexes: [ + { accessor: 'byShipClass', algorithm: 'btree', columns: ['shipClass'] }, + ], + }, + { + shipId: t.string().primaryKey(), + shipClass, + role: t.string(), + description: t.string(), + hull: t.u32(), + shields: t.u32(), + attack: t.u32(), + defense: t.u32(), + speed: t.u32(), + critBps: t.u32(), + dodgeBps: t.u32(), + } +); + +export const maneuverCatalog = table( + { + name: 'maneuver_catalog', + public: true, + indexes: [ + { accessor: 'byShipClass', algorithm: 'btree', columns: ['shipClass'] }, + { accessor: 'bySlot', algorithm: 'btree', columns: ['slot'] }, + ], + }, + { + maneuverId: t.string().primaryKey(), + shipClass, + slot: maneuverSlot, + name: t.string(), + description: t.string(), + damageBps: t.i32(), + defenseBps: t.i32(), + shieldRestore: t.u32(), + selfShieldCost: t.u32(), + critBonusBps: t.i32(), + dodgeBonusBps: t.i32(), + } +); + +export const duel = table( + { + name: 'duel', + public: false, + indexes: [ + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + { accessor: 'byUpdatedAt', algorithm: 'btree', columns: ['updatedAt'] }, + ], + }, + { + roomId: t.u64().primaryKey(), + status: duelStatus, + round: t.u32(), + winnerSubject: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +export const duelCombatant = table( + { + name: 'duel_combatant', + public: false, + indexes: [ + { accessor: 'byRoom', algorithm: 'btree', columns: ['roomId'] }, + { accessor: 'bySubject', algorithm: 'btree', columns: ['subject'] }, + ], + }, + { + combatantId: t.string().primaryKey(), + roomId: t.u64(), + subject: t.string(), + displayName: t.string(), + shipClass, + hull: t.u32(), + maxHull: t.u32(), + shields: t.u32(), + maxShields: t.u32(), + attack: t.u32(), + defense: t.u32(), + speed: t.u32(), + critBps: t.u32(), + dodgeBps: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const duelRoundLog = table( + { + name: 'duel_round_log', + public: false, + indexes: [ + { accessor: 'byRoom', algorithm: 'btree', columns: ['roomId'] }, + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + ], + }, + { + logId: t.u64().primaryKey().autoInc(), + roomId: t.u64(), + round: t.u32(), + message: t.string(), + createdAt: t.timestamp(), + } +); + +export const duelManeuver = table( + { + name: 'duel_maneuver', + public: false, + indexes: [ + { accessor: 'byRoom', algorithm: 'btree', columns: ['roomId'] }, + { accessor: 'bySubject', algorithm: 'btree', columns: ['subject'] }, + ], + }, + { + choiceId: t.string().primaryKey(), + roomId: t.u64(), + round: t.u32(), + subject: t.string(), + slot: maneuverSlot, + maneuverId: t.string(), + chosenAt: t.timestamp(), + } +); + +export const queueSummaryRow = t.object('ExampleLobbyQueueSummaryRow', { + pool: t.string(), + queuedTickets: t.u32(), + readyRooms: t.u32(), + activeRooms: t.u32(), +}); + +export const ratingRow = t.object('ExampleLobbyRatingRow', { + pool: t.string(), + subject: t.string(), + rating: t.i32(), + wins: t.u32(), + losses: t.u32(), + draws: t.u32(), + matches: t.u32(), +}); + +export const spacetimedb = schema({ + lobby, + shipCatalog, + maneuverCatalog, + pilot, + duel, + duelCombatant, + duelRoundLog, + duelManeuver, +}); + +export type Schema = InferSchema; +export type WriteCtx = ReducerCtx; +export type ReadCtx = WriteCtx | ViewCtx; +export type CombatantRow = Infer; +export type ManeuverRow = Infer; +export type DuelRow = Infer; +export type ShipClassValue = (typeof ShipClass)[keyof typeof ShipClass]; +export type ManeuverSlotValue = + (typeof ManeuverSlot)[keyof typeof ManeuverSlot]; + +export default spacetimedb; diff --git a/spacetime-lobby-ts/example/spacetimedb/src/views.ts b/spacetime-lobby-ts/example/spacetimedb/src/views.ts new file mode 100644 index 00000000000..77aeadd8a01 --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/src/views.ts @@ -0,0 +1,254 @@ +import { Range, t } from 'spacetimedb/server'; +import * as lobby from '@spacetimedb/lobby/submodule'; + +import { + RATING_POOL, + pilot, + duel, + duelCombatant, + duelRoundLog, + duelManeuver, + queueSummaryRow, + ratingRow, + spacetimedb, + type ReadCtx, +} from './schema'; + +const PLAYER_ROSTER_LIMIT = 1000; + +function takeRows(rows: Iterable, limit: number): T[] { + const result: T[] = []; + for (const row of rows) { + if (result.length >= limit) break; + result.push(row); + } + return result; +} + +function subjectFor(ctx: { sender: { toHexString(): string } }): string { + return ctx.sender.toHexString(); +} + +function activeRoomIdsForSubject(ctx: ReadCtx, subject: string): bigint[] { + const roomIds: bigint[] = []; + const seen = new Set(); + for (const seat of [ + ...ctx.db.lobby.lobbyRoomSeat.bySubject.filter(subject), + ]) { + const key = seat.roomId.toString(); + if (seen.has(key)) continue; + seen.add(key); + roomIds.push(seat.roomId); + } + return roomIds; +} + +export const myProfile = spacetimedb.view( + { name: 'my_profile', public: true }, + t.array(pilot.rowType), + ctx => { + const row = ctx.db.pilot.subject.find(subjectFor(ctx)); + return row ? [row] : []; + } +); + +export const players = spacetimedb.view( + { name: 'players', public: true }, + t.array(pilot.rowType), + ctx => takeRows(ctx.db.pilot.iter(), PLAYER_ROSTER_LIMIT) +); + +export const myLobbyRatings = spacetimedb.view( + { name: 'my_lobby_ratings', public: true }, + t.array(ratingRow), + ctx => + [...ctx.db.lobby.lobbySubjectRating.bySubject.filter(subjectFor(ctx))] + .map(row => ({ + pool: row.pool, + subject: row.subject, + rating: row.rating, + wins: row.wins, + losses: row.losses, + draws: row.draws, + matches: row.matches, + })) + .sort((a, b) => a.pool.localeCompare(b.pool)) +); + +export const lobbyRankedLeaderboard = spacetimedb.view( + { name: 'lobby_ranked_leaderboard', public: true }, + t.array(ratingRow), + ctx => + [...ctx.db.lobby.lobbySubjectRating.byPool.filter(RATING_POOL)] + .sort((a, b) => { + if (a.rating !== b.rating) return b.rating - a.rating; + return a.subject.localeCompare(b.subject); + }) + .slice(0, 10) + .map(row => ({ + pool: row.pool, + subject: row.subject, + rating: row.rating, + wins: row.wins, + losses: row.losses, + draws: row.draws, + matches: row.matches, + })) +); + +export const myDuels = spacetimedb.view( + { name: 'my_duels', public: true }, + t.array(duel.rowType), + ctx => { + const roomIds = new Set(); + const rows = []; + for (const seat of [ + ...ctx.db.lobby.lobbyRoomSeat.bySubject.filter(subjectFor(ctx)), + ]) { + const key = seat.roomId.toString(); + if (roomIds.has(key)) continue; + roomIds.add(key); + const duelRow = ctx.db.duel.roomId.find(seat.roomId); + if (duelRow) rows.push(duelRow); + } + return rows.sort((a, b) => { + const av = a.updatedAt.microsSinceUnixEpoch; + const bv = b.updatedAt.microsSinceUnixEpoch; + return av < bv ? 1 : av > bv ? -1 : 0; + }); + } +); + +export const myDuelCombatants = spacetimedb.view( + { name: 'my_duel_combatants', public: true }, + t.array(duelCombatant.rowType), + ctx => { + const roomIds = activeRoomIdsForSubject(ctx, subjectFor(ctx)); + return roomIds.flatMap(roomId => [ + ...ctx.db.duelCombatant.byRoom.filter(roomId), + ]); + } +); + +export const myDuelRoundLogs = spacetimedb.view( + { name: 'my_duel_round_logs', public: true }, + t.array(duelRoundLog.rowType), + ctx => { + const roomIds = new Set( + activeRoomIdsForSubject(ctx, subjectFor(ctx)).map(roomId => + roomId.toString() + ) + ); + const logs = [ + ...ctx.db.duelRoundLog.byCreatedAt.filter(new Range()), + ].filter(row => roomIds.has(row.roomId.toString())); + return logs + .sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch; + const bv = b.createdAt.microsSinceUnixEpoch; + return av < bv ? -1 : av > bv ? 1 : 0; + }) + .slice(-80); + } +); + +export const myDuelManeuvers = spacetimedb.view( + { name: 'my_duel_maneuvers', public: true }, + t.array(duelManeuver.rowType), + ctx => { + const roomIds = new Set( + activeRoomIdsForSubject(ctx, subjectFor(ctx)).map(roomId => + roomId.toString() + ) + ); + return [...ctx.db.duelManeuver.byRoom.filter(new Range())] + .filter(row => roomIds.has(row.roomId.toString())) + .sort((a, b) => { + if (a.roomId !== b.roomId) return a.roomId < b.roomId ? -1 : 1; + if (a.round !== b.round) return a.round - b.round; + return a.subject.localeCompare(b.subject); + }); + } +); + +export const myLobbyTickets = spacetimedb.view( + { name: 'my_lobby_tickets', public: true }, + lobby.t.array(lobby.lobbyQueueTicket.rowType), + ctx => [...ctx.db.lobby.lobbyQueueTicket.bySubject.filter(subjectFor(ctx))] +); + +export const myLobbyRooms = spacetimedb.view( + { name: 'my_lobby_rooms', public: true }, + lobby.t.array(lobby.lobbyRoom.rowType), + ctx => { + const seen = new Set(); + const rooms = []; + for (const seat of [ + ...ctx.db.lobby.lobbyRoomSeat.bySubject.filter(subjectFor(ctx)), + ]) { + const key = seat.roomId.toString(); + if (seen.has(key)) continue; + seen.add(key); + const room = ctx.db.lobby.lobbyRoom.roomId.find(seat.roomId); + if (room) rooms.push(room); + } + return rooms; + } +); + +export const myLobbyRoomSeats = spacetimedb.view( + { name: 'my_lobby_room_seats', public: true }, + lobby.t.array(lobby.lobbyRoomSeat.rowType), + ctx => { + const roomIds = new Set( + [...ctx.db.lobby.lobbyRoomSeat.bySubject.filter(subjectFor(ctx))].map( + seat => seat.roomId.toString() + ) + ); + return [...ctx.db.lobby.lobbyRoomSeat.iter()].filter(seat => + roomIds.has(seat.roomId.toString()) + ); + } +); + +export const lobbyQueueSummary = spacetimedb.view( + { name: 'lobby_queue_summary', public: true }, + t.array(queueSummaryRow), + ctx => { + const summary = new Map< + string, + { + pool: string; + queuedTickets: number; + readyRooms: number; + activeRooms: number; + } + >(); + const ensure = (pool: string) => { + let row = summary.get(pool); + if (!row) { + row = { pool, queuedTickets: 0, readyRooms: 0, activeRooms: 0 }; + summary.set(pool, row); + } + return row; + }; + for (const ticket of [ + ...ctx.db.lobby.lobbyQueueTicket.byStatus.filter( + lobby.TicketStatus.Queued + ), + ]) { + ensure(ticket.pool).queuedTickets++; + } + for (const room of [ + ...ctx.db.lobby.lobbyRoom.byStatus.filter(lobby.RoomStatus.Ready), + ]) { + ensure(room.pool).readyRooms++; + } + for (const room of [ + ...ctx.db.lobby.lobbyRoom.byStatus.filter(lobby.RoomStatus.Active), + ]) { + ensure(room.pool).activeRooms++; + } + return [...summary.values()].sort((a, b) => a.pool.localeCompare(b.pool)); + } +); diff --git a/spacetime-lobby-ts/example/spacetimedb/tsconfig.json b/spacetime-lobby-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..4b599551afe --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-lobby-ts/example/src/app.ts b/spacetime-lobby-ts/example/src/app.ts new file mode 100644 index 00000000000..703c16b568b --- /dev/null +++ b/spacetime-lobby-ts/example/src/app.ts @@ -0,0 +1,1074 @@ +import { DbConnection, tables, type ErrorContext } from './module_bindings'; + +import { + TOKEN_KEY_PREFIX, + MATCH_FALLBACK_MS, + shipClasses, + maneuverSlots, + shipColors, + maneuverSlotMeta, + maneuverFx, + selectActiveRoom, + selectHighlightedManeuver, + selectLatestDuel, + selectLatestTicket, + selectLobbyScreen, + type ServerConfig, + type TableEvents, + type ShipClass, + type ManeuverSlot, + type Pilot, + type LobbyTicket, + type LobbyRoom, + type LobbySeat, + type Duel, + type Combatant, + type ShipCatalogRow, + type ManeuverCatalogRow, + type DuelManeuver, + type RoundLog, + type QueueSummary, + type RatingRow, +} from './model'; + +let conn: DbConnection | null = null; +let me = ''; +let selectedShip: ShipClass = 'Interceptor'; +let screenOverride: 'setup' | null = null; +let fallbackTimer: ReturnType | null = null; +let autoJoinedRoom: string | null = null; +let playedRoomId: string | null = null; +let arenaSignature = ''; +let animatedRound = -1; +const prevVitals = new Map(); + +// Maneuver cards appear on hover or focus for elements with [data-maneuver-id]. +function registerTooltipHandlers(): void { + const tip = $('tooltip'); + let current: Element | null = null; + let hideTimer: ReturnType | null = null; + let showTimer: ReturnType | null = null; + + const place = (el: Element) => { + const r = el.getBoundingClientRect(); + const tr = tip.getBoundingClientRect(); + const left = Math.max( + 8, + Math.min( + r.left + r.width / 2 - tr.width / 2, + window.innerWidth - tr.width - 8 + ) + ); + const above = r.top - tr.height - 8; + tip.style.left = `${Math.round(left)}px`; + tip.style.top = `${Math.round(above < 8 ? r.bottom + 8 : above)}px`; + }; + + const show = (el: Element) => { + const id = el.getAttribute('data-maneuver-id'); + const move = id + ? rows(maneuverCatalogTable()).find(m => m.maneuverId === id) + : undefined; + if (!move) return; + const meta = maneuverSlotMeta[move.slot.tag]; + const fx = maneuverFx(move); + tip.style.setProperty('--slot', meta.color); + tip.innerHTML = ` +
${meta.label}${escapeHtml(move.name)}
+ ${fx ? `
${fx}
` : ''} +
${escapeHtml(move.description)}
+ `; + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + if (showTimer) { + clearTimeout(showTimer); + showTimer = null; + } + tip.hidden = false; + place(el); + showTimer = setTimeout(() => tip.classList.add('show'), 10); + }; + + const hide = () => { + if (showTimer) { + clearTimeout(showTimer); + showTimer = null; + } + tip.classList.remove('show'); + hideTimer = setTimeout(() => { + tip.hidden = true; + }, 130); + }; + + const enter = (target: EventTarget | null) => { + const el = + target instanceof Element ? target.closest('[data-maneuver-id]') : null; + if (!el || el === current) return; + current = el; + show(el); + }; + const leave = (related: EventTarget | null) => { + if (!current) return; + if (related instanceof Node && current.contains(related)) return; + current = null; + hide(); + }; + + document.addEventListener('pointerover', ev => enter(ev.target)); + document.addEventListener('pointerout', ev => leave(ev.relatedTarget)); + document.addEventListener('focusin', ev => enter(ev.target)); + document.addEventListener('focusout', () => { + if (current) { + current = null; + hide(); + } + }); + window.addEventListener( + 'scroll', + () => { + if (current) { + current = null; + hide(); + } + }, + true + ); + window.addEventListener('resize', () => { + if (current) { + current = null; + hide(); + } + }); +} + +function $(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) throw new Error(`missing #${id}`); + return el; +} + +function input(id: string): HTMLInputElement { + return $(id) as HTMLInputElement; +} + +function dialog(id: string): HTMLDialogElement { + return $(id) as HTMLDialogElement; +} + +function setText(id: string, value: string): void { + $(id).textContent = value; +} + +function defaultDisplayName(): string { + return me ? `Pilot ${me.slice(0, 6).toUpperCase()}` : 'Pilot'; +} + +function clearFallbackTimer(): void { + if (fallbackTimer == null) return; + clearTimeout(fallbackTimer); + fallbackTimer = null; +} + +function scheduleAiFallback(): void { + clearFallbackTimer(); + fallbackTimer = setTimeout(async () => { + fallbackTimer = null; + const ticket = latestTicket(); + if (!ticket || ticket.status.tag !== 'Queued') return; + try { + showToast('No rival found. Launching vs Arena AI.'); + requireConn().reducers.fallbackToAi({}); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), 'error'); + } + }, MATCH_FALLBACK_MS); +} + +function setScreen(id: 'setupScreen' | 'waitingScreen' | 'duelScreen'): void { + for (const screen of ['setupScreen', 'waitingScreen', 'duelScreen']) { + $(screen).classList.toggle('active', screen === id); + } +} + +function showToast(message: string, kind: 'ok' | 'error' = 'ok'): void { + const el = $('toast'); + el.textContent = message; + el.className = `toast ${kind}`; +} + +function errorMessage(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('duel.invalid_display_name')) + return 'Enter a name to start.'; + if (message.includes('duel.invalid_ship_class')) return 'Choose a ship.'; + if (message.includes('duel.room_not_found')) + return 'That duel is unavailable.'; + if (message.includes('duel.not_in_room')) + return 'Join the duel before advancing.'; + if (message.includes('duel.not_active')) return 'This duel is not active.'; + return message; +} + +// Suppress expected leave and teardown errors after a duel or room has ended. +function isBenignDuelError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /room_closed|not_in_room|room_not_found|duel_abandoned|duel_not_ready|not_active/i.test( + message + ); +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function pct(current: number, max: number): number { + if (max <= 0) return 0; + return Math.max(0, Math.min(100, Math.round((current / max) * 100))); +} + +function requireConn(): DbConnection { + if (!conn) throw new Error('stdb.disconnected'); + return conn; +} + +function rows(source: TableEvents): T[] { + return [...source.iter()]; +} + +function profileTable(): TableEvents { + return requireConn().db.myProfile; +} +function playersTable(): TableEvents { + return requireConn().db.players; +} + +function displayNameFor(subject: string): string { + const player = rows(playersTable()).find(row => row.subject === subject); + return player?.displayName ?? `Pilot ${subject.slice(0, 6).toUpperCase()}`; +} +function ticketsTable(): TableEvents { + return requireConn().db.myLobbyTickets; +} +function roomsTable(): TableEvents { + return requireConn().db.myLobbyRooms; +} +function seatsTable(): TableEvents { + return requireConn().db.myLobbyRoomSeats; +} +function summaryTable(): TableEvents { + return requireConn().db.lobbyQueueSummary; +} +function ratingsTable(): TableEvents { + return requireConn().db.myLobbyRatings; +} +function leaderboardTable(): TableEvents { + return requireConn().db.lobbyRankedLeaderboard; +} +function shipCatalogTable(): TableEvents { + return requireConn().db.shipCatalog; +} +function maneuverCatalogTable(): TableEvents { + return requireConn().db.maneuverCatalog; +} +function duelsTable(): TableEvents { + return requireConn().db.myDuels; +} +function combatantsTable(): TableEvents { + return requireConn().db.myDuelCombatants; +} +function logsTable(): TableEvents { + return requireConn().db.myDuelRoundLogs; +} +function maneuversTable(): TableEvents { + return requireConn().db.myDuelManeuvers; +} + +function tokenKey(config: ServerConfig): string { + return `${TOKEN_KEY_PREFIX}:${config.spacetimeUri}:${config.databaseName}`; +} + +function loadStdbToken(config: ServerConfig): string | undefined { + try { + return sessionStorage.getItem(tokenKey(config)) ?? undefined; + } catch { + return undefined; + } +} + +function saveStdbToken(config: ServerConfig, token: string): void { + try { + sessionStorage.setItem(tokenKey(config), token); + } catch { + /* Storage can be unavailable. */ + } +} + +function clearStdbToken(config: ServerConfig): void { + try { + sessionStorage.removeItem(tokenKey(config)); + } catch { + /* Storage can be unavailable. */ + } +} + +function isStaleTokenError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return ( + message.includes('Failed to verify token') || + message.includes('Unauthorized') + ); +} + +async function loadServerConfig(): Promise { + const r = await fetch('/api/config'); + if (!r.ok) throw new Error(`/api/config returned ${r.status}`); + return (await r.json()) as ServerConfig; +} + +function connectOnce( + config: ServerConfig, + token?: string +): Promise { + return new Promise((resolve, reject) => { + let builder = DbConnection.builder() + .withUri(config.spacetimeUri) + .withDatabaseName(config.databaseName); + if (token) builder = builder.withToken(token); + builder + .onConnect((connection, identity, token) => { + conn = connection; + me = identity.toHexString(); + if (token) saveStdbToken(config, token); + resolve(connection); + }) + .onDisconnect((_ctx, err) => { + showToast(err?.message ?? 'Disconnected.', 'error'); + }) + .onConnectError((_ctx, err) => reject(err)) + .build(); + }); +} + +async function connect(config: ServerConfig): Promise { + const token = loadStdbToken(config); + try { + return await connectOnce(config, token); + } catch (err) { + if (!token || !isStaleTokenError(err)) throw err; + clearStdbToken(config); + showToast('Session expired. Reconnecting.', 'error'); + return connectOnce(config); + } +} + +function currentProfile(): Pilot | undefined { + return rows(profileTable())[0]; +} + +function latestTicket(): LobbyTicket | undefined { + return selectLatestTicket(rows(ticketsTable())); +} + +function latestDuel(): Duel | undefined { + return selectLatestDuel(rows(duelsTable()), latestTicket()); +} + +function activeRoom(): LobbyRoom | undefined { + return selectActiveRoom(rows(roomsTable()), latestDuel(), latestTicket()); +} + +function selectedShipCatalog(): ShipCatalogRow | undefined { + return rows(shipCatalogTable()).find( + row => row.shipClass.tag === selectedShip + ); +} + +function statRows(ship: ShipCatalogRow): Array<[string, number, string]> { + return [ + ['Hull', pct(ship.hull, 160), String(ship.hull)], + ['Shields', pct(ship.shields, 80), String(ship.shields)], + ['Attack', pct(ship.attack, 38), String(ship.attack)], + ['Speed', pct(ship.speed, 8), String(ship.speed)], + ['Crit', pct(ship.critBps, 1400), `${Math.round(ship.critBps / 100)}%`], + ['Dodge', pct(ship.dodgeBps, 2200), `${Math.round(ship.dodgeBps / 100)}%`], + ]; +} + +function renderShipCarousel(): void { + const details = selectedShipCatalog(); + const card = document.querySelector('.launch-card'); + if (card instanceof HTMLElement) + card.style.setProperty('--ship', shipColors[selectedShip]); + setText('selectedShipRole', details?.role ?? 'Loading'); + setText('selectedShipName', selectedShip); + setText( + 'selectedShipDescription', + details?.description ?? 'Loading ship catalog from SpacetimeDB.' + ); + $('selectedShipVisual').className = + `ship-preview ${selectedShip.toLowerCase()}`; + $('shipRoster').innerHTML = shipClasses + .map( + cls => ` + + ` + ) + .join(''); + $('selectedShipStats').innerHTML = details + ? statRows(details) + .map( + ([label, pctValue, value]) => ` +
+
${label}${value}
+
+
+ ` + ) + .join('') + : '
Loading ship catalog.
'; + + const moves = rows(maneuverCatalogTable()) + .filter(move => move.shipClass.tag === selectedShip) + .sort( + (a, b) => + maneuverSlots.indexOf(a.slot.tag) - maneuverSlots.indexOf(b.slot.tag) + ); + $('shipAbilities').innerHTML = + moves.length === 0 + ? '' + : ` + Maneuvers +
+ ${moves + .map(move => { + const meta = maneuverSlotMeta[move.slot.tag]; + return ` +
+
${meta.icon}${meta.label}
+ ${escapeHtml(move.name)} +
+ `; + }) + .join('')} +
+ `; +} + +async function chooseShip(ship: ShipClass): Promise { + selectedShip = ship; + renderShipCarousel(); + requireConn().reducers.selectShip({ shipClass: { tag: ship } }); +} + +function renderProfile(): void { + const profile = currentProfile(); + selectedShip = profile?.shipClass.tag ?? selectedShip; + const nameInput = input('displayName'); + const nextName = profile?.displayName ?? defaultDisplayName(); + if (document.activeElement !== nameInput || !nameInput.value.trim()) { + nameInput.value = nextName; + } + renderShipCarousel(); +} + +function renderRanked(): void { + const rating = rows(ratingsTable()).find( + row => row.pool === 'spaceship_duel' + ); + setText('myRating', String(rating?.rating ?? 1000)); + setText( + 'myRecord', + rating + ? `${rating.wins}W ${rating.losses}L${rating.draws ? ` ${rating.draws}D` : ''}` + : '0W 0L' + ); + const leaderboard = rows(leaderboardTable()) + .filter( + row => row.pool === 'spaceship_duel' && !row.subject.startsWith('ai:') + ) + .sort( + (a, b) => + b.rating - a.rating || + b.wins - a.wins || + a.subject.localeCompare(b.subject) + ) + .slice(0, 10); + $('leaderboard').innerHTML = + leaderboard.length === 0 + ? '

No ranked pilots yet. Win a duel to get on the board.

' + : leaderboard + .map( + (row, index) => ` +
+ ${index + 1} + ${escapeHtml(displayNameFor(row.subject))}${row.subject === me ? ' (you)' : ''} + ${row.rating} +
+ ` + ) + .join(''); +} + +function maybeAutoJoin(): void { + if (screenOverride === 'setup') return; + const room = activeRoom(); + if (!room) return; + const mySeat = rows(seatsTable()).find( + seat => seat.roomId === room.roomId && seat.subject === me + ); + if (!mySeat || mySeat.status.tag === 'Joined') return; + const key = room.roomId.toString(); + if (autoJoinedRoom === key) return; + autoJoinedRoom = key; + try { + requireConn().reducers.joinDuelRoom({ roomId: room.roomId }); + } catch (err) { + autoJoinedRoom = null; + if (!isBenignDuelError(err)) showToast(errorMessage(err), 'error'); + } +} + +function renderLobby(): void { + const room = activeRoom(); + $('forfeitDuel').toggleAttribute('disabled', !room); + maybeAutoJoin(); +} + +function closeForfeitDialog(): void { + const modal = dialog('forfeitDialog'); + if (modal.open) modal.close(); +} + +async function forfeitActiveDuel(): Promise { + const room = activeRoom(); + autoJoinedRoom = null; + screenOverride = 'setup'; + closeForfeitDialog(); + render(); + if (!room) return; + try { + requireConn().reducers.leaveDuel({ roomId: room.roomId }); + } catch (err) { + // Surface only actionable errors while leaving a room that may already be closed. + if (!isBenignDuelError(err)) showToast(errorMessage(err), 'error'); + } +} + +function desiredScreen(): 'setupScreen' | 'waitingScreen' | 'duelScreen' { + const duel = latestDuel(); + const ticket = latestTicket(); + return selectLobbyScreen({ + screenOverride, + ticket, + duel, + room: activeRoom(), + playedRoomId, + }); +} + +function combatantCardHtml(row: Combatant): string { + return ` +
+
+
+
+ ${row.subject === me ? 'Your ship' : 'Opponent'} +

${escapeHtml(row.displayName)}

+

${row.shipClass.tag}

+
+
+
+
Hull
+
+
Shields
+
+
+
+ `; +} + +function highlightedManeuverFor( + duel: Duel | undefined, + row: Combatant +): DuelManeuver | undefined { + return selectHighlightedManeuver(duel, row, rows(maneuversTable())); +} + +function abilitiesHtml(duel: Duel | undefined, row: Combatant): string { + const abilities = rows(maneuverCatalogTable()) + .filter(move => move.shipClass.tag === row.shipClass.tag) + .sort( + (a, b) => + maneuverSlots.indexOf(a.slot.tag) - maneuverSlots.indexOf(b.slot.tag) + ); + if (abilities.length === 0) return ''; + const highlighted = highlightedManeuverFor(duel, row); + return ` + Abilities +
+ ${abilities + .map(move => { + const meta = maneuverSlotMeta[move.slot.tag]; + return ` +
+
${meta.icon}${meta.label}
+ ${escapeHtml(move.name)} +
+ `; + }) + .join('')} +
+ `; +} + +function flashCard(card: HTMLElement, kind: string): void { + const cls = `flash-${kind}`; + card.classList.remove('flash-hit', 'flash-crit', 'flash-shield'); + void card.offsetWidth; + card.classList.add(cls); + setTimeout(() => card.classList.remove(cls), 420); +} + +function shakeCard(card: HTMLElement, hard: boolean): void { + const cls = hard ? 'shake-hard' : 'shake'; + card.classList.remove('shake', 'shake-hard'); + void card.offsetWidth; + card.classList.add(cls); + setTimeout(() => card.classList.remove(cls), 480); +} + +function lungeCard(card: HTMLElement): void { + card.classList.add('attacking'); + setTimeout(() => card.classList.remove('attacking'), 200); +} + +function popNumber(card: HTMLElement, text: string, kind: string): void { + const layer = card.querySelector('.pops'); + if (!layer) return; + const pop = document.createElement('div'); + pop.className = `pop ${kind}`; + pop.textContent = text; + pop.style.setProperty( + '--pop-x', + `${Math.round((Math.random() - 0.5) * 44)}px` + ); + layer.appendChild(pop); + setTimeout(() => pop.remove(), 900); +} + +function animateRound( + host: HTMLElement, + duel: Duel, + combatants: Combatant[], + round: number +): void { + const cards = new Map(); + for (const el of host.querySelectorAll('.combatant')) { + cards.set((el as HTMLElement).dataset.subject ?? '', el as HTMLElement); + } + const roundLogs = rows(logsTable()).filter( + row => row.roomId === duel.roomId && row.round === round + ); + const critRound = roundLogs.some(row => /critical/i.test(row.message)); + const hadEvade = roundLogs.some(row => /evade|miss/i.test(row.message)); + + const damage = combatants.map(c => { + const prev = prevVitals.get(c.subject); + const prevHull = prev?.hull ?? c.hull; + const prevShields = prev?.shields ?? c.shields; + const dmg = Math.max(0, prevHull + prevShields - (c.hull + c.shields)); + const shieldOnly = prevHull === c.hull && prevShields > c.shields; + return { subject: c.subject, dmg, shieldOnly }; + }); + const maxDmg = Math.max(0, ...damage.map(d => d.dmg)); + + for (const d of damage) { + const card = cards.get(d.subject); + if (!card) continue; + if (d.dmg > 0) { + const isCrit = critRound && !d.shieldOnly && d.dmg === maxDmg; + const kind = d.shieldOnly ? 'shield' : isCrit ? 'crit' : 'hit'; + popNumber(card, `-${d.dmg}`, kind); + flashCard(card, kind); + shakeCard(card, isCrit); + for (const other of cards) { + if (other[0] !== d.subject) lungeCard(other[1]); + } + } else if (hadEvade) { + popNumber(card, 'EVADE', 'evade'); + } + } +} + +function renderManeuvers( + duel: Duel | undefined, + combatants: Combatant[], + complete: boolean +): void { + const host = $('maneuverActions'); + if (!duel || complete || duel.status.tag !== 'Active') { + host.innerHTML = ''; + host.hidden = true; + return; + } + const mine = combatants.find(row => row.subject === me); + if (!mine) { + host.innerHTML = ''; + host.hidden = true; + return; + } + const round = duel.round + 1; + const chosen = rows(maneuversTable()).find( + row => + row.roomId === duel.roomId && row.round === round && row.subject === me + ); + const catalog = rows(maneuverCatalogTable()) + .filter(row => row.shipClass.tag === mine.shipClass.tag) + .sort( + (a, b) => + maneuverSlots.indexOf(a.slot.tag) - maneuverSlots.indexOf(b.slot.tag) + ); + host.hidden = false; + host.innerHTML = ` +
+ ${chosen ? '✓ Locked in | waiting for opponent' : `Choose your maneuver | Round ${round}`} +
+
+ ${catalog + .map(row => { + const meta = maneuverSlotMeta[row.slot.tag]; + const isChosen = chosen?.slot.tag === row.slot.tag; + return ` + + `; + }) + .join('')} +
+ `; +} + +function renderArena(): void { + const duel = latestDuel(); + const combatants = rows(combatantsTable()).filter( + row => !duel || row.roomId === duel.roomId + ); + + const host = $('combatants'); + if (combatants.length === 0) { + host.innerHTML = + '
Queue from two tabs to create the duel.
'; + arenaSignature = ''; + animatedRound = -1; + prevVitals.clear(); + $('duelStatus').hidden = true; + renderManeuvers(undefined, [], false); + $('forfeitDuel').hidden = false; + $('newDuel').hidden = true; + $('home').hidden = true; + return; + } + + // Rebuild cards only when the combatant set changes, so HP bars can animate in place. + const round = duel?.round ?? 0; + const signature = `${duel?.roomId ?? ''}:${combatants.map(c => c.subject).join('|')}`; + if (signature !== arenaSignature) { + host.innerHTML = combatants.map(combatantCardHtml).join(''); + arenaSignature = signature; + animatedRound = round; + prevVitals.clear(); + } + + const cards = new Map(); + for (const el of host.querySelectorAll('.combatant')) { + cards.set((el as HTMLElement).dataset.subject ?? '', el as HTMLElement); + } + const complete = + !!duel && + (duel.status.tag === 'Complete' || duel.status.tag === 'Abandoned'); + + // Animate a freshly resolved round before applying the new bar values. + if (duel && round > animatedRound) { + animateRound(host, duel, combatants, round); + animatedRound = round; + } + + for (const row of combatants) { + const card = cards.get(row.subject); + if (!card) continue; + (card.querySelector('.hull-text') as HTMLElement).textContent = + `${row.hull}/${row.maxHull}`; + (card.querySelector('.shield-text') as HTMLElement).textContent = + `${row.shields}/${row.maxShields}`; + (card.querySelector('.hull-fill') as HTMLElement).style.width = + `${pct(row.hull, row.maxHull)}%`; + (card.querySelector('.shield-fill') as HTMLElement).style.width = + `${pct(row.shields, row.maxShields)}%`; + card.classList.toggle( + 'low', + row.hull > 0 && pct(row.hull, row.maxHull) <= 30 + ); + card.classList.toggle('dead', row.hull <= 0); + card.classList.toggle( + 'winner', + complete && !!duel!.winnerSubject && row.subject === duel!.winnerSubject + ); + card.classList.toggle( + 'loser', + complete && !!duel!.winnerSubject && row.subject !== duel!.winnerSubject + ); + const abilities = card.querySelector('.ship-abilities') as HTMLElement; + abilities.innerHTML = abilitiesHtml(duel, row); + } + + prevVitals.clear(); + for (const c of combatants) + prevVitals.set(c.subject, { hull: c.hull, shields: c.shields }); + + const statusEl = $('duelStatus'); + if (complete) { + const abandoned = duel!.status.tag === 'Abandoned'; + const youWon = duel!.winnerSubject === me; + statusEl.textContent = abandoned + ? 'Duel Abandoned' + : youWon + ? 'Victory' + : 'Defeat'; + statusEl.className = `${abandoned ? 'abandoned' : youWon ? 'win' : 'lose'} show`; + statusEl.hidden = false; + } else if (duel) { + statusEl.textContent = round >= 1 ? `Round ${round}` : 'Ready'; + statusEl.className = ''; + statusEl.hidden = false; + } else { + statusEl.hidden = true; + } + + renderManeuvers(duel, combatants, complete); + $('forfeitDuel').hidden = complete; + $('newDuel').hidden = !complete; + $('home').hidden = !complete; +} + +function render(): void { + if (!conn) return; + renderProfile(); + renderRanked(); + renderLobby(); + renderArena(); + const ticket = latestTicket(); + const duel = latestDuel(); + if ( + duel && + (duel.status.tag === 'Active' || duel.status.tag === 'Configuring') + ) { + playedRoomId = duel.roomId.toString(); + } + if (ticket?.status.tag !== 'Queued' || duel || activeRoom()) + clearFallbackTimer(); + setScreen(desiredScreen()); +} + +function registerRowCallbacks(): void { + const rerender = () => render(); + const sources = [ + profileTable(), + playersTable(), + ticketsTable(), + roomsTable(), + seatsTable(), + summaryTable(), + ratingsTable(), + leaderboardTable(), + shipCatalogTable(), + maneuverCatalogTable(), + duelsTable(), + combatantsTable(), + logsTable(), + maneuversTable(), + ]; + for (const source of sources) { + source.onInsert(rerender); + source.onUpdate(rerender); + source.onDelete(rerender); + } +} + +function registerUiHandlers(): void { + const moveShip = async (direction: -1 | 1) => { + const index = shipClasses.indexOf(selectedShip); + const next = + shipClasses[ + (index + direction + shipClasses.length) % shipClasses.length + ]; + try { + await chooseShip(next); + } catch (err) { + showToast(errorMessage(err), 'error'); + } + }; + $('shipRoster').addEventListener('click', ev => { + const btn = + ev.target instanceof Element ? ev.target.closest('[data-ship]') : null; + if (!(btn instanceof HTMLElement) || !btn.dataset.ship) return; + const ship = btn.dataset.ship as ShipClass; + if (ship !== selectedShip) + void chooseShip(ship).catch(err => showToast(errorMessage(err), 'error')); + }); + document.addEventListener('keydown', ev => { + if (!$('setupScreen').classList.contains('active')) return; + if (document.activeElement instanceof HTMLInputElement) return; + if (ev.key === 'ArrowLeft') void moveShip(-1); + else if (ev.key === 'ArrowRight') void moveShip(1); + }); + const saveDisplayName = async () => { + const value = input('displayName').value.trim(); + if (!value || value === currentProfile()?.displayName) return; + try { + requireConn().reducers.setDisplayName({ displayName: value }); + } catch (err) { + showToast(errorMessage(err), 'error'); + } + }; + input('displayName').addEventListener('change', () => void saveDisplayName()); + input('displayName').addEventListener('keydown', ev => { + if (ev.key === 'Enter') { + ev.preventDefault(); + input('displayName').blur(); + } + }); + $('findDuel').addEventListener('click', async () => { + try { + screenOverride = null; + autoJoinedRoom = null; + requireConn().reducers.setDisplayName({ + displayName: input('displayName').value, + }); + requireConn().reducers.selectShip({ shipClass: { tag: selectedShip } }); + requireConn().reducers.findDuel({}); + showToast('Looking for match.'); + scheduleAiFallback(); + } catch (err) { + showToast(errorMessage(err), 'error'); + } + }); + $('maneuverActions').addEventListener('click', async ev => { + const btn = + ev.target instanceof Element ? ev.target.closest('[data-slot]') : null; + if (!(btn instanceof HTMLElement) || !btn.dataset.slot) return; + const duel = latestDuel(); + if (!duel) return; + try { + requireConn().reducers.chooseManeuver({ + roomId: duel.roomId, + slot: { tag: btn.dataset.slot as ManeuverSlot }, + }); + } catch (err) { + showToast(errorMessage(err), 'error'); + } + }); + $('forfeitDuel').addEventListener('click', () => { + if (!activeRoom()) return; + dialog('forfeitDialog').showModal(); + }); + $('cancelForfeit').addEventListener('click', closeForfeitDialog); + $('confirmForfeit').addEventListener('click', () => void forfeitActiveDuel()); + dialog('forfeitDialog').addEventListener('click', ev => { + if (ev.target === dialog('forfeitDialog')) closeForfeitDialog(); + }); + $('newDuel').addEventListener('click', async () => { + const duel = latestDuel(); + try { + screenOverride = null; + autoJoinedRoom = null; + requireConn().reducers.queueAgain({ roomId: duel?.roomId }); + showToast('Looking for match.'); + scheduleAiFallback(); + } catch (err) { + showToast(errorMessage(err), 'error'); + } + }); + $('home').addEventListener('click', () => { + screenOverride = 'setup'; + render(); + }); + $('cancelSearch').addEventListener('click', async () => { + clearFallbackTimer(); + screenOverride = 'setup'; + render(); + for (const ticket of rows(ticketsTable()).filter( + t => t.status.tag === 'Queued' + )) { + try { + requireConn().reducers['lobby.cancelTicket']({ + ticketId: ticket.ticketId, + }); + } catch (err) { + if (!isBenignDuelError(err)) showToast(errorMessage(err), 'error'); + } + } + }); +} + +function subscribeToTables(connection: DbConnection): void { + connection + .subscriptionBuilder() + .onApplied(() => render()) + .onError((ctx: ErrorContext) => + console.error('subscription error', ctx.event) + ) + .subscribe([ + tables.myProfile, + tables.players, + tables.myLobbyTickets, + tables.myLobbyRooms, + tables.myLobbyRoomSeats, + tables.lobbyQueueSummary, + tables.myLobbyRatings, + tables.lobbyRankedLeaderboard, + tables.shipCatalog, + tables.maneuverCatalog, + tables.myDuels, + tables.myDuelCombatants, + tables.myDuelRoundLogs, + tables.myDuelManeuvers, + ]); +} + +async function main(): Promise { + const config = await loadServerConfig(); + const connection = await connect(config); + registerRowCallbacks(); + subscribeToTables(connection); + registerUiHandlers(); + registerTooltipHandlers(); + showToast('Connected.'); + render(); +} + +main().catch(err => { + showToast(errorMessage(err), 'error'); +}); diff --git a/spacetime-lobby-ts/example/src/model.ts b/spacetime-lobby-ts/example/src/model.ts new file mode 100644 index 00000000000..9419927d70f --- /dev/null +++ b/spacetime-lobby-ts/example/src/model.ts @@ -0,0 +1,300 @@ +export interface ServerConfig { + spacetimeUri: string; + databaseName: string; +} + +export type TableEvents = { + iter(): Iterable; + onInsert(cb: (ctx: EventContext, row: T) => void): void; + onUpdate(cb: (ctx: EventContext, old: T, row: T) => void): void; + onDelete(cb: (ctx: EventContext, row: T) => void): void; +}; + +export type EnumTag = { tag: T }; +export type ShipClass = 'Bulwark' | 'Interceptor' | 'Phantom' | 'Artillery'; +export type ManeuverSlot = 'Primary' | 'Defensive' | 'Risky'; + +export type Pilot = { + subject: string; + displayName: string; + shipClass: EnumTag; +}; + +export type LobbyTicket = { + ticketId: string; + pool: string; + status: EnumTag; + roomId?: bigint; + createdAt: { microsSinceUnixEpoch: bigint }; +}; + +export type LobbyRoom = { + roomId: bigint; + pool: string; + status: EnumTag; + capacity: number; + createdAt: { microsSinceUnixEpoch: bigint }; +}; + +export type LobbySeat = { + seatId: bigint; + roomId: bigint; + subject: string; + seatIndex: number; + status: EnumTag; +}; + +export type Duel = { + roomId: bigint; + status: EnumTag; + round: number; + winnerSubject?: string; + updatedAt: { microsSinceUnixEpoch: bigint }; +}; + +export type Combatant = { + roomId: bigint; + subject: string; + displayName: string; + shipClass: EnumTag; + hull: number; + maxHull: number; + shields: number; + maxShields: number; + attack: number; + defense: number; + speed: number; + critBps: number; + dodgeBps: number; +}; + +export type ShipCatalogRow = { + shipId: string; + shipClass: EnumTag; + role: string; + description: string; + hull: number; + shields: number; + attack: number; + defense: number; + speed: number; + critBps: number; + dodgeBps: number; +}; + +export type ManeuverCatalogRow = { + maneuverId: string; + shipClass: EnumTag; + slot: EnumTag; + name: string; + description: string; + damageBps: number; + defenseBps: number; + shieldRestore: number; + selfShieldCost: number; + critBonusBps: number; + dodgeBonusBps: number; +}; + +export type DuelManeuver = { + choiceId: string; + roomId: bigint; + round: number; + subject: string; + slot: EnumTag; + maneuverId: string; +}; + +export type RoundLog = { + logId: bigint; + roomId: bigint; + round: number; + message: string; + createdAt: { microsSinceUnixEpoch: bigint }; +}; + +export type QueueSummary = { + pool: string; + queuedTickets: number; + readyRooms: number; + activeRooms: number; +}; + +export type RatingRow = { + pool: string; + subject: string; + rating: number; + wins: number; + losses: number; + draws: number; + matches: number; +}; + +export type LobbyScreen = 'setupScreen' | 'waitingScreen' | 'duelScreen'; + +export function selectLatestTicket( + tickets: readonly LobbyTicket[] +): LobbyTicket | undefined { + return [...tickets].sort((a, b) => { + const aCreated = a.createdAt.microsSinceUnixEpoch; + const bCreated = b.createdAt.microsSinceUnixEpoch; + return aCreated < bCreated ? 1 : aCreated > bCreated ? -1 : 0; + })[0]; +} + +export function selectLatestDuel( + duels: readonly Duel[], + ticket: LobbyTicket | undefined +): Duel | undefined { + const newestFirst = [...duels].sort((a, b) => { + const aUpdated = a.updatedAt.microsSinceUnixEpoch; + const bUpdated = b.updatedAt.microsSinceUnixEpoch; + return aUpdated < bUpdated ? 1 : aUpdated > bUpdated ? -1 : 0; + }); + if (ticket?.roomId !== undefined) { + const ticketDuel = newestFirst.find(duel => duel.roomId === ticket.roomId); + if (ticketDuel) return ticketDuel; + } + return newestFirst.find( + duel => duel.status.tag !== 'Complete' && duel.status.tag !== 'Abandoned' + ); +} + +export function selectActiveRoom( + rooms: readonly LobbyRoom[], + duel: Duel | undefined, + ticket: LobbyTicket | undefined +): LobbyRoom | undefined { + if ( + duel && + duel.status.tag !== 'Complete' && + duel.status.tag !== 'Abandoned' + ) { + return rooms.find(room => room.roomId === duel.roomId); + } + if (ticket?.roomId === undefined) return undefined; + return rooms.find(room => room.roomId === ticket.roomId); +} + +export function selectLobbyScreen(input: { + screenOverride: 'setup' | null; + ticket: LobbyTicket | undefined; + duel: Duel | undefined; + room: LobbyRoom | undefined; + playedRoomId: string | null; +}): LobbyScreen { + const { screenOverride, ticket, duel, room, playedRoomId } = input; + if (screenOverride === 'setup') return 'setupScreen'; + if (ticket?.status.tag === 'Queued') return 'waitingScreen'; + if ( + duel && + (duel.status.tag === 'Complete' || duel.status.tag === 'Abandoned') + ) { + return playedRoomId === duel.roomId.toString() + ? 'duelScreen' + : 'setupScreen'; + } + if (duel || room) return 'duelScreen'; + return 'setupScreen'; +} + +export function selectHighlightedManeuver( + duel: Duel | undefined, + combatant: Combatant, + choices: readonly DuelManeuver[] +): DuelManeuver | undefined { + if (!duel || duel.status.tag === 'Configuring') return undefined; + const newestFirst = choices + .filter( + choice => + choice.roomId === combatant.roomId && + choice.subject === combatant.subject + ) + .sort((a, b) => b.round - a.round); + if (duel.status.tag === 'Active') { + return ( + newestFirst.find(choice => choice.round === duel.round + 1) ?? + newestFirst.find(choice => choice.round === duel.round) + ); + } + return newestFirst[0]; +} + +export const TOKEN_KEY_PREFIX = 'lobby-duel:stdb-token'; +export const MATCH_FALLBACK_MS = 4500; +export const shipClasses: ShipClass[] = [ + 'Bulwark', + 'Interceptor', + 'Phantom', + 'Artillery', +]; +export const maneuverSlots: ManeuverSlot[] = ['Primary', 'Defensive', 'Risky']; + +export const shipColors: Record = { + Bulwark: 'var(--green)', + Interceptor: 'var(--yellow)', + Phantom: 'var(--violet)', + Artillery: 'var(--red)', +}; + +// Each maneuver slot reads as a distinct tactical stance: strike / guard / gamble. +export const maneuverSlotMeta: Record< + ManeuverSlot, + { color: string; label: string; icon: string } +> = { + Primary: { + color: 'var(--red)', + label: 'Primary', + icon: '', + }, + Defensive: { + color: 'var(--cyan)', + label: 'Defensive', + icon: '', + }, + Risky: { + color: 'var(--violet)', + label: 'Special', + icon: '', + }, +}; + +export function maneuverEffects( + move: ManeuverCatalogRow +): Array<{ text: string; cost: boolean }> { + const signPct = (bps: number) => + `${bps > 0 ? '+' : ''}${Math.round(bps / 100)}%`; + const fx: Array<{ text: string; cost: boolean }> = []; + if (move.damageBps) + fx.push({ + text: `${signPct(move.damageBps)} dmg`, + cost: move.damageBps < 0, + }); + if (move.defenseBps) + fx.push({ + text: `${signPct(move.defenseBps)} def`, + cost: move.defenseBps < 0, + }); + if (move.critBonusBps) + fx.push({ + text: `${signPct(move.critBonusBps)} crit`, + cost: move.critBonusBps < 0, + }); + if (move.dodgeBonusBps) + fx.push({ + text: `${signPct(move.dodgeBonusBps)} dodge`, + cost: move.dodgeBonusBps < 0, + }); + if (move.shieldRestore) + fx.push({ text: `+${move.shieldRestore} shield`, cost: false }); + if (move.selfShieldCost) + fx.push({ text: `-${move.selfShieldCost} shield`, cost: true }); + return fx; +} + +export function maneuverFx(move: ManeuverCatalogRow): string { + return maneuverEffects(move) + .map(c => `${c.text}`) + .join(''); +} +import type { EventContext } from './module_bindings'; diff --git a/spacetime-lobby-ts/example/src/module_bindings/advance_duel_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/advance_duel_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/advance_duel_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/choose_maneuver_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/choose_maneuver_reducer.ts new file mode 100644 index 00000000000..68bf00e0400 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/choose_maneuver_reducer.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + ManeuverSlot, +} from "./types"; + +export default { + roomId: __t.u64(), + get slot() { + return ManeuverSlot; + }, +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/fallback_to_ai_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/fallback_to_ai_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/fallback_to_ai_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/find_duel_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/find_duel_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/find_duel_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/index.ts b/spacetime-lobby-ts/example/src/module_bindings/index.ts new file mode 100644 index 00000000000..c85f8365e3b --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/index.ts @@ -0,0 +1,422 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import AdvanceDuelReducer from "./advance_duel_reducer"; +import ChooseManeuverReducer from "./choose_maneuver_reducer"; +import FallbackToAiReducer from "./fallback_to_ai_reducer"; +import FindDuelReducer from "./find_duel_reducer"; +import JoinDuelRoomReducer from "./join_duel_room_reducer"; +import LeaveDuelReducer from "./leave_duel_reducer"; +import QueueAgainReducer from "./queue_again_reducer"; +import SelectShipReducer from "./select_ship_reducer"; +import SetDisplayNameReducer from "./set_display_name_reducer"; + +// Import all procedure arg schemas + +// Import all table schema definitions +import LobbyQueueSummaryRow from "./lobby_queue_summary_table"; +import LobbyRankedLeaderboardRow from "./lobby_ranked_leaderboard_table"; +import ManeuverCatalogRow from "./maneuver_catalog_table"; +import MyDuelCombatantsRow from "./my_duel_combatants_table"; +import MyDuelManeuversRow from "./my_duel_maneuvers_table"; +import MyDuelRoundLogsRow from "./my_duel_round_logs_table"; +import MyDuelsRow from "./my_duels_table"; +import MyLobbyRatingsRow from "./my_lobby_ratings_table"; +import MyLobbyRoomSeatsRow from "./my_lobby_room_seats_table"; +import MyLobbyRoomsRow from "./my_lobby_rooms_table"; +import MyLobbyTicketsRow from "./my_lobby_tickets_table"; +import MyProfileRow from "./my_profile_table"; +import PlayersRow from "./players_table"; +import ShipCatalogRow from "./ship_catalog_table"; + +// Import namespace table schema definitions +import Lobby_LobbyAdminMatchResultsRow from "./lobby/lobby_admin_match_results_table"; +import Lobby_LobbyAdminRoomSeatsRow from "./lobby/lobby_admin_room_seats_table"; +import Lobby_LobbyAdminRoomsRow from "./lobby/lobby_admin_rooms_table"; +import Lobby_LobbyAdminTicketsRow from "./lobby/lobby_admin_tickets_table"; +import Lobby_LobbyQueueSummaryRow from "./lobby/lobby_queue_summary_table"; +import Lobby_LobbyRankedLeaderboardRow from "./lobby/lobby_ranked_leaderboard_table"; +import Lobby_MyLobbyRatingsRow from "./lobby/my_lobby_ratings_table"; +import Lobby_MyLobbyRoomSeatsRow from "./lobby/my_lobby_room_seats_table"; +import Lobby_MyLobbyRoomsRow from "./lobby/my_lobby_rooms_table"; +import Lobby_MyLobbyTicketsRow from "./lobby/my_lobby_tickets_table"; + +// Import namespace reducer arg schemas +import Lobby_AddAdminIdentityReducer from "./lobby/add_admin_identity_reducer"; +import Lobby_CancelTicketReducer from "./lobby/cancel_ticket_reducer"; +import Lobby_CloseRoomReducer from "./lobby/close_room_reducer"; +import Lobby_ExpireTicketsReducer from "./lobby/expire_tickets_reducer"; +import Lobby_JoinQueueReducer from "./lobby/join_queue_reducer"; +import Lobby_JoinRankedQueueReducer from "./lobby/join_ranked_queue_reducer"; +import Lobby_JoinRoomReducer from "./lobby/join_room_reducer"; +import Lobby_LeaveRoomReducer from "./lobby/leave_room_reducer"; +import Lobby_RemoveAdminIdentityReducer from "./lobby/remove_admin_identity_reducer"; +import Lobby_SetRatingReducer from "./lobby/set_rating_reducer"; +import Lobby_UpdateConfigReducer from "./lobby/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Lobby_GetLobbyStatusProcedure from "./lobby/get_lobby_status_procedure"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + maneuverCatalog: __table({ + name: 'maneuver_catalog', + indexes: [ + { accessor: 'maneuverId', name: 'maneuver_catalog_maneuver_id_idx_btree', algorithm: 'btree', columns: [ + 'maneuverId', + ] }, + { accessor: 'byShipClass', name: 'maneuver_catalog_ship_class_idx_btree', algorithm: 'btree', columns: [ + 'shipClass', + ] }, + { accessor: 'bySlot', name: 'maneuver_catalog_slot_idx_btree', algorithm: 'btree', columns: [ + 'slot', + ] }, + ], + constraints: [ + { name: 'maneuver_catalog_maneuver_id_key', constraint: 'unique', columns: ['maneuverId'] }, + ], + }, ManeuverCatalogRow), + shipCatalog: __table({ + name: 'ship_catalog', + indexes: [ + { accessor: 'byShipClass', name: 'ship_catalog_ship_class_idx_btree', algorithm: 'btree', columns: [ + 'shipClass', + ] }, + { accessor: 'shipId', name: 'ship_catalog_ship_id_idx_btree', algorithm: 'btree', columns: [ + 'shipId', + ] }, + ], + constraints: [ + { name: 'ship_catalog_ship_id_key', constraint: 'unique', columns: ['shipId'] }, + ], + }, ShipCatalogRow), + lobbyQueueSummary: __table({ + name: 'lobby_queue_summary', + indexes: [ + ], + constraints: [ + ], + }, LobbyQueueSummaryRow), + lobbyRankedLeaderboard: __table({ + name: 'lobby_ranked_leaderboard', + indexes: [ + ], + constraints: [ + ], + }, LobbyRankedLeaderboardRow), + myDuelCombatants: __table({ + name: 'my_duel_combatants', + indexes: [ + ], + constraints: [ + ], + }, MyDuelCombatantsRow), + myDuelManeuvers: __table({ + name: 'my_duel_maneuvers', + indexes: [ + ], + constraints: [ + ], + }, MyDuelManeuversRow), + myDuelRoundLogs: __table({ + name: 'my_duel_round_logs', + indexes: [ + ], + constraints: [ + ], + }, MyDuelRoundLogsRow), + myDuels: __table({ + name: 'my_duels', + indexes: [ + ], + constraints: [ + ], + }, MyDuelsRow), + myLobbyRatings: __table({ + name: 'my_lobby_ratings', + indexes: [ + ], + constraints: [ + ], + }, MyLobbyRatingsRow), + myLobbyRoomSeats: __table({ + name: 'my_lobby_room_seats', + indexes: [ + ], + constraints: [ + ], + }, MyLobbyRoomSeatsRow), + myLobbyRooms: __table({ + name: 'my_lobby_rooms', + indexes: [ + ], + constraints: [ + ], + }, MyLobbyRoomsRow), + myLobbyTickets: __table({ + name: 'my_lobby_tickets', + indexes: [ + ], + constraints: [ + ], + }, MyLobbyTicketsRow), + myProfile: __table({ + name: 'my_profile', + indexes: [ + ], + constraints: [ + ], + }, MyProfileRow), + players: __table({ + name: 'players', + indexes: [ + ], + constraints: [ + ], + }, PlayersRow), + "lobby.lobby_admin_match_results": __table({ + name: 'lobby.lobby_admin_match_results', + indexes: [ + ], + constraints: [ + ], + }, Lobby_LobbyAdminMatchResultsRow), + "lobby.lobby_admin_room_seats": __table({ + name: 'lobby.lobby_admin_room_seats', + indexes: [ + ], + constraints: [ + ], + }, Lobby_LobbyAdminRoomSeatsRow), + "lobby.lobby_admin_rooms": __table({ + name: 'lobby.lobby_admin_rooms', + indexes: [ + ], + constraints: [ + ], + }, Lobby_LobbyAdminRoomsRow), + "lobby.lobby_admin_tickets": __table({ + name: 'lobby.lobby_admin_tickets', + indexes: [ + ], + constraints: [ + ], + }, Lobby_LobbyAdminTicketsRow), + "lobby.lobby_queue_summary": __table({ + name: 'lobby.lobby_queue_summary', + indexes: [ + ], + constraints: [ + ], + }, Lobby_LobbyQueueSummaryRow), + "lobby.lobby_ranked_leaderboard": __table({ + name: 'lobby.lobby_ranked_leaderboard', + indexes: [ + ], + constraints: [ + ], + }, Lobby_LobbyRankedLeaderboardRow), + "lobby.my_lobby_ratings": __table({ + name: 'lobby.my_lobby_ratings', + indexes: [ + ], + constraints: [ + ], + }, Lobby_MyLobbyRatingsRow), + "lobby.my_lobby_room_seats": __table({ + name: 'lobby.my_lobby_room_seats', + indexes: [ + ], + constraints: [ + ], + }, Lobby_MyLobbyRoomSeatsRow), + "lobby.my_lobby_rooms": __table({ + name: 'lobby.my_lobby_rooms', + indexes: [ + ], + constraints: [ + ], + }, Lobby_MyLobbyRoomsRow), + "lobby.my_lobby_tickets": __table({ + name: 'lobby.my_lobby_tickets', + indexes: [ + ], + constraints: [ + ], + }, Lobby_MyLobbyTicketsRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("advance_duel", AdvanceDuelReducer), + __reducerSchema("choose_maneuver", ChooseManeuverReducer), + __reducerSchema("fallback_to_ai", FallbackToAiReducer), + __reducerSchema("find_duel", FindDuelReducer), + __reducerSchema("join_duel_room", JoinDuelRoomReducer), + __reducerSchema("leave_duel", LeaveDuelReducer), + __reducerSchema("queue_again", QueueAgainReducer), + __reducerSchema("select_ship", SelectShipReducer), + __reducerSchema("set_display_name", SetDisplayNameReducer), + __reducerSchema("lobby.add_admin_identity", Lobby_AddAdminIdentityReducer), + __reducerSchema("lobby.cancel_ticket", Lobby_CancelTicketReducer), + __reducerSchema("lobby.close_room", Lobby_CloseRoomReducer), + __reducerSchema("lobby.expire_tickets", Lobby_ExpireTicketsReducer), + __reducerSchema("lobby.join_queue", Lobby_JoinQueueReducer), + __reducerSchema("lobby.join_ranked_queue", Lobby_JoinRankedQueueReducer), + __reducerSchema("lobby.join_room", Lobby_JoinRoomReducer), + __reducerSchema("lobby.leave_room", Lobby_LeaveRoomReducer), + __reducerSchema("lobby.remove_admin_identity", Lobby_RemoveAdminIdentityReducer), + __reducerSchema("lobby.set_rating", Lobby_SetRatingReducer), + __reducerSchema("lobby.update_config", Lobby_UpdateConfigReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("lobby.get_lobby_status", Lobby_GetLobbyStatusProcedure.params, Lobby_GetLobbyStatusProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + maneuverCatalog: __qb.maneuverCatalog, + shipCatalog: __qb.shipCatalog, + lobbyQueueSummary: __qb.lobbyQueueSummary, + lobbyRankedLeaderboard: __qb.lobbyRankedLeaderboard, + myDuelCombatants: __qb.myDuelCombatants, + myDuelManeuvers: __qb.myDuelManeuvers, + myDuelRoundLogs: __qb.myDuelRoundLogs, + myDuels: __qb.myDuels, + myLobbyRatings: __qb.myLobbyRatings, + myLobbyRoomSeats: __qb.myLobbyRoomSeats, + myLobbyRooms: __qb.myLobbyRooms, + myLobbyTickets: __qb.myLobbyTickets, + myProfile: __qb.myProfile, + players: __qb.players, + lobby: { + lobbyAdminMatchResults: __qb["lobby.lobby_admin_match_results"], + lobbyAdminRoomSeats: __qb["lobby.lobby_admin_room_seats"], + lobbyAdminRooms: __qb["lobby.lobby_admin_rooms"], + lobbyAdminTickets: __qb["lobby.lobby_admin_tickets"], + lobbyQueueSummary: __qb["lobby.lobby_queue_summary"], + lobbyRankedLeaderboard: __qb["lobby.lobby_ranked_leaderboard"], + myLobbyRatings: __qb["lobby.my_lobby_ratings"], + myLobbyRoomSeats: __qb["lobby.my_lobby_room_seats"], + myLobbyRooms: __qb["lobby.my_lobby_rooms"], + myLobbyTickets: __qb["lobby.my_lobby_tickets"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + advanceDuel: __reducerAccessors.advanceDuel, + chooseManeuver: __reducerAccessors.chooseManeuver, + fallbackToAi: __reducerAccessors.fallbackToAi, + findDuel: __reducerAccessors.findDuel, + joinDuelRoom: __reducerAccessors.joinDuelRoom, + leaveDuel: __reducerAccessors.leaveDuel, + queueAgain: __reducerAccessors.queueAgain, + selectShip: __reducerAccessors.selectShip, + setDisplayName: __reducerAccessors.setDisplayName, + lobby: { + addAdminIdentity: __reducerAccessors["lobby.addAdminIdentity"], + cancelTicket: __reducerAccessors["lobby.cancelTicket"], + closeRoom: __reducerAccessors["lobby.closeRoom"], + expireTickets: __reducerAccessors["lobby.expireTickets"], + joinQueue: __reducerAccessors["lobby.joinQueue"], + joinRankedQueue: __reducerAccessors["lobby.joinRankedQueue"], + joinRoom: __reducerAccessors["lobby.joinRoom"], + leaveRoom: __reducerAccessors["lobby.leaveRoom"], + removeAdminIdentity: __reducerAccessors["lobby.removeAdminIdentity"], + setRating: __reducerAccessors["lobby.setRating"], + updateConfig: __reducerAccessors["lobby.updateConfig"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + lobby: { + getLobbyStatus: __procedureAccessors["lobby.getLobbyStatus"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-lobby-ts/example/src/module_bindings/join_duel_room_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/join_duel_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/join_duel_room_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/leave_duel_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/leave_duel_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/leave_duel_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/add_admin_identity_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/add_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/add_admin_identity_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/cancel_ticket_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/cancel_ticket_reducer.ts new file mode 100644 index 00000000000..2098bfc3542 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/cancel_ticket_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + ticketId: __t.string(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/close_room_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/close_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/close_room_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/expire_tickets_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/expire_tickets_reducer.ts new file mode 100644 index 00000000000..dc602ed6dc0 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/expire_tickets_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + limit: __t.option(__t.u32()), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/get_lobby_status_procedure.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/get_lobby_status_procedure.ts new file mode 100644 index 00000000000..d6933140f3b --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/get_lobby_status_procedure.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/join_queue_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/join_queue_reducer.ts new file mode 100644 index 00000000000..4490f6c1830 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/join_queue_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + pool: __t.string(), + matchSize: __t.u32(), + attributesJson: __t.option(__t.string()), + ttlSeconds: __t.option(__t.u32()), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/join_ranked_queue_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/join_ranked_queue_reducer.ts new file mode 100644 index 00000000000..33f6e61037c --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/join_ranked_queue_reducer.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + pool: __t.string(), + matchSize: __t.u32(), + attributesJson: __t.option(__t.string()), + ttlSeconds: __t.option(__t.u32()), + ratingPool: __t.option(__t.string()), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/join_room_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/join_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/join_room_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/leave_room_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/leave_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/leave_room_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_match_results_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_match_results_table.ts new file mode 100644 index 00000000000..8b3e65b81d5 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_match_results_table.ts @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + resultId: __t.u64().primaryKey().name("result_id"), + roomId: __t.u64().name("room_id"), + pool: __t.string(), + winnerSubject: __t.option(__t.string()).name("winner_subject"), + loserSubject: __t.option(__t.string()).name("loser_subject"), + subjectA: __t.string().name("subject_a"), + subjectB: __t.string().name("subject_b"), + ratingABefore: __t.i32().name("rating_a_before"), + ratingAAfter: __t.i32().name("rating_a_after"), + ratingBBefore: __t.i32().name("rating_b_before"), + ratingBAfter: __t.i32().name("rating_b_after"), + reportedAt: __t.timestamp().name("reported_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_room_seats_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_room_seats_table.ts new file mode 100644 index 00000000000..089ec4f3f16 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_room_seats_table.ts @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + LobbySeatStatus, +} from "./types"; + + +export default __t.row({ + seatId: __t.u64().primaryKey().name("seat_id"), + roomId: __t.u64().name("room_id"), + subject: __t.string(), + ticketId: __t.option(__t.string()).name("ticket_id"), + seatIndex: __t.u32().name("seat_index"), + get status() { + return LobbySeatStatus; + }, + ready: __t.bool(), + joinedAt: __t.option(__t.timestamp()).name("joined_at"), + leftAt: __t.option(__t.timestamp()).name("left_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_rooms_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_rooms_table.ts new file mode 100644 index 00000000000..1ba9b3b5490 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_rooms_table.ts @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + LobbyRoomStatus, +} from "./types"; + + +export default __t.row({ + roomId: __t.u64().primaryKey().name("room_id"), + pool: __t.string(), + get status() { + return LobbyRoomStatus; + }, + capacity: __t.u32(), + metadataJson: __t.option(__t.string()).name("metadata_json"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + closedAt: __t.option(__t.timestamp()).name("closed_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_tickets_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_tickets_table.ts new file mode 100644 index 00000000000..e6e4c70d96f --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_tickets_table.ts @@ -0,0 +1,34 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + LobbyTicketStatus, +} from "./types"; + + +export default __t.row({ + ticketId: __t.string().primaryKey().name("ticket_id"), + pool: __t.string(), + subject: __t.string(), + get status() { + return LobbyTicketStatus; + }, + matchSize: __t.u32().name("match_size"), + ranked: __t.bool(), + rating: __t.option(__t.i32()), + ratingPool: __t.option(__t.string()).name("rating_pool"), + partyId: __t.option(__t.string()).name("party_id"), + attributesJson: __t.option(__t.string()).name("attributes_json"), + roomId: __t.option(__t.u64()).name("room_id"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + expiresAtMicros: __t.i64().name("expires_at_micros"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_queue_summary_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_queue_summary_table.ts new file mode 100644 index 00000000000..1218594a764 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_queue_summary_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + pool: __t.string(), + queuedTickets: __t.u32().name("queued_tickets"), + readyRooms: __t.u32().name("ready_rooms"), + activeRooms: __t.u32().name("active_rooms"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_ranked_leaderboard_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_ranked_leaderboard_table.ts new file mode 100644 index 00000000000..1c44ef6d4ee --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_ranked_leaderboard_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_ratings_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_ratings_table.ts new file mode 100644 index 00000000000..1c44ef6d4ee --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_ratings_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_room_seats_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_room_seats_table.ts new file mode 100644 index 00000000000..089ec4f3f16 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_room_seats_table.ts @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + LobbySeatStatus, +} from "./types"; + + +export default __t.row({ + seatId: __t.u64().primaryKey().name("seat_id"), + roomId: __t.u64().name("room_id"), + subject: __t.string(), + ticketId: __t.option(__t.string()).name("ticket_id"), + seatIndex: __t.u32().name("seat_index"), + get status() { + return LobbySeatStatus; + }, + ready: __t.bool(), + joinedAt: __t.option(__t.timestamp()).name("joined_at"), + leftAt: __t.option(__t.timestamp()).name("left_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_rooms_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_rooms_table.ts new file mode 100644 index 00000000000..1ba9b3b5490 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_rooms_table.ts @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + LobbyRoomStatus, +} from "./types"; + + +export default __t.row({ + roomId: __t.u64().primaryKey().name("room_id"), + pool: __t.string(), + get status() { + return LobbyRoomStatus; + }, + capacity: __t.u32(), + metadataJson: __t.option(__t.string()).name("metadata_json"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + closedAt: __t.option(__t.timestamp()).name("closed_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_tickets_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_tickets_table.ts new file mode 100644 index 00000000000..e6e4c70d96f --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_tickets_table.ts @@ -0,0 +1,34 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + LobbyTicketStatus, +} from "./types"; + + +export default __t.row({ + ticketId: __t.string().primaryKey().name("ticket_id"), + pool: __t.string(), + subject: __t.string(), + get status() { + return LobbyTicketStatus; + }, + matchSize: __t.u32().name("match_size"), + ranked: __t.bool(), + rating: __t.option(__t.i32()), + ratingPool: __t.option(__t.string()).name("rating_pool"), + partyId: __t.option(__t.string()).name("party_id"), + attributesJson: __t.option(__t.string()).name("attributes_json"), + roomId: __t.option(__t.u64()).name("room_id"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + expiresAtMicros: __t.i64().name("expires_at_micros"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/remove_admin_identity_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/remove_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/remove_admin_identity_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/set_rating_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/set_rating_reducer.ts new file mode 100644 index 00000000000..8a53e81760f --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/set_rating_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/types.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/types.ts new file mode 100644 index 00000000000..243ba354994 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/types.ts @@ -0,0 +1,182 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const LobbyAdminIdentity = __t.object("LobbyAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type LobbyAdminIdentity = __Infer; + +export const LobbyAdminMatchResults = __t.object("LobbyAdminMatchResults", {}); +export type LobbyAdminMatchResults = __Infer; + +export const LobbyAdminRoomSeats = __t.object("LobbyAdminRoomSeats", {}); +export type LobbyAdminRoomSeats = __Infer; + +export const LobbyAdminRooms = __t.object("LobbyAdminRooms", {}); +export type LobbyAdminRooms = __Infer; + +export const LobbyAdminTickets = __t.object("LobbyAdminTickets", {}); +export type LobbyAdminTickets = __Infer; + +export const LobbyConfig = __t.object("LobbyConfig", { + singleton: __t.bool(), + defaultTicketTtlSeconds: __t.u32(), + maxMatchSize: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type LobbyConfig = __Infer; + +export const LobbyMatchResult = __t.object("LobbyMatchResult", { + resultId: __t.u64(), + roomId: __t.u64(), + pool: __t.string(), + winnerSubject: __t.option(__t.string()), + loserSubject: __t.option(__t.string()), + subjectA: __t.string(), + subjectB: __t.string(), + ratingABefore: __t.i32(), + ratingAAfter: __t.i32(), + ratingBBefore: __t.i32(), + ratingBAfter: __t.i32(), + reportedAt: __t.timestamp(), +}); +export type LobbyMatchResult = __Infer; + +export const LobbyQueueSummary = __t.object("LobbyQueueSummary", {}); +export type LobbyQueueSummary = __Infer; + +export const LobbyQueueSummaryRow = __t.object("LobbyQueueSummaryRow", { + pool: __t.string(), + queuedTickets: __t.u32(), + readyRooms: __t.u32(), + activeRooms: __t.u32(), +}); +export type LobbyQueueSummaryRow = __Infer; + +export const LobbyQueueTicket = __t.object("LobbyQueueTicket", { + ticketId: __t.string(), + pool: __t.string(), + subject: __t.string(), + get status() { + return LobbyTicketStatus; + }, + matchSize: __t.u32(), + ranked: __t.bool(), + rating: __t.option(__t.i32()), + ratingPool: __t.option(__t.string()), + partyId: __t.option(__t.string()), + attributesJson: __t.option(__t.string()), + roomId: __t.option(__t.u64()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + expiresAtMicros: __t.i64(), +}); +export type LobbyQueueTicket = __Infer; + +export const LobbyRankedLeaderboard = __t.object("LobbyRankedLeaderboard", {}); +export type LobbyRankedLeaderboard = __Infer; + +export const LobbyRankedRatingRow = __t.object("LobbyRankedRatingRow", { + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), +}); +export type LobbyRankedRatingRow = __Infer; + +export const LobbyRoom = __t.object("LobbyRoom", { + roomId: __t.u64(), + pool: __t.string(), + get status() { + return LobbyRoomStatus; + }, + capacity: __t.u32(), + metadataJson: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + closedAt: __t.option(__t.timestamp()), +}); +export type LobbyRoom = __Infer; + +export const LobbyRoomSeat = __t.object("LobbyRoomSeat", { + seatId: __t.u64(), + roomId: __t.u64(), + subject: __t.string(), + ticketId: __t.option(__t.string()), + seatIndex: __t.u32(), + get status() { + return LobbySeatStatus; + }, + ready: __t.bool(), + joinedAt: __t.option(__t.timestamp()), + leftAt: __t.option(__t.timestamp()), + updatedAt: __t.timestamp(), +}); +export type LobbyRoomSeat = __Infer; + +// The tagged union or sum type for the algebraic type `LobbyRoomStatus`. +export const LobbyRoomStatus = __t.enum("LobbyRoomStatus", { + Ready: __t.unit(), + Active: __t.unit(), + Closed: __t.unit(), + Abandoned: __t.unit(), +}); +export type LobbyRoomStatus = __Infer; + +// The tagged union or sum type for the algebraic type `LobbySeatStatus`. +export const LobbySeatStatus = __t.enum("LobbySeatStatus", { + Reserved: __t.unit(), + Joined: __t.unit(), + Left: __t.unit(), + Disconnected: __t.unit(), +}); +export type LobbySeatStatus = __Infer; + +export const LobbySubjectRating = __t.object("LobbySubjectRating", { + ratingId: __t.string(), + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + ratingOrder: __t.i64(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type LobbySubjectRating = __Infer; + +// The tagged union or sum type for the algebraic type `LobbyTicketStatus`. +export const LobbyTicketStatus = __t.enum("LobbyTicketStatus", { + Queued: __t.unit(), + Matched: __t.unit(), + Cancelled: __t.unit(), + Expired: __t.unit(), +}); +export type LobbyTicketStatus = __Infer; + +export const MyLobbyRatings = __t.object("MyLobbyRatings", {}); +export type MyLobbyRatings = __Infer; + +export const MyLobbyRoomSeats = __t.object("MyLobbyRoomSeats", {}); +export type MyLobbyRoomSeats = __Infer; + +export const MyLobbyRooms = __t.object("MyLobbyRooms", {}); +export type MyLobbyRooms = __Infer; + +export const MyLobbyTickets = __t.object("MyLobbyTickets", {}); +export type MyLobbyTickets = __Infer; + diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/update_config_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/update_config_reducer.ts new file mode 100644 index 00000000000..0f85a99be65 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/update_config_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + defaultTicketTtlSeconds: __t.u32(), + maxMatchSize: __t.u32(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby_queue_summary_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby_queue_summary_table.ts new file mode 100644 index 00000000000..1218594a764 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby_queue_summary_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + pool: __t.string(), + queuedTickets: __t.u32().name("queued_tickets"), + readyRooms: __t.u32().name("ready_rooms"), + activeRooms: __t.u32().name("active_rooms"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby_ranked_leaderboard_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby_ranked_leaderboard_table.ts new file mode 100644 index 00000000000..1c44ef6d4ee --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby_ranked_leaderboard_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/maneuver_catalog_table.ts b/spacetime-lobby-ts/example/src/module_bindings/maneuver_catalog_table.ts new file mode 100644 index 00000000000..1e5b31d4193 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/maneuver_catalog_table.ts @@ -0,0 +1,34 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + ShipClass, + ManeuverSlot, +} from "./types"; + + +export default __t.row({ + maneuverId: __t.string().primaryKey().name("maneuver_id"), + get shipClass() { + return ShipClass.name("ship_class"); + }, + get slot() { + return ManeuverSlot; + }, + name: __t.string(), + description: __t.string(), + damageBps: __t.i32().name("damage_bps"), + defenseBps: __t.i32().name("defense_bps"), + shieldRestore: __t.u32().name("shield_restore"), + selfShieldCost: __t.u32().name("self_shield_cost"), + critBonusBps: __t.i32().name("crit_bonus_bps"), + dodgeBonusBps: __t.i32().name("dodge_bonus_bps"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_duel_combatants_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_duel_combatants_table.ts new file mode 100644 index 00000000000..4ad0a77b6f1 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_duel_combatants_table.ts @@ -0,0 +1,35 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + ShipClass, +} from "./types"; + + +export default __t.row({ + combatantId: __t.string().primaryKey().name("combatant_id"), + roomId: __t.u64().name("room_id"), + subject: __t.string(), + displayName: __t.string().name("display_name"), + get shipClass() { + return ShipClass.name("ship_class"); + }, + hull: __t.u32(), + maxHull: __t.u32().name("max_hull"), + shields: __t.u32(), + maxShields: __t.u32().name("max_shields"), + attack: __t.u32(), + defense: __t.u32(), + speed: __t.u32(), + critBps: __t.u32().name("crit_bps"), + dodgeBps: __t.u32().name("dodge_bps"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_duel_maneuvers_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_duel_maneuvers_table.ts new file mode 100644 index 00000000000..94ec937c71c --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_duel_maneuvers_table.ts @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + ManeuverSlot, +} from "./types"; + + +export default __t.row({ + choiceId: __t.string().primaryKey().name("choice_id"), + roomId: __t.u64().name("room_id"), + round: __t.u32(), + subject: __t.string(), + get slot() { + return ManeuverSlot; + }, + maneuverId: __t.string().name("maneuver_id"), + chosenAt: __t.timestamp().name("chosen_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_duel_round_logs_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_duel_round_logs_table.ts new file mode 100644 index 00000000000..5ccc1d071be --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_duel_round_logs_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + logId: __t.u64().primaryKey().name("log_id"), + roomId: __t.u64().name("room_id"), + round: __t.u32(), + message: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_duels_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_duels_table.ts new file mode 100644 index 00000000000..2e5e82ff970 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_duels_table.ts @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + DuelStatus, +} from "./types"; + + +export default __t.row({ + roomId: __t.u64().primaryKey().name("room_id"), + get status() { + return DuelStatus; + }, + round: __t.u32(), + winnerSubject: __t.option(__t.string()).name("winner_subject"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_lobby_ratings_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_ratings_table.ts new file mode 100644 index 00000000000..1c44ef6d4ee --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_ratings_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_lobby_room_seats_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_room_seats_table.ts new file mode 100644 index 00000000000..089ec4f3f16 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_room_seats_table.ts @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + LobbySeatStatus, +} from "./types"; + + +export default __t.row({ + seatId: __t.u64().primaryKey().name("seat_id"), + roomId: __t.u64().name("room_id"), + subject: __t.string(), + ticketId: __t.option(__t.string()).name("ticket_id"), + seatIndex: __t.u32().name("seat_index"), + get status() { + return LobbySeatStatus; + }, + ready: __t.bool(), + joinedAt: __t.option(__t.timestamp()).name("joined_at"), + leftAt: __t.option(__t.timestamp()).name("left_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_lobby_rooms_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_rooms_table.ts new file mode 100644 index 00000000000..1ba9b3b5490 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_rooms_table.ts @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + LobbyRoomStatus, +} from "./types"; + + +export default __t.row({ + roomId: __t.u64().primaryKey().name("room_id"), + pool: __t.string(), + get status() { + return LobbyRoomStatus; + }, + capacity: __t.u32(), + metadataJson: __t.option(__t.string()).name("metadata_json"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + closedAt: __t.option(__t.timestamp()).name("closed_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_lobby_tickets_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_tickets_table.ts new file mode 100644 index 00000000000..e6e4c70d96f --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_tickets_table.ts @@ -0,0 +1,34 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + LobbyTicketStatus, +} from "./types"; + + +export default __t.row({ + ticketId: __t.string().primaryKey().name("ticket_id"), + pool: __t.string(), + subject: __t.string(), + get status() { + return LobbyTicketStatus; + }, + matchSize: __t.u32().name("match_size"), + ranked: __t.bool(), + rating: __t.option(__t.i32()), + ratingPool: __t.option(__t.string()).name("rating_pool"), + partyId: __t.option(__t.string()).name("party_id"), + attributesJson: __t.option(__t.string()).name("attributes_json"), + roomId: __t.option(__t.u64()).name("room_id"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + expiresAtMicros: __t.i64().name("expires_at_micros"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_profile_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_profile_table.ts new file mode 100644 index 00000000000..82ca37479f5 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_profile_table.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + ShipClass, +} from "./types"; + + +export default __t.row({ + subject: __t.string().primaryKey(), + displayName: __t.string().name("display_name"), + get shipClass() { + return ShipClass.name("ship_class"); + }, + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/players_table.ts b/spacetime-lobby-ts/example/src/module_bindings/players_table.ts new file mode 100644 index 00000000000..82ca37479f5 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/players_table.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + ShipClass, +} from "./types"; + + +export default __t.row({ + subject: __t.string().primaryKey(), + displayName: __t.string().name("display_name"), + get shipClass() { + return ShipClass.name("ship_class"); + }, + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/queue_again_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/queue_again_reducer.ts new file mode 100644 index 00000000000..7f866437244 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/queue_again_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.option(__t.u64()), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/select_ship_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/select_ship_reducer.ts new file mode 100644 index 00000000000..1531f694080 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/select_ship_reducer.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + ShipClass, +} from "./types"; + +export default { + get shipClass() { + return ShipClass; + }, +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/set_display_name_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/set_display_name_reducer.ts new file mode 100644 index 00000000000..547493ef073 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/set_display_name_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + displayName: __t.string(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/ship_catalog_table.ts b/spacetime-lobby-ts/example/src/module_bindings/ship_catalog_table.ts new file mode 100644 index 00000000000..8da9e44a661 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/ship_catalog_table.ts @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + ShipClass, +} from "./types"; + + +export default __t.row({ + shipId: __t.string().primaryKey().name("ship_id"), + get shipClass() { + return ShipClass.name("ship_class"); + }, + role: __t.string(), + description: __t.string(), + hull: __t.u32(), + shields: __t.u32(), + attack: __t.u32(), + defense: __t.u32(), + speed: __t.u32(), + critBps: __t.u32().name("crit_bps"), + dodgeBps: __t.u32().name("dodge_bps"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/types.ts b/spacetime-lobby-ts/example/src/module_bindings/types.ts new file mode 100644 index 00000000000..5ec51715acc --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/types.ts @@ -0,0 +1,272 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const Duel = __t.object("Duel", { + roomId: __t.u64(), + get status() { + return DuelStatus; + }, + round: __t.u32(), + winnerSubject: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Duel = __Infer; + +export const DuelCombatant = __t.object("DuelCombatant", { + combatantId: __t.string(), + roomId: __t.u64(), + subject: __t.string(), + displayName: __t.string(), + get shipClass() { + return ShipClass; + }, + hull: __t.u32(), + maxHull: __t.u32(), + shields: __t.u32(), + maxShields: __t.u32(), + attack: __t.u32(), + defense: __t.u32(), + speed: __t.u32(), + critBps: __t.u32(), + dodgeBps: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type DuelCombatant = __Infer; + +export const DuelManeuver = __t.object("DuelManeuver", { + choiceId: __t.string(), + roomId: __t.u64(), + round: __t.u32(), + subject: __t.string(), + get slot() { + return ManeuverSlot; + }, + maneuverId: __t.string(), + chosenAt: __t.timestamp(), +}); +export type DuelManeuver = __Infer; + +export const DuelRoundLog = __t.object("DuelRoundLog", { + logId: __t.u64(), + roomId: __t.u64(), + round: __t.u32(), + message: __t.string(), + createdAt: __t.timestamp(), +}); +export type DuelRoundLog = __Infer; + +// The tagged union or sum type for the algebraic type `DuelStatus`. +export const DuelStatus = __t.enum("DuelStatus", { + Configuring: __t.unit(), + Active: __t.unit(), + Complete: __t.unit(), + Abandoned: __t.unit(), +}); +export type DuelStatus = __Infer; + +export const ExampleLobbyQueueSummaryRow = __t.object("ExampleLobbyQueueSummaryRow", { + pool: __t.string(), + queuedTickets: __t.u32(), + readyRooms: __t.u32(), + activeRooms: __t.u32(), +}); +export type ExampleLobbyQueueSummaryRow = __Infer; + +export const ExampleLobbyRatingRow = __t.object("ExampleLobbyRatingRow", { + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), +}); +export type ExampleLobbyRatingRow = __Infer; + +export const LobbyQueueSummary = __t.object("LobbyQueueSummary", {}); +export type LobbyQueueSummary = __Infer; + +export const LobbyQueueTicket = __t.object("LobbyQueueTicket", { + ticketId: __t.string(), + pool: __t.string(), + subject: __t.string(), + get status() { + return LobbyTicketStatus; + }, + matchSize: __t.u32(), + ranked: __t.bool(), + rating: __t.option(__t.i32()), + ratingPool: __t.option(__t.string()), + partyId: __t.option(__t.string()), + attributesJson: __t.option(__t.string()), + roomId: __t.option(__t.u64()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + expiresAtMicros: __t.i64(), +}); +export type LobbyQueueTicket = __Infer; + +export const LobbyRankedLeaderboard = __t.object("LobbyRankedLeaderboard", {}); +export type LobbyRankedLeaderboard = __Infer; + +export const LobbyRoom = __t.object("LobbyRoom", { + roomId: __t.u64(), + pool: __t.string(), + get status() { + return LobbyRoomStatus; + }, + capacity: __t.u32(), + metadataJson: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + closedAt: __t.option(__t.timestamp()), +}); +export type LobbyRoom = __Infer; + +export const LobbyRoomSeat = __t.object("LobbyRoomSeat", { + seatId: __t.u64(), + roomId: __t.u64(), + subject: __t.string(), + ticketId: __t.option(__t.string()), + seatIndex: __t.u32(), + get status() { + return LobbySeatStatus; + }, + ready: __t.bool(), + joinedAt: __t.option(__t.timestamp()), + leftAt: __t.option(__t.timestamp()), + updatedAt: __t.timestamp(), +}); +export type LobbyRoomSeat = __Infer; + +// The tagged union or sum type for the algebraic type `LobbyRoomStatus`. +export const LobbyRoomStatus = __t.enum("LobbyRoomStatus", { + Ready: __t.unit(), + Active: __t.unit(), + Closed: __t.unit(), + Abandoned: __t.unit(), +}); +export type LobbyRoomStatus = __Infer; + +// The tagged union or sum type for the algebraic type `LobbySeatStatus`. +export const LobbySeatStatus = __t.enum("LobbySeatStatus", { + Reserved: __t.unit(), + Joined: __t.unit(), + Left: __t.unit(), + Disconnected: __t.unit(), +}); +export type LobbySeatStatus = __Infer; + +// The tagged union or sum type for the algebraic type `LobbyTicketStatus`. +export const LobbyTicketStatus = __t.enum("LobbyTicketStatus", { + Queued: __t.unit(), + Matched: __t.unit(), + Cancelled: __t.unit(), + Expired: __t.unit(), +}); +export type LobbyTicketStatus = __Infer; + +export const ManeuverCatalog = __t.object("ManeuverCatalog", { + maneuverId: __t.string(), + get shipClass() { + return ShipClass; + }, + get slot() { + return ManeuverSlot; + }, + name: __t.string(), + description: __t.string(), + damageBps: __t.i32(), + defenseBps: __t.i32(), + shieldRestore: __t.u32(), + selfShieldCost: __t.u32(), + critBonusBps: __t.i32(), + dodgeBonusBps: __t.i32(), +}); +export type ManeuverCatalog = __Infer; + +// The tagged union or sum type for the algebraic type `ManeuverSlot`. +export const ManeuverSlot = __t.enum("ManeuverSlot", { + Primary: __t.unit(), + Defensive: __t.unit(), + Risky: __t.unit(), +}); +export type ManeuverSlot = __Infer; + +export const MyDuelCombatants = __t.object("MyDuelCombatants", {}); +export type MyDuelCombatants = __Infer; + +export const MyDuelManeuvers = __t.object("MyDuelManeuvers", {}); +export type MyDuelManeuvers = __Infer; + +export const MyDuelRoundLogs = __t.object("MyDuelRoundLogs", {}); +export type MyDuelRoundLogs = __Infer; + +export const MyDuels = __t.object("MyDuels", {}); +export type MyDuels = __Infer; + +export const MyLobbyRatings = __t.object("MyLobbyRatings", {}); +export type MyLobbyRatings = __Infer; + +export const MyLobbyRoomSeats = __t.object("MyLobbyRoomSeats", {}); +export type MyLobbyRoomSeats = __Infer; + +export const MyLobbyRooms = __t.object("MyLobbyRooms", {}); +export type MyLobbyRooms = __Infer; + +export const MyLobbyTickets = __t.object("MyLobbyTickets", {}); +export type MyLobbyTickets = __Infer; + +export const MyProfile = __t.object("MyProfile", {}); +export type MyProfile = __Infer; + +export const Pilot = __t.object("Pilot", { + subject: __t.string(), + displayName: __t.string(), + get shipClass() { + return ShipClass; + }, + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Pilot = __Infer; + +export const Players = __t.object("Players", {}); +export type Players = __Infer; + +export const ShipCatalog = __t.object("ShipCatalog", { + shipId: __t.string(), + get shipClass() { + return ShipClass; + }, + role: __t.string(), + description: __t.string(), + hull: __t.u32(), + shields: __t.u32(), + attack: __t.u32(), + defense: __t.u32(), + speed: __t.u32(), + critBps: __t.u32(), + dodgeBps: __t.u32(), +}); +export type ShipCatalog = __Infer; + +// The tagged union or sum type for the algebraic type `ShipClass`. +export const ShipClass = __t.enum("ShipClass", { + Bulwark: __t.unit(), + Interceptor: __t.unit(), + Phantom: __t.unit(), + Artillery: __t.unit(), +}); +export type ShipClass = __Infer; + diff --git a/spacetime-lobby-ts/example/src/module_bindings/types/procedures.ts b/spacetime-lobby-ts/example/src/module_bindings/types/procedures.ts new file mode 100644 index 00000000000..d5ac825c9ab --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/types/procedures.ts @@ -0,0 +1,10 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas + + diff --git a/spacetime-lobby-ts/example/src/module_bindings/types/reducers.ts b/spacetime-lobby-ts/example/src/module_bindings/types/reducers.ts new file mode 100644 index 00000000000..e99c2f4fd37 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/types/reducers.ts @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import AdvanceDuelReducer from "../advance_duel_reducer"; +import ChooseManeuverReducer from "../choose_maneuver_reducer"; +import FallbackToAiReducer from "../fallback_to_ai_reducer"; +import FindDuelReducer from "../find_duel_reducer"; +import JoinDuelRoomReducer from "../join_duel_room_reducer"; +import LeaveDuelReducer from "../leave_duel_reducer"; +import QueueAgainReducer from "../queue_again_reducer"; +import SelectShipReducer from "../select_ship_reducer"; +import SetDisplayNameReducer from "../set_display_name_reducer"; + +export type AdvanceDuelParams = __Infer; +export type ChooseManeuverParams = __Infer; +export type FallbackToAiParams = __Infer; +export type FindDuelParams = __Infer; +export type JoinDuelRoomParams = __Infer; +export type LeaveDuelParams = __Infer; +export type QueueAgainParams = __Infer; +export type SelectShipParams = __Infer; +export type SetDisplayNameParams = __Infer; + diff --git a/spacetime-lobby-ts/example/tsconfig.json b/spacetime-lobby-ts/example/tsconfig.json new file mode 100644 index 00000000000..1e69312d24d --- /dev/null +++ b/spacetime-lobby-ts/example/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-lobby-ts/package.json b/spacetime-lobby-ts/package.json new file mode 100644 index 00000000000..3b2e0b2f0ec --- /dev/null +++ b/spacetime-lobby-ts/package.json @@ -0,0 +1,65 @@ +{ + "name": "@spacetimedb/lobby", + "description": "Queueing, room assignment, ranking, and match result primitives for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-lobby-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-lobby-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "lobby", + "matchmaking", + "typescript" + ], + "scripts": { + "build": "spacetime build", + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts", + "spacetime:generate": "spacetime generate --lang typescript --out-dir ts-codegen", + "publish:module": "spacetime publish", + "publish:local": "spacetime publish --server local --yes spacetime-lobby", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-lobby" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^22.10.2", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-lobby-ts/scripts/test.ts b/spacetime-lobby-ts/scripts/test.ts new file mode 100644 index 00000000000..f8f9f0681ca --- /dev/null +++ b/spacetime-lobby-ts/scripts/test.ts @@ -0,0 +1,36 @@ +import * as assert from 'node:assert/strict'; +import { + expectedScore, + rankedBand, + rankedSelection, + updatedRating, +} from '../src/matchmaking.ts'; +import { lobbyCompositeKey } from '../src/keys.ts'; + +assert.equal(expectedScore(1000, 1000), 0.5); +assert.ok(expectedScore(1200, 1000) > 0.75); +assert.equal(updatedRating(1000, 0.5, 1), 1016); +assert.equal(updatedRating(1000, 0.5, 0), 984); +assert.equal(updatedRating(100, 1, 0), 100); +assert.equal(updatedRating(5000, 0, 1), 5000); + +const at = (microsSinceUnixEpoch: bigint) => ({ microsSinceUnixEpoch }); +assert.equal(rankedBand({ createdAt: at(100_000_000n) }, 100_000_000n), 100); +assert.equal(rankedBand({ createdAt: at(0n) }, 20_000_000n), 200); +assert.equal(rankedBand({ createdAt: at(0n) }, 1_000_000_000n), 800); + +const queue = [ + { ticketId: 'a', rating: 1000, ratingPool: 'ranked', createdAt: at(0n) }, + { ticketId: 'b', rating: 1080, ratingPool: 'ranked', createdAt: at(1n) }, + { ticketId: 'c', rating: 1400, ratingPool: 'ranked', createdAt: at(2n) }, +]; +assert.deepEqual( + rankedSelection(queue, 2, 5_000_000n)?.map(ticket => ticket.ticketId), + ['a', 'b'] +); +assert.equal(rankedSelection(queue, 3, 5_000_000n), undefined); + +assert.notEqual(lobbyCompositeKey('a:b', 'c'), lobbyCompositeKey('a', 'b:c')); +assert.equal(lobbyCompositeKey('ranked', 'player-1'), '6:ranked8:player-1'); + +console.log('lobby tests passed'); diff --git a/spacetime-lobby-ts/src/index.ts b/spacetime-lobby-ts/src/index.ts new file mode 100644 index 00000000000..d6be0038edf --- /dev/null +++ b/spacetime-lobby-ts/src/index.ts @@ -0,0 +1,25 @@ +export { default, init } from './submodule/schema'; +export { + add_admin_identity, + cancel_ticket, + close_room, + expire_tickets, + get_lobby_status, + join_ranked_queue, + join_queue, + join_room, + leave_room, + lobbyAdminMatchResults, + lobbyAdminRoomSeats, + lobbyAdminRooms, + lobbyAdminTickets, + lobbyQueueSummary, + lobbyRankedLeaderboard, + myLobbyRatings, + myLobbyRoomSeats, + myLobbyRooms, + myLobbyTickets, + remove_admin_identity, + set_rating, + update_config, +} from './submodule/operations'; diff --git a/spacetime-lobby-ts/src/keys.ts b/spacetime-lobby-ts/src/keys.ts new file mode 100644 index 00000000000..53c4b347eea --- /dev/null +++ b/spacetime-lobby-ts/src/keys.ts @@ -0,0 +1,3 @@ +export function lobbyCompositeKey(...parts: string[]): string { + return parts.map(part => `${part.length}:${part}`).join(''); +} diff --git a/spacetime-lobby-ts/src/matchmaking.ts b/spacetime-lobby-ts/src/matchmaking.ts new file mode 100644 index 00000000000..57d32c858e9 --- /dev/null +++ b/spacetime-lobby-ts/src/matchmaking.ts @@ -0,0 +1,67 @@ +export const DEFAULT_RATING = 1000; +export const MIN_RATING = 100; +export const MAX_RATING = 5000; + +const ELO_K = 32; +const RANKED_INITIAL_BAND = 100; +const RANKED_BAND_STEP = 50; +const RANKED_BAND_STEP_SECONDS = 10n; +const RANKED_MAX_BAND = 800; + +type RankedTicket = { + rating?: number | undefined; + ratingPool?: string | undefined; + createdAt: { microsSinceUnixEpoch: bigint }; +}; + +export function rankedBand(ticket: RankedTicket, now: bigint): number { + const waitedSeconds = + ticket.createdAt.microsSinceUnixEpoch >= now + ? 0n + : (now - ticket.createdAt.microsSinceUnixEpoch) / 1_000_000n; + const extra = + Number(waitedSeconds / RANKED_BAND_STEP_SECONDS) * RANKED_BAND_STEP; + return Math.min(RANKED_MAX_BAND, RANKED_INITIAL_BAND + extra); +} + +export function rankedSelection( + queued: T[], + matchSize: number, + now: bigint +): T[] | undefined { + for (const anchor of queued) { + const anchorRating = anchor.rating ?? DEFAULT_RATING; + const band = rankedBand(anchor, now); + const candidates = queued + .filter( + ticket => + Math.abs((ticket.rating ?? DEFAULT_RATING) - anchorRating) <= band + ) + .filter(ticket => ticket.ratingPool === anchor.ratingPool) + .sort((a, b) => { + const ar = Math.abs((a.rating ?? DEFAULT_RATING) - anchorRating); + const br = Math.abs((b.rating ?? DEFAULT_RATING) - anchorRating); + if (ar !== br) return ar - br; + const av = a.createdAt.microsSinceUnixEpoch; + const bv = b.createdAt.microsSinceUnixEpoch; + return av < bv ? -1 : av > bv ? 1 : 0; + }); + if (candidates.length >= matchSize) return candidates.slice(0, matchSize); + } + return undefined; +} + +export function expectedScore(rating: number, opponentRating: number): number { + return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400)); +} + +export function updatedRating( + rating: number, + expected: number, + score: number +): number { + return Math.max( + MIN_RATING, + Math.min(MAX_RATING, Math.round(rating + ELO_K * (score - expected))) + ); +} diff --git a/spacetime-lobby-ts/src/submodule.ts b/spacetime-lobby-ts/src/submodule.ts new file mode 100644 index 00000000000..bb433d54e9e --- /dev/null +++ b/spacetime-lobby-ts/src/submodule.ts @@ -0,0 +1,56 @@ +export { default } from './submodule/schema'; +export { + RoomStatus, + SeatStatus, + TicketStatus, + lobbyAdminIdentity, + lobbyConfig, + lobbyMatchResult, + lobbyQueueTicket, + lobbyRoom, + lobbyRoomSeat, + lobbySubjectRating, + roomStatus, + seatStatus, + t, + ticketStatus, +} from './submodule/schema'; +export { installLobby } from './submodule/install'; +export { + add_admin_identity, + cancelTicket, + cancel_ticket, + closeRoom, + close_room, + expire_tickets, + get_lobby_status, + joinRankedQueue, + joinQueue, + joinRoom, + join_ranked_queue, + join_queue, + join_room, + leaveRoom, + leave_room, + lobbyAdminMatchResults, + lobbyAdminRoomSeats, + lobbyAdminRooms, + lobbyAdminTickets, + lobbyQueueSummary, + lobbyRankedLeaderboard, + myLobbyRatings, + myLobbyRoomSeats, + myLobbyRooms, + myLobbyTickets, + remove_admin_identity, + reportMatchResult, + set_rating, + update_config, + type JoinQueueArgs, + type JoinRankedQueueArgs, + type JoinQueueResult, + type ReportMatchResultArgs, + type RoomSubjectArgs, + type SetRatingArgs, + type TicketSubjectArgs, +} from './submodule/operations'; diff --git a/spacetime-lobby-ts/src/submodule/install.ts b/spacetime-lobby-ts/src/submodule/install.ts new file mode 100644 index 00000000000..1cf53b55ca1 --- /dev/null +++ b/spacetime-lobby-ts/src/submodule/install.ts @@ -0,0 +1,22 @@ +import type { ReducerModuleCtx } from './schema'; + +const DEFAULT_TICKET_TTL_SECONDS = 60; +const DEFAULT_MAX_MATCH_SIZE = 16; + +export function installLobby(ctx: ReducerModuleCtx) { + if (ctx.db.lobbyConfig.singleton.find(true) == null) { + ctx.db.lobbyConfig.insert({ + singleton: true, + defaultTicketTtlSeconds: DEFAULT_TICKET_TTL_SECONDS, + maxMatchSize: DEFAULT_MAX_MATCH_SIZE, + updatedAt: ctx.timestamp, + }); + } + + if (ctx.db.lobbyAdminIdentity.identity.find(ctx.sender) == null) { + ctx.db.lobbyAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } +} diff --git a/spacetime-lobby-ts/src/submodule/operations.ts b/spacetime-lobby-ts/src/submodule/operations.ts new file mode 100644 index 00000000000..77fff54d43c --- /dev/null +++ b/spacetime-lobby-ts/src/submodule/operations.ts @@ -0,0 +1,814 @@ +import { Range, type Infer } from 'spacetimedb/server'; +import { + RoomStatus, + SeatStatus, + SenderError, + TicketStatus, + lobbyQueueTicket, + lobbyRoom, + lobbySubjectRating, + spacetimedb, + t, + type ProcedureModuleCtx, + type ViewModuleCtx, + type WriteCtx, +} from './schema'; +import { + DEFAULT_RATING, + MAX_RATING, + MIN_RATING, + expectedScore, + rankedSelection, + updatedRating, +} from '../matchmaking'; +import { lobbyCompositeKey } from '../keys'; + +const MAX_POOL_LENGTH = 96; +const MAX_SUBJECT_LENGTH = 160; +const MAX_JSON_LENGTH = 4096; +const DEFAULT_EXPIRE_LIMIT = 100; +const MAX_EXPIRE_LIMIT = 1000; +const MAX_MATCH_CANDIDATES = 5000; + +type QueueTicketRow = Infer; +type RoomRow = Infer; +type SubjectRatingRow = Infer; + +export type JoinQueueArgs = { + pool: string; + subject: string; + matchSize: number; + attributesJson?: string | undefined; + ttlSeconds?: number | undefined; +}; + +export type JoinRankedQueueArgs = JoinQueueArgs & { + ratingPool?: string | undefined; +}; + +export type TicketSubjectArgs = { + ticketId: string; + subject: string; +}; + +export type RoomSubjectArgs = { + roomId: bigint; + subject: string; +}; + +export type ReportMatchResultArgs = { + roomId: bigint; + subject: string; + winnerSubject?: string | undefined; +}; + +export type SetRatingArgs = { + pool: string; + subject: string; + rating: number; +}; + +export type JoinQueueResult = { + ticketId: string; + roomId?: bigint | undefined; +}; + +function fail(message: string): never { + throw new SenderError(`lobby.${message}`); +} + +function subjectForSender( + ctx: WriteCtx | ProcedureModuleCtx | ViewModuleCtx +): string { + return ctx.sender.toHexString(); +} + +function normalizeName(value: string, field: string, max: number): string { + const out = value.trim(); + if (!out) fail(`invalid_${field}`); + if (out.length > max) fail(`${field}_too_long`); + return out; +} + +function validateJson( + value: string | undefined, + field: string +): string | undefined { + if (value === undefined) return undefined; + const out = value.trim(); + if (!out) return undefined; + if (out.length > MAX_JSON_LENGTH) fail(`${field}_too_long`); + try { + const parsed = JSON.parse(out) as unknown; + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) + ) { + fail(`invalid_${field}_json`); + } + } catch { + fail(`invalid_${field}_json`); + } + return out; +} + +function nowMicros(ctx: WriteCtx): bigint { + return ctx.timestamp.microsSinceUnixEpoch; +} + +function ratingId(pool: string, subject: string): string { + return lobbyCompositeKey(pool, subject); +} + +function getRating( + ctx: WriteCtx | ViewModuleCtx, + pool: string, + subject: string +) { + return ctx.db.lobbySubjectRating.ratingId.find(ratingId(pool, subject)); +} + +function getOrCreateRating(ctx: WriteCtx, pool: string, subject: string) { + const existing = getRating(ctx, pool, subject); + if (existing) return existing; + const row = { + ratingId: ratingId(pool, subject), + pool, + subject, + rating: DEFAULT_RATING, + ratingOrder: BigInt(-DEFAULT_RATING), + wins: 0, + losses: 0, + draws: 0, + matches: 0, + updatedAt: ctx.timestamp, + }; + ctx.db.lobbySubjectRating.insert(row); + return row; +} + +function validateRating(value: number): number { + const rating = Math.trunc(value); + if (!Number.isInteger(rating) || rating < MIN_RATING || rating > MAX_RATING) { + fail('invalid_rating'); + } + return rating; +} + +function getConfig(ctx: WriteCtx) { + const existing = ctx.db.lobbyConfig.singleton.find(true); + if (existing) return existing; + const row = { + singleton: true, + defaultTicketTtlSeconds: 60, + maxMatchSize: 16, + updatedAt: ctx.timestamp, + }; + ctx.db.lobbyConfig.insert(row); + return row; +} + +function isAdmin(ctx: WriteCtx | ViewModuleCtx, sender = ctx.sender): boolean { + return ctx.db.lobbyAdminIdentity.identity.find(sender) != null; +} + +function requireAdmin(ctx: WriteCtx): void { + if (!isAdmin(ctx)) fail('not_authorized'); +} + +function isQueued(ticket: QueueTicketRow): boolean { + return ticket.status.tag === TicketStatus.Queued.tag; +} + +function isTerminalRoom(room: RoomRow): boolean { + return ( + room.status.tag === RoomStatus.Closed.tag || + room.status.tag === RoomStatus.Abandoned.tag + ); +} + +function take(rows: Iterable, limit: number): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +function countUpTo(rows: Iterable, limit: number): number { + let count = 0; + for (const _row of rows) { + if (count >= limit) break; + count++; + } + return count; +} + +function expireQueuedTickets(ctx: WriteCtx, limit: number): number { + const now = nowMicros(ctx); + let expired = 0; + for (const ticket of ctx.db.lobbyQueueTicket.byStatusExpiresAt.filter([ + TicketStatus.Queued, + new Range(undefined, { tag: 'included', value: now }), + ])) { + if (expired >= limit) break; + ctx.db.lobbyQueueTicket.ticketId.update({ + ...ticket, + status: TicketStatus.Expired, + updatedAt: ctx.timestamp, + }); + expired++; + } + return expired; +} + +function activeTicketsForSubjectPool( + ctx: WriteCtx, + subject: string, + pool: string +) { + return take( + ctx.db.lobbyQueueTicket.bySubjectStatus.filter([ + subject, + TicketStatus.Queued, + ]), + 1000 + ).filter(ticket => ticket.pool === pool && isQueued(ticket)); +} + +function seatsForRoom(ctx: WriteCtx | ViewModuleCtx, roomId: bigint) { + return take(ctx.db.lobbyRoomSeat.byRoom.filter(roomId), 128); +} + +function findSeat(ctx: WriteCtx, roomId: bigint, subject: string) { + for (const seat of ctx.db.lobbyRoomSeat.byRoomSubject.filter([ + roomId, + subject, + ])) + return seat; + return undefined; +} + +function refreshRoomAfterJoin(ctx: WriteCtx, roomId: bigint): void { + const room = ctx.db.lobbyRoom.roomId.find(roomId); + if (!room || isTerminalRoom(room)) return; + const seats = seatsForRoom(ctx, roomId); + if (seats.length === 0) return; + const allJoined = seats.every( + seat => seat.status.tag === SeatStatus.Joined.tag + ); + if (allJoined && room.status.tag === RoomStatus.Ready.tag) { + ctx.db.lobbyRoom.roomId.update({ + ...room, + status: RoomStatus.Active, + updatedAt: ctx.timestamp, + }); + } +} + +function markRoomAbandoned(ctx: WriteCtx, roomId: bigint): void { + const room = ctx.db.lobbyRoom.roomId.find(roomId); + if (!room || isTerminalRoom(room)) return; + ctx.db.lobbyRoom.roomId.update({ + ...room, + status: RoomStatus.Abandoned, + updatedAt: ctx.timestamp, + closedAt: ctx.timestamp, + }); +} + +function attemptMatch( + ctx: WriteCtx, + pool: string, + matchSize: number, + ranked: boolean +): bigint | undefined { + const now = nowMicros(ctx); + const queued = take( + ctx.db.lobbyQueueTicket.byPoolStatusCreatedAt.filter([ + pool, + TicketStatus.Queued, + new Range(), + ]), + MAX_MATCH_CANDIDATES + ).filter( + ticket => + ticket.pool === pool && + ticket.ranked === ranked && + ticket.matchSize === matchSize && + ticket.expiresAtMicros > now + ); + if (queued.length < matchSize) return undefined; + + const selected = ranked + ? rankedSelection(queued, matchSize, now) + : queued.slice(0, matchSize); + if (!selected || selected.length < matchSize) return undefined; + + const room = ctx.db.lobbyRoom.insert({ + roomId: 0n, + pool, + status: RoomStatus.Ready, + capacity: matchSize, + metadataJson: ranked + ? JSON.stringify({ + ranked: true, + ratingPool: selected[0].ratingPool ?? pool, + }) + : undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + closedAt: undefined, + }); + + selected.forEach((ticket, index) => { + ctx.db.lobbyRoomSeat.insert({ + seatId: 0n, + roomId: room.roomId, + subject: ticket.subject, + ticketId: ticket.ticketId, + seatIndex: index, + status: SeatStatus.Reserved, + ready: false, + joinedAt: undefined, + leftAt: undefined, + updatedAt: ctx.timestamp, + }); + ctx.db.lobbyQueueTicket.ticketId.update({ + ...ticket, + status: TicketStatus.Matched, + roomId: room.roomId, + updatedAt: ctx.timestamp, + }); + }); + + return room.roomId; +} + +export function joinQueue(ctx: WriteCtx, args: JoinQueueArgs): JoinQueueResult { + const config = getConfig(ctx); + const pool = normalizeName(args.pool, 'pool', MAX_POOL_LENGTH); + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const matchSize = Math.trunc(args.matchSize); + if ( + !Number.isInteger(matchSize) || + matchSize < 1 || + matchSize > config.maxMatchSize + ) { + fail('invalid_match_size'); + } + const ttlSeconds = + args.ttlSeconds === undefined + ? config.defaultTicketTtlSeconds + : Math.trunc(args.ttlSeconds); + if ( + !Number.isInteger(ttlSeconds) || + ttlSeconds < 1 || + ttlSeconds > 24 * 60 * 60 + ) { + fail('invalid_ttl_seconds'); + } + const attributesJson = validateJson(args.attributesJson, 'attributes'); + + expireQueuedTickets(ctx, DEFAULT_EXPIRE_LIMIT); + for (const ticket of activeTicketsForSubjectPool(ctx, subject, pool)) { + ctx.db.lobbyQueueTicket.ticketId.update({ + ...ticket, + status: TicketStatus.Cancelled, + updatedAt: ctx.timestamp, + }); + } + + const ticketId = `ticket:${ctx.newUuidV7().toString()}`; + ctx.db.lobbyQueueTicket.insert({ + ticketId, + pool, + subject, + status: TicketStatus.Queued, + matchSize, + ranked: false, + rating: undefined, + ratingPool: undefined, + partyId: undefined, + attributesJson, + roomId: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + expiresAtMicros: nowMicros(ctx) + BigInt(ttlSeconds) * 1_000_000n, + }); + + const roomId = attemptMatch(ctx, pool, matchSize, false); + return { ticketId, roomId }; +} + +export function joinRankedQueue( + ctx: WriteCtx, + args: JoinRankedQueueArgs +): JoinQueueResult { + const config = getConfig(ctx); + const pool = normalizeName(args.pool, 'pool', MAX_POOL_LENGTH); + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const matchSize = Math.trunc(args.matchSize); + if ( + !Number.isInteger(matchSize) || + matchSize < 1 || + matchSize > config.maxMatchSize + ) { + fail('invalid_match_size'); + } + const ttlSeconds = + args.ttlSeconds === undefined + ? config.defaultTicketTtlSeconds + : Math.trunc(args.ttlSeconds); + if ( + !Number.isInteger(ttlSeconds) || + ttlSeconds < 1 || + ttlSeconds > 24 * 60 * 60 + ) { + fail('invalid_ttl_seconds'); + } + const attributesJson = validateJson(args.attributesJson, 'attributes'); + const ratingPool = + args.ratingPool === undefined + ? pool + : normalizeName(args.ratingPool, 'rating_pool', MAX_POOL_LENGTH); + const rating = getOrCreateRating(ctx, ratingPool, subject).rating; + + expireQueuedTickets(ctx, DEFAULT_EXPIRE_LIMIT); + for (const ticket of activeTicketsForSubjectPool(ctx, subject, pool)) { + ctx.db.lobbyQueueTicket.ticketId.update({ + ...ticket, + status: TicketStatus.Cancelled, + updatedAt: ctx.timestamp, + }); + } + + const ticketId = `ticket:${ctx.newUuidV7().toString()}`; + ctx.db.lobbyQueueTicket.insert({ + ticketId, + pool, + subject, + status: TicketStatus.Queued, + matchSize, + ranked: true, + rating, + ratingPool, + partyId: undefined, + attributesJson, + roomId: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + expiresAtMicros: nowMicros(ctx) + BigInt(ttlSeconds) * 1_000_000n, + }); + + const roomId = attemptMatch(ctx, pool, matchSize, true); + return { ticketId, roomId }; +} + +function ratingPoolForRoom(room: RoomRow): string { + if (!room.metadataJson) return room.pool; + try { + const metadata = JSON.parse(room.metadataJson) as { ratingPool?: unknown }; + return typeof metadata.ratingPool === 'string' && metadata.ratingPool.trim() + ? metadata.ratingPool.trim() + : room.pool; + } catch { + return room.pool; + } +} + +function applyResultRow( + ctx: WriteCtx, + row: SubjectRatingRow, + score: number, + opponentRating: number +) { + const nextRating = updatedRating( + row.rating, + expectedScore(row.rating, opponentRating), + score + ); + const next = { + ...row, + rating: nextRating, + ratingOrder: BigInt(-nextRating), + wins: row.wins + (score === 1 ? 1 : 0), + losses: row.losses + (score === 0 ? 1 : 0), + draws: row.draws + (score === 0.5 ? 1 : 0), + matches: row.matches + 1, + updatedAt: ctx.timestamp, + }; + ctx.db.lobbySubjectRating.ratingId.update(next); + return next; +} + +export function reportMatchResult( + ctx: WriteCtx, + args: ReportMatchResultArgs +): void { + const reporter = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const room = ctx.db.lobbyRoom.roomId.find(args.roomId); + if (!room) fail('room_not_found'); + if (room.status.tag !== RoomStatus.Active.tag) fail('room_not_active'); + const seats = seatsForRoom(ctx, args.roomId).filter( + seat => seat.status.tag !== SeatStatus.Left.tag + ); + if (seats.length !== 2) fail('elo_requires_two_seats'); + if (!seats.some(seat => seat.subject === reporter) && !isAdmin(ctx)) + fail('not_room_participant'); + for (const _existing of ctx.db.lobbyMatchResult.byRoom.filter(args.roomId)) + return; + + const [seatA, seatB] = seats.sort((a, b) => a.seatIndex - b.seatIndex); + const winnerSubject = + args.winnerSubject === undefined + ? undefined + : normalizeName(args.winnerSubject, 'winner_subject', MAX_SUBJECT_LENGTH); + if ( + winnerSubject !== undefined && + winnerSubject !== seatA.subject && + winnerSubject !== seatB.subject + ) { + fail('winner_not_in_room'); + } + + const scoreA = + winnerSubject === undefined ? 0.5 : winnerSubject === seatA.subject ? 1 : 0; + const scoreB = 1 - scoreA; + const ratingPool = ratingPoolForRoom(room); + const ratingA = getOrCreateRating(ctx, ratingPool, seatA.subject); + const ratingB = getOrCreateRating(ctx, ratingPool, seatB.subject); + const nextA = applyResultRow(ctx, ratingA, scoreA, ratingB.rating); + const nextB = applyResultRow(ctx, ratingB, scoreB, ratingA.rating); + ctx.db.lobbyMatchResult.insert({ + resultId: 0n, + roomId: args.roomId, + pool: ratingPool, + winnerSubject, + loserSubject: + winnerSubject === undefined + ? undefined + : winnerSubject === seatA.subject + ? seatB.subject + : seatA.subject, + subjectA: seatA.subject, + subjectB: seatB.subject, + ratingABefore: ratingA.rating, + ratingAAfter: nextA.rating, + ratingBBefore: ratingB.rating, + ratingBAfter: nextB.rating, + reportedAt: ctx.timestamp, + }); +} + +export function cancelTicket(ctx: WriteCtx, args: TicketSubjectArgs): void { + const ticketId = normalizeName(args.ticketId, 'ticket_id', 200); + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const ticket = ctx.db.lobbyQueueTicket.ticketId.find(ticketId); + if (!ticket) fail('ticket_not_found'); + if (ticket.subject !== subject) fail('not_ticket_owner'); + if (!isQueued(ticket)) fail('ticket_not_queued'); + ctx.db.lobbyQueueTicket.ticketId.update({ + ...ticket, + status: TicketStatus.Cancelled, + updatedAt: ctx.timestamp, + }); +} + +export function joinRoom(ctx: WriteCtx, args: RoomSubjectArgs): void { + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const room = ctx.db.lobbyRoom.roomId.find(args.roomId); + if (!room) fail('room_not_found'); + if (isTerminalRoom(room)) fail('room_closed'); + const seat = findSeat(ctx, args.roomId, subject); + if (!seat) fail('seat_not_found'); + if (seat.status.tag === SeatStatus.Left.tag) fail('seat_left'); + ctx.db.lobbyRoomSeat.seatId.update({ + ...seat, + status: SeatStatus.Joined, + joinedAt: seat.joinedAt ?? ctx.timestamp, + updatedAt: ctx.timestamp, + }); + refreshRoomAfterJoin(ctx, args.roomId); +} + +export function leaveRoom(ctx: WriteCtx, args: RoomSubjectArgs): void { + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const room = ctx.db.lobbyRoom.roomId.find(args.roomId); + if (!room) fail('room_not_found'); + const seat = findSeat(ctx, args.roomId, subject); + if (!seat) fail('seat_not_found'); + if (seat.status.tag === SeatStatus.Left.tag) return; + ctx.db.lobbyRoomSeat.seatId.update({ + ...seat, + status: SeatStatus.Left, + ready: false, + leftAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + markRoomAbandoned(ctx, args.roomId); +} + +export function closeRoom(ctx: WriteCtx, args: RoomSubjectArgs): void { + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const room = ctx.db.lobbyRoom.roomId.find(args.roomId); + if (!room) fail('room_not_found'); + if (!findSeat(ctx, args.roomId, subject)) fail('seat_not_found'); + if (room.status.tag === RoomStatus.Closed.tag) return; + ctx.db.lobbyRoom.roomId.update({ + ...room, + status: RoomStatus.Closed, + updatedAt: ctx.timestamp, + closedAt: ctx.timestamp, + }); +} + +export const join_queue = spacetimedb.reducer( + { + pool: t.string(), + matchSize: t.u32(), + attributesJson: t.option(t.string()), + ttlSeconds: t.option(t.u32()), + }, + (ctx, args) => { + joinQueue(ctx, { + pool: args.pool, + subject: subjectForSender(ctx), + matchSize: args.matchSize, + attributesJson: args.attributesJson, + ttlSeconds: args.ttlSeconds, + }); + } +); + +export const join_ranked_queue = spacetimedb.reducer( + { + pool: t.string(), + matchSize: t.u32(), + attributesJson: t.option(t.string()), + ttlSeconds: t.option(t.u32()), + ratingPool: t.option(t.string()), + }, + (ctx, args) => { + joinRankedQueue(ctx, { + pool: args.pool, + subject: subjectForSender(ctx), + matchSize: args.matchSize, + attributesJson: args.attributesJson, + ttlSeconds: args.ttlSeconds, + ratingPool: args.ratingPool, + }); + } +); + +export const cancel_ticket = spacetimedb.reducer( + { ticketId: t.string() }, + (ctx, args) => { + cancelTicket(ctx, { + ticketId: args.ticketId, + subject: subjectForSender(ctx), + }); + } +); + +export const join_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, args) => { + joinRoom(ctx, { roomId: args.roomId, subject: subjectForSender(ctx) }); + } +); + +export const leave_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, args) => { + leaveRoom(ctx, { roomId: args.roomId, subject: subjectForSender(ctx) }); + } +); + +export const close_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, args) => { + closeRoom(ctx, { roomId: args.roomId, subject: subjectForSender(ctx) }); + } +); + +export const set_rating = spacetimedb.reducer( + { + pool: t.string(), + subject: t.string(), + rating: t.i32(), + }, + (ctx, args) => { + requireAdmin(ctx); + const pool = normalizeName(args.pool, 'pool', MAX_POOL_LENGTH); + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const rating = validateRating(args.rating); + const existing = getOrCreateRating(ctx, pool, subject); + ctx.db.lobbySubjectRating.ratingId.update({ + ...existing, + rating, + ratingOrder: BigInt(-rating), + updatedAt: ctx.timestamp, + }); + } +); + +export const expire_tickets = spacetimedb.reducer( + { limit: t.option(t.u32()) }, + (ctx, args) => { + requireAdmin(ctx); + const limit = args.limit ?? DEFAULT_EXPIRE_LIMIT; + if (limit < 1 || limit > MAX_EXPIRE_LIMIT) fail('invalid_expire_limit'); + expireQueuedTickets(ctx, limit); + } +); + +export const update_config = spacetimedb.reducer( + { + defaultTicketTtlSeconds: t.u32(), + maxMatchSize: t.u32(), + }, + (ctx, args) => { + requireAdmin(ctx); + if ( + args.defaultTicketTtlSeconds < 1 || + args.defaultTicketTtlSeconds > 24 * 60 * 60 + ) { + fail('invalid_default_ttl_seconds'); + } + if (args.maxMatchSize < 1 || args.maxMatchSize > 128) + fail('invalid_max_match_size'); + const config = getConfig(ctx); + ctx.db.lobbyConfig.singleton.update({ + ...config, + defaultTicketTtlSeconds: args.defaultTicketTtlSeconds, + maxMatchSize: args.maxMatchSize, + updatedAt: ctx.timestamp, + }); + } +); + +export const add_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, args) => { + requireAdmin(ctx); + if (ctx.db.lobbyAdminIdentity.identity.find(args.identity) != null) return; + ctx.db.lobbyAdminIdentity.insert({ + identity: args.identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } +); + +export const remove_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, args) => { + requireAdmin(ctx); + const row = ctx.db.lobbyAdminIdentity.identity.find(args.identity); + if (!row) return; + if (ctx.db.lobbyAdminIdentity.count() <= 1n) + fail('cannot_remove_last_admin'); + ctx.db.lobbyAdminIdentity.delete(row); + } +); + +export const get_lobby_status = spacetimedb.procedure({}, t.string(), ctx => { + const status = ctx.withTx(tx => { + const queuedTickets = countUpTo( + tx.db.lobbyQueueTicket.byStatus.filter(TicketStatus.Queued), + MAX_MATCH_CANDIDATES + ); + const readyRooms = countUpTo( + tx.db.lobbyRoom.byStatus.filter(RoomStatus.Ready), + MAX_MATCH_CANDIDATES + ); + const activeRooms = countUpTo( + tx.db.lobbyRoom.byStatus.filter(RoomStatus.Active), + MAX_MATCH_CANDIDATES + ); + const config = getConfig(tx); + return { + defaultTicketTtlSeconds: config.defaultTicketTtlSeconds, + maxMatchSize: config.maxMatchSize, + queuedTickets, + readyRooms, + activeRooms, + }; + }); + return JSON.stringify(status); +}); + +export { + lobbyAdminMatchResults, + lobbyAdminRoomSeats, + lobbyAdminRooms, + lobbyAdminTickets, + lobbyQueueSummary, + lobbyRankedLeaderboard, + myLobbyRatings, + myLobbyRoomSeats, + myLobbyRooms, + myLobbyTickets, +} from './views'; diff --git a/spacetime-lobby-ts/src/submodule/schema.ts b/spacetime-lobby-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..6e09f8885be --- /dev/null +++ b/spacetime-lobby-ts/src/submodule/schema.ts @@ -0,0 +1,270 @@ +import { + SenderError, + schema, + table, + t, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { installLobby } from './install'; + +export const ticketStatus = t.enum('LobbyTicketStatus', [ + 'Queued', + 'Matched', + 'Cancelled', + 'Expired', +]); +export const TicketStatus = { + Queued: { tag: 'Queued' as const }, + Matched: { tag: 'Matched' as const }, + Cancelled: { tag: 'Cancelled' as const }, + Expired: { tag: 'Expired' as const }, +}; + +export const roomStatus = t.enum('LobbyRoomStatus', [ + 'Ready', + 'Active', + 'Closed', + 'Abandoned', +]); +export const RoomStatus = { + Ready: { tag: 'Ready' as const }, + Active: { tag: 'Active' as const }, + Closed: { tag: 'Closed' as const }, + Abandoned: { tag: 'Abandoned' as const }, +}; + +export const seatStatus = t.enum('LobbySeatStatus', [ + 'Reserved', + 'Joined', + 'Left', + 'Disconnected', +]); +export const SeatStatus = { + Reserved: { tag: 'Reserved' as const }, + Joined: { tag: 'Joined' as const }, + Left: { tag: 'Left' as const }, + Disconnected: { tag: 'Disconnected' as const }, +}; + +export const lobbyConfig = table( + { name: 'lobby_config', public: false }, + { + singleton: t.bool().primaryKey(), + defaultTicketTtlSeconds: t.u32(), + maxMatchSize: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const lobbyAdminIdentity = table( + { name: 'lobby_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +export const lobbyQueueTicket = table( + { + name: 'lobby_queue_ticket', + public: false, + indexes: [ + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + { accessor: 'byPool', algorithm: 'btree', columns: ['pool'] }, + { accessor: 'bySubject', algorithm: 'btree', columns: ['subject'] }, + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + { + accessor: 'byPoolStatusCreatedAt', + algorithm: 'btree', + columns: ['pool', 'status', 'createdAt'], + }, + { + accessor: 'byStatusExpiresAt', + algorithm: 'btree', + columns: ['status', 'expiresAtMicros'], + }, + { + accessor: 'bySubjectStatus', + algorithm: 'btree', + columns: ['subject', 'status'], + }, + ], + }, + { + ticketId: t.string().primaryKey(), + pool: t.string(), + subject: t.string(), + status: ticketStatus, + matchSize: t.u32(), + ranked: t.bool(), + rating: t.option(t.i32()), + ratingPool: t.option(t.string()), + partyId: t.option(t.string()), + attributesJson: t.option(t.string()), + roomId: t.option(t.u64()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + expiresAtMicros: t.i64(), + } +); + +export const lobbySubjectRating = table( + { + name: 'lobby_subject_rating', + public: false, + indexes: [ + { accessor: 'byPool', algorithm: 'btree', columns: ['pool'] }, + { accessor: 'bySubject', algorithm: 'btree', columns: ['subject'] }, + { accessor: 'byRating', algorithm: 'btree', columns: ['rating'] }, + { + accessor: 'byLeaderboardOrder', + algorithm: 'btree', + columns: ['pool', 'ratingOrder', 'subject'], + }, + { accessor: 'byUpdatedAt', algorithm: 'btree', columns: ['updatedAt'] }, + ], + }, + { + ratingId: t.string().primaryKey(), + pool: t.string(), + subject: t.string(), + rating: t.i32(), + ratingOrder: t.i64(), + wins: t.u32(), + losses: t.u32(), + draws: t.u32(), + matches: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const lobbyRoom = table( + { + name: 'lobby_room', + public: false, + indexes: [ + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + { accessor: 'byPool', algorithm: 'btree', columns: ['pool'] }, + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + ], + }, + { + roomId: t.u64().primaryKey().autoInc(), + pool: t.string(), + status: roomStatus, + capacity: t.u32(), + metadataJson: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + closedAt: t.option(t.timestamp()), + } +); + +export const lobbyMatchResult = table( + { + name: 'lobby_match_result', + public: false, + indexes: [ + { accessor: 'byRoom', algorithm: 'btree', columns: ['roomId'] }, + { accessor: 'byPool', algorithm: 'btree', columns: ['pool'] }, + { accessor: 'byReportedAt', algorithm: 'btree', columns: ['reportedAt'] }, + ], + }, + { + resultId: t.u64().primaryKey().autoInc(), + roomId: t.u64(), + pool: t.string(), + winnerSubject: t.option(t.string()), + loserSubject: t.option(t.string()), + subjectA: t.string(), + subjectB: t.string(), + ratingABefore: t.i32(), + ratingAAfter: t.i32(), + ratingBBefore: t.i32(), + ratingBAfter: t.i32(), + reportedAt: t.timestamp(), + } +); + +export const lobbyRoomSeat = table( + { + name: 'lobby_room_seat', + public: false, + indexes: [ + { accessor: 'byRoom', algorithm: 'btree', columns: ['roomId'] }, + { accessor: 'bySubject', algorithm: 'btree', columns: ['subject'] }, + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + { + accessor: 'byRoomSubject', + algorithm: 'btree', + columns: ['roomId', 'subject'], + }, + ], + }, + { + seatId: t.u64().primaryKey().autoInc(), + roomId: t.u64(), + subject: t.string(), + ticketId: t.option(t.string()), + seatIndex: t.u32(), + status: seatStatus, + ready: t.bool(), + joinedAt: t.option(t.timestamp()), + leftAt: t.option(t.timestamp()), + updatedAt: t.timestamp(), + } +); + +export const queueSummaryRow = t.object('LobbyQueueSummaryRow', { + pool: t.string(), + queuedTickets: t.u32(), + readyRooms: t.u32(), + activeRooms: t.u32(), +}); + +export const lobbyStatusRow = t.object('LobbyStatusRow', { + defaultTicketTtlSeconds: t.u32(), + maxMatchSize: t.u32(), + queuedTickets: t.u32(), + readyRooms: t.u32(), + activeRooms: t.u32(), +}); + +export const rankedRatingRow = t.object('LobbyRankedRatingRow', { + pool: t.string(), + subject: t.string(), + rating: t.i32(), + wins: t.u32(), + losses: t.u32(), + draws: t.u32(), + matches: t.u32(), +}); + +export const spacetimedb = schema({ + lobbyConfig, + lobbyAdminIdentity, + lobbyQueueTicket, + lobbySubjectRating, + lobbyRoom, + lobbyMatchResult, + lobbyRoomSeat, +}); + +export const init = spacetimedb.init(ctx => { + installLobby(ctx); +}); + +export default spacetimedb; + +export type Schema = InferSchema; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx; +export type ViewModuleCtx = ViewCtx; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; + +export { SenderError, t }; diff --git a/spacetime-lobby-ts/src/submodule/views.ts b/spacetime-lobby-ts/src/submodule/views.ts new file mode 100644 index 00000000000..f1675c9b65f --- /dev/null +++ b/spacetime-lobby-ts/src/submodule/views.ts @@ -0,0 +1,256 @@ +import { Range } from 'spacetimedb/server'; +import { + RoomStatus, + TicketStatus, + lobbyMatchResult, + lobbyQueueTicket, + lobbyRoom, + lobbyRoomSeat, + queueSummaryRow, + rankedRatingRow, + spacetimedb, + t, + type ViewModuleCtx, +} from './schema'; + +const MAX_MATCH_CANDIDATES = 5000; +const MAX_VIEW_ROWS = 500; + +function subjectForSender(ctx: ViewModuleCtx): string { + return ctx.sender.toHexString(); +} + +function isAdmin(ctx: ViewModuleCtx): boolean { + return ctx.db.lobbyAdminIdentity.identity.find(ctx.sender) != null; +} + +function newestFirst( + rows: T[] +): T[] { + return rows.sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch; + const bv = b.createdAt.microsSinceUnixEpoch; + return av < bv ? 1 : av > bv ? -1 : 0; + }); +} + +function selectTopRows( + rows: Iterable, + limit: number, + compare: (a: T, b: T) => number +): T[] { + const selected: T[] = []; + for (const row of rows) { + let low = 0; + let high = selected.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (compare(row, selected[mid]!) < 0) high = mid; + else low = mid + 1; + } + if (low >= limit) continue; + selected.splice(low, 0, row); + if (selected.length > limit) selected.pop(); + } + return selected; +} + +function newestRowsBy( + rows: Iterable, + limit: number, + timestamp: (row: T) => bigint +): T[] { + return selectTopRows(rows, limit, (a, b) => { + const av = timestamp(a); + const bv = timestamp(b); + return av < bv ? 1 : av > bv ? -1 : 0; + }); +} + +function newestRows( + rows: Iterable, + limit: number +): T[] { + return newestRowsBy(rows, limit, row => row.createdAt.microsSinceUnixEpoch); +} + +function take(rows: Iterable, limit: number): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +export const myLobbyTickets = spacetimedb.view( + { name: 'my_lobby_tickets', public: true }, + t.array(lobbyQueueTicket.rowType), + ctx => + newestRows( + ctx.db.lobbyQueueTicket.bySubject.filter(subjectForSender(ctx)), + MAX_VIEW_ROWS + ) +); + +export const myLobbyRatings = spacetimedb.view( + { name: 'my_lobby_ratings', public: true }, + t.array(rankedRatingRow), + ctx => + selectTopRows( + ctx.db.lobbySubjectRating.bySubject.filter(subjectForSender(ctx)), + MAX_VIEW_ROWS, + (a, b) => a.pool.localeCompare(b.pool) + ) + .map(row => ({ + pool: row.pool, + subject: row.subject, + rating: row.rating, + wins: row.wins, + losses: row.losses, + draws: row.draws, + matches: row.matches, + })) + .sort((a, b) => a.pool.localeCompare(b.pool)) +); + +export const myLobbyRoomSeats = spacetimedb.view( + { name: 'my_lobby_room_seats', public: true }, + t.array(lobbyRoomSeat.rowType), + ctx => + newestRowsBy( + ctx.db.lobbyRoomSeat.bySubject.filter(subjectForSender(ctx)), + MAX_VIEW_ROWS, + row => row.updatedAt.microsSinceUnixEpoch + ) +); + +export const myLobbyRooms = spacetimedb.view( + { name: 'my_lobby_rooms', public: true }, + t.array(lobbyRoom.rowType), + ctx => { + const subject = subjectForSender(ctx); + const seen = new Set(); + const rooms = []; + for (const seat of newestRowsBy( + ctx.db.lobbyRoomSeat.bySubject.filter(subject), + MAX_VIEW_ROWS, + row => row.updatedAt.microsSinceUnixEpoch + )) { + const key = seat.roomId.toString(); + if (seen.has(key)) continue; + seen.add(key); + const room = ctx.db.lobbyRoom.roomId.find(seat.roomId); + if (room) rooms.push(room); + } + return newestFirst(rooms); + } +); + +export const lobbyQueueSummary = spacetimedb.view( + { name: 'lobby_queue_summary', public: true }, + t.array(queueSummaryRow), + ctx => { + const summary = new Map< + string, + { + pool: string; + queuedTickets: number; + readyRooms: number; + activeRooms: number; + } + >(); + const ensure = (pool: string) => { + let row = summary.get(pool); + if (!row) { + row = { pool, queuedTickets: 0, readyRooms: 0, activeRooms: 0 }; + summary.set(pool, row); + } + return row; + }; + for (const ticket of take( + ctx.db.lobbyQueueTicket.byStatus.filter(TicketStatus.Queued), + MAX_MATCH_CANDIDATES + )) { + ensure(ticket.pool).queuedTickets++; + } + for (const room of take( + ctx.db.lobbyRoom.byStatus.filter(RoomStatus.Ready), + MAX_MATCH_CANDIDATES + )) { + ensure(room.pool).readyRooms++; + } + for (const room of take( + ctx.db.lobbyRoom.byStatus.filter(RoomStatus.Active), + MAX_MATCH_CANDIDATES + )) { + ensure(room.pool).activeRooms++; + } + return [...summary.values()].sort((a, b) => a.pool.localeCompare(b.pool)); + } +); + +export const lobbyRankedLeaderboard = spacetimedb.view( + { name: 'lobby_ranked_leaderboard', public: true }, + t.array(rankedRatingRow), + ctx => + take( + ctx.db.lobbySubjectRating.byLeaderboardOrder.filter(new Range()), + 500 + ).map(row => ({ + pool: row.pool, + subject: row.subject, + rating: row.rating, + wins: row.wins, + losses: row.losses, + draws: row.draws, + matches: row.matches, + })) +); + +export const lobbyAdminTickets = spacetimedb.view( + { name: 'lobby_admin_tickets', public: true }, + t.array(lobbyQueueTicket.rowType), + ctx => + isAdmin(ctx) + ? newestRows( + ctx.db.lobbyQueueTicket.byCreatedAt.filter(new Range()), + MAX_VIEW_ROWS + ) + : [] +); + +export const lobbyAdminRooms = spacetimedb.view( + { name: 'lobby_admin_rooms', public: true }, + t.array(lobbyRoom.rowType), + ctx => + isAdmin(ctx) + ? newestRows( + ctx.db.lobbyRoom.byCreatedAt.filter(new Range()), + MAX_VIEW_ROWS + ) + : [] +); + +export const lobbyAdminRoomSeats = spacetimedb.view( + { name: 'lobby_admin_room_seats', public: true }, + t.array(lobbyRoomSeat.rowType), + ctx => (isAdmin(ctx) ? take(ctx.db.lobbyRoomSeat.iter(), 1000) : []) +); + +export const lobbyAdminMatchResults = spacetimedb.view( + { name: 'lobby_admin_match_results', public: true }, + t.array(lobbyMatchResult.rowType), + ctx => + isAdmin(ctx) + ? selectTopRows( + ctx.db.lobbyMatchResult.byReportedAt.filter(new Range()), + 500, + (a, b) => { + const av = a.reportedAt.microsSinceUnixEpoch; + const bv = b.reportedAt.microsSinceUnixEpoch; + return av < bv ? 1 : av > bv ? -1 : 0; + } + ) + : [] +); diff --git a/spacetime-lobby-ts/tsconfig.json b/spacetime-lobby-ts/tsconfig.json new file mode 100644 index 00000000000..c659d97428a --- /dev/null +++ b/spacetime-lobby-ts/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-presence-ts/.npmrc b/spacetime-presence-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-presence-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-presence-ts/LICENSE.txt b/spacetime-presence-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-presence-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-presence-ts/README.md b/spacetime-presence-ts/README.md new file mode 100644 index 00000000000..bab1c55ea9c --- /dev/null +++ b/spacetime-presence-ts/README.md @@ -0,0 +1,159 @@ +# @spacetimedb/presence + +Presence primitives for SpacetimeDB modules. + +## Install + +```bash +npm install @spacetimedb/presence spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +This package provides: + +- reusable presence table row/builders, +- helpers for heartbeats and status/activity updates, +- bounded sweep helpers for expired presence rows. + +## Usage + +### Integrate into an application + +Presence is a host-configured helper: the host chooses whether rows are public, +defines the scheduled sweep, and derives subjects from its authentication +model. The skeleton below owns those decisions explicitly. + +```ts +import { Range, schema, t, table } from 'spacetimedb/server'; +import { ScheduleAt } from 'spacetimedb'; +import { + createPresenceEntryTable, + createPresenceConfigTable, + installPresenceConfig, + presenceSweepTickRow, + runPresenceSweep, + upsertPresence, +} from '@spacetimedb/presence'; + +const presenceEntry = createPresenceEntryTable({ public: true }); +const presenceConfig = createPresenceConfigTable({ public: false }); +const presenceSweepTick = table( + { name: 'presence_sweep_tick' }, + presenceSweepTickRow +); + +const spacetimedb = schema({ + presenceEntry, + presenceConfig, + presenceSweepTick, +}); + +export const init = spacetimedb.init(ctx => { + installPresenceConfig(ctx); + ctx.db.presenceSweepTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval(30n * 1_000_000n), + }); +}); + +export const heartbeat = spacetimedb.procedure( + { scope: t.string(), status: t.option(t.string()) }, + t.unit(), + (ctx, args) => { + ctx.withTx(tx => { + upsertPresence(tx, { + scope: args.scope, + subject: ctx.sender.toHexString(), + status: args.status ?? 'online', + }); + }); + return {}; + } +); + +export const presence_sweep = spacetimedb.reducer( + { onSchedule: presenceSweepTick }, + { arg: presenceSweepTick.rowType }, + ctx => { + runPresenceSweep( + ctx, + ctx.db.presenceEntry.expiresAt.filter( + new Range(undefined, { tag: 'included', value: ctx.timestamp }) + ) + ); + } +); + +export default spacetimedb; +``` + +The public operation derives its subject from `ctx.sender`. Applications with +account authentication can use a verified session's stable user ID instead. +See the +[Presence Chat host module](./example/spacetimedb/) +for authenticated subjects, typing scopes, and bounded cleanup. + +After generating bindings, send heartbeats through the host procedure and +subscribe to the host's public or caller-scoped presence table: + +```ts +import { tables } from './module_bindings'; + +await conn.procedures.heartbeat({ + scope: 'room:42', + status: 'online', +}); + +conn + .subscriptionBuilder() + .subscribe([tables.myPresenceEntries.where(row => row.scope.eq('room:42'))]); +``` + +## API + +- `createPresenceEntryTable` and `createPresenceConfigTable` create the host + tables. +- `installPresenceConfig` installs default expiration policy. +- `upsertPresence` records a heartbeat or status change. +- `touchPresence` extends an existing lease while preserving its metadata. +- `removePresence` removes one scope and subject pair. +- `buildPresenceKey` creates the collision-safe compound key used by the + default tables. +- `sweepPresence` removes expired rows from a supplied iterator. +- `runPresenceSweep` removes a bounded batch from an expiration-index iterator + supplied by the host. +- `resolvePresenceSweepBatch` validates configured cleanup batch sizes. +- `presenceEntryRow`, `presenceConfigRow`, `presenceSweepTickRow`, and + `presenceTables` support lower-level table composition. +- `DEFAULT_PRESENCE_TTL_SECONDS`, `DEFAULT_PRESENCE_SWEEP_BATCH`, + `MAX_PRESENCE_SWEEP_BATCH`, and `DEFAULT_PRESENCE_STATUS` expose the package + limits and defaults. + +Package entrypoints: + +- `@spacetimedb/presence` exports the full standalone helper surface. +- `@spacetimedb/presence/presence` exports presence operations. +- `@spacetimedb/presence/tables` exports table builders. +- `@spacetimedb/presence/submodule` exports the ready-made submodule + namespace. + +The ready-made namespace publishes `presence_entry` rows. The `activity` and +`payloadJson` fields are visible to subscribed clients. Do not store secrets or +private application data in these fields. Its manual sweep and configuration +operations require a presence administrator. Sweep batches are limited to +10,000 rows per call. + +## Testing + +```bash +pnpm test +pnpm run typecheck +``` + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-presence-ts/example/.env.example b/spacetime-presence-ts/example/.env.example new file mode 100644 index 00000000000..56b03fdb217 --- /dev/null +++ b/spacetime-presence-ts/example/.env.example @@ -0,0 +1,26 @@ +# Copy to .env. The example server loads this on startup and bootstraps auth. + +# ---------------- Static server ---------------- +HOST=127.0.0.1 +PORT=8794 + +# ---------------- SpacetimeDB ---------------- +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +STDB_SERVER=http://127.0.0.1:3000 +SPACETIMEDB_DB_NAME=spacetime-presence-example + +# ---------------- Auth ---------------- +# Issuer URL is what gets embedded in the JWT and used for OAuth redirect +# callbacks. Defaults to http://localhost:${PORT}. +AUTH_ISSUER_URL= +AUTH_BASE_URL= +AUTH_COOKIE_NAME=stdb_auth +AUTH_SESSION_TTL_SECONDS=604800 +AUTH_ES256_PRIVATE_KEY_PEM= + +# ---------------- OAuth providers ---------------- +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/spacetime-presence-ts/example/.npmrc b/spacetime-presence-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-presence-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-presence-ts/example/README.md b/spacetime-presence-ts/example/README.md new file mode 100644 index 00000000000..748846d9326 --- /dev/null +++ b/spacetime-presence-ts/example/README.md @@ -0,0 +1,184 @@ +# Presence chat example + +This example is a small authenticated chat application built with +[`@spacetimedb/presence`](../). It combines presence, auth, files, and rate +limiting in one SpacetimeDB module and uses caller-scoped views for the browser. + +## What this demonstrates + +- Email/password accounts and optional Google or GitHub OAuth. +- Online, away, do-not-disturb, and invisible presence states. +- Servers, public or private rooms, membership, and room categories. +- Messages, replies, reactions, pins, attachments, edits, and deletion. +- Typing indicators, read cursors, unread badges, and room activity labels. +- Procedure and reducer rate limits for user-generated activity. +- Authenticated, user-scoped subscriptions over private tables. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity. A fresh publish seeds the publisher as the initial + authentication administrator. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +Confirm the local environment first: + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-presence-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open , create an account, and create or join a server and +room. Open a private/incognito window with a second account to exercise presence, +typing, unread counts, and isolation between users. + +`build:module:fresh` deletes and recreates only the local `spacetime-presence-example` +database. Use `pnpm run build:module` when existing local data must be preserved. + +## Use in your project + +This workspace tests the submodule source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/presence spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Add Auth, +Files, and Rate Limit for the corresponding application features. Chat servers, +rooms, messages, and reactions belong to the host application. + +## Configuration + +| Variable | Default | Purpose | +| ------------------------------ | ---------------------------- | ----------------------------------------------------------------- | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8794` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | HTTP endpoint used by the auth/file proxy. | +| `STDB_SERVER` | `STDB_HTTP` | CLI target used during startup configuration. | +| `SPACETIMEDB_DB_NAME` | `spacetime-presence-example` | Published database name. | +| `AUTH_ISSUER_URL` | `http://localhost:8794` | JWT issuer and OAuth redirect origin. | +| `AUTH_BASE_URL` | `AUTH_ISSUER_URL` | Browser-visible auth base URL. | +| `AUTH_COOKIE_NAME` | `stdb_auth` | Session-cookie name. | +| `AUTH_SESSION_TTL_SECONDS` | `604800` | Session lifetime in seconds. | +| `AUTH_ES256_PRIVATE_KEY_PEM` | generated by submodule | Optional persistent ES256 signing key. | +| Google/GitHub client variables | empty | Enables the matching OAuth provider when both values are present. | + +The development server loads `.env` and calls `set_auth_config` automatically on +startup using the logged-in CLI identity. + +`STDB_URI`, `STDB_HTTP`, and `STDB_SERVER` must all address the same SpacetimeDB +instance. `AUTH_ISSUER_URL` must match the origin users actually load, including +its scheme and port. + +## Architecture and data visibility + +```text +Browser + -> /auth/* and /files through the same-origin development proxy + -> SpacetimeDB reducers, procedures, and scoped subscriptions + +SpacetimeDB module + -> auth session -> linked application connection + -> presence, chat, files, and rate-limit submodules + -> my_* views filtered for the linked user and room membership +``` + +The browser subscribes only to views such as `my_servers`, `my_rooms`, +`my_room_messages`, `my_presence_entries`, and `my_rate_limit_status`. Server-side +view logic determines which rows the linked user may see. Client-side filters +provide presentation behavior only. + +Presence is connection-sensitive. The client sends heartbeats while active and +uses explicit status changes for away, do-not-disturb, and invisible states. The +scheduled `chat_sweep` reducer cleans up expired transient state. + +## Authentication flow + +1. The browser sends signup, login, refresh, logout, or OAuth requests to the + same-origin `/auth/*` path. +2. The Node server forwards the request to the module HTTP router. +3. A successful auth response supplies a short-lived application token. +4. The browser opens a SpacetimeDB connection and calls `link_connection` with + that token before subscribing to user-scoped views. + +The SpacetimeDB connection identity and the authenticated application user are +different concepts. Authorization in this example derives from linked auth users +and membership tables. + +## Security and deployment boundaries + +- A fresh publish seeds the publisher as the auth administrator. +- Passwords, OAuth secrets, signing keys, `.env`, and browser tokens must not be + committed or logged. +- Private-room and attachment access checks belong in the module and must remain + effective even if a client issues its own subscription query. +- The included Express process serves local development. Production deployment + needs TLS, explicit network binding, trusted-proxy rules, + origin controls, durable key management, and process supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a release smoke test, use two accounts and verify: + +1. Signup, login, refresh after reload, and logout all work. +2. Both users can join a public room and see messages, typing state, reactions, + presence changes, and read progress in real time. +3. A user outside a private room cannot subscribe to its messages or retrieve its + attachments. +4. Edits, deletes, pins, room administration, and server administration reject + unauthorized users. +5. Repeated writes eventually expose the expected rate-limit status and recover + after its window resets. + +## Troubleshooting + +- **The server exits during startup:** verify the local server is running, the + database is published, and the CLI identity is an auth administrator. +- **Auth works but subscriptions are empty:** inspect the `link_connection` call. + Scoped views begin returning rows after the connection is linked. +- **OAuth callback mismatch:** configure the provider with the exact callback URL + derived from `AUTH_ISSUER_URL`. +- **Users appear offline too quickly:** confirm the browser remains connected and + heartbeat calls reach the module within the configured limit. +- **A browser token is rejected after a fresh publish:** clear site data and sign in + again because the database and signing state were reset. + +## Important files + +- `spacetimedb/src/index.ts` - host schema, scoped views, chat operations, and + submodule registration. +- `server.ts` - environment loading, auth bootstrap, and HTTP proxy. +- `src/app.ts` - browser connection, linked-session setup, and subscriptions. +- `public/index.html` - the example interface. +- `public/ui.js` - chat state, rendering, and interaction handling. +- `public/styles.css` - chat presentation. diff --git a/spacetime-presence-ts/example/package.json b/spacetime-presence-ts/example/package.json new file mode 100644 index 00000000000..5fe3f17058f --- /dev/null +++ b/spacetime-presence-ts/example/package.json @@ -0,0 +1,29 @@ +{ + "name": "spacetime-presence-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "spacetime publish --server local --yes --module-path ./spacetimedb spacetime-presence-example && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "spacetime publish --server local --yes --delete-data=always --module-path ./spacetimedb spacetime-presence-example && pnpm run spacetime:generate && pnpm run build:app", + "check": "tsc --noEmit", + "test": "node scripts/test-ui-model.mjs", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run spacetime:generate && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/submodule-shared": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-presence-ts/example/public/chat-model.js b/spacetime-presence-ts/example/public/chat-model.js new file mode 100644 index 00000000000..c39acd42f39 --- /dev/null +++ b/spacetime-presence-ts/example/public/chat-model.js @@ -0,0 +1,170 @@ +import { chatState as state } from './chat-state.js'; + +export function hex(identity) { + return identity.toHexString(); +} + +export function activeRoom() { + return state.rooms.find(room => room.id === state.activeRoomId); +} + +export function activeServer() { + return state.servers.find(server => server.id === state.activeServerId); +} + +export function messageById(messageId) { + return state.messages.find(message => message.id === messageId); +} + +export function threadMessageById(messageId) { + return state.threadMessages.find(message => message.id === messageId); +} + +export function roomMembers(roomId) { + return state.members.filter(member => member.roomId === roomId); +} + +export function myMemberships() { + return state.members + .filter(member => member.userId === state.userId) + .map(member => member.roomId); +} + +function isOwnIdentity(identity) { + return Boolean(state.meHex) && hex(identity) === state.meHex; +} + +function canModerateActiveRoom() { + const room = activeRoom(); + const server = activeServer(); + return Boolean( + state.userId && + room && + (room.createdByUserId === state.userId || + server?.createdByUserId === state.userId) + ); +} + +export function canEditMessage(message) { + return Boolean(message && isOwnIdentity(message.author)); +} + +export function canDeleteMessage(message) { + return Boolean( + message && (isOwnIdentity(message.author) || canModerateActiveRoom()) + ); +} + +export function userByHex() { + const users = new Map(); + for (const user of state.users) users.set(hex(user.identity), user); + return users; +} + +export function userByUserId() { + const users = new Map(); + for (const user of state.users) users.set(user.userId, user); + return users; +} + +export function messageAuthorName(message, users = userByHex()) { + if (!message) return 'message'; + const authorHex = hex(message.author); + return users.get(authorHex)?.displayName || authorHex.slice(-6); +} + +export function messageSummary(message) { + if (!message) return 'Original message unavailable'; + const text = (message.content || '').trim(); + if (text) return text.length > 120 ? `${text.slice(0, 117)}...` : text; + const attachmentCount = state.attachments.filter( + attachment => attachment.messageId === message.id + ).length; + return attachmentCount + ? `${attachmentCount} attachment${attachmentCount === 1 ? '' : 's'}` + : 'Empty message'; +} + +function threadForRoot(rootMessageId) { + return state.threads.find(thread => thread.rootMessageId === rootMessageId); +} + +export function threadMessagesForRoot(rootMessageId) { + const thread = threadForRoot(rootMessageId); + if (!thread) return []; + return state.threadMessages.filter(message => message.threadId === thread.id); +} + +export function myServers() { + if (!state.userId) return []; + const serverIds = new Set(); + for (const member of state.serverMembers) { + if (member.userId === state.userId) { + serverIds.add(member.serverId.toString()); + } + } + return state.servers.filter(server => serverIds.has(server.id.toString())); +} + +export function roomsInServer(serverId) { + if (serverId === null || serverId === undefined) return []; + return state.rooms.filter(room => room.serverId === serverId); +} + +export function amServerOwner(serverId) { + const server = state.servers.find(candidate => candidate.id === serverId); + return Boolean(server && server.createdByUserId === state.userId); +} + +export function latestMessageByRoom() { + const latest = new Map(); + for (const message of state.messages) { + const existing = latest.get(message.roomId); + if (!existing || existing.id < message.id) { + latest.set(message.roomId, message); + } + } + return latest; +} + +export function myReadCursorByRoom() { + const cursors = new Map(); + for (const cursor of state.cursors) { + if (hex(cursor.identity) === state.meHex) { + cursors.set(cursor.roomId, cursor.lastReadMessageId); + } + } + return cursors; +} + +export function globalPresenceBySubject() { + const presence = new Map(); + for (const row of state.presence) { + if (row.scope === 'chat.global') presence.set(row.subject, row); + } + return presence; +} + +export function typingForRoom(roomId) { + const scope = `chat.typing:${roomId.toString()}`; + return state.presence + .filter(row => row.scope === scope) + .map(row => row.subject); +} + +export function statusOf(subjectHex, presence) { + return presence.get(subjectHex)?.status || 'invisible'; +} + +export function attachmentsByMessage() { + const attachments = new Map(); + for (const attachment of state.attachments) { + const existing = attachments.get(attachment.messageId) ?? []; + existing.push(attachment); + attachments.set(attachment.messageId, existing); + } + for (const list of attachments.values()) { + list.sort((left, right) => left.ordinal - right.ordinal); + } + return attachments; +} diff --git a/spacetime-presence-ts/example/public/chat-state.js b/spacetime-presence-ts/example/public/chat-state.js new file mode 100644 index 00000000000..fb31b9be7d8 --- /dev/null +++ b/spacetime-presence-ts/example/public/chat-state.js @@ -0,0 +1,27 @@ +export const chatState = { + meHex: '', + userId: null, + userEmail: '', + activeServerId: null, + activeRoomId: null, + authenticated: false, + admins: [], + servers: [], + serverMembers: [], + rooms: [], + users: [], + members: [], + messages: [], + reactions: [], + attachments: [], + threads: [], + threadMessages: [], + cursors: [], + presence: [], + rateLimitStatus: [], +}; + +export function applyChatData(next) { + const { userId, userEmail } = chatState; + Object.assign(chatState, next, { userId, userEmail }); +} diff --git a/spacetime-presence-ts/example/public/chat.css b/spacetime-presence-ts/example/public/chat.css new file mode 100644 index 00000000000..9dc5a5a0874 --- /dev/null +++ b/spacetime-presence-ts/example/public/chat.css @@ -0,0 +1,1398 @@ +/* Signed-in chat shell */ + +body { + background: var(--color-shade7); + margin: 0; +} +.app:not(.signed-out) { + background: var(--color-shade7); + padding: 0 !important; + height: 100vh; + width: 100vw; +} + +main.shell:not(.signed-out) { + width: 100vw; + max-width: 100vw; + height: 100vh; + margin: 0; + padding: 0; + display: flex !important; + flex-direction: row; + gap: 0; + border: none !important; + border-radius: 0 !important; + background: var(--color-shade7); + overflow: hidden; + box-shadow: none !important; +} + +/* Channel header sits across the top of (main + members), so toggling + member count keeps the header icons stable. chat-column is a + vertical stack: header on top, chat-body row underneath. */ +main.shell:not(.signed-out) > .chat-column { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--color-shade8); + overflow: hidden; +} +main.shell:not(.signed-out) > .chat-column > .chat-body { + flex: 1 1 auto; + display: flex; + flex-direction: row; + min-height: 0; +} +main.shell:not(.signed-out) > .chat-column > .chat-body > .main { + flex: 1 1 0; + min-width: 0; +} + +/* Members toggle hides the panel under the header without affecting the + header layout. */ +main.shell:not(.signed-out) .members.collapsed { + display: none !important; +} + +/* ---- server rail (far-left, 72px) ---- */ +main.shell:not(.signed-out) > .rail { + flex: 0 0 72px; + background: var(--color-shade7); + border-right: 1px solid var(--color-shade4); + padding: 12px 0 8px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + overflow-y: auto; + border-right: none; + margin: 0; +} +main.shell:not(.signed-out) > .rail::-webkit-scrollbar { + display: none; +} +.rail-icon, +.rail-home { + width: 48px; + height: 48px; + border-radius: 24px; + background: var(--color-shade5); + color: var(--color-green); + border: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + transition: + border-radius 120ms ease, + background 120ms ease, + color 120ms ease; + padding: 0; + font-family: var(--font-ibm); + font-size: 12px; + font-weight: 700; +} +.rail-icon:hover, +.rail-home:hover { + border-radius: 16px; + background: var(--color-green); + color: var(--color-shade8); +} +.rail-icon.active, +.rail-home.active { + border-radius: 16px; +} +.rail-icon.active::before, +.rail-home.active::before { + content: ''; + position: absolute; + left: -16px; + top: 50%; + transform: translateY(-50%); + width: 4px; + height: 40px; + background: var(--color-n1); + border-radius: 0 4px 4px 0; +} +.rail-divider { + width: 32px; + height: 2px; + background: var(--color-shade5); + border-radius: 1px; + margin: 4px 0; + flex: 0 0 2px; +} +.rail-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; +} +.rail-list .server-pill { + width: 48px; + height: 48px; + border-radius: 24px; + background: var(--color-shade5); + color: var(--color-n2); + border: none; + cursor: pointer; + font-family: var(--font-ibm); + font-size: 12px; + font-weight: 700; + transition: + border-radius 120ms ease, + background 120ms ease; +} +.rail-list .server-pill:hover { + border-radius: 16px; + background: var(--color-green); + color: var(--color-shade8); +} +.rail-list .server-pill.active { + border-radius: 16px; + background: var(--color-shade4); + color: var(--color-n1); +} + +.rail-create { + width: 48px; + height: 48px; + border-radius: 24px; + background: var(--color-shade5); + color: var(--color-green); + border: none; + cursor: pointer; + font-size: 24px; + line-height: 1; + margin-top: 4px; + transition: + border-radius 120ms ease, + background 120ms ease, + color 120ms ease; +} +.rail-create:hover { + border-radius: 16px; + background: var(--color-green); + color: var(--color-shade8); +} + +.sidebar-head { + position: relative; +} +.server-menu { + position: absolute; + top: 100%; + left: 8px; + right: 8px; + background: var(--color-shade8); + border: 1px solid var(--color-shade4); + border-radius: 6px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); + z-index: 20; + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; +} +.server-menu[hidden] { + display: none; +} +.server-menu button { + background: transparent; + border: none; + color: var(--color-n2); + font-size: 13px; + text-align: left; + padding: 8px 10px; + border-radius: 4px; + cursor: pointer; +} +.server-menu button:hover { + background: var(--color-shade6); + color: var(--color-n1); +} +.server-menu button.danger { + color: var(--color-red, #f04747); +} +.server-menu button.danger:hover { + background: var(--color-red, #f04747); + color: var(--color-n1); +} + +/* ---- channel sidebar (240px) ---- */ +main.shell:not(.signed-out) > .sidebar { + flex: 0 0 240px; + background: var(--color-shade7); + border-right: 1px solid var(--color-shade4); + display: flex; + flex-direction: column; + min-width: 0; + padding: 0; +} +.sidebar-head { + height: 48px; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + flex: 0 0 48px; + background: var(--color-shade7); + border-bottom: 1px solid var(--color-shade4); + cursor: pointer; +} +.sidebar-head:hover { + background: var(--color-shade4); +} +.sidebar-head-text { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} +.workspace-name { + font-size: 15px; + font-weight: 700; + color: var(--color-n1); + letter-spacing: -0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.workspace-chevron { + color: var(--color-n2); + flex: 0 0 18px; +} +.channels-scroll { + flex: 1; + overflow-y: auto; + padding: 8px 8px 0; + min-height: 0; +} +.channels-scroll::-webkit-scrollbar { + width: 8px; +} +.channels-scroll::-webkit-scrollbar-thumb { + background: var(--color-shade8); + border-radius: 4px; +} +.channels-scroll::-webkit-scrollbar-thumb:hover { + background: var(--color-shade7); +} + +/* Categories */ +.category > summary { + padding: 16px 6px 4px; + font-size: 12px; + color: var(--color-n4); + position: relative; + display: flex; + align-items: center; + gap: 4px; + min-height: 32px; + box-sizing: border-box; + outline: none; +} +.category > summary::-webkit-details-marker { + display: none; +} +.category > summary:hover { + color: var(--color-n2); +} +.category-add { + margin-left: auto; + background: transparent; + border: none; + color: var(--color-n4); + cursor: pointer; + font-size: 18px; + line-height: 1; + width: 18px; + height: 18px; + display: inline-flex; + visibility: hidden; + align-items: center; + justify-content: center; + padding: 0; + border-radius: 2px; +} +.category > summary:hover .category-add { + visibility: visible; +} +.category-add:hover { + color: var(--color-n1); +} +.category > summary > .category-name { + flex: 1; +} + +/* Channel rows (Discord-tight) */ +.room-row { + padding: 6px 8px; + font-size: 15px; + color: var(--color-n4); + border-radius: 4px; + margin-bottom: 1px; +} +.room-row:hover { + background: var(--color-shade4); + color: var(--color-n2); +} +.room-row.active { + background: var(--color-shade4); + color: var(--color-n1); +} +.room-row.has-unread:not(.active) { + color: var(--color-n1); +} +.channel-hash { + font-size: 20px; + line-height: 1; + color: var(--color-n4); + margin-right: 6px; +} +.room-row:hover .channel-hash { + color: var(--color-n2); +} +.room-row.active .channel-hash { + color: var(--color-n1); +} +.room-row.has-unread:not(.active) .channel-hash { + color: var(--color-n1); +} +.unread-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-n1); + margin-left: auto; + flex: 0 0 8px; +} +.lock-icon { + font-size: 12px; +} + +/* User panel at sidebar foot */ +main.shell:not(.signed-out) > .sidebar > .user-panel { + height: 52px; + background: rgba(0, 0, 0, 0.25); + padding: 0 8px; + display: flex; + align-items: center; + gap: 4px; + flex: 0 0 52px; + border-top: none; +} +main.shell:not(.signed-out) > .sidebar > .user-panel > #openAuthBtn { + width: 100%; +} +.user-bar { + width: 100%; + display: flex; + align-items: center; + gap: 4px; + border: none; + background: transparent; + padding: 0; + border-radius: 4px; +} +.user-bar.hidden { + display: none; +} +.user-bar-info { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + border-radius: 4px; + padding: 4px; + cursor: pointer; + background: transparent; + border: none; + text-align: left; +} +.user-bar-info:hover { + background: rgba(255, 255, 255, 0.04); +} +.user-bar .user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-green); + color: var(--color-shade8); + font-family: var(--font-ibm); + font-size: 14px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 32px; + position: relative; +} +.user-bar .user-avatar::after { + content: ''; + position: absolute; + right: -2px; + bottom: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--color-green); + border: 2px solid rgba(0, 0, 0, 0.5); +} +.user-bar-text { + display: flex; + flex-direction: column; + min-width: 0; + gap: 0; +} +.user-bar-name { + font-size: 13px; + font-weight: 600; + color: var(--color-n1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 110px; + line-height: 1.2; +} +.user-bar-status { + font-size: 11px; + color: var(--color-n3); + font-family: var(--font-ibm); + line-height: 1.2; +} +.user-bar-actions { + display: flex; + gap: 0; + flex: 0 0 auto; +} +.user-bar-actions .head-icon { + width: 32px; + height: 32px; + border-radius: 4px; + padding: 0; +} +.user-bar-actions .head-icon:hover { + background: rgba(255, 255, 255, 0.04); + color: var(--color-n1); +} + +/* ---- main chat area ---- */ +main.shell:not(.signed-out) .chat-body > .main { + flex: 1; + background: var(--color-shade7); + display: flex; + flex-direction: column; + min-width: 0; + padding: 0; + border-radius: 0; + border: none; +} + +/* Channel header */ +.channel-header { + height: 48px; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex: 0 0 48px; + background: var(--color-shade7); + border-bottom: 1px solid var(--color-shade4); + z-index: 1; +} +.channel-header-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.channel-hash-lg { + font-size: 24px; + color: var(--color-n4); + font-weight: 400; + line-height: 1; +} +.channel-name { + font-size: 16px; + font-weight: 700; + color: var(--color-n1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.channel-header-right { + display: flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; +} +.channel-search { + display: flex; + align-items: center; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + border-radius: 4px; + padding: 0 6px; + height: 24px; + margin-left: 8px; + gap: 4px; +} +.channel-search input { + background: transparent; + border: none; + outline: none; + color: var(--color-n2); + font-size: 12px; + padding: 0 4px; + width: 160px; + height: 100%; + font-family: var(--font-ibm); +} +.channel-search input::placeholder { + color: var(--color-n4); +} +.channel-search svg { + color: var(--color-n4); + flex: 0 0 14px; +} + +.head-icon { + width: 32px; + height: 32px; + border-radius: 4px; + background: transparent; + border: none; + color: var(--color-n3); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} +.head-icon:hover { + color: var(--color-n1); + background: rgba(255, 255, 255, 0.04); +} +.head-icon.hidden { + display: none; +} + +/* Messages */ +main.shell:not(.signed-out) .chat-body > .main > .message-scroll { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 16px 0; + background: var(--color-shade7); +} +main.shell:not(.signed-out) + .chat-body + > .main + > .message-scroll::-webkit-scrollbar { + width: 8px; +} +main.shell:not(.signed-out) + .chat-body + > .main + > .message-scroll::-webkit-scrollbar-thumb { + background: var(--color-shade7); + border-radius: 4px; +} +main.shell:not(.signed-out) + .chat-body + > .main + > .message-scroll::-webkit-scrollbar-thumb:hover { + background: var(--color-shade8); +} +main.shell:not(.signed-out) .chat-body > .main > .message-scroll > .messages { + list-style: none; + margin: 0; + padding: 0; + display: block; +} +.msg { + position: relative; + padding: 2px 16px 2px 72px; + min-height: 22px; +} +.msg.chunk-start { + margin-top: 18px; + padding-top: 4px; + display: grid; + grid-template-columns: 40px 1fr; + gap: 0 16px; + padding-left: 16px; + padding-right: 16px; +} +.msg.chunk-cont { + display: block; + padding-left: 72px; +} +.msg:hover { + background: rgba(4, 4, 5, 0.07); +} +.msg .avatar { + width: 40px; + height: 40px; + font-size: 16px; +} +.msg-col { + min-width: 0; +} +.msg-head { + display: flex; + align-items: baseline; + gap: 6px; + margin-bottom: 2px; +} +.msg-author { + color: var(--color-n1); + font-size: 15px; + font-weight: 500; +} +.msg-time { + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); +} +.msg.chunk-cont .msg-stamp-rail { + display: none; +} +.msg-body { + color: var(--color-n2); + font-size: 15px; + line-height: 1.375; + white-space: pre-wrap; + word-break: break-word; +} +.react-row { + margin-top: 4px; +} +.react-btn { + background: var(--color-shade4); + border: 1px solid var(--color-shade3); + font-size: 13px; +} +.react-btn.mine { + background: rgba(76, 244, 144, 0.08); + border-color: rgba(76, 244, 144, 0.4); + color: var(--color-green); +} + +/* Typing line */ +main.shell:not(.signed-out) .chat-body > .main > .typing { + padding: 0 16px; + height: 24px; + font-size: 13px; + color: var(--color-n3); + font-family: var(--font-inter); + flex: 0 0 24px; +} +.composer-reply { + margin: 0 16px; + padding: 8px 12px; + background: var(--color-shade6); + border: 1px solid var(--color-shade4); + border-bottom: none; + border-radius: 8px 8px 0 0; + color: var(--color-n3); + display: flex; + align-items: center; + gap: 8px; + min-height: 34px; +} +.composer-reply.hidden { + display: none; +} +.composer-reply-label { + color: var(--color-n2); + font-size: 12px; + font-weight: 600; + flex: 0 0 auto; +} +.composer-reply-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} +.composer-reply-cancel { + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--color-n3); + cursor: pointer; + flex: 0 0 auto; +} +.composer-reply-cancel:hover { + background: var(--color-shade4); + color: var(--color-n1); +} +.composer-reply.editing { + border-color: rgba(76, 244, 144, 0.35); +} +.composer-rate-limit { + margin: 0 16px 6px; + padding: 7px 10px; + border: 1px solid rgba(255, 162, 92, 0.38); + border-radius: 8px; + background: rgba(255, 162, 92, 0.08); + color: var(--color-orange); + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0; +} +.composer-rate-limit[hidden] { + display: none; +} + +/* Composer */ +main.shell:not(.signed-out) .chat-body > .main > .composer { + padding: 0 16px 24px; + background: var(--color-shade7); + flex: 0 0 auto; + display: block; +} +main.shell:not(.signed-out) .chat-body > .main > .composer.hidden { + display: none; +} +.composer { + background: var(--color-shade7) !important; + border: 1px solid var(--color-shade4) !important; + border-radius: 8px; + padding: 0 16px !important; + height: 44px; + display: flex !important; + align-items: center; + gap: 4px; + grid-template-columns: none !important; +} +.composer-attach { + background: transparent; + border: none; + color: var(--color-n3); + cursor: pointer; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + flex: 0 0 28px; +} +.composer-attach:hover { + color: var(--color-n1); +} +.composer-attach:disabled { + opacity: 0.35; + cursor: not-allowed; + pointer-events: none; +} +.composer #messageInput { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--color-n1); + font-size: 15px; + padding: 0 8px; + height: 44px; + width: auto; +} +.composer #messageInput::placeholder { + color: var(--color-n4); +} +.composer.rate-limited { + border-color: rgba(255, 162, 92, 0.45) !important; + background: rgba(255, 162, 92, 0.06) !important; +} +.composer-right { + display: flex; + gap: 2px; + align-items: center; + flex: 0 0 auto; +} +.composer-icon { + background: transparent; + border: none; + color: var(--color-n3); + cursor: pointer; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + padding: 0; + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 800; +} +.composer-icon:hover { + color: var(--color-n1); +} +.composer-gif { + border: 2px solid currentColor !important; + border-radius: 4px; + font-size: 9px; + width: auto; + padding: 0 4px; + line-height: 1; + height: 18px; +} +.composer-send { + background: var(--color-green); + border: none; + color: var(--color-shade8); + cursor: pointer; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + padding: 0; + margin-left: 4px; + flex: 0 0 28px; +} +.composer-send:hover:not(:disabled) { + background: var(--color-white); +} +.composer-send:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pending-attachments { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 16px; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + border-radius: 8px; + margin: 4px 16px; +} +.pending-attachments[hidden] { + display: none; +} +.pending-att { + position: relative; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + border-radius: 6px; + padding: 6px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 80px; + max-width: 140px; +} +.pending-att-preview { + width: 96px; + height: 64px; + border-radius: 4px; + display: grid; + place-items: center; + background: var(--color-shade6); + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.pending-att-meta { + font-size: 10px; + color: var(--color-n4); + font-family: var(--font-ibm); + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.pending-att-x { + position: absolute; + top: -6px; + right: -6px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--color-shade8); + color: var(--color-n2); + border: 1px solid var(--color-shade4); + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} +.pending-att-x:hover { + color: var(--color-red, #f04747); +} + +.msg-attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} +.msg-att-preview { + display: inline-flex; + padding: 0; + border: 0; + background: transparent; + cursor: zoom-in; +} +.msg-att-img { + max-width: 320px; + max-height: 220px; + border-radius: 6px; + object-fit: cover; + border: 1px solid var(--color-shade4); +} +.msg-att-preview:hover .msg-att-img { + border-color: var(--color-n4); +} +.msg-att-file { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + border-radius: 6px; + padding: 8px 10px; + color: var(--color-n2); + text-decoration: none; + font-size: 13px; + font-family: var(--font-ibm); +} +.msg-att-file:hover { + background: var(--color-shade6); + color: var(--color-n1); +} +.msg-att-size { + color: var(--color-n4); + font-size: 11px; +} + +.image-lightbox { + position: fixed; + inset: 0; + z-index: 3000; + display: none; + align-items: center; + justify-content: center; + padding: 48px; + background: rgba(0, 0, 0, 0.82); +} +.image-lightbox.open { + display: flex; +} +.image-lightbox-frame { + position: relative; + max-width: min(1080px, 100%); + max-height: 100%; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + gap: 12px; +} +.image-lightbox img { + max-width: 100%; + max-height: calc(100vh - 128px); + border-radius: 8px; + object-fit: contain; + box-shadow: 0 16px 64px rgba(0, 0, 0, 0.55); +} +.image-lightbox-caption { + color: var(--color-n2); + font-size: 13px; + text-align: center; + word-break: break-word; +} +.image-lightbox-close { + position: fixed; + top: 18px; + right: 18px; + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid var(--color-shade3); + background: var(--color-shade7); + color: var(--color-n2); + cursor: pointer; + font-size: 20px; + line-height: 1; +} +.image-lightbox-close:hover { + color: var(--color-n1); + border-color: var(--color-n4); +} + +/* Members panel */ +main.shell:not(.signed-out) .chat-body > .members { + flex: 0 0 240px; + background: var(--color-shade7); + border-left: 1px solid var(--color-shade4); +} +.members.no-room { + display: none; +} + +/* Auto-hide the chat shell sections during the anon (signed-out) view */ +main.shell.signed-out > .rail, +main.shell.signed-out > .sidebar, +main.shell.signed-out > .chat-column, +main.shell.signed-out > .main, +main.shell.signed-out > .members { + display: none; +} + +/* Cover the interface until restoreSession resolves to keep valid sessions + from flashing the login card. */ +.boot-splash { + position: fixed; + inset: 0; + z-index: 9999; + background: var(--color-shade7); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + color: var(--color-green); + transition: opacity 200ms ease; +} +.boot-splash svg { + animation: boot-pulse 1.4s ease-in-out infinite; +} +.boot-splash-label { + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-n4); +} +.boot-splash.fading { + opacity: 0; + pointer-events: none; +} +@keyframes boot-pulse { + 0%, + 100% { + opacity: 0.4; + transform: scale(0.95); + } + 50% { + opacity: 1; + transform: scale(1); + } +} +.app.loading { + display: none !important; +} + +/* Channel-header overlay panels (pinned messages / search results) */ +main.shell:not(.signed-out) .chat-body > .main { + position: relative; +} +.header-overlay { + position: absolute; + top: 0; + right: 0; + width: 420px; + max-width: 90%; + max-height: calc(100vh - 96px); + background: var(--color-shade6); + border-left: 1px solid var(--color-shade8); + border-bottom: 1px solid var(--color-shade8); + border-radius: 0 0 0 8px; + box-shadow: -4px 8px 20px rgba(0, 0, 0, 0.5); + z-index: 10; + display: flex; + flex-direction: column; + overflow: hidden; +} +.header-overlay[hidden] { + display: none; +} +.overlay-head { + height: 48px; + flex: 0 0 48px; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--color-shade8); + font-size: 14px; + font-weight: 600; + color: var(--color-n1); +} +.overlay-close { + background: transparent; + border: none; + color: var(--color-n3); + cursor: pointer; + font-size: 22px; + line-height: 1; + padding: 4px 8px; +} +.overlay-close:hover { + color: var(--color-n1); +} +.overlay-list { + list-style: none; + margin: 0; + padding: 8px; + overflow-y: auto; + flex: 1; + min-height: 0; +} +.overlay-msg { + padding: 10px 12px; + border-radius: 4px; + background: var(--color-shade5); + margin-bottom: 6px; +} +.overlay-msg:hover { + background: var(--color-shade4); + cursor: pointer; +} +.overlay-msg-head { + display: flex; + gap: 8px; + align-items: baseline; + margin-bottom: 4px; +} +.overlay-msg-author { + color: var(--color-n1); + font-weight: 600; + font-size: 13px; +} +.overlay-msg-time { + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); +} +.overlay-msg-body { + color: var(--color-n2); + font-size: 14px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} +.overlay-empty { + padding: 16px; + color: var(--color-n4); + font-size: 13px; + text-align: center; +} + +/* Absolute positioning keeps hover controls from resizing message rows. */ +.msg-toolbar { + position: absolute; + top: -14px; + right: 16px; + align-items: center; + background: var(--color-shade7); + border: 1px solid var(--color-shade8); + border-radius: 6px; + padding: 2px 4px; + display: none; + gap: 2px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + z-index: 5; +} +.msg:hover .msg-toolbar, +.msg:focus-within .msg-toolbar { + display: flex; +} +.msg-toolbar button { + background: transparent; + border: none; + color: var(--color-n3); + cursor: pointer; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border-radius: 4px; +} +.msg-toolbar button:hover { + color: var(--color-n1); + background: var(--color-shade4); +} +.msg-toolbar button.active { + color: var(--color-green); +} +.msg-toolbar .toolbar-react { + color: var(--color-n2); + font-size: 17px; +} +.msg-toolbar-sep { + width: 1px; + height: 20px; + margin: 0 2px; + background: var(--color-shade4); +} +.thread-pill { + display: inline-flex; + align-items: center; + gap: 3px; + margin-top: 5px; + padding: 0; + border: none; + background: transparent; + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); + cursor: pointer; +} +.thread-pill:hover { + color: var(--color-green); +} +.msg-toolbar button.has-thread { + color: var(--color-green); +} + +.thread-panel { + width: 440px; + max-width: min(92%, 440px); +} +.thread-root { + padding: 12px 14px; + border-bottom: 1px solid var(--color-shade8); + background: var(--color-shade6); +} +.thread-root-label { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 6px; +} +.thread-messages { + list-style: none; + margin: 0; + padding: 8px; + overflow-y: auto; + flex: 1; + min-height: 160px; +} +.thread-msg { + position: relative; + padding: 8px 10px; + border-radius: 4px; + margin-bottom: 4px; + background: var(--color-shade5); +} +.thread-msg-head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 3px; +} +.thread-msg-author { + color: var(--color-n1); + font-size: 13px; + font-weight: 600; +} +.thread-msg-time { + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); +} +.thread-msg-body { + color: var(--color-n2); + font-size: 14px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} +.thread-msg-actions { + margin-left: auto; + display: none; + gap: 2px; +} +.thread-msg:hover .thread-msg-actions, +.thread-msg:focus-within .thread-msg-actions { + display: inline-flex; +} +.thread-msg-actions button { + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--color-n3); + cursor: pointer; +} +.thread-msg-actions button:hover { + background: var(--color-shade4); + color: var(--color-n1); +} +.thread-msg-actions button.danger:hover { + color: var(--color-red, #f04747); +} +.thread-composer { + border-top: 1px solid var(--color-shade8); + padding: 10px; + display: flex; + gap: 8px; + background: var(--color-shade6); +} +.thread-composer input { + flex: 1; + min-width: 0; + height: 34px; +} +.thread-composer button { + flex: 0 0 auto; + height: 34px; +} + +.msg.pinned { + background: rgba(76, 244, 144, 0.03); +} +.pinned-marker { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 11px; + color: var(--color-green); + font-family: var(--font-ibm); + margin-left: 6px; +} + +/* Search input is a fixed-width inline field (no awkward expand) */ +.search-submit { + background: transparent; + border: none; + color: var(--color-n4); + cursor: pointer; + padding: 0 2px; + display: inline-flex; + align-items: center; +} +.search-submit:hover { + color: var(--color-n1); +} diff --git a/spacetime-presence-ts/example/public/index.html b/spacetime-presence-ts/example/public/index.html new file mode 100644 index 00000000000..9a7fcaab5a0 --- /dev/null +++ b/spacetime-presence-ts/example/public/index.html @@ -0,0 +1,560 @@ + + + + + + + SpacetimeDB Chat + + + + + +
+ + SpacetimeDB Chat +
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ # + +
+
+ + + +
+
+
+
+
+
    +
    + + + +
    + + + +
    + + + + +
    +
    + + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + diff --git a/spacetime-presence-ts/example/public/styles.css b/spacetime-presence-ts/example/public/styles.css new file mode 100644 index 00000000000..3000f6db4d0 --- /dev/null +++ b/spacetime-presence-ts/example/public/styles.css @@ -0,0 +1,1160 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap'); + +:root { + /* Tokens match spacetime-web/spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter Variable', 'Inter', sans-serif; + --font-source: 'Source Code Pro Variable', 'Source Code Pro', monospace; + --font-ibm: 'IBM Plex Mono', monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-green-25: #4cf49040; + --color-green-50: #4cf49080; + --color-green-75: #4cf490bf; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-yellow-10: #fbdc8e1a; + --color-yellow-20: #fbdc8e33; + --color-purple: #a880ff; + --color-purple-2: #8a38f5; + --color-orange: #ff9e9e; + --color-blue: #02befa; + --color-blue-10: #02befa1a; + --color-blue-20: #02befa33; + --color-pink: #ff80fb; + --color-teal: #00ccb4; + --color-red: #ff4c4c; + --color-brown: #3b3b3b; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n6: #202126; + --color-n7: #050505; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade2: #122530; + --color-shade3: #122129; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --color-border: var(--color-shade4); + --color-text: var(--color-n1); + --color-muted: var(--color-n4); + + --25px: 1.5625rem; + --28px: 1.75rem; + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + height: 100%; +} +body { + font-family: var(--font-inter); + color: var(--color-white); + background: var(--color-shade7); + overflow: hidden; +} +.hidden { + display: none !important; +} +[hidden] { + display: none !important; +} +.mono { + font-family: var(--font-ibm); +} +::selection { + background: var(--color-green); + color: var(--color-n8); +} + +.app { + width: 100%; + height: 100dvh; + padding: 12px; +} +.app.signed-out { + display: flex; + align-items: flex-start; + justify-content: center; + padding: 48px 32px; +} + +.shell { + width: 100%; + height: calc(100dvh - 24px); + border: 1px solid #17303b; + border-radius: var(--radius-lg); + overflow: hidden; + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + box-shadow: inset 0 1px 0 #26435166; + display: grid; + grid-template-columns: 72px 300px minmax(0, 1fr) 280px; +} +.shell.signed-out { + width: min(540px, 100%); + height: auto; + min-height: 0; + display: flex; + flex-direction: column; + background: transparent; + border: none; + border-radius: 0; + box-shadow: none; +} +.shell.signed-out .rail, +.shell.signed-out .sidebar, +.shell.signed-out .main, +.shell.signed-out .members { + display: none; +} + +.anon-view { + display: none; +} +.shell.signed-out .anon-view { + display: flex; + position: fixed; + inset: 0; + z-index: 50; + align-items: center; + justify-content: center; + padding: 24px; +} +.anon-body { + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.shell.signed-out .anon-view { + background: + radial-gradient( + ellipse 80% 50% at 50% 0%, + var(--color-green-20), + transparent 60% + ), + var(--color-shade7); +} + +.rail { + background: var(--color-shade8); + border-right: 1px solid var(--color-shade4); + padding: 10px 8px; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 10px; +} +.home-btn { + width: 56px; + height: 56px; + margin: 0 auto; + border: 1px solid #275061; + border-radius: 16px; + background: linear-gradient(180deg, #1f6f64, #174f46); + color: var(--color-white); + font-family: var(--font-ibm); + font-size: 16px; + letter-spacing: 0.07em; + cursor: pointer; +} +.home-btn:hover { + filter: brightness(1.07); +} + +.server-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + align-content: start; + gap: 8px; + overflow: auto; +} +.server-pill { + width: 56px; + height: 56px; + margin: 0 auto; + border: 1px solid var(--color-shade1); + border-radius: 16px; + background: var(--color-shade6); + color: var(--color-n2); + font-family: var(--font-ibm); + font-size: 12px; + display: grid; + place-items: center; + cursor: pointer; + position: relative; +} +.server-pill:hover { + background: var(--color-shade4); + color: var(--color-n1); +} +.server-pill.active { + border-color: var(--color-green); + color: var(--color-green); + background: var(--color-shade3); +} +.server-pill.unread::before { + content: ''; + position: absolute; + left: -8px; + width: 4px; + height: 18px; + border-radius: 999px; + background: var(--color-white); +} + +.sidebar { + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + border-right: 1px solid var(--color-shade4); + min-height: 0; + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; +} +.server-head { + border-bottom: 1px solid var(--color-shade4); + padding: 10px 12px; + display: grid; + gap: 8px; +} +.brand { + display: inline-flex; + align-items: center; + gap: 10px; +} +.brand-wordmark { + display: block; + height: 26px; +} +.brand-sub { + padding: 2px 7px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 10px; + color: #9cb1cb; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.conn { + width: fit-content; + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid #27414e; + border-radius: 999px; + padding: 4px 10px; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-n3); +} +.conn-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-n4); +} +.conn.good { + border-color: #31684c; + color: var(--color-green); +} +.conn.good .conn-dot { + background: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} +.conn.bad { + border-color: #6a2929; + color: var(--color-orange); +} +.conn.bad .conn-dot { + background: #ff4c4c; +} + +.channel-label { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 12px 6px; + font-family: var(--font-ibm); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-n4); +} +.channel-add { + background: transparent; + border: none; + color: var(--color-n4); + cursor: pointer; + font-size: 18px; + line-height: 1; + width: 22px; + height: 22px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} +.channel-add:hover { + color: var(--color-n1); + background: var(--color-shade4); +} + +input, +select { + width: 100%; + height: 40px; + background: var(--color-shade6); + border: 1px solid var(--color-shade4); + border-radius: 8px; + color: var(--color-n1); + font-size: 13px; + padding: 0 20px; + outline: none; + transition: border-color 0.15s ease; +} +input:hover, +select:hover { + border-color: var(--color-shade1); +} +input:focus, +select:focus { + border-color: var(--color-green); +} + +/* Buttons match spacetimedb.com Button.module.css: + default (tertiary) = shade7 bg, n2 text, shade4 hover bg, green active text + primary = n3 bg, n8 text, white hover bg, green active bg + secondary = transparent bg, shade1 border, green-on-active text + ghost / text = transparent bg, green text, shade5 hover bg */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 8px 16px; + border-radius: 4px; + border: 1px solid transparent; + background: var(--color-shade7); + color: var(--color-n2); + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: + background 0.2s, + border-color 0.2s, + color 0.2s; +} +.btn:hover:not(:disabled) { + background: var(--color-shade4); + color: var(--color-white); +} +.btn:active:not(:disabled) { + background: var(--color-shade4); + color: var(--color-green); +} +.btn:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn.primary { + background: var(--color-n3); + border: 2px solid var(--color-n3); + color: var(--color-n8); +} +.btn.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); +} +.btn.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); + color: var(--color-n8); +} +.btn.primary:disabled { + pointer-events: none; + background-color: var(--color-n4); + border-color: var(--color-n4); +} + +.btn.secondary { + background: transparent; + border: 1px solid var(--color-shade1); + color: var(--color-white); +} +.btn.secondary:hover:not(:disabled) { + background: transparent; + border-color: var(--color-white); + color: var(--color-white); +} +.btn.secondary:active:not(:disabled) { + background: transparent; + border-color: var(--color-green); + color: var(--color-green); +} + +.btn.ghost { + background: transparent; + border: none; + color: var(--color-green); +} +.btn.ghost:hover:not(:disabled) { + background: var(--color-shade5); + color: var(--color-green); +} +.btn.tiny { + height: auto; + padding: 4px 9px; + border-radius: 999px; + font-size: 11px; + font-family: var(--font-ibm); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.room-list-wrap { + min-height: 0; + overflow: auto; + padding: 8px; +} +.rooms { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; +} +.category { + margin-top: 4px; +} +.category > summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + padding: 12px 6px 4px; + font-family: var(--font-ibm); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-n4); + font-weight: 600; + user-select: none; +} +.category > summary::-webkit-details-marker { + display: none; +} +.category-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + flex: 0 0 12px; + transition: transform 120ms ease; + color: currentColor; +} +.category-chevron svg { + width: 12px; + height: 12px; + display: block; +} +.category:not([open]) .category-chevron { + transform: rotate(-90deg); +} +.category > summary:hover { + color: var(--color-n2); +} +.category-rooms { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.room-row { + border: 1px solid transparent; + border-radius: 4px; + padding: 5px 8px; + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + color: var(--color-n3); + font-size: 15px; + position: relative; +} +.channel-hash { + color: var(--color-n4); + font-weight: 400; + font-size: 18px; + line-height: 1; + margin-right: 2px; +} +.room-row:hover { + background: var(--color-shade4); + color: var(--color-n1); +} +.room-row:hover .channel-hash { + color: var(--color-n2); +} +.room-row.active { + background: var(--color-shade3); + color: var(--color-n1); +} +.room-row.active .channel-hash { + color: var(--color-n1); +} +.room-row.has-unread:not(.active) { + color: var(--color-n1); +} +.room-row.has-unread:not(.active) .channel-hash { + color: var(--color-n1); +} +.room-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.unread-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-green); + flex: 0 0 8px; + margin-left: auto; +} +.small { + color: var(--color-n4); + font-size: 11px; +} +.badge { + border-radius: 999px; + padding: 1px 6px; + background: #1f3f55; + color: #98d8ff; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.room-row .row-actions { + display: none; + gap: 4px; + align-items: center; +} +.room-row:hover .row-actions { + display: inline-flex; +} +.room-row .lock-icon { + color: var(--color-n4); + font-size: 12px; +} + +.user-panel { + border-top: 1px solid var(--color-shade4); + padding: 10px; +} +#openAuthBtn { + width: 100%; +} + +.main { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto auto auto; +} +.main-head { + border-bottom: 1px solid var(--color-shade4); + padding: 0 14px; + height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.main-head h2 { + margin: 0; + color: var(--color-n1); + font-size: 16px; + font-weight: 600; + letter-spacing: 0; +} +.main-meta { + display: flex; + align-items: center; + gap: 4px; +} + +.message-scroll { + min-height: 0; + overflow: auto; +} +.messages { + list-style: none; + margin: 0; + padding: 12px 0 16px; +} +.avatar { + width: var(--av-size, 32px); + height: var(--av-size, 32px); + border-radius: 50%; + background: var(--av-bg, var(--color-shade3)); + color: #fff; + font-family: var(--font-inter); + font-size: calc(var(--av-size, 32px) * 0.42); + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 var(--av-size, 32px); + user-select: none; +} +.msg { + position: relative; + padding: 2px 16px 2px 16px; + display: grid; + grid-template-columns: 40px 1fr; + gap: 0 12px; +} +.msg.chunk-start { + margin-top: 14px; + padding-top: 4px; +} +.msg.chunk-cont { + padding-top: 0; + padding-bottom: 0; +} +.msg:hover { + background: rgba(255, 255, 255, 0.025); +} +.msg-stamp-rail { + width: 36px; + height: 18px; + display: inline-block; +} +.msg-col { + min-width: 0; +} +.msg-head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 1px; +} +.msg-author { + color: var(--color-n1); + font-size: 14px; + font-weight: 600; +} +.msg-time { + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); +} +/* Chunk-start rows carry timestamps. */ +.msg-body { + color: var(--color-n2); + font-size: 14px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} +.edited-tag { + color: var(--color-n4); + font-size: 10px; + font-family: var(--font-ibm); + margin-left: 4px; +} +.reply-reference { + display: inline-flex; + position: relative; + align-items: center; + max-width: min(520px, 100%); + gap: 6px; + margin: -3px 0 2px 28px; + padding: 0; + border: none; + background: transparent; + color: var(--color-n4); + font: inherit; + font-size: 12px; + line-height: 1.3; + text-align: left; + cursor: pointer; +} +.reply-reference::before { + content: ''; + position: absolute; + left: -26px; + top: 4px; + width: 20px; + height: 8px; + border-left: 1px solid var(--color-n4); + border-top: 1px solid var(--color-n4); + border-radius: 6px 0 0 0; + opacity: 0.5; +} +.reply-reference:hover { + color: var(--color-n2); +} +.reply-author { + color: var(--color-n2); + font-weight: 600; +} +.reply-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.react-row { + margin-top: 4px; + display: flex; + gap: 4px; + flex-wrap: wrap; + align-items: center; +} +.react-btn { + border: 1px solid var(--color-shade1); + border-radius: 999px; + background: var(--color-shade6); + color: var(--color-n3); + padding: 3px 8px; + font-size: 11px; + cursor: pointer; +} +.react-btn:hover { + border-color: var(--color-white); + color: var(--color-white); +} +.react-btn.mine { + border-color: #2f7452; + background: #163629; + color: #b4f7d1; +} + +.empty-state { + padding: 40px 16px; + text-align: left; + color: var(--color-n3); +} +.empty-state h3 { + margin: 0 0 6px; + font-size: 28px; + line-height: 1.2; + font-weight: 700; + color: var(--color-n1); + letter-spacing: -0.02em; +} +.empty-state p { + margin: 0 0 14px; + color: var(--color-n4); + font-size: 14px; +} + +.sidebar-empty { + padding: 24px 12px; + color: var(--color-n4); + font-size: 13px; + display: flex; + flex-direction: column; + gap: 12px; +} +.sidebar-empty p { + margin: 0; +} + +.landing { + margin: 22px auto; + width: min(640px, 100%); + border: 1px solid #1f4a5d; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade2), var(--color-shade3)); + padding: 22px; + display: grid; + gap: 10px; +} +.landing h3 { + margin: 0; + color: var(--color-n1); + font-size: 31px; + font-weight: 500; + line-height: 1; + letter-spacing: -0.04em; +} +.landing p { + margin: 0; + color: var(--color-n3); + font-size: 16px; +} + +.typing { + min-height: 28px; + padding: 6px 14px; + color: var(--color-n4); + font-size: 12px; +} +.composer { + padding: 10px 12px; + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} +.head-icon { + background: transparent; + border: none; + color: var(--color-n3); + cursor: pointer; + padding: 6px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.head-icon:hover { + color: var(--color-n1); + background: var(--color-shade4); +} + +.user-bar { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--color-n1); + cursor: pointer; + text-align: left; +} +.user-bar:hover { + background: var(--color-shade4); +} +.user-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--color-green); + color: var(--color-shade7); + display: inline-flex; + align-items: center; + justify-content: center; + font-family: var(--font-ibm); + font-size: 11px; + font-weight: 700; + flex: 0 0 28px; +} +.user-bar-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 600; +} +.user-bar-cog { + color: var(--color-n4); + flex: 0 0 14px; +} +.user-bar:hover .user-bar-cog { + color: var(--color-n1); +} + +.user-menu { + position: fixed; + bottom: 60px; + left: 88px; + width: 260px; + background: var(--color-shade5); + border: 1px solid var(--color-shade3); + border-radius: var(--radius); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5); + padding: 12px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 10px; +} +.user-menu[hidden] { + display: none; +} +.user-menu-row { + display: flex; + flex-direction: column; + gap: 4px; +} +.user-menu-row label { + font-family: var(--font-ibm); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-n4); +} +.user-menu-sep { + height: 1px; + background: var(--color-shade3); + margin: 2px 0; +} +.btn.danger { + color: var(--color-orange); + border-color: #4a2222; +} +.btn.danger:hover { + background: #2a1212; + border-color: #6a2929; +} +.btn.block { + width: 100%; +} + +.toast-host { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 8px; + z-index: 2000; + pointer-events: none; +} +.toast { + background: var(--color-shade5); + border: 1px solid #2f644a; + color: var(--color-green); + font-family: var(--font-ibm); + font-size: 12px; + padding: 8px 14px; + border-radius: 999px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + opacity: 1; + transition: + opacity 220ms ease, + transform 220ms ease; +} +.toast.error { + border-color: #6a2929; + color: var(--color-orange); +} +.toast.hide { + opacity: 0; + transform: translateY(8px); +} + +.members.collapsed { + display: none; +} +.members { + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + border-left: 1px solid var(--color-shade4); + min-height: 0; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); +} +.members-head { + border-bottom: 1px solid var(--color-shade4); + padding: 12px; + display: flex; + align-items: center; + justify-content: space-between; +} +.members-head h2 { + margin: 0; + color: var(--color-n1); + font-size: 28px; + font-weight: 500; + letter-spacing: -0.04em; +} +.profile { + border-bottom: 1px solid var(--color-shade4); + padding: 10px 12px; + display: grid; + gap: 8px; +} +.profile-row { + display: grid; + grid-template-columns: 1fr 90px; + gap: 8px; +} +.member-scroll { + min-height: 0; + overflow: auto; +} +.member-list { + list-style: none; + margin: 0; + padding: 8px 6px; + display: flex; + flex-direction: column; + gap: 1px; +} +.member-section { + font-family: var(--font-ibm); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-n4); + font-weight: 600; + padding: 14px 6px 4px; +} +.member-row { + border-radius: 4px; + padding: 4px 6px; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} +.member-row:hover { + background: var(--color-shade4); +} +.member-row.offline { + opacity: 0.4; +} +.member-row.offline:hover { + opacity: 0.7; +} +.member-avatar-wrap { + position: relative; + flex: 0 0 32px; +} +.presence-pip { + position: absolute; + right: -2px; + bottom: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--color-n4); + border: 2px solid var(--color-shade5); +} +.presence-pip.online { + background: var(--color-green); +} +.presence-pip.away { + background: #f3c460; +} +.presence-pip.dnd { + background: #ff4c4c; +} +.presence-pip.invisible, +.presence-pip.offline { + background: var(--color-n4); +} +.member-name { + flex: 1; + font-size: 14px; + color: var(--color-n2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.member-you { + color: var(--color-n4); + font-size: 12px; + font-weight: 400; +} +.empty { + padding: 12px; + color: var(--color-n4); + font-size: 12px; +} + +.modal { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 16px; + background: #00000099; + z-index: 50; +} +.modal.open { + display: flex; +} +.modal-card { + width: min(420px, 100%); + border: 1px solid #1f4a5d; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + box-shadow: 0 22px 52px #00000088; +} +.modal-head { + border-bottom: 1px solid var(--color-shade4); + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-head h3 { + margin: 0; + color: var(--color-n1); + font-size: 16px; +} +.modal-body { + padding: 12px; + display: grid; + gap: 8px; +} +.modal-actions { + display: flex; + gap: 8px; +} + +@media (max-width: 1300px) { + .shell { + grid-template-columns: 72px 284px minmax(0, 1fr); + } + .members { + display: none; + } +} +@media (max-width: 980px) { + .shell { + grid-template-columns: 1fr; + } + .rail, + .sidebar { + display: none; + } +} diff --git a/spacetime-presence-ts/example/public/ui.js b/spacetime-presence-ts/example/public/ui.js new file mode 100644 index 00000000000..5b854880674 --- /dev/null +++ b/spacetime-presence-ts/example/public/ui.js @@ -0,0 +1,1753 @@ +import { + activeRoom, + activeServer, + amServerOwner, + attachmentsByMessage, + canDeleteMessage, + canEditMessage, + globalPresenceBySubject, + hex, + latestMessageByRoom, + messageAuthorName, + messageById, + messageSummary, + myMemberships, + myReadCursorByRoom, + myServers, + roomMembers, + roomsInServer, + statusOf, + threadMessageById, + threadMessagesForRoot, + typingForRoom, + userByHex, + userByUserId, +} from './chat-model.js'; +import { applyChatData, chatState as state } from './chat-state.js'; + +const $ = id => document.getElementById(id); +const REACTIONS = [ + { id: '+1', label: '\u{1F44D}' }, + { id: 'heart', label: '\u2764\uFE0F' }, + { id: 'joy', label: '\u{1F602}' }, + { id: 'wow', label: '\u{1F62E}' }, + { id: 'sad', label: '\u{1F622}' }, + { id: 'fire', label: '\u{1F525}' }, +]; + +let typingTimer = null; +let typingRenewTimer = null; +let pendingAtts = []; // { id, name, mimeType, bytes } +let pendingAttSeq = 0; +let replyTargetId = null; +let editTargetId = null; +let activeThreadRootMessageId = null; +let threadEditTargetId = null; +let composerRateLimitTimer = null; +const ATT_MAX_BYTES = 4_000_000; +const ATT_MAX_COUNT = 5; +const AUTH_TOKEN_KEY = 'chat:auth_token'; +const STDB_TOKEN_KEY = 'chat:stdb_token'; +const attachmentBlobUrls = new Map(); + +function fmtBytes(n) { + if (!Number.isFinite(n)) return '?'; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + +function authHeaderCandidates() { + try { + const tokens = [ + localStorage.getItem(AUTH_TOKEN_KEY), + localStorage.getItem(STDB_TOKEN_KEY), + ].filter((token, idx, arr) => token && arr.indexOf(token) === idx); + return tokens.map(token => ({ authorization: `Bearer ${token}` })); + } catch { + return []; + } +} + +async function attachmentBlobUrl(fileId, url) { + const cacheKey = `file:${fileId}`; + let blobUrl = attachmentBlobUrls.get(cacheKey); + if (blobUrl) return blobUrl; + if (window.chat?.getAttachmentFile) { + const file = await window.chat.getAttachmentFile(BigInt(fileId)); + blobUrl = URL.createObjectURL( + new Blob([file.bytes], { type: file.mimeType }) + ); + } else if (url) { + const attempts = [...authHeaderCandidates(), {}]; + let res = null; + for (const headers of attempts) { + res = await fetch(url, { headers, credentials: 'same-origin' }); + if (res.ok) break; + if (res.status !== 401 && res.status !== 403) break; + } + if (!res) throw new Error('no_response'); + if (!res.ok) throw new Error(`http_${res.status}`); + blobUrl = URL.createObjectURL(await res.blob()); + } else { + throw new Error('missing_file_url'); + } + attachmentBlobUrls.set(cacheKey, blobUrl); + return blobUrl; +} + +async function hydrateAttachmentImages(root = document) { + const imgs = [...root.querySelectorAll('img[data-file-id]')]; + for (const img of imgs) { + const fileId = img.dataset.fileId; + const url = img.dataset.fileUrl; + if (!fileId || img.dataset.loaded === '1') continue; + img.dataset.loaded = '1'; + try { + const blobUrl = await attachmentBlobUrl(fileId, url); + img.src = blobUrl; + const preview = img.closest('[data-preview-file-id]'); + if (preview) preview.dataset.previewSrc = blobUrl; + } catch (err) { + img.dataset.loaded = '0'; + img.classList.add('broken'); + img.alt = `${img.alt || 'attachment'} (failed to load)`; + console.warn('attachment image load failed', url, err); + } + } +} + +function closeImageLightbox() { + const box = $('imageLightbox'); + box.classList.remove('open'); + $('imageLightboxImg').removeAttribute('src'); + $('imageLightboxCaption').textContent = ''; +} + +function showImageLightbox(src, name) { + $('imageLightboxImg').src = src; + $('imageLightboxImg').alt = name || 'attachment'; + $('imageLightboxCaption').textContent = name || ''; + $('imageLightbox').classList.add('open'); + $('imageLightboxClose').focus(); +} + +async function openAttachmentPreview(button) { + const fileId = button.dataset.previewFileId; + const url = button.dataset.previewUrl; + const name = button.dataset.previewName || 'attachment'; + if (!fileId) return; + try { + const src = + button.dataset.previewSrc || (await attachmentBlobUrl(fileId, url)); + button.dataset.previewSrc = src; + showImageLightbox(src, name); + } catch (err) { + console.warn('attachment preview failed', err); + setResult('Image preview failed.', false); + } +} + +document.addEventListener('click', e => { + const button = e.target.closest('[data-preview-file-id]'); + if (!button) return; + e.preventDefault(); + openAttachmentPreview(button); +}); +$('imageLightboxClose').addEventListener('click', closeImageLightbox); +$('imageLightbox').addEventListener('click', e => { + if (e.target === $('imageLightbox')) closeImageLightbox(); +}); +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && $('imageLightbox').classList.contains('open')) { + closeImageLightbox(); + } +}); + +function focusAuthPanel() { + document.querySelector('#auth-panel input')?.focus(); +} + +function setResult(text, ok = true) { + if (!text) return; + const host = + document.getElementById('toastHost') || + (() => { + const d = document.createElement('div'); + d.id = 'toastHost'; + d.className = 'toast-host'; + document.body.appendChild(d); + return d; + })(); + const el = document.createElement('div'); + el.className = 'toast' + (ok ? '' : ' error'); + el.textContent = text; + host.appendChild(el); + setTimeout(() => { + el.classList.add('hide'); + setTimeout(() => el.remove(), 250); + }, 2200); +} +function reportAsync(label, promise) { + Promise.resolve(promise).catch(err => + setResult(`${label} failed: ${err.message ?? err}`, false) + ); +} +function resetAtMs(row) { + return Number(row.resetAt.microsSinceUnixEpoch) / 1000; +} +function currentSendLimit() { + if (!state.authenticated || !activeRoom()) return null; + const row = (state.rateLimitStatus || []).find( + r => r.scope === 'chat.send_message' + ); + if (!row || row.remaining > 0) return null; + const remainingSeconds = Math.ceil((resetAtMs(row) - Date.now()) / 1000); + return remainingSeconds > 0 ? { ...row, remainingSeconds } : null; +} +function renderComposerRateLimit() { + const el = $('composerRateLimit'); + if (!el) return; + const limited = currentSendLimit(); + el.hidden = !limited; + el.textContent = limited + ? `Rate limited. Try again in ${limited.remainingSeconds}s.` + : ''; +} +function updateRateLimitTicker() { + const limited = currentSendLimit(); + if (limited && !composerRateLimitTimer) { + composerRateLimitTimer = setInterval(updateComposerState, 250); + } else if (!limited && composerRateLimitTimer) { + clearInterval(composerRateLimitTimer); + composerRateLimitTimer = null; + } +} +function renderUserBar() { + if (!state.authenticated) return; + const users = userByHex(); + const me = users.get(state.meHex); + const fallback = + state.userEmail || (state.meHex ? state.meHex.slice(-6) : ''); + const label = me?.displayName || fallback; + const avatarSeed = label || '?'; + $('userBarName').textContent = label; + $('userAvatar').textContent = (avatarSeed[0] || '?').toUpperCase(); + const dn = $('displayName'); + if (dn && document.activeElement !== dn) dn.value = me?.displayName || ''; +} + +function setAuthedUi(user) { + const openBtn = $('openAuthBtn'); + const userBar = $('openUserMenuBtn'); + const shell = document.querySelector('.shell'); + const app = document.querySelector('.app'); + if (user) { + openBtn.classList.add('hidden'); + userBar.classList.remove('hidden'); + state.userEmail = user.email || ''; + renderUserBar(); + shell.classList.remove('signed-out'); + app.classList.remove('signed-out'); + } else { + openBtn.classList.remove('hidden'); + userBar.classList.add('hidden'); + $('userBarName').textContent = ''; + $('userAvatar').textContent = ''; + state.userEmail = ''; + shell.classList.add('signed-out'); + app.classList.add('signed-out'); + closeUserMenu(); + } +} +function updateComposerState() { + const room = activeRoom(); + const enabled = state.authenticated && Boolean(room); + const sendLimited = Boolean(currentSendLimit()); + $('messageInput').disabled = !enabled || sendLimited; + $('sendBtn').disabled = !enabled || sendLimited; + $('sendBtn').title = sendLimited ? 'Rate limited' : 'Send'; + $('attachBtn').disabled = !enabled || sendLimited; + $('composerRow').classList.toggle('rate-limited', sendLimited); + $('threadInput').disabled = sendLimited; + $('threadComposer').querySelector('button').disabled = sendLimited; + $('saveProfileBtn').disabled = !state.authenticated; + $('displayName').disabled = !state.authenticated; + $('status').disabled = !state.authenticated; + $('typingLine').classList.toggle('hidden', !state.authenticated); + $('composerRow').classList.toggle('hidden', !state.authenticated); + if (!enabled) clearComposerTarget(); + else renderComposerReply(); + $('toggleMembersBtn').classList.toggle('hidden', !room); + $('membersPanel').classList.toggle('no-room', !room); + if (!state.authenticated) + $('messageInput').placeholder = 'Sign in to send messages'; + else if (!room) $('messageInput').placeholder = 'Select a channel'; + else $('messageInput').placeholder = `Message #${room.name}`; + renderComposerRateLimit(); + updateRateLimitTicker(); +} +function requireAuthAction() { + if (state.authenticated) return true; + focusAuthPanel(); + setResult('Sign in to continue.', false); + return false; +} +function microsToDate(micros) { + return new Date(Number(micros) / 1000); +} +function fmtTime(ts) { + return microsToDate(ts.microsSinceUnixEpoch).toLocaleTimeString(); +} +function editIcon() { + return ''; +} +function deleteIcon() { + return ''; +} +function renderReplyReference(message, users) { + const parentId = message.replyToMessageId; + if (parentId === undefined || parentId === null) return ''; + const parent = messageById(parentId); + return ``; +} +function renderComposerReply() { + const preview = $('replyPreview'); + if (editTargetId !== null) { + const target = messageById(editTargetId); + const valid = Boolean( + target && activeRoom() && target.roomId === activeRoom().id + ); + preview.classList.toggle('hidden', !valid); + preview.classList.toggle('editing', true); + if (valid) { + $('replyPreviewLabel').textContent = 'Editing message'; + $('replyPreviewText').textContent = messageSummary(target); + } else { + editTargetId = null; + } + return; + } + preview.classList.toggle('editing', false); + const target = replyTargetId === null ? null : messageById(replyTargetId); + const room = activeRoom(); + const valid = Boolean(target && room && target.roomId === room.id); + preview.classList.toggle('hidden', !valid); + if (!valid) return; + $('replyPreviewLabel').textContent = + `Replying to ${messageAuthorName(target)}`; + $('replyPreviewText').textContent = messageSummary(target); +} +function setReplyTarget(messageId) { + const target = messageById(messageId); + const room = activeRoom(); + if (!target || !room || target.roomId !== room.id) return; + clearEditTarget(); + replyTargetId = messageId; + renderComposerReply(); + $('messageInput').focus(); +} +function clearReplyTarget() { + replyTargetId = null; + $('replyPreview').classList.remove('editing'); + $('replyPreview').classList.add('hidden'); +} +function setEditTarget(messageId) { + const target = messageById(messageId); + const room = activeRoom(); + if (!target || !room || target.roomId !== room.id || !canEditMessage(target)) + return; + clearReplyTarget(); + clearPendingAtts(); + editTargetId = messageId; + $('messageInput').value = target.content || ''; + renderComposerReply(); + $('messageInput').focus(); +} +function clearEditTarget() { + editTargetId = null; + $('replyPreview').classList.remove('editing'); + $('replyPreview').classList.add('hidden'); +} +function clearComposerTarget() { + clearReplyTarget(); + clearEditTarget(); +} +function closeThreadPanel() { + activeThreadRootMessageId = null; + clearThreadEditTarget(); + $('threadPanel').hidden = true; +} +function openThread(rootMessageId) { + const root = messageById(rootMessageId); + const room = activeRoom(); + if (!root || !room || root.roomId !== room.id) return; + activeThreadRootMessageId = rootMessageId; + closePinnedPanel(); + closeSearchPanel(); + renderThreadPanel(); + $('threadPanel').hidden = false; + $('threadInput').focus(); +} +function renderThreadPanel() { + const panel = $('threadPanel'); + if (activeThreadRootMessageId === null) { + panel.hidden = true; + return; + } + const root = messageById(activeThreadRootMessageId); + const room = activeRoom(); + if (!root || !room || root.roomId !== room.id) { + closeThreadPanel(); + return; + } + const users = userByHex(); + const threadMessages = threadMessagesForRoot(root.id); + if (threadEditTargetId !== null && !threadMessageById(threadEditTargetId)) + clearThreadEditTarget(); + $('threadHead').textContent = `Thread in #${room.name}`; + $('threadRoot').innerHTML = ` +
    Original message
    +
    + ${escapeHtml(messageAuthorName(root, users))} + ${fmtTime(root.createdAt)} +
    +
    ${escapeHtml(messageSummary(root))}
    + `; + $('threadList').innerHTML = + threadMessages.length === 0 + ? '
  • No thread replies yet.
  • ' + : threadMessages + .map( + m => ` +
  • +
    + ${escapeHtml(messageAuthorName(m, users))} + ${fmtTime(m.createdAt)} + ${m.editedAt ? '(edited)' : ''} + ${ + canEditMessage(m) || canDeleteMessage(m) + ? ` + ${canEditMessage(m) ? `` : ''} + ${canDeleteMessage(m) ? `` : ''} + ` + : '' + } +
    +
    ${escapeHtml(m.content)}
    +
  • + ` + ) + .join(''); + $('threadList') + .querySelectorAll('[data-thread-edit]') + .forEach(btn => { + btn.addEventListener('click', () => + setThreadEditTarget(BigInt(btn.dataset.threadEdit)) + ); + }); + $('threadList') + .querySelectorAll('[data-thread-delete]') + .forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Delete this thread message?')) return; + try { + await window.chat.deleteThreadMessage( + BigInt(btn.dataset.threadDelete) + ); + } catch (err) { + setResult(`thread delete failed: ${err.message ?? err}`, false); + } + }); + }); +} +function setThreadEditTarget(messageId) { + const target = threadMessageById(messageId); + if (!target || !canEditMessage(target)) return; + threadEditTargetId = messageId; + $('threadInput').value = target.content || ''; + $('threadInput').placeholder = 'Edit thread message'; + $('threadInput').focus(); +} +function clearThreadEditTarget() { + threadEditTargetId = null; + const input = $('threadInput'); + if (input) { + input.value = ''; + input.placeholder = 'Reply in thread'; + } +} +function bindRoomActions(container) { + container.querySelectorAll('[data-room-id]').forEach(node => { + node.addEventListener('click', async e => { + const target = e.target.closest('[data-action]') || e.target; + const roomId = BigInt(node.dataset.roomId); + if (target?.dataset?.action === 'leave') { + if (!requireAuthAction()) return; + try { + await window.chat.leaveRoom(roomId); + if (state.activeRoomId === roomId) { + state.activeRoomId = null; + clearComposerTarget(); + closeThreadPanel(); + window.chat.setActiveRoom(null); + } + } catch (err) { + console.error('leaveRoom failed', err); + setResult(`leave failed: ${err.message ?? err}`, false); + } + return; + } + if (target?.dataset?.action === 'settings') { + e.stopPropagation(); + if (!requireAuthAction()) return; + openChannelSettings(roomId); + return; + } + if (!requireAuthAction()) return; + const joined = new Set(myMemberships().map(x => x.toString())); + if (!joined.has(roomId.toString())) { + try { + await window.chat.joinRoom(roomId); + } catch (err) { + console.error('joinRoom failed', err); + setResult(`open failed: ${err.message ?? err}`, false); + return; + } + } + if (state.activeRoomId && state.activeRoomId !== roomId) { + stopTypingNow(); + clearComposerTarget(); + closeThreadPanel(); + } + state.activeRoomId = roomId; + window.chat.setActiveRoom(roomId); + try { + await window.chat.markRoomRead(roomId); + } catch (err) { + console.warn('markRoomRead failed', err); + } + renderAll(); + }); + }); +} + +function renderMessageAttachments( + messageId, + groupedAtts = attachmentsByMessage() +) { + const list = groupedAtts.get(messageId); + if (!list || list.length === 0) return ''; + return `
    ${list + .map(a => { + const isImg = a.mimeType.startsWith('image/'); + const url = `/files?id=${encodeURIComponent(a.fileId.toString())}`; + const fname = a.filename || `attachment-${a.id.toString()}`; + if (isImg) { + return ``; + } + return ` + ${escapeHtml(fname)} + ${fmtBytes(Number(a.size))} + `; + }) + .join('')}
    `; +} + +function hueFromString(s) { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (h * 31 + s.charCodeAt(i)) | 0; + } + return Math.abs(h) % 360; +} +function avatarSwatch(seed, label, sizePx) { + const hue = hueFromString(seed || '?'); + const text = (label || '?').trim().charAt(0).toUpperCase() || '?'; + return `${escapeHtml(text)}`; +} + +function renderServerRail() { + const rail = $('serverRail'); + if (!rail) return; + const mine = myServers(); + rail.innerHTML = mine + .map(s => { + const initials = (s.name || '?').trim().slice(0, 2).toUpperCase() || '?'; + const isActive = state.activeServerId === s.id; + return `
  • `; + }) + .join(''); + rail.querySelectorAll('[data-server-id]').forEach(btn => { + btn.addEventListener('click', () => { + const id = BigInt(btn.dataset.serverId); + if (state.activeServerId === id) return; + window.chat.setActiveServer(id); + }); + }); +} + +function renderSidebarHead() { + const srv = activeServer(); + const nameEl = $('activeServerName'); + if (nameEl) + nameEl.textContent = srv + ? srv.name + : state.authenticated && myServers().length === 0 + ? 'No server' + : 'SpacetimeDB'; + $('sidebarHead').classList.toggle('hidden', !state.authenticated); +} + +function renderRooms() { + const roomList = $('roomList'); + const latestByRoom = latestMessageByRoom(); + const myCursor = myReadCursorByRoom(); + + const visibleRooms = + state.activeServerId !== null ? roomsInServer(state.activeServerId) : []; + + const grouped = new Map(); + for (const r of visibleRooms) { + const cat = + r.category && r.category.trim() ? r.category.trim() : 'Text Channels'; + if (!grouped.has(cat)) grouped.set(cat, []); + grouped.get(cat).push(r); + } + const catNames = [...grouped.keys()].sort((a, b) => { + if (a === 'Text Channels') return -1; + if (b === 'Text Channels') return 1; + return a.localeCompare(b); + }); + + const isAdmin = + (state.admins || []).length === 0 || + (state.admins || []).includes(state.meHex); + const renderRow = r => { + const latest = latestByRoom.get(r.id); + const read = myCursor.get(r.id) ?? 0n; + const unread = latest ? latest.id > read : false; + const lockIcon = r.isPrivate + ? '🔒' + : ''; + const ownsRoom = r.createdByUserId === state.userId; + const canEdit = ownsRoom || isAdmin; + const gearSvg = + ''; + const settingsBtn = canEdit + ? `` + : ''; + const leaveBtn = !canEdit + ? '' + : ''; + const action = `${settingsBtn}${leaveBtn}`; + const badge = unread ? '' : ''; + return `
  • + # + ${escapeHtml(r.name)} + ${lockIcon}${badge} + ${action} +
  • `; + }; + + if (state.activeServerId === null) { + if (myServers().length === 0) { + roomList.innerHTML = ``; + $('sidebarCreateFirstServer')?.addEventListener( + 'click', + openCreateServerModal + ); + } else { + roomList.innerHTML = ``; + } + return; + } + if (visibleRooms.length === 0) { + roomList.innerHTML = ``; + $('sidebarCreateFirst')?.addEventListener('click', openCreateRoomModal); + return; + } + + const sections = catNames + .map(cat => { + const rows = grouped.get(cat).map(renderRow).join(''); + return `
    + + + ${escapeHtml(cat.toUpperCase())} + + +
      ${rows}
    +
    `; + }) + .join(''); + + roomList.innerHTML = sections; + bindRoomActions(roomList); +} + +function renderMessages() { + const room = activeRoom(); + const ul = $('messageList'); + if (!state.authenticated) { + ul.innerHTML = ''; + return; + } + if (!room) { + const mine = myServers(); + if (mine.length === 0) { + ul.innerHTML = `
  • Welcome

    Create your first server to start chatting.

  • `; + $('emptyCreateBtn')?.addEventListener('click', openCreateServerModal); + $('roomTitle').textContent = ''; + } else if (state.activeServerId === null) { + ul.innerHTML = `
  • Pick a server

    Choose a server on the left.

  • `; + $('roomTitle').textContent = ''; + } else if (roomsInServer(state.activeServerId).length === 0) { + ul.innerHTML = `
  • No channels yet

    Create a channel to start chatting in this server.

  • `; + $('emptyCreateRoomBtn')?.addEventListener('click', openCreateRoomModal); + $('roomTitle').textContent = ''; + } else { + ul.innerHTML = `
  • No channel selected

    Pick a channel on the left to start.

  • `; + $('roomTitle').textContent = 'Select a channel'; + } + const hash = document.getElementById('channelHashIcon'); + if (hash) hash.hidden = true; + return; + } + const hashEl = document.getElementById('channelHashIcon'); + if (hashEl) hashEl.hidden = false; + $('roomTitle').textContent = room.name; + const users = userByHex(); + const roomMessages = state.messages + .filter(m => m.roomId === room.id) + .slice(-300); + if (roomMessages.length === 0) { + ul.innerHTML = `
  • Welcome to #${escapeHtml(room.name)}!

    This is the start of the #${escapeHtml(room.name)} channel.

  • `; + return; + } + + const groupedReactions = new Map(); + for (const r of state.reactions) { + let byEmoji = groupedReactions.get(r.messageId); + if (!byEmoji) { + byEmoji = new Map(); + groupedReactions.set(r.messageId, byEmoji); + } + let entry = byEmoji.get(r.emoji); + if (!entry) { + entry = { count: 0, mine: false }; + byEmoji.set(r.emoji, entry); + } + entry.count++; + if (hex(r.identity) === state.meHex) entry.mine = true; + } + + const groupedAtts = attachmentsByMessage(); + const CHUNK_GAP_MICROS = 5n * 60n * 1_000_000n; // 5 minutes + const chipMap = new Map(REACTIONS.map(x => [x.id, x.label])); + + let prev = null; + const html = roomMessages + .map(m => { + const authorHex = hex(m.author); + const author = users.get(authorHex); + const name = author?.displayName || authorHex.slice(-6); + + const sameAuthor = prev && hex(prev.author) === authorHex; + const gap = prev + ? m.createdAt.microsSinceUnixEpoch - prev.createdAt.microsSinceUnixEpoch + : null; + const chunkStart = + !sameAuthor || (gap !== null && gap > CHUNK_GAP_MICROS); + prev = m; + + const byEmoji = groupedReactions.get(m.id) || new Map(); + const chips = [...byEmoji.entries()] + .map( + ([emoji, meta]) => + `` + ) + .join(''); + const reactionRow = chips ? `
    ${chips}
    ` : ''; + const mine = new Set( + [...byEmoji.entries()] + .filter(([, meta]) => meta.mine) + .map(([emoji]) => emoji) + ); + const quickReactions = REACTIONS.filter(r => !mine.has(r.id)) + .map( + r => + `` + ) + .join(''); + const editedTag = m.editedAt + ? ' (edited)' + : ''; + const replyRef = renderReplyReference(m, users); + + const isPinned = m.pinnedAt !== undefined && m.pinnedAt !== null; + const pinIcon = isPinned ? 'active' : ''; + const pinTitle = isPinned ? 'Unpin' : 'Pin'; + const threadMessages = threadMessagesForRoot(m.id); + const hasThread = threadMessages.length > 0; + const threadPill = hasThread + ? `` + : ''; + const replySvg = + ''; + const threadSvg = + ''; + const pinSvg = + ''; + const toolbarSep = quickReactions + ? '' + : ''; + const toolbar = `
    + ${quickReactions} + ${toolbarSep} + + + + ${canEditMessage(m) ? `` : ''} + ${canDeleteMessage(m) ? `` : ''} +
    `; + const pinnedMarker = isPinned + ? `${pinSvg}pinned` + : ''; + + if (chunkStart) { + return `
  • + ${avatarSwatch(authorHex, name, 36)} +
    +
    + ${escapeHtml(name)} + ${fmtTime(m.createdAt)} + ${pinnedMarker} +
    + ${replyRef} +
    ${escapeHtml(m.content)}${editedTag}
    + ${renderMessageAttachments(m.id, groupedAtts)} + ${reactionRow}${threadPill} +
    + ${toolbar} +
  • `; + } + return `
  • + +
    + ${replyRef} +
    ${escapeHtml(m.content)}${editedTag}${pinnedMarker}
    + ${renderMessageAttachments(m.id, groupedAtts)} + ${reactionRow}${threadPill} +
    + ${toolbar} +
  • `; + }) + .join(''); + ul.innerHTML = html; + hydrateAttachmentImages(ul); + + ul.querySelectorAll('[data-react]').forEach(btn => { + btn.addEventListener('click', () => { + if (!requireAuthAction()) return; + const mid = BigInt(btn.dataset.mid); + const emoji = btn.dataset.react; + try { + reportAsync('reaction', window.chat.toggleReaction(mid, emoji)); + } catch (err) { + setResult(`reaction failed: ${err.message ?? err}`, false); + } + }); + }); + ul.querySelectorAll('[data-reply]').forEach(btn => { + btn.addEventListener('click', () => { + if (!requireAuthAction()) return; + setReplyTarget(BigInt(btn.dataset.reply)); + }); + }); + ul.querySelectorAll('[data-thread]').forEach(btn => { + btn.addEventListener('click', () => { + if (!requireAuthAction()) return; + openThread(BigInt(btn.dataset.thread)); + }); + }); + ul.querySelectorAll('[data-jump-message]').forEach(btn => { + btn.addEventListener('click', () => { + const target = ul.querySelector( + `[data-message-id="${btn.dataset.jumpMessage}"]` + ); + if (target) + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }); + }); + ul.querySelectorAll('[data-pin]').forEach(btn => { + btn.addEventListener('click', () => { + if (!requireAuthAction()) return; + const mid = BigInt(btn.dataset.pin); + const isPinned = btn.classList.contains('active'); + try { + if (isPinned) reportAsync('unpin', window.chat.unpinMessage(mid)); + else reportAsync('pin', window.chat.pinMessage(mid)); + } catch (err) { + setResult(`pin failed: ${err.message ?? err}`, false); + } + }); + }); + ul.querySelectorAll('[data-edit]').forEach(btn => { + btn.addEventListener('click', () => { + if (!requireAuthAction()) return; + setEditTarget(BigInt(btn.dataset.edit)); + }); + }); + ul.querySelectorAll('[data-delete]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!requireAuthAction()) return; + if (!confirm('Delete this message?')) return; + try { + await window.chat.deleteMessage(BigInt(btn.dataset.delete)); + } catch (err) { + setResult(`delete failed: ${err.message ?? err}`, false); + } + }); + }); +} + +function closePinnedPanel() { + $('pinnedPanel').hidden = true; +} +function closeSearchPanel() { + $('searchPanel').hidden = true; + $('searchList').innerHTML = ''; +} +function renderPinnedPanel() { + const room = activeRoom(); + const list = $('pinnedList'); + if (!room) { + list.innerHTML = '
  • No channel selected.
  • '; + return; + } + const users = userByHex(); + const pinned = state.messages + .filter(m => m.roomId === room.id && m.pinnedAt) + .sort((a, b) => + a.pinnedAt.microsSinceUnixEpoch < b.pinnedAt.microsSinceUnixEpoch ? 1 : -1 + ); + if (pinned.length === 0) { + list.innerHTML = + '
  • No pinned messages in this channel.
  • '; + return; + } + const groupedAtts = attachmentsByMessage(); + list.innerHTML = pinned + .map(m => { + const author = users.get(hex(m.author)); + const name = author?.displayName || hex(m.author).slice(-6); + return `
  • +
    + ${escapeHtml(name)} + ${fmtTime(m.createdAt)} +
    +
    ${escapeHtml(m.content)}
    + ${renderMessageAttachments(m.id, groupedAtts)} +
  • `; + }) + .join(''); + hydrateAttachmentImages(list); +} +$('togglePinnedBtn').addEventListener('click', () => { + if ($('pinnedPanel').hidden) { + closeSearchPanel(); + renderPinnedPanel(); + $('pinnedPanel').hidden = false; + } else { + closePinnedPanel(); + } +}); +$('closePinnedBtn').addEventListener('click', closePinnedPanel); + +$('searchForm').addEventListener('submit', async e => { + e.preventDefault(); + if (!requireAuthAction()) return; + const room = activeRoom(); + if (!room) return; + const q = $('searchInput').value.trim(); + if (!q) { + closeSearchPanel(); + return; + } + closePinnedPanel(); + $('searchHead').textContent = + `Search: "${q.length > 30 ? q.slice(0, 30) + '…' : q}"`; + $('searchList').innerHTML = '
  • Searching…
  • '; + $('searchPanel').hidden = false; + try { + const results = await window.chat.searchMessages(room.id, q); + const users = userByHex(); + if (!results || results.length === 0) { + $('searchList').innerHTML = '
  • No matches.
  • '; + return; + } + $('searchList').innerHTML = results + .map(m => { + const author = users.get(hex(m.author)); + const name = author?.displayName || hex(m.author).slice(-6); + return `
  • +
    + ${escapeHtml(name)} + ${fmtTime(m.createdAt)} +
    +
    ${escapeHtml(m.content)}
    +
  • `; + }) + .join(''); + } catch (err) { + $('searchList').innerHTML = + `
  • Error: ${escapeHtml(err.message ?? String(err))}
  • `; + } +}); +$('closeSearchBtn').addEventListener('click', closeSearchPanel); + +function renderTyping() { + const room = activeRoom(); + if (!room) { + $('typingLine').textContent = ''; + return; + } + const users = userByHex(); + const typing = typingForRoom(room.id) + .filter(id => id !== state.meHex) + .map(id => users.get(id)?.displayName || id.slice(-6)); + if (typing.length === 0) $('typingLine').textContent = ''; + else if (typing.length === 1) + $('typingLine').textContent = `${typing[0]} is typing...`; + else $('typingLine').textContent = `${typing.length} people are typing...`; +} + +function renderMembers() { + const room = activeRoom(); + const list = $('memberList'); + if (!state.authenticated) { + list.innerHTML = '
  • Sign in to view members.
  • '; + return; + } + if (!room) { + list.innerHTML = '
  • Select a channel.
  • '; + return; + } + const presenceMap = globalPresenceBySubject(); + const usersByUid = userByUserId(); + const rows = roomMembers(room.id) + .map(m => { + const user = usersByUid.get(m.userId); + const h = user ? hex(user.identity) : ''; + const status = statusOf(h, presenceMap); + return { + hex: h, + name: user?.displayName || m.userId.slice(-6), + status, + mine: m.userId === state.userId, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + const online = rows.filter( + m => m.status !== 'invisible' && m.status !== 'offline' + ); + const offline = rows.filter( + m => m.status === 'invisible' || m.status === 'offline' + ); + + const renderRow = + m => `
  • + + ${avatarSwatch(m.hex, m.name, 32)} + + + ${escapeHtml(m.name)}${m.mine ? " (you)" : ''} +
  • `; + + const sections = []; + if (online.length) + sections.push( + `
  • Online (${online.length})
  • `, + ...online.map(renderRow) + ); + if (offline.length) + sections.push( + `
  • Offline (${offline.length})
  • `, + ...offline.map(renderRow) + ); + list.innerHTML = sections.join(''); + $('memberCount').textContent = `${rows.length}`; +} + +function scrollMessagesToBottom() { + const node = $('messageScroll'); + node.scrollTop = node.scrollHeight; +} +function renderAll() { + renderServerRail(); + renderSidebarHead(); + renderRooms(); + renderMessages(); + renderTyping(); + renderMembers(); + renderUserBar(); + updateComposerState(); + renderThreadPanel(); + if (!$('pinnedPanel').hidden) renderPinnedPanel(); +} +function escapeHtml(s) { + return String(s ?? '').replace( + /[&<>"']/g, + c => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[c] + ); +} + +function scheduleTyping() { + if (!state.authenticated) return; + const room = activeRoom(); + if (!room) return; + try { + void window.chat.startTyping(room.id).catch(() => {}); + } catch { + // The connection can close while the typing update is queued. + } + if (typingTimer) clearTimeout(typingTimer); + if (typingRenewTimer) clearInterval(typingRenewTimer); + typingTimer = setTimeout(() => { + try { + void window.chat.stopTyping(room.id).catch(() => {}); + } catch { + // The connection can close before the timer fires. + } + }, 1800); + typingRenewTimer = setInterval(() => { + try { + void window.chat.startTyping(room.id).catch(() => {}); + } catch { + // The connection can close while the typing state is renewed. + } + }, 1500); +} + +function stopTypingNow() { + const room = activeRoom(); + if (!room) return; + if (typingTimer) clearTimeout(typingTimer); + if (typingRenewTimer) clearInterval(typingRenewTimer); + typingTimer = null; + typingRenewTimer = null; + try { + void window.chat.stopTyping(room.id).catch(() => {}); + } catch { + // The connection can close while the typing state is cleared. + } +} + +function dismissBootSplash() { + const splash = document.getElementById('bootSplash'); + const app = document.querySelector('.app'); + if (app) app.classList.remove('loading'); + if (splash) { + splash.classList.add('fading'); + setTimeout(() => splash.remove(), 250); + } +} +window.addEventListener('chat:ready', dismissBootSplash); +// Fallback in case chat:ready never fires. +setTimeout(dismissBootSplash, 4000); + +window.addEventListener('chat:conn', e => { + const { state, detail } = e.detail; + for (const pill of [$('conn'), $('connAnon')]) { + if (!pill) continue; + pill.innerHTML = `${state === 'connected' ? 'connected' : detail || state}`; + pill.className = `conn ${state === 'connected' ? 'good' : 'bad'}`; + } +}); + +window.addEventListener('chat:data', e => { + const next = e.detail; + const roomChanged = state.activeRoomId !== next.activeRoomId; + applyChatData(next); + if (roomChanged) { + clearComposerTarget(); + closeThreadPanel(); + } + // Auto-pick a server when none is active and the user belongs to at least one. + if (state.authenticated && state.activeServerId === null) { + const mine = myServers(); + if (mine.length > 0) { + window.chat.setActiveServer(mine[0].id); + return; // setActiveServer fires emitData → another chat:data event will refresh + } + } + renderAll(); + if (roomChanged) scrollMessagesToBottom(); +}); + +window.addEventListener('chat:me', e => { + state.meHex = e.detail.meHex; +}); +window.addEventListener('chat:auth', e => { + const user = e.detail.user || null; + state.authenticated = Boolean(user); + state.userId = user?.userId || null; + if (!user && state.activeRoomId !== null) { + state.activeRoomId = null; + clearComposerTarget(); + closeThreadPanel(); + window.chat.setActiveRoom(null); + } + setAuthedUi(user); + renderAll(); +}); + +$('openAuthBtn').addEventListener('click', () => { + focusAuthPanel(); +}); + +$('authLogoutBtn').addEventListener('click', async () => { + try { + await window.chat.logout(); + setResult('Signed out.'); + } catch (err) { + setResult(`logout failed: ${err.message ?? err}`, false); + } +}); + +$('saveProfileBtn').addEventListener('click', async () => { + if (!requireAuthAction()) return; + try { + const displayName = $('displayName').value.trim(); + const status = $('status').value; + if (displayName) await window.chat.setDisplayName(displayName); + await window.chat.setStatus(status); + await window.chat.heartbeat(); + setResult('Profile updated.'); + } catch (err) { + setResult(`profile update failed: ${err.message ?? err}`, false); + } +}); + +function openCreateRoomModal() { + if (!requireAuthAction()) return; + if (state.activeServerId === null) { + openCreateServerModal(); + return; + } + $('createRoomModal').classList.add('open'); + $('roomName').focus(); +} +function closeCreateRoomModal() { + $('createRoomModal').classList.remove('open'); + $('roomName').value = ''; + $('roomCategory').value = ''; +} +$('openCreateRoomBtn')?.addEventListener('click', openCreateRoomModal); +$('closeCreateRoomBtn')?.addEventListener('click', closeCreateRoomModal); +$('createRoomModal')?.addEventListener('click', e => { + if (e.target.id === 'createRoomModal') closeCreateRoomModal(); +}); +document.getElementById('roomList')?.addEventListener('click', e => { + if (e.target.closest('.category-add')) { + e.preventDefault(); + e.stopPropagation(); + openCreateRoomModal(); + } +}); + +$('createRoomBtn').addEventListener('click', async () => { + if (!requireAuthAction()) return; + const serverId = state.activeServerId; + if (serverId === null) { + setResult('Pick a server first.', false); + return; + } + const name = $('roomName').value.trim(); + if (!name) return; + const isPrivate = $('roomPrivacy').value === 'private'; + const category = $('roomCategory').value.trim() || undefined; + const beforeIds = new Set(state.rooms.map(r => r.id.toString())); + const waitForRoom = e => { + const match = e.detail.rooms.find( + r => + !beforeIds.has(r.id.toString()) && + r.name === name && + r.serverId === serverId && + r.createdByUserId === state.userId + ); + if (!match) return; + window.removeEventListener('chat:data', waitForRoom); + if (state.activeRoomId && state.activeRoomId !== match.id) { + stopTypingNow(); + clearComposerTarget(); + closeThreadPanel(); + } + state.activeRoomId = match.id; + window.chat.setActiveRoom(match.id); + }; + window.addEventListener('chat:data', waitForRoom); + try { + await window.chat.createRoom(serverId, name, isPrivate, category); + closeCreateRoomModal(); + } catch (err) { + window.removeEventListener('chat:data', waitForRoom); + setResult(`create room failed: ${err.message ?? err}`, false); + } +}); + +function openCreateServerModal() { + if (!requireAuthAction()) return; + $('createServerModal').classList.add('open'); + $('serverNameInput').focus(); +} +function closeCreateServerModal() { + $('createServerModal').classList.remove('open'); + $('serverNameInput').value = ''; +} +$('createServerBtn')?.addEventListener('click', openCreateServerModal); +$('closeCreateServerBtn')?.addEventListener('click', closeCreateServerModal); +$('createServerModal')?.addEventListener('click', e => { + if (e.target.id === 'createServerModal') closeCreateServerModal(); +}); +$('createServerSubmitBtn')?.addEventListener('click', async () => { + if (!requireAuthAction()) return; + const name = $('serverNameInput').value.trim(); + if (!name) return; + const waitForServer = e => { + const match = e.detail.servers.find( + s => s.name === name && s.createdByUserId === state.userId + ); + if (!match) return; + window.removeEventListener('chat:data', waitForServer); + window.chat.setActiveServer(match.id); + }; + window.addEventListener('chat:data', waitForServer); + try { + await window.chat.createServer(name); + closeCreateServerModal(); + } catch (err) { + window.removeEventListener('chat:data', waitForServer); + setResult(`create server failed: ${err.message ?? err}`, false); + } +}); + +function openRenameServerModal() { + const srv = activeServer(); + if (!srv) return; + $('renameServerInput').value = srv.name; + $('renameServerModal').classList.add('open'); + $('renameServerInput').focus(); +} +function closeRenameServerModal() { + $('renameServerModal').classList.remove('open'); + $('renameServerInput').value = ''; +} +$('closeRenameServerBtn')?.addEventListener('click', closeRenameServerModal); +$('renameServerModal')?.addEventListener('click', e => { + if (e.target.id === 'renameServerModal') closeRenameServerModal(); +}); +$('renameServerSubmitBtn')?.addEventListener('click', async () => { + const srv = activeServer(); + if (!srv) { + closeRenameServerModal(); + return; + } + const name = $('renameServerInput').value.trim(); + if (!name || name === srv.name) { + closeRenameServerModal(); + return; + } + try { + await window.chat.renameServer(srv.id, name); + closeRenameServerModal(); + } catch (err) { + setResult(`rename failed: ${err.message ?? err}`, false); + } +}); + +// Sidebar header dropdown (server menu) +function toggleServerMenu(force) { + const menu = $('serverMenu'); + if (!menu) return; + if (force === false || !menu.hidden) menu.hidden = true; + else menu.hidden = false; +} +$('serverHeadBtn')?.addEventListener('click', e => { + e.stopPropagation(); + if (!state.activeServerId) { + if (state.authenticated) openCreateServerModal(); + return; + } + toggleServerMenu(); +}); +document.addEventListener('click', e => { + const menu = $('serverMenu'); + if (!menu || menu.hidden) return; + if (e.target.closest('#serverMenu') || e.target.closest('#serverHeadBtn')) + return; + toggleServerMenu(false); +}); +$('serverMenu')?.addEventListener('click', async e => { + const action = e.target.closest('[data-srv-action]')?.dataset.srvAction; + if (!action) return; + toggleServerMenu(false); + const srv = activeServer(); + if (!srv) return; + if (action === 'rename') { + if (!amServerOwner(srv.id)) { + setResult('Only the owner can rename.', false); + return; + } + openRenameServerModal(); + } else if (action === 'delete') { + if (!amServerOwner(srv.id)) { + setResult('Only the owner can delete.', false); + return; + } + if ( + !confirm( + `Delete server "${srv.name}"? This removes all channels and messages.` + ) + ) + return; + try { + await window.chat.deleteServer(srv.id); + window.chat.setActiveServer(null); + } catch (err) { + setResult(`delete failed: ${err.message ?? err}`, false); + } + } else if (action === 'leave') { + if (amServerOwner(srv.id)) { + setResult("Owners can't leave; delete the server instead.", false); + return; + } + if (!confirm(`Leave "${srv.name}"?`)) return; + try { + await window.chat.leaveServer(srv.id); + window.chat.setActiveServer(null); + } catch (err) { + setResult(`leave failed: ${err.message ?? err}`, false); + } + } +}); + +let csRoomId = null; +function openChannelSettings(roomId) { + const room = state.rooms.find(r => r.id === roomId); + if (!room) return; + csRoomId = roomId; + $('csName').value = room.name; + $('csName').disabled = false; + $('csCategory').value = room.category || ''; + $('csPrivacy').value = room.isPrivate ? 'private' : 'public'; + $('csPrivacy').disabled = false; + $('csDeleteBtn').disabled = false; + $('csDeleteBtn').title = ''; + $('channelSettingsModal').classList.add('open'); + $('csName').focus(); +} +function closeChannelSettings() { + csRoomId = null; + $('channelSettingsModal').classList.remove('open'); +} +$('closeChannelSettingsBtn')?.addEventListener('click', closeChannelSettings); +$('channelSettingsModal')?.addEventListener('click', e => { + if (e.target.id === 'channelSettingsModal') closeChannelSettings(); +}); +$('csSaveBtn')?.addEventListener('click', async () => { + if (csRoomId === null) return; + const room = state.rooms.find(r => r.id === csRoomId); + if (!room) { + closeChannelSettings(); + return; + } + const newName = $('csName').value.trim(); + const newCategory = $('csCategory').value.trim() || undefined; + const newPrivate = $('csPrivacy').value === 'private'; + try { + if (newName && newName !== room.name) + await window.chat.renameRoom(csRoomId, newName); + if ((room.category || '') !== (newCategory || '')) + await window.chat.setRoomCategory(csRoomId, newCategory); + if (newPrivate !== room.isPrivate) + await window.chat.setRoomPrivacy(csRoomId, newPrivate); + closeChannelSettings(); + } catch (err) { + setResult(`save failed: ${err.message ?? err}`, false); + } +}); +$('csDeleteBtn')?.addEventListener('click', async () => { + if (csRoomId === null) return; + const room = state.rooms.find(r => r.id === csRoomId); + if (!room) return; + if (!confirm(`Delete #${room.name}? This removes all messages and members.`)) + return; + try { + const deletedId = csRoomId; + await window.chat.deleteRoom(deletedId); + closeChannelSettings(); + if (state.activeRoomId === deletedId) { + state.activeRoomId = null; + clearComposerTarget(); + closeThreadPanel(); + window.chat.setActiveRoom(null); + } + } catch (err) { + setResult(`delete failed: ${err.message ?? err}`, false); + } +}); + +function openUserMenu() { + $('userMenu').hidden = false; +} +function closeUserMenu() { + $('userMenu').hidden = true; +} +function toggleUserMenu(e) { + e.stopPropagation(); + if ($('userMenu').hidden) openUserMenu(); + else closeUserMenu(); +} +$('openUserMenuBtn')?.addEventListener('click', toggleUserMenu); +document.addEventListener('click', e => { + const menu = $('userMenu'); + if (!menu || menu.hidden) return; + if (menu.contains(e.target)) return; + if (e.target.closest('#openUserMenuBtn')) return; + closeUserMenu(); +}); +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + closeUserMenu(); + closeCreateRoomModal(); + closeChannelSettings(); + closeCreateServerModal(); + closeRenameServerModal(); + toggleServerMenu(false); + } +}); + +$('toggleMembersBtn').addEventListener('click', () => { + $('membersPanel').classList.toggle('collapsed'); +}); + +$('homeBtn').addEventListener('click', () => { + if (!state.authenticated) { + focusAuthPanel(); + return; + } + const mine = myServers(); + if (mine.length === 0) { + openCreateServerModal(); + return; + } + window.chat.setActiveServer(mine[0].id); +}); + +function renderPendingAtts() { + const root = $('pendingAttachments'); + if (pendingAtts.length === 0) { + root.hidden = true; + root.innerHTML = ''; + return; + } + root.hidden = false; + root.replaceChildren(); + for (const attachment of pendingAtts) { + const item = document.createElement('div'); + item.className = 'pending-att'; + item.dataset.pid = String(attachment.id); + + const isImage = attachment.mimeType.startsWith('image/'); + const preview = document.createElement('div'); + preview.className = 'pending-att-preview'; + preview.textContent = isImage ? 'Image' : 'File'; + item.append(preview); + + const metadata = document.createElement('div'); + metadata.className = 'pending-att-meta'; + metadata.textContent = `${attachment.name} - ${fmtBytes(attachment.bytes.length)}`; + item.append(metadata); + + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'pending-att-x'; + removeButton.dataset.remove = String(attachment.id); + removeButton.setAttribute('aria-label', 'Remove'); + removeButton.textContent = '×'; + item.append(removeButton); + root.append(item); + } + root.querySelectorAll('[data-remove]').forEach(btn => { + btn.addEventListener('click', () => { + const id = Number(btn.dataset.remove); + const idx = pendingAtts.findIndex(p => p.id === id); + if (idx < 0) return; + pendingAtts.splice(idx, 1); + renderPendingAtts(); + }); + }); +} + +async function readFileAsBytes(file) { + const buf = await file.arrayBuffer(); + return new Uint8Array(buf); +} + +$('attachBtn')?.addEventListener('click', () => { + if (!requireAuthAction()) return; + $('attachInput').click(); +}); +$('attachInput')?.addEventListener('change', async e => { + const files = [...(e.target.files || [])]; + e.target.value = ''; + for (const f of files) { + if (pendingAtts.length >= ATT_MAX_COUNT) { + setResult(`Max ${ATT_MAX_COUNT} attachments.`, false); + break; + } + if (f.size > ATT_MAX_BYTES) { + setResult( + `${f.name}: too large (${fmtBytes(f.size)} > ${fmtBytes(ATT_MAX_BYTES)}).`, + false + ); + continue; + } + try { + const bytes = await readFileAsBytes(f); + const mimeType = f.type || 'application/octet-stream'; + pendingAtts.push({ + id: ++pendingAttSeq, + name: f.name, + mimeType, + bytes, + }); + } catch (err) { + setResult(`${f.name}: read failed - ${err.message ?? err}`, false); + } + } + renderPendingAtts(); +}); + +function clearPendingAtts() { + pendingAtts = []; + renderPendingAtts(); +} + +async function sendCurrentMessage() { + if (!state.authenticated) { + console.warn('send blocked: not authenticated'); + focusAuthPanel(); + return; + } + const room = activeRoom(); + if (!room) { + console.warn( + 'send blocked: no active room (activeRoomId=', + state.activeRoomId, + ')' + ); + setResult('Select a room first.', false); + return; + } + if (currentSendLimit()) { + updateComposerState(); + return; + } + const content = $('messageInput').value.trim(); + if (editTargetId !== null) { + if (!content) return; + try { + await window.chat.editMessage(editTargetId, content); + $('messageInput').value = ''; + clearComposerTarget(); + clearPendingAtts(); + } catch (err) { + console.error('editMessage failed', err); + setResult(`edit failed: ${err.message ?? err}`, false); + } + return; + } + const atts = pendingAtts.map(p => ({ + mimeType: p.mimeType, + filename: p.name, + bytes: p.bytes, + })); + if (!content && atts.length === 0) return; + const replyTo = replyTargetId === null ? undefined : replyTargetId; + try { + await window.chat.sendMessage(room.id, content, replyTo, atts); + $('messageInput').value = ''; + clearComposerTarget(); + clearPendingAtts(); + stopTypingNow(); + try { + await window.chat.markRoomRead(room.id); + } catch (e) { + console.warn('markRoomRead after send failed', e); + } + scrollMessagesToBottom(); + } catch (err) { + console.error('sendMessage failed', err); + setResult(`send failed: ${err.message ?? err}`, false); + } +} + +$('sendBtn').addEventListener('click', sendCurrentMessage); +$('cancelReplyBtn').addEventListener('click', () => { + clearComposerTarget(); + $('messageInput').value = ''; +}); +$('closeThreadBtn').addEventListener('click', closeThreadPanel); +$('threadComposer').addEventListener('submit', async e => { + e.preventDefault(); + if (!requireAuthAction()) return; + if (activeThreadRootMessageId === null) return; + if (currentSendLimit()) { + updateComposerState(); + return; + } + const content = $('threadInput').value.trim(); + if (!content) return; + try { + if (threadEditTargetId !== null) { + await window.chat.editThreadMessage(threadEditTargetId, content); + clearThreadEditTarget(); + return; + } + await window.chat.sendThreadMessage(activeThreadRootMessageId, content); + $('threadInput').value = ''; + } catch (err) { + console.error('sendThreadMessage failed', err); + setResult(`thread send failed: ${err.message ?? err}`, false); + } +}); +$('messageInput').addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendCurrentMessage(); + return; + } + scheduleTyping(); +}); +$('messageInput').addEventListener('input', scheduleTyping); +$('messageInput').addEventListener('blur', stopTypingNow); diff --git a/spacetime-presence-ts/example/scripts/test-ui-model.mjs b/spacetime-presence-ts/example/scripts/test-ui-model.mjs new file mode 100644 index 00000000000..e0bb7030ce4 --- /dev/null +++ b/spacetime-presence-ts/example/scripts/test-ui-model.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { + activeRoom, + activeServer, + amServerOwner, + attachmentsByMessage, + canDeleteMessage, + canEditMessage, + globalPresenceBySubject, + latestMessageByRoom, + messageAuthorName, + messageSummary, + myMemberships, + myReadCursorByRoom, + myServers, + roomsInServer, + threadMessagesForRoot, + typingForRoom, + userByHex, +} from '../public/chat-model.js'; +import { applyChatData, chatState } from '../public/chat-state.js'; + +const identity = value => ({ toHexString: () => value }); +const me = identity('me'); +const other = identity('other'); + +Object.assign(chatState, { + meHex: 'me', + userId: 10n, + userEmail: 'me@example.com', + activeServerId: 1n, + activeRoomId: 2n, + servers: [{ id: 1n, name: 'Server', createdByUserId: 10n }], + serverMembers: [ + { serverId: 1n, userId: 10n }, + { serverId: 3n, userId: 20n }, + ], + rooms: [ + { id: 2n, serverId: 1n, createdByUserId: 20n }, + { id: 4n, serverId: 3n, createdByUserId: 20n }, + ], + users: [ + { userId: 10n, identity: me, displayName: 'Me' }, + { userId: 20n, identity: other, displayName: 'Other' }, + ], + members: [ + { roomId: 2n, userId: 10n }, + { roomId: 4n, userId: 20n }, + ], + messages: [ + { id: 5n, roomId: 2n, author: other, content: 'First' }, + { id: 8n, roomId: 2n, author: me, content: '' }, + ], + attachments: [ + { id: 2n, fileId: 2n, messageId: 8n, ordinal: 2 }, + { id: 1n, fileId: 1n, messageId: 8n, ordinal: 1 }, + ], + threads: [{ id: 7n, rootMessageId: 5n }], + threadMessages: [ + { id: 9n, threadId: 7n }, + { id: 10n, threadId: 8n }, + ], + cursors: [ + { identity: me, roomId: 2n, lastReadMessageId: 5n }, + { identity: other, roomId: 2n, lastReadMessageId: 8n }, + ], + presence: [ + { scope: 'chat.global', subject: 'me', status: 'online' }, + { scope: 'chat.typing:2', subject: 'other', status: 'online' }, + ], +}); + +assert.equal(activeServer()?.id, 1n); +assert.equal(activeRoom()?.id, 2n); +assert.equal(amServerOwner(1n), true); +assert.deepEqual( + myServers().map(server => server.id), + [1n] +); +assert.deepEqual( + roomsInServer(1n).map(room => room.id), + [2n] +); +assert.deepEqual(myMemberships(), [2n]); +assert.equal(canEditMessage(chatState.messages[1]), true); +assert.equal(canEditMessage(chatState.messages[0]), false); +assert.equal(canDeleteMessage(chatState.messages[0]), true); +assert.equal(messageAuthorName(chatState.messages[0]), 'Other'); +assert.equal(messageSummary(chatState.messages[1]), '2 attachments'); +assert.deepEqual( + threadMessagesForRoot(5n).map(message => message.id), + [9n] +); +assert.equal(latestMessageByRoom().get(2n)?.id, 8n); +assert.equal(myReadCursorByRoom().get(2n), 5n); +assert.equal(globalPresenceBySubject().get('me')?.status, 'online'); +assert.deepEqual(typingForRoom(2n), ['other']); +assert.equal(userByHex().get('me')?.displayName, 'Me'); +assert.deepEqual( + attachmentsByMessage() + .get(8n) + ?.map(attachment => attachment.id), + [1n, 2n] +); + +applyChatData({ activeServerId: 3n, activeRoomId: 4n, rooms: [] }); +assert.equal(chatState.activeServerId, 3n); +assert.equal(chatState.userId, 10n); +assert.equal(chatState.userEmail, 'me@example.com'); + +console.log('presence UI model tests passed'); diff --git a/spacetime-presence-ts/example/server.ts b/spacetime-presence-ts/example/server.ts new file mode 100644 index 00000000000..bf07145b9c6 --- /dev/null +++ b/spacetime-presence-ts/example/server.ts @@ -0,0 +1,205 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const SPA_HTML = readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8'); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +// Shared env supplies secrets; example-local env supplies app defaults. +// Blank placeholders in the example .env should not erase shared secrets. +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8794', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-presence-example'; +const AUTH_ISSUER_URL = + process.env.AUTH_ISSUER_URL ?? `http://localhost:${PORT}`; +const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? AUTH_ISSUER_URL; +const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME ?? 'stdb_auth'; +const AUTH_SESSION_TTL_SECONDS = Number.parseInt( + process.env.AUTH_SESSION_TTL_SECONDS ?? `${60 * 60 * 24 * 7}`, + 10 +); +if ( + !Number.isInteger(AUTH_SESSION_TTL_SECONDS) || + AUTH_SESSION_TTL_SECONDS <= 0 +) { + throw new Error('AUTH_SESSION_TTL_SECONDS must be a positive integer'); +} +const STDB_SERVER = process.env.STDB_SERVER ?? STDB_HTTP; +const SPACETIME_BIN = 'spacetime'; + +function configuredValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function configuredPem(value: string | undefined): string | undefined { + return configuredValue(value)?.replace(/\\n/g, '\n'); +} + +const opt = (value: string | undefined) => + value === undefined ? JSON.stringify([1, []]) : JSON.stringify([0, value]); + +function configureAuthFromEnv(): void { + const args = [ + JSON.stringify(AUTH_ISSUER_URL), + opt(AUTH_BASE_URL), + opt(AUTH_COOKIE_NAME), + JSON.stringify([0, AUTH_SESSION_TTL_SECONDS]), + opt(configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM)), + opt(configuredValue(process.env.GOOGLE_CLIENT_ID)), + opt(configuredValue(process.env.GOOGLE_CLIENT_SECRET)), + opt(configuredValue(process.env.GITHUB_CLIENT_ID)), + opt(configuredValue(process.env.GITHUB_CLIENT_SECRET)), + ]; + + const result = spawnSync( + SPACETIME_BIN, + ['call', '--server', STDB_SERVER, DB_NAME, 'set_auth_config', ...args], + { stdio: 'inherit', shell: false } + ); + if (result.status !== 0) { + throw new Error(`auth config bootstrap failed (exit ${result.status})`); + } +} + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +app.get('/auth/password/reset', (_req: Request, res: Response) => { + res.type('html').send(SPA_HTML); +}); + +function proxyStdbRoute(prefix: string) { + return async (req: Request, res: Response) => { + const mountedUrl = req.url.startsWith('/?') ? req.url.slice(1) : req.url; + const fullPath = `${prefix}${mountedUrl}`; + const qIdx = fullPath.indexOf('?'); + const routePath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); + const query = qIdx < 0 ? '' : fullPath.slice(qIdx); + const upstreamUrl = `${STDB_HTTP}/v1/database/${DB_NAME}/route${routePath}${query}`; + + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string') headers[k] = v; + else if (Array.isArray(v)) headers[k] = v.join(', '); + } + delete headers.host; + delete headers['content-length']; + headers['x-forwarded-proto'] = headers['x-forwarded-proto'] ?? req.protocol; + + const init: RequestInit = { + method: req.method, + headers, + redirect: 'manual', + }; + if (req.method !== 'GET' && req.method !== 'HEAD') { + init.body = JSON.stringify(req.body); + headers['content-type'] = 'application/json'; + } + + try { + const upstream = await fetch(upstreamUrl, init); + res.status(upstream.status); + upstream.headers.forEach((val, key) => { + const lower = key.toLowerCase(); + if ( + lower === 'transfer-encoding' || + lower === 'content-encoding' || + lower === 'content-length' + ) + return; + res.setHeader(key, val); + }); + const buf = Buffer.from(await upstream.arrayBuffer()); + res.send(buf); + } catch (err) { + res.status(502).json({ + error: 'upstream_unreachable', + detail: (err as Error).message, + }); + } + }; +} + +app.use('/auth', proxyStdbRoute('/auth')); +app.use('/files', proxyStdbRoute('/files')); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, databaseName: DB_NAME }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + spacetimeUri: STDB_URI, + databaseName: DB_NAME, + auth: { + issuerUrl: AUTH_ISSUER_URL, + baseUrl: AUTH_BASE_URL, + cookieName: AUTH_COOKIE_NAME, + sessionTtlSeconds: AUTH_SESSION_TTL_SECONDS, + hasEs256PrivateKeyPem: Boolean( + configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM) + ), + }, + oauth: { + google: Boolean( + process.env.GOOGLE_CLIENT_ID?.trim() && + process.env.GOOGLE_CLIENT_SECRET?.trim() + ), + github: Boolean( + process.env.GITHUB_CLIENT_ID?.trim() && + process.env.GITHUB_CLIENT_SECRET?.trim() + ), + }, + }); +}); + +app.use('/assets', express.static(exampleUiAssetsDir)); +app.use(express.static(PUBLIC_DIR)); + +try { + console.log(`[auth] bootstrapping env config via ${SPACETIME_BIN}`); + configureAuthFromEnv(); + console.log(`[auth] bootstrapped env config issuer=${AUTH_ISSUER_URL}`); +} catch (err) { + console.error( + `[auth] env config bootstrap failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[auth] is the SpacetimeDB host running and the presence example module published?' + ); + process.exit(1); +} + +app.listen(PORT, HOST, () => { + console.log(`Chat example running at http://${HOST}:${PORT}`); + console.log(` STDB ws -> ${STDB_URI}`); + console.log(` STDB http-> ${STDB_HTTP} (proxy /auth/*, /files)`); + console.log(` Database -> ${DB_NAME}`); +}); diff --git a/spacetime-presence-ts/example/spacetimedb/.npmrc b/spacetime-presence-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-presence-ts/example/spacetimedb/package.json b/spacetime-presence-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..2762c9d48d2 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/package.json @@ -0,0 +1,21 @@ +{ + "name": "spacetime-presence-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-presence-example" + }, + "dependencies": { + "@spacetimedb/auth": "workspace:*", + "@spacetimedb/files": "workspace:*", + "@spacetimedb/presence": "workspace:*", + "@spacetimedb/rate-limit": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-presence-ts/example/spacetimedb/src/chat-policy.ts b/spacetime-presence-ts/example/spacetimedb/src/chat-policy.ts new file mode 100644 index 00000000000..6fe122809c4 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/src/chat-policy.ts @@ -0,0 +1,32 @@ +export const PRESENCE_SCOPE_GLOBAL = 'chat.global'; +export const PRESENCE_SCOPE_TYPING_PREFIX = 'chat.typing:'; + +export const RATE_LIMIT_SEND = { + scope: 'chat.send_message', + limit: 20, + windowSeconds: 30, +}; +export const RATE_LIMIT_TYPING = { + scope: 'chat.typing', + limit: 40, + windowSeconds: 10, +}; +export const RATE_LIMIT_ROOM_WRITE = { + scope: 'chat.room_write', + limit: 10, + windowSeconds: 60, +}; +export const RATE_LIMIT_REACTION = { + scope: 'chat.reaction', + limit: 40, + windowSeconds: 60, +}; +export const RATE_LIMIT_PROFILE = { + scope: 'chat.profile', + limit: 20, + windowSeconds: 60, +}; + +export function typingScope(roomId: bigint): string { + return `${PRESENCE_SCOPE_TYPING_PREFIX}${roomId}`; +} diff --git a/spacetime-presence-ts/example/spacetimedb/src/domain.ts b/spacetime-presence-ts/example/spacetimedb/src/domain.ts new file mode 100644 index 00000000000..0684306c0e4 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/src/domain.ts @@ -0,0 +1,331 @@ +import { Identity } from 'spacetimedb'; +import { + SenderError, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, +} from 'spacetimedb/server'; +import { getCallerUserId } from '@spacetimedb/auth/submodule'; +import { consumeRateLimit } from '@spacetimedb/rate-limit/submodule'; +import { removePresence, upsertPresence } from '@spacetimedb/presence'; +import { PRESENCE_SCOPE_GLOBAL, typingScope } from './chat-policy'; +import { ChatUserStatus } from './model'; +import type { DbSchema } from './schema'; + +const ONE_SECOND_MICROS = 1_000_000n; +const GLOBAL_PRESENCE_TTL_SECONDS = 35; +const ACTIVITY_WINDOW_SECONDS = 5 * 60; +const ROOM_ACTIVITY_HOT_THRESHOLD = 20; +const ROOM_ACTIVITY_ACTIVE_THRESHOLD = 5; + +export type Tx = ReducerCtx; +type CallerCtx = ProcedureCtx | ReducerCtx; + +export function senderError(message: string): never { + throw new SenderError(message); +} + +export function normalizeText( + name: string, + value: string, + maxLength: number +): string { + const normalized = value.trim().replace(/\s+/g, ' '); + if (!normalized) senderError(`chat.invalid_${name}`); + if (normalized.length > maxLength) senderError(`chat.${name}_too_long`); + return normalized; +} + +export function identityHex(identity: Identity): string { + return identity.toHexString(); +} + +export function identitiesEqual(left: Identity, right: Identity): boolean { + return left.isEqual(right); +} + +export function requireAuthenticatedUserId(ctx: CallerCtx): string { + const userId = getCallerUserId(ctx.as.auth); + if (!userId) senderError('auth.not_authenticated'); + return userId; +} + +export function enforceChatRateLimit( + tx: Tx, + userId: string, + scope: string, + limit: number, + windowSeconds: number, + cost = 1 +): void { + const result = consumeRateLimit(tx.as.rateLimit, { + key: `${scope}:user:${userId}`, + scope, + limit, + windowSeconds, + cost, + }); + if (!result.allowed) + senderError(`chat.rate_limited:${result.retryAfterSeconds}`); +} + +export function chatStatusToString(status: { tag: string }): string { + return status.tag.toLowerCase(); +} + +export function ensureUser(tx: Tx, userId: string) { + const existing = tx.db.chatUser.identity.find(tx.sender); + const authUser = tx.db.auth.authUser.userId.find(userId); + if (existing) { + if (existing.userId !== userId && authUser) { + tx.db.chatUser.identity.update({ + ...existing, + userId, + displayName: authUser.name ?? existing.displayName, + }); + } + return tx.db.chatUser.identity.find(tx.sender) ?? existing; + } + + const hex = identityHex(tx.sender); + const suffix = hex.slice(Math.max(0, hex.length - 6)); + const row = tx.db.chatUser.insert({ + identity: tx.sender, + userId, + displayName: authUser?.name ?? authUser?.email ?? `User-${suffix}`, + status: ChatUserStatus.Online, + createdAt: tx.timestamp, + lastActiveAt: tx.timestamp, + lastMessageAt: tx.timestamp, + }); + updateGlobalPresence(tx, row); + return row; +} + +export function updateGlobalPresence( + tx: Tx, + user: ReturnType +): void { + upsertPresence(tx, { + scope: PRESENCE_SCOPE_GLOBAL, + subject: identityHex(user.identity), + status: chatStatusToString(user.status), + payloadJson: JSON.stringify({ + displayName: user.displayName, + userId: user.userId, + }), + ttlSeconds: GLOBAL_PRESENCE_TTL_SECONDS, + }); +} + +export function requireRoom(tx: Tx, roomId: bigint) { + const row = tx.db.room.id.find(roomId); + if (!row) senderError('chat.room_not_found'); + return row; +} + +export function findMembership(tx: Tx, roomId: bigint, userId: string) { + for (const membership of tx.db.roomMember.roomId.filter(roomId)) { + if (membership.userId === userId) return membership; + } + return undefined; +} + +export function requireMembership(tx: Tx, roomId: bigint, userId: string) { + const membership = findMembership(tx, roomId, userId); + if (!membership) senderError('chat.not_room_member'); + return membership; +} + +function countRecentActivityEvents(tx: Tx, roomId: bigint): number { + const cutoff = + tx.timestamp.microsSinceUnixEpoch - + BigInt(ACTIVITY_WINDOW_SECONDS) * ONE_SECOND_MICROS; + let count = 0; + for (const event of tx.db.roomActivityEvent.roomId.filter(roomId)) { + if (event.createdAt.microsSinceUnixEpoch >= cutoff) count++; + } + return count; +} + +function activityLabel(score: number): string { + if (score >= ROOM_ACTIVITY_HOT_THRESHOLD) return 'hot'; + if (score >= ROOM_ACTIVITY_ACTIVE_THRESHOLD) return 'active'; + return score > 0 ? 'warm' : 'quiet'; +} + +export function updateRoomActivity(tx: Tx, roomId: bigint): void { + const room = tx.db.room.id.find(roomId); + if (!room) return; + const score = countRecentActivityEvents(tx, roomId); + tx.db.room.id.update({ + ...room, + activityScore: score, + activityLabel: activityLabel(score), + lastActivityAt: score > 0 ? tx.timestamp : room.lastActivityAt, + }); +} + +export function deleteAttachmentWithFile( + tx: Tx, + attachment: { id: bigint; fileId: bigint } +): void { + const blob = tx.db.files.fileBlob.fileId.find(attachment.fileId); + if (blob) tx.db.files.fileBlob.delete(blob); + const file = tx.db.files.file.id.find(attachment.fileId); + if (file) tx.db.files.file.delete(file); + tx.db.attachment.id.delete(attachment.id); +} + +export function deleteThread(tx: Tx, threadId: bigint): void { + for (const message of [...tx.db.threadMessage.threadId.filter(threadId)]) { + tx.db.threadMessage.id.delete(message.id); + } + const thread = tx.db.messageThread.id.find(threadId); + if (thread) tx.db.messageThread.id.delete(thread.id); +} + +export function deleteMessageTree(tx: Tx, message: { id: bigint }): void { + const thread = tx.db.messageThread.rootMessageId.find(message.id); + if (thread) deleteThread(tx, thread.id); + for (const attachment of [...tx.db.attachment.messageId.filter(message.id)]) { + deleteAttachmentWithFile(tx, attachment); + } + for (const reaction of [ + ...tx.db.messageReaction.messageId.filter(message.id), + ]) { + tx.db.messageReaction.id.delete(reaction.id); + } + tx.db.message.id.delete(message.id); +} + +export function upsertRoomReadCursor( + tx: Tx, + roomId: bigint, + lastReadMessageId: bigint +): void { + for (const cursor of tx.db.roomReadCursor.identity.filter(tx.sender)) { + if (cursor.roomId !== roomId) continue; + if (cursor.lastReadMessageId >= lastReadMessageId) return; + tx.db.roomReadCursor.id.update({ + ...cursor, + lastReadMessageId, + lastReadAt: tx.timestamp, + }); + return; + } + tx.db.roomReadCursor.insert({ + id: 0n, + roomId, + identity: tx.sender, + lastReadMessageId, + lastReadAt: tx.timestamp, + }); +} + +export function removeTypingPresence( + tx: Tx, + roomId: bigint, + identity: Identity +): void { + removePresence(tx, typingScope(roomId), identityHex(identity)); +} + +export function insertRoom( + tx: Tx, + options: { + serverId: bigint; + name: string; + category?: string; + isPrivate: boolean; + createdByUserId: string; + role: string; + } +) { + const room = tx.db.room.insert({ + id: 0n, + serverId: options.serverId, + name: options.name, + category: options.category, + createdByUserId: options.createdByUserId, + createdAt: tx.timestamp, + isPrivate: options.isPrivate, + activityLabel: 'quiet', + activityScore: 0, + lastActivityAt: undefined, + }); + tx.db.roomMember.insert({ + id: 0n, + roomId: room.id, + userId: options.createdByUserId, + role: options.role, + joinedAt: tx.timestamp, + }); + return room; +} + +export function requireServer(tx: Tx, serverId: bigint) { + const server = tx.db.server.id.find(serverId); + if (!server) senderError('chat.server_not_found'); + return server; +} + +export function findServerMembership(tx: Tx, serverId: bigint, userId: string) { + for (const membership of tx.db.serverMember.serverId.filter(serverId)) { + if (membership.userId === userId) return membership; + } + return undefined; +} + +export function requireServerMembership( + tx: Tx, + serverId: bigint, + userId: string +) { + const membership = findServerMembership(tx, serverId, userId); + if (!membership) senderError('chat.not_server_member'); + return membership; +} + +export function requireRoomAdminOrOwner( + tx: Tx, + roomId: bigint, + userId: string +) { + const targetRoom = requireRoom(tx, roomId); + const server = tx.db.server.id.find(targetRoom.serverId); + if ( + server?.createdByUserId === userId || + targetRoom.createdByUserId === userId + ) { + return targetRoom; + } + senderError('chat.not_room_admin'); +} + +export function canModerateRoom( + tx: Tx, + roomId: bigint, + userId: string +): boolean { + const targetRoom = requireRoom(tx, roomId); + const server = tx.db.server.id.find(targetRoom.serverId); + return ( + server?.createdByUserId === userId || targetRoom.createdByUserId === userId + ); +} + +export function canReadAttachmentFile( + tx: TransactionCtx, + userId: string, + fileId: bigint +): boolean { + for (const attachment of tx.db.attachment.fileId.filter(fileId)) { + const message = tx.db.message.id.find(attachment.messageId); + if (!message) continue; + for (const member of tx.db.roomMember.roomId.filter(message.roomId)) { + if (member.userId === userId) return true; + } + } + return false; +} diff --git a/spacetime-presence-ts/example/spacetimedb/src/index.ts b/spacetime-presence-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..70d61b51705 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,1212 @@ +import { ScheduleAt } from 'spacetimedb'; +import { Router, Range, t, type TransactionCtx } from 'spacetimedb/server'; +import { + installPresenceConfig, + removePresence, + runPresenceSweep, + upsertPresence, +} from '@spacetimedb/presence'; +import { + getPublicKeyPemParams, + githubCallbackHandler, + githubStartHandler, + googleCallbackHandler, + googleStartHandler, + linkConnectionParams, + listMySessionsParams, + logoutHandler, + makeEmailVerifyHandler, + makeEmailVerifyRequestHandler, + makeForgotPasswordHandler, + meHandler, + passwordLoginHandler, + passwordSignupHandler, + parseCookies, + refreshHandler, + resetPasswordHandler, + revokeMySessionParams, + revokeSessionParams, + setAuthConfigParams, + unlinkConnectionParams, + updateProfileParams, + publicKeyFromPem, + verifyJwt, + type MailParams, + type SendMailFn, +} from '@spacetimedb/auth/submodule'; +import * as auth from '@spacetimedb/auth/submodule'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import { + fileSha256Hex, + FILE_VISIBILITY_OWNER, +} from '@spacetimedb/files/submodule'; +import * as files from '@spacetimedb/files/submodule'; +import { + RATE_LIMIT_PROFILE, + RATE_LIMIT_REACTION, + RATE_LIMIT_ROOM_WRITE, + RATE_LIMIT_SEND, + RATE_LIMIT_TYPING, + typingScope, +} from './chat-policy'; +import { registerChatViews } from './views'; +import { chatSweepTick, spacetimedb, type DbSchema } from './schema'; + +const ONE_SECOND_MICROS = 1_000_000n; +const TYPING_TTL_SECONDS = 4; +const GLOBAL_PRESENCE_TTL_SECONDS = 35; +const CHAT_SWEEP_INTERVAL_SECONDS = 10n; +const ACTIVITY_WINDOW_SECONDS = 5 * 60; +const ACTIVITY_CLEANUP_BATCH = 1000; +const ROOM_NAME_MAX = 64; +const DISPLAY_NAME_MAX = 32; +const MESSAGE_MAX = 2000; +const ATTACHMENT_MAX_BYTES = 4_000_000; +const ATTACHMENT_MAX_COUNT = 5; +const ATTACHMENT_MIME_MAX = 128; +const ATTACHMENT_FILENAME_MAX = 256; + +const ALLOWED_REACTIONS = new Set(['+1', 'heart', 'joy', 'wow', 'sad', 'fire']); + +const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => { + console.log( + `[mail] to=${params.to} subject=${params.subject}\n${params.text}` + ); +}; + +// Chat presence states. The presence-ts submodule's presence_entry.status +// stays a free-form string (the submodule is consumer-agnostic); chat_user +// pins down the exact set of values this app supports. +import { chatUserStatus, message } from './model'; +import { + canModerateRoom, + canReadAttachmentFile, + deleteMessageTree, + chatStatusToString, + enforceChatRateLimit, + ensureUser, + findMembership, + findServerMembership, + identitiesEqual as eqIdentity, + identityHex, + insertRoom, + normalizeText, + removeTypingPresence, + requireAuthenticatedUserId, + requireMembership, + requireRoom, + requireRoomAdminOrOwner, + requireServer, + requireServerMembership, + senderError, + updateGlobalPresence, + updateRoomActivity, + upsertRoomReadCursor, + type Tx, +} from './domain'; + +export default spacetimedb; + +export type { DbSchema } from './schema'; +export const { + myServers, + myServerMembers, + myChatUsers, + myPresenceEntries, + myRooms, + myRoomMembers, + myRoomMessages, + myRoomMessageReactions, + myMessageThreads, + myThreadMessages, + myRoomAttachments, + myRoomReadCursors, + myAuthUser, + myRateLimitStatus, +} = registerChatViews(spacetimedb); +export const init = spacetimedb.init(ctx => { + auth.installAuth(ctx.as.auth); + files.installFiles(ctx.as.files); + rateLimit.installRateLimit(ctx.as.rateLimit); + installPresenceConfig(ctx, { + defaultTtlSeconds: GLOBAL_PRESENCE_TTL_SECONDS, + sweepBatch: 1000, + }); + ctx.db.chatSweepTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval( + CHAT_SWEEP_INTERVAL_SECONDS * ONE_SECOND_MICROS + ), + }); +}); + +export const set_auth_config = spacetimedb.reducer( + setAuthConfigParams, + (ctx, args) => { + auth.set_auth_config(ctx.as.auth, args); + } +); + +export const get_auth_public_key = spacetimedb.procedure( + getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + (ctx, args) => + auth.get_auth_public_key(ctx.as.auth, args) as { + publicKeyPem: string; + keyId: string; + issuerUrl: string; + } +); + +export const link_connection = spacetimedb.reducer( + linkConnectionParams, + (ctx, args) => { + auth.link_connection(ctx.as.auth, args); + } +); + +export const unlink_connection = spacetimedb.reducer( + unlinkConnectionParams, + (ctx, args) => { + auth.unlink_connection(ctx.as.auth, args); + } +); + +export const update_profile = spacetimedb.reducer( + updateProfileParams, + (ctx, args) => { + auth.update_profile(ctx.as.auth, args); + } +); + +export const revoke_session = spacetimedb.reducer( + revokeSessionParams, + (ctx, args) => { + auth.revoke_session(ctx.as.auth, args); + } +); + +export const list_my_sessions = spacetimedb.procedure( + listMySessionsParams, + t.object('MySessions', { + sessions: t.array( + t.object('MySession', { + sessionId: t.string(), + expiresAt: t.timestamp(), + createdAt: t.timestamp(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + isCurrent: t.bool(), + }) + ), + }), + (ctx, args) => auth.list_my_sessions(ctx.as.auth, args) +); + +export const revoke_my_session = spacetimedb.reducer( + revokeMySessionParams, + (ctx, args) => { + auth.revoke_my_session(ctx.as.auth, args); + } +); + +export const heartbeat = spacetimedb.reducer({}, ctx => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + const user = ensureUser(tx, userId); + const next = { ...user, lastActiveAt: tx.timestamp }; + tx.db.chatUser.identity.update(next); + updateGlobalPresence(tx, next); +}); + +export const whoami = spacetimedb.procedure( + {}, + t.object('WhoAmI', { + userId: t.option(t.string()), + senderIdentityHex: t.string(), + userDisplayName: t.option(t.string()), + userStatus: t.option(t.string()), + }), + ctx => + ctx.withTx(tx => { + const binding = tx.db.auth.authConnectionBinding.stdbIdentity.find( + tx.sender + ); + const userId = binding?.userId ?? undefined; + const user = tx.db.chatUser.identity.find(tx.sender); + return { + userId, + senderIdentityHex: identityHex(tx.sender), + userDisplayName: user?.displayName, + userStatus: user ? chatStatusToString(user.status) : undefined, + }; + }) +); + +export const set_display_name = spacetimedb.reducer( + { displayName: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const displayName = normalizeText( + 'display_name', + args.displayName, + DISPLAY_NAME_MAX + ); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_PROFILE.scope, + RATE_LIMIT_PROFILE.limit, + RATE_LIMIT_PROFILE.windowSeconds + ); + const user = ensureUser(tx, userId); + const next = { ...user, displayName, lastActiveAt: tx.timestamp }; + tx.db.chatUser.identity.update(next); + updateGlobalPresence(tx, next); + } +); + +export const set_status = spacetimedb.reducer( + { status: chatUserStatus }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_PROFILE.scope, + RATE_LIMIT_PROFILE.limit, + RATE_LIMIT_PROFILE.windowSeconds + ); + const user = ensureUser(tx, userId); + const next = { ...user, status: args.status, lastActiveAt: tx.timestamp }; + tx.db.chatUser.identity.update(next); + updateGlobalPresence(tx, next); + } +); + +export const create_server = spacetimedb.reducer( + { name: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const name = normalizeText('server_name', args.name, ROOM_NAME_MAX); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + ensureUser(tx, userId); + const srv = tx.db.server.insert({ + id: 0n, + name, + createdByUserId: userId, + createdAt: tx.timestamp, + }); + tx.db.serverMember.insert({ + id: 0n, + serverId: srv.id, + userId, + role: 'owner', + joinedAt: tx.timestamp, + }); + insertRoom(tx, { + serverId: srv.id, + name: 'general', + isPrivate: false, + createdByUserId: userId, + role: 'owner', + }); + } +); + +export const rename_server = spacetimedb.reducer( + { serverId: t.u64(), name: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const name = normalizeText('server_name', args.name, ROOM_NAME_MAX); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + const srv = requireServer(tx, args.serverId); + if (srv.createdByUserId !== userId) senderError('chat.not_server_owner'); + tx.db.server.id.update({ ...srv, name }); + } +); + +export const delete_server = spacetimedb.reducer( + { serverId: t.u64() }, + (ctx, { serverId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + const srv = requireServer(tx, serverId); + if (srv.createdByUserId !== userId) senderError('chat.not_server_owner'); + + for (const r of [...tx.db.room.serverId.filter(serverId)]) { + for (const m of [...tx.db.message.roomId.filter(r.id)]) { + deleteMessageTree(tx, m); + } + for (const c of [...tx.db.roomReadCursor.roomId.filter(r.id)]) + tx.db.roomReadCursor.id.delete(c.id); + for (const mem of [...tx.db.roomMember.roomId.filter(r.id)]) + tx.db.roomMember.id.delete(mem.id); + for (const ev of [...tx.db.roomActivityEvent.roomId.filter(r.id)]) + tx.db.roomActivityEvent.id.delete(ev.id); + tx.db.room.id.delete(r.id); + } + for (const sm of [...tx.db.serverMember.serverId.filter(serverId)]) + tx.db.serverMember.id.delete(sm.id); + tx.db.server.id.delete(serverId); + } +); + +export const join_server = spacetimedb.reducer( + { serverId: t.u64() }, + (ctx, { serverId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + ensureUser(tx, userId); + requireServer(tx, serverId); + if (findServerMembership(tx, serverId, userId)) return; + tx.db.serverMember.insert({ + id: 0n, + serverId, + userId, + role: 'member', + joinedAt: tx.timestamp, + }); + } +); + +export const leave_server = spacetimedb.reducer( + { serverId: t.u64() }, + (ctx, { serverId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + const srv = requireServer(tx, serverId); + if (srv.createdByUserId === userId) + senderError('chat.owner_cannot_leave_server'); + const mem = requireServerMembership(tx, serverId, userId); + tx.db.serverMember.id.delete(mem.id); + for (const r of [...tx.db.room.serverId.filter(serverId)]) { + const rm = findMembership(tx, r.id, userId); + if (rm) tx.db.roomMember.id.delete(rm.id); + } + } +); + +export const create_room = spacetimedb.reducer( + { + serverId: t.u64(), + name: t.string(), + isPrivate: t.bool(), + category: t.option(t.string()), + }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const name = normalizeText('room_name', args.name, ROOM_NAME_MAX); + const category = args.category + ? normalizeText('room_category', args.category, ROOM_NAME_MAX) + : undefined; + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + ensureUser(tx, userId); + requireServer(tx, args.serverId); + requireServerMembership(tx, args.serverId, userId); + insertRoom(tx, { + serverId: args.serverId, + name, + category, + isPrivate: args.isPrivate, + createdByUserId: userId, + role: 'owner', + }); + } +); + +export const join_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, { roomId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + ensureUser(tx, userId); + const targetRoom = requireRoom(tx, roomId); + if (targetRoom.isPrivate) senderError('chat.room_private'); + const existing = findMembership(tx, roomId, userId); + if (existing) return; + tx.db.roomMember.insert({ + id: 0n, + roomId, + userId, + role: 'member', + joinedAt: tx.timestamp, + }); + } +); + +export const leave_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, { roomId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + requireRoom(tx, roomId); + const membership = requireMembership(tx, roomId, userId); + tx.db.roomMember.id.delete(membership.id); + removeTypingPresence(tx, roomId, tx.sender); + } +); + +export const rename_room = spacetimedb.reducer( + { roomId: t.u64(), name: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const name = normalizeText('room_name', args.name, ROOM_NAME_MAX); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + const room = requireRoomAdminOrOwner(tx, args.roomId, userId); + tx.db.room.id.update({ ...room, name }); + } +); + +export const set_room_category = spacetimedb.reducer( + { roomId: t.u64(), category: t.option(t.string()) }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const category = args.category + ? normalizeText('room_category', args.category, ROOM_NAME_MAX) + : undefined; + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + const room = requireRoomAdminOrOwner(tx, args.roomId, userId); + tx.db.room.id.update({ ...room, category }); + } +); + +export const set_room_privacy = spacetimedb.reducer( + { roomId: t.u64(), isPrivate: t.bool() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + const room = requireRoomAdminOrOwner(tx, args.roomId, userId); + tx.db.room.id.update({ ...room, isPrivate: args.isPrivate }); + } +); + +export const delete_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, { roomId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + requireRoomAdminOrOwner(tx, roomId, userId); + + for (const m of [...tx.db.message.roomId.filter(roomId)]) { + deleteMessageTree(tx, m); + } + for (const c of [...tx.db.roomReadCursor.roomId.filter(roomId)]) + tx.db.roomReadCursor.id.delete(c.id); + for (const mem of [...tx.db.roomMember.roomId.filter(roomId)]) + tx.db.roomMember.id.delete(mem.id); + for (const ev of [...tx.db.roomActivityEvent.roomId.filter(roomId)]) + tx.db.roomActivityEvent.id.delete(ev.id); + removePresence(tx, typingScope(roomId), identityHex(tx.sender)); + tx.db.room.id.delete(roomId); + } +); + +const attachmentInput = t.object('AttachmentInput', { + mimeType: t.string(), + filename: t.option(t.string()), + bytes: t.array(t.u8()), +}); + +const attachmentFileResult = t.object('AttachmentFileResult', { + filename: t.option(t.string()), + mimeType: t.string(), + bytes: t.array(t.u8()), +}); + +export const get_attachment_file = spacetimedb.procedure( + { fileId: t.u64() }, + attachmentFileResult, + (ctx, args) => + ctx.withTx(tx => { + const binding = tx.db.auth.authConnectionBinding.stdbIdentity.find( + tx.sender + ); + if (!binding || !canReadAttachmentFile(tx, binding.userId, args.fileId)) { + senderError('chat.attachment_not_found'); + } + const file = tx.db.files.file.id.find(args.fileId); + if (!file) senderError('chat.attachment_not_found'); + const blob = tx.db.files.fileBlob.fileId.find(args.fileId); + if (!blob) senderError('chat.attachment_not_found'); + let filename: string | undefined; + for (const a of tx.db.attachment.fileId.filter(args.fileId)) { + filename = a.filename ?? undefined; + break; + } + return { + filename, + mimeType: file.mimeType, + bytes: blob.bytes, + }; + }) +); + +export const send_message = spacetimedb.reducer( + { + roomId: t.u64(), + content: t.string(), + replyToMessageId: t.option(t.u64()), + attachments: t.array(attachmentInput), + }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const trimmedContent = args.content.trim().replace(/\s+/g, ' '); + const hasAttachments = args.attachments.length > 0; + if (!hasAttachments && trimmedContent.length === 0) + senderError('chat.invalid_message'); + if (trimmedContent.length > MESSAGE_MAX) + senderError('chat.message_too_long'); + + if (args.attachments.length > ATTACHMENT_MAX_COUNT) { + senderError( + `chat.too_many_attachments:${args.attachments.length}/${ATTACHMENT_MAX_COUNT}` + ); + } + for (const a of args.attachments) { + if (a.mimeType.length === 0 || a.mimeType.length > ATTACHMENT_MIME_MAX) + senderError('chat.invalid_attachment_mime'); + if ( + a.filename !== undefined && + a.filename.length > ATTACHMENT_FILENAME_MAX + ) + senderError('chat.invalid_attachment_filename'); + if (a.bytes.length === 0) senderError('chat.empty_attachment'); + if (a.bytes.length > ATTACHMENT_MAX_BYTES) + senderError( + `chat.attachment_too_large:${a.bytes.length}/${ATTACHMENT_MAX_BYTES}` + ); + } + + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_SEND.scope, + RATE_LIMIT_SEND.limit, + RATE_LIMIT_SEND.windowSeconds + ); + const user = ensureUser(tx, userId); + requireRoom(tx, args.roomId); + requireMembership(tx, args.roomId, userId); + + if (args.replyToMessageId !== undefined) { + const parent = tx.db.message.id.find(args.replyToMessageId); + if (!parent || parent.roomId !== args.roomId) + senderError('chat.invalid_reply_target'); + } + + const msg = tx.db.message.insert({ + id: 0n, + roomId: args.roomId, + author: tx.sender, + content: trimmedContent, + createdAt: tx.timestamp, + editedAt: undefined, + replyToMessageId: args.replyToMessageId, + pinnedAt: undefined, + pinnedBy: undefined, + }); + + for (let i = 0; i < args.attachments.length; i++) { + const a = args.attachments[i]!; + const path = `/room/${args.roomId}/msg/${msg.id}/${i}`; + const file = tx.db.files.file.insert({ + id: 0n, + ownerPathKey: files.ownerPathKey(userId, path), + path, + ownerUserId: userId, + mimeType: a.mimeType, + size: BigInt(a.bytes.length), + sha256Hex: fileSha256Hex(a.bytes), + visibility: FILE_VISIBILITY_OWNER, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + tx.db.files.fileBlob.insert({ fileId: file.id, bytes: a.bytes }); + tx.db.attachment.insert({ + id: 0n, + messageId: msg.id, + fileId: file.id, + ownerUserId: userId, + ordinal: i, + filename: a.filename, + createdAt: tx.timestamp, + }); + } + + tx.db.roomActivityEvent.insert({ + id: 0n, + roomId: args.roomId, + createdAt: tx.timestamp, + }); + updateRoomActivity(tx, args.roomId); + removeTypingPresence(tx, args.roomId, tx.sender); + const nextUser = { + ...user, + lastActiveAt: tx.timestamp, + lastMessageAt: tx.timestamp, + }; + tx.db.chatUser.identity.update(nextUser); + updateGlobalPresence(tx, nextUser); + } +); + +export const edit_message = spacetimedb.reducer( + { messageId: t.u64(), content: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const content = normalizeText('message', args.content, MESSAGE_MAX); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_SEND.scope, + RATE_LIMIT_SEND.limit, + RATE_LIMIT_SEND.windowSeconds + ); + ensureUser(tx, userId); + const msg = tx.db.message.id.find(args.messageId); + if (!msg) senderError('chat.message_not_found'); + requireMembership(tx, msg.roomId, userId); + if (!eqIdentity(msg.author, tx.sender)) + senderError('chat.not_message_author'); + tx.db.message.id.update({ + ...msg, + content, + editedAt: tx.timestamp, + }); + } +); + +export const delete_message = spacetimedb.reducer( + { messageId: t.u64() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_SEND.scope, + RATE_LIMIT_SEND.limit, + RATE_LIMIT_SEND.windowSeconds + ); + ensureUser(tx, userId); + const msg = tx.db.message.id.find(args.messageId); + if (!msg) senderError('chat.message_not_found'); + requireMembership(tx, msg.roomId, userId); + if ( + !eqIdentity(msg.author, tx.sender) && + !canModerateRoom(tx, msg.roomId, userId) + ) + senderError('chat.not_message_author'); + deleteMessageTree(tx, msg); + } +); + +export const send_thread_message = spacetimedb.reducer( + { rootMessageId: t.u64(), content: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const content = normalizeText('thread_message', args.content, MESSAGE_MAX); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_SEND.scope, + RATE_LIMIT_SEND.limit, + RATE_LIMIT_SEND.windowSeconds + ); + const user = ensureUser(tx, userId); + const root = tx.db.message.id.find(args.rootMessageId); + if (!root) senderError('chat.message_not_found'); + requireMembership(tx, root.roomId, userId); + + let thread = tx.db.messageThread.rootMessageId.find(root.id); + if (!thread) { + thread = tx.db.messageThread.insert({ + id: 0n, + rootMessageId: root.id, + roomId: root.roomId, + createdBy: tx.sender, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + } else { + tx.db.messageThread.id.update({ ...thread, updatedAt: tx.timestamp }); + } + + tx.db.threadMessage.insert({ + id: 0n, + threadId: thread.id, + author: tx.sender, + content, + createdAt: tx.timestamp, + editedAt: undefined, + }); + + const nextUser = { + ...user, + lastActiveAt: tx.timestamp, + lastMessageAt: tx.timestamp, + }; + tx.db.chatUser.identity.update(nextUser); + updateGlobalPresence(tx, nextUser); + } +); + +export const edit_thread_message = spacetimedb.reducer( + { threadMessageId: t.u64(), content: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const content = normalizeText('thread_message', args.content, MESSAGE_MAX); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_SEND.scope, + RATE_LIMIT_SEND.limit, + RATE_LIMIT_SEND.windowSeconds + ); + ensureUser(tx, userId); + const msg = tx.db.threadMessage.id.find(args.threadMessageId); + if (!msg) senderError('chat.thread_message_not_found'); + const thread = tx.db.messageThread.id.find(msg.threadId); + if (!thread) senderError('chat.thread_not_found'); + requireMembership(tx, thread.roomId, userId); + if (!eqIdentity(msg.author, tx.sender)) + senderError('chat.not_message_author'); + tx.db.threadMessage.id.update({ ...msg, content, editedAt: tx.timestamp }); + } +); + +export const delete_thread_message = spacetimedb.reducer( + { threadMessageId: t.u64() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_SEND.scope, + RATE_LIMIT_SEND.limit, + RATE_LIMIT_SEND.windowSeconds + ); + ensureUser(tx, userId); + const msg = tx.db.threadMessage.id.find(args.threadMessageId); + if (!msg) senderError('chat.thread_message_not_found'); + const thread = tx.db.messageThread.id.find(msg.threadId); + if (!thread) senderError('chat.thread_not_found'); + requireMembership(tx, thread.roomId, userId); + if ( + !eqIdentity(msg.author, tx.sender) && + !canModerateRoom(tx, thread.roomId, userId) + ) + senderError('chat.not_message_author'); + tx.db.threadMessage.id.delete(msg.id); + let latestAt = thread.createdAt; + let remaining = 0; + for (const row of tx.db.threadMessage.threadId.filter(thread.id)) { + remaining++; + if ( + (row.createdAt.microsSinceUnixEpoch as bigint) > + (latestAt.microsSinceUnixEpoch as bigint) + ) + latestAt = row.createdAt; + } + if (remaining === 0) tx.db.messageThread.id.delete(thread.id); + else tx.db.messageThread.id.update({ ...thread, updatedAt: latestAt }); + } +); + +export const start_typing = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, { roomId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_TYPING.scope, + RATE_LIMIT_TYPING.limit, + RATE_LIMIT_TYPING.windowSeconds + ); + const user = ensureUser(tx, userId); + requireRoom(tx, roomId); + requireMembership(tx, roomId, userId); + upsertPresence(tx, { + scope: typingScope(roomId), + subject: identityHex(tx.sender), + status: 'typing', + activity: 'typing', + payloadJson: JSON.stringify({ + displayName: user.displayName, + userId: user.userId, + }), + ttlSeconds: TYPING_TTL_SECONDS, + }); + } +); + +export const stop_typing = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, { roomId }) => { + requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + removeTypingPresence(tx, roomId, tx.sender); + } +); + +export const mark_room_read = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, { roomId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + requireRoom(tx, roomId); + requireMembership(tx, roomId, userId); + let latestMessageId = 0n; + let latestMicros = 0n; + for (const msg of tx.db.message.roomId.filter(roomId)) { + const micros = msg.createdAt.microsSinceUnixEpoch as bigint; + if (micros > latestMicros) { + latestMicros = micros; + latestMessageId = msg.id; + } + } + upsertRoomReadCursor(tx, roomId, latestMessageId); + } +); + +export const toggle_reaction = spacetimedb.reducer( + { messageId: t.u64(), emoji: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const emoji = args.emoji.trim(); + if (!ALLOWED_REACTIONS.has(emoji)) + senderError('chat.invalid_reaction_emoji'); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_REACTION.scope, + RATE_LIMIT_REACTION.limit, + RATE_LIMIT_REACTION.windowSeconds + ); + ensureUser(tx, userId); + const msg = tx.db.message.id.find(args.messageId); + if (!msg) senderError('chat.message_not_found'); + requireMembership(tx, msg.roomId, userId); + for (const r of tx.db.messageReaction.messageId.filter(msg.id)) { + if (eqIdentity(r.identity, tx.sender) && r.emoji === emoji) { + tx.db.messageReaction.id.delete(r.id); + return; + } + } + tx.db.messageReaction.insert({ + id: 0n, + messageId: msg.id, + identity: tx.sender, + emoji, + createdAt: tx.timestamp, + }); + } +); + +export const pin_message = spacetimedb.reducer( + { messageId: t.u64() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + const msg = tx.db.message.id.find(args.messageId); + if (!msg) senderError('chat.message_not_found'); + requireMembership(tx, msg.roomId, userId); + if (msg.pinnedAt) return; + tx.db.message.id.update({ + ...msg, + pinnedAt: tx.timestamp, + pinnedBy: tx.sender, + }); + } +); + +export const unpin_message = spacetimedb.reducer( + { messageId: t.u64() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + const msg = tx.db.message.id.find(args.messageId); + if (!msg) senderError('chat.message_not_found'); + requireMembership(tx, msg.roomId, userId); + if (!msg.pinnedAt) return; + tx.db.message.id.update({ + ...msg, + pinnedAt: undefined, + pinnedBy: undefined, + }); + } +); + +const SEARCH_MAX_RESULTS = 50; +const SEARCH_QUERY_MAX = 200; +export const search_messages = spacetimedb.procedure( + { roomId: t.u64(), query: t.string() }, + t.array(message.rowType), + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const q = args.query.trim().slice(0, SEARCH_QUERY_MAX).toLowerCase(); + if (q.length === 0) return []; + return ctx.withTx(tx => { + requireMembership(tx, args.roomId, userId); + const matches = [...tx.db.message.roomId.filter(args.roomId)].filter(m => + m.content.toLowerCase().includes(q) + ); + matches.sort((a, b) => + b.createdAt.microsSinceUnixEpoch < a.createdAt.microsSinceUnixEpoch + ? -1 + : 1 + ); + return matches.slice(0, SEARCH_MAX_RESULTS); + }); + } +); + +export const chat_sweep = spacetimedb.reducer( + { onSchedule: chatSweepTick }, + { arg: chatSweepTick.rowType }, + ctx => { + runPresenceSweep( + ctx, + ctx.db.presenceEntry.expiresAt.filter( + new Range(undefined, { tag: 'included', value: ctx.timestamp }) + ) + ); + + const cutoff = + (ctx.timestamp.microsSinceUnixEpoch as bigint) - + BigInt(ACTIVITY_WINDOW_SECONDS) * ONE_SECOND_MICROS; + let deleted = 0; + const affectedRoomIds = new Set(); + for (const evt of ctx.db.roomActivityEvent.iter()) { + if (deleted >= ACTIVITY_CLEANUP_BATCH) break; + if ((evt.createdAt.microsSinceUnixEpoch as bigint) >= cutoff) continue; + affectedRoomIds.add(evt.roomId); + ctx.db.roomActivityEvent.delete(evt); + deleted++; + } + + for (const roomId of affectedRoomIds) { + updateRoomActivity(ctx, roomId); + } + } +); + +export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => + passwordSignupHandler(ctx.as.auth, req) +); +export const authPasswordLogin = spacetimedb.httpHandler((ctx, req) => + passwordLoginHandler(ctx.as.auth, req) +); +export const authMe = spacetimedb.httpHandler((ctx, req) => + meHandler(ctx.as.auth, req) +); +export const authLogout = spacetimedb.httpHandler((ctx, req) => + logoutHandler(ctx.as.auth, req) +); +export const authRefresh = spacetimedb.httpHandler((ctx, req) => + refreshHandler(ctx.as.auth, req) +); +export const authGoogleStart = spacetimedb.httpHandler((ctx, req) => + googleStartHandler(ctx.as.auth, req) +); +export const authGoogleCallback = spacetimedb.httpHandler((ctx, req) => + googleCallbackHandler(ctx.as.auth, req) +); +export const authGithubStart = spacetimedb.httpHandler((ctx, req) => + githubStartHandler(ctx.as.auth, req) +); +export const authGithubCallback = spacetimedb.httpHandler((ctx, req) => + githubCallbackHandler(ctx.as.auth, req) +); + +const fileServeHandler = files.createFileHttpHandler({ + getOwner: (ctx, req) => + ctx.withTx((tx: TransactionCtx) => { + const binding = tx.db.auth.authConnectionBinding.stdbIdentity.find( + tx.sender + ); + if (binding) return binding.userId; + + const cfg = tx.db.auth.authConfig.singleton.find(true); + if (!cfg) return undefined; + const bearer = req.headers.get('authorization'); + const cookies = parseCookies(req.headers.get('cookie')); + const tokens = [ + bearer && bearer.toLowerCase().startsWith('bearer ') + ? bearer.slice(7).trim() + : undefined, + cookies[cfg.cookieName], + ].filter((token): token is string => Boolean(token)); + for (const token of tokens) { + const verified = verifyJwt( + publicKeyFromPem(cfg.es256PublicKeyPem), + token, + { + issuer: cfg.issuerUrl, + nowSeconds: Number( + (tx.timestamp.microsSinceUnixEpoch as bigint) / 1_000_000n + ), + } + ); + if (!verified.ok || !verified.claims.jti) continue; + + const session = tx.db.auth.authSession.sessionId.find( + verified.claims.jti + ); + if (!session) continue; + if ( + (session.expiresAt.microsSinceUnixEpoch as bigint) <= + (tx.timestamp.microsSinceUnixEpoch as bigint) + ) { + continue; + } + if (session.userId === verified.claims.sub) return session.userId; + } + return undefined; + }), + canAccess: (ctx, _req, file, userId) => + ctx.withTx((tx: TransactionCtx) => { + if (!userId) return false; + if (file.ownerUserId === userId) return true; + for (const a of tx.db.attachment.fileId.filter(file.id)) { + const msg = tx.db.message.id.find(a.messageId); + if (!msg) continue; + for (const member of tx.db.roomMember.roomId.filter(msg.roomId)) { + if (member.userId === userId) return true; + } + } + return false; + }), +}); +export const fileServe = spacetimedb.httpHandler(fileServeHandler); + +const forgotHandler = makeForgotPasswordHandler({ + sendMail: consoleSendMail, + appName: 'Chat', +}); +const verifyRequestHandler = makeEmailVerifyRequestHandler({ + sendMail: consoleSendMail, + appName: 'Chat', +}); +const verifyHandler = makeEmailVerifyHandler({ + successRedirect: '/?verified=1', +}); + +export const authPasswordForgot = spacetimedb.httpHandler((ctx, req) => + forgotHandler(ctx.as.auth, req) +); +export const authPasswordReset = spacetimedb.httpHandler((ctx, req) => + resetPasswordHandler(ctx.as.auth, req) +); +export const authEmailVerifyRequest = spacetimedb.httpHandler((ctx, req) => + verifyRequestHandler(ctx.as.auth, req) +); +export const authEmailVerify = spacetimedb.httpHandler((ctx, req) => + verifyHandler(ctx.as.auth, req) +); + +export const router = spacetimedb.httpRouter( + new Router() + .post('/auth/password/signup', authPasswordSignup) + .post('/auth/password/login', authPasswordLogin) + .post('/auth/session/refresh', authRefresh) + .get('/auth/me', authMe) + .post('/auth/logout', authLogout) + .get('/auth/google/start', authGoogleStart) + .get('/auth/google/callback', authGoogleCallback) + .get('/auth/github/start', authGithubStart) + .get('/auth/github/callback', authGithubCallback) + .post('/auth/password/forgot', authPasswordForgot) + .post('/auth/password/reset', authPasswordReset) + .post('/auth/email/verify-request', authEmailVerifyRequest) + .get('/auth/email/verify', authEmailVerify) + .get('/files', fileServe) + .get('/files/', fileServe) + .head('/files/', fileServe) + .head('/files', fileServe) +); diff --git a/spacetime-presence-ts/example/spacetimedb/src/model.ts b/spacetime-presence-ts/example/spacetimedb/src/model.ts new file mode 100644 index 00000000000..f323c640f47 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/src/model.ts @@ -0,0 +1,192 @@ +import { table, t } from 'spacetimedb/server'; + +export const chatUserStatus = t.enum('ChatUserStatus', [ + 'Online', + 'Away', + 'Dnd', + 'Invisible', +]); +export const ChatUserStatus = { + Online: { tag: 'Online' as const }, + Away: { tag: 'Away' as const }, + Dnd: { tag: 'Dnd' as const }, + Invisible: { tag: 'Invisible' as const }, +}; + +export const chatUser = table( + { name: 'chat_user', public: false }, + { + identity: t.identity().primaryKey(), + userId: t.string().index(), + displayName: t.string(), + status: chatUserStatus.index(), + createdAt: t.timestamp().index(), + lastActiveAt: t.timestamp().index(), + lastMessageAt: t.timestamp(), + } +); + +export const server = table( + { name: 'server', public: false }, + { + id: t.u64().primaryKey().autoInc(), + name: t.string().index(), + createdByUserId: t.string().index(), + createdAt: t.timestamp(), + } +); + +export const serverMember = table( + { name: 'server_member', public: false }, + { + id: t.u64().primaryKey().autoInc(), + serverId: t.u64().index(), + userId: t.string().index(), + role: t.string(), + joinedAt: t.timestamp(), + } +); + +// Chat tables are scoped per-user via views below. Clients subscribe to the +// `my_*` views; the underlying tables are private. +export const room = table( + { name: 'room', public: false }, + { + id: t.u64().primaryKey().autoInc(), + serverId: t.u64().index(), + name: t.string().index(), + category: t.option(t.string()), + createdByUserId: t.string().index(), + createdAt: t.timestamp().index(), + isPrivate: t.bool(), + activityLabel: t.string().index(), + activityScore: t.u32(), + lastActivityAt: t.option(t.timestamp()), + } +); + +export const roomMember = table( + { name: 'room_member', public: false }, + { + id: t.u64().primaryKey().autoInc(), + roomId: t.u64().index(), + userId: t.string().index(), + role: t.string().index(), + joinedAt: t.timestamp(), + } +); + +export const message = table( + { name: 'message', public: false }, + { + id: t.u64().primaryKey().autoInc(), + roomId: t.u64().index(), + author: t.identity().index(), + content: t.string(), + createdAt: t.timestamp().index(), + editedAt: t.option(t.timestamp()), + replyToMessageId: t.option(t.u64()), + pinnedAt: t.option(t.timestamp()), + pinnedBy: t.option(t.identity()), + } +); + +export const messageReaction = table( + { name: 'message_reaction', public: false }, + { + id: t.u64().primaryKey().autoInc(), + messageId: t.u64().index(), + identity: t.identity().index(), + emoji: t.string().index(), + createdAt: t.timestamp(), + } +); + +export const messageThread = table( + { name: 'message_thread', public: false }, + { + id: t.u64().primaryKey().autoInc(), + rootMessageId: t.u64().unique(), + roomId: t.u64().index(), + createdBy: t.identity().index(), + createdAt: t.timestamp().index(), + updatedAt: t.timestamp().index(), + } +); + +export const threadMessage = table( + { name: 'thread_message', public: false }, + { + id: t.u64().primaryKey().autoInc(), + threadId: t.u64().index(), + author: t.identity().index(), + content: t.string(), + createdAt: t.timestamp().index(), + editedAt: t.option(t.timestamp()), + } +); + +export const attachment = table( + { name: 'attachment', public: false }, + { + id: t.u64().primaryKey().autoInc(), + messageId: t.u64().index(), + fileId: t.u64().index(), + ownerUserId: t.string().index(), + ordinal: t.u32(), + filename: t.option(t.string()), + createdAt: t.timestamp(), + } +); + +export const attachmentViewRow = t.object('RoomAttachment', { + id: t.u64(), + messageId: t.u64(), + fileId: t.u64(), + ownerUserId: t.string(), + ordinal: t.u32(), + filename: t.option(t.string()), + path: t.string(), + mimeType: t.string(), + size: t.u64(), + sha256Hex: t.string(), + visibility: t.string(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}); + +export const roomReadCursor = table( + { name: 'room_read_cursor', public: false }, + { + id: t.u64().primaryKey().autoInc(), + roomId: t.u64().index(), + identity: t.identity().index(), + lastReadMessageId: t.u64(), + lastReadAt: t.timestamp().index(), + } +); + +export const roomActivityEvent = table( + { name: 'room_activity_event', public: false }, + { + id: t.u64().primaryKey().autoInc(), + roomId: t.u64().index(), + createdAt: t.timestamp().index(), + } +); + +export const presenceEntry = table( + { name: 'presence_entry', public: false }, + { + key: t.string().primaryKey(), + scope: t.string().index(), + subject: t.string().index(), + status: t.string().index(), + activity: t.option(t.string()), + payloadJson: t.option(t.string()), + joinedAt: t.timestamp().index(), + lastSeenAt: t.timestamp().index(), + expiresAt: t.timestamp().index(), + updatedAt: t.timestamp(), + } +); diff --git a/spacetime-presence-ts/example/spacetimedb/src/schema.ts b/spacetime-presence-ts/example/spacetimedb/src/schema.ts new file mode 100644 index 00000000000..53dd533b127 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/src/schema.ts @@ -0,0 +1,60 @@ +import * as auth from '@spacetimedb/auth/submodule'; +import * as files from '@spacetimedb/files/submodule'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import { schema, table, t, type InferSchema } from 'spacetimedb/server'; +import { + attachment, + chatUser, + message, + messageReaction, + messageThread, + presenceEntry, + room, + roomActivityEvent, + roomMember, + roomReadCursor, + server, + serverMember, + threadMessage, +} from './model'; + +export const presenceConfig = table( + { name: 'presence_config', public: false }, + { + singleton: t.bool().primaryKey(), + defaultTtlSeconds: t.u32(), + sweepBatch: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const chatSweepTick = table( + { name: 'chat_sweep_tick' }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +export const spacetimedb = schema({ + auth, + files, + rateLimit, + chatUser, + server, + serverMember, + room, + roomMember, + message, + messageReaction, + messageThread, + threadMessage, + attachment, + roomReadCursor, + roomActivityEvent, + presenceEntry, + presenceConfig, + chatSweepTick, +}); + +export type DbSchema = InferSchema; diff --git a/spacetime-presence-ts/example/spacetimedb/src/views.ts b/spacetime-presence-ts/example/spacetimedb/src/views.ts new file mode 100644 index 00000000000..03c9ac6f6ce --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/src/views.ts @@ -0,0 +1,376 @@ +import { t, type ViewCtx } from 'spacetimedb/server'; +import { + RATE_LIMIT_PROFILE, + RATE_LIMIT_REACTION, + RATE_LIMIT_ROOM_WRITE, + RATE_LIMIT_SEND, + RATE_LIMIT_TYPING, + PRESENCE_SCOPE_GLOBAL, + typingScope, +} from './chat-policy'; +import { + attachmentViewRow, + chatUser, + message, + messageReaction, + messageThread, + presenceEntry, + room, + roomMember, + roomReadCursor, + server, + serverMember, + threadMessage, +} from './model'; +import type { DbSchema } from './schema'; + +type SpacetimeDb = typeof import('./schema').spacetimedb; + +function myRoomIds(ctx: ViewCtx): Set { + const out = new Set(); + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return out; + for (const membership of ctx.db.roomMember.userId.filter(binding.userId)) { + out.add(membership.roomId); + } + return out; +} + +function myServerIds(ctx: ViewCtx): Set { + const out = new Set(); + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return out; + for (const membership of ctx.db.serverMember.userId.filter(binding.userId)) { + out.add(membership.serverId); + } + for (const roomId of myRoomIds(ctx)) { + const roomRow = ctx.db.room.id.find(roomId); + if (roomRow) out.add(roomRow.serverId); + } + return out; +} + +function myMessageIds( + ctx: ViewCtx, + roomIds: Set +): Set { + const out = new Set(); + for (const roomId of roomIds) { + for (const row of ctx.db.message.roomId.filter(roomId)) out.add(row.id); + } + return out; +} + +function myThreadIds( + ctx: ViewCtx, + roomIds: Set +): Set { + const out = new Set(); + for (const roomId of roomIds) { + for (const row of ctx.db.messageThread.roomId.filter(roomId)) + out.add(row.id); + } + return out; +} + +function myVisibleUserIds( + ctx: ViewCtx, + serverIds = myServerIds(ctx), + roomIds = myRoomIds(ctx) +): Set { + const out = new Set(); + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (binding) out.add(binding.userId); + for (const serverId of serverIds) { + for (const row of ctx.db.serverMember.serverId.filter(serverId)) + out.add(row.userId); + } + for (const roomId of roomIds) { + for (const row of ctx.db.roomMember.roomId.filter(roomId)) + out.add(row.userId); + } + return out; +} + +function myVisibleIdentitySubjects( + ctx: ViewCtx, + userIds: Set +): Set { + const out = new Set(); + for (const userId of userIds) { + for (const user of ctx.db.chatUser.userId.filter(userId)) { + out.add(user.identity.toHexString()); + } + } + return out; +} + +export function registerChatViews(spacetimedb: SpacetimeDb) { + const authUserViewRow = t.object('ChatAuthUser', { + userId: t.string(), + email: t.string(), + emailVerified: t.bool(), + name: t.option(t.string()), + image: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + }); + const rateLimitStatusRow = t.object('ChatRateLimitStatus', { + scope: t.string(), + limit: t.u32(), + used: t.u32(), + remaining: t.u32(), + resetAt: t.timestamp(), + }); + + const myServers = spacetimedb.view( + { name: 'my_servers', public: true }, + t.array(server.rowType), + ctx => { + const ids = myServerIds(ctx); + const out = []; + for (const serverId of ids) { + const row = ctx.db.server.id.find(serverId); + if (row) out.push(row); + } + return out; + } + ); + + const myServerMembers = spacetimedb.view( + { name: 'my_server_members', public: true }, + t.array(serverMember.rowType), + ctx => { + const out = []; + for (const serverId of myServerIds(ctx)) { + for (const row of ctx.db.serverMember.serverId.filter(serverId)) + out.push(row); + } + return out; + } + ); + + const myChatUsers = spacetimedb.view( + { name: 'my_chat_users', public: true }, + t.array(chatUser.rowType), + ctx => { + const out = []; + for (const userId of myVisibleUserIds(ctx)) { + for (const row of ctx.db.chatUser.userId.filter(userId)) out.push(row); + } + return out; + } + ); + + const myPresenceEntries = spacetimedb.view( + { name: 'my_presence_entries', public: true }, + t.array(presenceEntry.rowType), + ctx => { + const roomIds = myRoomIds(ctx); + const visibleSubjects = myVisibleIdentitySubjects( + ctx, + myVisibleUserIds(ctx, myServerIds(ctx), roomIds) + ); + const out = []; + for (const subject of visibleSubjects) { + for (const entry of ctx.db.presenceEntry.subject.filter(subject)) { + if (entry.scope === PRESENCE_SCOPE_GLOBAL) out.push(entry); + } + } + for (const roomId of roomIds) { + for (const entry of ctx.db.presenceEntry.scope.filter( + typingScope(roomId) + )) { + if (visibleSubjects.has(entry.subject)) out.push(entry); + } + } + return out; + } + ); + + const myRooms = spacetimedb.view( + { name: 'my_rooms', public: true }, + t.array(room.rowType), + ctx => { + const out = []; + for (const roomId of myRoomIds(ctx)) { + const row = ctx.db.room.id.find(roomId); + if (row) out.push(row); + } + return out; + } + ); + + const myRoomMembers = spacetimedb.view( + { name: 'my_room_members', public: true }, + t.array(roomMember.rowType), + ctx => { + const out = []; + for (const roomId of myRoomIds(ctx)) { + for (const row of ctx.db.roomMember.roomId.filter(roomId)) + out.push(row); + } + return out; + } + ); + + const myRoomMessages = spacetimedb.view( + { name: 'my_room_messages', public: true }, + t.array(message.rowType), + ctx => { + const out = []; + for (const roomId of myRoomIds(ctx)) { + for (const row of ctx.db.message.roomId.filter(roomId)) out.push(row); + } + return out; + } + ); + + const myRoomMessageReactions = spacetimedb.view( + { name: 'my_room_message_reactions', public: true }, + t.array(messageReaction.rowType), + ctx => { + const rooms = myRoomIds(ctx); + const out = []; + for (const messageId of myMessageIds(ctx, rooms)) { + for (const row of ctx.db.messageReaction.messageId.filter(messageId)) + out.push(row); + } + return out; + } + ); + + const myMessageThreads = spacetimedb.view( + { name: 'my_message_threads', public: true }, + t.array(messageThread.rowType), + ctx => { + const out = []; + for (const roomId of myRoomIds(ctx)) { + for (const row of ctx.db.messageThread.roomId.filter(roomId)) + out.push(row); + } + return out; + } + ); + + const myThreadMessages = spacetimedb.view( + { name: 'my_thread_messages', public: true }, + t.array(threadMessage.rowType), + ctx => { + const rooms = myRoomIds(ctx); + const out = []; + for (const threadId of myThreadIds(ctx, rooms)) { + for (const row of ctx.db.threadMessage.threadId.filter(threadId)) + out.push(row); + } + return out; + } + ); + + const myRoomAttachments = spacetimedb.view( + { name: 'my_room_attachments', public: true }, + t.array(attachmentViewRow), + ctx => { + const rooms = myRoomIds(ctx); + const out = []; + for (const messageId of myMessageIds(ctx, rooms)) { + for (const attachment of ctx.db.attachment.messageId.filter( + messageId + )) { + const file = ctx.db.files.file.id.find(attachment.fileId); + if (!file) continue; + out.push({ + id: attachment.id, + messageId: attachment.messageId, + fileId: attachment.fileId, + ownerUserId: attachment.ownerUserId, + ordinal: attachment.ordinal, + filename: attachment.filename, + path: file.path, + mimeType: file.mimeType, + size: file.size, + sha256Hex: file.sha256Hex, + visibility: file.visibility, + createdAt: attachment.createdAt, + updatedAt: file.updatedAt, + }); + } + } + return out; + } + ); + + const myRoomReadCursors = spacetimedb.view( + { name: 'my_room_read_cursors', public: true }, + t.array(roomReadCursor.rowType), + ctx => [...ctx.db.roomReadCursor.identity.filter(ctx.sender)] + ); + + const myAuthUser = spacetimedb.view( + { name: 'my_auth_user', public: true }, + t.array(authUserViewRow), + ctx => { + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return []; + const row = ctx.db.auth.authUser.userId.find(binding.userId); + return row ? [row] : []; + } + ); + + const myRateLimitStatus = spacetimedb.view( + { name: 'my_rate_limit_status', public: true }, + t.array(rateLimitStatusRow), + ctx => { + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return []; + const out = []; + for (const limit of [ + RATE_LIMIT_SEND, + RATE_LIMIT_TYPING, + RATE_LIMIT_ROOM_WRITE, + RATE_LIMIT_REACTION, + RATE_LIMIT_PROFILE, + ]) { + const row = ctx.db.rateLimit.rateLimitBucket.key.find( + `${limit.scope}:user:${binding.userId}` + ); + if (!row) continue; + out.push({ + scope: limit.scope, + limit: limit.limit, + used: row.count, + remaining: Math.max(0, limit.limit - row.count), + resetAt: row.expiresAt, + }); + } + return out; + } + ); + + return { + myServers, + myServerMembers, + myChatUsers, + myPresenceEntries, + myRooms, + myRoomMembers, + myRoomMessages, + myRoomMessageReactions, + myMessageThreads, + myThreadMessages, + myRoomAttachments, + myRoomReadCursors, + myAuthUser, + myRateLimitStatus, + }; +} diff --git a/spacetime-presence-ts/example/spacetimedb/tsconfig.json b/spacetime-presence-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..f004a6cbc79 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-presence-ts/example/src/app.ts b/spacetime-presence-ts/example/src/app.ts new file mode 100644 index 00000000000..58ec8c1687c --- /dev/null +++ b/spacetime-presence-ts/example/src/app.ts @@ -0,0 +1,812 @@ +import { + authUrlState, + clearAuthResultParams, + mountAuthPanel, +} from '@spacetimedb/submodule-shared'; +import '@spacetimedb/submodule-shared/styles.css'; +import { + DbConnection, + tables, + type ErrorContext, + type EventContext, +} from './module_bindings/app/index.ts'; +import type { + PresenceEntry, + Server, + ChatAuthUser as AuthUserRow, + ChatRateLimitStatus, + MessageThread, + ThreadMessage, +} from './module_bindings/app/types.ts'; + +interface AttachmentInput { + mimeType: string; + filename: string | undefined; + bytes: Uint8Array; +} + +declare global { + interface Window { + chat?: { + setDisplayName: (displayName: string) => Promise; + setStatus: ( + status: 'online' | 'away' | 'dnd' | 'invisible' + ) => Promise; + createServer: (name: string) => Promise; + renameServer: (serverId: bigint, name: string) => Promise; + deleteServer: (serverId: bigint) => Promise; + joinServer: (serverId: bigint) => Promise; + leaveServer: (serverId: bigint) => Promise; + setActiveServer: (serverId: bigint | null) => void; + createRoom: ( + serverId: bigint, + name: string, + isPrivate: boolean, + category?: string + ) => Promise; + joinRoom: (roomId: bigint) => Promise; + leaveRoom: (roomId: bigint) => Promise; + sendMessage: ( + roomId: bigint, + content: string, + replyToMessageId?: bigint, + attachments?: AttachmentInput[] + ) => Promise; + sendThreadMessage: ( + rootMessageId: bigint, + content: string + ) => Promise; + getAttachmentFile: ( + fileId: bigint + ) => Promise<{ filename?: string; mimeType: string; bytes: Uint8Array }>; + editMessage: (messageId: bigint, content: string) => Promise; + deleteMessage: (messageId: bigint) => Promise; + editThreadMessage: ( + threadMessageId: bigint, + content: string + ) => Promise; + deleteThreadMessage: (threadMessageId: bigint) => Promise; + renameRoom: (roomId: bigint, name: string) => Promise; + setRoomCategory: (roomId: bigint, category?: string) => Promise; + setRoomPrivacy: (roomId: bigint, isPrivate: boolean) => Promise; + deleteRoom: (roomId: bigint) => Promise; + startTyping: (roomId: bigint) => Promise; + stopTyping: (roomId: bigint) => Promise; + markRoomRead: (roomId: bigint) => Promise; + toggleReaction: (messageId: bigint, emoji: string) => Promise; + pinMessage: (messageId: bigint) => Promise; + unpinMessage: (messageId: bigint) => Promise; + searchMessages: (roomId: bigint, query: string) => Promise; + setActiveRoom: (roomId: bigint | null) => void; + heartbeat: () => Promise; + signup: (args: { + email: string; + password: string; + name?: string; + }) => Promise; + login: (args: { email: string; password: string }) => Promise; + logout: () => Promise; + oauthStart: (provider: 'google' | 'github') => void; + forgotPassword: (email: string) => Promise; + resetPassword: (token: string, newPassword: string) => Promise; + requestEmailVerify: () => Promise; + whoami: () => Promise<{ + userId: string | undefined; + senderIdentityHex: string; + }>; + setProfile: (args: { name?: string; image?: string }) => Promise; + }; + } +} + +interface ServerConfig { + spacetimeUri: string; + databaseName: string; + oauth?: { + google?: boolean; + github?: boolean; + }; +} + +interface AuthUser { + userId: string; + email: string; + emailVerified: boolean; + name?: string; + image?: string; +} + +interface AuthRefreshResponse { + user: AuthUser; + token: string; + sessionExpiresAt: number; +} + +interface AuthMeResponse { + user: AuthUser; + sessionExpiresAt: number; +} + +const PRESENCE_SCOPE_GLOBAL = 'chat.global'; +const PRESENCE_SCOPE_TYPING_PREFIX = 'chat.typing:'; +const HEARTBEAT_INTERVAL_MS = 15_000; +const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000]; + +let config: ServerConfig | null = null; +let conn: DbConnection | null = null; +let meHex = ''; +let activeServerId: bigint | null = null; +let activeRoomId: bigint | null = null; +let heartbeatTimer: ReturnType | null = null; +let reconnectTimer: ReturnType | null = null; +let reconnectAttempt = 0; +let authUser: AuthUser | null = null; +let sessionExpiresAt: number | undefined; + +function normalizeError(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +function emitConnectionState( + state: 'connecting' | 'connected' | 'error', + detail?: string +): void { + window.dispatchEvent( + new CustomEvent('chat:conn', { detail: { state, detail } }) + ); +} + +function emitAuthState(): void { + window.dispatchEvent( + new CustomEvent('chat:auth', { + detail: { + user: authUser, + sessionExpiresAt, + senderIdentityHex: meHex, + }, + }) + ); +} + +function emitPresenceState(): void { + if (!conn) { + window.dispatchEvent( + new CustomEvent('chat:data', { + detail: { + meHex, + activeServerId, + activeRoomId, + servers: [], + serverMembers: [], + rooms: [], + users: [], + members: [], + messages: [], + reactions: [], + attachments: [], + threads: [], + threadMessages: [], + cursors: [], + presence: [], + rateLimitStatus: [], + authenticated: Boolean(authUser), + }, + }) + ); + return; + } + const c = conn; + const serverRows = [...c.db.myServers.iter()].sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch as bigint; + const bv = b.createdAt.microsSinceUnixEpoch as bigint; + return av < bv ? -1 : av > bv ? 1 : 0; + }); + const roomRows = [...c.db.myRooms.iter()].sort((a, b) => { + if (a.name === 'general') return -1; + if (b.name === 'general') return 1; + return a.name.localeCompare(b.name); + }); + const userRows = [...c.db.myChatUsers.iter()].sort((a, b) => + a.displayName.localeCompare(b.displayName) + ); + const messageRows = [...c.db.myRoomMessages.iter()].sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch as bigint; + const bv = b.createdAt.microsSinceUnixEpoch as bigint; + return av < bv ? -1 : av > bv ? 1 : 0; + }); + const threadRows = [...c.db.myMessageThreads.iter()].sort( + (a: MessageThread, b: MessageThread) => { + const av = a.updatedAt.microsSinceUnixEpoch as bigint; + const bv = b.updatedAt.microsSinceUnixEpoch as bigint; + return av < bv ? 1 : av > bv ? -1 : 0; + } + ); + const threadMessageRows = [...c.db.myThreadMessages.iter()].sort( + (a: ThreadMessage, b: ThreadMessage) => { + const av = a.createdAt.microsSinceUnixEpoch as bigint; + const bv = b.createdAt.microsSinceUnixEpoch as bigint; + return av < bv ? -1 : av > bv ? 1 : 0; + } + ); + window.dispatchEvent( + new CustomEvent('chat:data', { + detail: { + meHex, + activeServerId, + activeRoomId, + servers: serverRows, + serverMembers: [...c.db.myServerMembers.iter()], + rooms: roomRows, + users: userRows, + members: [...c.db.myRoomMembers.iter()], + messages: messageRows, + reactions: [...c.db.myRoomMessageReactions.iter()], + attachments: [...c.db.myRoomAttachments.iter()].sort( + (a, b) => a.ordinal - b.ordinal + ), + threads: threadRows, + threadMessages: threadMessageRows, + cursors: [...c.db.myRoomReadCursors.iter()], + presence: [...c.db.myPresenceEntries.iter()], + rateLimitStatus: [ + ...c.db.myRateLimitStatus.iter(), + ] as ChatRateLimitStatus[], + authenticated: Boolean(authUser), + }, + }) + ); +} + +async function callJson(path: string, body?: unknown): Promise { + const r = await fetch(path, { + method: body !== undefined ? 'POST' : 'GET', + headers: body !== undefined ? { 'content-type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'same-origin', + }); + let data: unknown = null; + try { + data = await r.json(); + } catch { + /* empty or non-JSON response */ + } + if (!r.ok) { + const error = + data && typeof data === 'object' && 'error' in data + ? String((data as { error: unknown }).error) + : `http_${r.status}`; + throw new Error(error); + } + return data as T; +} + +async function loadServerConfig(): Promise { + const r = await fetch('/api/config'); + if (!r.ok) throw new Error(`/api/config returned ${r.status}`); + const nextConfig = (await r.json()) as ServerConfig; + authPanel.setProviders({ + google: Boolean(nextConfig.oauth?.google), + github: Boolean(nextConfig.oauth?.github), + }); + return nextConfig; +} + +async function signup(args: { + email: string; + password: string; + name?: string; +}): Promise { + const result = await callJson<{ token: string }>('/auth/password/signup', { + email: args.email, + password: args.password, + name: args.name, + }); + await bindSession(result.token); +} + +async function login(args: { email: string; password: string }): Promise { + const result = await callJson<{ token: string }>('/auth/password/login', { + email: args.email, + password: args.password, + }); + await bindSession(result.token); +} + +function oauthStart(provider: 'google' | 'github'): void { + window.location.href = `/auth/${provider}/start?redirectTo=/`; +} + +async function forgotPassword(email: string): Promise { + await callJson('/auth/password/forgot', { email }); +} + +async function resetPassword( + token: string, + newPassword: string +): Promise { + await callJson('/auth/password/reset', { token, newPassword }); +} + +function requireConn(): DbConnection { + if (!conn) throw new Error('chat.disconnected'); + return conn; +} + +function clearHeartbeat(): void { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } +} + +function scheduleHeartbeat(): void { + clearHeartbeat(); + if (!authUser) return; + heartbeatTimer = setInterval(() => { + if (!conn || !authUser) return; + try { + void conn.reducers.heartbeat({}).catch(() => undefined); + } catch { + // A later heartbeat retries after transient connection failures. + } + }, HEARTBEAT_INTERVAL_MS); +} + +function scheduleReconnect(): void { + if (reconnectTimer != null) return; + const delay = + RECONNECT_DELAYS_MS[ + Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1) + ]; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + reconnectAttempt++; + main().catch(err => { + emitConnectionState('error', normalizeError(err)); + scheduleReconnect(); + }); + }, delay); +} + +const STDB_TOKEN_KEY = 'chat:stdb_token'; +const AUTH_TOKEN_KEY = 'chat:auth_token'; + +function loadStdbToken(): string | undefined { + try { + return localStorage.getItem(STDB_TOKEN_KEY) ?? undefined; + } catch { + return undefined; + } +} + +function saveStdbToken(token: string): void { + try { + localStorage.setItem(STDB_TOKEN_KEY, token); + } catch { + /* Storage can be unavailable. */ + } +} + +function saveAuthToken(token: string): void { + try { + localStorage.setItem(AUTH_TOKEN_KEY, token); + } catch { + /* Storage can be unavailable. */ + } +} + +function clearAuthToken(): void { + try { + localStorage.removeItem(AUTH_TOKEN_KEY); + } catch { + /* Storage can be unavailable. */ + } +} + +function connect(cfg: ServerConfig): Promise { + return new Promise((resolve, reject) => { + const priorToken = loadStdbToken(); + DbConnection.builder() + .withUri(cfg.spacetimeUri) + .withDatabaseName(cfg.databaseName) + .withToken(priorToken) + .onConnect((connection, _identity, token) => { + if (token) saveStdbToken(token); + resolve(connection); + }) + .onDisconnect((_ctx, err) => { + conn = null; + clearHeartbeat(); + emitConnectionState('error', err?.message ?? 'disconnected'); + scheduleReconnect(); + }) + .onConnectError((_ctx, err) => reject(err)) + .build(); + }); +} + +function subscribeToTables(connection: DbConnection): void { + connection + .subscriptionBuilder() + .onApplied(() => emitPresenceState()) + .onError((ctx: ErrorContext) => + console.error('subscription error', ctx.event) + ) + .subscribe([ + tables.myChatUsers, + tables.myServers, + tables.myServerMembers, + tables.myPresenceEntries, + tables.myRooms, + tables.myRoomMembers, + tables.myRoomMessages, + tables.myRoomMessageReactions, + tables.myRoomAttachments, + tables.myMessageThreads, + tables.myThreadMessages, + tables.myRoomReadCursors, + tables.myAuthUser, + tables.myRateLimitStatus, + ]); +} + +function registerRowCallbacks(connection: DbConnection): void { + const reRender = () => emitPresenceState(); + const tableAccessors = [ + connection.db.myChatUsers, + connection.db.myRooms, + connection.db.myRoomMembers, + connection.db.myRoomMessages, + connection.db.myRoomMessageReactions, + connection.db.myRoomAttachments, + connection.db.myServerMembers, + connection.db.myMessageThreads, + connection.db.myThreadMessages, + connection.db.myRoomReadCursors, + connection.db.myPresenceEntries, + connection.db.myRateLimitStatus, + ]; + for (const t of tableAccessors) { + t.onInsert(reRender); + t.onUpdate(reRender); + t.onDelete(reRender); + } + + connection.db.myServers.onInsert(reRender); + connection.db.myServers.onUpdate(reRender); + connection.db.myServers.onDelete((_ctx: EventContext, row: Server) => { + if (activeServerId === row.id) { + activeServerId = null; + activeRoomId = null; + } + emitPresenceState(); + }); + + const syncUserFromRow = (row: AuthUserRow) => { + if (!authUser || row.userId !== authUser.userId) return; + authUser = { + userId: row.userId, + email: row.email, + emailVerified: row.emailVerified, + name: row.name ?? undefined, + image: row.image ?? undefined, + }; + emitAuthState(); + }; + connection.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) => + syncUserFromRow(row) + ); + connection.db.myAuthUser.onUpdate( + (_ctx: EventContext, _old: AuthUserRow, neu: AuthUserRow) => + syncUserFromRow(neu) + ); + connection.db.myAuthUser.onDelete((_ctx: EventContext, row: AuthUserRow) => { + if (!authUser || row.userId !== authUser.userId) return; + authUser = null; + sessionExpiresAt = undefined; + emitAuthState(); + emitPresenceState(); + }); +} + +async function bindSession( + sessionToken: string, + refreshedUser?: AuthUser, + exp?: number +): Promise { + saveAuthToken(sessionToken); + const c = requireConn(); + await c.reducers.linkConnection({ sessionToken }); + const me = await c.procedures.whoami({}); + meHex = me.senderIdentityHex; + if (!refreshedUser) { + const meRes = await callJson('/auth/me'); + refreshedUser = meRes.user; + exp = meRes.sessionExpiresAt; + } + authUser = refreshedUser; + sessionExpiresAt = exp; + await c.reducers.heartbeat({}); + emitAuthState(); + emitPresenceState(); + scheduleHeartbeat(); +} + +async function restoreSession(): Promise { + try { + const refreshed = await callJson( + '/auth/session/refresh', + {} + ); + await bindSession( + refreshed.token, + refreshed.user, + refreshed.sessionExpiresAt + ); + return true; + } catch { + authUser = null; + sessionExpiresAt = undefined; + clearAuthToken(); + emitAuthState(); + clearHeartbeat(); + return false; + } +} + +function installApi(): void { + window.chat = { + setDisplayName: (displayName: string) => { + return requireConn().reducers.setDisplayName({ displayName }); + }, + setStatus: status => { + // UI passes lowercase strings; map to ChatUserStatus enum tags. + const tag = (status.charAt(0).toUpperCase() + status.slice(1)) as + | 'Online' + | 'Away' + | 'Dnd' + | 'Invisible'; + return requireConn().reducers.setStatus({ status: { tag } }); + }, + createServer: (name: string) => { + return requireConn().reducers.createServer({ name }); + }, + renameServer: (serverId: bigint, name: string) => { + return requireConn().reducers.renameServer({ serverId, name }); + }, + deleteServer: (serverId: bigint) => { + return requireConn().reducers.deleteServer({ serverId }); + }, + joinServer: (serverId: bigint) => { + return requireConn().reducers.joinServer({ serverId }); + }, + leaveServer: (serverId: bigint) => { + return requireConn().reducers.leaveServer({ serverId }); + }, + setActiveServer: (serverId: bigint | null) => { + activeServerId = serverId; + activeRoomId = null; + emitPresenceState(); + }, + createRoom: ( + serverId: bigint, + name: string, + isPrivate: boolean, + category?: string + ) => { + return requireConn().reducers.createRoom({ + serverId, + name, + isPrivate, + category, + }); + }, + joinRoom: (roomId: bigint) => { + return requireConn().reducers.joinRoom({ roomId }); + }, + leaveRoom: (roomId: bigint) => { + return requireConn().reducers.leaveRoom({ roomId }); + }, + sendMessage: ( + roomId: bigint, + content: string, + replyToMessageId?: bigint, + atts?: AttachmentInput[] + ) => { + return requireConn().reducers.sendMessage({ + roomId, + content, + replyToMessageId, + attachments: atts ?? [], + }); + }, + sendThreadMessage: (rootMessageId: bigint, content: string) => { + return requireConn().reducers.sendThreadMessage({ + rootMessageId, + content, + }); + }, + getAttachmentFile: async (fileId: bigint) => { + return await requireConn().procedures.getAttachmentFile({ fileId }); + }, + editMessage: (messageId: bigint, content: string) => { + return requireConn().reducers.editMessage({ messageId, content }); + }, + deleteMessage: (messageId: bigint) => { + return requireConn().reducers.deleteMessage({ messageId }); + }, + editThreadMessage: (threadMessageId: bigint, content: string) => { + return requireConn().reducers.editThreadMessage({ + threadMessageId, + content, + }); + }, + deleteThreadMessage: (threadMessageId: bigint) => { + return requireConn().reducers.deleteThreadMessage({ threadMessageId }); + }, + renameRoom: (roomId: bigint, name: string) => { + return requireConn().reducers.renameRoom({ roomId, name }); + }, + setRoomCategory: (roomId: bigint, category?: string) => { + return requireConn().reducers.setRoomCategory({ roomId, category }); + }, + setRoomPrivacy: (roomId: bigint, isPrivate: boolean) => { + return requireConn().reducers.setRoomPrivacy({ roomId, isPrivate }); + }, + deleteRoom: (roomId: bigint) => { + return requireConn().reducers.deleteRoom({ roomId }); + }, + startTyping: (roomId: bigint) => { + return requireConn().reducers.startTyping({ roomId }); + }, + stopTyping: (roomId: bigint) => { + return requireConn().reducers.stopTyping({ roomId }); + }, + markRoomRead: (roomId: bigint) => { + return requireConn().reducers.markRoomRead({ roomId }); + }, + toggleReaction: (messageId: bigint, emoji: string) => { + return requireConn().reducers.toggleReaction({ messageId, emoji }); + }, + pinMessage: (messageId: bigint) => { + return requireConn().reducers.pinMessage({ messageId }); + }, + unpinMessage: (messageId: bigint) => { + return requireConn().reducers.unpinMessage({ messageId }); + }, + searchMessages: async (roomId: bigint, query: string) => { + return await requireConn().procedures.searchMessages({ roomId, query }); + }, + setActiveRoom: (roomId: bigint | null) => { + activeRoomId = roomId; + emitPresenceState(); + }, + heartbeat: () => { + return requireConn().reducers.heartbeat({}); + }, + signup, + login, + logout: async () => { + const c = conn; + if (c) { + try { + await c.reducers.unlinkConnection({}); + } catch { + /* best-effort disconnect cleanup */ + } + } + await callJson('/auth/logout', {}); + authUser = null; + sessionExpiresAt = undefined; + clearAuthToken(); + clearHeartbeat(); + emitAuthState(); + emitPresenceState(); + }, + oauthStart, + forgotPassword, + resetPassword, + requestEmailVerify: async () => { + await callJson('/auth/email/verify-request', {}); + }, + whoami: async () => { + const r = await requireConn().procedures.whoami({}); + meHex = r.senderIdentityHex; + emitAuthState(); + return { + userId: r.userId, + senderIdentityHex: r.senderIdentityHex, + }; + }, + setProfile: args => { + return requireConn().reducers.updateProfile({ + name: args.name, + image: args.image, + }); + }, + }; +} + +const authResult = authUrlState(window.location); +const authPanelRoot = document.getElementById('auth-panel'); +if (!authPanelRoot) throw new Error('missing_auth_panel'); +const authPanel = mountAuthPanel(authPanelRoot, { + productName: 'Chat', + actions: { + login, + signup, + forgotPassword, + resetPassword, + oauthStart, + }, + initialMode: authResult.mode, + resetToken: authResult.resetToken, +}); +if (authResult.oauthError) { + authPanel.showMessage('error', `OAuth: ${authResult.oauthError}`); +} +if (authResult.verified) { + authPanel.showMessage('success', 'Email verified.'); +} +clearAuthResultParams(window.location, window.history); + +function typingScopeForRoom(roomId: bigint): string { + return `${PRESENCE_SCOPE_TYPING_PREFIX}${roomId.toString()}`; +} + +function derivePresenceSnapshot() { + if (!conn) + return { + global: [] as PresenceEntry[], + typingByRoom: {} as Record, + }; + const entries = [...conn.db.myPresenceEntries.iter()]; + const global = entries.filter(row => row.scope === PRESENCE_SCOPE_GLOBAL); + const typingByRoom: Record = {}; + for (const row of entries) { + if (!row.scope.startsWith(PRESENCE_SCOPE_TYPING_PREFIX)) continue; + const roomId = row.scope.slice(PRESENCE_SCOPE_TYPING_PREFIX.length); + if (!typingByRoom[roomId]) typingByRoom[roomId] = []; + typingByRoom[roomId].push(row.subject); + } + return { global, typingByRoom }; +} + +async function loadCurrentIdentity(connection: DbConnection): Promise { + const me = await connection.procedures.whoami({}); + meHex = me.senderIdentityHex; + const snap = derivePresenceSnapshot(); + window.dispatchEvent( + new CustomEvent('chat:me', { + detail: { + meHex, + globalPresence: snap.global, + typingByRoom: snap.typingByRoom, + typingScopeForRoom, + }, + }) + ); + emitAuthState(); +} + +async function main(): Promise { + emitConnectionState('connecting'); + if (!config) config = await loadServerConfig(); + + const connection = await connect(config); + conn = connection; + reconnectAttempt = 0; + emitConnectionState('connected'); + registerRowCallbacks(connection); + subscribeToTables(connection); + installApi(); + await loadCurrentIdentity(connection); + await restoreSession(); + window.dispatchEvent(new CustomEvent('chat:ready')); +} + +main().catch(err => { + emitConnectionState('error', normalizeError(err)); + scheduleReconnect(); +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionToken: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts new file mode 100644 index 00000000000..6573c3fe132 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + userId: __t.string().primaryKey().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/types.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/types.ts @@ -0,0 +1,56 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/types.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/types.ts new file mode 100644 index 00000000000..8df99e3d1b4 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/types.ts @@ -0,0 +1,137 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AuthAccount = __t.object("AuthAccount", { + accountId: __t.string(), + userId: __t.string(), + providerId: __t.string(), + providerAccountId: __t.string(), + passwordHash: __t.option(__t.string()), + accessToken: __t.option(__t.string()), + refreshToken: __t.option(__t.string()), + accessTokenExpiresAt: __t.option(__t.timestamp()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthAccount = __Infer; + +export const AuthAdminIdentity = __t.object("AuthAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type AuthAdminIdentity = __Infer; + +export const AuthConfig = __t.object("AuthConfig", { + singleton: __t.bool(), + issuerUrl: __t.string(), + baseUrl: __t.string(), + cookieName: __t.string(), + sessionTtlSeconds: __t.u64(), + es256PrivateKeyPem: __t.string(), + es256PublicKeyPem: __t.string(), + keyId: __t.string(), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type AuthConfig = __Infer; + +export const AuthConnectionBinding = __t.object("AuthConnectionBinding", { + stdbIdentity: __t.identity(), + userId: __t.string(), + linkedAt: __t.timestamp(), +}); +export type AuthConnectionBinding = __Infer; + +export const AuthOauthState = __t.object("AuthOauthState", { + state: __t.string(), + provider: __t.string(), + codeVerifier: __t.string(), + redirectTo: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthOauthState = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const AuthSession = __t.object("AuthSession", { + sessionId: __t.string(), + userId: __t.string(), + token: __t.string(), + expiresAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type AuthSession = __Infer; + +export const AuthSweeperTick = __t.object("AuthSweeperTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type AuthSweeperTick = __Infer; + +export const AuthUser = __t.object("AuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthUser = __Infer; + +export const AuthVerification = __t.object("AuthVerification", { + verificationId: __t.string(), + identifier: __t.string(), + value: __t.string(), + purpose: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthVerification = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/whoami_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/whoami_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/create_room_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/create_room_reducer.ts new file mode 100644 index 00000000000..1382e2f73fb --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/create_room_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + serverId: __t.u64(), + name: __t.string(), + isPrivate: __t.bool(), + category: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/create_server_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/create_server_reducer.ts new file mode 100644 index 00000000000..ce493ee8574 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/create_server_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + name: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/delete_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/delete_message_reducer.ts new file mode 100644 index 00000000000..104809a301c --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/delete_message_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + messageId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/delete_room_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/delete_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/delete_room_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/delete_server_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/delete_server_reducer.ts new file mode 100644 index 00000000000..8cee31ec781 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/delete_server_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + serverId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/delete_thread_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/delete_thread_message_reducer.ts new file mode 100644 index 00000000000..fa89973d643 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/delete_thread_message_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + threadMessageId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/edit_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/edit_message_reducer.ts new file mode 100644 index 00000000000..57eaa7d8f74 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/edit_message_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + messageId: __t.u64(), + content: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/edit_thread_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/edit_thread_message_reducer.ts new file mode 100644 index 00000000000..fe1bd145fa1 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/edit_thread_message_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + threadMessageId: __t.u64(), + content: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/files/types.ts b/spacetime-presence-ts/example/src/module_bindings/app/files/types.ts new file mode 100644 index 00000000000..a8336b9566f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/files/types.ts @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const File = __t.object("File", { + id: __t.u64(), + ownerPathKey: __t.string(), + path: __t.string(), + ownerUserId: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type File = __Infer; + +export const FileBlob = __t.object("FileBlob", { + fileId: __t.u64(), + bytes: __t.byteArray(), +}); +export type FileBlob = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/get_attachment_file_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/get_attachment_file_procedure.ts new file mode 100644 index 00000000000..f365f3373db --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/get_attachment_file_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AttachmentFileResult, +} from "./types"; + +export const params = { + fileId: __t.u64(), +}; +export const returnType = AttachmentFileResult \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/heartbeat_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/heartbeat_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/heartbeat_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/index.ts b/spacetime-presence-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..603a3478aac --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,488 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import CreateRoomReducer from "./create_room_reducer"; +import CreateServerReducer from "./create_server_reducer"; +import DeleteMessageReducer from "./delete_message_reducer"; +import DeleteRoomReducer from "./delete_room_reducer"; +import DeleteServerReducer from "./delete_server_reducer"; +import DeleteThreadMessageReducer from "./delete_thread_message_reducer"; +import EditMessageReducer from "./edit_message_reducer"; +import EditThreadMessageReducer from "./edit_thread_message_reducer"; +import HeartbeatReducer from "./heartbeat_reducer"; +import JoinRoomReducer from "./join_room_reducer"; +import JoinServerReducer from "./join_server_reducer"; +import LeaveRoomReducer from "./leave_room_reducer"; +import LeaveServerReducer from "./leave_server_reducer"; +import LinkConnectionReducer from "./link_connection_reducer"; +import MarkRoomReadReducer from "./mark_room_read_reducer"; +import PinMessageReducer from "./pin_message_reducer"; +import RenameRoomReducer from "./rename_room_reducer"; +import RenameServerReducer from "./rename_server_reducer"; +import RevokeMySessionReducer from "./revoke_my_session_reducer"; +import RevokeSessionReducer from "./revoke_session_reducer"; +import SendMessageReducer from "./send_message_reducer"; +import SendThreadMessageReducer from "./send_thread_message_reducer"; +import SetAuthConfigReducer from "./set_auth_config_reducer"; +import SetDisplayNameReducer from "./set_display_name_reducer"; +import SetRoomCategoryReducer from "./set_room_category_reducer"; +import SetRoomPrivacyReducer from "./set_room_privacy_reducer"; +import SetStatusReducer from "./set_status_reducer"; +import StartTypingReducer from "./start_typing_reducer"; +import StopTypingReducer from "./stop_typing_reducer"; +import ToggleReactionReducer from "./toggle_reaction_reducer"; +import UnlinkConnectionReducer from "./unlink_connection_reducer"; +import UnpinMessageReducer from "./unpin_message_reducer"; +import UpdateProfileReducer from "./update_profile_reducer"; + +// Import all procedure arg schemas +import * as GetAttachmentFileProcedure from "./get_attachment_file_procedure"; +import * as GetAuthPublicKeyProcedure from "./get_auth_public_key_procedure"; +import * as ListMySessionsProcedure from "./list_my_sessions_procedure"; +import * as SearchMessagesProcedure from "./search_messages_procedure"; +import * as WhoamiProcedure from "./whoami_procedure"; + +// Import all table schema definitions +import MyAuthUserRow from "./my_auth_user_table"; +import MyChatUsersRow from "./my_chat_users_table"; +import MyMessageThreadsRow from "./my_message_threads_table"; +import MyPresenceEntriesRow from "./my_presence_entries_table"; +import MyRateLimitStatusRow from "./my_rate_limit_status_table"; +import MyRoomAttachmentsRow from "./my_room_attachments_table"; +import MyRoomMembersRow from "./my_room_members_table"; +import MyRoomMessageReactionsRow from "./my_room_message_reactions_table"; +import MyRoomMessagesRow from "./my_room_messages_table"; +import MyRoomReadCursorsRow from "./my_room_read_cursors_table"; +import MyRoomsRow from "./my_rooms_table"; +import MyServerMembersRow from "./my_server_members_table"; +import MyServersRow from "./my_servers_table"; +import MyThreadMessagesRow from "./my_thread_messages_table"; + +// Import namespace table schema definitions +import AuthRateLimit_RateLimitConfigRow from "./auth/rateLimit/rate_limit_config_table"; +import RateLimit_RateLimitConfigRow from "./rateLimit/rate_limit_config_table"; +import Auth_MyAuthUserRow from "./auth/my_auth_user_table"; +import AuthRateLimit_AdminRateLimitBucketsRow from "./auth/rateLimit/admin_rate_limit_buckets_table"; +import RateLimit_AdminRateLimitBucketsRow from "./rateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import Auth_LinkConnectionReducer from "./auth/link_connection_reducer"; +import Auth_RevokeMySessionReducer from "./auth/revoke_my_session_reducer"; +import Auth_RevokeSessionReducer from "./auth/revoke_session_reducer"; +import Auth_SetAuthConfigReducer from "./auth/set_auth_config_reducer"; +import Auth_UnlinkConnectionReducer from "./auth/unlink_connection_reducer"; +import Auth_UpdateProfileReducer from "./auth/update_profile_reducer"; +import AuthRateLimit_AddRateLimitAdminReducer from "./auth/rateLimit/add_rate_limit_admin_reducer"; +import AuthRateLimit_ResetBucketsReducer from "./auth/rateLimit/reset_buckets_reducer"; +import AuthRateLimit_UpdateConfigReducer from "./auth/rateLimit/update_config_reducer"; +import RateLimit_AddRateLimitAdminReducer from "./rateLimit/add_rate_limit_admin_reducer"; +import RateLimit_ResetBucketsReducer from "./rateLimit/reset_buckets_reducer"; +import RateLimit_UpdateConfigReducer from "./rateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Auth_GetAuthPublicKeyProcedure from "./auth/get_auth_public_key_procedure"; +import * as Auth_ListMySessionsProcedure from "./auth/list_my_sessions_procedure"; +import * as Auth_WhoamiProcedure from "./auth/whoami_procedure"; +import * as AuthRateLimit_ConsumeProcedure from "./auth/rateLimit/consume_procedure"; +import * as AuthRateLimit_RunSweepProcedure from "./auth/rateLimit/run_sweep_procedure"; +import * as RateLimit_ConsumeProcedure from "./rateLimit/consume_procedure"; +import * as RateLimit_RunSweepProcedure from "./rateLimit/run_sweep_procedure"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + myAuthUser: __table({ + name: 'my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, MyAuthUserRow), + myChatUsers: __table({ + name: 'my_chat_users', + indexes: [ + ], + constraints: [ + ], + }, MyChatUsersRow), + myMessageThreads: __table({ + name: 'my_message_threads', + indexes: [ + ], + constraints: [ + ], + }, MyMessageThreadsRow), + myPresenceEntries: __table({ + name: 'my_presence_entries', + indexes: [ + ], + constraints: [ + ], + }, MyPresenceEntriesRow), + myRateLimitStatus: __table({ + name: 'my_rate_limit_status', + indexes: [ + ], + constraints: [ + ], + }, MyRateLimitStatusRow), + myRoomAttachments: __table({ + name: 'my_room_attachments', + indexes: [ + ], + constraints: [ + ], + }, MyRoomAttachmentsRow), + myRoomMembers: __table({ + name: 'my_room_members', + indexes: [ + ], + constraints: [ + ], + }, MyRoomMembersRow), + myRoomMessageReactions: __table({ + name: 'my_room_message_reactions', + indexes: [ + ], + constraints: [ + ], + }, MyRoomMessageReactionsRow), + myRoomMessages: __table({ + name: 'my_room_messages', + indexes: [ + ], + constraints: [ + ], + }, MyRoomMessagesRow), + myRoomReadCursors: __table({ + name: 'my_room_read_cursors', + indexes: [ + ], + constraints: [ + ], + }, MyRoomReadCursorsRow), + myRooms: __table({ + name: 'my_rooms', + indexes: [ + ], + constraints: [ + ], + }, MyRoomsRow), + myServerMembers: __table({ + name: 'my_server_members', + indexes: [ + ], + constraints: [ + ], + }, MyServerMembersRow), + myServers: __table({ + name: 'my_servers', + indexes: [ + ], + constraints: [ + ], + }, MyServersRow), + myThreadMessages: __table({ + name: 'my_thread_messages', + indexes: [ + ], + constraints: [ + ], + }, MyThreadMessagesRow), + "auth.rateLimit.rate_limit_config": __table({ + name: 'auth.rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, AuthRateLimit_RateLimitConfigRow), + "rateLimit.rate_limit_config": __table({ + name: 'rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, RateLimit_RateLimitConfigRow), + "auth.my_auth_user": __table({ + name: 'auth.my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, Auth_MyAuthUserRow), + "auth.rateLimit.admin_rate_limit_buckets": __table({ + name: 'auth.rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, AuthRateLimit_AdminRateLimitBucketsRow), + "rateLimit.admin_rate_limit_buckets": __table({ + name: 'rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, RateLimit_AdminRateLimitBucketsRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("create_room", CreateRoomReducer), + __reducerSchema("create_server", CreateServerReducer), + __reducerSchema("delete_message", DeleteMessageReducer), + __reducerSchema("delete_room", DeleteRoomReducer), + __reducerSchema("delete_server", DeleteServerReducer), + __reducerSchema("delete_thread_message", DeleteThreadMessageReducer), + __reducerSchema("edit_message", EditMessageReducer), + __reducerSchema("edit_thread_message", EditThreadMessageReducer), + __reducerSchema("heartbeat", HeartbeatReducer), + __reducerSchema("join_room", JoinRoomReducer), + __reducerSchema("join_server", JoinServerReducer), + __reducerSchema("leave_room", LeaveRoomReducer), + __reducerSchema("leave_server", LeaveServerReducer), + __reducerSchema("link_connection", LinkConnectionReducer), + __reducerSchema("mark_room_read", MarkRoomReadReducer), + __reducerSchema("pin_message", PinMessageReducer), + __reducerSchema("rename_room", RenameRoomReducer), + __reducerSchema("rename_server", RenameServerReducer), + __reducerSchema("revoke_my_session", RevokeMySessionReducer), + __reducerSchema("revoke_session", RevokeSessionReducer), + __reducerSchema("send_message", SendMessageReducer), + __reducerSchema("send_thread_message", SendThreadMessageReducer), + __reducerSchema("set_auth_config", SetAuthConfigReducer), + __reducerSchema("set_display_name", SetDisplayNameReducer), + __reducerSchema("set_room_category", SetRoomCategoryReducer), + __reducerSchema("set_room_privacy", SetRoomPrivacyReducer), + __reducerSchema("set_status", SetStatusReducer), + __reducerSchema("start_typing", StartTypingReducer), + __reducerSchema("stop_typing", StopTypingReducer), + __reducerSchema("toggle_reaction", ToggleReactionReducer), + __reducerSchema("unlink_connection", UnlinkConnectionReducer), + __reducerSchema("unpin_message", UnpinMessageReducer), + __reducerSchema("update_profile", UpdateProfileReducer), + __reducerSchema("auth.link_connection", Auth_LinkConnectionReducer), + __reducerSchema("auth.revoke_my_session", Auth_RevokeMySessionReducer), + __reducerSchema("auth.revoke_session", Auth_RevokeSessionReducer), + __reducerSchema("auth.set_auth_config", Auth_SetAuthConfigReducer), + __reducerSchema("auth.unlink_connection", Auth_UnlinkConnectionReducer), + __reducerSchema("auth.update_profile", Auth_UpdateProfileReducer), + __reducerSchema("auth.rateLimit.add_rate_limit_admin", AuthRateLimit_AddRateLimitAdminReducer), + __reducerSchema("auth.rateLimit.reset_buckets", AuthRateLimit_ResetBucketsReducer), + __reducerSchema("auth.rateLimit.update_config", AuthRateLimit_UpdateConfigReducer), + __reducerSchema("rateLimit.add_rate_limit_admin", RateLimit_AddRateLimitAdminReducer), + __reducerSchema("rateLimit.reset_buckets", RateLimit_ResetBucketsReducer), + __reducerSchema("rateLimit.update_config", RateLimit_UpdateConfigReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("get_attachment_file", GetAttachmentFileProcedure.params, GetAttachmentFileProcedure.returnType), + __procedureSchema("get_auth_public_key", GetAuthPublicKeyProcedure.params, GetAuthPublicKeyProcedure.returnType), + __procedureSchema("list_my_sessions", ListMySessionsProcedure.params, ListMySessionsProcedure.returnType), + __procedureSchema("search_messages", SearchMessagesProcedure.params, SearchMessagesProcedure.returnType), + __procedureSchema("whoami", WhoamiProcedure.params, WhoamiProcedure.returnType), + __procedureSchema("auth.get_auth_public_key", Auth_GetAuthPublicKeyProcedure.params, Auth_GetAuthPublicKeyProcedure.returnType), + __procedureSchema("auth.list_my_sessions", Auth_ListMySessionsProcedure.params, Auth_ListMySessionsProcedure.returnType), + __procedureSchema("auth.whoami", Auth_WhoamiProcedure.params, Auth_WhoamiProcedure.returnType), + __procedureSchema("auth.rateLimit.consume", AuthRateLimit_ConsumeProcedure.params, AuthRateLimit_ConsumeProcedure.returnType), + __procedureSchema("auth.rateLimit.run_sweep", AuthRateLimit_RunSweepProcedure.params, AuthRateLimit_RunSweepProcedure.returnType), + __procedureSchema("rateLimit.consume", RateLimit_ConsumeProcedure.params, RateLimit_ConsumeProcedure.returnType), + __procedureSchema("rateLimit.run_sweep", RateLimit_RunSweepProcedure.params, RateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + myAuthUser: __qb.myAuthUser, + myChatUsers: __qb.myChatUsers, + myMessageThreads: __qb.myMessageThreads, + myPresenceEntries: __qb.myPresenceEntries, + myRateLimitStatus: __qb.myRateLimitStatus, + myRoomAttachments: __qb.myRoomAttachments, + myRoomMembers: __qb.myRoomMembers, + myRoomMessageReactions: __qb.myRoomMessageReactions, + myRoomMessages: __qb.myRoomMessages, + myRoomReadCursors: __qb.myRoomReadCursors, + myRooms: __qb.myRooms, + myServerMembers: __qb.myServerMembers, + myServers: __qb.myServers, + myThreadMessages: __qb.myThreadMessages, + auth: { + myAuthUser: __qb["auth.my_auth_user"], + rateLimit: { + rateLimitConfig: __qb["auth.rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["auth.rateLimit.admin_rate_limit_buckets"], + }, + }, + rateLimit: { + rateLimitConfig: __qb["rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["rateLimit.admin_rate_limit_buckets"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + createRoom: __reducerAccessors.createRoom, + createServer: __reducerAccessors.createServer, + deleteMessage: __reducerAccessors.deleteMessage, + deleteRoom: __reducerAccessors.deleteRoom, + deleteServer: __reducerAccessors.deleteServer, + deleteThreadMessage: __reducerAccessors.deleteThreadMessage, + editMessage: __reducerAccessors.editMessage, + editThreadMessage: __reducerAccessors.editThreadMessage, + heartbeat: __reducerAccessors.heartbeat, + joinRoom: __reducerAccessors.joinRoom, + joinServer: __reducerAccessors.joinServer, + leaveRoom: __reducerAccessors.leaveRoom, + leaveServer: __reducerAccessors.leaveServer, + linkConnection: __reducerAccessors.linkConnection, + markRoomRead: __reducerAccessors.markRoomRead, + pinMessage: __reducerAccessors.pinMessage, + renameRoom: __reducerAccessors.renameRoom, + renameServer: __reducerAccessors.renameServer, + revokeMySession: __reducerAccessors.revokeMySession, + revokeSession: __reducerAccessors.revokeSession, + sendMessage: __reducerAccessors.sendMessage, + sendThreadMessage: __reducerAccessors.sendThreadMessage, + setAuthConfig: __reducerAccessors.setAuthConfig, + setDisplayName: __reducerAccessors.setDisplayName, + setRoomCategory: __reducerAccessors.setRoomCategory, + setRoomPrivacy: __reducerAccessors.setRoomPrivacy, + setStatus: __reducerAccessors.setStatus, + startTyping: __reducerAccessors.startTyping, + stopTyping: __reducerAccessors.stopTyping, + toggleReaction: __reducerAccessors.toggleReaction, + unlinkConnection: __reducerAccessors.unlinkConnection, + unpinMessage: __reducerAccessors.unpinMessage, + updateProfile: __reducerAccessors.updateProfile, + auth: { + linkConnection: __reducerAccessors["auth.linkConnection"], + revokeMySession: __reducerAccessors["auth.revokeMySession"], + revokeSession: __reducerAccessors["auth.revokeSession"], + setAuthConfig: __reducerAccessors["auth.setAuthConfig"], + unlinkConnection: __reducerAccessors["auth.unlinkConnection"], + updateProfile: __reducerAccessors["auth.updateProfile"], + rateLimit: { + addRateLimitAdmin: __reducerAccessors["auth.rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["auth.rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["auth.rateLimit.updateConfig"], + }, + }, + rateLimit: { + addRateLimitAdmin: __reducerAccessors["rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["rateLimit.updateConfig"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + getAttachmentFile: __procedureAccessors.getAttachmentFile, + getAuthPublicKey: __procedureAccessors.getAuthPublicKey, + listMySessions: __procedureAccessors.listMySessions, + searchMessages: __procedureAccessors.searchMessages, + whoami: __procedureAccessors.whoami, + auth: { + getAuthPublicKey: __procedureAccessors["auth.getAuthPublicKey"], + listMySessions: __procedureAccessors["auth.listMySessions"], + whoami: __procedureAccessors["auth.whoami"], + rateLimit: { + consume: __procedureAccessors["auth.rateLimit.consume"], + runSweep: __procedureAccessors["auth.rateLimit.runSweep"], + }, + }, + rateLimit: { + consume: __procedureAccessors["rateLimit.consume"], + runSweep: __procedureAccessors["rateLimit.runSweep"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/join_room_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/join_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/join_room_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/join_server_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/join_server_reducer.ts new file mode 100644 index 00000000000..8cee31ec781 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/join_server_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + serverId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/leave_room_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/leave_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/leave_room_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/leave_server_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/leave_server_reducer.ts new file mode 100644 index 00000000000..8cee31ec781 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/leave_server_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + serverId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/link_connection_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/link_connection_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionToken: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/mark_room_read_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/mark_room_read_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/mark_room_read_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_auth_user_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_auth_user_table.ts new file mode 100644 index 00000000000..5966b094061 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + userId: __t.string().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_chat_users_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_chat_users_table.ts new file mode 100644 index 00000000000..10a1254c677 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_chat_users_table.ts @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + ChatUserStatus, +} from "./types"; + + +export default __t.row({ + identity: __t.identity().primaryKey(), + userId: __t.string().name("user_id"), + displayName: __t.string().name("display_name"), + get status() { + return ChatUserStatus; + }, + createdAt: __t.timestamp().name("created_at"), + lastActiveAt: __t.timestamp().name("last_active_at"), + lastMessageAt: __t.timestamp().name("last_message_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_message_threads_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_message_threads_table.ts new file mode 100644 index 00000000000..5d3160c8709 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_message_threads_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + rootMessageId: __t.u64().name("root_message_id"), + roomId: __t.u64().name("room_id"), + createdBy: __t.identity().name("created_by"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_presence_entries_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_presence_entries_table.ts new file mode 100644 index 00000000000..70af5d56d15 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_presence_entries_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + key: __t.string().primaryKey(), + scope: __t.string(), + subject: __t.string(), + status: __t.string(), + activity: __t.option(__t.string()), + payloadJson: __t.option(__t.string()).name("payload_json"), + joinedAt: __t.timestamp().name("joined_at"), + lastSeenAt: __t.timestamp().name("last_seen_at"), + expiresAt: __t.timestamp().name("expires_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_rate_limit_status_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_rate_limit_status_table.ts new file mode 100644 index 00000000000..00e9df8e608 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_rate_limit_status_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + scope: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + resetAt: __t.timestamp().name("reset_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_room_attachments_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_room_attachments_table.ts new file mode 100644 index 00000000000..8aa2b599cea --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_room_attachments_table.ts @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64(), + messageId: __t.u64().name("message_id"), + fileId: __t.u64().name("file_id"), + ownerUserId: __t.string().name("owner_user_id"), + ordinal: __t.u32(), + filename: __t.option(__t.string()), + path: __t.string(), + mimeType: __t.string().name("mime_type"), + size: __t.u64(), + sha256Hex: __t.string().name("sha_256_hex"), + visibility: __t.string(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_room_members_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_room_members_table.ts new file mode 100644 index 00000000000..5f1878a592e --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_room_members_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + roomId: __t.u64().name("room_id"), + userId: __t.string().name("user_id"), + role: __t.string(), + joinedAt: __t.timestamp().name("joined_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_room_message_reactions_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_room_message_reactions_table.ts new file mode 100644 index 00000000000..2d86b514fed --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_room_message_reactions_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + messageId: __t.u64().name("message_id"), + identity: __t.identity(), + emoji: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_room_messages_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_room_messages_table.ts new file mode 100644 index 00000000000..be6f0a662b9 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_room_messages_table.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + roomId: __t.u64().name("room_id"), + author: __t.identity(), + content: __t.string(), + createdAt: __t.timestamp().name("created_at"), + editedAt: __t.option(__t.timestamp()).name("edited_at"), + replyToMessageId: __t.option(__t.u64()).name("reply_to_message_id"), + pinnedAt: __t.option(__t.timestamp()).name("pinned_at"), + pinnedBy: __t.option(__t.identity()).name("pinned_by"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_room_read_cursors_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_room_read_cursors_table.ts new file mode 100644 index 00000000000..d52cd7f1251 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_room_read_cursors_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + roomId: __t.u64().name("room_id"), + identity: __t.identity(), + lastReadMessageId: __t.u64().name("last_read_message_id"), + lastReadAt: __t.timestamp().name("last_read_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_rooms_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_rooms_table.ts new file mode 100644 index 00000000000..a2a98c957a1 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_rooms_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + serverId: __t.u64().name("server_id"), + name: __t.string(), + category: __t.option(__t.string()), + createdByUserId: __t.string().name("created_by_user_id"), + createdAt: __t.timestamp().name("created_at"), + isPrivate: __t.bool().name("is_private"), + activityLabel: __t.string().name("activity_label"), + activityScore: __t.u32().name("activity_score"), + lastActivityAt: __t.option(__t.timestamp()).name("last_activity_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_server_members_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_server_members_table.ts new file mode 100644 index 00000000000..2cf02263ab3 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_server_members_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + serverId: __t.u64().name("server_id"), + userId: __t.string().name("user_id"), + role: __t.string(), + joinedAt: __t.timestamp().name("joined_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_servers_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_servers_table.ts new file mode 100644 index 00000000000..bb9731a93c9 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_servers_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + name: __t.string(), + createdByUserId: __t.string().name("created_by_user_id"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_thread_messages_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_thread_messages_table.ts new file mode 100644 index 00000000000..07d885a44b7 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_thread_messages_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + threadId: __t.u64().name("thread_id"), + author: __t.identity(), + content: __t.string(), + createdAt: __t.timestamp().name("created_at"), + editedAt: __t.option(__t.timestamp()).name("edited_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/pin_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/pin_message_reducer.ts new file mode 100644 index 00000000000..104809a301c --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/pin_message_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + messageId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/add_rate_limit_admin_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/admin_rate_limit_buckets_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/consume_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/consume_procedure.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/rate_limit_config_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/reset_buckets_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/reset_buckets_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/run_sweep_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/types.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/types.ts @@ -0,0 +1,56 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/update_config_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/update_config_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rename_room_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/rename_room_reducer.ts new file mode 100644 index 00000000000..8d0f5f1d2de --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rename_room_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), + name: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rename_server_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/rename_server_reducer.ts new file mode 100644 index 00000000000..71d58e8cf73 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rename_server_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + serverId: __t.u64(), + name: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/revoke_session_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/revoke_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/search_messages_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/search_messages_procedure.ts new file mode 100644 index 00000000000..5226a56b306 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/search_messages_procedure.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + Message, +} from "./types"; + +export const params = { + roomId: __t.u64(), + query: __t.string(), +}; +export const returnType = __t.array(Message) \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/send_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/send_message_reducer.ts new file mode 100644 index 00000000000..37fa347fa30 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/send_message_reducer.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AttachmentInput, +} from "./types"; + +export default { + roomId: __t.u64(), + content: __t.string(), + replyToMessageId: __t.option(__t.u64()), + get attachments() { + return __t.array(AttachmentInput); + }, +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/send_thread_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/send_thread_message_reducer.ts new file mode 100644 index 00000000000..f846302477c --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/send_thread_message_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + rootMessageId: __t.u64(), + content: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/set_auth_config_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/set_auth_config_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/set_display_name_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/set_display_name_reducer.ts new file mode 100644 index 00000000000..547493ef073 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/set_display_name_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + displayName: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/set_room_category_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/set_room_category_reducer.ts new file mode 100644 index 00000000000..9feeae5be80 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/set_room_category_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), + category: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/set_room_privacy_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/set_room_privacy_reducer.ts new file mode 100644 index 00000000000..15b49b76a1e --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/set_room_privacy_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), + isPrivate: __t.bool(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/set_status_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/set_status_reducer.ts new file mode 100644 index 00000000000..15a4e2758f8 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/set_status_reducer.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + ChatUserStatus, +} from "./types"; + +export default { + get status() { + return ChatUserStatus; + }, +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/start_typing_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/start_typing_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/start_typing_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/stop_typing_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/stop_typing_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/stop_typing_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/toggle_reaction_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/toggle_reaction_reducer.ts new file mode 100644 index 00000000000..bf62b01c66f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/toggle_reaction_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + messageId: __t.u64(), + emoji: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/types.ts b/spacetime-presence-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..a77ec4af952 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,296 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const Attachment = __t.object("Attachment", { + id: __t.u64(), + messageId: __t.u64(), + fileId: __t.u64(), + ownerUserId: __t.string(), + ordinal: __t.u32(), + filename: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type Attachment = __Infer; + +export const AttachmentFileResult = __t.object("AttachmentFileResult", { + filename: __t.option(__t.string()), + mimeType: __t.string(), + bytes: __t.byteArray(), +}); +export type AttachmentFileResult = __Infer; + +export const AttachmentInput = __t.object("AttachmentInput", { + mimeType: __t.string(), + filename: __t.option(__t.string()), + bytes: __t.byteArray(), +}); +export type AttachmentInput = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const ChatAuthUser = __t.object("ChatAuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type ChatAuthUser = __Infer; + +export const ChatRateLimitStatus = __t.object("ChatRateLimitStatus", { + scope: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + resetAt: __t.timestamp(), +}); +export type ChatRateLimitStatus = __Infer; + +export const ChatSweepTick = __t.object("ChatSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type ChatSweepTick = __Infer; + +export const ChatUser = __t.object("ChatUser", { + identity: __t.identity(), + userId: __t.string(), + displayName: __t.string(), + get status() { + return ChatUserStatus; + }, + createdAt: __t.timestamp(), + lastActiveAt: __t.timestamp(), + lastMessageAt: __t.timestamp(), +}); +export type ChatUser = __Infer; + +// The tagged union or sum type for the algebraic type `ChatUserStatus`. +export const ChatUserStatus = __t.enum("ChatUserStatus", { + Online: __t.unit(), + Away: __t.unit(), + Dnd: __t.unit(), + Invisible: __t.unit(), +}); +export type ChatUserStatus = __Infer; + +export const Message = __t.object("Message", { + id: __t.u64(), + roomId: __t.u64(), + author: __t.identity(), + content: __t.string(), + createdAt: __t.timestamp(), + editedAt: __t.option(__t.timestamp()), + replyToMessageId: __t.option(__t.u64()), + pinnedAt: __t.option(__t.timestamp()), + pinnedBy: __t.option(__t.identity()), +}); +export type Message = __Infer; + +export const MessageReaction = __t.object("MessageReaction", { + id: __t.u64(), + messageId: __t.u64(), + identity: __t.identity(), + emoji: __t.string(), + createdAt: __t.timestamp(), +}); +export type MessageReaction = __Infer; + +export const MessageThread = __t.object("MessageThread", { + id: __t.u64(), + rootMessageId: __t.u64(), + roomId: __t.u64(), + createdBy: __t.identity(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type MessageThread = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MyChatUsers = __t.object("MyChatUsers", {}); +export type MyChatUsers = __Infer; + +export const MyMessageThreads = __t.object("MyMessageThreads", {}); +export type MyMessageThreads = __Infer; + +export const MyPresenceEntries = __t.object("MyPresenceEntries", {}); +export type MyPresenceEntries = __Infer; + +export const MyRateLimitStatus = __t.object("MyRateLimitStatus", {}); +export type MyRateLimitStatus = __Infer; + +export const MyRoomAttachments = __t.object("MyRoomAttachments", {}); +export type MyRoomAttachments = __Infer; + +export const MyRoomMembers = __t.object("MyRoomMembers", {}); +export type MyRoomMembers = __Infer; + +export const MyRoomMessageReactions = __t.object("MyRoomMessageReactions", {}); +export type MyRoomMessageReactions = __Infer; + +export const MyRoomMessages = __t.object("MyRoomMessages", {}); +export type MyRoomMessages = __Infer; + +export const MyRoomReadCursors = __t.object("MyRoomReadCursors", {}); +export type MyRoomReadCursors = __Infer; + +export const MyRooms = __t.object("MyRooms", {}); +export type MyRooms = __Infer; + +export const MyServerMembers = __t.object("MyServerMembers", {}); +export type MyServerMembers = __Infer; + +export const MyServers = __t.object("MyServers", {}); +export type MyServers = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const MyThreadMessages = __t.object("MyThreadMessages", {}); +export type MyThreadMessages = __Infer; + +export const PresenceConfig = __t.object("PresenceConfig", { + singleton: __t.bool(), + defaultTtlSeconds: __t.u32(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type PresenceConfig = __Infer; + +export const PresenceEntry = __t.object("PresenceEntry", { + key: __t.string(), + scope: __t.string(), + subject: __t.string(), + status: __t.string(), + activity: __t.option(__t.string()), + payloadJson: __t.option(__t.string()), + joinedAt: __t.timestamp(), + lastSeenAt: __t.timestamp(), + expiresAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type PresenceEntry = __Infer; + +export const Room = __t.object("Room", { + id: __t.u64(), + serverId: __t.u64(), + name: __t.string(), + category: __t.option(__t.string()), + createdByUserId: __t.string(), + createdAt: __t.timestamp(), + isPrivate: __t.bool(), + activityLabel: __t.string(), + activityScore: __t.u32(), + lastActivityAt: __t.option(__t.timestamp()), +}); +export type Room = __Infer; + +export const RoomActivityEvent = __t.object("RoomActivityEvent", { + id: __t.u64(), + roomId: __t.u64(), + createdAt: __t.timestamp(), +}); +export type RoomActivityEvent = __Infer; + +export const RoomAttachment = __t.object("RoomAttachment", { + id: __t.u64(), + messageId: __t.u64(), + fileId: __t.u64(), + ownerUserId: __t.string(), + ordinal: __t.u32(), + filename: __t.option(__t.string()), + path: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type RoomAttachment = __Infer; + +export const RoomMember = __t.object("RoomMember", { + id: __t.u64(), + roomId: __t.u64(), + userId: __t.string(), + role: __t.string(), + joinedAt: __t.timestamp(), +}); +export type RoomMember = __Infer; + +export const RoomReadCursor = __t.object("RoomReadCursor", { + id: __t.u64(), + roomId: __t.u64(), + identity: __t.identity(), + lastReadMessageId: __t.u64(), + lastReadAt: __t.timestamp(), +}); +export type RoomReadCursor = __Infer; + +export const Server = __t.object("Server", { + id: __t.u64(), + name: __t.string(), + createdByUserId: __t.string(), + createdAt: __t.timestamp(), +}); +export type Server = __Infer; + +export const ServerMember = __t.object("ServerMember", { + id: __t.u64(), + serverId: __t.u64(), + userId: __t.string(), + role: __t.string(), + joinedAt: __t.timestamp(), +}); +export type ServerMember = __Infer; + +export const ThreadMessage = __t.object("ThreadMessage", { + id: __t.u64(), + threadId: __t.u64(), + author: __t.identity(), + content: __t.string(), + createdAt: __t.timestamp(), + editedAt: __t.option(__t.timestamp()), +}); +export type ThreadMessage = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), + userDisplayName: __t.option(__t.string()), + userStatus: __t.option(__t.string()), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-presence-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..f70528b40b1 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/types/procedures.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as GetAttachmentFileProcedure from "../get_attachment_file_procedure"; +import * as GetAuthPublicKeyProcedure from "../get_auth_public_key_procedure"; +import * as ListMySessionsProcedure from "../list_my_sessions_procedure"; +import * as SearchMessagesProcedure from "../search_messages_procedure"; +import * as WhoamiProcedure from "../whoami_procedure"; + +export type GetAttachmentFileArgs = __Infer; +export type GetAttachmentFileResult = __Infer; +export type GetAuthPublicKeyArgs = __Infer; +export type GetAuthPublicKeyResult = __Infer; +export type ListMySessionsArgs = __Infer; +export type ListMySessionsResult = __Infer; +export type SearchMessagesArgs = __Infer; +export type SearchMessagesResult = __Infer; +export type WhoamiArgs = __Infer; +export type WhoamiResult = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-presence-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..16775962995 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,76 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import CreateRoomReducer from "../create_room_reducer"; +import CreateServerReducer from "../create_server_reducer"; +import DeleteMessageReducer from "../delete_message_reducer"; +import DeleteRoomReducer from "../delete_room_reducer"; +import DeleteServerReducer from "../delete_server_reducer"; +import DeleteThreadMessageReducer from "../delete_thread_message_reducer"; +import EditMessageReducer from "../edit_message_reducer"; +import EditThreadMessageReducer from "../edit_thread_message_reducer"; +import HeartbeatReducer from "../heartbeat_reducer"; +import JoinRoomReducer from "../join_room_reducer"; +import JoinServerReducer from "../join_server_reducer"; +import LeaveRoomReducer from "../leave_room_reducer"; +import LeaveServerReducer from "../leave_server_reducer"; +import LinkConnectionReducer from "../link_connection_reducer"; +import MarkRoomReadReducer from "../mark_room_read_reducer"; +import PinMessageReducer from "../pin_message_reducer"; +import RenameRoomReducer from "../rename_room_reducer"; +import RenameServerReducer from "../rename_server_reducer"; +import RevokeMySessionReducer from "../revoke_my_session_reducer"; +import RevokeSessionReducer from "../revoke_session_reducer"; +import SendMessageReducer from "../send_message_reducer"; +import SendThreadMessageReducer from "../send_thread_message_reducer"; +import SetAuthConfigReducer from "../set_auth_config_reducer"; +import SetDisplayNameReducer from "../set_display_name_reducer"; +import SetRoomCategoryReducer from "../set_room_category_reducer"; +import SetRoomPrivacyReducer from "../set_room_privacy_reducer"; +import SetStatusReducer from "../set_status_reducer"; +import StartTypingReducer from "../start_typing_reducer"; +import StopTypingReducer from "../stop_typing_reducer"; +import ToggleReactionReducer from "../toggle_reaction_reducer"; +import UnlinkConnectionReducer from "../unlink_connection_reducer"; +import UnpinMessageReducer from "../unpin_message_reducer"; +import UpdateProfileReducer from "../update_profile_reducer"; + +export type CreateRoomParams = __Infer; +export type CreateServerParams = __Infer; +export type DeleteMessageParams = __Infer; +export type DeleteRoomParams = __Infer; +export type DeleteServerParams = __Infer; +export type DeleteThreadMessageParams = __Infer; +export type EditMessageParams = __Infer; +export type EditThreadMessageParams = __Infer; +export type HeartbeatParams = __Infer; +export type JoinRoomParams = __Infer; +export type JoinServerParams = __Infer; +export type LeaveRoomParams = __Infer; +export type LeaveServerParams = __Infer; +export type LinkConnectionParams = __Infer; +export type MarkRoomReadParams = __Infer; +export type PinMessageParams = __Infer; +export type RenameRoomParams = __Infer; +export type RenameServerParams = __Infer; +export type RevokeMySessionParams = __Infer; +export type RevokeSessionParams = __Infer; +export type SendMessageParams = __Infer; +export type SendThreadMessageParams = __Infer; +export type SetAuthConfigParams = __Infer; +export type SetDisplayNameParams = __Infer; +export type SetRoomCategoryParams = __Infer; +export type SetRoomPrivacyParams = __Infer; +export type SetStatusParams = __Infer; +export type StartTypingParams = __Infer; +export type StopTypingParams = __Infer; +export type ToggleReactionParams = __Infer; +export type UnlinkConnectionParams = __Infer; +export type UnpinMessageParams = __Infer; +export type UpdateProfileParams = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/unlink_connection_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/unpin_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/unpin_message_reducer.ts new file mode 100644 index 00000000000..104809a301c --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/unpin_message_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + messageId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/update_profile_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/whoami_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/whoami_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-presence-ts/example/tsconfig.json b/spacetime-presence-ts/example/tsconfig.json new file mode 100644 index 00000000000..eee538c9afe --- /dev/null +++ b/spacetime-presence-ts/example/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "server.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-presence-ts/package.json b/spacetime-presence-ts/package.json new file mode 100644 index 00000000000..cecf3855096 --- /dev/null +++ b/spacetime-presence-ts/package.json @@ -0,0 +1,68 @@ +{ + "name": "@spacetimedb/presence", + "description": "Presence, heartbeat, activity, and expiration helpers for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./tables": { + "types": "./src/tables.ts", + "default": "./src/tables.ts" + }, + "./presence": { + "types": "./src/presence.ts", + "default": "./src/presence.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-presence-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-presence-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "presence", + "realtime", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^22.10.2", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-presence-ts/scripts/test.ts b/spacetime-presence-ts/scripts/test.ts new file mode 100644 index 00000000000..d23b723d6f8 --- /dev/null +++ b/spacetime-presence-ts/scripts/test.ts @@ -0,0 +1,226 @@ +import { Timestamp } from 'spacetimedb'; +import { + buildPresenceKey, + installPresenceConfig, + MAX_PRESENCE_SWEEP_BATCH, + removePresence, + resolvePresenceSweepBatch, + sweepPresence, + touchPresence, + updatePresenceConfig, + upsertPresence, + type PresenceEntryRow, +} from '../src/presence.ts'; + +let pass = 0; +let fail = 0; + +function assert(cond: boolean, name: string, detail = ''): void { + if (cond) { + pass++; + process.stdout.write(` ok ${name}\n`); + } else { + fail++; + process.stdout.write( + ` FAIL ${name}${detail ? `\n ${detail}` : ''}\n` + ); + } +} + +function assertThrows(fn: () => void, expected: string, name: string): void { + try { + fn(); + assert(false, name, `expected ${expected}`); + } catch (error) { + assert(error instanceof Error && error.message === expected, name); + } +} + +function makeTx(nowMicros = 0n) { + const rows = new Map(); + const tx = { + timestamp: new Timestamp(nowMicros), + db: { + presenceEntry: { + key: { + find: (key: string) => rows.get(key), + update: (row: PresenceEntryRow) => rows.set(row.key, row), + }, + insert: (row: PresenceEntryRow) => rows.set(row.key, row), + delete: (row: PresenceEntryRow) => rows.delete(row.key), + expiresAt: { + filter: function* () { + yield* [...rows.values()].sort((a, b) => + a.expiresAt.microsSinceUnixEpoch < + b.expiresAt.microsSinceUnixEpoch + ? -1 + : 1 + ); + }, + }, + }, + }, + rows, + }; + return tx; +} + +{ + const tx = makeTx(); + upsertPresence(tx, { + scope: 'room:1', + subject: 'fresh-a', + ttlSeconds: 100, + }); + upsertPresence(tx, { + scope: 'room:1', + subject: 'fresh-b', + ttlSeconds: 100, + }); + upsertPresence(tx, { + scope: 'room:1', + subject: 'expired', + ttlSeconds: 1, + }); + tx.timestamp = new Timestamp(2_000_000n); + const deleted = sweepPresence(tx, tx.db.presenceEntry.expiresAt.filter(), 2); + assert(deleted === 1, 'sweep reaches expired rows beyond fresh inserts'); + assert( + ![...tx.rows.values()].some(row => row.subject === 'expired'), + 'indexed sweep removes the expired row' + ); +} + +process.stdout.write('\npresence submodule\n'); + +{ + let config: + | { + singleton: boolean; + defaultTtlSeconds: number; + sweepBatch: number; + updatedAt: Timestamp; + } + | undefined; + const ctx = { + timestamp: new Timestamp(1n), + db: { + presenceConfig: { + singleton: { + find: () => config, + update: (row: typeof config) => { + config = row; + }, + }, + insert: (row: NonNullable) => { + config = row; + }, + }, + }, + }; + installPresenceConfig(ctx, { defaultTtlSeconds: 30, sweepBatch: 500 }); + ctx.timestamp = new Timestamp(2n); + updatePresenceConfig(ctx, { defaultTtlSeconds: 45, sweepBatch: 750 }); + assert( + config?.defaultTtlSeconds === 45 && config.sweepBatch === 750, + 'configuration updates an existing row' + ); + assert( + resolvePresenceSweepBatch(ctx) === 750, + 'sweep reads the updated batch size' + ); +} + +{ + const tx = makeTx(); + assertThrows( + () => sweepPresence(tx, [], MAX_PRESENCE_SWEEP_BATCH + 1), + 'presence.invalid_sweep_batch', + 'sweep rejects an excessive batch size' + ); +} + +assert( + buildPresenceKey('room::one', 'user') !== + buildPresenceKey('room', 'one::user'), + 'compound keys cannot collide through delimiters' +); + +{ + const tx = makeTx(); + const row = upsertPresence(tx, { + scope: 'room:1', + subject: 'user:alice', + status: 'online', + ttlSeconds: 30, + }); + assert(row.scope === 'room:1', 'inserts row'); + assert(tx.rows.size === 1, 'row count 1 after insert'); +} + +{ + const tx = makeTx(); + upsertPresence(tx, { + scope: 'room:1', + subject: 'user:alice', + status: 'away', + activity: 'editing', + payloadJson: '{"cursor":4}', + ttlSeconds: 30, + }); + tx.timestamp = new Timestamp(10_000_000n); + const row = touchPresence(tx, 'room:1', 'user:alice', 30); + assert( + row.status === 'away' && + row.activity === 'editing' && + row.payloadJson === '{"cursor":4}', + 'touch preserves presence metadata' + ); +} + +{ + const tx = makeTx(); + upsertPresence(tx, { + scope: 'room:1', + subject: 'user:alice', + status: 'online', + ttlSeconds: 30, + }); + tx.timestamp = new Timestamp(10_000_000n); + const row = upsertPresence(tx, { + scope: 'room:1', + subject: 'user:alice', + status: 'away', + ttlSeconds: 30, + }); + assert(row.status === 'away', 'upsert updates status'); + assert(tx.rows.size === 1, 'upsert keeps one row'); +} + +{ + const tx = makeTx(); + upsertPresence(tx, { + scope: 'room:1', + subject: 'user:alice', + ttlSeconds: 1, + }); + tx.timestamp = new Timestamp(2_000_000n); + const deleted = sweepPresence(tx, tx.db.presenceEntry.expiresAt.filter()); + assert(deleted === 1, 'sweep removes expired row'); + assert(tx.rows.size === 0, 'rows empty after sweep'); +} + +{ + const tx = makeTx(); + upsertPresence(tx, { + scope: 'room:1', + subject: 'user:alice', + ttlSeconds: 10, + }); + const removed = removePresence(tx, 'room:1', 'user:alice'); + assert(removed, 'removePresence returns true for existing row'); + assert(tx.rows.size === 0, 'removePresence deletes row'); +} + +process.stdout.write(`\n${pass} passed, ${fail} failed\n`); +process.exit(fail === 0 ? 0 : 1); diff --git a/spacetime-presence-ts/spacetimedb/.npmrc b/spacetime-presence-ts/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-presence-ts/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-presence-ts/spacetimedb/package.json b/spacetime-presence-ts/spacetimedb/package.json new file mode 100644 index 00000000000..6d1bae6c1a3 --- /dev/null +++ b/spacetime-presence-ts/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-presence-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-presence", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-presence" + }, + "dependencies": { + "@spacetimedb/presence": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-presence-ts/spacetimedb/src/index.ts b/spacetime-presence-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..160a0b54719 --- /dev/null +++ b/spacetime-presence-ts/spacetimedb/src/index.ts @@ -0,0 +1,2 @@ +export { default } from '../../src/submodule/index'; +export * from '../../src/submodule/index'; diff --git a/spacetime-presence-ts/spacetimedb/tsconfig.json b/spacetime-presence-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..8d8f9b03455 --- /dev/null +++ b/spacetime-presence-ts/spacetimedb/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/spacetime-presence-ts/src/index.ts b/spacetime-presence-ts/src/index.ts new file mode 100644 index 00000000000..3f384698714 --- /dev/null +++ b/spacetime-presence-ts/src/index.ts @@ -0,0 +1,34 @@ +export { + presenceEntryRow, + presenceConfigRow, + presenceSweepTickRow, + createPresenceEntryTable, + createPresenceConfigTable, + presenceEntryTable, + presenceConfigTable, + presenceTables, +} from './tables'; + +export { + DEFAULT_PRESENCE_TTL_SECONDS, + DEFAULT_PRESENCE_SWEEP_BATCH, + MAX_PRESENCE_SWEEP_BATCH, + DEFAULT_PRESENCE_STATUS, + buildPresenceKey, + installPresenceConfig, + upsertPresence, + touchPresence, + removePresence, + sweepPresence, + assertPresenceSweepBatch, + updatePresenceConfig, + resolvePresenceSweepBatch, + runPresenceSweep, + type PresenceConfigCtxLike, + type PresenceConfigReadCtxLike, + type PresenceEntryRow, + type PresenceSweepCtxLike, + type PresenceTxLike, + type PresenceInstallOpts, + type PresenceUpsertOpts, +} from './presence'; diff --git a/spacetime-presence-ts/src/presence.ts b/spacetime-presence-ts/src/presence.ts new file mode 100644 index 00000000000..1116d093b5e --- /dev/null +++ b/spacetime-presence-ts/src/presence.ts @@ -0,0 +1,300 @@ +import { Timestamp } from 'spacetimedb'; + +const ONE_SECOND_MICROS = 1_000_000n; +const U32_MAX = 0xffff_ffff; + +export const DEFAULT_PRESENCE_TTL_SECONDS = 30; +export const DEFAULT_PRESENCE_SWEEP_BATCH = 500; +export const MAX_PRESENCE_SWEEP_BATCH = 10_000; +export const DEFAULT_PRESENCE_STATUS = 'online'; +const MAX_SCOPE_LENGTH = 128; +const MAX_SUBJECT_LENGTH = 256; +const MAX_STATUS_LENGTH = 64; +const MAX_ACTIVITY_LENGTH = 256; +const MAX_PAYLOAD_LENGTH = 64 * 1024; +const MAX_TTL_SECONDS = 3600; + +export interface PresenceEntryRow { + key: string; + scope: string; + subject: string; + status: string; + activity: string | undefined; + payloadJson: string | undefined; + joinedAt: Timestamp; + lastSeenAt: Timestamp; + expiresAt: Timestamp; + updatedAt: Timestamp; +} + +interface PresenceConfigRow { + singleton: boolean; + defaultTtlSeconds: number; + sweepBatch: number; + updatedAt: Timestamp; +} + +export interface PresenceTxLike { + timestamp: Timestamp; + db: { + presenceEntry: { + key: { + find(key: string): PresenceEntryRow | null | undefined; + update(row: PresenceEntryRow): void; + }; + insert(row: PresenceEntryRow): void; + delete(row: PresenceEntryRow): void; + }; + }; +} + +export interface PresenceConfigCtxLike { + timestamp: Timestamp; + db: { + presenceConfig: { + singleton: { + find(key: boolean): PresenceConfigRow | null | undefined; + update(row: PresenceConfigRow): void; + }; + insert(row: PresenceConfigRow): void; + }; + }; +} + +export interface PresenceSweepCtxLike extends PresenceTxLike { + db: PresenceTxLike['db'] & { + presenceConfig: { + singleton: { + find(key: boolean): PresenceConfigRow | null | undefined; + }; + }; + }; +} + +export interface PresenceConfigReadCtxLike { + db: { + presenceConfig: { + singleton: { + find(key: boolean): PresenceConfigRow | null | undefined; + }; + }; + }; +} + +export interface PresenceUpsertOpts { + scope: string; + subject: string; + status?: string; + activity?: string; + payloadJson?: string; + ttlSeconds?: number; +} + +export interface PresenceInstallOpts { + defaultTtlSeconds?: number; + sweepBatch?: number; +} + +function assertPositiveU32(name: string, value: number): void { + if (!Number.isInteger(value) || value <= 0 || value > U32_MAX) { + throw new Error(`presence.invalid_${name}`); + } +} + +function sanitize(name: string, value: string): string { + const out = value.trim(); + if (out.length === 0) throw new Error(`presence.invalid_${name}`); + const maxLength = name === 'scope' ? MAX_SCOPE_LENGTH : MAX_SUBJECT_LENGTH; + if (out.length > maxLength) throw new Error(`presence.invalid_${name}`); + return out; +} + +function plusSeconds(ts: Timestamp, seconds: number): Timestamp { + return new Timestamp( + (ts.microsSinceUnixEpoch as bigint) + BigInt(seconds) * ONE_SECOND_MICROS + ); +} + +export function buildPresenceKey(scope: string, subject: string): string { + const normalizedScope = sanitize('scope', scope); + const normalizedSubject = sanitize('subject', subject); + return `${normalizedScope.length}:${normalizedScope}${normalizedSubject.length}:${normalizedSubject}`; +} + +export function installPresenceConfig( + ctx: PresenceConfigCtxLike, + opts?: PresenceInstallOpts +): void { + const defaultTtlSeconds = + opts?.defaultTtlSeconds ?? DEFAULT_PRESENCE_TTL_SECONDS; + const sweepBatch = opts?.sweepBatch ?? DEFAULT_PRESENCE_SWEEP_BATCH; + assertPositiveU32('default_ttl_seconds', defaultTtlSeconds); + assertPresenceSweepBatch(sweepBatch); + + const existing = ctx.db.presenceConfig.singleton.find(true); + if (!existing) { + ctx.db.presenceConfig.insert({ + singleton: true, + defaultTtlSeconds, + sweepBatch, + updatedAt: ctx.timestamp, + }); + return; + } +} + +export function assertPresenceSweepBatch(value: number): void { + assertPositiveU32('sweep_batch', value); + if (value > MAX_PRESENCE_SWEEP_BATCH) { + throw new Error('presence.invalid_sweep_batch'); + } +} + +export function updatePresenceConfig( + ctx: PresenceConfigCtxLike, + opts: Required +): void { + assertPositiveU32('default_ttl_seconds', opts.defaultTtlSeconds); + assertPresenceSweepBatch(opts.sweepBatch); + const existing = ctx.db.presenceConfig.singleton.find(true); + if (!existing) throw new Error('presence.config_missing'); + ctx.db.presenceConfig.singleton.update({ + ...existing, + defaultTtlSeconds: opts.defaultTtlSeconds, + sweepBatch: opts.sweepBatch, + updatedAt: ctx.timestamp, + }); +} + +export function upsertPresence( + tx: PresenceTxLike, + opts: PresenceUpsertOpts +): PresenceEntryRow { + const scope = sanitize('scope', opts.scope); + const subject = sanitize('subject', opts.subject); + const key = buildPresenceKey(scope, subject); + const ttlSeconds = opts.ttlSeconds ?? DEFAULT_PRESENCE_TTL_SECONDS; + assertPositiveU32('ttl_seconds', ttlSeconds); + if (ttlSeconds > MAX_TTL_SECONDS) + throw new Error('presence.invalid_ttl_seconds'); + + const status = (opts.status ?? DEFAULT_PRESENCE_STATUS).trim(); + if (status.length === 0 || status.length > MAX_STATUS_LENGTH) { + throw new Error('presence.invalid_status'); + } + const activity = opts.activity?.trim() || undefined; + if ((activity?.length ?? 0) > MAX_ACTIVITY_LENGTH) { + throw new Error('presence.invalid_activity'); + } + const payloadJson = opts.payloadJson?.trim() || undefined; + if ((payloadJson?.length ?? 0) > MAX_PAYLOAD_LENGTH) { + throw new Error('presence.invalid_payload'); + } + + const now = tx.timestamp; + const expiresAt = plusSeconds(now, ttlSeconds); + const existing = tx.db.presenceEntry.key.find(key); + + if (!existing) { + const inserted: PresenceEntryRow = { + key, + scope, + subject, + status, + activity, + payloadJson, + joinedAt: now, + lastSeenAt: now, + expiresAt, + updatedAt: now, + }; + tx.db.presenceEntry.insert(inserted); + return inserted; + } + + const updated: PresenceEntryRow = { + ...existing, + scope, + subject, + status, + activity, + payloadJson, + lastSeenAt: now, + expiresAt, + updatedAt: now, + }; + tx.db.presenceEntry.key.update(updated); + return updated; +} + +export function touchPresence( + tx: PresenceTxLike, + scope: string, + subject: string, + ttlSeconds = DEFAULT_PRESENCE_TTL_SECONDS +): PresenceEntryRow { + const key = buildPresenceKey(scope, subject); + const existing = tx.db.presenceEntry.key.find(key); + if (!existing) return upsertPresence(tx, { scope, subject, ttlSeconds }); + assertPositiveU32('ttl_seconds', ttlSeconds); + if (ttlSeconds > MAX_TTL_SECONDS) + throw new Error('presence.invalid_ttl_seconds'); + const now = tx.timestamp; + const updated = { + ...existing, + lastSeenAt: now, + expiresAt: plusSeconds(now, ttlSeconds), + updatedAt: now, + }; + tx.db.presenceEntry.key.update(updated); + return updated; +} + +export function removePresence( + tx: PresenceTxLike, + scope: string, + subject: string +): boolean { + const key = buildPresenceKey(scope, subject); + const existing = tx.db.presenceEntry.key.find(key); + if (!existing) return false; + tx.db.presenceEntry.delete(existing); + return true; +} + +export function sweepPresence( + tx: PresenceTxLike, + expiredRows: Iterable, + maxRows = DEFAULT_PRESENCE_SWEEP_BATCH +): number { + assertPresenceSweepBatch(maxRows); + const nowMicros = tx.timestamp.microsSinceUnixEpoch as bigint; + let deleted = 0; + for (const row of expiredRows) { + if (deleted >= maxRows) break; + if ((row.expiresAt.microsSinceUnixEpoch as bigint) > nowMicros) break; + tx.db.presenceEntry.delete(row); + deleted++; + } + return deleted; +} + +export function resolvePresenceSweepBatch( + ctx: PresenceConfigReadCtxLike +): number { + const cfg = ctx.db.presenceConfig.singleton.find(true); + const value = Number(cfg?.sweepBatch ?? DEFAULT_PRESENCE_SWEEP_BATCH); + return Number.isInteger(value) && + value > 0 && + value <= MAX_PRESENCE_SWEEP_BATCH + ? value + : DEFAULT_PRESENCE_SWEEP_BATCH; +} + +export function runPresenceSweep( + ctx: PresenceSweepCtxLike, + expiredRows: Iterable +): number { + const batch = resolvePresenceSweepBatch(ctx); + return sweepPresence(ctx, expiredRows, batch); +} diff --git a/spacetime-presence-ts/src/submodule.ts b/spacetime-presence-ts/src/submodule.ts new file mode 100644 index 00000000000..c8ee56669d1 --- /dev/null +++ b/spacetime-presence-ts/src/submodule.ts @@ -0,0 +1,11 @@ +export { default } from './submodule/index'; +export { installPresence } from './submodule/install'; +export { + add_presence_admin, + clear_presence, + heartbeat, + presenceEntriesAdmin, + presence_sweep, + run_sweep, + update_config, +} from './submodule/index'; diff --git a/spacetime-presence-ts/src/submodule/index.ts b/spacetime-presence-ts/src/submodule/index.ts new file mode 100644 index 00000000000..63dc1d08f23 --- /dev/null +++ b/spacetime-presence-ts/src/submodule/index.ts @@ -0,0 +1,243 @@ +import { Timestamp, type Identity } from 'spacetimedb'; +import { installPresence } from './install'; +import { + schema, + table, + t, + Range, + SenderError, + type InferSchema, + type ReducerCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { + DEFAULT_PRESENCE_SWEEP_BATCH, + DEFAULT_PRESENCE_STATUS, + MAX_PRESENCE_SWEEP_BATCH, + removePresence, + runPresenceSweep, + sweepPresence, + updatePresenceConfig, + upsertPresence, +} from '../index'; +import { + presenceConfigRow, + presenceEntryRow, + presenceSweepTickRow, +} from '../tables'; + +const presenceEntry = table( + { name: 'presence_entry', public: true }, + presenceEntryRow +); + +const presenceConfig = table( + { name: 'presence_config', public: true }, + presenceConfigRow +); + +const presenceAdminIdentity = table( + { name: 'presence_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +const presenceSweepTick = table( + { name: 'presence_sweep_tick' }, + presenceSweepTickRow +); + +const spacetimedb = schema({ + presenceEntry, + presenceConfig, + presenceAdminIdentity, + presenceSweepTick, +}); +export default spacetimedb; + +type Schema = InferSchema; +type Tx = ReducerCtx; + +const heartbeatResult = t.object('PresenceHeartbeatResult', { + scope: t.string(), + subject: t.string(), + status: t.string(), + expiresAt: t.timestamp(), +}); + +const DEFAULT_SCOPE = 'presence.global'; + +function takeRows(rows: Iterable, limit = 1000): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +function identityHex(identity: Identity): string { + return identity.toHexString(); +} + +// Subject is always the sender's Identity, never a caller-supplied value. +function buildSubject(ctx: { sender: Identity }): string { + return identityHex(ctx.sender); +} + +function sanitizeScope(scope: string | undefined): string { + const out = (scope ?? DEFAULT_SCOPE).trim(); + if (out.length === 0) throw new SenderError('presence.invalid_scope'); + return out; +} + +function isAdmin(ctx: ViewCtx): boolean { + return ctx.db.presenceAdminIdentity.identity.find(ctx.sender) != null; +} + +function requireAdmin(ctx: Tx): void { + if (ctx.db.presenceAdminIdentity.identity.find(ctx.sender) == null) { + throw new SenderError('presence.not_authorized'); + } +} + +function toU32(name: string, value: number, max = 0xffff_ffff): number { + if (!Number.isInteger(value) || value <= 0 || value > max) { + throw new SenderError(`presence.invalid_${name}`); + } + return value; +} + +// Fresh publishes seed the publishing owner as admin, install config, and start the expiry sweeper. +export const init = spacetimedb.init(ctx => { + installPresence(ctx); +}); + +export const heartbeat = spacetimedb.procedure( + { + scope: t.option(t.string()), + status: t.option(t.string()), + activity: t.option(t.string()), + payloadJson: t.option(t.string()), + ttlSeconds: t.option(t.u32()), + }, + heartbeatResult, + (ctx, args) => { + const scope = sanitizeScope(args.scope); + const subject = buildSubject(ctx); + const ttlSeconds = + args.ttlSeconds === undefined + ? undefined + : toU32('ttl_seconds', Number(args.ttlSeconds)); + + let out: { + scope: string; + subject: string; + status: string; + expiresAt: Timestamp; + } | null = null; + ctx.withTx(tx => { + const row = upsertPresence(tx, { + scope, + subject, + status: args.status ?? DEFAULT_PRESENCE_STATUS, + activity: args.activity, + payloadJson: args.payloadJson, + ttlSeconds, + }); + out = { + scope: row.scope, + subject: row.subject, + status: row.status, + expiresAt: row.expiresAt, + }; + }); + if (!out) throw new SenderError('presence.heartbeat_tx_failed'); + return out; + } +); + +export const clear_presence = spacetimedb.reducer( + { scope: t.option(t.string()) }, + (ctx, args) => { + const scope = sanitizeScope(args.scope); + const subject = buildSubject(ctx); + const tx: Tx = ctx; + removePresence(tx, scope, subject); + } +); + +export const run_sweep = spacetimedb.procedure( + { maxRows: t.option(t.u32()) }, + t.u32(), + (ctx, args) => { + const maxRows = + args.maxRows === undefined + ? undefined + : toU32('sweep_batch', Number(args.maxRows), MAX_PRESENCE_SWEEP_BATCH); + let deleted = 0; + ctx.withTx(tx => { + requireAdmin(tx); + deleted = sweepPresence( + tx, + tx.db.presenceEntry.expiresAt.filter( + new Range(undefined, { tag: 'included', value: tx.timestamp }) + ), + maxRows ?? DEFAULT_PRESENCE_SWEEP_BATCH + ); + }); + return deleted; + } +); + +export const add_presence_admin = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, args) => { + requireAdmin(ctx); + if (ctx.db.presenceAdminIdentity.identity.find(args.identity) == null) { + ctx.db.presenceAdminIdentity.insert({ + identity: args.identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + } +); + +export const update_config = spacetimedb.reducer( + { defaultTtlSeconds: t.u32(), sweepBatch: t.u32() }, + (ctx, args) => { + requireAdmin(ctx); + updatePresenceConfig(ctx, { + defaultTtlSeconds: toU32( + 'default_ttl_seconds', + Number(args.defaultTtlSeconds) + ), + sweepBatch: toU32( + 'sweep_batch', + Number(args.sweepBatch), + MAX_PRESENCE_SWEEP_BATCH + ), + }); + } +); + +export const presenceEntriesAdmin = spacetimedb.view( + { name: 'presence_entries_admin', public: true }, + t.array(presenceEntry.rowType), + ctx => (isAdmin(ctx) ? takeRows(ctx.db.presenceEntry.iter()) : []) +); + +export const presence_sweep = spacetimedb.reducer( + { onSchedule: presenceSweepTick }, + { arg: presenceSweepTick.rowType }, + (ctx, _args) => { + runPresenceSweep( + ctx, + ctx.db.presenceEntry.expiresAt.filter( + new Range(undefined, { tag: 'included', value: ctx.timestamp }) + ) + ); + } +); diff --git a/spacetime-presence-ts/src/submodule/install.ts b/spacetime-presence-ts/src/submodule/install.ts new file mode 100644 index 00000000000..0be7f639ace --- /dev/null +++ b/spacetime-presence-ts/src/submodule/install.ts @@ -0,0 +1,33 @@ +import { ScheduleAt } from 'spacetimedb'; +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import { + DEFAULT_PRESENCE_SWEEP_BATCH, + DEFAULT_PRESENCE_TTL_SECONDS, + installPresenceConfig, +} from '../index'; +import type spacetimedb from './index'; + +const ONE_SECOND_MICROS = 1_000_000n; +const SWEEP_INTERVAL_SECONDS = 10n; + +type Schema = InferSchema; +type InstallCtx = ReducerCtx; + +export function installPresence(ctx: InstallCtx) { + if (ctx.db.presenceAdminIdentity.identity.find(ctx.sender) == null) { + ctx.db.presenceAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + installPresenceConfig(ctx, { + defaultTtlSeconds: DEFAULT_PRESENCE_TTL_SECONDS, + sweepBatch: DEFAULT_PRESENCE_SWEEP_BATCH, + }); + ctx.db.presenceSweepTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval( + SWEEP_INTERVAL_SECONDS * ONE_SECOND_MICROS + ), + }); +} diff --git a/spacetime-presence-ts/src/tables.ts b/spacetime-presence-ts/src/tables.ts new file mode 100644 index 00000000000..09b9275435d --- /dev/null +++ b/spacetime-presence-ts/src/tables.ts @@ -0,0 +1,60 @@ +import { table, t } from 'spacetimedb/server'; + +export const presenceEntryRow = { + key: t.string().primaryKey(), + scope: t.string().index(), + subject: t.string().index(), + status: t.string().index(), + activity: t.option(t.string()), + payloadJson: t.option(t.string()), + joinedAt: t.timestamp().index(), + lastSeenAt: t.timestamp().index(), + expiresAt: t.timestamp().index(), + updatedAt: t.timestamp(), +}; + +export const presenceConfigRow = { + singleton: t.bool().primaryKey(), + defaultTtlSeconds: t.u32(), + sweepBatch: t.u32(), + updatedAt: t.timestamp(), +}; + +export const presenceSweepTickRow = { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), +}; + +export function createPresenceEntryTable(options?: { + name?: string; + public?: boolean; +}) { + return table( + { + name: options?.name ?? 'presence_entry', + public: options?.public ?? false, + }, + presenceEntryRow + ); +} + +export function createPresenceConfigTable(options?: { + name?: string; + public?: boolean; +}) { + return table( + { + name: options?.name ?? 'presence_config', + public: options?.public ?? false, + }, + presenceConfigRow + ); +} + +export const presenceEntryTable = createPresenceEntryTable(); +export const presenceConfigTable = createPresenceConfigTable(); + +export const presenceTables = { + presenceEntry: presenceEntryTable, + presenceConfig: presenceConfigTable, +}; diff --git a/spacetime-presence-ts/tsconfig.json b/spacetime-presence-ts/tsconfig.json new file mode 100644 index 00000000000..b2b4ecdcdda --- /dev/null +++ b/spacetime-presence-ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules"] +}