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..b94e342d --- /dev/null +++ b/backend/brico-bot/.env.example @@ -0,0 +1,67 @@ +# 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_RECONCILE_INTERVAL_MS=300000 +#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 + +# CDN that item/frame icon paths resolve against (Discord watch display thumbnails). +#BRICO_ASSET_CDN_BASE=https://cdn.brico.app + +# ── BitAuth/Discord link callback HTTP server ──────────────────────────────── +# Bind address and public base URL of the HTTP server (/auth/*, /icons/*, /discord/*, /metrics). +# Code defaults are container-friendly (0.0.0.0:8080, https://bot.brico.app); override all three +# when running bare on a workstation. +#BRICO_BOT_HTTP_HOST=127.0.0.1 +#BRICO_BOT_HTTP_PORT=8787 +#BRICO_BOT_HTTP_BASE_URL=http://127.0.0.1:8787 +# Where the browser lands after a link attempt (?linked= or ?linkError= appended). +#BRICO_BOT_LINK_RETURN_URL=http://localhost:3001/account/profile +# Comma-separated extra origins a login request's &returnUrl may redirect back to; the origin of +# BRICO_BOT_LINK_RETURN_URL is always allowed, anything else 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= + +# ── Discord bot (slash commands) ───────────────────────────────────────────── +# All three unset disables the bot (/discord/interactions returns 503). Interactions arrive as signed +# HTTP POSTs (no gateway); set the Developer Portal Interactions Endpoint URL to +# BRICO_BOT_HTTP_BASE_URL + /discord/interactions. PUBLIC_KEY is the application's Ed25519 key. +#DISCORD_BOT_TOKEN= +#DISCORD_PUBLIC_KEY= +#DISCORD_APPLICATION_ID= +# OAuth2 client secret; only needed for the self-serve /discord/install flow (unset: that route 503s). +#DISCORD_BOT_CLIENT_SECRET= +# Guild that `npm run discord:register-commands` registers to; not read by the bot process. +#DISCORD_TEST_GUILD_ID= 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..ec16a5b1 --- /dev/null +++ b/backend/brico-bot/README.md @@ -0,0 +1,155 @@ +# `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/app-cache.ts` | The bounty-related `brico-app` tables mirrored from row callbacks, with a per-tick delta of what changed. | +| `src/app/bounty-sink.ts` | Per tick, over the changed crafts and ledger rows only: resolves each craft's bounty, prices contributor effort, and resolves loyalty bonuses, writing `craft_bounty_assignment`/`craft_bounty_entitlement`/`loyalty_bonus_total`. | +| `src/spacetime/feed.ts` | Row-callback wiring shared by the prism row cache and the `brico-app` cache. | +| `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..25d44968 --- /dev/null +++ b/backend/brico-bot/package.json @@ -0,0 +1,35 @@ +{ + "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", + "discord:register-commands": "tsx src/discord/register-commands.ts" + }, + "dependencies": { + "@brico/bindings": "*", + "@brico/bitcraft-bindings": "*", + "@brico/crafts": "*", + "@discordjs/rest": "^2.6.3", + "discord-api-types": "^0.38.55", + "discord-interactions": "^4.4.0", + "jose": "^6.2.0", + "prom-client": "^15.1.3", + "sharp": "^0.34.5", + "spacetimedb": "2.10.2", + "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/app-cache.test.ts b/backend/brico-bot/src/app/app-cache.test.ts new file mode 100644 index 00000000..96f4e35d --- /dev/null +++ b/backend/brico-bot/src/app/app-cache.test.ts @@ -0,0 +1,168 @@ +/** + * app-cache.test.ts — the `brico-app` mirror against a fresh full read after random row operations, + * plus what `drain()` reports. + */ +import type {DbConnection} from "@brico/bindings/brico-app"; +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {Identity, Timestamp} from "spacetimedb"; + +import type {Logger} from "../log.ts"; +import {fakeTable} from "../testing/fake-table.ts"; +import {type AppCache, createAppCache, findAppDrift, tripleKey} from "./app-cache.ts"; + +function rng(seed: number) { + let state = seed; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 2 ** 32; + }; +} + +const identity = (n: number) => new Identity(n.toString(16).padStart(64, "0")); +const ACCOUNTS = [identity(1), identity(2), identity(3)]; +const warnings: string[] = []; +const spyLog: Logger = {debug() {}, info() {}, warn: message => void warnings.push(message), error() {}, child: () => spyLog}; + +function tables(cache: AppCache) { + const t = { + allLinkedIntegration: fakeTable(r => String(r.id), cache.linkedIntegration), + allBountyRule: fakeTable(r => r.id, cache.bountyRule), + allCraftBountyOverride: fakeTable(r => String(r.craftId), cache.craftBountyOverride), + allCraftBountyAssignment: fakeTable(r => String(r.craftId), cache.craftBountyAssignment), + allPrivateCraftBountyAssignment: fakeTable(r => String(r.craftId), cache.privateCraftBountyAssignment), + allCraftBountyEntitlement: fakeTable(r => String(r.id), cache.craftBountyEntitlement), + allLoyaltyReward: fakeTable(r => String(r.id), cache.loyaltyReward), + allLoyaltyRule: fakeTable(r => String(r.id), cache.loyaltyRule), + allLoyaltyBonusTotal: fakeTable(r => String(r.id), cache.loyaltyBonusTotal), + allBountyEntitlementTotal: fakeTable(r => String(r.id), cache.bountyEntitlementTotal), + }; + return {...t, conn: {db: t} as unknown as DbConnection}; +} + +const flatRule = (id: string, account: Identity, filterJson: string, extra: object = {}) => ({ + id, accountIdentity: account, priority: 0, filterJson, private: false, + value: {tag: "Flat", value: {ratioNumerator: 1n, ratioDenominator: 4n, currency: "hex-coin"}}, deletedAt: undefined, ...extra, +}); + +for (const seed of [1, 2, 3, 4]) { + test(`the cache equals a fresh full read after random operations (seed ${seed})`, () => { + const rand = rng(seed); + const int = (n: number) => Math.floor(rand() * n); + const pick = (items: readonly T[]): T => items[int(items.length)]!; + const chance = (p: number) => rand() < p; + + const cache = createAppCache(spyLog); + const t = tables(cache); + // Ledger rows are unique per (payer, payee, currency) in the module, so their ids are that key here. + const triple = () => { + const row = {payerAccountIdentity: pick(ACCOUNTS), payeePlayerId: BigInt(200 + int(3)), currency: pick(["hex-coin", "stelo"])}; + return {...row, id: tripleKey(row.payerAccountIdentity, row.payeePlayerId, row.currency)}; + }; + const tripleId = () => triple().id; + + const operations: (() => void)[] = [ + // At most one active link per player in the module, so each link id owns one player here. + () => { + const id = int(3); + t.allLinkedIntegration.upsert({id: BigInt(id), accountIdentity: pick(ACCOUNTS), provider: pick(["bitcraft-ea2", "bitcraft-ea2", "discord"]), externalId: String(200 + id), revokedAt: chance(0.3) ? Timestamp.now() : undefined}); + }, + () => t.allLinkedIntegration.remove(String(int(3))), + () => t.allBountyRule.upsert(flatRule(`rule${int(3)}`, pick(ACCOUNTS), JSON.stringify(pick([{field: "tier", cmp: "gte", value: 2}, {field: "payout", cmp: "gte", value: 1}])), {priority: int(2), deletedAt: chance(0.2) ? Timestamp.now() : undefined})), + () => t.allBountyRule.remove(`rule${int(3)}`), + () => t.allCraftBountyOverride.upsert({craftId: BigInt(int(4)), accountIdentity: pick(ACCOUNTS), ratioNumerator: BigInt(1 + int(2)), ratioDenominator: 2n, currency: "hex-coin", private: false}), + () => t.allCraftBountyOverride.remove(String(int(4))), + // A craft moving between the two assignment tables, delivering either table's change first. + () => { + const craftId = BigInt(int(4)); + const row = {craftId, ratioNumerator: BigInt(1 + int(2)), ratioDenominator: 4n, currency: "hex-coin", assignedByAccountIdentity: pick(ACCOUNTS)}; + const [into, other] = chance(0.5) ? [t.allCraftBountyAssignment, t.allPrivateCraftBountyAssignment] : [t.allPrivateCraftBountyAssignment, t.allCraftBountyAssignment]; + if (chance(0.5)) { + into.upsert(row); + other.remove(String(craftId)); + } else { + other.remove(String(craftId)); + into.upsert(row); + } + }, + () => t.allCraftBountyAssignment.remove(String(int(4))), + () => { + const craftId = BigInt(int(4)), playerId = BigInt(200 + int(3)); + t.allCraftBountyEntitlement.upsert({id: `${craftId}:${playerId}`, craftId, playerId, currency: "hex-coin", lastAssignedEffort: BigInt(int(100)), updatedAt: Timestamp.now()}); + }, + () => t.allCraftBountyEntitlement.remove(`${int(4)}:${200 + int(3)}`), + () => t.allLoyaltyReward.upsert({...triple(), ratioNumerator: 21n, ratioDenominator: 20n, updatedAt: Timestamp.now()}), + () => t.allLoyaltyReward.remove(tripleId()), + // A rule id may change payer: both payers' rule lists change. + () => t.allLoyaltyRule.upsert({id: BigInt(int(3)), payerAccountIdentity: pick(ACCOUNTS), currency: "hex-coin", spec: {tag: "EffortThreshold", value: {allCurrencies: chance(0.5), threshold: BigInt(int(100))}}, bonusRatioNumerator: 1n, bonusRatioDenominator: 50n, updatedAt: Timestamp.now()}), + () => t.allLoyaltyRule.remove(String(int(3))), + () => t.allLoyaltyBonusTotal.upsert({...triple(), bonusRatioNumerator: 1n, bonusRatioDenominator: 40n, updatedAt: Timestamp.now()}), + () => t.allLoyaltyBonusTotal.remove(tripleId()), + () => t.allBountyEntitlementTotal.upsert({...triple(), total: 1n, totalEffort: BigInt(int(100)), remainderNumerator: 0n, remainderDenominator: 1n, updatedAt: Timestamp.now()}), + () => t.allBountyEntitlementTotal.remove(tripleId()), + ]; + + for (let batch = 0; batch < 300; batch++) { + for (let i = 1 + int(5); i > 0; i--) pick(operations)(); + assert.equal(findAppDrift(cache, t.conn), null, `drift after batch ${batch}`); + } + }); +} + +test("drain reports what changed and then resets", () => { + const cache = createAppCache(spyLog); + const t = tables(cache); + cache.load(t.conn); + assert.equal(cache.drain().full, true, "a load is a full pass"); + assert.deepEqual(cache.drain(), {full: false, resolveAll: false, overrideCrafts: new Set(), assignmentCrafts: new Set(), totals: new Map(), loyaltyRulePayers: new Set()}); + + t.allCraftBountyOverride.upsert({craftId: 7n, accountIdentity: ACCOUNTS[0], ratioNumerator: 1n, ratioDenominator: 2n, currency: "hex-coin", private: false}); + t.allCraftBountyAssignment.upsert({craftId: 8n, ratioNumerator: 1n, ratioDenominator: 2n, currency: "hex-coin", assignedByAccountIdentity: ACCOUNTS[0]}); + t.allBountyEntitlementTotal.upsert({id: 1n, payerAccountIdentity: ACCOUNTS[1], payeePlayerId: 201n, currency: "stelo", total: 1n, totalEffort: 4n, remainderNumerator: 0n, remainderDenominator: 1n, updatedAt: Timestamp.now()}); + t.allLoyaltyRule.upsert({id: 1n, payerAccountIdentity: ACCOUNTS[2], currency: "stelo", spec: {tag: "EffortThreshold", value: {allCurrencies: false, threshold: 5n}}, bonusRatioNumerator: 1n, bonusRatioDenominator: 50n, updatedAt: Timestamp.now()}); + t.allLoyaltyReward.upsert({id: 1n, payerAccountIdentity: ACCOUNTS[2], payeePlayerId: 201n, currency: "stelo", ratioNumerator: 21n, ratioDenominator: 20n, updatedAt: Timestamp.now()}); + + const delta = cache.drain(); + assert.deepEqual([...delta.overrideCrafts], [7n]); + assert.deepEqual([...delta.assignmentCrafts], [8n]); + assert.deepEqual([...delta.totals.keys()], [tripleKey(ACCOUNTS[1], 201n, "stelo")]); + assert.deepEqual([...delta.loyaltyRulePayers], [ACCOUNTS[2].toHexString()]); + assert.equal(delta.resolveAll, false, "overrides, assignments and loyalty rows never re-resolve every craft"); + assert.equal(cache.drain().overrideCrafts.size, 0); + + t.allLinkedIntegration.upsert({id: 1n, accountIdentity: ACCOUNTS[0], provider: "bitcraft-ea2", externalId: "200", revokedAt: undefined}); + assert.equal(cache.drain().resolveAll, true); + assert.equal(cache.state.playerAccounts.get(200n)?.isEqual(ACCOUNTS[0]), true); + + t.allLinkedIntegration.upsert({id: 1n, accountIdentity: ACCOUNTS[0], provider: "bitcraft-ea2", externalId: "200", revokedAt: Timestamp.now()}); + assert.equal(cache.drain().resolveAll, true); + assert.equal(cache.state.playerAccounts.size, 0, "a revoked link no longer resolves"); +}); + +test("a bounty rule is usable only with a valid filter that does not depend on the bounty it assigns, and its failure is reported once", () => { + warnings.length = 0; + const cache = createAppCache(spyLog); + const t = tables(cache); + cache.load(t.conn); + + t.allBountyRule.upsert(flatRule("good", ACCOUNTS[0], JSON.stringify({field: "tier", cmp: "gte", value: 2}))); + t.allBountyRule.upsert(flatRule("circular", ACCOUNTS[0], JSON.stringify({field: "payout", cmp: "gte", value: 1}))); + t.allBountyRule.upsert(flatRule("broken", ACCOUNTS[0], "{not json")); + assert.deepEqual(cache.state.bountyRules.get(ACCOUNTS[0].toHexString())?.map(spec => spec.id), ["good"]); + t.allBountyRule.upsert(flatRule("good", ACCOUNTS[0], JSON.stringify({field: "tier", cmp: "gte", value: 3}))); + assert.equal(cache.state.bountyRules.get(ACCOUNTS[0].toHexString())?.length, 1); + assert.equal(warnings.length, 2, "each unusable rule warns once, however often rules are rebuilt"); + + t.allBountyRule.upsert(flatRule("good", ACCOUNTS[0], JSON.stringify({field: "tier", cmp: "gte", value: 3}), {deletedAt: Timestamp.now()})); + assert.equal(cache.state.bountyRules.has(ACCOUNTS[0].toHexString()), false, "a tombstoned rule is gone"); +}); + +test("a craft's assignment survives the old table's delete arriving after the new table's insert", () => { + const cache = createAppCache(spyLog); + const t = tables(cache); + const row = {craftId: 9n, ratioNumerator: 1n, ratioDenominator: 4n, currency: "hex-coin", assignedByAccountIdentity: ACCOUNTS[0]}; + t.allCraftBountyAssignment.upsert(row); + t.allPrivateCraftBountyAssignment.upsert(row); + t.allCraftBountyAssignment.remove("9"); + assert.equal(cache.state.assignments.get(9n)?.private, true); +}); diff --git a/backend/brico-bot/src/app/app-cache.ts b/backend/brico-bot/src/app/app-cache.ts new file mode 100644 index 00000000..9473127a --- /dev/null +++ b/backend/brico-bot/src/app/app-cache.ts @@ -0,0 +1,413 @@ +/** + * app-cache.ts — the `brico-app` tables the bounty engine reads, mirrored from row callbacks, with + * `drain()` reporting what changed since the previous drain. + * + * Callbacks only update the mirrored maps and record what became dirty. `load` + `findAppDrift` are + * the full-read path the cache is seeded from and checked against. + */ +import type {DbConnection} from "@brico/bindings/brico-app"; +import type { + BountyEntitlementTotal, + BountyRule, + BountyRuleValue, + CraftBountyEntitlement, + CraftBountyOverride, + LoyaltyBonusTotal, + LoyaltyReward, + LoyaltyRule, +} from "@brico/bindings/brico-app/types"; +import {type FilterNode, parseFilter, validateFilter} from "@brico/crafts/filter"; +import {isDeepStrictEqual} from "node:util"; +import type {Identity} from "spacetimedb"; + +import {createLogger, type Logger} from "../log.ts"; +import {keyedFeed, type TableFeed} from "../spacetime/feed.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; +} + +/** + * A stored assignment from either table. `private` is tagged on here: neither + * `craft_bounty_assignment` nor `craft_private_bounty_assignment` stores it, which table a craft's + * row lives in *is* its privacy. + */ +export interface ExistingBountyAssignment { + ratioNumerator: bigint; + ratioDenominator: bigint; + currency: string; + assignedByAccountIdentity: Identity; + private: boolean; +} + +/** A (payer, payee, currency) ledger key. */ +export interface Triple { + payer: Identity; + payee: bigint; + currency: string; +} + +export function tripleKey(payer: Identity, payee: bigint, currency: string): string { + return `${payer.toHexString()}:${payee}:${currency}`; +} + +type Db = DbConnection["db"]; +type RowOf = Db[K] extends {iter(): Iterable} ? R : never; +type LinkedIntegration = RowOf<"allLinkedIntegration">; +type Assignment = RowOf<"allCraftBountyAssignment">; + +/** The mirrored tables. Maps are mutated by callbacks, so read them synchronously. */ +export interface AppState { + /** `bitcraft-ea2` player entity id -> the brico account it is linked to, revoked links excluded. */ + readonly playerAccounts: ReadonlyMap; + /** Every account's usable bounty rules by account identity hex, in evaluation order (priority, then id). */ + readonly bountyRules: ReadonlyMap; + readonly overrides: ReadonlyMap; + /** Both assignment tables; they are mutually exclusive per craft. */ + readonly assignments: ReadonlyMap; + /** Keyed `craftId:playerId:currency`. */ + readonly entitlements: ReadonlyMap; + /** Keyed by `tripleKey`. */ + readonly loyaltyRewards: ReadonlyMap; + /** Every payer's automated loyalty rules by payer identity hex, ordered by id; rules add, so order never matters. */ + readonly loyaltyRules: ReadonlyMap; + /** Keyed by `tripleKey`. */ + readonly entitlementTotals: ReadonlyMap; + /** Keyed by `tripleKey`. */ + readonly loyaltyBonusTotals: ReadonlyMap; + /** `entitlementTotals` keys for one payee / one payer (identity hex). */ + readonly totalKeysByPayee: ReadonlyMap>; + readonly totalKeysByPayer: ReadonlyMap>; +} + +/** What `drain()` found changed since the previous drain. */ +export interface AppDelta { + /** True after `load`/`requestFull`: every step should re-evaluate from scratch rather than apply a delta. */ + full: boolean; + /** Linked accounts or bounty rules changed, so any craft's resolved bounty may differ. */ + resolveAll: boolean; + overrideCrafts: Set; + /** Crafts whose stored assignment row changed (including the bot's own writes arriving back). */ + assignmentCrafts: Set; + /** `bounty_entitlement_total` triples that changed, by `tripleKey`. */ + totals: Map; + /** Payers (identity hex) whose automated loyalty rules changed. */ + loyaltyRulePayers: Set; +} + +export function emptyAppDelta(full = false): AppDelta { + return {full, resolveAll: false, overrideCrafts: new Set(), assignmentCrafts: new Set(), totals: new Map(), loyaltyRulePayers: new Set()}; +} + +export interface AppCache { + readonly linkedIntegration: TableFeed; + readonly bountyRule: TableFeed; + readonly craftBountyOverride: TableFeed; + readonly craftBountyAssignment: TableFeed; + readonly privateCraftBountyAssignment: TableFeed; + readonly craftBountyEntitlement: TableFeed; + readonly loyaltyReward: TableFeed; + readonly loyaltyRule: TableFeed; + readonly loyaltyBonusTotal: TableFeed; + readonly bountyEntitlementTotal: TableFeed; + + /** Replaces everything with a full read of `conn`'s tables and makes the next drain `full`. */ + load(conn: DbConnection): void; + /** Makes the next drain `full`. */ + requestFull(): void; + drain(): AppDelta; + readonly state: AppState; +} + +function addTo(index: Map>, key: K, value: V): void { + const set = index.get(key); + if (set) set.add(value); + else index.set(key, new Set([value])); +} + +function removeFrom(index: Map>, key: K, value: V): void { + const set = index.get(key); + if (!set) return; + set.delete(value); + if (set.size === 0) index.delete(key); +} + +export function createAppCache(log: Logger): AppCache { + const links = new Map(); + const rules = new Map(); + const overrides = new Map(); + const assignments = new Map(); + const entitlements = new Map(); + const loyaltyRewards = new Map(); + const loyaltyRuleRows = new Map(); + const entitlementTotals = new Map(); + const loyaltyBonusTotals = new Map(); + const totalKeysByPayee = new Map>(); + const totalKeysByPayer = new Map>(); + + // Derived from `links`/`rules`/`loyaltyRuleRows`, rebuilt on first read after one of them changes. + let playerAccountsView: Map | null = null; + let bountyRulesView: Map | null = null; + let loyaltyRulesView: Map | null = null; + // Parsed filter per rule id, valid while its `filterJson` is unchanged; `null` = unusable (already warned about). + const parsedFilters = new Map(); + + let delta = emptyAppDelta(); + + /** Parses and validates a rule's filter. A rule that conditions on `payout`/`currency` is skipped, since it would depend on the bounty it assigns. */ + function parseRuleFilter(rule: BountyRule): FilterNode | null { + const cached = parsedFilters.get(rule.id); + if (cached && cached.json === rule.filterJson) return cached.filter; + + let filter: FilterNode | null = null; + try { + const parsedJson: unknown = JSON.parse(rule.filterJson); + 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("; ")}); + } else { + filter = parseFilter(parsedJson); + if (!filter) log.warn("skipping bounty rule: filter did not parse", {ruleId: rule.id}); + } + } catch { + log.warn("skipping bounty rule: filterJson is not valid JSON", {ruleId: rule.id}); + } + parsedFilters.set(rule.id, {json: rule.filterJson, filter}); + return filter; + } + + function buildBountyRules(): Map { + const byAccount = new Map(); + for (const rule of rules.values()) { + const filter = parseRuleFilter(rule); + if (!filter) continue; + const key = rule.accountIdentity.toHexString(); + const spec: BountyRuleSpec = {id: rule.id, priority: rule.priority, filter, value: rule.value, private: rule.private}; + const specs = byAccount.get(key); + if (specs) specs.push(spec); + else byAccount.set(key, [spec]); + } + for (const id of parsedFilters.keys()) if (!rules.has(id)) parsedFilters.delete(id); + for (const specs of byAccount.values()) specs.sort((a, b) => a.priority - b.priority || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return byAccount; + } + + function buildLoyaltyRules(): Map { + const byPayer = new Map(); + for (const rule of loyaltyRuleRows.values()) { + const key = rule.payerAccountIdentity.toHexString(); + const list = byPayer.get(key); + if (list) list.push(rule); + else byPayer.set(key, [rule]); + } + for (const list of byPayer.values()) list.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return byPayer; + } + + function setLink(row: LinkedIntegration): void { + if (row.provider !== "bitcraft-ea2" || row.revokedAt !== undefined) { + removeLink(row); + return; + } + links.set(row.id, row); + playerAccountsView = null; + delta.resolveAll = true; + } + + function removeLink(row: LinkedIntegration): void { + if (!links.delete(row.id)) return; + playerAccountsView = null; + delta.resolveAll = true; + } + + function setRule(row: BountyRule): void { + if (row.deletedAt !== undefined) { + removeRule(row); + return; + } + rules.set(row.id, row); + bountyRulesView = null; + delta.resolveAll = true; + } + + function removeRule(row: BountyRule): void { + if (!rules.delete(row.id)) return; + bountyRulesView = null; + delta.resolveAll = true; + } + + const assignmentFeed = (isPrivate: boolean): TableFeed => keyedFeed( + row => row.craftId, + row => { + assignments.set(row.craftId, {...row, private: isPrivate}); + delta.assignmentCrafts.add(row.craftId); + }, + row => { + // A craft moving between the two tables can deliver the new row before the old one's delete. + if (assignments.get(row.craftId)?.private !== isPrivate) return; + assignments.delete(row.craftId); + delta.assignmentCrafts.add(row.craftId); + }, + ); + + function setLoyaltyRule(row: LoyaltyRule): void { + loyaltyRuleRows.set(row.id, row); + loyaltyRulesView = null; + delta.loyaltyRulePayers.add(row.payerAccountIdentity.toHexString()); + } + + const entitlementKey = (row: CraftBountyEntitlement) => `${row.craftId}:${row.playerId}:${row.currency}`; + + function setTotal(row: BountyEntitlementTotal): void { + const key = tripleKey(row.payerAccountIdentity, row.payeePlayerId, row.currency); + entitlementTotals.set(key, row); + addTo(totalKeysByPayee, row.payeePlayerId, key); + addTo(totalKeysByPayer, row.payerAccountIdentity.toHexString(), key); + delta.totals.set(key, {payer: row.payerAccountIdentity, payee: row.payeePlayerId, currency: row.currency}); + } + + function removeTotal(row: BountyEntitlementTotal): void { + const key = tripleKey(row.payerAccountIdentity, row.payeePlayerId, row.currency); + if (entitlementTotals.get(key)?.id !== row.id) return; + entitlementTotals.delete(key); + removeFrom(totalKeysByPayee, row.payeePlayerId, key); + removeFrom(totalKeysByPayer, row.payerAccountIdentity.toHexString(), key); + delta.totals.set(key, {payer: row.payerAccountIdentity, payee: row.payeePlayerId, currency: row.currency}); + } + + function reset(): void { + for (const map of [links, rules, overrides, assignments, entitlements, loyaltyRewards, loyaltyRuleRows, entitlementTotals, loyaltyBonusTotals, totalKeysByPayee, totalKeysByPayer] as Map[]) map.clear(); + playerAccountsView = null; + bountyRulesView = null; + loyaltyRulesView = null; + parsedFilters.clear(); + } + + const cache: AppCache = { + linkedIntegration: keyedFeed(row => row.id, setLink, removeLink), + bountyRule: keyedFeed(row => row.id, setRule, removeRule), + craftBountyOverride: keyedFeed( + row => row.craftId, + row => { + overrides.set(row.craftId, row); + delta.overrideCrafts.add(row.craftId); + }, + row => { + overrides.delete(row.craftId); + delta.overrideCrafts.add(row.craftId); + }, + ), + craftBountyAssignment: assignmentFeed(false), + privateCraftBountyAssignment: assignmentFeed(true), + craftBountyEntitlement: keyedFeed( + entitlementKey, + row => void entitlements.set(entitlementKey(row), row), + row => { + if (entitlements.get(entitlementKey(row))?.id === row.id) entitlements.delete(entitlementKey(row)); + }, + ), + loyaltyReward: keyedFeed( + row => tripleKey(row.payerAccountIdentity, row.payeePlayerId, row.currency), + row => void loyaltyRewards.set(tripleKey(row.payerAccountIdentity, row.payeePlayerId, row.currency), row), + row => { + const key = tripleKey(row.payerAccountIdentity, row.payeePlayerId, row.currency); + if (loyaltyRewards.get(key)?.id === row.id) loyaltyRewards.delete(key); + }, + ), + loyaltyRule: { + insert: setLoyaltyRule, + update(before, after) { + delta.loyaltyRulePayers.add(before.payerAccountIdentity.toHexString()); + setLoyaltyRule(after); + }, + delete(row) { + if (!loyaltyRuleRows.delete(row.id)) return; + loyaltyRulesView = null; + delta.loyaltyRulePayers.add(row.payerAccountIdentity.toHexString()); + }, + }, + loyaltyBonusTotal: keyedFeed( + row => tripleKey(row.payerAccountIdentity, row.payeePlayerId, row.currency), + row => void loyaltyBonusTotals.set(tripleKey(row.payerAccountIdentity, row.payeePlayerId, row.currency), row), + row => { + const key = tripleKey(row.payerAccountIdentity, row.payeePlayerId, row.currency); + if (loyaltyBonusTotals.get(key)?.id === row.id) loyaltyBonusTotals.delete(key); + }, + ), + bountyEntitlementTotal: keyedFeed( + row => tripleKey(row.payerAccountIdentity, row.payeePlayerId, row.currency), + setTotal, + removeTotal, + ), + + load(conn) { + reset(); + const {db} = conn; + for (const row of db.allLinkedIntegration.iter()) setLink(row); + for (const row of db.allBountyRule.iter()) setRule(row); + for (const row of db.allCraftBountyOverride.iter()) overrides.set(row.craftId, row); + for (const row of db.allCraftBountyAssignment.iter()) assignments.set(row.craftId, {...row, private: false}); + for (const row of db.allPrivateCraftBountyAssignment.iter()) assignments.set(row.craftId, {...row, private: true}); + for (const row of db.allCraftBountyEntitlement.iter()) entitlements.set(entitlementKey(row), row); + for (const row of db.allLoyaltyReward.iter()) loyaltyRewards.set(tripleKey(row.payerAccountIdentity, row.payeePlayerId, row.currency), row); + for (const row of db.allLoyaltyRule.iter()) loyaltyRuleRows.set(row.id, row); + for (const row of db.allLoyaltyBonusTotal.iter()) loyaltyBonusTotals.set(tripleKey(row.payerAccountIdentity, row.payeePlayerId, row.currency), row); + for (const row of db.allBountyEntitlementTotal.iter()) setTotal(row); + delta = emptyAppDelta(true); + }, + + requestFull() { + delta.full = true; + }, + + drain() { + const drained = delta; + delta = emptyAppDelta(); + return drained; + }, + + state: { + get playerAccounts() { + if (!playerAccountsView) { + playerAccountsView = new Map(); + for (const link of links.values()) playerAccountsView.set(BigInt(link.externalId), link.accountIdentity); + } + return playerAccountsView; + }, + get bountyRules() { + return (bountyRulesView ??= buildBountyRules()); + }, + overrides, + assignments, + entitlements, + loyaltyRewards, + get loyaltyRules() { + return (loyaltyRulesView ??= buildLoyaltyRules()); + }, + entitlementTotals, + loyaltyBonusTotals, + totalKeysByPayee, + totalKeysByPayer, + }, + }; + return cache; +} + +const COMPARED: (keyof AppState)[] = [ + "playerAccounts", "bountyRules", "overrides", "assignments", "entitlements", + "loyaltyRewards", "loyaltyRules", "entitlementTotals", "loyaltyBonusTotals", +]; + +/** The mirrored tables of `cache` that differ from a fresh full read of `conn`, or `null` when they all agree. */ +export function findAppDrift(cache: AppCache, conn: DbConnection): string | null { + // Quiet: the cache being checked has already warned about any unusable rule. + const truth = createAppCache(createLogger("error")); + truth.load(conn); + const differing = COMPARED.filter(name => !isDeepStrictEqual(cache.state[name], truth.state[name])); + return differing.length === 0 ? null : differing.join(", "); +} diff --git a/backend/brico-bot/src/app/bounty-engine.test.ts b/backend/brico-bot/src/app/bounty-engine.test.ts new file mode 100644 index 00000000..7b6ff282 --- /dev/null +++ b/backend/brico-bot/src/app/bounty-engine.test.ts @@ -0,0 +1,446 @@ +/** + * bounty-engine.test.ts — the delta-driven `BountyEngine` against the same engine told to + * re-evaluate everything each tick, over random prism and `brico-app` row operations, with a fake + * `brico-app` module that applies (and echoes back) the engine's reducer calls. Both must issue the + * same writes every tick and leave the same module state. + */ +import type {DbConnection} from "@brico/bindings/brico-app"; +import type {CraftMeta} from "@brico/bindings/prism/types"; +import {matchAll} from "@brico/crafts/filter"; +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {Identity, Timestamp} from "spacetimedb"; + +import type {RecipeIndex, RecipeStatic} from "../game-data/recipes.ts"; +import type {Logger} from "../log.ts"; +import {readSnapshot} from "../relay/prism.ts"; +import {createRowCache} from "../relay/row-cache.ts"; +import {fakeTable} from "../testing/fake-table.ts"; +import {createAppCache} from "./app-cache.ts"; +import {bountyScope, createBountyEngine} from "./bounty-sink.ts"; +import type {BricoAppConnection} from "./connection.ts"; + +const quiet: Logger = {debug() {}, info() {}, warn() {}, error() {}, child: () => quiet}; + +function rng(seed: number) { + let state = seed; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 2 ** 32; + }; +} + +const RECIPES: RecipeIndex = new Map([ + [1, {effortRequired: 100, skillId: 3, buildingType: 10, levelRequired: 25, itemKey: "item:1", itemTag: "Food", inputItems: []}], + [2, {effortRequired: 40, skillId: 4, buildingType: 11, levelRequired: 5, itemKey: "cargo:2", itemTag: null, inputItems: []}], +]); + +const identity = (n: number) => new Identity(n.toString(16).padStart(64, "0")); +const ACCOUNTS = [identity(1), identity(2), identity(3)]; +const PLAYERS = [200n, 201n, 202n, 203n]; +const CLAIMS = [0n, 100n, 101n]; +const CRAFT_IDS = Array.from({length: 24}, (_, i) => BigInt(i + 1)); +const CURRENCIES = ["hex-coin", "stelo"]; +const FILTERS = [ + {field: "skill", cmp: "eq", value: 3}, + {field: "region", cmp: "eq", value: 1}, + {field: "skill", cmp: "eq", value: 4}, + {op: "not", child: {field: "region", cmp: "eq", value: 2}}, +]; + +const raw = (value: object) => value as unknown as T; +const hex = (account: Identity) => account.toHexString(); + +/** A fake `brico-app` module: the tables the cache mirrors, plus the reducers the engine calls, applied on `flush()` like a round trip. */ +function fakeModule(feeds: ReturnType, seed: number) { + const tables = { + allLinkedIntegration: fakeTable(r => String(r.id), feeds.linkedIntegration), + allBountyRule: fakeTable(r => r.id, feeds.bountyRule), + allCraftBountyOverride: fakeTable(r => String(r.craftId), feeds.craftBountyOverride), + allCraftBountyAssignment: fakeTable(r => String(r.craftId), feeds.craftBountyAssignment), + allPrivateCraftBountyAssignment: fakeTable(r => String(r.craftId), feeds.privateCraftBountyAssignment), + allCraftBountyEntitlement: fakeTable(r => String(r.id), feeds.craftBountyEntitlement), + allLoyaltyReward: fakeTable(r => String(r.id), feeds.loyaltyReward), + allLoyaltyRule: fakeTable(r => String(r.id), feeds.loyaltyRule), + allLoyaltyBonusTotal: fakeTable(r => String(r.id), feeds.loyaltyBonusTotal), + allBountyEntitlementTotal: fakeTable(r => String(r.id), feeds.bountyEntitlementTotal), + }; + const writes: string[] = []; + const queue: (() => void)[] = []; + let round = 0; + // Whether a call fails depends only on what it is and which tick it is in, not on the order the engine issued it. + const fails = (description: string) => { + let hash = 2166136261 ^ seed; + for (const char of `${round}:${description}`) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619) >>> 0; + return hash % 100 < 8; + }; + + const call = (description: string, apply: () => void): Promise => { + writes.push(description); + return new Promise((resolve, reject) => { + queue.push(() => { + if (fails(description)) return reject(new Error("injected failure")); + try { + apply(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }; + const find = (table: {rows: Map}, match: (row: any) => boolean) => [...table.rows.values()].find(match); + const assignmentOf = (craftId: bigint) => + tables.allCraftBountyAssignment.rows.get(String(craftId)) ?? tables.allPrivateCraftBountyAssignment.rows.get(String(craftId)); + const setEntitlement = (craftId: bigint, playerId: bigint, currency: string, effort: bigint) => { + const row = {id: `${craftId}:${playerId}:${currency}`, craftId, playerId, currency, lastAssignedEffort: effort, updatedAt: Timestamp.now()}; + tables.allCraftBountyEntitlement.upsert(row); + }; + + const reducers = { + assignCraftBounty: (args: any) => call( + `assign ${args.craftId} ${args.ratioNumerator}/${args.ratioDenominator} ${args.currency} ${hex(args.assignedByAccountIdentity)} ${args.private} [${args.baselines.map((b: any) => `${b.playerId}:${b.effort}`)}]`, + () => { + const [into, other] = args.private + ? [tables.allPrivateCraftBountyAssignment, tables.allCraftBountyAssignment] + : [tables.allCraftBountyAssignment, tables.allPrivateCraftBountyAssignment]; + into.upsert({craftId: args.craftId, ratioNumerator: args.ratioNumerator, ratioDenominator: args.ratioDenominator, currency: args.currency, assignedByAccountIdentity: args.assignedByAccountIdentity, assignedAt: args.assignedAt, updatedAt: args.updatedAt}); + other.remove(String(args.craftId)); + for (const baseline of args.baselines) { + if (!find(tables.allCraftBountyEntitlement, r => r.craftId === args.craftId && r.playerId === baseline.playerId && r.currency === args.currency)) { + setEntitlement(args.craftId, baseline.playerId, args.currency, baseline.effort); + } + } + }, + ), + clearCraftBounty: (args: any) => call(`clear ${args.craftId}`, () => { + tables.allCraftBountyAssignment.remove(String(args.craftId)); + tables.allPrivateCraftBountyAssignment.remove(String(args.craftId)); + }), + upsertCraftBountyEntitlement: (args: any) => call( + `entitle ${args.craftId}:${args.playerId}:${args.currency} effort=${args.effort} ratio=${args.ratioNumerator}/${args.ratioDenominator}`, + () => { + const assignment = assignmentOf(args.craftId); + if (!assignment) throw new Error("no bounty assignment"); + const existing = find(tables.allCraftBountyEntitlement, r => r.craftId === args.craftId && r.playerId === args.playerId && r.currency === args.currency); + const delta = BigInt(args.effort) - BigInt(existing?.lastAssignedEffort ?? 0n); + setEntitlement(args.craftId, args.playerId, args.currency, args.effort); + if (delta === 0n) return; + const payer = assignment.assignedByAccountIdentity as Identity; + const total = find(tables.allBountyEntitlementTotal, r => r.payerAccountIdentity.isEqual(payer) && r.payeePlayerId === args.playerId && r.currency === args.currency); + tables.allBountyEntitlementTotal.upsert({ + id: `${hex(payer)}:${args.playerId}:${args.currency}`, payerAccountIdentity: payer, payeePlayerId: args.playerId, currency: args.currency, + total: (total?.total ?? 0n) + (delta * args.ratioNumerator) / args.ratioDenominator, totalEffort: (total?.totalEffort ?? 0n) + delta, + remainderNumerator: 0n, remainderDenominator: 1n, updatedAt: Timestamp.now(), + }); + }, + ), + upsertLoyaltyBonusTotal: (args: any) => call( + `bonus ${hex(args.payerAccountIdentity)}:${args.payeePlayerId}:${args.currency} ${args.bonusRatioNumerator}/${args.bonusRatioDenominator}`, + () => { + tables.allLoyaltyBonusTotal.upsert({...args, id: `${hex(args.payerAccountIdentity)}:${args.payeePlayerId}:${args.currency}`}); + }, + ), + deleteLoyaltyBonusTotal: (args: any) => call( + `bonus-delete ${hex(args.payerAccountIdentity)}:${args.payeePlayerId}:${args.currency}`, + () => { + tables.allLoyaltyBonusTotal.remove(`${hex(args.payerAccountIdentity)}:${args.payeePlayerId}:${args.currency}`); + }, + ), + }; + + const conn = {isActive: true, db: tables, reducers} as unknown as DbConnection; + return { + tables, + conn, + writes, + /** Applies every queued reducer call in order, firing the cache's feeds like the echo of a round trip. */ + flush() { + for (const apply of queue.splice(0)) apply(); + round += 1; + }, + }; +} + +function createSide(options: {forceFull: boolean; seed: number}) { + const rowCache = createRowCache(RECIPES); + const prism = { + craftMeta: fakeTable(r => String(r.entityId), rowCache.craftMeta), + craftProgress: fakeTable(r => String(r.entityId), rowCache.craftProgress), + craftContribution: fakeTable(r => `${r.craftId}:${r.playerId}`, rowCache.craftContribution), + claimInfo: fakeTable(r => String(r.entityId), rowCache.claimInfo), + claimMember: fakeTable(r => `${r.claimEntityId}:${r.playerEntityId}`, rowCache.claimMember), + playerState: fakeTable(r => String(r.entityId), rowCache.playerState), + region: fakeTable(r => String(r.id), rowCache.region), + }; + const appCache = createAppCache(quiet); + const module = fakeModule(appCache, options.seed); + const app = {connection: {connection: module.conn}, isLive: true} as unknown as BricoAppConnection; + const engine = createBountyEngine(app, appCache, quiet); + let started = false; + + return { + rowCache, prism, appCache, module, engine, + /** One bridge tick's bounty steps, then the round trip of whatever they wrote. */ + async tick() { + if (!started) { + started = true; + rowCache.load(readSnapshot(raw({db: prism}))); + appCache.load(module.conn); + } + const delta = rowCache.drain(); + const appDelta = appCache.drain(); + const scope = bountyScope(delta, appDelta, rowCache.baseRows); + const effective = options.forceFull ? {...scope, full: true} : scope; + const assigned = engine.assign(rowCache.snapshot, rowCache.baseRows.values(), effective); + engine.updateEntitlements(rowCache.snapshot, effective); + engine.updateLoyaltyBonuses(rowCache.snapshot, effective); + // The full side diffs the whole map, the other trusts the engine's `changed`. + if (options.forceFull) rowCache.setAssignments(assigned.bounties); + else rowCache.setAssignments(assigned.bounties, assigned.changed); + rowCache.drain(); + const written = module.writes.splice(0); + module.flush(); + // Lets the engine's `.catch` handlers for rejected calls run before the next tick. + await new Promise(resolve => setImmediate(resolve)); + return written; + }, + /** The module's rows as comparable lines, ignoring timestamps. */ + state() { + const lines: string[] = []; + for (const [name, table] of Object.entries(module.tables)) { + for (const row of table.rows.values()) { + lines.push(`${name} ${JSON.stringify(row, (key, value) => key === "updatedAt" || key === "assignedAt" ? undefined : typeof value === "bigint" ? value.toString() : value)}`); + } + } + return lines.sort(); + }, + }; +} + +type Side = ReturnType; + +const craftMeta = (id: bigint, owner: bigint, claim: bigint, recipe: number, region: number, status: "Active" | "Claimed", firstSeenMicros: bigint) => raw({ + entityId: id, ownerEntityId: owner, claimEntityId: claim, buildingEntityId: 0n, firstSeen: new Timestamp(firstSeenMicros), + recipeId: recipe, count: 1, regionId: region, public: true, status: {tag: status}, +}); + +/** A random row operation as data, so both sides can apply the same one. */ +function generator(seed: number) { + const rand = rng(seed); + const int = (n: number) => Math.floor(rand() * n); + const pick = (items: readonly T[]): T => items[int(items.length)]!; + const chance = (p: number) => rand() < p; + const effort = new Map(); + let linkId = 1n; + let loyaltyId = 1n; + + const operations: (() => (side: Side) => void)[] = [ + () => { + const id = pick(CRAFT_IDS), owner = pick([0n, ...PLAYERS]), claim = pick(CLAIMS), recipe = pick([1, 2]), region = pick([1, 2]); + const status = chance(0.85) ? "Active" : "Claimed"; + const firstSeen = chance(0.5) ? 0n : BigInt(Date.now()) * 1000n; + return side => { + const existing = side.prism.craftMeta.rows.get(String(id)); + side.prism.craftMeta.upsert(craftMeta(id, owner, claim, recipe, region, status, existing ? existing.firstSeen.microsSinceUnixEpoch : firstSeen)); + }; + }, + () => { + const id = pick(CRAFT_IDS); + return side => side.prism.craftMeta.remove(String(id)); + }, + ...Array.from({length: 4}, () => () => { + const craftId = pick(CRAFT_IDS), playerId = pick(PLAYERS); + const next = (effort.get(`${craftId}:${playerId}`) ?? 0n) + BigInt(1 + int(60)); + effort.set(`${craftId}:${playerId}`, next); + return (side: Side) => side.prism.craftContribution.upsert({craftId, playerId, contribution: Number(next)}); + }), + () => { + const craftId = pick(CRAFT_IDS), playerId = pick(PLAYERS); + effort.delete(`${craftId}:${playerId}`); + return side => side.prism.craftContribution.remove(`${craftId}:${playerId}`); + }, + () => { + const row = { + entityId: 0n, regionId: 0, claimEntityId: pick(CLAIMS.slice(1)), playerEntityId: pick(PLAYERS), + build: chance(0.5), inventory: chance(0.3), officer: chance(0.3), coOwner: chance(0.2), owner: chance(0.4), + }; + return side => side.prism.claimMember.upsert(row); + }, + () => { + const key = `${pick(CLAIMS.slice(1))}:${pick(PLAYERS)}`; + return side => side.prism.claimMember.remove(key); + }, + () => { + const id = linkId++ % 5n, player = pick(PLAYERS), account = pick(ACCOUNTS), revoked = chance(0.2); + return side => side.module.tables.allLinkedIntegration.upsert({ + id, accountIdentity: account, provider: "bitcraft-ea2", externalId: player.toString(), revokedAt: revoked ? Timestamp.now() : undefined, + }); + }, + () => { + const id = String(pick([0n, 1n, 2n, 3n, 4n])); + return side => side.module.tables.allLinkedIntegration.remove(id); + }, + () => { + const id = `rule${int(4)}`, account = pick(ACCOUNTS), filter = pick(FILTERS), priority = int(3), isPrivate = chance(0.3); + const numerator = BigInt(1 + int(3)), currency = pick(CURRENCIES); + return side => side.module.tables.allBountyRule.upsert({ + id, accountIdentity: account, priority, filterJson: JSON.stringify(filter), private: isPrivate, + value: {tag: "Flat", value: {ratioNumerator: numerator, ratioDenominator: 4n, currency}}, deletedAt: undefined, + }); + }, + () => { + const id = `rule${int(4)}`; + return side => side.module.tables.allBountyRule.remove(id); + }, + () => { + const craftId = pick(CRAFT_IDS), account = pick(ACCOUNTS), isPrivate = chance(0.3), currency = pick(CURRENCIES); + return side => side.module.tables.allCraftBountyOverride.upsert({craftId, accountIdentity: account, ratioNumerator: 1n, ratioDenominator: 2n, currency, private: isPrivate}); + }, + () => { + const craftId = pick(CRAFT_IDS); + return side => side.module.tables.allCraftBountyOverride.remove(String(craftId)); + }, + ...Array.from({length: 2}, () => () => { + const id = BigInt(1 + int(5)), payer = pick(ACCOUNTS), currency = pick(CURRENCIES); + const spec = chance(0.4) + ? {tag: "ClaimMembership", value: {claimEntityId: pick(CLAIMS.slice(1)), requiredAccess: pick(["member", "build", "officer", "owner"])}} + : {tag: "EffortThreshold", value: {allCurrencies: chance(0.5), threshold: BigInt(20 + int(300))}}; + const numerator = BigInt(1 + int(3)), denominator = BigInt(pick([20, 40, 50])); + return (side: Side) => side.module.tables.allLoyaltyRule.upsert({ + id, payerAccountIdentity: payer, currency, spec, bonusRatioNumerator: numerator, bonusRatioDenominator: denominator, updatedAt: Timestamp.now(), + }); + }), + () => { + const id = String(1 + int(5)); + return side => side.module.tables.allLoyaltyRule.remove(id); + }, + () => { + const id = loyaltyId++ % 4n, payer = pick(ACCOUNTS), player = pick(PLAYERS), currency = pick(CURRENCIES); + return side => side.module.tables.allLoyaltyReward.upsert({id, payerAccountIdentity: payer, payeePlayerId: player, currency, ratioNumerator: 21n, ratioDenominator: 20n, updatedAt: Timestamp.now()}); + }, + () => { + const id = String(pick([0n, 1n, 2n, 3n])); + return side => side.module.tables.allLoyaltyReward.remove(id); + }, + // Someone else deleting a stored assignment: the engine must write it back. + () => { + const craftId = pick(CRAFT_IDS); + return side => { + side.module.tables.allCraftBountyAssignment.remove(String(craftId)); + side.module.tables.allPrivateCraftBountyAssignment.remove(String(craftId)); + }; + }, + ]; + return {chance, int, batch: () => Array.from({length: 1 + int(5)}, () => pick(operations)())}; +} + +const kinds = new Map(); + +for (const seed of [1, 2, 3, 4, 5, 6]) { + test(`delta-driven steps issue the same writes as a full pass each tick (seed ${seed})`, async () => { + const gen = generator(seed); + const sides = [createSide({forceFull: true, seed}), createSide({forceFull: false, seed})] as const; + const [oracle, candidate] = sides; + + for (let i = 0; i < 30; i++) for (const apply of gen.batch()) for (const side of sides) apply(side); + + for (let tick = 0; tick < 150; tick++) { + for (const apply of gen.batch()) for (const side of sides) apply(side); + const expected = await oracle.tick(); + const actual = await candidate.tick(); + + assert.deepEqual([...actual].sort(), [...expected].sort(), `writes at tick ${tick}`); + assert.deepEqual(candidate.state(), oracle.state(), `module state at tick ${tick}`); + assert.deepEqual(candidate.rowCache.rows, oracle.rowCache.rows, `served rows at tick ${tick}`); + for (const write of actual) kinds.set(write.split(" ")[0]!, (kinds.get(write.split(" ")[0]!) ?? 0) + 1); + } + }); +} + +test("the random runs exercise every kind of write", () => { + for (const kind of ["assign", "clear", "entitle", "bonus", "bonus-delete"]) assert.ok((kinds.get(kind) ?? 0) > 0, `no ${kind} write was ever issued`); +}); + +/** A hex-coin rule matching every craft, owned by account 1 who is linked to player 200, who owns craft 1. */ +function bountiedCraft(side: Side) { + side.module.tables.allLinkedIntegration.upsert({id: 1n, accountIdentity: ACCOUNTS[0], provider: "bitcraft-ea2", externalId: "200", revokedAt: undefined}); + side.module.tables.allBountyRule.upsert({ + id: "rule", accountIdentity: ACCOUNTS[0], priority: 0, filterJson: JSON.stringify(matchAll()), private: false, + value: {tag: "Flat", value: {ratioNumerator: 1n, ratioDenominator: 4n, currency: "hex-coin"}}, deletedAt: undefined, + }); + side.prism.craftMeta.upsert(craftMeta(1n, 200n, 0n, 1, 1, "Active", FRESH)); +} + +/** A craft prism first saw just now, so effort already on it when its bounty is assigned still counts. */ +const FRESH = BigInt(Date.now()) * 1000n; + +const bonuses = (writes: string[]) => writes.filter(write => write.startsWith("bonus")); + +test("an effort threshold crossed by a craft's contribution is applied on the next tick, though no craft changed", async () => { + const side = createSide({forceFull: false, seed: 1}); + bountiedCraft(side); + side.module.tables.allLoyaltyRule.upsert({ + id: 1n, payerAccountIdentity: ACCOUNTS[0], currency: "hex-coin", spec: {tag: "EffortThreshold", value: {allCurrencies: false, threshold: 100n}}, + bonusRatioNumerator: 1n, bonusRatioDenominator: 50n, updatedAt: Timestamp.now(), + }); + side.prism.craftContribution.upsert({craftId: 1n, playerId: 201n, contribution: 150}); + + const first = await side.tick(); + assert.ok(first.some(write => write.startsWith("entitle 1:201:hex-coin")), "the contribution is priced"); + assert.deepEqual(bonuses(first), [], "priced before the crossing is visible"); + + const second = await side.tick(); + assert.deepEqual(bonuses(second), [`bonus ${hex(ACCOUNTS[0])}:201:hex-coin 1/50`]); +}); + +test("an all-currencies threshold crossed in one currency updates the bonus of another currency of the same payee", async () => { + const side = createSide({forceFull: false, seed: 1}); + bountiedCraft(side); + side.prism.craftMeta.upsert(craftMeta(2n, 200n, 0n, 1, 1, "Active", FRESH)); + side.module.tables.allCraftBountyOverride.upsert({craftId: 2n, accountIdentity: ACCOUNTS[0], ratioNumerator: 1n, ratioDenominator: 4n, currency: "stelo", private: false}); + side.module.tables.allLoyaltyRule.upsert({ + id: 1n, payerAccountIdentity: ACCOUNTS[0], currency: "stelo", spec: {tag: "EffortThreshold", value: {allCurrencies: true, threshold: 100n}}, + bonusRatioNumerator: 1n, bonusRatioDenominator: 50n, updatedAt: Timestamp.now(), + }); + side.prism.craftContribution.upsert({craftId: 2n, playerId: 201n, contribution: 10}); + await side.tick(); + await side.tick(); + assert.deepEqual(bonuses(await side.tick()), [], "10 effort in stelo is below the combined threshold"); + + side.prism.craftContribution.upsert({craftId: 1n, playerId: 201n, contribution: 95}); + await side.tick(); + assert.deepEqual(bonuses(await side.tick()), [`bonus ${hex(ACCOUNTS[0])}:201:stelo 1/50`], "95 hex-coin + 10 stelo crosses 100 for the stelo rule"); +}); + +test("a claim-membership change updates the bonus of a payee with earnings and no live craft", async () => { + const side = createSide({forceFull: false, seed: 1}); + bountiedCraft(side); + side.module.tables.allLoyaltyRule.upsert({ + id: 1n, payerAccountIdentity: ACCOUNTS[0], currency: "hex-coin", spec: {tag: "ClaimMembership", value: {claimEntityId: 100n, requiredAccess: "member"}}, + bonusRatioNumerator: 1n, bonusRatioDenominator: 40n, updatedAt: Timestamp.now(), + }); + side.prism.craftContribution.upsert({craftId: 1n, playerId: 201n, contribution: 20}); + await side.tick(); + await side.tick(); + + side.prism.craftMeta.upsert(craftMeta(1n, 200n, 0n, 1, 1, "Claimed", FRESH)); + await side.tick(); + side.prism.claimMember.upsert({entityId: 0n, regionId: 0, claimEntityId: 100n, playerEntityId: 201n, build: false, inventory: false, officer: false, coOwner: false, owner: false}); + assert.deepEqual(bonuses(await side.tick()), [`bonus ${hex(ACCOUNTS[0])}:201:hex-coin 1/40`]); + + side.prism.claimMember.remove("100:201"); + assert.deepEqual(bonuses(await side.tick()), [`bonus-delete ${hex(ACCOUNTS[0])}:201:hex-coin`]); +}); + +test("a closed craft keeps its stored assignment until prism deletes the craft outright", async () => { + const side = createSide({forceFull: false, seed: 1}); + bountiedCraft(side); + assert.ok((await side.tick()).some(write => write.startsWith("assign 1 "))); + + side.prism.craftMeta.upsert(craftMeta(1n, 200n, 0n, 1, 1, "Claimed", FRESH)); + assert.deepEqual(await side.tick(), [], "the 24-hour tail leaves the assignment alone"); + + side.prism.craftMeta.remove("1"); + assert.deepEqual(await side.tick(), ["clear 1"]); +}); diff --git a/backend/brico-bot/src/app/bounty-sink.test.ts b/backend/brico-bot/src/app/bounty-sink.test.ts new file mode 100644 index 00000000..308b9702 --- /dev/null +++ b/backend/brico-bot/src/app/bounty-sink.test.ts @@ -0,0 +1,179 @@ +import type {BountyEntitlementTotal, LoyaltyRule} from "@brico/bindings/brico-app/types"; +import type {ClaimMember} from "@brico/bindings/prism/types"; +import assert from "node:assert/strict"; +import {describe, it} from "node:test"; +import {Identity, Timestamp} from "spacetimedb"; +import {assignmentBaselines, FRESH_CRAFT_WINDOW_MS, resolveAutomaticBonus} from "./bounty-sink.ts"; + +const PAYER = new Identity(1n); +const PLAYER_ID = 500n; +const CURRENCY = "hex-coin"; + +// `bonusRatioNumerator`/`bonusRatioDenominator` on a real `LoyaltyRule` is a bonus-only fraction +// ALREADY (e.g. `1/40` for +2.5%), never a multiplier — the frontend converts a typed multiplier +// like `1.025` into this shape once, before ever calling `upsertLoyaltyRule` (see +// `bonusFromMultiplier`'s doc comment). These defaults deliberately use the same bonus-fraction +// shape a real row would have, not the pre-conversion multiplier, so these tests can't silently +// pass against the double-conversion bug `resolveAutomaticBonus` once had. + +function claimMembershipRule(overrides: {claimEntityId?: bigint; requiredAccess?: string; currency?: string; bonusRatioNumerator?: bigint; bonusRatioDenominator?: bigint} = {}): LoyaltyRule { + return { + id: 1n, + payerAccountIdentity: PAYER, + currency: overrides.currency ?? CURRENCY, + spec: {tag: "ClaimMembership", value: {claimEntityId: overrides.claimEntityId ?? 100n, requiredAccess: overrides.requiredAccess ?? "member"}}, + bonusRatioNumerator: overrides.bonusRatioNumerator ?? 1n, // +2.5% + bonusRatioDenominator: overrides.bonusRatioDenominator ?? 40n, + updatedAt: Timestamp.now(), + }; +} + +function effortThresholdRule(overrides: {allCurrencies?: boolean; threshold?: bigint; currency?: string; bonusRatioNumerator?: bigint; bonusRatioDenominator?: bigint} = {}): LoyaltyRule { + return { + id: 2n, + payerAccountIdentity: PAYER, + currency: overrides.currency ?? CURRENCY, + spec: {tag: "EffortThreshold", value: {allCurrencies: overrides.allCurrencies ?? false, threshold: overrides.threshold ?? 50000n}}, + bonusRatioNumerator: overrides.bonusRatioNumerator ?? 3n, // +1.5% + bonusRatioDenominator: overrides.bonusRatioDenominator ?? 200n, + updatedAt: Timestamp.now(), + }; +} + +function entitlementTotal(playerId: bigint, currency: string, totalEffort: bigint): BountyEntitlementTotal { + return { + id: 0n, payerAccountIdentity: PAYER, payeePlayerId: playerId, currency, total: 0n, totalEffort, + remainderNumerator: 0n, remainderDenominator: 1n, updatedAt: Timestamp.now(), + }; +} + +function member(overrides: Partial> = {}): ClaimMember { + return { + entityId: 0n, regionId: 0, claimEntityId: 100n, playerEntityId: PLAYER_ID, + build: false, inventory: false, officer: false, coOwner: false, owner: false, + ...overrides, + }; +} + +describe("resolveAutomaticBonus", () => { + it("is zero with no rules", () => { + assert.deepEqual(resolveAutomaticBonus([], new Map(), new Map(), PAYER, PLAYER_ID, CURRENCY), {numerator: 0n, denominator: 1n}); + }); + + it("applies a claim-membership rule's bonus when the required flag is held", () => { + const rules = [claimMembershipRule({requiredAccess: "officer"})]; + const claimMembers = new Map([["100:500", member({officer: true})]]); + assert.deepEqual(resolveAutomaticBonus(rules, claimMembers, new Map(), PAYER, PLAYER_ID, CURRENCY), {numerator: 1n, denominator: 40n}); // +2.5% + }); + + it("regression: returns a stored bonus fraction as-is, never re-applying bonusFromMultiplier to it", () => { + // A real `loyalty_rule` row's bonusRatioNumerator/Denominator is already a bonus-only + // fraction (see the module comment above). Re-converting it here (the original bug) turns + // a stored `1/40` (+2.5%) into `(1-40)/40 = -97.5%` — exactly the wildly negative resolved + // totals reported against a real instance. + const rules = [claimMembershipRule({requiredAccess: "member", bonusRatioNumerator: 1n, bonusRatioDenominator: 40n})]; + const claimMembers = new Map([["100:500", member()]]); + const bonus = resolveAutomaticBonus(rules, claimMembers, new Map(), PAYER, PLAYER_ID, CURRENCY); + assert.ok(bonus.numerator >= 0n, "a valid rule's resolved bonus must never be negative"); + assert.deepEqual(bonus, {numerator: 1n, denominator: 40n}); + }); + + it("does not apply a claim-membership rule when the contributor holds no row at all", () => { + const rules = [claimMembershipRule({requiredAccess: "member"})]; + assert.deepEqual(resolveAutomaticBonus(rules, new Map(), new Map(), PAYER, PLAYER_ID, CURRENCY), {numerator: 0n, denominator: 1n}); + }); + + it("'member' matches a row that exists with every permission flag clear", () => { + const rules = [claimMembershipRule({requiredAccess: "member"})]; + const claimMembers = new Map([["100:500", member()]]); + assert.deepEqual(resolveAutomaticBonus(rules, claimMembers, new Map(), PAYER, PLAYER_ID, CURRENCY), {numerator: 1n, denominator: 40n}); + }); + + it("does not apply a claim-membership rule when the row exists but lacks the required flag", () => { + const rules = [claimMembershipRule({requiredAccess: "officer"})]; + const claimMembers = new Map([["100:500", member({build: true})]]); + assert.deepEqual(resolveAutomaticBonus(rules, claimMembers, new Map(), PAYER, PLAYER_ID, CURRENCY), {numerator: 0n, denominator: 1n}); + }); + + it("applies an effort-threshold rule once the same-currency total meets the threshold", () => { + const rules = [effortThresholdRule({threshold: 50000n})]; + const totals = new Map([[`${PAYER.toHexString()}:${PLAYER_ID}:${CURRENCY}`, entitlementTotal(PLAYER_ID, CURRENCY, 60000n)]]); + assert.deepEqual(resolveAutomaticBonus(rules, new Map(), totals, PAYER, PLAYER_ID, CURRENCY), {numerator: 3n, denominator: 200n}); // +1.5% + }); + + it("does not apply an effort-threshold rule below the threshold", () => { + const rules = [effortThresholdRule({threshold: 50000n})]; + const totals = new Map([[`${PAYER.toHexString()}:${PLAYER_ID}:${CURRENCY}`, entitlementTotal(PLAYER_ID, CURRENCY, 40000n)]]); + assert.deepEqual(resolveAutomaticBonus(rules, new Map(), totals, PAYER, PLAYER_ID, CURRENCY), {numerator: 0n, denominator: 1n}); + }); + + it("sums effort across every currency when allCurrencies is set", () => { + const rules = [effortThresholdRule({threshold: 50000n, allCurrencies: true})]; + const totals = new Map([ + [`${PAYER.toHexString()}:${PLAYER_ID}:hex-coin`, entitlementTotal(PLAYER_ID, "hex-coin", 30000n)], + [`${PAYER.toHexString()}:${PLAYER_ID}:other-coin`, entitlementTotal(PLAYER_ID, "other-coin", 25000n)], + ]); + // Neither currency alone clears 50000, but the sum (55000) does. + assert.deepEqual(resolveAutomaticBonus(rules, new Map(), totals, PAYER, PLAYER_ID, CURRENCY), {numerator: 3n, denominator: 200n}); + }); + + it("sums only the payee's own totals when given the payee index", () => { + const rules = [effortThresholdRule({threshold: 50000n, allCurrencies: true})]; + const other = new Identity(2n); + const mine = [`${PAYER.toHexString()}:${PLAYER_ID}:hex-coin`, `${PAYER.toHexString()}:${PLAYER_ID}:other-coin`]; + const totals = new Map([ + [mine[0], entitlementTotal(PLAYER_ID, "hex-coin", 30000n)], + [mine[1], entitlementTotal(PLAYER_ID, "other-coin", 25000n)], + [`${PAYER.toHexString()}:501:hex-coin`, entitlementTotal(501n, "hex-coin", 99999n)], + [`${other.toHexString()}:${PLAYER_ID}:hex-coin`, {...entitlementTotal(PLAYER_ID, "hex-coin", 99999n), payerAccountIdentity: other}], + ]); + const byPayee = new Map([[PLAYER_ID, new Set(mine)], [501n, new Set([`${PAYER.toHexString()}:501:hex-coin`])]]); + assert.deepEqual(resolveAutomaticBonus(rules, new Map(), totals, PAYER, PLAYER_ID, CURRENCY, byPayee), {numerator: 3n, denominator: 200n}); + assert.deepEqual(resolveAutomaticBonus(rules, new Map(), totals, PAYER, 999n, CURRENCY, byPayee), {numerator: 0n, denominator: 1n}); + }); + + it("ignores a rule for a different currency than the one being resolved", () => { + const rules = [claimMembershipRule({currency: "other-coin", requiredAccess: "member"})]; + const claimMembers = new Map([["100:500", member()]]); + assert.deepEqual(resolveAutomaticBonus(rules, claimMembers, new Map(), PAYER, PLAYER_ID, CURRENCY), {numerator: 0n, denominator: 1n}); + }); + + it("stacks several satisfied rules additively, not compounding", () => { + // +2.5% (claim) + 1.5% (50k tier) + 2% (1M tier) = +6% + const rules = [ + claimMembershipRule({requiredAccess: "officer", bonusRatioNumerator: 1n, bonusRatioDenominator: 40n}), + effortThresholdRule({threshold: 50000n, bonusRatioNumerator: 3n, bonusRatioDenominator: 200n}), + effortThresholdRule({threshold: 1000000n, bonusRatioNumerator: 1n, bonusRatioDenominator: 50n}), + ]; + const claimMembers = new Map([["100:500", member({officer: true})]]); + const totals = new Map([[`${PAYER.toHexString()}:${PLAYER_ID}:${CURRENCY}`, entitlementTotal(PLAYER_ID, CURRENCY, 1500000n)]]); + assert.deepEqual(resolveAutomaticBonus(rules, claimMembers, totals, PAYER, PLAYER_ID, CURRENCY), {numerator: 3n, denominator: 50n}); // +6% + }); +}); + +describe("assignmentBaselines", () => { + const contributions = new Map([[500n, 120n], [501n, 30n]]); + const firstSeen = 1_000_000n; + + it("protects everyone's existing effort on an established craft", () => { + const now = firstSeen + FRESH_CRAFT_WINDOW_MS + 1n; + assert.deepEqual(assignmentBaselines(contributions, firstSeen, now), [ + {playerId: 500n, effort: 120n}, + {playerId: 501n, effort: 30n}, + ]); + }); + + it("protects nothing on a brand-new craft, so effort that landed before the bot's first look still counts", () => { + assert.deepEqual(assignmentBaselines(contributions, firstSeen, firstSeen + 600n), []); + assert.deepEqual(assignmentBaselines(contributions, firstSeen, firstSeen + FRESH_CRAFT_WINDOW_MS), []); + }); + + it("includes the craft's owner like any other contributor", () => { + const now = firstSeen + FRESH_CRAFT_WINDOW_MS + 1n; + assert.equal(assignmentBaselines(new Map([[999n, 10n]]), firstSeen, now).length, 1); + }); + + it("is empty for a craft nobody has contributed to", () => { + assert.deepEqual(assignmentBaselines(undefined, firstSeen, firstSeen + 60_000n), []); + }); +}); 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..8e7098d2 --- /dev/null +++ b/backend/brico-bot/src/app/bounty-sink.ts @@ -0,0 +1,556 @@ +/** + * bounty-sink.ts — resolves and writes `craft_bounty_assignment`, `craft_bounty_entitlement` and + * `loyalty_bonus_total`. The bot is the sole writer of these tables: only it can see prism's + * craft/claim ownership *and* `brico-app`'s rules/overrides at the same time. + * + * Each step works only on what a tick's `BountyScope` says changed, reading `brico-app` state from + * the `AppCache`. Writes are fire-and-forget reducer calls diffed against the stored rows; a failed + * write is retried on the next tick. + */ +import type {DbConnection} from "@brico/bindings/brico-app"; +import type {BountyEntitlementTotal, BountyRuleValue, CraftBountyOverride, LoyaltyRule} from "@brico/bindings/brico-app/types"; +import type {ClaimMember} from "@brico/bindings/prism/types"; +import {addRatio, bonusFromMultiplier, reduceRatio} from "@brico/crafts/entitlement"; +import type {ClaimAccessFlag, CraftSubject} from "@brico/crafts/filter"; +import {claimAccessFlags} from "@brico/crafts/subject"; +import {Identity, Timestamp} from "spacetimedb"; + +import {compiledFilter} from "../compiled-filter.ts"; +import type {Logger} from "../log.ts"; +import {startStep, timeReducerCall} from "../metrics.ts"; +import type {CraftSnapshot} from "../relay/prism.ts"; +import type {RowDelta} from "../relay/row-cache.ts"; +import type {AssignedBounty, CraftRow} from "../relay/subject.ts"; +import {type AppCache, type AppDelta, type BountyRuleSpec, type Triple, tripleKey} from "./app-cache.ts"; +import type {BricoAppConnection} from "./connection.ts"; + +export 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 (!compiledFilter(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; +} + +/** + * A payee's total effort for one payer, either for one specific `currency` or (when `currency` is + * `undefined`, an `effortThreshold` rule's `allCurrencies: true`) summed across every currency that + * payer has ever bountied them in. Reads whatever `bounty_entitlement_total` last committed as of + * the start of the current tick — this tick's own in-flight `upsertCraftBountyEntitlement` calls + * haven't round-tripped back into the bot's subscribed table state yet, so this is naturally "before + * the current deltas" with no special handling needed. `totalKeysByPayee` narrows the all-currencies + * sum to the payee's own rows. + */ +function effortFor( + entitlementTotals: ReadonlyMap, + totalKeysByPayee: ReadonlyMap> | undefined, + payer: Identity, + playerId: bigint, + currency: string | undefined, +): bigint { + if (currency !== undefined) { + return entitlementTotals.get(tripleKey(payer, playerId, currency))?.totalEffort ?? 0n; + } + let sum = 0n; + const candidates = totalKeysByPayee ? [...(totalKeysByPayee.get(playerId) ?? [])].flatMap(key => entitlementTotals.get(key) ?? []) : entitlementTotals.values(); + for (const total of candidates) { + if (total.payeePlayerId === playerId && total.payerAccountIdentity.isEqual(payer)) sum += total.totalEffort; + } + return sum; +} + +/** + * Every currently-satisfied `loyalty_rule` bonus for one (payer, payee, currency), summed as an + * exact bonus fraction (see `@brico/crafts/entitlement`'s `addRatio` — several bonuses add, they + * never compound). `claimMembers` is `snapshot.claimMembers`, already loaded for *every* + * claim/player pair in the game (not narrowed to craft owners), so a claim-membership rule naming + * any claim id and any contributor is answerable with no new prism subscription. + * + * `rule.bonusRatioNumerator`/`bonusRatioDenominator` is **already a bonus-only fraction** (e.g. + * `1/40` for +2.5%) — unlike `loyalty_reward`'s `ratioNumerator`/`ratioDenominator`, which is a + * multiplier (>=1) and needs `bonusFromMultiplier` to convert it. `upsertLoyaltyRule`'s caller (the + * frontend's `RuleRow`) does that conversion once, at input time, before ever calling the reducer — + * applying it again here would double-convert (e.g. a stored `1/40` becoming `(1-40)/40 = -97.5%`), + * which is exactly the bug that produced wildly negative resolved totals before this fix. + */ +export function resolveAutomaticBonus( + rules: readonly LoyaltyRule[], + claimMembers: ReadonlyMap, + entitlementTotals: ReadonlyMap, + payer: Identity, + playerId: bigint, + currency: string, + totalKeysByPayee?: ReadonlyMap>, +): {numerator: bigint; denominator: bigint} { + let bonus = {numerator: 0n, denominator: 1n}; + for (const rule of rules) { + if (rule.currency !== currency) continue; + const satisfied = rule.spec.tag === "ClaimMembership" + ? claimAccessFlags(claimMembers.get(`${rule.spec.value.claimEntityId}:${playerId}`)) + .includes(rule.spec.value.requiredAccess as ClaimAccessFlag) + : effortFor(entitlementTotals, totalKeysByPayee, payer, playerId, rule.spec.value.allCurrencies ? undefined : currency) + >= rule.spec.value.threshold; + if (!satisfied) continue; + bonus = addRatio(bonus, {numerator: rule.bonusRatioNumerator, denominator: rule.bonusRatioDenominator}); + } + return bonus; +} + +/** + * A craft assigned a bounty within this long of prism first seeing it has all its effort counted, + * since contributions in the gap before the bot's first snapshot are not "pre-bounty". + */ +export const FRESH_CRAFT_WINDOW_MS = 5000n; + +/** + * Pre-bounty effort `assignCraftBounty` should protect (treat as unpaid) on a craft's first + * assignment: everyone's current effort, or nothing for a fresh craft. Compares prism's + * `craft_meta.firstSeen` to the bot's clock, so skew beyond the window misclassifies. + */ +export function assignmentBaselines( + byPlayer: ReadonlyMap | undefined, + firstSeenMs: bigint, + nowMs: bigint, +): {playerId: bigint; effort: bigint}[] { + if (byPlayer === undefined) return []; + if (nowMs - firstSeenMs <= FRESH_CRAFT_WINDOW_MS) return []; + return [...byPlayer].map(([playerId, effort]) => ({playerId, effort})); +} + +function sameBounty(a: ResolvedBounty, b: ResolvedBounty): boolean { + return a.ratioNumerator === b.ratioNumerator + && a.ratioDenominator === b.ratioDenominator + && a.currency === b.currency + && a.private === b.private + && a.assignedByAccountIdentity.isEqual(b.assignedByAccountIdentity); +} + +/** What one tick's steps have to look at: the prism row changes plus the `brico-app` changes. */ +export interface BountyScope { + /** Re-evaluate everything rather than only what changed (first tick, cache reload). */ + full: boolean; + /** Rows (without bounties) whose subject, owner or claim changed, or whose claim's owners changed. */ + changed: readonly CraftRow[]; + /** Crafts that left the open set. */ + removed: readonly bigint[]; + /** Crafts whose `craft_meta` row was deleted outright. */ + deleted: ReadonlySet; + /** An open craft's row (without bounty) by id; `undefined` when the craft isn't open. */ + rowOf(craftId: bigint): CraftRow | undefined; + /** Crafts whose contributions changed. */ + contributionCrafts: ReadonlySet; + /** Players whose claim membership changed. */ + memberPlayers: ReadonlySet; + app: AppDelta; +} + +/** The scope for the tick whose changes are `delta` (prism) and `app`; `baseRows` is the row cache's rows without bounties. */ +export function bountyScope(delta: RowDelta, app: AppDelta, baseRows: ReadonlyMap): BountyScope { + const changedIds = new Set(delta.changed.map(row => row.id)); + for (const craftId of delta.ownerChangedCrafts) changedIds.add(craftId.toString()); + return { + full: delta.full || app.full, + changed: [...changedIds].flatMap(id => baseRows.get(id) ?? []), + removed: delta.removedIds.map(id => BigInt(id)), + deleted: delta.deletedCrafts, + rowOf: craftId => baseRows.get(craftId.toString()), + contributionCrafts: delta.contributionCrafts, + memberPlayers: delta.memberPlayers, + app, + }; +} + +export interface AssignResult { + /** `craftId -> bounty` for the open crafts that have one; the engine mutates it in place on later ticks. */ + bounties: ReadonlyMap; + /** Crafts whose resolved bounty differs from the previous call's. */ + changed: ReadonlySet; +} + +/** The three steps run once per tick, in this order. */ +export interface BountyEngine { + /** + * Brings the bounty of every craft `scope` touches up to date against current `brico-app` state, + * writes any that differ from the stored assignments, and returns the resolved bounties — feed + * these into the row cache so `CraftSubject.payout` is live before watches are evaluated in the + * same tick. A change to the bounty rules or player-account links re-resolves every open craft; + * `rows` is only iterated then. Writes the assignment before `updateEntitlements` prices effort on it. + */ + assign(snapshot: CraftSnapshot, rows: Iterable, scope: BountyScope): AssignResult; + /** + * Prices new contributor effort into `craft_bounty_entitlement` for the crafts whose + * contributions changed or whose bounty was just assigned or changed. + */ + updateEntitlements(snapshot: CraftSnapshot, scope: BountyScope): void; + /** + * Recomputes `loyalty_bonus_total` for the (payer, payee, currency) triples known to + * `bounty_entitlement_total` whose inputs changed: their total (or another currency's total of + * the same payer and payee, for `allCurrencies` rules), the payee's claim membership, or the + * payer's loyalty rules. + */ + updateLoyaltyBonuses(snapshot: CraftSnapshot, scope: BountyScope): void; +} + +export function createBountyEngine(app: BricoAppConnection, cache: AppCache, log: Logger): BountyEngine { + const scoped = log.child("bounty"); + // Every open craft's resolved bounty, patched by `assign()`. `updateEntitlements` reads it, so + // it always follows `assign()` in the same tick (see `bridge.ts`'s `tick`). + let resolved = new Map(); + // `resolved` reflects a pass over every open craft; false until the first one, and after the connection drops. + let primed = false; + // Crafts whose bounty changed in `assign()`, waiting for `updateEntitlements` to price them. + let unpriced = new Set(); + let needFullPricing = true; + let needFullLoyalty = true; + // Work whose reducer call failed, retried next tick. + const retryAssign = new Set(); + const retryEntitlement = new Set(); + const retryBonus = new Map(); + // Triples whose bonus was already written this tick, since the stored rows haven't changed yet. + const writtenBonus = new Set(); + + const liveConnection = (): DbConnection | null => { + const conn = app.connection?.connection; + return conn?.isActive && app.isLive ? conn : null; + }; + + /** Writes (or clears) `loyalty_bonus_total` for one triple if `bonus` differs from what is stored. */ + function writeBonus(conn: DbConnection, triple: Triple, bonus: {numerator: bigint; denominator: bigint}): void { + const key = tripleKey(triple.payer, triple.payee, triple.currency); + if (writtenBonus.has(key)) return; + const previous = cache.state.loyaltyBonusTotals.get(key); + const previousBonus = previous + ? {numerator: previous.bonusRatioNumerator, denominator: previous.bonusRatioDenominator} + : {numerator: 0n, denominator: 1n}; + if (bonus.numerator === previousBonus.numerator && bonus.denominator === previousBonus.denominator) return; + if (bonus.numerator === 0n && !previous) return; + writtenBonus.add(key); + + const fields = {payerAccountIdentity: triple.payer, payeePlayerId: triple.payee, currency: triple.currency}; + const failed = (reducer: string) => (cause: unknown) => { + retryBonus.set(key, triple); + scoped.error(`${reducer} failed`, {payer: triple.payer.toHexString(), playerId: triple.payee.toString(), currency: triple.currency, error: cause instanceof Error ? cause.message : String(cause)}); + }; + if (bonus.numerator === 0n) { + timeReducerCall("delete_loyalty_bonus_total", conn.reducers.deleteLoyaltyBonusTotal(fields)).catch(failed("delete_loyalty_bonus_total")); + } else { + timeReducerCall("upsert_loyalty_bonus_total", conn.reducers.upsertLoyaltyBonusTotal({ + ...fields, bonusRatioNumerator: bonus.numerator, bonusRatioDenominator: bonus.denominator, updatedAt: Timestamp.now(), + })).catch(failed("upsert_loyalty_bonus_total")); + } + } + + return { + assign(snapshot, rows, scope) { + const conn = liveConnection(); + if (!conn) { + const previous = resolved; + resolved = new Map(); + primed = false; + needFullPricing = true; + needFullLoyalty = true; + return {bounties: resolved, changed: new Set(previous.keys())}; + } + + const state = cache.state; + const everything = !primed || scope.full || scope.app.resolveAll; + const resolve = (row: CraftRow): ResolvedBounty | undefined => resolveCraftBounty( + row.entityId, row.subject, row.ownerEntityId, row.claimEntityId, + state.playerAccounts, snapshot.claimOwners, state.bountyRules, state.overrides, + ) ?? undefined; + + let stop = startStep("assign_resolve_loop"); + const changed = new Set(); + const candidates = new Set(); + if (everything) { + const previous = resolved; + resolved = new Map(); + for (const row of rows) { + const bounty = resolve(row); + if (bounty) resolved.set(row.entityId, bounty); + } + for (const [craftId, bounty] of resolved) { + const before = previous.get(craftId); + if (!before || !sameBounty(before, bounty)) changed.add(craftId); + candidates.add(craftId); + } + for (const craftId of previous.keys()) if (!resolved.has(craftId)) changed.add(craftId); + for (const craftId of state.assignments.keys()) candidates.add(craftId); + primed = true; + } else { + const touched = new Set(); + const before = new Map(); + const update = (craftId: bigint, bounty: ResolvedBounty | undefined) => { + touched.add(craftId); + if (!before.has(craftId)) before.set(craftId, resolved.get(craftId)); + if (bounty) resolved.set(craftId, bounty); + else resolved.delete(craftId); + }; + for (const row of scope.changed) update(row.entityId, resolve(row)); + for (const craftId of scope.app.overrideCrafts) { + const row = scope.rowOf(craftId); + update(craftId, row ? resolve(row) : undefined); + } + for (const craftId of scope.removed) update(craftId, undefined); + for (const [craftId, previous] of before) { + const bounty = resolved.get(craftId); + if (!!previous !== !!bounty || (previous && bounty && !sameBounty(previous, bounty))) changed.add(craftId); + } + for (const craftId of touched) candidates.add(craftId); + for (const craftId of scope.deleted) candidates.add(craftId); + for (const craftId of scope.app.assignmentCrafts) candidates.add(craftId); + for (const craftId of retryAssign) candidates.add(craftId); + } + retryAssign.clear(); + for (const craftId of changed) unpriced.add(craftId); + stop(); + + stop = startStep("assign_write_loop"); + for (const craftId of candidates) { + const bounty = resolved.get(craftId); + const previous = state.assignments.get(craftId); + if (bounty) { + const unchanged = previous !== undefined + && previous.ratioNumerator === bounty.ratioNumerator + && previous.ratioDenominator === bounty.ratioDenominator + && previous.currency === bounty.currency + && previous.assignedByAccountIdentity.isEqual(bounty.assignedByAccountIdentity) + && previous.private === bounty.private; + if (unchanged) continue; + + const row = scope.rowOf(craftId); + 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(), + // Only a craft with no assignment as of last tick needs baselines. + baselines: previous === undefined && row + ? assignmentBaselines(snapshot.contributions.get(craftId), row.firstSeenMs, BigInt(Date.now())) + : [], + })).catch(cause => { + retryAssign.add(craftId); + scoped.error("assign_craft_bounty failed", {craftId: craftId.toString(), error: cause instanceof Error ? cause.message : String(cause)}); + }); + } else if (previous) { + // A stored assignment with no resolved bounty is cleared if its craft is open (the + // bounty went away) or gone from `craft_meta` entirely (past the 24-hour + // Claimed/Removed tail — see `isOpen` in prism.ts); one on a closed craft still in + // that tail is left alone. + if (!scope.rowOf(craftId) && snapshot.allCraftIds.has(craftId)) continue; + timeReducerCall("clear_craft_bounty", conn.reducers.clearCraftBounty({craftId})).catch(cause => { + retryAssign.add(craftId); + scoped.error("clear_craft_bounty failed", {craftId: craftId.toString(), error: cause instanceof Error ? cause.message : String(cause)}); + }); + } + } + stop(); + + return {bounties: resolved, changed}; + }, + + updateEntitlements(snapshot, scope) { + const conn = liveConnection(); + if (!conn) return; + + const state = cache.state; + const full = needFullPricing || scope.full; + needFullPricing = false; + writtenBonus.clear(); + + let crafts: Iterable; + if (full) { + crafts = [...resolved.keys()]; + } else { + crafts = new Set([...scope.contributionCrafts, ...unpriced, ...retryEntitlement]); + } + unpriced = new Set(); + retryEntitlement.clear(); + + const stop = startStep("ent_loop"); + for (const craftId of crafts) { + const bounty = resolved.get(craftId); + const byPlayer = snapshot.contributions.get(craftId); + if (!bounty || !byPlayer) continue; + for (const [playerId, effort] of byPlayer) { + // The module owns the actual conversion math (new effort at *this* ratio, plus + // whatever it's carrying in `remainderEffort`) — this call only ever hands over + // current facts (cumulative effort, current ratio), never a precomputed total, so + // a rate change here only prices effort from here on, never revalues the past. See + // `upsertCraftBountyEntitlement`'s doc comment. + const previous = state.entitlements.get(`${craftId}:${playerId}:${bounty.currency}`); + if (previous && previous.lastAssignedEffort === effort) continue; + + // Every bonus (manual assignment + every currently-satisfied automated rule) + // folds into the bounty ratio *before* the floor the module applies to new + // effort, keeping this a single floor operation per conversion. 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 payer = bounty.assignedByAccountIdentity; + const loyalty = state.loyaltyRewards.get(tripleKey(payer, playerId, bounty.currency)); + const manualBonus = loyalty ? bonusFromMultiplier(loyalty.ratioNumerator, loyalty.ratioDenominator) : {numerator: 0n, denominator: 1n}; + const automaticBonus = resolveAutomaticBonus( + state.loyaltyRules.get(payer.toHexString()) ?? [], + snapshot.claimMembers, state.entitlementTotals, payer, playerId, bounty.currency, state.totalKeysByPayee, + ); + const totalBonus = addRatio(manualBonus, automaticBonus); + let effectiveRatio = {numerator: bounty.ratioNumerator, denominator: bounty.ratioDenominator}; + if (totalBonus.numerator !== 0n) { + effectiveRatio = reduceRatio( + bounty.ratioNumerator * (totalBonus.numerator + totalBonus.denominator), + bounty.ratioDenominator * totalBonus.denominator, + ); + } + writeBonus(conn, {payer, payee: playerId, currency: bounty.currency}, automaticBonus); + + timeReducerCall("upsert_craft_bounty_entitlement", conn.reducers.upsertCraftBountyEntitlement({ + craftId, + playerId, + currency: bounty.currency, + effort, + ratioNumerator: effectiveRatio.numerator, + ratioDenominator: effectiveRatio.denominator, + updatedAt: Timestamp.now(), + })).catch(cause => { + retryEntitlement.add(craftId); + scoped.error("upsert_craft_bounty_entitlement failed", { + craftId: craftId.toString(), + playerId: playerId.toString(), + error: cause instanceof Error ? cause.message : String(cause), + }); + }); + } + } + stop(); + }, + + updateLoyaltyBonuses(snapshot, scope) { + const conn = liveConnection(); + if (!conn) return; + + const state = cache.state; + const full = needFullLoyalty || scope.full; + needFullLoyalty = false; + + const stop = startStep("loyalty_loop"); + const triples = new Map(); + const addKnown = (key: string) => { + const total = state.entitlementTotals.get(key); + if (total) triples.set(key, {payer: total.payerAccountIdentity, payee: total.payeePlayerId, currency: total.currency}); + }; + if (full) { + for (const key of state.entitlementTotals.keys()) addKnown(key); + } else { + // An `allCurrencies` threshold sums the payee's totals across currencies, so a + // change in one total can move the bonus of every currency of that payer and payee. + for (const changed of scope.app.totals.values()) { + for (const key of state.totalKeysByPayee.get(changed.payee) ?? []) { + if (state.entitlementTotals.get(key)?.payerAccountIdentity.isEqual(changed.payer)) addKnown(key); + } + } + for (const playerId of scope.memberPlayers) for (const key of state.totalKeysByPayee.get(playerId) ?? []) addKnown(key); + for (const payer of scope.app.loyaltyRulePayers) for (const key of state.totalKeysByPayer.get(payer) ?? []) addKnown(key); + } + for (const [key, triple] of retryBonus) triples.set(key, triple); + retryBonus.clear(); + + for (const triple of triples.values()) { + const bonus = resolveAutomaticBonus( + state.loyaltyRules.get(triple.payer.toHexString()) ?? [], + snapshot.claimMembers, state.entitlementTotals, triple.payer, triple.payee, triple.currency, state.totalKeysByPayee, + ); + writeBonus(conn, triple, bonus); + } + stop(); + }, + }; +} diff --git a/backend/brico-bot/src/app/connection.ts b/backend/brico-bot/src/app/connection.ts new file mode 100644 index 00000000..6d126c54 --- /dev/null +++ b/backend/brico-bot/src/app/connection.ts @@ -0,0 +1,333 @@ +/** + * 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 {appDriftTotal, startStep} from "../metrics.ts"; +import {createSupervisedConnection, type SupervisedConnection, type SupervisorOptions} from "../spacetime/connection.ts"; +import {attachFeed} from "../spacetime/feed.ts"; +import {type AppCache, findAppDrift} from "./app-cache.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): bigint; +} + +/** + * Tables/views the bridge knows how to subscribe to on `brico-app`, keyed by SQL name. + * + * `all_saved_craft_filter`/`all_craft_filter_notify_trigger` 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.count(), + }, + 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.count(), + }, + 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.count(), + }, + all_craft_filter_notify_trigger: { + query: tables.allCraftFilterNotifyTrigger, + listen: (conn, onChange) => { + conn.db.allCraftFilterNotifyTrigger.onInsert(onChange); + conn.db.allCraftFilterNotifyTrigger.onDelete(onChange); + conn.db.allCraftFilterNotifyTrigger.onUpdate(onChange); + }, + count: conn => conn.db.allCraftFilterNotifyTrigger.count(), + }, + all_discord_notify_sink: { + query: tables.allDiscordNotifySink, + listen: (conn, onChange) => { + conn.db.allDiscordNotifySink.onInsert(onChange); + conn.db.allDiscordNotifySink.onDelete(onChange); + conn.db.allDiscordNotifySink.onUpdate(onChange); + }, + count: conn => conn.db.allDiscordNotifySink.count(), + }, + all_discord_notify_target: { + query: tables.allDiscordNotifyTarget, + listen: (conn, onChange) => { + conn.db.allDiscordNotifyTarget.onInsert(onChange); + conn.db.allDiscordNotifyTarget.onDelete(onChange); + conn.db.allDiscordNotifyTarget.onUpdate(onChange); + }, + count: conn => conn.db.allDiscordNotifyTarget.count(), + }, + 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.count(), + }, + 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.count(), + }, + 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); + }, + // this is currently only used for the emptiness check to ensure service principal + // so we fake a 0 for this one table as it's always public + count: _ => 0n, // conn.db.allCraftBountyAssignment.count(), + }, + 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.count(), + }, + 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.count(), + }, + 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.count(), + }, + all_loyalty_rule: { + query: tables.allLoyaltyRule, + listen: (conn, onChange) => { + conn.db.allLoyaltyRule.onInsert(onChange); + conn.db.allLoyaltyRule.onDelete(onChange); + conn.db.allLoyaltyRule.onUpdate(onChange); + }, + count: conn => conn.db.allLoyaltyRule.count(), + }, + all_loyalty_bonus_total: { + query: tables.allLoyaltyBonusTotal, + listen: (conn, onChange) => { + conn.db.allLoyaltyBonusTotal.onInsert(onChange); + conn.db.allLoyaltyBonusTotal.onDelete(onChange); + conn.db.allLoyaltyBonusTotal.onUpdate(onChange); + }, + count: conn => conn.db.allLoyaltyBonusTotal.count(), + }, + all_bounty_entitlement_total: { + query: tables.allBountyEntitlementTotal, + listen: (conn, onChange) => { + conn.db.allBountyEntitlementTotal.onInsert(onChange); + conn.db.allBountyEntitlementTotal.onDelete(onChange); + conn.db.allBountyEntitlementTotal.onUpdate(onChange); + }, + count: conn => conn.db.allBountyEntitlementTotal.count(), + }, + all_discord_watch_display: { + query: tables.allDiscordWatchDisplay, + listen: (conn, onChange) => { + conn.db.allDiscordWatchDisplay.onInsert(onChange); + conn.db.allDiscordWatchDisplay.onDelete(onChange); + conn.db.allDiscordWatchDisplay.onUpdate(onChange); + }, + count: conn => conn.db.allDiscordWatchDisplay.count(), + }, + all_discord_guild_install: { + query: tables.allDiscordGuildInstall, + listen: (conn, onChange) => { + conn.db.allDiscordGuildInstall.onInsert(onChange); + conn.db.allDiscordGuildInstall.onDelete(onChange); + conn.db.allDiscordGuildInstall.onUpdate(onChange); + }, + count: conn => conn.db.allDiscordGuildInstall.count(), + }, +}; + +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; + /** Mirror of the bounty tables: fed from row callbacks, and replaced by a full read each time the initial subscription applies. */ + cache: AppCache; + /** How often to compare the cache against a full table read, adopting the full read on drift. 0 disables. */ + reconcileIntervalMs: number; + /** Called each time the initial subscription is applied (including after a reconnect), not on row changes. */ + onReady?(): 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; + let current: DbConnection | null = null; + const {cache} = options; + + 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); + + current = conn; + // Rows delivered with the initial subscription are covered by the full read at + // `onApplied`, so only later transactions reach the cache. + let loaded = false; + const accepts = (callbackCtx: {event: {tag: string}}) => loaded && callbackCtx.event.tag !== "SubscribeApplied"; + attachFeed(conn.db.allLinkedIntegration, cache.linkedIntegration, accepts); + attachFeed(conn.db.allBountyRule, cache.bountyRule, accepts); + attachFeed(conn.db.allCraftBountyOverride, cache.craftBountyOverride, accepts); + attachFeed(conn.db.allCraftBountyAssignment, cache.craftBountyAssignment, accepts); + attachFeed(conn.db.allPrivateCraftBountyAssignment, cache.privateCraftBountyAssignment, accepts); + attachFeed(conn.db.allCraftBountyEntitlement, cache.craftBountyEntitlement, accepts); + attachFeed(conn.db.allLoyaltyReward, cache.loyaltyReward, accepts); + attachFeed(conn.db.allLoyaltyRule, cache.loyaltyRule, accepts); + attachFeed(conn.db.allLoyaltyBonusTotal, cache.loyaltyBonusTotal, accepts); + attachFeed(conn.db.allBountyEntitlementTotal, cache.bountyEntitlementTotal, accepts); + + let subscription: SubscriptionHandle | null = conn + .subscriptionBuilder() + .onApplied(() => { + if (!ctx.isCurrent()) return; + cache.load(conn); + loaded = true; + ctx.setLive(); + options.onRowsChanged(); + options.onReady?.(); + 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) === 0n)) { + // 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 () => { + loaded = false; + subscription?.unsubscribe(); + subscription = null; + if (current === conn) current = null; + }; + }, + }, + {tokens: options.tokens, log: options.log, delayMs: options.delayMs, maxDelayMs: options.maxDelayMs}, + ); + + connection.start(); + + const reconcileTimer = options.reconcileIntervalMs > 0 + ? setInterval(() => { + if (!current?.isActive || !connection.isLive) return; + const stop = startStep("reconcile"); + const drift = findAppDrift(cache, current); + if (drift) { + appDriftTotal.inc(); + log.error("app cache drifted from the brico-app tables; adopting a full read", {tables: drift}); + cache.load(current); + options.onRowsChanged(); + } + stop(); + }, options.reconcileIntervalMs) + : null; + reconcileTimer?.unref(); + + return { + connection, + disabledReason: null, + get isLive() { + return connection.isLive; + }, + get identityHex() { + return identityHex; + }, + stop: () => { + if (reconcileTimer) clearInterval(reconcileTimer); + 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..bff6daaf --- /dev/null +++ b/backend/brico-bot/src/app/notification-sink.ts @@ -0,0 +1,55 @@ +/** + * notification-sink.ts — turns a fired watch into a `notification` row on `brico-app`, for the Toast + * sink. `event.watch.triggers` is the OR across all sinks, so this re-checks the filter's own + * toast-sink trigger row. + */ +import {Identity} from "spacetimedb"; +import type {MatchSink} from "../bridge.ts"; +import type {Logger} from "../log.ts"; +import {notificationsTotal, timeReducerCall} from "../metrics.ts"; +import type {BricoAppConnection} from "./connection.ts"; +import {findNotifyTrigger} from "./watch-source.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; + + const conn = app.connection?.connection; + if (!conn?.isActive) { + scoped.warn("dropping notification: brico-app not live", {watch: event.watch.id, kind: event.kind}); + notificationsTotal.inc({sink: "toast", kind: event.kind, outcome: "dropped"}); + return; + } + + const accountIdentity = Identity.fromString(event.watch.owner); + const trigger = findNotifyTrigger(conn, accountIdentity, event.watch.filterId, sink => sink.tag === "Toast"); + // Not enabled for this transition kind on the toast sink: matched and logged, but no notification row. + if (!trigger || !trigger[event.kind]) return; + + const craft = event.craft; + timeReducerCall("post_craft_notification", conn.reducers.postCraftNotification({ + accountIdentity, + 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, + }, + })).then(() => { + notificationsTotal.inc({sink: "toast", kind: event.kind, outcome: "ok"}); + }).catch(cause => { + notificationsTotal.inc({sink: "toast", kind: event.kind, outcome: "failed"}); + 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..1cfbfc0c --- /dev/null +++ b/backend/brico-bot/src/app/watch-source.ts @@ -0,0 +1,149 @@ +/** + * watch-source.ts — where the bridge gets the filters it evaluates. + * + * `brico-app`'s `saved_craft_filter` × `craft_filter_notify_trigger` join, + * read off the second connection, is the real source. + */ +import type {DbConnection} from "@brico/bindings/brico-app"; +import type {CraftFilterNotifyTrigger, SavedCraftFilter} 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"; + +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[]; +} + +/** + * The live trigger row for `(accountIdentity, filterId)` whose sink satisfies `matchSink` + * (e.g. `sink => sink.tag === "Toast"`); lets a `MatchSink` check its own enablement. `null` if none. + */ +export function findNotifyTrigger( + conn: DbConnection, + accountIdentity: Identity, + filterId: string, + matchSink: (sink: CraftFilterNotifyTrigger["sink"]) => boolean, +): CraftFilterNotifyTrigger | null { + for (const trigger of conn.db.allCraftFilterNotifyTrigger.iter()) { + if (trigger.deletedAt !== undefined) continue; + if (trigger.filterId !== filterId) continue; + if (!trigger.accountIdentity.isEqual(accountIdentity)) continue; + if (!matchSink(trigger.sink)) continue; + return trigger; + } + return null; +} + +/** `filterJson` as a `FilterNode`, or `null` (after a `log.warn`) when it is not valid JSON or not a valid filter. */ +function parseWatchFilter(filterId: string, filterJson: string, log: Logger): FilterNode | null { + try { + const raw: unknown = JSON.parse(filterJson); + const filter = parseFilter(raw); + if (filter) return filter; + log.warn("skipping watch: invalid filterJson", {filterId, problems: validateFilter(raw).join("; ")}); + } catch { + log.warn("skipping watch: filterJson is not valid JSON", {filterId}); + } + return null; +} + +/** + * Watches derived from `brico-app`'s live `all_saved_craft_filter` × `all_craft_filter_notify_trigger` + * join. + * + * A filter is watched if it has any non-tombstoned trigger row; its `triggers` are the OR across + * sinks. `filterJson` is untrusted, so it goes through `parseFilter` and is skipped with a + * `log.warn` on failure, never thrown. A trigger 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/triggers 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; + // Parsed filter per saved-filter id, valid while its `filterJson` is unchanged (`null` = unusable, + // already warned about). Keeps each `FilterNode`'s identity stable across snapshots. + const parsedFilters = new Map(); + return { + origin: "brico-app all_saved_craft_filter × all_craft_filter_notify_trigger", + 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); + } + + // Group by filterId so multiple sinks on one filter collapse into one WatchSpec. + const triggersByFilterId = new Map(); + for (const trigger of conn.db.allCraftFilterNotifyTrigger.iter()) { + if (trigger.deletedAt !== undefined) continue; + const bucket = triggersByFilterId.get(trigger.filterId); + if (bucket) bucket.push(trigger); + else triggersByFilterId.set(trigger.filterId, [trigger]); + } + + const specs: WatchSpec[] = []; + for (const [filterId, triggers] of triggersByFilterId) { + const savedFilter = filtersById.get(filterId); + const owner = triggers[0]?.accountIdentity; + if (!savedFilter || !owner || !savedFilter.accountIdentity.isEqual(owner)) { + log.warn("skipping watch: no matching saved filter", {filterId}); + continue; + } + + let cached = parsedFilters.get(filterId); + if (!cached || cached.json !== savedFilter.filterJson) { + cached = {json: savedFilter.filterJson, filter: parseWatchFilter(filterId, savedFilter.filterJson, log)}; + parsedFilters.set(filterId, cached); + } + const filter = cached.filter; + if (!filter) continue; + + specs.push({ + id: `watch:${filterId}`, + name: savedFilter.name, + owner: owner.toHexString(), + filterId, + triggers: { + added: triggers.some(t => t.added), + finished: triggers.some(t => t.finished), + removed: triggers.some(t => t.removed), + }, + filter, + }); + } + + for (const filterId of parsedFilters.keys()) { + if (!triggersByFilterId.has(filterId)) parsedFilters.delete(filterId); + } + + 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..d1875e29 --- /dev/null +++ b/backend/brico-bot/src/bridge.test.ts @@ -0,0 +1,226 @@ +/** + * 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 {type FilterNode, openWorkFilter} from "@brico/crafts/filter"; +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {Identity, Timestamp} from "spacetimedb"; +import {createAppCache} from "./app/app-cache.ts"; +import type {BountyEngine, BountyScope} 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"; +import {createRowCache} from "./relay/row-cache.ts"; +import type {AssignedBounty} from "./relay/subject.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: () => ({ + bounties: new Map([[1n, {ratioNumerator: 1n, ratioDenominator: 20n, currency: "hex-coin", private: false, assignedByAccountIdentity: new Identity(1n)}]]), + changed: new Set([1n]), + }), + updateEntitlements: () => {}, + updateLoyaltyBonuses: () => {}, + }; + 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"); +}); + +test("each tick hands the bounty steps the prism row changes and the brico-app changes", () => { + const scopes: BountyScope[] = []; + const watches: WatchSource = {origin: "test", watches: () => []}; + const cache = createRowCache(RECIPES); + const appCache = createAppCache(createLogger("error")); + const payer = new Identity(1n); + const bounty: BountyEngine = { + assign: (_snapshot, _rows, scope) => { + scopes.push(scope); + return {bounties: new Map(), changed: new Set()}; + }, + updateEntitlements: () => {}, + updateLoyaltyBonuses: () => {}, + }; + const bridge = createBridge({log: createLogger("error"), watches, sink: () => {}, recipes: RECIPES, cache, appCache, bounty}); + + bridge.onSnapshot(snapshot([craft()])); + cache.claimMember.insert({entityId: 0n, regionId: 0, claimEntityId: 100n, playerEntityId: 7n, build: false, inventory: false, officer: false, coOwner: false, owner: false}); + appCache.loyaltyRule.insert({ + id: 1n, payerAccountIdentity: payer, currency: "hex-coin", spec: {tag: "EffortThreshold", value: {allCurrencies: false, threshold: 10n}}, + bonusRatioNumerator: 1n, bonusRatioDenominator: 50n, updatedAt: Timestamp.now(), + }); + bridge.onTick(); + bridge.onTick(); + + assert.equal(scopes.length, 3); + assert.equal(scopes[0].full, true, "a snapshot load is a full pass"); + assert.equal(scopes[1].full, false); + assert.deepEqual([...scopes[1].memberPlayers], [7n]); + assert.deepEqual([...scopes[1].app.loyaltyRulePayers], [payer.toHexString()]); + assert.equal(scopes[2].memberPlayers.size + scopes[2].app.loyaltyRulePayers.size, 0, "drained changes are not reported twice"); +}); + +test("a private bounty is invisible to every watch but its assigner's", () => { + const hasBounty: FilterNode = {field: "payout", cmp: "gte", value: 0}; + const spec = (id: string, owner: string | null) => ({id, name: id, owner, filterId: null, triggers: {added: true, finished: true, removed: true}, filter: hasBounty}); + const watches: WatchSource = {origin: "test", watches: () => [spec("assigner", "owner-hex"), spec("other", "other-hex"), spec("ownerless", null)]}; + const bountied = new Map(); + const bounty: BountyEngine = { + assign: () => ({bounties: bountied, changed: new Set(bountied.keys())}), + updateEntitlements: () => {}, + updateLoyaltyBonuses: () => {}, + }; + const events: MatchEvent[] = []; + const bridge = createBridge({log: createLogger("error"), watches, sink: event => void events.push(event), recipes: RECIPES, bounty}); + + bridge.onSnapshot(snapshot([craft()])); + bountied.set(1n, {ratioNumerator: 1n, ratioDenominator: 20n, currency: "hex-coin", private: true, assignedByAccountIdentity: {toHexString: () => "owner-hex"}}); + bridge.onSnapshot(snapshot([craft()])); + + assert.deepEqual(events.map(e => [e.watch.id, e.kind]), [["assigner", "added"]]); + assert.equal(events[0].craft?.subject.payout, 0.05); + assert.equal(bridge.stats.matchCounts.get("other"), 0); + assert.equal(bridge.stats.matchCounts.get("ownerless"), 0); +}); + +test("a tick over cache changes reports only what those changes caused, and an edited filter re-evaluates every craft", () => { + const events: MatchEvent[] = []; + let filter: FilterNode = openWorkFilter(); + const watches: WatchSource = { + origin: "test", + watches: () => [{id: "w", name: "test watch", owner: null, filterId: null, triggers: {added: true, finished: true, removed: true}, filter}], + }; + const cache = createRowCache(RECIPES); + const bridge = createBridge({log: createLogger("error"), watches, sink: event => void events.push(event), recipes: RECIPES, cache}); + + bridge.onSnapshot(snapshot([craft()])); + cache.craftMeta.insert(craft({entityId: 2n})); + bridge.onTick(); + assert.deepEqual(events.map(e => [e.kind, e.craftId]), [["added", "2"]]); + + bridge.onTick(); + assert.equal(events.length, 1, "a tick with nothing changed reports nothing"); + + cache.craftMeta.update(craft({entityId: 2n}), craft({entityId: 2n, status: {tag: "Claimed"}} as Partial)); + bridge.onTick(); + assert.deepEqual(events.slice(1).map(e => [e.kind, e.craftId]), [["removed", "2"]]); + + filter = {field: "region", cmp: "eq", value: 999}; + bridge.onTick(); + assert.deepEqual(events.slice(2).map(e => [e.kind, e.craftId]), [["removed", "1"]]); + assert.equal(bridge.stats.matchCounts.get("w"), 0); +}); diff --git a/backend/brico-bot/src/bridge.ts b/backend/brico-bot/src/bridge.ts new file mode 100644 index 00000000..4c44aa1f --- /dev/null +++ b/backend/brico-bot/src/bridge.ts @@ -0,0 +1,288 @@ +/** + * 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. + * + * Open crafts are projected into `CraftSubject`s (`relay/subject.ts`) and kept current by the + * `RowCache`; each tick hands every watch the crafts that changed, with its 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. A watch that is new, edited, re-owned, or following a full cache reload is re-evaluated + * against every craft instead. 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 {type CompiledFilter, describeFilter} from "@brico/crafts/filter"; +import {createWatchMatcher, type MatchEvent as EngineMatchEvent, type WatchMatcher} from "@brico/crafts/watch"; +import {type AppCache, emptyAppDelta} from "./app/app-cache.ts"; +import {type BountyEngine, bountyScope} from "./app/bounty-sink.ts"; +import type {WatchSource, WatchSpec} from "./app/watch-source.ts"; +import {compiledFilter} from "./compiled-filter.ts"; + +import type {RecipeIndex} from "./game-data/recipes.ts"; +import type {Logger} from "./log.ts"; +import { + activeWatches, + bountyAssignDuration, + bountyEntitlementDuration, + currentMatches, + dirtyCraftsPerTick, + startStep, + watchEvaluationDuration, + watchMatchesTotal +} from "./metrics.ts"; +import type {CraftSnapshot} from "./relay/prism.ts"; +import {createRowCache, type RowCache, type RowDelta} from "./relay/row-cache.ts"; +import {type CraftRow, rowFor} 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 { + /** Run one tick over the changes the row cache has accumulated since the previous tick. */ + onTick(): void; + /** Replace the cache's contents with `snapshot` and run a tick that re-evaluates every watch against it. */ + 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; + /** The cache the relay's row callbacks feed; a private one is created if omitted. */ + cache?: RowCache; + /** + * 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 `tick`. + */ + bounty?: BountyEngine; + /** The `brico-app` mirror `bounty` reads; its changes are drained each tick. Without one, `bounty` sees no `brico-app` changes. */ + appCache?: AppCache; + /** Called once per tick with every open craft's resolved row (post-bounty-assignment), before watch matching. */ + onRowsComputed?(rows: CraftRow[]): void; +} + +/** Per-watch state: its matcher, plus the inputs the matcher's current match set was computed with. */ +interface WatchState { + matcher: WatchMatcher; + filter: CompiledFilter | null; + owner: string | null; + view: (row: CraftRow) => CraftRow; +} + +function mergeDeltas(first: RowDelta, second: RowDelta): RowDelta { + if (second.changed.length === 0) return {...first, full: first.full || second.full}; + const changed = new Map(first.changed.map(row => [row.id, row])); + for (const row of second.changed) changed.set(row.id, row); + return { + full: first.full || second.full, + changed: [...changed.values()], + removedIds: [...first.removedIds, ...second.removedIds], + contributionCrafts: new Set([...first.contributionCrafts, ...second.contributionCrafts]), + memberPlayers: new Set([...first.memberPlayers, ...second.memberPlayers]), + deletedCrafts: new Set([...first.deletedCrafts, ...second.deletedCrafts]), + ownerChangedCrafts: new Set([...first.ownerChangedCrafts, ...second.ownerChangedCrafts]), + }; +} + +export function createBridge(options: BridgeOptions): Bridge { + const log = options.log.child("bridge"); + const cache = options.cache ?? createRowCache(options.recipes); + const watchStates = 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); + } + + function tick(builtAtMs: number): void { + let stop = startStep("cache_drain"); + let delta = cache.drain(); + const appDelta = options.appCache?.drain() ?? emptyAppDelta(); + stop(); + + // 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. Resolution runs against the rows without bounties; a + // craft whose bounty changed comes back from the second drain with `CraftSubject.payout`/ + // `currency` applied, so the watch-matching loop below sees live data. + if (options.bounty) { + const scope = bountyScope(delta, appDelta, cache.baseRows); + const assignTimer = bountyAssignDuration.startTimer(); + const assigned = options.bounty.assign(cache.snapshot, cache.baseRows.values(), scope); + assignTimer(); + + const entitlementTimer = bountyEntitlementDuration.startTimer(); + options.bounty.updateEntitlements(cache.snapshot, scope); + entitlementTimer(); + + options.bounty.updateLoyaltyBonuses(cache.snapshot, scope); + + stop = startStep("cache_assignments"); + cache.setAssignments(assigned.bounties, assigned.changed); + delta = mergeDeltas(delta, cache.drain()); + stop(); + } + dirtyCraftsPerTick.observe(delta.changed.length); + + let allRows: CraftRow[] | undefined; + const everyRow = () => (allRows ??= [...cache.rows.values()]); + + if (options.onRowsComputed) options.onRowsComputed(everyRow()); + + stop = startStep("watch_source"); + const watches = options.watches.watches(); + stop(); + + stats.snapshots += 1; + stats.lastSnapshotAtMs = builtAtMs; + stats.lastOpenCrafts = cache.rows.size; + + 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 state = watchStates.get(watch.id); + if (!state) { + const created: WatchState = {matcher: createWatchMatcher(), filter: null, owner: null, view: row => rowFor(row, created.owner)}; + watchStates.set(watch.id, (state = created)); + } + + const filter = compiledFilter(watch.filter); + const {matcher} = state; + const wasPrimed = matcher.primed; + // The delta is only valid for a matcher whose match set was computed with this same + // filter and viewer, over the cache contents the delta is relative to. + const incremental = wasPrimed && !delta.full && state.filter === filter && state.owner === watch.owner; + state.filter = filter; + state.owner = watch.owner; + + const events = incremental + ? matcher.applyDelta(filter, delta.changed, delta.removedIds, state.view) + : matcher.update(filter, everyRow(), state.view); + for (const event of events) emit({...event, watch}); + if (!wasPrimed) { + log.info("primed", {id: watch.id, matches: matcher.matchCount, of: cache.rows.size}); + } + + 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 [...watchStates.keys()]) { + if (liveWatchIds.has(watchId)) continue; + watchStates.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)); + } + + return { + stats, + + onTick() { + tick(Date.now()); + }, + + onSnapshot(snapshot) { + cache.load(snapshot); + tick(snapshot.builtAtMs); + }, + + // 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/compiled-filter.ts b/backend/brico-bot/src/compiled-filter.ts new file mode 100644 index 00000000..89238118 --- /dev/null +++ b/backend/brico-bot/src/compiled-filter.ts @@ -0,0 +1,17 @@ +/** + * compiled-filter.ts — memoizes `compileFilter` by node identity, so a filter object that is reused + * across snapshots (a cached watch or bounty rule) is compiled once. + */ +import {type CompiledFilter, compileFilter, type FilterNode} from "@brico/crafts/filter"; + +const cache = new WeakMap(); + +/** The compiled form of `node`. `node` must not be mutated after the first call. */ +export function compiledFilter(node: FilterNode): CompiledFilter { + let compiled = cache.get(node); + if (!compiled) { + compiled = compileFilter(node); + cache.set(node, compiled); + } + return compiled; +} diff --git a/backend/brico-bot/src/config.ts b/backend/brico-bot/src/config.ts new file mode 100644 index 00000000..96555d34 --- /dev/null +++ b/backend/brico-bot/src/config.ts @@ -0,0 +1,241 @@ +/** + * 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; +} + +/** Config for the Discord bot itself (slash commands + REST delivery), separate from account linking. */ +export interface DiscordBotConfig { + /** Bot token, used to authenticate every outgoing REST call (`Authorization: Bot `). */ + token: string; + /** Ed25519 public key Discord signs every interaction payload with — verified by `verifyKey`. */ + publicKey: string; + /** Addresses the application's command-registration and interaction-followup routes. */ + applicationId: string; + /** Guild the registration script targets by default. */ + testGuildId: string | null; + /** OAuth2 client secret for the `/discord/install` callback; `null` disables only that flow. */ + installClientSecret: string | null; +} + +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 changed crafts are re-matched against the watches. */ + snapshotIntervalMs: number; + /** How often the incremental row cache is compared against a full table read. 0 disables. */ + reconcileIntervalMs: 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; + /** CDN origin that `iconAssetName` sprite paths resolve against (same as the frontend's `getAssetURL`). */ + assetCdnBase: string; + /** Host/port the BitAuth/Discord link callback HTTP server binds. */ + httpHost: string; + httpPort: number; + /** This process's public origin (e.g. `https://bot.brico.app`), used for redirect URIs and icon URLs. */ + httpBaseUrl: string; + /** 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; + /** `null` disables the Discord bot (interactions route 503s, no REST client). */ + discordBot: DiscordBotConfig | 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"), + }; +} + +/** `null` unless token, public key and application id are all set. */ +function discordBotConfig(): DiscordBotConfig | null { + const token = optionalStr("DISCORD_BOT_TOKEN"); + const publicKey = optionalStr("DISCORD_PUBLIC_KEY"); + const applicationId = optionalStr("DISCORD_APPLICATION_ID"); + if (token === null || publicKey === null || applicationId === null) return null; + return { + token, + publicKey, + applicationId, + testGuildId: optionalStr("DISCORD_TEST_GUILD_ID"), + installClientSecret: optionalStr("DISCORD_BOT_CLIENT_SECRET"), + }; +} + +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), + reconcileIntervalMs: int("BRICO_BOT_RECONCILE_INTERVAL_MS", 300_000), + 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")), + assetCdnBase: str("BRICO_ASSET_CDN_BASE", "https://cdn.brico.app"), + httpHost, + httpPort, + httpBaseUrl, + 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), + discordBot: discordBotConfig(), + }; +} diff --git a/backend/brico-bot/src/discord/commands/admin.ts b/backend/brico-bot/src/discord/commands/admin.ts new file mode 100644 index 00000000..20cdf542 --- /dev/null +++ b/backend/brico-bot/src/discord/commands/admin.ts @@ -0,0 +1,58 @@ +/** + * admin.ts — `/command-mode `: lets a server admin switch this guild between top-level + * commands and everything nested under `/brico`, re-registering the guild's commands immediately. + * Guild-only (`scope: "guild"`); the DM check in the handler only narrows the context type. + */ +import {DISCORD_COMMAND_MODE_LABELS, DISCORD_COMMAND_MODES, isDiscordCommandMode} from "@brico/crafts/discord-guild"; +import type {APIChatInputApplicationCommandInteraction} from "discord-api-types/v10"; +import {ApplicationCommandOptionType} from "discord-api-types/v10"; + +import {timeReducerCall} from "../../metrics.ts"; +import {registerGuildCommands} from "../register-guild-commands.ts"; +import {isCommandReply, requireInteractionContext, requirePermission} from "./context.ts"; +import {leafOptions, stringOption} from "./options.ts"; +import type {CommandDeps, CommandReply, LeafCommand} from "./registry.ts"; + +const MODE_OPTION_NAME = "mode"; + +async function handleCommandMode(interaction: APIChatInputApplicationCommandInteraction, deps: CommandDeps): Promise { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + if (ctx.kind === "dm") return {content: "Run this in a server, not a DM — command layout is a per-server setting."}; + + const permissionError = requirePermission(ctx); + if (permissionError) return permissionError; + + const mode = stringOption(leafOptions(interaction), MODE_OPTION_NAME); + if (!mode || !isDiscordCommandMode(mode)) return {content: "Pick flat or grouped."}; + + try { + await timeReducerCall("upsert_discord_guild_install", ctx.conn.reducers.upsertDiscordGuildInstall({guildId: ctx.guildId, commandMode: mode})); + await registerGuildCommands(deps.rest, deps.applicationId, ctx.guildId, mode); + } catch (cause) { + deps.log.warn("command-mode switch failed", {guildId: ctx.guildId, mode, error: cause instanceof Error ? cause.message : String(cause)}); + return {content: "Couldn't switch command layout — try again in a moment."}; + } + + return { + content: mode === "grouped" + ? "Commands are now grouped under `/brico` (e.g. `/brico link`, `/brico watch display`)." + : "Commands are now top-level (e.g. `/link`, `/watch display`).", + }; +} + +export const commandModeCommand: LeafCommand = { + path: ["command-mode"], + description: "Choose how brico's commands are organized in this server (admin only).", + options: [ + { + type: ApplicationCommandOptionType.String, + name: MODE_OPTION_NAME, + description: "flat (top-level commands) or grouped (under /brico)", + required: true, + choices: DISCORD_COMMAND_MODES.map(value => ({name: DISCORD_COMMAND_MODE_LABELS[value], value})), + }, + ], + scope: "guild", + handler: handleCommandMode, +}; diff --git a/backend/brico-bot/src/discord/commands/clear.ts b/backend/brico-bot/src/discord/commands/clear.ts new file mode 100644 index 00000000..4ea207ea --- /dev/null +++ b/backend/brico-bot/src/discord/commands/clear.ts @@ -0,0 +1,63 @@ +/** + * clear.ts — `/clear`, deletes the bot's own messages in the DM it's run from (`scope: "dm"`). + * + * Bulk-delete is guild-only, so messages are deleted one at a time. That can exceed Discord's + * 3-second interaction deadline, so the handler replies immediately and the sweep runs in the + * background. + */ +import {DiscordAPIError} from "@discordjs/rest"; +import type {APIChatInputApplicationCommandInteraction, APIMessage} from "discord-api-types/v10"; +import {Routes} from "discord-api-types/v10"; + +import {isCommandReply, requireInteractionContext} from "./context.ts"; +import type {CommandDeps, CommandReply, LeafCommand} from "./registry.ts"; + +const PAGE_SIZE = 100; + +async function clearOwnMessages(channelId: string, deps: CommandDeps): Promise { + // A bot user's id is its application id. + const botId = deps.applicationId; + let deleted = 0; + let before: string | undefined; + for (;;) { + const query = new URLSearchParams({limit: String(PAGE_SIZE)}); + if (before) query.set("before", before); + const page = (await deps.rest.get(Routes.channelMessages(channelId), {query})) as APIMessage[]; + if (page.length === 0) break; + + for (const message of page) { + if (message.author.id !== botId) continue; + try { + await deps.rest.delete(Routes.channelMessage(channelId, message.id)); + deleted++; + } catch (cause) { + // Already gone (e.g. a display repost raced us) — nothing to do. + if (!(cause instanceof DiscordAPIError && cause.status === 404)) throw cause; + } + } + // Pages come newest-first, so the last entry is the oldest. + before = page[page.length - 1].id; + if (page.length < PAGE_SIZE) break; + } + return deleted; +} + +async function handleClear(interaction: APIChatInputApplicationCommandInteraction, deps: CommandDeps): Promise { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + if (ctx.kind !== "dm") return {content: "This only works in a DM with the bot."}; + + const {channelId} = ctx; + void clearOwnMessages(channelId, deps).then( + deleted => deps.log.info("cleared dm messages", {channelId, deleted}), + cause => deps.log.warn("clearing dm messages failed", {channelId, error: cause instanceof Error ? cause.message : String(cause)}), + ); + return {content: "Clearing my messages from this conversation…"}; +} + +export const clearCommand: LeafCommand = { + path: ["clear"], + description: "Delete the bot's messages in this DM.", + scope: "dm", + handler: handleClear, +}; diff --git a/backend/brico-bot/src/discord/commands/context.ts b/backend/brico-bot/src/discord/commands/context.ts new file mode 100644 index 00000000..afb2d1d5 --- /dev/null +++ b/backend/brico-bot/src/discord/commands/context.ts @@ -0,0 +1,96 @@ +/** + * context.ts — shared preconditions for command and modal-submit handlers: a live `brico-app` + * connection, guild-vs-DM context, the invoker's permissions, and their linked brico account. + * + * Each `require*` function returns either the resolved value or a `CommandReply` to send straight + * back; use `isCommandReply` to tell them apart. `channelId` alone keys per-channel resources in + * both contexts (channel ids are globally unique); branch on `CommandContext.kind` only when the + * distinction matters. + */ +import type {DbConnection} from "@brico/bindings/brico-app"; +import type {APIInteractionGuildMember, APIUser} from "discord-api-types/v10"; +import {PermissionFlagsBits} from "discord-api-types/v10"; +import {Identity} from "spacetimedb"; + +import type {CommandDeps, CommandReply} from "./registry.ts"; + +/** The fields shared by chat-input and modal-submit interactions. Guild invocations carry `member` (whose `.user` is the invoker), DM invocations carry `user`. */ +export interface CommandInteractionLike { + member?: APIInteractionGuildMember; + user?: APIUser; + guild_id?: string; + channel?: {id: string}; +} + +export interface GuildCommandContext { + kind: "guild"; + member: APIInteractionGuildMember; + guildId: string; + channelId: string; + conn: DbConnection; +} + +export interface DmCommandContext { + kind: "dm"; + user: APIUser; + channelId: string; + conn: DbConnection; +} + +export type CommandContext = GuildCommandContext | DmCommandContext; + +export function isCommandReply(value: unknown): value is CommandReply { + return typeof value === "object" && value !== null && "content" in value; +} + +/** Resolves the guild/DM context and a live `brico-app` connection. */ +export function requireInteractionContext(interaction: CommandInteractionLike, deps: CommandDeps): CommandContext | CommandReply { + const channelId = interaction.channel?.id; + if (!channelId) return {content: "Couldn't tell which channel this is — try again."}; + + const conn = deps.app.connection?.connection; + if (!conn?.isActive) return {content: "brico is temporarily unavailable — try again in a moment."}; + + if (interaction.member) { + if (!interaction.guild_id) return {content: "Couldn't tell which server this is — try again."}; + return {kind: "guild", member: interaction.member, guildId: interaction.guild_id, channelId, conn}; + } + if (interaction.user) { + return {kind: "dm", user: interaction.user, channelId, conn}; + } + // Defensive: Discord always sends `member` or `user`. + return {content: "Couldn't tell who's running this command — try again."}; +} + +/** The invoking Discord user in either context. */ +export function discordUserFor(ctx: CommandContext): APIUser { + return ctx.kind === "guild" ? ctx.member.user : ctx.user; +} + +/** The invoking user's Discord id. */ +export function discordUserIdFor(ctx: CommandContext): string { + return discordUserFor(ctx).id; +} + +/** Reads the permission bitfield Discord includes on every guild interaction; no API call needed. */ +export function hasManageGuild(member: APIInteractionGuildMember): boolean { + return (BigInt(member.permissions) & PermissionFlagsBits.ManageGuild) !== 0n; +} + +/** Requires `MANAGE_GUILD` ("Manage Server") in a guild; always passes in a DM (the user's own conversation). Returns a reply on failure, `null` on success. */ +export function requirePermission(ctx: CommandContext): CommandReply | null { + if (ctx.kind === "dm") return null; + return hasManageGuild(ctx.member) ? null : {content: "You need the Manage Server permission in this server to do that here."}; +} + +/** Maps a Discord user id to the linked brico account via `linked_integration`; `null` if not linked. */ +export function resolveAccountIdentity(conn: DbConnection, discordUserId: string): Identity | null { + const link = conn.db.allLinkedIntegration.iter() + .find(l => l.provider === "discord" && l.externalId === discordUserId && l.revokedAt === undefined); + return link?.accountIdentity ?? null; +} + +export function requireLinkedAccount(conn: DbConnection, discordUserId: string): Identity | CommandReply { + const accountIdentity = resolveAccountIdentity(conn, discordUserId); + return accountIdentity ?? {content: "Link your brico account first — run `/link`."}; +} diff --git a/backend/brico-bot/src/discord/commands/index.ts b/backend/brico-bot/src/discord/commands/index.ts new file mode 100644 index 00000000..52fa2c50 --- /dev/null +++ b/backend/brico-bot/src/discord/commands/index.ts @@ -0,0 +1,24 @@ +/** + * index.ts — every registered command leaf; command registration and interaction dispatch both + * read this array. + */ +import {commandModeCommand} from "./admin.ts"; +import {clearCommand} from "./clear.ts"; +import {linkCommand} from "./link.ts"; +import {watchNotifyLinkCommand, watchNotifySetupCommand, watchNotifyUnlinkCommand} from "./notify.ts"; +import type {LeafCommand} from "./registry.ts"; +import {watchDisplayLimitCommand, watchDisplayStickyCommand} from "./watch-display-options.ts"; +import {watchDisplayCommand, watchDisplayRemoveCommand} from "./watch.ts"; + +export const commands: LeafCommand[] = [ + linkCommand, + commandModeCommand, + clearCommand, + watchDisplayCommand, + watchDisplayLimitCommand, + watchDisplayStickyCommand, + watchDisplayRemoveCommand, + watchNotifyLinkCommand, + watchNotifyUnlinkCommand, + watchNotifySetupCommand, +]; diff --git a/backend/brico-bot/src/discord/commands/link.ts b/backend/brico-bot/src/discord/commands/link.ts new file mode 100644 index 00000000..298b3954 --- /dev/null +++ b/backend/brico-bot/src/discord/commands/link.ts @@ -0,0 +1,47 @@ +/** + * link.ts — `/link`: starts bot-initiated Discord account linking via + * `beginIntegrationLinkForExternal` and replies with a redemption URL for the web app. Works in a + * guild or DM with no permission check. + */ +import type {APIChatInputApplicationCommandInteraction} from "discord-api-types/v10"; +import {randomUUID} from "node:crypto"; + +import {timeReducerCall} from "../../metrics.ts"; +import {discordUserFor, isCommandReply, requireInteractionContext} from "./context.ts"; +import type {CommandDeps, CommandReply, LeafCommand} from "./registry.ts"; + +async function handleLink(interaction: APIChatInputApplicationCommandInteraction, deps: CommandDeps): Promise { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const {conn} = ctx; + const discordUser = discordUserFor(ctx); + + const code = randomUUID(); + try { + await timeReducerCall("begin_integration_link_for_external", conn.reducers.beginIntegrationLinkForExternal({ + code, + provider: "discord", + externalId: discordUser.id, + externalHandle: discordUser.username, + })); + } catch (cause) { + deps.log.warn("begin_integration_link_for_external failed", { + discordUserId: discordUser.id, + error: cause instanceof Error ? cause.message : String(cause), + }); + return {content: "Couldn't start the link — try again in a moment."}; + } + + const redemptionUrl = `${new URL(deps.linkReturnUrl).origin}/account/link?code=${code}`; + return { + content: + `Open this link while signed in to Brico.app to finish linking your Discord account:\n<${redemptionUrl}>\n\n` + + "This link expires in 15 minutes.", + }; +} + +export const linkCommand: LeafCommand = { + path: ["link"], + description: "Link your Discord account to your Brico.app account.", + handler: handleLink, +}; diff --git a/backend/brico-bot/src/discord/commands/modal-options.ts b/backend/brico-bot/src/discord/commands/modal-options.ts new file mode 100644 index 00000000..2ec35f9c --- /dev/null +++ b/backend/brico-bot/src/discord/commands/modal-options.ts @@ -0,0 +1,57 @@ +/** + * modal-options.ts — reads field values from a `MODAL_SUBMIT` interaction, the counterpart of + * `options.ts`. Fields arrive wrapped in `Label` components and are looked up by the inner + * component's `custom_id`. + */ +import type {APIModalSubmission, ModalSubmitComponent} from "discord-api-types/v10"; +import {ComponentType} from "discord-api-types/v10"; + +/** A `MentionableSelect` field's selected user or role, resolved from the submission's own `resolved` map. */ +export interface ModalMentionable { + type: "user" | "role"; + id: string; + name: string; +} + +/** Label-wrapped fields keyed by the inner component's `custom_id`. */ +function fieldsByCustomId(submission: APIModalSubmission): Map { + const fields = new Map(); + for (const component of submission.components) { + if (component.type === ComponentType.Label) { + fields.set(component.component.custom_id, component.component); + } + } + return fields; +} + +export function modalTextInputValue(submission: APIModalSubmission, customId: string): string | undefined { + const field = fieldsByCustomId(submission).get(customId); + return field?.type === ComponentType.TextInput ? field.value : undefined; +} + +export function modalStringSelectValues(submission: APIModalSubmission, customId: string): readonly string[] { + const field = fieldsByCustomId(submission).get(customId); + return field?.type === ComponentType.StringSelect ? field.values : []; +} + +/** The selected value of a single-select string select; `undefined` if none. */ +export function modalStringSelectValue(submission: APIModalSubmission, customId: string): string | undefined { + return modalStringSelectValues(submission, customId)[0]; +} + +export function modalCheckboxGroupValues(submission: APIModalSubmission, customId: string): readonly string[] { + const field = fieldsByCustomId(submission).get(customId); + return field?.type === ComponentType.CheckboxGroup ? field.values : []; +} + +/** The selected mentionable; `undefined` if none or it isn't in `resolved`. */ +export function modalMentionableSelectValue(submission: APIModalSubmission, customId: string): ModalMentionable | undefined { + const field = fieldsByCustomId(submission).get(customId); + const id = field?.type === ComponentType.MentionableSelect ? field.values[0] : undefined; + if (!id) return undefined; + const user = submission.resolved?.users?.[id]; + if (user) return {type: "user", id, name: user.global_name ?? user.username}; + const role = submission.resolved?.roles?.[id]; + if (role) return {type: "role", id, name: role.name}; + return undefined; +} diff --git a/backend/brico-bot/src/discord/commands/notify.ts b/backend/brico-bot/src/discord/commands/notify.ts new file mode 100644 index 00000000..0f2e13be --- /dev/null +++ b/backend/brico-bot/src/discord/commands/notify.ts @@ -0,0 +1,275 @@ +/** + * notify.ts — `/watch notify-link`, `/watch notify-unlink` and `/watch notify-setup`, which manage + * Discord notification sinks (one per channel) and per-filter targets. They are hyphenated + * siblings rather than a `/watch notify` group because `LeafCommand.path` allows at most two + * segments. + * + * `notify-setup`'s modal has no room for a filter picker (4-component cap, see `watch.ts`), so the + * filter id travels in the modal `custom_id` as `` `${WATCH_NOTIFY_SETUP_MODAL_ID}:${filterId}` `` + * (matched by `resolveModalLeaf`). Per-event on/off is derived server-side by + * `attachDiscordNotifyTarget` from whether each template field was left empty. + */ +import type {DbConnection} from "@brico/bindings/brico-app"; +import type {DiscordNotifySink, DiscordNotifyTarget} from "@brico/bindings/brico-app/types"; +import {DEFAULT_NOTIFY_TEMPLATE} from "@brico/crafts/discord-notify"; +import {MAX_NOTIFY_TEMPLATE_LENGTH} from "@brico/crafts/errors"; +import type {TriggerKind} from "@brico/crafts/watch"; +import { + APIChatInputApplicationCommandInteraction, + APILabelComponent, + APIModalSubmission, + APIModalSubmitInteraction, + ApplicationCommandOptionType, + ChannelType, + ComponentType, + SelectMenuDefaultValueType, + TextInputStyle, +} from "discord-api-types/v10"; +import {randomUUID} from "node:crypto"; +import type {Identity} from "spacetimedb"; + +import {timeReducerCall} from "../../metrics.ts"; +import {discordUserIdFor, isCommandReply, requireInteractionContext, requireLinkedAccount, requirePermission} from "./context.ts"; +import {type ModalMentionable, modalMentionableSelectValue, modalTextInputValue} from "./modal-options.ts"; +import {leafOptions, mentionableOption, stringOption} from "./options.ts"; +import type {CommandDeps, CommandHandler, CommandReply, CommandShowModal, LeafCommand, ModalSubmitHandler} from "./registry.ts"; +import {findOwnSavedFilter, labelField, watchDisplayFilterAutocomplete} from "./watch.ts"; + +const FILTER_OPTION_NAME = "filter"; +const MENTIONABLE_OPTION_NAME = "mentionable"; +const WATCH_NOTIFY_SETUP_MODAL_ID = "watch_notify_setup"; +const MENTIONABLE_FIELD_ID = "mentionable"; +const TEMPLATE_FIELD_IDS: Record = {added: "added", finished: "finished", removed: "removed"}; + +/** The caller's own, non-tombstoned sink for this channel; `null` if `/watch notify-link` hasn't been run here. */ +function findOwnDiscordNotifySink(conn: DbConnection, accountIdentity: Identity, channelId: string): DiscordNotifySink | null { + return conn.db.allDiscordNotifySink.iter() + .find(sink => sink.deletedAt === undefined && sink.accountIdentity.isEqual(accountIdentity) && sink.channelId === channelId) ?? null; +} + +/** The existing `(account, filter, sink)` target, if any. */ +function findOwnDiscordNotifyTarget(conn: DbConnection, accountIdentity: Identity, filterId: string, sinkId: string): DiscordNotifyTarget | null { + return conn.db.allDiscordNotifyTarget.iter().find(target => + target.deletedAt === undefined && target.accountIdentity.isEqual(accountIdentity) && target.filterId === filterId && target.sinkId === sinkId, + ) ?? null; +} + +const handleNotifyLink: CommandHandler = async (interaction: APIChatInputApplicationCommandInteraction, deps: CommandDeps): Promise => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const permissionError = requirePermission(ctx); + if (permissionError) return permissionError; + const accountIdentity = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(accountIdentity)) return accountIdentity; + const {conn, channelId, kind} = ctx; + + const mentionable = mentionableOption(interaction, leafOptions(interaction), MENTIONABLE_OPTION_NAME); + const existing = findOwnDiscordNotifySink(conn, accountIdentity, channelId); + + const isThread = (type: ChannelType) => type === ChannelType.AnnouncementThread || type === ChannelType.PublicThread || type == ChannelType.PrivateThread; + + const channelName = kind === "guild" ? `${isThread(interaction.channel.type) ? "🧵" : "#"}${interaction.channel.name ?? interaction.channel.id}` + : /*kind === "dm" ? */`@${ctx.user.global_name ?? ctx.user.username}`; + + try { + await timeReducerCall("attach_discord_notify_sink", conn.reducers.attachDiscordNotifySink({ + id: existing?.id ?? randomUUID(), + accountIdentity, + channelId, + channelName, + defaultMentionType: mentionable?.kind ?? existing?.defaultMentionType, + defaultMentionId: mentionable ? (mentionable.kind === "user" ? mentionable.user.id : mentionable.role.id) : existing?.defaultMentionId, + defaultMentionName: mentionable + ? (mentionable.kind === "user" ? (mentionable.user.global_name ?? mentionable.user.username) : mentionable.role.name) + : existing?.defaultMentionName, + })); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + deps.log.warn("attach_discord_notify_sink failed", {error: message}); + return {content: `Couldn't link this channel: ${message}`}; + } + + return { + content: existing + ? "This channel's notification settings are updated. Run `/watch notify-setup` to configure it for a saved filter." + : "This channel is now linked for notifications. Run `/watch notify-setup` to configure it for a saved filter.", + }; +}; + +export const watchNotifyLinkCommand: LeafCommand = { + path: ["watch", "notify-link"], + description: "Link this channel as a Discord notification destination for your saved filters.", + options: [ + { + type: ApplicationCommandOptionType.Mentionable, + name: MENTIONABLE_OPTION_NAME, + description: "Default user or role to ping for notifications sent to this channel.", + required: false, + }, + ], + handler: handleNotifyLink, +}; + +const handleNotifyUnlink: CommandHandler = async (interaction: APIChatInputApplicationCommandInteraction, deps: CommandDeps): Promise => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const permissionError = requirePermission(ctx); + if (permissionError) return permissionError; + const accountIdentity = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(accountIdentity)) return accountIdentity; + const {conn, channelId} = ctx; + + const sink = findOwnDiscordNotifySink(conn, accountIdentity, channelId); + if (!sink) return {content: "This channel isn't linked for notifications."}; + + try { + await timeReducerCall("detach_discord_notify_sink", conn.reducers.detachDiscordNotifySink({id: sink.id})); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + deps.log.warn("detach_discord_notify_sink failed", {error: message}); + return {content: `Couldn't unlink this channel: ${message}`}; + } + + return {content: "This channel is no longer linked for notifications."}; +}; + +export const watchNotifyUnlinkCommand: LeafCommand = { + path: ["watch", "notify-unlink"], + description: "Unlink this channel as a Discord notification destination.", + handler: handleNotifyUnlink, +}; + +function mentionableField(mentionType: string | undefined, mentionId: string | undefined): APILabelComponent { + return labelField( + "Mentionable", + { + type: ComponentType.MentionableSelect, + custom_id: MENTIONABLE_FIELD_ID, + required: false, + min_values: 0, + max_values: 1, + default_values: mentionType === "user" + ? [{type: SelectMenuDefaultValueType.User, id: mentionId!}] + : mentionType === "role" + ? [{type: SelectMenuDefaultValueType.Role, id: mentionId!}] + : undefined, + }, + "Who to ping — leave empty for no mention.", + ); +} + +function templateField(kind: TriggerKind, label: string, value: string): APILabelComponent { + return labelField( + label, + { + type: ComponentType.TextInput, + custom_id: TEMPLATE_FIELD_IDS[kind], + style: TextInputStyle.Short, + required: false, + max_length: MAX_NOTIFY_TEMPLATE_LENGTH, + value, + }, + undefined, + ); +} + +/** Prefills default wording for a new pair; an existing pair shows its stored state, with an empty field meaning that event is disabled. */ +function buildNotifySetupModal(filterId: string, sink: DiscordNotifySink, existing: DiscordNotifyTarget | null): CommandShowModal { + const mentionType = existing?.mentionType ?? sink.defaultMentionType; + const mentionId = existing?.mentionId ?? sink.defaultMentionId; + + return { + modal: { + custom_id: `${WATCH_NOTIFY_SETUP_MODAL_ID}:${filterId}`, + title: "Setup craft watch notifications", + components: [ + mentionableField(mentionType, mentionId), + templateField("added", "When a craft now matches", existing ? (existing.addedTemplate ?? "") : DEFAULT_NOTIFY_TEMPLATE.added), + templateField("finished", "When a craft finishes", existing ? (existing.finishedTemplate ?? "") : DEFAULT_NOTIFY_TEMPLATE.finished), + templateField("removed", "When a craft no longer matches", existing ? (existing.removedTemplate ?? "") : DEFAULT_NOTIFY_TEMPLATE.removed), + ], + }, + }; +} + +const handleNotifySetup: CommandHandler = async (interaction: APIChatInputApplicationCommandInteraction, deps: CommandDeps): Promise => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const permissionError = requirePermission(ctx); + if (permissionError) return permissionError; + const accountIdentity = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(accountIdentity)) return accountIdentity; + const {conn, channelId} = ctx; + + const sink = findOwnDiscordNotifySink(conn, accountIdentity, channelId); + if (!sink) return {content: "This channel isn't linked yet — run `/watch notify-link` first."}; + + const filterId = stringOption(leafOptions(interaction), FILTER_OPTION_NAME); + const filter = filterId ? findOwnSavedFilter(conn, accountIdentity, filterId) : undefined; + if (!filter) return {content: "That filter is no longer available — pick it again."}; + + const existing = findOwnDiscordNotifyTarget(conn, accountIdentity, filter.id, sink.id); + return buildNotifySetupModal(filter.id, sink, existing); +}; + +const handleNotifySetupSubmit: ModalSubmitHandler = async (interaction: APIModalSubmitInteraction, deps: CommandDeps): Promise => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const permissionError = requirePermission(ctx); + if (permissionError) return permissionError; + const accountIdentity = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(accountIdentity)) return accountIdentity; + const {conn, channelId} = ctx; + + const sink = findOwnDiscordNotifySink(conn, accountIdentity, channelId); + if (!sink) return {content: "This channel isn't linked yet — run `/watch notify-link` first."}; + + const filterId = interaction.data.custom_id.slice(`${WATCH_NOTIFY_SETUP_MODAL_ID}:`.length); + const filter = findOwnSavedFilter(conn, accountIdentity, filterId); + if (!filter) return {content: "That filter is no longer available — run `/watch notify-setup` again."}; + + const submission: APIModalSubmission = interaction.data; + const mentionable: ModalMentionable | undefined = modalMentionableSelectValue(submission, MENTIONABLE_FIELD_ID); + const addedTemplate = modalTextInputValue(submission, TEMPLATE_FIELD_IDS.added) ?? ""; + const finishedTemplate = modalTextInputValue(submission, TEMPLATE_FIELD_IDS.finished) ?? ""; + const removedTemplate = modalTextInputValue(submission, TEMPLATE_FIELD_IDS.removed) ?? ""; + + try { + await timeReducerCall("attach_discord_notify_target", conn.reducers.attachDiscordNotifyTarget({ + id: randomUUID(), + accountIdentity, + filterId: filter.id, + discordSinkId: sink.id, + addedTemplate, + finishedTemplate, + removedTemplate, + mentionType: mentionable?.type, + mentionId: mentionable?.id, + mentionName: mentionable?.name, + })); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + deps.log.warn("attach_discord_notify_target failed", {error: message}); + return {content: `Couldn't set up that notification: ${message}`}; + } + + return {content: `Discord notifications for "${filter.name}" are set up in this channel.`}; +}; + +export const watchNotifySetupCommand: LeafCommand = { + path: ["watch", "notify-setup"], + description: "Configure Discord notifications for a saved filter in this channel.", + options: [ + { + type: ApplicationCommandOptionType.String, + name: FILTER_OPTION_NAME, + description: "Which saved filter to configure notifications for.", + required: true, + autocomplete: true, + }, + ], + handler: handleNotifySetup, + autocomplete: watchDisplayFilterAutocomplete, + modalCustomId: WATCH_NOTIFY_SETUP_MODAL_ID, + modalSubmit: handleNotifySetupSubmit, +}; diff --git a/backend/brico-bot/src/discord/commands/options.ts b/backend/brico-bot/src/discord/commands/options.ts new file mode 100644 index 00000000..c501d024 --- /dev/null +++ b/backend/brico-bot/src/discord/commands/options.ts @@ -0,0 +1,51 @@ +/** + * options.ts — reads argument values from a chat-input or autocomplete interaction. + */ +import type {APIApplicationCommandInteractionDataOption, APIRole, APIUser} from "discord-api-types/v10"; +import {ApplicationCommandOptionType} from "discord-api-types/v10"; + +import type {CommandLikeInteraction} from "./registry.ts"; + +/** The leaf's argument options, below any Subcommand/SubcommandGroup nesting. */ +export function leafOptions(interaction: CommandLikeInteraction): readonly APIApplicationCommandInteractionDataOption[] { + let options = interaction.data?.options; + while (options && options.length === 1) { + const [option] = options; + if (option.type !== ApplicationCommandOptionType.Subcommand && option.type !== ApplicationCommandOptionType.SubcommandGroup) break; + options = "options" in option ? option.options : undefined; + } + return options ?? []; +} + +export function stringOption(options: readonly APIApplicationCommandInteractionDataOption[], name: string): string | undefined { + const option = options.find(o => o.name === name); + return option && option.type === ApplicationCommandOptionType.String ? option.value : undefined; +} + +export function integerOption(options: readonly APIApplicationCommandInteractionDataOption[], name: string): number | undefined { + const option = options.find(o => o.name === name); + if (!option || option.type !== ApplicationCommandOptionType.Integer) return undefined; + // Autocomplete interactions deliver integers as strings. + return typeof option.value === "string" ? Number(option.value) : option.value; +} + +/** The option being typed into during autocomplete; `undefined` otherwise. */ +export function focusedOptionName(options: readonly APIApplicationCommandInteractionDataOption[]): string | undefined { + return options.find(o => "focused" in o && o.focused)?.name; +} + +/** A resolved `MENTIONABLE` option — either a user or a role, never both. */ +export type ResolvedMentionable = {kind: "user"; user: APIUser} | {kind: "role"; role: APIRole}; + +/** A `MENTIONABLE` option's value is a snowflake; the user or role object comes from `data.resolved`. `undefined` if unset or unresolved. */ +export function mentionableOption(interaction: CommandLikeInteraction, options: readonly APIApplicationCommandInteractionDataOption[], name: string): ResolvedMentionable | undefined { + const option = options.find(o => o.name === name); + if (!option || option.type !== ApplicationCommandOptionType.Mentionable) return undefined; + const id = option.value; + const resolved = interaction.data?.resolved; + const user = resolved?.users?.[id]; + if (user) return {kind: "user", user}; + const role = resolved?.roles?.[id]; + if (role) return {kind: "role", role}; + return undefined; +} diff --git a/backend/brico-bot/src/discord/commands/registry.test.ts b/backend/brico-bot/src/discord/commands/registry.test.ts new file mode 100644 index 00000000..dca784bd --- /dev/null +++ b/backend/brico-bot/src/discord/commands/registry.test.ts @@ -0,0 +1,125 @@ +/** + * registry.test.ts — round-trip tests for `buildCommandTree`/`resolveCommand` in both modes. + */ +import type {APIChatInputApplicationCommandInteraction} from "discord-api-types/v10"; +import {ApplicationCommandOptionType, InteractionType} from "discord-api-types/v10"; +import assert from "node:assert/strict"; +import {test} from "node:test"; + +import {commands} from "./index.ts"; +import type {LeafCommand} from "./registry.ts"; +import {buildCommandTree, leavesForScope, resolveCommand, resolveModalLeaf} from "./registry.ts"; + +const noop: LeafCommand["handler"] = async () => ({content: "ok"}); + +const LEAVES: LeafCommand[] = [ + {path: ["link"], description: "Link your Discord account.", handler: noop}, + {path: ["watch", "display"], description: "Show a live craft watch.", handler: noop}, + {path: ["watch", "notify"], description: "Get notified when a watch fires.", handler: noop}, +]; + +/** Builds the `options` chain Discord would send for a command invoked as `fullPath.join(" ")` — + * the last remaining segment is always a bare `Subcommand`, anything before it a `SubcommandGroup` + * wrapping the next segment (matches Discord's own two-level nesting limit). */ +function buildOptions(remaining: string[]): any[] | undefined { + if (remaining.length === 0) return undefined; + const [head, ...rest] = remaining; + if (rest.length === 0) return [{type: ApplicationCommandOptionType.Subcommand, name: head}]; + return [{type: ApplicationCommandOptionType.SubcommandGroup, name: head, options: buildOptions(rest)}]; +} + +function interactionFor(fullPath: string[]): APIChatInputApplicationCommandInteraction { + const [name, ...remaining] = fullPath; + return { + type: InteractionType.ApplicationCommand, + data: {name, options: buildOptions(remaining)}, + } as unknown as APIChatInputApplicationCommandInteraction; +} + +test("flat mode: a lone leaf becomes a bare command", () => { + const tree = buildCommandTree([LEAVES[0]], "flat"); + assert.equal(tree.length, 1); + assert.equal(tree[0].name, "link"); + assert.equal(tree[0].options, undefined); +}); + +test("flat mode: sibling leaves under one path segment become subcommands", () => { + const tree = buildCommandTree(LEAVES, "flat"); + const names = tree.map(c => c.name).sort(); + assert.deepEqual(names, ["link", "watch"]); + + const watch = tree.find(c => c.name === "watch")!; + const subNames = (watch.options ?? []).map((o: any) => o.name).sort(); + assert.deepEqual(subNames, ["display", "notify"]); + for (const option of watch.options ?? []) { + assert.equal((option as any).type, ApplicationCommandOptionType.Subcommand); + } +}); + +test("grouped mode: everything nests under one top-level command", () => { + const tree = buildCommandTree(LEAVES, "grouped", "brico"); + assert.equal(tree.length, 1); + assert.equal(tree[0].name, "brico"); + + const topNames = (tree[0].options ?? []).map((o: any) => o.name).sort(); + assert.deepEqual(topNames, ["link", "watch"]); + + const watchGroup = (tree[0].options ?? []).find((o: any) => o.name === "watch") as any; + assert.equal(watchGroup.type, ApplicationCommandOptionType.SubcommandGroup); + assert.deepEqual(watchGroup.options.map((o: any) => o.name).sort(), ["display", "notify"]); +}); + +test("resolveCommand finds a leaf via a flat-mode-shaped interaction", () => { + const interaction = interactionFor(["watch", "display"]); + const found = resolveCommand(LEAVES, interaction); + assert.equal(found?.path.join("."), "watch.display"); +}); + +test("resolveCommand finds the same leaf via a grouped-mode-shaped interaction", () => { + const interaction = interactionFor(["brico", "watch", "display"]); + const found = resolveCommand(LEAVES, interaction, "brico"); + assert.equal(found?.path.join("."), "watch.display"); +}); + +test("resolveCommand returns null for an unregistered command", () => { + const interaction = interactionFor(["nope"]); + assert.equal(resolveCommand(LEAVES, interaction), null); +}); + +test("buildCommandTree rejects a path deeper than two segments", () => { + const deep: LeafCommand[] = [{path: ["a", "b", "c"], description: "x", handler: noop}]; + assert.throws(() => buildCommandTree(deep, "flat")); +}); + +test("resolveModalLeaf matches an exact custom_id", () => { + const leaves: LeafCommand[] = [{path: ["watch", "display"], description: "x", handler: noop, modalCustomId: "watch_display_setup"}]; + assert.equal(resolveModalLeaf(leaves, "watch_display_setup"), leaves[0]); +}); + +test("resolveModalLeaf matches a `modalCustomId:context` suffix, for a modal with no room for its own context field", () => { + const leaves: LeafCommand[] = [{path: ["watch", "notify-setup"], description: "x", handler: noop, modalCustomId: "watch_notify_setup"}]; + assert.equal(resolveModalLeaf(leaves, "watch_notify_setup:filter-abc-123"), leaves[0]); +}); + +test("resolveModalLeaf does not match an unrelated custom_id sharing only a prefix", () => { + const leaves: LeafCommand[] = [{path: ["watch", "display"], description: "x", handler: noop, modalCustomId: "watch_display_setup"}]; + assert.equal(resolveModalLeaf(leaves, "watch_display_setup_other"), null); +}); + +test("leavesForScope keeps unscoped leaves in both scopes and scoped leaves in only their own", () => { + const leaves: LeafCommand[] = [ + {path: ["both"], description: "x", handler: noop}, + {path: ["guild-only"], description: "x", handler: noop, scope: "guild"}, + {path: ["dm-only"], description: "x", handler: noop, scope: "dm"}, + ]; + assert.deepEqual(leavesForScope(leaves, "guild").map(l => l.path[0]), ["both", "guild-only"]); + assert.deepEqual(leavesForScope(leaves, "dm").map(l => l.path[0]), ["both", "dm-only"]); +}); + +test("the real registered command set builds cleanly in both modes for each scope", () => { + for (const scope of ["guild", "dm"] as const) { + const scoped = leavesForScope(commands, scope); + assert.doesNotThrow(() => buildCommandTree(scoped, "flat")); + assert.doesNotThrow(() => buildCommandTree(scoped, "grouped")); + } +}); diff --git a/backend/brico-bot/src/discord/commands/registry.ts b/backend/brico-bot/src/discord/commands/registry.ts new file mode 100644 index 00000000..2ce0c984 --- /dev/null +++ b/backend/brico-bot/src/discord/commands/registry.ts @@ -0,0 +1,256 @@ +/** + * registry.ts — the command tree: leaf commands addressed by a path of 1-2 segments, serialized into + * Discord's registration JSON (`buildCommandTree`) and resolved back from an incoming interaction + * (`resolveCommand`). `"flat"` mode registers top-level commands; `"grouped"` nests everything under + * `/brico`. Resolution works for either mode. + */ +import type {DiscordCommandMode} from "@brico/crafts/discord-guild"; +import type {REST} from "@discordjs/rest"; +import type { + APIApplicationCommandAutocompleteInteraction, + APIApplicationCommandOption, + APIApplicationCommandSubcommandGroupOption, + APIApplicationCommandSubcommandOption, + APIChatInputApplicationCommandInteraction, + APIModalInteractionResponseCallbackData, + APIModalSubmitInteraction, + RESTPostAPIChatInputApplicationCommandsJSONBody, +} from "discord-api-types/v10"; +import {ApplicationCommandOptionType, ApplicationCommandType} from "discord-api-types/v10"; +import type {BricoAppConnection} from "../../app/connection.ts"; +import type {Logger} from "../../log.ts"; + +/** Everything a command handler needs beyond the interaction payload itself. */ +export interface CommandDeps { + log: Logger; + app: BricoAppConnection; + /** `new URL(linkReturnUrl).origin` is the frontend's own origin — used to build links back to it (e.g. `/account/link`). */ + linkReturnUrl: string; + /** The shared `@discordjs/rest` client. */ + rest: REST; + applicationId: string; +} + +export interface CommandReply { + content: string; + /** Defaults to `true` — almost every reply here carries something personal (a link code, an account state). */ + ephemeral?: boolean; +} + +/** Shows a modal as the interaction response instead of a reply (the two are mutually exclusive). */ +export interface CommandShowModal { + modal: APIModalInteractionResponseCallbackData; +} + +export type CommandHandler = ( + interaction: APIChatInputApplicationCommandInteraction, + deps: CommandDeps, +) => Promise; + +/** Returns at most 25 choices, Discord's autocomplete cap. */ +export type AutocompleteHandler = ( + interaction: APIApplicationCommandAutocompleteInteraction, + deps: CommandDeps, +) => Promise<{name: string; value: string}[]>; + +/** Handles the `MODAL_SUBMIT` interaction that follows a `handler` returning `CommandShowModal` with this same `modalCustomId`. */ +export type ModalSubmitHandler = ( + interaction: APIModalSubmitInteraction, + deps: CommandDeps, +) => Promise; + +export type CommandScope = "guild" | "dm"; + +/** The leaves that belong in `scope`'s registration — those with no `scope` of their own, or a matching one. */ +export function leavesForScope(leaves: LeafCommand[], scope: CommandScope): LeafCommand[] { + return leaves.filter(leaf => leaf.scope === undefined || leaf.scope === scope); +} + +export interface LeafCommand { + /** E.g. `["link"]` or `["watch", "display"]`; 1-2 segments (see `assertValidPath`). */ + path: string[]; + description: string; + /** Basic (non-subcommand) options for this leaf, e.g. a string/user/mentionable argument. */ + options?: APIApplicationCommandSubcommandOption["options"]; + handler: CommandHandler; + /** Where this leaf is registered: `"guild"`, `"dm"` (the global BotDM set), or both when omitted. Filtered by `leavesForScope`. */ + scope?: CommandScope; + /** Handles `APPLICATION_COMMAND_AUTOCOMPLETE` interactions for this leaf, if any of its options are `autocomplete: true`. */ + autocomplete?: AutocompleteHandler; + /** The `custom_id` of the modal `handler` shows; submissions are routed by it (`resolveModalLeaf`), so it must be unique across leaves. */ + modalCustomId?: string; + modalSubmit?: ModalSubmitHandler; +} + +/** Chat-input and autocomplete interactions share the same `data.name`/`data.options` shape. */ +export type CommandLikeInteraction = APIChatInputApplicationCommandInteraction | APIApplicationCommandAutocompleteInteraction; + +const DEFAULT_GROUP_NAME = "brico"; + +function assertValidPath(path: string[]): void { + if (path.length < 1 || path.length > 2) { + throw new Error( + `command path must be 1-2 segments, got ${JSON.stringify(path)} — Discord's own nesting ` + + `limit (command → optional subcommand-group → subcommand) leaves no room for more once ` + + `"grouped" mode adds its own wrapping segment`, + ); + } +} + +interface RelativeLeaf { + relPath: string[]; + leaf: LeafCommand; +} + +function groupByFirstSegment(entries: RelativeLeaf[]): Map { + const byFirstSegment = new Map(); + for (const entry of entries) { + const key = entry.relPath[0]; + const bucket = byFirstSegment.get(key); + if (bucket) bucket.push(entry); + else byFirstSegment.set(key, [entry]); + } + return byFirstSegment; +} + +/** Like `buildOptionsAt`, but every entry must be a bare subcommand (the deepest allowed level, inside a group). */ +function buildSubOptions(entries: RelativeLeaf[]): APIApplicationCommandSubcommandOption[] { + const options: APIApplicationCommandSubcommandOption[] = []; + for (const [name, bucket] of groupByFirstSegment(entries)) { + if (bucket.length !== 1 || bucket[0].relPath.length !== 1) { + throw new Error(`"${name}" needs a subcommand group here, but this helper only ever produces bare subcommands — check buildOptionsAt`); + } + const {leaf} = bucket[0]; + options.push({ + type: ApplicationCommandOptionType.Subcommand, + name, + description: leaf.description, + options: leaf.options, + }); + } + return options; +} + +/** Groups `entries` by first path segment into subcommands/groups. `depth` counts nesting levels already consumed; exceeding Discord's two-level limit throws. */ +function buildOptionsAt(entries: RelativeLeaf[], depth: number): APIApplicationCommandOption[] { + const options: APIApplicationCommandOption[] = []; + for (const [name, bucket] of groupByFirstSegment(entries)) { + if (bucket.length === 1 && bucket[0].relPath.length === 1) { + const {leaf} = bucket[0]; + options.push({ + type: ApplicationCommandOptionType.Subcommand, + name, + description: leaf.description, + options: leaf.options, + }); + continue; + } + + if (depth + 1 >= 2) { + throw new Error(`command group "${name}" needs a third nesting level, which Discord does not support — shorten one of its leaves' paths`); + } + const group: APIApplicationCommandSubcommandGroupOption = { + type: ApplicationCommandOptionType.SubcommandGroup, + name, + description: `${name} commands`, + options: buildSubOptions( + bucket.map(entry => ({relPath: entry.relPath.slice(1), leaf: entry.leaf})), + ), + }; + options.push(group); + } + return options; +} + +/** Same union `upsertDiscordGuildInstall` validates `commandMode` against. */ +export type CommandTreeMode = DiscordCommandMode; + +/** + * Serializes `leaves` into the body for a `PUT /applications/{id}/guilds/{guildId}/commands` call. + * `"flat"` gives each first path segment its own top-level command (`/link`, `/watch display`); + * `"grouped"` nests everything under `groupName` (`/brico link`, `/brico watch display`). + */ +export function buildCommandTree( + leaves: LeafCommand[], + mode: CommandTreeMode, + groupName: string = DEFAULT_GROUP_NAME, +): RESTPostAPIChatInputApplicationCommandsJSONBody[] { + for (const leaf of leaves) assertValidPath(leaf.path); + + if (mode === "grouped") { + return [ + { + type: ApplicationCommandType.ChatInput, + name: groupName, + description: "Brico.app commands.", + options: buildOptionsAt(leaves.map(leaf => ({relPath: leaf.path, leaf})), 0), + }, + ]; + } + + const byTopName = groupByFirstSegment(leaves.map(leaf => ({relPath: leaf.path, leaf}))); + const commands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; + for (const [name, bucket] of byTopName) { + if (bucket.length === 1 && bucket[0].relPath.length === 1) { + const {leaf} = bucket[0]; + commands.push({ + type: ApplicationCommandType.ChatInput, + name, + description: leaf.description, + options: leaf.options, + }); + continue; + } + if (bucket.some(entry => entry.relPath.length === 1)) { + throw new Error(`"${name}" has both a bare leaf and subcommand leaves sharing the same top-level name — ambiguous, rename one`); + } + // The top-level name is consumed, so recurse over the remaining segments. + commands.push({ + type: ApplicationCommandType.ChatInput, + name, + description: `${name} commands`, + options: buildOptionsAt( + bucket.map(entry => ({relPath: entry.relPath.slice(1), leaf: entry.leaf})), + 0, + ), + }); + } + return commands; +} + +/** Finds the leaf for an interaction's invoked path, as-is (flat) or with a leading `groupName` stripped (grouped). */ +export function resolveCommand( + leaves: LeafCommand[], + interaction: CommandLikeInteraction, + groupName: string = DEFAULT_GROUP_NAME, +): LeafCommand | null { + const data = interaction.data; + if (!data || typeof data.name !== "string") return null; + + const segments: string[] = [data.name]; + let options = data.options; + while (options && options.length === 1) { + const option = options[0]; + if (option.type !== ApplicationCommandOptionType.Subcommand && option.type !== ApplicationCommandOptionType.SubcommandGroup) break; + segments.push(option.name); + options = "options" in option ? option.options : undefined; + } + + const byPath = new Map(leaves.map(leaf => [leaf.path.join("."), leaf] as const)); + const full = byPath.get(segments.join(".")); + if (full) return full; + + if (segments[0] === groupName) { + const stripped = byPath.get(segments.slice(1).join(".")); + if (stripped) return stripped; + } + return null; +} + +/** + * Finds the leaf for a `MODAL_SUBMIT` by `custom_id`. Matches `modalCustomId` exactly or as a + * `` `${modalCustomId}:${context}` `` prefix, so a modal can carry context (e.g. a filter id) in its id. + */ +export function resolveModalLeaf(leaves: LeafCommand[], customId: string): LeafCommand | null { + return leaves.find(leaf => leaf.modalCustomId === customId || (leaf.modalCustomId !== undefined && customId.startsWith(`${leaf.modalCustomId}:`))) ?? null; +} diff --git a/backend/brico-bot/src/discord/commands/watch-display-options.ts b/backend/brico-bot/src/discord/commands/watch-display-options.ts new file mode 100644 index 00000000..4bda698c --- /dev/null +++ b/backend/brico-bot/src/discord/commands/watch-display-options.ts @@ -0,0 +1,123 @@ +/** + * watch-display-options.ts — `/watch display-limit` and `/watch display-sticky`: change `limit` / + * `stickyMinutes` on an existing display (the setup modal has no fields for them). Siblings of + * `/watch display` rather than children, since a name can't be both a bare subcommand and a group. + * Only edits an existing `discord_watch_display` row; it never creates one. + */ +import {MAX_DISPLAY_ROWS_HARD_CAP, MAX_STICKY_MINUTES, MIN_STICKY_MINUTES} from "@brico/crafts/discord-display"; +import type {APIChatInputApplicationCommandInteraction} from "discord-api-types/v10"; +import {ApplicationCommandOptionType} from "discord-api-types/v10"; + +import {timeReducerCall} from "../../metrics.ts"; +import {discordUserIdFor, isCommandReply, requireInteractionContext, requireLinkedAccount, requirePermission} from "./context.ts"; +import {integerOption, leafOptions, stringOption} from "./options.ts"; +import type {CommandDeps, CommandHandler, CommandReply, LeafCommand} from "./registry.ts"; +import {findExistingDisplay, findOwnSavedFilter, watchDisplayFilterAutocomplete} from "./watch.ts"; + +const FILTER_OPTION_NAME = "filter"; + +interface WatchDisplayOptionSpec { + path: string[]; + commandDescription: string; + optionName: string; + optionDescription: string; + field: "limit" | "stickyMinutes"; + minValue: number; + maxValue: number; + formatSuccess(filterName: string, value: number): string; +} + +function makeWatchDisplayOptionCommand(spec: WatchDisplayOptionSpec): LeafCommand { + const handler: CommandHandler = async (interaction: APIChatInputApplicationCommandInteraction, deps: CommandDeps): Promise => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const permissionError = requirePermission(ctx); + if (permissionError) return permissionError; + const accountIdentity = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(accountIdentity)) return accountIdentity; + const {conn, channelId} = ctx; + + const options = leafOptions(interaction); + const filterId = stringOption(options, FILTER_OPTION_NAME); + const value = integerOption(options, spec.optionName); + if (!filterId || value === undefined) return {content: "Choose a filter and a value."}; + + const filter = findOwnSavedFilter(conn, accountIdentity, filterId); + if (!filter) return {content: "That filter is no longer available — pick it again."}; + + const existing = findExistingDisplay(conn, accountIdentity, filterId, channelId); + if (!existing) return {content: `There's no watch display for "${filter.name}" in this channel yet — run \`/watch display\` first.`}; + + try { + await timeReducerCall("attach_discord_watch_display", conn.reducers.attachDiscordWatchDisplay({ + id: existing.id, + accountIdentity, + filterId: existing.filterId, + channelId: existing.channelId, + content: existing.content, + style: existing.style, + sortField: existing.sortField, + sortDirection: existing.sortDirection, + limit: spec.field === "limit" ? value : existing.limit, + stickyMinutes: spec.field === "stickyMinutes" ? value : existing.stickyMinutes, + refreshIntervalSeconds: existing.refreshIntervalSeconds, + shareCode: existing.shareCode, + })); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + deps.log.warn("attach_discord_watch_display failed", {error: message}); + return {content: `Couldn't update that: ${message}`}; + } + + return {content: spec.formatSuccess(filter.name, value)}; + }; + + return { + path: spec.path, + description: spec.commandDescription, + options: [ + { + type: ApplicationCommandOptionType.String, + name: FILTER_OPTION_NAME, + description: "Which watch display to edit (by its saved filter).", + required: true, + autocomplete: true, + }, + { + type: ApplicationCommandOptionType.Integer, + name: spec.optionName, + description: spec.optionDescription, + required: true, + min_value: spec.minValue, + max_value: spec.maxValue, + }, + ], + handler, + autocomplete: watchDisplayFilterAutocomplete, + }; +} + +export const watchDisplayLimitCommand: LeafCommand = makeWatchDisplayOptionCommand({ + path: ["watch", "display-limit"], + commandDescription: "Change how many crafts a watch display shows at once.", + optionName: "cap", + optionDescription: "Maximum number of crafts to show.", + field: "limit", + minValue: 1, + maxValue: MAX_DISPLAY_ROWS_HARD_CAP, + formatSuccess: (filterName, value) => `Watch display for "${filterName}" now shows up to ${value} craft${value === 1 ? "" : "s"}.`, +}); + +export const watchDisplayStickyCommand: LeafCommand = makeWatchDisplayOptionCommand({ + path: ["watch", "display-sticky"], + commandDescription: "Change how often a watch display reposts itself to stay at the bottom of the channel.", + optionName: "minutes", + optionDescription: "Minutes between reposts (0 disables reposting).", + field: "stickyMinutes", + minValue: MIN_STICKY_MINUTES, + maxValue: MAX_STICKY_MINUTES, + formatSuccess: (filterName, value) => + value === 0 + ? `Watch display for "${filterName}" no longer reposts itself.` + : `Watch display for "${filterName}" now reposts itself every ${value} minute${value === 1 ? "" : "s"}.`, +}); diff --git a/backend/brico-bot/src/discord/commands/watch.ts b/backend/brico-bot/src/discord/commands/watch.ts new file mode 100644 index 00000000..694e6f02 --- /dev/null +++ b/backend/brico-bot/src/discord/commands/watch.ts @@ -0,0 +1,360 @@ +/** + * watch.ts — `/watch display` and `/watch display-remove`. `/watch display` opens a setup modal + * (filter, style, field presets, sort) whose submit writes `attachDiscordWatchDisplay`; the + * optional `filter` argument only chooses which existing display prefills it. Posting and editing + * the message is `discord/display-manager.ts`'s job. + * + * **A modal holds 4 usable top-level components, despite the docs saying 5.** A 5th `Label` makes + * Discord silently drop the interaction response ("the application didn't respond"), with nothing + * to catch server-side. Sort field and direction are therefore one `SORT_FIELD_ID` select with + * `":"` values, and `limit`/`stickyMinutes` have no modal field (see + * `watch-display-options.ts`). + */ +import type {DbConnection} from "@brico/bindings/brico-app"; +import type {DiscordWatchDisplay} from "@brico/bindings/brico-app/types"; +import { + DEFAULT_DISPLAY_REFRESH_SECONDS, + DISCORD_DISPLAY_FIELD_PRESET_KEYS, + DISCORD_DISPLAY_FIELD_PRESETS, + DISCORD_DISPLAY_SORT_DIRECTION_LABELS, + DISCORD_DISPLAY_SORT_DIRECTIONS, + DISCORD_DISPLAY_SORT_FIELD_LABELS, + DISCORD_DISPLAY_SORT_FIELDS, + DISCORD_DISPLAY_STYLE_LABELS, + DISCORD_DISPLAY_STYLES, + type DiscordDisplayFieldPresetKey, + isDiscordDisplayFieldPresetKey, + isDiscordDisplaySortDirection, + isDiscordDisplaySortField, + isDiscordDisplayStyle, +} from "@brico/crafts/discord-display"; +import type { + APIApplicationCommandAutocompleteInteraction, + APIChatInputApplicationCommandInteraction, + APIComponentInLabel, + APILabelComponent, + APIModalSubmission, + APIModalSubmitInteraction, + APISelectMenuOption, +} from "discord-api-types/v10"; +import {ApplicationCommandOptionType, ComponentType} from "discord-api-types/v10"; +import {randomUUID} from "node:crypto"; +import type {Identity} from "spacetimedb"; + +import {timeReducerCall} from "../../metrics.ts"; +import {discordUserIdFor, isCommandReply, requireInteractionContext, requireLinkedAccount, requirePermission, resolveAccountIdentity} from "./context.ts"; +import {modalCheckboxGroupValues, modalStringSelectValue} from "./modal-options.ts"; +import {leafOptions, stringOption} from "./options.ts"; +import type {AutocompleteHandler, CommandDeps, CommandHandler, CommandReply, CommandShowModal, LeafCommand} from "./registry.ts"; + +const WATCH_DISPLAY_MODAL_ID = "watch_display_setup"; +/** The slash-command option name; `FILTER_FIELD_ID` below is the modal field's `custom_id`. */ +const FILTER_OPTION_NAME = "filter"; +const FILTER_FIELD_ID = "filter"; +const DISPLAY_OPTION_NAME = "display"; +const STYLE_FIELD_ID = "style"; +const PRESETS_FIELD_ID = "presets"; +/** Combined sort field + direction; option values are `":"`. */ +const SORT_FIELD_ID = "sort"; + +/** Defaults for a brand-new display; an edit keeps the existing row's values. */ +const DEFAULT_LIMIT = 10; +const DEFAULT_STICKY_MINUTES = 0; + +function sortOptionValue(field: string, direction: string): string { + return `${field}:${direction}`; +} + +/** Splits `":"`; `null` if either half is missing (validity of each half is checked by the caller). */ +function parseSortOptionValue(value: string): {field: string; direction: string} | null { + const [field, direction] = value.split(":"); + return field && direction ? {field, direction} : null; +} + +/** The caller's own non-tombstoned saved filters. Scans the table because `all_saved_craft_filter` is only indexed on `id`. */ +export function ownSavedFilters(conn: DbConnection, accountIdentity: Identity) { + return conn.db.allSavedCraftFilter.iter().filter(saved => saved.deletedAt === undefined && saved.accountIdentity.isEqual(accountIdentity)); +} + +/** One of the caller's own non-tombstoned saved filters by id; `undefined` if it's missing, deleted, or someone else's. */ +export function findOwnSavedFilter(conn: DbConnection, accountIdentity: Identity, filterId: string) { + const saved = conn.db.allSavedCraftFilter.id.find(filterId); + return saved && saved.deletedAt === undefined && saved.accountIdentity.isEqual(accountIdentity) ? saved : undefined; +} + +/** Any existing display for this `(account, channel)`, regardless of filter; used to prefill the modal when no `filter` argument is given. */ +function findExistingDisplayForChannel(conn: DbConnection, accountIdentity: Identity, channelId: string): DiscordWatchDisplay | null { + return conn.db.allDiscordWatchDisplay.iter() + .find(display => display.deletedAt === undefined && display.accountIdentity.isEqual(accountIdentity) && display.channelId === channelId) ?? null; +} + +/** The existing display for this exact `(account, filter, channel)`, if any. */ +export function findExistingDisplay(conn: DbConnection, accountIdentity: Identity, filterId: string, channelId: string): DiscordWatchDisplay | null { + return conn.db.allDiscordWatchDisplay.iter().find(display => + display.deletedAt === undefined && display.accountIdentity.isEqual(accountIdentity) && display.filterId === filterId && display.channelId === channelId, + ) ?? null; +} + +/** Autocomplete for a `filter` option: the caller's saved filters matching the typed text. */ +export const watchDisplayFilterAutocomplete: AutocompleteHandler = async (interaction: APIApplicationCommandAutocompleteInteraction, deps: CommandDeps) => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return [{name: "brico is temporarily unavailable — try again in a moment.", value: "unavailable"}]; + + const accountIdentity = resolveAccountIdentity(ctx.conn, discordUserIdFor(ctx)); + if (accountIdentity === null) { + // Autocomplete responses can only carry choices, so report this as one. + return [{name: "Link your brico account first — run /link", value: "unlinked"}]; + } + + const typed = (stringOption(leafOptions(interaction), FILTER_OPTION_NAME) ?? "").toLowerCase(); + return ownSavedFilters(ctx.conn, accountIdentity) + .filter(saved => saved.name.toLowerCase().includes(typed)) + .take(25) + .map(saved => ({name: saved.name, value: saved.id})) + .toArray(); +}; + +/** Every non-tombstoned display in the channel, whoever created it. */ +function channelDisplays(conn: DbConnection, channelId: string) { + return conn.db.allDiscordWatchDisplay.iter().filter(display => display.deletedAt === undefined && display.channelId === channelId); +} + +/** Autocomplete for `/watch display-remove`: the channel's existing displays, labeled by their filter's name; the value is the display id. */ +const watchDisplayRemoveAutocomplete: AutocompleteHandler = async (interaction: APIApplicationCommandAutocompleteInteraction, deps: CommandDeps) => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return [{name: "brico is temporarily unavailable — try again in a moment.", value: "unavailable"}]; + + if (resolveAccountIdentity(ctx.conn, discordUserIdFor(ctx)) === null) { + return [{name: "Link your brico account first — run /link", value: "unlinked"}]; + } + + const typed = (stringOption(leafOptions(interaction), DISPLAY_OPTION_NAME) ?? "").toLowerCase(); + return channelDisplays(ctx.conn, ctx.channelId) + .map(display => ({name: ctx.conn.db.allSavedCraftFilter.id.find(display.filterId)?.name ?? "deleted filter", value: display.id})) + .filter(choice => choice.name.toLowerCase().includes(typed)) + .take(25) + .toArray(); +}; + +export function labelField(label: string, component: APIComponentInLabel, description?: string): APILabelComponent { + return {type: ComponentType.Label, label, description, component}; +} + +function selectOptions(values: readonly T[], labels: Record, selected: T | undefined): APISelectMenuOption[] { + return values.map(value => ({value, label: labels[value], default: value === selected})); +} + +/** Every sort field x direction as one select's options (10, under Discord's 25-option cap). */ +function sortOptions(selectedField: string | undefined, selectedDirection: string | undefined): APISelectMenuOption[] { + const options: APISelectMenuOption[] = []; + for (const field of DISCORD_DISPLAY_SORT_FIELDS) { + for (const direction of DISCORD_DISPLAY_SORT_DIRECTIONS) { + options.push({ + value: sortOptionValue(field, direction), + label: `${DISCORD_DISPLAY_SORT_FIELD_LABELS[field]} — ${DISCORD_DISPLAY_SORT_DIRECTION_LABELS[direction]}`, + default: field === selectedField && direction === selectedDirection, + }); + } + } + return options; +} + +function presetsField(selected: readonly DiscordDisplayFieldPresetKey[]): APILabelComponent { + return labelField( + "Field presets", + { + type: ComponentType.CheckboxGroup, + custom_id: PRESETS_FIELD_ID, + required: false, + min_values: 0, + max_values: DISCORD_DISPLAY_FIELD_PRESET_KEYS.length, + options: DISCORD_DISPLAY_FIELD_PRESET_KEYS.map(key => ({ + value: key, + label: DISCORD_DISPLAY_FIELD_PRESETS[key].label, + default: selected.includes(key), + })), + }, + "Composed into the display in a fixed order, regardless of the order checked here.", + ); +} + +/** Builds the setup modal, prefilled from `existing` when set. */ +function buildSetupModal( + filterOptions: readonly APISelectMenuOption[], + existing: DiscordWatchDisplay | null, +): CommandShowModal { + const existingPresets = existing?.content.tag === "Presets" ? (existing.content.value.presets as DiscordDisplayFieldPresetKey[]) : DISCORD_DISPLAY_FIELD_PRESET_KEYS; + + return { + modal: { + custom_id: WATCH_DISPLAY_MODAL_ID, + title: "Setup craft watch display", + components: [ + labelField("Filter", {type: ComponentType.StringSelect, custom_id: FILTER_FIELD_ID, min_values: 1, max_values: 1, options: [...filterOptions]}), + labelField("Style", {type: ComponentType.StringSelect, custom_id: STYLE_FIELD_ID, min_values: 1, max_values: 1, options: selectOptions(DISCORD_DISPLAY_STYLES, DISCORD_DISPLAY_STYLE_LABELS, existing?.style as (typeof DISCORD_DISPLAY_STYLES)[number] | undefined)}), + presetsField(existingPresets), + labelField("Sort by", {type: ComponentType.StringSelect, custom_id: SORT_FIELD_ID, min_values: 1, max_values: 1, options: sortOptions(existing?.sortField, existing?.sortDirection)}), + ], + }, + }; +} + +async function handleWatchDisplay(interaction: APIChatInputApplicationCommandInteraction, deps: CommandDeps): Promise { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const permissionError = requirePermission(ctx); + if (permissionError) return permissionError; + const accountIdentity = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(accountIdentity)) return accountIdentity; + + // Discord's cap on select menu options. + const savedFilters = ownSavedFilters(ctx.conn, accountIdentity).take(25).toArray(); + if (savedFilters.length === 0) { + return {content: "You don't have any saved filters yet — save one on the Craft Browser first, then run this again."}; + } + + // An explicit `filter` prefills from that exact `(filter, channel)` display and pre-selects it + // even when no display exists yet; without one, prefill from any display in the channel. + const requestedFilterId = stringOption(leafOptions(interaction), FILTER_OPTION_NAME); + const existing = requestedFilterId + ? findExistingDisplay(ctx.conn, accountIdentity, requestedFilterId, ctx.channelId) + : findExistingDisplayForChannel(ctx.conn, accountIdentity, ctx.channelId); + const selectedFilterId = requestedFilterId ?? existing?.filterId; + const filterOptions = savedFilters.map(saved => ({label: saved.name, value: saved.id, default: selectedFilterId === saved.id})); + + return buildSetupModal(filterOptions, existing); +} + +async function handleWatchDisplaySubmit(interaction: APIModalSubmitInteraction, deps: CommandDeps): Promise { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const permissionError = requirePermission(ctx); + if (permissionError) return permissionError; + const accountIdentity = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(accountIdentity)) return accountIdentity; + const {conn, channelId} = ctx; + + const submission: APIModalSubmission = interaction.data; + + const filterId = modalStringSelectValue(submission, FILTER_FIELD_ID); + const filter = filterId ? findOwnSavedFilter(conn, accountIdentity, filterId) : undefined; + if (!filter) return {content: "That filter is no longer available — run `/watch display` again."}; + + const style = modalStringSelectValue(submission, STYLE_FIELD_ID); + if (!style || !isDiscordDisplayStyle(style)) return {content: "Choose a style."}; + + const sortValue = modalStringSelectValue(submission, SORT_FIELD_ID); + const parsedSort = sortValue ? parseSortOptionValue(sortValue) : null; + if (!parsedSort || !isDiscordDisplaySortField(parsedSort.field) || !isDiscordDisplaySortDirection(parsedSort.direction)) { + return {content: "Choose a sort."}; + } + const {field: sortField, direction: sortDirection} = parsedSort; + + const presets = modalCheckboxGroupValues(submission, PRESETS_FIELD_ID).filter(isDiscordDisplayFieldPresetKey); + + // Reuse the existing `shareCode` (minting a new one per edit would orphan `shared_filter` rows + // and change the link), and keep `limit`/`stickyMinutes`, which the modal can't edit. + const existing = findExistingDisplay(conn, accountIdentity, filter.id, channelId); + const limit = existing?.limit ?? DEFAULT_LIMIT; + const stickyMinutes = existing?.stickyMinutes ?? DEFAULT_STICKY_MINUTES; + let shareCode = existing?.shareCode; + if (shareCode === undefined) { + try { + const result = await timeReducerCall( + "create_shared_filters", + conn.procedures.createSharedFilters({filterIds: [filter.id], accountIdentity}), + ); + shareCode = result.code; + } catch (cause) { + // Best-effort: the display works without a share link. + deps.log.warn("create_shared_filters failed; display will have no share link", { + error: cause instanceof Error ? cause.message : String(cause), + }); + } + } + + try { + await timeReducerCall("attach_discord_watch_display", conn.reducers.attachDiscordWatchDisplay({ + id: randomUUID(), + accountIdentity, + filterId: filter.id, + channelId, + content: {tag: "Presets", value: {presets}}, + style, + sortField, + sortDirection, + limit, + stickyMinutes, + refreshIntervalSeconds: existing?.refreshIntervalSeconds ?? DEFAULT_DISPLAY_REFRESH_SECONDS, + shareCode, + })); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + deps.log.warn("attach_discord_watch_display failed", {error: message}); + return {content: `Couldn't set up that display: ${message}`}; + } + + return {content: `Watch display for "${filter.name}" is set up in this channel.`}; +} + +export const watchDisplayCommand: LeafCommand = { + path: ["watch", "display"], + description: "Set up a live-updating message of a saved filter's current matches in this channel.", + options: [ + { + type: ApplicationCommandOptionType.String, + name: FILTER_OPTION_NAME, + description: "Which saved filter to prefill the setup modal from, if this channel already watches more than one.", + required: false, + autocomplete: true, + }, + ], + handler: handleWatchDisplay, + autocomplete: watchDisplayFilterAutocomplete, + modalCustomId: WATCH_DISPLAY_MODAL_ID, + modalSubmit: handleWatchDisplaySubmit, +}; + +/** `/watch display-remove` — tombstones the `(account, filter, channel)` display. `display-manager.ts` also does this when the channel returns 403/404. */ +const handleWatchDisplayRemove: CommandHandler = async (interaction: APIChatInputApplicationCommandInteraction, deps: CommandDeps): Promise => { + const ctx = requireInteractionContext(interaction, deps); + if (isCommandReply(ctx)) return ctx; + const permissionError = requirePermission(ctx); + if (permissionError) return permissionError; + const accountIdentity = requireLinkedAccount(ctx.conn, discordUserIdFor(ctx)); + if (isCommandReply(accountIdentity)) return accountIdentity; + const {conn, channelId} = ctx; + + const displayId = stringOption(leafOptions(interaction), DISPLAY_OPTION_NAME); + const found = displayId ? conn.db.allDiscordWatchDisplay.id.find(displayId) : undefined; + const existing = found && found.deletedAt === undefined && found.channelId === channelId ? found : undefined; + if (!existing) return {content: "That watch display no longer exists in this channel — pick it again."}; + const filter = conn.db.allSavedCraftFilter.id.find(existing.filterId); + const filterName = filter?.name ?? "deleted filter"; + + try { + await timeReducerCall("detach_discord_watch_display", conn.reducers.detachDiscordWatchDisplay({id: existing.id})); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + deps.log.warn("detach_discord_watch_display failed", {error: message}); + return {content: `Couldn't remove that display: ${message}`}; + } + + return {content: `Watch display for "${filterName}" disabled.`}; +}; + +export const watchDisplayRemoveCommand: LeafCommand = { + path: ["watch", "display-remove"], + description: "Remove a watch display from this channel.", + options: [ + { + type: ApplicationCommandOptionType.String, + name: DISPLAY_OPTION_NAME, + description: "Which watch display in this channel to remove (by its saved filter).", + required: true, + autocomplete: true, + }, + ], + handler: handleWatchDisplayRemove, + autocomplete: watchDisplayRemoveAutocomplete, +}; diff --git a/backend/brico-bot/src/discord/display-format.test.ts b/backend/brico-bot/src/discord/display-format.test.ts new file mode 100644 index 00000000..3244afe0 --- /dev/null +++ b/backend/brico-bot/src/discord/display-format.test.ts @@ -0,0 +1,335 @@ +/** display-format.test.ts — filter/sort/render logic for watch display messages. */ +import {MAX_DISPLAY_ROWS_HARD_CAP} from "@brico/crafts/discord-display"; +import type {CraftSubject, FilterNode} from "@brico/crafts/filter"; +import {matchAll} from "@brico/crafts/filter"; +import type { + APIActionRowComponent, + APIButtonComponentWithURL, + APIContainerComponent, + APISectionComponent, + APITextDisplayComponent, + APIThumbnailComponent +} from "discord-api-types/v10"; +import {ComponentType} from "discord-api-types/v10"; +import assert from "node:assert/strict"; +import {test} from "node:test"; +import type {RecipeDisplayIndex} from "../game-data/recipes.ts"; +import type {SkillNameIndex} from "../game-data/skills.ts"; +import type {CraftRow} from "../relay/subject.ts"; +import type {DiscordWatchDisplayContentValue} from "./display-format.ts"; +import {buildDisplayComponents, selectDisplayRows} from "./display-format.ts"; + +function subject(overrides: Partial = {}): CraftSubject { + return { + region: 14, + claim: null, + item: null, + itemTag: null, + inputItems: [], + skill: null, + buildingType: null, + tier: 2, + effortTotal: 100, + effortRemaining: 40, + public: true, + complete: false, + owner: null, + ownerClaimAccess: null, + payout: null, + currency: null, + bountyPrivate: false, + ...overrides, + }; +} + +function row(overrides: Partial = {}): CraftRow { + return { + id: "1", + entityId: 1n, + claimEntityId: 0n, + ownerEntityId: 0n, + regionId: 14, + regionName: "Lumethis (R14)", + recipeId: 7, + count: 1, + claimName: null, + ownerName: null, + firstSeenMs: 0n, + subject: subject(), + privateBountyOwner: null, + publicView: null, + ...overrides, + }; +} + +/** A row whose bounty is private to `owner`, with the public view the real row builder attaches. */ +function privateBountyRow(id: string, owner: string, payout: number): CraftRow { + const full = subject({payout, currency: "hex-coin", bountyPrivate: true}); + const base = row({id}); + return { + ...base, + subject: full, + privateBountyOwner: owner, + publicView: {...base, subject: {...full, payout: null, currency: null, bountyPrivate: false}}, + }; +} + +const PRESETS_CONTENT: DiscordWatchDisplayContentValue = {tag: "Presets", value: {presets: ["effortProgress"]}}; +const BOT_HTTP_BASE_URL = "https://bot.brico.app"; +const ASSET_CDN_BASE = "https://cdn.brico.app"; +const FRONTEND_ORIGIN = "https://brico.app"; +const NO_SKILLS: SkillNameIndex = new Map(); + +function build( + content: DiscordWatchDisplayContentValue, + style: "compact" | "simple" | "icon", + rows: CraftRow[], + totalMatches: number, + recipeNames: RecipeDisplayIndex = new Map(), + shareCode: string | undefined = undefined, +) { + return buildDisplayComponents( + "Open smithing work", + content, + style, + rows, + totalMatches, + recipeNames, + NO_SKILLS, + FRONTEND_ORIGIN, + BOT_HTTP_BASE_URL, + ASSET_CDN_BASE, + shareCode, + ); +} + +test("selectDisplayRows lets a private bounty's owner match, sort and render it", () => { + const rows = [privateBountyRow("a", "viewer-hex", 0.5), row({id: "b", subject: subject({payout: 0.1, currency: "hex-coin"})})]; + const hasBounty: FilterNode = {field: "payout", cmp: "gte", value: 0}; + const selected = selectDisplayRows(rows, hasBounty, "viewer-hex", "bounty", "desc", 10); + assert.deepEqual(selected.rows.map(r => [r.id, r.subject.payout]), [["a", 0.5], ["b", 0.1]]); +}); + +test("selectDisplayRows hides a private bounty from everyone else without dropping the craft", () => { + const rows = [privateBountyRow("a", "owner-hex", 0.5), row({id: "b", subject: subject({payout: 0.1, currency: "hex-coin"})})]; + + const bountied = selectDisplayRows(rows, {field: "payout", cmp: "gte", value: 0}, "viewer-hex", "bounty", "desc", 10); + assert.deepEqual(bountied.rows.map(r => r.id), ["b"], "a payout filter never matches the hidden bounty"); + + const everything = selectDisplayRows(rows, matchAll(), "viewer-hex", "bounty", "desc", 10); + const hidden = everything.rows.find(r => r.id === "a"); + assert.equal(hidden?.subject.payout, null); + assert.equal(hidden?.subject.currency, null); + assert.equal(hidden?.subject.bountyPrivate, false); + + const anonymous = selectDisplayRows(rows, matchAll(), null, "bounty", "desc", 10); + assert.equal(anonymous.rows.find(r => r.id === "a")?.subject.payout, null); +}); + +test("selectDisplayRows filters, sorts ascending by remaining effort, and caps at limit", () => { + const rows = [ + row({id: "a", subject: subject({effortRemaining: 30})}), + row({id: "b", subject: subject({effortRemaining: 10})}), + row({id: "c", subject: subject({effortRemaining: 20})}), + ]; + const selected = selectDisplayRows(rows, matchAll(), null, "remaining", "asc", 2); + assert.deepEqual(selected.rows.map(r => r.id), ["b", "c"]); + assert.equal(selected.totalMatches, 3); +}); + +test("selectDisplayRows sorts descending when asked", () => { + const rows = [ + row({id: "a", subject: subject({effortRemaining: 30})}), + row({id: "b", subject: subject({effortRemaining: 10})}), + row({id: "c", subject: subject({effortRemaining: 20})}), + ]; + const selected = selectDisplayRows(rows, matchAll(), null, "remaining", "desc", 10); + assert.deepEqual(selected.rows.map(r => r.id), ["a", "c", "b"]); +}); + +test("selectDisplayRows sorts by newest (firstSeen)", () => { + const rows = [ + row({id: "old", firstSeenMs: 1000n}), + row({id: "new", firstSeenMs: 5000n}), + row({id: "mid", firstSeenMs: 3000n}), + ]; + const selected = selectDisplayRows(rows, matchAll(), null, "newest", "desc", 10); + assert.deepEqual(selected.rows.map(r => r.id), ["new", "mid", "old"]); +}); + +test("selectDisplayRows sorts by total effort", () => { + const rows = [ + row({id: "small", subject: subject({effortTotal: 10})}), + row({id: "big", subject: subject({effortTotal: 100})}), + ]; + const selected = selectDisplayRows(rows, matchAll(), null, "effort", "asc", 10); + assert.deepEqual(selected.rows.map(r => r.id), ["small", "big"]); +}); + +test("selectDisplayRows sorts by bounty, treating no-bounty as lowest", () => { + const rows = [ + row({id: "none", subject: subject({payout: null})}), + row({id: "low", subject: subject({payout: 0.1, currency: "hex-coin"})}), + row({id: "high", subject: subject({payout: 0.9, currency: "hex-coin"})}), + ]; + const selected = selectDisplayRows(rows, matchAll(), null, "bounty", "desc", 10); + assert.deepEqual(selected.rows.map(r => r.id), ["high", "low", "none"]); +}); + +test("selectDisplayRows only returns rows the filter actually matches, and totalMatches reflects that too", () => { + const rows = [row({id: "public", subject: subject({public: true})}), row({id: "private", subject: subject({public: false})})]; + const publicOnly = {field: "public" as const, cmp: "eq" as const, value: true}; + const selected = selectDisplayRows(rows, publicOnly, null, "newest", "asc", 10); + assert.deepEqual(selected.rows.map(r => r.id), ["public"]); + assert.equal(selected.totalMatches, 1); +}); + +test("buildDisplayComponents shows a placeholder text display for no matches", () => { + const components = build(PRESETS_CONTENT, "compact", [], 0); + const texts = components.filter((c): c is APITextDisplayComponent => c.type === ComponentType.TextDisplay); + assert.ok(texts.some(t => t.content.includes("No matching crafts right now"))); +}); + +test("buildDisplayComponents' header names the filter and reports the shown count", () => { + const components = build(PRESETS_CONTENT, "compact", [row()], 1); + const header = components[0] as APITextDisplayComponent; + assert.equal(header.type, ComponentType.TextDisplay); + assert.match(header.content, /Open smithing work/); + assert.match(header.content, /1 craft shown/); +}); + +test("compact style joins every rendered row into one TextDisplay with no separators", () => { + const components = build(PRESETS_CONTENT, "compact", [row({id: "a"}), row({id: "b"})], 2); + assert.equal(components.length, 2); + assert.equal(components.filter(c => c.type === ComponentType.Separator).length, 0); + const body = components[1] as APITextDisplayComponent; + assert.match(body.content, /tools\/crafts\/a/); + assert.match(body.content, /tools\/crafts\/b/); +}); + +test("simple style wraps each row in a tier-accented Container, no separators", () => { + const components = build(PRESETS_CONTENT, "simple", [row({id: "a", subject: subject({tier: 4})})], 1); + const container = components.find((c): c is APIContainerComponent => c.type === ComponentType.Container)!; + assert.ok(container, "expected a Container for the one matching row"); + assert.equal(container.accent_color, parseInt("49619C", 16)); + assert.equal(components.filter(c => c.type === ComponentType.Separator).length, 0); +}); + +test("simple style omits accent_color for an untiered craft", () => { + const components = build(PRESETS_CONTENT, "simple", [row({subject: subject({tier: null})})], 1); + const container = components.find((c): c is APIContainerComponent => c.type === ComponentType.Container)!; + assert.equal(container.accent_color, undefined); +}); + +test("icon style renders one Section per row with an icon thumbnail, no separators", () => { + const components = build( + PRESETS_CONTENT, + "icon", + [row({id: "42", recipeId: 7, ownerName: "Alice", subject: subject({effortTotal: 100, effortRemaining: 40, tier: 3})})], + 1, + new Map([[7, {name: "Plank", icon: {shape: "item" as const, id: 501}}]]), + ); + const section = components.find((c): c is APISectionComponent => c.type === ComponentType.Section)!; + assert.ok(section, "expected a Section component for the one matching row"); + const [text] = section.components; + assert.match(text.content, /\*\*Plank \[↗]\(\)\*\*/); + assert.match(text.content, /Effort left:/); + assert.equal(section.accessory.type, ComponentType.Thumbnail); + assert.equal((section.accessory as APIThumbnailComponent).media.url, `${BOT_HTTP_BASE_URL}/icons/item/501.webp`); + assert.equal(components.filter(c => c.type === ComponentType.Separator).length, 0); +}); + +test("icon style falls back to the CDN's generic Unknown sprite when a recipe's icon hasn't resolved", () => { + const components = build(PRESETS_CONTENT, "icon", [row({recipeId: 7})], 1, new Map([[7, {name: "Plank", icon: null}]])); + const section = components.find((c): c is APISectionComponent => c.type === ComponentType.Section)!; + assert.equal((section.accessory as APIThumbnailComponent).media.url, `${ASSET_CDN_BASE}/sprites/Unknown.webp`); +}); + +test("icon style self-trims to the 40-component budget regardless of a display's stored limit", () => { + const oversized = Array.from({length: MAX_DISPLAY_ROWS_HARD_CAP}, (_, i) => row({id: `r${i}`})); + const components = build(PRESETS_CONTENT, "icon", oversized, oversized.length); + const sectionCount = components.filter(c => c.type === ComponentType.Section).length; + // header(1) + footer reserve(2) + 3/row <= 40 → floor(37/3) = 12 + assert.equal(sectionCount, 12); + const header = components[0] as APITextDisplayComponent; + assert.match(header.content, /12 crafts shown/); +}); + +test("simple style self-trims to the 40-component budget", () => { + const oversized = Array.from({length: MAX_DISPLAY_ROWS_HARD_CAP}, (_, i) => row({id: `r${i}`})); + const components = build(PRESETS_CONTENT, "simple", oversized, oversized.length); + const containerCount = components.filter(c => c.type === ComponentType.Container).length; + // header(1) + footer reserve(2) + 2/row <= 40 → floor(37/2) = 18 + assert.equal(containerCount, 18); +}); + +test("compact style self-trims by character count, never by component count", () => { + const longTemplate: DiscordWatchDisplayContentValue = {tag: "Template", value: {template: "x".repeat(500)}}; + const many = Array.from({length: 20}, (_, i) => row({id: `r${i}`})); + const components = build(longTemplate, "compact", many, many.length); + assert.equal(components.length, 2); + const body = components[1] as APITextDisplayComponent; + assert.ok(body.content.length <= 4000); + // Well under 20 rows worth (500 chars * 20 = 10000) fit in the 4000-char budget. + assert.ok(body.content.split("\n").length < 20); +}); + +test("exactly one wasted render happens when the character budget (not the row count) is what cuts a display off", () => { + let lookups = 0; + const countingRecipeNames = { + get(id: number) { + lookups++; + return undefined; + }, + } as unknown as RecipeDisplayIndex; + + const longTemplate: DiscordWatchDisplayContentValue = {tag: "Template", value: {template: "x".repeat(500)}}; + const many = Array.from({length: 20}, (_, i) => row({id: `r${i}`})); + const components = build(longTemplate, "compact", many, many.length, countingRecipeNames); + const body = components[1] as APITextDisplayComponent; + const shown = body.content.split("\n").length; + + assert.ok(shown < many.length, "expected the character budget to actually cut this display off"); + // One row past whatever fit gets rendered (to learn it doesn't fit) and discarded; nothing beyond + // that is ever rendered, since the loop stops as soon as one row overflows the budget. + assert.equal(lookups, shown + 1); +}); + +test("rows beyond what a style can ever show are never rendered at all, and at most one extra render is wasted when the character budget (not the component budget) is what cuts it off", () => { + let lookups = 0; + const countingRecipeNames = { + get(id: number) { + lookups++; + return undefined; + }, + } as unknown as RecipeDisplayIndex; + + const oversized = Array.from({length: MAX_DISPLAY_ROWS_HARD_CAP}, (_, i) => row({id: `r${i}`})); + const components = build(PRESETS_CONTENT, "icon", oversized, oversized.length, countingRecipeNames); + const shown = components.filter(c => c.type === ComponentType.Section).length; + assert.equal(shown, 12); // same component-budget math as the "self-trims" test above + + // The component budget is checked before rendering, so only the rows shown are rendered. + assert.equal(lookups, shown); +}); + +test("buildDisplayComponents omits the footer link when there is no shareCode", () => { + const components = build(PRESETS_CONTENT, "compact", [row()], 1); + assert.equal(components.some(c => c.type === ComponentType.ActionRow), false); +}); + +test("buildDisplayComponents appends a 'see all' footer button reporting the total match count, not just what's shown", () => { + const components = build(PRESETS_CONTENT, "compact", [row()], 42, new Map(), "ABC123"); + const footer = components.at(-1) as APIActionRowComponent; + assert.equal(footer.type, ComponentType.ActionRow); + const [button] = footer.components; + assert.equal(button.url, "https://brico.app/tools/crafts/browse?share=ABC123"); + assert.match(button.label ?? "", /See all 42 matching crafts/); +}); + +test("an arbitrary 'template' content variant renders directly, without going through field presets", () => { + const template: DiscordWatchDisplayContentValue = {tag: "Template", value: {template: "custom: {username}"}}; + const components = build(template, "compact", [row({ownerName: "Bob"})], 1); + const body = components[1] as APITextDisplayComponent; + assert.equal(body.content, "custom: Bob"); +}); diff --git a/backend/brico-bot/src/discord/display-format.ts b/backend/brico-bot/src/discord/display-format.ts new file mode 100644 index 00000000..f15dda86 --- /dev/null +++ b/backend/brico-bot/src/discord/display-format.ts @@ -0,0 +1,253 @@ +/** + * display-format.ts — pure logic turning craft rows plus a watch display's config into Components V2 + * message components (styles `compact`, `simple`, `icon`). Rows render lazily until Discord's + * 4000-character or 40-component message limits would be exceeded. + */ +import {composeTemplateFromPresets, type DiscordDisplaySortDirection, type DiscordDisplaySortField, type DiscordDisplayStyle,} from "@brico/crafts/discord-display"; +import {renderDiscordTemplate} from "@brico/crafts/discord-template"; +import type {FilterNode} from "@brico/crafts/filter"; +import {tierColorHex} from "@brico/crafts/icon-render"; +import type {APIActionRowComponent, APIButtonComponentWithURL, APIContainerComponent, APIMessageTopLevelComponent, APISectionComponent} from "discord-api-types/v10"; +import {ButtonStyle, ComponentType} from "discord-api-types/v10"; + +import {compiledFilter} from "../compiled-filter.ts"; +import type {RecipeDisplayIndex, RecipeDisplayInfo, RecipeIconRef} from "../game-data/recipes.ts"; +import type {SkillNameIndex} from "../game-data/skills.ts"; +import {type CraftRow, rowFor} from "../relay/subject.ts"; + +/** Generated-binding shape of `discord_watch_display.content` (enum tags are PascalCase client-side). */ +export type DiscordWatchDisplayContentValue = + | {tag: "Presets"; value: {presets: readonly string[]}} + | {tag: "Template"; value: {template: string}}; + +function resolveTemplate(content: DiscordWatchDisplayContentValue): string { + return content.tag === "Presets" ? composeTemplateFromPresets(content.value.presets) : content.value.template; +} + +function compareAscending(field: DiscordDisplaySortField): (a: CraftRow, b: CraftRow) => number { + switch (field) { + case "remaining": + return (a, b) => a.subject.effortRemaining - b.subject.effortRemaining; + case "effort": + return (a, b) => a.subject.effortTotal - b.subject.effortTotal; + case "tier": + return (a, b) => (a.subject.tier ?? -Infinity) - (b.subject.tier ?? -Infinity); + case "bounty": + return (a, b) => (a.subject.payout ?? -Infinity) - (b.subject.payout ?? -Infinity); + case "newest": + // The difference of two unix-ms values is exact in a float64. + return (a, b) => Number(a.firstSeenMs - b.firstSeenMs); + default: { + const exhaustive: never = field; + throw new Error(`unhandled sort field: ${exhaustive as string}`); + } + } +} + +export interface DisplaySelection { + /** The rows to actually render, after sorting and capping at `limit`. */ + rows: CraftRow[]; + /** Rows matched before capping at `limit` (the footer link's count). */ + totalMatches: number; +} + +/** + * Filters `rows` by `filter` as seen by `viewerAccountHex` (a private bounty is invisible to + * everyone but its assigner, for matching, sorting and rendering alike), sorts them, and caps at `limit`. + */ +export function selectDisplayRows( + rows: readonly CraftRow[], + filter: FilterNode, + viewerAccountHex: string | null, + sortField: DiscordDisplaySortField, + sortDirection: DiscordDisplaySortDirection, + limit: number, +): DisplaySelection { + const ascending = compareAscending(sortField); + const compare = sortDirection === "desc" ? (a: CraftRow, b: CraftRow) => -ascending(a, b) : ascending; + const test = compiledFilter(filter); + const matches: CraftRow[] = []; + for (const row of rows) { + const visible = rowFor(row, viewerAccountHex); + if (test(visible.subject)) matches.push(visible); + } + matches.sort(compare); + return {rows: matches.slice(0, limit), totalMatches: matches.length}; +} + +/** Displays ping nobody, so `mentionText` is null. */ +function templateContextFor(row: CraftRow, info: RecipeDisplayInfo | undefined, skillNames: SkillNameIndex, frontendOrigin: string, filterName: string) { + return { + recipeName: info?.name ?? `Recipe #${row.recipeId}`, + craftId: row.id, + frontendOrigin, + ownerName: row.ownerName, + claimName: row.claimName, + regionId: row.regionId, + tier: row.subject.tier, + skillName: row.subject.skill !== null ? (skillNames.get(row.subject.skill) ?? null) : null, + effortTotal: row.subject.effortTotal, + effortRemaining: row.subject.effortRemaining, + payout: row.subject.payout, + currency: row.subject.currency, + filterName, + mentionText: null, + }; +} + +/** One row's rendered text plus its recipe info (reused for the `icon` style's thumbnail). */ +function renderRow( + row: CraftRow, + template: string, + recipeNames: RecipeDisplayIndex, + skillNames: SkillNameIndex, + frontendOrigin: string, + filterName: string, +): {text: string; info: RecipeDisplayInfo | undefined} { + const info = recipeNames.get(row.recipeId); + return {text: renderDiscordTemplate(template, templateContextFor(row, info, skillNames, frontendOrigin, filterName)), info}; +} + +/** Icon URL from the bot's `/icons/:shape/:id.webp` route, or the CDN's "Unknown" sprite when the recipe has no output. */ +function iconUrlFor(icon: RecipeIconRef | null, botHttpBaseUrl: string, assetCdnBase: string): string { + if (!icon) return `${assetCdnBase}/sprites/Unknown.webp`; + return `${botHttpBaseUrl}/icons/${icon.shape}/${icon.id}.webp`; +} + +function hexToAccentColor(hex: string): number { + return parseInt(hex.slice(1), 16); +} + +function headerText(filterName: string, shownCount: number): string { + const nowUnixSeconds = Math.floor(Date.now() / 1000); + return `**Craft watch: ${filterName}**\nUpdated • ${shownCount} craft${shownCount === 1 ? "" : "s"} shown`; +} + +function footerLabel(totalMatches: number): string { + return `See all ${totalMatches} matching craft${totalMatches === 1 ? "" : "s"}`; +} + +function footerButton(frontendOrigin: string, shareCode: string, totalMatches: number): APIActionRowComponent { + return { + type: ComponentType.ActionRow, + components: [{ + type: ComponentType.Button, + style: ButtonStyle.Link, + url: `${frontendOrigin}/tools/crafts/browse?share=${shareCode}`, + label: footerLabel(totalMatches), + }], + }; +} + +/** Discord's per-message limits (characters across text components; total components). */ +const MESSAGE_CHAR_BUDGET = 4000; +const MESSAGE_COMPONENT_BUDGET = 40; +const HEADER_COMPONENTS = 1; +/** `compact` style's row separator; its length is counted in the character budget. */ +const ROW_SEPARATOR = "\n"; +/** Reserved even without a `shareCode`, so the budget doesn't depend on the footer link. */ +const FOOTER_COMPONENTS_RESERVED = 2; +const SIMPLE_COMPONENTS_PER_ROW = 2; // Container + TextDisplay +const ICON_COMPONENTS_PER_ROW = 3; // Section + TextDisplay + Thumbnail + +/** + * The `components` array for a watch display; send with `flags: MessageFlags.IsComponentsV2` and no + * `embeds`/`content`. `frontendOrigin` is the web app origin the row and footer links point into; + * `botHttpBaseUrl`/`assetCdnBase` build icon URLs (`icon` style). The footer link is omitted when + * `shareCode` is undefined. + */ +export function buildDisplayComponents( + filterName: string, + content: DiscordWatchDisplayContentValue, + style: DiscordDisplayStyle, + rows: readonly CraftRow[], + totalMatches: number, + recipeNames: RecipeDisplayIndex, + skillNames: SkillNameIndex, + frontendOrigin: string, + botHttpBaseUrl: string, + assetCdnBase: string, + shareCode: string | undefined, +): APIMessageTopLevelComponent[] { + if (rows.length === 0) { + return [ + {type: ComponentType.TextDisplay, content: headerText(filterName, 0)}, + {type: ComponentType.TextDisplay, content: "_No matching crafts right now._"}, + ]; + } + + const template = resolveTemplate(content); + + // `rows.length` bounds the shown count, so this reserve is >= the final header's length. + const headerReserve = headerText(filterName, rows.length).length; + const footerReserve = shareCode !== undefined ? footerLabel(totalMatches).length : 0; + + let shown: number; + let rowComponents: APIMessageTopLevelComponent[]; + + // Rows render lazily: the component budget is checked before rendering a row, the character + // budget after, so at most one row per render is rendered and discarded. + if (style === "compact") { + let used = headerReserve + footerReserve; + const included: string[] = []; + for (const row of rows) { + const {text} = renderRow(row, template, recipeNames, skillNames, frontendOrigin, filterName); + const addition = (included.length > 0 ? ROW_SEPARATOR.length : 0) + text.length; + if (used + addition > MESSAGE_CHAR_BUDGET) break; + included.push(text); + used += addition; + } + shown = included.length; + rowComponents = shown > 0 ? [{type: ComponentType.TextDisplay, content: included.join(ROW_SEPARATOR)}] : []; + } else if (style === "simple") { + let usedChars = headerReserve + footerReserve; + let usedComponents = HEADER_COMPONENTS + FOOTER_COMPONENTS_RESERVED; + const containers: APIContainerComponent[] = []; + for (const row of rows) { + if (usedComponents + SIMPLE_COMPONENTS_PER_ROW > MESSAGE_COMPONENT_BUDGET) break; + const {text} = renderRow(row, template, recipeNames, skillNames, frontendOrigin, filterName); + if (usedChars + text.length > MESSAGE_CHAR_BUDGET) break; + const tier = row.subject.tier; + containers.push({ + type: ComponentType.Container, + accent_color: tier !== null ? hexToAccentColor(tierColorHex(tier)) : undefined, + components: [{type: ComponentType.TextDisplay, content: text}], + }); + usedChars += text.length; + usedComponents += SIMPLE_COMPONENTS_PER_ROW; + } + shown = containers.length; + rowComponents = containers; + } else { + let usedChars = headerReserve + footerReserve; + let usedComponents = HEADER_COMPONENTS + FOOTER_COMPONENTS_RESERVED; + const sections: APISectionComponent[] = []; + for (const row of rows) { + if (usedComponents + ICON_COMPONENTS_PER_ROW > MESSAGE_COMPONENT_BUDGET) break; + const {text, info} = renderRow(row, template, recipeNames, skillNames, frontendOrigin, filterName); + if (usedChars + text.length > MESSAGE_CHAR_BUDGET) break; + sections.push({ + type: ComponentType.Section, + components: [{type: ComponentType.TextDisplay, content: text}], + accessory: { + type: ComponentType.Thumbnail, + media: {url: iconUrlFor(info?.icon ?? null, botHttpBaseUrl, assetCdnBase)}, + description: info?.name ?? `Recipe #${row.recipeId}`, + }, + }); + usedChars += text.length; + usedComponents += ICON_COMPONENTS_PER_ROW; + } + shown = sections.length; + rowComponents = sections; + } + + const header: APIMessageTopLevelComponent = {type: ComponentType.TextDisplay, content: headerText(filterName, shown)}; + if (shown === 0) { + return [header, {type: ComponentType.TextDisplay, content: "_Nothing fit in this message — try fewer field presets or a smaller style._"}]; + } + + const components = [header, ...rowComponents]; + if (shareCode !== undefined) components.push(footerButton(frontendOrigin, shareCode, totalMatches)); + return components; +} diff --git a/backend/brico-bot/src/discord/display-manager.ts b/backend/brico-bot/src/discord/display-manager.ts new file mode 100644 index 00000000..af8c1947 --- /dev/null +++ b/backend/brico-bot/src/discord/display-manager.ts @@ -0,0 +1,248 @@ +/** + * display-manager.ts — keeps each `discord_watch_display` message in Discord in sync, via a fixed-cadence + * `sweep()` over the subscribed rows (a missed tick just makes the next refresh due later). + */ +import type {DbConnection} from "@brico/bindings/brico-app"; +import type {DiscordWatchDisplay} from "@brico/bindings/brico-app/types"; +import {isDiscordDisplaySortDirection, isDiscordDisplaySortField, isDiscordDisplayStyle} from "@brico/crafts/discord-display"; +import {parseFilter} from "@brico/crafts/filter"; +import {DiscordAPIError, type REST} from "@discordjs/rest"; +import type {APIMessageTopLevelComponent} from "discord-api-types/v10"; +import {MessageFlags, Routes} from "discord-api-types/v10"; +import {Timestamp} from "spacetimedb"; + +import type {BricoAppConnection} from "../app/connection.ts"; +import type {RecipeDisplayIndex} from "../game-data/recipes.ts"; +import type {SkillNameIndex} from "../game-data/skills.ts"; +import type {Logger} from "../log.ts"; +import {activeDisplays, displayFilters, displayRenderDuration, displayUpdatesTotal, timeReducerCall} from "../metrics.ts"; +import type {CraftRow} from "../relay/subject.ts"; +import type {DiscordWatchDisplayContentValue} from "./display-format.ts"; +import {buildDisplayComponents, selectDisplayRows} from "./display-format.ts"; + +/** How often the sweep runs. */ +const SWEEP_INTERVAL_MS = 5_000; +/** Stagger for newly seen displays so several don't post in the same tick. */ +const NEW_DISPLAY_JITTER_MS = 2_000; + +export interface DisplayManagerOptions { + app: BricoAppConnection; + rest: REST; + recipeNames: RecipeDisplayIndex; + skillNames: SkillNameIndex; + /** The brico web app's origin (e.g. `https://brico.app`), which craft links point into. */ + frontendOrigin: string; + /** brico-bot's public origin, serving icon thumbnails (`BotConfig.httpBaseUrl`). */ + botHttpBaseUrl: string; + /** Origin of the fallback icon sprite (`BotConfig.assetCdnBase`). */ + assetCdnBase: string; + log: Logger; +} + +export interface DisplayManager { + /** Feed from the bridge's `onRowsComputed`: the latest resolved craft rows. */ + onRowsComputed(rows: CraftRow[]): void; + stop(): void; +} + +function describeError(cause: unknown): string { + if (cause instanceof Error) return cause.message; + return String(cause); +} + +function isNotFound(cause: unknown): boolean { + return cause instanceof DiscordAPIError && cause.status === 404; +} + +/** The bot can no longer post in the channel (kicked, permission removed). */ +function isForbidden(cause: unknown): boolean { + return cause instanceof DiscordAPIError && cause.status === 403; +} + +export function createDisplayManager(options: DisplayManagerOptions): DisplayManager { + const log = options.log.child("display"); + let latestRows: CraftRow[] = []; + /** Display id → when it's next due for an in-place edit (seeded with jitter on first sight). */ + const nextDueAtMs = new Map(); + /** Display id → when it's next due for a sticky repost (delete + resend); only for `stickyMinutes > 0`. */ + const nextStickyAtMs = new Map(); + + async function postOrEdit(conn: DbConnection, display: DiscordWatchDisplay, components: APIMessageTopLevelComponent[], sticky: boolean): Promise { + let messageId = display.messageId; + const body = {components, flags: MessageFlags.IsComponentsV2}; + + if (!sticky && messageId !== undefined) { + try { + await options.rest.patch(Routes.channelMessage(display.channelId, messageId), {body}); + displayUpdatesTotal.inc({outcome: "edited"}); + await recordMessage(conn, display.id, messageId, true); + return; + } catch (cause) { + if (isForbidden(cause)) { + await markDeleted(conn, display); + return; + } + if (!isNotFound(cause)) { + displayUpdatesTotal.inc({outcome: "failed"}); + log.error("failed to edit display message", {display: display.id, error: describeError(cause)}); + return; + } + // The message was deleted out from under us — fall through and repost as new. + messageId = undefined; + } + } else if (sticky && messageId !== undefined) { + try { + await options.rest.delete(Routes.channelMessage(display.channelId, messageId)); + } catch (cause) { + if (!isNotFound(cause)) { + log.warn("failed to delete previous sticky message", {display: display.id, error: describeError(cause)}); + } + } + } + + try { + const posted = (await options.rest.post(Routes.channelMessages(display.channelId), {body})) as {id: string}; + displayUpdatesTotal.inc({outcome: "posted"}); + await recordMessage(conn, display.id, posted.id, false); + } catch (cause) { + if (isForbidden(cause) || isNotFound(cause)) { + await markDeleted(conn, display); + return; + } + displayUpdatesTotal.inc({outcome: "failed"}); + log.error("failed to post display message", {display: display.id, error: describeError(cause)}); + } + } + + async function recordMessage(conn: DbConnection, id: string, messageId: string, edited: boolean): Promise { + if (!conn.isActive) return; + await timeReducerCall( + "set_discord_watch_display_message", + conn.reducers.setDiscordWatchDisplayMessage({id, messageId, postedAt: Timestamp.now(), edited}), + ).catch(cause => { + log.error("set_discord_watch_display_message failed", {display: id, error: describeError(cause)}); + }); + } + + /** The channel stopped accepting posts (403/404): tombstone the display (same reducer as `/watch display-remove`). */ + async function markDeleted(conn: DbConnection, display: DiscordWatchDisplay): Promise { + if (!conn.isActive) return; + displayUpdatesTotal.inc({outcome: "detached"}); + log.warn("display channel no longer accessible; removing display", {display: display.id, channel: display.channelId}); + await timeReducerCall( + "detach_discord_watch_display", + conn.reducers.detachDiscordWatchDisplay({id: display.id}), + ).catch(cause => { + log.error("detach_discord_watch_display failed", {display: display.id, error: describeError(cause)}); + }); + } + + async function refresh(conn: DbConnection, display: DiscordWatchDisplay, sticky: boolean): Promise { + const savedFilter = conn.db.allSavedCraftFilter.id.find(display.filterId); + if (!savedFilter || !savedFilter.accountIdentity.isEqual(display.accountIdentity)) { + displayUpdatesTotal.inc({outcome: "skipped"}); + log.warn("skipping display: its saved filter is gone", {display: display.id}); + return; + } + if (!isDiscordDisplayStyle(display.style) || !isDiscordDisplaySortField(display.sortField) || !isDiscordDisplaySortDirection(display.sortDirection)) { + displayUpdatesTotal.inc({outcome: "skipped"}); + log.warn("skipping display: unknown style/sort field/sort direction", { + display: display.id, style: display.style, sortField: display.sortField, sortDirection: display.sortDirection, + }); + return; + } + + const filter = parseFilter(JSON.parse(savedFilter.filterJson)); + if (!filter) { + displayUpdatesTotal.inc({outcome: "skipped"}); + log.warn("skipping display: its saved filter's JSON is invalid", {display: display.id}); + return; + } + + const stopRender = displayRenderDuration.startTimer(); + const {rows, totalMatches} = selectDisplayRows(latestRows, filter, display.accountIdentity.toHexString(), display.sortField, display.sortDirection, display.limit); + const components = buildDisplayComponents( + savedFilter.name, + display.content as DiscordWatchDisplayContentValue, + display.style, + rows, + totalMatches, + options.recipeNames, + options.skillNames, + options.frontendOrigin, + options.botHttpBaseUrl, + options.assetCdnBase, + display.shareCode, + ); + stopRender(); + await postOrEdit(conn, display, components, sticky); + } + + async function sweep(): Promise { + const conn = options.app.connection?.connection; + if (!conn?.isActive || !options.app.isLive) return; + + const now = Date.now(); + const liveIds = new Set(); + const filterIds = new Set(); + + for (const display of conn.db.allDiscordWatchDisplay.iter()) { + if (display.deletedAt !== undefined) continue; + liveIds.add(display.id); + filterIds.add(display.filterId); + + let dueAt = nextDueAtMs.get(display.id); + if (dueAt === undefined) { + dueAt = now + Math.floor(Math.random() * NEW_DISPLAY_JITTER_MS); + nextDueAtMs.set(display.id, dueAt); + } + + let stickyDue = false; + if (display.stickyMinutes > 0) { + let stickyAt = nextStickyAtMs.get(display.id); + if (stickyAt === undefined) { + stickyAt = now + display.stickyMinutes * 60_000 + Math.floor(Math.random() * NEW_DISPLAY_JITTER_MS); + nextStickyAtMs.set(display.id, stickyAt); + } + if (now >= stickyAt) { + stickyDue = true; + nextStickyAtMs.set(display.id, now + display.stickyMinutes * 60_000); + } + } else { + nextStickyAtMs.delete(display.id); + } + + // A sticky repost also refreshes the content, so it replaces the plain edit. + if (!stickyDue && now < dueAt) continue; + + nextDueAtMs.set(display.id, now + display.refreshIntervalSeconds * 1000); + await refresh(conn, display, stickyDue).catch(cause => { + log.error("display refresh threw", {display: display.id, error: describeError(cause)}); + }); + } + + activeDisplays.set(liveIds.size); + displayFilters.set(filterIds.size); + + // Drop timers for displays that disappeared. + for (const id of [...nextDueAtMs.keys()]) { + if (!liveIds.has(id)) nextDueAtMs.delete(id); + } + for (const id of [...nextStickyAtMs.keys()]) { + if (!liveIds.has(id)) nextStickyAtMs.delete(id); + } + } + + const timer = setInterval(() => { + sweep().catch(cause => log.error("sweep threw", {error: describeError(cause)})); + }, SWEEP_INTERVAL_MS); + + return { + onRowsComputed(rows) { + latestRows = rows; + }, + stop() { + clearInterval(timer); + }, + }; +} diff --git a/backend/brico-bot/src/discord/icon-composite.ts b/backend/brico-bot/src/discord/icon-composite.ts new file mode 100644 index 00000000..0d0a301f --- /dev/null +++ b/backend/brico-bot/src/discord/icon-composite.ts @@ -0,0 +1,91 @@ +/** + * icon-composite.ts — renders an item/cargo's game-styled icon (tier background + sprite + rarity + * frame, as `GameIcon.tsx` draws it) into one `.webp` with `sharp`, cached in memory for the life + * of the process (CDN assets and final icons alike). + */ +import {ICON_CANVAS_SIZE, ICON_CONTAINER_SIZE, ICON_FRAME_PREFIX, ICON_SPRITE_SIZE, type IconShape, rarityToFrameSlug, tierColorHex,} from "@brico/crafts/icon-render"; +import sharp from "sharp"; + +import type {ItemIconFacts} from "../game-data/item-icons.ts"; + +async function fetchBuffer(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`asset fetch failed (${res.status}): ${url}`); + return Buffer.from(await res.arrayBuffer()); +} + +/** Keyed async memoizer; failures are evicted so the next request retries. */ +function memoize(): (key: string, compute: () => Promise) => Promise { + const cache = new Map>(); + return (key, compute) => { + let entry = cache.get(key); + if (!entry) { + entry = compute().catch(cause => { + cache.delete(key); + throw cause; + }); + cache.set(key, entry); + } + return entry; + }; +} + +export interface IconRenderer { + /** Renders (or returns the cached render of) one item/cargo's icon as a `.webp` buffer. */ + render(shape: IconShape, id: number, facts: ItemIconFacts): Promise; +} + +export function createIconRenderer(assetCdnBase: string): IconRenderer { + const sprite = memoize(); + const frame = memoize(); + const composite = memoize(); + + async function renderComposite(shape: IconShape, facts: ItemIconFacts): Promise { + const raritySlug = rarityToFrameSlug(facts.rarityTag); + const container = ICON_CONTAINER_SIZE[shape]; + const iconBox = ICON_SPRITE_SIZE[shape]; + const left = Math.round((container.width - iconBox.width) / 2); + const top = Math.round((container.height - iconBox.height) / 2); + + const [spriteRaw, frameRaw] = await Promise.all([ + sprite(facts.iconAssetName, () => fetchBuffer(`${assetCdnBase}/sprites/${facts.iconAssetName}.webp`)), + frame(`${shape}:${raritySlug}`, () => fetchBuffer(`${assetCdnBase}/UI/Frames/${ICON_FRAME_PREFIX[shape]}-${raritySlug}-dark.webp`)), + ]); + + // Sprite letterboxed into its box (`object-contain`) with the tier color behind it (`dest-over`). + const spriteLayer = await sharp(spriteRaw) + .resize(iconBox.width, iconBox.height, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}}) + .composite([{ + input: {create: {width: iconBox.width, height: iconBox.height, channels: 4, background: tierColorHex(facts.tier)}}, + blend: "dest-over", + }]) + .png() + .toBuffer(); + + // Frame stretched to fill the container (`object-fill`); its cutout reveals the sprite. + const frameLayer = await sharp(frameRaw) + .resize(container.width, container.height, {fit: "fill"}) + .toBuffer(); + + const containerImage = await sharp({create: {width: container.width, height: container.height, channels: 4, background: {r: 0, g: 0, b: 0, alpha: 0}}}) + .composite([ + {input: spriteLayer, left, top}, + {input: frameLayer, left: 0, top: 0}, + ]) + .png() + .toBuffer(); + + // Fit the (non-square) container, uncropped, into a square canvas, since Discord thumbnails are square. + return sharp(containerImage) + .resize(ICON_CANVAS_SIZE, ICON_CANVAS_SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}}) + .webp() + .toBuffer(); + } + + return { + render(shape, id, facts) { + const raritySlug = rarityToFrameSlug(facts.rarityTag); + return composite(`${shape}:${id}:${facts.tier}:${raritySlug}`, () => renderComposite(shape, facts)); + }, + }; +} diff --git a/backend/brico-bot/src/discord/install.ts b/backend/brico-bot/src/discord/install.ts new file mode 100644 index 00000000..a4393600 --- /dev/null +++ b/backend/brico-bot/src/discord/install.ts @@ -0,0 +1,49 @@ +/** + * install.ts — authorize URL and code exchange for the self-serve "add brico to your server" flow + * (the callback route itself lives in `http-server.ts`). + */ +import {PermissionFlagsBits} from "discord-api-types/v10"; + +const DISCORD_AUTHORIZE_URL = "https://discord.com/api/oauth2/authorize"; +const DISCORD_TOKEN_URL = "https://discord.com/api/oauth2/token"; + +/** Enough to post the watch-display and notification messages. */ +const INSTALL_PERMISSIONS = ( + PermissionFlagsBits.SendMessages | PermissionFlagsBits.EmbedLinks +).toString(); + +export function buildDiscordInstallAuthorizeUrl(options: {applicationId: string; redirectUri: string}): string { + const url = new URL(DISCORD_AUTHORIZE_URL); + url.searchParams.set("client_id", options.applicationId); + url.searchParams.set("scope", "bot applications.commands"); + url.searchParams.set("permissions", INSTALL_PERMISSIONS); + url.searchParams.set("redirect_uri", options.redirectUri); + url.searchParams.set("response_type", "code"); + return url.toString(); +} + +/** + * Exchanges the callback's `code` for the guild Discord actually granted; the callback's `guild_id` + * query param is forgeable and must not be used. `redirectUri` must match the authorize request's. + */ +export async function resolveInstalledGuildId(options: {applicationId: string; clientSecret: string; redirectUri: string; code: string}): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: options.code, + redirect_uri: options.redirectUri, + client_id: options.applicationId, + client_secret: options.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 token = (await tokenRes.json()) as {guild?: {id?: string}}; + const guildId = token.guild?.id; + if (!guildId) throw new Error("token exchange response carried no guild id"); + return guildId; +} diff --git a/backend/brico-bot/src/discord/interactions.ts b/backend/brico-bot/src/discord/interactions.ts new file mode 100644 index 00000000..db3f8942 --- /dev/null +++ b/backend/brico-bot/src/discord/interactions.ts @@ -0,0 +1,166 @@ +/** interactions.ts — handler for `POST /discord/interactions`, the HTTP transport for all slash commands (no gateway). */ +import type { + APIApplicationCommandAutocompleteInteraction, + APIChatInputApplicationCommandInteraction, + APIInteraction, + APIInteractionResponse, + APIModalSubmitInteraction +} from "discord-api-types/v10"; +import {InteractionResponseType, InteractionType, MessageFlags} from "discord-api-types/v10"; +import {verifyKey} from "discord-interactions"; +import type {IncomingMessage, ServerResponse} from "node:http"; + +import type {Logger} from "../log.ts"; +import {commandInvocationsTotal} from "../metrics.ts"; +import type {CommandDeps, LeafCommand} from "./commands/registry.ts"; +import {resolveCommand, resolveModalLeaf} from "./commands/registry.ts"; + +export interface InteractionsHandlerOptions { + /** Ed25519 public key from the Discord Developer Portal — every request is signed against this. */ + publicKey: string; + leaves: LeafCommand[]; + /** Group command name used by "grouped" mode; stripped when resolving (see `resolveCommand`). */ + groupName?: string; + deps: CommandDeps; + log: Logger; +} + +function readRawBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", chunk => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +function sendJson(res: ServerResponse, status: number, body: APIInteractionResponse | {error: string}): void { + const json = JSON.stringify(body); + res.writeHead(status, {"content-type": "application/json"}); + res.end(json); +} + +function messageResponse(content: string, ephemeral: boolean): APIInteractionResponse { + return { + type: InteractionResponseType.ChannelMessageWithSource, + data: {content, flags: ephemeral ? MessageFlags.Ephemeral : undefined}, + }; +} + +/** + * Verifies the Ed25519 signature over the raw body (before JSON parsing), answers Discord's `PING` + * (required to save the endpoint URL), and otherwise dispatches to the matching command leaf. + */ +export function createInteractionsHandler(options: InteractionsHandlerOptions) { + return async function handleInteractions(req: IncomingMessage, res: ServerResponse): Promise { + const signature = req.headers["x-signature-ed25519"]; + const timestamp = req.headers["x-signature-timestamp"]; + if (typeof signature !== "string" || typeof timestamp !== "string") { + res.writeHead(401); + res.end("missing signature headers"); + return; + } + + const rawBody = await readRawBody(req); + const validSignature = await verifyKey(rawBody, signature, timestamp, options.publicKey); + if (!validSignature) { + res.writeHead(401); + res.end("invalid request signature"); + return; + } + + let interaction: APIInteraction; + try { + interaction = JSON.parse(rawBody.toString("utf8")); + } catch { + res.writeHead(400); + res.end("invalid JSON"); + return; + } + + if (interaction.type === InteractionType.Ping) { + sendJson(res, 200, {type: InteractionResponseType.Pong}); + return; + } + + if (interaction.type === InteractionType.ApplicationCommandAutocomplete) { + const autocompleteInteraction = interaction as APIApplicationCommandAutocompleteInteraction; + const command = resolveCommand(options.leaves, autocompleteInteraction, options.groupName); + if (!command?.autocomplete) { + sendJson(res, 200, {type: InteractionResponseType.ApplicationCommandAutocompleteResult, data: {choices: []}}); + return; + } + try { + const choices = await command.autocomplete(autocompleteInteraction, options.deps); + sendJson(res, 200, {type: InteractionResponseType.ApplicationCommandAutocompleteResult, data: {choices}}); + } catch (cause) { + options.log.error("autocomplete handler threw", { + command: command.path.join("."), + error: cause instanceof Error ? cause.message : String(cause), + }); + sendJson(res, 200, {type: InteractionResponseType.ApplicationCommandAutocompleteResult, data: {choices: []}}); + } + return; + } + + if (interaction.type === InteractionType.ModalSubmit) { + const modalInteraction = interaction as APIModalSubmitInteraction; + const command = resolveModalLeaf(options.leaves, modalInteraction.data.custom_id); + if (!command?.modalSubmit) { + options.log.warn("unrecognized modal submission", {customId: modalInteraction.data.custom_id}); + sendJson(res, 200, messageResponse("Unrecognized modal.", true)); + return; + } + try { + const reply = await command.modalSubmit(modalInteraction, options.deps); + sendJson(res, 200, messageResponse(reply.content, reply.ephemeral ?? true)); + } catch (cause) { + options.log.error("modal submit handler threw", { + command: command.path.join("."), + error: cause instanceof Error ? cause.message : String(cause), + }); + sendJson(res, 200, messageResponse("Something went wrong finishing that setup.", true)); + } + return; + } + + if (interaction.type !== InteractionType.ApplicationCommand) { + // No component handlers are registered. + options.log.warn("unhandled interaction type", {type: interaction.type}); + sendJson(res, 200, messageResponse("This interaction type isn't supported yet.", true)); + return; + } + + const commandInteraction = interaction as APIChatInputApplicationCommandInteraction; + const command = resolveCommand(options.leaves, commandInteraction, options.groupName); + if (!command) { + options.log.warn("unrecognized command interaction", {name: commandInteraction.data?.name}); + sendJson(res, 200, messageResponse("Unrecognized command.", true)); + return; + } + + let status: string = ""; + try { + const result = await command.handler(commandInteraction, options.deps); + if ("modal" in result) { + sendJson(res, 200, {type: InteractionResponseType.Modal, data: result.modal}); + } else { + sendJson(res, 200, messageResponse(result.content, result.ephemeral ?? true)); + } + status = "ok"; + } catch (cause) { + options.log.error("command handler threw", { + command: command.path.join("."), + error: cause instanceof Error ? cause.message : String(cause), + }); + sendJson(res, 200, messageResponse("Something went wrong running that command.", true)); + status = "error"; + } finally { + commandInvocationsTotal.inc({ + command: command.path.join("."), + context: commandInteraction.guild_id ? "guild" : "dm", + result: status + }); + } + }; +} diff --git a/backend/brico-bot/src/discord/notification-sink.ts b/backend/brico-bot/src/discord/notification-sink.ts new file mode 100644 index 00000000..8ff6d7ca --- /dev/null +++ b/backend/brico-bot/src/discord/notification-sink.ts @@ -0,0 +1,168 @@ +/** + * notification-sink.ts — the Discord `MatchSink`, posting to each linked channel. `event.watch.triggers` + * is the OR across all sinks, so this re-checks each Discord sink's own trigger row. + */ +import type {DbConnection} from "@brico/bindings/brico-app"; +import type {CraftFilterNotifyTrigger, DiscordNotifySink, DiscordNotifyTarget} from "@brico/bindings/brico-app/types"; +import {DEFAULT_NOTIFY_TEMPLATE} from "@brico/crafts/discord-notify"; +import {renderDiscordTemplate} from "@brico/crafts/discord-template"; +import type {TriggerKind} from "@brico/crafts/watch"; +import {DiscordAPIError, type REST} from "@discordjs/rest"; +import {RESTPostAPIChannelMessageJSONBody, Routes} from "discord-api-types/v10"; +import {Identity} from "spacetimedb"; + +import type {BricoAppConnection} from "../app/connection.ts"; +import type {MatchEvent, MatchSink} from "../bridge.ts"; +import type {RecipeDisplayIndex} from "../game-data/recipes.ts"; +import type {SkillNameIndex} from "../game-data/skills.ts"; +import type {Logger} from "../log.ts"; +import {notificationsTotal, timeReducerCall} from "../metrics.ts"; +import type {CraftRow} from "../relay/subject.ts"; + +export interface DiscordNotificationSinkOptions { + app: BricoAppConnection; + rest: REST; + recipeNames: RecipeDisplayIndex; + skillNames: SkillNameIndex; + /** The brico web app's origin, which craft links point into. */ + frontendOrigin: string; + log: Logger; +} + +function findDiscordNotifySink(conn: DbConnection, id: string): DiscordNotifySink | null { + for (const sink of conn.db.allDiscordNotifySink.iter()) { + if (sink.id === id && sink.deletedAt === undefined) return sink; + } + return null; +} + +function findDiscordNotifyTarget(conn: DbConnection, accountIdentity: Identity, filterId: string, sinkId: string): DiscordNotifyTarget | null { + for (const target of conn.db.allDiscordNotifyTarget.iter()) { + if (target.deletedAt !== undefined) continue; + if (target.filterId !== filterId || target.sinkId !== sinkId) continue; + if (!target.accountIdentity.isEqual(accountIdentity)) continue; + return target; + } + return null; +} + +/** Pair-specific override (on `discord_notify_target`) → sink default → no mention at all. */ +function mentionTextFor(target: DiscordNotifyTarget | null, sink: DiscordNotifySink): string | null { + const type = target?.mentionType ?? sink.defaultMentionType; + const id = target?.mentionId ?? sink.defaultMentionId; + if (!type || !id) return null; + return type === "user" ? `<@${id}>` : `<@&${id}>`; +} + +/** `undefined` (no custom template) falls back to the shared default for that event kind. */ +function templateFor(target: DiscordNotifyTarget | null, kind: TriggerKind): string { + if (!target) return DEFAULT_NOTIFY_TEMPLATE[kind]; + const stored = kind === "added" ? target.addedTemplate : kind === "finished" ? target.finishedTemplate : target.removedTemplate; + return stored ?? DEFAULT_NOTIFY_TEMPLATE[kind]; +} + +function describeError(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** The channel is gone or the bot can no longer post there (403/404), as opposed to a transient failure. */ +function isGone(cause: unknown): boolean { + return cause instanceof DiscordAPIError && (cause.status === 403 || cause.status === 404); +} + +export function createDiscordNotificationSink(options: DiscordNotificationSinkOptions): MatchSink { + const log = options.log.child("notify"); + return event => { + if (event.watch.owner === null || event.watch.filterId === null) return; + const conn = options.app.connection?.connection; + if (!conn?.isActive) return; + + const accountIdentity = Identity.fromString(event.watch.owner); + const filterId = event.watch.filterId; + + const triggers: CraftFilterNotifyTrigger[] = []; + for (const trigger of conn.db.allCraftFilterNotifyTrigger.iter()) { + if (trigger.deletedAt !== undefined) continue; + if (trigger.filterId !== filterId) continue; + if (!trigger.accountIdentity.isEqual(accountIdentity)) continue; + if (trigger.sink.tag !== "Discord") continue; + if (!trigger[event.kind]) continue; + triggers.push(trigger); + } + if (triggers.length === 0) return; + + for (const trigger of triggers) { + // `trigger.sink.tag === "Discord"` was already checked in the filter loop above. + const sinkId = (trigger.sink as {tag: "Discord"; value: {sinkId: string}}).value.sinkId; + const sink = findDiscordNotifySink(conn, sinkId); + if (!sink) continue; + const target = findDiscordNotifyTarget(conn, accountIdentity, filterId, sinkId); + + sendNotification(options, event, sink, target, log).catch(cause => { + log.error("failed to send Discord notification", {watch: event.watch.id, sink: sinkId, error: describeError(cause)}); + }); + } + }; +} + +async function sendNotification( + options: DiscordNotificationSinkOptions, + event: MatchEvent, + sink: DiscordNotifySink, + target: DiscordNotifyTarget | null, + log: Logger, +): Promise { + const craft: CraftRow | null = event.craft; + const info = craft ? options.recipeNames.get(craft.recipeId) : undefined; + const skillName = craft?.subject.skill != null ? (options.skillNames.get(craft.subject.skill) ?? null) : null; + + const template = templateFor(target, event.kind); + const rendered = renderDiscordTemplate(template, { + recipeName: info?.name ?? (craft ? `Recipe #${craft.recipeId}` : `Craft #${event.craftId}`), + craftId: event.craftId, + frontendOrigin: options.frontendOrigin, + ownerName: craft?.ownerName ?? null, + claimName: craft?.claimName ?? null, + regionId: craft?.regionId ?? 0, + tier: craft?.subject.tier ?? null, + skillName, + effortTotal: craft?.subject.effortTotal ?? 0, + effortRemaining: craft?.subject.effortRemaining ?? 0, + payout: craft?.subject.payout ?? null, + currency: craft?.subject.currency ?? null, + filterName: event.watch.name, + mentionText: mentionTextFor(target, sink), + }); + + const mentionType = target?.mentionType ?? sink.defaultMentionType; + const mentionId = target?.mentionId ?? sink.defaultMentionId; + const body = { + content: rendered, + allowed_mentions: { + parse: [], + users: mentionType === "user" && mentionId ? [mentionId] : [], + roles: mentionType === "role" && mentionId ? [mentionId] : [], + }, + } satisfies RESTPostAPIChannelMessageJSONBody; + + try { + await options.rest.post(Routes.channelMessages(sink.channelId), {body}); + notificationsTotal.inc({sink: "discord", kind: event.kind, outcome: "ok"}); + } catch (cause) { + notificationsTotal.inc({sink: "discord", kind: event.kind, outcome: isGone(cause) ? "detached" : "failed"}); + if (isGone(cause)) { + log.warn("notify sink channel no longer accessible; removing sink", {sink: sink.id, channel: sink.channelId}); + const conn = options.app.connection?.connection; + if (conn?.isActive) { + await timeReducerCall( + "detach_discord_notify_sink", + conn.reducers.detachDiscordNotifySink({id: sink.id}), + ).catch(detachCause => { + log.error("detach_discord_notify_sink failed", {sink: sink.id, error: describeError(detachCause)}); + }); + } + return; + } + log.error("failed to post notification message", {sink: sink.id, channel: sink.channelId, error: describeError(cause)}); + } +} diff --git a/backend/brico-bot/src/discord/register-commands.ts b/backend/brico-bot/src/discord/register-commands.ts new file mode 100644 index 00000000..e662b743 --- /dev/null +++ b/backend/brico-bot/src/discord/register-commands.ts @@ -0,0 +1,90 @@ +/** + * register-commands.ts — manual script (`npm run discord:register-commands --workspace @brico/bot -- `) + * that PUTs the command tree to a guild or, with `--global`, the DM-only set. A PUT bulk-overwrites + * the scope, so it removes any command not in the tree. + * + * Flags: `--guild ` (default `DISCORD_TEST_GUILD_ID`), `--mode flat|grouped` (default `flat`; + * `grouped` nests everything under `/brico`; ignored with `--global`), `--global`, `--clear` (PUT an + * empty list; `--clear --global` removes leftover global commands). + * + * Global commands are limited to `contexts: [BotDM]`; otherwise they would also appear in every + * guild alongside that guild's own registration. + */ +import {REST} from "@discordjs/rest"; +import {Routes} from "discord-api-types/v10"; + +import {loadConfig, loadEnvFile} from "../config.ts"; +import {commands} from "./commands/index.ts"; +import {buildCommandTree, type CommandTreeMode, leavesForScope} from "./commands/registry.ts"; +import {registerGlobalCommands, registerGuildCommands} from "./register-guild-commands.ts"; + +interface Args { + mode: CommandTreeMode; + guildId: string | undefined; + global: boolean; + clear: boolean; +} + +function parseArgs(argv: string[]): Args { + let mode: CommandTreeMode = "flat"; + let guildId: string | undefined; + let global = false; + let clear = false; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--mode" && argv[i + 1]) { + const value = argv[++i]; + if (value !== "flat" && value !== "grouped") throw new Error(`--mode must be "flat" or "grouped", got ${JSON.stringify(value)}`); + mode = value; + } else if (argv[i] === "--guild" && argv[i + 1]) { + guildId = argv[++i]; + } else if (argv[i] === "--global") { + global = true; + } else if (argv[i] === "--clear") { + clear = true; + } + } + return {mode, guildId, global, clear}; +} + +async function main(): Promise { + loadEnvFile(); + const discordBot = loadConfig().discordBot; + if (!discordBot) { + throw new Error("DISCORD_BOT_TOKEN, DISCORD_PUBLIC_KEY, and DISCORD_APPLICATION_ID must all be set"); + } + + const {mode, guildId: guildIdArg, global, clear} = parseArgs(process.argv.slice(2)); + const rest = new REST({version: "10"}).setToken(discordBot.token); + + if (global) { + // Global registration is always flat and DM-only (see `registerGlobalCommands`). + if (clear) { + console.log("clearing all global commands"); + await rest.put(Routes.applicationCommands(discordBot.applicationId), {body: []}); + } else { + const dmCommands = leavesForScope(commands, "dm"); + console.log(`registering ${dmCommands.length} DM-only command(s) globally:`, dmCommands.map(c => c.path.join("."))); + await registerGlobalCommands(rest, discordBot.applicationId); + } + console.log("done — global command changes can take up to an hour to propagate everywhere"); + return; + } + + const guildId = guildIdArg ?? discordBot.testGuildId; + if (!guildId) throw new Error("pass --guild or set DISCORD_TEST_GUILD_ID"); + + if (clear) { + console.log(`clearing all commands in guild ${guildId}`); + await rest.put(Routes.applicationGuildCommands(discordBot.applicationId, guildId), {body: []}); + } else { + const body = buildCommandTree(leavesForScope(commands, "guild"), mode); + console.log(`registering ${body.length} command(s) to guild ${guildId} (mode=${mode}):`, body.map(c => c.name)); + await registerGuildCommands(rest, discordBot.applicationId, guildId, mode); + } + console.log("done"); +} + +main().catch(cause => { + console.error(cause); + process.exit(1); +}); diff --git a/backend/brico-bot/src/discord/register-guild-commands.ts b/backend/brico-bot/src/discord/register-guild-commands.ts new file mode 100644 index 00000000..bc371b13 --- /dev/null +++ b/backend/brico-bot/src/discord/register-guild-commands.ts @@ -0,0 +1,22 @@ +/** register-guild-commands.ts — PUTs a guild's command tree (or the global DM-only set); shared by the CLI script, install callback, `/command-mode` and startup resync. */ +import type {DiscordCommandMode} from "@brico/crafts/discord-guild"; +import type {REST} from "@discordjs/rest"; +import {ApplicationIntegrationType, InteractionContextType, Routes} from "discord-api-types/v10"; + +import {commands} from "./commands"; +import {buildCommandTree, leavesForScope} from "./commands/registry.ts"; + +export async function registerGuildCommands(rest: REST, applicationId: string, guildId: string, mode: DiscordCommandMode): Promise { + const body = buildCommandTree(leavesForScope(commands, "guild"), mode); + await rest.put(Routes.applicationGuildCommands(applicationId, guildId), {body}); +} + +/** Registers the DM-scoped commands globally, flat and `BotDM`-only (guild commands are registered per guild). */ +export async function registerGlobalCommands(rest: REST, applicationId: string): Promise { + const body = buildCommandTree(leavesForScope(commands, "dm"), "flat").map(command => ({ + ...command, + contexts: [InteractionContextType.BotDM], + integration_types: [ApplicationIntegrationType.GuildInstall], + })); + await rest.put(Routes.applicationCommands(applicationId), {body}); +} diff --git a/backend/brico-bot/src/discord/rest-client.ts b/backend/brico-bot/src/discord/rest-client.ts new file mode 100644 index 00000000..3776d188 --- /dev/null +++ b/backend/brico-bot/src/discord/rest-client.ts @@ -0,0 +1,9 @@ +/** + * rest-client.ts — the shared `@discordjs/rest` client (it queues requests per rate-limit bucket). + * The bot has no gateway connection; everything is REST. + */ +import {REST} from "@discordjs/rest"; + +export function createDiscordRestClient(token: string): REST { + return new REST({version: "10"}).setToken(token); +} diff --git a/backend/brico-bot/src/discord/sync-guild-commands.ts b/backend/brico-bot/src/discord/sync-guild-commands.ts new file mode 100644 index 00000000..e9c28e8e --- /dev/null +++ b/backend/brico-bot/src/discord/sync-guild-commands.ts @@ -0,0 +1,60 @@ +/** + * sync-guild-commands.ts — re-registers commands for every guild the bot is currently in (listed via + * `GET /users/@me/guilds`, so removed guilds are skipped) plus the global DM-only set. Runs at + * startup so deploys reach already-installed guilds. A guild without a `discord_guild_install` row + * (invited manually) gets `"flat"`. + */ +import type {DbConnection} from "@brico/bindings/brico-app"; +import {isDiscordCommandMode} from "@brico/crafts/discord-guild"; +import type {REST} from "@discordjs/rest"; +import {Routes} from "discord-api-types/v10"; + +import type {Logger} from "../log.ts"; +import {registerGlobalCommands, registerGuildCommands} from "./register-guild-commands.ts"; + +/** Max page size for `GET /users/@me/guilds`. */ +const GUILDS_PAGE_SIZE = 200; + +async function listCurrentGuildIds(rest: REST): Promise { + const ids: string[] = []; + let after: string | undefined; + for (;;) { + const query = new URLSearchParams({limit: String(GUILDS_PAGE_SIZE)}); + if (after) query.set("after", after); + const page = (await rest.get(Routes.userGuilds(), {query})) as {id: string}[]; + for (const guild of page) ids.push(guild.id); + if (page.length < GUILDS_PAGE_SIZE) break; + after = page[page.length - 1].id; + } + return ids; +} + +export async function syncAllGuildCommands(options: {rest: REST; applicationId: string; conn: DbConnection; log: Logger}): Promise { + const {rest, applicationId, conn, log} = options; + + const modeByGuildId = new Map(); + for (const row of conn.db.allDiscordGuildInstall.iter()) modeByGuildId.set(row.guildId, row.commandMode); + + const guildIds = await listCurrentGuildIds(rest); + log.info("resyncing guild commands", {guilds: guildIds.length}); + + for (const guildId of guildIds) { + const stored = modeByGuildId.get(guildId); + const mode = stored && isDiscordCommandMode(stored) ? stored : "flat"; + try { + await registerGuildCommands(rest, applicationId, guildId, mode); + } catch (cause) { + log.warn("guild command resync failed for one guild — continuing with the rest", { + guildId, + error: cause instanceof Error ? cause.message : String(cause), + }); + } + } + + try { + await registerGlobalCommands(rest, applicationId); + log.info("synced global commands. may take time to show up.") + } catch (cause) { + log.warn("global command resync failed", {error: cause instanceof Error ? cause.message : String(cause)}); + } +} diff --git a/backend/brico-bot/src/game-data/item-icons.ts b/backend/brico-bot/src/game-data/item-icons.ts new file mode 100644 index 00000000..25c87257 --- /dev/null +++ b/backend/brico-bot/src/game-data/item-icons.ts @@ -0,0 +1,39 @@ +/** + * item-icons.ts — sprite, tier and rarity for every item/cargo (the whole catalog, not just recipe + * outputs), read from offline `item_desc`/`cargo_desc` BSATN. Backs the `/icons/:shape/:id.webp` route. + */ +import {CargoDesc, ItemDesc} from "@brico/bitcraft-bindings/types"; +import type {IconShape} from "@brico/crafts/icon-render"; + +import {loadGameDataTable} from "./load.ts"; + +export interface ItemIconFacts { + iconAssetName: string; + tier: number; + /** `ItemDesc`/`CargoDesc.rarity.tag`. */ + rarityTag: string; +} + +/** `":"` — item and cargo ids are separate id spaces and can collide, so the shape is part of the key. */ +export function itemIconKey(shape: IconShape, id: number): string { + return `${shape}:${id}`; +} + +/** `itemIconKey(shape, id)` → icon facts. */ +export type ItemIconIndex = ReadonlyMap; + +export async function loadItemIconIndex(gameDataDir: string): Promise { + const [itemRows, cargoRows] = await Promise.all([ + loadGameDataTable(gameDataDir, "item_desc", ItemDesc.algebraicType), + loadGameDataTable(gameDataDir, "cargo_desc", CargoDesc.algebraicType), + ]); + + const index = new Map(); + for (const item of itemRows) { + index.set(itemIconKey("item", item.id), {iconAssetName: item.iconAssetName, tier: item.tier, rarityTag: item.rarity.tag}); + } + for (const cargo of cargoRows) { + index.set(itemIconKey("cargo", cargo.id), {iconAssetName: cargo.iconAssetName, tier: cargo.tier, rarityTag: cargo.rarity.tag}); + } + return index; +} 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..5b27a77d --- /dev/null +++ b/backend/brico-bot/src/game-data/recipes.ts @@ -0,0 +1,120 @@ +/** + * 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 {IconShape} from "@brico/crafts/icon-render"; +import {craftingRecipeDisplayName} from "@brico/crafts/names"; +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 itemNameFor( + stack: {itemId: number; itemType: ItemType} | undefined, + items: ReadonlyMap, + cargo: ReadonlyMap, +): string | undefined { + if (!stack) return undefined; + const desc = stack.itemType.tag === ItemType.Item.tag ? items.get(stack.itemId) : cargo.get(stack.itemId); + return desc?.name; +} + +/** Key into `ItemIconIndex` (`item-icons.ts`) for a recipe's output icon. */ +export interface RecipeIconRef { + shape: IconShape; + id: number; +} + +function iconRefFor(stack: {itemId: number; itemType: ItemType} | undefined): RecipeIconRef | null { + if (!stack) return null; + return {shape: stack.itemType.tag === ItemType.Item.tag ? "item" : "cargo", id: stack.itemId}; +} + +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 interface RecipeDisplayInfo { + /** See `@brico/crafts/names`'s `craftingRecipeDisplayName`. */ + name: string; + /** `null` when the recipe crafts nothing (some recipe rows have an empty `craftedItemStacks`). */ + icon: RecipeIconRef | null; +} + +/** `craft_meta.recipeId` → display info (name + icon). */ +export type RecipeDisplayIndex = ReadonlyMap; + +async function loadGameData(gameDataDir: string) { + 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 {recipeRows, items, cargo}; +} + +export async function loadRecipeIndex(gameDataDir: string): Promise { + const {recipeRows, items, cargo} = await loadGameData(gameDataDir); + return new Map(recipeRows.map(recipe => [recipe.id, toStatic(recipe, items, cargo)])); +} + +/** Presentation-only recipe names/icons, kept out of `RecipeStatic`/`CraftSubject` since filters never use them. */ +export async function loadRecipeDisplayIndex(gameDataDir: string): Promise { + const {recipeRows, items, cargo} = await loadGameData(gameDataDir); + return new Map(recipeRows.map(recipe => { + const output = recipe.craftedItemStacks[0]; + return [ + recipe.id, + { + name: craftingRecipeDisplayName( + recipe.name, + itemNameFor(output, items, cargo), + itemNameFor(recipe.consumedItemStacks[0], items, cargo), + recipe.buildingRequirement?.buildingType ?? null, + ), + icon: iconRefFor(output), + }, + ]; + })); +} diff --git a/backend/brico-bot/src/game-data/skills.ts b/backend/brico-bot/src/game-data/skills.ts new file mode 100644 index 00000000..12f95c15 --- /dev/null +++ b/backend/brico-bot/src/game-data/skills.ts @@ -0,0 +1,12 @@ +/** skills.ts — skill id → display name index, read from offline BSATN game data. */ +import {SkillDesc} from "@brico/bitcraft-bindings/types"; + +import {loadGameDataTable} from "./load.ts"; + +/** `RecipeStatic.skillId` → that skill's display name. */ +export type SkillNameIndex = ReadonlyMap; + +export async function loadSkillNameIndex(gameDataDir: string): Promise { + const rows = await loadGameDataTable(gameDataDir, "skill_desc", SkillDesc.algebraicType); + return new Map(rows.map(skill => [skill.id, skill.name])); +} diff --git a/backend/brico-bot/src/http-server.ts b/backend/brico-bot/src/http-server.ts new file mode 100644 index 00000000..86d3b5bf --- /dev/null +++ b/backend/brico-bot/src/http-server.ts @@ -0,0 +1,294 @@ +/** + * http-server.ts — the small persistent HTTP surface `brico-bot` needs to be BitAuth's and + * Discord's OAuth relying party. Also serves the Discord bot routes: `POST /discord/interactions`, + * the `/discord/install` redirect pair, and `GET /icons/:shape/:id.webp` (watch display thumbnails). + * + * Both OAuth link 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 {IconRenderer} from "./discord/icon-composite.ts"; +import {type ItemIconIndex, itemIconKey} from "./game-data/item-icons.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; + /** Handler for `POST /discord/interactions`; `null` when the bot is not configured (route 503s). */ + discordInteractions: ((req: IncomingMessage, res: ServerResponse) => Promise) | null; + /** Self-serve "add to your server" flow (`/discord/install[/callback]`); `null` disables it (503). */ + discordInstall: { + buildAuthorizeUrl(): string; + /** Exchanges the callback's `code` for the granted guild id (the `guild_id` query param is untrusted). */ + resolveInstalledGuildId(code: string): Promise; + /** Persists the install (`upsertDiscordGuildInstall`) and re-runs guild command registration for it. */ + onInstalled(args: {guildId: string}): Promise; + } | null; + /** Backs `GET /icons/:shape/:id.webp`. */ + icons: { + index: ItemIconIndex; + renderer: IconRenderer; + }; +} + +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)); + } + } + + function handleDiscordInstall(res: ServerResponse): void { + if (!options.discordInstall) return sendText(res, 503, "Discord bot install is not configured"); + redirect(res, options.discordInstall.buildAuthorizeUrl()); + } + + async function handleDiscordInstallCallback(url: URL, res: ServerResponse): Promise { + if (!options.discordInstall) return sendText(res, 503, "Discord bot install is not configured"); + const installedPageUrl = `${defaultReturnOrigin}/discord/installed`; + + const oauthError = url.searchParams.get("error"); + if (oauthError) return redirect(res, withParam(installedPageUrl, "error", oauthError)); + const code = url.searchParams.get("code"); + if (!code) return redirect(res, withParam(installedPageUrl, "error", "installation was cancelled")); + + // Still undefined in the catch if the token exchange itself failed. + let guildId: string | undefined; + try { + // The query string's `guild_id` is deliberately not read; it can be forged. + guildId = await options.discordInstall.resolveInstalledGuildId(code); + await options.discordInstall.onInstalled({guildId}); + options.log.info("Installed in Discord guild", {guildId}); + redirect(res, withParam(installedPageUrl, "guild", guildId)); + } catch (cause) { + options.log.error("Discord install finalization failed", {guildId, error: describeError(cause)}); + redirect(res, withParam(installedPageUrl, "error", "couldn't finish installing the bot — try again in a moment")); + } + } + + async function handleMetrics(res: ServerResponse): Promise { + res.writeHead(200, {"content-type": registry.contentType}); + res.end(await registry.metrics()); + } + + const iconPathPattern = /^\/icons\/(item|cargo)\/(\d+)\.webp$/; + + async function handleIcon(shape: "item" | "cargo", id: number, res: ServerResponse): Promise { + const facts = options.icons.index.get(itemIconKey(shape, id)); + if (!facts) return sendText(res, 404, "unknown icon"); + try { + const image = await options.icons.renderer.render(shape, id, facts); + // Output is fixed for the loaded game-data version, so it can be cached indefinitely. + res.writeHead(200, {"content-type": "image/webp", "cache-control": "public, max-age=31536000, immutable"}); + res.end(image); + } catch (cause) { + options.log.error("icon render failed", {shape, id, error: describeError(cause)}); + sendText(res, 502, "icon render failed"); + } + } + + 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), + "/discord/install": (_url, res) => handleDiscordInstall(res), + "/discord/install/callback": (url, res) => handleDiscordInstallCallback(url, res), + }; + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? `${options.host}:${options.port}`}`); + + if (req.method === "POST" && url.pathname === "/discord/interactions") { + if (!options.discordInteractions) { + sendText(res, 503, "Discord bot is not configured"); + return; + } + options.discordInteractions(req, res).catch(cause => { + options.log.error("discord interactions route threw", {error: describeError(cause)}); + if (!res.headersSent) sendText(res, 500, "internal error"); + }); + return; + } + + const iconMatch = req.method === "GET" ? iconPathPattern.exec(url.pathname) : null; + if (iconMatch) { + handleIcon(iconMatch[1] as "item" | "cargo", Number(iconMatch[2]), res).catch(cause => { + options.log.error("icon route threw", {path: url.pathname, error: describeError(cause)}); + if (!res.headersSent) sendText(res, 500, "internal error"); + }); + return; + } + + 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..8708c534 --- /dev/null +++ b/backend/brico-bot/src/main.ts @@ -0,0 +1,286 @@ +/** + * 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 {createAppCache} from "./app/app-cache.ts"; +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 {commands as discordCommands} from "./discord/commands/index.ts"; +import {createDisplayManager} from "./discord/display-manager.ts"; +import {createIconRenderer} from "./discord/icon-composite.ts"; +import {buildDiscordInstallAuthorizeUrl, resolveInstalledGuildId} from "./discord/install.ts"; +import {createInteractionsHandler} from "./discord/interactions.ts"; +import {createDiscordNotificationSink} from "./discord/notification-sink.ts"; +import {registerGuildCommands} from "./discord/register-guild-commands.ts"; +import {createDiscordRestClient} from "./discord/rest-client.ts"; +import {syncAllGuildCommands} from "./discord/sync-guild-commands.ts"; +import {loadItemIconIndex} from "./game-data/item-icons.ts"; +import {loadRecipeDisplayIndex, loadRecipeIndex} from "./game-data/recipes.ts"; +import {loadSkillNameIndex} from "./game-data/skills.ts"; +import {createLinkHttpServer} from "./http-server.ts"; +import {createLogger} from "./log.ts"; +import {timeReducerCall} from "./metrics.ts"; +import {createPrismRelay, type PrismRelay} from "./relay/prism.ts"; +import {createRowCache} from "./relay/row-cache.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)", + discordBot: config.discordBot ? "(enabled)" : "(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}); + + // Recipe/skill/icon display data for the Discord features; loaded even when the bot is disabled. + const recipeNames = await loadRecipeDisplayIndex(config.gameDataDir); + + const skillNames = await loadSkillNameIndex(config.gameDataDir); + + // Backs `GET /icons/:shape/:id.webp`. + const itemIcons = await loadItemIconIndex(config.gameDataDir); + const iconRenderer = createIconRenderer(config.assetCdnBase); + + const tokens = createFileTokenStore(config.stateDir); + const supervisor = { + tokens, + log, + delayMs: config.reconnectDelayMs, + maxDelayMs: config.reconnectMaxDelayMs, + }; + + // Forward reference: `app`'s own callbacks are only ever invoked later (once its socket is + // live), by which point `relay` below is already assigned — this lets `app` (constructed first, + // so the watch source can consult it from the very first prism snapshot) reach `relay.mark()` + // without restructuring construction order. + let relay: PrismRelay | undefined; + const appCache = createAppCache(log.child("app")); + + // 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, + cache: appCache, + reconcileIntervalMs: config.reconcileIntervalMs, + onRowsChanged: () => { + log.debug("brico-app rows changed"); + // No second timer: piggyback on prism's own coalesced tick, so a rule/reward edit with + // no coincidental prism activity still lands within one `snapshotIntervalMs`. + relay?.mark(); + }, + // Forward reference: `discordBot`/`discordRest` are declared below but assigned before the socket is live. + onReady: () => { + if (!discordBot || !discordRest) return; + const conn = app.connection?.connection; + if (!conn) return; + void syncAllGuildCommands({rest: discordRest, applicationId: discordBot.applicationId, conn, log: log.child("discord")}).catch(cause => { + log.error("guild command resync failed", {error: cause instanceof Error ? cause.message : String(cause)}); + }); + }, + }); + 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"); + + // Local copy so closures below can narrow it (TS won't narrow `config.discordBot` across them). + const discordBot = config.discordBot; + + // Shared REST client for all outgoing Discord calls. + const discordRest = discordBot ? createDiscordRestClient(discordBot.token) : null; + + // Slash commands. No gateway: interactions arrive as signed HTTP POSTs on the HTTP server below. + const discordInteractions = discordBot && discordRest + ? createInteractionsHandler({ + publicKey: discordBot.publicKey, + leaves: discordCommands, + deps: { + log: log.child("discord"), + app, + linkReturnUrl: config.linkReturnUrl, + rest: discordRest, + applicationId: discordBot.applicationId, + }, + log: log.child("discord"), + }) + : null; + if (!discordInteractions) log.warn("Discord bot is disabled — set DISCORD_BOT_TOKEN/DISCORD_PUBLIC_KEY/DISCORD_APPLICATION_ID to enable it"); + + // Self-serve install flow. Requires the client secret: the token exchange is how the granted guild + // is trusted, so the callback's own `guild_id` query param is never used. + const installClientSecret = discordBot?.installClientSecret ?? null; + const discordInstall = discordBot && discordRest && installClientSecret + ? { + buildAuthorizeUrl: () => buildDiscordInstallAuthorizeUrl({ + applicationId: discordBot.applicationId, + redirectUri: `${config.httpBaseUrl}/discord/install/callback`, + }), + resolveInstalledGuildId: (code: string) => resolveInstalledGuildId({ + applicationId: discordBot.applicationId, + clientSecret: installClientSecret, + redirectUri: `${config.httpBaseUrl}/discord/install/callback`, + code, + }), + onInstalled: async ({guildId}: {guildId: string}) => { + const conn = app.connection?.connection; + if (!conn) throw new Error("brico-app connection is not live"); + await timeReducerCall("upsert_discord_guild_install", conn.reducers.upsertDiscordGuildInstall({guildId, commandMode: "flat"})); + await registerGuildCommands(discordRest, discordBot.applicationId, guildId, "flat"); + }, + } + : null; + if (discordBot && discordRest && !installClientSecret) { + log.warn("Discord self-serve install is disabled — set DISCORD_BOT_CLIENT_SECRET to enable /discord/install (the manual invite flow still works without it)"); + } + + // Periodically refreshes `/watch display` messages from the bridge's per-snapshot rows (`onRowsComputed`). + const displayManager = config.discordBot && discordRest + ? createDisplayManager({ + app, + rest: discordRest, + recipeNames, + skillNames, + frontendOrigin: new URL(config.linkReturnUrl).origin, + botHttpBaseUrl: config.httpBaseUrl, + assetCdnBase: config.assetCdnBase, + log: log.child("discord"), + }) + : null; + + // Discord half of the notification sink; combined with the in-site sink below. + const discordNotificationSink = config.discordBot && discordRest + ? createDiscordNotificationSink({ + app, + rest: discordRest, + recipeNames, + skillNames, + frontendOrigin: new URL(config.linkReturnUrl).origin, + log: log.child("discord"), + }) + : null; + + 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})); + }, + discordInteractions, + discordInstall, + icons: {index: itemIcons, renderer: iconRenderer}, + }); + linkHttp.start(); + + const watches = createAccountWatchSource(app, log.child("watches")); + + const bounty = createBountyEngine(app, appCache, log); + + const cache = createRowCache(recipes); + const bridge = createBridge({ + log, + cache, + watches, + sink: combineSinks( + createLoggingSink(log), + createNotificationSink(app, log), + ...(discordNotificationSink ? [discordNotificationSink] : []), + ), + recipes, + bounty, + appCache, + onRowsComputed: rows => displayManager?.onRowsComputed(rows), + }); + + relay = createPrismRelay({ + ...supervisor, + target: config.prism, + snapshotIntervalMs: config.snapshotIntervalMs, + cache, + recipes, + reconcileIntervalMs: config.reconcileIntervalMs, + onTick: () => bridge.onTick(), + }); + 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); + displayManager?.stop(); + 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..c1549631 --- /dev/null +++ b/backend/brico-bot/src/metrics.ts @@ -0,0 +1,173 @@ +/** + * 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}); + +/** Sub-millisecond to one-second buckets; the per-tick work is mostly a few milliseconds. */ +const FINE_BUCKETS = [0.0005, 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1]; + +/** Time to read prism's relay tables into the row cache when a subscription applies (`relay/prism.ts`'s `readSnapshot`). */ +export const snapshotBuildDuration = new Histogram({ + name: "brico_bot_snapshot_build_duration_seconds", + help: "Time to read prism's relay tables into the row cache", + registers: [registry], +}); + +/** Times the row cache disagreed with a full read of the relay tables during reconciliation. */ +export const relayDriftTotal = new Counter({ + name: "brico_bot_relay_drift_total", + help: "Reconciliations that found the row cache differing from a full read of the relay tables", + registers: [registry], +}); + +/** Times the `brico-app` cache disagreed with a full read of the tables during reconciliation. */ +export const appDriftTotal = new Counter({ + name: "brico_bot_app_drift_total", + help: "Reconciliations that found the brico-app cache differing from a full read of the tables", + registers: [registry], +}); + +/** Crafts whose rows changed in one tick (the rows the watch matchers re-evaluated). */ +export const dirtyCraftsPerTick = new Histogram({ + name: "brico_bot_dirty_crafts_per_tick", + help: "Crafts whose row changed since the previous tick", + buckets: [0, 1, 5, 10, 25, 50, 100, 150, 250, 500, 1000, 5000, 20000], + registers: [registry], +}); + +/** Time to run every watch's filter against one snapshot (the loop in `bridge.ts`'s `tick`). */ +export const watchEvaluationDuration = new Histogram({ + name: "brico_bot_watch_evaluation_duration_seconds", + help: "Time to evaluate every watch's filter against the crafts that changed in one tick", + buckets: FINE_BUCKETS, + 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 tick", + buckets: FINE_BUCKETS, + 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 tick", + buckets: FINE_BUCKETS, + registers: [registry], +}); + +/** Time spent in one named step of the per-snapshot pipeline (`bridge.ts`, `bounty-sink.ts`). */ +export const tickStepDuration = new Histogram({ + name: "brico_bot_tick_step_duration_seconds", + help: "Time spent in one step of the per-snapshot pipeline", + labelNames: ["step"] as const, + buckets: FINE_BUCKETS, + registers: [registry], +}); + +/** Starts a `tickStepDuration` timer for `step`; call the returned function to stop it. */ +export function startStep(step: string): () => void { + const stop = tickStepDuration.startTimer({step}); + return () => void stop(); +} + +/** 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], +}); + +/** Notifications handed to a sink, by sink (`toast`/`discord`), transition kind and outcome (`ok`, `failed`, `dropped`, `detached`). */ +export const notificationsTotal = new Counter({ + name: "brico_bot_notifications_total", + help: "Notifications sent to a sink, by sink, kind and outcome", + labelNames: ["sink", "kind", "outcome"] as const, + registers: [registry], +}); + +/** `/watch display` messages currently being kept in sync. */ +export const activeDisplays = new Gauge({ + name: "brico_bot_active_displays", + help: "Live Discord watch displays", + registers: [registry], +}); + +/** Distinct saved filters those displays evaluate — includes filters that were deleted but are still displayed. */ +export const displayFilters = new Gauge({ + name: "brico_bot_display_filters", + help: "Distinct saved filters evaluated by live Discord watch displays", + registers: [registry], +}); + +/** Display refreshes by outcome: `edited`, `posted`, `skipped` (filter gone or invalid), `failed`, `detached` (channel no longer accessible). */ +export const displayUpdatesTotal = new Counter({ + name: "brico_bot_display_updates_total", + help: "Discord watch display refreshes by outcome", + labelNames: ["outcome"] as const, + registers: [registry], +}); + +/** Time to select and render one display's rows (filter evaluation over every open craft plus formatting), excluding the Discord call. */ +export const displayRenderDuration = new Histogram({ + name: "brico_bot_display_render_duration_seconds", + help: "Time to select and render one Discord watch display's rows", + buckets: FINE_BUCKETS, + registers: [registry], +}); + +/** Slash command invocations by command path (e.g. `watch.display`), context (`guild` or `dm`), and result (`ok` or `error`). */ +export const commandInvocationsTotal = new Counter({ + name: "brico_bot_command_invocations_total", + help: "Slash command invocations by command and context", + labelNames: ["command", "context", "result"] as const, + 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.test.ts b/backend/brico-bot/src/relay/prism.test.ts new file mode 100644 index 00000000..ff7f70bd --- /dev/null +++ b/backend/brico-bot/src/relay/prism.test.ts @@ -0,0 +1,38 @@ +/** + * prism.test.ts — `readSnapshot` against a fake connection. + */ +import type {DbConnection} from "@brico/bindings/prism"; +import assert from "node:assert/strict"; +import {test} from "node:test"; + +import {readSnapshot} from "./prism.ts"; + +function fakeConnection() { + const table = (rows: unknown[]) => ({iter: () => rows}); + return { + db: { + craftMeta: table([{entityId: 1n, status: {tag: "Active"}}, {entityId: 2n, status: {tag: "Claimed"}}]), + craftProgress: table([{entityId: 1n, progress: 5}]), + claimInfo: table([{entityId: 10n, name: "claim"}]), + claimMember: table([ + {claimEntityId: 10n, playerEntityId: 20n, owner: true}, + {claimEntityId: 10n, playerEntityId: 21n, owner: false}, + {claimEntityId: 10n, playerEntityId: 22n, owner: true}, + ]), + craftContribution: table([{craftId: 1n, playerId: 20n, contribution: 3}]), + playerState: table([{entityId: 20n, name: "someone"}]), + region: table([{id: 1, name: "region"}]), + }, + } as unknown as DbConnection; +} + +test("readSnapshot keeps only open crafts in `crafts` but every craft id, and lists claim owners in table order", () => { + const snapshot = readSnapshot(fakeConnection()); + assert.deepEqual(snapshot.crafts.map(craft => craft.entityId), [1n]); + assert.deepEqual([...snapshot.allCraftIds], [1n, 2n]); + assert.deepEqual(snapshot.claimOwners.get(10n), [20n, 22n]); + assert.equal(snapshot.claimMembers.size, 3); + assert.equal(snapshot.contributions.get(1n)?.get(20n), 3n); + assert.equal(snapshot.players.get(20n)?.name, "someone"); + assert.equal(snapshot.regions.get(1)?.name, "region"); +}); diff --git a/backend/brico-bot/src/relay/prism.ts b/backend/brico-bot/src/relay/prism.ts new file mode 100644 index 00000000..f3d41b07 --- /dev/null +++ b/backend/brico-bot/src/relay/prism.ts @@ -0,0 +1,281 @@ +/** + * prism.ts — the bridge's connection to prism's `relay-module` (the BitCraft read-mirror). + * + * Row callbacks feed a `RowCache` (`row-cache.ts`), which keeps the joined craft rows current; + * the coalescer then ticks the consumer at most once per interval. The full table read + * (`readSnapshot`) seeds the cache when a subscription applies and is what the periodic + * reconciliation compares the cache against. A craft's *recipe* is not subscribed to — that's static + * game data, read once from offline BSATN via `../game-data/recipes.ts`. + */ +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 {RecipeIndex} from "../game-data/recipes.ts"; +import type {Logger} from "../log.ts"; +import {relayDriftTotal, snapshotBuildDuration, startStep} from "../metrics.ts"; +import {createCoalescer} from "../spacetime/coalesce.ts"; +import {createSupervisedConnection, type SupervisedConnection, type SupervisorOptions} from "../spacetime/connection.ts"; +import {attachFeed, type RowEvents, type TableFeed} from "../spacetime/feed.ts"; +import {findDrift, type RowCache} from "./row-cache.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 readClaimMembers(conn: DbConnection): Pick { + 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]); + } + } + return {claimMembers, claimOwners}; +} + +/** Reads every relay table into a snapshot. */ +export 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, claimOwners} = readClaimMembers(conn); + + 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; + start(): void; + stop(): void; + /** + * Marks the coalescer dirty so the next tick (within `snapshotIntervalMs`) runs, even though + * nothing in prism itself changed — used to promptly reflect a `brico-app`-only change (a bounty + * rule or loyalty reward edit) without a second timer. + */ + mark(): void; +} + +export interface PrismRelayOptions extends SupervisorOptions { + target: SpacetimeTarget; + /** How long row changes are coalesced before `onTick` runs. */ + snapshotIntervalMs: number; + /** Kept current by the relay's row callbacks; the consumer drains it from `onTick`. */ + cache: RowCache; + recipes: RecipeIndex; + /** Called once per coalesced tick, after the cache has been loaded from the initial subscription. */ + onTick(): void; + /** How often to compare the cache against a full table read, adopting the full read on drift. 0 disables. */ + reconcileIntervalMs: number; +} + +export function createPrismRelay(options: PrismRelayOptions): PrismRelay { + const log: Logger = options.log.child("relay"); + const {cache} = options; + let current: DbConnection | null = null; + // Row callbacks are ignored until the initial subscription has been loaded into the cache. + let live = false; + let lastReconcileMs = Date.now(); + + const reconcile = (conn: DbConnection) => { + const stop = startStep("reconcile"); + const drift = findDrift(cache, readSnapshot(conn), options.recipes); + if (drift) { + relayDriftTotal.inc(); + log.error("row cache drifted from the relay tables; adopting a full read", drift); + cache.load(readSnapshot(conn)); + coalescer.mark(); + } + stop(); + }; + + const rebuild = () => { + if (!current?.isActive || !live) return; + const stopTick = startStep("tick_total"); + options.onTick(); + stopTick(); + if (options.reconcileIntervalMs > 0 && Date.now() - lastReconcileMs >= options.reconcileIntervalMs) { + lastReconcileMs = Date.now(); + reconcile(current); + } + }; + 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; + live = false; + + // Rows delivered with the initial subscription are covered by the full read at + // `onApplied`, so only later transactions reach the cache. + const accepts = (callbackCtx: {event: {tag: string}}) => live && callbackCtx.event.tag !== "SubscribeApplied"; + const feed = (table: RowEvents, target: TableFeed, changed?: (before: T, after: T) => boolean) => + attachFeed(table, target, accepts, coalescer.mark, changed); + feed(conn.db.craftMeta, cache.craftMeta); + feed(conn.db.craftProgress, cache.craftProgress); + feed(conn.db.craftContribution, cache.craftContribution); + feed(conn.db.claimInfo, cache.claimInfo); + feed(conn.db.claimMember, cache.claimMember); + feed(conn.db.region, cache.region); + // A player's `online` flag and region churn constantly and nothing reads them; only a + // rename changes what a row says. + feed(conn.db.playerState, cache.playerState, (before, after) => before.name !== after.name); + + let subscription: SubscriptionHandle | null = conn + .subscriptionBuilder() + .onApplied(() => { + if (!ctx.isCurrent()) return; + const timer = snapshotBuildDuration.startTimer(); + cache.load(readSnapshot(conn)); + timer(); + live = true; + lastReconcileMs = Date.now(); + ctx.setLive(); + coalescer.flush(); + log.info("initial snapshot applied", { + openCrafts: cache.baseRows.size, + claims: cache.snapshot.claims.size, + claimMembers: cache.snapshot.claimMembers.size, + contributingCrafts: cache.snapshot.contributions.size, + players: cache.snapshot.players.size, + regions: cache.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 () => { + live = false; + subscription?.unsubscribe(); + subscription = null; + if (current === conn) current = null; + }; + }, + }, + {tokens: options.tokens, log: options.log, delayMs: options.delayMs, maxDelayMs: options.maxDelayMs}, + ); + + return { + connection, + start() { + coalescer.start(); + connection.start(); + }, + stop() { + coalescer.stop(); + connection.stop(); + }, + mark: coalescer.mark, + }; +} diff --git a/backend/brico-bot/src/relay/row-cache.test.ts b/backend/brico-bot/src/relay/row-cache.test.ts new file mode 100644 index 00000000..d81d87c6 --- /dev/null +++ b/backend/brico-bot/src/relay/row-cache.test.ts @@ -0,0 +1,222 @@ +/** + * row-cache.test.ts — random relay row operations applied to a fake relay, checking the incremental + * row cache + `applyDelta` against the full path (`readSnapshot` + `craftRowsFrom` + `applyBounties` + * + `update`) after every batch. + */ +import type {DbConnection} from "@brico/bindings/prism"; +import type {ClaimInfo, ClaimMember, CraftMeta, CraftProgress, PlayerState, Region} from "@brico/bindings/prism/types"; +import {type FilterNode, matchAll, openWorkFilter} from "@brico/crafts/filter"; +import {createWatchMatcher, type MatchEvent} from "@brico/crafts/watch"; +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {Timestamp} from "spacetimedb"; +import {compiledFilter} from "../compiled-filter.ts"; + +import type {RecipeIndex, RecipeStatic} from "../game-data/recipes.ts"; +import {fakeTable} from "../testing/fake-table.ts"; +import {readSnapshot} from "./prism.ts"; +import {createRowCache, findDrift} from "./row-cache.ts"; +import {applyBounties, type AssignedBounty, type CraftRow, craftRowsFrom, rowFor} from "./subject.ts"; + +function rng(seed: number) { + let state = seed; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 2 ** 32; + }; +} + +const RECIPES: RecipeIndex = new Map([ + [1, {effortRequired: 100, skillId: 3, buildingType: 10, levelRequired: 25, itemKey: "item:1", itemTag: "Food", inputItems: [{key: "item:5", tag: "Grain"}]}], + [2, {effortRequired: 40, skillId: 4, buildingType: 11, levelRequired: 5, itemKey: "cargo:2", itemTag: null, inputItems: []}], + [3, {effortRequired: 300, skillId: 3, buildingType: 10, levelRequired: 45, itemKey: "item:3", itemTag: "Tool", inputItems: [{key: "item:1", tag: "Food"}, {key: "item:6", tag: "Ore"}]}], +]); + +const CRAFT_IDS = Array.from({length: 24}, (_, i) => BigInt(i + 1)); +const CLAIM_IDS = [0n, 100n, 101n, 102n]; +const PLAYER_IDS = [0n, 200n, 201n, 202n, 203n]; +const REGION_IDS = [1, 2, 3]; +const ACCOUNTS = ["acctA", "acctB"]; + +function harness(seed: number) { + const rand = rng(seed); + const int = (n: number) => Math.floor(rand() * n); + const pick = (items: readonly T[]): T => items[int(items.length)]!; + const chance = (p: number) => rand() < p; + + const cache = createRowCache(RECIPES); + const tables = { + craftMeta: fakeTable(r => String(r.entityId), cache.craftMeta), + craftProgress: fakeTable(r => String(r.entityId), cache.craftProgress), + craftContribution: fakeTable<{craftId: bigint; playerId: bigint; contribution: number}>(r => `${r.craftId}:${r.playerId}`, cache.craftContribution), + claimInfo: fakeTable(r => String(r.entityId), cache.claimInfo), + claimMember: fakeTable(r => `${r.claimEntityId}:${r.playerEntityId}`, cache.claimMember), + playerState: fakeTable(r => String(r.entityId), cache.playerState), + region: fakeTable(r => String(r.id), cache.region), + }; + const conn = {db: tables} as unknown as DbConnection; + + const operations: (() => void)[] = [ + () => { + const entityId = pick(CRAFT_IDS); + const existing = tables.craftMeta.rows.get(String(entityId)); + tables.craftMeta.upsert({ + entityId, + ownerEntityId: pick(PLAYER_IDS), + claimEntityId: pick(CLAIM_IDS), + buildingEntityId: 0n, + firstSeen: existing?.firstSeen ?? new Timestamp(BigInt(int(1000)) * 1000n), + recipeId: pick([1, 2, 3, 99]), + count: 1 + int(3), + regionId: pick(REGION_IDS), + public: chance(0.7), + status: {tag: chance(0.8) ? "Active" : pick(["Claimed", "Removed"])}, + } as CraftMeta); + }, + () => tables.craftMeta.remove(String(pick(CRAFT_IDS))), + () => tables.craftProgress.upsert({entityId: pick(CRAFT_IDS), progress: int(1000)} as CraftProgress), + () => tables.craftProgress.remove(String(pick(CRAFT_IDS))), + () => tables.craftContribution.upsert({craftId: pick(CRAFT_IDS), playerId: pick(PLAYER_IDS), contribution: int(500)}), + () => tables.craftContribution.remove(`${pick(CRAFT_IDS)}:${pick(PLAYER_IDS)}`), + () => tables.claimInfo.upsert({entityId: pick(CLAIM_IDS), name: pick(["Alpha", "Beta", ""])} as ClaimInfo), + () => tables.claimInfo.remove(String(pick(CLAIM_IDS))), + () => tables.claimMember.upsert({ + claimEntityId: pick(CLAIM_IDS), playerEntityId: pick(PLAYER_IDS), + build: chance(0.5), inventory: chance(0.5), officer: chance(0.3), coOwner: chance(0.2), owner: chance(0.4), + } as ClaimMember), + () => tables.claimMember.remove(`${pick(CLAIM_IDS)}:${pick(PLAYER_IDS)}`), + () => tables.playerState.upsert({entityId: pick(PLAYER_IDS), name: pick(["ann", "bob", "cyd"]), online: chance(0.5)} as unknown as PlayerState), + () => tables.playerState.remove(String(pick(PLAYER_IDS))), + () => tables.region.upsert({id: pick(REGION_IDS), name: pick(["North", "South", ""])} as Region), + () => tables.region.remove(String(pick(REGION_IDS))), + ]; + const randomAssignments = (): ReadonlyMap => { + const next = new Map(); + for (const id of CRAFT_IDS) { + if (!chance(0.25)) continue; + const account = pick(ACCOUNTS); + next.set(id, { + ratioNumerator: BigInt(1 + int(3)), ratioDenominator: 4n, currency: "hex-coin", private: chance(0.5), + assignedByAccountIdentity: {toHexString: () => account}, + }); + } + return next; + }; + return {rand, int, pick, chance, cache, conn, tables, operations, randomAssignments}; +} + +const WATCHES: {id: string; filter: FilterNode; owner: string | null}[] = [ + {id: "all", filter: matchAll(), owner: null}, + {id: "open", filter: openWorkFilter(), owner: "acctA"}, + {id: "access", filter: {field: "ownerAccess", cmp: "all", value: ["member", "build"]}, owner: null}, + {id: "payout-a", filter: {field: "payout", cmp: "gte", value: 0.5}, owner: "acctA"}, + {id: "payout-b", filter: {op: "or", children: [{field: "currency", cmp: "eq", value: "hex-coin"}, {field: "complete", cmp: "eq", value: true}]}, owner: "acctB"}, + {id: "payout-none", filter: {op: "not", child: {field: "payout", cmp: "gte", value: 0.5}}, owner: null}, + {id: "input", filter: {field: "inputItem", cmp: "eq", value: "item:1", quantifier: "any"}, owner: null}, + {id: "complete", filter: {field: "complete", cmp: "eq", value: true}, owner: null}, + {id: "tier", filter: {field: "tier", cmp: "gte", value: 3}, owner: "acctB"}, +]; + +function summarize(events: MatchEvent[]): string[] { + return events.map(event => `${event.kind}:${event.craftId}`).sort(); +} + +for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) { + test(`incremental rows and watch events equal the full rebuild (seed ${seed})`, () => { + const {int, chance, cache, conn, operations, randomAssignments, pick} = harness(seed); + + // Seed some state, then adopt it the way a (re)connect does. + for (let i = 0; i < 60; i++) pick(operations)(); + let assignments = randomAssignments(); + cache.load(readSnapshot(conn)); + cache.setAssignments(assignments); + const initial = cache.drain(); + assert.ok(initial.changed.length >= 0); + + const oracle = new Map(WATCHES.map(watch => [watch.id, createWatchMatcher()])); + const candidate = new Map(WATCHES.map(watch => [watch.id, createWatchMatcher()])); + const oracleRows = () => applyBounties(craftRowsFrom(readSnapshot(conn), RECIPES), assignments); + for (const watch of WATCHES) { + const view = (row: CraftRow) => rowFor(row, watch.owner); + oracle.get(watch.id)!.update(compiledFilter(watch.filter), oracleRows(), view); + candidate.get(watch.id)!.update(compiledFilter(watch.filter), [...cache.rows.values()], view); + } + + for (let batch = 0; batch < 150; batch++) { + for (let i = 1 + int(5); i > 0; i--) pick(operations)(); + if (chance(0.3)) { + assignments = randomAssignments(); + cache.setAssignments(assignments); + } + + const snapshot = readSnapshot(conn); + const expected = applyBounties(craftRowsFrom(snapshot, RECIPES), assignments); + const delta = cache.drain(); + + assert.deepEqual(new Map(expected.map(row => [row.id, row])), new Map(cache.rows), `rows after batch ${batch}`); + assert.deepEqual(snapshot.claimOwners, cache.snapshot.claimOwners, `claimOwners after batch ${batch}`); + assert.deepEqual(snapshot.contributions, cache.snapshot.contributions, `contributions after batch ${batch}`); + assert.deepEqual(snapshot.allCraftIds, cache.snapshot.allCraftIds); + assert.equal(findDrift(cache, snapshot, RECIPES), null, `drift after batch ${batch}`); + + for (const watch of WATCHES) { + const view = (row: CraftRow) => rowFor(row, watch.owner); + const filter = compiledFilter(watch.filter); + const want = oracle.get(watch.id)!.update(filter, expected, view); + const got = candidate.get(watch.id)!.applyDelta(filter, delta.changed, delta.removedIds, view); + assert.deepEqual(summarize(got), summarize(want), `${watch.id} events after batch ${batch}`); + assert.equal(candidate.get(watch.id)!.matchCount, oracle.get(watch.id)!.matchCount, `${watch.id} match count after batch ${batch}`); + const wantAdded = new Map(want.filter(e => e.kind !== "removed").map(e => [`${e.kind}:${e.craftId}`, e.craft])); + for (const event of got) { + if (event.kind !== "removed") assert.deepEqual(event.craft, wantAdded.get(`${event.kind}:${event.craftId}`)); + } + } + } + }); +} + +test("findDrift reports a table change the cache never heard about", () => { + const {cache, conn, tables} = harness(11); + tables.craftMeta.rows.set("1", { + entityId: 1n, ownerEntityId: 0n, claimEntityId: 0n, buildingEntityId: 0n, firstSeen: new Timestamp(0n), + recipeId: 1, count: 1, regionId: 1, public: true, status: {tag: "Active"}, + } as CraftMeta); + cache.load(readSnapshot(conn)); + cache.drain(); + assert.equal(findDrift(cache, readSnapshot(conn), RECIPES), null); + + tables.craftProgress.rows.set("1", {entityId: 1n, progress: 7} as CraftProgress); + const drift = findDrift(cache, readSnapshot(conn), RECIPES); + assert.deepEqual(drift, {first: "craft 1", crafts: 1}); + + tables.craftMeta.rows.delete("1"); + assert.match(findDrift(cache, readSnapshot(conn), RECIPES)?.first ?? "", /craft ids.*craft 1 \(not open\)/); +}); + +test("drain reports the players whose claim membership changed and the crafts deleted outright, then resets", () => { + const {cache, conn, tables} = harness(12); + cache.load(readSnapshot(conn)); + cache.drain(); + + const craft = (status: "Active" | "Claimed") => ({ + entityId: 1n, ownerEntityId: 200n, claimEntityId: 100n, buildingEntityId: 0n, firstSeen: new Timestamp(0n), + recipeId: 1, count: 1, regionId: 1, public: true, status: {tag: status}, + }) as CraftMeta; + tables.craftMeta.upsert(craft("Active")); + tables.claimMember.upsert({claimEntityId: 100n, playerEntityId: 200n, build: true, inventory: false, officer: false, coOwner: false, owner: false} as ClaimMember); + let delta = cache.drain(); + assert.deepEqual([...delta.memberPlayers], [200n]); + assert.equal(delta.deletedCrafts.size, 0); + + tables.craftMeta.upsert(craft("Claimed")); + delta = cache.drain(); + assert.deepEqual(delta.removedIds, ["1"]); + assert.equal(delta.deletedCrafts.size, 0, "closing a craft is not deleting it"); + assert.equal(delta.memberPlayers.size, 0); + + tables.claimMember.remove("100:200"); + tables.craftMeta.remove("1"); + delta = cache.drain(); + assert.deepEqual([...delta.memberPlayers], [200n]); + assert.deepEqual([...delta.deletedCrafts], [1n]); +}); diff --git a/backend/brico-bot/src/relay/row-cache.ts b/backend/brico-bot/src/relay/row-cache.ts new file mode 100644 index 00000000..1eced660 --- /dev/null +++ b/backend/brico-bot/src/relay/row-cache.ts @@ -0,0 +1,379 @@ +/** + * row-cache.ts — the relay tables mirrored from row callbacks, with each open craft's `CraftRow` + * rebuilt only when something it depends on changes. + * + * Callbacks only record what became dirty; `drain()` does the work and returns the rows whose match + * outcome may have changed. `readSnapshot` + `craftRowsFrom` + `applyBounties` is the full-rebuild + * equivalent and the oracle `row-cache.test.ts` checks this against. + */ +import type {ClaimInfo, ClaimMember, CraftMeta, CraftProgress, PlayerState, Region} from "@brico/bindings/prism/types"; + +import {isDeepStrictEqual} from "node:util"; + +import type {RecipeIndex} from "../game-data/recipes.ts"; +import {keyedFeed, type TableFeed} from "../spacetime/feed.ts"; +import type {CraftSnapshot} from "./prism.ts"; +import {applyBounty, type AssignedBounty, buildRow, type CraftRow, craftRowsFrom, displayFields} from "./subject.ts"; + +/** What `drain()` found changed since the previous drain. */ +export interface RowDelta { + /** True after `load`: every row is in `changed` and consumers should re-evaluate from scratch rather than apply a delta. */ + full: boolean; + /** Rows (with bounties applied) that are new or whose match outcome may differ; feed to `applyDelta`. */ + changed: CraftRow[]; + /** Craft ids that left the open set. */ + removedIds: string[]; + /** Crafts whose contributions changed; the entitlement step's work list. */ + contributionCrafts: Set; + /** Players with a changed `claim_member` row; their automated loyalty bonuses may resolve differently. */ + memberPlayers: Set; + /** Crafts whose `craft_meta` row was deleted outright (not merely closed), whose stored bounty assignments can go. */ + deletedCrafts: Set; + /** Crafts in claims whose list of owners changed; their rows are unchanged but whose bounty resolution may differ. */ + ownerChangedCrafts: Set; +} + +export interface RowCache { + readonly craftMeta: TableFeed; + readonly craftProgress: TableFeed; + readonly craftContribution: TableFeed<{craftId: bigint; playerId: bigint; contribution: number | bigint}>; + readonly claimInfo: TableFeed; + readonly claimMember: TableFeed; + readonly playerState: TableFeed; + readonly region: TableFeed; + + /** Replaces everything with `snapshot`'s contents and marks every open craft dirty. */ + load(snapshot: CraftSnapshot): void; + /** + * Adopts `assignments` and dirties each craft whose bounty changed. With `changed` (the crafts the + * caller already knows differ), those are dirtied without diffing, and `assignments` is read live + * afterwards rather than copied, so the caller must keep it current. + */ + setAssignments(assignments: ReadonlyMap, changed?: Iterable): void; + drain(): RowDelta; + + /** Every open craft's current row, bounties applied, keyed by `CraftRow.id`. */ + readonly rows: ReadonlyMap; + /** The same rows without bounties — what bounty resolution evaluates. */ + readonly baseRows: ReadonlyMap; + /** Live view of the mirrored tables (`crafts` is not maintained; use `rows`). Maps are mutated by callbacks, so read it synchronously. */ + readonly snapshot: CraftSnapshot; +} + +function sameBounty(a: AssignedBounty, b: AssignedBounty): boolean { + return a.ratioNumerator === b.ratioNumerator + && a.ratioDenominator === b.ratioDenominator + && a.currency === b.currency + && a.private === b.private + && a.assignedByAccountIdentity.toHexString() === b.assignedByAccountIdentity.toHexString(); +} + +function addTo(index: Map>, key: K, id: bigint): void { + const set = index.get(key); + if (set) set.add(id); + else index.set(key, new Set([id])); +} + +function removeFrom(index: Map>, key: K, id: bigint): void { + const set = index.get(key); + if (!set) return; + set.delete(id); + if (set.size === 0) index.delete(key); +} + +export function createRowCache(recipes: RecipeIndex): RowCache { + const openCrafts = new Map(); + const 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, + }; + // `claimEntityId -> playerEntityId -> row`, in table order, from which `claimOwners` is rebuilt per claim. + const membersByClaim = new Map>(); + + const baseRows = new Map(); + const rows = new Map(); + let assignments: ReadonlyMap = new Map(); + + // Reverse indexes over `baseRows`, keyed like the maps they depend on. + const craftsByClaimOwner = new Map>(); + const craftsByClaim = new Map>(); + const craftsByOwner = new Map>(); + + let subjectDirty = new Set(); + let displayDirty = new Set(); + let contributionCrafts = new Set(); + let memberPlayers = new Set(); + let deletedCrafts = new Set(); + let ownerChangedCrafts = new Set(); + let fullPending = false; + + const dirtyAll = (index: Map>, key: K, into: Set) => { + const ids = index.get(key); + if (ids) for (const id of ids) into.add(id); + }; + + function unindex(row: CraftRow): void { + if (row.claimEntityId !== 0n) { + removeFrom(craftsByClaim, row.claimEntityId, row.entityId); + if (row.ownerEntityId !== 0n) removeFrom(craftsByClaimOwner, `${row.claimEntityId}:${row.ownerEntityId}`, row.entityId); + } + if (row.ownerEntityId !== 0n) removeFrom(craftsByOwner, row.ownerEntityId, row.entityId); + } + + function index(row: CraftRow): void { + if (row.claimEntityId !== 0n) { + addTo(craftsByClaim, row.claimEntityId, row.entityId); + if (row.ownerEntityId !== 0n) addTo(craftsByClaimOwner, `${row.claimEntityId}:${row.ownerEntityId}`, row.entityId); + } + if (row.ownerEntityId !== 0n) addTo(craftsByOwner, row.ownerEntityId, row.entityId); + } + + function setMember(row: ClaimMember): void { + snapshot.claimMembers.set(`${row.claimEntityId}:${row.playerEntityId}`, row); + let members = membersByClaim.get(row.claimEntityId); + if (!members) membersByClaim.set(row.claimEntityId, (members = new Map())); + members.set(row.playerEntityId, row); + refreshOwners(row.claimEntityId); + dirtyAll(craftsByClaimOwner, `${row.claimEntityId}:${row.playerEntityId}`, subjectDirty); + memberPlayers.add(row.playerEntityId); + } + + function deleteMember(row: ClaimMember): void { + snapshot.claimMembers.delete(`${row.claimEntityId}:${row.playerEntityId}`); + const members = membersByClaim.get(row.claimEntityId); + members?.delete(row.playerEntityId); + if (members?.size === 0) membersByClaim.delete(row.claimEntityId); + refreshOwners(row.claimEntityId); + dirtyAll(craftsByClaimOwner, `${row.claimEntityId}:${row.playerEntityId}`, subjectDirty); + memberPlayers.add(row.playerEntityId); + } + + function refreshOwners(claimId: bigint): void { + const owners: bigint[] = []; + for (const member of membersByClaim.get(claimId)?.values() ?? []) if (member.owner) owners.push(member.playerEntityId); + const previous = snapshot.claimOwners.get(claimId) ?? []; + if (owners.length > 0) snapshot.claimOwners.set(claimId, owners); + else snapshot.claimOwners.delete(claimId); + if (owners.length !== previous.length || owners.some((owner, i) => owner !== previous[i])) dirtyAll(craftsByClaim, claimId, ownerChangedCrafts); + } + + function setContribution(row: {craftId: bigint; playerId: bigint; contribution: number | bigint}): void { + let byPlayer = snapshot.contributions.get(row.craftId); + if (!byPlayer) snapshot.contributions.set(row.craftId, (byPlayer = new Map())); + byPlayer.set(row.playerId, BigInt(row.contribution)); + contributionCrafts.add(row.craftId); + } + + function deleteContribution(row: {craftId: bigint; playerId: bigint}): void { + const byPlayer = snapshot.contributions.get(row.craftId); + byPlayer?.delete(row.playerId); + if (byPlayer?.size === 0) snapshot.contributions.delete(row.craftId); + contributionCrafts.add(row.craftId); + } + + function setCraft(row: CraftMeta): void { + snapshot.allCraftIds.add(row.entityId); + if (row.status.tag === "Active") openCrafts.set(row.entityId, row); + else openCrafts.delete(row.entityId); + subjectDirty.add(row.entityId); + } + + function deleteCraft(row: CraftMeta): void { + snapshot.allCraftIds.delete(row.entityId); + openCrafts.delete(row.entityId); + subjectDirty.add(row.entityId); + deletedCrafts.add(row.entityId); + } + + function regionRowsDirty(regionId: number): void { + for (const row of baseRows.values()) if (row.regionId === regionId) displayDirty.add(row.entityId); + } + + function finalRow(base: CraftRow): CraftRow { + const bounty = assignments.get(base.entityId); + return bounty ? applyBounty(base, bounty) : base; + } + + return { + craftMeta: keyedFeed(row => row.entityId, setCraft, deleteCraft), + craftProgress: keyedFeed( + row => row.entityId, + row => { + snapshot.progress.set(row.entityId, row); + subjectDirty.add(row.entityId); + }, + row => { + snapshot.progress.delete(row.entityId); + subjectDirty.add(row.entityId); + }, + ), + craftContribution: keyedFeed(row => `${row.craftId}:${row.playerId}`, setContribution, deleteContribution), + claimInfo: keyedFeed( + row => row.entityId, + row => { + snapshot.claims.set(row.entityId, row); + dirtyAll(craftsByClaim, row.entityId, displayDirty); + }, + row => { + snapshot.claims.delete(row.entityId); + dirtyAll(craftsByClaim, row.entityId, displayDirty); + }, + ), + claimMember: keyedFeed(row => `${row.claimEntityId}:${row.playerEntityId}`, setMember, deleteMember), + playerState: { + insert(row) { + snapshot.players.set(row.entityId, row); + dirtyAll(craftsByOwner, row.entityId, displayDirty); + }, + // Only a rename changes a row; `online`/region updates just replace the stored row. + update(before, after) { + snapshot.players.set(after.entityId, after); + if (before.name !== after.name) dirtyAll(craftsByOwner, after.entityId, displayDirty); + }, + delete(row) { + snapshot.players.delete(row.entityId); + dirtyAll(craftsByOwner, row.entityId, displayDirty); + }, + }, + region: keyedFeed( + row => row.id, + row => { + snapshot.regions.set(row.id, row); + regionRowsDirty(row.id); + }, + row => { + snapshot.regions.delete(row.id); + regionRowsDirty(row.id); + }, + ), + + load(source) { + openCrafts.clear(); + for (const craft of source.crafts) openCrafts.set(craft.entityId, craft); + snapshot.allCraftIds = new Set(source.allCraftIds); + snapshot.progress = new Map(source.progress); + snapshot.claims = new Map(source.claims); + snapshot.claimMembers = new Map(source.claimMembers); + snapshot.contributions = new Map([...source.contributions].map(([craftId, byPlayer]) => [craftId, new Map(byPlayer)])); + snapshot.claimOwners = new Map(); + snapshot.players = new Map(source.players); + snapshot.regions = new Map(source.regions); + membersByClaim.clear(); + for (const member of source.claimMembers.values()) { + let members = membersByClaim.get(member.claimEntityId); + if (!members) membersByClaim.set(member.claimEntityId, (members = new Map())); + members.set(member.playerEntityId, member); + } + for (const claimId of membersByClaim.keys()) refreshOwners(claimId); + + baseRows.clear(); + rows.clear(); + craftsByClaimOwner.clear(); + craftsByClaim.clear(); + craftsByOwner.clear(); + subjectDirty = new Set(openCrafts.keys()); + displayDirty = new Set(); + contributionCrafts = new Set(snapshot.contributions.keys()); + memberPlayers = new Set(); + deletedCrafts = new Set(); + ownerChangedCrafts = new Set(); + fullPending = true; + }, + + setAssignments(next, changed) { + if (changed) { + for (const craftId of changed) subjectDirty.add(craftId); + assignments = next; + return; + } + for (const [craftId, bounty] of next) { + const previous = assignments.get(craftId); + if (!previous || !sameBounty(previous, bounty)) subjectDirty.add(craftId); + } + for (const craftId of assignments.keys()) if (!next.has(craftId)) subjectDirty.add(craftId); + assignments = new Map(next); + }, + + drain() { + const changed: CraftRow[] = []; + const removedIds: string[] = []; + + for (const craftId of subjectDirty) { + const craft = openCrafts.get(craftId); + const id = craftId.toString(); + const previous = baseRows.get(id); + if (previous) unindex(previous); + if (!craft) { + if (previous) { + baseRows.delete(id); + rows.delete(id); + removedIds.push(id); + } + continue; + } + const base = buildRow(craft, snapshot, recipes); + baseRows.set(id, base); + index(base); + const row = finalRow(base); + rows.set(id, row); + changed.push(row); + } + + for (const craftId of displayDirty) { + if (subjectDirty.has(craftId)) continue; + const id = craftId.toString(); + const previous = baseRows.get(id); + if (!previous) continue; + const base = {...previous, ...displayFields(previous.regionId, previous.claimEntityId, previous.ownerEntityId, snapshot)}; + baseRows.set(id, base); + rows.set(id, finalRow(base)); + } + + const delta = {full: fullPending, changed, removedIds, contributionCrafts, memberPlayers, deletedCrafts, ownerChangedCrafts}; + fullPending = false; + subjectDirty = new Set(); + displayDirty = new Set(); + contributionCrafts = new Set(); + memberPlayers = new Set(); + deletedCrafts = new Set(); + ownerChangedCrafts = new Set(); + return delta; + }, + + rows, + baseRows, + snapshot, + }; +} + +/** + * The first way `cache` (as of its last `drain`) disagrees with `truth`, a snapshot read straight + * from the relay tables, plus how many crafts differ; `null` when they agree. Rows are compared + * without bounties, which are derived from the assignments the cache is handed. + */ +export function findDrift(cache: RowCache, truth: CraftSnapshot, recipes: RecipeIndex): {first: string; crafts: number} | null { + const problems: string[] = []; + if (!isDeepStrictEqual(truth.allCraftIds, cache.snapshot.allCraftIds)) problems.push("craft ids"); + if (!isDeepStrictEqual(truth.contributions, cache.snapshot.contributions)) problems.push("contributions"); + if (!isDeepStrictEqual(truth.claimOwners, cache.snapshot.claimOwners)) problems.push("claim owners"); + + let crafts = 0; + const noteCraft = (label: string) => { + if (crafts++ === 0) problems.push(label); + }; + const expectedIds = new Set(); + for (const row of craftRowsFrom(truth, recipes)) { + expectedIds.add(row.id); + if (!isDeepStrictEqual(row, cache.baseRows.get(row.id))) noteCraft(`craft ${row.id}`); + } + for (const id of cache.baseRows.keys()) if (!expectedIds.has(id)) noteCraft(`craft ${id} (not open)`); + return problems.length === 0 ? null : {first: problems.join(", "), crafts}; +} diff --git a/backend/brico-bot/src/relay/subject.test.ts b/backend/brico-bot/src/relay/subject.test.ts new file mode 100644 index 00000000..58a18810 --- /dev/null +++ b/backend/brico-bot/src/relay/subject.test.ts @@ -0,0 +1,56 @@ +/** + * subject.test.ts — how resolved bounties are layered onto already-built craft rows. + */ +import assert from "node:assert/strict"; +import {test} from "node:test"; + +import {applyBounties, type AssignedBounty, type CraftRow, rowFor} from "./subject.ts"; + +function row(entityId: bigint): CraftRow { + return { + id: entityId.toString(), + entityId, + claimEntityId: 0n, + ownerEntityId: 0n, + regionId: 1, + regionName: "r", + recipeId: 1, + count: 1, + claimName: null, + ownerName: null, + firstSeenMs: 0n, + subject: { + region: 1, claim: null, item: null, itemTag: null, inputItems: [], skill: null, tier: null, buildingType: null, + effortTotal: 10, effortRemaining: 10, public: true, complete: false, owner: null, ownerClaimAccess: null, + payout: null, currency: null, bountyPrivate: false, + }, + privateBountyOwner: null, + publicView: null, + }; +} + +const bounty = (isPrivate: boolean): AssignedBounty => ({ + ratioNumerator: 1n, ratioDenominator: 4n, currency: "hex-coin", private: isPrivate, + assignedByAccountIdentity: {toHexString: () => "assigner"}, +}); + +test("applyBounties leaves unbountied rows untouched and returns the same array when there are no bounties", () => { + const rows = [row(1n), row(2n)]; + assert.equal(applyBounties(rows, new Map()), rows); + const applied = applyBounties(rows, new Map([[2n, bounty(false)]])); + assert.equal(applied[0], rows[0]); + assert.equal(applied[1].subject.payout, 0.25); + assert.equal(applied[1].subject.currency, "hex-coin"); + assert.equal(applied[1].publicView, null); + assert.equal(rows[1].subject.payout, null, "the input row is not mutated"); +}); + +test("a private bounty gets a public view equal to the unbountied row, visible only to its assigner", () => { + const rows = [row(1n)]; + const [applied] = applyBounties(rows, new Map([[1n, bounty(true)]])); + assert.equal(applied.subject.bountyPrivate, true); + assert.equal(applied.privateBountyOwner, "assigner"); + assert.equal(rowFor(applied, "assigner"), applied); + assert.equal(rowFor(applied, "someone-else"), rows[0]); + assert.equal(rowFor(applied, null).subject.payout, null); +}); diff --git a/backend/brico-bot/src/relay/subject.ts b/backend/brico-bot/src/relay/subject.ts new file mode 100644 index 00000000..fdfe12df --- /dev/null +++ b/backend/brico-bot/src/relay/subject.ts @@ -0,0 +1,121 @@ +/** + * 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 {CraftMeta} from "@brico/bindings/prism/types"; +import type {CraftSubject} from "@brico/crafts/filter"; +import {claimDisplayName, regionDisplayName} from "@brico/crafts/names"; +import {bountyFields, 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; + /** `id` and the claim/owner ids (`0n` when absent) as bigints, so per-craft lookups don't re-parse strings. */ + entityId: bigint; + claimEntityId: bigint; + ownerEntityId: bigint; + regionId: number; + regionName: string; + recipeId: number; + count: number; + claimName: string | null; + ownerName: string | null; + /** `craft_meta.firstSeen` in millis. */ + firstSeenMs: bigint; + subject: CraftSubject; + /** Hex identity of the account that assigned this craft's bounty, only when that bounty is private. */ + privateBountyOwner: string | null; + /** This row as everyone but `privateBountyOwner` sees it (bounty cleared); `null` unless the bounty is private. */ + publicView: CraftRow | null; +} + +/** A resolved bounty plus the account it came from, which decides who may see it if private. */ +export interface AssignedBounty extends CraftBountyFacts { + assignedByAccountIdentity: {toHexString(): string}; +} + +/** The row `viewerAccountHex` may see: a private bounty is hidden from everyone but its assigner. Allocation-free. */ +export function rowFor(row: CraftRow, viewerAccountHex: string | null): CraftRow { + return row.publicView !== null && row.privateBountyOwner !== viewerAccountHex ? row.publicView : row; +} + +/** The relay tables a row is joined from. */ +export type RowInputs = Pick; + +/** The display-only fields of a row; changing them never changes what a filter matches. */ +export function displayFields(regionId: number, claimEntityId: bigint, ownerEntityId: bigint, inputs: Pick): Pick { + const claim = claimEntityId === 0n ? undefined : inputs.claims.get(claimEntityId); + const owner = ownerEntityId === 0n ? undefined : inputs.players.get(ownerEntityId); + return { + regionName: regionDisplayName(inputs.regions.get(regionId)?.name, regionId), + claimName: claim ? claimDisplayName(claim.name) : null, + ownerName: owner?.name ?? null, + }; +} + +/** One craft as a row with no bounty. */ +export function buildRow(craft: CraftMeta, inputs: RowInputs, recipes: RecipeIndex): CraftRow { + const recipe = recipes.get(craft.recipeId); + const progress = inputs.progress.get(craft.entityId); + const ownerClaimMember = inputs.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, + ); + + return { + id: craft.entityId.toString(), + entityId: craft.entityId, + claimEntityId: craft.claimEntityId, + ownerEntityId: craft.ownerEntityId, + regionId: craft.regionId, + recipeId: craft.recipeId, + count: craft.count, + firstSeenMs: craft.firstSeen.toMillis(), + subject, + privateBountyOwner: null, + publicView: null, + ...displayFields(craft.regionId, craft.claimEntityId, craft.ownerEntityId, inputs), + } satisfies CraftRow; +} + +/** Every open craft as a row with no bounty; `applyBounties` layers resolved bounties on top. */ +export function craftRowsFrom(snapshot: CraftSnapshot, recipes: RecipeIndex): CraftRow[] { + return snapshot.crafts.map(craft => buildRow(craft, snapshot, recipes)); +} + +/** `row` (which must carry no bounty) with `bounty` applied. */ +export function applyBounty(row: CraftRow, bounty: AssignedBounty): CraftRow { + const visible: CraftRow = {...row, subject: {...row.subject, ...bountyFields(bounty)}}; + if (!bounty.private) return visible; + return {...visible, privateBountyOwner: bounty.assignedByAccountIdentity.toHexString(), publicView: row}; +} + +/** + * `rows` with each craft's resolved bounty applied. Rows without a bounty are returned as-is (same + * objects), so this costs a pass over the array plus one allocation per bountied craft. + */ +export function applyBounties(rows: readonly CraftRow[], assignments: ReadonlyMap): CraftRow[] { + if (assignments.size === 0) return rows as CraftRow[]; + return rows.map(row => { + const bounty = assignments.get(row.entityId); + return bounty ? applyBounty(row, bounty) : row; + }); +} 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/feed.ts b/backend/brico-bot/src/spacetime/feed.ts new file mode 100644 index 00000000..0bd9d9d1 --- /dev/null +++ b/backend/brico-bot/src/spacetime/feed.ts @@ -0,0 +1,57 @@ +/** + * feed.ts — the shape of a table mirror's row feed, and the wiring from SpacetimeDB row callbacks into it. + */ + +/** What a table's row callbacks feed in. `update` carries the whole before/after pair like the SDK's `onUpdate`. */ +export interface TableFeed { + insert(row: T): void; + update(before: T, after: T): void; + delete(row: T): void; +} + +/** The slice of a generated table the row callbacks attach to. */ +export interface RowEvents { + onInsert(cb: (ctx: {event: {tag: string}}, row: T) => void): void; + onDelete(cb: (ctx: {event: {tag: string}}, row: T) => void): void; + onUpdate(cb: (ctx: {event: {tag: string}}, before: T, after: T) => void): void; +} + +/** + * Forwards `table`'s row callbacks to `target` whenever `accepts` allows, then calls `onChange` + * (for an update, only when `changed(before, after)`). + */ +export function attachFeed( + table: RowEvents, + target: TableFeed, + accepts: (ctx: {event: {tag: string}}) => boolean, + onChange: () => void = () => {}, + changed: (before: T, after: T) => boolean = () => true, +): void { + table.onInsert((ctx, row) => { + if (!accepts(ctx)) return; + target.insert(row); + onChange(); + }); + table.onDelete((ctx, row) => { + if (!accepts(ctx)) return; + target.delete(row); + onChange(); + }); + table.onUpdate((ctx, before, after) => { + if (!accepts(ctx)) return; + target.update(before, after); + if (changed(before, after)) onChange(); + }); +} + +/** A feed for a table keyed by `key`: an update that moves a row to a different key removes the old key first, like the SDK's table cache. */ +export function keyedFeed(key: (row: T) => unknown, set: (row: T) => void, remove: (row: T) => void): TableFeed { + return { + insert: set, + update: (before, after) => { + if (key(before) !== key(after)) remove(before); + set(after); + }, + delete: remove, + }; +} 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/src/testing/fake-table.ts b/backend/brico-bot/src/testing/fake-table.ts new file mode 100644 index 00000000..76f21790 --- /dev/null +++ b/backend/brico-bot/src/testing/fake-table.ts @@ -0,0 +1,24 @@ +/** + * fake-table.ts — an in-memory keyed table for tests that fires a `TableFeed` on every operation, like the SDK's row callbacks. + */ +import type {TableFeed} from "../spacetime/feed.ts"; + +export function fakeTable(key: (row: T) => string, feed: TableFeed) { + const rows = new Map(); + return { + rows, + iter: () => rows.values(), + upsert(row: T) { + const before = rows.get(key(row)); + rows.set(key(row), row); + if (before) feed.update(before, row); + else feed.insert(row); + }, + remove(k: string) { + const before = rows.get(k); + if (!before) return; + rows.delete(k); + feed.delete(before); + }, + }; +} 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..9f5be953 --- /dev/null +++ b/backend/brico-module/.gitignore @@ -0,0 +1,8 @@ +# `spacetime build`/`publish` bundle the module here. Build output, not source. +spacetimedb/dist/ + +spacetime.local.json + +# import-payouts.ts's own SpacetimeDB connection identity/token. only a localhost-signed, but no reason to commit. +scripts/*.token +scripts/*.csv \ 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.ts b/backend/brico-module/scripts/import-payouts.ts new file mode 100644 index 00000000..20dd8616 --- /dev/null +++ b/backend/brico-module/scripts/import-payouts.ts @@ -0,0 +1,240 @@ +#!/usr/bin/env -S npx tsx +// @ts-nocheck +// Imports one payer's historical `bounty_entitlement_total` / `bounty_payout_record` rows from +// spreadsheet CSV exports via the service-principal-gated `import_historical_bounty_ledger` +// reducer (`spacetimedb/src/reducers/payouts.ts`), using the generated bindings + SpacetimeDB SDK +// directly rather than shelling out to the `spacetime` CLI — the whole CSV goes over in one +// reducer call instead of one `spacetime call` per row. +// +// The connecting identity must already be registered as a service principal (see +// `register_service_principal`); the first run against a fresh identity prints the exact command +// to do that and exits. +// +// Usage (from this directory, or `backend/brico-module/spacetimedb` via `npm run import:payouts --`): +// npx tsx import-payouts.ts [options] +// +// Options: +// --entitled-and-paid= entityId,name,totalEffort,coins,paidOut CSV (default: entitled_and_paid.csv) +// --host= SpacetimeDB host URI (default: http://127.0.0.1:3000) +// --database= module/database name (default: brico-app) +// --token-file= where to persist this script's own connection identity's auth token +// (default: .import-payouts.token next to this script) +// --dry-run parse and print the rows without connecting or calling the reducer + +import {DbConnection} from '@brico/bindings/brico-app'; +import type {HistoricalBountyLedgerRow} from '@brico/bindings/brico-app/types'; +import {csvParse} from 'd3-dsv'; +import * as fs from 'node:fs'; +import {readFileSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {Identity} from 'spacetimedb'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + +// 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'; + +interface Options { + entitledAndPaid: string; + host: string; + database: string; + tokenFile: string; + dryRun: boolean; + payerIdentity: string; +} + +function parseArgs(argv: readonly string[]): Options { + const options = { + entitledAndPaid: path.join(scriptDir, 'entitled_and_paid.csv'), + host: 'http://127.0.0.1:3000', + database: 'brico-app', + tokenFile: path.join(scriptDir, '.import-payouts.token'), + dryRun: false, + }; + const positional: string[] = []; + 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('--host=')) options.host = arg.slice('--host='.length); + else if (arg.startsWith('--database=')) options.database = arg.slice('--database='.length); + else if (arg.startsWith('--token-file=')) options.tokenFile = arg.slice('--token-file='.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.ts [--entitled-and-paid=path] ' + + '[--host=uri] [--database=name] [--token-file=path] [--dry-run]' + ); + } + return {...options, payerIdentity: normalizeIdentity(positional[0])}; +} + +function normalizeIdentity(raw: string): string { + 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 (thousands separators + * stripped first) — 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: string): bigint | null { + const trimmed = raw.trim().replace(/,/g, ''); + return /^[0-9]+$/.test(trimmed) ? BigInt(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. + * Thousands separators (e.g. "1,234.56") are stripped before parsing. + */ +function floorDecimalToBigInt(raw: string | undefined): bigint { + const trimmed = (raw ?? '').trim().replace(/,/g, ''); + 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; +} + +/** Parses an RFC4180 CSV (quoted fields may contain commas). */ +function parseCsv(filePath: string): {rows: Record[]; requireColumns: (...names: string[]) => void} { + if (!fs.existsSync(filePath)) return {rows: [], requireColumns: () => {}}; + const text = readFileSync(filePath, 'utf8'); + const parsed = csvParse(text); + const requireColumns = (...names: string[]) => { + for (const name of names) { + if (!parsed.columns.includes(name)) { + throw new Error(`${filePath}: missing expected column "${name}" (found: ${parsed.columns.join(', ')})`); + } + } + }; + return {rows: parsed, requireColumns}; +} + +function buildRows(options: Options): {rows: HistoricalBountyLedgerRow[]; skipped: number} { + const {rows: csvRows, requireColumns} = parseCsv(options.entitledAndPaid); + requireColumns('entityId', 'totalEffort', 'coins', 'paidOut'); + + const rows: HistoricalBountyLedgerRow[] = []; + let skipped = 0; + for (const row of csvRows) { + const payeePlayerId = parsePlayerId(row.entityId ?? ''); + if (payeePlayerId === null) { + console.warn(`entitled_and_paid: skipping row with unusable player id: ${JSON.stringify(row)}`); + skipped++; + continue; + } + rows.push({ + payeePlayerId, + currency: CURRENCY, + totalEffort: floorDecimalToBigInt(row.totalEffort), + total: floorDecimalToBigInt(row.coins), + paidTotal: floorDecimalToBigInt(row.paidOut), + }); + } + return {rows, skipped}; +} + +/** Same file-based, 0600 persistence `backend/brico-bot` uses for its own connection tokens. */ +function readToken(tokenFile: string): string | undefined { + try { + const token = readFileSync(tokenFile, 'utf8').trim(); + return token === '' ? undefined : token; + } catch { + return undefined; + } +} + +function writeToken(tokenFile: string, token: string): void { + fs.writeFileSync(tokenFile, token, {encoding: 'utf8', mode: 0o600}); +} + +interface Connected { + conn: DbConnection; + identityHex: string; +} + +function connect(options: Options): Promise { + return new Promise((resolve, reject) => { + DbConnection.builder() + .withUri(options.host) + .withDatabaseName(options.database) + .withToken(readToken(options.tokenFile)) + .onConnect((conn, identity, token) => { + writeToken(options.tokenFile, token); + const identityHex = identity.toHexString(); + console.log(`connected as ${identityHex}`); + resolve({conn, identityHex}); + }) + .onConnectError((_ctx, error) => { + reject(new Error(`connect error: ${error?.message ?? 'unknown'}`)); + }) + .build(); + }); +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + const {rows, skipped} = buildRows(options); + + if (options.dryRun) { + console.log(`bounty ledger: would import ${rows.length} row(s) for payer ${options.payerIdentity}, ${skipped} skipped`); + for (const row of rows) { + console.log(JSON.stringify({ + payeePlayerId: row.payeePlayerId.toString(), + currency: row.currency, + totalEffort: row.totalEffort.toString(), + total: row.total.toString(), + paidTotal: row.paidTotal.toString(), + })); + } + return; + } + + if (rows.length === 0) { + console.log(`bounty ledger: nothing to import (${skipped} skipped)`); + return; + } + + const {conn, identityHex} = await connect(options); + try { + await conn.reducers.importHistoricalBountyLedger({ + payerAccountIdentity: new Identity(options.payerIdentity), + rows, + }); + console.log(`bounty ledger: imported ${rows.length} row(s), ${skipped} skipped`); + } catch (cause) { + if (cause instanceof Error && cause.message === 'NOT_SERVICE_PRINCIPAL') { + throw new Error( + `this script's own identity (${identityHex}) is not a registered service principal — register it once with: ` + + `spacetime call ${options.database} register_service_principal '"${identityHex}"' '"import-payouts"'` + ); + } + throw cause; + } finally { + conn.disconnect(); + } +} + +try { + await 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..58bfffa6 --- /dev/null +++ b/backend/brico-module/spacetimedb/package-lock.json @@ -0,0 +1,258 @@ +{ + "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": { + "@types/d3-dsv": "^3.0.7", + "d3-dsv": "^3.0.1", + "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/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "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/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "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/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "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..37dc9b6b --- /dev/null +++ b/backend/brico-module/spacetimedb/package.json @@ -0,0 +1,23 @@ +{ + "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 --yes --lang typescript --out-dir ../../common/bindings/brico-app --module-path ./spacetimedb", + "import:payouts": "tsx ../scripts/import-payouts.ts", + "seed:dev": "bash ../scripts/seed-dev-data.sh" + }, + "dependencies": { + "@brico/crafts": "file:../../../common/crafts", + "spacetimedb": "2.10.0" + }, + "devDependencies": { + "@types/d3-dsv": "^3.0.7", + "d3-dsv": "^3.0.1", + "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..3b4ce62c --- /dev/null +++ b/backend/brico-module/spacetimedb/src/index.ts @@ -0,0 +1,33 @@ +/* + * 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 + * - discord: Discord bot watch displays, notification sinks and guild installs + * - notify: per-(filter, sink) notification triggers + */ + +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 './reducers/discord'; +export * from './reducers/notify'; + +export * from './views/accounts'; +export * from './views/integrations'; +export * from './views/crafts'; +export * from './views/notifications'; +export * from './views/bounties'; +export * from './views/payouts'; +export * from './views/discord'; +export * from './views/notify'; 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..4e72b436 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/reducers/bounties.ts @@ -0,0 +1,299 @@ +// 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']; + +/** One contributor's pre-bounty effort on a craft, see `assignCraftBounty`'s `baselines`. */ +const AssignmentBaseline = t.object('AssignmentBaseline', { + playerId: t.u64(), + effort: t.i64(), +}); + +/** + * 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. + * + * `assignedAt` is only ever set on a true insert — an update *preserves* whatever the row already + * had, even though the bot passes a fresh `Timestamp.now()` on every call, including plain ratio/ + * payer/currency edits. That's what makes it mean "assigned (continuously) since," not "last edited" + * (`updatedAt` already covers that): `upsertCraftBountyEntitlement` compares it against a + * contributor's own `craft_bounty_entitlement.updatedAt` to tell an ordinary edit of a still-active + * bounty (no gap, price normally) apart from a bounty that was cleared and only later reassigned + * (a real gap, whose effort must not retroactively price) — a plain ratio edit must never look like + * a gap, or every rate change would re-trigger the same retroactive-repricing bug this was meant to + * close. + * + * `baselines` (contributors' effort before this bounty) is applied only when this call creates the + * assignment: each listed player without a `craft_bounty_entitlement` row gets one at that effort + * (with `assignedAt` as `updatedAt`, so it is not seen as a gap), so `upsertCraftBountyEntitlement` + * prices only later effort. Omit players whose existing effort should be paid in full. + * + * `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). Switching tables is itself a delete-then-insert, so it resets + * `assignedAt` in the table gaining the row — same as any other clear-then-reassign. + */ +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(), + baselines: t.array(AssignmentBaseline), + }, + (ctx, {craftId, ratioNumerator, ratioDenominator, currency, assignedByAccountIdentity, private: isPrivate, assignedAt, updatedAt, baselines}) => { + requireServicePrincipal(ctx); + validateBountyCurrency(currency); + if (ctx.db.account.identity.find(assignedByAccountIdentity) === null) { + throw new SenderError(CraftError.UNKNOWN_BRICO_ACCOUNT); + } + + if (ctx.db.craft_bounty_assignment.craftId.find(craftId) === null + && ctx.db.craft_private_bounty_assignment.craftId.find(craftId) === null) { + for (const {playerId, effort} of baselines) { + if ([...ctx.db.craft_bounty_entitlement.by_craft_player_currency.filter([craftId, playerId, currency])].length > 0) continue; + ctx.db.craft_bounty_entitlement.insert({id: 0n, craftId, playerId, currency, lastAssignedEffort: effort, updatedAt: assignedAt}); + } + } + + 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, 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, 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..e2a2cfde --- /dev/null +++ b/backend/brico-module/spacetimedb/src/reducers/crafts.ts @@ -0,0 +1,194 @@ +// 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, requireServicePrincipal} 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}); + } +); + +// --------------------------------------------------------------------------- +// 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. + * + * A service principal (`brico-bot`) may pass `accountIdentity` to mint a code on behalf of that + * account; self-service callers omit it. + */ +export const createSharedFilters = spacetimedb.procedure( + {filterIds: t.array(t.string()), accountIdentity: t.option(t.identity())}, + CreateSharedFiltersResult, + (ctx, {filterIds, accountIdentity}) => { + 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); + } + const actingIdentity = accountIdentity ?? ctx.sender; + + return ctx.withTx(tx => { + // Checked on `tx`: the outer `ProcedureCtx` has no `db`. + if (!actingIdentity.isEqual(ctx.sender)) requireServicePrincipal(tx); + if (tx.db.account.identity.find(actingIdentity) === 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(actingIdentity) || 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: actingIdentity, + 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/discord.ts b/backend/brico-module/spacetimedb/src/reducers/discord.ts new file mode 100644 index 00000000..e5ff5fb8 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/reducers/discord.ts @@ -0,0 +1,450 @@ +// noinspection JSUnusedGlobalSymbols + +import { + isDiscordDisplayFieldPresetKey, + isDiscordDisplaySortDirection, + isDiscordDisplaySortField, + isDiscordDisplayStyle, + MAX_DISPLAY_REFRESH_SECONDS, + MAX_DISPLAY_ROWS_HARD_CAP, + MAX_STICKY_MINUTES, + MIN_DISPLAY_REFRESH_SECONDS, + MIN_STICKY_MINUTES, +} from '@brico/crafts/discord-display'; +import {isDiscordCommandMode} from '@brico/crafts/discord-guild'; +import {CraftError, MAX_DISPLAY_TEMPLATE_LENGTH, MAX_NOTIFY_TEMPLATE_LENGTH} from '@brico/crafts/errors'; +import type {Infer} from 'spacetimedb/server'; +import {SenderError, t} from 'spacetimedb/server'; +import {requireAccount, requireServicePrincipal} from '../lib/auth'; +import {clampClientTimestamp, isAtLeastAsNew} from '../lib/sync'; +import {spacetimedb} from '../schema'; +import {DiscordWatchDisplayContent} from '../tables/discord'; + +/** Presets must all be known keys; a template is checked for length only. */ +function validateContent(content: Infer): void { + if (content.tag === 'presets') { + for (const preset of content.value.presets) { + if (!isDiscordDisplayFieldPresetKey(preset)) { + throw new SenderError(`${CraftError.UNKNOWN_FIELD_PRESET}:${preset}`); + } + } + } else if (content.value.template.length > MAX_DISPLAY_TEMPLATE_LENGTH) { + throw new SenderError(CraftError.DISPLAY_TEMPLATE_TOO_LONG); + } +} + +/** + * Creates or edits a watch display, upserted by `(accountIdentity, filterId, channelId)`. Service + * principal only; `accountIdentity` is the filter owner. `id` is caller-generated (reducers must be + * deterministic) and used only on insert; `shareCode` is stored as given on both paths. + */ +export const attachDiscordWatchDisplay = spacetimedb.reducer( + { + id: t.string(), + accountIdentity: t.identity(), + filterId: t.string(), + channelId: t.string(), + content: DiscordWatchDisplayContent, + style: t.string(), + sortField: t.string(), + sortDirection: t.string(), + limit: t.u32(), + stickyMinutes: t.u32(), + refreshIntervalSeconds: t.u32(), + shareCode: t.option(t.string()), + }, + (ctx, {id, accountIdentity, filterId, channelId, content, style, sortField, sortDirection, limit, stickyMinutes, refreshIntervalSeconds, shareCode}) => { + requireServicePrincipal(ctx); + + const filter = ctx.db.saved_craft_filter.id.find(filterId); + if (filter === null || !filter.accountIdentity.isEqual(accountIdentity) || filter.deletedAt !== undefined) { + throw new SenderError(CraftError.UNKNOWN_SAVED_FILTER_FOR_DISPLAY); + } + validateContent(content); + if (!isDiscordDisplayStyle(style)) { + throw new SenderError(`${CraftError.UNKNOWN_DISPLAY_STYLE}:${style}`); + } + if (!isDiscordDisplaySortField(sortField)) { + throw new SenderError(`${CraftError.UNKNOWN_SORT_FIELD}:${sortField}`); + } + if (!isDiscordDisplaySortDirection(sortDirection)) { + throw new SenderError(`${CraftError.UNKNOWN_SORT_DIRECTION}:${sortDirection}`); + } + if (limit < 1 || limit > MAX_DISPLAY_ROWS_HARD_CAP) { + throw new SenderError(CraftError.DISPLAY_LIMIT_TOO_LARGE); + } + if (stickyMinutes < MIN_STICKY_MINUTES || stickyMinutes > MAX_STICKY_MINUTES) { + throw new SenderError(CraftError.STICKY_MINUTES_OUT_OF_RANGE); + } + if (refreshIntervalSeconds < MIN_DISPLAY_REFRESH_SECONDS || refreshIntervalSeconds > MAX_DISPLAY_REFRESH_SECONDS) { + throw new SenderError(CraftError.DISPLAY_REFRESH_OUT_OF_RANGE); + } + + let existing = null; + for (const row of ctx.db.discord_watch_display.accountIdentity.filter(accountIdentity)) { + if (row.filterId === filterId && row.channelId === channelId && row.deletedAt === undefined) { + existing = row; + break; + } + } + + if (existing === null) { + ctx.db.discord_watch_display.insert({ + id, + accountIdentity, + filterId, + channelId, + content, + style, + sortField, + sortDirection, + limit, + stickyMinutes, + refreshIntervalSeconds, + shareCode, + messageId: undefined, + lastPostedAt: undefined, + lastEditedAt: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + deletedAt: undefined, + }); + } else { + ctx.db.discord_watch_display.id.update({ + ...existing, + content, + style, + sortField, + sortDirection, + limit, + stickyMinutes, + refreshIntervalSeconds, + shareCode, + updatedAt: ctx.timestamp, + }); + } + } +); + +/** Tombstones a watch display. Service principal only. */ +export const detachDiscordWatchDisplay = spacetimedb.reducer( + {id: t.string()}, + (ctx, {id}) => { + requireServicePrincipal(ctx); + const existing = ctx.db.discord_watch_display.id.find(id); + if (existing === null || existing.deletedAt !== undefined) { + throw new SenderError(CraftError.UNKNOWN_DISCORD_WATCH_DISPLAY); + } + ctx.db.discord_watch_display.id.update({...existing, updatedAt: ctx.timestamp, deletedAt: ctx.timestamp}); + } +); + +/** + * Records the message `brico-bot` just posted. `edited` distinguishes a PATCH of the existing message + * (bumps `lastEditedAt` only) from a fresh POST (bumps `lastPostedAt`, resets `lastEditedAt`). + */ +export const setDiscordWatchDisplayMessage = spacetimedb.reducer( + {id: t.string(), messageId: t.string(), postedAt: t.timestamp(), edited: t.bool()}, + (ctx, {id, messageId, postedAt, edited}) => { + requireServicePrincipal(ctx); + const existing = ctx.db.discord_watch_display.id.find(id); + if (existing === null) throw new SenderError(CraftError.UNKNOWN_DISCORD_WATCH_DISPLAY); + ctx.db.discord_watch_display.id.update({ + ...existing, + messageId, + lastPostedAt: edited ? existing.lastPostedAt : postedAt, + lastEditedAt: edited ? postedAt : undefined, + }); + } +); + +/** + * Creates or edits a Discord notification sink, upserted by `(accountIdentity, channelId)`. Service + * principal only; `id` is used only on insert. + */ +export const attachDiscordNotifySink = spacetimedb.reducer( + { + id: t.string(), + accountIdentity: t.identity(), + channelId: t.string(), + channelName: t.option(t.string()), + defaultMentionType: t.option(t.string()), + defaultMentionId: t.option(t.string()), + defaultMentionName: t.option(t.string()), + }, + (ctx, {id, accountIdentity, channelId, channelName, defaultMentionType, defaultMentionId, defaultMentionName}) => { + requireServicePrincipal(ctx); + + let existing = null; + for (const row of ctx.db.discord_notify_sink.accountIdentity.filter(accountIdentity)) { + if (row.channelId === channelId && row.deletedAt === undefined) { + existing = row; + break; + } + } + + if (existing === null) { + ctx.db.discord_notify_sink.insert({ + id, + accountIdentity, + channelId, + channelName, + defaultMentionType, + defaultMentionId, + defaultMentionName, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + deletedAt: undefined, + }); + } else { + ctx.db.discord_notify_sink.id.update({ + ...existing, + channelName, + defaultMentionType, + defaultMentionId, + defaultMentionName, + updatedAt: ctx.timestamp, + }); + } + } +); + +/** Tombstones a notification sink. Callable by the owner or a service principal, like `unlinkIntegration`. */ +export const detachDiscordNotifySink = spacetimedb.reducer( + {id: t.string()}, + (ctx, {id}) => { + const existing = ctx.db.discord_notify_sink.id.find(id); + if (existing === null || existing.deletedAt !== undefined) { + throw new SenderError(CraftError.UNKNOWN_DISCORD_NOTIFY_SINK); + } + if (!existing.accountIdentity.isEqual(ctx.sender)) requireServicePrincipal(ctx); + ctx.db.discord_notify_sink.id.update({...existing, updatedAt: ctx.timestamp, deletedAt: ctx.timestamp}); + } +); + +function validateNotifyTemplateLength(template: string): void { + if (template.length > MAX_NOTIFY_TEMPLATE_LENGTH) throw new SenderError(CraftError.NOTIFY_TEMPLATE_TOO_LONG); +} + +/** Trims the template; empty stores as `undefined` (use the default). */ +function storedTemplate(value: string): string | undefined { + value = value.trim(); + return value.length > 0 ? value : undefined; +} + +/** + * Self-service edit of a Discord target's per-event wording. Never touches `mention*` (bot-set) or + * `craft_filter_notify_trigger`. + */ +export const upsertDiscordNotifyTargetTemplate = spacetimedb.reducer( + { + id: t.string(), + filterId: t.string(), + discordSinkId: t.string(), + addedTemplate: t.string(), + finishedTemplate: t.string(), + removedTemplate: t.string(), + updatedAt: t.timestamp(), + }, + (ctx, {id, filterId, discordSinkId, addedTemplate, finishedTemplate, removedTemplate, updatedAt}) => { + requireAccount(ctx); + + const filter = ctx.db.saved_craft_filter.id.find(filterId); + if (filter === null || !filter.accountIdentity.isEqual(ctx.sender) || filter.deletedAt !== undefined) { + throw new SenderError(CraftError.UNKNOWN_SAVED_FILTER_FOR_NOTIFY); + } + const sink = ctx.db.discord_notify_sink.id.find(discordSinkId); + if (sink === null || !sink.accountIdentity.isEqual(ctx.sender) || sink.deletedAt !== undefined) { + throw new SenderError(CraftError.UNKNOWN_DISCORD_NOTIFY_SINK); + } + validateNotifyTemplateLength(addedTemplate); + validateNotifyTemplateLength(finishedTemplate); + validateNotifyTemplateLength(removedTemplate); + + let existing = null; + for (const row of ctx.db.discord_notify_target.accountIdentity.filter(ctx.sender)) { + if (row.filterId === filterId && row.sinkId === discordSinkId && row.deletedAt === undefined) { + existing = row; + break; + } + } + + const clampedUpdatedAt = clampClientTimestamp(ctx, updatedAt); + if (existing !== null && !isAtLeastAsNew(clampedUpdatedAt, existing.updatedAt)) return; + + const addedStored = storedTemplate(addedTemplate); + const finishedStored = storedTemplate(finishedTemplate); + const removedStored = storedTemplate(removedTemplate); + + if (existing === null) { + ctx.db.discord_notify_target.insert({ + id, + accountIdentity: ctx.sender, + filterId, + sinkId: discordSinkId, + addedTemplate: addedStored, + finishedTemplate: finishedStored, + removedTemplate: removedStored, + mentionType: undefined, + mentionId: undefined, + mentionName: undefined, + updatedAt: clampedUpdatedAt, + deletedAt: undefined, + }); + } else { + ctx.db.discord_notify_target.id.update({ + ...existing, + addedTemplate: addedStored, + finishedTemplate: finishedStored, + removedTemplate: removedStored, + updatedAt: clampedUpdatedAt, + deletedAt: undefined, + }); + } + } +); + +/** Tombstones the caller's own Discord target. Silent no-op if already gone. */ +export const detachDiscordNotifyTarget = spacetimedb.reducer( + {id: t.string(), deletedAt: t.timestamp()}, + (ctx, {id, deletedAt}) => { + requireAccount(ctx); + const existing = ctx.db.discord_notify_target.id.find(id); + if (existing === null) return; + if (!existing.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.DISCORD_NOTIFY_TARGET_BELONGS_TO_ANOTHER_ACCOUNT); + } + const clampedDeletedAt = clampClientTimestamp(ctx, deletedAt); + if (!isAtLeastAsNew(clampedDeletedAt, existing.updatedAt)) return; + ctx.db.discord_notify_target.id.update({...existing, updatedAt: clampedDeletedAt, deletedAt: clampedDeletedAt}); + } +); + +/** + * Service-principal writer for `/watch notify-setup`: upserts the `discord_notify_target` row + * (including `mention*`) and the matching `craft_filter_notify_trigger` row by + * `(accountIdentity, filterId, discordSinkId)`. Each trigger bool is on iff its template is + * non-empty. `id` is used for whichever row(s) get inserted. + */ +export const attachDiscordNotifyTarget = spacetimedb.reducer( + { + id: t.string(), + accountIdentity: t.identity(), + filterId: t.string(), + discordSinkId: t.string(), + addedTemplate: t.string(), + finishedTemplate: t.string(), + removedTemplate: t.string(), + mentionType: t.option(t.string()), + mentionId: t.option(t.string()), + mentionName: t.option(t.string()), + }, + (ctx, {id, accountIdentity, filterId, discordSinkId, addedTemplate, finishedTemplate, removedTemplate, mentionType, mentionId, mentionName}) => { + requireServicePrincipal(ctx); + + const filter = ctx.db.saved_craft_filter.id.find(filterId); + if (filter === null || !filter.accountIdentity.isEqual(accountIdentity) || filter.deletedAt !== undefined) { + throw new SenderError(CraftError.UNKNOWN_SAVED_FILTER_FOR_NOTIFY); + } + const sink = ctx.db.discord_notify_sink.id.find(discordSinkId); + if (sink === null || !sink.accountIdentity.isEqual(accountIdentity) || sink.deletedAt !== undefined) { + throw new SenderError(CraftError.UNKNOWN_DISCORD_NOTIFY_SINK); + } + validateNotifyTemplateLength(addedTemplate); + validateNotifyTemplateLength(finishedTemplate); + validateNotifyTemplateLength(removedTemplate); + + const addedStored = storedTemplate(addedTemplate); + const finishedStored = storedTemplate(finishedTemplate); + const removedStored = storedTemplate(removedTemplate); + + let existingTarget = null; + for (const row of ctx.db.discord_notify_target.accountIdentity.filter(accountIdentity)) { + if (row.filterId === filterId && row.sinkId === discordSinkId && row.deletedAt === undefined) { + existingTarget = row; + break; + } + } + + if (existingTarget === null) { + ctx.db.discord_notify_target.insert({ + id, + accountIdentity, + filterId, + sinkId: discordSinkId, + addedTemplate: addedStored, + finishedTemplate: finishedStored, + removedTemplate: removedStored, + mentionType, + mentionId, + mentionName, + updatedAt: ctx.timestamp, + deletedAt: undefined, + }); + } else { + ctx.db.discord_notify_target.id.update({ + ...existingTarget, + addedTemplate: addedStored, + finishedTemplate: finishedStored, + removedTemplate: removedStored, + mentionType, + mentionId, + mentionName, + updatedAt: ctx.timestamp, + }); + } + + let existingTrigger = null; + for (const row of ctx.db.craft_filter_notify_trigger.accountIdentity.filter(accountIdentity)) { + if (row.filterId === filterId && row.sink.tag === 'discord' && row.sink.value.sinkId === discordSinkId && row.deletedAt === undefined) { + existingTrigger = row; + break; + } + } + + const added = addedStored !== undefined; + const finished = finishedStored !== undefined; + const removed = removedStored !== undefined; + + if (existingTrigger === null) { + ctx.db.craft_filter_notify_trigger.insert({ + id, + accountIdentity, + filterId, + sink: {tag: 'discord', value: {sinkId: discordSinkId}}, + added, + finished, + removed, + updatedAt: ctx.timestamp, + deletedAt: undefined, + }); + } else { + ctx.db.craft_filter_notify_trigger.id.update({ + ...existingTrigger, + added, + finished, + removed, + updatedAt: ctx.timestamp, + deletedAt: undefined, + }); + } + } +); + +/** Upserts a guild's command layout, keyed on `guildId`. Service principal only. */ +export const upsertDiscordGuildInstall = spacetimedb.reducer( + {guildId: t.string(), commandMode: t.string()}, + (ctx, {guildId, commandMode}) => { + requireServicePrincipal(ctx); + if (!isDiscordCommandMode(commandMode)) { + throw new SenderError(`${CraftError.UNKNOWN_COMMAND_MODE}:${commandMode}`); + } + + const existing = ctx.db.discord_guild_install.guildId.find(guildId); + if (existing === null) { + ctx.db.discord_guild_install.insert({guildId, commandMode, createdAt: ctx.timestamp, updatedAt: ctx.timestamp}); + } else { + ctx.db.discord_guild_install.guildId.update({...existing, commandMode, updatedAt: ctx.timestamp}); + } + } +); 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/notify.ts b/backend/brico-module/spacetimedb/src/reducers/notify.ts new file mode 100644 index 00000000..388a2d3b --- /dev/null +++ b/backend/brico-module/spacetimedb/src/reducers/notify.ts @@ -0,0 +1,91 @@ +// noinspection JSUnusedGlobalSymbols + +import {CraftError} from '@brico/crafts/errors'; +import {SenderError, t} from 'spacetimedb/server'; +import {requireAccount} from '../lib/auth'; +import {clampClientTimestamp, isAtLeastAsNew} from '../lib/sync'; +import {spacetimedb} from '../schema'; +import {NotifySinkRef} from '../tables/notify'; + +/** + * Creates or edits the caller's trigger for one (filter, sink). Existing rows are found by scanning + * the caller's rows, since there is no index over the sink sum type. + */ +export const upsertCraftFilterNotifyTrigger = spacetimedb.reducer( + { + id: t.string(), + filterId: t.string(), + sink: NotifySinkRef, + added: t.bool(), + finished: t.bool(), + removed: t.bool(), + updatedAt: t.timestamp(), + }, + (ctx, {id, filterId, sink, added, finished, removed, updatedAt}) => { + requireAccount(ctx); + + const filter = ctx.db.saved_craft_filter.id.find(filterId); + if (filter === null || !filter.accountIdentity.isEqual(ctx.sender) || filter.deletedAt !== undefined) { + throw new SenderError(CraftError.UNKNOWN_SAVED_FILTER_FOR_NOTIFY); + } + if (sink.tag === 'discord') { + const discordSink = ctx.db.discord_notify_sink.id.find(sink.value.sinkId); + if (discordSink === null || !discordSink.accountIdentity.isEqual(ctx.sender) || discordSink.deletedAt !== undefined) { + throw new SenderError(CraftError.UNKNOWN_DISCORD_NOTIFY_SINK); + } + } + + let existing = null; + for (const row of ctx.db.craft_filter_notify_trigger.accountIdentity.filter(ctx.sender)) { + if (row.filterId !== filterId || row.deletedAt !== undefined) continue; + if (row.sink.tag !== sink.tag) continue; + if (row.sink.tag === 'discord' && sink.tag === 'discord' && row.sink.value.sinkId !== sink.value.sinkId) continue; + existing = row; + break; + } + + const clampedUpdatedAt = clampClientTimestamp(ctx, updatedAt); + if (existing !== null && !isAtLeastAsNew(clampedUpdatedAt, existing.updatedAt)) return; + + if (existing === null) { + ctx.db.craft_filter_notify_trigger.insert({ + id, + accountIdentity: ctx.sender, + filterId, + sink, + added, + finished, + removed, + updatedAt: clampedUpdatedAt, + deletedAt: undefined, + }); + } else { + ctx.db.craft_filter_notify_trigger.id.update({ + ...existing, + added, + finished, + removed, + updatedAt: clampedUpdatedAt, + deletedAt: undefined, + }); + } + } +); + +/** Tombstones the caller's own trigger row (no hard delete). Silent no-op if already gone. */ +export const detachCraftFilterNotifyTrigger = spacetimedb.reducer( + {id: t.string(), deletedAt: t.timestamp()}, + (ctx, {id, deletedAt}) => { + requireAccount(ctx); + const existing = ctx.db.craft_filter_notify_trigger.id.find(id); + if (existing === null) return; + if (!existing.accountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.CRAFT_FILTER_NOTIFY_TRIGGER_BELONGS_TO_ANOTHER_ACCOUNT); + } + + const clampedDeletedAt = clampClientTimestamp(ctx, deletedAt); + if (!isAtLeastAsNew(clampedDeletedAt, existing.updatedAt)) return; + + ctx.db.craft_filter_notify_trigger.id.update({...existing, updatedAt: clampedDeletedAt, deletedAt: clampedDeletedAt}); + } +); 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..7c040b12 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/reducers/payouts.ts @@ -0,0 +1,416 @@ +// noinspection JSUnusedGlobalSymbols + +import {advancePool, reduceRatio} from '@brico/crafts/entitlement'; +import {CraftError} from '@brico/crafts/errors'; +import {CLAIM_ACCESS_FLAGS} from '@brico/crafts/filter'; +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'; +import {LoyaltyRuleSpec} from '../tables/payouts'; + +/** 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); + } +); + +/** + * Create or edit one of the caller's own automated loyalty rules. Unlike `loyalty_reward`, a rule + * names no payee at all — `id: undefined` inserts a new row (autoInc); `id: Some(id)` edits that row + * after an ownership check. Any number of rules per (payer, currency) is allowed — e.g. two + * effort-threshold tiers at different thresholds. + */ +export const upsertLoyaltyRule = spacetimedb.reducer( + { + id: t.option(t.u64()), + currency: t.string(), + spec: LoyaltyRuleSpec, + bonusRatioNumerator: t.i64(), + bonusRatioDenominator: t.i64(), + updatedAt: t.timestamp(), + }, + (ctx, {id, currency, spec, bonusRatioNumerator, bonusRatioDenominator, updatedAt}) => { + requireAccount(ctx); + validateBountyCurrency(currency); + validateRatio(bonusRatioNumerator, bonusRatioDenominator); + if (spec.tag === 'claimMembership') { + if (!(CLAIM_ACCESS_FLAGS as readonly string[]).includes(spec.value.requiredAccess)) { + throw new SenderError(`${CraftError.UNKNOWN_CLAIM_ACCESS_FLAG}:${spec.value.requiredAccess}`); + } + } else if (spec.value.threshold < 0n) { + throw new SenderError(CraftError.EFFORT_THRESHOLD_MUST_NOT_BE_NEGATIVE); + } + + if (id === undefined) { + ctx.db.loyalty_rule.insert({ + id: 0n, + payerAccountIdentity: ctx.sender, + currency, + spec, + bonusRatioNumerator, + bonusRatioDenominator, + updatedAt, + }); + return; + } + + const existing = ctx.db.loyalty_rule.id.find(id); + if (existing === null) throw new SenderError(CraftError.UNKNOWN_LOYALTY_RULE); + if (!existing.payerAccountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.RULE_BELONGS_TO_ANOTHER_ACCOUNT); + } + ctx.db.loyalty_rule.id.update({...existing, currency, spec, bonusRatioNumerator, bonusRatioDenominator, updatedAt}); + } +); + +/** Hard deletes one of the caller's own loyalty rules. No-op if already gone. */ +export const deleteLoyaltyRule = spacetimedb.reducer( + {id: t.u64()}, + (ctx, {id}) => { + requireAccount(ctx); + const existing = ctx.db.loyalty_rule.id.find(id); + if (existing === null) return; + if (!existing.payerAccountIdentity.isEqual(ctx.sender)) { + throw new SenderError(CraftError.RULE_BELONGS_TO_ANOTHER_ACCOUNT); + } + ctx.db.loyalty_rule.id.delete(id); + } +); + +/** + * The caller's own rows for one `(payeePlayerId, currency)` pair, same lookup shape as + * `findOwnLoyaltyReward`. + */ +function findLoyaltyBonusTotal(ctx: Ctx, payerAccountIdentity: Identity, payeePlayerId: bigint, currency: string) { + return [...ctx.db.loyalty_bonus_total.by_payer_payee.filter([payerAccountIdentity, payeePlayerId])] + .find(row => row.currency === currency) ?? null; +} + +/** + * Service-principal-only: sets the absolute resolved sum of every currently-satisfied + * `loyalty_rule` for one (payer, payee, currency) — `brico-bot` always recomputes the full current + * bonus from scratch, never an incremental delta, same "set, don't fold" reasoning as + * `importHistoricalBountyLedger`. + */ +export const upsertLoyaltyBonusTotal = spacetimedb.reducer( + { + payerAccountIdentity: t.identity(), + payeePlayerId: t.u64(), + currency: t.string(), + bonusRatioNumerator: t.i64(), + bonusRatioDenominator: t.i64(), + updatedAt: t.timestamp(), + }, + (ctx, {payerAccountIdentity, payeePlayerId, currency, bonusRatioNumerator, bonusRatioDenominator, updatedAt}) => { + requireServicePrincipal(ctx); + validateBountyCurrency(currency); + // A resolved bonus is a sum of non-negative rule bonuses (see `resolveAutomaticBonus`) and + // must never itself be negative — validated here too, not just trusted from the bot, since a + // bug in the bot's own math (as opposed to a malicious caller) is exactly the failure mode + // this guards against. + validateRatio(bonusRatioNumerator, bonusRatioDenominator); + + const existing = findLoyaltyBonusTotal(ctx, payerAccountIdentity, payeePlayerId, currency); + if (existing === null) { + ctx.db.loyalty_bonus_total.insert({ + id: 0n, payerAccountIdentity, payeePlayerId, currency, bonusRatioNumerator, bonusRatioDenominator, updatedAt, + }); + } else { + ctx.db.loyalty_bonus_total.id.update({...existing, bonusRatioNumerator, bonusRatioDenominator, updatedAt}); + } + } +); + +/** Service-principal-only: deletes a resolved bonus total once no rule matches any more. No-op if already gone. */ +export const deleteLoyaltyBonusTotal = spacetimedb.reducer( + {payerAccountIdentity: t.identity(), payeePlayerId: t.u64(), currency: t.string()}, + (ctx, {payerAccountIdentity, payeePlayerId, currency}) => { + requireServicePrincipal(ctx); + const existing = findLoyaltyBonusTotal(ctx, payerAccountIdentity, payeePlayerId, currency); + if (existing === null) return; + ctx.db.loyalty_bonus_total.id.delete(existing.id); + } +); + +/** + * Folds `deltaFraction` (this call's new effort, priced at whatever ratio is in effect right now — + * *not* yet floored) into the (payer, payee, currency) row of `bounty_entitlement_total`, inserting + * a new row if none exists yet — the atomic "update the total" half of `upsertCraftBountyEntitlement` + * below. Combines `deltaFraction` with whatever fraction this row is already carrying from a + * *previous* call (from this craft or any other craft this payer has bountied this payee in), + * floors once, grows `total` by that floored amount, and carries whatever's left of the combined + * fraction forward — so a fractional remainder is pooled across every craft, not stranded in + * whichever craft happened to produce it. 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, + deltaFraction: {numerator: bigint; denominator: bigint}, + deltaEffort: bigint, + updatedAt: Timestamp, +): void { + const existing = [...ctx.db.bounty_entitlement_total.by_payer_payee.filter([payerAccountIdentity, payeePlayerId])] + .find(row => row.currency === currency); + const carriedRemainder = existing === undefined + ? {numerator: 0n, denominator: 1n} + : {numerator: existing.remainderNumerator, denominator: existing.remainderDenominator}; + + const {earned, remainder} = advancePool(carriedRemainder, deltaFraction); + + if (existing === undefined) { + ctx.db.bounty_entitlement_total.insert({ + id: 0n, payerAccountIdentity, payeePlayerId, currency, + total: earned, totalEffort: deltaEffort, + remainderNumerator: remainder.numerator, remainderDenominator: remainder.denominator, + updatedAt, + }); + } else { + ctx.db.bounty_entitlement_total.id.update({ + ...existing, total: existing.total + earned, totalEffort: existing.totalEffort + deltaEffort, + remainderNumerator: remainder.numerator, remainderDenominator: remainder.denominator, + updatedAt, + }); + } +} + +/** + * A craft's resolved bounty assignment, checking both the public and private assignment tables — + * same "either one, whichever has the row" lookup `assignCraftBounty`/`clearCraftBounty` already do, + * since a craft's assignment lives in exactly one of the two tables at a time. + */ +function findCraftBountyAssignment(ctx: Ctx, craftId: bigint) { + return ctx.db.craft_bounty_assignment.craftId.find(craftId) ?? ctx.db.craft_private_bounty_assignment.craftId.find(craftId); +} + +/** + * Written by `brico-bot` whenever a (craft, contributor, currency) triple's cumulative effort or + * effective ratio might have changed — i.e. whenever `craft_contribution` changes, or a loyalty + * bonus resolves differently, for a craft that has a `craft_bounty_assignment`. `effort` is always + * the contributor's current *cumulative* contribution (a fact straight from prism, independent of + * this table's own state), and `ratioNumerator`/`ratioDenominator` is whatever ratio is in effect + * right now. This reducer only computes the new effort since `lastAssignedEffort` and prices it at + * that ratio as an *exact fraction* (`@brico/crafts/entitlement`'s `addRatio`/`reduceRatio` — never + * effort units, since a ratio whose numerator isn't 1 has no whole-effort equivalent for a fractional + * currency amount); the actual flooring and fractional carry happens in `addToEntitlementTotal`, + * pooled across every craft this payer has bountied this payee in, not per-craft — see that + * function's doc comment for why. + * + * A triple with no `existing` row is priced from a zero baseline. Effort predating the bounty is + * excluded because `assignCraftBounty` inserts baseline rows for existing contributors in the same + * transaction that creates the assignment. + * + * An `existing` row can itself predate a *gap*: a bounty can be cleared (its assignment row deleted) + * and reassigned later while the contributor keeps working the craft in between. `assignedAt` is only + * ever set on a true insert (see `assignCraftBounty`), so `assignment.assignedAt` newer than this + * row's own `updatedAt` means the row hasn't been priced since *this* continuous assignment began — + * there was a bounty-free gap since it was last touched. In that case the effort baseline jumps + * straight to the current `effort` (so this call's delta is zero — the gap's effort is never priced). + * A plain ratio/payer/currency edit on a bounty that was never cleared leaves `assignedAt` untouched + * too, so it can never be mistaken for a gap. + * + * Also folds the delta 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(), + effort: t.i64(), + ratioNumerator: t.i64(), + ratioDenominator: t.i64(), + updatedAt: t.timestamp(), + }, + (ctx, {craftId, playerId, currency, effort, ratioNumerator, ratioDenominator, updatedAt}) => { + requireServicePrincipal(ctx); + validateRatio(ratioNumerator, ratioDenominator); + + const assignment = findCraftBountyAssignment(ctx, 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; + // A gap: this row hasn't been priced since the *current* (continuous) assignment began, so + // whatever effort accrued in between (while the bounty was actually cleared) must not be + // priced — jump the effort baseline to now. + const hasGap = existing !== null && assignment.assignedAt.microsSinceUnixEpoch > existing.updatedAt.microsSinceUnixEpoch; + const lastAssignedEffort = existing === null ? 0n : hasGap ? effort : existing.lastAssignedEffort; + const deltaEffort = effort - lastAssignedEffort; + + if (existing === null) { + ctx.db.craft_bounty_entitlement.insert({id: 0n, craftId, playerId, currency, lastAssignedEffort: effort, updatedAt}); + } else { + ctx.db.craft_bounty_entitlement.id.update({...existing, lastAssignedEffort: effort, updatedAt}); + } + + if (deltaEffort !== 0n) { + // The new effort's exact currency value at the ratio in effect *now* — not yet floored; + // `addToEntitlementTotal` combines it with whatever fraction is still owed from any craft + // and floors once, pooled. + const deltaFraction = reduceRatio(deltaEffort * ratioNumerator, ratioDenominator); + addToEntitlementTotal(ctx, assignment.assignedByAccountIdentity, playerId, currency, deltaFraction, 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, + }); + } + ctx.db.bounty_payout_record_log.insert({ + id: 0n, + payerAccountIdentity: ctx.sender, + payeePlayerId, + currency, + amount: delta, + createdAt: ctx.timestamp, + }); + } +); + +// --------------------------------------------------------------------------- +// Historical backfill — one-time import of pre-brico spreadsheet ledgers +// --------------------------------------------------------------------------- + +const HistoricalBountyLedgerRow = t.object('HistoricalBountyLedgerRow', { + payeePlayerId: t.u64(), + currency: t.string(), + totalEffort: t.i64(), + total: t.i64(), + paidTotal: t.i64(), +}); + +/** + * Service-principal-only backfill of a payer's `bounty_entitlement_total` and `bounty_payout_record` + * rows from a pre-brico spreadsheet ledger, one (payee, currency) row at a time. 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. Calling this twice for the + * same triple simply overwrites it with the given totals, which makes a partially failed import safe + * to re-run. + * + * All of a payer's rows are taken in a single call — rather than one call per row — so the whole + * import commits (or fails) as one transaction instead of leaving a partial ledger behind a + * mid-import failure. + */ +export const importHistoricalBountyLedger = spacetimedb.reducer( + { + payerAccountIdentity: t.identity(), + rows: t.array(HistoricalBountyLedgerRow), + }, + (ctx, {payerAccountIdentity, rows}) => { + requireServicePrincipal(ctx); + + for (const {payeePlayerId, currency, totalEffort, total, paidTotal} of rows) { + 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, + remainderNumerator: 0n, remainderDenominator: 1n, 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}); + } + } + } +); diff --git a/backend/brico-module/spacetimedb/src/schema.ts b/backend/brico-module/spacetimedb/src/schema.ts new file mode 100644 index 00000000..c7dc80ed --- /dev/null +++ b/backend/brico-module/spacetimedb/src/schema.ts @@ -0,0 +1,51 @@ +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 {discord_guild_install, discord_notify_sink, discord_notify_target, discord_watch_display} from './tables/discord'; +import {integration_link_request, linked_integration} from './tables/integrations'; +import {notification} from './tables/notifications'; +import {craft_filter_notify_trigger} from './tables/notify'; +import { + bounty_entitlement_total, + bounty_payout_record, + bounty_payout_record_log, + craft_bounty_entitlement, + loyalty_bonus_total, + loyalty_reward, + loyalty_rule +} 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, + bounty_payout_record_log, + loyalty_reward, + loyalty_rule, + loyalty_bonus_total, + discord_watch_display, + discord_notify_sink, + discord_notify_target, + craft_filter_notify_trigger, + discord_guild_install, +}); + +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..ed0b961c --- /dev/null +++ b/backend/brico-module/spacetimedb/src/tables/bounties.ts @@ -0,0 +1,110 @@ +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. + * + * `assignedAt` means "assigned continuously since," not "last edited" (that's `updatedAt`) — see + * `assignCraftBounty`'s doc comment. `upsertCraftBountyEntitlement` relies on it to tell a plain + * ratio/payer edit of a still-active bounty apart from a real clear-then-reassign gap. + */ +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..d631fae4 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/tables/crafts.ts @@ -0,0 +1,54 @@ +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()), + } +); + +/** + * DEPRECATED: do not use. + */ +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/discord.ts b/backend/brico-module/spacetimedb/src/tables/discord.ts new file mode 100644 index 00000000..4e8ac6ab --- /dev/null +++ b/backend/brico-module/spacetimedb/src/tables/discord.ts @@ -0,0 +1,110 @@ +import {t, table} from 'spacetimedb/server'; + +/** + * A display's per-craft content: either field presets (composed into a template by + * `@brico/crafts/discord-display`) or a raw template string. `template` is validated for length + * only; an unrecognized `{token}` renders as literal text. + */ +export const DiscordWatchDisplayContent = t.enum('DiscordWatchDisplayContent', { + presets: t.object('DiscordWatchDisplayPresets', {presets: t.array(t.string())}), + template: t.object('DiscordWatchDisplayTemplate', {template: t.string()}), +}); + +/** + * A periodically-refreshed message showing the first N matches for a saved craft filter, posted to + * one Discord channel. Written only by `brico-bot` (service principal), which checks the invoking + * user's channel permissions; `accountIdentity` is the owner of the `saved_craft_filter` it reads. + * `channelId` is globally unique, so there is no `guildId`. + */ +export const discord_watch_display = table( + { + name: 'discord_watch_display', + indexes: [{accessor: 'by_channel', algorithm: 'btree', columns: ['channelId']}], + }, + { + id: t.string().primaryKey(), // bot-generated (crypto.randomUUID()) + accountIdentity: t.identity().index('btree'), + filterId: t.string(), + channelId: t.string(), + content: DiscordWatchDisplayContent, + style: t.string(), // 'compact' | 'simple' | 'icon' + sortField: t.string(), // 'remaining' | 'effort' | 'tier' | 'bounty' | 'newest' + sortDirection: t.string(), // 'asc' | 'desc' + limit: t.u32(), + /** Minutes between delete-and-resend (instead of edit-in-place) to keep the message at the bottom of the channel. `0` disables. */ + stickyMinutes: t.u32(), + refreshIntervalSeconds: t.u32(), + /** A `shared_filter` code for the footer link, minted once by `brico-bot` and reused across edits. */ + shareCode: t.option(t.string()), + /** Set by the bot after each (re)post. */ + messageId: t.option(t.string()), + lastPostedAt: t.option(t.timestamp()), + lastEditedAt: t.option(t.timestamp()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + deletedAt: t.option(t.timestamp()), + } +); + +/** + * A linked Discord notification destination, one per (account, channel). Filters point at it via + * `craft_filter_notify_trigger` (`sink: {tag: 'discord', sinkId}`) and `discord_notify_target`. + * `channelName`/`defaultMentionName` are cached display strings for the web settings page, which has + * no Discord API access. Created/edited only by `brico-bot` (it checks channel permissions); + * `detachDiscordNotifySink` is also callable by the owner. + */ +export const discord_notify_sink = table( + {name: 'discord_notify_sink'}, + { + id: t.string().primaryKey(), // bot-generated (crypto.randomUUID()) + accountIdentity: t.identity().index('btree'), + channelId: t.string().index('btree'), + channelName: t.option(t.string()), + /** Default mention, used when the `discord_notify_target` has no override. */ + defaultMentionType: t.option(t.string()), // 'user' | 'role' + defaultMentionId: t.option(t.string()), + defaultMentionName: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + deletedAt: t.option(t.timestamp()), + } +); + +/** + * Discord-specific wording/mention config for one (account, filter, Discord sink) pair; on/off lives + * in `craft_filter_notify_trigger`. An `undefined` template means the default + * (`@brico/crafts/discord-notify`'s `DEFAULT_NOTIFY_TEMPLATE`). `mention*` overrides the sink's + * `defaultMention*` (`undefined` inherits) and is written only by `attachDiscordNotifyTarget`. + */ +export const discord_notify_target = table( + {name: 'discord_notify_target'}, + { + id: t.string().primaryKey(), // client- or bot-generated uuid + accountIdentity: t.identity().index('btree'), + filterId: t.string(), + sinkId: t.string(), // discord_notify_sink.id + addedTemplate: t.option(t.string()), + finishedTemplate: t.option(t.string()), + removedTemplate: t.option(t.string()), + mentionType: t.option(t.string()), // 'user' | 'role' + mentionId: t.option(t.string()), + mentionName: t.option(t.string()), + updatedAt: t.timestamp(), + deletedAt: t.option(t.timestamp()), + } +); + +/** + * The command layout (`@brico/crafts/discord-guild`'s `DiscordCommandMode`) a guild last chose, + * persisted across bot restarts. Not an allow-list: a guild with no row keeps whatever + * `register-commands.ts` last registered. Written only by `brico-bot`. + */ +export const discord_guild_install = table( + {name: 'discord_guild_install'}, + { + guildId: t.string().primaryKey(), + commandMode: t.string(), // 'flat' | 'grouped' + createdAt: t.timestamp(), + updatedAt: 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/notify.ts b/backend/brico-module/spacetimedb/src/tables/notify.ts new file mode 100644 index 00000000..0ca362f8 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/tables/notify.ts @@ -0,0 +1,30 @@ +import {t, table} from 'spacetimedb/server'; + +/** + * Which sink a `craft_filter_notify_trigger` row is for. `toast` is the implicit single in-site sink; + * `discord` carries the `discord_notify_sink.id` it targets. + */ +export const NotifySinkRef = t.enum('NotifySinkRef', { + toast: t.object('ToastNotifySinkRef', {}), + discord: t.object('DiscordNotifySinkRef', {sinkId: t.string()}), +}); + +/** + * Per-(filter, sink, event) on/off flags for every sink kind. Sink-specific settings (templates, + * mentions) live in their own tables, e.g. `discord_notify_target`. Self-service: toggling a trigger + * grants no new channel access. + */ +export const craft_filter_notify_trigger = table( + {name: 'craft_filter_notify_trigger'}, + { + id: t.string().primaryKey(), // client- (toast) or bot- (discord) generated uuid + accountIdentity: t.identity().index('btree'), + filterId: t.string().index('btree'), + sink: NotifySinkRef, + added: t.bool(), + finished: t.bool(), + removed: t.bool(), + updatedAt: t.timestamp(), + deletedAt: 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..528b01b9 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/tables/payouts.ts @@ -0,0 +1,207 @@ +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(), + } +); + +/** + * A payer-authored, payee-agnostic condition — unlike `loyalty_reward`, this names no payee at + * all: `brico-bot` evaluates it against *whichever* contributor is currently being paid, and grants + * `bonusRatioNumerator`/`bonusRatioDenominator` to anyone who currently satisfies it. + * + * Unlike `loyalty_reward.ratioNumerator`/`ratioDenominator` (a *multiplier*, >=1, e.g. `1.025`), + * this is a **bonus-only fraction already** (e.g. `1/40` for +2.5%) — the same units + * `bounty-sink.ts`'s `addRatio` sums several bonuses in, and the same units `loyalty_bonus_total` + * below stores its resolved sum in. The frontend converts a user-typed multiplier (the UI still + * shows/edits `1.025`, same as the manual reward's field) into this shape once, via + * `bonusFromMultiplier`, before ever calling `upsertLoyaltyRule` — `brico-bot` must read this field + * as-is, never re-apply `bonusFromMultiplier` to it, or it double-converts (e.g. a stored `1/40` + * becoming `(1-40)/40 = -97.5%`). + */ +export const LoyaltyRuleSpec = t.enum('LoyaltyRuleSpec', { + claimMembership: t.object('ClaimMembershipLoyaltyRule', { + claimEntityId: t.u64(), + /** One of `ClaimAccessFlag` (`@brico/crafts/filter`) — `"member"` is "a claim_member row exists, no flags set." */ + requiredAccess: t.string(), + }), + effortThreshold: t.object('EffortThresholdLoyaltyRule', { + /** false = only this rule's own `currency`'s effort; true = summed across every currency this payer has bountied this payee in. */ + allCurrencies: t.bool(), + threshold: t.i64(), + }), +}); + +/** + * A payer's automated loyalty rule. No uniqueness constraint (unlike `loyalty_reward`'s per-payee + * triple): a payer can have any number of rules per currency — e.g. two effort-threshold tiers at + * different thresholds, both able to fire for the same payee at once. + */ +export const loyalty_rule = table( + { + name: 'loyalty_rule', + indexes: [{accessor: 'by_payer', algorithm: 'btree', columns: ['payerAccountIdentity']}], + }, + { + id: t.u64().primaryKey().autoInc(), + payerAccountIdentity: t.identity(), + /** The bounty currency this rule's bonus applies to — independent of `effortThreshold`'s own currency scope. */ + currency: t.string(), + spec: LoyaltyRuleSpec, + bonusRatioNumerator: t.i64(), + bonusRatioDenominator: t.i64(), + updatedAt: t.timestamp(), + } +); + +/** + * The resolved sum of every currently-satisfied `loyalty_rule` for one (payer, payee, currency) — + * written only by `brico-bot`, never folding in the manual `loyalty_reward` (the frontend combines + * the two for display). Row absence means "zero automated bonus right now": `brico-bot` deletes the + * row rather than writing zero, mirroring `clearCraftBounty`'s "absence is the off state" convention. + * Same shape/uniqueness convention as `bounty_entitlement_total` below (`by_payer_payee`, filtered to + * `currency` in reducer code, not a database constraint). + */ +export const loyalty_bonus_total = table( + { + name: 'loyalty_bonus_total', + indexes: [{accessor: 'by_payer_payee', algorithm: 'btree', columns: ['payerAccountIdentity', 'payeePlayerId']}], + }, + { + id: t.u64().primaryKey().autoInc(), + payerAccountIdentity: t.identity(), + payeePlayerId: t.u64().index('btree'), + currency: t.string(), + bonusRatioNumerator: t.i64(), + bonusRatioDenominator: t.i64(), + updatedAt: t.timestamp(), + } +); + +/** + * Per-(craft, contributor, currency) baseline for computing each `upsertCraftBountyEntitlement` + * call's *new* effort (since `lastAssignedEffort`) at whatever ratio is in effect right now — so a + * bounty rate change only ever applies to effort accrued after the change, never re-valuing effort + * already priced under the old rate. This stays keyed per-craft (unlike the two totals tables below) + * because the delta computation is per-effort against that craft's own bounty ratio. The actual + * currency math (flooring, fractional carry) happens once, pooled across every craft, in + * `bounty_entitlement_total` below — this table has no currency total of its own, only the effort + * baseline needed to compute each call's delta. + */ +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(), + updatedAt: t.timestamp(), + } +); + +/** + * The sum of a payee's earnings 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(), + /** + * The fractional currency amount (< 1, exact — see `@brico/crafts/entitlement`'s `addRatio`) + * left over after the last floor, carried into the next craft's delta rather than dropped — + * pooled across *every* craft this payer has ever bountied this payee in, per currency, so a + * craft that finishes owing e.g. 0.7 currency doesn't strand that fraction: it's still here + * to combine with the next craft's delta, whichever craft that is. Stored as an exact + * fraction, not an effort amount: a ratio's numerator need not be 1, so "effort not yet worth + * a whole currency unit" doesn't correspond to a whole number of effort units. + */ + remainderNumerator: t.i64(), + remainderDenominator: t.i64(), + } +); + +/** + * 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(), + } +); + +/** + * The append-only transaction log behind `bounty_payout_record.paidTotal` — one row per + * `recordBountyPayment` call, in whatever direction (positive or negative `amount`) it was + * recorded. Rows are inserted, never edited or deleted: `bounty_payout_record.paidTotal` is (and + * must remain) the running sum of every row here for that (payer, payee, currency) triple, but is + * kept as its own table rather than recomputed from this log on every read, so aggregate reports + * never have to scan a payer's full transaction history. + */ +export const bounty_payout_record_log = table( + { + name: 'bounty_payout_record_log', + 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(), + amount: t.i64(), + createdAt: 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..188ea493 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/views/crafts.ts @@ -0,0 +1,22 @@ +// noinspection JSUnusedGlobalSymbols + +import {t} from 'spacetimedb/server'; +import {spacetimedb} from '../schema'; +import {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)) +); + +/** 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); + } +); diff --git a/backend/brico-module/spacetimedb/src/views/discord.ts b/backend/brico-module/spacetimedb/src/views/discord.ts new file mode 100644 index 00000000..413af454 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/views/discord.ts @@ -0,0 +1,66 @@ +// noinspection JSUnusedGlobalSymbols + +import {t} from 'spacetimedb/server'; +import {spacetimedb} from '../schema'; +import {discord_guild_install, discord_notify_sink, discord_notify_target, discord_watch_display} from '../tables/discord'; + +/** The caller's own watch displays, tombstoned ones included — same reason as `mySavedCraftFilter`. */ +export const myDiscordWatchDisplay = spacetimedb.view( + {name: 'my_discord_watch_display', public: true}, + t.array(discord_watch_display.rowType), + ctx => ctx.from.discord_watch_display.where(d => d.accountIdentity.eq(ctx.sender)) +); + +/** Unfiltered watch-display feed for brico-bot. Same trusted-constant-predicate query view as `all_account`. */ +export const allDiscordWatchDisplay = spacetimedb.view( + {name: 'all_discord_watch_display', public: true}, + t.array(discord_watch_display.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.discord_watch_display.where(_ => isTrusted); + } +); + +/** The caller's own linked Discord notification sinks, tombstoned ones included — same reason as `mySavedCraftFilter`. */ +export const myDiscordNotifySink = spacetimedb.view( + {name: 'my_discord_notify_sink', public: true}, + t.array(discord_notify_sink.rowType), + ctx => ctx.from.discord_notify_sink.where(d => d.accountIdentity.eq(ctx.sender)) +); + +/** Unfiltered notify-sink feed for brico-bot. Same trusted-constant-predicate query view as `all_account`. */ +export const allDiscordNotifySink = spacetimedb.view( + {name: 'all_discord_notify_sink', public: true}, + t.array(discord_notify_sink.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.discord_notify_sink.where(_ => isTrusted); + } +); + +/** The caller's own Discord notification targets, tombstoned ones included — same reason as `mySavedCraftFilter`. */ +export const myDiscordNotifyTarget = spacetimedb.view( + {name: 'my_discord_notify_target', public: true}, + t.array(discord_notify_target.rowType), + ctx => ctx.from.discord_notify_target.where(d => d.accountIdentity.eq(ctx.sender)) +); + +/** Unfiltered notify-target feed for brico-bot. Same trusted-constant-predicate query view as `all_account`. */ +export const allDiscordNotifyTarget = spacetimedb.view( + {name: 'all_discord_notify_target', public: true}, + t.array(discord_notify_target.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.discord_notify_target.where(_ => isTrusted); + } +); + +/** Unfiltered guild-install feed for brico-bot. No `my_*` counterpart: the table is guild-scoped. */ +export const allDiscordGuildInstall = spacetimedb.view( + {name: 'all_discord_guild_install', public: true}, + t.array(discord_guild_install.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.discord_guild_install.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/notify.ts b/backend/brico-module/spacetimedb/src/views/notify.ts new file mode 100644 index 00000000..87d812c8 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/views/notify.ts @@ -0,0 +1,22 @@ +// noinspection JSUnusedGlobalSymbols + +import {t} from 'spacetimedb/server'; +import {spacetimedb} from '../schema'; +import {craft_filter_notify_trigger} from '../tables/notify'; + +/** The caller's own notification trigger rows, tombstoned ones included — same reason as `mySavedCraftFilter`. */ +export const myCraftFilterNotifyTrigger = spacetimedb.view( + {name: 'my_craft_filter_notify_trigger', public: true}, + t.array(craft_filter_notify_trigger.rowType), + ctx => ctx.from.craft_filter_notify_trigger.where(t => t.accountIdentity.eq(ctx.sender)) +); + +/** Unfiltered notification-trigger feed for brico-bot. Same trusted-constant-predicate query view as `all_account`. */ +export const allCraftFilterNotifyTrigger = spacetimedb.view( + {name: 'all_craft_filter_notify_trigger', public: true}, + t.array(craft_filter_notify_trigger.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.craft_filter_notify_trigger.where(_ => isTrusted); + } +); 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..9bae0fe0 --- /dev/null +++ b/backend/brico-module/spacetimedb/src/views/payouts.ts @@ -0,0 +1,340 @@ +// noinspection JSUnusedGlobalSymbols + +import type {Identity, Timestamp} from 'spacetimedb'; +import {t} from 'spacetimedb/server'; +import type {VCtx} from '../schema'; +import {spacetimedb} from '../schema'; +import {bounty_entitlement_total, bounty_payout_record, craft_bounty_entitlement, loyalty_bonus_total, loyalty_reward, loyalty_rule} 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 automated loyalty rules, as a payer. */ +export const myLoyaltyRule = spacetimedb.view( + {name: 'my_loyalty_rule', public: true}, + t.array(loyalty_rule.rowType), + ctx => ctx.from.loyalty_rule.where(r => r.payerAccountIdentity.eq(ctx.sender)) +); + +/** Unfiltered loyalty-rule feed for brico-bot, which needs every payer's rules to resolve automated bonuses. */ +export const allLoyaltyRule = spacetimedb.view( + {name: 'all_loyalty_rule', public: true}, + t.array(loyalty_rule.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.loyalty_rule.where(_ => isTrusted); + } +); + +/** The caller's own resolved automated-bonus totals, as a payer — for the loyalty page's read-only display. */ +export const myLoyaltyBonusTotal = spacetimedb.view( + {name: 'my_loyalty_bonus_total', public: true}, + t.array(loyalty_bonus_total.rowType), + ctx => ctx.from.loyalty_bonus_total.where(r => r.payerAccountIdentity.eq(ctx.sender)) +); + +/** Unfiltered resolved-bonus-total feed for brico-bot, which reads this back to diff against before writing. */ +export const allLoyaltyBonusTotal = spacetimedb.view( + {name: 'all_loyalty_bonus_total', public: true}, + t.array(loyalty_bonus_total.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.loyalty_bonus_total.where(_ => isTrusted); + } +); + +/** + * Unfiltered `bounty_entitlement_total` feed for brico-bot — needed so it can read a payee's + * existing total effort per (payer, payee, currency) to evaluate an `effortThreshold` loyalty rule. + * `bounty_entitlement_total` otherwise has no service-principal view, only the two per-caller + * procedural views (`myEntitlementsAsContributor`/`myEntitlementsAsPayer` below). + */ +export const allBountyEntitlementTotal = spacetimedb.view( + {name: 'all_bounty_entitlement_total', public: true}, + t.array(bounty_entitlement_total.rowType), + ctx => { + const isTrusted = ctx.db.service_principal.identity.find(ctx.sender) !== null; + return ctx.from.bounty_entitlement_total.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; +} + +/** + * One row of the append-only `bounty_payout_record_log` (see its doc comment), `payerName` + * resolved server-side same as `ContributorEntitlementRow`/`PayerEntitlementRow` below. Shared by + * both directions — `myBountyPayoutRecordLogAsPayer` and `myBountyPayoutRecordLogAsContributor` + * differ only in which underlying rows they return, never the shape. + */ +const BountyPayoutLogRow = t.row('BountyPayoutLogRow', { + id: t.u64().primaryKey(), + payerAccountIdentity: t.identity(), + payerName: t.string(), + payeePlayerId: t.u64(), + currency: t.string(), + amount: t.i64(), + createdAt: t.timestamp(), +}); + +/** The caller's own transaction log as a payer — every row `recordBountyPayment` appended under + * the caller's own identity, across every payee and currency. */ +export const myBountyPayoutRecordLogAsPayer = spacetimedb.view( + {name: 'my_bounty_payout_record_log_as_payer', public: true}, + t.array(BountyPayoutLogRow), + ctx => { + const payerName = resolvePayerDisplayName(ctx, ctx.sender); + return [...ctx.db.bounty_payout_record_log.by_payer_payee.filter(ctx.sender)].map(row => ({ + id: row.id, + payerAccountIdentity: row.payerAccountIdentity, + payerName, + payeePlayerId: row.payeePlayerId, + currency: row.currency, + amount: row.amount, + createdAt: row.createdAt, + })); + } +); + +/** The caller's own transaction log as a contributor, across every BitCraft player id they've ever + * linked — same player-id resolution as `myEntitlementsAsContributor` below. */ +export const myBountyPayoutRecordLogAsContributor = spacetimedb.view( + {name: 'my_bounty_payout_record_log_as_contributor', public: true}, + t.array(BountyPayoutLogRow), + 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 []; + + return [...playerIds].flatMap(playerId => + [...ctx.db.bounty_payout_record_log.payeePlayerId.filter(playerId)].map(row => ({ + id: row.id, + payerAccountIdentity: row.payerAccountIdentity, + payerName: resolvePayerDisplayName(ctx, row.payerAccountIdentity), + payeePlayerId: row.payeePlayerId, + currency: row.currency, + amount: row.amount, + createdAt: row.createdAt, + })) + ); + } +); + +/** + * 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_entitlement_total_table.ts b/common/bindings/brico-app/all_bounty_entitlement_total_table.ts new file mode 100644 index 00000000..b39a65b5 --- /dev/null +++ b/common/bindings/brico-app/all_bounty_entitlement_total_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.u64().primaryKey(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + payeePlayerId: __t.u64().name("payee_player_id"), + currency: __t.string(), + total: __t.i64(), + totalEffort: __t.i64().name("total_effort"), + updatedAt: __t.timestamp().name("updated_at"), + remainderNumerator: __t.i64().name("remainder_numerator"), + remainderDenominator: __t.i64().name("remainder_denominator"), +}); 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..398be85d --- /dev/null +++ b/common/bindings/brico-app/all_craft_bounty_entitlement_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(), + craftId: __t.u64().name("craft_id"), + playerId: __t.u64().name("player_id"), + currency: __t.string(), + lastAssignedEffort: __t.i64().name("last_assigned_effort"), + 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_notify_trigger_table.ts b/common/bindings/brico-app/all_craft_filter_notify_trigger_table.ts new file mode 100644 index 00000000..4ba5d987 --- /dev/null +++ b/common/bindings/brico-app/all_craft_filter_notify_trigger_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 {NotifySinkRef,} from "./types"; + + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + filterId: __t.string().name("filter_id"), + get sink() { + return NotifySinkRef; + }, + 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_discord_guild_install_table.ts b/common/bindings/brico-app/all_discord_guild_install_table.ts new file mode 100644 index 00000000..322a7f69 --- /dev/null +++ b/common/bindings/brico-app/all_discord_guild_install_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({ + guildId: __t.string().primaryKey().name("guild_id"), + commandMode: __t.string().name("command_mode"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/common/bindings/brico-app/all_discord_notify_sink_table.ts b/common/bindings/brico-app/all_discord_notify_sink_table.ts new file mode 100644 index 00000000..43357c0c --- /dev/null +++ b/common/bindings/brico-app/all_discord_notify_sink_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + channelId: __t.string().name("channel_id"), + channelName: __t.option(__t.string()).name("channel_name"), + defaultMentionType: __t.option(__t.string()).name("default_mention_type"), + defaultMentionId: __t.option(__t.string()).name("default_mention_id"), + defaultMentionName: __t.option(__t.string()).name("default_mention_name"), + 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_discord_notify_target_table.ts b/common/bindings/brico-app/all_discord_notify_target_table.ts new file mode 100644 index 00000000..5cf562ac --- /dev/null +++ b/common/bindings/brico-app/all_discord_notify_target_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + filterId: __t.string().name("filter_id"), + sinkId: __t.string().name("sink_id"), + addedTemplate: __t.option(__t.string()).name("added_template"), + finishedTemplate: __t.option(__t.string()).name("finished_template"), + removedTemplate: __t.option(__t.string()).name("removed_template"), + mentionType: __t.option(__t.string()).name("mention_type"), + mentionId: __t.option(__t.string()).name("mention_id"), + mentionName: __t.option(__t.string()).name("mention_name"), + updatedAt: __t.timestamp().name("updated_at"), + deletedAt: __t.option(__t.timestamp()).name("deleted_at"), +}); diff --git a/common/bindings/brico-app/all_discord_watch_display_table.ts b/common/bindings/brico-app/all_discord_watch_display_table.ts new file mode 100644 index 00000000..8bf3e272 --- /dev/null +++ b/common/bindings/brico-app/all_discord_watch_display_table.ts @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; +import {DiscordWatchDisplayContent,} from "./types"; + + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + filterId: __t.string().name("filter_id"), + channelId: __t.string().name("channel_id"), + get content() { + return DiscordWatchDisplayContent; + }, + style: __t.string(), + sortField: __t.string().name("sort_field"), + sortDirection: __t.string().name("sort_direction"), + limit: __t.u32(), + stickyMinutes: __t.u32().name("sticky_minutes"), + refreshIntervalSeconds: __t.u32().name("refresh_interval_seconds"), + shareCode: __t.option(__t.string()).name("share_code"), + messageId: __t.option(__t.string()).name("message_id"), + lastPostedAt: __t.option(__t.timestamp()).name("last_posted_at"), + lastEditedAt: __t.option(__t.timestamp()).name("last_edited_at"), + 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_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_bonus_total_table.ts b/common/bindings/brico-app/all_loyalty_bonus_total_table.ts new file mode 100644 index 00000000..30d14bf1 --- /dev/null +++ b/common/bindings/brico-app/all_loyalty_bonus_total_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(), + bonusRatioNumerator: __t.i64().name("bonus_ratio_numerator"), + bonusRatioDenominator: __t.i64().name("bonus_ratio_denominator"), + updatedAt: __t.timestamp().name("updated_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_loyalty_rule_table.ts b/common/bindings/brico-app/all_loyalty_rule_table.ts new file mode 100644 index 00000000..0f14b32f --- /dev/null +++ b/common/bindings/brico-app/all_loyalty_rule_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; +import {LoyaltyRuleSpec,} from "./types"; + + +export default __t.row({ + id: __t.u64().primaryKey(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + currency: __t.string(), + get spec() { + return LoyaltyRuleSpec; + }, + bonusRatioNumerator: __t.i64().name("bonus_ratio_numerator"), + bonusRatioDenominator: __t.i64().name("bonus_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..2b4846e7 --- /dev/null +++ b/common/bindings/brico-app/assign_craft_bounty_reducer.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +import {AssignmentBaseline,} from "./types"; + +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(), + get baselines() { + return __t.array(AssignmentBaseline); + }, +}; diff --git a/common/bindings/brico-app/attach_discord_notify_sink_reducer.ts b/common/bindings/brico-app/attach_discord_notify_sink_reducer.ts new file mode 100644 index 00000000..8667cec4 --- /dev/null +++ b/common/bindings/brico-app/attach_discord_notify_sink_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + id: __t.string(), + accountIdentity: __t.identity(), + channelId: __t.string(), + channelName: __t.option(__t.string()), + defaultMentionType: __t.option(__t.string()), + defaultMentionId: __t.option(__t.string()), + defaultMentionName: __t.option(__t.string()), +}; diff --git a/common/bindings/brico-app/attach_discord_notify_target_reducer.ts b/common/bindings/brico-app/attach_discord_notify_target_reducer.ts new file mode 100644 index 00000000..adcb8794 --- /dev/null +++ b/common/bindings/brico-app/attach_discord_notify_target_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"; + +export default { + id: __t.string(), + accountIdentity: __t.identity(), + filterId: __t.string(), + discordSinkId: __t.string(), + addedTemplate: __t.string(), + finishedTemplate: __t.string(), + removedTemplate: __t.string(), + mentionType: __t.option(__t.string()), + mentionId: __t.option(__t.string()), + mentionName: __t.option(__t.string()), +}; diff --git a/common/bindings/brico-app/attach_discord_watch_display_reducer.ts b/common/bindings/brico-app/attach_discord_watch_display_reducer.ts new file mode 100644 index 00000000..6ded3733 --- /dev/null +++ b/common/bindings/brico-app/attach_discord_watch_display_reducer.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +import {DiscordWatchDisplayContent,} from "./types"; + +export default { + id: __t.string(), + accountIdentity: __t.identity(), + filterId: __t.string(), + channelId: __t.string(), + get content() { + return DiscordWatchDisplayContent; + }, + style: __t.string(), + sortField: __t.string(), + sortDirection: __t.string(), + limit: __t.u32(), + stickyMinutes: __t.u32(), + refreshIntervalSeconds: __t.u32(), + shareCode: __t.option(__t.string()), +}; 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..ff1b8edf --- /dev/null +++ b/common/bindings/brico-app/create_shared_filters_procedure.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"; + +import {CreateSharedFiltersResult,} from "./types"; + +export const params = { + filterIds: __t.array(__t.string()), + accountIdentity: __t.option(__t.identity()), +}; +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_loyalty_bonus_total_reducer.ts b/common/bindings/brico-app/delete_loyalty_bonus_total_reducer.ts new file mode 100644 index 00000000..b7ba6b01 --- /dev/null +++ b/common/bindings/brico-app/delete_loyalty_bonus_total_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 { + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), +}; 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_loyalty_rule_reducer.ts b/common/bindings/brico-app/delete_loyalty_rule_reducer.ts new file mode 100644 index 00000000..6b5d8547 --- /dev/null +++ b/common/bindings/brico-app/delete_loyalty_rule_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/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/detach_craft_filter_notify_trigger_reducer.ts b/common/bindings/brico-app/detach_craft_filter_notify_trigger_reducer.ts new file mode 100644 index 00000000..3814a918 --- /dev/null +++ b/common/bindings/brico-app/detach_craft_filter_notify_trigger_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/detach_discord_notify_sink_reducer.ts b/common/bindings/brico-app/detach_discord_notify_sink_reducer.ts new file mode 100644 index 00000000..9d4ba9e9 --- /dev/null +++ b/common/bindings/brico-app/detach_discord_notify_sink_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.string(), +}; diff --git a/common/bindings/brico-app/detach_discord_notify_target_reducer.ts b/common/bindings/brico-app/detach_discord_notify_target_reducer.ts new file mode 100644 index 00000000..3814a918 --- /dev/null +++ b/common/bindings/brico-app/detach_discord_notify_target_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/detach_discord_watch_display_reducer.ts b/common/bindings/brico-app/detach_discord_watch_display_reducer.ts new file mode 100644 index 00000000..9d4ba9e9 --- /dev/null +++ b/common/bindings/brico-app/detach_discord_watch_display_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.string(), +}; 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..e18d619a --- /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"; + +import {HistoricalBountyLedgerRow,} from "./types"; + +export default { + payerAccountIdentity: __t.identity(), + get rows() { + return __t.array(HistoricalBountyLedgerRow); + }, +}; diff --git a/common/bindings/brico-app/index.ts b/common/bindings/brico-app/index.ts new file mode 100644 index 00000000..edac0b28 --- /dev/null +++ b/common/bindings/brico-app/index.ts @@ -0,0 +1,498 @@ +// 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 AllBountyEntitlementTotalRow from "./all_bounty_entitlement_total_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 AllCraftFilterNotifyTriggerRow from "./all_craft_filter_notify_trigger_table"; +import AllDiscordGuildInstallRow from "./all_discord_guild_install_table"; +import AllDiscordNotifySinkRow from "./all_discord_notify_sink_table"; +import AllDiscordNotifyTargetRow from "./all_discord_notify_target_table"; +import AllDiscordWatchDisplayRow from "./all_discord_watch_display_table"; +import AllIntegrationLinkRequestRow from "./all_integration_link_request_table"; +import AllLinkedIntegrationRow from "./all_linked_integration_table"; +import AllLoyaltyBonusTotalRow from "./all_loyalty_bonus_total_table"; +import AllLoyaltyRewardRow from "./all_loyalty_reward_table"; +import AllLoyaltyRuleRow from "./all_loyalty_rule_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 AttachDiscordNotifySinkReducer from "./attach_discord_notify_sink_reducer"; +import AttachDiscordNotifyTargetReducer from "./attach_discord_notify_target_reducer"; +import AttachDiscordWatchDisplayReducer from "./attach_discord_watch_display_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 DeleteLoyaltyBonusTotalReducer from "./delete_loyalty_bonus_total_reducer"; +import DeleteLoyaltyRewardReducer from "./delete_loyalty_reward_reducer"; +import DeleteLoyaltyRuleReducer from "./delete_loyalty_rule_reducer"; +import DeleteNotificationReducer from "./delete_notification_reducer"; +import DeleteSavedCraftFilterReducer from "./delete_saved_craft_filter_reducer"; +import DetachCraftFilterNotifyTriggerReducer from "./detach_craft_filter_notify_trigger_reducer"; +import DetachDiscordNotifySinkReducer from "./detach_discord_notify_sink_reducer"; +import DetachDiscordNotifyTargetReducer from "./detach_discord_notify_target_reducer"; +import DetachDiscordWatchDisplayReducer from "./detach_discord_watch_display_reducer"; +import EnsureAccountReducer from "./ensure_account_reducer"; +import ImportHistoricalBountyLedgerReducer from "./import_historical_bounty_ledger_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 MyBountyPayoutRecordLogAsContributorRow from "./my_bounty_payout_record_log_as_contributor_table"; +import MyBountyPayoutRecordLogAsPayerRow from "./my_bounty_payout_record_log_as_payer_table"; +import MyBountyRuleRow from "./my_bounty_rule_table"; +import MyCraftBountyOverrideRow from "./my_craft_bounty_override_table"; +import MyCraftFilterNotifyTriggerRow from "./my_craft_filter_notify_trigger_table"; +import MyDiscordNotifySinkRow from "./my_discord_notify_sink_table"; +import MyDiscordNotifyTargetRow from "./my_discord_notify_target_table"; +import MyDiscordWatchDisplayRow from "./my_discord_watch_display_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 MyLoyaltyBonusTotalRow from "./my_loyalty_bonus_total_table"; +import MyLoyaltyRewardRow from "./my_loyalty_reward_table"; +import MyLoyaltyRuleRow from "./my_loyalty_rule_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 SetDiscordWatchDisplayMessageReducer from "./set_discord_watch_display_message_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 UpsertCraftFilterNotifyTriggerReducer from "./upsert_craft_filter_notify_trigger_reducer"; +import UpsertDiscordGuildInstallReducer from "./upsert_discord_guild_install_reducer"; +import UpsertDiscordNotifyTargetTemplateReducer from "./upsert_discord_notify_target_template_reducer"; +import UpsertLoyaltyBonusTotalReducer from "./upsert_loyalty_bonus_total_reducer"; +import UpsertLoyaltyRewardReducer from "./upsert_loyalty_reward_reducer"; +import UpsertLoyaltyRuleReducer from "./upsert_loyalty_rule_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), + allBountyEntitlementTotal: __table({ + name: 'all_bounty_entitlement_total', + indexes: [ + ], + constraints: [ + ], + }, AllBountyEntitlementTotalRow), + 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), + allCraftFilterNotifyTrigger: __table({ + name: 'all_craft_filter_notify_trigger', + indexes: [ + ], + constraints: [ + ], + }, AllCraftFilterNotifyTriggerRow), + allDiscordGuildInstall: __table({ + name: 'all_discord_guild_install', + indexes: [ + ], + constraints: [ + ], + }, AllDiscordGuildInstallRow), + allDiscordNotifySink: __table({ + name: 'all_discord_notify_sink', + indexes: [ + ], + constraints: [ + ], + }, AllDiscordNotifySinkRow), + allDiscordNotifyTarget: __table({ + name: 'all_discord_notify_target', + indexes: [ + ], + constraints: [ + ], + }, AllDiscordNotifyTargetRow), + allDiscordWatchDisplay: __table({ + name: 'all_discord_watch_display', + indexes: [ + ], + constraints: [ + ], + }, AllDiscordWatchDisplayRow), + allIntegrationLinkRequest: __table({ + name: 'all_integration_link_request', + indexes: [ + ], + constraints: [ + ], + }, AllIntegrationLinkRequestRow), + allLinkedIntegration: __table({ + name: 'all_linked_integration', + indexes: [ + ], + constraints: [ + ], + }, AllLinkedIntegrationRow), + allLoyaltyBonusTotal: __table({ + name: 'all_loyalty_bonus_total', + indexes: [ + ], + constraints: [ + ], + }, AllLoyaltyBonusTotalRow), + allLoyaltyReward: __table({ + name: 'all_loyalty_reward', + indexes: [ + ], + constraints: [ + ], + }, AllLoyaltyRewardRow), + allLoyaltyRule: __table({ + name: 'all_loyalty_rule', + indexes: [ + ], + constraints: [ + ], + }, AllLoyaltyRuleRow), + 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), + myBountyPayoutRecordLogAsContributor: __table({ + name: 'my_bounty_payout_record_log_as_contributor', + indexes: [ + ], + constraints: [ + ], + }, MyBountyPayoutRecordLogAsContributorRow), + myBountyPayoutRecordLogAsPayer: __table({ + name: 'my_bounty_payout_record_log_as_payer', + indexes: [ + ], + constraints: [ + ], + }, MyBountyPayoutRecordLogAsPayerRow), + myBountyRule: __table({ + name: 'my_bounty_rule', + indexes: [ + ], + constraints: [ + ], + }, MyBountyRuleRow), + myCraftBountyOverride: __table({ + name: 'my_craft_bounty_override', + indexes: [ + ], + constraints: [ + ], + }, MyCraftBountyOverrideRow), + myCraftFilterNotifyTrigger: __table({ + name: 'my_craft_filter_notify_trigger', + indexes: [ + ], + constraints: [ + ], + }, MyCraftFilterNotifyTriggerRow), + myDiscordNotifySink: __table({ + name: 'my_discord_notify_sink', + indexes: [ + ], + constraints: [ + ], + }, MyDiscordNotifySinkRow), + myDiscordNotifyTarget: __table({ + name: 'my_discord_notify_target', + indexes: [ + ], + constraints: [ + ], + }, MyDiscordNotifyTargetRow), + myDiscordWatchDisplay: __table({ + name: 'my_discord_watch_display', + indexes: [ + ], + constraints: [ + ], + }, MyDiscordWatchDisplayRow), + 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), + myLoyaltyBonusTotal: __table({ + name: 'my_loyalty_bonus_total', + indexes: [ + ], + constraints: [ + ], + }, MyLoyaltyBonusTotalRow), + myLoyaltyReward: __table({ + name: 'my_loyalty_reward', + indexes: [ + ], + constraints: [ + ], + }, MyLoyaltyRewardRow), + myLoyaltyRule: __table({ + name: 'my_loyalty_rule', + indexes: [ + ], + constraints: [ + ], + }, MyLoyaltyRuleRow), + 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("attach_discord_notify_sink", AttachDiscordNotifySinkReducer), + __reducerSchema("attach_discord_notify_target", AttachDiscordNotifyTargetReducer), + __reducerSchema("attach_discord_watch_display", AttachDiscordWatchDisplayReducer), + __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_loyalty_bonus_total", DeleteLoyaltyBonusTotalReducer), + __reducerSchema("delete_loyalty_reward", DeleteLoyaltyRewardReducer), + __reducerSchema("delete_loyalty_rule", DeleteLoyaltyRuleReducer), + __reducerSchema("delete_notification", DeleteNotificationReducer), + __reducerSchema("delete_saved_craft_filter", DeleteSavedCraftFilterReducer), + __reducerSchema("detach_craft_filter_notify_trigger", DetachCraftFilterNotifyTriggerReducer), + __reducerSchema("detach_discord_notify_sink", DetachDiscordNotifySinkReducer), + __reducerSchema("detach_discord_notify_target", DetachDiscordNotifyTargetReducer), + __reducerSchema("detach_discord_watch_display", DetachDiscordWatchDisplayReducer), + __reducerSchema("ensure_account", EnsureAccountReducer), + __reducerSchema("import_historical_bounty_ledger", ImportHistoricalBountyLedgerReducer), + __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_discord_watch_display_message", SetDiscordWatchDisplayMessageReducer), + __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_notify_trigger", UpsertCraftFilterNotifyTriggerReducer), + __reducerSchema("upsert_discord_guild_install", UpsertDiscordGuildInstallReducer), + __reducerSchema("upsert_discord_notify_target_template", UpsertDiscordNotifyTargetTemplateReducer), + __reducerSchema("upsert_loyalty_bonus_total", UpsertLoyaltyBonusTotalReducer), + __reducerSchema("upsert_loyalty_reward", UpsertLoyaltyRewardReducer), + __reducerSchema("upsert_loyalty_rule", UpsertLoyaltyRuleReducer), + __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_payout_record_log_as_contributor_table.ts b/common/bindings/brico-app/my_bounty_payout_record_log_as_contributor_table.ts new file mode 100644 index 00000000..22fffef5 --- /dev/null +++ b/common/bindings/brico-app/my_bounty_payout_record_log_as_contributor_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"), + payerName: __t.string().name("payer_name"), + payeePlayerId: __t.u64().name("payee_player_id"), + currency: __t.string(), + amount: __t.i64(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/common/bindings/brico-app/my_bounty_payout_record_log_as_payer_table.ts b/common/bindings/brico-app/my_bounty_payout_record_log_as_payer_table.ts new file mode 100644 index 00000000..22fffef5 --- /dev/null +++ b/common/bindings/brico-app/my_bounty_payout_record_log_as_payer_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"), + payerName: __t.string().name("payer_name"), + payeePlayerId: __t.u64().name("payee_player_id"), + currency: __t.string(), + amount: __t.i64(), + createdAt: __t.timestamp().name("created_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_notify_trigger_table.ts b/common/bindings/brico-app/my_craft_filter_notify_trigger_table.ts new file mode 100644 index 00000000..4ba5d987 --- /dev/null +++ b/common/bindings/brico-app/my_craft_filter_notify_trigger_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 {NotifySinkRef,} from "./types"; + + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + filterId: __t.string().name("filter_id"), + get sink() { + return NotifySinkRef; + }, + 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_discord_notify_sink_table.ts b/common/bindings/brico-app/my_discord_notify_sink_table.ts new file mode 100644 index 00000000..43357c0c --- /dev/null +++ b/common/bindings/brico-app/my_discord_notify_sink_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + channelId: __t.string().name("channel_id"), + channelName: __t.option(__t.string()).name("channel_name"), + defaultMentionType: __t.option(__t.string()).name("default_mention_type"), + defaultMentionId: __t.option(__t.string()).name("default_mention_id"), + defaultMentionName: __t.option(__t.string()).name("default_mention_name"), + 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_discord_notify_target_table.ts b/common/bindings/brico-app/my_discord_notify_target_table.ts new file mode 100644 index 00000000..5cf562ac --- /dev/null +++ b/common/bindings/brico-app/my_discord_notify_target_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + filterId: __t.string().name("filter_id"), + sinkId: __t.string().name("sink_id"), + addedTemplate: __t.option(__t.string()).name("added_template"), + finishedTemplate: __t.option(__t.string()).name("finished_template"), + removedTemplate: __t.option(__t.string()).name("removed_template"), + mentionType: __t.option(__t.string()).name("mention_type"), + mentionId: __t.option(__t.string()).name("mention_id"), + mentionName: __t.option(__t.string()).name("mention_name"), + updatedAt: __t.timestamp().name("updated_at"), + deletedAt: __t.option(__t.timestamp()).name("deleted_at"), +}); diff --git a/common/bindings/brico-app/my_discord_watch_display_table.ts b/common/bindings/brico-app/my_discord_watch_display_table.ts new file mode 100644 index 00000000..8bf3e272 --- /dev/null +++ b/common/bindings/brico-app/my_discord_watch_display_table.ts @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; +import {DiscordWatchDisplayContent,} from "./types"; + + +export default __t.row({ + id: __t.string().primaryKey(), + accountIdentity: __t.identity().name("account_identity"), + filterId: __t.string().name("filter_id"), + channelId: __t.string().name("channel_id"), + get content() { + return DiscordWatchDisplayContent; + }, + style: __t.string(), + sortField: __t.string().name("sort_field"), + sortDirection: __t.string().name("sort_direction"), + limit: __t.u32(), + stickyMinutes: __t.u32().name("sticky_minutes"), + refreshIntervalSeconds: __t.u32().name("refresh_interval_seconds"), + shareCode: __t.option(__t.string()).name("share_code"), + messageId: __t.option(__t.string()).name("message_id"), + lastPostedAt: __t.option(__t.timestamp()).name("last_posted_at"), + lastEditedAt: __t.option(__t.timestamp()).name("last_edited_at"), + 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_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_bonus_total_table.ts b/common/bindings/brico-app/my_loyalty_bonus_total_table.ts new file mode 100644 index 00000000..30d14bf1 --- /dev/null +++ b/common/bindings/brico-app/my_loyalty_bonus_total_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(), + bonusRatioNumerator: __t.i64().name("bonus_ratio_numerator"), + bonusRatioDenominator: __t.i64().name("bonus_ratio_denominator"), + updatedAt: __t.timestamp().name("updated_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_loyalty_rule_table.ts b/common/bindings/brico-app/my_loyalty_rule_table.ts new file mode 100644 index 00000000..0f14b32f --- /dev/null +++ b/common/bindings/brico-app/my_loyalty_rule_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; +import {LoyaltyRuleSpec,} from "./types"; + + +export default __t.row({ + id: __t.u64().primaryKey(), + payerAccountIdentity: __t.identity().name("payer_account_identity"), + currency: __t.string(), + get spec() { + return LoyaltyRuleSpec; + }, + bonusRatioNumerator: __t.i64().name("bonus_ratio_numerator"), + bonusRatioDenominator: __t.i64().name("bonus_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_discord_watch_display_message_reducer.ts b/common/bindings/brico-app/set_discord_watch_display_message_reducer.ts new file mode 100644 index 00000000..9df01942 --- /dev/null +++ b/common/bindings/brico-app/set_discord_watch_display_message_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(), + messageId: __t.string(), + postedAt: __t.timestamp(), + edited: __t.bool(), +}; 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..61ba404c --- /dev/null +++ b/common/bindings/brico-app/types.ts @@ -0,0 +1,590 @@ +// THIS FILE IS AUTOMATICALLY 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 AllBountyEntitlementTotal = __t.object("AllBountyEntitlementTotal", {}); +export type AllBountyEntitlementTotal = __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 AllCraftFilterNotifyTrigger = __t.object("AllCraftFilterNotifyTrigger", {}); +export type AllCraftFilterNotifyTrigger = __Infer; + +export const AllDiscordGuildInstall = __t.object("AllDiscordGuildInstall", {}); +export type AllDiscordGuildInstall = __Infer; + +export const AllDiscordNotifySink = __t.object("AllDiscordNotifySink", {}); +export type AllDiscordNotifySink = __Infer; + +export const AllDiscordNotifyTarget = __t.object("AllDiscordNotifyTarget", {}); +export type AllDiscordNotifyTarget = __Infer; + +export const AllDiscordWatchDisplay = __t.object("AllDiscordWatchDisplay", {}); +export type AllDiscordWatchDisplay = __Infer; + +export const AllIntegrationLinkRequest = __t.object("AllIntegrationLinkRequest", {}); +export type AllIntegrationLinkRequest = __Infer; + +export const AllLinkedIntegration = __t.object("AllLinkedIntegration", {}); +export type AllLinkedIntegration = __Infer; + +export const AllLoyaltyBonusTotal = __t.object("AllLoyaltyBonusTotal", {}); +export type AllLoyaltyBonusTotal = __Infer; + +export const AllLoyaltyReward = __t.object("AllLoyaltyReward", {}); +export type AllLoyaltyReward = __Infer; + +export const AllLoyaltyRule = __t.object("AllLoyaltyRule", {}); +export type AllLoyaltyRule = __Infer; + +export const AllPrivateCraftBountyAssignment = __t.object("AllPrivateCraftBountyAssignment", {}); +export type AllPrivateCraftBountyAssignment = __Infer; + +export const AllSavedCraftFilter = __t.object("AllSavedCraftFilter", {}); +export type AllSavedCraftFilter = __Infer; + +export const AssignmentBaseline = __t.object("AssignmentBaseline", { + playerId: __t.u64(), + effort: __t.i64(), +}); +export type AssignmentBaseline = __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(), + remainderNumerator: __t.i64(), + remainderDenominator: __t.i64(), +}); +export type BountyEntitlementTotal = __Infer; + +export const BountyPayoutLogRow = __t.object("BountyPayoutLogRow", { + id: __t.u64(), + payerAccountIdentity: __t.identity(), + payerName: __t.string(), + payeePlayerId: __t.u64(), + currency: __t.string(), + amount: __t.i64(), + createdAt: __t.timestamp(), +}); +export type BountyPayoutLogRow = __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 BountyPayoutRecordLog = __t.object("BountyPayoutRecordLog", { + id: __t.u64(), + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), + amount: __t.i64(), + createdAt: __t.timestamp(), +}); +export type BountyPayoutRecordLog = __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 ClaimMembershipLoyaltyRule = __t.object("ClaimMembershipLoyaltyRule", { + claimEntityId: __t.u64(), + requiredAccess: __t.string(), +}); +export type ClaimMembershipLoyaltyRule = __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(), + 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 CraftFilterNotifyTrigger = __t.object("CraftFilterNotifyTrigger", { + id: __t.string(), + accountIdentity: __t.identity(), + filterId: __t.string(), + get sink() { + return NotifySinkRef; + }, + added: __t.bool(), + finished: __t.bool(), + removed: __t.bool(), + updatedAt: __t.timestamp(), + deletedAt: __t.option(__t.timestamp()), +}); +export type CraftFilterNotifyTrigger = __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 DiscordGuildInstall = __t.object("DiscordGuildInstall", { + guildId: __t.string(), + commandMode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type DiscordGuildInstall = __Infer; + +export const DiscordNotifySink = __t.object("DiscordNotifySink", { + id: __t.string(), + accountIdentity: __t.identity(), + channelId: __t.string(), + channelName: __t.option(__t.string()), + defaultMentionType: __t.option(__t.string()), + defaultMentionId: __t.option(__t.string()), + defaultMentionName: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + deletedAt: __t.option(__t.timestamp()), +}); +export type DiscordNotifySink = __Infer; + +export const DiscordNotifySinkRef = __t.object("DiscordNotifySinkRef", { + sinkId: __t.string(), +}); +export type DiscordNotifySinkRef = __Infer; + +export const DiscordNotifyTarget = __t.object("DiscordNotifyTarget", { + id: __t.string(), + accountIdentity: __t.identity(), + filterId: __t.string(), + sinkId: __t.string(), + addedTemplate: __t.option(__t.string()), + finishedTemplate: __t.option(__t.string()), + removedTemplate: __t.option(__t.string()), + mentionType: __t.option(__t.string()), + mentionId: __t.option(__t.string()), + mentionName: __t.option(__t.string()), + updatedAt: __t.timestamp(), + deletedAt: __t.option(__t.timestamp()), +}); +export type DiscordNotifyTarget = __Infer; + +export const DiscordWatchDisplay = __t.object("DiscordWatchDisplay", { + id: __t.string(), + accountIdentity: __t.identity(), + filterId: __t.string(), + channelId: __t.string(), + get content() { + return DiscordWatchDisplayContent; + }, + style: __t.string(), + sortField: __t.string(), + sortDirection: __t.string(), + limit: __t.u32(), + stickyMinutes: __t.u32(), + refreshIntervalSeconds: __t.u32(), + shareCode: __t.option(__t.string()), + messageId: __t.option(__t.string()), + lastPostedAt: __t.option(__t.timestamp()), + lastEditedAt: __t.option(__t.timestamp()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + deletedAt: __t.option(__t.timestamp()), +}); +export type DiscordWatchDisplay = __Infer; + +// The tagged union or sum type for the algebraic type `DiscordWatchDisplayContent`. +export const DiscordWatchDisplayContent = __t.enum("DiscordWatchDisplayContent", { + get Presets() { + return DiscordWatchDisplayPresets; + }, + get Template() { + return DiscordWatchDisplayTemplate; + }, +}); +export type DiscordWatchDisplayContent = __Infer; + +export const DiscordWatchDisplayPresets = __t.object("DiscordWatchDisplayPresets", { + presets: __t.array(__t.string()), +}); +export type DiscordWatchDisplayPresets = __Infer; + +export const DiscordWatchDisplayTemplate = __t.object("DiscordWatchDisplayTemplate", { + template: __t.string(), +}); +export type DiscordWatchDisplayTemplate = __Infer; + +export const EffortThresholdLoyaltyRule = __t.object("EffortThresholdLoyaltyRule", { + allCurrencies: __t.bool(), + threshold: __t.i64(), +}); +export type EffortThresholdLoyaltyRule = __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 HistoricalBountyLedgerRow = __t.object("HistoricalBountyLedgerRow", { + payeePlayerId: __t.u64(), + currency: __t.string(), + totalEffort: __t.i64(), + total: __t.i64(), + paidTotal: __t.i64(), +}); +export type HistoricalBountyLedgerRow = __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 LoyaltyBonusTotal = __t.object("LoyaltyBonusTotal", { + id: __t.u64(), + payerAccountIdentity: __t.identity(), + payeePlayerId: __t.u64(), + currency: __t.string(), + bonusRatioNumerator: __t.i64(), + bonusRatioDenominator: __t.i64(), + updatedAt: __t.timestamp(), +}); +export type LoyaltyBonusTotal = __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 LoyaltyRule = __t.object("LoyaltyRule", { + id: __t.u64(), + payerAccountIdentity: __t.identity(), + currency: __t.string(), + get spec() { + return LoyaltyRuleSpec; + }, + bonusRatioNumerator: __t.i64(), + bonusRatioDenominator: __t.i64(), + updatedAt: __t.timestamp(), +}); +export type LoyaltyRule = __Infer; + +// The tagged union or sum type for the algebraic type `LoyaltyRuleSpec`. +export const LoyaltyRuleSpec = __t.enum("LoyaltyRuleSpec", { + get ClaimMembership() { + return ClaimMembershipLoyaltyRule; + }, + get EffortThreshold() { + return EffortThresholdLoyaltyRule; + }, +}); +export type LoyaltyRuleSpec = __Infer; + +export const MyAccount = __t.object("MyAccount", {}); +export type MyAccount = __Infer; + +export const MyBountyPayoutRecordAsPayer = __t.object("MyBountyPayoutRecordAsPayer", {}); +export type MyBountyPayoutRecordAsPayer = __Infer; + +export const MyBountyPayoutRecordLogAsContributor = __t.object("MyBountyPayoutRecordLogAsContributor", {}); +export type MyBountyPayoutRecordLogAsContributor = __Infer; + +export const MyBountyPayoutRecordLogAsPayer = __t.object("MyBountyPayoutRecordLogAsPayer", {}); +export type MyBountyPayoutRecordLogAsPayer = __Infer; + +export const MyBountyRule = __t.object("MyBountyRule", {}); +export type MyBountyRule = __Infer; + +export const MyCraftBountyOverride = __t.object("MyCraftBountyOverride", {}); +export type MyCraftBountyOverride = __Infer; + +export const MyCraftFilterNotifyTrigger = __t.object("MyCraftFilterNotifyTrigger", {}); +export type MyCraftFilterNotifyTrigger = __Infer; + +export const MyDiscordNotifySink = __t.object("MyDiscordNotifySink", {}); +export type MyDiscordNotifySink = __Infer; + +export const MyDiscordNotifyTarget = __t.object("MyDiscordNotifyTarget", {}); +export type MyDiscordNotifyTarget = __Infer; + +export const MyDiscordWatchDisplay = __t.object("MyDiscordWatchDisplay", {}); +export type MyDiscordWatchDisplay = __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 MyLoyaltyBonusTotal = __t.object("MyLoyaltyBonusTotal", {}); +export type MyLoyaltyBonusTotal = __Infer; + +export const MyLoyaltyReward = __t.object("MyLoyaltyReward", {}); +export type MyLoyaltyReward = __Infer; + +export const MyLoyaltyRule = __t.object("MyLoyaltyRule", {}); +export type MyLoyaltyRule = __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; + +// The tagged union or sum type for the algebraic type `NotifySinkRef`. +export const NotifySinkRef = __t.enum("NotifySinkRef", { + get Toast() { + return ToastNotifySinkRef; + }, + get Discord() { + return DiscordNotifySinkRef; + }, +}); +export type NotifySinkRef = __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; + +export const ToastNotifySinkRef = __t.object("ToastNotifySinkRef", {}); +export type ToastNotifySinkRef = __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..3950f6c4 --- /dev/null +++ b/common/bindings/brico-app/types/reducers.ts @@ -0,0 +1,98 @@ +// THIS FILE IS AUTOMATICALLY 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 AttachDiscordNotifySinkReducer from "../attach_discord_notify_sink_reducer"; +import AttachDiscordNotifyTargetReducer from "../attach_discord_notify_target_reducer"; +import AttachDiscordWatchDisplayReducer from "../attach_discord_watch_display_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 DeleteLoyaltyBonusTotalReducer from "../delete_loyalty_bonus_total_reducer"; +import DeleteLoyaltyRewardReducer from "../delete_loyalty_reward_reducer"; +import DeleteLoyaltyRuleReducer from "../delete_loyalty_rule_reducer"; +import DeleteNotificationReducer from "../delete_notification_reducer"; +import DeleteSavedCraftFilterReducer from "../delete_saved_craft_filter_reducer"; +import DetachCraftFilterNotifyTriggerReducer from "../detach_craft_filter_notify_trigger_reducer"; +import DetachDiscordNotifySinkReducer from "../detach_discord_notify_sink_reducer"; +import DetachDiscordNotifyTargetReducer from "../detach_discord_notify_target_reducer"; +import DetachDiscordWatchDisplayReducer from "../detach_discord_watch_display_reducer"; +import EnsureAccountReducer from "../ensure_account_reducer"; +import ImportHistoricalBountyLedgerReducer from "../import_historical_bounty_ledger_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 SetDiscordWatchDisplayMessageReducer from "../set_discord_watch_display_message_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 UpsertCraftFilterNotifyTriggerReducer from "../upsert_craft_filter_notify_trigger_reducer"; +import UpsertDiscordGuildInstallReducer from "../upsert_discord_guild_install_reducer"; +import UpsertDiscordNotifyTargetTemplateReducer from "../upsert_discord_notify_target_template_reducer"; +import UpsertLoyaltyBonusTotalReducer from "../upsert_loyalty_bonus_total_reducer"; +import UpsertLoyaltyRewardReducer from "../upsert_loyalty_reward_reducer"; +import UpsertLoyaltyRuleReducer from "../upsert_loyalty_rule_reducer"; +import UpsertSavedCraftFilterReducer from "../upsert_saved_craft_filter_reducer"; + +export type AssignCraftBountyParams = __Infer; +export type AttachDiscordNotifySinkParams = __Infer; +export type AttachDiscordNotifyTargetParams = __Infer; +export type AttachDiscordWatchDisplayParams = __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 DeleteLoyaltyBonusTotalParams = __Infer; +export type DeleteLoyaltyRewardParams = __Infer; +export type DeleteLoyaltyRuleParams = __Infer; +export type DeleteNotificationParams = __Infer; +export type DeleteSavedCraftFilterParams = __Infer; +export type DetachCraftFilterNotifyTriggerParams = __Infer; +export type DetachDiscordNotifySinkParams = __Infer; +export type DetachDiscordNotifyTargetParams = __Infer; +export type DetachDiscordWatchDisplayParams = __Infer; +export type EnsureAccountParams = __Infer; +export type ImportHistoricalBountyLedgerParams = __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 SetDiscordWatchDisplayMessageParams = __Infer; +export type SetDisplayNameParams = __Infer; +export type UnlinkIntegrationParams = __Infer; +export type UpsertBountyRuleParams = __Infer; +export type UpsertCraftBountyEntitlementParams = __Infer; +export type UpsertCraftBountyOverrideParams = __Infer; +export type UpsertCraftFilterNotifyTriggerParams = __Infer; +export type UpsertDiscordGuildInstallParams = __Infer; +export type UpsertDiscordNotifyTargetTemplateParams = __Infer; +export type UpsertLoyaltyBonusTotalParams = __Infer; +export type UpsertLoyaltyRewardParams = __Infer; +export type UpsertLoyaltyRuleParams = __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..16efc81c --- /dev/null +++ b/common/bindings/brico-app/upsert_craft_bounty_entitlement_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + craftId: __t.u64(), + playerId: __t.u64(), + currency: __t.string(), + effort: __t.i64(), + ratioNumerator: __t.i64(), + ratioDenominator: __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_notify_trigger_reducer.ts b/common/bindings/brico-app/upsert_craft_filter_notify_trigger_reducer.ts new file mode 100644 index 00000000..63fa4ff6 --- /dev/null +++ b/common/bindings/brico-app/upsert_craft_filter_notify_trigger_reducer.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +import {NotifySinkRef,} from "./types"; + +export default { + id: __t.string(), + filterId: __t.string(), + get sink() { + return NotifySinkRef; + }, + added: __t.bool(), + finished: __t.bool(), + removed: __t.bool(), + updatedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/upsert_discord_guild_install_reducer.ts b/common/bindings/brico-app/upsert_discord_guild_install_reducer.ts new file mode 100644 index 00000000..4481ad93 --- /dev/null +++ b/common/bindings/brico-app/upsert_discord_guild_install_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 { + guildId: __t.string(), + commandMode: __t.string(), +}; diff --git a/common/bindings/brico-app/upsert_discord_notify_target_template_reducer.ts b/common/bindings/brico-app/upsert_discord_notify_target_template_reducer.ts new file mode 100644 index 00000000..57a372a6 --- /dev/null +++ b/common/bindings/brico-app/upsert_discord_notify_target_template_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import {t as __t,} from "spacetimedb"; + +export default { + id: __t.string(), + filterId: __t.string(), + discordSinkId: __t.string(), + addedTemplate: __t.string(), + finishedTemplate: __t.string(), + removedTemplate: __t.string(), + updatedAt: __t.timestamp(), +}; diff --git a/common/bindings/brico-app/upsert_loyalty_bonus_total_reducer.ts b/common/bindings/brico-app/upsert_loyalty_bonus_total_reducer.ts new file mode 100644 index 00000000..b59fb648 --- /dev/null +++ b/common/bindings/brico-app/upsert_loyalty_bonus_total_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(), + bonusRatioNumerator: __t.i64(), + bonusRatioDenominator: __t.i64(), + 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_loyalty_rule_reducer.ts b/common/bindings/brico-app/upsert_loyalty_rule_reducer.ts new file mode 100644 index 00000000..69bdf5e0 --- /dev/null +++ b/common/bindings/brico-app/upsert_loyalty_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 {LoyaltyRuleSpec,} from "./types"; + +export default { + id: __t.option(__t.u64()), + currency: __t.string(), + get spec() { + return LoyaltyRuleSpec; + }, + bonusRatioNumerator: __t.i64(), + bonusRatioDenominator: __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..f7640800 --- /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.10.2" + } +} 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..9e97115b --- /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.10.2" + } +} 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/discord-display.ts b/common/crafts/discord-display.ts new file mode 100644 index 00000000..969c46b6 --- /dev/null +++ b/common/crafts/discord-display.ts @@ -0,0 +1,85 @@ +/** + * Closed vocabularies for a Discord watch display's style/sort/content config, shared by + * `backend/brico-module` (write-time validation) and `backend/brico-bot` (rendering). + */ + +export const DISCORD_DISPLAY_STYLES = ["compact", "simple", "icon"] as const; +export type DiscordDisplayStyle = (typeof DISCORD_DISPLAY_STYLES)[number]; + +export function isDiscordDisplayStyle(value: string): value is DiscordDisplayStyle { + return (DISCORD_DISPLAY_STYLES as readonly string[]).includes(value); +} + +export const DISCORD_DISPLAY_STYLE_LABELS: Record = { + compact: "Compact (one block of text)", + simple: "Simple (tier-colored cards)", + icon: "Icon (cards with item icons)", +}; + +export const DISCORD_DISPLAY_SORT_FIELDS = ["remaining", "effort", "tier", "bounty", "newest"] as const; +export type DiscordDisplaySortField = (typeof DISCORD_DISPLAY_SORT_FIELDS)[number]; + +export function isDiscordDisplaySortField(value: string): value is DiscordDisplaySortField { + return (DISCORD_DISPLAY_SORT_FIELDS as readonly string[]).includes(value); +} + +export const DISCORD_DISPLAY_SORT_FIELD_LABELS: Record = { + remaining: "Effort remaining", + effort: "Total effort", + tier: "Tier", + bounty: "Bounty rate", + newest: "Newest", +}; + +export const DISCORD_DISPLAY_SORT_DIRECTIONS = ["asc", "desc"] as const; +export type DiscordDisplaySortDirection = (typeof DISCORD_DISPLAY_SORT_DIRECTIONS)[number]; + +export function isDiscordDisplaySortDirection(value: string): value is DiscordDisplaySortDirection { + return (DISCORD_DISPLAY_SORT_DIRECTIONS as readonly string[]).includes(value); +} + +export const DISCORD_DISPLAY_SORT_DIRECTION_LABELS: Record = { + asc: "Ascending (lowest first)", + desc: "Descending (highest first)", +}; + +/** + * Selectable template lines. A composed template always starts with `**{craft:name} {link}**`, then + * one line per selected preset in this object's key order (the canonical order, regardless of the + * order stored in a display's `presets`). + */ +export const DISCORD_DISPLAY_FIELD_PRESETS = { + ownerLocation: {label: "Owner/Location", templateLine: "{username} · {claim} {region:short}"}, + tierSkill: {label: "Tier/Skill", templateLine: "{tier:short} {skill}"}, + effortProgress: {label: "Effort/Progress", templateLine: "Effort left: {remaining:short} · {progress:percent} done"}, + bountyPayout: {label: "Bounty/Payout", templateLine: "Bounty: {bounty:effort} · {payout:remaining} left"}, +} satisfies Record; + +export const DISCORD_DISPLAY_FIELD_PRESET_KEYS = Object.keys(DISCORD_DISPLAY_FIELD_PRESETS) as DiscordDisplayFieldPresetKey[]; + +export type DiscordDisplayFieldPresetKey = keyof typeof DISCORD_DISPLAY_FIELD_PRESETS; + +export function isDiscordDisplayFieldPresetKey(value: string): value is DiscordDisplayFieldPresetKey { + return Object.prototype.hasOwnProperty.call(DISCORD_DISPLAY_FIELD_PRESETS, value); +} + +/** Builds a display's per-craft template from its selected presets, in canonical preset order. */ +export function composeTemplateFromPresets(presets: readonly string[]): string { + const selected = new Set(presets); + const lines = DISCORD_DISPLAY_FIELD_PRESET_KEYS + .filter(key => selected.has(key)) + .map(key => DISCORD_DISPLAY_FIELD_PRESETS[key].templateLine); + return ["**{craft:name} {link}**", ...lines].join("\n"); +} + +/** Refresh bounds, kept above Discord's per-channel message-edit rate limit. */ +export const MIN_DISPLAY_REFRESH_SECONDS = 15; +export const MAX_DISPLAY_REFRESH_SECONDS = 3600; +export const DEFAULT_DISPLAY_REFRESH_SECONDS = 30; + +/** `0` disables the "delete + resend to stay at the bottom of the channel" behavior entirely. */ +export const MIN_STICKY_MINUTES = 0; +export const MAX_STICKY_MINUTES = 7 * 24 * 60; + +/** Reducer-side ceiling on a display's `limit`; the bot further trims rows per render to fit Discord's message size limits. */ +export const MAX_DISPLAY_ROWS_HARD_CAP = 50; diff --git a/common/crafts/discord-guild.ts b/common/crafts/discord-guild.ts new file mode 100644 index 00000000..d38fc311 --- /dev/null +++ b/common/crafts/discord-guild.ts @@ -0,0 +1,13 @@ +/** Valid values of `discord_guild_install.commandMode`, shared by the module (validation) and the bot's command registry. */ + +export const DISCORD_COMMAND_MODES = ["flat", "grouped"] as const; +export type DiscordCommandMode = (typeof DISCORD_COMMAND_MODES)[number]; + +export function isDiscordCommandMode(value: string): value is DiscordCommandMode { + return (DISCORD_COMMAND_MODES as readonly string[]).includes(value); +} + +export const DISCORD_COMMAND_MODE_LABELS: Record = { + flat: "Flat (top-level commands, e.g. /link)", + grouped: "Grouped (under /brico, e.g. /brico link)", +}; diff --git a/common/crafts/discord-notify.ts b/common/crafts/discord-notify.ts new file mode 100644 index 00000000..91fdb3d9 --- /dev/null +++ b/common/crafts/discord-notify.ts @@ -0,0 +1,9 @@ +/** Default notification wording, used by the bot when a `discord_notify_target` has no custom template for an event. */ +import type {TriggerKind} from './watch'; + +/** Default per-event templates; `{mention}` is notification-only, the rest are `renderDiscordTemplate` tokens. */ +export const DEFAULT_NOTIFY_TEMPLATE: Record = { + added: '{mention} {filterName}: **{craft:name}** now matches. {link}', + finished: '{mention} {filterName}: **{craft:name}** finished. {link}', + removed: '{mention} {filterName}: **{craft:name}** no longer matches. {link}', +}; diff --git a/common/crafts/discord-template.test.ts b/common/crafts/discord-template.test.ts new file mode 100644 index 00000000..aaf86032 --- /dev/null +++ b/common/crafts/discord-template.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for the Discord watch display template renderer. + */ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {composeTemplateFromPresets} from "./discord-display.ts"; +import {type DiscordTemplateContext, formatCompact, renderDiscordTemplate} from "./discord-template.ts"; + +function ctx(overrides: Partial = {}): DiscordTemplateContext { + return { + recipeName: "Peerless Spool of Thread", + craftId: "123", + frontendOrigin: "https://brico.app", + ownerName: "Vasus", + claimName: "Notsolis", + regionId: 14, + tier: 2, + skillName: "Tailoring", + effortTotal: 100, + effortRemaining: 15, + payout: 1 / 15.3, + currency: "hex-coin", + filterName: "Open work", + mentionText: null, + ...overrides, + }; +} + +test("formatCompact abbreviates large numbers", () => { + assert.equal(formatCompact(10_900_000), "10.9M"); + assert.equal(formatCompact(124_000), "124K"); + assert.equal(formatCompact(42), "42"); +}); + +test("renderDiscordTemplate substitutes every known token", () => { + assert.equal( + renderDiscordTemplate("{craft:link}", ctx()), + "[Peerless Spool of Thread]()", + ); + assert.equal(renderDiscordTemplate("{username}", ctx()), "Vasus"); + assert.equal(renderDiscordTemplate("{claim}", ctx()), "Notsolis"); + assert.equal(renderDiscordTemplate("{region:short}", ctx()), "(R14)"); + assert.equal(renderDiscordTemplate("{tier:short}", ctx()), "T2"); + assert.equal(renderDiscordTemplate("{skill}", ctx()), "Tailoring"); + assert.equal(renderDiscordTemplate("{remaining:short}", ctx({effortRemaining: 10_900_000})), "10.9M"); + assert.equal(renderDiscordTemplate("{progress:percent}", ctx({effortTotal: 100, effortRemaining: 15})), "85%"); + assert.equal(renderDiscordTemplate("{bounty:effort}", ctx({payout: 1 / 15.3, currency: "hex-coin"})), "15.3 effort/<:HexCoin:1555600295235559514>"); + assert.equal(renderDiscordTemplate("{payout:remaining}", ctx({payout: 1 / 15.3, effortRemaining: 124_000})), "8.1K"); + assert.equal(renderDiscordTemplate("{filterName}", ctx()), "Open work"); + assert.equal(renderDiscordTemplate("{mention}", ctx({mentionText: "<@123>"})), "<@123>"); +}); + +test("renderDiscordTemplate resolves {mention} to an empty string when no mentionable is configured", () => { + assert.equal(renderDiscordTemplate("{mention} hello", ctx({mentionText: null})), " hello"); +}); + +test("renderDiscordTemplate falls back to a dash for absent optional facts", () => { + assert.equal(renderDiscordTemplate("{username}", ctx({ownerName: null})), "—"); + assert.equal(renderDiscordTemplate("{claim}", ctx({claimName: null})), "—"); + assert.equal(renderDiscordTemplate("{tier:short}", ctx({tier: null})), "—"); + assert.equal(renderDiscordTemplate("{skill}", ctx({skillName: null})), "—"); + assert.equal(renderDiscordTemplate("{bounty:effort}", ctx({payout: null})), "—"); + assert.equal(renderDiscordTemplate("{payout:remaining}", ctx({payout: null})), "—"); +}); + +test("renderDiscordTemplate leaves an unrecognized token as literal text", () => { + assert.equal(renderDiscordTemplate("before {nonsense} after", ctx()), "before {nonsense} after"); +}); + +test("renderDiscordTemplate reproduces the full example layout from all four presets", () => { + const template = composeTemplateFromPresets(["ownerLocation", "tierSkill", "effortProgress", "bountyPayout"]); + const rendered = renderDiscordTemplate(template, ctx({effortTotal: 100, effortRemaining: 20, payout: 0.1})); + assert.equal( + rendered, + [ + "**Peerless Spool of Thread [↗]()**", + "Vasus · Notsolis (R14)", + "T2 Tailoring", + "Effort left: 20 · 80% done", + "Bounty: 10 effort/<:HexCoin:1555600295235559514> · 2 left", + ].join("\n"), + ); +}); + +test("composeTemplateFromPresets always uses canonical order regardless of input order", () => { + const template = composeTemplateFromPresets(["bountyPayout", "ownerLocation"]); + assert.equal( + template, + ["**{craft:name} {link}**", "{username} · {claim} {region:short}", "Bounty: {bounty:effort} · {payout:remaining} left"].join("\n"), + ); +}); + +test("composeTemplateFromPresets with no presets is just the craft link", () => { + assert.equal(composeTemplateFromPresets([]), "**{craft:name} {link}**"); +}); diff --git a/common/crafts/discord-template.ts b/common/crafts/discord-template.ts new file mode 100644 index 00000000..ac672eb1 --- /dev/null +++ b/common/crafts/discord-template.ts @@ -0,0 +1,69 @@ +/** + * Substitutes `{token}`/`{token:format}` placeholders in a Discord display or notification template + * against one craft's facts. Unrecognized tokens are left as literal text rather than throwing. + */ +import {CURRENCY_EMOJI, CURRENCY_LABELS} from "./filter.ts"; + +/** Everything one craft's rendered template line might reference. */ +export interface DiscordTemplateContext { + recipeName: string; + craftId: string; + frontendOrigin: string; + ownerName: string | null; + claimName: string | null; + regionId: number; + tier: number | null; + skillName: string | null; + effortTotal: number; + effortRemaining: number; + payout: number | null; + currency: string | null; + /** The watch's saved-filter name. */ + filterName: string; + /** `<@userId>`/`<@&roleId>`, or `null` if none is configured; notifications only. */ + mentionText: string | null; +} + +const DASH = "—"; + +/** `10900000` → `"10.9M"`, `124000` → `"124K"`. */ +export function formatCompact(n: number): string { + return new Intl.NumberFormat("en", {notation: "compact", maximumFractionDigits: 1}).format(n); +} + +type TokenResolver = (ctx: DiscordTemplateContext) => string; + +const TOKENS: Record = { + "craft:link": ctx => `[${ctx.recipeName}](<${ctx.frontendOrigin}/tools/crafts/${ctx.craftId}>)`, + "craft:name": ctx => `${ctx.recipeName}`, + "link": ctx => `[↗](<${ctx.frontendOrigin}/tools/crafts/${ctx.craftId}>)`, + username: ctx => ctx.ownerName ?? DASH, + claim: ctx => ctx.claimName ?? DASH, + "region:short": ctx => `(R${ctx.regionId})`, + "tier:short": ctx => (ctx.tier !== null ? `T${ctx.tier}` : DASH), + skill: ctx => ctx.skillName ?? DASH, + "remaining:short": ctx => formatCompact(ctx.effortRemaining), + "progress:percent": ctx => { + const percent = ctx.effortTotal > 0 ? (100 * (ctx.effortTotal - ctx.effortRemaining)) / ctx.effortTotal : 0; + return `${Math.round(percent)}%`; + }, + "bounty:effort": ctx => { + if (ctx.payout === null || ctx.currency === null || ctx.payout <= 0) return DASH; + const effortPerCurrency = 1 / ctx.payout; + const currencyLabel = CURRENCY_EMOJI[ctx.currency] ?? CURRENCY_LABELS[ctx.currency] ?? ctx.currency; + return `${effortPerCurrency.toLocaleString("en", {maximumFractionDigits: 1})} effort/${currencyLabel}`; + }, + "payout:remaining": ctx => (ctx.payout !== null ? formatCompact(ctx.payout * ctx.effortRemaining) : DASH), + filterName: ctx => ctx.filterName, + mention: ctx => ctx.mentionText ?? "", +}; + +const TOKEN_PATTERN = /\{([^{}]+)}/g; + +/** Substitutes every recognized `{token}`/`{token:format}` in `template` against `ctx`; an unrecognized token is left as-is. */ +export function renderDiscordTemplate(template: string, ctx: DiscordTemplateContext): string { + return template.replace(TOKEN_PATTERN, (placeholder, token: string) => { + const resolve = TOKENS[token]; + return resolve ? resolve(ctx) : placeholder; + }); +} diff --git a/common/crafts/entitlement.test.ts b/common/crafts/entitlement.test.ts new file mode 100644 index 00000000..1fcfb43f --- /dev/null +++ b/common/crafts/entitlement.test.ts @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; +import {describe, it} from "node:test"; +import {addRatio, advancePool, bonusFromMultiplier, computeEntitlement, fitFraction, multiplierFromBonus, 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); + }); +}); + +describe("bonusFromMultiplier", () => { + it("converts a multiplier to a bonus-only fraction", () => { + assert.deepEqual(bonusFromMultiplier(105n, 100n), {numerator: 1n, denominator: 20n}); + }); + + it("is zero for an exact no-op multiplier", () => { + assert.deepEqual(bonusFromMultiplier(100n, 100n), {numerator: 0n, denominator: 1n}); + }); +}); + +describe("multiplierFromBonus", () => { + it("is the inverse of bonusFromMultiplier", () => { + assert.deepEqual(multiplierFromBonus(1n, 40n), {numerator: 41n, denominator: 40n}); + const bonus = bonusFromMultiplier(105n, 100n); + assert.deepEqual(multiplierFromBonus(bonus.numerator, bonus.denominator), reduceRatio(105n, 100n)); + }); + + it("is 1 for a zero bonus", () => { + assert.deepEqual(multiplierFromBonus(0n, 1n), {numerator: 1n, denominator: 1n}); + }); +}); + +describe("addRatio", () => { + it("adds two fractions with different denominators, reduced", () => { + assert.deepEqual(addRatio({numerator: 1n, denominator: 2n}, {numerator: 1n, denominator: 3n}), {numerator: 5n, denominator: 6n}); + }); + + it("is the identity when adding zero", () => { + assert.deepEqual(addRatio({numerator: 0n, denominator: 1n}, {numerator: 3n, denominator: 7n}), {numerator: 3n, denominator: 7n}); + }); + + it("matches the worked 2%+2.5%+1.5%+2%=8% example, additive rather than compounding", () => { + const manual = bonusFromMultiplier(102n, 100n); // +2% + const claimRule = bonusFromMultiplier(1025n, 1000n); // +2.5% + const effortTier1 = bonusFromMultiplier(1015n, 1000n); // +1.5% + const effortTier2 = bonusFromMultiplier(102n, 100n); // +2% + + const totalBonus = [claimRule, effortTier1, effortTier2].reduce(addRatio, manual); + assert.deepEqual(totalBonus, {numerator: 2n, denominator: 25n}); // 8% = 2/25 + + const finalMultiplier = {numerator: totalBonus.numerator + totalBonus.denominator, denominator: totalBonus.denominator}; + assert.deepEqual(finalMultiplier, {numerator: 27n, denominator: 25n}); // 1.08 + + // Composed against a sample bounty ratio (1 Hex Coin per 20 effort), one floor + const bountyRatioNumerator = 1n; + const bountyRatioDenominator = 20n; + const effectiveNumerator = bountyRatioNumerator * finalMultiplier.numerator; + const effectiveDenominator = bountyRatioDenominator * finalMultiplier.denominator; + assert.equal(computeEntitlement(500n, effectiveNumerator, effectiveDenominator), 27n); // floor(500/20 * 1.08) = floor(27) = 27 + }); +}); + +describe("advancePool", () => { + const f = (numerator: bigint, denominator: bigint) => ({numerator, denominator}); + + it("floors and carries like the single-pool rule", () => { + let carry = f(0n, 1n); + let total = 0n; + for (const delta of [f(3n, 5n), f(3n, 5n), f(1n, 2n)]) { + const {earned, remainder} = advancePool(carry, delta); + total += earned; + carry = remainder; + } + assert.equal(total, 1n); + assert.deepEqual(carry, f(7n, 10n)); + }); + + it("rounds a carry that does not fit i64 down instead of overflowing", () => { + const {earned, remainder} = advancePool(f(1n, 2n ** 62n + 1n), f(1n, 2n ** 62n - 1n)); + assert.equal(earned, 0n); + assert.ok(remainder.denominator <= 2n ** 63n - 1n); + assert.ok(remainder.numerator * (2n ** 62n + 1n) * (2n ** 62n - 1n) <= (2n ** 62n - 1n + 2n ** 62n + 1n) * remainder.denominator); + }); + + it("floors a negative delta so the carry stays in [0, 1)", () => { + const {earned, remainder} = advancePool(f(1n, 4n), f(-1n, 2n)); + assert.equal(earned, -1n); + assert.deepEqual(remainder, f(3n, 4n)); + }); + + it("fitFraction leaves a fitting fraction exact", () => { + assert.deepEqual(fitFraction(f(3n, 7n)), f(3n, 7n)); + }); +}); diff --git a/common/crafts/entitlement.ts b/common/crafts/entitlement.ts new file mode 100644 index 00000000..8f68dab2 --- /dev/null +++ b/common/crafts/entitlement.ts @@ -0,0 +1,121 @@ +/** + * 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}; +} + +/** + * Converts a multiplier (>=1, e.g. `1.025` for +2.5% — the shape every loyalty multiplier/bonus is + * typed in and stored as) into a bonus-only fraction (e.g. `1/40`). Several bonuses (a manual + * assignment plus any number of satisfied automated rules) must **add** — 2%+2.5%+1.5%+2%=8% — not + * compound as multipliers (`1.02 x 1.025 x 1.015 x 1.02 =/= 1.08`), so every bonus source is + * converted to this shape before summing (`addRatio`) and the sum is converted back to a multiplier + * (`1 + bonus`) once, immediately before composing with a craft's bounty ratio. + */ +export function bonusFromMultiplier(numerator: bigint, denominator: bigint): {numerator: bigint; denominator: bigint} { + return reduceRatio(numerator - denominator, denominator); +} + +/** The inverse of `bonusFromMultiplier` — a bonus-only fraction back to a multiplier (e.g. `1/40` -> `41/40`, i.e. `1.025`), for displaying a stored bonus in the same "type 1.025" units a multiplier field edits. */ +export function multiplierFromBonus(numerator: bigint, denominator: bigint): {numerator: bigint; denominator: bigint} { + return reduceRatio(numerator + denominator, denominator); +} + +/** Exact fraction addition (`a + b`), reduced — never through a float. */ +export function addRatio( + a: {numerator: bigint; denominator: bigint}, + b: {numerator: bigint; denominator: bigint}, +): {numerator: bigint; denominator: bigint} { + return reduceRatio(a.numerator * b.denominator + b.numerator * a.denominator, a.denominator * b.denominator); +} + +/** + * 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)); +} + +const I64_MAX = 2n ** 63n - 1n; +const FIT_DENOMINATOR = 10n ** 18n; + +/** + * Returns a non-negative fraction that fits i64, unchanged when it already does. Otherwise it is + * rounded down to a `10^-18` grid — denominators multiply on every `addRatio` of unlike ratios and + * would otherwise outgrow the column, which wraps silently on write. + */ +export function fitFraction(f: {numerator: bigint; denominator: bigint}): {numerator: bigint; denominator: bigint} { + if (f.numerator <= I64_MAX && f.denominator <= I64_MAX) return f; + return reduceRatio((f.numerator * FIT_DENOMINATOR) / f.denominator, FIT_DENOMINATOR); +} + +/** + * Adds `delta` (an unfloored currency amount) to a pool's carried fraction, floors once, and + * returns the whole units earned plus the new carry in `[0, 1)`, rounded down to fit i64 if needed. + * A negative `delta` (effort that dropped) can make `earned` negative. + */ +export function advancePool( + carry: {numerator: bigint; denominator: bigint}, + delta: {numerator: bigint; denominator: bigint}, +): {earned: bigint; remainder: {numerator: bigint; denominator: bigint}} { + const combined = addRatio(carry, delta); + let earned = combined.numerator / combined.denominator; + // bigint division truncates toward zero; step down so the remainder is never negative. + if (combined.numerator < 0n && earned * combined.denominator !== combined.numerator) earned -= 1n; + const remainder = fitFraction(reduceRatio(combined.numerator - earned * combined.denominator, combined.denominator)); + return {earned, remainder}; +} diff --git a/common/crafts/errors.ts b/common/crafts/errors.ts new file mode 100644 index 00000000..74716d91 --- /dev/null +++ b/common/crafts/errors.ts @@ -0,0 +1,146 @@ +/** + * errors.ts — the shared `SenderError` code contract between `backend/brico-module` and the + * frontend. + * + * 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; +/** `attachDiscordWatchDisplay`'s `template` content; only length is validated, not token grammar. */ +export const MAX_DISPLAY_TEMPLATE_LENGTH = 2000; +/** `discord_notify_target`'s `added`/`finished`/`removedTemplate` columns (single-line messages). */ +export const MAX_NOTIFY_TEMPLATE_LENGTH = 250; + +// ── 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. + */ +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..98fda3ed 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.10.2", "tailwind-merge": "^3.3.0", "tailwindcss-animate": "^1.0.7", "vinxi": "^0.5.8" diff --git a/frontend/public/bitcraft.svg b/frontend/public/bitcraft.svg new file mode 100644 index 00000000..1fa0658c --- /dev/null +++ b/frontend/public/bitcraft.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/discord.svg b/frontend/public/discord.svg new file mode 100644 index 00000000..53fe9077 --- /dev/null +++ b/frontend/public/discord.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file 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..6ee6ce43 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%; @@ -255,5 +255,6 @@ height: 1px; margin-top: calc(1em + 1px); margin-right: 4px; - flex-grow: 1; + flex: 1 1 0; + min-width: 2rem; } 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
+ + + Payer + Player + Amount + Date + + + + 0} + fallback={ + + + Loading…}> + No transactions yet. + + + + } + > + + {row => ( + + {row.payerName} + {props.playerName(row.payeePlayerId.toString())} + + + + {row.amount > 0n ? "+" : ""}{row.amount.toString()} + + + + {new Date(Number(row.createdAt.toMillis())).toLocaleString(uiLocale())} + + + )} + + + +
+ + + + ); +} diff --git a/frontend/src/components/crafts/PayoutRateButton.tsx b/frontend/src/components/crafts/PayoutRateButton.tsx new file mode 100644 index 00000000..a9c9d278 --- /dev/null +++ b/frontend/src/components/crafts/PayoutRateButton.tsx @@ -0,0 +1,34 @@ +/** + * PayoutRateButton.tsx — a bounty rate, decimal-formatted in whichever direction `~/lib/settings`'s + * shared `payoutDisplayMode` currently prefers. Clicking it flips that setting (and so every other + * instance of this component, not just this one). `rate` is always currency-per-effort — storage + * and the real ledger math (`@brico/crafts/entitlement`) never go through this, only display. + */ +import {msg} from "@lingui/core/macro"; +import {useLingui} from "@lingui/solid"; +import {Trans} from "@lingui/solid/macro"; +import {Show} from "solid-js"; +import {CurrencyLabel} from "~/lib/crafts/filter-condition"; +import {uiLocale} from "~/lib/i18n"; +import {useSettings} from "~/lib/settings"; + +export function PayoutRateButton(props: {rate: number; currency: string}) { + const {_} = useLingui(); + const settings = useSettings(); + const inverted = () => settings.payoutDisplayMode() === "effortPerCurrency"; + const rateText = () => { + const value = inverted() ? (props.rate > 0 ? 1 / props.rate : 0) : props.rate; + return value.toLocaleString(uiLocale(), {maximumFractionDigits: 4}); + }; + return ( + + ); +} diff --git a/frontend/src/components/crafts/PlayerPicker.tsx b/frontend/src/components/crafts/PlayerPicker.tsx new file mode 100644 index 00000000..90407ebe --- /dev/null +++ b/frontend/src/components/crafts/PlayerPicker.tsx @@ -0,0 +1,167 @@ +/** + * PlayerPicker.tsx — searchable picker over a fixed set of BitCraft players, styled after + * `FilterBuilder`'s `OptionPicker` but simpler: every tick publishes immediately, since there's no + * shared leaf row to tear this popover down out from under an in-progress pick. + * + * `ClaimPicker` (below) shares the same popover/search/list shell via the unexported generic + * `EntityPicker` — the only thing that differs between the two is which field of the caller's + * option shape is the id (`playerId` vs `claimId`) and the placeholder text, both supplied by each + * thin wrapper rather than duplicating the ~90-line shell. + */ +import {msg} from "@lingui/core/macro"; +import {useLingui} from "@lingui/solid"; +import {Trans} from "@lingui/solid/macro"; +import {TbOutlineCheck as IconCheck} from "solid-icons/tb"; +import {createMemo, createSignal, For, type JSX, Show} from "solid-js"; +import {Badge} from "~/components/ui/badge"; +import {Button} from "~/components/ui/button"; +import {Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList} from "~/components/ui/command"; +import {Popover, PopoverContent, PopoverTrigger} from "~/components/ui/popover"; +import {cn} from "~/lib/utils"; + +export interface PlayerOption { + playerId: string; + name: string; +} + +export interface ClaimOption { + claimId: string; + name: string; +} + +interface EntityOption { + id: string; + name: string; +} + +const MAX_VISIBLE_OPTIONS = 100; + +function EntityPicker(props: { + options: EntityOption[]; + selected: string[]; + onChange: (ids: string[]) => void; + /** Multiselect (toggle membership, stay open) vs. single-select (replace, close on pick). */ + multiple: boolean; + searchPlaceholder: string; + selectManyPlaceholder: JSX.Element; + selectOnePlaceholder: JSX.Element; +}) { + const [search, setSearch] = createSignal(""); + const [open, setOpen] = createSignal(false); + + const nameFor = (id: string) => props.options.find(o => o.id === id)?.name ?? id; + + /** + * Visible options, ranked so the ones someone is actually looking for surface first. + */ + const matches = createMemo(() => { + const needle = search().toLowerCase(); + const selected = new Set(props.selected); + const bucket = (option: EntityOption): number => { + if (needle) { + if (option.name.toLowerCase() === needle) return 0; + return selected.has(option.id) ? 1 : 2; + } + return selected.has(option.id) ? 0 : 1; + }; + const visible = needle ? props.options.filter(o => o.name.toLowerCase().includes(needle)) : props.options; + return visible + .map((option, index) => ({option, index, bucket: bucket(option)})) + .sort((a, b) => a.bucket - b.bucket || a.index - b.index) + .slice(0, MAX_VISIBLE_OPTIONS) + .map(({option}) => option); + }); + + const pick = (id: string) => { + if (props.multiple) { + const selected = props.selected; + props.onChange(selected.includes(id) ? selected.filter(existing => existing !== id) : [...selected, id]); + return; + } + props.onChange(props.selected.includes(id) ? [] : [id]); + setOpen(false); + }; + + return ( + + } variant="outline" class="h-auto min-h-9 min-w-48 flex-1 justify-start py-1.5 font-normal whitespace-normal"> + 0} fallback={{props.multiple ? props.selectManyPlaceholder : props.selectOnePlaceholder}}> + {nameFor(props.selected[0])}}> + + + {id => {nameFor(id)}} + + 3}> + +{props.selected.length - 3} + + + + + + + + + + No matches. + + + {option => ( + pick(option.id)}> +
+ +
+ {option.name} +
+ )} +
+
+
+
+
+
+ ); +} + +export function PlayerPicker(props: { + options: PlayerOption[]; + selected: string[]; + onChange: (playerIds: string[]) => void; + /** Multiselect (toggle membership, stay open) vs. single-select (replace, close on pick). Defaults to `true`. */ + multiple?: boolean; +}) { + const {_} = useLingui(); + return ( + ({id: o.playerId, name: o.name}))} + selected={props.selected} + onChange={props.onChange} + multiple={props.multiple ?? true} + searchPlaceholder={_(msg`Search players…`)} + selectManyPlaceholder={Select players…} + selectOnePlaceholder={Select player…} + /> + ); +} + +/** Always single-select — a claim-membership loyalty rule only ever names one claim. */ +export function ClaimPicker(props: { + options: ClaimOption[]; + selected: string[]; + onChange: (claimIds: string[]) => void; +}) { + const {_} = useLingui(); + return ( + ({id: o.claimId, name: o.name}))} + selected={props.selected} + onChange={props.onChange} + multiple={false} + searchPlaceholder={_(msg`Search claims…`)} + selectManyPlaceholder={Select claims…} + selectOnePlaceholder={Select claim…} + /> + ); +} diff --git a/frontend/src/components/crafts/QuickFilterGrid.tsx b/frontend/src/components/crafts/QuickFilterGrid.tsx new file mode 100644 index 00000000..8f01f0aa --- /dev/null +++ b/frontend/src/components/crafts/QuickFilterGrid.tsx @@ -0,0 +1,106 @@ +/** + * QuickFilterGrid.tsx — the craft browser's claim/player/item pickers and skill/tier toggle grids. + */ +import type {FilterValue} from "@brico/crafts/filter"; +import {msg} from "@lingui/core/macro"; +import {useLingui} from "@lingui/solid"; +import {For, Show} from "solid-js"; +import {type FieldOption, OptionPicker} from "~/components/crafts/FilterBuilder"; +import {FontIcon} from "~/components/icons/font-icons"; +import {TierIcon} from "~/components/shared/GameIcon"; +import {Button} from "~/components/ui/button"; +import {cn} from "~/lib/utils"; + +export interface QuickFilterSkill { + id: number; + name: string; + iconAssetName?: string; +} + +/** One of the picker dropdowns: its option list and current selection. */ +export interface QuickFilterPicker { + options: FieldOption[]; + selected: FilterValue[]; + onChange: (values: FilterValue[]) => void; +} + +/** + * Reads `picker` through `props` so the `OptionPicker` is created once: the page hands in a fresh + * `picker` object on every snapshot, and rebuilding the element would close an open dropdown. + */ +function QuickPicker(props: {picker: QuickFilterPicker; placeholder: string}) { + return ( + props.picker.onChange(values)} + /> + ); +} + +export function QuickFilterGrid(props: { + claim: QuickFilterPicker; + owner: QuickFilterPicker; + item: QuickFilterPicker; + skills: QuickFilterSkill[]; + tiers: number[]; + selectedSkills: ReadonlySet; + selectedTiers: ReadonlySet; + active: boolean; + onToggleSkill: (id: number) => void; + onToggleTier: (tier: number) => void; +}) { + const {_} = useLingui(); + return ( +
+
+ + + +
+
+ + {skill => { + const selected = () => props.selectedSkills.has(skill.id); + return ( + + ); + }} + +
+
+ + {tier => { + const selected = () => props.selectedTiers.has(tier); + return ( + + ); + }} + +
+
+ ); +} diff --git a/frontend/src/components/crafts/SavedFiltersDialog.tsx b/frontend/src/components/crafts/SavedFiltersDialog.tsx new file mode 100644 index 00000000..34fc7514 --- /dev/null +++ b/frontend/src/components/crafts/SavedFiltersDialog.tsx @@ -0,0 +1,389 @@ +/** + * SavedFiltersDialog.tsx — the craft browser's "Saved Filters" import/export dialog. + * + * Import accepts three shapes in one textarea: + * - a single `{name?, filter}` export + * - a JSON array of exports (invalid entries are skipped rather than failing the whole paste) + * - a share code minted by `createSharedFilters` (redeemed via the `readSharedFilters` procedure). + * A file upload always goes through the JSON path. + * + * Export lists one row per saved filter plus a synthetic "All filters" row bundling every one of + * them; each row can be copied as JSON, downloaded as a file, or (logged in only, since a share + * link is backed by the account-owned `saved_craft_filter` row server-side) turned into a share + * code via `createSharedFilters`. + */ +import type {ReadSharedFiltersResult} from "@brico/bindings/brico-app/types"; +import {type FilterField, type FilterNode, type FilterValue, parseFilter, validateFilter} from "@brico/crafts/filter"; +import {msg, plural, t} from "@lingui/core/macro"; +import {useLingui} from "@lingui/solid"; +import {Plural, Trans} from "@lingui/solid/macro"; +import { + TbOutlineClipboardCheck as IconClipboardCheck, + TbOutlineCopy as IconCopy, + TbOutlineDownload as IconDownload, + TbOutlineShare as IconShare, + TbOutlineTrash as IconRemove, + TbOutlineUpload as IconUpload, +} from "solid-icons/tb"; +import {createMemo, createSignal, For, Show} from "solid-js"; +import {Button} from "~/components/ui/button"; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from "~/components/ui/dialog"; +import {Popover, PopoverAnchor, PopoverContent, PopoverTrigger} from "~/components/ui/popover"; +import {TextField, TextFieldTextArea} from "~/components/ui/text-field"; +import {useAccount} from "~/lib/account/state"; +import {describeServerError} from "~/lib/crafts/error-vocab"; +import {describeFilterLocalized} from "~/lib/crafts/filter-condition"; +import {trackUILocale} from "~/lib/i18n"; +import {useLabel} from "~/lib/labels"; +import {type CraftWatchTriggers, SavedCraftFilter, useSettings} from "~/lib/settings"; +import {BRICO_APP_SERVER} from "~/lib/spacetime/brico-app"; +import {useConnection} from "~/lib/spacetime/manager"; +import {cn, downloadTextFile, useCopy} from "~/lib/utils"; + +export type FilterExport = {name: string; filter: FilterNode}; + +/** The share-code alphabet's shape (see `SHARE_CODE_ALPHABET` in the module) — used only to tell a + * pasted code apart from JSON, never to validate it; an unknown code is just rejected by the server. */ +const SHARE_CODE_PATTERN = /^[2-9A-HJ-NP-Z]{6}$/i; + +/** Unwraps one import candidate — a bare `FilterNode`, or a `{name, filter}` export. */ +function unwrapCandidate(raw: unknown): {node: unknown; name: string | null} { + if (raw && typeof raw === "object" && "filter" in (raw as Record)) { + const candidate = raw as Partial; + return {node: candidate.filter, name: typeof candidate.name === "string" ? candidate.name : null}; + } + return {node: raw, name: null}; +} + +/** `json`'s top-level value, treating an array as a batch of candidates and anything else as one. */ +function candidatesFrom(json: unknown): {node: unknown; name: string | null}[] { + return (Array.isArray(json) ? json : [json]).map(unwrapCandidate); +} + +/** Keeps only the structurally valid candidates — a batch import skips bad entries, it doesn't fail outright. */ +function validEntriesFrom(candidates: {node: unknown; name: string | null}[]): FilterExport[] { + trackUILocale(); + const entries: FilterExport[] = []; + candidates.forEach((candidate, index) => { + if (validateFilter(candidate.node).length === 0) { + const fallback = candidates.length > 1 ? t`Imported filter ${index + 1}` : t`Imported filter`; + entries.push({name: candidate.name ?? fallback, filter: candidate.node as FilterNode}); + } + }); + return entries; +} + +/** Parses pasted/uploaded JSON text into import entries, or an error message if none were valid. */ +function parseImportJson(text: string): {entries: FilterExport[]; error: string | null} { + trackUILocale(); + let json: unknown; + try { + json = JSON.parse(text); + } catch { + return {entries: [], error: t`Not valid JSON.`}; + } + const entries = validEntriesFrom(candidatesFrom(json)); + return entries.length > 0 ? {entries, error: null} : {entries: [], error: t`No valid filters found.`}; +} + +/** Turns a redeemed share code's rows into import entries, dropping any that fail to parse. */ +function entriesFromShared(result: ReadSharedFiltersResult): FilterExport[] { + const entries: FilterExport[] = []; + for (const row of result.filters) { + let parsedJson: unknown; + try { + parsedJson = JSON.parse(row.filterJson); + } catch { + continue; + } + const filter = parseFilter(parsedJson); + if (filter) entries.push({name: row.name, filter}); + } + return entries; +} + +interface ExportRow { + key: string; + name: string; + filterIds: string[]; + json: string; +} + +function ExportRowView(props: {row: ExportRow; canShare: boolean; onShare: (filterIds: string[]) => Promise, onDelete: (key: string) => void}) { + const {_} = useLingui(); + const [copy, copied] = useCopy(() => props.row.json); + const [shareOpen, setShareOpen] = createSignal(false); + const [shareCode, setShareCode] = createSignal(null); + const [shareError, setShareError] = createSignal(null); + const [shareBusy, setShareBusy] = createSignal(false); + const [copyCode, codeCopied] = useCopy(() => shareCode() ?? ""); + const [confirmOpen, setConfirmOpen] = createSignal(false); + + const onShareOpenChange = (open: boolean) => { + setShareOpen(open); + if (!open || shareCode() !== null || shareBusy()) return; + setShareBusy(true); + setShareError(null); + props.onShare(props.row.filterIds) + .then(setShareCode) + .catch((cause: unknown) => setShareError(describeServerError(cause))) + .finally(() => setShareBusy(false)); + }; + + return ( +
+ {props.row.name} +
+ + + } variant="ghost" size="icon" class="size-8" + aria-label={_(msg`Get a share link for ${props.row.name}`)} title={_(msg`Share link`)} + > + + + +

Generating…

+ {message =>

{message()}

}
+ + {code => ( +
+ {code()} + +
+ )} +
+
+
+
+ + + + { if (e.shiftKey) props.onDelete(props.row.key); }} + > + + + + + + Delete "{props.row.name}"?}> + Delete all filters? + + + This can't be undone. The filter will still be visible via previously created share codes, Discord displays, etc. + + + + + + + +
+
+ ); +} + +export function SavedFiltersDialog(props: { + open: boolean; + onOpenChange: (open: boolean) => void; + savedFilters: SavedCraftFilter[]; + onImport: (entries: FilterExport[]) => void; + /** Resolves an id/number to a display label for the pending-import preview's filter summaries — see `describeFilterLocalized`. */ + labelFor?: (field: FilterField, value: FilterValue) => string; +}) { + const {_} = useLingui(); + const label = useLabel(); + const {isLoggedIn} = useAccount(); + const conn = useConnection(BRICO_APP_SERVER); + + const {savedCraftFilters, setSavedCraftFilters, craftFilterWatches, setCraftFilterWatches} = useSettings(); + const setWatchFor = (id: string, next: CraftWatchTriggers) => { + const {[id]: _dropped, ...rest} = craftFilterWatches(); + setCraftFilterWatches(next.added || next.finished || next.removed ? {...rest, [id]: next} : rest); + }; + + const [importText, setImportText] = createSignal(""); + const [importBusy, setImportBusy] = createSignal(false); + const [importMessage, setImportMessage] = createSignal<{text: string; error: boolean} | null>(null); + const [pendingImport, setPendingImport] = createSignal(null); + let fileInput: HTMLInputElement | undefined; + + // Parsing never applies an import directly — it stages the candidates behind the confirm + // popover below (`pendingImport`) so the user can see what they're about to add first. + const presentImport = (entries: FilterExport[], error: string | null) => { + setImportBusy(false); + if (entries.length === 0) { + setImportMessage({text: error ?? _(msg`No valid filters found.`), error: true}); + return; + } + setPendingImport(entries); + }; + + const confirmImport = () => { + const entries = pendingImport(); + setPendingImport(null); + if (!entries) return; + props.onImport(entries); + setImportText(""); + setImportMessage({text: _(msg({message: plural(entries.length, {one: "Imported # filter.", other: "Imported # filters."})})), error: false}); + }; + + const cancelImport = () => setPendingImport(null); + + const importFromFile = (text: string) => { + const {entries, error} = parseImportJson(text); + presentImport(entries, error); + }; + + const importFromTextarea = () => { + const trimmed = importText().trim(); + if (!trimmed) return; + setImportBusy(true); + setImportMessage(null); + + let looksLikeJson = true; + try { + JSON.parse(trimmed); + } catch { + looksLikeJson = false; + } + + if (!looksLikeJson && SHARE_CODE_PATTERN.test(trimmed)) { + const active = conn.active(); + if (!active) { + presentImport([], _(msg`Not connected.`)); + return; + } + active.procedures.readSharedFilters({code: trimmed.toUpperCase()}) + .then(result => { + const entries = entriesFromShared(result); + presentImport(entries, entries.length === 0 ? _(msg`That share code has no importable filters.`) : null); + }) + .catch((cause: unknown) => presentImport([], describeServerError(cause))); + return; + } + + const {entries, error} = parseImportJson(trimmed); + presentImport(entries, error); + }; + + const onFileChange = (e: Event) => { + const input = e.currentTarget as HTMLInputElement; + const file = input.files?.[0]; + input.value = ""; + if (!file) return; + setImportBusy(true); + setImportMessage(null); + file.text().then(importFromFile); + }; + + const exportRows = createMemo(() => { + const rows = props.savedFilters.map((saved): ExportRow => ({ + key: saved.id, + name: saved.name, + filterIds: [saved.id], + json: JSON.stringify({name: saved.name, filter: saved.filter} satisfies FilterExport), + })); + if (props.savedFilters.length > 0) { + rows.push({ + key: "__all__", + name: "All filters", + filterIds: props.savedFilters.map(saved => saved.id), + json: JSON.stringify(props.savedFilters.map((saved): FilterExport => ({name: saved.name, filter: saved.filter}))), + }); + } + return rows; + }); + + const NO_WATCH: CraftWatchTriggers = {added: false, finished: false, removed: false}; + const onDelete = (key: string) => { + const savedList = key === "__all__" ? savedCraftFilters() : [savedCraftFilters().find(s => s.id === key)].filter(s => !!s); + savedList.forEach(saved => { + setSavedCraftFilters(savedCraftFilters().filter(other => other.id !== saved.id)); + setWatchFor(saved.id, NO_WATCH); + }); + } + + const shareFilters = async (filterIds: string[]): Promise => { + const active = conn.active(); + if (!active) throw new Error(_(msg`Not connected.`)); + const result = await active.procedures.createSharedFilters({filterIds}); + return result.code; + }; + + return ( + + + + Import / Export + +
+
+

Import

+ {setImportText(value); setImportMessage(null);}}> + + + { if (!open) cancelImport(); }}> + + + + + + +

+ Import ? +

+
+ + {entry => ( +
+

{entry.name}

+

{describeFilterLocalized(entry.filter, label, props.labelFor)}

+
+ )} +
+
+
+ + +
+
+
+ + {message =>

{message().text}

} +
+
+ +
+

Export

+ +

Log in to create share links.

+
+ 0} fallback={

No saved filters yet.

}> +
+ + {row => } + +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/data-table/live-table.tsx b/frontend/src/components/data-table/live-table.tsx new file mode 100644 index 00000000..8060dd85 --- /dev/null +++ b/frontend/src/components/data-table/live-table.tsx @@ -0,0 +1,265 @@ +/** + * live-table.tsx — the shared table chrome, for tables whose rows are a live feed. + * + * `DataTable` is built around the static database tables: it owns faceted filters, URL + * round-tripping and per-table session state, and it installs the three faceted row models on + * every table whether or not anything uses them. A feed like the craft browser wants none of that + * — filtering there belongs to `FilterBuilder`, there is no page to navigate back to, and + * re-deriving per-column facets over a few thousand rows twice a second is pure cost. + * + * What it does want is the parts that make a table on this site *look and behave like the others*: + * sortable column headers with the same dropdown, the same pagination bar, the same "View" column + * toggle, and the same persisted hidden-column set — plus, optionally, a plain single-column + * name search (`searchColumnId`), for the reports that group rows and need one. Those are already + * three separate components, so this is a thin frame around them and a `createSolidTable` + * configured with the row models that are actually needed. + */ +import {msg} from "@lingui/core/macro"; +import {useLingui} from "@lingui/solid"; +import {Trans} from "@lingui/solid/macro"; +import { + type ColumnDef, + type ColumnFiltersState, + createSolidTable, + type ExpandedState, + getCoreRowModel, + getExpandedRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type HeaderContext, + type InitialTableState, + type VisibilityState, +} from "@tanstack/solid-table"; +import {createEffect, createMemo, createSignal, For, type JSX, Show} from "solid-js"; +import {Dynamic} from "solid-js/web"; +import {TableColumnHeader} from "~/components/data-table/table-column-header"; +import {TablePagination} from "~/components/data-table/table-pagination"; +import {TableViewOptions} from "~/components/data-table/table-view-options"; +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "~/components/ui/table"; +import {TextField, TextFieldInput} from "~/components/ui/text-field"; +import {useLabel} from "~/lib/labels"; +import {type TableSessionState, useSettings} from "~/lib/settings"; + +type LiveTableProps = { + /** + * Identifies the table to the settings store, which is where the hidden-column set is kept. + * Shares the namespace with `DataTable`'s tables, so it has to be unique across both. + */ + name: string; + columns: ColumnDef[]; + data: TData[]; + /** Stable row identity. Worth supplying for a feed, whose row objects are rebuilt each tick. */ + getRowId?: (row: TData, index: number) => string; + /** Enables hierarchical rows (group parent + expandable subrows) when supplied — wires + * `getExpandedRowModel`/`getSubRows`. Inert when omitted: no row ever has subrows, so the + * table's other consumers are unaffected. */ + getSubRows?: (row: TData) => TData[] | undefined; + /** + * Column id to filter by name when supplied — renders a search box in the toolbar, to the + * left of `toolbar`. Uses `filterFromLeafRows` so a match on a subrow keeps its group's + * parent row (and thus the group header) visible even though the parent itself doesn't match. + */ + searchColumnId?: string; + initialState?: InitialTableState; + /** Controls placed in the toolbar row, to the left of the "View" button. */ + toolbar?: JSX.Element; + /** Shown in place of the rows when there are none. */ + empty?: JSX.Element; + /** Replaces the pagination bar's default "{filtered} of {core} row(s) shown" text. */ + paginationLabel?: JSX.Element; + /** + * Sort/pagination state supplied by the caller — e.g. from a persisted setting — in place of + * this table's own ephemeral `TableSessionState`. Every other `LiveTable` should keep omitting + * this: their sort/page reset on reload on purpose, same as any other per-table session state. + */ + session?: Pick; +}; + +export function LiveTable(props: LiveTableProps) { + const {_} = useLingui(); + const {tableHiddenColumns, getTableSession} = useSettings(); + const label = useLabel(); + + const [search, setSearch] = createSignal(""); + const columnFilters = createMemo(() => { + const value = search().trim(); + return props.searchColumnId && value ? [{id: props.searchColumnId, value}] : []; + }); + + // Same storage as `DataTable`'s, so a column hidden here stays hidden across reloads and the + // "View" dropdown behaves identically. Read once: `TableViewOptions` writes both this table's + // state and the persisted copy, and re-reading would fight it. + const [columnVisibility, setColumnVisibility] = createSignal( + Object.fromEntries((tableHiddenColumns()[props.name] ?? []).map(id => [id, false])), + ); + + // live tables don't persist the name-search filter across reloads, but we use existing session + // mechanisms to persist sorting and pagination across page transitions — unless the caller + // hands in its own (persisted) session, e.g. the craft browser's. + const session = props.session ?? getTableSession(props.name); + const [sorting, setSorting] = [session.sorting, session.setSorting]; + const [pagination, setPagination] = [session.pagination, session.setPagination]; + + // Expansion doesn't need to survive a reload the way sorting/pagination do, so it's a plain + // local signal rather than another `getTableSession` slot. + const [expanded, setExpanded] = createSignal({}); + + if (!sorting().length && props.initialState?.sorting?.length) { + setSorting(props.initialState.sorting); + } + + // `DataTable` fills in missing headers by assigning to the caller's column defs during render. + // Copying instead keeps a caller's module-level column array from being mutated under it. + const columns = createMemo[]>(() => props.columns.map(column => column.header !== undefined ? column : { + ...column, + // Spreading a `ColumnDef` union widens it past the accessor-vs-display discrimination, so + // the narrowing has to be re-asserted; only `header` is being added. + header: (ctx: HeaderContext) => ( + + ), + } as ColumnDef)); + + const table = createSolidTable({ + meta: {name: props.name}, + get data() { + return props.data; + }, + get columns() { + return columns(); + }, + state: { + get sorting() { + return sorting(); + }, + get columnVisibility() { + return columnVisibility(); + }, + get pagination() { + return pagination(); + }, + get expanded() { + return expanded(); + }, + get columnFilters() { + return columnFilters(); + }, + }, + initialState: props.initialState, + getRowId: props.getRowId, + getSubRows: props.getSubRows, + // A match on a subrow keeps its parent row around too, so a filtered group still shows its + // header — the row itself just won't match the filter on its own account. + filterFromLeafRows: true, + enableRowSelection: false, + enableMultiSort: true, + // A live feed replaces `data` several times a second, and the default would read every one + // of those as "new data, go back to page 1" — the reader could never leave the first page. + autoResetPageIndex: false, + // Same reasoning, one feature over: the default would collapse every expanded group on the + // next tick. + autoResetExpanded: false, + // A group's subrows always render on the group's own page, never split off onto the next + // page by the page-size cutoff. + paginateExpandedRows: false, + onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: setPagination, + onExpandedChange: setExpanded, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + // With the automatic reset off, a filter that narrows the feed can leave the reader stranded + // past the last page, so clamp explicitly instead. + createEffect(() => { + const pageCount = table.getPageCount(); + if (pageCount > 0 && pagination().pageIndex >= pageCount) table.setPageIndex(pageCount - 1); + }); + + return ( +
+
+
+ + + + + + {props.toolbar} +
+ +
+
+ + + + {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-column-header.tsx b/frontend/src/components/data-table/table-column-header.tsx index 0f8ce2ca..aba230ca 100644 --- a/frontend/src/components/data-table/table-column-header.tsx +++ b/frontend/src/components/data-table/table-column-header.tsx @@ -47,18 +47,20 @@ export function TableColumnHeader(props: TableColumnHeaderProps) { }); }; + const right = () => props.column.columnDef.meta?.align === "right"; + return ( {props.title}} + fallback={
{props.title}
} > -
+
} variant="ghost" size="sm" - class="-ml-3 h-8 data-[expanded]:bg-accent" + class={cn("h-8 data-[expanded]:bg-accent", right() ? "-mr-3" : "-ml-3")} > {props.title}}> {props.children} 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(() => (