From 45f012af4989df26e83d15ca23507804807783e6 Mon Sep 17 00:00:00 2001 From: wizjany Date: Mon, 31 Aug 2026 23:19:26 -0400 Subject: [PATCH 01/13] Craft browser and bounty management. * Backend STDB module + persistent service. * Update Bitcraft bindings to 2.x. * Account system. * Connection management. * There's a lot here I'm not listing it all. --- .dockerignore | 18 + .github/workflows/docker-build-brico-bot.yml | 44 +- .github/workflows/i18n-translate-sync.yml | 6 +- .gitignore | 15 +- .gitmodules | 4 +- backend/brico-bot/.env.example | 56 + backend/brico-bot/.gitignore | 4 + backend/brico-bot/Dockerfile | 27 + backend/brico-bot/README.md | 154 + backend/brico-bot/docker-compose.example.yml | 28 + backend/brico-bot/package.json | 30 + backend/brico-bot/src/app/bounty-sink.ts | 265 ++ backend/brico-bot/src/app/bounty-source.ts | 156 + backend/brico-bot/src/app/connection.ts | 217 + .../brico-bot/src/app/notification-sink.ts | 48 + backend/brico-bot/src/app/watch-source.ts | 95 + backend/brico-bot/src/auth/discord-oauth.ts | 72 + .../brico-bot/src/auth/link-state-store.ts | 57 + backend/brico-bot/src/auth/oidc-client.ts | 110 + backend/brico-bot/src/auth/pkce.ts | 20 + backend/brico-bot/src/bridge.test.ts | 136 + backend/brico-bot/src/bridge.ts | 207 + backend/brico-bot/src/config.ts | 200 + backend/brico-bot/src/game-data/load.ts | 18 + backend/brico-bot/src/game-data/recipes.ts | 62 + backend/brico-bot/src/http-server.ts | 208 + backend/brico-bot/src/log.ts | 52 + backend/brico-bot/src/main.ts | 147 + backend/brico-bot/src/metrics.ts | 84 + backend/brico-bot/src/relay/prism.ts | 243 + backend/brico-bot/src/relay/subject.ts | 67 + backend/brico-bot/src/spacetime/coalesce.ts | 49 + backend/brico-bot/src/spacetime/connection.ts | 251 + .../brico-bot/src/spacetime/token-store.ts | 43 + backend/brico-bot/tsconfig.json | 20 + backend/brico-module/.gitignore | 4 + backend/brico-module/README.md | 23 + .../brico-module/scripts/import-payouts.mjs | 224 + .../brico-module/scripts/publish-local.mjs | 47 + backend/brico-module/spacetime.json | 4 + backend/brico-module/spacetime.local.json | 3 + .../spacetimedb/package-lock.json | 186 + backend/brico-module/spacetimedb/package.json | 19 + backend/brico-module/spacetimedb/src/index.ts | 27 + .../brico-module/spacetimedb/src/lib/auth.ts | 57 + .../spacetimedb/src/lib/bounty.ts | 26 + .../spacetimedb/src/lib/filters.ts | 20 + .../brico-module/spacetimedb/src/lib/sync.ts | 20 + .../spacetimedb/src/reducers/accounts.ts | 112 + .../spacetimedb/src/reducers/bounties.ts | 268 ++ .../spacetimedb/src/reducers/crafts.ts | 257 + .../spacetimedb/src/reducers/integrations.ts | 237 + .../spacetimedb/src/reducers/notifications.ts | 74 + .../spacetimedb/src/reducers/payouts.ts | 246 + .../brico-module/spacetimedb/src/schema.ts | 33 + .../spacetimedb/src/tables/accounts.ts | 40 + .../spacetimedb/src/tables/bounties.ts | 106 + .../spacetimedb/src/tables/crafts.ts | 57 + .../spacetimedb/src/tables/integrations.ts | 60 + .../spacetimedb/src/tables/notifications.ts | 48 + .../spacetimedb/src/tables/payouts.ts | 98 + .../spacetimedb/src/views/accounts.ts | 26 + .../spacetimedb/src/views/bounties.ts | 63 + .../spacetimedb/src/views/crafts.ts | 39 + .../spacetimedb/src/views/integrations.ts | 50 + .../spacetimedb/src/views/notifications.ts | 15 + .../spacetimedb/src/views/payouts.ts | 230 + .../brico-module/spacetimedb/tsconfig.json | 23 + common/README.md | 26 + common/bindings/brico-app/README.md | 61 + .../bindings/brico-app/all_account_table.ts | 15 + .../brico-app/all_bounty_rule_table.ts | 22 + .../all_craft_bounty_assignment_table.ts | 16 + .../all_craft_bounty_entitlement_table.ts | 16 + .../all_craft_bounty_override_table.ts | 16 + .../brico-app/all_craft_filter_watch_table.ts | 16 + .../all_integration_link_request_table.ts | 17 + .../brico-app/all_linked_integration_table.ts | 16 + .../brico-app/all_loyalty_reward_table.ts | 16 + ...l_private_craft_bounty_assignment_table.ts | 16 + .../brico-app/all_saved_craft_filter_table.ts | 16 + .../brico-app/assign_craft_bounty_reducer.ts | 17 + ...n_integration_link_for_external_reducer.ts | 13 + .../begin_integration_link_reducer.ts | 11 + .../claim_integration_link_reducer.ts | 10 + .../brico-app/clear_craft_bounty_reducer.ts | 10 + .../create_shared_filters_procedure.ts | 13 + .../brico-app/delete_bounty_rule_reducer.ts | 11 + .../delete_craft_bounty_override_reducer.ts | 10 + .../delete_craft_filter_watch_reducer.ts | 11 + .../delete_loyalty_reward_reducer.ts | 11 + .../brico-app/delete_notification_reducer.ts | 10 + .../delete_saved_craft_filter_reducer.ts | 11 + .../brico-app/ensure_account_reducer.ts | 7 + ...import_historical_bounty_ledger_reducer.ts | 15 + ...mport_historical_loyalty_reward_reducer.ts | 14 + common/bindings/brico-app/index.ts | 362 ++ ...link_discord_via_spacetime_auth_reducer.ts | 7 + .../brico-app/link_integration_reducer.ts | 13 + .../mark_all_notifications_read_reducer.ts | 7 + .../mark_notification_read_reducer.ts | 10 + common/bindings/brico-app/my_account_table.ts | 15 + .../my_bounty_payout_record_as_payer_table.ts | 15 + .../brico-app/my_bounty_rule_table.ts | 22 + .../my_craft_bounty_override_table.ts | 16 + .../brico-app/my_craft_filter_watch_table.ts | 16 + .../my_entitlements_as_contributor_table.ts | 17 + .../my_entitlements_as_payer_table.ts | 17 + .../my_integration_link_request_table.ts | 17 + .../brico-app/my_linked_integration_table.ts | 16 + .../brico-app/my_loyalty_reward_table.ts | 16 + .../brico-app/my_notification_table.ts | 18 + ...y_private_craft_bounty_assignment_table.ts | 16 + .../brico-app/my_saved_craft_filter_table.ts | 16 + .../note_integration_link_external_reducer.ts | 12 + .../post_craft_notification_reducer.ts | 15 + .../read_shared_filters_procedure.ts | 13 + .../record_bounty_payment_reducer.ts | 12 + .../register_service_principal_reducer.ts | 11 + .../brico-app/reorder_bounty_rules_reducer.ts | 10 + .../revoke_service_principal_reducer.ts | 10 + .../brico-app/set_display_name_reducer.ts | 10 + common/bindings/brico-app/types.ts | 347 ++ common/bindings/brico-app/types/procedures.ts | 16 + common/bindings/brico-app/types/reducers.ts | 74 + .../brico-app/unlink_integration_reducer.ts | 10 + .../brico-app/upsert_bounty_rule_reducer.ts | 19 + ...upsert_craft_bounty_entitlement_reducer.ts | 15 + .../upsert_craft_bounty_override_reducer.ts | 15 + .../upsert_craft_filter_watch_reducer.ts | 14 + .../upsert_loyalty_reward_reducer.ts | 14 + .../upsert_saved_craft_filter_reducer.ts | 13 + common/bindings/package.json | 16 + common/bindings/prism/claim_info_table.ts | 16 + common/bindings/prism/claim_member_table.ts | 18 + common/bindings/prism/claim_meta_table.ts | 14 + common/bindings/prism/claim_supply_table.ts | 15 + .../prism/craft_contribution_table.ts | 13 + common/bindings/prism/craft_meta_table.ts | 23 + common/bindings/prism/craft_progress_table.ts | 12 + common/bindings/prism/enemy_location_table.ts | 14 + common/bindings/prism/growth_timers_table.ts | 15 + common/bindings/prism/herd_location_table.ts | 15 + common/bindings/prism/index.ts | 486 ++ .../bindings/prism/player_location_table.ts | 13 + common/bindings/prism/player_state_table.ts | 13 + common/bindings/prism/recipe_meta_table.ts | 14 + common/bindings/prism/region_table.ts | 18 + .../bindings/prism/resource_location_table.ts | 14 + common/bindings/prism/types.ts | 259 ++ common/bitcraft-bindings/package.json | 13 + common/bitcraft-bindings/upstream | 1 + common/crafts/entitlement.test.ts | 102 + common/crafts/entitlement.ts | 66 + common/crafts/errors.ts | 109 + common/crafts/filter.test.ts | 483 ++ common/crafts/filter.ts | 619 +++ common/crafts/names.test.ts | 40 + common/crafts/names.ts | 41 + common/crafts/package.json | 23 + common/crafts/subject.test.ts | 138 + common/crafts/subject.ts | 140 + common/crafts/tsconfig.json | 17 + common/crafts/watch.test.ts | 127 + common/crafts/watch.ts | 127 + docs/SPACETIME.md | 188 + frontend/package.json | 10 +- frontend/scripts/check-barrel-imports.mjs | 28 +- frontend/scripts/check-nested-item-lists.mjs | 13 +- .../scripts/i18n/list-translatable-fields.mjs | 18 +- frontend/scripts/i18n/verify-translations.mjs | 8 +- frontend/scripts/icons/analyze-icon-usage.mjs | 21 +- frontend/src/app.css | 4 +- frontend/src/app.tsx | 26 +- frontend/src/bindings | 1 - frontend/src/components/Nav.tsx | 103 +- frontend/src/components/app-sidebar.tsx | 55 +- .../src/components/crafts/CurrencySelect.tsx | 19 + .../src/components/crafts/FilterBuilder.tsx | 729 +++ .../crafts/LocalGroupingsManager.tsx | 124 + frontend/src/components/crafts/OutputIcon.tsx | 22 + .../src/components/crafts/PlayerPicker.tsx | 113 + .../src/components/crafts/QuickFilterGrid.tsx | 69 + .../components/crafts/SavedFiltersDialog.tsx | 389 ++ .../src/components/data-table/live-table.tsx | 232 + .../data-table/table-faceted-filter.tsx | 4 +- .../data-table/table-pagination.tsx | 14 +- .../data-table/table-view-options.tsx | 2 +- frontend/src/components/fun/BricoLootBox.tsx | 9 +- .../shared/ConnectionStatusBadge.tsx | 43 + frontend/src/components/shared/GameIcon.tsx | 23 +- frontend/src/components/shared/ItemStacks.tsx | 81 +- .../src/components/shared/PlaceableGraph.tsx | 8 +- frontend/src/components/shared/QuestGraph.tsx | 13 +- .../src/components/shared/RecipeDisplay.tsx | 81 +- .../src/components/shared/RelTablePresets.tsx | 5 +- .../src/components/shared/RouteTabHeader.tsx | 44 + frontend/src/components/ui/checkbox.tsx | 59 + frontend/src/components/ui/dialog.tsx | 6 +- frontend/src/components/ui/popover.tsx | 3 +- frontend/src/components/ui/toast.tsx | 326 ++ frontend/src/entry-client.tsx | 2 +- frontend/src/entry-server.tsx | 2 + frontend/src/lib/account/brico-bot.ts | 16 + frontend/src/lib/account/links.tsx | 160 + frontend/src/lib/account/oidc.ts | 101 + frontend/src/lib/account/route-tabs.tsx | 26 + frontend/src/lib/account/state.tsx | 173 + .../lib/{spacetime.ts => bitcraft-data.ts} | 136 +- frontend/src/lib/bitcraft-utils.ts | 344 +- frontend/src/lib/crafts/bounty-rules.ts | 87 + frontend/src/lib/crafts/bounty.ts | 69 + frontend/src/lib/crafts/currency-ids.ts | 12 + frontend/src/lib/crafts/currency.test.ts | 10 + .../src/lib/crafts/entitlement-reports.ts | 216 + frontend/src/lib/crafts/entries.ts | 262 ++ frontend/src/lib/crafts/error-vocab.ts | 79 + frontend/src/lib/crafts/filter-condition.tsx | 227 + .../src/lib/crafts/filter-sync-merge.test.ts | 124 + frontend/src/lib/crafts/filter-sync-merge.ts | 155 + frontend/src/lib/crafts/filter-sync.tsx | 279 ++ frontend/src/lib/crafts/filter-vocab.ts | 132 + frontend/src/lib/crafts/loyalty-rewards.ts | 62 + frontend/src/lib/crafts/options.ts | 126 + frontend/src/lib/crafts/payout.ts | 100 + frontend/src/lib/crafts/relay.ts | 474 ++ frontend/src/lib/crafts/watches.ts | 74 + frontend/src/lib/data-translation.ts | 8 +- frontend/src/lib/game-links.tsx | 7 +- frontend/src/lib/game-strings.ts | 9 +- frontend/src/lib/global-search.ts | 2 +- frontend/src/lib/notifications/state.tsx | 212 + frontend/src/lib/og-meta.ts | 2 +- frontend/src/lib/placeables.ts | 8 +- frontend/src/lib/progression.ts | 24 +- frontend/src/lib/quests.ts | 20 +- frontend/src/lib/recipe-sources.tsx | 52 +- frontend/src/lib/relations.ts | 78 +- frontend/src/lib/settings.tsx | 204 +- frontend/src/lib/sidebar-items.ts | 1 + frontend/src/lib/spacetime/adapter.ts | 68 + frontend/src/lib/spacetime/brico-app.ts | 120 + frontend/src/lib/spacetime/connection.ts | 380 ++ frontend/src/lib/spacetime/manager.tsx | 346 ++ frontend/src/lib/spacetime/prism.ts | 138 + .../src/lib/table-defs/abilities-table.tsx | 2 +- .../src/lib/table-defs/achievement-table.tsx | 4 +- frontend/src/lib/table-defs/biome-table.tsx | 2 +- frontend/src/lib/table-defs/buffs-table.tsx | 4 +- .../src/lib/table-defs/buildings-table.tsx | 235 +- frontend/src/lib/table-defs/cargo-table.tsx | 4 +- .../lib/table-defs/claim-research-table.tsx | 2 +- .../src/lib/table-defs/collectible-table.tsx | 6 +- frontend/src/lib/table-defs/combat-table.tsx | 4 +- .../src/lib/table-defs/creature-table.tsx | 2 +- .../src/lib/table-defs/deployables-table.tsx | 459 +- .../src/lib/table-defs/equipment-table.tsx | 7 +- frontend/src/lib/table-defs/food-table.tsx | 5 +- .../src/lib/table-defs/item-list-table.tsx | 5 +- frontend/src/lib/table-defs/item-table.tsx | 5 +- .../src/lib/table-defs/knowledge-table.tsx | 4 +- frontend/src/lib/table-defs/paving-table.tsx | 2 +- .../lib/table-defs/pillar-shaping-table.tsx | 2 +- .../src/lib/table-defs/placeables-table.tsx | 4 +- .../src/lib/table-defs/prospecting-table.tsx | 4 +- frontend/src/lib/table-defs/quests-table.tsx | 20 +- .../src/lib/table-defs/resources-table.tsx | 48 +- frontend/src/lib/table-defs/skills-table.tsx | 3 +- .../src/lib/table-defs/terraforming-table.tsx | 3 +- frontend/src/lib/table-defs/tools-table.tsx | 5 +- .../lib/table-defs/traveler-tasks-table.tsx | 187 +- .../lib/table-defs/traveler-trades-table.tsx | 5 +- frontend/src/lib/table-defs/weapon-table.tsx | 5 +- .../src/lib/table-utils/column-builders.tsx | 22 +- .../lib/table-utils/detail-group-builders.tsx | 4 +- .../lib/table-utils/detail-tab-builders.tsx | 44 +- .../lib/table-utils/stats-column-builder.tsx | 3 +- frontend/src/lib/utils.ts | 10 + frontend/src/locales/de/messages.po | 1471 +++++- frontend/src/locales/en/messages.po | 1478 +++++- frontend/src/locales/es/messages.po | 1471 +++++- frontend/src/locales/fr/messages.po | 1471 +++++- frontend/src/locales/ja/messages.po | 1471 +++++- frontend/src/locales/pl/messages.po | 1471 +++++- frontend/src/locales/pt-BR/messages.po | 1471 +++++- frontend/src/locales/ru/messages.po | 1471 +++++- frontend/src/locales/zh-Hans/messages.po | 1471 +++++- frontend/src/locales/zh-Hant/messages.po | 1471 +++++- frontend/src/middleware.ts | 2 +- frontend/src/routes/account/bounties.tsx | 150 + frontend/src/routes/account/bounty-rules.tsx | 574 +++ frontend/src/routes/account/callback.tsx | 61 + frontend/src/routes/account/index.tsx | 94 + frontend/src/routes/account/link.tsx | 99 + frontend/src/routes/account/loyalty.tsx | 242 + frontend/src/routes/account/notifications.tsx | 153 + frontend/src/routes/account/payees.tsx | 335 ++ frontend/src/routes/account/payouts.tsx | 186 + frontend/src/routes/account/profile.tsx | 328 ++ frontend/src/routes/account/settings.tsx | 154 + frontend/src/routes/database/ability/[id].tsx | 2 +- .../src/routes/database/ability/index.tsx | 4 +- .../src/routes/database/achievement/[id].tsx | 4 +- .../src/routes/database/achievement/index.tsx | 4 +- frontend/src/routes/database/biome/[id].tsx | 4 +- frontend/src/routes/database/biome/index.tsx | 4 +- frontend/src/routes/database/buff/[id].tsx | 2 +- frontend/src/routes/database/buff/index.tsx | 4 +- .../src/routes/database/building/[id].tsx | 5 +- .../src/routes/database/building/index.tsx | 4 +- frontend/src/routes/database/cargo/[id].tsx | 4 +- frontend/src/routes/database/cargo/index.tsx | 4 +- .../routes/database/claim-research/[id].tsx | 6 +- .../routes/database/claim-research/index.tsx | 4 +- .../src/routes/database/collectible/[id].tsx | 7 +- .../src/routes/database/collectible/index.tsx | 4 +- frontend/src/routes/database/combat/[id].tsx | 5 +- frontend/src/routes/database/combat/index.tsx | 4 +- .../src/routes/database/creature/[id].tsx | 7 +- .../src/routes/database/creature/index.tsx | 4 +- .../src/routes/database/deployable/[id].tsx | 4 +- .../src/routes/database/deployable/index.tsx | 4 +- .../src/routes/database/equipment/index.tsx | 4 +- frontend/src/routes/database/food/index.tsx | 4 +- .../src/routes/database/item-list/[id].tsx | 2 +- .../src/routes/database/item-list/index.tsx | 4 +- frontend/src/routes/database/item/[id].tsx | 8 +- frontend/src/routes/database/item/index.tsx | 4 +- .../src/routes/database/knowledge/[id].tsx | 2 +- .../src/routes/database/knowledge/index.tsx | 4 +- frontend/src/routes/database/paving/[id].tsx | 5 +- frontend/src/routes/database/paving/index.tsx | 4 +- .../routes/database/pillar-shaping/[id].tsx | 5 +- .../routes/database/pillar-shaping/index.tsx | 4 +- .../src/routes/database/placeable/[id].tsx | 2 +- .../src/routes/database/placeable/index.tsx | 4 +- .../src/routes/database/progression/index.tsx | 2 +- .../src/routes/database/prospecting/[id].tsx | 4 +- .../src/routes/database/prospecting/index.tsx | 4 +- .../src/routes/database/quest-chain/[id].tsx | 5 +- .../src/routes/database/quest-chain/index.tsx | 4 +- .../src/routes/database/resource/[id].tsx | 5 +- .../src/routes/database/resource/index.tsx | 4 +- frontend/src/routes/database/skill/[id].tsx | 22 +- frontend/src/routes/database/skill/index.tsx | 4 +- .../src/routes/database/terraforming/[id].tsx | 2 +- .../routes/database/terraforming/index.tsx | 4 +- frontend/src/routes/database/tool/index.tsx | 4 +- .../routes/database/traveler-task/[id].tsx | 2 +- .../routes/database/traveler-task/index.tsx | 4 +- .../routes/database/traveler-trade/[id].tsx | 2 +- .../routes/database/traveler-trade/index.tsx | 4 +- frontend/src/routes/database/weapon/index.tsx | 4 +- frontend/src/routes/settings.tsx | 32 +- frontend/src/routes/sitemap.xml.ts | 2 +- frontend/src/routes/tools/crafts/[id].tsx | 641 +++ frontend/src/routes/tools/crafts/browse.tsx | 847 ++++ frontend/src/routes/tools/emblem.tsx | 5 +- frontend/src/routes/tools/placeable-graph.tsx | 4 +- frontend/src/routes/tools/quest-graph.tsx | 2 +- frontend/tsconfig.json | 3 + package-lock.json | 4123 ++++++++--------- package.json | 4 +- 363 files changed, 39324 insertions(+), 3483 deletions(-) create mode 100644 .dockerignore create mode 100644 backend/brico-bot/.env.example create mode 100644 backend/brico-bot/.gitignore create mode 100644 backend/brico-bot/Dockerfile create mode 100644 backend/brico-bot/README.md create mode 100644 backend/brico-bot/docker-compose.example.yml create mode 100644 backend/brico-bot/package.json create mode 100644 backend/brico-bot/src/app/bounty-sink.ts create mode 100644 backend/brico-bot/src/app/bounty-source.ts create mode 100644 backend/brico-bot/src/app/connection.ts create mode 100644 backend/brico-bot/src/app/notification-sink.ts create mode 100644 backend/brico-bot/src/app/watch-source.ts create mode 100644 backend/brico-bot/src/auth/discord-oauth.ts create mode 100644 backend/brico-bot/src/auth/link-state-store.ts create mode 100644 backend/brico-bot/src/auth/oidc-client.ts create mode 100644 backend/brico-bot/src/auth/pkce.ts create mode 100644 backend/brico-bot/src/bridge.test.ts create mode 100644 backend/brico-bot/src/bridge.ts create mode 100644 backend/brico-bot/src/config.ts create mode 100644 backend/brico-bot/src/game-data/load.ts create mode 100644 backend/brico-bot/src/game-data/recipes.ts create mode 100644 backend/brico-bot/src/http-server.ts create mode 100644 backend/brico-bot/src/log.ts create mode 100644 backend/brico-bot/src/main.ts create mode 100644 backend/brico-bot/src/metrics.ts create mode 100644 backend/brico-bot/src/relay/prism.ts create mode 100644 backend/brico-bot/src/relay/subject.ts create mode 100644 backend/brico-bot/src/spacetime/coalesce.ts create mode 100644 backend/brico-bot/src/spacetime/connection.ts create mode 100644 backend/brico-bot/src/spacetime/token-store.ts create mode 100644 backend/brico-bot/tsconfig.json create mode 100644 backend/brico-module/.gitignore create mode 100644 backend/brico-module/README.md create mode 100644 backend/brico-module/scripts/import-payouts.mjs create mode 100644 backend/brico-module/scripts/publish-local.mjs create mode 100644 backend/brico-module/spacetime.json create mode 100644 backend/brico-module/spacetime.local.json create mode 100644 backend/brico-module/spacetimedb/package-lock.json create mode 100644 backend/brico-module/spacetimedb/package.json create mode 100644 backend/brico-module/spacetimedb/src/index.ts create mode 100644 backend/brico-module/spacetimedb/src/lib/auth.ts create mode 100644 backend/brico-module/spacetimedb/src/lib/bounty.ts create mode 100644 backend/brico-module/spacetimedb/src/lib/filters.ts create mode 100644 backend/brico-module/spacetimedb/src/lib/sync.ts create mode 100644 backend/brico-module/spacetimedb/src/reducers/accounts.ts create mode 100644 backend/brico-module/spacetimedb/src/reducers/bounties.ts create mode 100644 backend/brico-module/spacetimedb/src/reducers/crafts.ts create mode 100644 backend/brico-module/spacetimedb/src/reducers/integrations.ts create mode 100644 backend/brico-module/spacetimedb/src/reducers/notifications.ts create mode 100644 backend/brico-module/spacetimedb/src/reducers/payouts.ts create mode 100644 backend/brico-module/spacetimedb/src/schema.ts create mode 100644 backend/brico-module/spacetimedb/src/tables/accounts.ts create mode 100644 backend/brico-module/spacetimedb/src/tables/bounties.ts create mode 100644 backend/brico-module/spacetimedb/src/tables/crafts.ts create mode 100644 backend/brico-module/spacetimedb/src/tables/integrations.ts create mode 100644 backend/brico-module/spacetimedb/src/tables/notifications.ts create mode 100644 backend/brico-module/spacetimedb/src/tables/payouts.ts create mode 100644 backend/brico-module/spacetimedb/src/views/accounts.ts create mode 100644 backend/brico-module/spacetimedb/src/views/bounties.ts create mode 100644 backend/brico-module/spacetimedb/src/views/crafts.ts create mode 100644 backend/brico-module/spacetimedb/src/views/integrations.ts create mode 100644 backend/brico-module/spacetimedb/src/views/notifications.ts create mode 100644 backend/brico-module/spacetimedb/src/views/payouts.ts create mode 100644 backend/brico-module/spacetimedb/tsconfig.json create mode 100644 common/README.md create mode 100644 common/bindings/brico-app/README.md create mode 100644 common/bindings/brico-app/all_account_table.ts create mode 100644 common/bindings/brico-app/all_bounty_rule_table.ts create mode 100644 common/bindings/brico-app/all_craft_bounty_assignment_table.ts create mode 100644 common/bindings/brico-app/all_craft_bounty_entitlement_table.ts create mode 100644 common/bindings/brico-app/all_craft_bounty_override_table.ts create mode 100644 common/bindings/brico-app/all_craft_filter_watch_table.ts create mode 100644 common/bindings/brico-app/all_integration_link_request_table.ts create mode 100644 common/bindings/brico-app/all_linked_integration_table.ts create mode 100644 common/bindings/brico-app/all_loyalty_reward_table.ts create mode 100644 common/bindings/brico-app/all_private_craft_bounty_assignment_table.ts create mode 100644 common/bindings/brico-app/all_saved_craft_filter_table.ts create mode 100644 common/bindings/brico-app/assign_craft_bounty_reducer.ts create mode 100644 common/bindings/brico-app/begin_integration_link_for_external_reducer.ts create mode 100644 common/bindings/brico-app/begin_integration_link_reducer.ts create mode 100644 common/bindings/brico-app/claim_integration_link_reducer.ts create mode 100644 common/bindings/brico-app/clear_craft_bounty_reducer.ts create mode 100644 common/bindings/brico-app/create_shared_filters_procedure.ts create mode 100644 common/bindings/brico-app/delete_bounty_rule_reducer.ts create mode 100644 common/bindings/brico-app/delete_craft_bounty_override_reducer.ts create mode 100644 common/bindings/brico-app/delete_craft_filter_watch_reducer.ts create mode 100644 common/bindings/brico-app/delete_loyalty_reward_reducer.ts create mode 100644 common/bindings/brico-app/delete_notification_reducer.ts create mode 100644 common/bindings/brico-app/delete_saved_craft_filter_reducer.ts create mode 100644 common/bindings/brico-app/ensure_account_reducer.ts create mode 100644 common/bindings/brico-app/import_historical_bounty_ledger_reducer.ts create mode 100644 common/bindings/brico-app/import_historical_loyalty_reward_reducer.ts create mode 100644 common/bindings/brico-app/index.ts create mode 100644 common/bindings/brico-app/link_discord_via_spacetime_auth_reducer.ts create mode 100644 common/bindings/brico-app/link_integration_reducer.ts create mode 100644 common/bindings/brico-app/mark_all_notifications_read_reducer.ts create mode 100644 common/bindings/brico-app/mark_notification_read_reducer.ts create mode 100644 common/bindings/brico-app/my_account_table.ts create mode 100644 common/bindings/brico-app/my_bounty_payout_record_as_payer_table.ts create mode 100644 common/bindings/brico-app/my_bounty_rule_table.ts create mode 100644 common/bindings/brico-app/my_craft_bounty_override_table.ts create mode 100644 common/bindings/brico-app/my_craft_filter_watch_table.ts create mode 100644 common/bindings/brico-app/my_entitlements_as_contributor_table.ts create mode 100644 common/bindings/brico-app/my_entitlements_as_payer_table.ts create mode 100644 common/bindings/brico-app/my_integration_link_request_table.ts create mode 100644 common/bindings/brico-app/my_linked_integration_table.ts create mode 100644 common/bindings/brico-app/my_loyalty_reward_table.ts create mode 100644 common/bindings/brico-app/my_notification_table.ts create mode 100644 common/bindings/brico-app/my_private_craft_bounty_assignment_table.ts create mode 100644 common/bindings/brico-app/my_saved_craft_filter_table.ts create mode 100644 common/bindings/brico-app/note_integration_link_external_reducer.ts create mode 100644 common/bindings/brico-app/post_craft_notification_reducer.ts create mode 100644 common/bindings/brico-app/read_shared_filters_procedure.ts create mode 100644 common/bindings/brico-app/record_bounty_payment_reducer.ts create mode 100644 common/bindings/brico-app/register_service_principal_reducer.ts create mode 100644 common/bindings/brico-app/reorder_bounty_rules_reducer.ts create mode 100644 common/bindings/brico-app/revoke_service_principal_reducer.ts create mode 100644 common/bindings/brico-app/set_display_name_reducer.ts create mode 100644 common/bindings/brico-app/types.ts create mode 100644 common/bindings/brico-app/types/procedures.ts create mode 100644 common/bindings/brico-app/types/reducers.ts create mode 100644 common/bindings/brico-app/unlink_integration_reducer.ts create mode 100644 common/bindings/brico-app/upsert_bounty_rule_reducer.ts create mode 100644 common/bindings/brico-app/upsert_craft_bounty_entitlement_reducer.ts create mode 100644 common/bindings/brico-app/upsert_craft_bounty_override_reducer.ts create mode 100644 common/bindings/brico-app/upsert_craft_filter_watch_reducer.ts create mode 100644 common/bindings/brico-app/upsert_loyalty_reward_reducer.ts create mode 100644 common/bindings/brico-app/upsert_saved_craft_filter_reducer.ts create mode 100644 common/bindings/package.json create mode 100644 common/bindings/prism/claim_info_table.ts create mode 100644 common/bindings/prism/claim_member_table.ts create mode 100644 common/bindings/prism/claim_meta_table.ts create mode 100644 common/bindings/prism/claim_supply_table.ts create mode 100644 common/bindings/prism/craft_contribution_table.ts create mode 100644 common/bindings/prism/craft_meta_table.ts create mode 100644 common/bindings/prism/craft_progress_table.ts create mode 100644 common/bindings/prism/enemy_location_table.ts create mode 100644 common/bindings/prism/growth_timers_table.ts create mode 100644 common/bindings/prism/herd_location_table.ts create mode 100644 common/bindings/prism/index.ts create mode 100644 common/bindings/prism/player_location_table.ts create mode 100644 common/bindings/prism/player_state_table.ts create mode 100644 common/bindings/prism/recipe_meta_table.ts create mode 100644 common/bindings/prism/region_table.ts create mode 100644 common/bindings/prism/resource_location_table.ts create mode 100644 common/bindings/prism/types.ts create mode 100644 common/bitcraft-bindings/package.json create mode 160000 common/bitcraft-bindings/upstream create mode 100644 common/crafts/entitlement.test.ts create mode 100644 common/crafts/entitlement.ts create mode 100644 common/crafts/errors.ts create mode 100644 common/crafts/filter.test.ts create mode 100644 common/crafts/filter.ts create mode 100644 common/crafts/names.test.ts create mode 100644 common/crafts/names.ts create mode 100644 common/crafts/package.json create mode 100644 common/crafts/subject.test.ts create mode 100644 common/crafts/subject.ts create mode 100644 common/crafts/tsconfig.json create mode 100644 common/crafts/watch.test.ts create mode 100644 common/crafts/watch.ts create mode 100644 docs/SPACETIME.md delete mode 160000 frontend/src/bindings create mode 100644 frontend/src/components/crafts/CurrencySelect.tsx create mode 100644 frontend/src/components/crafts/FilterBuilder.tsx create mode 100644 frontend/src/components/crafts/LocalGroupingsManager.tsx create mode 100644 frontend/src/components/crafts/OutputIcon.tsx create mode 100644 frontend/src/components/crafts/PlayerPicker.tsx create mode 100644 frontend/src/components/crafts/QuickFilterGrid.tsx create mode 100644 frontend/src/components/crafts/SavedFiltersDialog.tsx create mode 100644 frontend/src/components/data-table/live-table.tsx create mode 100644 frontend/src/components/shared/ConnectionStatusBadge.tsx create mode 100644 frontend/src/components/shared/RouteTabHeader.tsx create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/components/ui/toast.tsx create mode 100644 frontend/src/lib/account/brico-bot.ts create mode 100644 frontend/src/lib/account/links.tsx create mode 100644 frontend/src/lib/account/oidc.ts create mode 100644 frontend/src/lib/account/route-tabs.tsx create mode 100644 frontend/src/lib/account/state.tsx rename frontend/src/lib/{spacetime.ts => bitcraft-data.ts} (80%) create mode 100644 frontend/src/lib/crafts/bounty-rules.ts create mode 100644 frontend/src/lib/crafts/bounty.ts create mode 100644 frontend/src/lib/crafts/currency-ids.ts create mode 100644 frontend/src/lib/crafts/currency.test.ts create mode 100644 frontend/src/lib/crafts/entitlement-reports.ts create mode 100644 frontend/src/lib/crafts/entries.ts create mode 100644 frontend/src/lib/crafts/error-vocab.ts create mode 100644 frontend/src/lib/crafts/filter-condition.tsx create mode 100644 frontend/src/lib/crafts/filter-sync-merge.test.ts create mode 100644 frontend/src/lib/crafts/filter-sync-merge.ts create mode 100644 frontend/src/lib/crafts/filter-sync.tsx create mode 100644 frontend/src/lib/crafts/filter-vocab.ts create mode 100644 frontend/src/lib/crafts/loyalty-rewards.ts create mode 100644 frontend/src/lib/crafts/options.ts create mode 100644 frontend/src/lib/crafts/payout.ts create mode 100644 frontend/src/lib/crafts/relay.ts create mode 100644 frontend/src/lib/crafts/watches.ts create mode 100644 frontend/src/lib/notifications/state.tsx create mode 100644 frontend/src/lib/spacetime/adapter.ts create mode 100644 frontend/src/lib/spacetime/brico-app.ts create mode 100644 frontend/src/lib/spacetime/connection.ts create mode 100644 frontend/src/lib/spacetime/manager.tsx create mode 100644 frontend/src/lib/spacetime/prism.ts create mode 100644 frontend/src/routes/account/bounties.tsx create mode 100644 frontend/src/routes/account/bounty-rules.tsx create mode 100644 frontend/src/routes/account/callback.tsx create mode 100644 frontend/src/routes/account/index.tsx create mode 100644 frontend/src/routes/account/link.tsx create mode 100644 frontend/src/routes/account/loyalty.tsx create mode 100644 frontend/src/routes/account/notifications.tsx create mode 100644 frontend/src/routes/account/payees.tsx create mode 100644 frontend/src/routes/account/payouts.tsx create mode 100644 frontend/src/routes/account/profile.tsx create mode 100644 frontend/src/routes/account/settings.tsx create mode 100644 frontend/src/routes/tools/crafts/[id].tsx create mode 100644 frontend/src/routes/tools/crafts/browse.tsx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..cc087a48 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +# Keeps the build context small for `docker build -f backend/brico-bot/Dockerfile .` — the +# Dockerfile only COPYs package.json/package-lock.json, common/{bindings,crafts,bitcraft-bindings} +# and backend/brico-bot, but everything else in the repo still gets sent to the Docker daemon as +# build context unless excluded here. +.git +.github +.idea +.claude +node_modules +**/node_modules +docs + +# Not needed by brico-bot's image (see its Dockerfile's header comment) and by far the largest +# thing in the repo otherwise. +frontend + +backend/brico-bot/.state +backend/brico-bot/.env diff --git a/.github/workflows/docker-build-brico-bot.yml b/.github/workflows/docker-build-brico-bot.yml index b0e81ab9..947419e8 100644 --- a/.github/workflows/docker-build-brico-bot.yml +++ b/.github/workflows/docker-build-brico-bot.yml @@ -1,9 +1,51 @@ name: Build brico-bot docker image on: workflow_dispatch: + push: + branches: + - main + paths: + - "backend/brico-bot/**" + - "common/bindings/**" + - "common/crafts/**" + - "common/bitcraft-bindings/**" + - "package.json" + - "package-lock.json" jobs: build-brico-bot: runs-on: ubuntu-latest + env: + REGISTRY: ghcr.io + IMAGE_NAME: bitcrafttoolbox/brico-bot + permissions: + contents: read + packages: write + attestations: write + id-token: write steps: - - run: + - uses: actions/checkout@v6 + with: + # common/bitcraft-bindings/upstream is a submodule the image build actually needs + # (@brico/bitcraft-bindings exports its raw .ts); frontend/public/bsatn comes along too + # but is small and unused by this build. + submodules: true + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.REGISTRY }} + username: BitCraftToolBox + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push image + id: push + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + with: + context: . + file: backend/brico-bot/Dockerfile + push: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/i18n-translate-sync.yml b/.github/workflows/i18n-translate-sync.yml index c76041c1..fe54acee 100644 --- a/.github/workflows/i18n-translate-sync.yml +++ b/.github/workflows/i18n-translate-sync.yml @@ -61,7 +61,7 @@ jobs: git diff --cached --quiet || git commit -m "chore: refresh i18n catalog (translate-sync backstop)" - name: Upload sources to Crowdin - uses: crowdin/github-action@v3.0.0-next.2 + uses: crowdin/github-action@v3.2.0 with: upload_sources: true upload_translations: false @@ -92,7 +92,7 @@ jobs: run: npm ci - name: Download translations from Crowdin - uses: crowdin/github-action@v3.0.0-next.2 + uses: crowdin/github-action@v3.2.0 with: upload_sources: false upload_translations: false @@ -114,7 +114,7 @@ jobs: # after the download above: anything still empty locally was just confirmed empty on # Crowdin, so this can't clobber a translator's more recent edit. - name: Upload backfilled translations to Crowdin - uses: crowdin/github-action@v3.0.0-next.2 + uses: crowdin/github-action@v3.2.0 with: upload_sources: false upload_translations: true diff --git a/.gitignore b/.gitignore index f30badca..87f267a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,15 @@ -# Dependencies. npm workspaces hoist to the repo root. +# Dependencies. npm workspaces hoist to the repo root; backend/brico-module keeps its own +# nested install because it is deliberately outside the workspace (see common/README.md). node_modules/ + +# IDEs and editors +/.idea + +# System files +.DS_Store +Thumbs.db + +# used by main branch until craft manager lands +frontend/src/bindings +# used only for local ssr testing - CF has this set on the project settings +frontend/wrangler.toml \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index ba7f5f9c..d27dfd43 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,6 +2,6 @@ path = frontend/public/bsatn url = https://github.com/BitCraftToolBox/BitCraft_BSATN_Data [submodule "frontend/src/bindings"] - path = frontend/src/bindings + path = common/bitcraft-bindings/upstream url = https://github.com/BitCraftToolBox/BitCraft_Bindings - branch = ts-region + branch = ts-region-2 diff --git a/backend/brico-bot/.env.example b/backend/brico-bot/.env.example new file mode 100644 index 00000000..a1be9def --- /dev/null +++ b/backend/brico-bot/.env.example @@ -0,0 +1,56 @@ +# brico-bot configuration. Copy to `.env` (gitignored) and edit; real environment variables win +# over anything set here. + +# ── prism's relay-module (the BitCraft read-mirror) ────────────────────────── +#BRICO_RELAY_HOST=http://127.0.0.1:3000 +#BRICO_RELAY_MODULE=prism-relay + +# ── brico-app (brico's own module) ─────────────────────────────────────────── +#BRICO_APP_ENABLED=1 +#BRICO_APP_HOST=http://127.0.0.1:3000 +#BRICO_APP_MODULE=brico-app + +# ── bridge behaviour ──────────────────────────────────────────────────────── +#BRICO_BOT_SNAPSHOT_INTERVAL_MS=1000 +#BRICO_BOT_RECONNECT_DELAY_MS=5000 +#BRICO_BOT_RECONNECT_MAX_DELAY_MS=60000 +#BRICO_BOT_HEARTBEAT_MS=60000 +#BRICO_BOT_LOG_LEVEL=info + +# ── paths (relative paths resolve against backend/brico-bot/) ─────────────── +#BRICO_BOT_STATE_DIR=.state + +# ── BitAuth/Discord link callback HTTP server ──────────────────────────────── +# Bind address for the small HTTP server serving /auth/bitauth/*, /auth/discord/* and /metrics (see +# src/http-server.ts, src/metrics.ts). Code defaults are container-friendly (0.0.0.0:8080, and +# BRICO_BOT_HTTP_BASE_URL — what gets embedded in redirect_uri defaults below — pointed at the real +# public bot.brico.app). Running bare on a workstation instead wants all three overridden below. +#BRICO_BOT_HTTP_HOST=127.0.0.1 +#BRICO_BOT_HTTP_PORT=8787 +#BRICO_BOT_HTTP_BASE_URL=http://127.0.0.1:8787 +# Default/fallback for where the browser lands after a link attempt, with ?linked= or +# ?linkError= appended. +#BRICO_BOT_LINK_RETURN_URL=http://localhost:3001/account/profile +# Extra origins (comma-separated, e.g. https://preview.brico.app,https://brico.app) that a login +# request's own &returnUrl= param is allowed to send the browser back to — this one process is +# shared by multiple frontend deploys, each of which wants to land back on itself +# rather than on BRICO_BOT_LINK_RETURN_URL's configured origin. BRICO_BOT_LINK_RETURN_URL's own +# origin is always implicitly allowed; anything not listed here falls back to it. +#BRICO_BOT_LINK_RETURN_URL_ALLOWED_ORIGINS= + +# ── BitAuth (BitCraft account linking) ─────────────────────────────────────── +# Both unset (the default) disables BitAuth linking entirely — /auth/bitauth/* returns 503 rather +# than the process failing to start. Register brico-bot's callback URL +# (BITAUTH_REDIRECT_URI, or BRICO_BOT_HTTP_BASE_URL + /auth/bitauth/callback) with BitAuth first. +#BITAUTH_ISSUER=https://auth.trinit.is +#BITAUTH_CLIENT_ID= +#BITAUTH_CLIENT_SECRET= +#BITAUTH_REDIRECT_URI= +#BITAUTH_SCOPE=openid profile + +# ── Discord OAuth (account linking for a non-Discord brico login) ─────────── +# Both unset (the default) disables this route — the SpacetimeAuth-login shortcut and the +# bot-initiated /link flow are unaffected. Register the callback URL as a Discord OAuth2 redirect. +#DISCORD_OAUTH_CLIENT_ID= +#DISCORD_OAUTH_CLIENT_SECRET= +#DISCORD_OAUTH_REDIRECT_URI= diff --git a/backend/brico-bot/.gitignore b/backend/brico-bot/.gitignore new file mode 100644 index 00000000..fa709bfa --- /dev/null +++ b/backend/brico-bot/.gitignore @@ -0,0 +1,4 @@ +# Connection tokens — credentials, one file per (uri, database). See src/spacetime/token-store.ts. +.state/ +# Local configuration +.env diff --git a/backend/brico-bot/Dockerfile b/backend/brico-bot/Dockerfile new file mode 100644 index 00000000..8130cee4 --- /dev/null +++ b/backend/brico-bot/Dockerfile @@ -0,0 +1,27 @@ +# Build context is the REPO ROOT, not this directory — npm workspaces means the lockfile and the +# common/ packages this depends on live outside backend/brico-bot/. Build from the repo root with: +# docker build -f backend/brico-bot/Dockerfile -t brico-bot . +FROM node:24-alpine + +LABEL org.opencontainers.image.source="https://github.com/BitCraftToolBox/brico" + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY common/bindings common/bindings +COPY common/crafts common/crafts +COPY common/bitcraft-bindings common/bitcraft-bindings +COPY backend/brico-bot backend/brico-bot + +# `frontend` is a declared workspace too, but npm workspaces silently skips a workspace glob entry +# with no package.json at that path +RUN npm ci --omit=dev && npm cache clean --force + +WORKDIR /app/backend/brico-bot +RUN mkdir -p .state && chown -R node:node .state +USER node + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -qO- "http://127.0.0.1:${BRICO_BOT_HTTP_PORT:-8080}/healthz" || exit 1 + +ENTRYPOINT ["npx", "tsx", "src/main.ts"] diff --git a/backend/brico-bot/README.md b/backend/brico-bot/README.md new file mode 100644 index 00000000..7742274a --- /dev/null +++ b/backend/brico-bot/README.md @@ -0,0 +1,154 @@ +# `brico-bot` — the bridge worker + +A **long-running Node process**, not a Cloudflare Worker and not a request handler. It holds two +live SpacetimeDB connections at once and runs brico's shared filter engine across both: + +``` +prism relay-module ──▶┐ + craft_meta │ + craft_progress ├──▶ brico-bot ──▶ bounty engine ──▶ craft_bounty_assignment/_entitlement + craft_contribution │ (one process, two DbConnections, + claim_info/_member │ + static CraftingRecipeDesc read once from offline BSATN) + player_state,region │ + ├──▶ @brico/crafts/filter ──▶ watch matches ──▶ notification rows +brico-app ────────────┘ (logged too, always) + all_account, all_linked_integration, all_saved_craft_filter, all_craft_filter_watch, + all_bounty_rule, all_craft_bounty_override/_assignment/_entitlement, all_loyalty_reward +``` + +Individual SpacetimeDB modules cannot subscribe to each other, so joining prism's world data to +brico's per-user watches (and, separately, resolving bounties against prism's craft/claim ownership) +has to happen in a **client** holding both connections. That client is this service. It also runs a +small persistent HTTP server alongside the two connections — BitAuth's OIDC relying party and +Discord's OAuth-handshake linking route, both of which finalize into `brico-app`'s +`noteIntegrationLinkExternal` reducer (see "BitAuth/Discord account linking" below), plus `/healthz` +and a Prometheus `/metrics` endpoint (see "Metrics" below). The Discord bot itself (slash commands, +guild installation), Stelo's relying-party flow, and a report HTTP API are not implemented yet. + +## Running it + +From the repo root (npm workspaces — install from the root, never from here): + +```sh +npm install +npm start --workspace backend/brico-bot # or: npm run dev --workspace backend/brico-bot +npm test --workspace backend/brico-bot +npm run typecheck --workspace backend/brico-bot +``` + +Copy `.env.example` to `.env` (gitignored) to change configuration. + +### Runtime: `tsx`, not bare `node` + +The `common/` workspace packages export raw `.ts` with no build step, and +`@brico/bitcraft-bindings` emits `export namespace`, which Node's strip-only TS loader rejects with +`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. So the runtime is `tsx` — for `npm start`, `npm run dev` and +the tests (`node --import tsx --test`) alike. `tsc` is only ever run with `--noEmit`; there is no +build output and no `dist/`. A real `tsc` build stays a viable swap later (`moduleResolution` would +have to move off `bundler`), but nothing needs it today. + +## Configuration + +Every variable is optional. Real environment variables beat `.env`. Note the names are **not** the +frontend's `VITE_*` ones: this process is not built by Vite, does not read `frontend/.env.local`, +and reusing those names would imply otherwise. + +## BitAuth/Discord account linking + +`src/http-server.ts` serves four linking routes, all driven by `brico-app`'s shared +`integration_link_request` mechanism (plus `/healthz` and `/metrics`, see "Metrics" below): + +- `/auth/bitauth/login?linkCode=` and `/auth/bitauth/callback` — BitAuth's Authorization Code + + PKCE (S256) flow. `src/auth/oidc-client.ts` does OIDC discovery against `BITAUTH_ISSUER` + (`/.well-known/openid-configuration`) rather than hardcoding BitAuth's `/connect/*` paths, and + validates the returned ID token against the issuer's live JWKS with + [`jose`](https://github.com/panva/jose). **The `iss` claim BitAuth's discovery document declares + is `https://auth.trinit.is/` (trailing slash) — the JWT verification issuer is always read from + that discovery document, never from the configured `BITAUTH_ISSUER` string, so this can't silently + mismatch.** +- `/auth/discord/login?linkCode=` and `/auth/discord/callback` — Discord's OAuth-handshake + variant (`src/auth/discord-oauth.ts`), plain OAuth2 against `/users/@me` (no ID token, no PKCE — + Discord's authorization-code flow doesn't require it for a confidential client). This is + independent of `linkDiscordViaSpacetimeAuth` (the module-side shortcut for a Discord-login brico + account) and of the bot-initiated `/link` slash command (not implemented yet — needs a real + discord.js bot process). + +Both callbacks correlate the OAuth `state` param back to the link `code` a browser already created +via `beginIntegrationLink` using `src/auth/link-state-store.ts`, an in-memory, single-process TTL +map — separate from `integration_link_request` because neither BitAuth nor Discord know brico's link +codes, only the OAuth `state` this process minted for them. Either callback finalizes by calling +`noteIntegrationLinkExternal({code, externalId, externalHandle})` on the live `brico-app` connection +this process already holds, then redirects the browser back with `?linked=` or +`?linkError=` using a provided `returnUrl`. + +## Metrics + +`GET /metrics` on the same HTTP server (`src/http-server.ts`) serves a Prometheus exposition +(`src/metrics.ts`, [`prom-client`](https://github.com/siimon/prom-client)) alongside the +process's default Node metrics (`collectDefaultMetrics`). It covers the things that actually vary +with load — snapshot build time, watch/bounty evaluation time, reducer call latency by reducer +name, and watch match counts — deliberately **not** row counts (SpacetimeDB's own metrics already +cover table sizes) and deliberately **not** anything labeled by watch id: watch ids come from +user-created saved filters, an unbounded set. + +## `brico-app` visibility — the one non-obvious setup step + +Every `brico-app` table is private; clients read through **views**. `my_*` views (`my_account`, +`my_linked_integration`, `my_saved_craft_filter`, `my_craft_filter_watch`) are filtered to +`ctx.sender`; the `all_*` views are the unfiltered feed built for this service, and they return +**no rows** unless the connection's identity is registered in `service_principal`. An empty +`all_account` is therefore an *authorization* result, not an error — +the process logs the exact command to fix it, including its own identity: + +```sh +spacetime call --server local brico-app register_service_principal '""' '"brico-bot"' +``` + +The identity is stable across restarts because the connection token is persisted under +`BRICO_BOT_STATE_DIR` (one file per uri+database, mode 0600 — these are credentials; delete a file +to get a fresh identity). Running as the database owner also works and needs no registration. + +## Layout + +| Path | What it is | +| --- | --- | +| `src/main.ts` | Entrypoint: wires both connections, the bridge, sinks and the HTTP server; signal handling. | +| `src/config.ts` | Every env knob, in one place. | +| `src/log.ts` | Tiny leveled/scoped logger. | +| `src/metrics.ts` | Prometheus counters/histograms, served by `http-server.ts`'s `/metrics` route. | +| `src/bridge.ts` | The bridge: relay snapshots × watches → `evaluateFilter` → added/finished/removed events, with bounty assignment run first each tick so watches see live `payout`/`currency`. | +| `src/bridge.test.ts` | Match-transition tests against synthetic snapshots. | +| `src/spacetime/connection.ts` | Module-agnostic supervised connection: token store, backoff, generation-guarded callbacks. | +| `src/spacetime/token-store.ts` | Filesystem token persistence (the Node stand-in for `localStorage`). | +| `src/spacetime/coalesce.ts` | "Rebuild at most once per interval, and only if something changed." | +| `src/relay/prism.ts` | prism connection + coalesced `CraftSnapshot`, including `craft_contribution`. | +| `src/relay/subject.ts` | Relay rows + static recipe data → `CraftSubject` for the filter engine. | +| `src/game-data/load.ts` | Reads one offline BSATN static-data table straight off disk. | +| `src/game-data/recipes.ts` | `CraftingRecipeDesc` → `RecipeIndex` (effort/skill/level/output item), loaded once at startup. | +| `src/app/connection.ts` | `brico-app` connection and its table registry. | +| `src/app/watch-source.ts` | Where watches come from: `brico-app` accounts, a JSON file, or built-in defaults. | +| `src/app/notification-sink.ts` | Turns a fired watch into a `notification` row on `brico-app` via `postCraftNotification`. | +| `src/app/bounty-source.ts` | Loads bounty rules/overrides/assignments/entitlements/loyalty rewards off `brico-app`'s `all_*` views. | +| `src/app/bounty-sink.ts` | Resolves each open craft's bounty and per-contributor entitlement, writing `craft_bounty_assignment`/`craft_bounty_entitlement`. | +| `src/http-server.ts` | The BitAuth/Discord link callback HTTP server. | +| `src/auth/oidc-client.ts` | Generic OIDC Authorization Code + PKCE relying-party client, used for BitAuth. | +| `src/auth/discord-oauth.ts` | Discord's plain-OAuth2 account-linking handshake. | +| `src/auth/pkce.ts` | PKCE (S256) and OAuth `state` generation. | +| `src/auth/link-state-store.ts` | In-memory TTL map correlating an OAuth `state` to a pending `brico-app` link code. | + +Shared code is imported **by package name**, never by relative path or a tsconfig alias: +`@brico/crafts/filter`, `@brico/bindings/prism`, `@brico/bindings/brico-app`. The filter engine is +**not** vendored here — byte-for-byte agreement with the frontend's matching behaviour is the entire +reason it is a workspace package. The declared `spacetimedb` range is kept identical to +`common/bindings/package.json`'s so npm hoists one copy: two copies of a SpacetimeDB SDK in one +process would mean two module registries and two sets of `DbConnection`/row-class identities. + +## Warning + +- **The offline BSATN assets have to actually be checked out.** `src/game-data/recipes.ts` reads + `crafting_recipe_desc.bsatn` straight off disk (default: `frontend/public/bsatn/static/`, see + `BRICO_BOT_GAME_DATA_DIR`), so a checkout of this repo without the frontend's static assets — or a + standalone deploy of just this package — needs that variable pointed at a real copy. Missing or + unreadable is a startup-time fatal error, deliberately: every craft's effort/skill/tier/item is + derived from this table, so starting the bridge without it would silently misclassify every craft + as effort-0 and item-less rather than failing loudly. diff --git a/backend/brico-bot/docker-compose.example.yml b/backend/brico-bot/docker-compose.example.yml new file mode 100644 index 00000000..752edd67 --- /dev/null +++ b/backend/brico-bot/docker-compose.example.yml @@ -0,0 +1,28 @@ +# Example service block for brico-bot — not meant to be run standalone with `docker compose up` +# here. It's written to be copy-pasted into an existing compose stack (see prism's +# configs/docker-compose.prism.yml for the sibling services this is meant to sit next to: an nginx +# reverse proxy terminating bot.brico.app and forwarding to this container, and a Prometheus +# instance scraping its /metrics). +services: + brico-bot: + container_name: brico-bot + image: ghcr.io/bitcrafttoolbox/brico-bot:latest + restart: unless-stopped + env_file: config/brico-bot/env + environment: + # Code defaults already assume a container (0.0.0.0, and BRICO_BOT_HTTP_BASE_URL pointed at + # the real https://bot.brico.app) — nothing to override here for the HTTP bind itself. + # + # NOT baked into the image (see the Dockerfile) — point this at the mounted checkout below. + BRICO_BOT_GAME_DATA_DIR: /data/bsatn/static + volumes: + # A checkout of https://github.com/BitCraftToolBox/BitCraft_BSATN_Data (frontend/public/bsatn + # in this repo) — only the `static/` subfolder is actually read. + - ./data/bsatn/static:/data/bsatn/static:ro + # Connection tokens (this process's stable SpacetimeDB identity — see the README's + # "brico-app visibility" section) persist here across restarts; the default path is relative + # to the package, so a volume at that exact path works with no env override. + - brico-bot-state:/app/backend/brico-bot/.state + +volumes: + brico-bot-state: \ No newline at end of file diff --git a/backend/brico-bot/package.json b/backend/brico-bot/package.json new file mode 100644 index 00000000..afe096ea --- /dev/null +++ b/backend/brico-bot/package.json @@ -0,0 +1,30 @@ +{ + "name": "@brico/bot", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Persistent bridge worker: holds live SpacetimeDB connections to prism's relay-module and brico-app, runs the shared filter engine across both.", + "main": "src/main.ts", + "scripts": { + "start": "tsx src/main.ts", + "dev": "tsx watch src/main.ts", + "test": "node --import tsx --test \"src/**/*.test.ts\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@brico/bindings": "*", + "@brico/bitcraft-bindings": "*", + "@brico/crafts": "*", + "jose": "^6.2.0", + "prom-client": "^15.1.3", + "spacetimedb": "^2.9.0", + "tsx": "^4.20.6" + }, + "devDependencies": { + "@types/node": "^26.1.2", + "typescript": "^7.0.2" + }, + "engines": { + "node": ">=24" + } +} diff --git a/backend/brico-bot/src/app/bounty-sink.ts b/backend/brico-bot/src/app/bounty-sink.ts new file mode 100644 index 00000000..4901ffc1 --- /dev/null +++ b/backend/brico-bot/src/app/bounty-sink.ts @@ -0,0 +1,265 @@ +/** + * bounty-sink.ts — resolves and writes `craft_bounty_assignment`/`craft_bounty_entitlement`. + * The bot is the sole writer of both tables: only it can see prism's craft/claim ownership *and* + * `brico-app`'s rules/overrides at the same time. + * + * `resolveCraftBounty` mirrors `notification-sink.ts`'s fire-and-forget reducer-call style for the + * actual writes, but every write here is a full upsert/delete rather than an insert-only append: + * the bot recomputes a craft's bounty from scratch on every relevant snapshot, so there is no + * separate "what changed" tracking to get wrong — an unchanged resolution is simply skipped. + */ +import type {BountyRuleValue, CraftBountyOverride} from "@brico/bindings/brico-app/types"; +import {computeEntitlement, reduceRatio} from "@brico/crafts/entitlement"; +import type {CraftSubject} from "@brico/crafts/filter"; +import {evaluateFilter} from "@brico/crafts/filter"; +import type {CraftBountyFacts} from "@brico/crafts/subject"; +import {Identity, Timestamp} from "spacetimedb"; + +import type {Logger} from "../log.ts"; +import {timeReducerCall} from "../metrics.ts"; +import type {CraftSnapshot} from "../relay/prism.ts"; +import type {CraftRow} from "../relay/subject.ts"; +import {type BountyRuleSpec, loadAssignments, loadBountyRules, loadEntitlements, loadLoyaltyRewards, loadOverrides, resolvePlayerAccounts} from "./bounty-source.ts"; +import type {BricoAppConnection} from "./connection.ts"; + +interface ResolvedBounty { + ratioNumerator: bigint; + ratioDenominator: bigint; + currency: string; + assignedByAccountIdentity: Identity; + /** Whether the matched rule/override was marked private — decides which of the two assignment tables `assign()` writes to. */ + private: boolean; +} + +/** + * First priority-ordered rule whose filter matches `subject`. A grid rule's missing/empty cell for + * this craft's `(skill, tier)` means "no bounty from this row," not "any bounty" — falls through + * to the next rule rather than stopping here. + */ +function matchRules(subject: CraftSubject, specs: readonly BountyRuleSpec[], accountIdentity: Identity): ResolvedBounty | null { + for (const spec of specs) { + if (!evaluateFilter(spec.filter, subject)) continue; + const value: BountyRuleValue = spec.value; + + if (value.tag === "Flat") { + return { + ratioNumerator: value.value.ratioNumerator, + ratioDenominator: value.value.ratioDenominator, + currency: value.value.currency, + assignedByAccountIdentity: accountIdentity, + private: spec.private, + }; + } + + if (subject.skill === null || subject.tier === null) continue; + const cell = value.value.cells.find(c => c.skillId === subject.skill && c.tier === subject.tier); + if (!cell) continue; + return { + ratioNumerator: cell.ratioNumerator, + ratioDenominator: cell.ratioDenominator, + currency: value.value.currency, + assignedByAccountIdentity: accountIdentity, + private: spec.private, + }; + } + return null; +} + +/** + * Resolves one craft's bounty: override first (only if its account actually owns the craft or its + * claim — an override belonging to neither is invalid intent and is skipped, falling through to + * rule evaluation exactly as if it didn't exist), then the personal owner's rules, then the claim + * owner's — **the personal owner wins outright** if they have any match at all. + */ +export function resolveCraftBounty( + craftId: bigint, + subject: CraftSubject, + ownerEntityId: bigint, + claimEntityId: bigint, + playerAccounts: ReadonlyMap, + claimOwners: ReadonlyMap, + rulesByAccount: ReadonlyMap, + overridesByCraft: ReadonlyMap, +): ResolvedBounty | null { + const ownerAccount = ownerEntityId !== 0n ? playerAccounts.get(ownerEntityId) : undefined; + const claimOwnerAccounts = (claimEntityId !== 0n ? claimOwners.get(claimEntityId) ?? [] : []) + .map(playerId => playerAccounts.get(playerId)) + .filter((identity): identity is Identity => identity !== undefined); + + const override = overridesByCraft.get(craftId); + if (override) { + const ownsIt = (ownerAccount?.isEqual(override.accountIdentity) ?? false) + || claimOwnerAccounts.some(identity => identity.isEqual(override.accountIdentity)); + if (ownsIt) { + return { + ratioNumerator: override.ratioNumerator, + ratioDenominator: override.ratioDenominator, + currency: override.currency, + assignedByAccountIdentity: override.accountIdentity, + private: override.private, + }; + } + } + + if (ownerAccount) { + const resolved = matchRules(subject, rulesByAccount.get(ownerAccount.toHexString()) ?? [], ownerAccount); + if (resolved) return resolved; + } + + for (const claimOwnerAccount of claimOwnerAccounts) { + const resolved = matchRules(subject, rulesByAccount.get(claimOwnerAccount.toHexString()) ?? [], claimOwnerAccount); + if (resolved) return resolved; + } + + return null; +} + +export interface BountyEngine { + /** + * Resolves every row's bounty against current `brico-app` state, writes any that changed, and + * returns the fresh `craftId -> bounty` map — feed this into a second `craftRowsFrom` pass so + * `CraftSubject.payout` is live before watches are evaluated in the same tick. + */ + assign(snapshot: CraftSnapshot, rows: readonly CraftRow[]): ReadonlyMap; + /** Recomputes and writes any changed per-contributor entitlements for the given assignments. */ + updateEntitlements(snapshot: CraftSnapshot, assignments: ReadonlyMap): void; +} + +export function createBountyEngine(app: BricoAppConnection, log: Logger): BountyEngine { + const scoped = log.child("bounty"); + // `craftId -> assignedByAccountIdentity` for whatever `assign()` most recently resolved — kept + // out of `CraftBountyFacts` itself (a shared, frontend-visible type) since only this engine's + // own `updateEntitlements` needs the payer identity, to key its loyalty-reward lookup. Rebuilt + // wholesale on every `assign()` call, which always runs immediately before `updateEntitlements` + // in the same tick (see `bridge.ts`'s `onSnapshot`), so it's never stale when read. + let assignedByCraft = new Map(); + + return { + assign(snapshot, rows) { + const conn = app.connection?.connection; + const resolved = new Map(); + if (!conn?.isActive) return resolved; + + const playerAccounts = resolvePlayerAccounts(app); + const rulesByAccount = loadBountyRules(app, scoped); + const overridesByCraft = loadOverrides(app); + const existingAssignments = loadAssignments(app); + const nextAssignedByCraft = new Map(); + + for (const row of rows) { + const craftId = BigInt(row.id); + const ownerEntityId = row.subject.owner ? BigInt(row.subject.owner) : 0n; + const claimEntityId = row.subject.claim ? BigInt(row.subject.claim) : 0n; + const bounty = resolveCraftBounty( + craftId, row.subject, ownerEntityId, claimEntityId, + playerAccounts, snapshot.claimOwners, rulesByAccount, overridesByCraft, + ); + + if (bounty) { + resolved.set(craftId, bounty); + nextAssignedByCraft.set(craftId, bounty.assignedByAccountIdentity); + } + + const previous = existingAssignments.get(craftId); + const unchanged = previous !== undefined && bounty !== null + && previous.ratioNumerator === bounty.ratioNumerator + && previous.ratioDenominator === bounty.ratioDenominator + && previous.currency === bounty.currency + && previous.assignedByAccountIdentity.isEqual(bounty.assignedByAccountIdentity) + && previous.private === bounty.private; + if (unchanged) continue; + if (bounty === null && previous === undefined) continue; + + if (bounty) { + timeReducerCall("assign_craft_bounty", conn.reducers.assignCraftBounty({ + craftId, + ratioNumerator: bounty.ratioNumerator, + ratioDenominator: bounty.ratioDenominator, + currency: bounty.currency, + assignedByAccountIdentity: bounty.assignedByAccountIdentity, + private: bounty.private, + assignedAt: Timestamp.now(), + updatedAt: Timestamp.now(), + })).catch(cause => { + scoped.error("assign_craft_bounty failed", {craftId: row.id, error: cause instanceof Error ? cause.message : String(cause)}); + }); + } else { + timeReducerCall("clear_craft_bounty", conn.reducers.clearCraftBounty({craftId})).catch(cause => { + scoped.error("clear_craft_bounty failed", {craftId: row.id, error: cause instanceof Error ? cause.message : String(cause)}); + }); + } + } + + // `rows` only covers Active crafts, so a craft that has aged all the way out of + // `craft_meta` (past the 24-hour Claimed/Removed tail — see `isOpen` in prism.ts) never + // shows up above and its assignment would otherwise linger forever. Sweep those here. + for (const craftId of existingAssignments.keys()) { + if (snapshot.allCraftIds.has(craftId)) continue; + timeReducerCall("clear_craft_bounty", conn.reducers.clearCraftBounty({craftId})).catch(cause => { + scoped.error("clear_craft_bounty failed", {craftId: craftId.toString(), error: cause instanceof Error ? cause.message : String(cause)}); + }); + } + + assignedByCraft = nextAssignedByCraft; + return resolved; + }, + + updateEntitlements(snapshot, assignments) { + const conn = app.connection?.connection; + if (!conn?.isActive) return; + + const ownerByCraft = new Map(); + for (const craft of snapshot.crafts) { + if (craft.ownerEntityId !== 0n) ownerByCraft.set(craft.entityId, craft.ownerEntityId); + } + + const existing = loadEntitlements(app); + const loyaltyRewards = loadLoyaltyRewards(app); + for (const [craftId, bounty] of assignments) { + const byPlayer = snapshot.contributions.get(craftId); + if (!byPlayer) continue; + const ownerEntityId = ownerByCraft.get(craftId); + + for (const [playerId, effort] of byPlayer) { + // A craft's own owner never earns an entitlement for it — whoever pays the + // bounty is by definition someone else, so paying the owner out of their own + // bounty is meaningless. + if (playerId === ownerEntityId) continue; + + // Loyalty rewards fold into the bounty ratio *before* the floor, keeping this a single + // floor operation, matching `computeEntitlement`'s own no-carryover guarantee. This makes + // `craft_bounty_entitlement`/`bounty_entitlement_total` the loyalty-adjusted + // ("actual") ledger; the frontend's estimated-payout preview never sees a + // multiplier and keeps using the raw assigned ratio. + const assignedByAccountIdentity = assignedByCraft.get(craftId); + const loyalty = assignedByAccountIdentity + ? loyaltyRewards.get(`${assignedByAccountIdentity.toHexString()}:${playerId}:${bounty.currency}`) + : undefined; + const effectiveRatio = loyalty + ? reduceRatio(bounty.ratioNumerator * loyalty.ratioNumerator, bounty.ratioDenominator * loyalty.ratioDenominator) + : {numerator: bounty.ratioNumerator, denominator: bounty.ratioDenominator}; + + const entitledTotal = computeEntitlement(effort, effectiveRatio.numerator, effectiveRatio.denominator); + const id = `${craftId}:${playerId}:${bounty.currency}`; + const previous = existing.get(id); + if (previous && previous.lastAssignedEffort === effort && previous.entitledTotal === entitledTotal) continue; + + timeReducerCall("upsert_craft_bounty_entitlement", conn.reducers.upsertCraftBountyEntitlement({ + craftId, + playerId, + currency: bounty.currency, + lastAssignedEffort: effort, + entitledTotal, + updatedAt: Timestamp.now(), + })).catch(cause => { + scoped.error("upsert_craft_bounty_entitlement failed", { + craftId: craftId.toString(), + playerId: playerId.toString(), + error: cause instanceof Error ? cause.message : String(cause), + }); + }); + } + } + }, + }; +} diff --git a/backend/brico-bot/src/app/bounty-source.ts b/backend/brico-bot/src/app/bounty-source.ts new file mode 100644 index 00000000..7bfe94fa --- /dev/null +++ b/backend/brico-bot/src/app/bounty-source.ts @@ -0,0 +1,156 @@ +/** + * bounty-source.ts — where `bounty-sink.ts` gets the rules, overrides and ownership facts it + * resolves a craft's bounty from. + * + * Same shape as `watch-source.ts`'s `createAccountWatchSource`: a plain in-memory join over + * `brico-app`'s `all_*` views, read off the second connection and re-run every snapshot. Untrusted + * `filterJson` goes through `@brico/crafts/filter`'s `parseFilter`/`validateFilter` the same way. + * Additionally, a rule whose filter references `payout`/`currency` is skipped here (not just + * excluded from the frontend's field picker), since a bounty rule that could condition on the very + * bounty it assigns would be circular. + */ +import type {BountyRule, BountyRuleValue, CraftBountyEntitlement, CraftBountyOverride, LoyaltyReward} from "@brico/bindings/brico-app/types"; +import {type FilterNode, parseFilter, validateFilter} from "@brico/crafts/filter"; +import type {Identity} from "spacetimedb"; + +import type {Logger} from "../log.ts"; +import type {BricoAppConnection} from "./connection.ts"; + +/** A `bounty_rule` row with its `filterJson` already parsed and validated. */ +export interface BountyRuleSpec { + id: string; + priority: number; + filter: FilterNode; + value: BountyRuleValue; + private: boolean; +} + +/** `bitcraft-ea2` player entity id -> the brico account it's linked to, revoked links excluded. */ +export function resolvePlayerAccounts(app: BricoAppConnection): Map { + const byPlayer = new Map(); + const conn = app.connection?.connection; + if (!conn?.isActive || !app.isLive) return byPlayer; + + for (const link of conn.db.allLinkedIntegration.iter()) { + if (link.provider !== "bitcraft-ea2" || link.revokedAt !== undefined) continue; + byPlayer.set(BigInt(link.externalId), link.accountIdentity); + } + return byPlayer; +} + +/** + * Every account's usable bounty rules, keyed by account identity hex and sorted by priority + * (ascending — `reorderBountyRules` rewrites priorities to `0..N-1`, so 0 is evaluated first). + * Tombstoned, unparseable, or `payout`/`currency`-referencing rules are skipped with a `log.warn`, + * never thrown — a malformed or invalid rule must not take the bridge down. + */ +export function loadBountyRules(app: BricoAppConnection, log: Logger): Map { + const byAccount = new Map(); + const conn = app.connection?.connection; + if (!conn?.isActive || !app.isLive) return byAccount; + + for (const rule of conn.db.allBountyRule.iter() as Iterable) { + if (rule.deletedAt !== undefined) continue; + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(rule.filterJson); + } catch { + log.warn("skipping bounty rule: filterJson is not valid JSON", {ruleId: rule.id}); + continue; + } + + const problems = validateFilter(parsedJson, "filter", ["payout", "currency"]); + if (problems.length > 0) { + log.warn("skipping bounty rule: invalid or disallowed filter", {ruleId: rule.id, problems: problems.join("; ")}); + continue; + } + const filter = parseFilter(parsedJson); + if (!filter) { + log.warn("skipping bounty rule: filter did not parse", {ruleId: rule.id}); + continue; + } + + const key = rule.accountIdentity.toHexString(); + const specs = byAccount.get(key); + const spec: BountyRuleSpec = {id: rule.id, priority: rule.priority, filter, value: rule.value, private: rule.private}; + if (specs) specs.push(spec); + else byAccount.set(key, [spec]); + } + + for (const specs of byAccount.values()) specs.sort((a, b) => a.priority - b.priority); + return byAccount; +} + +/** `craftId -> craft_bounty_override` row, for the crafts that currently have one. */ +export function loadOverrides(app: BricoAppConnection): Map { + const byCraft = new Map(); + const conn = app.connection?.connection; + if (!conn?.isActive || !app.isLive) return byCraft; + for (const override of conn.db.allCraftBountyOverride.iter()) byCraft.set(override.craftId, override); + return byCraft; +} + +/** + * A previously-resolved assignment, from either table — `private` is tagged on here rather than + * read off the row itself, since neither `craft_bounty_assignment` nor + * `craft_private_bounty_assignment` stores a `private` column; which table a craft's row lives in + * *is* its privacy. + */ +export interface ExistingBountyAssignment { + ratioNumerator: bigint; + ratioDenominator: bigint; + currency: string; + assignedByAccountIdentity: Identity; + private: boolean; +} + +/** + * `craftId -> assignment` row, so the bot can diff against what's already resolved — merges + * `craft_bounty_assignment` and `craft_private_bounty_assignment`, since the two are mutually + * exclusive per craft (see `assignCraftBounty`'s "only one of the two tables" invariant). + */ +export function loadAssignments(app: BricoAppConnection): Map { + const byCraft = new Map(); + const conn = app.connection?.connection; + if (!conn?.isActive || !app.isLive) return byCraft; + for (const assignment of conn.db.allCraftBountyAssignment.iter()) { + byCraft.set(assignment.craftId, {...assignment, private: false}); + } + for (const assignment of conn.db.allPrivateCraftBountyAssignment.iter()) { + byCraft.set(assignment.craftId, {...assignment, private: true}); + } + return byCraft; +} + +/** + * `craftId:playerId:currency` -> `craft_bounty_entitlement` row. `id` is a plain autoInc now (the + * table's natural key is the composite lookup in `by_craft_player_currency`, not `id` itself), so + * the map is keyed by that composite string, built the same way `bounty-sink.ts`'s + * `updateEntitlements` builds it for its own lookups. + */ +export function loadEntitlements(app: BricoAppConnection): Map { + const byId = new Map(); + const conn = app.connection?.connection; + if (!conn?.isActive || !app.isLive) return byId; + for (const entitlement of conn.db.allCraftBountyEntitlement.iter()) { + byId.set(`${entitlement.craftId}:${entitlement.playerId}:${entitlement.currency}`, entitlement); + } + return byId; +} + +/** + * `${payerAccountIdentity}:${payeePlayerId}:${currency}` -> the payer's `loyalty_reward` ratio for + * that payee/currency. Consumed by `bounty-sink.ts`'s `updateEntitlements`, which composes this + * with a craft's assigned bounty ratio *before* calling `computeEntitlement`, so it behaves like a + * straight percentage bonus with no fractional carryover. + */ +export function loadLoyaltyRewards(app: BricoAppConnection): Map { + const byId = new Map(); + const conn = app.connection?.connection; + if (!conn?.isActive || !app.isLive) return byId; + for (const reward of conn.db.allLoyaltyReward.iter() as Iterable) { + byId.set(`${reward.payerAccountIdentity.toHexString()}:${reward.payeePlayerId}:${reward.currency}`, reward); + } + return byId; +} diff --git a/backend/brico-bot/src/app/connection.ts b/backend/brico-bot/src/app/connection.ts new file mode 100644 index 00000000..1ff4cd8c --- /dev/null +++ b/backend/brico-bot/src/app/connection.ts @@ -0,0 +1,217 @@ +/** + * connection.ts — the bridge's *second* SpacetimeDB connection, to brico's own `brico-app` module. + */ +import {DbConnection, type SubscriptionHandle, tables} from "@brico/bindings/brico-app"; +import type {RowTypedQuery} from "spacetimedb"; + +import type {SpacetimeTarget} from "../config.ts"; +import type {Logger} from "../log.ts"; +import {createSupervisedConnection, type SupervisedConnection, type SupervisorOptions} from "../spacetime/connection.ts"; + +interface AppTable { + /** Generated query builder for the subscription. */ + query: RowTypedQuery; + /** Row-change callbacks, so the bridge re-reads when the module's state moves. */ + listen(conn: DbConnection, onChange: () => void): void; + /** Row count, for the "the second socket really carries rows" log line. */ + count(conn: DbConnection): number; +} + +/** + * Tables/views the bridge knows how to subscribe to on `brico-app`, keyed by SQL name. + * + * `all_saved_craft_filter`/`all_craft_filter_watch` are the tables the bridge reads its filters + * from; the real join lives in `watch-source.ts`'s `createAccountWatchSource`. + */ +const APP_TABLES: Record = { + all_account: { + query: tables.allAccount, + listen: (conn, onChange) => { + conn.db.allAccount.onInsert(onChange); + conn.db.allAccount.onDelete(onChange); + conn.db.allAccount.onUpdate(onChange); + }, + count: conn => [...conn.db.allAccount.iter()].length, + }, + all_linked_integration: { + query: tables.allLinkedIntegration, + listen: (conn, onChange) => { + conn.db.allLinkedIntegration.onInsert(onChange); + conn.db.allLinkedIntegration.onDelete(onChange); + conn.db.allLinkedIntegration.onUpdate(onChange); + }, + count: conn => [...conn.db.allLinkedIntegration.iter()].length, + }, + all_saved_craft_filter: { + query: tables.allSavedCraftFilter, + listen: (conn, onChange) => { + conn.db.allSavedCraftFilter.onInsert(onChange); + conn.db.allSavedCraftFilter.onDelete(onChange); + conn.db.allSavedCraftFilter.onUpdate(onChange); + }, + count: conn => [...conn.db.allSavedCraftFilter.iter()].length, + }, + all_craft_filter_watch: { + query: tables.allCraftFilterWatch, + listen: (conn, onChange) => { + conn.db.allCraftFilterWatch.onInsert(onChange); + conn.db.allCraftFilterWatch.onDelete(onChange); + conn.db.allCraftFilterWatch.onUpdate(onChange); + }, + count: conn => [...conn.db.allCraftFilterWatch.iter()].length, + }, + all_bounty_rule: { + query: tables.allBountyRule, + listen: (conn, onChange) => { + conn.db.allBountyRule.onInsert(onChange); + conn.db.allBountyRule.onDelete(onChange); + conn.db.allBountyRule.onUpdate(onChange); + }, + count: conn => [...conn.db.allBountyRule.iter()].length, + }, + all_craft_bounty_override: { + query: tables.allCraftBountyOverride, + listen: (conn, onChange) => { + conn.db.allCraftBountyOverride.onInsert(onChange); + conn.db.allCraftBountyOverride.onDelete(onChange); + conn.db.allCraftBountyOverride.onUpdate(onChange); + }, + count: conn => [...conn.db.allCraftBountyOverride.iter()].length, + }, + all_craft_bounty_assignment: { + query: tables.allCraftBountyAssignment, + listen: (conn, onChange) => { + conn.db.allCraftBountyAssignment.onInsert(onChange); + conn.db.allCraftBountyAssignment.onDelete(onChange); + conn.db.allCraftBountyAssignment.onUpdate(onChange); + }, + count: conn => [...conn.db.allCraftBountyAssignment.iter()].length, + }, + all_private_craft_bounty_assignment: { + query: tables.allPrivateCraftBountyAssignment, + listen: (conn, onChange) => { + conn.db.allPrivateCraftBountyAssignment.onInsert(onChange); + conn.db.allPrivateCraftBountyAssignment.onDelete(onChange); + conn.db.allPrivateCraftBountyAssignment.onUpdate(onChange); + }, + count: conn => [...conn.db.allPrivateCraftBountyAssignment.iter()].length, + }, + all_craft_bounty_entitlement: { + query: tables.allCraftBountyEntitlement, + listen: (conn, onChange) => { + conn.db.allCraftBountyEntitlement.onInsert(onChange); + conn.db.allCraftBountyEntitlement.onDelete(onChange); + conn.db.allCraftBountyEntitlement.onUpdate(onChange); + }, + count: conn => [...conn.db.allCraftBountyEntitlement.iter()].length, + }, + all_loyalty_reward: { + query: tables.allLoyaltyReward, + listen: (conn, onChange) => { + conn.db.allLoyaltyReward.onInsert(onChange); + conn.db.allLoyaltyReward.onDelete(onChange); + conn.db.allLoyaltyReward.onUpdate(onChange); + }, + count: conn => [...conn.db.allLoyaltyReward.iter()].length, + }, +}; + +export interface BricoAppConnection { + readonly connection: SupervisedConnection | null; + /** Why the connection is not running, or `null` when it is. */ + readonly disabledReason: string | null; + readonly isLive: boolean; + /** Hex identity this connection authenticated as — what `register_service_principal` needs. */ + readonly identityHex: string | null; + stop(): void; +} + +export interface BricoAppOptions extends SupervisorOptions { + /** `null` when `BRICO_APP_ENABLED` is off. */ + target: SpacetimeTarget | null; + /** Called whenever the module's rows change, so the watch source re-reads on the next snapshot. */ + onRowsChanged(): void; +} + +export function startBricoAppConnection(options: BricoAppOptions): BricoAppConnection { + const log: Logger = options.log.child("app"); + const {target} = options; + + if (!target) { + return { + connection: null, + disabledReason: "BRICO_APP_ENABLED is not set — set it (and BRICO_APP_HOST) to bring up the brico-app half of the bridge", + isLive: false, + identityHex: null, + stop: () => {}, + }; + } + + let identityHex: string | null = null; + + const connection = createSupervisedConnection( + { + label: target.label, + uri: target.uri, + database: target.database, + dial: handlers => + DbConnection.builder() + .withUri(target.uri) + .withDatabaseName(target.database) + .withToken(handlers.token) + .onConnect((conn, identity, token) => { + identityHex = identity.toHexString(); + handlers.onConnect(conn, identity, token); + }) + .onDisconnect((_ctx, error) => handlers.onDisconnect(error)) + .onConnectError((_ctx, error) => handlers.onConnectError(error)) + .build(), + onConnected: (conn, ctx) => { + const tables = Object.keys(APP_TABLES); + for (const name of tables) APP_TABLES[name].listen(conn, options.onRowsChanged); + + let subscription: SubscriptionHandle | null = conn + .subscriptionBuilder() + .onApplied(() => { + if (!ctx.isCurrent()) return; + ctx.setLive(); + options.onRowsChanged(); + const counts = tables.map(name => `${name}=${APP_TABLES[name].count(conn)}`).join(" "); + log.info("initial subscription applied", {identity: identityHex ?? "?", rows: counts}); + if (tables.every(name => APP_TABLES[name].count(conn) === 0)) { + // Almost always the authorization case rather than an empty database: + // the `all_*` views return nothing to an unregistered identity. + log.warn( + "every subscribed view is empty — if this identity is not in service_principal that is expected; " + + `register it with: spacetime call ${target.database} register_service_principal '"${identityHex ?? ""}"' '"brico-bot"'`, + ); + } + }) + .onError(errorCtx => { + ctx.setError(`subscription error: ${errorCtx.event ? String(errorCtx.event) : "unknown"}`); + }) + .subscribe(Object.values(APP_TABLES).map(t => t.query)); + + return () => { + subscription?.unsubscribe(); + subscription = null; + }; + }, + }, + {tokens: options.tokens, log: options.log, delayMs: options.delayMs, maxDelayMs: options.maxDelayMs}, + ); + + connection.start(); + + return { + connection, + disabledReason: null, + get isLive() { + return connection.isLive; + }, + get identityHex() { + return identityHex; + }, + stop: () => connection.stop(), + }; +} diff --git a/backend/brico-bot/src/app/notification-sink.ts b/backend/brico-bot/src/app/notification-sink.ts new file mode 100644 index 00000000..a5d81bd4 --- /dev/null +++ b/backend/brico-bot/src/app/notification-sink.ts @@ -0,0 +1,48 @@ +/** + * notification-sink.ts — turns a fired watch into a `notification` row on `brico-app`. + */ +import {Identity} from "spacetimedb"; +import type {MatchSink} from "../bridge.ts"; +import type {Logger} from "../log.ts"; +import {timeReducerCall} from "../metrics.ts"; +import type {BricoAppConnection} from "./connection.ts"; + +export function createNotificationSink(app: BricoAppConnection, log: Logger): MatchSink { + const scoped = log.child("notify"); + return event => { + // File/default watches have no account — nothing to notify. + if (event.watch.owner === null || event.watch.filterId === null) return; + // The user didn't ask to be notified about this transition kind for this watch — the + // bridge still matched and logged it (useful for debugging what the filter matches), but + // it must not become a `notification` row. Same gate the frontend's own local watch + // runner (`~/lib/crafts/watches.ts`) applies before calling `onNotify`. + if (!event.watch.triggers[event.kind]) return; + + const conn = app.connection?.connection; + if (!conn?.isActive) { + scoped.warn("dropping notification: brico-app not live", {watch: event.watch.id, kind: event.kind}); + return; + } + + const craft = event.craft; + timeReducerCall("post_craft_notification", conn.reducers.postCraftNotification({ + accountIdentity: Identity.fromString(event.watch.owner), + payload: { + watchId: event.watch.filterId, + filterName: event.watch.name, + eventKind: event.kind, + craftId: event.craftId, + recipeId: craft?.recipeId ?? -1, + regionName: craft?.regionName ?? "Unknown region", + claimName: craft?.claimName ?? undefined, + ownerName: craft?.ownerName ?? undefined, + }, + })).catch(cause => { + scoped.error("post_craft_notification failed", { + watch: event.watch.id, + kind: event.kind, + error: cause instanceof Error ? cause.message : String(cause), + }); + }); + }; +} diff --git a/backend/brico-bot/src/app/watch-source.ts b/backend/brico-bot/src/app/watch-source.ts new file mode 100644 index 00000000..4a24a303 --- /dev/null +++ b/backend/brico-bot/src/app/watch-source.ts @@ -0,0 +1,95 @@ +/** + * watch-source.ts — where the bridge gets the filters it evaluates. + * + * `brico-app`'s `saved_craft_filter` × `craft_filter_watch` join, read off the second connection, + * is the real source. + */ +import type {SavedCraftFilter} from "@brico/bindings/brico-app/types"; +import {type FilterNode, parseFilter, validateFilter} from "@brico/crafts/filter"; + +import type {Logger} from "../log.ts"; +import type {BricoAppConnection} from "./connection.ts"; + +export interface WatchSpec { + /** Stable id — the key the bridge's per-watch match set is stored under. */ + id: string; + name: string; + owner: string | null; + filterId: string | null; + triggers: {added: boolean; finished: boolean; removed: boolean}; + filter: FilterNode; +} + +export interface WatchSource { + /** Human description of where these came from, for the startup log. */ + readonly origin: string; + /** Current watch set. Cheap — the bridge calls this every snapshot. */ + watches(): readonly WatchSpec[]; +} + +/** + * Watches derived from `brico-app`'s live `all_saved_craft_filter` × `all_craft_filter_watch` join. + * + * For each non-tombstoned `all_craft_filter_watch` row, the matching `all_saved_craft_filter` row + * supplies the actual `FilterNode`. This is untrusted JSON at this boundary, so it goes through + * `parseFilter` and is skipped with a `log.warn` on failure, never thrown. A watch row with no matching + * (or mismatched-owner, or tombstoned) filter row is skipped the same way. + * + * This is a genuine cross-database join: accounts' filters/watches come off the `brico-app` + * connection, crafts off prism's, and the same `@brico/crafts/filter` engine decides what each + * account would be notified about. + * + * Returns `[]` when the connection is down, not yet live, or authorized to see nothing (the `all_*` + * views are empty for an identity that is not a registered `service_principal`). + */ +export function createAccountWatchSource(app: BricoAppConnection, log: Logger): WatchSource { + let lastCount = -1; + return { + origin: "brico-app all_saved_craft_filter × all_craft_filter_watch", + watches() { + const conn = app.connection?.connection; + if (!conn?.isActive || !app.isLive) return []; + + const filtersById = new Map(); + for (const savedFilter of conn.db.allSavedCraftFilter.iter()) { + if (savedFilter.deletedAt !== undefined) continue; + filtersById.set(savedFilter.id, savedFilter); + } + + const specs: WatchSpec[] = []; + for (const watch of conn.db.allCraftFilterWatch.iter()) { + if (watch.deletedAt !== undefined) continue; + + const savedFilter = filtersById.get(watch.filterId); + if (!savedFilter || !savedFilter.accountIdentity.isEqual(watch.accountIdentity)) { + log.warn("skipping watch: no matching saved filter", {filterId: watch.filterId}); + continue; + } + + const filter = parseFilter(JSON.parse(savedFilter.filterJson)); + if (!filter) { + log.warn("skipping watch: invalid filterJson", { + filterId: watch.filterId, + problems: validateFilter(JSON.parse(savedFilter.filterJson)).join("; "), + }); + continue; + } + + specs.push({ + id: `watch:${watch.filterId}`, + name: savedFilter.name, + owner: watch.accountIdentity.toHexString(), + filterId: watch.filterId, + triggers: {added: watch.added, finished: watch.finished, removed: watch.removed}, + filter, + }); + } + + if (specs.length !== lastCount) { + lastCount = specs.length; + log.info("watches derived from brico-app saved filters", {count: specs.length}); + } + return specs; + }, + }; +} \ No newline at end of file diff --git a/backend/brico-bot/src/auth/discord-oauth.ts b/backend/brico-bot/src/auth/discord-oauth.ts new file mode 100644 index 00000000..1b0cbd1b --- /dev/null +++ b/backend/brico-bot/src/auth/discord-oauth.ts @@ -0,0 +1,72 @@ +/** + * discord-oauth.ts — the OAuth-handshake variant of Discord account linking, for a user who logged + * into brico a way other than Discord. (A user who *did* log in via Discord never needs this route — + * the module's `linkDiscordViaSpacetimeAuth` shortcut covers that case directly.) + * + * Plain OAuth2 (Discord's own docs: https://discord.com/developers/docs/topics/oauth2), not OIDC — + * there is no ID token here, just an access token used once against `/users/@me` to read the + * snowflake and username, structurally identical to what BitAuth's `sub`/`preferred_username` + * provide. No PKCE: this is a confidential client (client_secret held server-side here), same as + * BitAuth's, and Discord's authorization-code flow does not require it for that client type. + */ +const DISCORD_AUTHORIZE_URL = "https://discord.com/api/oauth2/authorize"; +const DISCORD_TOKEN_URL = "https://discord.com/api/oauth2/token"; +const DISCORD_ME_URL = "https://discord.com/api/users/@me"; + +export interface DiscordOAuthConfig { + clientId: string; + clientSecret: string; + redirectUri: string; +} + +export interface DiscordIdentity { + /** The Discord snowflake — stable, this is the `externalId`. */ + id: string; + /** Display name — mutable, this is the `externalHandle`. */ + username: string; +} + +export interface DiscordOAuthClient { + buildAuthorizeUrl(state: string): string; + exchangeCode(code: string): Promise; +} + +export function createDiscordOAuthClient(config: DiscordOAuthConfig): DiscordOAuthClient { + return { + buildAuthorizeUrl(state) { + const url = new URL(DISCORD_AUTHORIZE_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", config.clientId); + url.searchParams.set("redirect_uri", config.redirectUri); + url.searchParams.set("scope", "identify"); + url.searchParams.set("state", state); + return url.toString(); + }, + + async exchangeCode(code) { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: config.redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + }); + const tokenRes = await fetch(DISCORD_TOKEN_URL, { + method: "POST", + headers: {"content-type": "application/x-www-form-urlencoded"}, + body: body.toString(), + }); + if (!tokenRes.ok) { + throw new Error(`Discord token exchange failed: HTTP ${tokenRes.status} ${await tokenRes.text()}`); + } + const tokens = (await tokenRes.json()) as {access_token: string}; + + const meRes = await fetch(DISCORD_ME_URL, { + headers: {authorization: `Bearer ${tokens.access_token}`}, + }); + if (!meRes.ok) throw new Error(`Discord /users/@me failed: HTTP ${meRes.status}`); + const me = (await meRes.json()) as {id: string; username: string}; + return {id: me.id, username: me.username}; + }, + }; +} diff --git a/backend/brico-bot/src/auth/link-state-store.ts b/backend/brico-bot/src/auth/link-state-store.ts new file mode 100644 index 00000000..79b86bd7 --- /dev/null +++ b/backend/brico-bot/src/auth/link-state-store.ts @@ -0,0 +1,57 @@ +/** + * link-state-store.ts — correlates an OAuth `state` param to the brico link it belongs to, across + * the redirect round trip to BitAuth/Discord and back. + * + * Deliberately separate from `brico-app`'s own `integration_link_request` table: that table + * correlates a *link code* (`beginIntegrationLink`'s `code`) to a brico account and an eventual + * external id, and lives in the module so both this process and the browser can see it. This store + * correlates the *OAuth* `state`/PKCE `code_verifier` to that same link code only while a redirect + * is in flight — neither BitAuth nor Discord know brico's link codes, so something on this side has + * to remember which pending link a given `state` belongs to when the callback comes back. Purely + * in-memory: a restart mid-flow just means the user's redirect 404s and they retry, same as an + * abandoned tab already does to `integration_link_request`'s own TTL. + */ + +export interface PendingOAuthLink { + linkCode: string; + /** Only BitAuth's flow needs this (Discord's OAuth handshake has no PKCE step here). */ + codeVerifier?: string; + /** The already-validated return URL the login request asked for; `undefined` means "use the + * configured default" (see `resolveReturnUrl` in `http-server.ts`). */ + returnUrl?: string; + createdAt: number; +} + +export interface LinkStateStore { + put(state: string, entry: Omit): void; + /** One-shot: a `state` is consumed by its callback and cannot be replayed. */ + take(state: string): PendingOAuthLink | undefined; +} + +/** Same TTL as `integration_link_request`'s own expiry (see `LINK_TTL_MICROS` in the module). */ +const STATE_TTL_MS = 15 * 60 * 1000; + +export function createLinkStateStore(): LinkStateStore { + const entries = new Map(); + + function sweep(): void { + const cutoff = Date.now() - STATE_TTL_MS; + for (const [state, entry] of entries) { + if (entry.createdAt < cutoff) entries.delete(state); + } + } + + return { + put(state, entry) { + sweep(); + entries.set(state, {...entry, createdAt: Date.now()}); + }, + take(state) { + const entry = entries.get(state); + entries.delete(state); + if (entry === undefined) return undefined; + if (entry.createdAt < Date.now() - STATE_TTL_MS) return undefined; + return entry; + }, + }; +} diff --git a/backend/brico-bot/src/auth/oidc-client.ts b/backend/brico-bot/src/auth/oidc-client.ts new file mode 100644 index 00000000..f76e4d6b --- /dev/null +++ b/backend/brico-bot/src/auth/oidc-client.ts @@ -0,0 +1,110 @@ +/** + * oidc-client.ts — a minimal Authorization Code + PKCE (S256) relying-party client against any + * spec-compliant OIDC issuer, used for BitAuth (`https://auth.trinit.is`). + * + * Deliberately generic rather than hardcoding BitAuth's `/connect/*` paths: those are that specific + * issuer's routes, but OIDC discovery (`/.well-known/openid-configuration`) is the standard way to + * find them, and BitAuth's own docs confirm it exposes one. Discovery is cached in memory for the + * process lifetime — an issuer's endpoints don't move without a coordinated migration. + */ +import {createRemoteJWKSet, jwtVerify} from "jose"; + +interface OidcDiscovery { + /** The issuer's own canonical self-identification — not necessarily byte-identical to the + * configured base URL (BitAuth's discovery document declares `https://auth.trinit.is/`, with a + * trailing slash, verified against its live `/.well-known/openid-configuration`). This is what + * must be checked against an ID token's `iss` claim, never the configured value — see below. */ + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + jwks_uri: string; +} + +export interface OidcClientConfig { + /** Base URL discovery is fetched from (`/.well-known/openid-configuration`), e.g. + * `https://auth.trinit.is` — need not exactly match the `issuer` an ID token actually carries; + * that value is read from the discovery document itself (see `OidcDiscovery.issuer`), never + * from this config, since the two can differ by a trailing slash. */ + issuer: string; + clientId: string; + clientSecret: string; + redirectUri: string; + /** Space-separated, e.g. `openid profile`. */ + scope: string; +} + +export interface OidcVerifiedIdentity { + sub: string; + /** Full ID-token claim set, so a caller can read a provider-specific display-name claim. */ + claims: Record; +} + +export interface OidcClient { + buildAuthorizeUrl(state: string, codeChallenge: string): Promise; + /** Exchanges an authorization code, validates the ID token's signature/issuer/audience against + * the issuer's live JWKS, and returns its claims. Throws on any failure — a link with an + * unverified identity is worse than no link. */ + exchangeCode(code: string, codeVerifier: string): Promise; +} + +export function createOidcClient(config: OidcClientConfig): OidcClient { + let discovery: Promise | null = null; + let jwks: ReturnType | null = null; + + function discover(): Promise { + if (discovery === null) { + discovery = fetch(`${config.issuer}/.well-known/openid-configuration`).then(async res => { + if (!res.ok) throw new Error(`OIDC discovery failed for ${config.issuer}: HTTP ${res.status}`); + return await res.json() as Promise; + }); + } + return discovery; + } + + return { + async buildAuthorizeUrl(state, codeChallenge) { + const {authorization_endpoint} = await discover(); + const url = new URL(authorization_endpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", config.clientId); + url.searchParams.set("redirect_uri", config.redirectUri); + url.searchParams.set("scope", config.scope); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); + }, + + async exchangeCode(code, codeVerifier) { + const {issuer, token_endpoint, jwks_uri} = await discover(); + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: config.redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + code_verifier: codeVerifier, + }); + const tokenRes = await fetch(token_endpoint, { + method: "POST", + headers: {"content-type": "application/x-www-form-urlencoded"}, + body: body.toString(), + }); + if (!tokenRes.ok) { + throw new Error(`BitAuth token exchange failed: HTTP ${tokenRes.status} ${await tokenRes.text()}`); + } + const tokens = (await tokenRes.json()) as {id_token?: string}; + if (!tokens.id_token) throw new Error("BitAuth token response had no id_token"); + + if (jwks === null) jwks = createRemoteJWKSet(new URL(jwks_uri)); + const {payload} = await jwtVerify(tokens.id_token, jwks, { + issuer, + audience: config.clientId, + }); + if (typeof payload.sub !== "string" || payload.sub === "") { + throw new Error("BitAuth id_token is missing sub"); + } + return {sub: payload.sub, claims: payload as Record}; + }, + }; +} diff --git a/backend/brico-bot/src/auth/pkce.ts b/backend/brico-bot/src/auth/pkce.ts new file mode 100644 index 00000000..154f38f3 --- /dev/null +++ b/backend/brico-bot/src/auth/pkce.ts @@ -0,0 +1,20 @@ +/** + * pkce.ts — Authorization Code + PKCE (S256) primitives, and a bare OAuth `state` generator. + * + * Only BitAuth's flow uses the challenge/verifier pair (a confidential OIDC client still gets PKCE + * per the architecture doc); Discord's OAuth handshake below only needs `generateState`. + */ +import {createHash, randomBytes} from "node:crypto"; + +export function generateState(): string { + return randomBytes(16).toString("hex"); +} + +export function generateCodeVerifier(): string { + // 32 random bytes base64url-encoded is within the 43-128 char range RFC 7636 requires. + return randomBytes(32).toString("base64url"); +} + +export function codeChallengeS256(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} diff --git a/backend/brico-bot/src/bridge.test.ts b/backend/brico-bot/src/bridge.test.ts new file mode 100644 index 00000000..015d5844 --- /dev/null +++ b/backend/brico-bot/src/bridge.test.ts @@ -0,0 +1,136 @@ +/** + * bridge.test.ts — the bridge's own wiring, on top of the shared match engine. + * + * The added/finished/removed transition logic itself is `@brico/crafts/watch`'s job now, and is + * pinned there (`common/crafts/watch.test.ts`) against synthetic subjects with no relay or + * SpacetimeDB types involved. What's left here is specific to this process: that `craftRowsFrom` + * feeds real relay rows into the shared engine correctly, that each watch id gets its own matcher, + * and that a watch dropping out of the `WatchSource` discards its match state so it re-primes rather + * than silently resuming (see the comment at the call site in `bridge.ts`). + * + * Run with `npm test` in this workspace (`node --import tsx --test`) — `tsx` rather than bare + * `node` because the workspace packages export raw `.ts`. + */ +import type {CraftMeta, CraftProgress} from "@brico/bindings/prism/types"; +import {openWorkFilter} from "@brico/crafts/filter"; +import type {CraftBountyFacts} from "@brico/crafts/subject"; +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {Timestamp} from "spacetimedb"; +import type {BountyEngine} from "./app/bounty-sink.ts"; +import type {WatchSource} from "./app/watch-source.ts"; + +import {createBridge, type MatchEvent} from "./bridge.ts"; +import type {RecipeIndex, RecipeStatic} from "./game-data/recipes.ts"; +import {createLogger} from "./log.ts"; +import {type CraftSnapshot, EMPTY_SNAPSHOT} from "./relay/prism.ts"; + +const RECIPE: RecipeStatic = { + effortRequired: 100, + skillId: 3, + buildingType: 127749503, + levelRequired: 25, + itemKey: "item:1150001", + itemTag: "Basic Food", + inputItems: [], +}; +const RECIPE_ID = 7; +const RECIPES: RecipeIndex = new Map([[RECIPE_ID, RECIPE]]); + +function craft(overrides: Partial = {}): CraftMeta { + return { + entityId: 1n, + ownerEntityId: 0n, + claimEntityId: 0n, + buildingEntityId: 0n, + firstSeen: Timestamp.UNIX_EPOCH, + recipeId: RECIPE_ID, + count: 2, + regionId: 14, + public: true, + status: {tag: "Active"}, + ...overrides, + } as CraftMeta; +} + +function snapshot(crafts: CraftMeta[], progressRows: CraftProgress[] = []): CraftSnapshot { + return { + ...EMPTY_SNAPSHOT, + crafts: crafts.filter(row => row.status.tag === "Active"), + allCraftIds: new Set(crafts.map(row => row.entityId)), + progress: new Map(progressRows.map(row => [row.entityId, row])), + builtAtMs: Date.now(), + }; +} + +/** A bridge with a fixed single watch and an event-collecting sink. */ +function harness(filter = openWorkFilter()) { + const events: MatchEvent[] = []; + const watches: WatchSource = { + origin: "test", + watches: () => [{id: "w", name: "test watch", owner: null, filterId: null, triggers: {added: true, finished: true, removed: true}, filter}], + }; + // `error` silences the bridge's own informational logging without stubbing the logger. + const bridge = createBridge({log: createLogger("error"), watches, sink: event => void events.push(event), recipes: RECIPES}); + return {bridge, events}; +} + +test("a real relay snapshot primes without emitting events, and reports the right match count", () => { + const {bridge, events} = harness(); + bridge.onSnapshot(snapshot([craft()])); + assert.equal(events.length, 0); + assert.equal(bridge.stats.matchCounts.get("w"), 1); +}); + +test("a craft appearing after priming is added once, with the bridge's richer CraftRow attached", () => { + const {bridge, events} = harness(); + bridge.onSnapshot(snapshot([craft()])); + bridge.onSnapshot(snapshot([craft(), craft({entityId: 2n})])); + assert.deepEqual(events.map(e => [e.kind, e.craftId]), [["added", "2"]]); + assert.equal(events[0].watch.id, "w"); + assert.equal(events[0].craft?.recipeId, RECIPE_ID); +}); + +test("dropping a watch discards its match state so it re-primes", () => { + const events: MatchEvent[] = []; + let live = true; + const watches: WatchSource = { + origin: "test", + watches: () => (live ? [{id: "w", name: "test watch", owner: null, filterId: null, triggers: {added: true, finished: true, removed: true}, filter: openWorkFilter()}] : []), + }; + const bridge = createBridge({log: createLogger("error"), watches, sink: event => void events.push(event), recipes: RECIPES}); + + bridge.onSnapshot(snapshot([craft()])); + live = false; + bridge.onSnapshot(snapshot([craft()])); + assert.equal(bridge.stats.matchCounts.has("w"), false); + + live = true; + bridge.onSnapshot(snapshot([craft()])); + assert.equal(events.length, 0, "a returning watch primes again rather than re-announcing matches"); +}); + +test("bounties are assigned before watches are evaluated in the same tick", () => { + // A filter on `payout` must see a freshly-assigned bounty in the very tick it's assigned, not + // the tick after — the design doc's "Notification-ordering requirement". + const events: MatchEvent[] = []; + const watches: WatchSource = { + origin: "test", + watches: () => [{ + id: "w", + name: "has a bounty", + owner: null, + filterId: null, + triggers: {added: true, finished: true, removed: true}, + filter: {field: "payout", cmp: "gte", value: 0}, + }], + }; + const bounty: BountyEngine = { + assign: () => new Map([[1n, {ratioNumerator: 1n, ratioDenominator: 20n, currency: "hex-coin", private: false}]]), + updateEntitlements: () => {}, + }; + const bridge = createBridge({log: createLogger("error"), watches, sink: event => void events.push(event), recipes: RECIPES, bounty}); + + bridge.onSnapshot(snapshot([craft()])); + assert.equal(bridge.stats.matchCounts.get("w"), 1, "the bounty-carrying craft matches on the very first snapshot"); +}); diff --git a/backend/brico-bot/src/bridge.ts b/backend/brico-bot/src/bridge.ts new file mode 100644 index 00000000..7ebbd509 --- /dev/null +++ b/backend/brico-bot/src/bridge.ts @@ -0,0 +1,207 @@ +/** + * bridge.ts — the actual bridge: relay rows on one side, watches on the other, one filter engine. + * + * Individual SpacetimeDB modules cannot subscribe to each other, so joining prism's world data to + * brico's per-user watches has to happen in a *client* that holds both connections. This function + * is that join, and it is intentionally the only place in the process that knows both halves exist. + * + * Per snapshot it projects every open craft into a `CraftSubject` (`relay/subject.ts`) and hands + * each watch's rows and filter to `@brico/crafts/watch`'s `createWatchMatcher` — the same + * added/finished/removed transition engine the frontend runs for its own (backend-free) watches, so + * the two previews can never disagree about what fired. What stays here, rather than in that shared + * engine, is everything about *this* process's watches specifically: where they come from + * (`WatchSource`), per-watch bookkeeping across snapshots (one matcher per watch id, pruned when a + * watch disappears from the source), stats, and dispatch to a sink. + */ +import {describeFilter} from "@brico/crafts/filter"; +import {createWatchMatcher, type MatchEvent as EngineMatchEvent, type WatchMatcher} from "@brico/crafts/watch"; +import type {BountyEngine} from "./app/bounty-sink.ts"; +import type {WatchSource, WatchSpec} from "./app/watch-source.ts"; + +import type {RecipeIndex} from "./game-data/recipes.ts"; +import type {Logger} from "./log.ts"; +import {activeWatches, bountyAssignDuration, bountyEntitlementDuration, currentMatches, watchEvaluationDuration, watchMatchesTotal} from "./metrics.ts"; +import type {CraftSnapshot} from "./relay/prism.ts"; +import {type CraftRow, craftRowsFrom} from "./relay/subject.ts"; + +/** What the sink is told about. `finished` fires once, on the transition into completeness. */ +export type TriggerKind = EngineMatchEvent["kind"]; + +export interface MatchEvent { + kind: TriggerKind; + watch: WatchSpec; + /** The craft, or just its id for `removed` (the row may be gone from the snapshot). */ + craft: CraftRow | null; + craftId: string; +} + +/** Where match events go. `main.ts` combines the logging sink with the notification sink; a Discord sink would join the same way. */ +export type MatchSink = (event: MatchEvent) => void; + +export interface BridgeStats { + snapshots: number; + lastSnapshotAtMs: number; + lastOpenCrafts: number; + /** watch id → current match count. */ + matchCounts: Map; + events: Record; +} + +export interface Bridge { + /** Feed a freshly built relay snapshot through every watch. */ + onSnapshot(snapshot: CraftSnapshot): void; + readonly stats: BridgeStats; + /** One-line summary for the heartbeat. */ + describe(): string; +} + +export interface BridgeOptions { + log: Logger; + watches: WatchSource; + sink: MatchSink; + /** Static recipe facts (effort, skill, level, output item), read once from offline BSATN. */ + recipes: RecipeIndex; + /** + * Bounty/payout resolution — optional so a bridge without a live `brico-app` connection (or a + * test harness) can omit it entirely. When present, bounties are assigned *before* watches are + * evaluated in the same tick — see the ordering note in `onSnapshot`. + */ + bounty?: BountyEngine; +} + +export function createBridge(options: BridgeOptions): Bridge { + const log = options.log.child("bridge"); + const matchers = new Map>(); + const stats: BridgeStats = { + snapshots: 0, + lastSnapshotAtMs: 0, + lastOpenCrafts: 0, + matchCounts: new Map(), + events: {added: 0, finished: 0, removed: 0}, + }; + // Watches are logged the first time they are seen so a run's log explains what it is matching, + // without re-printing the same descriptions every snapshot. + const announced = new Set(); + + function emit(event: MatchEvent): void { + stats.events[event.kind] += 1; + watchMatchesTotal.inc({kind: event.kind}); + options.sink(event); + } + + return { + stats, + + onSnapshot(snapshot) { + let rows = craftRowsFrom(snapshot, options.recipes); + + // Bounties must be assigned before watches are evaluated in this same tick — otherwise + // a filter/notification referencing `payout` could see stale (null) data for a craft + // that in fact already has a bounty. `rows` is rebuilt a second time, now with + // `CraftSubject.payout`/`currency` resolved, so the watch-matching loop below sees live + // data. + if (options.bounty) { + const assignTimer = bountyAssignDuration.startTimer(); + const assignments = options.bounty.assign(snapshot, rows); + assignTimer(); + + const entitlementTimer = bountyEntitlementDuration.startTimer(); + options.bounty.updateEntitlements(snapshot, assignments); + entitlementTimer(); + + rows = craftRowsFrom(snapshot, options.recipes, assignments); + } + + const watches = options.watches.watches(); + + stats.snapshots += 1; + stats.lastSnapshotAtMs = snapshot.builtAtMs; + stats.lastOpenCrafts = rows.length; + + const liveWatchIds = new Set(); + + const watchTimer = watchEvaluationDuration.startTimer(); + for (const watch of watches) { + liveWatchIds.add(watch.id); + if (!announced.has(watch.id)) { + announced.add(watch.id); + log.info("watching", {id: watch.id, name: watch.name, filter: describeFilter(watch.filter)}); + } + + let matcher = matchers.get(watch.id); + if (!matcher) { + matcher = createWatchMatcher(); + matchers.set(watch.id, matcher); + } + + const wasPrimed = matcher.primed; + for (const event of matcher.update(watch.filter, rows)) { + emit({...event, watch}); + } + if (!wasPrimed) { + log.info("primed", {id: watch.id, matches: matcher.matchCount, of: rows.length}); + } + + stats.matchCounts.set(watch.id, matcher.matchCount); + } + watchTimer(); + + // A watch that disappeared from the source (deleted saved filter, edited file) must not + // keep its match set around: if it comes back, every current match is legitimately + // "added" again, and holding stale state would suppress exactly those notifications. + for (const watchId of [...matchers.keys()]) { + if (liveWatchIds.has(watchId)) continue; + matchers.delete(watchId); + stats.matchCounts.delete(watchId); + announced.delete(watchId); + log.info("watch removed from source, dropping match state", {id: watchId}); + } + + activeWatches.set(stats.matchCounts.size); + currentMatches.set([...stats.matchCounts.values()].reduce((sum, count) => sum + count, 0)); + }, + + // Deliberately a summary, not a per-watch breakdown — with enough saved filters this line + // would otherwise grow without bound on every heartbeat. Per-watch match counts live in + // `stats.matchCounts` for anything that needs them in-process; `metrics.ts`'s + // `activeWatches`/`currentMatches` gauges are the aggregate, scrapable equivalent. + describe() { + const totalMatches = [...stats.matchCounts.values()].reduce((sum, count) => sum + count, 0); + const ageMs = stats.lastSnapshotAtMs === 0 ? -1 : Date.now() - stats.lastSnapshotAtMs; + return `snapshots=${stats.snapshots} openCrafts=${stats.lastOpenCrafts} watches=${stats.matchCounts.size} totalMatches=${totalMatches} snapshotAgeMs=${ageMs} events[added=${stats.events.added} finished=${stats.events.finished} removed=${stats.events.removed}]`; + }, + }; +} + +/** + * Fans one match event out to every sink, in order. Wrap, don't reshape — same style as + * `createLayeredWatchSource`: the bridge still holds exactly one `MatchSink`, and `main.ts` decides + * what actually receives events (logging today, `brico-app` notifications too, Discord later). + */ +export function combineSinks(...sinks: MatchSink[]): MatchSink { + return event => { + for (const sink of sinks) sink(event); + }; +} + +/** + * Logs the event. Kept separate from `createBridge` so a future sink joins beside this one instead + * of editing the matching logic — and so a test can assert on events without parsing log output. + */ +export function createLoggingSink(log: Logger): MatchSink { + const scoped = log.child("match"); + return event => { + const craft = event.craft; + scoped.info(event.kind, { + watch: event.watch.id, + craft: event.craftId, + recipe: craft?.recipeId, + count: craft?.count, + region: craft?.regionName, + claim: craft?.claimName ?? undefined, + owner: craft?.ownerName ?? undefined, + tier: craft?.subject.tier ?? undefined, + effort: craft ? `${craft.subject.effortTotal - craft.subject.effortRemaining}/${craft.subject.effortTotal}` : undefined, + }); + }; +} diff --git a/backend/brico-bot/src/config.ts b/backend/brico-bot/src/config.ts new file mode 100644 index 00000000..3f0578de --- /dev/null +++ b/backend/brico-bot/src/config.ts @@ -0,0 +1,200 @@ +/** + * config.ts — every environment knob the bridge worker reads, in one place. + * + * Deliberately *not* Vite's `VITE_*` names: this is a plain Node service, those variables are + * inlined at build time by a bundler this process never runs, and reusing them would imply the + * frontend's `.env.local` configures the bot (it doesn't). + */ +import {existsSync} from "node:fs"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; + +import type {LogLevel} from "./log.ts"; + +/** `backend/brico-bot/` — anchors default paths to the package, not to the caller's cwd. */ +export const PACKAGE_ROOT = path.resolve(fileURLToPath(import.meta.url), "../.."); + +/** + * Loads `backend/brico-bot/.env` if present. + * + * `process.loadEnvFile` is built into Node ≥20.12, so there is no dotenv dependency to carry. Real + * environment variables already set win: `loadEnvFile` does not overwrite them. + */ +export function loadEnvFile(): string | null { + const envPath = path.join(PACKAGE_ROOT, ".env"); + if (!existsSync(envPath)) return null; + process.loadEnvFile(envPath); + return envPath; +} + +export interface SpacetimeTarget { + /** Human label used in logs and as the token-store key prefix. */ + label: string; + uri: string; + database: string; +} + +/** Config for BitAuth's OIDC relying-party flow. `null` when unconfigured — that half of the HTTP + * server 503s instead of the process failing to start (see `oidc-client.ts`). */ +export interface BitAuthConfig { + issuer: string; + clientId: string; + clientSecret: string; + redirectUri: string; + scope: string; +} + +/** Config for Discord's OAuth-handshake linking flow. `null` when unconfigured. */ +export interface DiscordOAuthConfig { + clientId: string; + clientSecret: string; + redirectUri: string; +} + +export interface BotConfig { + /** prism's `relay-module` — the read-mirror of BitCraft. Always required. */ + prism: SpacetimeTarget; + /** + * brico's own module. `null` disables the second connection entirely. + */ + bricoApp: SpacetimeTarget | null; + /** How long row changes are coalesced before the snapshot is rebuilt and filters re-run. */ + snapshotIntervalMs: number; + /** First reconnect delay; doubles per consecutive failure up to `reconnectMaxDelayMs`. */ + reconnectDelayMs: number; + reconnectMaxDelayMs: number; + /** Where connection tokens are persisted (the Node stand-in for the browser's localStorage). */ + stateDir: string; + logLevel: LogLevel; + /** Log a one-line connection/match summary on this cadence. 0 disables. */ + heartbeatMs: number; + /** + * Directory holding BitCraft's offline BSATN static-data files (`.bsatn`) + */ + gameDataDir: string; + /** Host/port the BitAuth/Discord link callback HTTP server binds. */ + httpHost: string; + httpPort: number; + /** Default/fallback for where the browser is redirected after a link attempt, + * `?linked=` or `?linkError=` appended. Used when the login request didn't + * supply a `returnUrl` (or supplied one outside `linkReturnUrlAllowedOrigins`). */ + linkReturnUrl: string; + /** Extra origins `/auth//login?...&returnUrl=` is allowed to redirect back to — + * e.g. a preview-branch deploy — beyond `linkReturnUrl`'s own origin (always implicitly + * allowed). Without this, every environment sharing this one bot process would bounce users + * back to whichever origin `linkReturnUrl` happens to be set to. */ + linkReturnUrlAllowedOrigins: string[]; + /** `null` disables the BitAuth half of the HTTP server (its routes 503) */ + bitauth: BitAuthConfig | null; + /** `null` disables Discord's OAuth-handshake linking route */ + discordOAuth: DiscordOAuthConfig | null; +} + +function str(name: string, fallback: string): string { + const raw = process.env[name]; + return raw === undefined || raw.trim() === "" ? fallback : raw.trim(); +} + +function optionalStr(name: string): string | null { + const raw = process.env[name]; + return raw === undefined || raw.trim() === "" ? null : raw.trim(); +} + +function int(name: string, fallback: number): number { + const raw = optionalStr(name); + if (raw === null) return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`${name} must be a non-negative number, got ${JSON.stringify(raw)}`); + } + return Math.floor(parsed); +} + +function bool(name: string, fallback: boolean): boolean { + const raw = optionalStr(name)?.toLowerCase(); + if (raw === undefined || raw === null) return fallback; + if (["1", "true", "yes", "on"].includes(raw)) return true; + if (["0", "false", "no", "off"].includes(raw)) return false; + throw new Error(`${name} must be a boolean-ish value, got ${JSON.stringify(raw)}`); +} + +function logLevel(): LogLevel { + const raw = str("BRICO_BOT_LOG_LEVEL", "info").toLowerCase(); + if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") return raw; + throw new Error(`BRICO_BOT_LOG_LEVEL must be debug|info|warn|error, got ${JSON.stringify(raw)}`); +} + +/** `null` unless every required variable for this provider is set — see `BitAuthConfig`/`DiscordOAuthConfig`. */ +function bitAuthConfig(httpBaseUrl: string): BitAuthConfig | null { + const clientId = optionalStr("BITAUTH_CLIENT_ID"); + const clientSecret = optionalStr("BITAUTH_CLIENT_SECRET"); + if (clientId === null || clientSecret === null) return null; + return { + issuer: str("BITAUTH_ISSUER", "https://auth.trinit.is"), + clientId, + clientSecret, + redirectUri: str("BITAUTH_REDIRECT_URI", `${httpBaseUrl}/auth/bitauth/callback`), + // openid profile is sufficient for the sub/preferred_username this app reads. + scope: str("BITAUTH_SCOPE", "openid profile"), + }; +} + +function discordOAuthConfig(httpBaseUrl: string): DiscordOAuthConfig | null { + const clientId = optionalStr("DISCORD_OAUTH_CLIENT_ID"); + const clientSecret = optionalStr("DISCORD_OAUTH_CLIENT_SECRET"); + if (clientId === null || clientSecret === null) return null; + return { + clientId, + clientSecret, + redirectUri: str("DISCORD_OAUTH_REDIRECT_URI", `${httpBaseUrl}/auth/discord/callback`), + }; +} + +export function loadConfig(): BotConfig { + const stateDir = path.resolve(PACKAGE_ROOT, str("BRICO_BOT_STATE_DIR", ".state")); + // Defaults are container-friendly (bind every interface; the real public URL this deploys + // behind) — running bare on a workstation wants BRICO_BOT_HTTP_HOST/PORT/BASE_URL overridden + // to the loopback values in `.env.example` instead. + const httpHost = str("BRICO_BOT_HTTP_HOST", "0.0.0.0"); + const httpPort = int("BRICO_BOT_HTTP_PORT", 8080); + // The base a provider's dashboard needs registered as a redirect URI. Deliberately not derived + // from httpHost/httpPort — "0.0.0.0" is a bind address, not something BitAuth/Discord (or a + // browser) can ever be redirected to. + const httpBaseUrl = str("BRICO_BOT_HTTP_BASE_URL", "https://bot.brico.app"); + + const prism: SpacetimeTarget = { + label: "prism", + uri: str("BRICO_RELAY_HOST", "https://st.prism.brico.app"), + database: str("BRICO_RELAY_MODULE", "prism-relay"), + }; + + const appEnabled = bool("BRICO_APP_ENABLED", true); + const bricoApp: SpacetimeTarget | null = appEnabled + ? { + label: "brico-app", + uri: str("BRICO_APP_HOST", "https://st.prism.brico.app"), + database: str("BRICO_APP_MODULE", "brico-app"), + } + : null; + + return { + prism, + bricoApp, + snapshotIntervalMs: int("BRICO_BOT_SNAPSHOT_INTERVAL_MS", 1000), + reconnectDelayMs: int("BRICO_BOT_RECONNECT_DELAY_MS", 5000), + reconnectMaxDelayMs: int("BRICO_BOT_RECONNECT_MAX_DELAY_MS", 60_000), + stateDir, + logLevel: logLevel(), + heartbeatMs: int("BRICO_BOT_HEARTBEAT_MS", 60_000), + gameDataDir: path.resolve(PACKAGE_ROOT, str("BRICO_BOT_GAME_DATA_DIR", "../../frontend/public/bsatn/static")), + httpHost, + httpPort, + linkReturnUrl: str("BRICO_BOT_LINK_RETURN_URL", "https://brico.app/account/profile"), + linkReturnUrlAllowedOrigins: (optionalStr("BRICO_BOT_LINK_RETURN_URL_ALLOWED_ORIGINS") ?? "") + .split(",") + .map(origin => origin.trim()) + .filter(origin => origin !== ""), + bitauth: bitAuthConfig(httpBaseUrl), + discordOAuth: discordOAuthConfig(httpBaseUrl), + }; +} diff --git a/backend/brico-bot/src/game-data/load.ts b/backend/brico-bot/src/game-data/load.ts new file mode 100644 index 00000000..01cebdc1 --- /dev/null +++ b/backend/brico-bot/src/game-data/load.ts @@ -0,0 +1,18 @@ +/** + * load.ts — reads a static BitCraft game-data table straight off disk. + */ +import {readFile} from "node:fs/promises"; +import path from "node:path"; +import {AlgebraicType, BinaryReader} from "spacetimedb"; + +/** + * Deserializes one `.bsatn` file (an array of `itemType`) from `dir`. + * + * Mirrors `frontend/src/lib/bitcraft-data.ts`'s `fetchBSATNFrom`, but without the fetch. + */ +export async function loadGameDataTable(dir: string, name: string, itemType: AlgebraicType): Promise { + const filePath = path.join(dir, `${name}.bsatn`); + const buffer = await readFile(filePath); + const reader = new BinaryReader(new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)); + return AlgebraicType.makeDeserializer(AlgebraicType.Array(itemType))(reader) as T[]; +} diff --git a/backend/brico-bot/src/game-data/recipes.ts b/backend/brico-bot/src/game-data/recipes.ts new file mode 100644 index 00000000..476a59c2 --- /dev/null +++ b/backend/brico-bot/src/game-data/recipes.ts @@ -0,0 +1,62 @@ +/** + * recipes.ts — the static half of a craft's recipe, read from `CraftingRecipeDesc`. + */ +import {CargoDesc, CraftingRecipeDesc, ItemDesc, ItemType} from "@brico/bitcraft-bindings/types"; +import type {CraftInputItem} from "@brico/crafts/filter"; +import type {RecipeStatic} from "@brico/crafts/subject"; + +import {loadGameDataTable} from "./load.ts"; + +export type {RecipeStatic}; + +/** `"item:"` or `"cargo:"` for one recipe stack */ +function keyFor(stack: {itemId: number; itemType: ItemType}): string { + return `${stack.itemType.tag === ItemType.Item.tag ? "item" : "cargo"}:${stack.itemId}`; +} + +/** A stack's item category, or `null` when its `ItemDesc`/`CargoDesc` row hasn't resolved. */ +function tagFor( + stack: {itemId: number; itemType: ItemType} | undefined, + items: ReadonlyMap, + cargo: ReadonlyMap, +): string | null { + if (!stack) return null; + const desc = stack.itemType.tag === ItemType.Item.tag ? items.get(stack.itemId) : cargo.get(stack.itemId); + return desc?.tag ?? null; +} + +function toStatic( + recipe: CraftingRecipeDesc, + items: ReadonlyMap, + cargo: ReadonlyMap, +): RecipeStatic { + const requirement = recipe.levelRequirements[0]; + const stack = recipe.craftedItemStacks[0]; + const inputItems: CraftInputItem[] = recipe.consumedItemStacks.map(input => ({ + key: keyFor(input), + tag: tagFor(input, items, cargo), + })); + return { + effortRequired: recipe.actionsRequired, + skillId: requirement?.skillId ?? null, + buildingType: recipe.buildingRequirement?.buildingType ?? null, + levelRequired: requirement?.level ?? 0, + itemKey: stack ? keyFor(stack) : null, + itemTag: tagFor(stack, items, cargo), + inputItems, + }; +} + +/** `craft_meta.recipeId` → the static facts about that recipe. Loaded once; never changes at runtime. */ +export type RecipeIndex = ReadonlyMap; + +export async function loadRecipeIndex(gameDataDir: string): Promise { + const [recipeRows, itemRows, cargoRows] = await Promise.all([ + loadGameDataTable(gameDataDir, "crafting_recipe_desc", CraftingRecipeDesc.algebraicType), + loadGameDataTable(gameDataDir, "item_desc", ItemDesc.algebraicType), + loadGameDataTable(gameDataDir, "cargo_desc", CargoDesc.algebraicType), + ]); + const items = new Map(itemRows.map(item => [item.id, item])); + const cargo = new Map(cargoRows.map(row => [row.id, row])); + return new Map(recipeRows.map(recipe => [recipe.id, toStatic(recipe, items, cargo)])); +} diff --git a/backend/brico-bot/src/http-server.ts b/backend/brico-bot/src/http-server.ts new file mode 100644 index 00000000..60676825 --- /dev/null +++ b/backend/brico-bot/src/http-server.ts @@ -0,0 +1,208 @@ +/** + * http-server.ts — the small persistent HTTP surface `brico-bot` needs to be BitAuth's and + * Discord's OAuth relying party. + * + * Both providers follow the same shape end to end: + * 1. The browser has already called `beginIntegrationLink({code, provider})` on `brico-app`, then navigates here. + * 2. `/auth//login` mints an OAuth `state` (and, for BitAuth, a PKCE pair), remembers + * the brico link `code` against it in `LinkStateStore`, and redirects out. + * 3. `/auth//callback` resolves `state` back to that `code`, exchanges the + * authorization code, and calls `noteIntegrationLinkExternal({code, externalId, + * externalHandle})` on `brico-app` — finalizing the link, since `accountIdentity` was already + * set in step 1. + * 4. Either way, the browser is redirected back to a return URL with `?linked=` or + * `?linkError=` for the frontend to render. Step 1's navigation may carry + * `&returnUrl=` so a different frontend origin (e.g. a preview-branch deploy) gets sent + * back to itself rather than to `returnUrl`'s configured default — see `resolveReturnUrl`. + */ +import {createServer, type IncomingMessage, type ServerResponse} from "node:http"; +import type {DiscordOAuthClient} from "./auth/discord-oauth.ts"; + +import {createLinkStateStore, type LinkStateStore} from "./auth/link-state-store.ts"; +import type {OidcClient} from "./auth/oidc-client.ts"; +import {codeChallengeS256, generateCodeVerifier, generateState} from "./auth/pkce.ts"; +import type {Logger} from "./log.ts"; +import {registry} from "./metrics.ts"; + +export interface LinkHttpServerOptions { + host: string; + port: number; + log: Logger; + /** Default/fallback for where the browser lands after a link attempt, success or failure. */ + returnUrl: string; + /** Extra origins a login request's `returnUrl` param is allowed to target, beyond `returnUrl`'s + * own origin (always implicitly allowed). Anything else falls back to `returnUrl`. */ + returnUrlAllowedOrigins: string[]; + /** `null` when `BITAUTH_CLIENT_ID`/etc. are not configured — that provider 404s instead of crashing startup. */ + bitauth: OidcClient | null; + discordOAuth: DiscordOAuthClient | null; + /** Calls `brico-app`'s `noteIntegrationLinkExternal`. Rejects with the reducer's own `SenderError` + * message (e.g. "unknown or expired link code", the cross-account collision message) — that + * message is safe to surface to the browser as-is. */ + noteExternalLink(args: {code: string; externalId: string; externalHandle: string | undefined}): Promise; +} + +export interface LinkHttpServer { + start(): void; + stop(): void; +} + +function sendText(res: ServerResponse, status: number, body: string): void { + res.writeHead(status, {"content-type": "text/plain; charset=utf-8"}); + res.end(body); +} + +function redirect(res: ServerResponse, location: string): void { + res.writeHead(302, {location}); + res.end(); +} + +function withParam(base: string, key: string, value: string): string { + const url = new URL(base); + url.searchParams.set(key, value); + return url.toString(); +} + +function describeError(cause: unknown): string { + if (cause instanceof Error) return cause.message; + return String(cause); +} + +export function createLinkHttpServer(options: LinkHttpServerOptions): LinkHttpServer { + const stateStore: LinkStateStore = createLinkStateStore(); + const defaultReturnOrigin = new URL(options.returnUrl).origin; + const allowedReturnOrigins = new Set([defaultReturnOrigin, ...options.returnUrlAllowedOrigins]); + + /** + * A login request's `returnUrl` param is untrusted browser input — used unchecked, it's an open + * redirect. Only honor it when its origin is `returnUrl`'s own or in `returnUrlAllowedOrigins`; + * otherwise (missing, unparsable, or not allowlisted) fall back to the configured default. + */ + function resolveReturnUrl(url: URL): string { + const requested = url.searchParams.get("returnUrl"); + if (!requested) return options.returnUrl; + try { + const parsed = new URL(requested); + return allowedReturnOrigins.has(parsed.origin) ? parsed.toString() : options.returnUrl; + } catch { + return options.returnUrl; + } + } + + function failLink(res: ServerResponse, returnUrl: string, message: string): void { + redirect(res, withParam(returnUrl, "linkError", message)); + } + + async function handleBitauthLogin(url: URL, res: ServerResponse): Promise { + if (!options.bitauth) return sendText(res, 503, "BitAuth linking is not configured"); + const linkCode = url.searchParams.get("linkCode"); + if (!linkCode) return sendText(res, 400, "missing linkCode"); + + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + stateStore.put(state, {linkCode, codeVerifier, returnUrl: resolveReturnUrl(url)}); + const authorizeUrl = await options.bitauth.buildAuthorizeUrl(state, codeChallengeS256(codeVerifier)); + redirect(res, authorizeUrl); + } + + async function handleBitauthCallback(url: URL, res: ServerResponse): Promise { + if (!options.bitauth) return sendText(res, 503, "BitAuth linking is not configured"); + const state = url.searchParams.get("state"); + if (!state) return sendText(res, 400, "missing state"); + const pending = stateStore.take(state); + if (!pending || pending.codeVerifier === undefined) return sendText(res, 400, "unknown or expired state"); + const returnUrl = pending.returnUrl ?? options.returnUrl; + + const oauthError = url.searchParams.get("error"); + if (oauthError) return failLink(res, returnUrl, `BitAuth: ${oauthError}`); + const code = url.searchParams.get("code"); + if (!code) return failLink(res, returnUrl, "BitAuth callback missing code"); + + try { + const identity = await options.bitauth.exchangeCode(code, pending.codeVerifier); + const externalHandle = typeof identity.claims.preferred_username === "string" + ? identity.claims.preferred_username + : undefined; + await options.noteExternalLink({code: pending.linkCode, externalId: identity.sub, externalHandle}); + redirect(res, withParam(returnUrl, "linked", "bitcraft-ea2")); + } catch (cause) { + options.log.warn("BitAuth link finalization failed", {error: describeError(cause)}); + failLink(res, returnUrl, describeError(cause)); + } + } + + function handleDiscordLogin(url: URL, res: ServerResponse): void { + if (!options.discordOAuth) return sendText(res, 503, "Discord linking is not configured"); + const linkCode = url.searchParams.get("linkCode"); + if (!linkCode) return sendText(res, 400, "missing linkCode"); + + const state = generateState(); + stateStore.put(state, {linkCode, returnUrl: resolveReturnUrl(url)}); + redirect(res, options.discordOAuth.buildAuthorizeUrl(state)); + } + + async function handleDiscordCallback(url: URL, res: ServerResponse): Promise { + if (!options.discordOAuth) return sendText(res, 503, "Discord linking is not configured"); + const state = url.searchParams.get("state"); + if (!state) return sendText(res, 400, "missing state"); + const pending = stateStore.take(state); + if (!pending) return sendText(res, 400, "unknown or expired state"); + const returnUrl = pending.returnUrl ?? options.returnUrl; + + const oauthError = url.searchParams.get("error"); + if (oauthError) return failLink(res, returnUrl, `Discord: ${oauthError}`); + const code = url.searchParams.get("code"); + if (!code) return failLink(res, returnUrl, "Discord callback missing code"); + + try { + const identity = await options.discordOAuth.exchangeCode(code); + await options.noteExternalLink({ + code: pending.linkCode, + externalId: identity.id, + externalHandle: identity.username, + }); + redirect(res, withParam(returnUrl, "linked", "discord")); + } catch (cause) { + options.log.warn("Discord link finalization failed", {error: describeError(cause)}); + failLink(res, returnUrl, describeError(cause)); + } + } + + async function handleMetrics(res: ServerResponse): Promise { + res.writeHead(200, {"content-type": registry.contentType}); + res.end(await registry.metrics()); + } + + const routes: Record void | Promise> = { + "/healthz": (_url, res) => sendText(res, 200, "ok"), + "/metrics": (_url, res) => handleMetrics(res), + "/auth/bitauth/login": (url, res) => handleBitauthLogin(url, res), + "/auth/bitauth/callback": (url, res) => handleBitauthCallback(url, res), + "/auth/discord/login": (url, res) => handleDiscordLogin(url, res), + "/auth/discord/callback": (url, res) => handleDiscordCallback(url, res), + }; + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? `${options.host}:${options.port}`}`); + const handler = routes[url.pathname]; + if (!handler) { + sendText(res, 404, "not found"); + return; + } + Promise.resolve(handler(url, res)).catch(cause => { + options.log.error("link route threw", {path: url.pathname, error: describeError(cause)}); + if (!res.headersSent) sendText(res, 500, "internal error"); + }); + }); + + return { + start() { + server.listen(options.port, options.host, () => { + options.log.info("link http server listening", {host: options.host, port: options.port}); + }); + }, + stop() { + server.close(); + }, + }; +} diff --git a/backend/brico-bot/src/log.ts b/backend/brico-bot/src/log.ts new file mode 100644 index 00000000..78ce5f1b --- /dev/null +++ b/backend/brico-bot/src/log.ts @@ -0,0 +1,52 @@ +/** + * log.ts — a deliberately tiny leveled logger. + */ + +export type LogLevel = "debug" | "info" | "warn" | "error"; + +const ORDER: Record = {debug: 10, info: 20, warn: 30, error: 40}; + +export interface Logger { + debug(message: string, fields?: Record): void; + info(message: string, fields?: Record): void; + warn(message: string, fields?: Record): void; + error(message: string, fields?: Record): void; + /** A child logger whose scope is `.`. */ + child(name: string): Logger; +} + +function renderFields(fields: Record | undefined): string { + if (!fields) return ""; + const parts: string[] = []; + for (const [key, value] of Object.entries(fields)) { + if (value === undefined) continue; + // bigint has no JSON encoding and entity ids are the most common field here, so render + // scalars directly and only fall back to JSON for structures. + const rendered = typeof value === "object" && value !== null ? JSON.stringify(value, jsonSafe) : String(value); + parts.push(`${key}=${rendered}`); + } + return parts.length > 0 ? ` ${parts.join(" ")}` : ""; +} + +function jsonSafe(_key: string, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; +} + +export function createLogger(minLevel: LogLevel, scope = "bot"): Logger { + const threshold = ORDER[minLevel]; + + function emit(level: LogLevel, message: string, fields?: Record): void { + if (ORDER[level] < threshold) return; + const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(5)} [${scope}] ${message}${renderFields(fields)}`; + if (level === "error" || level === "warn") console.error(line); + else console.log(line); + } + + return { + debug: (message, fields) => emit("debug", message, fields), + info: (message, fields) => emit("info", message, fields), + warn: (message, fields) => emit("warn", message, fields), + error: (message, fields) => emit("error", message, fields), + child: name => createLogger(minLevel, `${scope}.${name}`), + }; +} diff --git a/backend/brico-bot/src/main.ts b/backend/brico-bot/src/main.ts new file mode 100644 index 00000000..1e5ff02f --- /dev/null +++ b/backend/brico-bot/src/main.ts @@ -0,0 +1,147 @@ +/** + * main.ts — brico-bot's entrypoint. + * + * A long-running process: it dials two SpacetimeDB modules, keeps them alive, and runs the shared + * filter engine over the join. + * + * The BitAuth/Discord link HTTP server below (`createLinkHttpServer`) is a consumer of the + * `brico-app` connection, not of the bridge, so it starts and stops independently of the + * relay/watch-matching machinery. A future Discord bot or report HTTP API attaches the same way: + * each is a *consumer* of an existing connection, started after it and shut down with it. + */ +import {createBountyEngine} from "./app/bounty-sink.ts"; +import {startBricoAppConnection} from "./app/connection.ts"; +import {createNotificationSink} from "./app/notification-sink.ts"; +import {createAccountWatchSource} from "./app/watch-source.ts"; +import {createDiscordOAuthClient} from "./auth/discord-oauth.ts"; +import {createOidcClient} from "./auth/oidc-client.ts"; +import {combineSinks, createBridge, createLoggingSink} from "./bridge.ts"; +import {loadConfig, loadEnvFile} from "./config.ts"; +import {loadRecipeIndex} from "./game-data/recipes.ts"; +import {createLinkHttpServer} from "./http-server.ts"; +import {createLogger} from "./log.ts"; +import {timeReducerCall} from "./metrics.ts"; +import {createPrismRelay} from "./relay/prism.ts"; +import {createFileTokenStore} from "./spacetime/token-store.ts"; + +async function main(): Promise { + const envFile = loadEnvFile(); + const config = loadConfig(); + const log = createLogger(config.logLevel); + + if (envFile) log.info("loaded env file", {file: envFile}); + log.info("starting brico-bot bridge worker", { + node: process.version, + prism: `${config.prism.uri}/${config.prism.database}`, + bricoApp: config.bricoApp ? `${config.bricoApp.uri}/${config.bricoApp.database}` : "(disabled)", + snapshotIntervalMs: config.snapshotIntervalMs, + stateDir: config.stateDir, + gameDataDir: config.gameDataDir, + }); + + // Loaded once, up front, and fatal on failure: every craft's `effortRequired`/`skill`/`tier`/ + // `item` is derived from this, so a bridge that started matching without it would silently + // treat every craft as effort-0 and item-less rather than failing loudly. + const recipes = await loadRecipeIndex(config.gameDataDir); + log.info("loaded static recipe data", {gameDataDir: config.gameDataDir, recipes: recipes.size}); + + const tokens = createFileTokenStore(config.stateDir); + const supervisor = { + tokens, + log, + delayMs: config.reconnectDelayMs, + maxDelayMs: config.reconnectMaxDelayMs, + }; + + // The brico-app connection comes up first so the watch source can consult it from the very + // first prism snapshot. Non-blocking — the socket dials in the background — and an unreachable + // or disabled brico-app leaves the prism half of the bridge entirely untouched. + const app = startBricoAppConnection({ + ...supervisor, + target: config.bricoApp, + onRowsChanged: () => log.debug("brico-app rows changed"), + }); + if (app.disabledReason) log.warn("brico-app half of the bridge is not running", {reason: app.disabledReason}); + + // The BitAuth/Discord OAuth relying-party HTTP surface. Independent of the bridge/watch + // matching below; it only ever calls `noteIntegrationLinkExternal` on the same `brico-app` + // connection. + const bitauthClient = config.bitauth + ? createOidcClient({ + issuer: config.bitauth.issuer, + clientId: config.bitauth.clientId, + clientSecret: config.bitauth.clientSecret, + redirectUri: config.bitauth.redirectUri, + scope: config.bitauth.scope, + }) + : null; + const discordOAuthClient = config.discordOAuth ? createDiscordOAuthClient(config.discordOAuth) : null; + if (!bitauthClient) log.warn("BitAuth linking is disabled — set BITAUTH_CLIENT_ID/BITAUTH_CLIENT_SECRET to enable it"); + if (!discordOAuthClient) log.warn("Discord OAuth linking is disabled — set DISCORD_OAUTH_CLIENT_ID/DISCORD_OAUTH_CLIENT_SECRET to enable it"); + + const linkHttp = createLinkHttpServer({ + host: config.httpHost, + port: config.httpPort, + log: log.child("http"), + returnUrl: config.linkReturnUrl, + returnUrlAllowedOrigins: config.linkReturnUrlAllowedOrigins, + bitauth: bitauthClient, + discordOAuth: discordOAuthClient, + noteExternalLink: async ({code, externalId, externalHandle}) => { + const conn = app.connection?.connection; + if (!conn) throw new Error("brico-app connection is not live"); + await timeReducerCall("note_integration_link_external", conn.reducers.noteIntegrationLinkExternal({code, externalId, externalHandle})); + }, + }); + linkHttp.start(); + + const watches = createAccountWatchSource(app, log.child("watches")); + + const bridge = createBridge({ + log, + watches, + sink: combineSinks(createLoggingSink(log), createNotificationSink(app, log)), + recipes, + bounty: createBountyEngine(app, log), + }); + + const relay = createPrismRelay({ + ...supervisor, + target: config.prism, + snapshotIntervalMs: config.snapshotIntervalMs, + onSnapshot: snapshot => bridge.onSnapshot(snapshot), + }); + relay.start(); + + const heartbeat = config.heartbeatMs > 0 + ? setInterval(() => { + log.info(`heartbeat prism=${relay.connection.status} bricoApp=${app.connection?.status ?? "disabled"} ${bridge.describe()}`); + }, config.heartbeatMs) + : null; + + let shuttingDown = false; + const shutdown = (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; + log.info("shutting down", {signal, summary: bridge.describe()}); + if (heartbeat) clearInterval(heartbeat); + linkHttp.stop(); + relay.stop(); + app.stop(); + // Give the SDK's close frames a moment, then exit rather than trusting every handle to be + // released — a bridge worker that hangs on SIGTERM gets SIGKILLed by its supervisor anyway. + setTimeout(() => process.exit(0), 250).unref(); + }; + process.on("SIGINT", () => shutdown("SIGINT")); + process.on("SIGTERM", () => shutdown("SIGTERM")); + + // A rejected promise inside an SDK callback must not silently leave the bridge half-alive. + process.on("unhandledRejection", reason => { + log.error("unhandled rejection", {error: reason instanceof Error ? reason.stack ?? reason.message : String(reason)}); + }); +} + +main().catch(cause => { + console.error(cause); + process.exit(1); +}); diff --git a/backend/brico-bot/src/metrics.ts b/backend/brico-bot/src/metrics.ts new file mode 100644 index 00000000..0664092e --- /dev/null +++ b/backend/brico-bot/src/metrics.ts @@ -0,0 +1,84 @@ +/** + * metrics.ts — Prometheus counters/histograms for the bridge worker, served over `http-server.ts`'s + * existing `/metrics` route. + */ +import {collectDefaultMetrics, Counter, Gauge, Histogram, Registry} from "prom-client"; + +export const registry = new Registry(); +collectDefaultMetrics({register: registry}); + +/** Time to rebuild a `CraftSnapshot` from prism's relay tables (`relay/prism.ts`'s `readSnapshot`). */ +export const snapshotBuildDuration = new Histogram({ + name: "brico_bot_snapshot_build_duration_seconds", + help: "Time to build a CraftSnapshot from prism's relay tables", + registers: [registry], +}); + +/** Time to run every watch's filter against one snapshot (the loop in `bridge.ts`'s `onSnapshot`). */ +export const watchEvaluationDuration = new Histogram({ + name: "brico_bot_watch_evaluation_duration_seconds", + help: "Time to evaluate every watch's filter against one snapshot", + registers: [registry], +}); + +/** Time spent resolving + writing bounty assignments for one snapshot (`BountyEngine.assign`). */ +export const bountyAssignDuration = new Histogram({ + name: "brico_bot_bounty_assign_duration_seconds", + help: "Time to resolve and write craft bounty assignments for one snapshot", + registers: [registry], +}); + +/** Time spent resolving + writing per-contributor entitlements (`BountyEngine.updateEntitlements`). */ +export const bountyEntitlementDuration = new Histogram({ + name: "brico_bot_bounty_entitlement_duration_seconds", + help: "Time to resolve and write per-contributor bounty entitlements for one snapshot", + registers: [registry], +}); + +/** Round-trip duration of a `brico-app` reducer call, by reducer name and outcome. */ +export const reducerCallDuration = new Histogram({ + name: "brico_bot_reducer_call_duration_seconds", + help: "Duration of brico-app reducer calls", + labelNames: ["reducer", "outcome"] as const, + registers: [registry], +}); + +/** Watch filter transitions fired, by kind (`added`/`finished`/`removed`) — cumulative, never reset. */ +export const watchMatchesTotal = new Counter({ + name: "brico_bot_watch_matches_total", + help: "Watch filter transitions fired, by kind", + labelNames: ["kind"] as const, + registers: [registry], +}); + +/** Number of watches evaluated on the most recent snapshot. */ +export const activeWatches = new Gauge({ + name: "brico_bot_active_watches", + help: "Number of watches evaluated on the most recent snapshot", + registers: [registry], +}); + +/** Sum of every watch's current match count, as of the most recent snapshot. */ +export const currentMatches = new Gauge({ + name: "brico_bot_current_matches", + help: "Sum of current match counts across all watches, as of the most recent snapshot", + registers: [registry], +}); + +/** + * Times a fire-and-forget reducer call without changing its resolve/reject behavior — callers keep + * their existing `.catch(...)` chain, this just sits between the reducer call and it. + */ +export function timeReducerCall(reducer: string, call: Promise): Promise { + const start = performance.now(); + return call.then( + value => { + reducerCallDuration.observe({reducer, outcome: "ok"}, (performance.now() - start) / 1000); + return value; + }, + error => { + reducerCallDuration.observe({reducer, outcome: "error"}, (performance.now() - start) / 1000); + throw error; + }, + ); +} diff --git a/backend/brico-bot/src/relay/prism.ts b/backend/brico-bot/src/relay/prism.ts new file mode 100644 index 00000000..72911155 --- /dev/null +++ b/backend/brico-bot/src/relay/prism.ts @@ -0,0 +1,243 @@ +/** + * prism.ts — the bridge's connection to prism's `relay-module` (the BitCraft read-mirror). + * + * Publishes a coalesced *snapshot* of raw relay rows rather than per-row events, for the same + * reason `frontend/src/lib/crafts/relay.ts` does: filter evaluation needs the whole joined picture + * (a craft's claim, its owner's membership in that claim), so a per-row callback can't usefully + * answer "does this match" on its own, and progress deltas arrive far faster than any consumer + * needs to re-decide. A craft's *recipe* is deliberately not part of this — that's static game + * data, read once from offline BSATN via `../game-data/recipes.ts`, not something to subscribe to. + * + * The snapshot holds relay rows untouched. Projecting them into something the filter engine can + * evaluate is `subject.ts`'s job. + */ +import {DbConnection, type SubscriptionHandle, tables} from "@brico/bindings/prism"; +import type {ClaimInfo, ClaimMember, CraftMeta, CraftProgress, PlayerState, Region} from "@brico/bindings/prism/types"; +import type {SpacetimeTarget} from "../config.ts"; +import type {Logger} from "../log.ts"; +import {snapshotBuildDuration} from "../metrics.ts"; +import {createCoalescer} from "../spacetime/coalesce.ts"; +import {createSupervisedConnection, type SupervisedConnection, type SupervisorOptions} from "../spacetime/connection.ts"; + +/** How much progress-per-player has arrived on a still-open craft, for the payout phase's per-contributor entitlement math. */ +type ContributionMap = Map>; + +/** Everything the bridge reads out of the relay, joined by the ids the filter engine needs. */ +export interface CraftSnapshot { + /** Only `Active` crafts — see `isOpen`. */ + crafts: CraftMeta[]; + /** + * Every `craft_meta` entityId currently mirrored, even if not 'Active'. Used to clear + * bounties from crafts when they become canceled/collected. + */ + allCraftIds: Set; + /** Keyed by the craft's `entityId`; `craft_progress` has no region column. */ + progress: Map; + claims: Map; + /** Keyed by `` `${claimEntityId}:${playerEntityId}` `` — a *specific* player's claim access. */ + claimMembers: Map; + /** Cumulative contribution per craft, per contributor — `craftId -> playerId -> effort`. Feeds + * the payout phase's `computeEntitlement`, never the filter engine (which only needs the + * craft-level totals `buildCraftSubject` already derives from `craft_progress`). */ + contributions: ContributionMap; + /** `claimEntityId -> playerEntityId[]` with `owner === true` for that claim — every owner, not + * just a specific player's own membership row (unlike `claimMembers`), since bounty resolution + * needs to ask "who owns this claim" independent of whom the craft's own owner is. */ + claimOwners: Map; + players: Map; + regions: Map; + /** When this snapshot was built, for staleness reporting. */ + builtAtMs: number; +} + +export const EMPTY_SNAPSHOT: CraftSnapshot = { + crafts: [], + allCraftIds: new Set(), + progress: new Map(), + claims: new Map(), + claimMembers: new Map(), + contributions: new Map(), + claimOwners: new Map(), + players: new Map(), + regions: new Map(), + builtAtMs: 0, +}; + +/** + * Crafts that are actually live work. + * + * `craft_meta` retains a craft for 24 hours after it leaves the game, flipping `status` to + * `Claimed`/`Removed` instead of deleting the row, so history stays queryable. Every consumer has + * to apply this filter itself — the relay does not drop the row, and its query builder can't + * compare a sum-typed column server-side, so it can't be pushed into the subscription either. + * Without it the bridge would fire watch notifications for a day's worth of dead orders. + */ +function isOpen(craft: CraftMeta): boolean { + return craft.status.tag === "Active"; +} + +function readSnapshot(conn: DbConnection): CraftSnapshot { + const progress = new Map(); + for (const row of conn.db.craftProgress.iter()) progress.set(row.entityId, row); + + const claims = new Map(); + for (const row of conn.db.claimInfo.iter()) claims.set(row.entityId, row); + + const claimMembers = new Map(); + const claimOwners = new Map(); + for (const row of conn.db.claimMember.iter() as Iterable) { + claimMembers.set(`${row.claimEntityId}:${row.playerEntityId}`, row); + if (row.owner) { + const owners = claimOwners.get(row.claimEntityId); + if (owners) owners.push(row.playerEntityId); + else claimOwners.set(row.claimEntityId, [row.playerEntityId]); + } + } + + const contributions: ContributionMap = new Map(); + for (const row of conn.db.craftContribution.iter()) { + let byPlayer = contributions.get(row.craftId); + if (!byPlayer) { + byPlayer = new Map(); + contributions.set(row.craftId, byPlayer); + } + byPlayer.set(row.playerId, BigInt(row.contribution)); + } + + const players = new Map(); + for (const row of conn.db.playerState.iter()) players.set(row.entityId, row); + + const regions = new Map(); + for (const row of conn.db.region.iter()) regions.set(row.id, row); + + const allCraftMeta = [...conn.db.craftMeta.iter()]; + const allCraftIds = new Set(allCraftMeta.map(craft => craft.entityId)); + + return { + crafts: allCraftMeta.filter(isOpen), + allCraftIds, + progress, + claims, + claimMembers, + contributions, + claimOwners, + players, + regions, + builtAtMs: Date.now(), + }; +} + +export interface PrismRelay { + readonly connection: SupervisedConnection; + /** Most recent coalesced snapshot; `EMPTY_SNAPSHOT` until the first subscription applies. */ + readonly snapshot: CraftSnapshot; + start(): void; + stop(): void; +} + +export interface PrismRelayOptions extends SupervisorOptions { + target: SpacetimeTarget; + /** How long row changes are coalesced before the snapshot is rebuilt. */ + snapshotIntervalMs: number; + /** Called with each freshly built snapshot. This is what drives the bridge. */ + onSnapshot(snapshot: CraftSnapshot): void; +} + +export function createPrismRelay(options: PrismRelayOptions): PrismRelay { + const log: Logger = options.log.child("relay"); + let snapshot: CraftSnapshot = EMPTY_SNAPSHOT; + let current: DbConnection | null = null; + + const rebuild = () => { + if (!current?.isActive) return; + const timer = snapshotBuildDuration.startTimer(); + snapshot = readSnapshot(current); + timer(); + options.onSnapshot(snapshot); + }; + const coalescer = createCoalescer(rebuild, options.snapshotIntervalMs); + + const connection = createSupervisedConnection( + { + label: options.target.label, + uri: options.target.uri, + database: options.target.database, + dial: handlers => + DbConnection.builder() + .withUri(options.target.uri) + .withDatabaseName(options.target.database) + .withToken(handlers.token) + .onConnect((conn, identity, token) => handlers.onConnect(conn, identity, token)) + .onDisconnect((_ctx, error) => handlers.onDisconnect(error)) + .onConnectError((_ctx, error) => handlers.onConnectError(error)) + .build(), + onConnected: (conn, ctx) => { + current = conn; + + for (const table of [ + conn.db.craft_meta, + conn.db.craft_progress, + conn.db.claim_info, + conn.db.claim_member, + conn.db.craft_contribution, + conn.db.player_state, + conn.db.region, + ]) { + table.onInsert(coalescer.mark); + table.onDelete(coalescer.mark); + table.onUpdate(coalescer.mark); + } + + let subscription: SubscriptionHandle | null = conn + .subscriptionBuilder() + .onApplied(() => { + if (!ctx.isCurrent()) return; + ctx.setLive(); + coalescer.flush(); + log.info("initial snapshot applied", { + openCrafts: snapshot.crafts.length, + claims: snapshot.claims.size, + claimMembers: snapshot.claimMembers.size, + contributingCrafts: snapshot.contributions.size, + players: snapshot.players.size, + regions: snapshot.regions.size, + }); + }) + .onError(errorCtx => { + ctx.setError(`subscription error: ${errorCtx.event ? String(errorCtx.event) : "unknown"}`); + }) + .subscribe([ + tables.craftMeta, + tables.craftProgress, + tables.claimInfo, + tables.claimMember, + tables.craftContribution, + tables.playerState, + tables.region, + ]); + + return () => { + subscription?.unsubscribe(); + subscription = null; + if (current === conn) current = null; + }; + }, + }, + {tokens: options.tokens, log: options.log, delayMs: options.delayMs, maxDelayMs: options.maxDelayMs}, + ); + + return { + connection, + get snapshot() { + return snapshot; + }, + start() { + coalescer.start(); + connection.start(); + }, + stop() { + coalescer.stop(); + connection.stop(); + }, + }; +} diff --git a/backend/brico-bot/src/relay/subject.ts b/backend/brico-bot/src/relay/subject.ts new file mode 100644 index 00000000..7a54f01d --- /dev/null +++ b/backend/brico-bot/src/relay/subject.ts @@ -0,0 +1,67 @@ +/** + * subject.ts — projects relay rows into the `CraftRow` the bridge logs and matches watches against. + * + * The actual `CraftSubject` math (`@brico/crafts/subject`'s `buildCraftSubject`) is shared with the + * frontend's `frontend/src/lib/crafts/entries.ts` — this file's job is just resolving *this* + * process's inputs to it (recipe statics from `RecipeIndex`, the owner's `claim_member` row from the + * snapshot) and adding the handful of display fields a log line needs. + */ +import type {CraftSubject} from "@brico/crafts/filter"; +import {claimDisplayName, regionDisplayName} from "@brico/crafts/names"; +import {buildCraftSubject, type CraftBountyFacts} from "@brico/crafts/subject"; + +import type {RecipeIndex} from "../game-data/recipes.ts"; +import type {CraftSnapshot} from "./prism.ts"; + +/** A craft projected for matching, with just enough context to make a log line readable. */ +export interface CraftRow { + /** Craft entity id as a bigint string — the identity a watch's match set is keyed by, and the + * form `id`-kind filter leaves compare against (64-bit ids overflow `number`). */ + id: string; + regionId: number; + regionName: string; + recipeId: number; + count: number; + claimName: string | null; + ownerName: string | null; + subject: CraftSubject; +} + +export function craftRowsFrom( + snapshot: CraftSnapshot, + recipes: RecipeIndex, + assignments?: ReadonlyMap, +): CraftRow[] { + return snapshot.crafts.map(craft => { + const recipe = recipes.get(craft.recipeId); + const progress = snapshot.progress.get(craft.entityId); + const claim = craft.claimEntityId === 0n ? undefined : snapshot.claims.get(craft.claimEntityId); + const owner = craft.ownerEntityId === 0n ? undefined : snapshot.players.get(craft.ownerEntityId); + const ownerClaimMember = snapshot.claimMembers.get(`${craft.claimEntityId}:${craft.ownerEntityId}`); + + const subject = buildCraftSubject( + { + regionId: craft.regionId, + claimEntityId: craft.claimEntityId, + ownerEntityId: craft.ownerEntityId, + count: craft.count, + public: craft.public, + progressRaw: progress?.progress ?? 0, + }, + recipe, + ownerClaimMember, + assignments?.get(craft.entityId), + ); + + return { + id: craft.entityId.toString(), + regionId: craft.regionId, + regionName: regionDisplayName(snapshot.regions.get(craft.regionId)?.name, craft.regionId), + recipeId: craft.recipeId, + count: craft.count, + claimName: claim ? claimDisplayName(claim.name) : null, + ownerName: owner?.name ?? null, + subject, + } satisfies CraftRow; + }); +} diff --git a/backend/brico-bot/src/spacetime/coalesce.ts b/backend/brico-bot/src/spacetime/coalesce.ts new file mode 100644 index 00000000..cdade693 --- /dev/null +++ b/backend/brico-bot/src/spacetime/coalesce.ts @@ -0,0 +1,49 @@ +/** + * coalesce.ts — "rebuild at most once per interval, and only if something changed". + * + * The frontend's craft relay reaches for `@solid-primitives/scheduled`'s `throttle` for this; + * the reason is the same here and not framework-specific. + * + * Differs from a plain throttle in one way that matters for a service: the timer is a single + * long-lived interval that skips ticks when nothing is dirty, rather than a timer armed per burst. + * An idle bridge then does no work at all instead of churning timers. + */ + +export interface Coalescer { + /** Something changed; the next tick should rebuild. Cheap — safe to call per row callback. */ + mark(): void; + /** Rebuild immediately and clear the dirty flag (used on `onApplied`). */ + flush(): void; + start(): void; + stop(): void; +} + +export function createCoalescer(run: () => void, intervalMs: number): Coalescer { + let dirty = false; + let timer: NodeJS.Timeout | null = null; + + return { + mark() { + dirty = true; + }, + flush() { + dirty = false; + run(); + }, + start() { + if (timer !== null) return; + timer = setInterval(() => { + if (!dirty) return; + dirty = false; + run(); + }, intervalMs); + }, + stop() { + if (timer !== null) { + clearInterval(timer); + timer = null; + } + dirty = false; + }, + }; +} diff --git a/backend/brico-bot/src/spacetime/connection.ts b/backend/brico-bot/src/spacetime/connection.ts new file mode 100644 index 00000000..a8e5484f --- /dev/null +++ b/backend/brico-bot/src/spacetime/connection.ts @@ -0,0 +1,251 @@ +/** + * connection.ts — a supervised, self-healing SpacetimeDB connection for a long-running process. + * + * This is the Node-side counterpart of the pattern proven in `frontend/src/lib/crafts/relay.ts`: + * token persistence, reconnect with backoff, and **generation-guarded callbacks** (the SDK fires + * `onDisconnect` asynchronously, after the socket actually closes, so a deliberately-replaced + * connection's callback can still run once a newer one has already been dialed — every callback + * therefore captures the generation it was created for and no-ops when it's stale). + * + * It is *not* an import of that file, and must not become one. That file is Solid-flavored + * (`createSignal`/`onMount`/`onCleanup`) and browser-flavored (`localStorage`, one connection for + * the lifetime of a component); this one is callback-flavored and lives for the lifetime of a + * process. + * + * Generic over the module: the generated `DbConnection` of every module is a distinct class, so the + * per-module bits (which builder to call, which tables to subscribe) are supplied by the caller as + * a `ConnectionSpec` and everything else here is module-agnostic. That is also what keeps this + * usable for `brico-app` without importing its bindings — see `app/connection.ts`. + */ +import type {Logger} from "../log.ts"; +import type {TokenStore} from "./token-store.ts"; + +/** The slice of a generated `DbConnection` this supervisor needs. */ +export interface LiveConnection { + readonly isActive: boolean; + disconnect(): void; +} + +export type Teardown = () => void; + +/** Reported for observability only — reconnect decisions are driven by the SDK's callbacks. */ +export type ConnectionStatus = "idle" | "connecting" | "syncing" | "live" | "error" | "stopped"; + +/** What a `ConnectionSpec.dial` implementation must wire onto the SDK's builder. */ +export interface DialHandlers { + /** Previously stored token, or `undefined` to connect anonymously and be issued a new one. */ + token: string | undefined; + onConnect(conn: TConn, identity: {toHexString(): string}, token: string): void; + onDisconnect(error?: Error): void; + onConnectError(error?: Error): void; +} + +/** Handed to `onConnected` so the spec can report subscription progress back to the supervisor. */ +export interface ConnectedContext { + /** The initial subscription has been applied — the client cache is populated. */ + setLive(): void; + /** A subscription (not the socket) failed. Does not itself trigger a reconnect. */ + setError(message: string): void; + /** True while this connect attempt is still the current one — check before doing real work. */ + isCurrent(): boolean; +} + +export interface ConnectionSpec { + label: string; + uri: string; + database: string; + /** Build and dial. Wire every handler in `handlers` onto the generated builder. */ + dial(handlers: DialHandlers): TConn; + /** + * Register table callbacks and subscriptions. Runs inside the generation guard, immediately + * after the token is stored. Return a teardown (typically `() => sub.unsubscribe()`). + */ + onConnected(conn: TConn, ctx: ConnectedContext): Teardown | void; +} + +export interface SupervisedConnection { + readonly label: string; + readonly status: ConnectionStatus; + readonly error: string | null; + /** The live connection, or `null` when not currently connected. */ + readonly connection: TConn | null; + /** True once the initial subscription has been applied. */ + readonly isLive: boolean; + start(): void; + /** Drop the current connection and dial again immediately, resetting backoff. */ + reconnect(): void; + /** Permanent: cancels any pending reconnect and stops supervising. */ + stop(): void; +} + +export interface SupervisorOptions { + tokens: TokenStore; + log: Logger; + /** First reconnect delay. Doubles per consecutive failure, capped at `maxDelayMs`. */ + delayMs: number; + maxDelayMs: number; +} + +export function createSupervisedConnection( + spec: ConnectionSpec, + options: SupervisorOptions, +): SupervisedConnection { + const log = options.log.child(spec.label); + const tokenKey = `${spec.label}:${spec.uri}/${spec.database}/auth_token`; + + let connection: TConn | null = null; + let teardownSubscriptions: Teardown | null = null; + let reconnectTimer: NodeJS.Timeout | null = null; + let status: ConnectionStatus = "idle"; + let error: string | null = null; + let isLive = false; + let stopped = false; + let attempt = 0; + // See the file header: every callback captures this and bails if it has moved on. + let generation = 0; + + function clearReconnectTimer(): void { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + } + + function teardown(): void { + try { + teardownSubscriptions?.(); + } catch (cause) { + log.debug("subscription teardown threw", {error: describe(cause)}); + } + teardownSubscriptions = null; + isLive = false; + if (connection) { + try { + connection.disconnect(); + } catch (cause) { + log.debug("disconnect threw", {error: describe(cause)}); + } + connection = null; + } + } + + /** Exponential backoff with jitter, so a relay restart doesn't get a synchronised stampede. */ + function nextDelayMs(): number { + const base = Math.min(options.delayMs * 2 ** Math.max(attempt - 1, 0), options.maxDelayMs); + return Math.round(base * (0.75 + Math.random() * 0.5)); + } + + function scheduleReconnect(message: string): void { + if (stopped) return; + // A single failed attempt fires *both* `onConnectError` and `onDisconnect` (observed + // against SpacetimeDB 2.10 for a nonexistent database), and each of them is a legitimate + // reason to reconnect. Retiring the generation here makes whichever arrives second no-op, + // so one failure costs one `attempt` — without this the backoff doubles twice per cycle + // and a brief outage escalates to the maximum delay in half the intended time. + generation += 1; + clearReconnectTimer(); + status = "error"; + error = message; + attempt += 1; + const delay = nextDelayMs(); + log.warn(message, {retryInMs: delay, attempt}); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); + } + + function connect(): void { + clearReconnectTimer(); + teardown(); + if (stopped) return; + + const gen = ++generation; + status = "connecting"; + log.info("dialing", {uri: spec.uri, database: spec.database}); + + const ctx: ConnectedContext = { + isCurrent: () => gen === generation && !stopped, + setLive: () => { + if (gen !== generation || stopped) return; + status = "live"; + error = null; + isLive = true; + // Only a *fully* established connection resets backoff. Resetting it in + // `onConnect` would turn a module that accepts sockets and then fails every + // subscription into a tight reconnect loop. + attempt = 0; + log.info("live"); + }, + setError: message => { + if (gen !== generation || stopped) return; + status = "error"; + error = message; + log.error(message); + }, + }; + + try { + connection = spec.dial({ + token: options.tokens.read(tokenKey), + onConnect: (conn, identity, token) => { + if (gen !== generation || stopped) return; + options.tokens.write(tokenKey, token); + status = "syncing"; + error = null; + log.info("connected", {identity: identity.toHexString()}); + teardownSubscriptions = spec.onConnected(conn, ctx) ?? null; + }, + onDisconnect: cause => { + if (gen !== generation || stopped) return; + scheduleReconnect(`disconnected: ${describe(cause)}`); + }, + onConnectError: cause => { + if (gen !== generation || stopped) return; + scheduleReconnect(`connect error: ${describe(cause)}`); + }, + }); + } catch (cause) { + // `build()` can throw synchronously (bad URI, bad module name); that path never fires + // `onConnectError`, so it needs its own retry or the supervisor would silently die. + scheduleReconnect(`dial failed: ${describe(cause)}`); + } + } + + return { + label: spec.label, + get status() { + return status; + }, + get error() { + return error; + }, + get connection() { + return connection; + }, + get isLive() { + return isLive; + }, + start() { + if (stopped) throw new Error(`connection ${spec.label} was stopped and cannot restart`); + if (status !== "idle") return; + connect(); + }, + reconnect() { + attempt = 0; + connect(); + }, + stop() { + stopped = true; + clearReconnectTimer(); + teardown(); + status = "stopped"; + }, + }; +} + +function describe(cause: unknown): string { + if (cause === undefined || cause === null) return "no reason given"; + if (cause instanceof Error) return cause.message || cause.name; + return String(cause); +} diff --git a/backend/brico-bot/src/spacetime/token-store.ts b/backend/brico-bot/src/spacetime/token-store.ts new file mode 100644 index 00000000..65c9b3e9 --- /dev/null +++ b/backend/brico-bot/src/spacetime/token-store.ts @@ -0,0 +1,43 @@ +/** + * token-store.ts — the Node stand-in for the browser's `localStorage` connection-token slot. + * + * These are credentials. The directory is created 0700 and files 0600; nothing here is ever logged. + */ +import {mkdirSync, readFileSync, writeFileSync} from "node:fs"; +import path from "node:path"; + +export interface TokenStore { + read(key: string): string | undefined; + write(key: string, token: string): void; +} + +/** Anything that could escape the state directory or upset a filesystem becomes an underscore. */ +function fileNameFor(key: string): string { + return `${key.replace(/[^a-zA-Z0-9._-]+/g, "_")}.token`; +} + +export function createFileTokenStore(dir: string): TokenStore { + let ensured = false; + const ensure = () => { + if (ensured) return; + mkdirSync(dir, {recursive: true, mode: 0o700}); + ensured = true; + }; + + return { + read(key) { + try { + const token = readFileSync(path.join(dir, fileNameFor(key)), "utf8").trim(); + return token === "" ? undefined : token; + } catch { + // No token yet (or an unreadable one) simply means "connect anonymously and store + // whatever the server issues" — never a reason to fail startup. + return undefined; + } + }, + write(key, token) { + ensure(); + writeFileSync(path.join(dir, fileNameFor(key)), token, {encoding: "utf8", mode: 0o600}); + }, + }; +} diff --git a/backend/brico-bot/tsconfig.json b/backend/brico-bot/tsconfig.json new file mode 100644 index 00000000..29f0e6c7 --- /dev/null +++ b/backend/brico-bot/tsconfig.json @@ -0,0 +1,20 @@ +{ + // Matches `common/crafts` and `frontend`: the workspace packages export raw `.ts` with no build + // step, so every consumer needs `moduleResolution: "bundler"` and something that can load TS. + // Here that something is `tsx` (see README) — `tsc` is only ever run with `--noEmit`. + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + // Relative imports carry their `.ts` extension so the same source runs under `tsx` and, + // if it ever needs to, Node's own resolver — which takes relative specifiers literally. + "allowImportingTsExtensions": true, + "strict": true, + "noImplicitAny": false, + "isolatedModules": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/backend/brico-module/.gitignore b/backend/brico-module/.gitignore new file mode 100644 index 00000000..b65610ab --- /dev/null +++ b/backend/brico-module/.gitignore @@ -0,0 +1,4 @@ +# `spacetime build`/`publish` bundle the module here. Build output, not source. +spacetimedb/dist/ + +spacetime.local.json \ No newline at end of file diff --git a/backend/brico-module/README.md b/backend/brico-module/README.md new file mode 100644 index 00000000..dd4fdac7 --- /dev/null +++ b/backend/brico-module/README.md @@ -0,0 +1,23 @@ +# `backend/brico-module` — the `brico-app` SpacetimeDB module + +brico's own SpacetimeDB module (TypeScript, `spacetimedb`). Owns brico-specific per-user app +state; prism's `relay-module` stays the read-mirror of BitCraft. + +## Commands + +Run from `spacetimedb/`, or from the repo root with `--prefix backend/brico-module/spacetimedb`: + +```sh +npm run typecheck # tsc --noEmit +npm run build # spacetime build +npm run publish:local # spacetime publish --server local --yes (see below) +npm run generate # regenerate common/bindings/brico-app +``` + +A local server must be running (`spacetime start`) for publish. After a schema change, publish +*and* regenerate bindings (to `common/bindings/brico-app/`). + +`publish:local` runs `scripts/publish-local.mjs` rather than `spacetime publish` directly: it +temporarily adds `localhost` as a trusted JWT issuer in `auth.ts` (what a local dev server issues +for tokenless connections), publishes, then restores the file. Published-to-production builds +never trust `localhost`. diff --git a/backend/brico-module/scripts/import-payouts.mjs b/backend/brico-module/scripts/import-payouts.mjs new file mode 100644 index 00000000..a75d3af1 --- /dev/null +++ b/backend/brico-module/scripts/import-payouts.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +// Imports one payer's historical `bounty_entitlement_total` / `bounty_payout_record` / +// `loyalty_reward` rows from spreadsheet CSV exports (see `entitled_and_paid.csv`/`loyalty.csv` in +// this directory for the expected shape) via the service-principal-gated +// `import_historical_bounty_ledger` / `import_historical_loyalty_reward` reducers +// (`spacetimedb/src/reducers/payouts.ts`). Shells out to the `spacetime` CLI per row rather than +// opening an SDK connection — this is a one-off admin backfill, not a long-running process, and the +// CLI already carries the operator's own login identity (which must be a `service_principal`; the +// module publisher qualifies for free, see `backend/brico-bot/README.md`'s "Running as the database +// owner" note). +// +// Usage (from this directory, or `backend/brico-module/spacetimedb` via `npm run import:payouts --`): +// node import-payouts.mjs [options] +// +// Options: +// --entitled-and-paid= entityId,name,totalEffort,coins,paidOut CSV (default: entitled_and_paid.csv) +// --loyalty= playerEntityId,name,multiplier CSV (default: loyalty.csv) +// --server= spacetime CLI server nickname (default: local) +// --database= spacetime database name (default: brico-app) +// --dry-run print the `spacetime call` invocations instead of running them +import {execFileSync} from 'node:child_process'; +import {readFileSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const moduleDir = path.join(scriptDir, '..'); + +// Hardcoded, not a flag: every historical sheet here is denominated in hex-coin, and the reducers +// only allow-list currencies that are ever meant to be manually recorded (see `validateBountyCurrency` +// / `validateManualBountyCurrency` in `spacetimedb/src/lib/bounty.ts`). +const CURRENCY = 'hex-coin'; + +function parseArgs(argv) { + const options = { + entitledAndPaid: path.join(scriptDir, 'entitled_and_paid.csv'), + loyalty: path.join(scriptDir, 'loyalty.csv'), + server: 'local', + database: 'brico-app', + dryRun: false, + }; + const positional = []; + for (const arg of argv) { + if (arg === '--dry-run') options.dryRun = true; + else if (arg.startsWith('--entitled-and-paid=')) options.entitledAndPaid = arg.slice('--entitled-and-paid='.length); + else if (arg.startsWith('--loyalty=')) options.loyalty = arg.slice('--loyalty='.length); + else if (arg.startsWith('--server=')) options.server = arg.slice('--server='.length); + else if (arg.startsWith('--database=')) options.database = arg.slice('--database='.length); + else if (arg.startsWith('--')) throw new Error(`unknown option: ${arg}`); + else positional.push(arg); + } + if (positional.length !== 1) { + throw new Error( + 'usage: import-payouts.mjs [--entitled-and-paid=path] [--loyalty=path] ' + + '[--server=name] [--database=name] [--dry-run]' + ); + } + options.payerIdentity = normalizeIdentity(positional[0]); + return options; +} + +function normalizeIdentity(raw) { + const hex = raw.startsWith('0x') || raw.startsWith('0X') ? raw.slice(2) : raw; + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error(`not a 64-hex-character identity: ${raw}`); + } + return hex.toLowerCase(); +} + +/** + * Parses a u64 player id, rejecting anything but a plain integer literal — in particular the + * scientific notation (e.g. "5.76461E+17") a spreadsheet produces for a cell it auto-formatted as a + * number, which has already thrown away the digits needed to recover the real id. Kept as a string, + * never round-tripped through `Number`, since these ids routinely exceed `Number.MAX_SAFE_INTEGER`. + */ +function parsePlayerId(raw) { + const trimmed = raw.trim(); + return /^[0-9]+$/.test(trimmed) ? trimmed : null; +} + +/** + * Floors an arbitrary-precision decimal string to a bigint (toward -Infinity), via exact string/ + * bigint arithmetic. `Math.floor(Number(str))` would round-trip through a 64-bit float, which is + * exact for every value these sheets actually contain, but floors ties on negative values the wrong + * way if it's ever off by an ULP — doing it on the digit string sidesteps the question entirely. + */ +function floorDecimalToBigInt(raw) { + const trimmed = (raw ?? '').trim(); + if (trimmed === '') return 0n; + const negative = trimmed.startsWith('-'); + const abs = negative ? trimmed.slice(1) : trimmed; + const [intPart, fracPart = ''] = abs.split('.'); + const intValue = BigInt(intPart === '' ? '0' : intPart); + if (!negative) return intValue; + const hasFraction = /[1-9]/.test(fracPart); + return hasFraction ? -(intValue + 1n) : -intValue; +} + +/** + * Converts a decimal multiplier string (e.g. "1.0225") to an exact integer ratio via string + * splitting, so e.g. 1.0225 doesn't round-trip through float math into 1.0224999999999997. + */ +function decimalToRatio(raw) { + const trimmed = raw.trim(); + if (!/^[0-9]+(\.[0-9]+)?$/.test(trimmed)) return null; + const [intPart, fracPart = ''] = trimmed.split('.'); + if (fracPart === '') return {numerator: BigInt(intPart), denominator: 1n}; + return {numerator: BigInt(intPart + fracPart), denominator: 10n ** BigInt(fracPart.length)}; +} + +/** No quoted fields in these exports (names are ignored, so a stray comma in one doesn't matter). */ +function parseCsv(filePath) { + const text = readFileSync(filePath, 'utf8'); + const lines = text.split(/\r?\n/).filter(line => line.length > 0); + const header = lines[0].split(',').map(cell => cell.trim()); + const rows = lines.slice(1).map(line => line.split(',')); + const columnIndex = name => { + const index = header.indexOf(name); + if (index === -1) throw new Error(`${filePath}: missing expected column "${name}" (found: ${header.join(', ')})`); + return index; + }; + return {rows, columnIndex}; +} + +/** + * `--` before `call_parts` so a negative i64 argument (e.g. a paid-total correction) isn't parsed + * as an unknown CLI flag by clap. No `shell: true` — args are passed straight through to the + * process, so the embedded JSON-string quotes in `payerArg`/currency args need no manual escaping. + */ +function callReducer(options, reducer, args) { + const cliArgs = ['call', '--server', options.server, '--', options.database, reducer, ...args]; + if (options.dryRun) { + console.log(`spacetime ${cliArgs.join(' ')}`); + return; + } + execFileSync('spacetime', cliArgs, {cwd: moduleDir, encoding: 'utf8'}); +} + +function describeFailure(cause) { + const stderr = typeof cause?.stderr === 'string' ? cause.stderr : ''; + const errorLine = stderr.split('\n').map(line => line.trim()).find(line => line.startsWith('Error:')); + if (errorLine) return errorLine; + if (stderr.trim()) return stderr.trim(); + return cause instanceof Error ? cause.message : String(cause); +} + +function importBountyLedger(options) { + const {rows, columnIndex} = parseCsv(options.entitledAndPaid); + const idCol = columnIndex('entityId'); + const effortCol = columnIndex('totalEffort'); + const coinsCol = columnIndex('coins'); + const paidCol = columnIndex('paidOut'); + const payerArg = JSON.stringify(options.payerIdentity); + const currencyArg = JSON.stringify(CURRENCY); + + let imported = 0, skipped = 0, failed = 0; + for (const row of rows) { + const playerId = parsePlayerId(row[idCol] ?? ''); + if (playerId === null) { + console.warn(`entitled_and_paid: skipping row with unusable player id: ${row.join(',')}`); + skipped++; + continue; + } + const totalEffort = floorDecimalToBigInt(row[effortCol]); + const total = floorDecimalToBigInt(row[coinsCol]); + const paidTotal = floorDecimalToBigInt(row[paidCol]); + try { + callReducer(options, 'import_historical_bounty_ledger', [ + payerArg, playerId, currencyArg, String(totalEffort), String(total), String(paidTotal), + ]); + imported++; + } catch (cause) { + console.error(`entitled_and_paid: failed for player ${playerId}: ${describeFailure(cause)}`); + failed++; + } + } + return {imported, skipped, failed}; +} + +function importLoyaltyRewards(options) { + const {rows, columnIndex} = parseCsv(options.loyalty); + const idCol = columnIndex('playerEntityId'); + const multiplierCol = columnIndex('multiplier'); + const payerArg = JSON.stringify(options.payerIdentity); + const currencyArg = JSON.stringify(CURRENCY); + + let imported = 0, skipped = 0, failed = 0; + for (const row of rows) { + const playerId = parsePlayerId(row[idCol] ?? ''); + const ratio = decimalToRatio(row[multiplierCol] ?? ''); + if (playerId === null || ratio === null) { + console.warn(`loyalty: skipping row with unusable data: ${row.join(',')}`); + skipped++; + continue; + } + try { + callReducer(options, 'import_historical_loyalty_reward', [ + payerArg, playerId, currencyArg, String(ratio.numerator), String(ratio.denominator), + ]); + imported++; + } catch (cause) { + console.error(`loyalty: failed for player ${playerId}: ${describeFailure(cause)}`); + failed++; + } + } + return {imported, skipped, failed}; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const ledger = importBountyLedger(options); + const loyalty = importLoyaltyRewards(options); + + console.log(`bounty ledger: ${ledger.imported} imported, ${ledger.skipped} skipped, ${ledger.failed} failed`); + console.log(`loyalty reward: ${loyalty.imported} imported, ${loyalty.skipped} skipped, ${loyalty.failed} failed`); + if (ledger.failed > 0 || loyalty.failed > 0) process.exitCode = 1; +} + +try { + main(); +} catch (cause) { + console.error(cause instanceof Error ? cause.message : String(cause)); + process.exitCode = 1; +} diff --git a/backend/brico-module/scripts/publish-local.mjs b/backend/brico-module/scripts/publish-local.mjs new file mode 100644 index 00000000..be9e3fa4 --- /dev/null +++ b/backend/brico-module/scripts/publish-local.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// `spacetime publish --server local --yes`, but with `localhost` temporarily trusted as a JWT +// issuer in auth.ts for the duration of the publish. Local dev servers issue `localhost` tokens +// for tokenless connections; a published module must never trust them, so this is reverted +// (even on failure) rather than left as a permanent default in auth.ts. +import {execFileSync} from 'node:child_process'; +import {readFileSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const moduleDir = path.join(scriptDir, '..'); +const authPath = path.join(moduleDir, 'spacetimedb', 'src', 'lib', 'auth.ts'); + +const original = readFileSync(authPath, 'utf8'); +const productionLine = 'const ACCEPTED_ISSUERS = [SPACETIMEAUTH_ISSUER];'; +const localLine = "const ACCEPTED_ISSUERS = [SPACETIMEAUTH_ISSUER, 'localhost'];"; + +if (!original.includes(productionLine)) { + throw new Error( + `auth.ts no longer contains the expected ACCEPTED_ISSUERS line — update publish-local.mjs to match.`, + ); +} + +writeFileSync(authPath, original.replace(productionLine, localLine)); + +let restored = false; +const restore = () => { + if (restored) return; + restored = true; + writeFileSync(authPath, original); +}; +process.on('exit', restore); + +// Extract arguments after `--` +const doubleHyphenIndex = process.argv.indexOf('--'); +const extraArgs = doubleHyphenIndex !== -1 ? process.argv.slice(doubleHyphenIndex + 1) : []; + +try { + execFileSync('spacetime', ['publish', '--server', 'local', '--yes', ...extraArgs], { + cwd: moduleDir, + stdio: 'inherit', + shell: true, + }); +} finally { + restore(); +} diff --git a/backend/brico-module/spacetime.json b/backend/brico-module/spacetime.json new file mode 100644 index 00000000..1a018167 --- /dev/null +++ b/backend/brico-module/spacetime.json @@ -0,0 +1,4 @@ +{ + "server": "local", + "module-path": "./spacetimedb" +} \ No newline at end of file diff --git a/backend/brico-module/spacetime.local.json b/backend/brico-module/spacetime.local.json new file mode 100644 index 00000000..a398f445 --- /dev/null +++ b/backend/brico-module/spacetime.local.json @@ -0,0 +1,3 @@ +{ + "database": "brico-app" +} \ No newline at end of file diff --git a/backend/brico-module/spacetimedb/package-lock.json b/backend/brico-module/spacetimedb/package-lock.json new file mode 100644 index 00000000..cbda3e12 --- /dev/null +++ b/backend/brico-module/spacetimedb/package-lock.json @@ -0,0 +1,186 @@ +{ + "name": "brico-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "brico-app", + "version": "1.0.0", + "dependencies": { + "@brico/crafts": "file:../../../common/crafts", + "spacetimedb": "2.10.0" + }, + "devDependencies": { + "typescript": "~5.6.2" + } + }, + "../../../common/crafts": { + "name": "@brico/crafts", + "version": "0.0.0", + "devDependencies": { + "@types/node": "^26.1.2", + "typescript": "^7.0.2" + } + }, + "node_modules/@brico/crafts": { + "resolved": "../../../common/crafts", + "link": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/spacetimedb": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/spacetimedb/-/spacetimedb-2.10.0.tgz", + "integrity": "sha512-M37qGhHA+eLJsjcom441WUqy1jdY9glCxZ98Dm48IH+K45CBsrXBuskLKMj4paWBGSHnIi8eTTo05prFqv18GQ==", + "license": "ISC", + "dependencies": { + "base64-js": "^1.5.1", + "headers-polyfill": "^4.0.3", + "object-inspect": "^1.13.4", + "prettier": "^3.3.3", + "pure-rand": "^7.0.1", + "safe-stable-stringify": "^2.5.0", + "statuses": "^2.0.2", + "url-polyfill": "^1.1.14" + }, + "peerDependencies": { + "@angular/core": ">=17.0.0", + "@tanstack/react-query": "^5.0.0", + "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0", + "solid-js": "^1.6.0", + "svelte": "^4.0.0 || ^5.0.0", + "undici": "^6.19.2", + "vue": "^3.3.0" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@tanstack/react-query": { + "optional": true + }, + "react": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "undici": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/url-polyfill": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", + "integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==", + "license": "MIT" + } + } +} diff --git a/backend/brico-module/spacetimedb/package.json b/backend/brico-module/spacetimedb/package.json new file mode 100644 index 00000000..43e0e4fc --- /dev/null +++ b/backend/brico-module/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "brico-app", + "private": true, + "version": "1.0.0", + "scripts": { + "typecheck": "tsc --noEmit", + "build": "cd .. && spacetime build", + "publish:local": "node ../scripts/publish-local.mjs", + "publish:prod": "cd .. && spacetime publish --server local --yes", + "generate": "cd .. && spacetime generate --lang typescript --out-dir ../../common/bindings/brico-app --module-path ./spacetimedb" + }, + "dependencies": { + "@brico/crafts": "file:../../../common/crafts", + "spacetimedb": "2.10.0" + }, + "devDependencies": { + "typescript": "~5.6.2" + } +} diff --git a/backend/brico-module/spacetimedb/src/index.ts b/backend/brico-module/spacetimedb/src/index.ts new file mode 100644 index 00000000..5490ce44 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/index.ts @@ -0,0 +1,27 @@ +/* + * brico-app — brico's own SpacetimeDB module. + * + * Split by area: + * - accounts: identity/account lifecycle + * - integrations: auth-provider links (BitAuth, Discord, Stelo) and the shared out-of-band link-request flow + * - crafts: craft filter/watch sync and filter sharing + * - notifications: generic, craft-sourced notifications + * - bounties: bounty rules, per-craft overrides and resolved assignments + * - payouts: entitlement ledgers, payment records and manual loyalty rewards + */ + +export {default} from './schema'; + +export * from './reducers/accounts'; +export * from './reducers/integrations'; +export * from './reducers/crafts'; +export * from './reducers/notifications'; +export * from './reducers/bounties'; +export * from './reducers/payouts'; + +export * from './views/accounts'; +export * from './views/integrations'; +export * from './views/crafts'; +export * from './views/notifications'; +export * from './views/bounties'; +export * from './views/payouts'; diff --git a/backend/brico-module/spacetimedb/src/lib/auth.ts b/backend/brico-module/spacetimedb/src/lib/auth.ts new file mode 100644 index 00000000..c74fe8a1 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/lib/auth.ts @@ -0,0 +1,57 @@ +import {CraftError} from '@brico/crafts/errors'; +import type {JwtClaims} from 'spacetimedb/server'; +import {SenderError} from 'spacetimedb/server'; +import type {Ctx} from '../schema'; + +/** + * SpacetimeDB's own OIDC provider. **Shared and multi-tenant** — every app that offers + * SpacetimeAuth login gets ID tokens from this exact issuer URL, distinguished from each other + * only by the `aud` claim (the registered client id). + */ +export const SPACETIMEAUTH_ISSUER = 'https://auth.spacetimedb.com/oidc'; + +/** + * Brico's own registered SpacetimeAuth client id . Must mirror the frontend's + * `CLIENT_ID` (`frontend/src/lib/account/oidc.ts`). + */ +export const SPACETIMEAUTH_CLIENT_ID = 'client_034GOTUp60ggtFOXkImWEk'; + +/** + * Issuers whose tokens may provision or touch a brico account, checked by `requireTrustedJwt` + * below. `localhost` is added specifically only with `publish:local`, not a default. + */ +const ACCEPTED_ISSUERS = [SPACETIMEAUTH_ISSUER]; + +/** + * Validates and returns the caller's JWT claims. + * + * Any reducer that reads claims off `ctx.senderAuth.jwt` beyond `ctx.sender` itself (i.e. anything + * from `fullPayload`, like `linkDiscordViaSpacetimeAuth`) must call this first, not just rely + * on `ensureAccount` having checked it once: a session can *reconnect* with a different token that + * still derives the same `Identity` (same `iss`+`sub`) but carries a different, unchecked `aud` — + * `ensureAccount` passing once at account-creation time says nothing about what token the *current* + * connection actually presented. + */ +export function requireTrustedJwt(ctx: Ctx): JwtClaims { + const jwt = ctx.senderAuth.jwt; + if (jwt === null) throw new SenderError(CraftError.NOT_OIDC_AUTHENTICATED); + if (!ACCEPTED_ISSUERS.includes(jwt.issuer)) { + throw new SenderError(`${CraftError.ISSUER_NOT_ACCEPTED}:${jwt.issuer}`); + } + if (jwt.issuer === SPACETIMEAUTH_ISSUER && !jwt.audience.includes(SPACETIMEAUTH_CLIENT_ID)) { + throw new SenderError(`${CraftError.AUDIENCE_NOT_ACCEPTED}:${jwt.audience.join(',')}`); + } + return jwt; +} + +export function requireServicePrincipal(ctx: Ctx): void { + if (ctx.db.service_principal.identity.find(ctx.sender) === null) { + throw new SenderError(CraftError.NOT_SERVICE_PRINCIPAL); + } +} + +export function requireAccount(ctx: Ctx) { + const row = ctx.db.account.identity.find(ctx.sender); + if (row === null) throw new SenderError(CraftError.NO_BRICO_ACCOUNT); + return row; +} diff --git a/backend/brico-module/spacetimedb/src/lib/bounty.ts b/backend/brico-module/spacetimedb/src/lib/bounty.ts new file mode 100644 index 00000000..ec456073 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/lib/bounty.ts @@ -0,0 +1,26 @@ +import {CraftError} from '@brico/crafts/errors'; +import {BOUNTY_CURRENCIES, MANUALLY_RECORDED_BOUNTY_CURRENCIES} from '@brico/crafts/filter'; +import {SenderError} from 'spacetimedb/server'; + +const BOUNTY_CURRENCY_SET = new Set(BOUNTY_CURRENCIES); +export function validateBountyCurrency(currency: string): void { + if (!BOUNTY_CURRENCY_SET.has(currency)) { + throw new SenderError(`${CraftError.UNKNOWN_CURRENCY}:${currency}`); + } +} + +const MANUAL_BOUNTY_CURRENCY_SET = new Set(MANUALLY_RECORDED_BOUNTY_CURRENCIES); +export function validateManualBountyCurrency(currency: string): void { + if (!MANUAL_BOUNTY_CURRENCY_SET.has(currency)) { + throw new SenderError(`${CraftError.UNKNOWN_CURRENCY}:${currency}`); + } +} + +/** + * Every caller-writable ratio (`craft_bounty_override`, both `bounty_rule` shapes, `loyalty_reward`) + * must be non-negative in both halves. + */ +export function validateRatio(ratioNumerator: bigint, ratioDenominator: bigint): void { + if (ratioDenominator <= 0n) throw new SenderError(CraftError.RATIO_DENOMINATOR_MUST_BE_POSITIVE); + if (ratioNumerator < 0n) throw new SenderError(CraftError.RATIO_NUMERATOR_MUST_NOT_BE_NEGATIVE); +} diff --git a/backend/brico-module/spacetimedb/src/lib/filters.ts b/backend/brico-module/spacetimedb/src/lib/filters.ts new file mode 100644 index 00000000..adfc02d4 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/lib/filters.ts @@ -0,0 +1,20 @@ +import {CraftError} from '@brico/crafts/errors'; +import {type FilterField, validateFilter} from '@brico/crafts/filter'; +import {SenderError} from 'spacetimedb/server'; + +/** + * Parses and structurally validates a `filterJson` payload, throwing `SenderError` on anything + * `@brico/crafts/filter`'s `validateFilter` would reject. + */ +export function requireValidFilterJson(filterJson: string, disallowedFields?: readonly FilterField[]): void { + let parsed: unknown; + try { + parsed = JSON.parse(filterJson); + } catch { + throw new SenderError(CraftError.FILTER_INVALID_JSON); + } + const problems = validateFilter(parsed, 'filter', disallowedFields); + if (problems.length > 0) { + throw new SenderError(`${CraftError.FILTER_INVALID}:${problems.join('; ')}`); + } +} diff --git a/backend/brico-module/spacetimedb/src/lib/sync.ts b/backend/brico-module/spacetimedb/src/lib/sync.ts new file mode 100644 index 00000000..5f95aea3 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/lib/sync.ts @@ -0,0 +1,20 @@ +import type {Timestamp} from 'spacetimedb'; +import type {Ctx} from '../schema'; + +const MAX_CLOCK_SKEW_MICROS = 5n * 60n * 1_000_000n; + +/** + * Clamps a client-supplied `updatedAt` to `ctx.timestamp` if it's more than ~5 minutes ahead of + * the server clock, so a broken client clock can't permanently win every future LWW comparison. + * The client's own timestamp is trusted otherwise (not replaced outright) so a client's offline + * edit ordering is preserved across a multi-device sync. + */ +export function clampClientTimestamp(ctx: Ctx, supplied: Timestamp): Timestamp { + const maxAllowed = ctx.timestamp.microsSinceUnixEpoch + MAX_CLOCK_SKEW_MICROS; + return supplied.microsSinceUnixEpoch > maxAllowed ? ctx.timestamp : supplied; +} + +/** Server-side LWW guard: only a `candidate` at least as new as `existing` may apply. */ +export function isAtLeastAsNew(candidate: Timestamp, existing: Timestamp): boolean { + return candidate.microsSinceUnixEpoch >= existing.microsSinceUnixEpoch; +} diff --git a/backend/brico-module/spacetimedb/src/reducers/accounts.ts b/backend/brico-module/spacetimedb/src/reducers/accounts.ts new file mode 100644 index 00000000..f6dce862 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/reducers/accounts.ts @@ -0,0 +1,112 @@ +// noinspection JSUnusedGlobalSymbols + +import {CraftError, MAX_DISPLAY_NAME_LENGTH} from '@brico/crafts/errors'; +import {SenderError, t} from 'spacetimedb/server'; +import {requireAccount, requireServicePrincipal, requireTrustedJwt} from '../lib/auth'; +import {spacetimedb} from '../schema'; + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +export const init = spacetimedb.init(ctx => { + // The publisher of the module is the first service principal; every other one is granted + // by an existing principal. This avoids a first-call-wins race on a reachable instance. + if (ctx.db.service_principal.identity.find(ctx.sender) === null) { + ctx.db.service_principal.insert({ + identity: ctx.sender, + label: 'module-publisher', + grantedAt: ctx.timestamp, + }); + } +}); + +/** + * Deliberately does NOT create accounts. Real users (not service principals) must + * explicitly call ensure_account to do that. + */ +export const onConnect = spacetimedb.clientConnected(ctx => { + const existing = ctx.db.account.identity.find(ctx.sender); + if (existing !== null) { + ctx.db.account.identity.update({...existing, lastSeenAt: ctx.timestamp}); + } +}); + +export const onDisconnect = spacetimedb.clientDisconnected(ctx => { + const existing = ctx.db.account.identity.find(ctx.sender); + if (existing !== null) { + ctx.db.account.identity.update({...existing, lastSeenAt: ctx.timestamp}); + } +}); + +// --------------------------------------------------------------------------- +// Reducers +// --------------------------------------------------------------------------- + +/** + * Provision the caller's brico account, idempotently. This is the one call a freshly + * logged-in client makes; calling it again just refreshes `lastSeenAt`. + * + * `issuer`/`subject` are read from `ctx.senderAuth.jwt`, never from arguments. + */ +export const ensureAccount = spacetimedb.reducer(ctx => { + const jwt = requireTrustedJwt(ctx); + if (ctx.db.service_principal.identity.find(ctx.sender) !== null) { + throw new SenderError(CraftError.SERVICE_PRINCIPAL_CANNOT_HOLD_ACCOUNT); + } + + const existing = ctx.db.account.identity.find(ctx.sender); + if (existing !== null) { + ctx.db.account.identity.update({...existing, lastSeenAt: ctx.timestamp}); + return; + } + ctx.db.account.insert({ + identity: ctx.sender, + issuer: jwt.issuer, + subject: jwt.subject, + displayName: undefined, + createdAt: ctx.timestamp, + lastSeenAt: ctx.timestamp, + }); +}); + +/** A client renaming itself. */ +export const setDisplayName = spacetimedb.reducer( + {displayName: t.option(t.string())}, + (ctx, {displayName}) => { + const existing = requireAccount(ctx); + const trimmed = displayName?.trim(); + if (trimmed && trimmed.length > MAX_DISPLAY_NAME_LENGTH) { + throw new SenderError(CraftError.DISPLAY_NAME_TOO_LONG); + } + ctx.db.account.identity.update({ + ...existing, + displayName: trimmed === undefined || trimmed === '' ? undefined : trimmed, + }); + } +); + +/** Grant full visibility and write access to another identity (e.g. brico-bot's service identity). */ +export const registerServicePrincipal = spacetimedb.reducer( + {identity: t.identity(), label: t.string()}, + (ctx, {identity, label}) => { + requireServicePrincipal(ctx); + if (ctx.db.service_principal.identity.find(identity) !== null) return; + // The inverse of `ensure_account`'s guard. + if (ctx.db.account.identity.find(identity) !== null) { + throw new SenderError(CraftError.IDENTITY_ALREADY_HAS_ACCOUNT); + } + ctx.db.service_principal.insert({identity, label, grantedAt: ctx.timestamp}); + } +); + +export const revokeServicePrincipal = spacetimedb.reducer( + {identity: t.identity()}, + (ctx, {identity}) => { + requireServicePrincipal(ctx); + if (identity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.SERVICE_PRINCIPAL_CANNOT_REVOKE_SELF); + } + ctx.db.service_principal.identity.delete(identity); + } +); diff --git a/backend/brico-module/spacetimedb/src/reducers/bounties.ts b/backend/brico-module/spacetimedb/src/reducers/bounties.ts new file mode 100644 index 00000000..4c8d1fff --- /dev/null +++ b/backend/brico-module/spacetimedb/src/reducers/bounties.ts @@ -0,0 +1,268 @@ +// noinspection JSUnusedGlobalSymbols + +import {CraftError, MAX_FILTER_JSON_LENGTH} from '@brico/crafts/errors'; +import type {FilterField} from '@brico/crafts/filter'; +import {SenderError, t} from 'spacetimedb/server'; +import {requireAccount, requireServicePrincipal} from '../lib/auth'; +import {validateBountyCurrency, validateRatio} from '../lib/bounty'; +import {requireValidFilterJson} from '../lib/filters'; +import {clampClientTimestamp, isAtLeastAsNew} from '../lib/sync'; +import {spacetimedb} from '../schema'; +import {BountyRuleValue} from '../tables/bounties'; + +// --------------------------------------------------------------------------- +// Bounties & payouts +// --------------------------------------------------------------------------- + +/** + * `payout`/`currency` are excluded here too (see the `bounty_rule` table's doc comment) — a rule + * that could condition on the very bounty it assigns is circular. + */ +const BOUNTY_RULE_DISALLOWED_FIELDS: readonly FilterField[] = ['payout', 'currency']; + +/** + * Create or edit one of the caller's own bounty rules. Editing (including re-editing a + * tombstoned row) clears `deletedAt`, same "un-delete/resurrect" convention as + * `upsertSavedCraftFilter`. + */ +export const upsertBountyRule = spacetimedb.reducer( + {id: t.string(), filterJson: t.string(), value: BountyRuleValue, priority: t.i32(), private: t.bool(), updatedAt: t.timestamp()}, + (ctx, {id, filterJson, value, priority, private: isPrivate, updatedAt}) => { + requireAccount(ctx); + if (filterJson.length > MAX_FILTER_JSON_LENGTH) { + throw new SenderError(CraftError.FILTER_TOO_LARGE); + } + requireValidFilterJson(filterJson, BOUNTY_RULE_DISALLOWED_FIELDS); + validateBountyCurrency(value.value.currency); + if (value.tag === 'flat') { + validateRatio(value.value.ratioNumerator, value.value.ratioDenominator); + } else { + for (const cell of value.value.cells) validateRatio(cell.ratioNumerator, cell.ratioDenominator); + } + + const existing = ctx.db.bounty_rule.id.find(id); + if (existing !== null && !existing.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.RULE_BELONGS_TO_ANOTHER_ACCOUNT); + } + + const clampedUpdatedAt = clampClientTimestamp(ctx, updatedAt); + if (existing !== null && !isAtLeastAsNew(clampedUpdatedAt, existing.updatedAt)) return; + + if (existing === null) { + ctx.db.bounty_rule.insert({ + id, + accountIdentity: ctx.sender, + priority, + filterJson, + value, + private: isPrivate, + createdAt: ctx.timestamp, + updatedAt: clampedUpdatedAt, + deletedAt: undefined, + }); + } else { + ctx.db.bounty_rule.id.update({ + ...existing, + priority, + filterJson, + value, + private: isPrivate, + updatedAt: clampedUpdatedAt, + deletedAt: undefined, + }); + } + } +); + +/** Tombstones the caller's own bounty rule (no hard delete). */ +export const deleteBountyRule = spacetimedb.reducer( + {id: t.string(), deletedAt: t.timestamp()}, + (ctx, {id, deletedAt}) => { + requireAccount(ctx); + const existing = ctx.db.bounty_rule.id.find(id); + if (existing === null) return; + if (!existing.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.RULE_BELONGS_TO_ANOTHER_ACCOUNT); + } + + const clampedDeletedAt = clampClientTimestamp(ctx, deletedAt); + if (!isAtLeastAsNew(clampedDeletedAt, existing.updatedAt)) return; + + ctx.db.bounty_rule.id.update({...existing, updatedAt: clampedDeletedAt, deletedAt: clampedDeletedAt}); + } +); + +/** + * Rewrites `priority` to `0..N-1` for the caller's own rules, in the given order — this is what + * actually enforces "unique priority per account" in practice, rather than a database-level + * constraint. Ids in `orderedIds` that don't exist or belong to another account are silently + * skipped, so a stale client-side list can't corrupt another account's rules. Always operates over + * *all* of the caller's rules, not just the ones named in `orderedIds`: any of the caller's own + * rules omitted from `orderedIds` are appended afterward (keeping their relative priority order), + * so a partial list from the client can never leave two of the caller's own rules sharing a + * priority. + */ +export const reorderBountyRules = spacetimedb.reducer( + {orderedIds: t.array(t.string())}, + (ctx, {orderedIds}) => { + requireAccount(ctx); + + const own = [...ctx.db.bounty_rule.accountIdentity.filter(ctx.sender)].sort((a, b) => a.priority - b.priority); + const byId = new Map(own.map(rule => [rule.id, rule])); + + const seen = new Set(); + const ordered: typeof own = []; + for (const id of orderedIds) { + const rule = byId.get(id); + if (rule === undefined || seen.has(id)) continue; + seen.add(id); + ordered.push(rule); + } + for (const rule of own) { + if (!seen.has(rule.id)) ordered.push(rule); + } + + ordered.forEach((rule, index) => { + if (rule.priority === index) return; + ctx.db.bounty_rule.id.update({...rule, priority: index, updatedAt: ctx.timestamp}); + }); + } +); + +/** + * Create or edit the caller's own single-craft bounty override. No craft-ownership check at the + * module level: only `brico-bot` can see prism's craft/claim ownership, so an invalid override + * just never produces a `craft_bounty_assignment` when the bot evaluates it. + */ +export const upsertCraftBountyOverride = spacetimedb.reducer( + { + craftId: t.u64(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + currency: t.string(), + private: t.bool(), + updatedAt: t.timestamp(), + }, + (ctx, {craftId, ratioNumerator, ratioDenominator, currency, private: isPrivate, updatedAt}) => { + requireAccount(ctx); + validateBountyCurrency(currency); + validateRatio(ratioNumerator, ratioDenominator); + + const existing = ctx.db.craft_bounty_override.craftId.find(craftId); + if (existing !== null && !existing.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.OVERRIDE_BELONGS_TO_ANOTHER_ACCOUNT); + } + + const clampedUpdatedAt = clampClientTimestamp(ctx, updatedAt); + if (existing !== null && !isAtLeastAsNew(clampedUpdatedAt, existing.updatedAt)) return; + + if (existing === null) { + ctx.db.craft_bounty_override.insert({ + craftId, + accountIdentity: ctx.sender, + ratioNumerator, + ratioDenominator, + currency, + private: isPrivate, + updatedAt: clampedUpdatedAt, + }); + } else { + ctx.db.craft_bounty_override.craftId.update({ + ...existing, + ratioNumerator, + ratioDenominator, + currency, + private: isPrivate, + updatedAt: clampedUpdatedAt, + }); + } + } +); + +/** Deletes the caller's own override — a hard delete, unlike `bounty_rule`, since there's no sync backlog to reconcile. */ +export const deleteCraftBountyOverride = spacetimedb.reducer( + {craftId: t.u64()}, + (ctx, {craftId}) => { + requireAccount(ctx); + const existing = ctx.db.craft_bounty_override.craftId.find(craftId); + if (existing === null) return; + if (!existing.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.OVERRIDE_BELONGS_TO_ANOTHER_ACCOUNT); + } + ctx.db.craft_bounty_override.craftId.delete(craftId); + } +); + +/** + * Written by `brico-bot` once it resolves a craft's bounty (override beats rule, priority order, + * personal owner beats claim owner) — never directly by a browser, same reasoning as + * `postCraftNotification`. Always a full upsert: the bot recomputes the whole row from scratch. + * + * `private` (sourced from whichever rule/override matched) selects which of the two mutually + * exclusive assignment tables gets the row — the other table's row for this craft, if any, is + * deleted, so a craft never has a resolved assignment in both at once (e.g. a rule that used to be + * public and is now marked private). + */ +export const assignCraftBounty = spacetimedb.reducer( + { + craftId: t.u64(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + currency: t.string(), + assignedByAccountIdentity: t.identity(), + private: t.bool(), + assignedAt: t.timestamp(), + updatedAt: t.timestamp(), + }, + (ctx, {craftId, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, private: isPrivate, assignedAt, updatedAt}) => { + requireServicePrincipal(ctx); + validateBountyCurrency(currency); + if (ctx.db.account.identity.find(assignedByAccountIdentity) === null) { + throw new SenderError(CraftError.UNKNOWN_BRICO_ACCOUNT); + } + + if (isPrivate) { + if (ctx.db.craft_bounty_assignment.craftId.find(craftId) !== null) { + ctx.db.craft_bounty_assignment.craftId.delete(craftId); + } + const existing = ctx.db.craft_private_bounty_assignment.craftId.find(craftId); + if (existing === null) { + ctx.db.craft_private_bounty_assignment.insert({ + craftId, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, assignedAt, updatedAt, + }); + } else { + ctx.db.craft_private_bounty_assignment.craftId.update({ + ...existing, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, assignedAt, updatedAt, + }); + } + } else { + if (ctx.db.craft_private_bounty_assignment.craftId.find(craftId) !== null) { + ctx.db.craft_private_bounty_assignment.craftId.delete(craftId); + } + const existing = ctx.db.craft_bounty_assignment.craftId.find(craftId); + if (existing === null) { + ctx.db.craft_bounty_assignment.insert({ + craftId, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, assignedAt, updatedAt, + }); + } else { + ctx.db.craft_bounty_assignment.craftId.update({ + ...existing, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, assignedAt, updatedAt, + }); + } + } + } +); + +/** Written by `brico-bot` when a craft no longer resolves to any override or matching rule, or the craft no longer exists in prism (post-24-hour-expiry). Clears both the public and private assignment tables — the bot doesn't need to know which one held the row. */ +export const clearCraftBounty = spacetimedb.reducer( + {craftId: t.u64()}, + (ctx, {craftId}) => { + requireServicePrincipal(ctx); + if (ctx.db.craft_bounty_assignment.craftId.find(craftId) !== null) { + ctx.db.craft_bounty_assignment.craftId.delete(craftId); + } + if (ctx.db.craft_private_bounty_assignment.craftId.find(craftId) !== null) { + ctx.db.craft_private_bounty_assignment.craftId.delete(craftId); + } + } +); diff --git a/backend/brico-module/spacetimedb/src/reducers/crafts.ts b/backend/brico-module/spacetimedb/src/reducers/crafts.ts new file mode 100644 index 00000000..2211d17a --- /dev/null +++ b/backend/brico-module/spacetimedb/src/reducers/crafts.ts @@ -0,0 +1,257 @@ +// noinspection JSUnusedGlobalSymbols + +import {CraftError, MAX_FILTER_JSON_LENGTH, MAX_FILTER_NAME_LENGTH, MAX_SHARED_FILTER_IDS} from '@brico/crafts/errors'; +import type {Random} from 'spacetimedb/server'; +import {SenderError, t} from 'spacetimedb/server'; +import {requireAccount} from '../lib/auth'; +import {requireValidFilterJson} from '../lib/filters'; +import {clampClientTimestamp, isAtLeastAsNew} from '../lib/sync'; +import {spacetimedb} from '../schema'; + +// --------------------------------------------------------------------------- +// Craft filter/watch sync +// --------------------------------------------------------------------------- + +/** + * Create or edit the caller's own saved filter. Editing (including re-editing a tombstoned row) + * clears `deletedAt` — this is the "un-delete/resurrect" case the merge algorithm relies on. + */ +export const upsertSavedCraftFilter = spacetimedb.reducer( + {id: t.string(), name: t.string(), filterJson: t.string(), updatedAt: t.timestamp()}, + (ctx, {id, name, filterJson, updatedAt}) => { + requireAccount(ctx); + if (name.length > MAX_FILTER_NAME_LENGTH) { + throw new SenderError(CraftError.FILTER_NAME_TOO_LONG); + } + if (filterJson.length > MAX_FILTER_JSON_LENGTH) { + throw new SenderError(CraftError.FILTER_TOO_LARGE); + } + requireValidFilterJson(filterJson); + + const existing = ctx.db.saved_craft_filter.id.find(id); + if (existing !== null && !existing.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.FILTER_BELONGS_TO_ANOTHER_ACCOUNT); + } + + const clampedUpdatedAt = clampClientTimestamp(ctx, updatedAt); + if (existing !== null && !isAtLeastAsNew(clampedUpdatedAt, existing.updatedAt)) return; + + if (existing === null) { + ctx.db.saved_craft_filter.insert({ + id, + accountIdentity: ctx.sender, + name, + filterJson, + createdAt: ctx.timestamp, + updatedAt: clampedUpdatedAt, + deletedAt: undefined, + }); + } else { + ctx.db.saved_craft_filter.id.update({ + ...existing, + name, + filterJson, + updatedAt: clampedUpdatedAt, + deletedAt: undefined, + }); + } + } +); + +/** + * Tombstones the caller's own saved filter (no hard delete — see the table's doc comment) and + * cascades the tombstone to its watch row, if any, so a deleted filter doesn't leave a dangling + * watch behind. + */ +export const deleteSavedCraftFilter = spacetimedb.reducer( + {id: t.string(), deletedAt: t.timestamp()}, + (ctx, {id, deletedAt}) => { + requireAccount(ctx); + const existing = ctx.db.saved_craft_filter.id.find(id); + if (existing === null) return; + if (!existing.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.FILTER_BELONGS_TO_ANOTHER_ACCOUNT); + } + + const clampedDeletedAt = clampClientTimestamp(ctx, deletedAt); + if (!isAtLeastAsNew(clampedDeletedAt, existing.updatedAt)) return; + + ctx.db.saved_craft_filter.id.update({...existing, updatedAt: clampedDeletedAt, deletedAt: clampedDeletedAt}); + + const watch = ctx.db.craft_filter_watch.filterId.find(id); + if (watch !== null && watch.deletedAt === undefined) { + ctx.db.craft_filter_watch.filterId.update({...watch, updatedAt: clampedDeletedAt, deletedAt: clampedDeletedAt}); + } + } +); + +/** + * Create or edit the watch on one of the caller's own saved filters. Requires the referenced + * `saved_craft_filter` to exist and belong to the caller — a watch can't outlive or outrun its + * filter's ownership. + */ +export const upsertCraftFilterWatch = spacetimedb.reducer( + {filterId: t.string(), added: t.bool(), finished: t.bool(), removed: t.bool(), updatedAt: t.timestamp()}, + (ctx, {filterId, added, finished, removed, updatedAt}) => { + requireAccount(ctx); + + const filter = ctx.db.saved_craft_filter.id.find(filterId); + if (filter === null || !filter.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.UNKNOWN_SAVED_FILTER); + } + + const existing = ctx.db.craft_filter_watch.filterId.find(filterId); + if (existing !== null && !existing.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.WATCH_BELONGS_TO_ANOTHER_ACCOUNT); + } + + const clampedUpdatedAt = clampClientTimestamp(ctx, updatedAt); + if (existing !== null && !isAtLeastAsNew(clampedUpdatedAt, existing.updatedAt)) return; + + if (existing === null) { + ctx.db.craft_filter_watch.insert({ + filterId, + accountIdentity: ctx.sender, + added, + finished, + removed, + updatedAt: clampedUpdatedAt, + deletedAt: undefined, + }); + } else { + ctx.db.craft_filter_watch.filterId.update({ + ...existing, + added, + finished, + removed, + updatedAt: clampedUpdatedAt, + deletedAt: undefined, + }); + } + } +); + +/** Tombstones the caller's own watch row (no hard delete). */ +export const deleteCraftFilterWatch = spacetimedb.reducer( + {filterId: t.string(), deletedAt: t.timestamp()}, + (ctx, {filterId, deletedAt}) => { + requireAccount(ctx); + const existing = ctx.db.craft_filter_watch.filterId.find(filterId); + if (existing === null) return; + if (!existing.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.WATCH_BELONGS_TO_ANOTHER_ACCOUNT); + } + + const clampedDeletedAt = clampClientTimestamp(ctx, deletedAt); + if (!isAtLeastAsNew(clampedDeletedAt, existing.updatedAt)) return; + + ctx.db.craft_filter_watch.filterId.update({...existing, updatedAt: clampedDeletedAt, deletedAt: clampedDeletedAt}); + } +); + +// --------------------------------------------------------------------------- +// Saved filter sharing +// --------------------------------------------------------------------------- +// +// Two **procedures**, not reducers — a reducer cannot return a value to its caller, and the whole point +// of `createSharedFilters` is handing the freshly-minted code straight back. Both do their +// account/ownership checks and their single write inside one `ctx.withTx(...)` — a procedure's +// outer `ProcedureCtx` has no `.db`/`.senderAuth` of its own, only the `TransactionCtx` a +// `withTx` callback receives does (it's a plain `ReducerCtx` under a different name). + +/** 30 characters: digits 2-9 and A-Z, minus the visually-ambiguous `0 1 7 I L O`. */ +const SHARE_CODE_ALPHABET = '2345689ABCDEFGHJKMNPQRSTUVWXYZ'; +const SHARE_CODE_LENGTH = 6; + +/** Six characters from `SHARE_CODE_ALPHABET`, using the procedure's own deterministic RNG. */ +function generateShareCode(random: Random): string { + let code = ''; + for (let i = 0; i < SHARE_CODE_LENGTH; i++) { + code += SHARE_CODE_ALPHABET[random.integerInRange(0, SHARE_CODE_ALPHABET.length - 1)]; + } + return code; +} + +const CreateSharedFiltersResult = t.object('CreateSharedFiltersResult', { + code: t.string(), +}); + +/** + * Mints a share code for one or more of the caller's own saved filters. Ownership of every id in + * `filterIds` is checked inside the same transaction that inserts the `shared_filter` row, so a + * filter deleted or transferred mid-call can't slip a code past the check. + */ +export const createSharedFilters = spacetimedb.procedure( + {filterIds: t.array(t.string())}, + CreateSharedFiltersResult, + (ctx, {filterIds}) => { + if (filterIds.length === 0) throw new SenderError(CraftError.NO_FILTERS_SELECTED); + if (filterIds.length > MAX_SHARED_FILTER_IDS) { + throw new SenderError(CraftError.TOO_MANY_FILTERS_SELECTED); + } + + return ctx.withTx(tx => { + if (tx.db.account.identity.find(ctx.sender) === null) { + throw new SenderError(CraftError.NO_BRICO_ACCOUNT); + } + for (const id of filterIds) { + const filter = tx.db.saved_craft_filter.id.find(id); + if (filter === null || !filter.accountIdentity.isEqual(ctx.sender) || filter.deletedAt !== undefined) { + throw new SenderError(CraftError.UNKNOWN_SAVED_FILTER); + } + } + + // Collisions are astronomically unlikely at 30^6 codes, but cheap to guard against outright + // rather than ever hand back a code that silently overwrites someone else's share. + let code = generateShareCode(tx.random); + for (let attempt = 0; attempt < 5 && tx.db.shared_filter.code.find(code) !== null; attempt++) { + code = generateShareCode(tx.random); + } + if (tx.db.shared_filter.code.find(code) !== null) { + throw new SenderError(CraftError.SHARE_CODE_GENERATION_FAILED); + } + + tx.db.shared_filter.insert({ + code, + accountIdentity: ctx.sender, + filterIds: [...filterIds], + createdAt: tx.timestamp, + }); + return {code}; + }); + } +); + +const SharedFilterRow = t.object('SharedFilterRow', { + name: t.string(), + filterJson: t.string(), +}); + +const ReadSharedFiltersResult = t.object('ReadSharedFiltersResult', { + filters: t.array(SharedFilterRow), +}); + +/** + * Redeems a share code into the `{name, filterJson}` pairs it points to. The filter ids themselves + * never leave this procedure: only the saved filter's own content comes back, so a recipient can + * import it but never learn (or reference) the sharer's `saved_craft_filter.id`. + * This is callable anonymously to allow redemption of share links without an account. + */ +export const readSharedFilters = spacetimedb.procedure( + {code: t.string()}, + ReadSharedFiltersResult, + (ctx, {code}) => { + return ctx.withTx(tx => { + const shared = tx.db.shared_filter.code.find(code); + if (shared === null) throw new SenderError(CraftError.UNKNOWN_SHARE_CODE); + + const filters: { name: string; filterJson: string }[] = []; + for (const id of shared.filterIds) { + const filter = tx.db.saved_craft_filter.id.find(id); + if (filter === null/* || filter.deletedAt !== undefined*/) continue; + filters.push({name: filter.name, filterJson: filter.filterJson}); + } + return {filters}; + }); + } +); diff --git a/backend/brico-module/spacetimedb/src/reducers/integrations.ts b/backend/brico-module/spacetimedb/src/reducers/integrations.ts new file mode 100644 index 00000000..da4ef462 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/reducers/integrations.ts @@ -0,0 +1,237 @@ +// noinspection JSUnusedGlobalSymbols + +import {CraftError} from '@brico/crafts/errors'; +import type {Identity} from 'spacetimedb'; +import {ScheduleAt} from 'spacetimedb'; +import {SenderError, t} from 'spacetimedb/server'; +import {requireAccount, requireServicePrincipal, requireTrustedJwt} from '../lib/auth'; +import type {Ctx} from '../schema'; +import {spacetimedb} from '../schema'; +import {integration_link_request} from '../tables/integrations'; + +/** + * Shared body of `linkIntegration` and `tryFinalizeIntegrationLink` below. Enforces "at most one + * active brico account per (provider, externalId)". + */ +function linkIntegrationInternal( + ctx: Ctx, + accountIdentity: Identity, + provider: string, + externalId: string, + externalHandle: string | undefined, +): void { + const account = ctx.db.account.identity.find(accountIdentity); + if (account === null) { + throw new SenderError(CraftError.UNKNOWN_BRICO_ACCOUNT); + } + + for (const row of ctx.db.linked_integration.by_provider_external_id.filter([ + provider, + externalId, + ])) { + if (row.revokedAt !== undefined) continue; + // Re-linking the same account to its own already-active link is a no-op (keeps `linkedAt`); + // any other account holding an active link on this external id is the attribution hole above. + if (row.accountIdentity.isEqual(accountIdentity)) return; + throw new SenderError(CraftError.EXTERNAL_ACCOUNT_ALREADY_LINKED); + } + + if (!externalHandle?.trim()) externalHandle = undefined; + + ctx.db.linked_integration.insert({ + id: 0n, + accountIdentity, + provider, + externalId, + externalHandle, + linkedAt: ctx.timestamp, + revokedAt: undefined, + }); + + // UX convenience, not identity: set display name on first time link. + if (provider === 'bitcraft-ea2' && externalHandle && account.displayName === undefined) { + const activeBitcraftLinks = [...ctx.db.linked_integration.accountIdentity.filter(accountIdentity)] + .filter(link => link.provider === 'bitcraft-ea2' && link.revokedAt === undefined); + if (activeBitcraftLinks.length === 1) { + ctx.db.account.identity.update({...account, displayName: externalHandle}); + } + } +} + +/** + * Written by brico-bot after it has verified control of the external account out-of-band. + */ +export const linkIntegration = spacetimedb.reducer( + { + accountIdentity: t.identity(), + provider: t.string(), + externalId: t.string(), + externalHandle: t.string(), + }, + (ctx, {accountIdentity, provider, externalId, externalHandle}) => { + requireServicePrincipal(ctx); + linkIntegrationInternal(ctx, accountIdentity, provider, externalId, externalHandle); + } +); + +/** Revocation is soft, so the link history survives. Callable by the owner or by brico-bot. */ +export const unlinkIntegration = spacetimedb.reducer( + {id: t.u64()}, + (ctx, {id}) => { + const row = ctx.db.linked_integration.id.find(id); + if (row === null) throw new SenderError(CraftError.UNKNOWN_INTEGRATION_LINK); + if (!row.accountIdentity.isEqual(ctx.sender)) requireServicePrincipal(ctx); + if (row.revokedAt !== undefined) return; + ctx.db.linked_integration.id.update({...row, revokedAt: ctx.timestamp}); + } +); + +// --------------------------------------------------------------------------- +// Integration link requests — shared out-of-band verification correlation +// --------------------------------------------------------------------------- + +/** Long enough to survive an OIDC redirect round trip, short enough that an abandoned attempt doesn't linger. */ +const LINK_TTL_MICROS = 15n * 60n * 1_000_000n; + +type IntegrationLinkRequestRow = NonNullable< + ReturnType +>; + +/** + * Row is auto-deleted once this runs (scheduled-table semantics); nothing else to do. + * `onSchedule: integration_link_request` (rather than the table's own, deprecated `scheduled` + * option) is what wires this reducer to the table without `tables/integrations.ts` needing to + * import anything from this file. + */ +export const expireIntegrationLinkRequest = spacetimedb.reducer( + {onSchedule: integration_link_request}, + {request: integration_link_request.rowType}, + () => { + } +); + +/** + * Once both `accountIdentity` and `externalId` are known, finalizes into a `linked_integration` + * row and deletes the pending request; otherwise persists whichever side the caller just filled + * in. Called by both completion paths below (`claimIntegrationLink`, `noteIntegrationLinkExternal`) + * — the one thing that differs between a browser-initiated and a bot-initiated request is *which* + * side was filled in at creation, not how finalization works. + */ +function tryFinalizeIntegrationLink(ctx: Ctx, request: IntegrationLinkRequestRow): void { + if (request.accountIdentity !== undefined && request.externalId !== undefined) { + linkIntegrationInternal( + ctx, + request.accountIdentity, + request.provider, + request.externalId, + request.externalHandle, + ); + ctx.db.integration_link_request.id.delete(request.id); + } else { + ctx.db.integration_link_request.id.update(request); + } +} + +/** + * Browser side, already logged in — used when the flow starts on brico (BitAuth redirect, Stelo + * wallet linking): the browser generates `code`, redirects out to `brico-bot` for verification, + * and `noteIntegrationLinkExternal` below finalizes once the bot round-trips back. + */ +export const beginIntegrationLink = spacetimedb.reducer( + {code: t.string(), provider: t.string()}, + (ctx, {code, provider}) => { + requireAccount(ctx); + if (ctx.db.integration_link_request.code.find(code) !== null) { + throw new SenderError(CraftError.LINK_CODE_ALREADY_IN_USE); + } + ctx.db.integration_link_request.insert({ + id: 0n, + scheduledAt: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + LINK_TTL_MICROS), + code, + provider, + accountIdentity: ctx.sender, + externalId: undefined, + externalHandle: undefined, + createdAt: ctx.timestamp, + }); + } +); + +/** + * Bot side — used when the flow starts on the external system (e.g. a Discord slash command that + * already knows the caller's Discord id but not their brico account yet). `claimIntegrationLink` + * below finalizes once the user opens the link brico-bot hands back and is logged in. + */ +export const beginIntegrationLinkForExternal = spacetimedb.reducer( + { + code: t.string(), + provider: t.string(), + externalId: t.string(), + externalHandle: t.option(t.string()), + }, + (ctx, {code, provider, externalId, externalHandle}) => { + requireServicePrincipal(ctx); + if (ctx.db.integration_link_request.code.find(code) !== null) { + throw new SenderError(CraftError.LINK_CODE_ALREADY_IN_USE); + } + ctx.db.integration_link_request.insert({ + id: 0n, + scheduledAt: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + LINK_TTL_MICROS), + code, + provider, + accountIdentity: undefined, + externalId, + externalHandle, + createdAt: ctx.timestamp, + }); + } +); + +/** Browser fills in `accountIdentity` for a bot-initiated request (Discord `/link` redemption). */ +export const claimIntegrationLink = spacetimedb.reducer( + {code: t.string()}, + (ctx, {code}) => { + requireAccount(ctx); + const request = ctx.db.integration_link_request.code.find(code); + if (request === null) throw new SenderError(CraftError.LINK_CODE_UNKNOWN_OR_EXPIRED); + if (request.accountIdentity !== undefined) { + throw new SenderError(CraftError.LINK_CODE_ALREADY_CLAIMED); + } + tryFinalizeIntegrationLink(ctx, {...request, accountIdentity: ctx.sender}); + } +); + +/** Bot fills in `externalId` for a browser-initiated request (BitAuth callback, Stelo confirmation). */ +export const noteIntegrationLinkExternal = spacetimedb.reducer( + {code: t.string(), externalId: t.string(), externalHandle: t.option(t.string())}, + (ctx, {code, externalId, externalHandle}) => { + requireServicePrincipal(ctx); + const request = ctx.db.integration_link_request.code.find(code); + if (request === null) throw new SenderError(CraftError.LINK_CODE_UNKNOWN_OR_EXPIRED); + if (request.externalId !== undefined) { + throw new SenderError(CraftError.LINK_CODE_ALREADY_RESOLVED); + } + tryFinalizeIntegrationLink(ctx, {...request, externalId, externalHandle}); + } +); + +/** + * Discord-login shortcut: when a user's SpacetimeAuth login method is Discord, the ID token that + * authenticated this very connection already carries the Discord snowflake (`provider_id`) and + * display name (`preferred_username`). + */ +export const linkDiscordViaSpacetimeAuth = spacetimedb.reducer(ctx => { + requireAccount(ctx); + const claims = requireTrustedJwt(ctx).fullPayload; + if (claims.login_method !== 'discord') { + throw new SenderError(CraftError.LOGIN_NOT_VIA_DISCORD); + } + const externalId = claims.provider_id; + if (typeof externalId !== 'string' || externalId === '') { + throw new SenderError(CraftError.DISCORD_CLAIMS_MISSING_PROVIDER_ID); + } + const externalHandle = typeof claims.preferred_username === 'string' + ? claims.preferred_username + : undefined; + linkIntegrationInternal(ctx, ctx.sender, 'discord', externalId, externalHandle); +}); diff --git a/backend/brico-module/spacetimedb/src/reducers/notifications.ts b/backend/brico-module/spacetimedb/src/reducers/notifications.ts new file mode 100644 index 00000000..f002201e --- /dev/null +++ b/backend/brico-module/spacetimedb/src/reducers/notifications.ts @@ -0,0 +1,74 @@ +// noinspection JSUnusedGlobalSymbols + +import {CraftError} from '@brico/crafts/errors'; +import {SenderError, t} from 'spacetimedb/server'; +import {requireAccount, requireServicePrincipal} from '../lib/auth'; +import {spacetimedb} from '../schema'; +import {CraftNotificationPayload} from '../tables/notifications'; + +// --------------------------------------------------------------------------- +// Generic notifications, craft-sourced +// --------------------------------------------------------------------------- + +const CRAFT_NOTIFICATION_EVENT_KINDS = new Set(['added', 'finished', 'removed']); + +/** + * Written by `brico-bot` once a watch fires (`notification-sink.ts`). Kept as the one + * craft-specific entry point; a future watchable type gets its own `postXNotification` + * reducer, all inserting into this same `notification` table. + */ +export const postCraftNotification = spacetimedb.reducer( + {accountIdentity: t.identity(), payload: CraftNotificationPayload}, + (ctx, {accountIdentity, payload}) => { + requireServicePrincipal(ctx); + if (!CRAFT_NOTIFICATION_EVENT_KINDS.has(payload.eventKind)) { + throw new SenderError(`${CraftError.UNKNOWN_EVENT_KIND}:${payload.eventKind}`); + } + if (ctx.db.account.identity.find(accountIdentity) === null) { + throw new SenderError(CraftError.UNKNOWN_BRICO_ACCOUNT); + } + + ctx.db.notification.insert({ + id: 0n, + accountIdentity, + payload: {tag: 'craft', value: payload}, + createdAt: ctx.timestamp, + readAt: undefined, + }); + } +); + +/** Marks one notification read. Callable by its owner or by brico-bot, modeled on `unlinkIntegration`. */ +export const markNotificationRead = spacetimedb.reducer( + {id: t.u64()}, + (ctx, {id}) => { + const row = ctx.db.notification.id.find(id); + if (row === null) return; + if (!row.accountIdentity.isEqual(ctx.sender)) requireServicePrincipal(ctx); + if (row.readAt !== undefined) return; + ctx.db.notification.id.update({...row, readAt: ctx.timestamp}); + } +); + +/** Marks every one of the caller's own notifications read. */ +export const markAllNotificationsRead = spacetimedb.reducer(ctx => { + requireAccount(ctx); + for (const row of ctx.db.notification.accountIdentity.filter(ctx.sender)) { + if (row.readAt === undefined) { + ctx.db.notification.id.update({...row, readAt: ctx.timestamp}); + } + } +}); + +/** Hard-deletes one or more notifications. */ +export const deleteNotification = spacetimedb.reducer( + {ids: t.array(t.u64())}, + (ctx, {ids}) => { + for (const id of ids) { + const row = ctx.db.notification.id.find(id); + if (row === null) continue; + if (!row.accountIdentity.isEqual(ctx.sender)) requireServicePrincipal(ctx); + ctx.db.notification.id.delete(id); + } + } +); diff --git a/backend/brico-module/spacetimedb/src/reducers/payouts.ts b/backend/brico-module/spacetimedb/src/reducers/payouts.ts new file mode 100644 index 00000000..b7fe1956 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/reducers/payouts.ts @@ -0,0 +1,246 @@ +// noinspection JSUnusedGlobalSymbols + +import {CraftError} from '@brico/crafts/errors'; +import type {Identity, Timestamp} from 'spacetimedb'; +import {SenderError, t} from 'spacetimedb/server'; +import {requireAccount, requireServicePrincipal} from '../lib/auth'; +import {validateBountyCurrency, validateManualBountyCurrency, validateRatio} from '../lib/bounty'; +import {clampClientTimestamp, isAtLeastAsNew} from '../lib/sync'; +import type {Ctx} from '../schema'; +import {spacetimedb} from '../schema'; + +/** The caller's own rows for one `(payeePlayerId, currency)` pair. `loyalty_reward` rows are + * always looked up scoped to `ctx.sender` via `payerAccountIdentity`, so there is no cross-account row to + * accidentally target and no separate "belongs to another account" check needed. + */ +function findOwnLoyaltyReward(ctx: Ctx, payeePlayerId: bigint, currency: string) { + return [...ctx.db.loyalty_reward.payerAccountIdentity.filter(ctx.sender)] + .find(row => row.payeePlayerId === payeePlayerId && row.currency === currency) ?? null; +} + +/** + * Create or edit the caller's own payout multiplier for one payee/currency. + */ +export const upsertLoyaltyReward = spacetimedb.reducer( + { + payeePlayerId: t.u64(), + currency: t.string(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + updatedAt: t.timestamp(), + }, + (ctx, {payeePlayerId, currency, ratioNumerator, ratioDenominator, updatedAt}) => { + requireAccount(ctx); + validateBountyCurrency(currency); + validateRatio(ratioNumerator, ratioDenominator); + // A loyalty reward is a *reward* — it must only ever boost a payout, never reduce it, so the + // multiplier itself (ratioNumerator/ratioDenominator) must be at least 1. + if (ratioNumerator < ratioDenominator) throw new SenderError(CraftError.LOYALTY_MULTIPLIER_TOO_SMALL); + + const existing = findOwnLoyaltyReward(ctx, payeePlayerId, currency); + const clampedUpdatedAt = clampClientTimestamp(ctx, updatedAt); + if (existing !== null && !isAtLeastAsNew(clampedUpdatedAt, existing.updatedAt)) return; + + if (existing === null) { + ctx.db.loyalty_reward.insert({ + id: 0n, + payerAccountIdentity: ctx.sender, + payeePlayerId, + currency, + ratioNumerator, + ratioDenominator, + updatedAt: clampedUpdatedAt, + }); + } else { + ctx.db.loyalty_reward.id.update({...existing, ratioNumerator, ratioDenominator, updatedAt: clampedUpdatedAt}); + } + } +); + +/** Hard deletes the caller's own loyalty reward for one payee/currency. */ +export const deleteLoyaltyReward = spacetimedb.reducer( + {payeePlayerId: t.u64(), currency: t.string()}, + (ctx, {payeePlayerId, currency}) => { + requireAccount(ctx); + const existing = findOwnLoyaltyReward(ctx, payeePlayerId, currency); + if (existing === null) return; + ctx.db.loyalty_reward.id.delete(existing.id); + } +); + +/** + * Adds `delta` to the (payer, payee, currency) row of `bounty_entitlement_total`, inserting a new + * row at `delta` if none exists yet — the atomic "update the total" half of + * `upsertCraftBountyEntitlement` below. Looked up via `by_payer_payee` and filtered to `currency` + * in JS rather than a dedicated 3-column index, since uniqueness on the triple is enforced here, + * not by the database (see the table's doc comment). + */ +function addToEntitlementTotal( + ctx: Ctx, + payerAccountIdentity: Identity, + payeePlayerId: bigint, + currency: string, + delta: bigint, + deltaEffort: bigint, + updatedAt: Timestamp, +): void { + const existing = [...ctx.db.bounty_entitlement_total.by_payer_payee.filter([payerAccountIdentity, payeePlayerId])] + .find(row => row.currency === currency); + if (existing === undefined) { + ctx.db.bounty_entitlement_total.insert({id: 0n, payerAccountIdentity, payeePlayerId, currency, total: delta, totalEffort: deltaEffort, updatedAt}); + } else { + ctx.db.bounty_entitlement_total.id.update({...existing, total: existing.total + delta, totalEffort: existing.totalEffort + deltaEffort, updatedAt}); + } +} + +/** + * Written by `brico-bot` whenever `computeEntitlement` produces a new cumulative total for a + * (craft, contributor, currency) triple — i.e. whenever `craft_contribution` changes for a craft + * that has a `craft_bounty_assignment`. Also folds the *delta* since that triple's last-recorded + * `entitledTotal` into `bounty_entitlement_total`'s running (payer, payee, currency) sum, keyed off + * the bounty's current `assignedByAccountIdentity` — so if a craft's bounty is ever reassigned to a + * different payer mid-craft, effort already recorded under the old payer stays theirs, and only + * new effort from here on accrues to whoever pays now. That craft must have an assignment, since + * the bot only ever calls this for a craft that has one. + */ +export const upsertCraftBountyEntitlement = spacetimedb.reducer( + { + craftId: t.u64(), + playerId: t.u64(), + currency: t.string(), + lastAssignedEffort: t.i64(), + entitledTotal: t.i64(), + updatedAt: t.timestamp(), + }, + (ctx, {craftId, playerId, currency, lastAssignedEffort, entitledTotal, updatedAt}) => { + requireServicePrincipal(ctx); + + const assignment = ctx.db.craft_bounty_assignment.craftId.find(craftId); + if (assignment === null) throw new SenderError(CraftError.NO_BOUNTY_ASSIGNMENT); + + const existing = [...ctx.db.craft_bounty_entitlement.by_craft_player_currency.filter([craftId, playerId, currency])][0] ?? null; + const deltaCurrency = entitledTotal - (existing?.entitledTotal ?? 0n); + const deltaEffort = lastAssignedEffort - (existing?.lastAssignedEffort ?? 0n); + + if (existing === null) { + ctx.db.craft_bounty_entitlement.insert({id: 0n, craftId, playerId, currency, lastAssignedEffort, entitledTotal, updatedAt}); + } else { + ctx.db.craft_bounty_entitlement.id.update({...existing, lastAssignedEffort, entitledTotal, updatedAt}); + } + + if (deltaCurrency !== 0n || deltaEffort !== 0n) { + addToEntitlementTotal(ctx, assignment.assignedByAccountIdentity, playerId, currency, deltaCurrency, deltaEffort, updatedAt); + } + } +); + +/** + * Adjusts the caller's own (`ctx.sender`, `payeePlayerId`, `currency`) running ledger by `delta` + * (positive or negative). This is independent of any particular craft. The only validation is the + * manual currency allow-list. + */ +export const recordBountyPayment = spacetimedb.reducer( + {payeePlayerId: t.u64(), currency: t.string(), delta: t.i64()}, + (ctx, {payeePlayerId, currency, delta}) => { + requireAccount(ctx); + validateManualBountyCurrency(currency); + const existing = [...ctx.db.bounty_payout_record.by_payer_payee.filter([ctx.sender, payeePlayerId])] + .find(row => row.currency === currency); + if (existing === undefined) { + ctx.db.bounty_payout_record.insert({ + id: 0n, + payerAccountIdentity: ctx.sender, + payeePlayerId, + currency, + paidTotal: delta, + updatedAt: ctx.timestamp, + }); + } else { + ctx.db.bounty_payout_record.id.update({ + ...existing, + paidTotal: existing.paidTotal + delta, + updatedAt: ctx.timestamp, + }); + } + } +); + +// --------------------------------------------------------------------------- +// Historical backfill — one-time import of pre-brico spreadsheet ledgers +// --------------------------------------------------------------------------- + +/** + * Service-principal-only backfill of one (payer, payee, currency)'s `bounty_entitlement_total` and + * `bounty_payout_record` rows from a pre-brico spreadsheet ledger. Unlike `upsertCraftBountyEntitlement` + * and `recordBountyPayment` above, this *sets* the absolute totals rather than folding in a delta — + * the importing script already has the spreadsheet's final historical totals, not an increment to + * apply on top of an existing row (there is no craft-by-craft history to replay them from). Calling + * this twice for the same triple simply overwrites it with the given totals, which makes a partially + * failed import safe to re-run. + */ +export const importHistoricalBountyLedger = spacetimedb.reducer( + { + payerAccountIdentity: t.identity(), + payeePlayerId: t.u64(), + currency: t.string(), + totalEffort: t.i64(), + total: t.i64(), + paidTotal: t.i64(), + }, + (ctx, {payerAccountIdentity, payeePlayerId, currency, totalEffort, total, paidTotal}) => { + requireServicePrincipal(ctx); + validateManualBountyCurrency(currency); + + const existingEntitlement = [...ctx.db.bounty_entitlement_total.by_payer_payee.filter([payerAccountIdentity, payeePlayerId])] + .find(row => row.currency === currency); + if (existingEntitlement === undefined) { + ctx.db.bounty_entitlement_total.insert({ + id: 0n, payerAccountIdentity, payeePlayerId, currency, total, totalEffort, updatedAt: ctx.timestamp, + }); + } else { + ctx.db.bounty_entitlement_total.id.update({...existingEntitlement, total, totalEffort, updatedAt: ctx.timestamp}); + } + + const existingPayout = [...ctx.db.bounty_payout_record.by_payer_payee.filter([payerAccountIdentity, payeePlayerId])] + .find(row => row.currency === currency); + if (existingPayout === undefined) { + ctx.db.bounty_payout_record.insert({ + id: 0n, payerAccountIdentity, payeePlayerId, currency, paidTotal, updatedAt: ctx.timestamp, + }); + } else { + ctx.db.bounty_payout_record.id.update({...existingPayout, paidTotal, updatedAt: ctx.timestamp}); + } + } +); + +/** + * Service-principal-only backfill of one (payer, payee, currency)'s `loyalty_reward` row from a + * pre-brico spreadsheet. Same "set, don't fold" reasoning as `importHistoricalBountyLedger` above — + * a multiplier is a standalone fact, not a delta, so overwriting is exactly what re-running the + * import for the same triple should do. + */ +export const importHistoricalLoyaltyReward = spacetimedb.reducer( + { + payerAccountIdentity: t.identity(), + payeePlayerId: t.u64(), + currency: t.string(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + }, + (ctx, {payerAccountIdentity, payeePlayerId, currency, ratioNumerator, ratioDenominator}) => { + requireServicePrincipal(ctx); + validateBountyCurrency(currency); + validateRatio(ratioNumerator, ratioDenominator); + if (ratioNumerator < ratioDenominator) throw new SenderError(CraftError.LOYALTY_MULTIPLIER_TOO_SMALL); + + const existing = [...ctx.db.loyalty_reward.payerAccountIdentity.filter(payerAccountIdentity)] + .find(row => row.payeePlayerId === payeePlayerId && row.currency === currency); + if (existing === undefined) { + ctx.db.loyalty_reward.insert({ + id: 0n, payerAccountIdentity, payeePlayerId, currency, ratioNumerator, ratioDenominator, updatedAt: ctx.timestamp, + }); + } else { + ctx.db.loyalty_reward.id.update({...existing, ratioNumerator, ratioDenominator, updatedAt: ctx.timestamp}); + } + } +); diff --git a/backend/brico-module/spacetimedb/src/schema.ts b/backend/brico-module/spacetimedb/src/schema.ts new file mode 100644 index 00000000..57bc3ccf --- /dev/null +++ b/backend/brico-module/spacetimedb/src/schema.ts @@ -0,0 +1,33 @@ +import type {InferSchema, ReducerCtx, ViewCtx} from 'spacetimedb/server'; +import {schema} from 'spacetimedb/server'; +import {account, service_principal} from './tables/accounts'; +import {bounty_rule, craft_bounty_assignment, craft_bounty_override, craft_private_bounty_assignment} from './tables/bounties'; +import {craft_filter_watch, saved_craft_filter, shared_filter} from './tables/crafts'; +import {integration_link_request, linked_integration} from './tables/integrations'; +import {notification} from './tables/notifications'; +import {bounty_entitlement_total, bounty_payout_record, craft_bounty_entitlement, loyalty_reward} from './tables/payouts'; + +const spacetimedb = schema({ + account, + linked_integration, + integration_link_request, + service_principal, + saved_craft_filter, + craft_filter_watch, + shared_filter, + notification, + bounty_rule, + craft_bounty_override, + craft_bounty_assignment, + craft_private_bounty_assignment, + craft_bounty_entitlement, + bounty_entitlement_total, + bounty_payout_record, + loyalty_reward, +}); + +export {spacetimedb}; +export default spacetimedb; + +export type Ctx = ReducerCtx>; +export type VCtx = ViewCtx>; diff --git a/backend/brico-module/spacetimedb/src/tables/accounts.ts b/backend/brico-module/spacetimedb/src/tables/accounts.ts new file mode 100644 index 00000000..7cb8836e --- /dev/null +++ b/backend/brico-module/spacetimedb/src/tables/accounts.ts @@ -0,0 +1,40 @@ +import {t, table} from 'spacetimedb/server'; + +/** A brico account: one row per SpacetimeAuth-authenticated identity. */ +export const account = table( + { + name: 'account', + indexes: [ + // brico-bot holds raw ID tokens and resolves (iss, sub) -> account with this. + { + accessor: 'by_issuer_subject', + algorithm: 'btree', + columns: ['issuer', 'subject'], + }, + ], + }, + { + /** `ctx.sender` — derived by SpacetimeDB from the ID token's `iss` + `sub`. */ + identity: t.identity().primaryKey(), + /** `iss` claim of the token that opened the connection (e.g. SpacetimeAuth's issuer). */ + issuer: t.string(), + /** `sub` claim — the provider's stable user id. */ + subject: t.string(), + /** Optional user-chosen display name. Never populated from PII claims. */ + displayName: t.option(t.string()), + createdAt: t.timestamp(), + lastSeenAt: t.timestamp(), + } +); + +/** + * Identities allowed to read `all_` views and write privileged information. + */ +export const service_principal = table( + {name: 'service_principal'}, + { + identity: t.identity().primaryKey(), + label: t.string(), + grantedAt: t.timestamp(), + } +); diff --git a/backend/brico-module/spacetimedb/src/tables/bounties.ts b/backend/brico-module/spacetimedb/src/tables/bounties.ts new file mode 100644 index 00000000..b1118c52 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/tables/bounties.ts @@ -0,0 +1,106 @@ +import {t, table} from 'spacetimedb/server'; + +// --------------------------------------------------------------------------- +// Bounties & payouts +// --------------------------------------------------------------------------- + +export const GridCell = t.object('GridCell', { + skillId: t.u32(), + tier: t.u32(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), +}); + +/** + * A bounty rule's value: either one flat ratio, or a skill/tier grid. + */ +export const BountyRuleValue = t.enum('BountyRuleValue', { + flat: t.object('FlatBountyValue', { + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + currency: t.string(), + }), + grid: t.object('GridBountyValue', { + cells: t.array(GridCell), + currency: t.string(), + }), +}); + +/** + * A filter-based or skill/tier-grid bounty rule. `filterJson` must not + * reference `payout`/`currency` — enforced as a hard error in the frontend's `FilterBuilder`, + * again here in `upsertBountyRule` (`requireValidFilterJson(..., BOUNTY_RULE_DISALLOWED_FIELDS)`), + * and `brico-bot` still skips/ignores any rule whose filter references them at evaluation time as a + * last line of defense. + */ +export const bounty_rule = table( + {name: 'bounty_rule'}, + { + id: t.string().primaryKey(), + accountIdentity: t.identity().index('btree'), + /** Unique per account in practice — enforced by `reorderBountyRules` rewriting all of an + * account's rules together, not by a database-level constraint. */ + priority: t.i32(), + filterJson: t.string(), + value: BountyRuleValue, + /** When true, any assignment this rule produces goes to `craft_private_bounty_assignment` instead of the public `craft_bounty_assignment`. */ + private: t.bool(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + deletedAt: t.option(t.timestamp()), + } +); + +/** + * The direct "add/remove a bounty on this one craft" action from the craft detail page. No + * tombstone: deleting this row simply falls back to normal rule evaluation for that craft, same + * as never having set it. + */ +export const craft_bounty_override = table( + {name: 'craft_bounty_override'}, + { + craftId: t.u64().primaryKey(), + accountIdentity: t.identity(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + currency: t.string(), + /** When true, this override's assignment goes to `craft_private_bounty_assignment` instead of the public `craft_bounty_assignment`. */ + private: t.bool(), + updatedAt: t.timestamp(), + } +); + +/** + * The resolved, effectively-public bounty for a craft. Only `brico-bot` ever writes this table. + */ +export const craft_bounty_assignment = table( + {name: 'craft_bounty_assignment'}, + { + craftId: t.u64().primaryKey(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + currency: t.string(), + assignedByAccountIdentity: t.identity(), + assignedAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +/** + * Same shape as `craft_bounty_assignment`, but for bounties whose rule/override was marked + * `private`. Never surfaced through `all_craft_bounty_assignment` view. Instead, indexed on + * the assigning account so `myPrivateCraftBountyAssignment` can let a payer validate their + * own private bounties. + */ +export const craft_private_bounty_assignment = table( + {name: 'craft_private_bounty_assignment'}, + { + craftId: t.u64().primaryKey(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + currency: t.string(), + assignedByAccountIdentity: t.identity().index('btree'), + assignedAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); diff --git a/backend/brico-module/spacetimedb/src/tables/crafts.ts b/backend/brico-module/spacetimedb/src/tables/crafts.ts new file mode 100644 index 00000000..33ae6283 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/tables/crafts.ts @@ -0,0 +1,57 @@ +import {t, table} from 'spacetimedb/server'; + +/** + * A craft-browser filter saved by an account, synced across devices. `id` is client-generated + * (`crypto.randomUUID()`) so a device can create one offline and reconcile it later without a + * server round trip first. + * + * `updatedAt` is CLIENT-supplied, not server-stamped: the whole point of this table is reconciling + * a backlog of edits made while offline, and `ctx.timestamp` would make whichever device's backlog + * arrives *last* always win, regardless of which edit is actually newer. + */ +export const saved_craft_filter = table( + {name: 'saved_craft_filter'}, + { + id: t.string().primaryKey(), + accountIdentity: t.identity().index('btree'), + name: t.string(), + filterJson: t.string(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + deletedAt: t.option(t.timestamp()), + } +); + +/** + * Watch trigger flags for a saved filter, 1:1 on `filterId` (no own id) — mirrors + * `craftFilterWatches: Record` in `settings.tsx`. No row / a tombstoned row / + * all-flags-false are all "off", matching the frontend's convention of dropping the map entry + * when every trigger goes false, so there is no separate `enabled` column. + */ +export const craft_filter_watch = table( + {name: 'craft_filter_watch'}, + { + filterId: t.string().primaryKey(), + accountIdentity: t.identity().index('btree'), + added: t.bool(), + finished: t.bool(), + removed: t.bool(), + updatedAt: t.timestamp(), + deletedAt: t.option(t.timestamp()), + } +); + +/** + * A share code minted by `createSharedFilters` for one or more of the caller's own + * `saved_craft_filter` rows, redeemed by `readSharedFilters`. Private — no client ever subscribes + * to it directly, only ever reached through those two procedures. + */ +export const shared_filter = table( + {name: 'shared_filter'}, + { + code: t.string().primaryKey(), + accountIdentity: t.identity().index('btree'), + filterIds: t.array(t.string()), + createdAt: t.timestamp(), + } +); diff --git a/backend/brico-module/spacetimedb/src/tables/integrations.ts b/backend/brico-module/spacetimedb/src/tables/integrations.ts new file mode 100644 index 00000000..0a7e999f --- /dev/null +++ b/backend/brico-module/spacetimedb/src/tables/integrations.ts @@ -0,0 +1,60 @@ +import {t, table} from 'spacetimedb/server'; + +/** + * brico account <-> external system <-> external id. + * Generalized so all providers share one shape. + */ +export const linked_integration = table( + { + name: 'linked_integration', + indexes: [ + { + accessor: 'by_provider_external_id', + algorithm: 'btree', + columns: ['provider', 'externalId'], + }, + ], + }, + { + id: t.u64().primaryKey().autoInc(), + accountIdentity: t.identity().index('btree'), + provider: t.string(), + /** The external system's stable user id (e.g. a Discord snowflake). */ + externalId: t.string(), + /** Mutable, display-only handle from the external system, if it exposes one. */ + externalHandle: t.option(t.string()), + linkedAt: t.timestamp(), + /** Set instead of deleting the row, so link history is retained. */ + revokedAt: t.option(t.timestamp()), + } +); + +/** + * A pending link between a brico account and an external identity, filled in from one or both + * sides before it resolves into a `linked_integration` row. Scheduled removal so an abandoned + * link (user closes the tab mid-flow, a Discord code never redeemed) cleans itself up rather than + * accumulating. Shared by every provider needing an out-of-band verification step instead of a + * bespoke correlation table per provider. + */ +export const integration_link_request = table( + { + name: 'integration_link_request', + indexes: [{accessor: 'accountIdentity', algorithm: 'btree', columns: ['accountIdentity']}], + }, + { + id: t.u64().primaryKey().autoInc(), + /** ~15 minutes out from creation — see `LINK_TTL_MICROS` in `reducers/integrations.ts`. */ + scheduledAt: t.scheduleAt(), + /** Client- or bot-generated correlation token, e.g. `crypto.randomUUID()`. */ + code: t.string().unique(), + provider: t.string(), + /** Filled by the browser side (set at creation for a browser-initiated link, by + * `claimIntegrationLink` for a bot-initiated one). */ + accountIdentity: t.option(t.identity()), + /** Filled by the bot side (set at creation for a bot-initiated request, by + * `noteIntegrationLinkExternal` for a browser-initiated one). */ + externalId: t.option(t.string()), + externalHandle: t.option(t.string()), + createdAt: t.timestamp(), + } +); diff --git a/backend/brico-module/spacetimedb/src/tables/notifications.ts b/backend/brico-module/spacetimedb/src/tables/notifications.ts new file mode 100644 index 00000000..3140e154 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/tables/notifications.ts @@ -0,0 +1,48 @@ +import {t, table} from 'spacetimedb/server'; + +/** + * A craft-sourced notification's payload. + */ +export const CraftNotificationPayload = t.object('CraftNotificationPayload', { + /** `craft_filter_watch.filterId` at fire time — a loose join key, not an FK (the filter/watch + * may since have been renamed or deleted; this payload is a snapshot). */ + watchId: t.string(), + filterName: t.string(), + /** 'added' | 'finished' | 'removed', validated in `postCraftNotification`. */ + eventKind: t.string(), + craftId: t.string(), + recipeId: t.i32(), + regionName: t.string(), + claimName: t.option(t.string()), + ownerName: t.option(t.string()), +}); + +/** + * Generic from the start: one row shape, a sum-typed payload column that varies per source + * (`craft` today, others later) — a future watchable type adds a variant here, not a second table. + */ +export const NotificationPayload = t.enum('NotificationPayload', { + craft: CraftNotificationPayload, +}); + +/** + * One notification for one account. Hard-deleted on removal. + * + * No dedup/idempotency index: `createWatchMatcher` only emits events after its first (silent, + * priming) `update()` call, and a `brico-bot` restart always rebuilds matchers unprimed, so a + * restart cannot regenerate `added` events for already-matching crafts. A unique index would guard + * a failure mode the code cannot currently produce, and would fight a legitimate case (the same + * filter/craft/event triple recurring later, e.g. added -> removed -> added again). + */ +export const notification = table( + { + name: 'notification', + }, + { + id: t.u64().primaryKey().autoInc(), + accountIdentity: t.identity().index('btree'), + payload: NotificationPayload, + createdAt: t.timestamp(), + readAt: t.option(t.timestamp()), + } +); diff --git a/backend/brico-module/spacetimedb/src/tables/payouts.ts b/backend/brico-module/spacetimedb/src/tables/payouts.ts new file mode 100644 index 00000000..2ce102be --- /dev/null +++ b/backend/brico-module/spacetimedb/src/tables/payouts.ts @@ -0,0 +1,98 @@ +import {t, table} from 'spacetimedb/server'; + +/** + * A payer's payout multiplier for one payee player, per currency (manual loyalty rewards). + */ +export const loyalty_reward = table( + {name: 'loyalty_reward'}, + { + id: t.u64().primaryKey().autoInc(), + payerAccountIdentity: t.identity().index('btree'), + payeePlayerId: t.u64(), + currency: t.string(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + updatedAt: t.timestamp(), + } +); + +/** + * Per-(craft, contributor, currency) earnings ledger — a monotonic cumulative floor recomputed by + * `brico-bot` via `@brico/crafts/entitlement`'s `computeEntitlement` whenever `craft_contribution` + * changes for a craft that has an assignment. This stays keyed per-craft (unlike the two totals + * tables below) because the math is per-effort against that craft's own bounty ratio. Both the + * craft and the per-craft effort have to be stored somewhere to run the delta computation, but + * nothing else ever needs to see a bounty broken down by craft, only by payer/payee/currency. + */ +export const craft_bounty_entitlement = table( + { + name: 'craft_bounty_entitlement', + indexes: [ + {accessor: 'by_craft_player_currency', algorithm: 'btree', columns: ['craftId', 'playerId', 'currency']}, + ], + }, + { + id: t.u64().primaryKey().autoInc(), + craftId: t.u64(), + playerId: t.u64(), + currency: t.string(), + /** Cumulative contribution as of the last computation. */ + lastAssignedEffort: t.i64(), + entitledTotal: t.i64(), + updatedAt: t.timestamp(), + } +); + +/** + * The sum of a payee's `craft_bounty_entitlement` across every craft a given payer has bountied + * for them, per currency. Kept updated atomically by `upsertCraftBountyEntitlement` whenever it + * records a per-craft delta, so this table is always the running sum, never recomputed. Same shape + * as `bounty_payout_record` below on purpose — they're the "owed" and"paid" side of the same + * payer/payee/currency relationship. + */ +export const bounty_entitlement_total = table( + { + name: 'bounty_entitlement_total', + indexes: [ + // The write path (`addToEntitlementTotal`) and `myEntitlementsAsPayer` (payer known, any + // payee) look up by (payer, payee) and scan the (few) matching currencies in JS; a third + // index column for currency would be correct too, but isn't needed since uniqueness on + // (payer, payee, currency) is enforced in reducer code, not by the database. + {accessor: 'by_payer_payee', algorithm: 'btree', columns: ['payerAccountIdentity', 'payeePlayerId']}, + ], + }, + { + id: t.u64().primaryKey().autoInc(), + payerAccountIdentity: t.identity(), + // Own index (not just a trailing column of `by_payer_payee`, which can't be prefix-scanned + // from this side): `myEntitlementsAsContributor` looks up the caller's own (few) linked player + // ids one at a time, any payer, and a view must not fall back to `.iter()` over the whole table + // to get there. + payeePlayerId: t.u64().index('btree'), + currency: t.string(), + total: t.i64(), + totalEffort: t.i64(), + updatedAt: t.timestamp(), + } +); + +/** + * A payer<->payee payment ledger, accumulated across every craft that payer has bountied for that + * payee. Inserts to this table must be verified based on currency. Users must never be allowed + * to insert trust-backed currencies themselves, only trust-less ones. + */ +export const bounty_payout_record = table( + { + name: 'bounty_payout_record', + indexes: [{accessor: 'by_payer_payee', algorithm: 'btree', columns: ['payerAccountIdentity', 'payeePlayerId']}], + }, + { + id: t.u64().primaryKey().autoInc(), + payerAccountIdentity: t.identity(), + // Same reasoning as `bounty_entitlement_total.payeePlayerId` above. + payeePlayerId: t.u64().index('btree'), + currency: t.string(), + paidTotal: t.i64(), + updatedAt: t.timestamp(), + } +); diff --git a/backend/brico-module/spacetimedb/src/views/accounts.ts b/backend/brico-module/spacetimedb/src/views/accounts.ts new file mode 100644 index 00000000..6b8bc8ec --- /dev/null +++ b/backend/brico-module/spacetimedb/src/views/accounts.ts @@ -0,0 +1,26 @@ +// noinspection JSUnusedGlobalSymbols + +import {t} from 'spacetimedb/server'; +import {spacetimedb} from '../schema'; +import {account} from '../tables/accounts'; + +/** + * The caller's own account row. At most one row, but declared as an array because this is a *query* view. + */ +export const myAccount = spacetimedb.view( + {name: 'my_account', public: true}, + t.array(account.rowType), + ctx => ctx.from.account.where(a => a.identity.eq(ctx.sender)) +); + +/** + * Unfiltered account feed for brico-bot. Same table, no identity predicate. + */ +export const allAccount = spacetimedb.view( + {name: 'all_account', public: true}, + t.array(account.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.account.where(_ => isTrusted); + } +); diff --git a/backend/brico-module/spacetimedb/src/views/bounties.ts b/backend/brico-module/spacetimedb/src/views/bounties.ts new file mode 100644 index 00000000..d983c45f --- /dev/null +++ b/backend/brico-module/spacetimedb/src/views/bounties.ts @@ -0,0 +1,63 @@ +// noinspection JSUnusedGlobalSymbols + +import {t} from 'spacetimedb/server'; +import {spacetimedb} from '../schema'; +import {bounty_rule, craft_bounty_assignment, craft_bounty_override, craft_private_bounty_assignment} from '../tables/bounties'; + +/** The caller's own bounty rules, tombstoned ones included — same reason as `mySavedCraftFilter`. */ +export const myBountyRule = spacetimedb.view( + {name: 'my_bounty_rule', public: true}, + t.array(bounty_rule.rowType), + ctx => ctx.from.bounty_rule.where(r => r.accountIdentity.eq(ctx.sender)) +); + +/** Unfiltered bounty-rule feed for brico-bot. Same trusted-constant-predicate query view as `all_account`. */ +export const allBountyRule = spacetimedb.view( + {name: 'all_bounty_rule', public: true}, + t.array(bounty_rule.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.bounty_rule.where(_ => isTrusted); + } +); + +/** The caller's own single-craft bounty overrides. */ +export const myCraftBountyOverride = spacetimedb.view( + {name: 'my_craft_bounty_override', public: true}, + t.array(craft_bounty_override.rowType), + ctx => ctx.from.craft_bounty_override.where(o => o.accountIdentity.eq(ctx.sender)) +); + +/** Unfiltered override feed for brico-bot. Same trusted-constant-predicate query view as `all_account`. */ +export const allCraftBountyOverride = spacetimedb.view( + {name: 'all_craft_bounty_override', public: true}, + t.array(craft_bounty_override.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.craft_bounty_override.where(_ => isTrusted); + } +); + +/** The resolved bounty for every craft — public in effect, so it's unconditional, no `my_` counterpart. */ +export const allCraftBountyAssignment = spacetimedb.view( + {name: 'all_craft_bounty_assignment', public: true}, + t.array(craft_bounty_assignment.rowType), + ctx => ctx.from.craft_bounty_assignment.where(_ => true) +); + +/** The caller's own private bounty assignments — lets a payer validate the private bounties they resolved, using the `by_assigned_by` index. */ +export const myPrivateCraftBountyAssignment = spacetimedb.view( + {name: 'my_private_craft_bounty_assignment', public: true}, + t.array(craft_private_bounty_assignment.rowType), + ctx => ctx.from.craft_private_bounty_assignment.where(a => a.assignedByAccountIdentity.eq(ctx.sender)) +); + +/** Unfiltered private-bounty-assignment feed for brico-bot. Same trusted-constant-predicate query view as `all_account`. */ +export const allPrivateCraftBountyAssignment = spacetimedb.view( + {name: 'all_private_craft_bounty_assignment', public: true}, + t.array(craft_private_bounty_assignment.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.craft_private_bounty_assignment.where(_ => isTrusted); + } +); diff --git a/backend/brico-module/spacetimedb/src/views/crafts.ts b/backend/brico-module/spacetimedb/src/views/crafts.ts new file mode 100644 index 00000000..38496ed5 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/views/crafts.ts @@ -0,0 +1,39 @@ +// noinspection JSUnusedGlobalSymbols + +import {t} from 'spacetimedb/server'; +import {spacetimedb} from '../schema'; +import {craft_filter_watch, saved_craft_filter} from '../tables/crafts'; + +/** The caller's own saved filters, tombstoned ones included so the sync layer can see deletions. */ +export const mySavedCraftFilter = spacetimedb.view( + {name: 'my_saved_craft_filter', public: true}, + t.array(saved_craft_filter.rowType), + ctx => ctx.from.saved_craft_filter.where(f => f.accountIdentity.eq(ctx.sender)) +); + +/** The caller's own watch rows, tombstoned ones included — same reason as `mySavedCraftFilter`. */ +export const myCraftFilterWatch = spacetimedb.view( + {name: 'my_craft_filter_watch', public: true}, + t.array(craft_filter_watch.rowType), + ctx => ctx.from.craft_filter_watch.where(w => w.accountIdentity.eq(ctx.sender)) +); + +/** Unfiltered saved-filter feed for brico-bot. Same trusted-constant-predicate query view as `all_account`. */ +export const allSavedCraftFilter = spacetimedb.view( + {name: 'all_saved_craft_filter', public: true}, + t.array(saved_craft_filter.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.saved_craft_filter.where(_ => isTrusted); + } +); + +/** Unfiltered watch feed for brico-bot. Same trusted-constant-predicate query view as `all_account`. */ +export const allCraftFilterWatch = spacetimedb.view( + {name: 'all_craft_filter_watch', public: true}, + t.array(craft_filter_watch.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.craft_filter_watch.where(_ => isTrusted); + } +); diff --git a/backend/brico-module/spacetimedb/src/views/integrations.ts b/backend/brico-module/spacetimedb/src/views/integrations.ts new file mode 100644 index 00000000..6aece16f --- /dev/null +++ b/backend/brico-module/spacetimedb/src/views/integrations.ts @@ -0,0 +1,50 @@ +// noinspection JSUnusedGlobalSymbols + +import {t} from 'spacetimedb/server'; +import {spacetimedb} from '../schema'; +import {integration_link_request, linked_integration} from '../tables/integrations'; + +/** The caller's own integration links, revoked ones included (client decides what to show). */ +export const myLinkedIntegration = spacetimedb.view( + {name: 'my_linked_integration', public: true}, + t.array(linked_integration.rowType), + ctx => ctx.from.linked_integration.where(l => l.accountIdentity.eq(ctx.sender)) +); + +/** Unfiltered integration feed for brico-bot. Service principals only, same trusted-constant-predicate query view as `all_account`. */ +export const allLinkedIntegration = spacetimedb.view( + {name: 'all_linked_integration', public: true}, + t.array(linked_integration.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.linked_integration.where(_ => isTrusted); + } +); + +/** + * The caller's own pending link request(s), so the browser can show "waiting for confirmation..." + * UI while a browser-initiated link (BitAuth, Stelo) is out for verification. A bot-initiated + * request (Discord `/link`) only ever has `accountIdentity` set for the instant between + * `claimIntegrationLink` and finalization, so it never lingers here. + * + * Procedural, not a query view: `accountIdentity` is `t.option(t.identity())` (unset until a + * bot-initiated request is claimed), and the query builder's `.eq(ctx.sender)` on an optional + * identity column does not match rows at all — verified against a live local instance, rows that + * plainly matched at the SQL/table level never reached this view. `accountIdentity.filter(ctx.sender)` + * against the same index does match correctly. + */ +export const myIntegrationLinkRequest = spacetimedb.view( + {name: 'my_integration_link_request', public: true}, + t.array(integration_link_request.rowType), + ctx => [...ctx.db.integration_link_request.accountIdentity.filter(ctx.sender)] +); + +/** Unfiltered pending-link feed for brico-bot (e.g. Stelo candidate submissions awaiting its verification). Same trusted-constant-predicate query view as `all_account`. */ +export const allIntegrationLinkRequest = spacetimedb.view( + {name: 'all_integration_link_request', public: true}, + t.array(integration_link_request.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.integration_link_request.where(_ => isTrusted); + } +); diff --git a/backend/brico-module/spacetimedb/src/views/notifications.ts b/backend/brico-module/spacetimedb/src/views/notifications.ts new file mode 100644 index 00000000..01ebce58 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/views/notifications.ts @@ -0,0 +1,15 @@ +import {t} from 'spacetimedb/server'; +import {spacetimedb} from '../schema'; +import {notification} from '../tables/notifications'; + +// noinspection JSUnusedGlobalSymbols +/** + * The caller's own notifications, read and unread alike (the client decides what to show). + */ +export const myNotification = spacetimedb.view( + {name: 'my_notification', public: true}, + t.array(notification.rowType), + ctx => ctx.from.notification.where(n => n.accountIdentity.eq(ctx.sender)) +); + +// No bot `all_` view, as it never needs to read notifications, only write them. diff --git a/backend/brico-module/spacetimedb/src/views/payouts.ts b/backend/brico-module/spacetimedb/src/views/payouts.ts new file mode 100644 index 00000000..1eb728db --- /dev/null +++ b/backend/brico-module/spacetimedb/src/views/payouts.ts @@ -0,0 +1,230 @@ +// noinspection JSUnusedGlobalSymbols + +import type {Identity, Timestamp} from 'spacetimedb'; +import {t} from 'spacetimedb/server'; +import type {VCtx} from '../schema'; +import {spacetimedb} from '../schema'; +import {bounty_payout_record, craft_bounty_entitlement, loyalty_reward} from '../tables/payouts'; + +/** Unfiltered entitlement feed for brico-bot, which needs to read current entitlements back to compute deltas. */ +export const allCraftBountyEntitlement = spacetimedb.view( + {name: 'all_craft_bounty_entitlement', public: true}, + t.array(craft_bounty_entitlement.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.craft_bounty_entitlement.where(_ => isTrusted); + } +); + +/** The caller's own loyalty-reward rows, as a payer. */ +export const myLoyaltyReward = spacetimedb.view( + {name: 'my_loyalty_reward', public: true}, + t.array(loyalty_reward.rowType), + ctx => ctx.from.loyalty_reward.where(r => r.payerAccountIdentity.eq(ctx.sender)) +); + +/** Unfiltered loyalty-reward feed for brico-bot, which needs every payer's rows to fold into entitlement computation. */ +export const allLoyaltyReward = spacetimedb.view( + {name: 'all_loyalty_reward', public: true}, + t.array(loyalty_reward.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.loyalty_reward.where(_ => isTrusted); + } +); + +/** The caller's own payment ledger rows as a payer (see `recordBountyPayment`). */ +export const myBountyPayoutRecordAsPayer = spacetimedb.view( + {name: 'my_bounty_payout_record_as_payer', public: true}, + t.array(bounty_payout_record.rowType), + ctx => ctx.from.bounty_payout_record.where(r => r.payerAccountIdentity.eq(ctx.sender)) +); + +/** + * Resolves a payer account's display name for the report views below: its own `displayName` if + * set; otherwise its oldest still-active `bitcraft-ea2` link's handle (preferring the longest- + * standing proof of who they are over a more recent one); otherwise its most-recently-revoked + * one's handle (a payer must have linked a BitCraft account at some point to have assigned a + * bounty — unlinking doesn't let them out of an existing payer relationship); otherwise, for a + * genuinely broken row (no handle recorded on the chosen link either), the identity itself. + */ +function resolvePayerDisplayName(ctx: VCtx, payerAccountIdentity: Identity): string { + const account = ctx.db.account.identity.find(payerAccountIdentity); + if (account?.displayName) return account.displayName; + + let oldestActive: { linkedAt: Timestamp; externalHandle?: string } | null = null; + let mostRecentRevoked: { revokedAt: Timestamp; externalHandle?: string } | null = null; + for (const link of ctx.db.linked_integration.accountIdentity.filter(payerAccountIdentity)) { + if (link.provider !== 'bitcraft-ea2') continue; + if (link.revokedAt === undefined) { + if (oldestActive === null || link.linkedAt.microsSinceUnixEpoch < oldestActive.linkedAt.microsSinceUnixEpoch) { + oldestActive = link; + } + } else if (mostRecentRevoked === null || link.revokedAt.microsSinceUnixEpoch > mostRecentRevoked.revokedAt.microsSinceUnixEpoch) { + mostRecentRevoked = {revokedAt: link.revokedAt, externalHandle: link.externalHandle}; + } + } + return (oldestActive ?? mostRecentRevoked)?.externalHandle ?? payerAccountIdentity.toHexString(); +} + +/** + * Resolves the brico account a payee player id should be grouped under for the manager-side + * report, or `null` if it shouldn't be grouped at all. Only an *active* `bitcraft-ea2` link + * counts, and only when that account has itself set a `displayName`: an unnamed account is + * reported exactly as if the player id weren't linked. + */ +function resolvePayeeAccount(ctx: VCtx, payeePlayerId: bigint): { identity: Identity; displayName: string } | null { + for (const link of ctx.db.linked_integration.by_provider_external_id.filter(['bitcraft-ea2', payeePlayerId.toString()])) { + if (link.revokedAt !== undefined) continue; + const account = ctx.db.account.identity.find(link.accountIdentity); + return account?.displayName ? {identity: account.identity, displayName: account.displayName} : null; + } + return null; +} + +/** + * Output row for `myEntitlementsAsContributor`: one per (payer, currency) the caller has earned + * from or been paid by, across any of their own linked BitCraft player ids. `payerName` is + * resolved by the view itself (see `resolvePayerDisplayName`): never left for the frontend to look up, + * since `linked_integration` is otherwise private to other accounts. + */ +const ContributorEntitlementRow = t.row('ContributorEntitlementRow', { + id: t.string().primaryKey(), + payerAccountIdentity: t.identity(), + payerName: t.string(), + payeePlayerId: t.u64(), + currency: t.string(), + effortTotal: t.i64(), + earnedTotal: t.i64(), + paidTotal: t.i64(), +}); + +/** + * Output row for `myEntitlementsAsPayer`: one per (payee player id, currency) the caller has + * bountied. `payeeAccountIdentity`/`payeeName` are only populated when the player id resolves to an + * account that has itself set a display name (see `resolvePayeeAccount`). An account with no + * display name is reported exactly as if the player id weren't linked at all. + */ +const PayerEntitlementRow = t.row('PayerEntitlementRow', { + id: t.string().primaryKey(), + payeePlayerId: t.u64(), + payeeAccountIdentity: t.option(t.identity()), + payeeName: t.option(t.string()), + currency: t.string(), + effortTotal: t.i64(), + earnedTotal: t.i64(), + paidTotal: t.i64(), +}); + +/** + * The caller's own entitlements as a contributor, across every BitCraft player id they've ever + * linked (`bitcraft-ea2`, revoked included — past earnings on a since-unlinked character are still + * theirs): one row per (payer, currency) they've earned from or been paid by, summed off the two + * totals tables directly. Procedural, not a query view. + */ +export const myEntitlementsAsContributor = spacetimedb.view( + {name: 'my_entitlements_as_contributor', public: true}, + t.array(ContributorEntitlementRow), + ctx => { + const playerIds = new Set(); + for (const link of ctx.db.linked_integration.accountIdentity.filter(ctx.sender)) { + if (link.provider === 'bitcraft-ea2') playerIds.add(BigInt(link.externalId)); + } + if (playerIds.size === 0) return []; + + const keyOf = (payer: Identity, playerId: bigint, currency: string) => `${payer.toHexString()}:${playerId}:${currency}`; + const rows = new Map(); + + // `playerIds` is small in practice (a handful of linked characters at most), so a per-id + // indexed lookup here beats a full scan of either totals table — a view must never fall back + // to `.iter()` over a whole table when the caller only cares about a known, bounded id set. + for (const playerId of playerIds) { + for (const total of ctx.db.bounty_entitlement_total.payeePlayerId.filter(playerId)) { + rows.set(keyOf(total.payerAccountIdentity, total.payeePlayerId, total.currency), { + payerAccountIdentity: total.payerAccountIdentity, + payeePlayerId: total.payeePlayerId, + currency: total.currency, + effortTotal: total.totalEffort, + earnedTotal: total.total, + paidTotal: 0n, + }); + } + } + for (const playerId of playerIds) { + for (const paid of ctx.db.bounty_payout_record.payeePlayerId.filter(playerId)) { + const key = keyOf(paid.payerAccountIdentity, paid.payeePlayerId, paid.currency); + const row = rows.get(key); + if (row) row.paidTotal = paid.paidTotal; + else rows.set(key, { + payerAccountIdentity: paid.payerAccountIdentity, + payeePlayerId: paid.payeePlayerId, + currency: paid.currency, + effortTotal: 0n, + earnedTotal: 0n, + paidTotal: paid.paidTotal, + }); + } + } + + return [...rows.values()].map(row => ({ + id: keyOf(row.payerAccountIdentity, row.payeePlayerId, row.currency), + payerAccountIdentity: row.payerAccountIdentity, + payerName: resolvePayerDisplayName(ctx, row.payerAccountIdentity), + payeePlayerId: row.payeePlayerId, + currency: row.currency, + effortTotal: row.effortTotal, + earnedTotal: row.earnedTotal, + paidTotal: row.paidTotal, + })); + } +); + +/** + * The other direction of `myEntitlementsAsContributor`: every payee the caller has bountied + * (`payerAccountIdentity == ctx.sender` on both totals tables), one row per (payee player id, + * currency), with the payee's grouping account resolved per `resolvePayeeAccount`. + */ +export const myEntitlementsAsPayer = spacetimedb.view( + {name: 'my_entitlements_as_payer', public: true}, + t.array(PayerEntitlementRow), + ctx => { + const keyOf = (playerId: bigint, currency: string) => `${playerId}:${currency}`; + const rows = new Map(); + + for (const total of ctx.db.bounty_entitlement_total.by_payer_payee.filter(ctx.sender)) { + rows.set(keyOf(total.payeePlayerId, total.currency), { + payeePlayerId: total.payeePlayerId, + currency: total.currency, + effortTotal: total.totalEffort, + earnedTotal: total.total, + paidTotal: 0n, + }); + } + for (const paid of ctx.db.bounty_payout_record.by_payer_payee.filter(ctx.sender)) { + const key = keyOf(paid.payeePlayerId, paid.currency); + const row = rows.get(key); + if (row) row.paidTotal = paid.paidTotal; + else rows.set(key, { + payeePlayerId: paid.payeePlayerId, + currency: paid.currency, + effortTotal: 0n, + earnedTotal: 0n, + paidTotal: paid.paidTotal, + }); + } + + return [...rows.values()].map(row => { + const account = resolvePayeeAccount(ctx, row.payeePlayerId); + return { + id: keyOf(row.payeePlayerId, row.currency), + payeePlayerId: row.payeePlayerId, + payeeAccountIdentity: account?.identity, + payeeName: account?.displayName, + currency: row.currency, + effortTotal: row.effortTotal, + earnedTotal: row.earnedTotal, + paidTotal: row.paidTotal, + }; + }); + } +); diff --git a/backend/brico-module/spacetimedb/tsconfig.json b/backend/brico-module/spacetimedb/tsconfig.json new file mode 100644 index 00000000..71071638 --- /dev/null +++ b/backend/brico-module/spacetimedb/tsconfig.json @@ -0,0 +1,23 @@ + +/* + * This tsconfig is used for TypeScript projects created with `spacetimedb init + * --lang typescript`. You can modify it as needed for your project, although + * some options are required by SpacetimeDB. + */ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + + /* The following options are required by SpacetimeDB + * and should not be modified + */ + "target": "ESNext", + "lib": ["ES2021", "dom"], + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"] +} \ No newline at end of file diff --git a/common/README.md b/common/README.md new file mode 100644 index 00000000..27aa6e36 --- /dev/null +++ b/common/README.md @@ -0,0 +1,26 @@ +# `common/` — shared workspace packages + +Code imported by more than one project in this repo. Every entry here is an npm workspace member +(see the root `package.json`), so consumers depend on it **by package name** — never by a relative +path or a tsconfig `paths` alias. That keeps resolution working identically under Vite, `tsc`, +`tsx`, and plain Node with no per-consumer configuration, and guarantees a single hoisted copy of +the SpacetimeDB SDKs rather than one per project (two copies means two module registries and two +sets of row/connection class identities in the same bundle). + +| Package | Contents | Depends on | +| --- | --- | --- | +| `@brico/crafts` | Framework-free craft logic. `./filter` is the AND/OR/NOT filter engine that the web preview and `brico-bot`'s notification matching must agree on byte-for-byte. | nothing | +| `@brico/bindings` | Generated SpacetimeDB **2.x** client bindings: `./prism` (prism's `relay-module`), `./brico-app` (Phase 1 Track A, not yet generated). | `spacetimedb@^2.9` | +| `@brico/bitcraft-bindings` | Generated BitCraft upstream schema bindings, SpacetimeDB SDK **2.x**. `src/` is the `BitCraft_Bindings` git submodule (branch `ts-region-2`). Used only for offline BSATN decoding (`~/lib/bitcraft-data.ts`, `backend/brico-bot/src/game-data/load.ts`) — no live connection anywhere reads from it. | `spacetimedb@^2.9` | + +## Notes + +- **Both bindings packages now share one SpacetimeDB SDK major** (`spacetimedb@^2.9`, same package + name). Keep each consumer's declared range identical to the one here — if the ranges diverge, npm + nests a second copy under the consumer instead of hoisting, reintroducing the duplicate-registry + problem. There is no longer a second SDK package (`@clockworklabs/spacetimedb-sdk@1.x`) anywhere + in the tree. +- The packages export raw `.ts`. There is no build step, so `moduleResolution: "bundler"` (or a + bundler/`tsx`) is required in every consumer. +- `backend/brico-module` is deliberately **not** a workspace member: it pins its own TypeScript and + builds to WASM through the SpacetimeDB CLI, and a module cannot consume client bindings anyway. diff --git a/common/bindings/brico-app/README.md b/common/bindings/brico-app/README.md new file mode 100644 index 00000000..fd089352 --- /dev/null +++ b/common/bindings/brico-app/README.md @@ -0,0 +1,61 @@ +# `brico-app` bindings + +Generated SpacetimeDB 2.x client bindings for the `brico-app` module (`backend/brico-module/`), +imported as `@brico/bindings/brico-app`. Generated output — do not hand-edit. Regenerate with: + +```sh +npm run generate --prefix backend/brico-module/spacetimedb +``` + +(`backend/brico-module` is deliberately not a workspace member, hence `--prefix` rather than +`--workspace`.) + +## What is and isn't here + +`account`, `linked_integration`, `service_principal`, `saved_craft_filter` and +`craft_filter_watch` are **private** tables — the CLI reports `Skipping private tables during +codegen` and no client can subscribe to them. Clients read through views instead, so the only +table accessors on `DbConnection` are: + +| accessor | SQL name | contents | +| --- | --- | --- | +| `conn.db.myAccount` | `my_account` | the caller's own `account` row, or nothing | +| `conn.db.myLinkedIntegration` | `my_linked_integration` | the caller's own integration links | +| `conn.db.mySavedCraftFilter` | `my_saved_craft_filter` | the caller's own saved craft filters, tombstoned ones included | +| `conn.db.myCraftFilterWatch` | `my_craft_filter_watch` | the caller's own filter watches, tombstoned ones included | +| `conn.db.allAccount` | `all_account` | every `account` row — empty unless the caller is a registered service principal | +| `conn.db.allLinkedIntegration` | `all_linked_integration` | ditto for integration links | +| `conn.db.allSavedCraftFilter` | `all_saved_craft_filter` | ditto for saved craft filters | +| `conn.db.allCraftFilterWatch` | `all_craft_filter_watch` | ditto for filter watches | + +`my_*` views filter by `ctx.sender`; `all_*` views are the same kind of query view but with a +constant `service_principal`-membership predicate instead of a per-row one. That distinction is +invisible from here — a client cannot tell a view from a table. + +Row types (`Account`, `LinkedIntegration`, `ServicePrincipal`, `SavedCraftFilter`, +`CraftFilterWatch`) live in `@brico/bindings/brico-app/types`; reducer arg types in +`@brico/bindings/brico-app/types/reducers`. Filter/watch sync reducers: +`conn.reducers.upsertSavedCraftFilter({...})`, `deleteSavedCraftFilter({...})`, +`upsertCraftFilterWatch({...})`, `deleteCraftFilterWatch({...})` — each single-params-object, same +as `setDisplayName`. + +## `ensureAccount()` is required + +No `account` row exists until a client calls it — connecting does not create one. Call +`conn.reducers.ensureAccount({})` once after the connection opens; it is idempotent. Note the +**`{}` is required** — an argumentless reducer still generates a one-parameter accessor, so +`ensureAccount()` is a type error. `brico-bot` must *not* call it: service principals are refused, +since a trusted reader is not a user. + +## Two naming gotchas versus `../prism` + +- **Accessors are camelCase here, snake_case in `prism`.** `conn.db.myAccount` / + `reducers.setDisplayName`, not `conn.db.my_account`. `prism` is a Rust module generated by an + older CLI; this one is a TS module generated by CLI 2.9. SQL strings are still snake_case + (`SELECT * FROM my_account`), and `tables.myAccount` is the query-builder handle to pass to + `.subscribe([...])`. +- **Reducers take a single params object**, e.g. `conn.reducers.setDisplayName({ displayName })`. + +`types.ts` also emits empty `t.object("MyAccount", {})` placeholders for each view. Those are a +codegen artifact, not the row shape — use `Account` / `LinkedIntegration`, or the default export of +the matching `*_table.ts`. diff --git a/common/bindings/brico-app/all_account_table.ts b/common/bindings/brico-app/all_account_table.ts new file mode 100644 index 00000000..8995214a --- /dev/null +++ b/common/bindings/brico-app/all_account_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + identity: __t.identity().primaryKey(), + issuer: __t.string(), + subject: __t.string(), + displayName: __t.option(__t.string()).name("display_name"), + createdAt: __t.timestamp().name("created_at"), + lastSeenAt: __t.timestamp().name("last_seen_at"), +}); diff --git a/common/bindings/brico-app/all_bounty_rule_table.ts b/common/bindings/brico-app/all_bounty_rule_table.ts new file mode 100644 index 00000000..053b13ef --- /dev/null +++ b/common/bindings/brico-app/all_bounty_rule_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 {t as __t,} from "spacetimedb"; +import {BountyRuleValue,} from "./types"; + + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + priority: __t.i32(), + filterJson: __t.string().name("filter_json"), + get value() { + return BountyRuleValue; + }, + private: __t.bool(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + deletedAt: __t.option(__t.timestamp()).name("deleted_at"), +}); diff --git a/common/bindings/brico-app/all_craft_bounty_assignment_table.ts b/common/bindings/brico-app/all_craft_bounty_assignment_table.ts new file mode 100644 index 00000000..b67c2eb2 --- /dev/null +++ b/common/bindings/brico-app/all_craft_bounty_assignment_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + craftId: __t.u64().primaryKey().name("craft_id"), + ratioNumerator: __t.i64().name("ratio_numerator"), + ratioDenominator: __t.i64().name("ratio_denominator"), + currency: __t.string(), + assignedByAccountIdentity: __t.identity().name("assigned_by_account_identity"), + assignedAt: __t.timestamp().name("assigned_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/all_craft_bounty_entitlement_table.ts b/common/bindings/brico-app/all_craft_bounty_entitlement_table.ts new file mode 100644 index 00000000..a2707f94 --- /dev/null +++ b/common/bindings/brico-app/all_craft_bounty_entitlement_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + craftId: __t.u64().name("craft_id"), + playerId: __t.u64().name("player_id"), + currency: __t.string(), + lastAssignedEffort: __t.i64().name("last_assigned_effort"), + entitledTotal: __t.i64().name("entitled_total"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/all_craft_bounty_override_table.ts b/common/bindings/brico-app/all_craft_bounty_override_table.ts new file mode 100644 index 00000000..0ddecf79 --- /dev/null +++ b/common/bindings/brico-app/all_craft_bounty_override_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + craftId: __t.u64().primaryKey().name("craft_id"), + accountIdentity: __t.identity().name("account_identity"), + ratioNumerator: __t.i64().name("ratio_numerator"), + ratioDenominator: __t.i64().name("ratio_denominator"), + currency: __t.string(), + private: __t.bool(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/all_craft_filter_watch_table.ts b/common/bindings/brico-app/all_craft_filter_watch_table.ts new file mode 100644 index 00000000..26d061f4 --- /dev/null +++ b/common/bindings/brico-app/all_craft_filter_watch_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + filterId: __t.string().primaryKey().name("filter_id"), + accountIdentity: __t.identity().name("account_identity"), + added: __t.bool(), + finished: __t.bool(), + removed: __t.bool(), + updatedAt: __t.timestamp().name("updated_at"), + deletedAt: __t.option(__t.timestamp()).name("deleted_at"), +}); diff --git a/common/bindings/brico-app/all_integration_link_request_table.ts b/common/bindings/brico-app/all_integration_link_request_table.ts new file mode 100644 index 00000000..baae7bea --- /dev/null +++ b/common/bindings/brico-app/all_integration_link_request_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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + scheduledAt: __t.scheduleAt().name("scheduled_at"), + code: __t.string(), + provider: __t.string(), + accountIdentity: __t.option(__t.identity()).name("account_identity"), + externalId: __t.option(__t.string()).name("external_id"), + externalHandle: __t.option(__t.string()).name("external_handle"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/common/bindings/brico-app/all_linked_integration_table.ts b/common/bindings/brico-app/all_linked_integration_table.ts new file mode 100644 index 00000000..cebd363f --- /dev/null +++ b/common/bindings/brico-app/all_linked_integration_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + provider: __t.string(), + externalId: __t.string().name("external_id"), + externalHandle: __t.option(__t.string()).name("external_handle"), + linkedAt: __t.timestamp().name("linked_at"), + revokedAt: __t.option(__t.timestamp()).name("revoked_at"), +}); diff --git a/common/bindings/brico-app/all_loyalty_reward_table.ts b/common/bindings/brico-app/all_loyalty_reward_table.ts new file mode 100644 index 00000000..0afc2810 --- /dev/null +++ b/common/bindings/brico-app/all_loyalty_reward_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + payeePlayerId: __t.u64().name("payee_player_id"), + currency: __t.string(), + ratioNumerator: __t.i64().name("ratio_numerator"), + ratioDenominator: __t.i64().name("ratio_denominator"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/all_private_craft_bounty_assignment_table.ts b/common/bindings/brico-app/all_private_craft_bounty_assignment_table.ts new file mode 100644 index 00000000..b67c2eb2 --- /dev/null +++ b/common/bindings/brico-app/all_private_craft_bounty_assignment_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + craftId: __t.u64().primaryKey().name("craft_id"), + ratioNumerator: __t.i64().name("ratio_numerator"), + ratioDenominator: __t.i64().name("ratio_denominator"), + currency: __t.string(), + assignedByAccountIdentity: __t.identity().name("assigned_by_account_identity"), + assignedAt: __t.timestamp().name("assigned_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/all_saved_craft_filter_table.ts b/common/bindings/brico-app/all_saved_craft_filter_table.ts new file mode 100644 index 00000000..e154fd25 --- /dev/null +++ b/common/bindings/brico-app/all_saved_craft_filter_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + name: __t.string(), + filterJson: __t.string().name("filter_json"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + deletedAt: __t.option(__t.timestamp()).name("deleted_at"), +}); diff --git a/common/bindings/brico-app/assign_craft_bounty_reducer.ts b/common/bindings/brico-app/assign_craft_bounty_reducer.ts new file mode 100644 index 00000000..8a0ff65e --- /dev/null +++ b/common/bindings/brico-app/assign_craft_bounty_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 {t as __t,} from "spacetimedb"; + +export default { + craftId: __t.u64(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + currency: __t.string(), + assignedByAccountIdentity: __t.identity(), + private: __t.bool(), + assignedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/begin_integration_link_for_external_reducer.ts b/common/bindings/brico-app/begin_integration_link_for_external_reducer.ts new file mode 100644 index 00000000..590b4d4a --- /dev/null +++ b/common/bindings/brico-app/begin_integration_link_for_external_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 {t as __t,} from "spacetimedb"; + +export default { + code: __t.string(), + provider: __t.string(), + externalId: __t.string(), + externalHandle: __t.option(__t.string()), +}; diff --git a/common/bindings/brico-app/begin_integration_link_reducer.ts b/common/bindings/brico-app/begin_integration_link_reducer.ts new file mode 100644 index 00000000..92f5135c --- /dev/null +++ b/common/bindings/brico-app/begin_integration_link_reducer.ts @@ -0,0 +1,11 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default { + code: __t.string(), + provider: __t.string(), +}; diff --git a/common/bindings/brico-app/claim_integration_link_reducer.ts b/common/bindings/brico-app/claim_integration_link_reducer.ts new file mode 100644 index 00000000..64686a8e --- /dev/null +++ b/common/bindings/brico-app/claim_integration_link_reducer.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 {t as __t,} from "spacetimedb"; + +export default { + code: __t.string(), +}; diff --git a/common/bindings/brico-app/clear_craft_bounty_reducer.ts b/common/bindings/brico-app/clear_craft_bounty_reducer.ts new file mode 100644 index 00000000..85db8016 --- /dev/null +++ b/common/bindings/brico-app/clear_craft_bounty_reducer.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 {t as __t,} from "spacetimedb"; + +export default { + craftId: __t.u64(), +}; diff --git a/common/bindings/brico-app/create_shared_filters_procedure.ts b/common/bindings/brico-app/create_shared_filters_procedure.ts new file mode 100644 index 00000000..73bad290 --- /dev/null +++ b/common/bindings/brico-app/create_shared_filters_procedure.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 {t as __t,} from "spacetimedb"; + +import {CreateSharedFiltersResult,} from "./types"; + +export const params = { + filterIds: __t.array(__t.string()), +}; +export const returnType = CreateSharedFiltersResult \ No newline at end of file diff --git a/common/bindings/brico-app/delete_bounty_rule_reducer.ts b/common/bindings/brico-app/delete_bounty_rule_reducer.ts new file mode 100644 index 00000000..3814a918 --- /dev/null +++ b/common/bindings/brico-app/delete_bounty_rule_reducer.ts @@ -0,0 +1,11 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default { + id: __t.string(), + deletedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/delete_craft_bounty_override_reducer.ts b/common/bindings/brico-app/delete_craft_bounty_override_reducer.ts new file mode 100644 index 00000000..85db8016 --- /dev/null +++ b/common/bindings/brico-app/delete_craft_bounty_override_reducer.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 {t as __t,} from "spacetimedb"; + +export default { + craftId: __t.u64(), +}; diff --git a/common/bindings/brico-app/delete_craft_filter_watch_reducer.ts b/common/bindings/brico-app/delete_craft_filter_watch_reducer.ts new file mode 100644 index 00000000..203b2cab --- /dev/null +++ b/common/bindings/brico-app/delete_craft_filter_watch_reducer.ts @@ -0,0 +1,11 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default { + filterId: __t.string(), + deletedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/delete_loyalty_reward_reducer.ts b/common/bindings/brico-app/delete_loyalty_reward_reducer.ts new file mode 100644 index 00000000..46d25842 --- /dev/null +++ b/common/bindings/brico-app/delete_loyalty_reward_reducer.ts @@ -0,0 +1,11 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default { + payeePlayerId: __t.u64(), + currency: __t.string(), +}; diff --git a/common/bindings/brico-app/delete_notification_reducer.ts b/common/bindings/brico-app/delete_notification_reducer.ts new file mode 100644 index 00000000..4e8702ab --- /dev/null +++ b/common/bindings/brico-app/delete_notification_reducer.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 {t as __t,} from "spacetimedb"; + +export default { + ids: __t.array(__t.u64()), +}; diff --git a/common/bindings/brico-app/delete_saved_craft_filter_reducer.ts b/common/bindings/brico-app/delete_saved_craft_filter_reducer.ts new file mode 100644 index 00000000..3814a918 --- /dev/null +++ b/common/bindings/brico-app/delete_saved_craft_filter_reducer.ts @@ -0,0 +1,11 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default { + id: __t.string(), + deletedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/ensure_account_reducer.ts b/common/bindings/brico-app/ensure_account_reducer.ts new file mode 100644 index 00000000..922587a7 --- /dev/null +++ b/common/bindings/brico-app/ensure_account_reducer.ts @@ -0,0 +1,7 @@ +// 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 */ + +export default {}; diff --git a/common/bindings/brico-app/import_historical_bounty_ledger_reducer.ts b/common/bindings/brico-app/import_historical_bounty_ledger_reducer.ts new file mode 100644 index 00000000..49a5465b --- /dev/null +++ b/common/bindings/brico-app/import_historical_bounty_ledger_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 {t as __t,} from "spacetimedb"; + +export default { + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), + totalEffort: __t.i64(), + total: __t.i64(), + paidTotal: __t.i64(), +}; diff --git a/common/bindings/brico-app/import_historical_loyalty_reward_reducer.ts b/common/bindings/brico-app/import_historical_loyalty_reward_reducer.ts new file mode 100644 index 00000000..4e9fdffd --- /dev/null +++ b/common/bindings/brico-app/import_historical_loyalty_reward_reducer.ts @@ -0,0 +1,14 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default { + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), +}; diff --git a/common/bindings/brico-app/index.ts b/common/bindings/brico-app/index.ts new file mode 100644 index 00000000..6fa875d6 --- /dev/null +++ b/common/bindings/brico-app/index.ts @@ -0,0 +1,362 @@ +// 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.10.0 (commit baca5cdf77577ed4e3f30da48a5158189c4ea43f). + +/* eslint-disable */ +/* tslint:disable */ +import { + convertToAccessorMap as __convertToAccessorMap, + DbConnectionBuilder as __DbConnectionBuilder, + type DbConnectionConfig as __DbConnectionConfig, + DbConnectionImpl as __DbConnectionImpl, + type ErrorContextInterface as __ErrorContextInterface, + type EventContextInterface as __EventContextInterface, + makeQueryBuilder as __makeQueryBuilder, + procedures as __procedures, + procedureSchema as __procedureSchema, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + reducers as __reducers, + reducerSchema as __reducerSchema, + type RemoteModule as __RemoteModule, + schema as __schema, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, + table as __table, +} from "spacetimedb"; + +// Import all table schema definitions +import AllAccountRow from "./all_account_table"; +import AllBountyRuleRow from "./all_bounty_rule_table"; +import AllCraftBountyAssignmentRow from "./all_craft_bounty_assignment_table"; +import AllCraftBountyEntitlementRow from "./all_craft_bounty_entitlement_table"; +import AllCraftBountyOverrideRow from "./all_craft_bounty_override_table"; +import AllCraftFilterWatchRow from "./all_craft_filter_watch_table"; +import AllIntegrationLinkRequestRow from "./all_integration_link_request_table"; +import AllLinkedIntegrationRow from "./all_linked_integration_table"; +import AllLoyaltyRewardRow from "./all_loyalty_reward_table"; +import AllPrivateCraftBountyAssignmentRow from "./all_private_craft_bounty_assignment_table"; +import AllSavedCraftFilterRow from "./all_saved_craft_filter_table"; + +// Import all reducer arg schemas +import AssignCraftBountyReducer from "./assign_craft_bounty_reducer"; +import BeginIntegrationLinkForExternalReducer from "./begin_integration_link_for_external_reducer"; +import BeginIntegrationLinkReducer from "./begin_integration_link_reducer"; +import ClaimIntegrationLinkReducer from "./claim_integration_link_reducer"; +import ClearCraftBountyReducer from "./clear_craft_bounty_reducer"; + +// Import all procedure arg schemas +import * as CreateSharedFiltersProcedure from "./create_shared_filters_procedure"; +import DeleteBountyRuleReducer from "./delete_bounty_rule_reducer"; +import DeleteCraftBountyOverrideReducer from "./delete_craft_bounty_override_reducer"; +import DeleteCraftFilterWatchReducer from "./delete_craft_filter_watch_reducer"; +import DeleteLoyaltyRewardReducer from "./delete_loyalty_reward_reducer"; +import DeleteNotificationReducer from "./delete_notification_reducer"; +import DeleteSavedCraftFilterReducer from "./delete_saved_craft_filter_reducer"; +import EnsureAccountReducer from "./ensure_account_reducer"; +import ImportHistoricalBountyLedgerReducer from "./import_historical_bounty_ledger_reducer"; +import ImportHistoricalLoyaltyRewardReducer from "./import_historical_loyalty_reward_reducer"; +import LinkDiscordViaSpacetimeAuthReducer from "./link_discord_via_spacetime_auth_reducer"; +import LinkIntegrationReducer from "./link_integration_reducer"; +import MarkAllNotificationsReadReducer from "./mark_all_notifications_read_reducer"; +import MarkNotificationReadReducer from "./mark_notification_read_reducer"; +import MyAccountRow from "./my_account_table"; +import MyBountyPayoutRecordAsPayerRow from "./my_bounty_payout_record_as_payer_table"; +import MyBountyRuleRow from "./my_bounty_rule_table"; +import MyCraftBountyOverrideRow from "./my_craft_bounty_override_table"; +import MyCraftFilterWatchRow from "./my_craft_filter_watch_table"; +import MyEntitlementsAsContributorRow from "./my_entitlements_as_contributor_table"; +import MyEntitlementsAsPayerRow from "./my_entitlements_as_payer_table"; +import MyIntegrationLinkRequestRow from "./my_integration_link_request_table"; +import MyLinkedIntegrationRow from "./my_linked_integration_table"; +import MyLoyaltyRewardRow from "./my_loyalty_reward_table"; +import MyNotificationRow from "./my_notification_table"; +import MyPrivateCraftBountyAssignmentRow from "./my_private_craft_bounty_assignment_table"; +import MySavedCraftFilterRow from "./my_saved_craft_filter_table"; +import NoteIntegrationLinkExternalReducer from "./note_integration_link_external_reducer"; +import PostCraftNotificationReducer from "./post_craft_notification_reducer"; +import * as ReadSharedFiltersProcedure from "./read_shared_filters_procedure"; +import RecordBountyPaymentReducer from "./record_bounty_payment_reducer"; +import RegisterServicePrincipalReducer from "./register_service_principal_reducer"; +import ReorderBountyRulesReducer from "./reorder_bounty_rules_reducer"; +import RevokeServicePrincipalReducer from "./revoke_service_principal_reducer"; +import SetDisplayNameReducer from "./set_display_name_reducer"; +import UnlinkIntegrationReducer from "./unlink_integration_reducer"; +import UpsertBountyRuleReducer from "./upsert_bounty_rule_reducer"; +import UpsertCraftBountyEntitlementReducer from "./upsert_craft_bounty_entitlement_reducer"; +import UpsertCraftBountyOverrideReducer from "./upsert_craft_bounty_override_reducer"; +import UpsertCraftFilterWatchReducer from "./upsert_craft_filter_watch_reducer"; +import UpsertLoyaltyRewardReducer from "./upsert_loyalty_reward_reducer"; +import UpsertSavedCraftFilterReducer from "./upsert_saved_craft_filter_reducer"; + +/** 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({ + allAccount: __table({ + name: 'all_account', + indexes: [ + ], + constraints: [ + ], + }, AllAccountRow), + allBountyRule: __table({ + name: 'all_bounty_rule', + indexes: [ + ], + constraints: [ + ], + }, AllBountyRuleRow), + allCraftBountyAssignment: __table({ + name: 'all_craft_bounty_assignment', + indexes: [ + ], + constraints: [ + ], + }, AllCraftBountyAssignmentRow), + allCraftBountyEntitlement: __table({ + name: 'all_craft_bounty_entitlement', + indexes: [ + ], + constraints: [ + ], + }, AllCraftBountyEntitlementRow), + allCraftBountyOverride: __table({ + name: 'all_craft_bounty_override', + indexes: [ + ], + constraints: [ + ], + }, AllCraftBountyOverrideRow), + allCraftFilterWatch: __table({ + name: 'all_craft_filter_watch', + indexes: [ + ], + constraints: [ + ], + }, AllCraftFilterWatchRow), + allIntegrationLinkRequest: __table({ + name: 'all_integration_link_request', + indexes: [ + ], + constraints: [ + ], + }, AllIntegrationLinkRequestRow), + allLinkedIntegration: __table({ + name: 'all_linked_integration', + indexes: [ + ], + constraints: [ + ], + }, AllLinkedIntegrationRow), + allLoyaltyReward: __table({ + name: 'all_loyalty_reward', + indexes: [ + ], + constraints: [ + ], + }, AllLoyaltyRewardRow), + allPrivateCraftBountyAssignment: __table({ + name: 'all_private_craft_bounty_assignment', + indexes: [ + ], + constraints: [ + ], + }, AllPrivateCraftBountyAssignmentRow), + allSavedCraftFilter: __table({ + name: 'all_saved_craft_filter', + indexes: [ + ], + constraints: [ + ], + }, AllSavedCraftFilterRow), + myAccount: __table({ + name: 'my_account', + indexes: [ + ], + constraints: [ + ], + }, MyAccountRow), + myBountyPayoutRecordAsPayer: __table({ + name: 'my_bounty_payout_record_as_payer', + indexes: [ + ], + constraints: [ + ], + }, MyBountyPayoutRecordAsPayerRow), + myBountyRule: __table({ + name: 'my_bounty_rule', + indexes: [ + ], + constraints: [ + ], + }, MyBountyRuleRow), + myCraftBountyOverride: __table({ + name: 'my_craft_bounty_override', + indexes: [ + ], + constraints: [ + ], + }, MyCraftBountyOverrideRow), + myCraftFilterWatch: __table({ + name: 'my_craft_filter_watch', + indexes: [ + ], + constraints: [ + ], + }, MyCraftFilterWatchRow), + myEntitlementsAsContributor: __table({ + name: 'my_entitlements_as_contributor', + indexes: [ + ], + constraints: [ + ], + }, MyEntitlementsAsContributorRow), + myEntitlementsAsPayer: __table({ + name: 'my_entitlements_as_payer', + indexes: [ + ], + constraints: [ + ], + }, MyEntitlementsAsPayerRow), + myIntegrationLinkRequest: __table({ + name: 'my_integration_link_request', + indexes: [ + ], + constraints: [ + ], + }, MyIntegrationLinkRequestRow), + myLinkedIntegration: __table({ + name: 'my_linked_integration', + indexes: [ + ], + constraints: [ + ], + }, MyLinkedIntegrationRow), + myLoyaltyReward: __table({ + name: 'my_loyalty_reward', + indexes: [ + ], + constraints: [ + ], + }, MyLoyaltyRewardRow), + myNotification: __table({ + name: 'my_notification', + indexes: [ + ], + constraints: [ + ], + }, MyNotificationRow), + myPrivateCraftBountyAssignment: __table({ + name: 'my_private_craft_bounty_assignment', + indexes: [ + ], + constraints: [ + ], + }, MyPrivateCraftBountyAssignmentRow), + mySavedCraftFilter: __table({ + name: 'my_saved_craft_filter', + indexes: [ + ], + constraints: [ + ], + }, MySavedCraftFilterRow), +}); + +/** 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("assign_craft_bounty", AssignCraftBountyReducer), + __reducerSchema("begin_integration_link", BeginIntegrationLinkReducer), + __reducerSchema("begin_integration_link_for_external", BeginIntegrationLinkForExternalReducer), + __reducerSchema("claim_integration_link", ClaimIntegrationLinkReducer), + __reducerSchema("clear_craft_bounty", ClearCraftBountyReducer), + __reducerSchema("delete_bounty_rule", DeleteBountyRuleReducer), + __reducerSchema("delete_craft_bounty_override", DeleteCraftBountyOverrideReducer), + __reducerSchema("delete_craft_filter_watch", DeleteCraftFilterWatchReducer), + __reducerSchema("delete_loyalty_reward", DeleteLoyaltyRewardReducer), + __reducerSchema("delete_notification", DeleteNotificationReducer), + __reducerSchema("delete_saved_craft_filter", DeleteSavedCraftFilterReducer), + __reducerSchema("ensure_account", EnsureAccountReducer), + __reducerSchema("import_historical_bounty_ledger", ImportHistoricalBountyLedgerReducer), + __reducerSchema("import_historical_loyalty_reward", ImportHistoricalLoyaltyRewardReducer), + __reducerSchema("link_discord_via_spacetime_auth", LinkDiscordViaSpacetimeAuthReducer), + __reducerSchema("link_integration", LinkIntegrationReducer), + __reducerSchema("mark_all_notifications_read", MarkAllNotificationsReadReducer), + __reducerSchema("mark_notification_read", MarkNotificationReadReducer), + __reducerSchema("note_integration_link_external", NoteIntegrationLinkExternalReducer), + __reducerSchema("post_craft_notification", PostCraftNotificationReducer), + __reducerSchema("record_bounty_payment", RecordBountyPaymentReducer), + __reducerSchema("register_service_principal", RegisterServicePrincipalReducer), + __reducerSchema("reorder_bounty_rules", ReorderBountyRulesReducer), + __reducerSchema("revoke_service_principal", RevokeServicePrincipalReducer), + __reducerSchema("set_display_name", SetDisplayNameReducer), + __reducerSchema("unlink_integration", UnlinkIntegrationReducer), + __reducerSchema("upsert_bounty_rule", UpsertBountyRuleReducer), + __reducerSchema("upsert_craft_bounty_entitlement", UpsertCraftBountyEntitlementReducer), + __reducerSchema("upsert_craft_bounty_override", UpsertCraftBountyOverrideReducer), + __reducerSchema("upsert_craft_filter_watch", UpsertCraftFilterWatchReducer), + __reducerSchema("upsert_loyalty_reward", UpsertLoyaltyRewardReducer), + __reducerSchema("upsert_saved_craft_filter", UpsertSavedCraftFilterReducer), +); + +/** 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_shared_filters", CreateSharedFiltersProcedure.params, CreateSharedFiltersProcedure.returnType), + __procedureSchema("read_shared_filters", ReadSharedFiltersProcedure.params, ReadSharedFiltersProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.10.0" 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. */ +export const tables: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); + +/** The procedures available in this remote SpacetimeDB module. */ +export const procedures = __convertToAccessorMap(proceduresSchema.procedures); + +/** 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/common/bindings/brico-app/link_discord_via_spacetime_auth_reducer.ts b/common/bindings/brico-app/link_discord_via_spacetime_auth_reducer.ts new file mode 100644 index 00000000..922587a7 --- /dev/null +++ b/common/bindings/brico-app/link_discord_via_spacetime_auth_reducer.ts @@ -0,0 +1,7 @@ +// 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 */ + +export default {}; diff --git a/common/bindings/brico-app/link_integration_reducer.ts b/common/bindings/brico-app/link_integration_reducer.ts new file mode 100644 index 00000000..9ff99b4a --- /dev/null +++ b/common/bindings/brico-app/link_integration_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 {t as __t,} from "spacetimedb"; + +export default { + accountIdentity: __t.identity(), + provider: __t.string(), + externalId: __t.string(), + externalHandle: __t.string(), +}; diff --git a/common/bindings/brico-app/mark_all_notifications_read_reducer.ts b/common/bindings/brico-app/mark_all_notifications_read_reducer.ts new file mode 100644 index 00000000..922587a7 --- /dev/null +++ b/common/bindings/brico-app/mark_all_notifications_read_reducer.ts @@ -0,0 +1,7 @@ +// 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 */ + +export default {}; diff --git a/common/bindings/brico-app/mark_notification_read_reducer.ts b/common/bindings/brico-app/mark_notification_read_reducer.ts new file mode 100644 index 00000000..6b5d8547 --- /dev/null +++ b/common/bindings/brico-app/mark_notification_read_reducer.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 {t as __t,} from "spacetimedb"; + +export default { + id: __t.u64(), +}; diff --git a/common/bindings/brico-app/my_account_table.ts b/common/bindings/brico-app/my_account_table.ts new file mode 100644 index 00000000..8995214a --- /dev/null +++ b/common/bindings/brico-app/my_account_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + identity: __t.identity().primaryKey(), + issuer: __t.string(), + subject: __t.string(), + displayName: __t.option(__t.string()).name("display_name"), + createdAt: __t.timestamp().name("created_at"), + lastSeenAt: __t.timestamp().name("last_seen_at"), +}); diff --git a/common/bindings/brico-app/my_bounty_payout_record_as_payer_table.ts b/common/bindings/brico-app/my_bounty_payout_record_as_payer_table.ts new file mode 100644 index 00000000..bf378df7 --- /dev/null +++ b/common/bindings/brico-app/my_bounty_payout_record_as_payer_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + payeePlayerId: __t.u64().name("payee_player_id"), + currency: __t.string(), + paidTotal: __t.i64().name("paid_total"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/my_bounty_rule_table.ts b/common/bindings/brico-app/my_bounty_rule_table.ts new file mode 100644 index 00000000..053b13ef --- /dev/null +++ b/common/bindings/brico-app/my_bounty_rule_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 {t as __t,} from "spacetimedb"; +import {BountyRuleValue,} from "./types"; + + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + priority: __t.i32(), + filterJson: __t.string().name("filter_json"), + get value() { + return BountyRuleValue; + }, + private: __t.bool(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + deletedAt: __t.option(__t.timestamp()).name("deleted_at"), +}); diff --git a/common/bindings/brico-app/my_craft_bounty_override_table.ts b/common/bindings/brico-app/my_craft_bounty_override_table.ts new file mode 100644 index 00000000..0ddecf79 --- /dev/null +++ b/common/bindings/brico-app/my_craft_bounty_override_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + craftId: __t.u64().primaryKey().name("craft_id"), + accountIdentity: __t.identity().name("account_identity"), + ratioNumerator: __t.i64().name("ratio_numerator"), + ratioDenominator: __t.i64().name("ratio_denominator"), + currency: __t.string(), + private: __t.bool(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/my_craft_filter_watch_table.ts b/common/bindings/brico-app/my_craft_filter_watch_table.ts new file mode 100644 index 00000000..26d061f4 --- /dev/null +++ b/common/bindings/brico-app/my_craft_filter_watch_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + filterId: __t.string().primaryKey().name("filter_id"), + accountIdentity: __t.identity().name("account_identity"), + added: __t.bool(), + finished: __t.bool(), + removed: __t.bool(), + updatedAt: __t.timestamp().name("updated_at"), + deletedAt: __t.option(__t.timestamp()).name("deleted_at"), +}); diff --git a/common/bindings/brico-app/my_entitlements_as_contributor_table.ts b/common/bindings/brico-app/my_entitlements_as_contributor_table.ts new file mode 100644 index 00000000..e7e87480 --- /dev/null +++ b/common/bindings/brico-app/my_entitlements_as_contributor_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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + payerName: __t.string().name("payer_name"), + payeePlayerId: __t.u64().name("payee_player_id"), + currency: __t.string(), + effortTotal: __t.i64().name("effort_total"), + earnedTotal: __t.i64().name("earned_total"), + paidTotal: __t.i64().name("paid_total"), +}); diff --git a/common/bindings/brico-app/my_entitlements_as_payer_table.ts b/common/bindings/brico-app/my_entitlements_as_payer_table.ts new file mode 100644 index 00000000..95a440a0 --- /dev/null +++ b/common/bindings/brico-app/my_entitlements_as_payer_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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + payeePlayerId: __t.u64().name("payee_player_id"), + payeeAccountIdentity: __t.option(__t.identity()).name("payee_account_identity"), + payeeName: __t.option(__t.string()).name("payee_name"), + currency: __t.string(), + effortTotal: __t.i64().name("effort_total"), + earnedTotal: __t.i64().name("earned_total"), + paidTotal: __t.i64().name("paid_total"), +}); diff --git a/common/bindings/brico-app/my_integration_link_request_table.ts b/common/bindings/brico-app/my_integration_link_request_table.ts new file mode 100644 index 00000000..baae7bea --- /dev/null +++ b/common/bindings/brico-app/my_integration_link_request_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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + scheduledAt: __t.scheduleAt().name("scheduled_at"), + code: __t.string(), + provider: __t.string(), + accountIdentity: __t.option(__t.identity()).name("account_identity"), + externalId: __t.option(__t.string()).name("external_id"), + externalHandle: __t.option(__t.string()).name("external_handle"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/common/bindings/brico-app/my_linked_integration_table.ts b/common/bindings/brico-app/my_linked_integration_table.ts new file mode 100644 index 00000000..cebd363f --- /dev/null +++ b/common/bindings/brico-app/my_linked_integration_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + provider: __t.string(), + externalId: __t.string().name("external_id"), + externalHandle: __t.option(__t.string()).name("external_handle"), + linkedAt: __t.timestamp().name("linked_at"), + revokedAt: __t.option(__t.timestamp()).name("revoked_at"), +}); diff --git a/common/bindings/brico-app/my_loyalty_reward_table.ts b/common/bindings/brico-app/my_loyalty_reward_table.ts new file mode 100644 index 00000000..0afc2810 --- /dev/null +++ b/common/bindings/brico-app/my_loyalty_reward_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + payeePlayerId: __t.u64().name("payee_player_id"), + currency: __t.string(), + ratioNumerator: __t.i64().name("ratio_numerator"), + ratioDenominator: __t.i64().name("ratio_denominator"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/my_notification_table.ts b/common/bindings/brico-app/my_notification_table.ts new file mode 100644 index 00000000..eb47f3f7 --- /dev/null +++ b/common/bindings/brico-app/my_notification_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 {t as __t,} from "spacetimedb"; +import {NotificationPayload,} from "./types"; + + +export default __t.row({ + id: __t.u64().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + get payload() { + return NotificationPayload; + }, + createdAt: __t.timestamp().name("created_at"), + readAt: __t.option(__t.timestamp()).name("read_at"), +}); diff --git a/common/bindings/brico-app/my_private_craft_bounty_assignment_table.ts b/common/bindings/brico-app/my_private_craft_bounty_assignment_table.ts new file mode 100644 index 00000000..b67c2eb2 --- /dev/null +++ b/common/bindings/brico-app/my_private_craft_bounty_assignment_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + craftId: __t.u64().primaryKey().name("craft_id"), + ratioNumerator: __t.i64().name("ratio_numerator"), + ratioDenominator: __t.i64().name("ratio_denominator"), + currency: __t.string(), + assignedByAccountIdentity: __t.identity().name("assigned_by_account_identity"), + assignedAt: __t.timestamp().name("assigned_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/my_saved_craft_filter_table.ts b/common/bindings/brico-app/my_saved_craft_filter_table.ts new file mode 100644 index 00000000..e154fd25 --- /dev/null +++ b/common/bindings/brico-app/my_saved_craft_filter_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + name: __t.string(), + filterJson: __t.string().name("filter_json"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + deletedAt: __t.option(__t.timestamp()).name("deleted_at"), +}); diff --git a/common/bindings/brico-app/note_integration_link_external_reducer.ts b/common/bindings/brico-app/note_integration_link_external_reducer.ts new file mode 100644 index 00000000..25890d77 --- /dev/null +++ b/common/bindings/brico-app/note_integration_link_external_reducer.ts @@ -0,0 +1,12 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default { + code: __t.string(), + externalId: __t.string(), + externalHandle: __t.option(__t.string()), +}; diff --git a/common/bindings/brico-app/post_craft_notification_reducer.ts b/common/bindings/brico-app/post_craft_notification_reducer.ts new file mode 100644 index 00000000..dcb90aac --- /dev/null +++ b/common/bindings/brico-app/post_craft_notification_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 {t as __t,} from "spacetimedb"; + +import {CraftNotificationPayload,} from "./types"; + +export default { + accountIdentity: __t.identity(), + get payload() { + return CraftNotificationPayload; + }, +}; diff --git a/common/bindings/brico-app/read_shared_filters_procedure.ts b/common/bindings/brico-app/read_shared_filters_procedure.ts new file mode 100644 index 00000000..a426eddb --- /dev/null +++ b/common/bindings/brico-app/read_shared_filters_procedure.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 {t as __t,} from "spacetimedb"; + +import {ReadSharedFiltersResult,} from "./types"; + +export const params = { + code: __t.string(), +}; +export const returnType = ReadSharedFiltersResult \ No newline at end of file diff --git a/common/bindings/brico-app/record_bounty_payment_reducer.ts b/common/bindings/brico-app/record_bounty_payment_reducer.ts new file mode 100644 index 00000000..563c4a0d --- /dev/null +++ b/common/bindings/brico-app/record_bounty_payment_reducer.ts @@ -0,0 +1,12 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default { + payeePlayerId: __t.u64(), + currency: __t.string(), + delta: __t.i64(), +}; diff --git a/common/bindings/brico-app/register_service_principal_reducer.ts b/common/bindings/brico-app/register_service_principal_reducer.ts new file mode 100644 index 00000000..e4592d13 --- /dev/null +++ b/common/bindings/brico-app/register_service_principal_reducer.ts @@ -0,0 +1,11 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default { + identity: __t.identity(), + label: __t.string(), +}; diff --git a/common/bindings/brico-app/reorder_bounty_rules_reducer.ts b/common/bindings/brico-app/reorder_bounty_rules_reducer.ts new file mode 100644 index 00000000..de0b6572 --- /dev/null +++ b/common/bindings/brico-app/reorder_bounty_rules_reducer.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 {t as __t,} from "spacetimedb"; + +export default { + orderedIds: __t.array(__t.string()), +}; diff --git a/common/bindings/brico-app/revoke_service_principal_reducer.ts b/common/bindings/brico-app/revoke_service_principal_reducer.ts new file mode 100644 index 00000000..ec27a61e --- /dev/null +++ b/common/bindings/brico-app/revoke_service_principal_reducer.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 {t as __t,} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/common/bindings/brico-app/set_display_name_reducer.ts b/common/bindings/brico-app/set_display_name_reducer.ts new file mode 100644 index 00000000..dd81a8dd --- /dev/null +++ b/common/bindings/brico-app/set_display_name_reducer.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 {t as __t,} from "spacetimedb"; + +export default { + displayName: __t.option(__t.string()), +}; diff --git a/common/bindings/brico-app/types.ts b/common/bindings/brico-app/types.ts new file mode 100644 index 00000000..fd88e475 --- /dev/null +++ b/common/bindings/brico-app/types.ts @@ -0,0 +1,347 @@ +// 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, t as __t,} from "spacetimedb"; + +export const Account = __t.object("Account", { + identity: __t.identity(), + issuer: __t.string(), + subject: __t.string(), + displayName: __t.option(__t.string()), + createdAt: __t.timestamp(), + lastSeenAt: __t.timestamp(), +}); +export type Account = __Infer; + +export const AllAccount = __t.object("AllAccount", {}); +export type AllAccount = __Infer; + +export const AllBountyRule = __t.object("AllBountyRule", {}); +export type AllBountyRule = __Infer; + +export const AllCraftBountyAssignment = __t.object("AllCraftBountyAssignment", {}); +export type AllCraftBountyAssignment = __Infer; + +export const AllCraftBountyEntitlement = __t.object("AllCraftBountyEntitlement", {}); +export type AllCraftBountyEntitlement = __Infer; + +export const AllCraftBountyOverride = __t.object("AllCraftBountyOverride", {}); +export type AllCraftBountyOverride = __Infer; + +export const AllCraftFilterWatch = __t.object("AllCraftFilterWatch", {}); +export type AllCraftFilterWatch = __Infer; + +export const AllIntegrationLinkRequest = __t.object("AllIntegrationLinkRequest", {}); +export type AllIntegrationLinkRequest = __Infer; + +export const AllLinkedIntegration = __t.object("AllLinkedIntegration", {}); +export type AllLinkedIntegration = __Infer; + +export const AllLoyaltyReward = __t.object("AllLoyaltyReward", {}); +export type AllLoyaltyReward = __Infer; + +export const AllPrivateCraftBountyAssignment = __t.object("AllPrivateCraftBountyAssignment", {}); +export type AllPrivateCraftBountyAssignment = __Infer; + +export const AllSavedCraftFilter = __t.object("AllSavedCraftFilter", {}); +export type AllSavedCraftFilter = __Infer; + +export const BountyEntitlementTotal = __t.object("BountyEntitlementTotal", { + id: __t.u64(), + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), + total: __t.i64(), + totalEffort: __t.i64(), + updatedAt: __t.timestamp(), +}); +export type BountyEntitlementTotal = __Infer; + +export const BountyPayoutRecord = __t.object("BountyPayoutRecord", { + id: __t.u64(), + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), + paidTotal: __t.i64(), + updatedAt: __t.timestamp(), +}); +export type BountyPayoutRecord = __Infer; + +export const BountyRule = __t.object("BountyRule", { + id: __t.string(), + accountIdentity: __t.identity(), + priority: __t.i32(), + filterJson: __t.string(), + get value() { + return BountyRuleValue; + }, + private: __t.bool(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + deletedAt: __t.option(__t.timestamp()), +}); +export type BountyRule = __Infer; + +// The tagged union or sum type for the algebraic type `BountyRuleValue`. +export const BountyRuleValue = __t.enum("BountyRuleValue", { + get Flat() { + return FlatBountyValue; + }, + get Grid() { + return GridBountyValue; + }, +}); +export type BountyRuleValue = __Infer; + +export const ContributorEntitlementRow = __t.object("ContributorEntitlementRow", { + id: __t.string(), + payerAccountIdentity: __t.identity(), + payerName: __t.string(), + payeePlayerId: __t.u64(), + currency: __t.string(), + effortTotal: __t.i64(), + earnedTotal: __t.i64(), + paidTotal: __t.i64(), +}); +export type ContributorEntitlementRow = __Infer; + +export const CraftBountyAssignment = __t.object("CraftBountyAssignment", { + craftId: __t.u64(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + currency: __t.string(), + assignedByAccountIdentity: __t.identity(), + assignedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type CraftBountyAssignment = __Infer; + +export const CraftBountyEntitlement = __t.object("CraftBountyEntitlement", { + id: __t.u64(), + craftId: __t.u64(), + playerId: __t.u64(), + currency: __t.string(), + lastAssignedEffort: __t.i64(), + entitledTotal: __t.i64(), + updatedAt: __t.timestamp(), +}); +export type CraftBountyEntitlement = __Infer; + +export const CraftBountyOverride = __t.object("CraftBountyOverride", { + craftId: __t.u64(), + accountIdentity: __t.identity(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + currency: __t.string(), + private: __t.bool(), + updatedAt: __t.timestamp(), +}); +export type CraftBountyOverride = __Infer; + +export const CraftFilterWatch = __t.object("CraftFilterWatch", { + filterId: __t.string(), + accountIdentity: __t.identity(), + added: __t.bool(), + finished: __t.bool(), + removed: __t.bool(), + updatedAt: __t.timestamp(), + deletedAt: __t.option(__t.timestamp()), +}); +export type CraftFilterWatch = __Infer; + +export const CraftNotificationPayload = __t.object("CraftNotificationPayload", { + watchId: __t.string(), + filterName: __t.string(), + eventKind: __t.string(), + craftId: __t.string(), + recipeId: __t.i32(), + regionName: __t.string(), + claimName: __t.option(__t.string()), + ownerName: __t.option(__t.string()), +}); +export type CraftNotificationPayload = __Infer; + +export const CraftPrivateBountyAssignment = __t.object("CraftPrivateBountyAssignment", { + craftId: __t.u64(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + currency: __t.string(), + assignedByAccountIdentity: __t.identity(), + assignedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type CraftPrivateBountyAssignment = __Infer; + +export const CreateSharedFiltersResult = __t.object("CreateSharedFiltersResult", { + code: __t.string(), +}); +export type CreateSharedFiltersResult = __Infer; + +export const FlatBountyValue = __t.object("FlatBountyValue", { + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + currency: __t.string(), +}); +export type FlatBountyValue = __Infer; + +export const GridBountyValue = __t.object("GridBountyValue", { + get cells() { + return __t.array(GridCell); + }, + currency: __t.string(), +}); +export type GridBountyValue = __Infer; + +export const GridCell = __t.object("GridCell", { + skillId: __t.u32(), + tier: __t.u32(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), +}); +export type GridCell = __Infer; + +export const IntegrationLinkRequest = __t.object("IntegrationLinkRequest", { + id: __t.u64(), + scheduledAt: __t.scheduleAt(), + code: __t.string(), + provider: __t.string(), + accountIdentity: __t.option(__t.identity()), + externalId: __t.option(__t.string()), + externalHandle: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type IntegrationLinkRequest = __Infer; + +export const LinkedIntegration = __t.object("LinkedIntegration", { + id: __t.u64(), + accountIdentity: __t.identity(), + provider: __t.string(), + externalId: __t.string(), + externalHandle: __t.option(__t.string()), + linkedAt: __t.timestamp(), + revokedAt: __t.option(__t.timestamp()), +}); +export type LinkedIntegration = __Infer; + +export const LoyaltyReward = __t.object("LoyaltyReward", { + id: __t.u64(), + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + updatedAt: __t.timestamp(), +}); +export type LoyaltyReward = __Infer; + +export const MyAccount = __t.object("MyAccount", {}); +export type MyAccount = __Infer; + +export const MyBountyPayoutRecordAsPayer = __t.object("MyBountyPayoutRecordAsPayer", {}); +export type MyBountyPayoutRecordAsPayer = __Infer; + +export const MyBountyRule = __t.object("MyBountyRule", {}); +export type MyBountyRule = __Infer; + +export const MyCraftBountyOverride = __t.object("MyCraftBountyOverride", {}); +export type MyCraftBountyOverride = __Infer; + +export const MyCraftFilterWatch = __t.object("MyCraftFilterWatch", {}); +export type MyCraftFilterWatch = __Infer; + +export const MyEntitlementsAsContributor = __t.object("MyEntitlementsAsContributor", {}); +export type MyEntitlementsAsContributor = __Infer; + +export const MyEntitlementsAsPayer = __t.object("MyEntitlementsAsPayer", {}); +export type MyEntitlementsAsPayer = __Infer; + +export const MyIntegrationLinkRequest = __t.object("MyIntegrationLinkRequest", {}); +export type MyIntegrationLinkRequest = __Infer; + +export const MyLinkedIntegration = __t.object("MyLinkedIntegration", {}); +export type MyLinkedIntegration = __Infer; + +export const MyLoyaltyReward = __t.object("MyLoyaltyReward", {}); +export type MyLoyaltyReward = __Infer; + +export const MyNotification = __t.object("MyNotification", {}); +export type MyNotification = __Infer; + +export const MyPrivateCraftBountyAssignment = __t.object("MyPrivateCraftBountyAssignment", {}); +export type MyPrivateCraftBountyAssignment = __Infer; + +export const MySavedCraftFilter = __t.object("MySavedCraftFilter", {}); +export type MySavedCraftFilter = __Infer; + +export const Notification = __t.object("Notification", { + id: __t.u64(), + accountIdentity: __t.identity(), + get payload() { + return NotificationPayload; + }, + createdAt: __t.timestamp(), + readAt: __t.option(__t.timestamp()), +}); +export type Notification = __Infer; + +// The tagged union or sum type for the algebraic type `NotificationPayload`. +export const NotificationPayload = __t.enum("NotificationPayload", { + get Craft() { + return CraftNotificationPayload; + }, +}); +export type NotificationPayload = __Infer; + +export const PayerEntitlementRow = __t.object("PayerEntitlementRow", { + id: __t.string(), + payeePlayerId: __t.u64(), + payeeAccountIdentity: __t.option(__t.identity()), + payeeName: __t.option(__t.string()), + currency: __t.string(), + effortTotal: __t.i64(), + earnedTotal: __t.i64(), + paidTotal: __t.i64(), +}); +export type PayerEntitlementRow = __Infer; + +export const ReadSharedFiltersResult = __t.object("ReadSharedFiltersResult", { + get filters() { + return __t.array(SharedFilterRow); + }, +}); +export type ReadSharedFiltersResult = __Infer; + +export const SavedCraftFilter = __t.object("SavedCraftFilter", { + id: __t.string(), + accountIdentity: __t.identity(), + name: __t.string(), + filterJson: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + deletedAt: __t.option(__t.timestamp()), +}); +export type SavedCraftFilter = __Infer; + +export const ServicePrincipal = __t.object("ServicePrincipal", { + identity: __t.identity(), + label: __t.string(), + grantedAt: __t.timestamp(), +}); +export type ServicePrincipal = __Infer; + +export const SharedFilter = __t.object("SharedFilter", { + code: __t.string(), + accountIdentity: __t.identity(), + filterIds: __t.array(__t.string()), + createdAt: __t.timestamp(), +}); +export type SharedFilter = __Infer; + +export const SharedFilterRow = __t.object("SharedFilterRow", { + name: __t.string(), + filterJson: __t.string(), +}); +export type SharedFilterRow = __Infer; + diff --git a/common/bindings/brico-app/types/procedures.ts b/common/bindings/brico-app/types/procedures.ts new file mode 100644 index 00000000..179120a3 --- /dev/null +++ b/common/bindings/brico-app/types/procedures.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 {type Infer as __Infer} from "spacetimedb"; + +// Import all procedure arg schemas +import * as CreateSharedFiltersProcedure from "../create_shared_filters_procedure"; +import * as ReadSharedFiltersProcedure from "../read_shared_filters_procedure"; + +export type CreateSharedFiltersArgs = __Infer; +export type CreateSharedFiltersResult = __Infer; +export type ReadSharedFiltersArgs = __Infer; +export type ReadSharedFiltersResult = __Infer; + diff --git a/common/bindings/brico-app/types/reducers.ts b/common/bindings/brico-app/types/reducers.ts new file mode 100644 index 00000000..02992c72 --- /dev/null +++ b/common/bindings/brico-app/types/reducers.ts @@ -0,0 +1,74 @@ +// 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 AssignCraftBountyReducer from "../assign_craft_bounty_reducer"; +import BeginIntegrationLinkForExternalReducer from "../begin_integration_link_for_external_reducer"; +import BeginIntegrationLinkReducer from "../begin_integration_link_reducer"; +import ClaimIntegrationLinkReducer from "../claim_integration_link_reducer"; +import ClearCraftBountyReducer from "../clear_craft_bounty_reducer"; +import DeleteBountyRuleReducer from "../delete_bounty_rule_reducer"; +import DeleteCraftBountyOverrideReducer from "../delete_craft_bounty_override_reducer"; +import DeleteCraftFilterWatchReducer from "../delete_craft_filter_watch_reducer"; +import DeleteLoyaltyRewardReducer from "../delete_loyalty_reward_reducer"; +import DeleteNotificationReducer from "../delete_notification_reducer"; +import DeleteSavedCraftFilterReducer from "../delete_saved_craft_filter_reducer"; +import EnsureAccountReducer from "../ensure_account_reducer"; +import ImportHistoricalBountyLedgerReducer from "../import_historical_bounty_ledger_reducer"; +import ImportHistoricalLoyaltyRewardReducer from "../import_historical_loyalty_reward_reducer"; +import LinkDiscordViaSpacetimeAuthReducer from "../link_discord_via_spacetime_auth_reducer"; +import LinkIntegrationReducer from "../link_integration_reducer"; +import MarkAllNotificationsReadReducer from "../mark_all_notifications_read_reducer"; +import MarkNotificationReadReducer from "../mark_notification_read_reducer"; +import NoteIntegrationLinkExternalReducer from "../note_integration_link_external_reducer"; +import PostCraftNotificationReducer from "../post_craft_notification_reducer"; +import RecordBountyPaymentReducer from "../record_bounty_payment_reducer"; +import RegisterServicePrincipalReducer from "../register_service_principal_reducer"; +import ReorderBountyRulesReducer from "../reorder_bounty_rules_reducer"; +import RevokeServicePrincipalReducer from "../revoke_service_principal_reducer"; +import SetDisplayNameReducer from "../set_display_name_reducer"; +import UnlinkIntegrationReducer from "../unlink_integration_reducer"; +import UpsertBountyRuleReducer from "../upsert_bounty_rule_reducer"; +import UpsertCraftBountyEntitlementReducer from "../upsert_craft_bounty_entitlement_reducer"; +import UpsertCraftBountyOverrideReducer from "../upsert_craft_bounty_override_reducer"; +import UpsertCraftFilterWatchReducer from "../upsert_craft_filter_watch_reducer"; +import UpsertLoyaltyRewardReducer from "../upsert_loyalty_reward_reducer"; +import UpsertSavedCraftFilterReducer from "../upsert_saved_craft_filter_reducer"; + +export type AssignCraftBountyParams = __Infer; +export type BeginIntegrationLinkParams = __Infer; +export type BeginIntegrationLinkForExternalParams = __Infer; +export type ClaimIntegrationLinkParams = __Infer; +export type ClearCraftBountyParams = __Infer; +export type DeleteBountyRuleParams = __Infer; +export type DeleteCraftBountyOverrideParams = __Infer; +export type DeleteCraftFilterWatchParams = __Infer; +export type DeleteLoyaltyRewardParams = __Infer; +export type DeleteNotificationParams = __Infer; +export type DeleteSavedCraftFilterParams = __Infer; +export type EnsureAccountParams = __Infer; +export type ImportHistoricalBountyLedgerParams = __Infer; +export type ImportHistoricalLoyaltyRewardParams = __Infer; +export type LinkDiscordViaSpacetimeAuthParams = __Infer; +export type LinkIntegrationParams = __Infer; +export type MarkAllNotificationsReadParams = __Infer; +export type MarkNotificationReadParams = __Infer; +export type NoteIntegrationLinkExternalParams = __Infer; +export type PostCraftNotificationParams = __Infer; +export type RecordBountyPaymentParams = __Infer; +export type RegisterServicePrincipalParams = __Infer; +export type ReorderBountyRulesParams = __Infer; +export type RevokeServicePrincipalParams = __Infer; +export type SetDisplayNameParams = __Infer; +export type UnlinkIntegrationParams = __Infer; +export type UpsertBountyRuleParams = __Infer; +export type UpsertCraftBountyEntitlementParams = __Infer; +export type UpsertCraftBountyOverrideParams = __Infer; +export type UpsertCraftFilterWatchParams = __Infer; +export type UpsertLoyaltyRewardParams = __Infer; +export type UpsertSavedCraftFilterParams = __Infer; + diff --git a/common/bindings/brico-app/unlink_integration_reducer.ts b/common/bindings/brico-app/unlink_integration_reducer.ts new file mode 100644 index 00000000..6b5d8547 --- /dev/null +++ b/common/bindings/brico-app/unlink_integration_reducer.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 {t as __t,} from "spacetimedb"; + +export default { + id: __t.u64(), +}; diff --git a/common/bindings/brico-app/upsert_bounty_rule_reducer.ts b/common/bindings/brico-app/upsert_bounty_rule_reducer.ts new file mode 100644 index 00000000..b3cc4eac --- /dev/null +++ b/common/bindings/brico-app/upsert_bounty_rule_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 {t as __t,} from "spacetimedb"; + +import {BountyRuleValue,} from "./types"; + +export default { + id: __t.string(), + filterJson: __t.string(), + get value() { + return BountyRuleValue; + }, + priority: __t.i32(), + private: __t.bool(), + updatedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/upsert_craft_bounty_entitlement_reducer.ts b/common/bindings/brico-app/upsert_craft_bounty_entitlement_reducer.ts new file mode 100644 index 00000000..a621510e --- /dev/null +++ b/common/bindings/brico-app/upsert_craft_bounty_entitlement_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 {t as __t,} from "spacetimedb"; + +export default { + craftId: __t.u64(), + playerId: __t.u64(), + currency: __t.string(), + lastAssignedEffort: __t.i64(), + entitledTotal: __t.i64(), + updatedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/upsert_craft_bounty_override_reducer.ts b/common/bindings/brico-app/upsert_craft_bounty_override_reducer.ts new file mode 100644 index 00000000..db70440a --- /dev/null +++ b/common/bindings/brico-app/upsert_craft_bounty_override_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 {t as __t,} from "spacetimedb"; + +export default { + craftId: __t.u64(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + currency: __t.string(), + private: __t.bool(), + updatedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/upsert_craft_filter_watch_reducer.ts b/common/bindings/brico-app/upsert_craft_filter_watch_reducer.ts new file mode 100644 index 00000000..c290073a --- /dev/null +++ b/common/bindings/brico-app/upsert_craft_filter_watch_reducer.ts @@ -0,0 +1,14 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default { + filterId: __t.string(), + added: __t.bool(), + finished: __t.bool(), + removed: __t.bool(), + updatedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/upsert_loyalty_reward_reducer.ts b/common/bindings/brico-app/upsert_loyalty_reward_reducer.ts new file mode 100644 index 00000000..8b3d4494 --- /dev/null +++ b/common/bindings/brico-app/upsert_loyalty_reward_reducer.ts @@ -0,0 +1,14 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default { + payeePlayerId: __t.u64(), + currency: __t.string(), + ratioNumerator: __t.i64(), + ratioDenominator: __t.i64(), + updatedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/upsert_saved_craft_filter_reducer.ts b/common/bindings/brico-app/upsert_saved_craft_filter_reducer.ts new file mode 100644 index 00000000..ef2e5684 --- /dev/null +++ b/common/bindings/brico-app/upsert_saved_craft_filter_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 {t as __t,} from "spacetimedb"; + +export default { + id: __t.string(), + name: __t.string(), + filterJson: __t.string(), + updatedAt: __t.timestamp(), +}; diff --git a/common/bindings/package.json b/common/bindings/package.json new file mode 100644 index 00000000..a2643694 --- /dev/null +++ b/common/bindings/package.json @@ -0,0 +1,16 @@ +{ + "name": "@brico/bindings", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Generated SpacetimeDB 2.x client bindings (prism's relay-module, brico-app). Regenerate with the SpacetimeDB CLI; do not hand-edit.", + "exports": { + "./prism": "./prism/index.ts", + "./prism/*": "./prism/*.ts", + "./brico-app": "./brico-app/index.ts", + "./brico-app/*": "./brico-app/*.ts" + }, + "dependencies": { + "spacetimedb": "^2.9.0" + } +} diff --git a/common/bindings/prism/claim_info_table.ts b/common/bindings/prism/claim_info_table.ts new file mode 100644 index 00000000..b245fddc --- /dev/null +++ b/common/bindings/prism/claim_info_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + regionId: __t.u8().name("region_id"), + bank: __t.bool(), + marketplace: __t.bool(), + waystone: __t.bool(), + research: __t.array(__t.i32()), + name: __t.string(), +}); diff --git a/common/bindings/prism/claim_member_table.ts b/common/bindings/prism/claim_member_table.ts new file mode 100644 index 00000000..3f3a032f --- /dev/null +++ b/common/bindings/prism/claim_member_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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + regionId: __t.u8().name("region_id"), + claimEntityId: __t.u64().name("claim_entity_id"), + playerEntityId: __t.u64().name("player_entity_id"), + build: __t.bool(), + inventory: __t.bool(), + officer: __t.bool(), + coOwner: __t.bool().name("co_owner"), + owner: __t.bool(), +}); diff --git a/common/bindings/prism/claim_meta_table.ts b/common/bindings/prism/claim_meta_table.ts new file mode 100644 index 00000000..fd614c16 --- /dev/null +++ b/common/bindings/prism/claim_meta_table.ts @@ -0,0 +1,14 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + regionId: __t.u8().name("region_id"), + x: __t.i32(), + z: __t.i32(), + buildingDescId: __t.i32().name("building_desc_id"), +}); diff --git a/common/bindings/prism/claim_supply_table.ts b/common/bindings/prism/claim_supply_table.ts new file mode 100644 index 00000000..5b5acc1a --- /dev/null +++ b/common/bindings/prism/claim_supply_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + regionId: __t.u8().name("region_id"), + supplies: __t.i32(), + numTiles: __t.u32().name("num_tiles"), + numTileNeighbors: __t.u32().name("num_tile_neighbors"), + buildingMaintenance: __t.f32().name("building_maintenance"), +}); diff --git a/common/bindings/prism/craft_contribution_table.ts b/common/bindings/prism/craft_contribution_table.ts new file mode 100644 index 00000000..3393b27f --- /dev/null +++ b/common/bindings/prism/craft_contribution_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + craftId: __t.u64().name("craft_id"), + playerId: __t.u64().name("player_id"), + contribution: __t.i32(), +}); diff --git a/common/bindings/prism/craft_meta_table.ts b/common/bindings/prism/craft_meta_table.ts new file mode 100644 index 00000000..e20d878f --- /dev/null +++ b/common/bindings/prism/craft_meta_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 {t as __t,} from "spacetimedb"; +import {CraftStatus,} from "./types"; + + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + ownerEntityId: __t.u64().name("owner_entity_id"), + claimEntityId: __t.u64().name("claim_entity_id"), + buildingEntityId: __t.u64().name("building_entity_id"), + firstSeen: __t.timestamp().name("first_seen"), + recipeId: __t.i32().name("recipe_id"), + count: __t.i32(), + regionId: __t.u8().name("region_id"), + public: __t.bool(), + get status() { + return CraftStatus; + }, +}); diff --git a/common/bindings/prism/craft_progress_table.ts b/common/bindings/prism/craft_progress_table.ts new file mode 100644 index 00000000..d913ecec --- /dev/null +++ b/common/bindings/prism/craft_progress_table.ts @@ -0,0 +1,12 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + lastSeen: __t.timestamp().name("last_seen"), + progress: __t.i32(), +}); diff --git a/common/bindings/prism/enemy_location_table.ts b/common/bindings/prism/enemy_location_table.ts new file mode 100644 index 00000000..d6994d78 --- /dev/null +++ b/common/bindings/prism/enemy_location_table.ts @@ -0,0 +1,14 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + enemyType: __t.i32().name("enemy_type"), + x: __t.i32(), + z: __t.i32(), + regionId: __t.u8().name("region_id"), +}); diff --git a/common/bindings/prism/growth_timers_table.ts b/common/bindings/prism/growth_timers_table.ts new file mode 100644 index 00000000..60e37885 --- /dev/null +++ b/common/bindings/prism/growth_timers_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + resourceId: __t.i32().name("resource_id"), + x: __t.i32(), + z: __t.i32(), + regionId: __t.u8().name("region_id"), + endTimestamp: __t.timestamp().name("end_timestamp"), +}); diff --git a/common/bindings/prism/herd_location_table.ts b/common/bindings/prism/herd_location_table.ts new file mode 100644 index 00000000..7d7e52be --- /dev/null +++ b/common/bindings/prism/herd_location_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + enemyType: __t.i32().name("enemy_type"), + enemyParamsAiDescId: __t.i32().name("enemy_params_ai_desc_id"), + x: __t.i32(), + z: __t.i32(), + regionId: __t.u8().name("region_id"), +}); diff --git a/common/bindings/prism/index.ts b/common/bindings/prism/index.ts new file mode 100644 index 00000000..6b79f51b --- /dev/null +++ b/common/bindings/prism/index.ts @@ -0,0 +1,486 @@ +// 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.9.0 (commit 9e0d92412ff2248f401a8ad12d535f2b5ac30912). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + type DbConnectionConfig as __DbConnectionConfig, + DbConnectionImpl as __DbConnectionImpl, + type ErrorContextInterface as __ErrorContextInterface, + type EventContextInterface as __EventContextInterface, + makeQueryBuilder as __makeQueryBuilder, + procedures as __procedures, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + reducers as __reducers, + type RemoteModule as __RemoteModule, + schema as __schema, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, + table as __table, +} from "spacetimedb"; + +// Import all reducer arg schemas +// Import all procedure arg schemas +// Import all table schema definitions +import ClaimInfoRow from "./claim_info_table"; +import ClaimMemberRow from "./claim_member_table"; +import ClaimMetaRow from "./claim_meta_table"; +import ClaimSupplyRow from "./claim_supply_table"; +import CraftContributionRow from "./craft_contribution_table"; +import CraftMetaRow from "./craft_meta_table"; +import CraftProgressRow from "./craft_progress_table"; +import EnemyLocationRow from "./enemy_location_table"; +import GrowthTimersRow from "./growth_timers_table"; +import HerdLocationRow from "./herd_location_table"; +import PlayerLocationRow from "./player_location_table"; +import PlayerStateRow from "./player_state_table"; +import RecipeMetaRow from "./recipe_meta_table"; +import RegionRow from "./region_table"; +import ResourceLocationRow from "./resource_location_table"; + +/** 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({ + claimInfo: __table({ + name: 'claim_info', + indexes: [ + { accessor: 'entity_id', name: 'claim_info_entity_id_idx_hash', algorithm: 'btree', columns: [ + 'entityId', + ] }, + { accessor: 'by_region', name: 'claim_info_region_id_idx_btree', algorithm: 'btree', columns: [ + 'regionId', + ] }, + ], + constraints: [ + { name: 'claim_info_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, ClaimInfoRow), + claimMember: __table({ + name: 'claim_member', + indexes: [ + { accessor: 'by_claim', name: 'claim_member_claim_entity_id_idx_btree', algorithm: 'btree', columns: [ + 'claimEntityId', + ] }, + { accessor: 'entity_id', name: 'claim_member_entity_id_idx_hash', algorithm: 'btree', columns: [ + 'entityId', + ] }, + { accessor: 'by_player', name: 'claim_member_player_entity_id_idx_btree', algorithm: 'btree', columns: [ + 'playerEntityId', + ] }, + { accessor: 'by_region', name: 'claim_member_region_id_idx_btree', algorithm: 'btree', columns: [ + 'regionId', + ] }, + ], + constraints: [ + { name: 'claim_member_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, ClaimMemberRow), + claimMeta: __table({ + name: 'claim_meta', + indexes: [ + { accessor: 'by_building', name: 'claim_meta_building_desc_id_idx_btree', algorithm: 'btree', columns: [ + 'buildingDescId', + ] }, + { accessor: 'entity_id', name: 'claim_meta_entity_id_idx_hash', algorithm: 'btree', columns: [ + 'entityId', + ] }, + { accessor: 'by_region', name: 'claim_meta_region_id_idx_btree', algorithm: 'btree', columns: [ + 'regionId', + ] }, + ], + constraints: [ + { name: 'claim_meta_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, ClaimMetaRow), + claimSupply: __table({ + name: 'claim_supply', + indexes: [ + { accessor: 'entity_id', name: 'claim_supply_entity_id_idx_hash', algorithm: 'btree', columns: [ + 'entityId', + ] }, + { accessor: 'by_region', name: 'claim_supply_region_id_idx_btree', algorithm: 'btree', columns: [ + 'regionId', + ] }, + ], + constraints: [ + { name: 'claim_supply_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, ClaimSupplyRow), + craftContribution: __table({ + name: 'craft_contribution', + indexes: [ + { accessor: 'by_craft', name: 'craft_contribution_craft_id_idx_btree', algorithm: 'btree', columns: [ + 'craftId', + ] }, + { accessor: 'by_craft_and_player', name: 'craft_contribution_craft_id_player_id_idx_hash', algorithm: 'btree', columns: [ + 'craftId', + 'playerId', + ] }, + { accessor: 'id', name: 'craft_contribution_id_idx_btree', algorithm: 'btree', columns: [ + 'id', + ] }, + ], + constraints: [ + { name: 'craft_contribution_id_key', constraint: 'unique', columns: ['id'] }, + ], + }, CraftContributionRow), + craftMeta: __table({ + name: 'craft_meta', + indexes: [ + { accessor: 'by_claim', name: 'craft_meta_claim_entity_id_idx_btree', algorithm: 'btree', columns: [ + 'claimEntityId', + ] }, + { accessor: 'entity_id', name: 'craft_meta_entity_id_idx_hash', algorithm: 'btree', columns: [ + 'entityId', + ] }, + { accessor: 'by_owner_and_claim', name: 'craft_meta_owner_entity_id_claim_entity_id_idx_btree', algorithm: 'btree', columns: [ + 'ownerEntityId', + 'claimEntityId', + ] }, + { accessor: 'by_owner', name: 'craft_meta_owner_entity_id_idx_btree', algorithm: 'btree', columns: [ + 'ownerEntityId', + ] }, + ], + constraints: [ + { name: 'craft_meta_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, CraftMetaRow), + craftProgress: __table({ + name: 'craft_progress', + indexes: [ + { accessor: 'entity_id', name: 'craft_progress_entity_id_idx_hash', algorithm: 'btree', columns: [ + 'entityId', + ] }, + ], + constraints: [ + { name: 'craft_progress_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, CraftProgressRow), + enemyLocation: __table({ + name: 'enemy_location', + indexes: [ + { accessor: 'by_enemy_type', name: 'enemy_location_enemy_type_idx_btree', algorithm: 'btree', columns: [ + 'enemyType', + ] }, + { accessor: 'by_enemy_type_and_region', name: 'enemy_location_enemy_type_region_id_idx_btree', algorithm: 'btree', columns: [ + 'enemyType', + 'regionId', + ] }, + { accessor: 'entity_id', name: 'enemy_location_entity_id_idx_hash', algorithm: 'btree', columns: [ + 'entityId', + ] }, + { accessor: 'by_region', name: 'enemy_location_region_id_idx_btree', algorithm: 'btree', columns: [ + 'regionId', + ] }, + ], + constraints: [ + { name: 'enemy_location_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, EnemyLocationRow), + growthTimers: __table({ + name: 'growth_timers', + indexes: [ + { accessor: 'entity_id', name: 'growth_timers_entity_id_idx_hash', algorithm: 'btree', columns: [ + 'entityId', + ] }, + { accessor: 'by_region', name: 'growth_timers_region_id_idx_btree', algorithm: 'btree', columns: [ + 'regionId', + ] }, + ], + constraints: [ + { name: 'growth_timers_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, GrowthTimersRow), + herdLocation: __table({ + name: 'herd_location', + indexes: [ + { accessor: 'by_enemy_type', name: 'herd_location_enemy_type_idx_btree', algorithm: 'btree', columns: [ + 'enemyType', + ] }, + { accessor: 'by_enemy_type_and_region', name: 'herd_location_enemy_type_region_id_idx_btree', algorithm: 'btree', columns: [ + 'enemyType', + 'regionId', + ] }, + { accessor: 'entity_id', name: 'herd_location_entity_id_idx_hash', algorithm: 'btree', columns: [ + 'entityId', + ] }, + { accessor: 'by_region', name: 'herd_location_region_id_idx_btree', algorithm: 'btree', columns: [ + 'regionId', + ] }, + ], + constraints: [ + { name: 'herd_location_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, HerdLocationRow), + playerLocation: __table({ + name: 'player_location', + indexes: [ + { accessor: 'entity_id', name: 'player_location_entity_id_idx_hash', algorithm: 'btree', columns: [ + 'entityId', + ] }, + { accessor: 'by_region', name: 'player_location_region_id_idx_btree', algorithm: 'btree', columns: [ + 'regionId', + ] }, + ], + constraints: [ + { name: 'player_location_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, PlayerLocationRow), + playerState: __table({ + name: 'player_state', + indexes: [ + { accessor: 'entity_id', name: 'player_state_entity_id_idx_hash', algorithm: 'btree', columns: [ + 'entityId', + ] }, + { accessor: 'by_region', name: 'player_state_region_id_idx_btree', algorithm: 'btree', columns: [ + 'regionId', + ] }, + ], + constraints: [ + { name: 'player_state_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, PlayerStateRow), + recipeMeta: __table({ + name: 'recipe_meta', + indexes: [ + { accessor: 'id', name: 'recipe_meta_id_idx_hash', algorithm: 'btree', columns: [ + 'id', + ] }, + ], + constraints: [ + { name: 'recipe_meta_id_key', constraint: 'unique', columns: ['id'] }, + ], + }, RecipeMetaRow), + region: __table({ + name: 'region', + indexes: [ + { accessor: 'id', name: 'region_id_idx_direct', algorithm: 'btree', columns: [ + 'id', + ] }, + ], + constraints: [ + { name: 'region_id_key', constraint: 'unique', columns: ['id'] }, + ], + }, RegionRow), + resourceLocation: __table({ + name: 'resource_location', + indexes: [ + { accessor: 'entity_id', name: 'resource_location_entity_id_idx_hash', algorithm: 'btree', columns: [ + 'entityId', + ] }, + { accessor: 'by_region', name: 'resource_location_region_id_idx_btree', algorithm: 'btree', columns: [ + 'regionId', + ] }, + { accessor: 'by_resource', name: 'resource_location_resource_id_idx_btree', algorithm: 'btree', columns: [ + 'resourceId', + ] }, + { accessor: 'by_resource_and_region', name: 'resource_location_resource_id_region_id_idx_btree', algorithm: 'btree', columns: [ + 'resourceId', + 'regionId', + ] }, + ], + constraints: [ + { name: 'resource_location_entity_id_key', constraint: 'unique', columns: ['entityId'] }, + ], + }, ResourceLocationRow), +}); + +/** 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( +); + +/** 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( +); + +type __SchemaWithTableAccessorAliases = Omit & { + tables: typeof tablesSchema.schemaType.tables & { + /** @deprecated Use `claimInfo` instead. This alias will be removed in the next major version. */ + readonly "claim_info": Omit & { readonly accessorName: "claim_info" }; + /** @deprecated Use `claimMember` instead. This alias will be removed in the next major version. */ + readonly "claim_member": Omit & { readonly accessorName: "claim_member" }; + /** @deprecated Use `claimMeta` instead. This alias will be removed in the next major version. */ + readonly "claim_meta": Omit & { readonly accessorName: "claim_meta" }; + /** @deprecated Use `claimSupply` instead. This alias will be removed in the next major version. */ + readonly "claim_supply": Omit & { readonly accessorName: "claim_supply" }; + /** @deprecated Use `craftContribution` instead. This alias will be removed in the next major version. */ + readonly "craft_contribution": Omit & { readonly accessorName: "craft_contribution" }; + /** @deprecated Use `craftMeta` instead. This alias will be removed in the next major version. */ + readonly "craft_meta": Omit & { readonly accessorName: "craft_meta" }; + /** @deprecated Use `craftProgress` instead. This alias will be removed in the next major version. */ + readonly "craft_progress": Omit & { readonly accessorName: "craft_progress" }; + /** @deprecated Use `enemyLocation` instead. This alias will be removed in the next major version. */ + readonly "enemy_location": Omit & { readonly accessorName: "enemy_location" }; + /** @deprecated Use `growthTimers` instead. This alias will be removed in the next major version. */ + readonly "growth_timers": Omit & { readonly accessorName: "growth_timers" }; + /** @deprecated Use `herdLocation` instead. This alias will be removed in the next major version. */ + readonly "herd_location": Omit & { readonly accessorName: "herd_location" }; + /** @deprecated Use `playerLocation` instead. This alias will be removed in the next major version. */ + readonly "player_location": Omit & { readonly accessorName: "player_location" }; + /** @deprecated Use `playerState` instead. This alias will be removed in the next major version. */ + readonly "player_state": Omit & { readonly accessorName: "player_state" }; + /** @deprecated Use `recipeMeta` instead. This alias will be removed in the next major version. */ + readonly "recipe_meta": Omit & { readonly accessorName: "recipe_meta" }; + /** @deprecated Use `resourceLocation` instead. This alias will be removed in the next major version. */ + readonly "resource_location": Omit & { readonly accessorName: "resource_location" }; + }; +}; + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.9.0" as const, + }, + tables: tablesSchema.schemaType.tables as __SchemaWithTableAccessorAliases["tables"], + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + __SchemaWithTableAccessorAliases, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +const tableAccessorAliases = { + "claim_info": "claimInfo", + "claim_member": "claimMember", + "claim_meta": "claimMeta", + "claim_supply": "claimSupply", + "craft_contribution": "craftContribution", + "craft_meta": "craftMeta", + "craft_progress": "craftProgress", + "enemy_location": "enemyLocation", + "growth_timers": "growthTimers", + "herd_location": "herdLocation", + "player_location": "playerLocation", + "player_state": "playerState", + "recipe_meta": "recipeMeta", + "resource_location": "resourceLocation", +} as const; + +function __withTableAccessorAliases(target: T, freeze = false): T { + const out = Object.create(Object.getPrototypeOf(target)) as T & Record; + Object.defineProperties(out, Object.getOwnPropertyDescriptors(target)); + for (const [deprecatedAccessor, targetAccessor] of Object.entries(tableAccessorAliases)) { + if (deprecatedAccessor in out) { + continue; + } + Object.defineProperty(out, deprecatedAccessor, { + enumerable: true, + configurable: false, + get: () => out[targetAccessor], + }); + } + return freeze ? Object.freeze(out) : out; +} + +type __DbViewBase = __DbConnectionImpl["db"]; +export type DbView = __DbViewBase & { + /** @deprecated Use `claimInfo` instead. This alias will be removed in the next major version. */ + readonly "claim_info": __DbViewBase["claimInfo"]; + /** @deprecated Use `claimMember` instead. This alias will be removed in the next major version. */ + readonly "claim_member": __DbViewBase["claimMember"]; + /** @deprecated Use `claimMeta` instead. This alias will be removed in the next major version. */ + readonly "claim_meta": __DbViewBase["claimMeta"]; + /** @deprecated Use `claimSupply` instead. This alias will be removed in the next major version. */ + readonly "claim_supply": __DbViewBase["claimSupply"]; + /** @deprecated Use `craftContribution` instead. This alias will be removed in the next major version. */ + readonly "craft_contribution": __DbViewBase["craftContribution"]; + /** @deprecated Use `craftMeta` instead. This alias will be removed in the next major version. */ + readonly "craft_meta": __DbViewBase["craftMeta"]; + /** @deprecated Use `craftProgress` instead. This alias will be removed in the next major version. */ + readonly "craft_progress": __DbViewBase["craftProgress"]; + /** @deprecated Use `enemyLocation` instead. This alias will be removed in the next major version. */ + readonly "enemy_location": __DbViewBase["enemyLocation"]; + /** @deprecated Use `growthTimers` instead. This alias will be removed in the next major version. */ + readonly "growth_timers": __DbViewBase["growthTimers"]; + /** @deprecated Use `herdLocation` instead. This alias will be removed in the next major version. */ + readonly "herd_location": __DbViewBase["herdLocation"]; + /** @deprecated Use `playerLocation` instead. This alias will be removed in the next major version. */ + readonly "player_location": __DbViewBase["playerLocation"]; + /** @deprecated Use `playerState` instead. This alias will be removed in the next major version. */ + readonly "player_state": __DbViewBase["playerState"]; + /** @deprecated Use `recipeMeta` instead. This alias will be removed in the next major version. */ + readonly "recipe_meta": __DbViewBase["recipeMeta"]; + /** @deprecated Use `resourceLocation` instead. This alias will be removed in the next major version. */ + readonly "resource_location": __DbViewBase["resourceLocation"]; +}; + +type __TablesBase = __QueryBuilder; +export type Tables = __TablesBase & { + /** @deprecated Use `claimInfo` instead. This alias will be removed in the next major version. */ + readonly "claim_info": __TablesBase["claimInfo"]; + /** @deprecated Use `claimMember` instead. This alias will be removed in the next major version. */ + readonly "claim_member": __TablesBase["claimMember"]; + /** @deprecated Use `claimMeta` instead. This alias will be removed in the next major version. */ + readonly "claim_meta": __TablesBase["claimMeta"]; + /** @deprecated Use `claimSupply` instead. This alias will be removed in the next major version. */ + readonly "claim_supply": __TablesBase["claimSupply"]; + /** @deprecated Use `craftContribution` instead. This alias will be removed in the next major version. */ + readonly "craft_contribution": __TablesBase["craftContribution"]; + /** @deprecated Use `craftMeta` instead. This alias will be removed in the next major version. */ + readonly "craft_meta": __TablesBase["craftMeta"]; + /** @deprecated Use `craftProgress` instead. This alias will be removed in the next major version. */ + readonly "craft_progress": __TablesBase["craftProgress"]; + /** @deprecated Use `enemyLocation` instead. This alias will be removed in the next major version. */ + readonly "enemy_location": __TablesBase["enemyLocation"]; + /** @deprecated Use `growthTimers` instead. This alias will be removed in the next major version. */ + readonly "growth_timers": __TablesBase["growthTimers"]; + /** @deprecated Use `herdLocation` instead. This alias will be removed in the next major version. */ + readonly "herd_location": __TablesBase["herdLocation"]; + /** @deprecated Use `playerLocation` instead. This alias will be removed in the next major version. */ + readonly "player_location": __TablesBase["playerLocation"]; + /** @deprecated Use `playerState` instead. This alias will be removed in the next major version. */ + readonly "player_state": __TablesBase["playerState"]; + /** @deprecated Use `recipeMeta` instead. This alias will be removed in the next major version. */ + readonly "recipe_meta": __TablesBase["recipeMeta"]; + /** @deprecated Use `resourceLocation` instead. This alias will be removed in the next major version. */ + readonly "resource_location": __TablesBase["resourceLocation"]; +}; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +const tablesBase: __TablesBase = __makeQueryBuilder(tablesSchema.schemaType); +export const tables: Tables = __withTableAccessorAliases(tablesBase, true) as Tables; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = Omit<__EventContextInterface, "db"> & { db: DbView }; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = Omit<__ReducerEventContextInterface, "db"> & { db: DbView }; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = Omit<__SubscriptionEventContextInterface, "db"> & { db: DbView }; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = Omit<__ErrorContextInterface, "db"> & { db: DbView }; +/** 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 { + declare db: DbView; + + constructor(config: __DbConnectionConfig) { + super(config); + this.db = __withTableAccessorAliases(this.db) as DbView; + } + + /** 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/common/bindings/prism/player_location_table.ts b/common/bindings/prism/player_location_table.ts new file mode 100644 index 00000000..e1cc6f57 --- /dev/null +++ b/common/bindings/prism/player_location_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + x: __t.i32(), + z: __t.i32(), + regionId: __t.u8().name("region_id"), +}); diff --git a/common/bindings/prism/player_state_table.ts b/common/bindings/prism/player_state_table.ts new file mode 100644 index 00000000..885e9889 --- /dev/null +++ b/common/bindings/prism/player_state_table.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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + regionId: __t.u8().name("region_id"), + online: __t.bool(), + name: __t.string(), +}); diff --git a/common/bindings/prism/recipe_meta_table.ts b/common/bindings/prism/recipe_meta_table.ts new file mode 100644 index 00000000..681c7c2f --- /dev/null +++ b/common/bindings/prism/recipe_meta_table.ts @@ -0,0 +1,14 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.i32().primaryKey(), + effortRequired: __t.i32().name("effort_required"), + skillId: __t.i32().name("skill_id"), + expPerProgress: __t.f32().name("exp_per_progress"), + levelRequired: __t.i32().name("level_required"), +}); diff --git a/common/bindings/prism/region_table.ts b/common/bindings/prism/region_table.ts new file mode 100644 index 00000000..44446977 --- /dev/null +++ b/common/bindings/prism/region_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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.u8().primaryKey(), + name: __t.string(), + minChunkX: __t.u16().name("min_chunk_x"), + minChunkZ: __t.u16().name("min_chunk_z"), + widthChunks: __t.u16().name("width_chunks"), + heightChunks: __t.u16().name("height_chunks"), + initialized: __t.bool(), + allowPlayers: __t.bool().name("allow_players"), + allowPlayerSpawns: __t.bool().name("allow_player_spawns"), +}); diff --git a/common/bindings/prism/resource_location_table.ts b/common/bindings/prism/resource_location_table.ts new file mode 100644 index 00000000..c4e218f4 --- /dev/null +++ b/common/bindings/prism/resource_location_table.ts @@ -0,0 +1,14 @@ +// 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 {t as __t,} from "spacetimedb"; + +export default __t.row({ + entityId: __t.u64().primaryKey().name("entity_id"), + resourceId: __t.i32().name("resource_id"), + regionId: __t.u8().name("region_id"), + x: __t.i32(), + z: __t.i32(), +}); diff --git a/common/bindings/prism/types.ts b/common/bindings/prism/types.ts new file mode 100644 index 00000000..1ff73cd8 --- /dev/null +++ b/common/bindings/prism/types.ts @@ -0,0 +1,259 @@ +// 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, t as __t,} from "spacetimedb"; + +export const ClaimInfo = __t.object("ClaimInfo", { + entityId: __t.u64(), + regionId: __t.u8(), + bank: __t.bool(), + marketplace: __t.bool(), + waystone: __t.bool(), + research: __t.array(__t.i32()), + name: __t.string(), +}); +export type ClaimInfo = __Infer; + +// The tagged union or sum type for the algebraic type `ClaimInfoField`. +export const ClaimInfoField = __t.enum("ClaimInfoField", { + Name: __t.string(), + Bank: __t.bool(), + Marketplace: __t.bool(), + Waystone: __t.bool(), + Research: __t.array(__t.i32()), +}); +export type ClaimInfoField = __Infer; + +export const ClaimInfoUpdate = __t.object("ClaimInfoUpdate", { + entityId: __t.u64(), + get field() { + return ClaimInfoField; + }, +}); +export type ClaimInfoUpdate = __Infer; + +export const ClaimMember = __t.object("ClaimMember", { + entityId: __t.u64(), + regionId: __t.u8(), + claimEntityId: __t.u64(), + playerEntityId: __t.u64(), + build: __t.bool(), + inventory: __t.bool(), + officer: __t.bool(), + coOwner: __t.bool(), + owner: __t.bool(), +}); +export type ClaimMember = __Infer; + +export const ClaimMeta = __t.object("ClaimMeta", { + entityId: __t.u64(), + regionId: __t.u8(), + x: __t.i32(), + z: __t.i32(), + buildingDescId: __t.i32(), +}); +export type ClaimMeta = __Infer; + +export const ClaimOwnerUpdate = __t.object("ClaimOwnerUpdate", { + claimEntityId: __t.u64(), + ownerPlayerEntityId: __t.u64(), +}); +export type ClaimOwnerUpdate = __Infer; + +export const ClaimSupply = __t.object("ClaimSupply", { + entityId: __t.u64(), + regionId: __t.u8(), + supplies: __t.i32(), + numTiles: __t.u32(), + numTileNeighbors: __t.u32(), + buildingMaintenance: __t.f32(), +}); +export type ClaimSupply = __Infer; + +export const CraftContribution = __t.object("CraftContribution", { + id: __t.u64(), + craftId: __t.u64(), + playerId: __t.u64(), + contribution: __t.i32(), +}); +export type CraftContribution = __Infer; + +export const CraftContributionDelta = __t.object("CraftContributionDelta", { + craftId: __t.u64(), + playerId: __t.u64(), + progressDelta: __t.i32(), + progressTotal: __t.i32(), + lastSeen: __t.timestamp(), +}); +export type CraftContributionDelta = __Infer; + +export const CraftExpiry = __t.object("CraftExpiry", { + craftId: __t.u64(), + get status() { + return CraftStatus; + }, +}); +export type CraftExpiry = __Infer; + +export const CraftMeta = __t.object("CraftMeta", { + entityId: __t.u64(), + ownerEntityId: __t.u64(), + claimEntityId: __t.u64(), + buildingEntityId: __t.u64(), + firstSeen: __t.timestamp(), + recipeId: __t.i32(), + count: __t.i32(), + regionId: __t.u8(), + public: __t.bool(), + get status() { + return CraftStatus; + }, +}); +export type CraftMeta = __Infer; + +export const CraftProgress = __t.object("CraftProgress", { + entityId: __t.u64(), + lastSeen: __t.timestamp(), + progress: __t.i32(), +}); +export type CraftProgress = __Infer; + +export const CraftPublicUpdate = __t.object("CraftPublicUpdate", { + craftId: __t.u64(), + public: __t.bool(), +}); +export type CraftPublicUpdate = __Infer; + +// The tagged union or sum type for the algebraic type `CraftStatus`. +export const CraftStatus = __t.enum("CraftStatus", { + Active: __t.unit(), + Claimed: __t.unit(), + Removed: __t.unit(), +}); +export type CraftStatus = __Infer; + +export const CraftUpdate = __t.object("CraftUpdate", { + entityId: __t.u64(), + ownerEntityId: __t.u64(), + claimEntityId: __t.u64(), + buildingEntityId: __t.u64(), + firstSeen: __t.timestamp(), + recipeId: __t.i32(), + count: __t.i32(), + regionId: __t.u8(), + public: __t.bool(), + progress: __t.i32(), + lastSeen: __t.timestamp(), +}); +export type CraftUpdate = __Infer; + +export const EnemyLocation = __t.object("EnemyLocation", { + entityId: __t.u64(), + enemyType: __t.i32(), + x: __t.i32(), + z: __t.i32(), + regionId: __t.u8(), +}); +export type EnemyLocation = __Infer; + +export const ExpiringCraft = __t.object("ExpiringCraft", { + craftId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type ExpiringCraft = __Infer; + +export const GrowthTimer = __t.object("GrowthTimer", { + entityId: __t.u64(), + resourceId: __t.i32(), + x: __t.i32(), + z: __t.i32(), + regionId: __t.u8(), + endTimestamp: __t.timestamp(), +}); +export type GrowthTimer = __Infer; + +export const GrowthTimerUpdate = __t.object("GrowthTimerUpdate", { + entityId: __t.u64(), + endTimestamp: __t.timestamp(), +}); +export type GrowthTimerUpdate = __Infer; + +export const HerdLocation = __t.object("HerdLocation", { + entityId: __t.u64(), + enemyType: __t.i32(), + enemyParamsAiDescId: __t.i32(), + x: __t.i32(), + z: __t.i32(), + regionId: __t.u8(), +}); +export type HerdLocation = __Infer; + +export const MobileMoveUpdate = __t.object("MobileMoveUpdate", { + entityId: __t.u64(), + regionId: __t.u8(), + x: __t.i32(), + z: __t.i32(), +}); +export type MobileMoveUpdate = __Infer; + +export const PlayerLocation = __t.object("PlayerLocation", { + entityId: __t.u64(), + x: __t.i32(), + z: __t.i32(), + regionId: __t.u8(), +}); +export type PlayerLocation = __Infer; + +export const PlayerRenameUpdate = __t.object("PlayerRenameUpdate", { + entityId: __t.u64(), + name: __t.string(), +}); +export type PlayerRenameUpdate = __Infer; + +export const PlayerState = __t.object("PlayerState", { + entityId: __t.u64(), + regionId: __t.u8(), + online: __t.bool(), + name: __t.string(), +}); +export type PlayerState = __Infer; + +export const RecipeMeta = __t.object("RecipeMeta", { + id: __t.i32(), + effortRequired: __t.i32(), + skillId: __t.i32(), + expPerProgress: __t.f32(), + levelRequired: __t.i32(), +}); +export type RecipeMeta = __Infer; + +export const Region = __t.object("Region", { + id: __t.u8(), + name: __t.string(), + minChunkX: __t.u16(), + minChunkZ: __t.u16(), + widthChunks: __t.u16(), + heightChunks: __t.u16(), + initialized: __t.bool(), + allowPlayers: __t.bool(), + allowPlayerSpawns: __t.bool(), +}); +export type Region = __Infer; + +export const RelayConfig = __t.object("RelayConfig", { + id: __t.u8(), + identity: __t.identity(), +}); +export type RelayConfig = __Infer; + +export const ResourceLocation = __t.object("ResourceLocation", { + entityId: __t.u64(), + resourceId: __t.i32(), + regionId: __t.u8(), + x: __t.i32(), + z: __t.i32(), +}); +export type ResourceLocation = __Infer; + diff --git a/common/bitcraft-bindings/package.json b/common/bitcraft-bindings/package.json new file mode 100644 index 00000000..2f21ff05 --- /dev/null +++ b/common/bitcraft-bindings/package.json @@ -0,0 +1,13 @@ +{ + "name": "@brico/bitcraft-bindings", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "BitCraft upstream schema bindings (SpacetimeDB SDK 2.x). `upstream/` is a git submodule (BitCraft_Bindings, branch ts-region-2) — never edit it in place.", + "exports": { + "./*": "./upstream/src/*.ts" + }, + "dependencies": { + "spacetimedb": "^2.9.0" + } +} diff --git a/common/bitcraft-bindings/upstream b/common/bitcraft-bindings/upstream new file mode 160000 index 00000000..a7ab1b51 --- /dev/null +++ b/common/bitcraft-bindings/upstream @@ -0,0 +1 @@ +Subproject commit a7ab1b5171d4089fbe87c8ee170844285c7de485 diff --git a/common/crafts/entitlement.test.ts b/common/crafts/entitlement.test.ts new file mode 100644 index 00000000..2a89d7c1 --- /dev/null +++ b/common/crafts/entitlement.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import {describe, it} from "node:test"; +import {computeEntitlement, parseDecimalRatio, reduceRatio} from "./entitlement.ts"; + +describe("computeEntitlement", () => { + it("matches the worked example: ratio 1 Hex Coin per 20 effort", () => { + // docs/craft-manager-phase-3-bounties-payouts.md's confirmed worked example. + assert.equal(computeEntitlement(130n, 1n, 20n), 6n); + assert.equal(computeEntitlement(90n, 1n, 20n), 4n); + assert.equal(computeEntitlement(80n, 1n, 20n), 4n); + // Sum of individually-floored entitlements (14) never exceeds the ideal total floored off + // combined effort (floor(300/20) = 15) — the structural no-overpayment guarantee. + const sum = computeEntitlement(130n, 1n, 20n) + computeEntitlement(90n, 1n, 20n) + computeEntitlement(80n, 1n, 20n); + assert.equal(sum, 14n); + assert.ok(sum <= computeEntitlement(300n, 1n, 20n)); + }); + + it("pays only the delta on top of an already-recorded entitlement, as more effort comes in", () => { + // Alice: 130 -> 140 effort. Bob unchanged at 90. Carol: 80 -> 100 effort. + const alicePrev = computeEntitlement(130n, 1n, 20n); + const aliceNow = computeEntitlement(140n, 1n, 20n); + assert.equal(aliceNow - alicePrev, 1n); + + const bobPrev = computeEntitlement(90n, 1n, 20n); + const bobNow = computeEntitlement(90n, 1n, 20n); + assert.equal(bobNow - bobPrev, 0n); + + const carolPrev = computeEntitlement(80n, 1n, 20n); + const carolNow = computeEntitlement(100n, 1n, 20n); + assert.equal(carolNow - carolPrev, 1n); + }); + + it("floors exactly at a multiple of the ratio, with no remainder", () => { + assert.equal(computeEntitlement(100n, 1n, 20n), 5n); + assert.equal(computeEntitlement(99n, 1n, 20n), 4n); + assert.equal(computeEntitlement(101n, 1n, 20n), 5n); + }); + + it("is exact for large effort/ratio values a float would round", () => { + // Number.MAX_SAFE_INTEGER is 2^53 - 1; a naive `Number()` cast here would lose precision. + const hugeEffort = 9_007_199_254_740_993n; // 2^53 + 1 + assert.equal(computeEntitlement(hugeEffort, 1n, 1n), hugeEffort); + assert.equal(computeEntitlement(hugeEffort, 3n, 3n), hugeEffort); + }); + + it("is zero for zero effort", () => { + assert.equal(computeEntitlement(0n, 1n, 20n), 0n); + }); +}); + +describe("reduceRatio", () => { + it("reduces by gcd", () => { + assert.deepEqual(reduceRatio(40n, 100n), {numerator: 2n, denominator: 5n}); + }); + + it("reduces zero to 0/1", () => { + assert.deepEqual(reduceRatio(0n, 20n), {numerator: 0n, denominator: 1n}); + }); + + it("leaves an already-reduced fraction alone", () => { + assert.deepEqual(reduceRatio(1n, 20n), {numerator: 1n, denominator: 20n}); + }); +}); + +describe("parseDecimalRatio", () => { + it("parses a simple decimal into a reduced fraction", () => { + assert.deepEqual(parseDecimalRatio("0.4"), {numerator: 2n, denominator: 5n}); + }); + + it("reduces trailing zeros to the same fraction as without them", () => { + assert.deepEqual(parseDecimalRatio("0.40"), parseDecimalRatio("0.4")); + }); + + it("parses a whole number with no decimal point", () => { + assert.deepEqual(parseDecimalRatio("5"), {numerator: 5n, denominator: 1n}); + }); + + it("parses zero", () => { + assert.deepEqual(parseDecimalRatio("0"), {numerator: 0n, denominator: 1n}); + assert.deepEqual(parseDecimalRatio("0.0"), {numerator: 0n, denominator: 1n}); + }); + + it("parses a multi-digit fraction exactly, never through a float round-trip", () => { + // 0.1 + 0.2 famously isn't 0.3 in floating point; this must not go anywhere near that path. + assert.deepEqual(parseDecimalRatio("0.123456789"), {numerator: 123456789n, denominator: 1000000000n}); + }); + + it("parses a leading-dot decimal the same as its explicit-zero spelling", () => { + assert.deepEqual(parseDecimalRatio(".5"), parseDecimalRatio("0.5")); + assert.deepEqual(parseDecimalRatio(".25"), {numerator: 1n, denominator: 4n}); + }); + + it("rejects malformed input", () => { + assert.equal(parseDecimalRatio(""), null); + assert.equal(parseDecimalRatio("."), null); + assert.equal(parseDecimalRatio("5."), null); + assert.equal(parseDecimalRatio("1.2.3"), null); + assert.equal(parseDecimalRatio("-1"), null); + assert.equal(parseDecimalRatio("abc"), null); + assert.equal(parseDecimalRatio("1,5"), null); + }); +}); diff --git a/common/crafts/entitlement.ts b/common/crafts/entitlement.ts new file mode 100644 index 00000000..b35d6a2a --- /dev/null +++ b/common/crafts/entitlement.ts @@ -0,0 +1,66 @@ +/** + * entitlement.ts — the payout math shared by `brico-bot`'s real ledger writes and the frontend's + * "estimated payout" preview, so there is exactly one implementation of the floor-based rule, not + * two hand-duplicated copies that could quietly drift apart. + * + * No floating point anywhere here — ratios and cumulative effort are always integers or exact + * integer fractions (`bigint`), matching every other 64-bit quantity this package handles (see + * `subject.ts`'s id-precision notes). A decimal ratio typed into the UI is parsed into a reduced + * integer fraction by `parseDecimalRatio`'s exact decimal-string arithmetic, never through a float + * round-trip. + */ + +function gcd(a: bigint, b: bigint): bigint { + let x = a < 0n ? -a : a; + let y = b < 0n ? -b : b; + while (y !== 0n) { + [x, y] = [y, x % y]; + } + return x; +} + +/** Reduces a non-negative fraction to lowest terms — `0` always reduces to `0/1`. */ +export function reduceRatio(numerator: bigint, denominator: bigint): {numerator: bigint; denominator: bigint} { + if (numerator === 0n) return {numerator: 0n, denominator: 1n}; + const divisor = gcd(numerator, denominator); + return {numerator: numerator / divisor, denominator: denominator / divisor}; +} + +/** + * A contributor's total entitlement off their own cumulative effort — a monotonic floor, never + * negative-adjusted: `floor(cumulativeEffort × ratioNumerator / ratioDenominator)`. + * + * Each caller only ever pays the **delta** since that contributor's own last-recorded entitlement + * (`craft_bounty_entitlement.entitledTotal`) — re-running this on the new cumulative effort and + * subtracting the old result is always correct, with no separate "what changed" tracking needed. + * Since each contributor's entitlement is floored independently off their own effort, the sum of + * individually-floored entitlements can never exceed the craft's ideal total (floored off combined + * effort) — `floor(a) + floor(b) ≤ floor(a + b)` for any real numbers — so this guarantees no + * aggregate overpayment for free, with no largest-remainder or pot-splitting logic needed. + */ +export function computeEntitlement(cumulativeEffort: bigint, ratioNumerator: bigint, ratioDenominator: bigint): bigint { + // bigint division truncates toward zero, which is exactly `floor` since every value here is + // non-negative. + return (cumulativeEffort * ratioNumerator) / ratioDenominator; +} + +// The integer part is optional exactly when there's a fractional part to lead with instead — ".25" +// reads the same as "0.25" to anyone typing it, so it's accepted the same way; a bare "." (neither +// part present) is still rejected, and so is a trailing "." with no digits after it. +const DECIMAL_PATTERN = /^(\d+(\.\d+)?|\.\d+)$/; + +/** + * Parses a plain decimal string (e.g. `"0.4"` or `".4"`, typed into a ratio input) into an exact, + * reduced integer fraction — by counting decimal places and scaling, never by round-tripping through + * a float. Returns `null` for anything that isn't a bare non-negative decimal number (empty string, + * multiple decimal points, a sign, non-digit characters). + */ +export function parseDecimalRatio(input: string): {numerator: bigint; denominator: bigint} | null { + const trimmed = input.trim(); + if (!DECIMAL_PATTERN.test(trimmed)) return null; + const [integerPart, fractionalPart] = trimmed.split("."); + if (fractionalPart === undefined) return reduceRatio(BigInt(integerPart), 1n); + // `integerPart` is `""` for a leading-dot input like ".25" — `BigInt("" + "25")` still parses + // fine, so no special-casing is needed beyond the regex accepting the shape at all. + return reduceRatio(BigInt(integerPart + fractionalPart), 10n ** BigInt(fractionalPart.length)); +} diff --git a/common/crafts/errors.ts b/common/crafts/errors.ts new file mode 100644 index 00000000..b9677fd8 --- /dev/null +++ b/common/crafts/errors.ts @@ -0,0 +1,109 @@ +/** + * errors.ts — the shared `SenderError` code contract between `backend/brico-module` and the + * frontend, per `docs/craft-manager-i18n-design.md` part 2's decided design (option A: structured + * error codes, not the frontend-only regex table the doc originally recommended — codes turned out + * to be legible enough in `spacetime logs`/`spacetime call` output on their own that there was no + * "keep the log text human-sentence-readable" cost left to justify the regex approach's ongoing + * drift risk). + * + * Plain TypeScript, no imports — this lives in `@brico/crafts` (already a dependency of both + * `backend/brico-module` and the frontend) rather than a new package, since colocating it costs + * nothing extra and keeps one fewer `file:` dependency to wire up. + * + * Wire format: `new SenderError(CraftError.SOME_CODE)` for the common case (a bare code, nothing + * else — legible on its own, e.g. `FILTER_TOO_LARGE`). A `` `${CraftError.SOME_CODE}:${value}` `` + * suffix only for the handful of codes that carry a genuinely dynamic value no shared constant can + * substitute for (see each code's doc comment below) — `describeServerError` on the frontend splits + * on the first `:`. Every *fixed* limit (`MAX_FILTER_NAME_LENGTH` etc.) lives here too, specifically + * so neither side ever needs to put that number on the wire: the frontend imports the same constant + * the module validates against and interpolates it directly into its translated message. + */ + +// ── Shared limits ──────────────────────────────────────────────── +// +// Moved here from `backend/brico-module/spacetimedb/src/index.ts` so the frontend's translated +// error messages can interpolate the exact same number the module validates against, without it +// ever needing to travel in the thrown `SenderError` itself. + +export const MAX_DISPLAY_NAME_LENGTH = 40; +export const MAX_FILTER_NAME_LENGTH = 80; +export const MAX_FILTER_JSON_LENGTH = 8 * 1024; +export const MAX_SHARED_FILTER_IDS = 200; + +// ── Error codes ────────────────────────────────────────────────── + +/** + * A `const` object (not a bare `type` union) so both `backend/brico-module` and the frontend get + * autocomplete and refactor-safety at every `throw new SenderError(CraftError.SOME_CODE)` call site, + * instead of hand-typed string literals repeated ~40 times. + * + * Every reducer/procedure that can reject a caller-triggered request needs *a* code here — including + * the internal/service-principal-only ones nobody but `brico-bot` can trigger — because the value of + * this contract is being exhaustive: `frontend/src/lib/crafts/error-vocab.ts`'s `ERROR_VOCAB` is a + * plain (non-`Partial`) `Record`, so TypeScript refuses to compile if this + * object grows an entry that file hasn't caught up to. Low-priority codes just get plain, unfancy + * `msg` text there rather than bespoke wording. + */ +export const CraftError = { + // Account / identity + NOT_OIDC_AUTHENTICATED: 'NOT_OIDC_AUTHENTICATED', + /** Dynamic — carries the rejected issuer as its `:`-suffix (`requireTrustedJwt`, an OIDC-config-level error no normal user flow can trigger). */ + ISSUER_NOT_ACCEPTED: 'ISSUER_NOT_ACCEPTED', + /** Dynamic — carries the rejected (comma-joined) audience list as its `:`-suffix, same caveat as `ISSUER_NOT_ACCEPTED`. */ + AUDIENCE_NOT_ACCEPTED: 'AUDIENCE_NOT_ACCEPTED', + NOT_SERVICE_PRINCIPAL: 'NOT_SERVICE_PRINCIPAL', + /** The caller (`requireAccount`) has no `account` row — as opposed to `UNKNOWN_BRICO_ACCOUNT`, which is about some *other* referenced identity. */ + NO_BRICO_ACCOUNT: 'NO_BRICO_ACCOUNT', + SERVICE_PRINCIPAL_CANNOT_HOLD_ACCOUNT: 'SERVICE_PRINCIPAL_CANNOT_HOLD_ACCOUNT', + /** `setDisplayName` — see `MAX_DISPLAY_NAME_LENGTH`. */ + DISPLAY_NAME_TOO_LONG: 'DISPLAY_NAME_TOO_LONG', + IDENTITY_ALREADY_HAS_ACCOUNT: 'IDENTITY_ALREADY_HAS_ACCOUNT', + SERVICE_PRINCIPAL_CANNOT_REVOKE_SELF: 'SERVICE_PRINCIPAL_CANNOT_REVOKE_SELF', + /** A *referenced* identity (not the caller) has no `account` row — see `NO_BRICO_ACCOUNT`. */ + UNKNOWN_BRICO_ACCOUNT: 'UNKNOWN_BRICO_ACCOUNT', + + // Integration links + EXTERNAL_ACCOUNT_ALREADY_LINKED: 'EXTERNAL_ACCOUNT_ALREADY_LINKED', + UNKNOWN_INTEGRATION_LINK: 'UNKNOWN_INTEGRATION_LINK', + LINK_CODE_ALREADY_IN_USE: 'LINK_CODE_ALREADY_IN_USE', + LINK_CODE_UNKNOWN_OR_EXPIRED: 'LINK_CODE_UNKNOWN_OR_EXPIRED', + LINK_CODE_ALREADY_CLAIMED: 'LINK_CODE_ALREADY_CLAIMED', + LINK_CODE_ALREADY_RESOLVED: 'LINK_CODE_ALREADY_RESOLVED', + LOGIN_NOT_VIA_DISCORD: 'LOGIN_NOT_VIA_DISCORD', + DISCORD_CLAIMS_MISSING_PROVIDER_ID: 'DISCORD_CLAIMS_MISSING_PROVIDER_ID', + + // Saved filters / watches + FILTER_INVALID_JSON: 'FILTER_INVALID_JSON', + /** Dynamic — carries `validateFilter`'s joined (English, deliberately not decomposed — see the design doc) problem list as its `:`-suffix, for `spacetime logs`. The frontend's `ERROR_VOCAB` entry ignores the suffix and shows a generic "that filter isn't valid" message. */ + FILTER_INVALID: 'FILTER_INVALID', + /** See `MAX_FILTER_NAME_LENGTH`. */ + FILTER_NAME_TOO_LONG: 'FILTER_NAME_TOO_LONG', + /** See `MAX_FILTER_JSON_LENGTH`. */ + FILTER_TOO_LARGE: 'FILTER_TOO_LARGE', + FILTER_BELONGS_TO_ANOTHER_ACCOUNT: 'FILTER_BELONGS_TO_ANOTHER_ACCOUNT', + UNKNOWN_SAVED_FILTER: 'UNKNOWN_SAVED_FILTER', + WATCH_BELONGS_TO_ANOTHER_ACCOUNT: 'WATCH_BELONGS_TO_ANOTHER_ACCOUNT', + /** Dynamic — carries the rejected event kind as its `:`-suffix; only `brico-bot` (a service principal) can trigger this, never a real user flow. */ + UNKNOWN_EVENT_KIND: 'UNKNOWN_EVENT_KIND', + + // Bounties & payouts + RULE_BELONGS_TO_ANOTHER_ACCOUNT: 'RULE_BELONGS_TO_ANOTHER_ACCOUNT', + RATIO_DENOMINATOR_MUST_BE_POSITIVE: 'RATIO_DENOMINATOR_MUST_BE_POSITIVE', + /** A negative numerator with a positive denominator still divides to a negative ratio — `parseDecimalRatio` can never produce one (no sign in its grammar), so this is defense-in-depth against a direct reducer call, not a normal UI flow. Covers `craft_bounty_override` and every `bounty_rule` ratio (the flat value and each grid cell). */ + RATIO_NUMERATOR_MUST_NOT_BE_NEGATIVE: 'RATIO_NUMERATOR_MUST_NOT_BE_NEGATIVE', + OVERRIDE_BELONGS_TO_ANOTHER_ACCOUNT: 'OVERRIDE_BELONGS_TO_ANOTHER_ACCOUNT', + NO_BOUNTY_ASSIGNMENT: 'NO_BOUNTY_ASSIGNMENT', + /** Dynamic — carries the rejected currency id as its `:`-suffix. The currency `
.iter()`, only once `requestResource(...).ready()` is `true` | +| Showing connection health | `connection.status()` (`connecting` / `syncing` / `live` / `error` / `closed`) | +| Knowing when *my* data is actually here | The `ready` a specific `requestResource(...)` call returned — not `status()` | +| A live signal driving `createEffect` side effects | Wrap the specific boolean/derived value in its own `createMemo` first — see "Signals over live data" | +| A table whose rows arrive as a live feed | `LiveTable`, not `DataTable` | +| Adding a new prism table to a resource | `prismTable(tables.x)` (whole table) or `prismRows(tables.x, tables.x.where(...))` (narrowed) | +| Adding a new brico-app view to a resource | `bricoAppTable(tables.x)` | + +## Connection manager + +`useConnection(server)` (`lib/spacetime/manager.tsx`) returns a `ManagedConnection`: `status`, +`error`, `active`, `reconnect`, `close`, `primeToken`/`clearToken`, and `requestResource`. Call it +during render — it registers an `onCleanup` that releases the claim on unmount. Holding the +connection alone (before requesting any resource) is itself a claim, which is why a page can render +`status()` immediately without racing a `requestResource` call. + +The manager keys connections by `(uri, module)` and reference-counts two things independently: + +- **Per resource** — two callers passing the same `spec.key` share one subscription; it drops only + after the last claim releases. +- **Per connection** — the socket closes only after every resource *and* every bare `connection()` + claim on it is gone. + +Both use a grace period (default 20s) before actually tearing anything down, so a route transition +(browse → detail → back) never pays for a fresh handshake and full re-subscribe. A request that +arrives inside the grace window just cancels the pending teardown. + +`AccountProvider` (`lib/account/state.tsx`) is the one caller that holds a bare, app-lifetime +`useConnection(BRICO_APP_SERVER)` claim and calls `conn.close()` itself on logout, rather than +relying on ref-counting — its own claim never unwinds (the provider is mounted once, above the +router), so nothing else would ever bring the ref count to zero. + +## Adding a resource + +A `ResourceSpec` is `{ key, tables }`. `key` is the resource's *identity* — two callers who +want the same rows must pass the same key, and a per-object resource folds the id into it +(`crafts:detail:1234`). `tables` is a list of `{ table, query }` pairs, built with a helper from the +matching binding file: + +```ts +// Whole table: +prismTable(tables.claimInfo) +bricoAppTable(tables.myAccount) + +// Narrowed (server-side .where(...)): +prismRows(tables.playerState, tables.playerState.where(row => row.entityId.eq(id))) +``` + +Then: `const request = connection.requestResource(spec, onChange)`. `onChange` fires once when the +subscription first applies and again on any row change in those tables — it is **per requester**, +and the SDK delivers one callback per row, so coalesce it yourself (see `relay.ts`'s +`throttle`/`leadingAndTrailing` use for the pattern: a snapshot rebuild coalesced to one per +`REBUILD_INTERVAL_MS`, not a reactive graph re-rendering per row). + +`request.ready()` and `request.release()` are the two things you get back — see "Status vs +readiness" below for `ready`. `release()` is idempotent and auto-wired to the owning computation's +cleanup when called during render; call it yourself if the request was made outside one (e.g. from +a timer). + +**prism vs brico-app**: pick the server that owns the data you need. World/game data (crafts, +claims, players, regions) is prism, always anonymous. Per-account data (accounts, saved filters, +watches, bounty rules, payouts) is brico-app, and requires the connection to have a token primed +(`conn.primeToken(idToken)`) before it will return non-empty `my_*` rows — see +`backend/brico-module/README.md` for the view-level enforcement. A page needing both holds two +separate `useConnection` calls; nothing links them below the resource layer, so it's on your query +logic (e.g. matching a brico-app `linked_integration.externalId` against a prism `player_state` +row) to join across them client-side. + +## Status vs readiness + +These answer different questions and gating the wrong one is the most common bug in this layer: + +- **`connection.status()`** is connection-wide: `connecting → syncing → live`, or `error`/`closed`. + It flips to `live` once every resource *registered at that moment* has applied, and by design + **never reverts to `syncing`** afterward (see `refreshStatus` in `connection.ts`). Adding a new + resource to an already-live connection (routing from the craft browser to one craft's detail page, + which shares the socket) leaves `status()` reporting `live` immediately — before the new + resource's own subscription has actually applied. Use `status()` only for connection health (a + badge, a reconnect button), never as a proxy for "my data is here." +- **A resource's own `ready()`** (from `requestResource`'s return value) is per-request: freshly + `false` on every new request, `true` once *that* request's subscription has applied. This is what + a page should gate its "loading" state on. `relay.ts`'s `CraftRelay.ready` and + `CraftDetailRelay.ready` are the reference examples — both are deliberately distinct from + `status()`, with doc comments spelling out exactly this gap. + +A frequent follow-on bug: a *coalesced* rebuild (a throttled snapshot, like `relay.ts`'s) can lag +`ready()` flipping true by up to one throttle interval, which briefly makes an empty/stale snapshot +look "ready." Where that matters, rebuild synchronously the instant `ready()` flips, in addition to +the throttled path — see `relay.ts`'s repeated `if (isReady) rebuild();` before `setReady(isReady)`. + +## Signals/memos over live data + +A live row (an account, a craft, anything arriving over a subscription) gets replaced on **every** +write, including ones nothing on screen actually depends on — `account`'s row is rewritten every +time `lastSeenAt` bumps, from this device's own reconnects or another live session on the same +account. Solid's `createEffect` re-runs on every write to a signal it reads, not just ones that +change a value the effect cares about. + +**Never gate a `createEffect` with cleanup/resource side effects (subscribing, `requestResource`, +opening a connection, ...) directly on a live row or a naive derivation of one.** Wrap the specific +thing the effect actually depends on in its own `createMemo` first, so the effect only re-runs when +*that* value changes: + +```ts +// Bad: re-runs on every row write, including irrelevant ones (e.g. lastSeenAt). +createEffect(() => { if (account() !== null) doSomething(); }); + +// Good: stable across writes that don't change the derived value. +const isLoggedIn = createMemo(() => account() !== null); +createEffect(() => { if (isLoggedIn()) doSomething(); }); +``` + +`lib/account/state.tsx`'s `isLoggedIn` is the canonical example — its own doc comment and +`account`'s both explain this, and `lib/crafts/filter-sync.tsx`'s `loggedIn` hit the bug this +guards against (a second session on the same account touching the row re-triggered subscribe/ +unsubscribe). This applies to any live row, not just accounts — the pattern is general, not an +account-specific quirk. + +## `LiveTable` + +`components/data-table/live-table.tsx` — table chrome for rows that arrive as a **live feed** +(subscription-driven, ticking several times a second), as opposed to `DataTable`, which is built +around the static game-data tables (search, faceted filters, URL round-tripping, per-table facet +row models installed unconditionally). A feed doesn't want any of that: filtering belongs to +whatever domain-specific filter UI the page already has (e.g. `FilterBuilder`), and re-deriving +per-column facets over a fast-changing dataset is pure waste. + +`LiveTable` keeps only sortable headers, pagination, the "View" column-visibility toggle and the +persisted hidden-column set — shared with `DataTable` via the same settings store, so a column +hidden on one behaves identically on the other (`name` must be unique across both). It disables +`autoResetPageIndex`/`autoResetExpanded` (a live feed replacing `data` every tick would otherwise +bounce the reader back to page 1 / collapse every expanded group on the next tick). Pass +`getRowId` — feed row objects are rebuilt every tick, so stable identity has to come from the data, +not object reference. + +## Backend: tables, views, reducers + +Full details live in `backend/brico-module/README.md` (schema/views/reducers) and +`backend/brico-bot/README.md` (the process that bridges prism + brico-app). Two rules worth +repeating here because they're easy to get wrong from the frontend side: + +- **New tables are private by default.** A client never subscribes to a table directly — it + subscribes to a **view**, gated on `ctx.sender` (a `my_*` view) or on `service_principal` + membership (an `all_*` view, for `brico-bot`). Prefer a *query* view (`ctx.from.x.where(...)`) — + the server filters and maintains it incrementally. Only fall back to a *procedural* view + (`ctx.db` + a materialized array) when the result spans multiple tables, or when the filter column + is `t.option(...)` (`.eq()` on an optional column silently matches nothing — use an index + accessor's `.filter()` instead). +- **Reducers validate every argument server-side.** A reducer is a public entry point regardless of + what UI calls it — a hand-crafted `spacetime call` bypasses every frontend parser. Validate shape + (e.g. `saved_craft_filter.filterJson` against `validateFilter`), check ownership before mutating + another account's row, and never trust an identity passed as an argument — use `ctx.sender`. + +## Verification + +```bash +npm run typecheck # frontend +npm run typecheck --prefix backend/brico-module/spacetimedb # backend module +npm run typecheck --workspace backend/brico-bot # bridge worker +``` diff --git a/frontend/package.json b/frontend/package.json index ae4be3a7..b62999ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "build:ci": "node scripts/check-barrel-imports.mjs && node scripts/gen-version-info.mjs && node scripts/icons/gen-og-icons.mjs && vinxi build", "start": "vinxi start", "typecheck": "node scripts/check-barrel-imports.mjs && tsc --noEmit", + "test": "node --test \"src/**/*.test.ts\"", "gen-icons": "node scripts/icons/gen-font-icons.mjs", "analyze-icons": "npx tsx scripts/icons/analyze-icon-usage.mjs", "i18n:extract": "node scripts/i18n/extract.mjs", @@ -17,9 +18,11 @@ "i18n:diff-check": "node scripts/i18n/diff-check.mjs" }, "dependencies": { - "@clockworklabs/spacetimedb-sdk": "^1.3.0", + "@brico/bindings": "*", + "@brico/bitcraft-bindings": "*", + "@brico/crafts": "*", "@fontsource-variable/inter": "^5.2.8", - "@kobalte/core": "^0.13.10", + "@kobalte/core": "^0.13.14", "@lingui/core": "^6.6.0", "@lingui/solid": "^6.6.0", "@solid-primitives/scheduled": "^1.5.3", @@ -41,10 +44,11 @@ "d3-transition": "^3.0.1", "d3-zoom": "^3.0.0", "fuse.js": "^7.4.2", + "oidc-client-ts": "^3.5.0", "solid-icons": "^1.1.0", "solid-js": "^1.9.5", "solid-spinner": "^0.2.0", - "spacetimedb": "^2.5.0", + "spacetimedb": "^2.9.0", "tailwind-merge": "^3.3.0", "tailwindcss-animate": "^1.0.7", "vinxi": "^0.5.8" diff --git a/frontend/scripts/check-barrel-imports.mjs b/frontend/scripts/check-barrel-imports.mjs index 85603325..25e1dc2c 100644 --- a/frontend/scripts/check-barrel-imports.mjs +++ b/frontend/scripts/check-barrel-imports.mjs @@ -1,13 +1,15 @@ #!/usr/bin/env node /** * Fails the build / typecheck if any source file contains a barrel import from - * "~/bindings/src" (the auto-generated SpacetimeDB SDK index). + * "@brico/bitcraft-bindings/index" (the auto-generated `index.ts`, which pulls in the full + * `DbConnection`/reducer/schema machinery for a *live* SpacetimeDB connection). * - * ✅ Allowed: from "~/bindings/src/item_type_type" - * ❌ Blocked: from "~/bindings/src" + * ✅ Allowed: from "@brico/bitcraft-bindings/types" + * ❌ Blocked: from "@brico/bitcraft-bindings/index" * - * The barrel re-exports every table descriptor at once, bloating bundles and - * making tree-shaking impossible. Always import the specific module instead. + * `@brico/bitcraft-bindings` is only ever used here for offline BSATN decoding (see + * `~/lib/bitcraft-data.ts`), never a live connection — importing `index.ts` would drag that whole + * connection surface (and its own `spacetimedb` re-exports) into the bundle for nothing. */ import {readdirSync, readFileSync} from "fs"; @@ -15,9 +17,11 @@ import {join, relative} from "path"; const ROOT = new URL("../src", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"); -// Matches: from "~/bindings/src" or from '~/bindings/src' -// The look-ahead ensures the quote closes *immediately* – no slash or identifier follows. -const BARREL_RE = /from\s+["']~\/bindings\/src["']/; +// Matches: from "@brico/bitcraft-bindings/index" or from '@brico/bitcraft-bindings' +// (either quote style). The package's `exports` map has no "." entry, so the bare specifier +// doesn't actually resolve — it's matched here only to produce this error instead of a +// resolution failure. The quote must close *immediately*: no slash or identifier follows. +const BARREL_RE = /from\s+["']@brico\/bitcraft-bindings(?:\/index)?["']/; /** Recursively walk a directory and yield .ts / .tsx file paths. */ function* walk(dir) { @@ -43,11 +47,11 @@ for (const filePath of walk(ROOT)) { if (BARREL_RE.test(line)) { const rel = relative(ROOT, filePath).replace(/\\/g, "/"); console.error( - `\n\x1b[31mBarrel import from "~/bindings/src" is not allowed.\x1b[0m\n` + + `\n\x1b[31mBarrel import from the @brico/bitcraft-bindings index is not allowed.\x1b[0m\n` + ` ${rel}:${idx + 1}\n` + ` ${line.trim()}\n` + - `\n Use a specific module instead, e.g.:\n` + - ` \x1b[32mimport { ItemDesc } from "~/bindings/src/item_desc_type"\x1b[0m\n` + `\n Use the types module instead, e.g.:\n` + + ` \x1b[32mimport { ItemDesc } from "@brico/bitcraft-bindings/types"\x1b[0m\n` ); found = true; } @@ -57,6 +61,6 @@ for (const filePath of walk(ROOT)) { if (found) { process.exit(1); } else { - console.log("\x1b[32m✓ No barrel imports from ~/bindings/src found.\x1b[0m"); + console.log("\x1b[32m✓ No barrel imports from the @brico/bitcraft-bindings index found.\x1b[0m"); } diff --git a/frontend/scripts/check-nested-item-lists.mjs b/frontend/scripts/check-nested-item-lists.mjs index 09f629d3..03d70ced 100644 --- a/frontend/scripts/check-nested-item-lists.mjs +++ b/frontend/scripts/check-nested-item-lists.mjs @@ -17,13 +17,12 @@ * Run: npx tsx scripts/check-nested-item-lists.mjs */ -import {AlgebraicType, BinaryReader} from "@clockworklabs/spacetimedb-sdk"; +import {ItemDesc, ItemListDesc} from "@brico/bitcraft-bindings/types"; import {readFileSync} from "node:fs"; import {dirname, resolve} from "node:path"; import {fileURLToPath} from "node:url"; -import {ItemDesc} from "../src/bindings/src/item_desc_type.ts"; -import {ItemListDesc} from "../src/bindings/src/item_list_desc_type.ts"; +import {AlgebraicType, BinaryReader} from "spacetimedb"; const __dir = dirname(fileURLToPath(import.meta.url)); const ROOT = __dir.endsWith("scripts") ? resolve(__dir, "..") : __dir; @@ -33,9 +32,9 @@ const ROOT = __dir.endsWith("scripts") ? resolve(__dir, "..") : __dir; function loadTable(tableName, binding) { const bsatnPath = resolve(ROOT, `public/bsatn/static/${tableName}.bsatn`); const data = readFileSync(bsatnPath); - const arrayType = AlgebraicType.createArrayType(binding.getTypeScriptAlgebraicType()); + const deserialize = AlgebraicType.makeDeserializer(AlgebraicType.Array(binding.algebraicType)); const reader = new BinaryReader(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); - return arrayType.deserialize(reader); + return deserialize(reader); } // ── Load data ────────────────────────────────────────────────── @@ -44,10 +43,10 @@ console.log("Loading BSATN data…"); const allLists = loadTable("item_list_desc", ItemListDesc); const allItems = loadTable("item_desc", ItemDesc); -/** @type {Map} */ +/** @type {Map} */ const itemById = new Map(allItems.map(i => [i.id, i])); -/** @type {Map} */ +/** @type {Map} */ const listById = new Map(allLists.map(l => [l.id, l])); console.log(` ${allLists.length} item lists, ${allItems.length} items\n`); diff --git a/frontend/scripts/i18n/list-translatable-fields.mjs b/frontend/scripts/i18n/list-translatable-fields.mjs index 5ac08d78..b68510e5 100644 --- a/frontend/scripts/i18n/list-translatable-fields.mjs +++ b/frontend/scripts/i18n/list-translatable-fields.mjs @@ -1,7 +1,7 @@ /** * One-off audit script backing the `TRANSLATABLE_FIELDS` constant in `src/lib/data-translation.ts`. * - * Reflects on every table registered in `BitCraftTables` (same `spacetimeType.product.elements` + * Reflects on every table registered in `BitCraftTables` (same `spacetimeType.value.elements` * technique as `BitCraftTable.tagToOrdinal()`), collects every field whose type is a string — * plain `String`, `Option`, or `String[]` — and reports it grouped by *field name*, since * the decision we need to make is a flat, global, human-reviewed name list rather than a per-table @@ -12,11 +12,11 @@ * * Run: npm run i18n:audit-fields [-- --samples 5] [--field name] */ -import {AlgebraicType, BinaryReader} from "@clockworklabs/spacetimedb-sdk"; import {readFileSync} from "node:fs"; import path from "node:path"; import {fileURLToPath} from "node:url"; -import {BitCraftTables} from "../../src/lib/spacetime.ts"; +import {AlgebraicType, BinaryReader} from "spacetimedb"; +import {BitCraftTables} from "~/lib/bitcraft-data.ts"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const BSATN_DIR = path.join(ROOT, "public/bsatn/static"); @@ -35,10 +35,10 @@ const ONLY_FIELD = flag("field", null); */ function stringShape(type) { if (!type) return null; - if (type.type === "String") return "string"; - if (type.type === "ArrayType" && type.array?.type === "String") return "string[]"; - if (type.type === "SumType") { - const variants = type.sum?.variants ?? []; + if (type.tag === "String") return "string"; + if (type.tag === "Array" && type.value?.tag === "String") return "string[]"; + if (type.tag === "Sum") { + const variants = type.value?.variants ?? []; const some = variants.find(v => v.name === "some"); const isOption = variants.length === 2 && some && variants.some(v => v.name === "none"); if (isOption) { @@ -79,7 +79,7 @@ function loadRows(table) { try { const bytes = readFileSync(file); const reader = new BinaryReader(new Uint8Array(bytes)); - return AlgebraicType.createArrayType(table.spacetimeType).deserialize(reader); + return AlgebraicType.makeDeserializer(AlgebraicType.Array(table.spacetimeType))(reader); } catch (e) { console.warn(`[warn] could not load ${table.spacetimeName}.bsatn: ${e.message}`); return null; @@ -91,7 +91,7 @@ const byField = new Map(); let tablesScanned = 0; for (const [key, table] of Object.entries(BitCraftTables)) { - const elements = table.spacetimeType?.product?.elements ?? []; + const elements = table.spacetimeType?.value?.elements ?? []; const stringFields = elements .map(e => ({name: e.name, shape: stringShape(e.algebraicType)})) .filter(e => e.shape && (!ONLY_FIELD || e.name === ONLY_FIELD)); diff --git a/frontend/scripts/i18n/verify-translations.mjs b/frontend/scripts/i18n/verify-translations.mjs index 9e11dd12..03d75f36 100644 --- a/frontend/scripts/i18n/verify-translations.mjs +++ b/frontend/scripts/i18n/verify-translations.mjs @@ -8,13 +8,13 @@ * * Run: npm run i18n:verify [-- ] (default locale: de) */ -import {AlgebraicType, BinaryReader} from "@clockworklabs/spacetimedb-sdk"; import {csvParse} from "d3-dsv"; import {readFileSync} from "node:fs"; import path from "node:path"; import {fileURLToPath} from "node:url"; -import {sourceRow, TRANSLATABLE_FIELDS, translatableFieldsOf, translateRow} from "../../src/lib/data-translation.ts"; -import {BitCraftTables} from "../../src/lib/spacetime.ts"; +import {AlgebraicType, BinaryReader} from "spacetimedb"; +import {BitCraftTables} from "~/lib/bitcraft-data.ts"; +import {sourceRow, TRANSLATABLE_FIELDS, translatableFieldsOf, translateRow} from "~/lib/data-translation.ts"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const BSATN_DIR = path.join(ROOT, "public/bsatn/static"); @@ -28,7 +28,7 @@ const check = (ok, label) => { function rowsOf(table) { const bytes = readFileSync(path.join(BSATN_DIR, `${table.spacetimeName}.bsatn`)); - return AlgebraicType.createArrayType(table.spacetimeType).deserialize(new BinaryReader(new Uint8Array(bytes))); + return AlgebraicType.makeDeserializer(AlgebraicType.Array(table.spacetimeType))(new BinaryReader(new Uint8Array(bytes))); } // ── 1. Fetch + parse the CSV the same way data-translation.ts does ── diff --git a/frontend/scripts/icons/analyze-icon-usage.mjs b/frontend/scripts/icons/analyze-icon-usage.mjs index 9c3e2f8e..a02e79c9 100644 --- a/frontend/scripts/icons/analyze-icon-usage.mjs +++ b/frontend/scripts/icons/analyze-icon-usage.mjs @@ -1,5 +1,3 @@ -// noinspection ES6PreferShortImport - /** * analyze-icon-usage.mjs * @@ -18,26 +16,18 @@ * (or: npx tsx scripts/analyze-icon-usage.mjs) */ -import {AlgebraicType, BinaryReader} from "@clockworklabs/spacetimedb-sdk"; +import {AbilityCustomDesc, BuffDesc, CombatActionDesc, EmpireIconDesc, NpcDesc, ProspectingDesc, SkillDesc} from "@brico/bitcraft-bindings/types"; import {readdirSync, readFileSync} from "node:fs"; import {dirname, resolve} from "node:path"; import {fileURLToPath} from "node:url"; - -// Binding type modules — they export namespaces with getTypeScriptAlgebraicType() -import {AbilityCustomDesc} from "../../src/bindings/src/ability_custom_desc_type.ts"; -import {BuffDesc} from "../../src/bindings/src/buff_desc_type.ts"; -import {CombatActionDesc} from "../../src/bindings/src/combat_action_desc_type.ts"; -import {EmpireIconDesc} from "../../src/bindings/src/empire_icon_desc_type.ts"; -import {NpcDesc} from "../../src/bindings/src/npc_desc_type.ts"; -import {ProspectingDesc} from "../../src/bindings/src/prospecting_desc_type.ts"; -import {SkillDesc} from "../../src/bindings/src/skill_desc_type.ts"; +import {AlgebraicType, BinaryReader} from "spacetimedb"; const __dir = dirname(fileURLToPath(import.meta.url)); const ROOT = __dir.endsWith("icons") ? resolve(__dir, "../..") : __dir; /** * Map from table name (as used in font_icons.json / BSATN filenames) - * to the binding namespace that provides getTypeScriptAlgebraicType(). + * to the binding value that provides `.algebraicType`. */ const TABLE_BINDINGS = { ability_custom_desc: AbilityCustomDesc, @@ -114,13 +104,12 @@ export function readBsatnIconField(rootDir, tableName, fieldName) { return []; } - const itemType = binding.getTypeScriptAlgebraicType(); - const arrayType = AlgebraicType.createArrayType(itemType); + const deserialize = AlgebraicType.makeDeserializer(AlgebraicType.Array(binding.algebraicType)); const reader = new BinaryReader(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); let rows; try { - rows = arrayType.deserialize(reader); + rows = deserialize(reader); } catch (e) { console.warn(` ⚠ Failed to deserialize ${tableName}: ${e.message}`); return []; diff --git a/frontend/src/app.css b/frontend/src/app.css index f331c5c0..e260f2fe 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -102,10 +102,10 @@ --sidebar-background: 0 0% 0%; --sidebar-foreground: 0 0% 90%; - --card: 0 0% 4%; + --card: 0 0% 0%; --card-foreground: 0 0% 90%; - --popover: 0 0% 10%; + --popover: 0 0% 0%; --popover-foreground: 0 0% 90%; --muted: 0 0% 10%; diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index eb66849b..69623308 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -1,4 +1,4 @@ -import {ColorModeProvider, ColorModeScript} from "@kobalte/core" +import {ColorModeProvider} from "@kobalte/core" import {I18nProvider} from "@lingui/solid"; import {MetaProvider} from "@solidjs/meta"; import {Router} from "@solidjs/router"; @@ -11,12 +11,18 @@ import {AppSidebar} from "~/components/app-sidebar" import AppLoadingScreen from "~/components/AppLoadingScreen"; import RishEmulator from "~/components/fun/RishEmulator"; import {SidebarProvider} from "~/components/ui/sidebar"; +import {Toaster} from "~/components/ui/toast"; +import {LinkedIntegrationsProvider} from "~/lib/account/links"; +import {AccountProvider} from "~/lib/account/state"; +import {useGameDataReady, useLoadingProgress} from "~/lib/bitcraft-data"; import {isBotUserAgent} from "~/lib/bot-detect"; +import {FilterSyncProvider} from "~/lib/crafts/filter-sync"; import {setActiveDataLocale} from "~/lib/data-translation"; import {GlobalSearchProvider} from "~/lib/global-search-context"; import {activateUILocale, i18n} from "~/lib/i18n"; -import {KEYS, SettingsProvider, useSettings} from "~/lib/settings"; -import {useGameDataReady, useLoadingProgress} from "~/lib/spacetime"; +import {NotificationsProvider} from "~/lib/notifications/state"; +import {SettingsProvider, useSettings} from "~/lib/settings"; +import {ConnectionManagerProvider} from "~/lib/spacetime/manager"; /** Inner wrapper — needs to be a child of SettingsProvider so useSettings() resolves */ function AppRoot(props: { children: any }) { @@ -55,8 +61,8 @@ function AppRoot(props: { children: any }) { return ( <> - + }> @@ -92,7 +98,17 @@ export default function App() { - {props.children} + + + + + + {props.children} + + + + + diff --git a/frontend/src/bindings b/frontend/src/bindings deleted file mode 160000 index 3bcac62a..00000000 --- a/frontend/src/bindings +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3bcac62acd26ca83c8749a08bafde150c78e4d96 diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx index 0d301aca..3ac83f9b 100644 --- a/frontend/src/components/Nav.tsx +++ b/frontend/src/components/Nav.tsx @@ -6,14 +6,17 @@ import {A} from "@solidjs/router"; import { TbOutlineDeviceLaptop as IconLaptop, + TbOutlineLoader2 as IconConnecting, TbOutlineMoon as IconMoon, + TbOutlinePlugConnected as IconConnected, + TbOutlinePlugConnectedX as IconConnectionError, + TbOutlinePlugOff as IconDisconnected, TbOutlineSearch as IconSearch, TbOutlineSettings as IconSettings, TbOutlineSun as IconSun, TbOutlineWorld as IconWorld, } from "solid-icons/tb" -import {For, JSX, Show} from "solid-js"; -import {isDev} from "solid-js/web"; +import {createMemo, For, JSX, Match, Show, Switch} from "solid-js"; import GlobalSearchInput from "~/components/GlobalSearchInput"; import {Button} from "~/components/ui/button" import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger} from "~/components/ui/dropdown-menu" @@ -21,6 +24,8 @@ import {SidebarTrigger} from "~/components/ui/sidebar"; import {dataLocaleFor} from "~/lib/data-translation"; import {localeLabel, PSEUDOLOCALE_ENABLED, UI_LOCALES, type UILocale} from "~/lib/i18n"; import {AUTO_LOCALE, useSettings} from "~/lib/settings"; +import {type ConnectionSnapshot, useConnectionManager} from "~/lib/spacetime/manager"; +import {Popover, PopoverContent, PopoverTrigger} from "./ui/popover"; function LanguageMenu() { const settings = useSettings(); @@ -34,12 +39,12 @@ function LanguageMenu() { settings.setDataLocale(AUTO_LOCALE); }; - const translationsPage = isDev || PSEUDOLOCALE_ENABLED ? () => (<> + const translationsPage = () => settings.devMenusEnabled() || PSEUDOLOCALE_ENABLED ? <> All Translations - ) : () => <>; + : <>; return ( @@ -66,13 +71,14 @@ function LanguageMenu() { } function DarkModeToggle() { - const {setColorMode} = useColorMode() - const colorPage = isDev ? () => (<> + const {devMenusEnabled} = useSettings(); + const {setColorMode} = useColorMode(); + const colorPage = () => devMenusEnabled() ? <> Theme Colors - ) : () => <>; + : <>; return ( @@ -100,6 +106,81 @@ function DarkModeToggle() { ) } +type OverallConnectionStatus = "idle" | "live" | "pending" | "error"; + +function overallConnectionStatus(connections: ConnectionSnapshot[]): OverallConnectionStatus { + // `closed` covers both an explicit `close()` and a connection held (e.g. by `AccountProvider`) + // but never opened — neither is "trying to connect", so it must not read as `pending` forever. + const relevant = connections.filter(c => c.status !== "closed"); + if (relevant.length === 0) return "idle"; + if (relevant.some(c => c.status === "error")) return "error"; + if (relevant.some(c => c.status !== "live")) return "pending"; + return "live"; +} + +const CONNECTION_STATUS_CLASS: Record = { + idle: "text-muted-foreground", + live: "text-success-foreground", + pending: "text-warning-foreground", + error: "text-destructive", +}; + +/** Navbar status icon for the app-wide `ConnectionManager` — a tooltip lists what it's holding. */ +function ConnectionStatusIndicator() { + const {_} = useLingui(); + const {devMenusEnabled, connectionManagerOpen: open, setConnectionManagerOpen: setOpen} = useSettings(); + const connections = useConnectionManager().snapshot; + const overall = createMemo(() => overallConnectionStatus(connections())); + const toggleOpen = () => setOpen(!open()); + + return ( + + + + + + + + + + + + + + + + + + + 0} fallback={
No active connections
}> +
+ + {(conn) => ( +
+
+ {conn.module} +
+ ({conn.status}{(error) => `: ${error()}`}) +
+
+ + {(resource) => ( +
+ {resource.key} {resource.requests} + {" "}(pending) +
+ )} +
+
+ )} +
+
+
+
+
+ ); +} + /** * Accessible name for the breadcrumb landmark. Carries a translator comment because BitCraft has * its own literal "Breadcrumb …" in the catalog, so a bare "Breadcrumb" is ambiguous out of context. @@ -116,6 +197,7 @@ interface NavProps { export default function Nav(props: NavProps) { const {_} = useLingui(); + const {devMenusEnabled} = useSettings(); return ( //
, not
+ + + {headerGroup => ( + + + {header => ( + + + + + + )} + + + )} + + + + + + {props.empty ?? No results.} + + + } + > + {/* `For`, keyed on the row's stable id (from `getRowId`) rather than the + row object a feed rebuilds every tick. Keying on the object made + every `` a brand-new DOM node each snapshot, which reset + hover/CSS state (and would reset any per-row interactive state) even + when nothing displayed had changed. Looking the row back up by id + inside a memo keeps the `` element itself stable; cells + are still rebuilt on every tick, which is fine — no cell here holds + interactive state, and the column defs read `row.original` + non-reactively on purpose (see the comment above `COLUMNS`), relying + on that rebuild to pick up fresh values. `Index` is not an + alternative for the cells, since a cell receives its context as a + one-time spread and would never see the new row. */} + row.id)}> + {rowId => { + const row = createMemo(() => table.getRowModel().rows.find(r => r.id === rowId)); + return ( + + {currentRow => ( + + + {cell => ( + + + + )} + + + )} + + ); + }} + + + +
+ + + + ); +} diff --git a/frontend/src/components/data-table/table-faceted-filter.tsx b/frontend/src/components/data-table/table-faceted-filter.tsx index 7de26d4f..71d489a7 100644 --- a/frontend/src/components/data-table/table-faceted-filter.tsx +++ b/frontend/src/components/data-table/table-faceted-filter.tsx @@ -665,8 +665,8 @@ export function TableFacetedFilter(props: TableFacetedFilterProps) )?.get(option.value)}> {(count) => ( - {count()} - + {count()} + )} diff --git a/frontend/src/components/data-table/table-pagination.tsx b/frontend/src/components/data-table/table-pagination.tsx index cb1014d4..79b54b1a 100644 --- a/frontend/src/components/data-table/table-pagination.tsx +++ b/frontend/src/components/data-table/table-pagination.tsx @@ -1,5 +1,5 @@ +import {Plural, Trans} from "@lingui/solid/macro"; import type {Table} from "@tanstack/solid-table" - import { TbOutlineCheck as IconCheck, TbOutlineChevronLeft as IconChevronLeft, @@ -7,13 +7,15 @@ import { TbOutlineChevronsLeft as IconChevronsLeft, TbOutlineChevronsRight as IconChevronsRight } from "solid-icons/tb" -import {createSignal, Show} from "solid-js"; +import {createSignal, type JSX, Show} from "solid-js"; import {Button} from "~/components/ui/button" import {NumberField, NumberFieldInput} from "~/components/ui/number-field"; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "~/components/ui/select" type TablePaginationProps = { table: Table + /** Replaces the default "{filtered} of {core} row(s) shown" text, for a caller with its own counts to report. */ + label?: JSX.Element } export function TablePagination(props: TablePaginationProps) { @@ -38,8 +40,12 @@ export function TablePagination(props: TablePaginationProps) { return (
- {props.table.getFilteredRowModel().rows.length} of{" "} - {props.table.getCoreRowModel().rows.length} row(s) shown. + {props.table.getFilteredRowModel().rows.length} of{" "} + shown. + }> + {props.label} +
diff --git a/frontend/src/components/data-table/table-view-options.tsx b/frontend/src/components/data-table/table-view-options.tsx index e4bdfaf6..907c2706 100644 --- a/frontend/src/components/data-table/table-view-options.tsx +++ b/frontend/src/components/data-table/table-view-options.tsx @@ -71,7 +71,7 @@ export function TableViewOptions(props: TableViewOptionsProps) { Toggle columns -
+
diff --git a/frontend/src/components/fun/BricoLootBox.tsx b/frontend/src/components/fun/BricoLootBox.tsx index 7faedc95..0e5427f4 100644 --- a/frontend/src/components/fun/BricoLootBox.tsx +++ b/frontend/src/components/fun/BricoLootBox.tsx @@ -1,15 +1,12 @@ +import type {CargoDesc, ItemDesc, ItemListDesc} from "@brico/bitcraft-bindings/types"; +import {ItemType, Rarity} from "@brico/bitcraft-bindings/types"; import {useColorMode} from "@kobalte/core"; import {type Component, createEffect, createSignal, on, onCleanup, onMount, Show} from "solid-js"; import {Portal} from "solid-js/web"; -import type {CargoDesc} from "~/bindings/src/cargo_desc_type"; -import type {ItemDesc} from "~/bindings/src/item_desc_type"; -import type {ItemListDesc} from "~/bindings/src/item_list_desc_type"; -import {ItemType} from "~/bindings/src/item_type_type"; -import Rarity from "~/bindings/src/rarity_type"; import {CargoIcon, ItemIcon, rarityToFrameSlug, SHAPE_SIZES} from "~/components/shared/GameIcon"; +import {BitCraftTables} from "~/lib/bitcraft-data"; import {ASSET_CDN_BASE, getAssetURL, Tiers} from "~/lib/bitcraft-utils"; import {useSettings} from "~/lib/settings"; -import {BitCraftTables} from "~/lib/spacetime"; import {cn} from "~/lib/utils"; const CHEST_ASSET = "GeneratedIcons/Other/Buildings/Storage/ChestSmallT4"; diff --git a/frontend/src/components/shared/ConnectionStatusBadge.tsx b/frontend/src/components/shared/ConnectionStatusBadge.tsx new file mode 100644 index 00000000..3f66b6a2 --- /dev/null +++ b/frontend/src/components/shared/ConnectionStatusBadge.tsx @@ -0,0 +1,43 @@ +import {type Accessor, createMemo, Show} from "solid-js"; +import {Badge} from "~/components/ui/badge"; +import type {ConnectionStatus} from "~/lib/spacetime/connection"; + +export interface ConnectionLike { + status: Accessor; + error: Accessor; +} + +type CombinedStatus = "error" | "syncing" | "live"; + +/** + * One combined status pill for however many live connections a page depends on — the same + * closed/error/pending/live reduction as `Nav.tsx`'s `overallConnectionStatus`, but scoped to this + * page's own connections (not every connection the app holds) and surfacing the first connection's + * error text rather than a hover list. `closed` is filtered out first, same reasoning as there: a + * connection held but never opened, or explicitly closed, isn't "trying to connect" and must not + * make the badge look permanently stuck; when every connection is closed the badge hides entirely. + */ +export function ConnectionStatusBadge(props: {connections: ConnectionLike[]}) { + const relevant = createMemo(() => props.connections.filter(c => c.status() !== "closed")); + const combined = createMemo(() => { + const conns = relevant(); + if (conns.length === 0) return null; + if (conns.some(c => c.status() === "error")) return "error"; + if (conns.some(c => c.status() !== "live")) return "syncing"; + return "live"; + }); + const firstError = createMemo(() => relevant().find(c => c.error())?.error() ?? null); + + return ( + + {status => ( + <> + + {message => {message()}} + + {status()} + + )} + + ); +} diff --git a/frontend/src/components/shared/GameIcon.tsx b/frontend/src/components/shared/GameIcon.tsx index d3be5392..96f22165 100644 --- a/frontend/src/components/shared/GameIcon.tsx +++ b/frontend/src/components/shared/GameIcon.tsx @@ -4,28 +4,19 @@ * with a single configurable component. */ +import {BuildingDesc, CargoDesc, CollectibleDesc, EnemyDesc, ItemDesc, ItemListDesc, PlaceableDesc, Rarity, ResourceDesc, SkillDesc} from "@brico/bitcraft-bindings/types"; import {useColorMode} from "@kobalte/core"; import {msg} from "@lingui/core/macro"; import {A, useNavigate} from "@solidjs/router"; import {Component, ComponentProps, createSignal, JSX, Show, splitProps} from "solid-js"; -import {BuildingDesc} from "~/bindings/src/building_desc_type"; -import {CargoDesc} from "~/bindings/src/cargo_desc_type"; -import {CollectibleDesc} from "~/bindings/src/collectible_desc_type"; -import {EnemyDesc} from "~/bindings/src/enemy_desc_type"; -import {ItemDesc} from "~/bindings/src/item_desc_type"; -import {ItemListDesc} from "~/bindings/src/item_list_desc_type"; -import {PlaceableDesc} from "~/bindings/src/placeable_desc_type"; -import {Rarity} from "~/bindings/src/rarity_type"; -import {ResourceDesc} from "~/bindings/src/resource_desc_type"; -import {SkillDesc} from "~/bindings/src/skill_desc_type"; import {FontIcon} from "~/components/icons/font-icons"; import {Tooltip, TooltipContent, TooltipTrigger} from "~/components/ui/tooltip"; +import {BitCraftTables} from "~/lib/bitcraft-data"; import {ASSET_CDN_BASE, getAssetURL, getBuildingTier, Rarities, Tiers} from "~/lib/bitcraft-utils"; import {rarityLabel} from "~/lib/game-strings"; import {gameText, useLabel} from "~/lib/labels"; import {getItemListSource} from "~/lib/relations"; import {useSettings} from "~/lib/settings"; -import {BitCraftTables} from "~/lib/spacetime"; import {cn} from "~/lib/utils"; // ─── Shape Definitions ────────────────────────────────────────── @@ -140,7 +131,7 @@ export const TierIcon: Component = (props) => { const { midnightDark } = useSettings(); const label = useLabel(); // "Tier {0}" is one of the game's own strings, placeholder and all — see `interpolate` in labels.ts. - const tierTitle = () => label(gameText(msg`Tier ${props.tier}`)); + const tierTitle = () => label(gameText(props.tier === -1 ? msg`Untiered` : msg`Tier ${props.tier}`)); const t0Filter = () => colorMode() === "dark" ? midnightDark() @@ -149,7 +140,7 @@ export const TierIcon: Component = (props) => { : "brightness(0) saturate(100%) invert(87%) sepia(27%) saturate(207%) hue-rotate(11deg) brightness(93%) contrast(84%)"; // light const inRange = props.tier >= 1 && props.tier <= 10; if (!inRange) return ( -
+
= (props) => {

{props.tier}

+ }>{props.tier === -1 ? "-" : props.tier}

); return ( {`T${props.tier}`} ) diff --git a/frontend/src/components/shared/ItemStacks.tsx b/frontend/src/components/shared/ItemStacks.tsx index 85a97ff8..b77d7429 100644 --- a/frontend/src/components/shared/ItemStacks.tsx +++ b/frontend/src/components/shared/ItemStacks.tsx @@ -9,25 +9,17 @@ * expandStack() — Smart dispatcher: resolves item lists, renders the right component */ +import {CargoDesc, InputItemStack, ItemDesc, ItemListDesc, ItemListPossibility, ItemStack, ItemType, ProbabilisticItemStack, QuestDropDesc} from "@brico/bitcraft-bindings/types"; import {Trans} from "@lingui/solid/macro"; import {A} from "@solidjs/router"; -import {Component, createMemo, For, JSX, Show} from "solid-js"; -import {CargoDesc} from "~/bindings/src/cargo_desc_type"; -import {InputItemStack} from "~/bindings/src/input_item_stack_type"; -import {ItemDesc} from "~/bindings/src/item_desc_type"; -import {ItemListDesc} from "~/bindings/src/item_list_desc_type"; -import {ItemListPossibility} from "~/bindings/src/item_list_possibility_type"; -import {ItemStack} from "~/bindings/src/item_stack_type"; -import {ItemType} from "~/bindings/src/item_type_type"; -import {ProbabilisticItemStack} from "~/bindings/src/probabilistic_item_stack_type"; -import {QuestDropDesc} from "~/bindings/src/quest_drop_desc_type"; +import {Accessor, Component, createMemo, For, JSX, Show} from "solid-js"; import {CargoIcon, ItemIcon, SHAPE_SIZES} from "~/components/shared/GameIcon"; import {Button} from "~/components/ui/button"; import {Popover, PopoverContent, PopoverTrigger} from "~/components/ui/popover"; import {Tooltip, TooltipContent, TooltipTrigger} from "~/components/ui/tooltip"; +import {BitCraftTables} from "~/lib/bitcraft-data"; import {QuestChainLinkById} from "~/lib/game-links"; import {useSettings} from "~/lib/settings"; -import {BitCraftTables} from "~/lib/spacetime"; import {cn, fixFloat} from "~/lib/utils"; // ─── Utility ──────────────────────────────────────────────────── @@ -155,6 +147,8 @@ export const ProbItemStackIcon: Component<{ small?: boolean; noInteract?: boolean; showItemListsIfEmpty?: boolean; + /** See `ItemListDisplay.quantity` — scales the inner stack's (or list's average) quantity. */ + multiplier?: Accessor; }> = (props) => { const inner = () => props.probStack.itemStack; @@ -185,6 +179,7 @@ export const ProbItemStackIcon: Component<{ stack={stack()} small={props.small ?? true} noInteract={props.noInteract} + displayQty={props.multiplier ? stack().quantity * props.multiplier() : undefined} />
}> @@ -194,7 +189,8 @@ export const ProbItemStackIcon: Component<{ itemList={list()} probability={props.probStack.probability} chances={props.chances} - originalIcon={() => } + quantity={props.multiplier ? () => stack().quantity * props.multiplier!() : undefined} + originalIcon={() => } small={props.small} noInteract={props.noInteract} showIfEmpty={props.showItemListsIfEmpty} @@ -306,11 +302,25 @@ export const ItemListDisplay: Component<{ small?: boolean; noInteract?: boolean; showIfEmpty?: boolean; + /** + * How many independent picks from this list the resolved stack represents — e.g. a recipe + * output of 5×(item list) on an order of 3 is 15 picks. Unlike the probability/chances average + * toggle below (a user preference for how to *view* a percentage), this is always applied: it + * is not optional information, it is the actual quantity being produced. + */ + quantity?: Accessor; }> = (props) => { const { displayProbabilityAsAverage, flattenItemListOutputs } = useSettings(); const sorted = createMemo(() => [...props.itemList.possibilities].sort((a, b) => b.probability - a.probability) ); + const quantityMultiplier = () => props.quantity?.() ?? 1; + const probabilityMultiplier = () => + displayProbabilityAsAverage() && props.chances && props.probability ? props.chances * props.probability : 1; + const displayQtyFor = (avg: AveragedStack): number | undefined => { + const multiplier = quantityMultiplier() * probabilityMultiplier(); + return multiplier === 1 ? undefined : fixFloat(avg.avgQty * multiplier); + }; /** True if any possibility contains an item that resolves to an inner item list */ const hasInnerLists = createMemo(() => { @@ -339,6 +349,13 @@ export const ItemListDisplay: Component<{ + {/* Deterministic quantity badge — independent of the probability badge above, since + a list can be both probabilistic (badge above) and multi-picked (badge here). */} + + + ×{fixFloat(quantityMultiplier())} + + {/* Averaged items in a styled group */}
@@ -346,7 +363,7 @@ export const ItemListDisplay: Component<{ {(avg) => ( @@ -377,6 +394,7 @@ export const ItemListDisplay: Component<{ possibilities={sorted()} originalIcon={props.originalIcon} hasInnerLists={hasInnerLists()} + quantity={props.quantity} /> @@ -390,6 +408,7 @@ const ItemListPopover: Component<{ possibilities: ItemListPossibility[]; originalIcon?: () => JSX.Element; hasInnerLists: boolean; + quantity?: Accessor; }> = (props) => { const { flattenItemListOutputs, setFlattenItemListOutputs } = useSettings(); const totalWeight = createMemo(() => @@ -398,9 +417,14 @@ const ItemListPopover: Component<{ return (
- - {props.itemList.name} - +
+ + {props.itemList.name} + + + ×{fixFloat(props.quantity!())} + +
{/* Show original item that resolves to this list */} @@ -482,6 +506,10 @@ export const InputItemStackArray: Component<{ stacks: InputItemStack[]; small?: boolean; class?: string; + /** Scales each stack's displayed quantity (e.g. a craft order's count) without touching `stacks` + * itself — keeping `stacks` unscaled lets callers pass a stable array reference across live + * updates, so `` doesn't tear down and rebuild every item (and its tooltip) on every tick. */ + multiplier?: Accessor; }> = (props) => (
@@ -497,7 +525,7 @@ export const InputItemStackArray: Component<{ - +
)}
@@ -574,18 +602,24 @@ export const QuestDropDisplay: Component<{ export function expandStack( input: ItemStack | ProbabilisticItemStack | ItemListDesc, chances?: number, - showEmptyItemList?: boolean + showEmptyItemList?: boolean, + /** See `ItemListDisplay.quantity` — an extra scale factor (e.g. a craft order's count) applied + * on top of the stack's own quantity. Passed as an accessor, not a number, so a live-updating + * caller doesn't have to bake the multiplied value into a new stack object just to display it — + * doing that defeats ``'s item identity and tears down/rebuilds this component (and any + * open tooltip in it) on every unrelated tick. */ + multiplier?: Accessor ): JSX.Element { // ProbabilisticItemStack if ("probability" in input && "itemStack" in input) { const probStack = input as ProbabilisticItemStack; if (!probStack.itemStack) return <>; - return ; + return ; } // ItemListDesc if ("possibilities" in input) { - return ; + return ; } // Plain ItemStack — check if it resolves to an item list @@ -595,13 +629,14 @@ export function expandStack( if (item?.itemListId) { const list = BitCraftTables.ItemListDesc.indexedBy("id")().get(item.itemListId); if (list) { + const quantity = () => stack.quantity * (multiplier?.() ?? 1); return } + itemList={list} quantity={quantity} showIfEmpty={showEmptyItemList} + originalIcon={() => } />; } } } - return ; + return ; } diff --git a/frontend/src/components/shared/PlaceableGraph.tsx b/frontend/src/components/shared/PlaceableGraph.tsx index 90034c30..931d6177 100644 --- a/frontend/src/components/shared/PlaceableGraph.tsx +++ b/frontend/src/components/shared/PlaceableGraph.tsx @@ -12,18 +12,16 @@ * using cubic bezier detour paths (backward edges below, forward edges above). */ +import {PlaceableGrowthDesc, PlaceableInteractionDesc, PlaceablePlacementDesc} from "@brico/bitcraft-bindings/types"; import {t} from "@lingui/core/macro"; import * as d3Selection from "d3-selection"; import * as d3Zoom from "d3-zoom"; import {TbOutlineArrowBarRight as IconStart, TbOutlineArrowBarToRight as IconEnd} from "solid-icons/tb"; import {createEffect, createMemo, createSignal, For, onCleanup, onMount, Show} from "solid-js"; -import {PlaceableGrowthDesc} from "~/bindings/src/placeable_growth_desc_type"; -import {PlaceableInteractionDesc} from "~/bindings/src/placeable_interaction_desc_type"; -import {PlaceablePlacementDesc} from "~/bindings/src/placeable_placement_desc_type"; import {CargoIcon, ItemIcon, PlaceableIcon} from "~/components/shared/GameIcon"; +import {BitCraftTables} from "~/lib/bitcraft-data"; import {trackUILocale} from "~/lib/i18n"; import {growthByPlaceable as growthByPlaceableMap, interactionsByOutcome, interactionsByPlaceable as interactionsByPlaceableMap} from "~/lib/placeables"; -import {BitCraftTables} from "~/lib/spacetime"; import {readableSeconds} from "~/lib/utils"; // ─── Types ────────────────────────────────────────────────────── @@ -818,7 +816,7 @@ export function PlaceableGraph(props: PlaceableGraphProps) { const [hoveredNode, setHoveredNode] = createSignal(null); // Path calculator state - const [pathMode, setPathMode] = createSignal(false); + const [pathMode, _setPathMode] = createSignal(false); const [pathStart, setPathStart] = createSignal(null); const [pathEnd, setPathEnd] = createSignal(null); diff --git a/frontend/src/components/shared/QuestGraph.tsx b/frontend/src/components/shared/QuestGraph.tsx index 56d2da70..c3ed538f 100644 --- a/frontend/src/components/shared/QuestGraph.tsx +++ b/frontend/src/components/shared/QuestGraph.tsx @@ -1,13 +1,14 @@ +import {QuestRequirement} from "@brico/bitcraft-bindings/types"; import * as d3Selection from "d3-selection"; import "d3-transition"; import * as d3Zoom from "d3-zoom"; import {TbOutlineCheck as IconCheck, TbOutlineSearch as IconSearch, TbOutlineX as IconX,} from "solid-icons/tb"; import {Accessor, createEffect, createMemo, createSignal, DEV, For, onCleanup, onMount, Show} from "solid-js"; -import {QuestRequirement} from "~/bindings/src/quest_requirement_type"; import {Popover, PopoverContent, PopoverTrigger} from "~/components/ui/popover"; +import {BitCraftTables} from "~/lib/bitcraft-data"; import {LinkedList, QuestChainLink} from "~/lib/game-links"; import {computeQuestTree, getQuestTreeIds, questChainCompleter, stagesByChain} from "~/lib/quests"; -import {BitCraftTables} from "~/lib/spacetime"; +import {useSettings} from "~/lib/settings.tsx"; import {ReqOrRewardLink} from "~/lib/table-defs/quests-table"; import {cn} from "~/lib/utils"; @@ -961,6 +962,7 @@ export function QuestGraph(props: QuestGraphProps) { let searchWrapperRef: HTMLDivElement | undefined; let searchInputRef: HTMLInputElement | undefined; + const {devMenusEnabled} = useSettings(); const [dims, setDims] = createSignal({w: 900, h: 600}); const [transformStr, setTransformStr] = createSignal("translate(0,0) scale(1)"); const [searchQuery, setSearchQuery] = createSignal(""); @@ -1218,8 +1220,7 @@ export function QuestGraph(props: QuestGraphProps) { return (
{/* Search bar + debug toggle */}
@@ -1286,7 +1287,7 @@ export function QuestGraph(props: QuestGraphProps) {
- + +
+) + +const Toaster = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ToastListProps, ["class"]) + toasterOwner = getOwner() + const isDesktop = useIsDesktop() + return ( + + +
+ 0 && isDesktop()}> + + + + 0 && !isDesktop()}> + + +
+
+
+ ) +} + +type ToastRootProps = ToastPrimitive.ToastRootProps & + VariantProps & { class?: string | undefined } + +const Toast = (props: PolymorphicProps>) => { + const [local, others] = splitProps(props as ToastRootProps, ["class", "variant"]) + return ( + + ) +} + +type ToastCloseButtonProps = + ToastPrimitive.ToastCloseButtonProps & { class?: string | undefined } + +const ToastClose = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ToastCloseButtonProps, ["class"]) + return ( + + + + + + + ) +} + +type ToastTitleProps = ToastPrimitive.ToastTitleProps & { + class?: string | undefined +} + +const ToastTitle = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ToastTitleProps, ["class"]) + return +} + +type ToastDescriptionProps = + ToastPrimitive.ToastDescriptionProps & { class?: string | undefined } + +const ToastDescription = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as ToastDescriptionProps, ["class"]) + return +} + +function showToast(props: ShowToastOptions) { + queueToast(props) +} + +// No caller uses this yet, so it intentionally bypasses `queueToast`'s admission/priority queue — +// route it through `showToast`-style bookkeeping if that changes. +function showToastPromise( + promise: Promise | (() => Promise), + options: { + loading?: JSX.Element + success?: (data: T) => JSX.Element + error?: (error: U) => JSX.Element + duration?: number + } +) { + const variant: { [key in ToastPrimitive.ToastPromiseState]: ToastVariant } = { + pending: "default", + fulfilled: "success", + rejected: "error" + } + return ToastPrimitive.toaster.promise(promise, (props) => ( + + + {options.loading} + {options.success?.(props.data!)} + {options.error?.(props.error!)} + + + )) +} + +export { Toaster, Toast, ToastClose, ToastTitle, ToastDescription, showToast, showToastPromise, dismissAll as clearToasts } diff --git a/frontend/src/entry-client.tsx b/frontend/src/entry-client.tsx index b84964c1..f8d45cbe 100644 --- a/frontend/src/entry-client.tsx +++ b/frontend/src/entry-client.tsx @@ -1,8 +1,8 @@ // @refresh reload import {mount, StartClient} from "@solidjs/start/client"; import {render} from "solid-js/web"; +import {preloadAllTablesClient} from "~/lib/bitcraft-data"; import {isBotUserAgent} from "~/lib/bot-detect"; -import {preloadAllTablesClient} from "~/lib/spacetime"; const el = document.getElementById("app")!; diff --git a/frontend/src/entry-server.tsx b/frontend/src/entry-server.tsx index 2d09f0e5..ea7d42e0 100644 --- a/frontend/src/entry-server.tsx +++ b/frontend/src/entry-server.tsx @@ -1,4 +1,5 @@ // @refresh reload +import {ColorModeScript} from "@kobalte/core"; import {createHandler, StartServer} from "@solidjs/start/server"; import {PSEUDOLOCALE_ENABLED} from "~/lib/i18n"; import {KEYS} from "~/lib/settings"; @@ -20,6 +21,7 @@ export default createHandler(() => (